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