from types import SimpleNamespace as NS from app.services.ml_retrain_pipeline import _resolve_trade_price_v2 def test_executed_price_preferred(): assert _resolve_trade_price_v2(NS(executed_price=100, total_amount=1, executed_quantity=1, order_price=5)) == 100.0 def test_null_falls_back_to_amount_over_qty(): assert _resolve_trade_price_v2(NS(executed_price=None, total_amount=1000, executed_quantity=10, order_price=5)) == 100.0 def test_null_falls_back_to_order_price(): assert _resolve_trade_price_v2(NS(executed_price=None, total_amount=None, executed_quantity=None, order_price=55)) == 55.0 def test_all_missing_zero(): assert _resolve_trade_price_v2(NS(executed_price=None, total_amount=None, executed_quantity=None, order_price=None)) == 0.0 # ─── 라운드4: 성능 저하 감시 / 장중 재학습 방지 ─────────────────────────── import asyncio from datetime import datetime import pandas as pd import pytest from app.services import ml_retrain_pipeline as mrp def test_filled_statuses_include_completed(): assert "COMPLETED" in mrp._FILLED_TRADE_STATUSES def test_load_trade_history_filters_completed(monkeypatch): captured = {} class _Res: def scalars(self): return self def all(self): return [] class _Sess: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, stmt): captured["sql"] = str(stmt.compile(compile_kwargs={"literal_binds": True})) return _Res() monkeypatch.setattr(mrp, "AsyncSessionLocal", lambda: _Sess()) asyncio.run(mrp._load_trade_history(months=1)) assert "COMPLETED" in captured["sql"] @pytest.mark.parametrize( "dt,expected", [ (datetime(2026, 10, 7, 9, 30), False), # 수 장중 (datetime(2026, 10, 7, 15, 0), False), (datetime(2026, 10, 7, 20, 0), True), # 수 야간 (datetime(2026, 10, 7, 3, 0), True), (datetime(2026, 10, 10, 11, 0), True), # 토 ], ) def test_safe_window(dt, expected): assert mrp._is_retrain_safe_window_v2(dt) is expected def test_run_retrain_builds_features_in_thread(monkeypatch): seen = {} async def fake_load(months=6): return pd.DataFrame({"stock_code": ["A"], "close": [1.0]}) def fake_build(df): import threading seen["thread"] = threading.current_thread() return [], [] monkeypatch.setattr(mrp, "_load_daily_prices", fake_load) monkeypatch.setattr(mrp, "_build_training_lists_impl", fake_build) import threading res = asyncio.run(mrp.run_retrain(force=True)) assert res["status"] == "skipped" assert seen["thread"] is not threading.main_thread() def test_scheduler_defers_retrain_during_market_hours(monkeypatch): calls = {"retrain": 0} async def fake_check(): return True, {"degraded": True} async def fake_retrain(*a, **k): calls["retrain"] += 1 return {"status": "completed"} class _FakeDT(datetime): @classmethod def now(cls, tz=None): return cls(2026, 10, 7, 9, 45, tzinfo=tz) async def fake_sleep(sec): if sec == 3600: raise asyncio.CancelledError monkeypatch.setattr(mrp, "check_performance_degradation", fake_check) monkeypatch.setattr(mrp, "run_retrain", fake_retrain) monkeypatch.setattr(mrp, "datetime", _FakeDT) monkeypatch.setattr(mrp.asyncio, "sleep", fake_sleep) with pytest.raises(asyncio.CancelledError): asyncio.run(mrp.ml_retrain_scheduler()) assert calls["retrain"] == 0