import os import unittest from datetime import datetime, timezone from unittest.mock import patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") from src.api import get_health from src.api.backtest import get_recent_backtest_summaries, normalize_backtest_summary_row from src.api.server import route_request from src.api.signals import get_recent_signals from src.services.backtest import BacktestCostConfig, BacktestQualityGate, BacktestReplayConfig from src.services.execution import AccountRiskState from src.main import run_closed_loop, select_closed_loop_backtest_subject_type class FakeResult: def __init__(self, rows): self._rows = rows def mappings(self): return self def all(self): return self._rows class FakeSession: def __init__(self, rows): self.rows = rows def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return None def execute(self, sql, params): return FakeResult(self.rows) class ApiTests(unittest.TestCase): def test_health_returns_expected_fields(self) -> None: health = get_health() self.assertEqual(health["app"], "ai-ict") self.assertIn("database_configured", health) self.assertIn("session_timezone", health) def test_get_recent_signals_uses_session_query(self) -> None: rows = [{ "id": 1, "side": "buy", "status": "awaiting_confirmation", "invalidations": ["mss invalidated", "fvg fully mitigated"], "meta": { "structure_state": "aligned", "mss_state": "accepted", "mss_status_reason": "retest_held", "fvg_status": "open", "fvg_status_reason": "no_touch", "execution_permission": True, "fvg_fill_pct": 0.0, "fvg_touch_count": 0, "fvg_data_quality_warnings": [], }, }] with patch("src.api.signals.SessionLocal", return_value=FakeSession(rows)): result = get_recent_signals(instrument_id=1, limit=1) self.assertEqual(result[0]["id"], 1) self.assertEqual(result[0]["structure_state"], "aligned") self.assertEqual(result[0]["mss_state"], "accepted") self.assertEqual(result[0]["mss_status_reason"], "retest_held") self.assertEqual(result[0]["fvg_status"], "open") self.assertEqual(result[0]["fvg_status_reason"], "no_touch") self.assertTrue(result[0]["execution_permission"]) self.assertEqual(result[0]["fvg_fill_pct"], 0.0) self.assertEqual(result[0]["fvg_touch_count"], 0) self.assertEqual(result[0]["fvg_data_quality_warnings"], []) self.assertEqual(result[0]["invalidations_normalized"], ["mss_invalidated", "fvg_mitigated"]) self.assertEqual(result[0]["signal_context"]["structure_state"], "aligned") self.assertEqual(result[0]["signal_context"]["invalidations_normalized"], ["mss_invalidated", "fvg_mitigated"]) def test_get_recent_backtest_summaries_uses_session_query(self) -> None: rows = [{"id": 10, "summary": {"total_signals": 3}}] with patch("src.api.backtest.SessionLocal", return_value=FakeSession(rows)): result = get_recent_backtest_summaries(instrument_id=1, limit=1) self.assertEqual(result[0]["id"], 10) self.assertIn("quality_gate", result[0]["summary"]) def test_normalize_backtest_summary_row_preserves_existing_quality_gate(self) -> None: row = {"id": 10, "summary": {"total_signals": 3, "quality_gate": {"passed": True}, "by_symbol": {"BTC-USDT": {"count": 3}}}} result = normalize_backtest_summary_row(row) self.assertEqual(result["summary"]["quality_gate"], {"passed": True}) self.assertEqual(result["summary"]["by_symbol"]["BTC-USDT"]["count"], 3) def test_route_request_trading_candidates_returns_items_payload(self) -> None: status, payload = route_request( "/trading/candidates", {"instrument_id": ["1"], "limit": ["1"]}, dependencies={"trade_plan_candidates": lambda instrument_id, limit: [{"id": 1}]}, ) self.assertEqual(status, 200) self.assertEqual(payload, {"items": [{"id": 1}]}) def test_route_request_trading_candidates_requires_instrument_id(self) -> None: with self.assertRaisesRegex(ValueError, "missing_required_query_param:instrument_id"): route_request( "/trading/candidates", {}, dependencies={"trade_plan_candidates": lambda instrument_id, limit: []}, ) class FakeSessionGenerator: timezone = __import__("datetime").timezone.utc def __init__(self): self.calls = [] def generate_for_date(self, instrument_id, timeframe, session_date): self.calls.append({"instrument_id": instrument_id, "timeframe": timeframe}) return 5 class FakeSwingService: def detect_swings(self, **kwargs): return [type("Swing", (), {"kind": "swing_high", "price": 101.0, "ts": "t", "strength": "ST", "left_bars": 2, "right_bars": 2, "confirmed": True})()] def persist_swings(self, **kwargs): return 1 class FakeRangeService: def build_active_range(self, **kwargs): return type("Range", (), {"high": 110.0, "low": 90.0, "equilibrium": 100.0, "high_swing_id": 1, "low_swing_id": 2, "ts_start": "t"})() def persist_active_range(self, **kwargs): return 1 class FakeLiquidityService: def build_candidates(self, **kwargs): return [type("Pool", (), {"side": "buy_side", "pool_type": "prior_high", "price_low": 101.0, "price_high": 101.0, "reference_ids": [1], "created_ts": "t", "meta": {}})()] def persist_candidates(self, **kwargs): return 1 class FakeSweepService: def detect_sweeps(self, **kwargs): return [type("Sweep", (), {"side_swept": "buy_side", "pool_id": 1, "sweep_high": 102.0, "sweep_low": 99.0, "sweep_ts": "t", "close_back_inside": True, "validation_level": "validated", "status": "confirmed", "meta": {}})()] def persist_sweeps(self, **kwargs): return 1 class FakeDisplacementService: def detect_displacement(self, **kwargs): return [type("Disp", (), {"direction": "bullish", "start_ts": "t1", "end_ts": "t2", "strength_score": 0.8, "body_ratio": 2.0, "has_fvg": True, "is_valid": True})()] def persist_displacement(self, **kwargs): return 1 class FakeMSSService: def detect_mss(self, **kwargs): return [type("MSS", (), {"direction": "bullish", "broken_level": 100.0, "reference_swing_id": 1, "displacement_event_id": 2, "accepted": True, "ts": "t", "meta": {}})()] def persist_mss(self, **kwargs): return 1 class FakeFVGService: def detect_fvg(self, **kwargs): return [type("FVG", (), {"direction": "bullish", "upper": 103.0, "lower": 101.0, "start_ts": "t1", "end_ts": "t2", "fill_pct": 0.0, "status": "open", "related_displacement_id": 2, "meta": {}})()] def persist_fvg(self, **kwargs): return 1 def update_existing_fvgs(self, **kwargs): return 1 class FakeBiasService: def __init__(self): self.persist_calls = 0 def build_bias(self, **kwargs): return type("Bias", (), {"daily_bias": "bullish", "confidence": 0.7, "execution_intensity": "high", "narrative_code": "bullish_structure_shift", "draw_side": "buy_side", "range_id": 1, "reason_codes": ["latest_mss"], "snapshot_ts": "t"})() def persist_bias(self, **kwargs): self.persist_calls += 1 return 11 class FakeSignalService: def __init__(self): self.persist_calls = 0 def build_signal(self, **kwargs): return type("Signal", (), {"side": "buy", "status": "awaiting_confirmation", "entry_low": 101.0, "entry_high": 103.0, "stop_loss": 99.0, "tp1": 104.0, "tp2": 106.0, "rr_tp1": 1.0, "rr_tp2": 2.0, "session_code": "LONDON", "created_ts": "t", "expires_ts": "t2", "invalidations": [], "evidence": {}, "meta": {"execution_permission": True}})() def persist_signal(self, **kwargs): self.persist_calls += 1 return 1 class FakeExecutionService: def __init__(self): self.calls = 0 self.settlement_calls = 0 self.last_create_kwargs = None def create_execution(self, **kwargs): self.calls += 1 self.last_create_kwargs = kwargs return 99 def settle_latest_execution_for_signal(self, **kwargs): self.settlement_calls += 1 return {"id": 99, "status": "closed", "meta": {"lifecycle_state": "tp2_closed", "risk_checks": []}} def persist_execution_settlement(self, **kwargs): self.settlement_calls += 1 return {"id": 99, "status": "closed", "meta": {"lifecycle_state": "tp2_closed", "risk_checks": []}} class FakeBacktestService: def __init__(self): self.last_kwargs = None def run_backtest(self, **kwargs): self.last_kwargs = kwargs outcome = type("Outcome", (), {"signal_id": 1, "outcome": "win", "mfe": 3.0, "mae": 1.0, "tp1_hit": True, "tp2_hit": True, "invalidated_before_entry": False, "error_tags": [], "meta": {"signal_side": "buy", "session_codes": ["LONDON"], "weekday": "Monday"}})() return 55, [outcome] class FakePreMarketReportService: def build_report(self, **kwargs): return {"report_type": "pre_market_plan"} class FakeIntradayReportService: def build_report(self, **kwargs): return {"report_type": "intraday_explanation"} class FakePostTradeReviewService: def build_report(self, **kwargs): return {"report_type": "post_trade_review"} class ClosedLoopTests(unittest.TestCase): def test_select_closed_loop_backtest_subject_type_falls_back_to_signal_when_replay_history_is_sparse(self) -> None: subject_type = select_closed_loop_backtest_subject_type( recent_trade_plan_candidates=[ { "trading_plan_id": 1, "model_code": "OSOK", "side": "buy", "setup_timeframe": "1m", "trigger_timeframe": "1m", "created_ts": datetime(2026, 5, 6, 0, 0, tzinfo=timezone.utc), "status": "blocked", "confirmation_state": {"executable": False}, "missing_confirmations": ["sweep_confirmed"], "entry": {}, }, { "trading_plan_id": 1, "model_code": "OSOK", "side": "buy", "setup_timeframe": "1m", "trigger_timeframe": "1m", "created_ts": datetime(2026, 5, 6, 1, 0, tzinfo=timezone.utc), "status": "blocked", "confirmation_state": {"executable": False}, "missing_confirmations": ["fvg_retrace_confirmed"], "entry": {}, }, ], quality_gate=BacktestQualityGate(min_executed_signals=30), ) self.assertEqual(subject_type, "signal") def test_run_closed_loop_returns_summary_and_reports(self) -> None: result = run_closed_loop( instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, dependencies={ "session_generator": FakeSessionGenerator(), "swing_service": FakeSwingService(), "range_service": FakeRangeService(), "liquidity_service": FakeLiquidityService(), "sweep_service": FakeSweepService(), "displacement_service": FakeDisplacementService(), "mss_service": FakeMSSService(), "fvg_service": FakeFVGService(), "bias_service": FakeBiasService(), "signal_service": FakeSignalService(), "execution_service": FakeExecutionService(), "backtest_service": FakeBacktestService(), "pre_market_report_service": FakePreMarketReportService(), "intraday_report_service": FakeIntradayReportService(), "post_trade_review_service": FakePostTradeReviewService(), "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], }, ) self.assertEqual(result["writes"]["sessions"], 5) self.assertEqual(result["writes"]["execution_id"], 99) self.assertEqual(result["writes"]["backtest_run_id"], 55) self.assertEqual(result["reports"]["pre_market"]["report_type"], "pre_market_plan") self.assertEqual(result["reports"]["intraday"]["report_type"], "intraday_explanation") self.assertEqual(result["reports"]["post_trade"]["report_type"], "post_trade_review") self.assertEqual(result["writes"]["execution_status"], "closed") self.assertEqual(result["writes"]["execution_lifecycle_state"], "tp2_closed") self.assertIn("backtest_quality_gate", result) self.assertFalse(result["backtest_quality_gate"]["passed"]) def test_run_closed_loop_repeat_uses_service_idempotency_contracts(self) -> None: bias_service = FakeBiasService() signal_service = FakeSignalService() execution_service = FakeExecutionService() recent_signals = [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}] deps = { "session_generator": FakeSessionGenerator(), "swing_service": FakeSwingService(), "range_service": FakeRangeService(), "liquidity_service": FakeLiquidityService(), "sweep_service": FakeSweepService(), "displacement_service": FakeDisplacementService(), "mss_service": FakeMSSService(), "fvg_service": FakeFVGService(), "bias_service": bias_service, "signal_service": signal_service, "execution_service": execution_service, "backtest_service": FakeBacktestService(), "pre_market_report_service": FakePreMarketReportService(), "intraday_report_service": FakeIntradayReportService(), "post_trade_review_service": FakePostTradeReviewService(), "get_recent_signals": lambda instrument_id, limit: recent_signals, "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], } first = run_closed_loop(instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, dependencies=deps) second = run_closed_loop(instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, dependencies=deps) self.assertEqual(first["writes"]["bias_snapshot_id"], 11) self.assertEqual(second["writes"]["bias_snapshot_id"], 11) self.assertEqual(bias_service.persist_calls, 2) self.assertEqual(signal_service.persist_calls, 2) self.assertEqual(execution_service.calls, 2) self.assertEqual(execution_service.settlement_calls, 2) def test_run_closed_loop_passes_backtest_cost_config(self) -> None: backtest_service = FakeBacktestService() cost_config = BacktestCostConfig(fee_rate=0.0005, slippage_rate=0.0005, fixed_cost_r=0.05) deps = { "session_generator": FakeSessionGenerator(), "swing_service": FakeSwingService(), "range_service": FakeRangeService(), "liquidity_service": FakeLiquidityService(), "sweep_service": FakeSweepService(), "displacement_service": FakeDisplacementService(), "mss_service": FakeMSSService(), "fvg_service": FakeFVGService(), "bias_service": FakeBiasService(), "signal_service": FakeSignalService(), "execution_service": FakeExecutionService(), "backtest_service": backtest_service, "pre_market_report_service": FakePreMarketReportService(), "intraday_report_service": FakeIntradayReportService(), "post_trade_review_service": FakePostTradeReviewService(), "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], } run_closed_loop( instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, backtest_cost_config=cost_config, dependencies=deps, ) self.assertEqual(backtest_service.last_kwargs["cost_config"], cost_config) def test_run_closed_loop_passes_backtest_quality_gate(self) -> None: backtest_service = FakeBacktestService() quality_gate = BacktestQualityGate(min_total_signals=1, min_profit_factor_r=1.0) deps = { "session_generator": FakeSessionGenerator(), "swing_service": FakeSwingService(), "range_service": FakeRangeService(), "liquidity_service": FakeLiquidityService(), "sweep_service": FakeSweepService(), "displacement_service": FakeDisplacementService(), "mss_service": FakeMSSService(), "fvg_service": FakeFVGService(), "bias_service": FakeBiasService(), "signal_service": FakeSignalService(), "execution_service": FakeExecutionService(), "backtest_service": backtest_service, "pre_market_report_service": FakePreMarketReportService(), "intraday_report_service": FakeIntradayReportService(), "post_trade_review_service": FakePostTradeReviewService(), "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], "get_recent_trade_plan_candidates": lambda instrument_id, limit: [ { "trading_plan_id": 1, "model_code": "OSOK", "side": "buy", "setup_timeframe": "1m", "trigger_timeframe": "1m", "created_ts": datetime(2026, 5, 6, 0, 0, tzinfo=timezone.utc), "status": "blocked", "confirmation_state": {"executable": False}, "missing_confirmations": ["sweep_confirmed"], "entry": {}, } ], "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], } run_closed_loop( instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, backtest_quality_gate=quality_gate, dependencies=deps, ) self.assertEqual(backtest_service.last_kwargs["quality_gate"], quality_gate) self.assertEqual(backtest_service.last_kwargs["subject_type"], "signal") self.assertIsNone(backtest_service.last_kwargs["trading_plan_id"]) self.assertIsNone(backtest_service.last_kwargs["candidate_model_code"]) def test_run_closed_loop_passes_backtest_replay_mode_and_config(self) -> None: backtest_service = FakeBacktestService() replay_config = BacktestReplayConfig(left_bars=1, right_bars=1) deps = { "session_generator": FakeSessionGenerator(), "swing_service": FakeSwingService(), "range_service": FakeRangeService(), "liquidity_service": FakeLiquidityService(), "sweep_service": FakeSweepService(), "displacement_service": FakeDisplacementService(), "mss_service": FakeMSSService(), "fvg_service": FakeFVGService(), "bias_service": FakeBiasService(), "signal_service": FakeSignalService(), "execution_service": FakeExecutionService(), "backtest_service": backtest_service, "pre_market_report_service": FakePreMarketReportService(), "intraday_report_service": FakeIntradayReportService(), "post_trade_review_service": FakePostTradeReviewService(), "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], } run_closed_loop( instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, backtest_mode="replay_no_lookahead", backtest_replay_config=replay_config, dependencies=deps, ) self.assertEqual(backtest_service.last_kwargs["mode"], "replay_no_lookahead") self.assertEqual(backtest_service.last_kwargs["replay_config"], replay_config) def test_run_closed_loop_passes_execution_account_state(self) -> None: execution_service = FakeExecutionService() account_state = AccountRiskState(daily_pnl_pct=-0.01, consecutive_losses=1) deps = { "session_generator": FakeSessionGenerator(), "swing_service": FakeSwingService(), "range_service": FakeRangeService(), "liquidity_service": FakeLiquidityService(), "sweep_service": FakeSweepService(), "displacement_service": FakeDisplacementService(), "mss_service": FakeMSSService(), "fvg_service": FakeFVGService(), "bias_service": FakeBiasService(), "signal_service": FakeSignalService(), "execution_service": execution_service, "backtest_service": FakeBacktestService(), "pre_market_report_service": FakePreMarketReportService(), "intraday_report_service": FakeIntradayReportService(), "post_trade_review_service": FakePostTradeReviewService(), "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], } run_closed_loop( instrument_id=1, timeframe="1m", backtest_limit=10, run_execution=True, execution_account_state=account_state, dependencies=deps, ) self.assertEqual(execution_service.last_create_kwargs["account_state"], account_state) def test_run_closed_loop_supports_multi_timeframe_runtime_and_backtest(self) -> None: session_generator = FakeSessionGenerator() backtest_service = FakeBacktestService() deps = { "session_generator": session_generator, "swing_service": FakeSwingService(), "range_service": FakeRangeService(), "liquidity_service": FakeLiquidityService(), "sweep_service": FakeSweepService(), "displacement_service": FakeDisplacementService(), "mss_service": FakeMSSService(), "fvg_service": FakeFVGService(), "bias_service": FakeBiasService(), "signal_service": FakeSignalService(), "execution_service": FakeExecutionService(), "backtest_service": backtest_service, "pre_market_report_service": FakePreMarketReportService(), "intraday_report_service": FakeIntradayReportService(), "post_trade_review_service": FakePostTradeReviewService(), "get_recent_signals": lambda instrument_id, limit: [{"id": 1, "session_code": "LONDON", "status": "awaiting_confirmation", "side": "buy", "invalidations": []}], "get_recent_backtest_summaries": lambda instrument_id, limit: [{"id": 55, "summary": {"total_signals": 1}}], } result = run_closed_loop( instrument_id=1, timeframe="5m", bias_tf="1h", setup_tf="15m", trigger_tf="5m", backtest_limit=10, run_execution=True, dependencies=deps, ) self.assertEqual(session_generator.calls[0]["timeframe"], "5m") self.assertEqual(result["timeframe_set"], {"bias_tf": "1h", "setup_tf": "15m", "trigger_tf": "5m"}) self.assertIn("1h", result["writes_by_timeframe"]) self.assertIn("15m", result["writes_by_timeframe"]) self.assertIn("5m", result["writes_by_timeframe"]) self.assertEqual(backtest_service.last_kwargs["timeframe_set"], {"bias_tf": "1h", "setup_tf": "15m", "trigger_tf": "5m"}) if __name__ == "__main__": unittest.main()