726 lines
31 KiB
Python
726 lines
31 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, 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: []},
|
|
)
|
|
|
|
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()
|