Add real-time market truth layer
This commit is contained in:
@@ -30,6 +30,7 @@ class DocumentationConsistencyTests(unittest.TestCase):
|
||||
top_level_docs,
|
||||
{
|
||||
"AI_ICT_实盘交易分析系统开发计划_v1.md",
|
||||
"AI_ICT_实盘辅助决策系统开发方案_v2.md",
|
||||
"ICT交易全书_出版终校版.md",
|
||||
"training_console_public_surface_v1.md",
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ from src.services.execution import evaluate_execution_decision
|
||||
from src.services.market_state import (
|
||||
MarketStateService,
|
||||
build_session_state,
|
||||
build_gap_state,
|
||||
build_stream_state,
|
||||
evaluate_freshness,
|
||||
evaluate_news_window,
|
||||
evaluate_opening_volatility,
|
||||
@@ -98,6 +100,8 @@ class MarketStateServiceTests(unittest.TestCase):
|
||||
self.assertEqual(state.session.session_code, "LONDON")
|
||||
self.assertTrue(state.session.allows_new_positions)
|
||||
self.assertFalse(state.news_window.is_active)
|
||||
self.assertEqual(state.stream_state.code, "market_stream_disconnected")
|
||||
self.assertEqual(state.data_gap_state.code, "market_data_gap_unavailable")
|
||||
|
||||
|
||||
class RiskGovernorServiceTests(unittest.TestCase):
|
||||
@@ -141,6 +145,23 @@ class RiskGovernorServiceTests(unittest.TestCase):
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn("news_window_active", decision.reject_codes)
|
||||
|
||||
def test_risk_governor_rejects_stream_disconnect_and_gap(self) -> None:
|
||||
decision = RiskGovernorService().evaluate(
|
||||
context=RiskContext(
|
||||
market_state={
|
||||
"freshness": {"code": "market_data_fresh"},
|
||||
"session": {"allows_new_positions": True},
|
||||
"stream_state": build_stream_state({"code": "market_stream_disconnected"}).to_dict(),
|
||||
"data_gap_state": build_gap_state({"code": "market_data_gap_detected", "is_gap": True, "source": "stream"}).to_dict(),
|
||||
}
|
||||
),
|
||||
execution_mode="paper",
|
||||
)
|
||||
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertIn("market_stream_disconnected", decision.reject_codes)
|
||||
self.assertIn("market_data_gap_detected", decision.reject_codes)
|
||||
|
||||
def test_risk_governor_rejects_low_quality_backtest_gate_for_semi_auto_only(self) -> None:
|
||||
context = RiskContext(
|
||||
backtest_quality_gate={
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
||||
|
||||
from src.services.market_data.okx_client import OKXCandle
|
||||
from src.services.market_state import (
|
||||
DATA_GAP_CODE_DETECTED,
|
||||
EconomicCalendarProvider,
|
||||
MarketDataStreamService,
|
||||
MarketStateService,
|
||||
MarketTruthRuntimeState,
|
||||
MarketTruthSupervisor,
|
||||
STREAM_CODE_CONNECTED,
|
||||
STREAM_CODE_DISCONNECTED,
|
||||
build_data_gap_state,
|
||||
)
|
||||
from src.services.market_state.news_event_provider import NewsProviderSnapshot
|
||||
|
||||
|
||||
class MarketDataStreamServiceTests(unittest.TestCase):
|
||||
def test_stream_service_parses_ticker_and_candle_messages(self) -> None:
|
||||
service = MarketDataStreamService(url="wss://example.test")
|
||||
service.subscribe(
|
||||
instrument_id=1,
|
||||
symbol="BTC-USDT",
|
||||
inst_id="BTC-USDT-SWAP",
|
||||
timeframe="1m",
|
||||
)
|
||||
|
||||
service._on_message(None, json.dumps({"event": "subscribe", "arg": {"channel": "tickers", "instId": "BTC-USDT-SWAP"}}))
|
||||
service._on_message(
|
||||
None,
|
||||
json.dumps(
|
||||
{
|
||||
"arg": {"channel": "tickers", "instId": "BTC-USDT-SWAP"},
|
||||
"data": [{"last": "65000.5", "ts": "1778032800000"}],
|
||||
}
|
||||
),
|
||||
)
|
||||
service._on_message(
|
||||
None,
|
||||
json.dumps(
|
||||
{
|
||||
"arg": {"channel": "candle1m", "instId": "BTC-USDT-SWAP"},
|
||||
"data": [["1778032800000", "64990.1", "65010.0", "64980.0", "65000.5", "12.3", "0", "0", "1"]],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
snapshot = service.get_snapshot(instrument_id=1, timeframe="1m")
|
||||
|
||||
self.assertEqual(snapshot["code"], STREAM_CODE_CONNECTED)
|
||||
self.assertTrue(snapshot["connected"])
|
||||
self.assertTrue(snapshot["subscribed"])
|
||||
self.assertEqual(snapshot["latest_price"], 65000.5)
|
||||
self.assertEqual(snapshot["latest_candle_open"], 64990.1)
|
||||
self.assertEqual(snapshot["latest_candle_close"], 65000.5)
|
||||
self.assertIsNotNone(snapshot["last_confirmed_candle_close_ts"])
|
||||
|
||||
def test_build_data_gap_state_detects_missing_candle_interval(self) -> None:
|
||||
now = datetime(2026, 5, 7, 12, 5, tzinfo=timezone.utc)
|
||||
gap_state = build_data_gap_state(
|
||||
stream_snapshot={"code": STREAM_CODE_CONNECTED, "gap_detected": False},
|
||||
latest_candle_ts=now - timedelta(minutes=4),
|
||||
timeframe="1m",
|
||||
now=now,
|
||||
gap_multiplier=2,
|
||||
)
|
||||
|
||||
self.assertTrue(gap_state["is_gap"])
|
||||
self.assertEqual(gap_state["code"], DATA_GAP_CODE_DETECTED)
|
||||
|
||||
|
||||
class EconomicCalendarProviderTests(unittest.TestCase):
|
||||
def test_provider_marks_active_high_impact_event(self) -> None:
|
||||
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
|
||||
provider = EconomicCalendarProvider(
|
||||
pre_window_minutes=15,
|
||||
post_window_minutes=15,
|
||||
high_impact_only=True,
|
||||
)
|
||||
|
||||
snapshot = provider.fetch_snapshot(
|
||||
now=now,
|
||||
rows=[
|
||||
{
|
||||
"id": "cpi",
|
||||
"title": "US CPI",
|
||||
"country": "USD",
|
||||
"impact": "High",
|
||||
"dateUtc": now.isoformat(),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
self.assertTrue(snapshot.is_active)
|
||||
self.assertEqual(snapshot.code, "news_window_active")
|
||||
self.assertEqual(snapshot.event_count, 1)
|
||||
|
||||
|
||||
class MarketTruthSupervisorTests(unittest.TestCase):
|
||||
def test_supervisor_runs_rest_fallback_and_records_incident(self) -> None:
|
||||
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
|
||||
repository = _SupervisorRepository(now=now)
|
||||
candle_repository = _FallbackCandleRepository(repository)
|
||||
supervisor = MarketTruthSupervisor(
|
||||
repository=repository,
|
||||
candle_repository=candle_repository,
|
||||
stream_service=_DisconnectedStreamService(now=now),
|
||||
rest_client=_FallbackOKXClient(now=now),
|
||||
news_provider=_InactiveNewsProvider(now=now),
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
state = supervisor.current_state(instrument_id=1, timeframe="1m", now=now)
|
||||
|
||||
self.assertEqual(state.stream_state["code"], STREAM_CODE_DISCONNECTED)
|
||||
self.assertEqual(candle_repository.write_calls, 1)
|
||||
self.assertTrue(any(item["incident_code"] == "rest_backfill_applied" for item in repository.incidents))
|
||||
self.assertTrue(any(item["incident_code"] == STREAM_CODE_DISCONNECTED for item in repository.incidents))
|
||||
|
||||
def test_market_state_service_uses_supervisor_runtime_state(self) -> None:
|
||||
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
|
||||
repository = _SupervisorRepository(now=now)
|
||||
supervisor = _StaticSupervisor(now=now)
|
||||
service = MarketStateService(repository=repository, market_truth_supervisor=supervisor)
|
||||
|
||||
state = service.build_market_state(instrument_id=1, timeframe="1m", now=now)
|
||||
|
||||
payload = state.to_dict()
|
||||
self.assertIn("stream_state", payload)
|
||||
self.assertIn("data_gap_state", payload)
|
||||
self.assertIn("news_state", payload)
|
||||
self.assertEqual(payload["stream_state"]["code"], STREAM_CODE_CONNECTED)
|
||||
self.assertEqual(payload["latest_candle"]["latest_price"], 65111.0)
|
||||
|
||||
|
||||
class _SupervisorRepository:
|
||||
def __init__(self, now: datetime) -> None:
|
||||
self.now = now
|
||||
self.latest_candle = {
|
||||
"id": 1,
|
||||
"instrument_id": 1,
|
||||
"timeframe": "1m",
|
||||
"ts_close": now - timedelta(minutes=10),
|
||||
"open": 64000.0,
|
||||
"close": 64010.0,
|
||||
}
|
||||
self.incidents: list[dict] = []
|
||||
|
||||
def fetch_active_instruments_by_symbols(self, symbols: list[str]) -> list[dict]:
|
||||
return [{"id": 1, "symbol": "BTC-USDT", "active": True}] if "BTC-USDT" in symbols else []
|
||||
|
||||
def fetch_instrument_by_id(self, instrument_id: int):
|
||||
return {"id": instrument_id, "symbol": "BTC-USDT", "active": True}
|
||||
|
||||
def fetch_latest_candle(self, instrument_id: int, timeframe: str):
|
||||
return dict(self.latest_candle)
|
||||
|
||||
def fetch_session_at(self, instrument_id: int, ts):
|
||||
return {
|
||||
"id": 5,
|
||||
"session_code": "LONDON",
|
||||
"status": "open",
|
||||
"start_ts": self.now - timedelta(minutes=20),
|
||||
"end_ts": self.now + timedelta(hours=2),
|
||||
}
|
||||
|
||||
def insert_market_truth_incident(self, **kwargs) -> int:
|
||||
self.incidents.append(dict(kwargs))
|
||||
return len(self.incidents)
|
||||
|
||||
|
||||
class _FallbackCandleRepository:
|
||||
def __init__(self, repository: _SupervisorRepository) -> None:
|
||||
self.repository = repository
|
||||
self.write_calls = 0
|
||||
|
||||
def upsert_many(self, candles, instrument_id: int) -> int:
|
||||
self.write_calls += 1
|
||||
latest = candles[-1]
|
||||
self.repository.latest_candle = {
|
||||
"id": 99,
|
||||
"instrument_id": instrument_id,
|
||||
"timeframe": latest.timeframe,
|
||||
"ts_close": datetime.fromtimestamp((latest.ts_open_ms + 60_000 - 1) / 1000, tz=timezone.utc),
|
||||
"open": latest.open,
|
||||
"close": latest.close,
|
||||
}
|
||||
return len(candles)
|
||||
|
||||
|
||||
class _DisconnectedStreamService:
|
||||
def __init__(self, now: datetime) -> None:
|
||||
self.now = now
|
||||
self.subscriptions = []
|
||||
|
||||
def subscribe(self, **kwargs) -> None:
|
||||
self.subscriptions.append(dict(kwargs))
|
||||
|
||||
def start(self) -> None:
|
||||
return
|
||||
|
||||
def stop(self) -> None:
|
||||
return
|
||||
|
||||
def iter_subscriptions(self):
|
||||
return []
|
||||
|
||||
def get_snapshot(self, *, instrument_id: int, timeframe: str, now=None) -> dict:
|
||||
return {
|
||||
"instrument_id": instrument_id,
|
||||
"timeframe": timeframe,
|
||||
"code": STREAM_CODE_DISCONNECTED,
|
||||
"connected": False,
|
||||
"subscribed": False,
|
||||
"reconnect_count": 1,
|
||||
"last_message_ts": None,
|
||||
"heartbeat_age_seconds": None,
|
||||
"lag_seconds": None,
|
||||
"latest_price": None,
|
||||
"latest_candle_open": None,
|
||||
"latest_candle_close": None,
|
||||
"latest_candle_open_ts": None,
|
||||
"latest_candle_close_ts": None,
|
||||
"last_confirmed_candle_close_ts": None,
|
||||
"last_disconnect_reason": "socket_closed",
|
||||
"last_error": "socket_closed",
|
||||
"gap_detected": False,
|
||||
"gap_seconds": None,
|
||||
}
|
||||
|
||||
|
||||
class _FallbackOKXClient:
|
||||
def __init__(self, now: datetime) -> None:
|
||||
self.now = now
|
||||
|
||||
def get_candles(self, inst_id: str, bar: str, limit: int):
|
||||
return [
|
||||
OKXCandle(
|
||||
inst_id=inst_id,
|
||||
bar=bar,
|
||||
ts=str(int((self.now - timedelta(minutes=1)).timestamp() * 1000)),
|
||||
open="65000.0",
|
||||
high="65120.0",
|
||||
low="64980.0",
|
||||
close="65111.0",
|
||||
volume="5.2",
|
||||
confirm="1",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class _InactiveNewsProvider:
|
||||
def __init__(self, now: datetime) -> None:
|
||||
self.now = now
|
||||
|
||||
def fetch_snapshot(self, *, now: datetime):
|
||||
return NewsProviderSnapshot(
|
||||
provider_name="test",
|
||||
fetched_ts=now,
|
||||
freshness_code="news_provider_fresh",
|
||||
active_events=[],
|
||||
event_count=0,
|
||||
is_active=False,
|
||||
code="news_window_inactive",
|
||||
error=None,
|
||||
)
|
||||
|
||||
def evaluate_cached_snapshot(self, *, snapshot, now: datetime):
|
||||
return snapshot
|
||||
|
||||
|
||||
class _StaticSupervisor:
|
||||
def __init__(self, now: datetime) -> None:
|
||||
self.now = now
|
||||
|
||||
def current_state(self, *, instrument_id: int, timeframe: str, now: datetime):
|
||||
return MarketTruthRuntimeState(
|
||||
stream_state={
|
||||
"code": STREAM_CODE_CONNECTED,
|
||||
"connected": True,
|
||||
"subscribed": True,
|
||||
"reconnect_count": 0,
|
||||
"last_message_ts": now.isoformat(),
|
||||
"heartbeat_age_seconds": 1.0,
|
||||
"lag_seconds": 1.0,
|
||||
"latest_price": 65111.0,
|
||||
"latest_candle_open": 65000.0,
|
||||
"latest_candle_close": 65111.0,
|
||||
"latest_candle_open_ts": (now - timedelta(minutes=1)).isoformat(),
|
||||
"latest_candle_close_ts": now.isoformat(),
|
||||
"last_confirmed_candle_close_ts": now.isoformat(),
|
||||
"last_disconnect_reason": None,
|
||||
"last_error": None,
|
||||
},
|
||||
data_gap_state={
|
||||
"is_gap": False,
|
||||
"code": "market_data_gap_clear",
|
||||
"expected_interval_seconds": 60,
|
||||
"gap_seconds": 0.0,
|
||||
"last_candle_ts": now.isoformat(),
|
||||
"source": "stream",
|
||||
},
|
||||
news_snapshot=NewsProviderSnapshot(
|
||||
provider_name="test",
|
||||
fetched_ts=now,
|
||||
freshness_code="news_provider_fresh",
|
||||
active_events=[],
|
||||
event_count=0,
|
||||
is_active=False,
|
||||
code="news_window_inactive",
|
||||
error=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user