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

@@ -26,6 +26,15 @@ UNKNOWN = {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "ReadTimeou
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
_FILL_CHECKS: list = []
async def _record_fill_check(order_id, stock_code, stock_name, quantity, price, side, *args, **kwargs):
"""체결 확인 종단 기록형 목 — 즉시 완료(대기·KIS 조회 없음)."""
_FILL_CHECKS.append({"order_id": order_id, "stock_code": stock_code, "quantity": quantity,
"side": side, "kwargs": kwargs})
@pytest.fixture(autouse=True)
def _reset(monkeypatch):
import app.services.swing.exit_service as es
@@ -41,6 +50,13 @@ def _reset(monkeypatch):
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
import app.services.trade_executor as te
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
# [하드닝 R2 2026-10-09] 성공한 시장가 매도는 _schedule_swing_sell_fill_check_v2 →
# spawn_tracked_task_v2(_verify_and_sync_execution_v3(..., delay=3)) 로 **실제** 체결 확인
# 코루틴을 띄운다. 목이 없으면 테스트 종료 후 pending 으로 남고(“Task was destroyed but it is
# pending”), 3초가 지나면 get_order_execution(KIS)·enqueue_update_trade_history 를 호출해
# conftest 차단에만 의존했다(§4.1 위반). 공통 종단(_verify_and_sync_execution_v3)에 기록형 목.
_FILL_CHECKS.clear()
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _record_fill_check)
class _Sim:
@@ -338,3 +354,121 @@ async def test_m4_cancel_during_order_still_records(monkeypatch):
break
await asyncio.sleep(0)
assert len(sim.history) == 1 and sim.closed == 1
# ── [2026-10-09 R1] 연속손실 판정 = 비용 차감 순손익 (CLAUDE.md §5.1) ─────────
def _full_signal(remaining):
return ExitSignal("SELL_ALL", "전량 익절", 1.0)
def _expected_net(avg, price, qty):
from app.services.trade_helpers import calculate_trade_costs
costs = calculate_trade_costs(entry_price=avg, exit_price=price, quantity=qty,
is_domestic=True, market="KOSPI")
return (price - avg) * qty - costs["total_cost"], costs["total_cost"]
def test_r1_swing_net_pnl_matches_trade_helpers_and_flips_small_gain():
"""+0.1% 는 총손익 양수지만 왕복 비용(수수료×2+매도세) 차감 후 음수. 비용 정의는
trade_helpers.calculate_trade_costs 와 동일(KOSPI/KOSDAQ 세율 모두)."""
import app.services.swing.exit_service as es
from app.services.trade_helpers import calculate_trade_costs
for mkt, label in (("KRX", "KOSPI"), ("KOSDAQ", "KOSDAQ")):
r = es._swing_net_pnl_v2(10000, 10010, 10, mkt, domestic=True)
c = calculate_trade_costs(entry_price=10000, exit_price=10010, quantity=10,
is_domestic=True, market=label)
assert r["gross_profit_loss"] == 100
assert r["total_cost"] == c["total_cost"]
assert r["net_profit_loss"] == 100 - c["total_cost"]
assert r["net_profit_loss"] < 0
# 해외는 거래세 없음(trade_helpers 와 동일)
r = es._swing_net_pnl_v2(100, 101, 10, "NASD", domestic=False)
c = calculate_trade_costs(entry_price=100, exit_price=101, quantity=10, is_domestic=False)
assert r["total_cost"] == c["total_cost"]
@pytest.mark.asyncio
async def test_r1_full_exit_within_cost_band_counts_as_loss(monkeypatch):
"""[재현 반전] 스윙 10주 +0.1% 전량 청산. 이전: is_loss=False(총손익 +100원), net_profit_loss
에 총손익 기록. 이제: 순손익 기준 is_loss=True, net_profit_loss·commission 은 비용 차감값."""
avg, price = 10000, 10010
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
await sim.cycles(1)
assert sim.sent == [10]
net, cost = _expected_net(avg, price, 10)
assert net < 0 < (price - avg) * 10
sim.loss.assert_awaited_once()
assert sim.loss.await_args.kwargs["is_loss"] is True
sell = [h for h in sim.history if h["order_type"] == "SELL"]
assert len(sell) == 1
assert sell[0]["net_profit_loss"] == net
assert sell[0]["commission"] == cost
@pytest.mark.asyncio
async def test_r1_full_exit_clear_gain_is_not_loss(monkeypatch):
"""비용을 넘는 이익(+5%)은 여전히 승 — 판정이 과도하게 뒤집히지 않음."""
avg, price = 10000, 10500
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal)
await sim.cycles(1)
sim.loss.assert_awaited_once()
assert sim.loss.await_args.kwargs["is_loss"] is False
net, _ = _expected_net(avg, price, 10)
assert [h for h in sim.history if h["order_type"] == "SELL"][0]["net_profit_loss"] == net
@pytest.mark.asyncio
async def test_r1_unknown_sell_reconcile_uses_net_pnl(monkeypatch):
"""결과 불명 전량 매도가 KIS 잔고로 체결 확인되는 경로도 순손익 기준으로 판정·기록."""
avg, price = 10000, 10010
sim = _Sim(monkeypatch, swing=10, dt=0, price=price, avg=avg, signal=_full_signal,
responses=[UNKNOWN])
await sim.cycles(3)
assert sum(sim.sent) == 10
sim.loss.assert_awaited_once()
assert sim.loss.await_args.kwargs["is_loss"] is True
done = [h for h in sim.history if "체결 확인" in (h.get("exit_reason") or "")]
assert len(done) == 1
net, cost = _expected_net(avg, price, 10)
assert done[0]["net_profit_loss"] == net and done[0]["commission"] == cost
# ── 하드닝 R2 (2026-10-09): 체결 확인 백그라운드 태스크가 테스트 밖으로 새지 않는다 ─────
@pytest.mark.asyncio
async def test_r2_fill_check_terminal_is_mocked_and_task_completes(monkeypatch):
"""성공 매도 → 체결 확인 예약은 기록형 목 종단으로 수렴하고, swing_sell_fill 태스크가
테스트 종료 전에 끝난다(실 _verify_and_sync_execution_v3 미실행 → KIS 조회 0)."""
import app.services.trade_executor as te
assert te._verify_and_sync_execution_v3 is _record_fill_check
sim = _Sim(monkeypatch, swing=10, dt=0)
await sim.cycles(1)
assert sim.sent == [10] and sim.closed == 1
assert len(_FILL_CHECKS) == 1
fc = _FILL_CHECKS[0]
assert fc["stock_code"] == "005930" and fc["quantity"] == 10 and fc["side"] == "SELL"
assert fc["kwargs"]["sell_pnl_basis"]["strategy_type"] == "swing"
# 기록형 목은 즉시 완료 — 한 틱 양보 후 남은 swing_sell_fill 태스크가 없어야 한다
await asyncio.sleep(0)
await asyncio.sleep(0)
pending = [t for t in asyncio.all_tasks()
if (t.get_name() or "").startswith("swing_sell_fill:") and not t.done()]
assert pending == []
@pytest.mark.asyncio
async def test_r2_fill_check_real_coroutine_would_hit_kis_is_not_reached(monkeypatch):
"""종단 목이 빠지면 실 코루틴이 get_order_execution 을 부른다는 전제를 예외 발생형 목으로
고정 — 이 파일의 매도 경로가 그 메서드에 도달하지 않음을 증명한다."""
sim = _Sim(monkeypatch, swing=10, dt=0)
async def _boom(*a, **k):
raise AssertionError("get_order_execution 실호출 경로 도달")
sim.api.get_order_execution = AsyncMock(side_effect=_boom)
import app.services.trade_executor as te
monkeypatch.setattr(te, "korea_investment_api", sim.api, raising=False)
await sim.cycles(1)
await asyncio.sleep(0)
sim.api.get_order_execution.assert_not_called()
assert len(_FILL_CHECKS) == 1