- 스윙 체결가 확인 전 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
199 lines
6.3 KiB
Python
199 lines
6.3 KiB
Python
"""하드닝 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())
|