import json import unittest from datetime import datetime, timezone from scripts import run_backtest_batch from scripts.run_backtest_batch import ( build_candidate_model_codes_from_env, build_instrument_ids_from_env, build_session_groups_from_env, build_subject_types_from_env, build_timeframe_sets_from_env, run_backtest_batch as run_batch, ) from src.services.backtest import BacktestOutcome, BacktestQualityGate class RunBacktestBatchScriptTests(unittest.TestCase): def test_build_batch_dimensions_from_env(self) -> None: env = { "BACKTEST_BATCH_INSTRUMENT_IDS": "1,2", "BACKTEST_BATCH_SUBJECT_TYPES": "plan_replay,signal", "BACKTEST_BATCH_TIMEFRAME_SETS": "1h/15m/5m;15m/5m/1m", "BACKTEST_BATCH_SESSION_GROUPS": "ALL;LONDON,NY_AM;ASIA", "BACKTEST_BATCH_CANDIDATE_MODEL_CODES": "ALL,OSOK", } self.assertEqual(build_instrument_ids_from_env(env), [1, 2]) self.assertEqual(build_subject_types_from_env(env), ["plan_replay", "signal"]) self.assertEqual( build_timeframe_sets_from_env(env), [ {"bias_tf": "1h", "setup_tf": "15m", "trigger_tf": "5m"}, {"bias_tf": "15m", "setup_tf": "5m", "trigger_tf": "1m"}, ], ) self.assertEqual(build_session_groups_from_env(env), [[], ["LONDON", "NY_AM"], ["ASIA"]]) self.assertEqual(build_candidate_model_codes_from_env(env), [None, "OSOK"]) def test_run_batch_aggregates_quality_gate_and_metadata(self) -> None: service = FakeBatchBacktestService( { "plan_replay": [_win_outcome(signal_id=None, trading_plan_id=10)], "signal": [_loss_outcome(signal_id=1)], } ) payload = run_batch( service=service, batch_id="batch-test", instrument_ids=[1], limit=25, subject_types=["plan_replay", "signal"], modes=["replay_no_lookahead"], timeframe_sets=[{"bias_tf": "1h", "setup_tf": "15m", "trigger_tf": "5m"}], session_groups=[[], ["LONDON"]], candidate_model_codes=[None], require_quality_gate=False, quality_gate=BacktestQualityGate(min_total_signals=1, min_executed_signals=1, min_profit_factor_r=0.0), cost_config=None, replay_config=None, ) self.assertEqual(payload["status"], "completed") self.assertEqual(payload["run_count"], 3) self.assertEqual(payload["total_result_count"], 3) self.assertEqual(payload["aggregate_by_subject"]["plan_replay"]["run_count"], 1) self.assertEqual(payload["aggregate_by_subject"]["signal"]["run_count"], 2) self.assertEqual(service.calls[0]["run_metadata"]["batch_id"], "batch-test") self.assertEqual(service.calls[0]["run_metadata"]["execution_boundary"], "read_only_backtest_no_order_submission") self.assertEqual(payload["execution_boundary"], "read_only_backtest_no_order_submission") def test_main_outputs_json_payload(self) -> None: service = FakeBatchBacktestService({"plan_replay": [_win_outcome(signal_id=None, trading_plan_id=10)]}) output = _Capture() original_service = run_backtest_batch.BacktestService run_backtest_batch.BacktestService = lambda: service try: with output: exit_code = run_backtest_batch.main( env={ "BACKTEST_BATCH_OUTPUT_JSON": "1", "BACKTEST_BATCH_ID": "batch-json", "BACKTEST_BATCH_SUBJECT_TYPES": "plan_replay", "BACKTEST_BATCH_SESSION_GROUPS": "ALL", "BACKTEST_MIN_TOTAL_SIGNALS": "1", "BACKTEST_MIN_EXECUTED_SIGNALS": "1", "BACKTEST_MIN_PROFIT_FACTOR_R": "0", } ) finally: run_backtest_batch.BacktestService = original_service payload = json.loads(output.value) self.assertEqual(exit_code, 0) self.assertEqual(payload["batch_id"], "batch-json") self.assertEqual(payload["run_count"], 1) class FakeBatchBacktestService: def __init__(self, outcomes_by_subject: dict[str, list[BacktestOutcome]]) -> None: self.outcomes_by_subject = outcomes_by_subject self.calls = [] def run_backtest(self, **kwargs): self.calls.append(kwargs) return 100 + len(self.calls), list(self.outcomes_by_subject.get(kwargs["subject_type"]) or []) class _Capture: def __enter__(self): import contextlib import io self._stream = io.StringIO() self._context = contextlib.redirect_stdout(self._stream) self._context.__enter__() return self def __exit__(self, *args): self._context.__exit__(*args) self.value = self._stream.getvalue() def _win_outcome(*, signal_id, trading_plan_id=None) -> BacktestOutcome: return BacktestOutcome( signal_id=signal_id, outcome="win", mfe=2.0, mae=0.0, tp1_hit=True, tp2_hit=True, invalidated_before_entry=False, error_tags=[], trading_plan_id=trading_plan_id, meta={ "signal_side": "buy", "signal_symbol": "BTC-USDT", "session_codes": ["LONDON"], "weekday": "Thursday", "mfe_r": 2.0, "mae_r": 0.0, "r_multiple": 2.0, "cost_r": 0.0, }, ) def _loss_outcome(*, signal_id) -> BacktestOutcome: return BacktestOutcome( signal_id=signal_id, outcome="loss", mfe=0.2, mae=1.0, tp1_hit=False, tp2_hit=False, invalidated_before_entry=False, error_tags=["stopped_out"], meta={ "signal_side": "sell", "signal_symbol": "BTC-USDT", "session_codes": ["NY_AM"], "weekday": "Thursday", "mfe_r": 0.2, "mae_r": 1.0, "r_multiple": -1.0, "cost_r": 0.0, }, ) if __name__ == "__main__": unittest.main()