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:
2026-10-02 12:01:31 +09:00
parent 0f89a9d678
commit 9fab4018d4
99 changed files with 15712 additions and 1718 deletions

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -141,3 +141,157 @@ def schedule(
_pending_tasks.add(task)
task.add_done_callback(_pending_tasks.discard)
return task
# ── [M7 2026-10-02] 부분체결·미체결 판정용 v2 ─────────────────────────────────
# 기존 parse_fill_row/fetch_fill/schedule 은 **체결수량>0 인 첫 조회 결과**만 돌려줘
# (a) 체결 0 과 조회 실패를 구분하지 못하고 (b) 아직 체결 중인 부분체결도 확정으로 본다.
# 매수 쪽 수량 보정은 "주문이 종결됐다"는 근거가 있을 때만 해야 한다 — 미종결 부분체결을
# 수량으로 확정하면 나머지가 체결된 뒤 실보유 > 인메모리가 되어 EOD 청산 뒤 잔량이 남는다.
# 종결 판정: 체결수량 ≥ 주문수량, 또는 응답의 잔여수량(rmn_qty)=0(취소·거부 포함).
# rmn_qty 가 없는 응답에서 체결<주문이면 '미확정' 으로 남긴다(보정하지 않음, warning).
def parse_fill_row_v2(rows: list, order_id: str) -> Optional[dict]:
"""주문번호가 일치하는 행 → {filled_qty, avg_price, ord_qty, rmn_qty}. 없거나 파싱 불가면 None.
체결수량 0 행도 돌려준다(미체결·취소 판정용). ord_qty/rmn_qty 는 응답에 없으면 None.
"""
target = _norm_odno(order_id)
if not target:
return None
for row in rows or []:
if not isinstance(row, dict):
continue
if _norm_odno(row.get("odno")) != target:
continue
try:
qty = int(float(row.get("tot_ccld_qty", 0) or 0))
avg = float(row.get("avg_prvs", 0) or 0)
if avg <= 0 and qty > 0:
amt = float(row.get("tot_ccld_amt", 0) or 0)
avg = amt / qty if amt > 0 else 0.0
_oq = row.get("ord_qty")
ord_qty = int(float(_oq)) if _oq not in (None, "") else None
_rq = row.get("rmn_qty")
rmn_qty = int(float(_rq)) if _rq not in (None, "") else None
_jq = row.get("rjct_qty")
rjct_qty = int(float(_jq)) if _jq not in (None, "") else None
except (TypeError, ValueError):
return None
if qty > 0 and avg <= 0:
return None
cncl_yn = str(row.get("cncl_yn", "") or "").upper() or None
return {"filled_qty": max(0, qty), "avg_price": avg, "ord_qty": ord_qty,
"rmn_qty": rmn_qty, "cncl_yn": cncl_yn, "rjct_qty": rjct_qty}
return None
def is_terminal_fill(info: dict, ordered_qty: int) -> bool:
"""주문 종결 여부 — 전량 체결 / 잔여수량 0 / 취소(cncl_yn=Y) / 거부수량>0.
[Low 2026-10-02] 거부·취소 행은 응답에 따라 rmn_qty 가 비거나 0 이 아닐 수 있어
cncl_yn·rjct_qty 도 본다(체결 0주 종결 판정 누락 방지).
"""
filled = int(info.get("filled_qty", 0) or 0)
if ordered_qty > 0 and filled >= ordered_qty:
return True
if str(info.get("cncl_yn") or "").upper() == "Y":
return True
rj = info.get("rjct_qty")
rmn = info.get("rmn_qty")
if rj is not None and int(rj) > 0 and (rmn is None or int(rmn) <= 0):
return True
return rmn is not None and int(rmn) <= 0
async def _poll_fill_v2(stock_code: str, order_id: str, ordered_qty: int) -> Optional[dict]:
"""종결될 때까지(또는 시도 소진까지) 폴링. 마지막으로 본 행 정보를 돌려준다."""
attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS))
interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC))
await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC)))
last: Optional[dict] = None
for i in range(attempts):
rows = await korea_investment_api.get_todays_orders(stock_code)
info = parse_fill_row_v2(rows, order_id)
if info is not None:
last = info
if is_terminal_fill(info, ordered_qty):
return info
if i < attempts - 1:
await asyncio.sleep(interval)
return last
async def fetch_fill_v2(
stock_code: str, order_id: str, side: str, ordered_qty: int,
) -> Optional[dict]:
"""[M7] 주문 체결 상태. 조회 실패·시간초과·행 없음이면 None + warning.
반환 dict 에 `terminal`(bool) 이 추가된다.
"""
if not order_id:
logger.warning(f"[DTFill] {stock_code} {side}: 주문번호 없음 — 신호가 기록 유지")
return None
timeout = float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)
try:
info = await asyncio.wait_for(
_poll_fill_v2(stock_code, order_id, ordered_qty), timeout=timeout,
)
except asyncio.TimeoutError:
logger.warning(
f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) "
f"order_id={order_id} — 신호가 기록 유지"
)
return None
except Exception as e:
logger.warning(
f"[DTFill] {stock_code} {side} 체결 조회 실패 order_id={order_id}: "
f"{type(e).__name__}: {e} — 신호가 기록 유지"
)
return None
if info is None:
logger.warning(
f"[DTFill] {stock_code} {side} 주문 행 미확인 "
f"({int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)}회 조회) order_id={order_id} — 신호가 기록 유지"
)
return None
info = dict(info)
info["terminal"] = is_terminal_fill(info, ordered_qty)
return info
def schedule_v2(
stock_code: str,
order_id: str,
side: str,
ordered_qty: int,
on_result: Callable[[dict], Awaitable[None]],
) -> Optional[asyncio.Task]:
"""[M7] fetch_fill_v2 결과(체결 0·부분체결 포함)를 `on_result(info)` 로 넘긴다.
`DAY_TRADE_FILL_CHECK_ENABLED=False` 면 아무것도 하지 않는다(None).
"""
if not settings.DAY_TRADE_FILL_CHECK_ENABLED:
return None
async def _run() -> None:
info = await fetch_fill_v2(stock_code, order_id, side, ordered_qty)
if info is None:
return
try:
await on_result(info)
except Exception as e:
logger.warning(
f"[DTFill] {stock_code} {side} 체결 결과 반영 실패 order_id={order_id}: "
f"{type(e).__name__}: {e}",
exc_info=True,
)
try:
task = asyncio.get_running_loop().create_task(_run())
except RuntimeError as e:
logger.warning(f"[DTFill] {stock_code} {side} 체결 확인 예약 실패: {e}")
return None
_pending_tasks.add(task)
task.add_done_callback(_pending_tasks.discard)
return task

