fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강

- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:37:16 +09:00
parent 0feaa61bfc
commit d140055e64
60 changed files with 2853 additions and 50 deletions

View File

@@ -859,6 +859,82 @@ class DayTradingState:
return out
async def verify_restored_today_positions_v3(
self, session_factory=None, holdings_fetcher=None, today_rows_fetcher=None,
) -> dict:
"""[C-2 r9] v2 + 주문가능수량·당일 PENDING DT 매도 반영.
v2 는 KIS **보유수량(hldg)** 만 봤다. 재기동 직전 접수된 미체결 DT 매도(VI 단일가 등)가
있으면 hldg 는 그대로라 포지션이 복원되고, 일반 청산이 다시 팔아 같은 종목 스윙 보유분이
팔릴 수 있다(§3.5). 이제:
- 판정 수량 = min(주문가능, 보유) − 비DT active (주문가능에서는 접수된 매도가 이미 빠진다)
- 당일 TradeHistory 에 DT SELL PENDING 이 있는 종목은 복원 보류(warning).
⚠️ **현재 DT 경로는 PENDING SELL 행을 쓰지 않는다** — 매도 TradeHistory 는 접수 확정 뒤
COMPLETED 로만 들어가고, 결과 불명 매도는 행을 남기지 않는다. 따라서 이 분기는 현재 발동하지
않는 방어 코드다(PENDING 을 쓰는 경로가 생길 때 대비). 재기동 직전 미체결 매도의 **실효 방어는
위의 min(주문가능, 보유)** 이다. 장중 close_stale_positions 도 같은 규칙
(_restore_lost_today_positions_v2_impl)으로 재검증한다.
- 원장 조회 실패: DAY_TRADE_ORPHAN_DETECT_ENABLED 면 전부 보류(v2 의 판정 불가 규칙과 동일).
이 보류는 실효 동작이다(조회 실패 시 기동 복원을 장중 재검증에 맡김).
DB 는 SELECT 전용(§3.2 ③ restore 경로, commit 없음).
"""
from app.core.config import settings
today = _today_kst()
cand_codes = [
c for c, p in self.positions.items()
if p.get("restored_from_db") and _entry_date(p.get("entry_time")) == today
]
pre_held: list[str] = []
if cand_codes:
rows = None
try:
_f = today_rows_fetcher or (lambda: _fetch_today_trade_rows_impl(today, session_factory))
rows = await asyncio.wait_for(_f(), timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 복원 검증: 당일 원장(PENDING 매도) 조회 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 복원 검증: 당일 원장(PENDING 매도) 조회 실패: {type(e).__name__}: {e}")
# 보류 판정은 try 밖 (§4)
if rows is None:
hold_codes = set(cand_codes) if settings.DAY_TRADE_ORPHAN_DETECT_ENABLED else set()
_why = "당일 PENDING 매도 확인 불가"
else:
hold_codes = {
getattr(r, "stock_code", None) for r in rows
if str(getattr(r, "strategy_type", "") or "") == "daytrading"
and str(getattr(r, "order_type", "") or "").upper() == "SELL"
and str(getattr(r, "status", "") or "").upper() == "PENDING"
} & set(cand_codes)
_why = "당일 DT 매도 PENDING(결과 미확정)"
if hold_codes:
async with self.trade_lock:
for c in sorted(hold_codes):
if self.positions.pop(c, None) is not None:
pre_held.append(c)
logger.warning(
f"[DayTrading] ⚠️ 기동 복원 보류 {pre_held} — {_why}. 미체결 매도 물량 재매도 방지, "
f"장중 close_stale_positions 가 다음 사이클에 재검증"
)
async def _sellable_capped_holdings():
if holdings_fetcher is None:
from app.services.daytrading.exit_service import _fetch_kis_holdings_v2_impl as _hf
else:
_hf = holdings_fetcher
h = await _hf()
if h is None:
return None
return {c: _cap_hldg_by_sellable_v2(v) for c, v in h.items()}
out = await self.verify_restored_today_positions_v2(
session_factory=session_factory, holdings_fetcher=_sellable_capped_holdings,
)
out["held"] = pre_held + list(out.get("held") or [])
out["checked"] = int(out.get("checked", 0)) + len(pre_held)
return out
def _active_trade_row_to_position(trade) -> dict:
"""ActiveTrade(day_trade) 행 → 인메모리 포지션 dict (restore_from_db 와 고아 복원 공용).
@@ -924,6 +1000,17 @@ def _entry_date(entry_time) -> date | None:
return None
def _cap_hldg_by_sellable_v2(v) -> dict:
"""[C-2 r9] {"hldg","sellable"} → hldg 를 min(주문가능, 보유) 로 낮춘 사본. sellable 없으면 그대로."""
d = dict(v or {})
if d.get("sellable") is not None:
try:
d["hldg"] = min(int(d.get("hldg", 0) or 0), int(d.get("sellable", 0) or 0))
except (TypeError, ValueError):
d["hldg"] = 0 # 파싱 불가 → 복원 근거 없음(보류 쪽)
return d
async def _fetch_non_dt_active_qty_by_code_impl(codes: list, session_factory=None) -> dict[str, int]:
"""비-DT(스윙·유입·legacy NULL) active ActiveTrade 종목별 수량 합 — SELECT 전용(§3.2 ③, commit 없음).