fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -0,0 +1,157 @@
"""[하드닝 R3 2026-10-09] 스윙 매도 직전 KIS 상한 — DT 이중 차감 / 상한 매도의 '전량 청산' 오처리.
결함: 상한 = min(주문가능, 보유) − DT. 주문가능에는 대기 중인 매도 주문(DT 미체결 매도 포함)이 이미
빠져 있어 DT 를 이중 차감했고, 상한에 걸려 일부만 판 매도도 `sell_qty >= quantity(캡 적용 후)` 로
ActiveTrade close·loss_tracker·closed_today 까지 '전량 청산' 처리했다.
수정: 상한 = min(주문가능, 보유 − DT). 전량 판정은 상한 적용 전 스윙 보유(min(장부, 보유 − DT)) 대비.
KIS 시뮬레이터·주문 목(공통 종단 safe_place_order)은 R5 테스트의 _Sim 을 재사용한다(§4.1).
"""
from unittest.mock import AsyncMock
import pytest
from app.services.mean_reversion_strategy import ExitSignal
from tests.test_swing_r5_pending_sell_20261002 import UNKNOWN, _Sim, _reset # noqa: F401 (autouse fixture)
def _stop(remaining):
return ExitSignal("SELL_ALL", "손절", 1.0)
def _partial(remaining):
return ExitSignal("SELL_PARTIAL", "1차 익절", 0.5) if remaining > 0.5 else None
def _track(monkeypatch, sim):
"""ActiveTrade upsert 의 quantity·remaining_pct 와 closed_today 를 기록."""
import app.services.db_worker as dbw
from app.services.daytrading.position_manager import day_trading_state
sim.upserts = []
async def _upsert(p):
sim.upserts.append(dict(p))
if sim.at is not None and "remaining_pct" in p:
sim.at.remaining_pct = p["remaining_pct"]
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _upsert)
monkeypatch.setattr(day_trading_state, "closed_today", set())
return day_trading_state
@pytest.mark.asyncio
async def test_dt_pending_sell_capped_sale_is_not_full_close(monkeypatch):
"""[재현 반전] 스윙 10 + DT 5, DT 매도 5주 미체결(보유 15, 주문가능 10). 상한은 과다매도 방지를
위해 보수적으로 10−5=5 유지(대기분이 스윙 것일 수 있음). 이전: 5주만 팔고 '전량 청산'
(close·loss_tracker·closed_today). 이제: 분할 처리(잔량 5) → DT 대기 체결 후 잔량 5주 매도 시 전량 청산."""
sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop)
dts = _track(monkeypatch, sim)
sim.kis = {"hldg": 15, "psbl": 10}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert "005930" not in dts.closed_today
assert sim.upserts[-1]["quantity"] == 5
# DT 대기 매도 체결 → DT 인메모리 0, KIS 보유 = 스윙 잔량 5
sim.kis = {"hldg": 5, "psbl": 5}
monkeypatch.setattr(dts, "positions", {})
await sim.cycles(1)
assert sim.sent == [5, 5] and sim.kis["hldg"] == 0
assert sim.closed == 1
sim.loss.assert_awaited_once()
assert "005930" in dts.closed_today
@pytest.mark.asyncio
async def test_swing_own_open_order_not_resold_into_dt_shares(monkeypatch):
"""상한을 보유 − DT 로 완화하면 스윙 자신의 미체결 매도(10주)와 재매도가 모두 체결돼 DT 10주까지
팔린다. 보수적 상한 유지 → 재매도 없음(R5 test_c1_open_order_at_broker_blocks_resell 과 같은 불변식)."""
sim = _Sim(monkeypatch, swing=10, dt=10, signal=_stop)
_track(monkeypatch, sim)
sim.kis = {"hldg": 20, "psbl": 10} # 스윙 10주 매도 주문이 브로커에 미체결
await sim.cycles(2)
assert sim.sent == []
@pytest.mark.asyncio
async def test_capped_stop_is_partial_until_actual_remainder_zero(monkeypatch):
"""스윙 10, 지정가 익절 5주 미체결(보유 10, 주문가능 5)에서 손절. 이전: 5주 매도 + close +
loss_tracker + closed_today. 이제: 5주 매도 후 ActiveTrade 잔량 5·remaining_pct 0.5 만 갱신,
close·loss_tracker 없음. 묶인 주문이 풀리면 다음 주기 잔량 5주 매도 시 그때 전량 청산."""
sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop)
dts = _track(monkeypatch, sim)
sim.kis = {"hldg": 10, "psbl": 5}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert "005930" not in dts.closed_today
assert sim.upserts[-1]["quantity"] == 5
assert sim.upserts[-1]["remaining_pct"] == 0.5 # 단계 차감(1.0−1.0=0) 아님 — 실제 잔량 비율
# 묶여 있는 동안(주문가능 0)에는 주문하지 않는다
await sim.cycles(1)
assert sim.sent == [5]
# 지정가 취소 → 주문가능 회복 → 잔량 매도 = 이번엔 전량 청산
sim.kis["psbl"] = sim.kis["hldg"]
await sim.cycles(1)
assert sim.sent == [5, 5] and sim.kis["hldg"] == 0
assert sim.closed == 1
sim.loss.assert_awaited_once()
assert "005930" in dts.closed_today
@pytest.mark.asyncio
async def test_capped_partial_take_profit_not_closed(monkeypatch):
"""스윙 20 (보유 20, 주문가능 5)에서 50% 익절 10주 → 5주만 매도. 분할 단계는 소진(재발동 방지),
ActiveTrade 잔량 15, close·loss_tracker 없음."""
sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial)
_track(monkeypatch, sim)
sim.kis = {"hldg": 20, "psbl": 5}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert sim.upserts[-1]["quantity"] == 15
assert sim.upserts[-1]["remaining_pct"] == 0.5
await sim.cycles(2)
assert sim.sent == [5] # 같은 단계 재발동 없음
@pytest.mark.asyncio
async def test_capped_stop_unknown_reconciled_as_partial(monkeypatch):
"""상한에 걸린 손절이 결과 불명 → 체결 대조 시에도 스윙 보유(10) 대비 부분 체결로 반영."""
sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop, responses=[UNKNOWN])
_track(monkeypatch, sim)
sim.kis = {"hldg": 10, "psbl": 5}
await sim.cycles(1)
pend = sim.es._pending_unknown_sells["005930"]
assert pend["swing_qty"] == 10 and pend["sell_pct"] == 0.0
await sim.cycles(1)
assert "005930" not in sim.es._pending_unknown_sells
assert sim.closed == 0
sim.loss.assert_not_awaited()
assert sim.upserts[-1]["quantity"] == 5
assert sim.upserts[-1]["remaining_pct"] == 0.5
@pytest.mark.asyncio
async def test_inflated_ledger_still_closes_when_kis_swing_empty(monkeypatch):
"""장부 스윙 10(Portfolio 15 − DT 5)인데 KIS 보유 12 → KIS 스윙 7주 전량 매도 = 전량 청산 유지."""
sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop)
_track(monkeypatch, sim)
sim.kis = {"hldg": 12, "psbl": 12}
await sim.cycles(1)
assert sim.sent == [7]
assert sim.closed == 1
sim.loss.assert_awaited_once()
@pytest.mark.asyncio
async def test_dt_unreflected_buy_never_sells_dt_inmemory_qty(monkeypatch):
"""DT 매수 직후 KIS 보유 미반영(보유 10, DT 인메모리 5) — 보수적으로 보유 − DT = 5 만 매도(과다매도 없음)."""
sim = _Sim(monkeypatch, swing=10, dt=5, signal=_stop)
_track(monkeypatch, sim)
sim.kis = {"hldg": 10, "psbl": 10}
await sim.cycles(1)
assert sim.sent == [5]
assert sim.kis["hldg"] == 5