View File

@@ -90,6 +90,12 @@ class DayTradingState:
self.portfolio_cache_dirty: bool = True
# 매매 실행 락 — positions 수정 시 반드시 획득
self.trade_lock = asyncio.Lock()
# [H-B 2026-10-02] 청산 주문 진행 중 종목. exit_service._execute_exit_impl 이
# trade_lock 안에서 등록하고 shield 된 주문 작업의 finally 에서 해제한다.
# 청산 판정 경로(손절 선판정·분봉 판정·폴백 손절·force_close_all)는 이 종목을 건너뛴다
# — 주문은 진행 중인데 인메모리 수량이 아직 그대로라 같은 물량을 다시 파는 것을 막는다.
# 날짜 초기화(reset_if_new_day)로 지우지 않는다(진행 중 주문의 보호가 풀린다).
self.exits_in_progress: set[str] = set()
def reset_if_new_day(self) -> bool:
"""날짜가 바뀌면 일일 상태 초기화.
@@ -209,6 +215,10 @@ class DayTradingState:
"""
self.daily_pnl += float(delta)
def is_exit_in_progress(self, stock_code: str) -> bool:
"""[H-B] 해당 종목의 청산 주문이 진행 중(또는 결과 불명 보류 중)인지."""
return stock_code in self.exits_in_progress
def get_position(self, stock_code: str) -> dict | None:
"""특정 종목의 활성 포지션 정보 반환.
@@ -494,7 +504,7 @@ class DayTradingState:
f"기본값 적용: {target_price:.0f}"
)
self.positions[trade.stock_code] = {
_restored = {
"stock_name": trade.stock_name or trade.stock_code,
"strategy": trade.strategy_name or "day_trade",
"entry_price": entry_price,
@@ -509,6 +519,20 @@ class DayTradingState:
"order_id": trade.order_id or "",
"restored_from_db": True,
}
# [M1 2026-10-02] 분할매도 이력 복원. exit_service 의 분할매도가
# remaining_pct(<1.0) 를 ActiveTrade 에 남긴다. 복원 시 이를 보지 않으면
# target_price 가 원래 목표가로 되살아나 재기동 직후 같은 50% 분할이
# 재발동한다(잔량의 절반을 또 판다). 인메모리 분할 후 상태와 동일하게
# 목표가를 제거(inf)하고 partial_sold 를 세운다 → 트레일링만 적용.
if _is_partial_sold_row(trade):
_restored["target_price"] = float("inf")
_restored["partial_sold"] = True
logger.warning(
f"[DayTrading] {trade.stock_code} 분할매도 이력 복원 "
f"(remaining_pct={trade.remaining_pct}) — 목표가 분할 재발동 차단, "
f"트레일링 고점={_restored['highest_price']:.0f}"
)
self.positions[trade.stock_code] = _restored
restored += 1
logger.info(
f"[DayTrading] DB에서 포지션 복원: {trade.stock_code} "
@@ -527,6 +551,21 @@ class DayTradingState:
return restored
def _is_partial_sold_row(trade) -> bool:
"""[M1] ActiveTrade 행이 분할매도 이후 상태인지 (remaining_pct < 1.0)."""
try:
rp = getattr(trade, "remaining_pct", None)
return rp is not None and 0.0 < float(rp) < 1.0
except (TypeError, ValueError):
return False
# 싱글톤 인스턴스 — daytrading 패키지 전체에서 공유
# entry_service, exit_service, scheduler에서 동일 인스턴스 참조
day_trading_state = DayTradingState()
# [M5 2026-10-02] 데이트레이딩 사이클 전역 락 — 스케줄러와 수동 API(/api/daytrading/cycle)가
# 공유한다. 같은 프로세스에서 청산 체크·진입 스캔이 두 경로로 동시에 돌면 같은 포지션을
# 두 번 매도하거나 슬롯·예산 검사를 동시에 통과해 한도를 넘겨 매수한다.
# trade_lock(인메모리 dict 보호, 짧게 잡음)과 다르다 — 이 락은 '한 사이클 단계' 전체를 감싼다.
dt_cycle_lock = asyncio.Lock()

View File

@@ -30,12 +30,16 @@ from app.core.market_schedule import (
seconds_until_market_open,
)
from app.services.mdd_manager import mdd_manager
from app.services.daytrading.position_manager import day_trading_state
from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock
from app.services.daytrading.entry_service import (
collect_opening_ranges, scan_and_enter,
get_watchlist, populate_daytrading_watchlist, is_watchlist_auto,
)
from app.services.daytrading.exit_service import check_and_exit_positions, force_close_all
from app.services.daytrading.exit_service import (
check_and_exit_positions, force_close_all, check_stops_only_v2,
)
from app.core.log_throttle import warn_throttled # noqa: E402
logger = logging.getLogger(__name__)
@@ -206,9 +210,10 @@ async def daytrading_scheduler() -> None:
# EOD 강제 청산 (15:20)
if is_eod_flat_time():
if day_trading_state.positions:
exits = await _guard(
force_close_all(reason="EOD 강제 청산 (15:20)"), "EOD 강제청산", 300
)
async with dt_cycle_lock:
exits = await _guard(
force_close_all(reason="EOD 강제 청산 (15:20)"), "EOD 강제청산", 300
)
if exits:
logger.info(f"[DayTrading] EOD 청산: {len(exits)}건")
await asyncio.sleep(30)
@@ -216,6 +221,14 @@ async def daytrading_scheduler() -> None:
# 09:00~09:30 ORB 수집
if is_opening_range_period():
# [M4 2026-10-02] 이 분기는 아래에서 continue 하므로 예전에는 09:00~09:30
# 동안 청산 판정이 전혀 없었다(전일 미청산·재기동 복원 포지션 방치).
# ORB 수집보다 먼저 전일 포지션 청산 + 손절선 판정을 수행한다(§8.1-11).
# 진입·익절 규칙은 건드리지 않는다(ORB 구간엔 신규 진입이 없다).
async with dt_cycle_lock:
orb_exits = await _guard(check_stops_only_v2(), "ORB 구간 손절 체크")
if orb_exits:
logger.info(f"[DayTrading] ORB 구간 청산: {len(orb_exits)}건")
if not get_watchlist():
try:
count = await populate_daytrading_watchlist()
@@ -240,8 +253,8 @@ async def daytrading_scheduler() -> None:
try:
await populate_daytrading_watchlist()
wl = get_watchlist()
except Exception:
pass
except Exception as _swallowed:
warn_throttled(logger, "scheduler:243", "[DayTrade] 감시종목 자동 갱신 실패: %s", _swallowed)
logger.info(
f"[DayTrading] 🔄 cycle: mdd_ok={short_term_ok}, "
@@ -249,7 +262,9 @@ async def daytrading_scheduler() -> None:
)
# 청산 체크 (MDD 무관 — 기존 포지션 보호)
exits = await _guard(check_and_exit_positions(), "청산 체크")
# [M5] 수동 /cycle API 와 공유하는 전역 락 — 같은 포지션 이중 매도 방지
async with dt_cycle_lock:
exits = await _guard(check_and_exit_positions(), "청산 체크")
if exits:
logger.info(f"[DayTrading] 청산: {len(exits)}건")
@@ -268,7 +283,8 @@ async def daytrading_scheduler() -> None:
# 진입 (MDD 허용 시만)
if short_term_ok:
entries = await _guard(scan_and_enter(), "진입 스캔")
async with dt_cycle_lock:
entries = await _guard(scan_and_enter(), "진입 스캔")
if entries:
logger.info(f"[DayTrading] 진입: {len(entries)}건")
else:
@@ -279,7 +295,8 @@ async def daytrading_scheduler() -> None:
# 그 외 시간 — 보유 포지션 청산 체크만
if day_trading_state.positions:
exits = await _guard(check_and_exit_positions(), "장외 청산 체크")
async with dt_cycle_lock:
exits = await _guard(check_and_exit_positions(), "장외 청산 체크")
if exits:
logger.info(f"[DayTrading] 장외 청산: {len(exits)}건")