Two independent bugs left the near-close scan running on the previous session's close, silently degrading live execution to the stale_close floor (~1.57 Sharpe) instead of the intended ~1.77 close-fill case. 1. OHLCV window never covered the current day. Daily bars are stamped at session start (04:00Z under EDT), so an end of midnight-on-end_date landed before that day's bar and dropped it. Widening the window alone fails the whole request with 'subscription does not permit querying recent SIP data', so end is also clamped to now-20min. Today's bar is now returned, roughly 20 minutes behind live -- within the staleness the near-close design already assumed. Intraday runs therefore store a partial bar and ingestion progress reaches today, which made incremental resume skip the after-close refresh entirely. collect_ohlcv_final() re-pulls the last sessions so the consolidated bar overwrites the partial one before outcome eval. 2. APScheduler's from_crontab() passes day-of-week to its own field where 0=Monday, so '1-5' meant Tue-Sat: every Monday was skipped and the scanner ran Saturdays on stale data. Weekday schedules now use names. Stored settings already corrected via Admin; this fixes the defaults. Tests cover both: today's bar inside the window, the delayed-data clamp, historical windows untruncated, and a week of fire times asserting Monday is present and weekends are not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
111 lines
4.0 KiB
Python
111 lines
4.0 KiB
Python
"""Unit tests for the cron pipeline schedule config."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.exceptions import ValidationError
|
||
from app.scheduler import SCHEDULE_DEFAULTS, validate_cron
|
||
from app.services.admin_service import get_schedule_config, update_schedule_config
|
||
|
||
|
||
@pytest.fixture
|
||
async def session() -> AsyncSession:
|
||
from tests.conftest import _test_session_factory
|
||
|
||
async with _test_session_factory() as session:
|
||
yield session
|
||
|
||
|
||
class TestValidateCron:
|
||
def test_accepts_valid(self):
|
||
validate_cron("0 7 * * *", "Europe/Berlin")
|
||
validate_cron("0 14-22 * * 1-5", "UTC")
|
||
|
||
def test_rejects_bad_cron(self):
|
||
with pytest.raises(Exception):
|
||
validate_cron("not a cron", "UTC")
|
||
|
||
def test_rejects_bad_timezone(self):
|
||
with pytest.raises(Exception):
|
||
validate_cron("0 7 * * *", "Mars/Phobos")
|
||
|
||
|
||
class TestTradingDayCrons:
|
||
"""APScheduler's from_crontab() uses 0=Monday, so numeric "1-5" means
|
||
Tue–Sat: it skips every Monday and fires on Saturdays. Weekday schedules
|
||
must therefore be spelled with day *names*.
|
||
"""
|
||
|
||
_WEEKDAY_KEYS = (
|
||
"schedule_near_close_pipeline_cron",
|
||
"schedule_after_close_pipeline_cron",
|
||
"schedule_intraday_pipeline_cron",
|
||
)
|
||
|
||
@pytest.mark.parametrize("key", _WEEKDAY_KEYS)
|
||
def test_fires_monday_and_never_saturday(self, key: str):
|
||
from datetime import datetime, timedelta
|
||
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
trigger = CronTrigger.from_crontab(
|
||
SCHEDULE_DEFAULTS[key], timezone=SCHEDULE_DEFAULTS["schedule_timezone"]
|
||
)
|
||
# Walk a full week of fire times from a known Sunday.
|
||
cursor = datetime(2026, 7, 19, tzinfo=trigger.timezone)
|
||
weekdays = set()
|
||
previous = None
|
||
for _ in range(12):
|
||
fire = trigger.get_next_fire_time(previous, cursor)
|
||
weekdays.add(fire.strftime("%a"))
|
||
previous = fire
|
||
cursor = fire + timedelta(seconds=1)
|
||
|
||
assert "Mon" in weekdays, f"{key} skips Mondays — numeric day-of-week?"
|
||
assert {"Sat", "Sun"}.isdisjoint(weekdays), f"{key} fires on a weekend"
|
||
|
||
def test_fundamentals_runs_on_monday(self):
|
||
from datetime import datetime
|
||
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
|
||
trigger = CronTrigger.from_crontab(
|
||
SCHEDULE_DEFAULTS["schedule_fundamentals_cron"],
|
||
timezone=SCHEDULE_DEFAULTS["schedule_timezone"],
|
||
)
|
||
fire = trigger.get_next_fire_time(
|
||
None, datetime(2026, 7, 19, tzinfo=trigger.timezone)
|
||
)
|
||
assert fire.strftime("%a") == "Mon"
|
||
|
||
|
||
class TestScheduleConfig:
|
||
async def test_defaults_when_unset(self, session: AsyncSession):
|
||
config = await get_schedule_config(session)
|
||
assert config == SCHEDULE_DEFAULTS
|
||
|
||
async def test_update_and_read_back(self, session: AsyncSession):
|
||
updated = await update_schedule_config(
|
||
session, {"schedule_daily_pipeline_cron": "30 6 * * *"}
|
||
)
|
||
assert updated["schedule_daily_pipeline_cron"] == "30 6 * * *"
|
||
# untouched keys keep their defaults
|
||
assert updated["schedule_intraday_pipeline_cron"] == SCHEDULE_DEFAULTS["schedule_intraday_pipeline_cron"]
|
||
|
||
config = await get_schedule_config(session)
|
||
assert config["schedule_daily_pipeline_cron"] == "30 6 * * *"
|
||
|
||
async def test_rejects_bad_cron(self, session: AsyncSession):
|
||
with pytest.raises(ValidationError):
|
||
await update_schedule_config(session, {"schedule_fundamentals_cron": "every monday"})
|
||
|
||
async def test_rejects_bad_timezone(self, session: AsyncSession):
|
||
with pytest.raises(ValidationError):
|
||
await update_schedule_config(session, {"schedule_timezone": "Nowhere/Void"})
|
||
|
||
async def test_rejects_unknown_key(self, session: AsyncSession):
|
||
with pytest.raises(ValidationError):
|
||
await update_schedule_config(session, {"schedule_bogus": "0 0 * * *"})
|