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

@@ -1553,6 +1553,8 @@ async def _resolve_unknown_exit_v2_impl(
hldg = int(_hh.get("hldg", 0) or 0)
sellable = int(_hh.get("sellable", 0) or 0)
dt_sellable = max(0, min(sellable, hldg) - int(nd))
# [C-1 r9] v3 가 '보유 기준 DT 몫 온전 여부'를 보도록 판정 근거를 남긴다(KIS 재조회 없이)
_last_unknown_resolve_snapshot[stock_code] = (hldg, sellable, int(nd))
if dt_sellable <= max(0, int(total_qty) - int(qty)):
rows = await _lookup_todays_orders_impl(stock_code)
row = _find_accepted_sell_row(rows, stock_code, qty, since)
@@ -1595,6 +1597,177 @@ def _note_unknown_lookup_failure(stock_code: str) -> None:
logger.error(f"[DayExit] 수동 청산 경보 발송 실패({stock_code}): {e}")
async def _fetch_dt_ledger_net_qty_impl(stock_code: str) -> int | None:
"""[C-1 r9] DT 원장(TradeHistory strategy_type='daytrading') 순체결수량. 실패면 None.
창은 DAY_TRADE_ORPHAN_LOOKBACK_DAYS(전일 포지션 경로도 덮도록 당일만이 아니다).
집계는 _dt_net_qty_from_history — BUY 는 COMPLETED 만, SELL 은 FAILED/REJECTED 외 전부
(PENDING 포함)를 차감한다. 결과 불명 매도의 재매도 상한에서만 호출되는 드문 경로다.
"""
try:
from app.core.database import AsyncSessionLocal
from app.models.models import TradeHistory
from sqlalchemy import select as _select
_since = datetime.combine(
_kst_now().date() - timedelta(days=int(settings.DAY_TRADE_ORPHAN_LOOKBACK_DAYS)),
datetime.min.time(),
)
# DB-READ-EXCEPTION: §3.2 ③-c 와 같은 읽기 전용 SELECT 1건, commit/flush/add 없음.
# 쓰기는 db_worker 경유. 결과 불명 매도의 재매도 상한 확인에서만 호출된다.
async with AsyncSessionLocal() as _db:
_rows = (await _db.execute(
_select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.strategy_type == "daytrading",
TradeHistory.created_at >= _since,
)
)).scalars().all()
return int(_dt_net_qty_from_history(_rows).get(stock_code, 0))
except Exception as e:
logger.error(f"[DayExit] DT 원장 순체결 조회 실패({stock_code}): {e}")
return None
# [C-1 r9] v2 판정 근거 스냅샷 {code: (hldg, sellable, 비DT)} — v3 가 즉시 pop 한다
_last_unknown_resolve_snapshot: dict[str, tuple[int, int, int]] = {}
# [C-1 r9] 재매도 보류 상태(판정 불가) — lookup_failed 와 같은 처리(보류·N회 경보)
_UNKNOWN_HOLD_STATUSES = ("lookup_failed", "ambiguous", "ledger_failed", "orders_failed", "ledger_nonpositive")
async def _lookup_todays_orders_v2_impl(stock_code: str) -> list | None:
"""[C-1 r9 2차] 당일 주문내역 — **조회 실패·시간초과면 None**(get_todays_orders_v2 계약).
v1(_lookup_todays_orders_impl)은 실패에도 [] 를 받아 "주문 없음"과 구분할 수 없었다.
호출부는 None 을 판정 보류(재매도 금지)로 취급한다.
"""
try:
return await asyncio.wait_for(
korea_investment_api.get_todays_orders_v2(stock_code),
timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC),
)
except asyncio.TimeoutError:
logger.warning(f"[DayExit] 당일 주문내역(v2) 조회 시간초과 ({stock_code}) — 판정 보류")
except Exception as e:
logger.warning(f"[DayExit] 당일 주문내역(v2) 조회 실패 ({stock_code}): {e} — 판정 보류")
return None
def _find_accepted_sell_row_strict_v2(rows, stock_code: str, qty: int, since) -> dict | None:
"""[C-1 r9 2차] 불명 주문 등록 시각 이후·수량 일치·유효(체결>0 또는 접수 상태) 매도 행.
_find_accepted_sell_row 와 달리 **주문시각(ord_tmd)이 없거나 since 가 없으면 매칭하지 않는다**
(시각 근거 없는 같은 수량 행 — 스윙 매도 등 — 을 DT 접수로 오인하지 않게). since 에서
DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC 만큼만 앞당겨 시계 오차를 허용한다.
"""
if since is None:
return None
_w = float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC)
_since_hms = (since - timedelta(seconds=_w)).strftime("%H%M%S")
for r in rows or []:
if not _row_is_live_or_filled_sell(r, stock_code):
continue
try:
if int(float(r.get("ord_qty", 0) or 0)) != int(qty):
continue
except (TypeError, ValueError):
continue
t = str(r.get("ord_tmd", "") or "")
if not t or t < _since_hms:
continue
odno = _norm_odno(r.get("odno", ""))
if odno and (_today_key(), odno) in _finalized_sell_odnos:
continue
return r
return None
async def _resolve_unknown_exit_v3_impl(
stock_code: str, qty: int, since, total_qty: int,
) -> tuple[str, dict | None, int]:
"""[C-1 r9, 2차 2026-10-12] v2 판정 + HTS·수동 보유분(ActiveTrade 없음) 재매도 방지.
v2 는 "DT 몫 = min(주문가능, 보유) − 비-DT active" 로 본다. ActiveTrade 없는 수동 보유분 M 이
있으면 원 주문이 체결됐어도 not_accepted 가 되고 재시도가 수동 물량을 판다. 결과 불명 SELL 은
TradeHistory 행을 남기지 않아 원장도 매수 수량 그대로다(원장만으로는 못 막는다).
v2 가 not_accepted 일 때 순서대로:
1) 당일 주문내역 v2(실패면 None). None → "orders_failed"(보류). 등록 시각 이후·수량 일치·유효
매도 행이 있으면 **온전 여부와 무관하게** accepted.
2) 행 없음 + 보유 기준 DT 몫(보유 − 비DT) < 주문 전 수량 → "ambiguous"(보류).
3) 원장 순체결 실패 → "ledger_failed", ≤ 0 → "ledger_nonpositive"(둘 다 보류 + error·경보).
4) 그 외(주문내역 [] = KIS 미접수 실증, 원장 > 0) → 재매도, 상한 min(DT 매도가능, 원장, q).
⚠️ 잔여: 주문내역이 첫 페이지에 안 보이면(당일 주문 다수) 4) 로 떨어져 수동 물량을 최대 q 주
팔 수 있다. 같은 수량·등록 이후 스윙 매도는 1) 에서 DT 접수로 오인될 수 있다(과소매도 쪽).
상한 계산은 try 밖. 보류 상태들은 _UNKNOWN_HOLD_STATUSES.
"""
st, resp, dt_sellable = await _resolve_unknown_exit_v2_impl(stock_code, qty, since, total_qty)
_snap = _last_unknown_resolve_snapshot.pop(stock_code, None)
if st != "not_accepted":
return st, resp, dt_sellable
# 1) 주문내역(v2) — 조회 실패(None)면 보류. 행이 있으면 온전 판정과 무관하게 접수 확정.
rows = await _lookup_todays_orders_v2_impl(stock_code)
if rows is None:
return "orders_failed", None, 0
row = _find_accepted_sell_row_strict_v2(rows, stock_code, qty, since)
if row is not None:
odno = str(row.get("odno", "") or "")
logger.warning(
f"[DayExit] {stock_code} 결과 불명 매도 → 당일 주문내역 일치 행(등록 이후·{qty}주, "
f"ODNO={odno or '미확인'})으로 접수 판정 (잔고상 DT 매도가능 {dt_sellable}주)"
)
return "accepted", {"rt_cd": "0", "msg1": "resolved_by_orders", "output": {"ODNO": odno}}, dt_sellable
# 2) 행 없음 + 보유 기준 DT 몫이 온전하지 않음 → 판정 불가(보류)
intact = _snap is None or (int(_snap[0]) - int(_snap[2])) >= int(total_qty)
if not intact:
logger.error(
f"[DayExit] 🚨 {stock_code} 결과 불명 매도 판정 불가 — 보유 {_snap[0]}주 − 비DT {_snap[2]}주가 "
f"주문 전 DT {total_qty}주보다 적은데 DT 매도가능 {dt_sellable}주가 주문 후 기대치"
f"({max(0, int(total_qty) - int(qty))}주)보다 많고 주문내역 일치 행도 없음. "
f"ActiveTrade 없는 수동·HTS 보유분일 수 있어 재매도 보류"
)
return "ambiguous", None, 0
# 3) 원장 — 실패·순체결 ≤ 0 이면 보류(과다매도보다 보류, §3.5)
ledger = await _fetch_dt_ledger_net_qty_impl(stock_code)
if ledger is None:
return "ledger_failed", None, 0
if ledger <= 0:
logger.error(
f"[DayExit] 🚨 {stock_code} 결과 불명 매도 — DT 원장 순체결 {ledger}주(≤0)라 DT 몫을 확인할 수 "
f"없음. 재매도 보류(원장 반영 지연이면 다음 확인에서 해소)"
)
return "ledger_nonpositive", None, 0
# 4) 주문내역 [] 실증(KIS 가 접수한 적 없음) + 원장 > 0 → 재매도 허용, 상한 min(DT 매도가능, 원장, q)
capped = min(int(dt_sellable), int(ledger), int(qty))
if capped < dt_sellable:
logger.warning(
f"[DayExit] {stock_code} 결과 불명 재매도 상한 축소: DT 매도가능 {dt_sellable} → "
f"min(원장 {ledger}, 원 주문 {qty}) = {capped}주"
)
return "not_accepted", None, capped
def _note_unknown_hold_v2(stock_code: str, status: str) -> None:
"""[C-1 r9] 보류 사유별 로그. 조회 실패 계열은 기존 N회 연속 텔레그램 경보를 공유한다."""
if status == "orders_failed":
_error_throttled(
f"dt_exit:unknown_orders_failed:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 당일 주문내역 조회 실패 — 재매도 보류 중",
)
if status == "ledger_nonpositive":
_error_throttled(
f"dt_exit:unknown_ledger_nonpos:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + DT 원장 순체결 ≤ 0 — 재매도 보류 중. 계좌 확인 필요",
)
if status == "ambiguous":
_error_throttled(
f"dt_exit:unknown_ambiguous:{stock_code}",
f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 접수 여부 판정 불가(수동 보유분 혼재 의심) — "
f"재매도 보류 중. 계좌 확인 필요",
)
_note_unknown_lookup_failure(stock_code)
async def _place_and_finalize_exit_impl(
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
quantity: int, total_qty: int, is_full_exit: bool, is_stale: bool,
@@ -1625,14 +1798,15 @@ async def _place_and_finalize_exit_impl(
# [H-2] 직전 불명 주문이 미해결이면 새 주문 전에 다시 확인한다(잔고 기준).
_prev = _unresolved_exit_unknown.get(_ukey)
if _prev is not None:
_st, _resp, _dt_sellable = await _resolve_unknown_exit_v2_impl(
# [C-1 r9] v3 — 수동·HTS 보유분 혼재 시 not_accepted 오판 방지 + DT 원장 순체결 상한
_st, _resp, _dt_sellable = await _resolve_unknown_exit_v3_impl(
stock_code, int(_prev["qty"]), _prev["since"],
int(_prev.get("total_qty", total_qty)),
)
if _st == "lookup_failed":
if _st in _UNKNOWN_HOLD_STATUSES:
# 판단 불가 — 재매도하지 않고 보류를 이어간다(guarded finally 가 HOLD 뒤 해제)
_last_exit_order_outcome[stock_code] = "unknown"
_note_unknown_lookup_failure(stock_code)
_note_unknown_hold_v2(stock_code, _st)
return None
_unknown_lookup_failures.pop(_ukey, None)
if _st == "accepted":
@@ -2888,6 +3062,14 @@ async def _detect_dt_orphans_impl(db, active_dt_rows, stale: dict, today) -> tup
return orphans, restores
async def _restore_lost_today_positions_v2_impl(rows, kis_holdings: dict, other_qty: dict) -> int:
"""[C-2 r9] 판정 수량을 min(주문가능, 보유) 로 — 접수·미체결 DT 매도 물량을 복원해 재매도하지 않게."""
from app.services.daytrading.position_manager import _cap_hldg_by_sellable_v2
_capped = {c: _cap_hldg_by_sellable_v2(v) for c, v in (kis_holdings or {}).items()}
return await _restore_lost_today_positions_impl(rows, _capped, other_qty)
async def _restore_lost_today_positions_impl(rows, kis_holdings: dict, other_qty: dict) -> int:
"""당일 active 행 → 인메모리 복원 (restore_from_db 와 같은 변환). 복원 수 반환."""
from app.services.daytrading.position_manager import _active_trade_row_to_position
@@ -3061,7 +3243,7 @@ async def close_stale_positions() -> list[dict]:
continue
_judgeable.append(_r)
if _judgeable:
await _restore_lost_today_positions_impl(_judgeable, kis_holdings, other_strategy_qty)
await _restore_lost_today_positions_v2_impl(_judgeable, kis_holdings, other_strategy_qty)
if not stale:
return results