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:
@@ -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 없음).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user