import os import unittest from datetime import datetime, timezone os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") from src.services.backtest import ( BacktestCostConfig, BACKTEST_SUBJECT_PLAN_REPLAY, build_timeframe_bucket, classify_backtest_lifecycle_state, BacktestOutcome, BacktestQualityGate, BacktestReplayConfig, BacktestService, BacktestSignal, build_cumulative_r_curve, compute_max_consecutive_losses, compute_max_drawdown_r, compute_outcome_r_multiple, build_plan_replay_units, estimate_trade_cost_r, evaluate_backtest_quality, evaluate_signal_outcome, evaluate_signal_outcome_with_replay, normalize_timeframe_set, summarize_backtest_outcomes, ) from src.repositories.backtest_repository import BacktestRepository class BacktestOutcomeTests(unittest.TestCase): def test_classifies_backtest_lifecycle_states(self) -> None: self.assertEqual( classify_backtest_lifecycle_state(outcome="win", tp1_hit=True, tp2_hit=True, invalidated_before_entry=False), "tp2_closed", ) self.assertEqual( classify_backtest_lifecycle_state(outcome="breakeven", tp1_hit=True, tp2_hit=False, invalidated_before_entry=False), "tp1_reached", ) self.assertEqual( classify_backtest_lifecycle_state(outcome="loss", tp1_hit=False, tp2_hit=False, invalidated_before_entry=False), "stopped_out", ) self.assertEqual( classify_backtest_lifecycle_state(outcome="missed", tp1_hit=False, tp2_hit=False, invalidated_before_entry=False), "missed", ) self.assertEqual( classify_backtest_lifecycle_state(outcome="missed", tp1_hit=False, tp2_hit=False, invalidated_before_entry=True), "invalidated_before_entry", ) def test_buy_signal_uses_stop_first_when_entry_and_target_are_ambiguous(self) -> None: signal = BacktestSignal( signal_id=1, side="buy", entry_low=100.0, entry_high=101.0, stop_loss=99.0, tp1=102.0, tp2=104.0, ) outcome = evaluate_signal_outcome( signal, [{"high": 105.0, "low": 98.0}], ) self.assertEqual(outcome.outcome, "loss") self.assertFalse(outcome.invalidated_before_entry) self.assertIn("stopped_out", outcome.error_tags) self.assertIn("same_candle_entry_exit_ambiguous", outcome.error_tags) self.assertTrue(outcome.meta["same_candle_ambiguous"]) self.assertEqual(outcome.meta["same_candle_priority"], "stop_first") def test_untriggered_entry_is_missed_without_invalidating_before_entry(self) -> None: signal = BacktestSignal( signal_id=2, side="sell", entry_low=100.0, entry_high=101.0, stop_loss=102.0, tp1=99.0, tp2=97.0, ) outcome = evaluate_signal_outcome( signal, [{"high": 99.5, "low": 98.0}], ) self.assertEqual(outcome.outcome, "missed") self.assertFalse(outcome.invalidated_before_entry) self.assertIn("entry_not_triggered", outcome.error_tags) self.assertFalse(outcome.meta["entry_triggered"]) def test_sell_signal_can_reach_tp2(self) -> None: signal = BacktestSignal( signal_id=3, side="sell", entry_low=100.0, entry_high=101.0, stop_loss=103.0, tp1=99.0, tp2=97.0, rr_tp1=0.75, rr_tp2=2.5, ) outcome = evaluate_signal_outcome( signal, [ {"high": 100.5, "low": 99.5}, {"high": 100.0, "low": 96.5}, ], ) self.assertEqual(outcome.outcome, "win") self.assertTrue(outcome.tp1_hit) self.assertTrue(outcome.tp2_hit) self.assertAlmostEqual(outcome.meta["mfe_r"], 1.6) self.assertAlmostEqual(outcome.meta["mae_r"], 0.0) self.assertEqual(outcome.meta["r_multiple"], 2.5) self.assertEqual(outcome.error_tags, []) def test_outcome_r_multiple_applies_cost_to_executed_results(self) -> None: self.assertAlmostEqual(compute_outcome_r_multiple("win", rr_tp2=2.5, cost_r=0.1), 2.4) self.assertAlmostEqual(compute_outcome_r_multiple("loss", cost_r=0.1), -1.1) self.assertEqual(compute_outcome_r_multiple("missed", cost_r=0.1), 0.0) self.assertAlmostEqual( compute_outcome_r_multiple( "breakeven", rr_tp1=1.0, cost_r=0.1, tp1_hit=True, tp1_partial_fraction=0.5, stopped_after_tp1=True, move_stop_applied=True, ), 0.4, ) def test_cost_model_reduces_executed_r_multiple(self) -> None: signal = BacktestSignal( signal_id=4, side="sell", entry_low=100.0, entry_high=101.0, stop_loss=103.0, tp1=99.0, tp2=97.0, rr_tp1=0.75, rr_tp2=2.5, ) cost_config = BacktestCostConfig(fee_rate=0.0005, slippage_rate=0.0005, fixed_cost_r=0.05) expected_cost_r = estimate_trade_cost_r( signal=signal, outcome="win", entry_price=100.5, entry_triggered=True, cost_config=cost_config, ) outcome = evaluate_signal_outcome( signal, [ {"high": 100.5, "low": 99.5}, {"high": 100.0, "low": 96.5}, ], cost_config=cost_config, ) self.assertAlmostEqual(expected_cost_r, 0.129) self.assertAlmostEqual(outcome.meta["cost_r"], expected_cost_r) self.assertAlmostEqual(outcome.meta["r_multiple"], 2.5 - expected_cost_r) self.assertEqual(outcome.meta["cost_model"]["fee_rate"], 0.0005) def test_tp1_partial_and_move_stop_affect_backtest_r_multiple(self) -> None: signal = BacktestSignal( signal_id=40, side="buy", entry_low=100.0, entry_high=101.0, stop_loss=99.0, tp1=102.0, tp2=104.0, rr_tp1=1.0, rr_tp2=2.3333333333, management_rules=[ {"code": "tp1_partial", "fraction": 0.5}, {"code": "move_stop", "to": "breakeven"}, ], ) outcome = evaluate_signal_outcome( signal, [ {"high": 102.1, "low": 100.2}, {"high": 101.0, "low": 100.4}, ], ) self.assertEqual(outcome.outcome, "breakeven") self.assertTrue(outcome.tp1_hit) self.assertFalse(outcome.tp2_hit) self.assertAlmostEqual(outcome.meta["r_multiple"], 0.5) self.assertTrue(outcome.meta["management_applied"]["tp1_partial"]) self.assertTrue(outcome.meta["management_applied"]["move_stop"]) self.assertTrue(outcome.meta["management_applied"]["remaining_position_closed"]) def test_replay_no_lookahead_tracks_visible_candles_and_confirmed_swings_at_entry(self) -> None: signal = BacktestSignal( signal_id=5, side="buy", entry_low=96.0, entry_high=97.0, stop_loss=95.0, tp1=100.0, tp2=103.0, rr_tp1=1.0, rr_tp2=3.0, ) outcome = evaluate_signal_outcome_with_replay( signal, [ {"ts_open": 1, "ts_close": 2, "open": 97.7, "high": 98.0, "low": 97.5, "close": 97.8}, {"ts_open": 2, "ts_close": 3, "open": 97.0, "high": 99.0, "low": 96.2, "close": 98.0}, {"ts_open": 3, "ts_close": 4, "open": 97.2, "high": 98.0, "low": 96.5, "close": 97.4}, {"ts_open": 4, "ts_close": 5, "open": 98.5, "high": 103.5, "low": 97.0, "close": 103.0}, ], replay_config=BacktestReplayConfig(left_bars=1, right_bars=1), ) self.assertEqual(outcome.outcome, "win") self.assertEqual(outcome.meta["backtest_mode"], "replay_no_lookahead") self.assertEqual(outcome.meta["replay_entry_frame_index"], 1) self.assertEqual(outcome.meta["replay_known_candles_at_entry"], 2) self.assertEqual(outcome.meta["replay_confirmed_swing_count_at_entry"], 0) self.assertEqual(outcome.meta["replay_confirmed_swing_count_total"], 2) self.assertEqual(outcome.meta["replay_last_confirmed_swing_at_entry"], None) self.assertEqual(outcome.meta["replay_exit_frame_index"], 3) self.assertEqual(outcome.meta["replay_left_bars"], 1) self.assertEqual(outcome.meta["replay_right_bars"], 1) def test_summary_includes_expectancy_and_profit_factor_r(self) -> None: outcomes = [ BacktestOutcome( signal_id=1, outcome="win", mfe=3.0, mae=0.5, tp1_hit=True, tp2_hit=True, invalidated_before_entry=False, error_tags=[], meta={"signal_side": "buy", "signal_symbol": "BTC-USDT", "signal_timeframe_bucket": "15m/5m/1m", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 2.6, "mae_r": 0.4, "cost_r": 0.1, "r_multiple": 2.4}, ), BacktestOutcome( signal_id=2, 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": "buy", "signal_symbol": "BTC-USDT", "signal_timeframe_bucket": "15m/5m/1m", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 0.2, "mae_r": 1.2, "cost_r": 0.1, "r_multiple": -1.1}, ), BacktestOutcome( signal_id=3, outcome="loss", mfe=0.0, mae=0.0, tp1_hit=False, tp2_hit=False, invalidated_before_entry=False, error_tags=["stopped_out"], meta={"signal_side": "buy", "signal_symbol": "BTC-USDT", "signal_timeframe_bucket": "15m/5m/1m", "session_codes": ["LONDON"], "weekday": "Monday", "mfe_r": 0.1, "mae_r": 1.1, "cost_r": 0.1, "r_multiple": -1.1}, ), BacktestOutcome( signal_id=4, outcome="missed", mfe=0.0, mae=0.0, tp1_hit=False, tp2_hit=False, invalidated_before_entry=False, error_tags=["entry_not_triggered"], meta={"signal_side": "sell", "signal_symbol": "ETH-USDT", "signal_timeframe_bucket": "1h/15m/5m", "session_codes": ["NY_AM"], "weekday": "Tuesday", "mfe_r": 0.0, "mae_r": 0.0, "cost_r": 0.0, "r_multiple": 0.0}, ), ] summary = summarize_backtest_outcomes(outcomes) self.assertAlmostEqual(summary["total_r"], 0.2) self.assertEqual(summary["lifecycle_states"]["tp2_closed"], 1) self.assertEqual(summary["lifecycle_states"]["stopped_out"], 2) self.assertEqual(summary["lifecycle_states"]["missed"], 1) self.assertEqual(summary["by_symbol"]["BTC-USDT"]["count"], 3) self.assertEqual(summary["by_symbol"]["ETH-USDT"]["misses"], 1) self.assertEqual(summary["by_timeframe"]["15m/5m/1m"]["count"], 3) self.assertEqual(summary["by_timeframe"]["1h/15m/5m"]["misses"], 1) self.assertAlmostEqual(summary["expectancy_r"], 0.05) self.assertAlmostEqual(summary["profit_factor_r"], 2.4 / 2.2) self.assertEqual(summary["executed_signals"], 3) self.assertAlmostEqual(summary["executed_rate"], 0.75) self.assertAlmostEqual(summary["executed_expectancy_r"], 0.2 / 3) self.assertAlmostEqual(summary["executed_profit_factor_r"], 2.4 / 2.2) self.assertAlmostEqual(summary["avg_mfe_r"], 0.725) self.assertAlmostEqual(summary["avg_mae_r"], 0.675) self.assertAlmostEqual(summary["max_drawdown_r"], 2.2) self.assertEqual(summary["max_consecutive_losses"], 2) self.assertEqual(summary["cumulative_r_curve"], [2.4, 1.2999999999999998, 0.19999999999999973, 0.19999999999999973]) self.assertAlmostEqual(summary["total_cost_r"], 0.3) self.assertAlmostEqual(summary["avg_cost_r"], 0.075) self.assertAlmostEqual(summary["by_session"]["LONDON"]["expectancy_r"], 0.2 / 3) self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_mfe_r"], 2.9 / 3) self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_mae_r"], 2.7 / 3) self.assertAlmostEqual(summary["by_session"]["LONDON"]["avg_cost_r"], 0.1) self.assertAlmostEqual(summary["by_session"]["LONDON"]["max_drawdown_r"], 2.2) self.assertEqual(summary["by_session"]["LONDON"]["max_consecutive_losses"], 2) self.assertEqual(summary["by_session"]["LONDON"]["executed"], 3) self.assertAlmostEqual(summary["by_session"]["LONDON"]["executed_expectancy_r"], 0.2 / 3) self.assertAlmostEqual(summary["by_side"]["buy"]["total_r"], 0.2) def test_drawdown_helpers_track_cumulative_r_peak_to_trough(self) -> None: values = [1.0, -0.5, 2.0, -3.0, 0.5] self.assertEqual(build_cumulative_r_curve(values), [1.0, 0.5, 2.5, -0.5, 0.0]) self.assertEqual(compute_max_drawdown_r(values), 3.0) def test_counts_max_consecutive_losses(self) -> None: outcomes = [ BacktestOutcome(1, "loss", 0.0, 1.0, False, False, False, [], {}), BacktestOutcome(2, "loss", 0.0, 1.0, False, False, False, [], {}), BacktestOutcome(3, "breakeven", 0.0, 0.0, True, False, False, [], {}), BacktestOutcome(4, "loss", 0.0, 1.0, False, False, False, [], {}), ] self.assertEqual(compute_max_consecutive_losses(outcomes), 2) def test_backtest_quality_gate_passes_when_metrics_are_healthy(self) -> None: result = evaluate_backtest_quality( { "total_signals": 120, "executed_signals": 80, "expectancy_r": 0.15, "profit_factor_r": 1.4, "max_drawdown_r": 4.5, "max_consecutive_losses": 3, } ) self.assertTrue(result["passed"]) self.assertEqual(result["reasons"], []) def test_backtest_quality_gate_rejects_weak_metrics(self) -> None: result = evaluate_backtest_quality( { "total_signals": 12, "executed_signals": 3, "expectancy_r": -0.05, "profit_factor_r": 0.9, "max_drawdown_r": 12.0, "max_consecutive_losses": 8, }, gate=BacktestQualityGate(min_total_signals=50, max_drawdown_r=6.0, max_consecutive_losses=4), ) self.assertFalse(result["passed"]) self.assertEqual( result["reasons"], [ "sample_size_too_small", "executed_sample_too_small", "expectancy_below_threshold", "profit_factor_below_threshold", "drawdown_above_threshold", "consecutive_losses_above_threshold", ], ) def test_plan_replay_quality_gate_uses_executed_sample_basis(self) -> None: result = evaluate_backtest_quality( { "subject_type": BACKTEST_SUBJECT_PLAN_REPLAY, "total_signals": 2, "executed_signals": 35, "expectancy_r": 0.2, "profit_factor_r": 1.6, "max_drawdown_r": 4.0, "max_consecutive_losses": 2, }, gate=BacktestQualityGate(min_total_signals=100, min_executed_signals=30), sample_basis="executed", ) self.assertTrue(result["passed"]) self.assertEqual(result["reasons"], []) self.assertEqual(result["sample_basis"], "executed") def test_build_plan_replay_units_tracks_appearance_and_confirmation_timeline(self) -> None: rows = [ { "candidate_id": 1, "trading_plan_id": 77, "candidate_model_code": "OSOK", "side": "buy", "setup_timeframe": "15m", "trigger_timeframe": "5m", "created_ts": datetime(2026, 5, 6, 0, 0, tzinfo=timezone.utc), "expires_ts": datetime(2026, 5, 6, 4, 0, tzinfo=timezone.utc), "confirmation_state": {"executable": False}, "missing_confirmations": ["sweep_confirmed"], "entry": {}, "candidate_meta": {}, "candidate_status": "awaiting_confirmation", }, { "candidate_id": 2, "trading_plan_id": 77, "candidate_model_code": "OSOK", "side": "buy", "setup_timeframe": "15m", "trigger_timeframe": "5m", "created_ts": datetime(2026, 5, 6, 0, 5, tzinfo=timezone.utc), "expires_ts": datetime(2026, 5, 6, 4, 0, tzinfo=timezone.utc), "confirmation_state": {"executable": True}, "missing_confirmations": [], "entry_low": 100.0, "entry_high": 100.2, "stop_loss": 99.0, "tp1": 101.0, "tp2": 103.0, "management_rules": [{"code": "tp1_partial", "fraction": 0.5}], "entry": { "entry_low": 100.0, "entry_high": 100.2, "stop_loss": 99.0, "tp1": 101.0, "tp2": 103.0, "target_plan": { "status": "ready", "tp1": {"price": 101.0, "objective_layer": "IRL", "source": {"type": "session_pivot", "table": "market_sessions", "field": "high"}}, "tp2": {"price": 103.0, "objective_layer": "ERL", "source": {"type": "dealing_range_boundary", "table": "dealing_ranges", "field": "high"}}, }, "management_rules": [{"code": "tp1_partial", "fraction": 0.5}], }, "candidate_status": "executable", }, ] units = build_plan_replay_units(rows) self.assertEqual(len(units), 1) self.assertEqual(units[0]["candidate_id"], 2) self.assertEqual(units[0]["appeared_ts"], rows[0]["created_ts"]) self.assertEqual(units[0]["confirmed_ts"], rows[1]["created_ts"]) self.assertAlmostEqual(units[0]["rr_tp1"], 0.8181818181) self.assertEqual(units[0]["target_plan"]["tp2"]["objective_layer"], "ERL") class BacktestServiceRunTests(unittest.TestCase): def test_run_backtest_persists_quality_gate_in_summary(self) -> None: case = self class FakeBacktestRepository: def __init__(self) -> None: self.summary = {} self.results = [] self.run_config = {} def fetch_candidate_signals(self, instrument_id: int, limit: int, timeframe_set=None, session_codes=None): case.assertEqual(instrument_id, 1) case.assertEqual(limit, 10) case.assertEqual(timeframe_set, {"bias_tf": "1m", "setup_tf": "1m", "trigger_tf": "1m"}) return [ { "id": 7, "created_ts": datetime(2026, 4, 29, 9, 0, tzinfo=timezone.utc), "expires_ts": datetime(2026, 4, 29, 10, 0, tzinfo=timezone.utc), "side": "buy", "entry_low": 100.0, "entry_high": 101.0, "stop_loss": 99.0, "tp1": 102.0, "tp2": 104.0, "rr_tp1": 1.0, "rr_tp2": 2.0, "symbol": "BTC-USDT", "bias_tf": "1m", "setup_tf": "1m", "trigger_tf": "1m", "target_plan": { "status": "ready", "tp1": {"price": 102.0, "objective_layer": "IRL", "source": {"type": "session_pivot", "table": "market_sessions", "field": "high"}}, "tp2": {"price": 104.0, "objective_layer": "ERL", "source": {"type": "dealing_range_boundary", "table": "dealing_ranges", "field": "high"}}, "management_rules": [{"code": "tp1_partial"}], }, "signal_meta": { "target_plan": { "status": "ready", "tp1": {"price": 102.0, "objective_layer": "IRL", "source": {"type": "session_pivot", "table": "market_sessions", "field": "high"}}, "tp2": {"price": 104.0, "objective_layer": "ERL", "source": {"type": "dealing_range_boundary", "table": "dealing_ranges", "field": "high"}}, "management_rules": [{"code": "tp1_partial"}], }, "management_rules": [{"code": "tp1_partial"}], }, } ] def insert_backtest_run(self, **kwargs): self.run_config = kwargs["config"] return 42 def fetch_sessions_covering_window(self, **kwargs): return [{"session_code": "LONDON"}] def fetch_future_candles(self, **kwargs): return [{"high": 104.5, "low": 100.0}] def insert_backtest_result(self, **kwargs): self.results.append(kwargs) def update_backtest_run_summary(self, backtest_run_id: int, summary: dict) -> None: case.assertEqual(backtest_run_id, 42) self.summary = summary repository = FakeBacktestRepository() service = BacktestService(repository=repository) run_id, outcomes = service.run_backtest(instrument_id=1, limit=10) self.assertEqual(run_id, 42) self.assertEqual(len(outcomes), 1) self.assertEqual(len(repository.results), 1) self.assertEqual(repository.results[0]["meta"]["signal_symbol"], "BTC-USDT") self.assertEqual(repository.results[0]["meta"]["signal_timeframe_bucket"], "1m/1m/1m") self.assertEqual(repository.results[0]["meta"]["target_plan"]["tp1"]["objective_layer"], "IRL") self.assertEqual(repository.results[0]["meta"]["management_rules"][0]["code"], "tp1_partial") self.assertEqual(repository.summary["by_symbol"]["BTC-USDT"]["count"], 1) self.assertEqual(repository.summary["by_timeframe"]["1m/1m/1m"]["count"], 1) self.assertEqual(repository.results[0]["meta"]["session_codes"], ["LONDON"]) self.assertIn("quality_gate", repository.summary) self.assertFalse(repository.summary["quality_gate"]["passed"]) self.assertIn("sample_size_too_small", repository.summary["quality_gate"]["reasons"]) self.assertIn("executed_sample_too_small", repository.summary["quality_gate"]["reasons"]) self.assertEqual(repository.run_config["quality_gate"]["min_total_signals"], 100) self.assertEqual(repository.run_config["quality_gate"]["min_executed_signals"], 30) def test_run_backtest_preserves_signal_session_code_when_no_session_window_matches(self) -> None: case = self class FakeBacktestRepository: def __init__(self) -> None: self.results = [] def fetch_candidate_signals(self, instrument_id: int, limit: int, timeframe_set=None, session_codes=None): return [ { "id": 7, "created_ts": datetime(2026, 4, 29, 21, 0, tzinfo=timezone.utc), "expires_ts": datetime(2026, 4, 29, 22, 0, tzinfo=timezone.utc), "side": "buy", "entry_low": 100.0, "entry_high": 101.0, "stop_loss": 99.0, "tp1": 102.0, "tp2": 104.0, "rr_tp1": 1.0, "rr_tp2": 2.0, "symbol": "BTC-USDT", "bias_tf": "1m", "setup_tf": "1m", "trigger_tf": "1m", "session_code": "OFF_HOURS", "target_plan": {}, "signal_meta": {}, } ] def insert_backtest_run(self, **kwargs): return 99 def fetch_sessions_covering_window(self, **kwargs): return [] def fetch_future_candles(self, **kwargs): return [] def insert_backtest_result(self, **kwargs): self.results.append(kwargs) def update_backtest_run_summary(self, backtest_run_id: int, summary: dict) -> None: case.assertEqual(backtest_run_id, 99) repository = FakeBacktestRepository() service = BacktestService(repository=repository) run_id, outcomes = service.run_backtest(instrument_id=1, limit=1) self.assertEqual(run_id, 99) self.assertEqual(len(outcomes), 1) self.assertEqual(repository.results[0]["meta"]["session_codes"], ["OFF_HOURS"]) self.assertEqual(repository.results[0]["meta"]["session_filter"], "outside_window") self.assertIn("outside_session_window", repository.results[0]["error_tags"]) def test_run_backtest_persists_replay_mode_and_replay_meta(self) -> None: case = self class FakeBacktestRepository: def __init__(self) -> None: self.results = [] self.run_config = {} def fetch_candidate_signals(self, instrument_id: int, limit: int, timeframe_set=None, session_codes=None): case.assertEqual(instrument_id, 1) case.assertEqual(limit, 10) return [ { "id": 8, "created_ts": datetime(2026, 4, 29, 9, 0, tzinfo=timezone.utc), "expires_ts": datetime(2026, 4, 29, 10, 0, tzinfo=timezone.utc), "side": "buy", "entry_low": 96.0, "entry_high": 97.0, "stop_loss": 95.0, "tp1": 100.0, "tp2": 103.0, "rr_tp1": 1.0, "rr_tp2": 3.0, "symbol": "BTC-USDT", "bias_tf": "1m", "setup_tf": "1m", "trigger_tf": "1m", } ] def insert_backtest_run(self, **kwargs): self.run_config = kwargs["config"] return 77 def fetch_sessions_covering_window(self, **kwargs): return [{"session_code": "LONDON"}] def fetch_future_candles(self, **kwargs): return [ {"ts_open": 1, "ts_close": 2, "open": 97.7, "high": 98.0, "low": 97.5, "close": 97.8}, {"ts_open": 2, "ts_close": 3, "open": 97.0, "high": 99.0, "low": 96.2, "close": 98.0}, {"ts_open": 3, "ts_close": 4, "open": 97.2, "high": 98.0, "low": 96.5, "close": 97.4}, {"ts_open": 4, "ts_close": 5, "open": 98.5, "high": 103.5, "low": 97.0, "close": 103.0}, ] def insert_backtest_result(self, **kwargs): self.results.append(kwargs) def update_backtest_run_summary(self, backtest_run_id: int, summary: dict) -> None: return None repository = FakeBacktestRepository() service = BacktestService(repository=repository) run_id, outcomes = service.run_backtest( instrument_id=1, limit=10, mode="replay_no_lookahead", replay_config=BacktestReplayConfig(left_bars=1, right_bars=1), ) self.assertEqual(run_id, 77) self.assertEqual(len(outcomes), 1) self.assertEqual(repository.run_config["mode"], "replay_no_lookahead") self.assertEqual(repository.run_config["replay"]["left_bars"], 1) self.assertEqual(repository.run_config["replay"]["right_bars"], 1) self.assertEqual(repository.results[0]["meta"]["backtest_mode"], "replay_no_lookahead") self.assertEqual(repository.results[0]["meta"]["replay_confirmed_swing_count_total"], 2) def test_run_backtest_supports_plan_replay_subject_and_plan_metrics(self) -> None: case = self class FakePlanReplayRepository: def __init__(self) -> None: self.summary = {} self.results = [] self.run_config = {} def fetch_active_trading_plan(self, instrument_id: int, trading_plan_id: int = None): return {"id": 77} def insert_backtest_run(self, **kwargs): self.run_config = kwargs return 501 def fetch_plan_replay_candidate_rows(self, **kwargs): case.assertEqual(kwargs["trading_plan_id"], 77) return [ { "candidate_id": 11, "trading_plan_id": 77, "candidate_model_code": "OSOK", "candidate_status": "awaiting_confirmation", "side": "buy", "setup_timeframe": "15m", "trigger_timeframe": "5m", "created_ts": datetime(2026, 5, 6, 0, 0, tzinfo=timezone.utc), "expires_ts": datetime(2026, 5, 6, 4, 0, tzinfo=timezone.utc), "confirmation_state": {"executable": False}, "missing_confirmations": ["sweep_confirmed"], "entry": {}, "invalidations": [], "candidate_meta": {}, "allowed_sessions": ["LONDON"], "primary_draw": {"side": "buy_side"}, }, { "candidate_id": 12, "trading_plan_id": 77, "candidate_model_code": "OSOK", "candidate_status": "executable", "side": "buy", "setup_timeframe": "15m", "trigger_timeframe": "5m", "created_ts": datetime(2026, 5, 6, 0, 5, tzinfo=timezone.utc), "expires_ts": datetime(2026, 5, 6, 4, 0, tzinfo=timezone.utc), "confirmation_state": {"executable": True}, "missing_confirmations": [], "entry": { "entry_low": 100.0, "entry_high": 100.2, "stop_loss": 99.0, "tp1": 101.0, "tp2": 103.0, "target_plan": { "status": "ready", "tp1": {"price": 101.0, "objective_layer": "IRL", "source": {"type": "session_pivot", "table": "market_sessions", "field": "high"}}, "tp2": {"price": 103.0, "objective_layer": "ERL", "source": {"type": "dealing_range_boundary", "table": "dealing_ranges", "field": "high"}}, }, "management_rules": [ {"code": "tp1_partial", "fraction": 0.5}, {"code": "move_stop", "to": "breakeven"}, ], }, "invalidations": [], "candidate_meta": { "management_rules": [ {"code": "tp1_partial", "fraction": 0.5}, {"code": "move_stop", "to": "breakeven"}, ] }, "allowed_sessions": ["LONDON"], "primary_draw": {"side": "buy_side"}, }, ] def fetch_sessions_covering_window(self, **kwargs): return [{"session_code": "LONDON"}] def fetch_future_candles(self, **kwargs): return [ {"ts_open": 1, "ts_close": 2, "high": 101.2, "low": 100.0, "open": 100.1, "close": 101.0}, {"ts_open": 2, "ts_close": 3, "high": 101.0, "low": 99.9, "open": 100.9, "close": 100.1}, ] def insert_backtest_result(self, **kwargs): self.results.append(kwargs) def update_backtest_run_summary(self, backtest_run_id: int, summary: dict) -> None: self.summary = summary repository = FakePlanReplayRepository() service = BacktestService(repository=repository) run_id, outcomes = service.run_backtest( instrument_id=1, subject_type=BACKTEST_SUBJECT_PLAN_REPLAY, trading_plan_id=77, timeframe_set={"bias_tf": "1h", "setup_tf": "15m", "trigger_tf": "5m"}, ) self.assertEqual(run_id, 501) self.assertEqual(len(outcomes), 1) self.assertEqual(repository.run_config["subject_type"], BACKTEST_SUBJECT_PLAN_REPLAY) self.assertEqual(repository.results[0]["trading_plan_id"], 77) self.assertEqual(repository.results[0]["candidate_id"], 12) self.assertEqual(repository.summary["subject_type"], BACKTEST_SUBJECT_PLAN_REPLAY) self.assertEqual(repository.summary["plan_count"], 1) self.assertEqual(repository.summary["confirmed_candidates"], 1) self.assertAlmostEqual(repository.summary["candidate_confirmation_rate"], 1.0) self.assertIn("draw_target_hit_rate", repository.summary) def test_normalize_timeframe_set_and_bucket_helpers(self) -> None: normalized = normalize_timeframe_set({"bias_tf": "1h", "setup_tf": "15m"}) self.assertEqual(normalized, {"bias_tf": "1h", "setup_tf": "15m", "trigger_tf": "15m"}) self.assertEqual(build_timeframe_bucket(**normalized), "1h/15m/15m") class BacktestRepositoryQueryTests(unittest.TestCase): def test_fetch_candidate_signals_keeps_rows_without_bias_snapshot_under_timeframe_filter(self) -> None: captured = {} class StubResult: def mappings(self): return self def all(self): return [{"id": 1, "bias_tf": None, "setup_tf": None, "trigger_tf": None}] class StubSession: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, sql, params): captured["sql"] = str(sql) captured["params"] = dict(params) return StubResult() original_session_local = BacktestRepository.fetch_candidate_signals.__globals__["SessionLocal"] try: BacktestRepository.fetch_candidate_signals.__globals__["SessionLocal"] = lambda: StubSession() repo = BacktestRepository() rows = repo.fetch_candidate_signals( instrument_id=1, limit=5, timeframe_set={"bias_tf": "1m", "setup_tf": "1m", "trigger_tf": "1m"}, ) finally: BacktestRepository.fetch_candidate_signals.__globals__["SessionLocal"] = original_session_local self.assertEqual(rows[0]["id"], 1) self.assertIn("ts.bias_snapshot_id is null or bs.bias_tf = :bias_tf", captured["sql"]) self.assertIn("ts.bias_snapshot_id is null or bs.setup_tf = :setup_tf", captured["sql"]) self.assertIn("ts.bias_snapshot_id is null or bs.trigger_tf = :trigger_tf", captured["sql"]) self.assertIn("ts.status = 'awaiting_confirmation'", captured["sql"]) self.assertIn("coalesce((ts.meta->>'execution_permission')::boolean, false) = true", captured["sql"]) self.assertIn("not coalesce(ts.invalidations, '[]'::jsonb) ? 'session_mismatch'", captured["sql"]) self.assertEqual(captured["params"]["bias_tf"], "1m") self.assertEqual(captured["params"]["setup_tf"], "1m") self.assertEqual(captured["params"]["trigger_tf"], "1m") class BacktestRepositorySerializationTests(unittest.TestCase): def test_insert_backtest_result_serializes_datetime_in_meta(self) -> None: payload = { "signal_created_ts": datetime(2026, 4, 24, 12, 0, tzinfo=timezone.utc), "weekday": "Friday", } class StubSession: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def execute(self, _sql, params): captured.update(params) class StubBegin: def __enter__(self): return StubSession() def __exit__(self, exc_type, exc, tb): return False captured: dict = {} original_begin = BacktestRepository.insert_backtest_result.__globals__["SessionLocal"].begin try: BacktestRepository.insert_backtest_result.__globals__["SessionLocal"].begin = lambda: StubBegin() repo = BacktestRepository() repo.insert_backtest_result( backtest_run_id=1, signal_id=1, outcome="missed", mfe=0.0, mae=0.0, tp1_hit=False, tp2_hit=False, invalidated_before_entry=False, error_tags=[], meta=payload, ) finally: BacktestRepository.insert_backtest_result.__globals__["SessionLocal"].begin = original_begin self.assertIn("meta", captured) self.assertIn("2026-04-24 12:00:00+00:00", captured["meta"]) if __name__ == "__main__": unittest.main()