fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -0,0 +1,452 @@
"""체결가 미확인 TradeHistory 후속 보정 (스윙 스케줄러 사이클에서 호출).
[2026-10-08] 운영 관측: TradeHistory #432(풍산)·#433(현대차)의 executed_price 가 주문가
(현재가×1.005, 호가단위상 불가능한 값)로 남았다. 주문 직후의 체결 확인
(`trade_executor._verify_and_sync_execution_v3`)이 폴링 15초 안에 체결을 못 보거나 조회가
실패하면 그 뒤로 아무도 체결가를 채우지 않았다.
이제 주문 경로들은 확인 전 executed_price 를 **NULL** 로 기록한다(주문가 대체 금지). 이 모듈은
"당일 · COMPLETED · order_id 있음 · executed_price IS NULL · 일정 시간 경과" 행을 골라
KIS 체결조회(inquire-daily-ccld)로 실제 체결가·체결수량을 확인하고 db_worker 큐로 갱신한다.
규칙
- DB 는 **읽기만**(호출부의 SELECT 전용 세션). 쓰기는 db_worker 큐(§3.2).
- 사이클당 KIS 조회 상한·행별 재시도 간격·최대 시도 수를 둔다(모의 초당 2건 제한 대비).
- 체결 0 · 미종결 부분체결은 갱신하지 않는다(상태 변경 없음 — 수동 확인 대상, warning).
- SELL 손익은 기록 시점 추정치에서 진입가를 역산해 체결가로 재계산한다:
p(추정가) = total_amount / order_quantity,
entry = p − (net_profit_loss + commission) / order_quantity
(모든 SELL 기록 경로가 total_amount=추정가×수량, net = (추정가−진입가)×수량 − commission
으로 기록한다는 전제. 전제가 깨지면 손익은 갱신하지 않고 가격·금액만 갱신한다.)
- [하드닝 R1 2026-10-09] 미확인 판정은 `unconfirmed_fill_clause_v2`(NULL + 레거시 '체결가=주문가 &
slippage NULL'). BUY 의 ActiveTrade 진입가 보정은 entry_reason 으로 확정한 전략명(동등)만 —
확정 불가면 보정하지 않고 warning. 일일 지표는 `get_fill_sweep_health_v2()`.
"""
from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime, time as dtime, timedelta
from sqlalchemy import select, or_, and_
from app.core.timeutil import now_kst
from app.models.models import ActiveTrade, TradeHistory
from app.services.utils import _is_domestic
from app.services.db_worker import DT_STRATEGY_NAMES, not_dt_strategy_clause
logger = logging.getLogger(__name__)
# 사이클당 최대 KIS 체결조회 수 / 주문 후 최소 경과(직후 확인 태스크와 경합 방지) /
# 같은 행 재조회 최소 간격 / 행별 최대 시도
FILL_SWEEP_MAX_LOOKUPS_PER_CYCLE = 2
FILL_SWEEP_MIN_AGE_SEC = 120
FILL_SWEEP_RETRY_INTERVAL_SEC = 180
FILL_SWEEP_MAX_ATTEMPTS = 5
# [하드닝 R4 2026-10-09] 종가 단일가(15:20~15:30) 주문 체결 확인.
# 동시호가 주문은 15:30 에야 체결되는데 sweep 은 장중(is_market_open, 15:30 미만)에만 돌았다 →
# 15:28 주문은 sweep 기회 0회로 executed_price 가 영구 NULL 이었다(운영 10/7 스윙 BUY 2건 15:22·15:28).
# - 장 마감 직후 창(15:30 ~ +SWING_FILL_SWEEP_POST_CLOSE_MINUTES)에 스케줄러의 장외 분기가
# `run_post_close_fill_sweep_v2` 로 같은 sweep 을 돌린다(당일 행 그대로 — 같은 날짜 조회).
# - 동시호가 구간에 들어온 주문은 15:30 전엔 체결될 수 없으므로 조회하지 않는다(시도 횟수 보존).
_KRX_CLOSING_AUCTION_START = dtime(15, 20)
_KRX_MARKET_CLOSE = dtime(15, 30)
_DEFAULT_POST_CLOSE_MINUTES = 30
_DEFAULT_POST_CLOSE_INTERVAL_SEC = 60.0
_DEFAULT_POST_CLOSE_TIMEOUT_SEC = 60.0
def _settings_num_v2(name: str, default: float) -> float:
try:
from app.core.config import settings
v = float(getattr(settings, name, default))
except Exception as e:
logger.warning(f"[FillSweep] 설정 {name} 읽기 실패 — 기본값 {default}: {type(e).__name__}: {e}")
return float(default)
return v if v > 0 else float(default)
def _post_close_window_v2(now: datetime) -> tuple[datetime, datetime]:
start = now.replace(hour=_KRX_MARKET_CLOSE.hour, minute=_KRX_MARKET_CLOSE.minute,
second=0, microsecond=0)
minutes = _settings_num_v2("SWING_FILL_SWEEP_POST_CLOSE_MINUTES", _DEFAULT_POST_CLOSE_MINUTES)
return start, start + timedelta(minutes=minutes)
def post_close_sweep_active_v2(now: datetime | None = None) -> bool:
"""KRX 영업일 15:30 ~ 15:30+N분(장 마감 직후 체결 확인 창) 여부."""
from app.core.market_schedule import is_krx_holiday
now = now or now_kst()
if now.weekday() >= 5 or is_krx_holiday(now.date()):
return False
start, end = _post_close_window_v2(now)
return start <= now < end
def _auction_fill_pending_v2(created_at, now: datetime) -> bool:
"""동시호가(15:20~) 당일 주문인데 아직 15:30 전 — 체결될 수 없으므로 조회 보류."""
if created_at is None or not hasattr(created_at, "time"):
return False
try:
same_day = created_at.date() == now.date()
except Exception as e:
logger.warning(f"[FillSweep] created_at 비교 실패: {type(e).__name__}: {e}")
return False
return same_day and created_at.time() >= _KRX_CLOSING_AUCTION_START and now.time() < _KRX_MARKET_CLOSE
async def run_post_close_fill_sweep_v2(now: datetime | None = None) -> float | None:
"""장 마감 직후 체결 확인 1회 (스윙 스케줄러 장외 분기에서 호출).
창 밖이면 None(호출부는 기존 대기 그대로). 창 안이면 sweep 을 돌리고 다음 호출까지의
권장 대기 상한(초)을 반환한다 — 장외 분기의 최대 1800초 대기가 창을 건너뛰지 않게.
DB 는 SELECT 전용 세션, 쓰기는 db_worker 큐(sweep 본체와 동일). 예외·시간초과는 warning
후 흡수한다(체결가 보정은 리스크 가드가 아니다 — 다음 호출에서 재시도).
"""
now = now or now_kst()
if not post_close_sweep_active_v2(now):
return None
_, end = _post_close_window_v2(now)
interval = _settings_num_v2("SWING_FILL_SWEEP_POST_CLOSE_INTERVAL_SEC", _DEFAULT_POST_CLOSE_INTERVAL_SEC)
timeout = _settings_num_v2("SWING_FILL_SWEEP_TIMEOUT_SEC", _DEFAULT_POST_CLOSE_TIMEOUT_SEC)
try:
from app.core.database import AsyncSessionLocal
# DB-READ-EXCEPTION: 장 마감 직후 체결가 미확인 TradeHistory/ActiveTrade SELECT 전용(commit 없음, 갱신은 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT
async with AsyncSessionLocal() as session:
n = await asyncio.wait_for(sweep_unconfirmed_fills_v2(session), timeout=timeout)
if n:
logger.info(f"[FillSweep] 장 마감 후 체결가 보정 {n}건")
except asyncio.TimeoutError:
logger.warning(f"[FillSweep] 장 마감 후 체결가 보정 {timeout:.0f}초 초과 — 다음 호출에서 재시도")
except Exception as e:
logger.warning(f"[FillSweep] 장 마감 후 체결가 보정 실패: {type(e).__name__}: {e}")
remain = (end - now).total_seconds()
return max(15.0, min(interval, remain))
# {trade_id: (attempts, last_try_monotonic)} — 당일 한정(날짜가 바뀌면 비운다)
_attempts: dict[int, tuple[int, float]] = {}
_attempts_day: str = ""
# [하드닝 R5 2026-10-09] 당일 갱신을 이미 큐잉한 TradeHistory id. 워커 반영 전(큐 지연·DLQ)에는 행이
# 여전히 '미확인'으로 조회되므로, 재조회·재큐잉(특히 비멱등 진입가 보정)을 막기 위해 당일 제외한다.
_queued_ids: set[int] = set()
def _reset_if_new_day(day_iso: str) -> None:
global _attempts_day
if _attempts_day != day_iso:
_attempts.clear()
_queued_ids.clear()
_attempts_day = day_iso
def _exhausted_ids() -> list[int]:
return [tid for tid, (n, _) in _attempts.items() if n >= FILL_SWEEP_MAX_ATTEMPTS]
def _due(trade_id: int, now_mono: float) -> bool:
n, last = _attempts.get(trade_id, (0, 0.0))
if n >= FILL_SWEEP_MAX_ATTEMPTS:
return False
return n == 0 or (now_mono - last) >= FILL_SWEEP_RETRY_INTERVAL_SEC
def _note_attempt(trade_id: int, now_mono: float) -> int:
n, _ = _attempts.get(trade_id, (0, 0.0))
n += 1
_attempts[trade_id] = (n, now_mono)
return n
def derive_sell_entry_price_v2(row) -> float | None:
"""기록 시점 추정치에서 진입가를 역산 (순수 함수). 불가하면 None."""
try:
q = int(row.order_quantity or 0)
amt = float(row.total_amount or 0)
if row.net_profit_loss is None or q <= 0 or amt <= 0:
return None
p = amt / q
entry = p - (float(row.net_profit_loss) + float(row.commission or 0)) / q
except (TypeError, ValueError, ZeroDivisionError):
return None
return entry if entry > 0 else None
# 진입가 보정 대상 TradeHistory.strategy_type — 스윙 소유만(DT 는 fill_reconciler, 수동·유입은
# ActiveTrade 진입가를 이 주문가로 기록하지 않는다).
_SWING_BUY_STRATEGY_TYPES = (None, "", "swing")
# [하드닝 R1 2026-10-09] 시딩(동기화 유입) ActiveTrade 식별 — swing/exit_service 와 같은 규약.
# 시딩 행의 entry_price 는 KIS 평단이라 주문가→체결가 보정 대상이 아니다.
_SEEDED_STRATEGY_NAMES = ("synced",)
_SEEDED_ENTRY_REASON_PREFIXES = ("동기화 유입", "외부 유입 포지션")
def _is_seeded_active_row_v2(r) -> bool:
name = (getattr(r, "strategy_name", None) or "").strip()
reason = (getattr(r, "entry_reason", None) or "").strip()
return name in _SEEDED_STRATEGY_NAMES or reason.startswith(_SEEDED_ENTRY_REASON_PREFIXES)
def resolve_entry_fill_target_v2(row, active_rows) -> tuple[str | None, str]:
"""BUY TradeHistory 행이 만든(갱신한) 스윙 ActiveTrade 의 strategy_name 을 확정 (순수 함수).
[하드닝 R1 2026-10-09] 이전에는 `strategy_name_not=DT` 로 '가장 최근 비-DT active 행'을
보정했다. 같은 종목에 시딩('synced') 행이나 다른 듀얼 전략 행이 있으면 엉뚱한 행의
entry_price 가 (체결가−주문가)×수량/행수량 만큼 틀어졌다(운영: 103140·005380 은 스윙 행이
이미 closed 이고 synced 행만 active — 구 경로라면 synced 행을 보정했다).
매칭: 진입 시 executor 가 TradeHistory·ActiveTrade 양쪽에 **같은 entry_reason** 을 기록한다
(추가매수도 ActiveTrade.entry_reason 을 최신 매수 사유로 덮어쓴다). 따라서
1) 비-DT·비시딩·strategy_name 있는 active 행 중 entry_reason 이 같은 행을 찾고
2) 그 strategy_name 이 하나로 확정되며
3) db_worker 가 그 전략명으로 고를 행(같은 전략명 중 id 최대)이 바로 그 행일 때만
전략명을 돌려준다. 하나라도 어긋나면 (None, 사유) — 보정하지 않는다(확정 불가 시 보정 금지).
Returns:
(strategy_name | None, 사유 문자열)
"""
reason = (getattr(row, "entry_reason", None) or "").strip()
if not reason:
return None, "TradeHistory.entry_reason 없음"
eligible = [
r for r in (active_rows or [])
if (getattr(r, "strategy_name", None) or "").strip()
and (getattr(r, "strategy_name", None) or "") not in DT_STRATEGY_NAMES
and not _is_seeded_active_row_v2(r)
]
matched = [r for r in eligible if (getattr(r, "entry_reason", None) or "").strip() == reason]
if not matched:
return None, "entry_reason 일치하는 스윙 소유 active 행 없음(청산됐거나 사유 갱신됨)"
names = {r.strategy_name for r in matched}
if len(names) != 1:
return None, f"entry_reason 일치 행의 전략명이 여러 개 {sorted(names)}"
name = next(iter(names))
same_name = [r for r in eligible if r.strategy_name == name]
newest = max(same_name, key=lambda r: int(getattr(r, "id", 0) or 0))
if newest not in matched:
return None, f"전략명 {name} 의 최신 active 행(id={getattr(newest, 'id', None)})이 이 매수의 행이 아님"
return name, "ok"
async def _load_active_rows_for_fill_v2(db, stock_code: str):
"""SELECT 전용 — 호출부 세션(스윙 스케줄러 exit_session, DB-READ-EXCEPTION)으로 읽는다."""
return (await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
)).scalars().all()
async def _enqueue_buy_entry_adjust_v2(row, filled_qty: int, filled_price: float, db=None) -> bool:
"""스윙 BUY 체결 확인 시 **이 매수가 만든** ActiveTrade.entry_price 를 체결가로 보정 (큐 경유).
직후 확인(`_verify_and_sync_execution_v3`)이 이미 보정을 큐잉한 주문이면 건너뛴다(비멱등).
대상 행은 `resolve_entry_fill_target_v2` 로 전략명을 확정해 `strategy_name`(동등) 으로 지정한다.
확정할 수 없으면(db 없음 포함) 보정하지 않고 warning 만 남긴다 — 엉뚱한 행(시딩·타 전략)의
진입가를 움직이면 손절선·손익이 틀어지기 때문.
"""
from app.services.trade_executor import entry_fill_already_adjusted_v2, note_entry_fill_adjusted_v2
from app.services.db_worker import enqueue_active_trade_upsert
if getattr(row, "strategy_type", None) not in _SWING_BUY_STRATEGY_TYPES:
return False
if entry_fill_already_adjusted_v2(row.order_id):
return False
order_price = float(row.order_price or 0)
if order_price <= 0 or filled_price <= 0 or filled_qty <= 0:
return False
if db is None:
logger.warning(
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} 진입가 보정 생략 — "
f"ActiveTrade 조회 세션 없음(대상 행 확정 불가)"
)
return False
try:
active_rows = await _load_active_rows_for_fill_v2(db, row.stock_code)
except Exception as e:
logger.warning(
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} 진입가 보정 생략 — "
f"ActiveTrade 조회 실패: {type(e).__name__}: {e}"
)
return False
target, why = resolve_entry_fill_target_v2(row, active_rows)
if not target:
logger.warning(
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} ActiveTrade 진입가 보정 생략 — "
f"{why} (주문가 {order_price:,.2f} → 체결가 {float(filled_price):,.2f}, 수동 확인)"
)
return False
# [하드닝 R5 2026-10-09] 큐잉 **전에** 보정 완료로 표시한다(trade_executor 직후 확인 경로와 동일 순서).
# entry_fill_adjust 는 비멱등(entry_price += 차이)이라, 태스크가 큐에 머무는/DLQ 재시도 중인 동안
# 다음 sweep 이 같은 주문을 다시 보정하면 진입가·손절선이 누적 이동한다. 표시 후 큐잉이 실패하면
# 보정이 유실될 뿐(손절선 불변) 이중 이동은 일어나지 않는다 — 더 안전한 쪽.
note_entry_fill_adjusted_v2(row.order_id)
await enqueue_active_trade_upsert({
"stock_code": row.stock_code,
"strategy_name": target,
"entry_fill_adjust": {
"order_price": order_price,
"filled_price": float(filled_price),
"filled_qty": int(filled_qty),
},
})
return True
def unconfirmed_fill_clause_v2():
"""'체결가 미확인' TradeHistory 판정식 (모니터링 지표와 sweep 이 공유하는 단일 정의)."""
return or_(
TradeHistory.executed_price.is_(None),
and_(
TradeHistory.slippage.is_(None),
TradeHistory.order_price.isnot(None),
TradeHistory.executed_price == TradeHistory.order_price,
),
)
# 일일 모니터링 지표 — 마지막 sweep 기준(프로세스 메모리, /health·로그용)
_last_stats: dict = {"day": "", "candidates": 0, "exhausted": 0, "queued": 0, "exhausted_warned": 0}
def get_fill_sweep_health_v2() -> dict:
"""당일 체결 미확인 지표: candidates=마지막 sweep 이 본 미확인 행 수(상한 20),
exhausted=최대 시도 소진(수동 확인 대상) 행 수, queued=당일 보정 큐잉 누계."""
return dict(_last_stats)
async def sweep_unconfirmed_fills_v2(db) -> int:
"""체결가 미확인 행 보정. 반환 = 갱신 큐잉 건수.
호출부는 SELECT 전용 세션을 넘긴다(commit 없음).
"""
from app.services.korea_investment_api import korea_investment_api
from app.services.trade_executor import _fill_update_fields_v2
from app.services.db_worker import enqueue_update_trade_history
now = now_kst()
_reset_if_new_day(now.date().isoformat())
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# created_at 은 naive KST 로 저장된다(now_kst 기본값과 동일 규약)
cutoff = now - timedelta(seconds=FILL_SWEEP_MIN_AGE_SEC)
if getattr(day_start, "tzinfo", None) is not None:
day_start = day_start.replace(tzinfo=None)
cutoff = cutoff.replace(tzinfo=None)
if _last_stats.get("day") != now.date().isoformat():
_last_stats.update(day=now.date().isoformat(), candidates=0, exhausted=0, queued=0,
exhausted_warned=0)
rows = (await db.execute(
select(TradeHistory)
.where(
TradeHistory.status == "COMPLETED",
# [하드닝 R1 2026-10-09] 미확인 = 체결가 NULL(현 규약) 또는 레거시 규약
# (체결가=주문가 대체 + slippage NULL — 체결 확인은 항상 slippage 를 채운다).
# 운영 10/1~: 스윙 SELL 15/15·BUY 3/12 가 후자로 남아 손익·슬리피지가 주문가 기준이었다.
unconfirmed_fill_clause_v2(),
TradeHistory.order_id.isnot(None),
TradeHistory.order_id != "",
TradeHistory.created_at >= day_start,
TradeHistory.created_at <= cutoff,
or_(TradeHistory.currency_code.is_(None), TradeHistory.currency_code == "KRW"),
TradeHistory.order_type.in_(("BUY", "SELL")),
# DT 체결 보정은 daytrading/fill_reconciler 소관 — 이중 보정 방지(NULL 은 포함)
or_(TradeHistory.strategy_type.is_(None),
TradeHistory.strategy_type.notin_(DT_STRATEGY_NAMES)),
# 시도 소진 행이 상위 20건을 영구 점유해 새 행이 밀리지 않도록 제외
# 큐잉 완료(워커 반영 대기) 행도 제외 — 재보정·상위 20건 점유 방지
TradeHistory.id.notin_((_exhausted_ids() + sorted(_queued_ids)) or [-1]),
)
.order_by(TradeHistory.created_at.asc())
.limit(20)
)).scalars().all()
_last_stats["candidates"] = len(rows)
_last_stats["exhausted"] = len(_exhausted_ids())
if _last_stats["exhausted"] > int(_last_stats.get("exhausted_warned", 0)):
# 소진 건수가 늘 때만 경보(2분 주기 로그 반복 방지)
_last_stats["exhausted_warned"] = _last_stats["exhausted"]
logger.warning(
f"[FillSweep] 체결 미확인 시도 소진 {_last_stats['exhausted']}건(당일) — "
f"executed_price/slippage 수동 확인 필요: ids={sorted(_exhausted_ids())[:10]}"
)
if not rows:
return 0
mono = time.monotonic()
lookups = 0
queued = 0
for row in rows:
if lookups >= FILL_SWEEP_MAX_LOOKUPS_PER_CYCLE:
break
if row.id in _queued_ids or not _due(row.id, mono):
continue
if _auction_fill_pending_v2(getattr(row, "created_at", None), now):
continue # 종가 동시호가 주문 — 15:30 체결 전 조회는 시도만 소진한다(장 마감 후 창에서 확인)
if not _is_domestic(row.stock_code):
continue # 국내 체결조회 전용(해외는 currency_code 가 비어 있는 행이 있어 코드로도 거른다)
lookups += 1
n = _note_attempt(row.id, mono)
info = await korea_investment_api.get_order_execution(str(row.order_id), True)
filled_qty = int((info or {}).get("filled_qty", 0) or 0)
filled_price = float((info or {}).get("filled_price", 0) or 0)
remaining = (info or {}).get("remaining_qty")
ordered = int(row.order_quantity or 0)
if not (info or {}).get("ok"):
logger.warning(
f"[FillSweep] {row.stock_code} #{row.id} 체결조회 실패 ({n}/{FILL_SWEEP_MAX_ATTEMPTS}) "
f"order_id={row.order_id}"
)
continue
if filled_qty <= 0 or filled_price <= 0:
logger.warning(
f"[FillSweep] {row.stock_code} #{row.id} {row.order_type} 체결 미확인 "
f"(체결 {filled_qty}주, 주문행발견={info.get('found')}, 잔량={remaining}) "
f"({n}/{FILL_SWEEP_MAX_ATTEMPTS}) — 갱신 안 함, 소진 시 수동 확인"
)
continue
if filled_qty < ordered and remaining != 0:
logger.warning(
f"[FillSweep] {row.stock_code} #{row.id} 부분체결 {filled_qty}/{ordered}주 미종결 "
f"(잔량={remaining}) — 종결 후 재시도"
)
continue
filled_qty = min(filled_qty, ordered) if ordered > 0 else filled_qty
ref = float(row.order_price or 0) or (float(row.total_amount or 0) / ordered if ordered else 0.0)
slippage = ((filled_price - ref) / ref * 100) if ref > 0 else 0.0
basis = None
if row.order_type == "SELL":
entry = derive_sell_entry_price_v2(row)
if entry is not None:
basis = {
"entry_price": entry,
"market": "KOSPI",
"is_domestic": True,
"net_rate": False,
}
else:
logger.warning(
f"[FillSweep] {row.stock_code} #{row.id} SELL 진입가 역산 불가 — 체결가·금액만 갱신"
)
fields = _fill_update_fields_v2(row.order_type, filled_qty, filled_price, slippage, basis)
fields.pop("executed_at", None) # 실제 체결 시각을 모른다 — 기존 값 유지
# 큐잉 전에 표시 — 이후 단계가 예외로 빠져도 같은 행을 당일 다시 보정하지 않는다
_queued_ids.add(row.id)
_attempts.pop(row.id, None)
await enqueue_update_trade_history(row.id, fields)
if row.order_type == "BUY":
await _enqueue_buy_entry_adjust_v2(row, filled_qty, filled_price, db)
queued += 1
_last_stats["queued"] = int(_last_stats.get("queued", 0)) + 1
logger.info(
f"[FillSweep] {row.stock_code} #{row.id} {row.order_type} 체결 확인 — "
f"{filled_qty}주 @ {filled_price:,.2f} (기준가 {ref:,.2f}, 슬리피지 {slippage:+.3f}%)"
)
return queued