- 스윙 체결가 확인 전 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
453 lines
23 KiB
Python
453 lines
23 KiB
Python
"""체결가 미확인 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
|