Files
auto-stock/backend/app/services/swing/fill_sweep.py
shuim a264561576 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
2026-10-10 12:17:49 +09:00

453 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""체결가 미확인 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