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:
198
backend/tests/test_portfolio_hardening_r1.py
Normal file
198
backend/tests/test_portfolio_hardening_r1.py
Normal 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())
|
||||
Reference in New Issue
Block a user