Review of the shadow book found seven ways the two books could leak into
each other; all are fixed here. The most serious silently invalidated the
comparison the shadow book exists to make.
- Shadow holdings no longer suppress the manual candidate list. The
open-trade exclusion filtered on any book, so shadow taking the
top-ranked names removed exactly those from the user's list and alerts,
confining the discretionary book to leftovers. Scoped to the manual
book. Closed-trade alerts and paper-book equity were leaking the same
way and are likewise scoped.
- Shadow sizing now matches _simulate_portfolio: min(1% risk, 20% notional
cap, available cash) from marked equity, plus the sub- dust guard.
Previously risk-only from realized equity, so a tight stop produced a
multiples-of-equity leveraged position the strategy would never take.
- Shadow only trades setups from the scan that just ran (<6h old) with one
setup per ticker. A failed or disabled scan step could otherwise open
positions from a prior session at stale prices.
- Gate-reset transitions are observed for both books, so a shadow stop-out
completes fail -> requalify instead of staying locked forever.
- Manual list/close endpoints default to the manual book and reject
hand-closing shadow trades; the performance endpoint is scoped to the
caller so 'your picks' is not every user's book.
- run_shadow_book is registered as a paused job so Admin can trigger it.
Also anchors three pre-existing paper-trade tests (and the new alpaca
window test) on the UTC date. They build fixtures from the local date but
the service stamps opened_at in UTC, so they failed only between 00:00 and
02:00 in a UTC+hh timezone -- latent on ba2df8b, exposed by the clock.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
4.8 KiB
Python
150 lines
4.8 KiB
Python
"""Unit tests for app.scheduler module."""
|
|
|
|
import pytest
|
|
|
|
from app.scheduler import (
|
|
_consume_backtest_options,
|
|
_consume_backtest_target_model,
|
|
_parse_frequency,
|
|
_resume_tickers,
|
|
_last_successful,
|
|
configure_scheduler,
|
|
queue_backtest_options,
|
|
queue_backtest_target_model,
|
|
scheduler,
|
|
)
|
|
|
|
|
|
def test_manual_backtest_target_model_is_one_shot():
|
|
assert queue_backtest_target_model("structural_sr") == "structural_sr"
|
|
assert _consume_backtest_target_model() == "structural_sr"
|
|
assert _consume_backtest_target_model() == "production_gtl"
|
|
|
|
|
|
def test_manual_backtest_target_model_rejects_removed_research_arms():
|
|
with pytest.raises(ValueError, match="Unknown backtest target model"):
|
|
queue_backtest_target_model("production_control")
|
|
|
|
|
|
def test_manual_backtest_options_are_one_shot_and_default_back_to_weekly():
|
|
assert queue_backtest_options("structural_sr", "daily") == (
|
|
"structural_sr",
|
|
"daily",
|
|
)
|
|
assert _consume_backtest_options() == ("structural_sr", "daily")
|
|
assert _consume_backtest_options() == ("production_gtl", "weekly")
|
|
|
|
|
|
class TestParseFrequency:
|
|
def test_hourly(self):
|
|
assert _parse_frequency("hourly") == {"hours": 1}
|
|
|
|
def test_daily(self):
|
|
assert _parse_frequency("daily") == {"hours": 24}
|
|
|
|
def test_case_insensitive(self):
|
|
assert _parse_frequency("Hourly") == {"hours": 1}
|
|
assert _parse_frequency("DAILY") == {"hours": 24}
|
|
|
|
def test_weekly_maps_to_one_week(self):
|
|
assert _parse_frequency("weekly") == {"weeks": 1}
|
|
|
|
def test_unknown_defaults_to_daily(self):
|
|
assert _parse_frequency("monthly") == {"hours": 24}
|
|
assert _parse_frequency("") == {"hours": 24}
|
|
|
|
|
|
class TestResumeTickers:
|
|
def test_no_previous_returns_full_list(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = None
|
|
result = _resume_tickers(symbols, "test_job")
|
|
assert result == ["AAPL", "GOOG", "MSFT"]
|
|
|
|
def test_resume_after_first(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = "AAPL"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
# Should start from GOOG, then wrap around
|
|
assert result == ["GOOG", "MSFT", "AAPL"]
|
|
|
|
def test_resume_after_middle(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT", "TSLA"]
|
|
_last_successful["test_job"] = "GOOG"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
assert result == ["MSFT", "TSLA", "AAPL", "GOOG"]
|
|
|
|
def test_resume_after_last(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = "MSFT"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
# All already processed, wraps to full list
|
|
assert result == ["AAPL", "GOOG", "MSFT"]
|
|
|
|
def test_unknown_last_returns_full_list(self):
|
|
symbols = ["AAPL", "GOOG", "MSFT"]
|
|
_last_successful["test_job"] = "NVDA"
|
|
result = _resume_tickers(symbols, "test_job")
|
|
assert result == ["AAPL", "GOOG", "MSFT"]
|
|
|
|
def test_empty_list(self):
|
|
_last_successful["test_job"] = "AAPL"
|
|
result = _resume_tickers([], "test_job")
|
|
assert result == []
|
|
|
|
|
|
class TestConfigureScheduler:
|
|
def test_configure_adds_all_jobs(self):
|
|
# Remove any existing jobs first
|
|
scheduler.remove_all_jobs()
|
|
configure_scheduler()
|
|
jobs = scheduler.get_jobs()
|
|
job_ids = {j.id for j in jobs}
|
|
assert job_ids == {
|
|
"data_collector",
|
|
"data_backfill",
|
|
"benchmark_collector",
|
|
"sentiment_collector",
|
|
"fundamental_collector",
|
|
"rr_scanner",
|
|
"shadow_book",
|
|
"ticker_universe_sync",
|
|
"outcome_evaluator",
|
|
"alerts",
|
|
"market_regime",
|
|
"regime_monitor",
|
|
"event_study",
|
|
"backtest",
|
|
"daily_pipeline",
|
|
"near_close_pipeline",
|
|
"after_close_pipeline",
|
|
"intraday_pipeline",
|
|
}
|
|
|
|
def test_configure_is_idempotent(self):
|
|
scheduler.remove_all_jobs()
|
|
configure_scheduler()
|
|
configure_scheduler() # Should replace, not duplicate
|
|
job_ids = [j.id for j in scheduler.get_jobs()]
|
|
# Each ID should appear exactly once
|
|
assert sorted(job_ids) == sorted([
|
|
"after_close_pipeline",
|
|
"alerts",
|
|
"backtest",
|
|
"benchmark_collector",
|
|
"daily_pipeline",
|
|
"intraday_pipeline",
|
|
"data_collector",
|
|
"data_backfill",
|
|
"fundamental_collector",
|
|
"market_regime",
|
|
"near_close_pipeline",
|
|
"regime_monitor",
|
|
"event_study",
|
|
"outcome_evaluator",
|
|
"rr_scanner",
|
|
"sentiment_collector",
|
|
"shadow_book",
|
|
"ticker_universe_sync",
|
|
])
|