Files
ai-exchange/tests/unit/test_api_and_main.py
T
2026-05-22 08:21:24 +08:00

838 lines
37 KiB
Python

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, get_strategy_backtest_surface, 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"])
self.assertEqual(result[0]["ict_rulebook_mapping"]["surface"], "strategy_backtest")
self.assertIn("backtest_evidence", result[0]["ict_rulebook_mapping"]["present_concepts"])
self.assertTrue(result[0]["doctrine_research_status"]["research_only"])
self.assertEqual(result[0]["doctrine_research_status"]["research_only_reason"], "missing_doctrine_setup_playbook")
self.assertEqual(result[0]["liquidity_kernel_status"]["status"], "study_only")
self.assertIn("missing_liquidity_target", result[0]["liquidity_kernel_status"]["reasons"])
self.assertEqual(result[0]["pd_array_reaction_status"]["status"], "study_only")
self.assertIn("missing_pd_array", result[0]["pd_array_reaction_status"]["reasons"])
self.assertEqual(result[0]["time_session_kernel_status"]["status"], "study_only")
self.assertIn("missing_session_window", result[0]["time_session_kernel_status"]["reasons"])
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)
self.assertEqual(result["ict_rulebook_mapping"]["surface"], "strategy_backtest")
self.assertIn("backtest_evidence", result["ict_rulebook_mapping"]["present_concepts"])
self.assertEqual(result["doctrine_research_status"]["status"], "research_only")
def test_strategy_backtest_surface_exposes_cockpit_promotion_gate(self) -> None:
rows = [
{
"id": 10,
"summary": {
"total_signals": 3,
"quality_gate": {"passed": False, "reasons": ["sample_size_too_small"]},
},
}
]
with patch("src.api.backtest.SessionLocal", return_value=FakeSession(rows)):
result = get_strategy_backtest_surface(instrument_id=1, limit=1)
self.assertEqual(result["cockpit_promotion_gate"]["status"], "blocked")
self.assertIn("sample_size_too_small", result["cockpit_promotion_gate"]["reasons"])
self.assertEqual(result["doctrine_rule_gap_summary"]["item_count"], 1)
self.assertEqual(result["doctrine_rule_gap_summary"]["research_only_count"], 1)
self.assertEqual(result["liquidity_kernel_summary"]["item_count"], 1)
self.assertIn("missing_liquidity_target", result["liquidity_kernel_summary"]["reason_counts"])
self.assertEqual(result["pd_array_reaction_summary"]["item_count"], 1)
self.assertIn("missing_pd_array", result["pd_array_reaction_summary"]["reason_counts"])
self.assertEqual(result["time_session_kernel_summary"]["item_count"], 1)
self.assertIn("missing_session_window", result["time_session_kernel_summary"]["reason_counts"])
def test_route_request_trading_doctrine_rules_returns_d1_matrix(self) -> None:
status, payload = route_request(
"/trading/doctrine-rules",
{},
dependencies={"trading_doctrine_rules": lambda: {"phase": "D1_MARKET_LANGUAGE_KERNEL", "rules": [{"rule_id": "range_defined"}]}},
)
self.assertEqual(status, 200)
self.assertEqual(payload["phase"], "D1_MARKET_LANGUAGE_KERNEL")
self.assertEqual(payload["rules"][0]["rule_id"], "range_defined")
def test_route_request_trading_doctrine_playbooks_returns_d2_registry(self) -> None:
status, payload = route_request(
"/trading/doctrine-playbooks",
{},
dependencies={
"trading_doctrine_playbooks": lambda: {
"phase": "D2_PLAYBOOK_BACKTEST_COHORT",
"playbooks": [{"setup_playbook_id": "ICT-CH1-6-LONDON-DISCOUNT-REVERSAL"}],
}
},
)
self.assertEqual(status, 200)
self.assertEqual(payload["phase"], "D2_PLAYBOOK_BACKTEST_COHORT")
self.assertEqual(payload["playbooks"][0]["setup_playbook_id"], "ICT-CH1-6-LONDON-DISCOUNT-REVERSAL")
def test_route_request_trading_liquidity_kernel_returns_d3_registry(self) -> None:
status, payload = route_request(
"/trading/liquidity-kernel",
{},
dependencies={
"trading_liquidity_kernel": lambda: {
"phase": "D3_LIQUIDITY_KERNEL",
"rules": [{"rule_id": "stop_run_sweep_raid_validated"}],
}
},
)
self.assertEqual(status, 200)
self.assertEqual(payload["phase"], "D3_LIQUIDITY_KERNEL")
self.assertEqual(payload["rules"][0]["rule_id"], "stop_run_sweep_raid_validated")
def test_route_request_trading_pd_array_kernel_returns_d4_registry(self) -> None:
status, payload = route_request(
"/trading/pd-array-kernel",
{},
dependencies={
"trading_pd_array_kernel": lambda: {
"phase": "D4_PD_ARRAY_REACTION_KERNEL",
"rules": [{"rule_id": "pd_array_relevance_matrix"}],
}
},
)
self.assertEqual(status, 200)
self.assertEqual(payload["phase"], "D4_PD_ARRAY_REACTION_KERNEL")
self.assertEqual(payload["rules"][0]["rule_id"], "pd_array_relevance_matrix")
def test_route_request_trading_time_session_kernel_returns_d5_registry(self) -> None:
status, payload = route_request(
"/trading/time-session-kernel",
{},
dependencies={
"trading_time_session_kernel": lambda: {
"phase": "D5_TIME_SESSION_KERNEL",
"rules": [{"rule_id": "session_window_verifiable"}],
}
},
)
self.assertEqual(status, 200)
self.assertEqual(payload["phase"], "D5_TIME_SESSION_KERNEL")
self.assertEqual(payload["rules"][0]["rule_id"], "session_window_verifiable")
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: []},
)
def test_route_request_visual_snapshot_create(self) -> None:
with patch("src.api.server.create_visual_snapshot", return_value={"snapshot_id": 12, "status": "captured", "watch_run_id": 9}):
status, payload = route_request(
"/trading/visual-watch/snapshots",
{},
method="POST",
body={"watch_run_id": 9, "image_path": "captures/btc.png", "source_name": "capture_bot"},
)
self.assertEqual(status, 201)
self.assertEqual(payload["snapshot_id"], 12)
self.assertEqual(payload["watch_run_id"], 9)
def test_route_request_visual_snapshot_capture(self) -> None:
captured_kwargs = {}
def fake_capture(**kwargs):
captured_kwargs.update(kwargs)
return {
"status": "captured",
"watch_run_id": kwargs["watch_run_id"],
"snapshot_id": 21,
"image_path": "captures/watch-9.png",
"html_path": "captures/watch-9.html",
"artifact_path": "captures/watch-9.json",
}
status, payload = route_request(
"/trading/visual-watch/capture",
{},
method="POST",
body={"watch_run_id": 9, "source_name": "capture_bot", "capture_url": "file:///tmp/chart.html", "browser_capture_enabled": True},
dependencies={
"trading_visual_capture_snapshot": fake_capture
},
)
self.assertEqual(status, 201)
self.assertEqual(payload["snapshot_id"], 21)
self.assertEqual(payload["image_path"], "captures/watch-9.png")
self.assertEqual(captured_kwargs["capture_url"], "file:///tmp/chart.html")
self.assertTrue(captured_kwargs["browser_capture_enabled"])
def test_route_request_visual_review_create(self) -> None:
with patch("src.api.server.create_visual_review", return_value={"review_id": 14, "status": "reviewed", "watch_run_id": 9, "visual_state": "aligned"}):
status, payload = route_request(
"/trading/visual-watch/reviews",
{},
method="POST",
body={"watch_run_id": 9, "snapshot_id": 12, "visual_state": "aligned", "visual_confidence": 0.9},
)
self.assertEqual(status, 201)
self.assertEqual(payload["review_id"], 14)
self.assertEqual(payload["visual_state"], "aligned")
def test_route_request_visual_review_run(self) -> None:
status, payload = route_request(
"/trading/visual-watch/review",
{},
method="POST",
body={"watch_run_id": 9, "visual_state": "caution"},
dependencies={
"trading_visual_review_run": lambda **kwargs: {
"status": "reviewed",
"watch_run_id": kwargs["watch_run_id"],
"visual_state": kwargs["visual_state"],
}
},
)
self.assertEqual(status, 201)
self.assertEqual(payload["watch_run_id"], 9)
self.assertEqual(payload["visual_state"], "caution")
def test_route_request_visual_review_pending(self) -> None:
status, payload = route_request(
"/trading/visual-watch/review-pending",
{},
method="POST",
body={"limit": 1, "instrument_id": 1},
dependencies={
"trading_visual_review_pending": lambda **kwargs: {
"status": "completed",
"attempted": 1,
"reviewed": 1,
"items": [{"watch_run_id": 11, "visual_state": "confirm"}],
**kwargs,
}
},
)
self.assertEqual(status, 200)
self.assertEqual(payload["reviewed"], 1)
self.assertEqual(payload["instrument_id"], 1)
def test_route_request_visual_capture_cleanup(self) -> None:
status, payload = route_request(
"/trading/visual-watch/cleanup-captures",
{},
method="POST",
body={"retention_hours": 72, "dry_run": True, "max_delete_files": 25},
dependencies={
"trading_visual_cleanup_captures": lambda **kwargs: {
"status": "completed",
"retention_hours": kwargs["retention_hours"],
"dry_run": kwargs["dry_run"],
"deleted_file_count": 0,
"max_delete_files": kwargs["max_delete_files"],
}
},
)
self.assertEqual(status, 200)
self.assertEqual(payload["retention_hours"], 72)
self.assertTrue(payload["dry_run"])
self.assertEqual(payload["max_delete_files"], 25)
def test_route_request_alert_feedback_create(self) -> None:
status, payload = route_request(
"/trading/alerts/7/feedback",
{},
method="POST",
body={"label": "useful", "note": "timely", "created_by": "local_operator"},
dependencies={
"trading_alert_feedback_create": lambda **kwargs: {
"status": "created",
"feedback_id": 91,
"alert_id": kwargs["alert_id"],
"label": kwargs["label"],
}
},
)
self.assertEqual(status, 201)
self.assertEqual(payload["feedback_id"], 91)
self.assertEqual(payload["alert_id"], 7)
self.assertEqual(payload["label"], "useful")
def test_route_request_visual_review_feedback_create(self) -> None:
status, payload = route_request(
"/trading/visual-watch/reviews/14/feedback",
{},
method="POST",
body={"label": "visual_helpful", "note": "helped confirm"},
dependencies={
"trading_visual_review_feedback_create": lambda **kwargs: {
"status": "created",
"feedback_id": 92,
"visual_review_id": kwargs["visual_review_id"],
"label": kwargs["label"],
}
},
)
self.assertEqual(status, 201)
self.assertEqual(payload["feedback_id"], 92)
self.assertEqual(payload["visual_review_id"], 14)
self.assertEqual(payload["label"], "visual_helpful")
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()