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:
@@ -592,6 +592,8 @@ def _parse_restored_unknown_sell_v2(stock_code: str, raw) -> "dict | None":
|
||||
entry["prior_realized_net"] = float(entry["prior_realized_net"])
|
||||
if entry["qty"] <= 0:
|
||||
raise ValueError("qty<=0")
|
||||
# [R9] monotonic 시각은 프로세스 간 비교 불가 — 복원 대기는 "이 프로세스의 모든 매수보다 먼저 등록"으로 본다
|
||||
entry.pop("since_mono", None)
|
||||
return entry
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error(
|
||||
@@ -652,6 +654,88 @@ async def _restore_unknown_sells_v2() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _unknown_sell_expired_v2(pend: dict, now_kst_dt=None) -> bool:
|
||||
"""[하드닝 R9 2026-10-12, §8.5-38 H-C] 등록일(KST) 장 마감(15:30) 이후면 True. 등록 시각 불명이면 False."""
|
||||
from datetime import datetime, time as dtime
|
||||
from app.core.timeutil import KST, now_kst
|
||||
since = pend.get("since")
|
||||
try:
|
||||
since_f = float(since)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
now_dt = now_kst_dt or now_kst() # KST naive (timeutil 규약)
|
||||
reg = datetime.fromtimestamp(since_f, tz=KST).replace(tzinfo=None)
|
||||
close_dt = datetime.combine(reg.date(), dtime(15, 30))
|
||||
if reg >= close_dt:
|
||||
# 장 마감 이후 등록(정상 운영에선 없음)은 다음 날 장 마감까지 유지
|
||||
from datetime import timedelta
|
||||
close_dt += timedelta(days=1)
|
||||
return now_dt > close_dt
|
||||
|
||||
|
||||
async def _try_reconcile_before_expire_v2(code: str) -> bool:
|
||||
"""TTL 폐기 직전 대조 1회. 대기가 해제됐으면(체결 반영/미체결 확인) True. 조회 실패·대기 유지면 False."""
|
||||
try:
|
||||
snap = await _presell_kis_holding_v2(code)
|
||||
if snap is not None:
|
||||
await _reconcile_unknown_sell_v2(code, *snap)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SwingExit] {code}: TTL 폐기 전 결과 불명 매도 대조 실패 — {type(e).__name__}: {e}")
|
||||
return False
|
||||
return code not in _pending_unknown_sells
|
||||
|
||||
|
||||
async def _expire_unknown_sells_v2(now_kst_dt=None) -> list:
|
||||
"""[하드닝 R9 2026-10-12, §8.5-38 H-C] 결과 불명 매도 대기 폐기 — 반환 = 폐기한 종목.
|
||||
|
||||
(1) 등록일 장 마감(15:30 KST) 이후: 당일 미체결 주문은 소멸하므로 대조 없이 폐기.
|
||||
(2) 대기 등록 이후 같은 종목의 신규 매수 접수가 관측됨(trade_executor 사이클 매수 누적):
|
||||
대기의 pre_hldg 가 새 포지션과 섞여 가짜 체결이 기록될 수 있으므로 폐기.
|
||||
어느 쪽이든 체결분 장부 반영이 누락됐을 수 있어 warning + 텔레그램(수동 확인)을 남긴다.
|
||||
판정 자체는 try 밖 — 매수 누적 조회 실패만 warning 후 (1) 만 적용한다.
|
||||
"""
|
||||
if not _pending_unknown_sells:
|
||||
return []
|
||||
recent_buys: dict = {}
|
||||
try:
|
||||
from app.services.trade_executor import _recent_cycle_buys_v2
|
||||
recent_buys = _recent_cycle_buys_v2() or {}
|
||||
except Exception as e:
|
||||
logger.warning(f"[SwingExit] 사이클 매수 누적 조회 실패 — 재매수 폐기 판정 생략: {type(e).__name__}: {e}")
|
||||
dropped = []
|
||||
for code, pend in list(_pending_unknown_sells.items()):
|
||||
why = None
|
||||
if _unknown_sell_expired_v2(pend, now_kst_dt):
|
||||
# 폐기 전 KIS 잔고 대조 1회 — 장 후반 등록 후 사이클이 없었던 항목이 대조 없이 버려지지 않게.
|
||||
# 대조로 판정(체결 반영 또는 미체결 확인 해제)되면 그 결과로 종결, 못 하면 폐기+경보.
|
||||
if await _try_reconcile_before_expire_v2(code):
|
||||
continue
|
||||
why = "등록일 장 마감 경과"
|
||||
else:
|
||||
buy = recent_buys.get(code)
|
||||
if isinstance(buy, dict):
|
||||
try:
|
||||
buy_ts = float(buy.get("ts", 0))
|
||||
reg_mono = pend.get("since_mono")
|
||||
# 복원된 대기(since_mono 없음)는 이 프로세스의 모든 매수보다 먼저 등록된 것
|
||||
if reg_mono is None or buy_ts > float(reg_mono):
|
||||
why = "등록 이후 같은 종목 신규 매수 접수"
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(f"[SwingExit] {code}: 재매수 폐기 판정 시각 형식 이상 — 대기 유지 ({e})")
|
||||
if why is None:
|
||||
continue
|
||||
_pending_unknown_sells.pop(code, None)
|
||||
await _forget_unknown_sell_v2(code)
|
||||
dropped.append(code)
|
||||
msg = (
|
||||
f"[SwingExit] {code}: 결과 불명 매도 대기({pend.get('qty')}주) 대조 없이 폐기 — {why}. "
|
||||
"체결분 장부(TradeHistory·ActiveTrade·loss_tracker) 반영이 누락됐을 수 있음, 수동 확인 필요"
|
||||
)
|
||||
logger.warning(msg)
|
||||
_notify_swing_exit_alert_v2(f"unknown_sell_expired:{code}", "SWING_EXIT", msg)
|
||||
return dropped
|
||||
|
||||
|
||||
async def _reconcile_orphan_unknown_sells_v2(holding_codes: set) -> None:
|
||||
"""보유 목록에 없는 종목의 결과 불명 대기를 KIS 잔고로 대조.
|
||||
|
||||
@@ -732,6 +816,65 @@ async def _fetch_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None":
|
||||
return hq, sq
|
||||
|
||||
|
||||
def _holding_from_rows_v2(rows: list, stock_code: str) -> "tuple[bool, int, int] | None":
|
||||
"""잔고 행 목록에서 (발견 여부, 보유, 주문가능). 형식 이상이면 None. 주문가능 없으면 보유로 본다."""
|
||||
hq = sq = 0
|
||||
found = False
|
||||
try:
|
||||
for h in rows:
|
||||
if not isinstance(h, dict) or str(h.get("pdno", "") or "") != stock_code:
|
||||
continue
|
||||
found = True
|
||||
_h = max(0, int(float(h.get("hldg_qty", 0) or 0)))
|
||||
_s = _h
|
||||
if h.get("ord_psbl_qty") not in (None, ""):
|
||||
_s = max(0, min(_h, int(float(h.get("ord_psbl_qty") or 0))))
|
||||
hq += _h
|
||||
sq += _s
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return found, hq, sq
|
||||
|
||||
|
||||
async def _fetch_kis_holding_v3(stock_code: str) -> "tuple[int, int] | None":
|
||||
"""[하드닝 R9 2026-10-12, §8.5-37] v2 + 연속조회(get_balance_all_v2).
|
||||
|
||||
- 예외·오류 응답(rt_cd≠0)·형식 이상: None(보류) — v2 와 동일.
|
||||
- 완전 응답(`_pages` 있음, `_partial` 없음): 미발견 = 미보유 (0, 0) 확정. 행 수가 페이지 한도
|
||||
이상이어도 보류하지 않는다(20종목 이상 보유 시 2페이지 종목의 손절이 풀린다).
|
||||
- 불완전 응답(`_partial` True): 미발견이면 다음 페이지에 있을 수 있음 → None(보류, error).
|
||||
- `_pages` 없는 응답(구 형태): v2 의 "첫 페이지 가득 + 미발견 = 보류" 휴리스틱 유지.
|
||||
"""
|
||||
try:
|
||||
bal = await korea_investment_api.get_balance_all_v2()
|
||||
except Exception as e:
|
||||
logger.error(f"[SwingExit] KIS 잔고(연속조회) 조회 예외 ({stock_code}): {type(e).__name__}: {e} — 매도 보류")
|
||||
return None
|
||||
if not isinstance(bal, dict) or bal.get("rt_cd") not in (None, "0"):
|
||||
return None
|
||||
rows = bal.get("output1")
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
got = _holding_from_rows_v2(rows, stock_code)
|
||||
if got is None:
|
||||
return None
|
||||
found, hq, sq = got
|
||||
if not found:
|
||||
if "_pages" in bal:
|
||||
if bal.get("_partial"):
|
||||
logger.error(
|
||||
f"[SwingExit] {stock_code} KIS 잔고 연속조회 불완전({bal.get('_pages')}페이지, {len(rows)}행)"
|
||||
f"에 없음 — 다음 페이지 가능, 매도 보류"
|
||||
)
|
||||
return None
|
||||
elif len(rows) >= int(settings.SWING_BALANCE_FULL_PAGE_ROWS):
|
||||
logger.error(
|
||||
f"[SwingExit] {stock_code} KIS 잔고 첫 페이지({len(rows)}행)에 없음 — 페이지 초과 가능, 매도 보류"
|
||||
)
|
||||
return None
|
||||
return hq, sq
|
||||
|
||||
|
||||
# [C-1 2026-10-02 6회차] 종목별 매도 직전 KIS 잔고 확인 연속 실패 횟수
|
||||
_presell_check_failures: dict[str, int] = {}
|
||||
|
||||
@@ -743,7 +886,7 @@ async def _presell_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None":
|
||||
(결과 불명 체결·외부 매도 등). 매도 수량 상한을 항상 KIS 실보유로 잡아 DT 물량 매도를
|
||||
구조적으로 막는다. 조회 실패 시 매도 보류 — 청산 지연을 감수한다(과다매도보다 낫다).
|
||||
"""
|
||||
got = await _fetch_kis_holding_v2(stock_code)
|
||||
got = await _fetch_kis_holding_v3(stock_code) # [R9] 연속조회(§8.5-37)
|
||||
if got is None:
|
||||
n = _presell_check_failures.get(stock_code, 0) + 1
|
||||
_presell_check_failures[stock_code] = n
|
||||
@@ -1686,6 +1829,8 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
_apply_census_v2(_census, len(_run_holding_codes))
|
||||
except Exception as e:
|
||||
logger.warning(f"[SwingExit] 보유 집계 경보 처리 실패(판정 계속): {type(e).__name__}: {e}")
|
||||
# [하드닝 R9] 불명 대기 TTL(장 마감)·재매수 폐기 — 대조보다 먼저(§8.5-38 H-C)
|
||||
await _expire_unknown_sells_v2()
|
||||
# [하드닝 R2] 보유 목록 밖(전량 체결 후 sync 로 행 삭제 등)의 불명 대기 대조 — 유령 ActiveTrade 방지
|
||||
if _pending_unknown_sells:
|
||||
await _reconcile_orphan_unknown_sells_v2(
|
||||
@@ -2034,6 +2179,18 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
)
|
||||
continue
|
||||
|
||||
# [하드닝 R9 2026-10-12, §8.5-38 H-B] 결과 불명 매도 대기(미결) 중: 분할 익절은 건너뛴다 —
|
||||
# remaining_pct 가 아직 갱신되지 않아 같은 단계가 재발동한다. 손절·전량 청산은 계속 허용
|
||||
# (§8.1-11). 수량 상한은 아래 KIS 상한 min(주문가능, 보유) − DT — 불명 주문이 접수됐다면
|
||||
# 주문가능이 이미 줄어 있어 과다매도가 안 난다. 판정은 try 밖(§4).
|
||||
if (domestic and stock_code in _pending_unknown_sells
|
||||
and float(getattr(exit_signal, "sell_pct", 1.0) or 1.0) < 1.0):
|
||||
logger.warning(
|
||||
f"[SwingExit] {stock_code}: 결과 불명 매도 대기 중 — 분할 익절({exit_signal.reason}) 건너뜀, "
|
||||
"손절·전량 신호만 허용"
|
||||
)
|
||||
continue
|
||||
|
||||
# ── 매도 수량 결정 (소량 분할 방지) ─────────────────────
|
||||
# [M5 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율.
|
||||
# check_exit_signal / _check_momentum_exit 의 remaining_pct 게이트가
|
||||
@@ -2164,10 +2321,23 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
f"{err} — 해외 잔고 확인 미지원, 수동 확인 필요"
|
||||
)
|
||||
elif _outcome == "unknown":
|
||||
# [하드닝 R9] 이미 대기 중(분할 불명 → 손절 불명)이면 덮어써서 앞선 불명 수량을
|
||||
# 잃지 않도록 합산한다. 기준 잔고는 앞선 대기의 pre_hldg(더 이른 스냅샷)를 유지.
|
||||
_prev_pend = _pending_unknown_sells.get(stock_code)
|
||||
_new_qty = int(sell_qty)
|
||||
_new_pre = int(_pre_hldg if _pre_hldg is not None else total_qty)
|
||||
if _prev_pend:
|
||||
logger.warning(
|
||||
f"[SwingExit] {stock_code}: 기존 결과 불명 대기({_prev_pend.get('qty')}주)에 "
|
||||
f"새 불명 매도 {sell_qty}주 합산"
|
||||
)
|
||||
_new_qty += int(_prev_pend.get("qty") or 0)
|
||||
_new_pre = max(_new_pre, int(_prev_pend.get("pre_hldg") or 0))
|
||||
_pending_unknown_sells[stock_code] = {
|
||||
"qty": int(sell_qty),
|
||||
"pre_hldg": int(_pre_hldg if _pre_hldg is not None else total_qty),
|
||||
"qty": _new_qty,
|
||||
"pre_hldg": _new_pre,
|
||||
"since": time.time(),
|
||||
"since_mono": time.monotonic(),
|
||||
"price": float(latest_price),
|
||||
"avg_price": float(avg_price),
|
||||
"swing_qty": int(_swing_basis),
|
||||
@@ -2184,6 +2354,9 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
}
|
||||
# [하드닝 R2] 재기동에도 대조가 이어지도록 Redis 에 미러링(실패는 warning)
|
||||
await _persist_unknown_sell_v2(stock_code, _pending_unknown_sells[stock_code])
|
||||
# [하드닝 R9, H-B] 불명 경로도 청산 쿨다운 기록 — 추천 경로 _execute_sell 공용
|
||||
# 쿨다운과 분할 신호 재매도를 막는다(전량형은 쿨다운 우회 규칙 그대로).
|
||||
_recent_exits[stock_code] = time.time()
|
||||
_trigger_portfolio_sync_v2()
|
||||
logger.error(
|
||||
f"[SwingExit] 🚨 매도 주문 결과 불명 ({stock_code}, {sell_qty}주, msg_cd={_mc}): "
|
||||
@@ -2229,6 +2402,17 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
|
||||
_recent_exits[stock_code] = time.time()
|
||||
|
||||
# [하드닝 R9, §8.5-38 H-B] 결과 불명 대기 중 정상 접수된 매도(손절·전량)는 대기 기준 잔고에서
|
||||
# 차감한다 — 안 하면 다음 대조의 (pre_hldg − KIS 보유)에 이 매도분이 섞여 불명 체결로 이중 기록.
|
||||
_pend_now = _pending_unknown_sells.get(stock_code) if domestic else None
|
||||
if _pend_now is not None:
|
||||
_pend_now["pre_hldg"] = max(0, int(_pend_now.get("pre_hldg") or 0) - int(sell_qty))
|
||||
logger.warning(
|
||||
f"[SwingExit] {stock_code}: 결과 불명 대기 중 정상 매도 {sell_qty}주 접수 — "
|
||||
f"대기 기준 잔고 → {_pend_now['pre_hldg']}주"
|
||||
)
|
||||
await _persist_unknown_sell_v2(stock_code, _pend_now)
|
||||
|
||||
# [2026-10-08] 실제 체결가·체결수량 확인 → TradeHistory 체결가·금액·순손익 보정
|
||||
# (백그라운드, db_worker 큐 경유). 이전엔 신호가(latest_price)가 그대로 체결가로
|
||||
# 남았다. 국내만 — 해외 체결조회 경로 없음(US_TRADING_ENABLED=False).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1184,6 +1184,75 @@ async def _run_recommendation_entry_v2(
|
||||
await execute_swing_entries(rec, entry_session)
|
||||
|
||||
|
||||
# ── [하드닝 R9 2026-10-12] 청산 미종결 사이클의 MDD 축소·진입 생략 (redteam #8) ─────────────
|
||||
# 청산 판정이 상한 초과(TIMED_OUT)로 백그라운드에서 계속 돌거나, 이전 판정이 아직 진행 중이라
|
||||
# 생략(SKIPPED)됐거나, 예외로 끝났으면 그 사이클의 MDD 축소·신규 진입을 하지 않는다. detached 청산과
|
||||
# MDD 축소가 같은 종목을 동시에 매도하면 분할 단계 판정이 이중 적용될 수 있고, 청산 판정을 끝내지
|
||||
# 못한 상태에서 매수를 내는 것은 '청산 우선'(§8.1-11) 위반이다. 청산 자체는 계속 실행된다 —
|
||||
# 다음 사이클이 청산 판정을 정상 완료하면 자동 재개(_inflight_steps 의 '청산 판정' 태스크 기준).
|
||||
_EXIT_STEP_NAME = "청산 판정"
|
||||
|
||||
|
||||
# [R9 재수정] 예외로 끝난 청산 단계는 태스크가 종료돼 동시 매도가 없으므로 **진입만** 생략하고
|
||||
# MDD 축소는 실행한다. TIMED_OUT·SKIPPED·백그라운드 진행 중만 축소·진입 둘 다 생략.
|
||||
_DEFAULT_SWING_EXIT_UNSETTLED_ESCALATE_CYCLES = 3
|
||||
# MDD 축소가 '청산 진행 중'으로 연속 생략된 사이클 수(정상 청산 사이클에서 리셋)
|
||||
_mdd_unsettled_skip_state: dict = {"count": 0, "escalated": False}
|
||||
|
||||
|
||||
def _exit_phase_unsettled_reason_v2(exit_res, raised: bool) -> str | None:
|
||||
"""진입 생략 사유(예외 포함). 정상이면 None (순수 판정 + 인메모리 조회)."""
|
||||
if raised:
|
||||
return "청산 단계 예외"
|
||||
return _exit_inflight_reason_v2(exit_res)
|
||||
|
||||
|
||||
def _exit_inflight_reason_v2(exit_res) -> str | None:
|
||||
"""청산 판정이 아직 돌고 있을 수 있는 사유(MDD 축소·진입 모두 생략). 아니면 None."""
|
||||
if exit_res is _STEP_TIMED_OUT:
|
||||
return "청산 판정 상한 초과(백그라운드 진행 중)"
|
||||
if exit_res is _STEP_SKIPPED:
|
||||
return "이전 청산 판정이 아직 진행 중"
|
||||
_t = _inflight_steps.get(_EXIT_STEP_NAME)
|
||||
if _t is not None and not _t.done():
|
||||
return "청산 판정 백그라운드 진행 중"
|
||||
return None
|
||||
|
||||
|
||||
def _skip_for_unsettled_exit_v2(reason: str | None, stage: str) -> bool:
|
||||
"""reason 이 있으면 warning + (스로틀) 텔레그램 후 True — 호출부가 해당 단계를 생략한다."""
|
||||
if not reason:
|
||||
return False
|
||||
msg = (f"[Swing] {stage} 생략 — {reason}. 같은 종목 이중 매도·청산 전 매수 방지, "
|
||||
f"청산 판정이 정상 완료되는 사이클에 재개")
|
||||
logger.warning(msg)
|
||||
_notify_throttled_v2(f"exit_unsettled:{stage}", "SWING_SCHEDULER", msg)
|
||||
return True
|
||||
|
||||
|
||||
def _note_mdd_reduction_unsettled_v2(skipped: bool) -> None:
|
||||
"""MDD 축소 연속 생략 에스컬레이션. skipped=False 면 리셋.
|
||||
|
||||
축소를 강제 실행하지는 않는다 — 청산 판정이 백그라운드에서 같은 종목을 매도 중일 수 있어
|
||||
이중 매도 위험이 축소 지연보다 크다. 대신 N사이클 연속이면 error + 텔레그램(스트릭당 1회).
|
||||
"""
|
||||
if not skipped:
|
||||
_mdd_unsettled_skip_state["count"] = 0
|
||||
_mdd_unsettled_skip_state["escalated"] = False
|
||||
return
|
||||
_mdd_unsettled_skip_state["count"] += 1
|
||||
n = _mdd_unsettled_skip_state["count"]
|
||||
limit = _cfg_int_v2(
|
||||
"SWING_EXIT_UNSETTLED_ESCALATE_CYCLES", _DEFAULT_SWING_EXIT_UNSETTLED_ESCALATE_CYCLES,
|
||||
)
|
||||
if n >= limit and not _mdd_unsettled_skip_state["escalated"]:
|
||||
_mdd_unsettled_skip_state["escalated"] = True
|
||||
msg = (f"MDD 축소 {n}사이클 연속 미실행(청산 판정 미종결, 기준 {limit}사이클) — 수동 확인 필요. "
|
||||
f"이중 매도 방지로 축소를 강제하지 않음")
|
||||
logger.error(f"[Swing] 🚨 {msg}")
|
||||
_notify_throttled_v2("exit_unsettled:mdd_escalate", "SWING_SCHEDULER", msg)
|
||||
|
||||
|
||||
async def swing_scheduler() -> None:
|
||||
"""60초 주기 스윙 트레이딩 스케줄러.
|
||||
|
||||
@@ -1248,6 +1317,8 @@ async def swing_scheduler() -> None:
|
||||
# 매도가 같은 종목에 겹쳐 분할 매도가 이중 실행될 수 있다(과다매도).
|
||||
_note_market_open_v2(_now_mono_v2())
|
||||
_exit_timeout = _cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", _DEFAULT_EXIT_STEP_TIMEOUT_SEC)
|
||||
_exit_res = None
|
||||
_exit_raised = False
|
||||
try:
|
||||
_exit_res = await _guard_detached_v2(_run_exit_phase_v2, "청산 판정", _exit_timeout)
|
||||
if _exit_res is _STEP_TIMED_OUT:
|
||||
@@ -1255,8 +1326,14 @@ async def swing_scheduler() -> None:
|
||||
elif _exit_res is _STEP_SKIPPED:
|
||||
_record_exit_failure_v2("이전 청산 판정이 아직 진행 중(상한 초과 상태)")
|
||||
except Exception as e:
|
||||
_exit_raised = True
|
||||
logger.error(f"[Swing] 청산 단계 오류: {e}", exc_info=True)
|
||||
_record_exit_failure_v2(f"{type(e).__name__}: {e}")
|
||||
# [하드닝 R9] 청산 미종결이면 이번 사이클 MDD 축소·진입 생략(판정은 try 밖 — 가드)
|
||||
_exit_unsettled = _exit_phase_unsettled_reason_v2(_exit_res, _exit_raised)
|
||||
_exit_inflight = _exit_inflight_reason_v2(_exit_res)
|
||||
if _exit_inflight is None:
|
||||
_note_mdd_reduction_unsettled_v2(False) # 청산 판정 종결(정상/예외) → 리셋
|
||||
try:
|
||||
from app.core import heartbeat as _hb
|
||||
_hb.beat("swing", "exit_phase_done")
|
||||
@@ -1378,7 +1455,12 @@ async def swing_scheduler() -> None:
|
||||
)
|
||||
else:
|
||||
logger.warning("[Swing] MDD 초과 — 신규 진입 차단")
|
||||
if mdd_manager.reduction_applied:
|
||||
_mdd_skip = bool(mdd_manager.reduction_applied) and _skip_for_unsettled_exit_v2(
|
||||
_exit_inflight, "MDD 축소",
|
||||
)
|
||||
if _mdd_skip:
|
||||
_note_mdd_reduction_unsettled_v2(True)
|
||||
if mdd_manager.reduction_applied and not _mdd_skip:
|
||||
try:
|
||||
reduce_pct = 0.50 if mdd_manager.reduction_applied == "20pct" else 0.30
|
||||
mdd_action = {
|
||||
@@ -1416,6 +1498,9 @@ async def swing_scheduler() -> None:
|
||||
# scan_dual_strategy_signals 가 일봉 조회 전에 생략한다.
|
||||
# [하드닝 R1] 진입(평균회귀→듀얼)은 주문 경로라 비취소형 상한. 초과 시 루프는
|
||||
# 다음 사이클(청산 우선)로 진행하고, 끝날 때까지 새 진입을 시작하지 않는다.
|
||||
if _skip_for_unsettled_exit_v2(_exit_unsettled, "진입"):
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
await _guard_detached_v2(
|
||||
lambda: _run_entry_phase_v2(
|
||||
get_json,
|
||||
|
||||
Reference in New Issue
Block a user