You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

121 lines
4.2 KiB
Python

"""Tests: AutoScheduler catchup re-runs missed phases after restart."""
from __future__ import annotations
import datetime as dt
from unittest.mock import AsyncMock, MagicMock, patch
from zoneinfo import ZoneInfo
import pytest
ET = ZoneInfo("America/New_York")
def _make_scheduler():
from apps.web.paper_trading_service import AutoScheduler
sched = AutoScheduler()
sched._sessions = ["session_a"]
sched._db_path = "/tmp/test.db"
sched._log = lambda msg: None
sched._dry_run = False
return sched
@pytest.mark.asyncio
async def test_run_open_reruns_if_missed_and_not_in_phases():
"""If server restarts at 10 AM ET and run_open not in processed_phases, it must run."""
sched = _make_scheduler()
# now_et = 10:00 AM ET on a trading day
now_et = dt.datetime(2026, 4, 24, 10, 0, 0, tzinfo=ET)
mock_state = MagicMock()
mock_session = MagicMock()
mock_session.session_id = "sid-abc"
mock_state.get_session.return_value = mock_session
mock_state.is_phase_processed.return_value = False # not yet run
run_trading_calls = []
async def fake_run_trading(cmd, sessions):
run_trading_calls.append(cmd)
with (
patch.object(sched, "_now_et", return_value=now_et),
patch.object(sched, "_is_trading_day", return_value=True),
patch.object(sched, "_prev_trading_day", return_value=dt.date(2026, 4, 23)),
patch.object(sched, "_run_pipeline", new_callable=AsyncMock, return_value=True),
patch.object(sched, "_run_trading", side_effect=fake_run_trading),
patch("apps.paper_trader.state.StateManager", return_value=mock_state),
):
await sched._run_catchup()
assert "run-open" in run_trading_calls
@pytest.mark.asyncio
async def test_run_open_not_rerun_if_already_in_phases():
"""If run_open already succeeded today (in processed_phases), skip it."""
sched = _make_scheduler()
now_et = dt.datetime(2026, 4, 24, 11, 0, 0, tzinfo=ET)
mock_state = MagicMock()
mock_session = MagicMock()
mock_session.session_id = "sid-abc"
mock_state.get_session.return_value = mock_session
mock_state.is_phase_processed.return_value = True # already ran
run_trading_calls = []
async def fake_run_trading(cmd, sessions):
run_trading_calls.append(cmd)
with (
patch.object(sched, "_now_et", return_value=now_et),
patch.object(sched, "_is_trading_day", return_value=True),
patch.object(sched, "_prev_trading_day", return_value=dt.date(2026, 4, 23)),
patch.object(sched, "_run_pipeline", new_callable=AsyncMock, return_value=True),
patch.object(sched, "_run_trading", side_effect=fake_run_trading),
patch("apps.paper_trader.state.StateManager", return_value=mock_state),
):
await sched._run_catchup()
assert "run-open" not in run_trading_calls
@pytest.mark.asyncio
async def test_run_close_reruns_if_missed():
"""If server restarts at 16:00 ET and run_close not in processed_phases, it must run."""
sched = _make_scheduler()
now_et = dt.datetime(2026, 4, 24, 16, 0, 0, tzinfo=ET)
mock_state = MagicMock()
mock_session = MagicMock()
mock_session.session_id = "sid-abc"
mock_state.get_session.return_value = mock_session
# is_phase_processed: next_open=True (already ran), reaction_close=False (missed)
def is_phase_processed(sid, date, phase):
return phase == "next_open"
mock_state.is_phase_processed.side_effect = is_phase_processed
run_trading_calls = []
async def fake_run_trading(cmd, sessions):
run_trading_calls.append(cmd)
with (
patch.object(sched, "_now_et", return_value=now_et),
patch.object(sched, "_is_trading_day", return_value=True),
patch.object(sched, "_prev_trading_day", return_value=dt.date(2026, 4, 23)),
patch.object(sched, "_run_pipeline", new_callable=AsyncMock, return_value=True),
patch.object(sched, "_run_trading", side_effect=fake_run_trading),
patch("apps.paper_trader.state.StateManager", return_value=mock_state),
):
await sched._run_catchup()
assert "run-close" in run_trading_calls
# run_open should NOT re-run (market already closed)
assert "run-open" not in run_trading_calls