fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,8 @@ from app.services.market_playbook import (
|
||||
is_daytrading_allowed_by_playbook, is_vi_active,
|
||||
)
|
||||
|
||||
from app.core.log_throttle import warn_throttled # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 한국 표준시(KST) 타임존 — 장 시간/시간대 체크에 사용
|
||||
@@ -130,6 +132,25 @@ _CANDLE_CACHE_TTL: float = 180.0
|
||||
# 라운드로빈 시작 오프셋
|
||||
_scan_offset: int = 0
|
||||
|
||||
# [H1 2026-10-02] DAY_TRADE_ORB_FULL_RANGE=True 일 때 확정된 ORB 종목(당일). 이 시각 이후의
|
||||
# 첫 계산을 확정본으로 둔다 — 레인지 창은 09:00~09:29(calculate_opening_range 정의).
|
||||
_ORB_FULL_RANGE_FINALIZE_AT = dtime(9, 29)
|
||||
_orb_finalized: set[str] = set()
|
||||
_orb_finalized_date = None
|
||||
|
||||
|
||||
def _reset_orb_finalized_if_new_day() -> None:
|
||||
global _orb_finalized_date
|
||||
_today = datetime.now(_KST).date()
|
||||
if _orb_finalized_date != _today:
|
||||
_orb_finalized.clear()
|
||||
_orb_finalized_date = _today
|
||||
|
||||
|
||||
# [H2/M5 2026-10-02] 주문 진행 중 슬롯 예약 {stock_code: 예약 금액}. 주문 직전 trade_lock
|
||||
# 안에서 등록하고, 포지션 등록(같은 락) 또는 주문 실패 시 해제한다.
|
||||
_pending_entries: dict[str, float] = {}
|
||||
|
||||
|
||||
# ── RSI·갭 진단 로깅 (2026-09-30) ─────────────────────────────────────────────
|
||||
# 배경: 장중 5분봉 RSI 는 워밍업 시계열(전일 봉 포함)을 쓰므로 오버나이트 갭이 종가
|
||||
@@ -471,7 +492,17 @@ async def _get_candles_cached(stock_code: str) -> list:
|
||||
cached = _candle_cache.get(stock_code)
|
||||
if cached and (time.monotonic() - cached[0]) < _CANDLE_CACHE_TTL:
|
||||
return cached[1]
|
||||
candles = await _fetch_candles_impl(stock_code)
|
||||
# [H2 2026-10-02] miss 경로에도 종목별 상한 — 한 종목 무응답이 스캔 전체를 붙잡아
|
||||
# 스케줄러 _guard 에 통째로 취소되던 경로를 막는다(선조회와 같은 상한).
|
||||
try:
|
||||
candles = await asyncio.wait_for(
|
||||
_fetch_candles_impl(stock_code), timeout=_SCAN_PREFETCH_TIMEOUT
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"[DayEntry] 분봉 조회 {_SCAN_PREFETCH_TIMEOUT:.0f}초 초과: {stock_code} — 이번 사이클 스킵"
|
||||
)
|
||||
return []
|
||||
if candles:
|
||||
_candle_cache[stock_code] = (time.monotonic(), candles)
|
||||
return candles
|
||||
@@ -530,9 +561,26 @@ async def collect_opening_ranges() -> None:
|
||||
|
||||
_collected_before = len(day_trading_state.orb_cache)
|
||||
|
||||
# [H1 2026-10-02] ⚠️ 현행(기본, DAY_TRADE_ORB_FULL_RANGE=False) 실제 동작:
|
||||
# `has_orb()` 단락 때문에 **첫 수집 성공 시점의 레인지로 하루가 고정**된다.
|
||||
# calculate_opening_range 는 1분봉 5개면 값을 내므로 09:00 첫 사이클 직후(대개 09:05
|
||||
# 전후)의 5분 레인지가 ORB 가 된다 — 문서(§7)의 09:00~09:29 레인지가 아니다.
|
||||
# 돌파 신호가 더 많이 나는 쪽이라 사용자 결정으로 현행을 유지한다.
|
||||
# ON: 09:29 이전에는 매 수집마다 재계산(확정 아님), 09:29 이후 첫 계산을 확정본으로 두고
|
||||
# 그때부터만 Redis 에 저장한다(09:29 이전 저장분은 재기동 시 확정본으로 쓰지 않는다).
|
||||
_full_range = bool(settings.DAY_TRADE_ORB_FULL_RANGE)
|
||||
_now_t = datetime.now(_KST).time()
|
||||
_finalize_now = _now_t >= _ORB_FULL_RANGE_FINALIZE_AT
|
||||
if _full_range:
|
||||
_reset_orb_finalized_if_new_day()
|
||||
_finalized_changed = False
|
||||
|
||||
for stock_code in list(_watchlist.keys()):
|
||||
# 이미 ORB가 수집된 종목은 건너뜀
|
||||
if day_trading_state.has_orb(stock_code):
|
||||
# 이미 ORB가 수집된 종목은 건너뜀 (FULL_RANGE 면 '확정된' 종목만 건너뜀)
|
||||
if _full_range:
|
||||
if stock_code in _orb_finalized:
|
||||
continue
|
||||
elif day_trading_state.has_orb(stock_code):
|
||||
continue
|
||||
try:
|
||||
# 당일 분봉 전체 조회 → 시초가 레인지 계산
|
||||
@@ -542,6 +590,9 @@ async def collect_opening_ranges() -> None:
|
||||
orb = calculate_opening_range(candles)
|
||||
if orb:
|
||||
day_trading_state.set_orb(stock_code, orb)
|
||||
if _full_range and _finalize_now:
|
||||
_orb_finalized.add(stock_code)
|
||||
_finalized_changed = True
|
||||
# [2026-09-30] debug → info. **레인지가 몇 개 봉으로 확정됐는지**가
|
||||
# 로그에 남아야 한다: `calculate_opening_range` 는 봉 5개면 값을
|
||||
# 확정하고 이 루프는 `has_orb()` 로 단락하므로, 실제로는 09:00~09:29
|
||||
@@ -564,9 +615,14 @@ async def collect_opening_ranges() -> None:
|
||||
# KIS API 레이트 리밋 방지 (초당 약 3회)
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.debug(f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}")
|
||||
warn_throttled(logger, "entry_service:567", f"[DayEntry] ORB 수집 실패 ({stock_code}): {e}")
|
||||
|
||||
if len(day_trading_state.orb_cache) != _collected_before:
|
||||
if _full_range:
|
||||
# 확정 전(09:29 이전) 레인지는 저장하지 않는다 — 재기동 시 부분 레인지가 확정본으로
|
||||
# 복원되는 것을 막는다.
|
||||
if _finalized_changed:
|
||||
await day_trading_state.save_orb_cache()
|
||||
elif len(day_trading_state.orb_cache) != _collected_before:
|
||||
await day_trading_state.save_orb_cache()
|
||||
|
||||
|
||||
@@ -666,14 +722,27 @@ async def scan_and_enter() -> list[dict]:
|
||||
return entries
|
||||
|
||||
# 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화
|
||||
# [M6 2026-10-02] 예전에는 판정까지 try 안에 있고 `except: pass` 라, 조회가 예외를
|
||||
# 내면 Crisis 모드 차단이 조용히 통과됐다. 조회만 try 안에서 하고 **실패 시 진입 차단
|
||||
# (fail-closed)** + warning, 판정은 try 밖에서 한다(§4, §8.1-10). 청산 경로는 무관.
|
||||
_adp_dt = None
|
||||
_adp_err = None
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
_adp_dt = get_current_adaptive_params()
|
||||
if not _adp_dt.fallback_used and not _adp_dt.daytrading_enabled:
|
||||
logger.info("[DayEntry] 적응형 전략 %s 모드 — 데이트레이딩 비활성화", _adp_dt.trading_mode.upper())
|
||||
return entries
|
||||
except Exception:
|
||||
pass # adaptive 모듈 실패 시 기존 로직 유지
|
||||
except Exception as e:
|
||||
_adp_err = e
|
||||
if _adp_dt is None:
|
||||
logger.warning(
|
||||
f"[DayEntry] ⛔ 적응형 모드 조회 실패 — 신규 진입 차단(fail-closed): {_adp_err}"
|
||||
)
|
||||
return entries
|
||||
if not getattr(_adp_dt, "fallback_used", False) and not getattr(_adp_dt, "daytrading_enabled", True):
|
||||
logger.info(
|
||||
"[DayEntry] 적응형 전략 %s 모드 — 데이트레이딩 비활성화",
|
||||
str(getattr(_adp_dt, "trading_mode", "?")).upper(),
|
||||
)
|
||||
return entries
|
||||
|
||||
# [P0] 플레이북 체크 — FOMC/옵션만기/서킷브레이커 등 이벤트 당일 차단
|
||||
if not is_daytrading_allowed_by_playbook():
|
||||
@@ -693,16 +762,25 @@ async def scan_and_enter() -> list[dict]:
|
||||
# [P0] 시장 국면 체크 — CRASH/HALTED에서만 진입 차단
|
||||
# 데이트레이딩은 분봉 기반이라 일봉 SIDEWAYS/strong_bearish와 무관하게
|
||||
# 장중 변동성이 있으면 진입 가능 (CLAUDE.md 7.3 전략 독립성)
|
||||
# [M6 2026-10-02] 조회 실패 시 "계속 진행" 이던 것을 fail-closed 로. CRASH/HALTED 를
|
||||
# 확인할 수 없는 상태에서 진입하면 차단 게이트가 KIS/네이버 장애 때 정확히 사라진다.
|
||||
_BLOCK_REGIMES_DT = {"crash", "halted"}
|
||||
regime = None
|
||||
try:
|
||||
regime_info = await check_market_regime()
|
||||
regime = regime_info.get("regime", "neutral")
|
||||
if regime in _BLOCK_REGIMES_DT:
|
||||
logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단")
|
||||
return entries
|
||||
logger.info(f"[DayEntry] 시장 국면: {regime}")
|
||||
regime = (regime_info or {}).get("regime") if isinstance(regime_info, dict) else None
|
||||
except Exception as e:
|
||||
logger.info(f"[DayEntry] 시장 국면 조회 실패 (계속 진행): {e}")
|
||||
logger.warning(f"[DayEntry] ⛔ 시장 국면 조회 실패 — 신규 진입 차단(fail-closed): {e}")
|
||||
return entries
|
||||
if not regime:
|
||||
logger.warning(
|
||||
f"[DayEntry] ⛔ 시장 국면 판정값 없음({regime_info!r:.80}) — 신규 진입 차단(fail-closed)"
|
||||
)
|
||||
return entries
|
||||
if regime in _BLOCK_REGIMES_DT:
|
||||
logger.info(f"[DayEntry] ⛔ {regime.upper()} 국면 — 데이트레이딩 진입 차단")
|
||||
return entries
|
||||
logger.info(f"[DayEntry] 시장 국면: {regime}")
|
||||
|
||||
# [시간대 제한 완화 2026-09-22] 점심시간(11:30~13:00) VWAP/RSI_BB 제한 제거.
|
||||
# 신규 진입 마감만 유지 — settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN (기본 15:00,
|
||||
@@ -808,6 +886,29 @@ async def scan_and_enter() -> list[dict]:
|
||||
_scan_offset = (_scan_offset + idx) % len(candidates)
|
||||
break
|
||||
|
||||
# [M8 2026-10-02] 한 사이클 안에서 앞선 진입이 쓴 현금·사용률을 반영한다.
|
||||
# 예전에는 사이클 시작 시점의 available_cash 를 모든 종목에 그대로 넘기고 95% 가드도
|
||||
# 시작 시 1회만 봐서, 한 사이클 다건 진입이 현금·전체 사용률 한도를 함께 넘을 수 있었다.
|
||||
# 리스크 가드이므로 try 밖에서 판정한다(§4).
|
||||
if available_cash <= 0:
|
||||
truncated = len(candidates) - idx
|
||||
logger.warning(
|
||||
f"[DayEntry] ⛔ 사이클 내 가용현금 소진 — 남은 {truncated}종목 진입 중단"
|
||||
)
|
||||
break
|
||||
_dt_inv_now = sum(
|
||||
pos.get("entry_price", 0) * pos.get("quantity", 0)
|
||||
for pos in day_trading_state.positions.values()
|
||||
)
|
||||
_usage_now = max(0.0, swing_invested + _dt_inv_now) / total_assets
|
||||
if _usage_now > settings.MAX_TOTAL_CAPITAL_USAGE:
|
||||
truncated = len(candidates) - idx
|
||||
logger.warning(
|
||||
f"[DayEntry] ⛔ 사이클 내 전체 자본 사용률 초과 {_usage_now:.1%} > "
|
||||
f"{settings.MAX_TOTAL_CAPITAL_USAGE:.0%} — 남은 {truncated}종목 진입 중단"
|
||||
)
|
||||
break
|
||||
|
||||
scanned += 1
|
||||
try:
|
||||
entry = await _try_enter(
|
||||
@@ -821,6 +922,11 @@ async def scan_and_enter() -> list[dict]:
|
||||
no_signal += 1
|
||||
except Exception as e:
|
||||
logger.error(f"[DayEntry] 진입 시도 오류 ({stock_code}): {e}", exc_info=True)
|
||||
entry = None
|
||||
if entry:
|
||||
# 신호가 기준 차감(체결가 보정은 백그라운드). 음수로 내려가지 않게 0 하한.
|
||||
_spent = float(entry.get("price", 0) or 0) * int(entry.get("quantity", 0) or 0)
|
||||
available_cash = max(0.0, available_cash - _spent)
|
||||
else:
|
||||
# 전 종목을 소화했으면 오프셋을 리셋해 다음 사이클도 앞에서부터 본다
|
||||
_scan_offset = 0
|
||||
@@ -917,8 +1023,12 @@ async def _try_enter(
|
||||
# 신호 체크 (ORB → VWAP → RSI+BB 순)
|
||||
signal = None
|
||||
if orb:
|
||||
# ORB: 당일 1분봉 — 직전 1분 거래량과 갭 판정이 당일 기준이어야 한다
|
||||
signal = check_orb_signal(stock_code, stock_name, candles_today, orb, current_price)
|
||||
# ORB: 당일 1분봉 — 직전 1분 거래량과 갭 판정이 당일 기준이어야 한다.
|
||||
# [M3 2026-10-02] DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED=True 일 때만 워밍업(전일 포함)
|
||||
# 시계열을 넘긴다 — check_orb_signal 이 전일 종가 대비 갭을 계산한 뒤 내부에서
|
||||
# 세션을 당일로 자르므로 거래량·양봉 판정은 동일하다. OFF(기본)는 현행 그대로.
|
||||
_orb_input = candles if settings.DAY_TRADE_ORB_OVERNIGHT_GAP_ENABLED else candles_today
|
||||
signal = check_orb_signal(stock_code, stock_name, _orb_input, orb, current_price)
|
||||
# [P1] 점심시간(11:30~13:00)에는 VWAP/RSI_BB 진입 제한 — 유동성 저하
|
||||
if not is_lunch_break:
|
||||
if not signal and vwap:
|
||||
@@ -969,12 +1079,74 @@ async def _try_enter(
|
||||
mcp_approved, mcp_reason = await request_mcp_risk_assessment(
|
||||
trade_plan, {"strategy": "daytrading"}, {"stock_code": stock_code}
|
||||
)
|
||||
if not mcp_approved:
|
||||
logger.debug(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}")
|
||||
return None
|
||||
except Exception:
|
||||
pass # MCP 실패 시 진입 계속
|
||||
except Exception as _mcp_err:
|
||||
# MCP 는 선택적 계층 — 실패 시 진입은 계속하되 조용히 삼키지 않는다(§4)
|
||||
warn_throttled(
|
||||
logger, "entry_service:mcp_risk",
|
||||
f"[DayEntry] MCP 리스크 평가 실패 — 평가 없이 진행 ({stock_code}): {_mcp_err}",
|
||||
)
|
||||
mcp_approved, mcp_reason = True, None
|
||||
# 거부 판정은 try 밖(§4: 가드가 예외로 우회되지 않게)
|
||||
if not mcp_approved:
|
||||
logger.info(f"[DayEntry] MCP 거부: {stock_code} — {mcp_reason}")
|
||||
return None
|
||||
|
||||
# [H2/M5 2026-10-02] 주문 직전 재검사 + 슬롯 예약 (trade_lock 안).
|
||||
# 신호 판정 ~ 주문 사이에 분봉 조회·MCP 평가 등 await 가 있어 그 사이 다른 경로
|
||||
# (수동 /cycle, 같은 사이클의 앞선 진입)가 슬롯·예산을 소비할 수 있다. 보유·당일청산·
|
||||
# 포지션 한도(예약 포함)·DT 예산 잔여를 **주문 직전에** 다시 보고 슬롯을 예약한다.
|
||||
# 리스크 가드이므로 try 로 감싸지 않는다(§4) — 바깥 try 는 로그용.
|
||||
_reject = None
|
||||
async with day_trading_state.trade_lock:
|
||||
if stock_code in day_trading_state.positions or stock_code in _pending_entries:
|
||||
_reject = "이미 보유/주문 진행 중"
|
||||
elif day_trading_state.is_closed_today(stock_code):
|
||||
_reject = "당일 청산 종목"
|
||||
elif day_trading_state.position_count() + len(_pending_entries) >= MAX_POSITIONS:
|
||||
_reject = f"포지션 한도 {MAX_POSITIONS} (주문 진행 중 포함)"
|
||||
else:
|
||||
_amt_now = sum(
|
||||
pos.get("entry_price", 0) * pos.get("quantity", 0)
|
||||
for pos in day_trading_state.positions.values()
|
||||
) + sum(_pending_entries.values())
|
||||
_final_budget = _clamp_day_trade_budget(
|
||||
budget,
|
||||
total_assets=total_assets,
|
||||
available_cash=available_cash,
|
||||
current_day_trade_amount=_amt_now,
|
||||
)
|
||||
_final_qty = int(_final_budget // current_price) if current_price > 0 else 0
|
||||
if _final_qty <= 0:
|
||||
_reject = "DT 예산 소진(주문 직전 재검사)"
|
||||
else:
|
||||
if _final_qty < quantity:
|
||||
logger.warning(
|
||||
f"[DayEntry] {stock_code} 주문 직전 예산 재검사로 수량 축소 "
|
||||
f"{quantity} → {_final_qty}주"
|
||||
)
|
||||
quantity = _final_qty
|
||||
_pending_entries[stock_code] = current_price * quantity
|
||||
if _reject:
|
||||
logger.info(f"[DayEntry] ⛔ {stock_code} 주문 직전 재검사 거부 — {_reject}")
|
||||
return None
|
||||
|
||||
# [H2] 주문 전송 ~ 인메모리·ActiveTrade·TradeHistory 반영을 shield 한다(청산 쪽
|
||||
# _execute_exit_impl 과 같은 패턴). 스케줄러 _guard(180초)가 이 구간을 취소하면
|
||||
# "브로커는 샀는데 인메모리·DB 에 포지션이 없다" → 손절·EOD 청산 대상에서 빠진 고아 보유.
|
||||
return await asyncio.shield(_place_and_record_entry_impl(
|
||||
stock_code, stock_name, signal, quantity, current_price,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _place_and_record_entry_impl(
|
||||
stock_code: str, stock_name: str, signal, quantity: int, current_price: float,
|
||||
) -> dict | None:
|
||||
"""[H2] 매수 주문 + 체결 후 상태 반영 (shield 구간). 슬롯 예약은 끝에서 해제한다."""
|
||||
try:
|
||||
# KIS API 매수 주문 (시장가) — 분산락+세마포어 보호
|
||||
from app.services.trade_executor import safe_place_order
|
||||
order_resp = await safe_place_order(
|
||||
@@ -1005,6 +1177,8 @@ async def _try_enter(
|
||||
"reason": signal.reason,
|
||||
"order_id": order_id,
|
||||
})
|
||||
# 예약 → 실제 포지션으로 전환(같은 락 안에서 — 한도 이중 계상 방지)
|
||||
_pending_entries.pop(stock_code, None)
|
||||
|
||||
# DB 기록 (큐 경유)
|
||||
await enqueue_trade_history({
|
||||
@@ -1055,8 +1229,10 @@ async def _try_enter(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True)
|
||||
logger.error(f"[DayEntry] 매수 주문/반영 오류 ({stock_code}): {e}", exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
_pending_entries.pop(stock_code, None)
|
||||
|
||||
|
||||
def _schedule_buy_fill_reconcile(
|
||||
@@ -1076,40 +1252,107 @@ def _schedule_buy_fill_reconcile(
|
||||
from app.core.timeutil import now_kst as _now_kst
|
||||
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
|
||||
|
||||
async def _on_fill(filled_qty: int, avg_price: float) -> None:
|
||||
if filled_qty < quantity:
|
||||
logger.warning(
|
||||
f"[DayEntry] 매수 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
|
||||
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준(수동 확인 필요)"
|
||||
)
|
||||
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
|
||||
async def _on_result(info: dict) -> None:
|
||||
# [M7 2026-10-02] 부분체결·미체결 보정. 예전에는 부분체결이면 warning 만 남기고
|
||||
# 인메모리·ActiveTrade 수량을 주문 수량으로 둬서, 청산이 실보유보다 많이 매도
|
||||
# (주문거부 또는 같은 종목 스윙 물량 매도)했다. 보정은 **주문 종결**(전량 체결 또는
|
||||
# 잔여수량 0) 근거가 있을 때만 한다 — 미종결 부분체결을 확정하면 나머지 체결분이
|
||||
# 인메모리에서 빠져 EOD 청산 후 잔량이 남는다.
|
||||
from decimal import Decimal
|
||||
from app.services.db_worker import enqueue_update_trade_history_by_order_id
|
||||
await enqueue_update_trade_history_by_order_id(order_id, {
|
||||
from app.services.db_worker import (
|
||||
enqueue_update_trade_history_by_order_id, enqueue_close_active_trade,
|
||||
)
|
||||
filled_qty = int(info.get("filled_qty", 0) or 0)
|
||||
avg_price = float(info.get("avg_price", 0) or 0)
|
||||
terminal = bool(info.get("terminal"))
|
||||
_ident = {
|
||||
# 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다
|
||||
"order_date": _order_date,
|
||||
"order_type": "BUY",
|
||||
"strategy_type": "daytrading",
|
||||
}
|
||||
|
||||
if filled_qty <= 0:
|
||||
if not terminal:
|
||||
logger.warning(
|
||||
f"[DayEntry] 매수 체결 0 (미종결) 확인: {stock_code} order_id={order_id} "
|
||||
f"— 인메모리 유지, 수동 확인 필요"
|
||||
)
|
||||
return
|
||||
removed = False
|
||||
async with day_trading_state.trade_lock:
|
||||
pos = day_trading_state.positions.get(stock_code)
|
||||
if pos is not None and str(pos.get("order_id", "")) == str(order_id):
|
||||
day_trading_state.remove_position(stock_code) # closed_today 포함(당일 재진입 차단)
|
||||
removed = True
|
||||
await enqueue_update_trade_history_by_order_id(order_id, {
|
||||
**_ident, "executed_quantity": 0, "total_amount": Decimal("0"),
|
||||
"status": "CANCELLED",
|
||||
})
|
||||
if removed:
|
||||
await day_trading_state.save_daily_state()
|
||||
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
|
||||
logger.warning(
|
||||
f"[DayEntry] ⚠️ 매수 미체결 종결: {stock_code} 0/{quantity}주 order_id={order_id} "
|
||||
+ ("— 인메모리 포지션 제거·ActiveTrade(day_trade) 정리" if removed
|
||||
else "— 포지션 이미 청산/교체됨, 원장만 보정")
|
||||
)
|
||||
return
|
||||
|
||||
partial_final = terminal and filled_qty < quantity
|
||||
if filled_qty < quantity and not terminal:
|
||||
logger.warning(
|
||||
f"[DayEntry] 매수 부분체결(미종결) 확인: {stock_code} {filled_qty}/{quantity}주 "
|
||||
f"order_id={order_id} — 종결 미확인이라 수량 보정 보류(수동 확인 필요)"
|
||||
)
|
||||
_book_qty = filled_qty if partial_final else quantity
|
||||
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
|
||||
_upd = {
|
||||
**_ident,
|
||||
"executed_price": Decimal(str(round(avg_price, 2))),
|
||||
"total_amount": Decimal(str(round(avg_price * quantity, 2))),
|
||||
"total_amount": Decimal(str(round(avg_price * _book_qty, 2))),
|
||||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||||
})
|
||||
}
|
||||
if partial_final:
|
||||
_upd["executed_quantity"] = filled_qty
|
||||
await enqueue_update_trade_history_by_order_id(order_id, _upd)
|
||||
|
||||
updated = False
|
||||
new_qty = None
|
||||
async with day_trading_state.trade_lock:
|
||||
pos = day_trading_state.positions.get(stock_code)
|
||||
if pos is not None and str(pos.get("order_id", "")) == str(order_id):
|
||||
pos["entry_price"] = float(avg_price)
|
||||
updated = True
|
||||
if partial_final:
|
||||
# 이미 일부 청산됐을 수 있으므로 '미체결분'만큼만 뺀다
|
||||
new_qty = max(0, int(pos.get("quantity", 0) or 0) - (quantity - filled_qty))
|
||||
if new_qty > 0:
|
||||
pos["quantity"] = new_qty
|
||||
else:
|
||||
day_trading_state.remove_position(stock_code)
|
||||
if updated:
|
||||
await enqueue_active_trade_upsert({
|
||||
_at = {
|
||||
"stock_code": stock_code,
|
||||
"strategy_name": "day_trade",
|
||||
"entry_price": float(avg_price),
|
||||
})
|
||||
}
|
||||
if partial_final and new_qty:
|
||||
_at["quantity"] = new_qty
|
||||
if partial_final and not new_qty:
|
||||
await day_trading_state.save_daily_state()
|
||||
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
|
||||
else:
|
||||
await enqueue_active_trade_upsert(_at)
|
||||
if partial_final:
|
||||
logger.warning(
|
||||
f"[DayEntry] ⚠️ 매수 부분체결 종결: {stock_code} {filled_qty}/{quantity}주 "
|
||||
f"order_id={order_id} — 인메모리·ActiveTrade 수량 보정 → {new_qty if updated else '(포지션 없음)'}"
|
||||
)
|
||||
logger.info(
|
||||
f"[DayEntry] 매수 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
|
||||
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%)"
|
||||
+ ("" if updated else " — 포지션 이미 청산/교체됨, 원장만 보정")
|
||||
)
|
||||
|
||||
return fill_reconciler.schedule(stock_code, order_id, "BUY", _on_fill)
|
||||
return fill_reconciler.schedule_v2(stock_code, order_id, "BUY", quantity, _on_result)
|
||||
|
||||
Reference in New Issue
Block a user