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,198 @@
"""하드닝 R1: 팬텀 ActiveTrade close 유예 / FX 폴백 총자산 제외 / 상태 집합."""
import asyncio
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from app.api.endpoints import portfolio as pf
from app.core.config import settings
@pytest.fixture(autouse=True)
def _reset():
pf._phantom_state_reset()
yield
pf._phantom_state_reset()
NOW = datetime(2026, 10, 9, 10, 0, 0)
def _row(age_sec, qty=5):
ts = NOW - timedelta(seconds=age_sec)
return SimpleNamespace(status="active", quantity=qty, created_at=ts, updated_at=ts)
def _run(rows, recent):
return pf._reconcile_active_trade_quantities(
{"005930": rows}, {}, {"005930": "swing"}, {"005930": 0},
phantom_guard=True, recent_trade_codes=recent, now=NOW,
)
def test_new_row_not_closed():
r = _row(5)
acts = _run([r], set())
assert r.status == "active" and acts[0]["action"] == "close_deferred"
def test_recent_trade_defers():
r = _row(99999)
_run([r], {"005930"})
assert r.status == "active"
def test_recent_trades_unknown_defers():
r = _row(99999)
_run([r], None)
assert r.status == "active"
def test_closes_only_after_two_consecutive_misses():
r = _row(99999)
_run([r], set())
assert r.status == "active"
acts = _run([r], set())
assert r.status == "closed" and acts[0]["action"] == "closed"
def test_miss_counter_reset_when_held():
r = _row(99999)
_run([r], set())
pf._reconcile_active_trade_quantities(
{"005930": [r]}, {"005930": 5}, {"005930": "swing"}, {"005930": 5},
phantom_guard=True, recent_trade_codes=set(), now=NOW)
_run([r], set())
assert r.status == "active"
def test_legacy_call_unchanged():
r = _row(1)
acts = pf._reconcile_active_trade_quantities({"005930": [r]}, {}, {}, None)
assert r.status == "closed" and acts[0]["action"] == "closed"
def test_inherit_helper_failure_returns_none():
class Boom:
async def execute(self, *_a, **_k):
raise RuntimeError("db down")
assert asyncio.run(pf._recent_closed_non_dt_row_v2(Boom(), "005930", NOW)) is None
def test_config_default_fx_excluded_from_total_assets(monkeypatch):
monkeypatch.setattr(pf, "_last_good_fx_rate", 0.0)
monkeypatch.setattr(settings, "US_DEFAULT_FX_RATE", 1350.0, raising=False)
cache = pf.PortfolioCache()
cache.holdings = [SimpleNamespace(
stock_code="AAPL", market="NASD", quantity=10, average_price=100, current_price=200,
currency_code="USD", exchange_code="NASD", stock_name="A", profit_loss=0,
profit_loss_rate=0, strategy_type="us_swing")]
bal = {"rt_cd": "0", "output1": [], "output2": [{"tot_evlu_amt": "10000000",
"dnca_tot_amt": "10000000", "prvs_rcdl_excc_amt": "10000000"}]}
asyncio.run(cache.cache_summary_v2(bal, {}))
s = cache.summary
assert s["exchange_rate_source"] == "config_default"
assert s["exchange_rate_estimate"] == 1350.0
assert s["exchange_rate_unavailable"] is True
assert s["total_assets"] == pytest.approx(10000000)
def test_realized_status_superset():
from app.core.trade_status import REALIZED_PNL_STATUSES
assert {"COMPLETED", "FILLED", "EXECUTED"} <= set(REALIZED_PNL_STATUSES)
assert "PENDING" not in REALIZED_PNL_STATUSES
# ---- R4: 재시딩 승계 표식 / 실청산 구분 -------------------------------------
from app.services.swing import exit_service as _xs
class _Res:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
class _DB:
def __init__(self, rows=None, boom=False):
self.rows, self.boom = rows or [], boom
async def execute(self, *_a, **_k):
if self.boom:
raise RuntimeError("db down")
return _Res(self.rows)
def _closed(qty=5, ep=10000.0):
return SimpleNamespace(id=7, stock_code="005930", quantity=qty, entry_price=ep,
updated_at=NOW, created_at=NOW - timedelta(days=3))
def test_inherited_reason_is_recognised_as_seeded():
reason = pf._inherited_entry_reason_v2("기술: RSI 과매도")
assert reason.endswith("기술: RSI 과매도")
row = SimpleNamespace(strategy_name="swing", entry_reason=reason)
assert _xs._is_seeded_active_trade_v2(row)
assert pf._inherited_entry_reason_v2(reason) == reason # 이중 접두사 없음
def test_real_full_exit_not_inherited(monkeypatch):
async def row(*_a, **_k):
return _closed(qty=5)
monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row)
got = asyncio.run(pf._inheritable_closed_row_v3(_DB([(5, 5)]), "005930", 10000.0, NOW))
assert got is None
def test_phantom_close_is_inherited(monkeypatch):
async def row(*_a, **_k):
return _closed(qty=5)
monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row)
got = asyncio.run(pf._inheritable_closed_row_v3(_DB([]), "005930", 10000.0, NOW))
assert got is not None and got.id == 7
def test_sell_lookup_failure_not_inherited(monkeypatch):
async def row(*_a, **_k):
return _closed(qty=5)
monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row)
assert asyncio.run(pf._inheritable_closed_row_v3(_DB(boom=True), "005930", 10000.0, NOW)) is None
def test_entry_price_divergence_not_inherited(monkeypatch):
async def row(*_a, **_k):
return _closed(qty=5, ep=10000.0)
monkeypatch.setattr(pf, "_recent_closed_non_dt_row_v2", row)
avg = 10000.0 * (1 + pf._PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE + 0.05)
assert asyncio.run(pf._inheritable_closed_row_v3(_DB([]), "005930", avg, NOW)) is None
def test_no_untracked_create_task_in_portfolio():
"""fire-and-forget 는 spawn_tracked_v2 로만 (GC 소멸·종료 drain 누락 방지)."""
import inspect
src = inspect.getsource(pf)
code = [l for l in src.splitlines()
if "asyncio.create_task(" in l and not l.lstrip().startswith("#") and "(실시간" not in l]
assert code == []
def test_spawn_tracked_keeps_strong_ref_and_clears():
from app.core import background_tasks as bt
async def _go():
ev = asyncio.Event()
async def _w():
await ev.wait()
t = bt.spawn_tracked_v2(_w())
assert t in bt._tracked
ev.set()
await t
await asyncio.sleep(0)
assert t not in bt._tracked
asyncio.run(_go())