Files
ai-exchange/tests/unit/test_market_truth_runtime.py
T
2026-05-11 23:37:47 +08:00

512 lines
18 KiB
Python

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,
NewsProviderChain,
STREAM_CODE_CONNECTED,
STREAM_CODE_DISCONNECTED,
TradingEconomicsCalendarProvider,
build_data_gap_state,
)
from src.services.market_state.news_event_provider import NEWS_CODE_PROVIDER_CACHED, NEWS_CODE_PROVIDER_ERROR, NEWS_CODE_PROVIDER_FRESH, NewsProviderSnapshot
class MarketDataStreamServiceTests(unittest.TestCase):
def test_stream_service_defaults_to_business_websocket(self) -> None:
service = MarketDataStreamService()
self.assertEqual(service.url, "wss://ws.okx.com:8443/ws/v5/business")
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": "candle1m", "instId": "BTC-USDT-SWAP"}}))
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"]],
}
),
)
self.assertEqual(service.iter_subscriptions()[0].candle_channel, "candle1m")
self.assertEqual(service.iter_subscriptions()[0].active_channels, ["candle1m"])
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"])
service_no_ticker = MarketDataStreamService(url="wss://example.test")
service_no_ticker.subscribe(
instrument_id=1,
symbol="BTC-USDT",
inst_id="BTC-USDT-SWAP",
timeframe="1m",
)
service_no_ticker._on_message(None, json.dumps({"event": "subscribe", "arg": {"channel": "candle1m", "instId": "BTC-USDT-SWAP"}}))
service_no_ticker._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_no_ticker = service_no_ticker.get_snapshot(instrument_id=1, timeframe="1m")
self.assertEqual(snapshot_no_ticker["code"], STREAM_CODE_CONNECTED)
self.assertEqual(snapshot_no_ticker["latest_price"], 65000.5)
self.assertTrue(snapshot_no_ticker["subscribed"])
self.assertTrue(snapshot_no_ticker["connected"])
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)
def test_tradingeconomics_provider_parses_high_impact_rows(self) -> None:
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
provider = TradingEconomicsCalendarProvider(high_impact_only=True)
snapshot = provider.fetch_snapshot(
now=now,
rows=[
{
"CalendarId": "42",
"Event": "US CPI",
"Country": "United States",
"Importance": 3,
"Date": now.isoformat(),
}
],
)
self.assertEqual(snapshot.freshness_code, NEWS_CODE_PROVIDER_FRESH)
self.assertEqual(snapshot.code, "news_window_active")
self.assertEqual(snapshot.active_events[0]["title"], "US CPI")
def test_provider_chain_uses_first_successful_snapshot(self) -> None:
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
chain = NewsProviderChain(providers=[_FailingNewsProvider(), _WorkingNewsProvider(now=now)])
snapshot = chain.fetch_snapshot(now=now)
self.assertEqual(snapshot.provider_name, "working_news_provider")
self.assertEqual(snapshot.freshness_code, NEWS_CODE_PROVIDER_FRESH)
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_supervisor_keeps_running_when_rest_fallback_fails(self) -> None:
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
repository = _SupervisorRepository(now=now)
supervisor = MarketTruthSupervisor(
repository=repository,
candle_repository=_FallbackCandleRepository(repository),
stream_service=_DisconnectedStreamService(now=now),
rest_client=_FailingOKXClient(),
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.assertTrue(any(item["incident_code"] == "rest_backfill_failed" for item in repository.incidents))
def test_supervisor_uses_cached_news_snapshot_when_provider_errors(self) -> None:
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
repository = _SupervisorRepository(now=now)
repository.cached_news_snapshot = {
"provider_name": "tradingeconomics_snapshot",
"fetched_ts": now - timedelta(minutes=30),
"freshness_code": "news_provider_fresh",
"event_count": 1,
"is_active": False,
"code": "news_window_inactive",
"active_events": [],
"error": None,
"raw_payload": {"rows": []},
}
supervisor = MarketTruthSupervisor(
repository=repository,
candle_repository=_FallbackCandleRepository(repository),
stream_service=_DisconnectedStreamService(now=now),
rest_client=_FallbackOKXClient(now=now),
news_provider=_FailingChainProvider(),
enabled=False,
)
state = supervisor.current_state(instrument_id=1, timeframe="1m", now=now)
self.assertEqual(state.news_snapshot.freshness_code, NEWS_CODE_PROVIDER_CACHED)
self.assertEqual(state.news_snapshot.provider_name, "tradingeconomics_snapshot")
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] = []
self.cached_news_snapshot = None
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)
def upsert_news_provider_snapshot(self, **kwargs) -> int:
self.cached_news_snapshot = dict(kwargs)
return 1
def fetch_latest_news_provider_snapshot(self):
return dict(self.cached_news_snapshot) if self.cached_news_snapshot else None
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 _FailingOKXClient:
def get_candles(self, inst_id: str, bar: str, limit: int):
raise TimeoutError("rest_timeout")
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,
raw_payload={},
)
def evaluate_cached_snapshot(self, *, snapshot, now: datetime):
return snapshot
class _FailingNewsProvider:
provider_name = "failing_news_provider"
def fetch_snapshot(self, *, now: datetime):
return NewsProviderSnapshot(
provider_name=self.provider_name,
fetched_ts=None,
freshness_code=NEWS_CODE_PROVIDER_ERROR,
active_events=[],
event_count=0,
is_active=False,
code=NEWS_CODE_PROVIDER_ERROR,
error="rate_limited",
raw_payload={},
)
class _WorkingNewsProvider:
provider_name = "working_news_provider"
def __init__(self, now: datetime) -> None:
self.now = now
def fetch_snapshot(self, *, now: datetime):
return NewsProviderSnapshot(
provider_name=self.provider_name,
fetched_ts=now,
freshness_code=NEWS_CODE_PROVIDER_FRESH,
active_events=[],
event_count=1,
is_active=False,
code="news_window_inactive",
error=None,
raw_payload={"rows": [{"Event": "US CPI"}]},
)
class _FailingChainProvider:
def fetch_snapshot(self, *, now: datetime):
return NewsProviderSnapshot(
provider_name="failing_chain",
fetched_ts=None,
freshness_code=NEWS_CODE_PROVIDER_ERROR,
active_events=[],
event_count=0,
is_active=False,
code=NEWS_CODE_PROVIDER_ERROR,
error="provider_unavailable",
raw_payload={},
)
def evaluate_cached_snapshot(self, *, snapshot, now: datetime):
if snapshot is None:
return NewsProviderSnapshot(
provider_name="failing_chain",
fetched_ts=None,
freshness_code="news_provider_stale",
active_events=[],
event_count=0,
is_active=False,
code="news_provider_stale",
error="snapshot_missing",
raw_payload={},
)
return NewsProviderSnapshot(
provider_name=snapshot.provider_name,
fetched_ts=snapshot.fetched_ts,
freshness_code=NEWS_CODE_PROVIDER_CACHED,
active_events=list(snapshot.active_events),
event_count=snapshot.event_count,
is_active=snapshot.is_active,
code=snapshot.code,
error=snapshot.error,
raw_payload=dict(snapshot.raw_payload or {}),
)
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,
raw_payload={},
),
)
if __name__ == "__main__":
unittest.main()