"""하드닝 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())