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