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

@@ -299,6 +299,84 @@ async def _enqueue_buy_entry_adjust_v2(row, filled_qty: int, filled_price: float
return True
# ── [하드닝 R9 2026-10-12] 늦게 확정된 0체결·부분체결 스윙 BUY 의 ActiveTrade 원복 ─────────────
# 직후 확인(trade_executor._verify_and_sync_execution_v2)이 15초 폴링 동안 잔량 대기로 끝나면 확정은
# 여기 몫인데, 이전에는 0체결 확정을 warning 후 continue(CANCELLED 강등·ActiveTrade close 없음),
# 부분체결 확정은 TradeHistory·진입가 보정만 하고 ActiveTrade.quantity 를 주문수량으로 남겼다
# → 유령 active 행·수량 과대가 sync 유령 정리 전까지 분할매도 수량·보유 슬롯을 왜곡했다.
# 원복 정보는 (1) 같은 프로세스면 trade_executor 레지스트리(직후 확인이 재등록), (2) 재기동 등으로
# 없으면 TradeHistory 행(entry_reason·order_quantity)과 active 행으로 재구성한다.
# 멱등: 행 id 를 `_queued_ids` 에 먼저 넣고, close 는 이미 닫힌 행에 no-op, 축소는 절대값(=체결수량).
# Portfolio 는 스윙 주문 시점에 갱신하지 않으므로(KIS sync 가 진실) 되돌릴 것이 없다.
def _is_swing_buy_row_v2(row) -> bool:
return (getattr(row, "order_type", None) == "BUY"
and getattr(row, "strategy_type", None) in _SWING_BUY_STRATEGY_TYPES)
def reconstruct_buy_rollback_v2(row, active_rows, filled_qty: int) -> tuple[dict | None, str]:
"""TradeHistory BUY 행 + 그 종목 active 행들로 원복 정보를 재구성 (순수 함수).
반환 (rollback | None, 사유). rollback 은 trade_executor 레지스트리와 같은 모양
{stock_code, strategy_name, quantity, accumulate}.
- 대상 행 확정은 진입가 보정과 같은 규칙(resolve_entry_fill_target_v2) — 확정 불가면 원복 안 함.
- 행 수량 == 주문수량 → 신규 진입(accumulate False).
- 행 수량 > 주문수량 → 추가매수(accumulate True — 원복 함수가 수동 확인 경보).
- 행 수량 == 체결수량(< 주문수량) → 이미 원복됨(None, "already").
- 그 외(행 수량 < 주문수량: 이미 일부 매도 등) → 역산 근거 없음(None).
"""
ordered = int(getattr(row, "order_quantity", 0) or 0)
if ordered <= 0:
return None, "order_quantity 없음"
target, why = resolve_entry_fill_target_v2(row, active_rows)
if not target:
return None, why
same = [r for r in (active_rows or []) if getattr(r, "strategy_name", None) == target]
newest = max(same, key=lambda r: int(getattr(r, "id", 0) or 0))
at_qty = int(getattr(newest, "quantity", 0) or 0)
rb = {
"stock_code": row.stock_code,
"strategy_name": target,
"quantity": ordered,
"accumulate": at_qty > ordered,
}
if at_qty >= ordered:
return rb, "ok"
if filled_qty > 0 and at_qty == int(filled_qty):
return None, "already"
return None, f"active 행 수량 {at_qty} < 주문수량 {ordered}(일부 매도 등) — 역산 근거 없음"
async def _resolve_buy_rollback_v2(row, db, filled_qty: int) -> dict | None:
"""레지스트리 우선, 없으면 TradeHistory 행으로 재구성. 확정 불가면 warning 후 None."""
from app.services.trade_executor import _pop_buy_entry_rollback_v2
rb = _pop_buy_entry_rollback_v2(row.order_id, row.stock_code)
if rb:
return rb
if db is None:
logger.warning(
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} BUY 원복 정보 없음·조회 세션 없음 "
f"— ActiveTrade 수동 확인 필요"
)
return None
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)} BUY 원복용 ActiveTrade 조회 실패 — "
f"수동 확인 필요: {type(e).__name__}: {e}"
)
return None
rb, why = reconstruct_buy_rollback_v2(row, active_rows, filled_qty)
if rb is None and why != "already":
logger.warning(
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} BUY 체결 {filled_qty}/"
f"{int(row.order_quantity or 0)}주 확정 — ActiveTrade 원복 생략: {why} (수동 확인)"
)
return rb
def unconfirmed_fill_clause_v2():
"""'체결가 미확인' TradeHistory 판정식 (모니터링 지표와 sweep 이 공유하는 단일 정의)."""
return or_(
@@ -404,6 +482,26 @@ async def sweep_unconfirmed_fills_v2(db) -> int:
f"order_id={row.order_id}"
)
continue
if (
filled_qty <= 0 and _is_swing_buy_row_v2(row) and info.get("found")
and (remaining == 0 or info.get("cancelled"))
):
# [하드닝 R9] 0체결 + 잔량 소멸 확정 = 매수 불성립 → CANCELLED + ActiveTrade close.
from app.services.trade_executor import (
_mark_trade_cancelled_v2, _rollback_buy_active_trade_v2,
)
_queued_ids.add(row.id) # 먼저 표시 — 이후 예외여도 당일 재처리 안 함(멱등)
_attempts.pop(row.id, None)
await _mark_trade_cancelled_v2(str(row.order_id), row.id, filled_qty=0)
_rb = await _resolve_buy_rollback_v2(row, db, 0)
_res = await _rollback_buy_active_trade_v2(str(row.order_id), _rb, 0)
queued += 1
_last_stats["queued"] = int(_last_stats.get("queued", 0)) + 1
logger.warning(
f"[FillSweep] {row.stock_code} #{row.id} BUY 0체결 소멸 확정 — CANCELLED 강등, "
f"ActiveTrade 원복={_res} (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} 체결 미확인 "
@@ -441,6 +539,16 @@ async def sweep_unconfirmed_fills_v2(db) -> int:
_queued_ids.add(row.id)
_attempts.pop(row.id, None)
await enqueue_update_trade_history(row.id, fields)
if _is_swing_buy_row_v2(row) and ordered > 0 and filled_qty < ordered:
# [하드닝 R9] 부분체결 확정 → ActiveTrade.quantity 를 체결수량으로(절대값 — 멱등).
# 진입가 보정보다 먼저 큐잉해야 보정 분모(행 수량)가 체결수량이 된다(직후 확인과 같은 순서).
from app.services.trade_executor import _rollback_buy_active_trade_v2
_rb = await _resolve_buy_rollback_v2(row, db, filled_qty)
await _rollback_buy_active_trade_v2(str(row.order_id), _rb, filled_qty)
elif row.order_type == "BUY":
# 전량 체결 확정 — 남은 원복 정보 폐기(레지스트리 정리)
from app.services.trade_executor import _pop_buy_entry_rollback_v2
_pop_buy_entry_rollback_v2(row.order_id, row.stock_code)
if row.order_type == "BUY":
await _enqueue_buy_entry_adjust_v2(row, filled_qty, filled_price, db)
queued += 1