Add real-time market truth layer

This commit is contained in:
Codex
2026-05-07 00:12:12 +08:00
parent 057c1debd0
commit 82c1f20879
27 changed files with 2398 additions and 39 deletions
@@ -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={
+322
View File
@@ -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()