Stabilize news provider chain

This commit is contained in:
Codex
2026-05-08 17:08:55 +08:00
parent 85c471a0ef
commit afe34a2b7b
10 changed files with 518 additions and 93 deletions
+4
View File
@@ -32,6 +32,10 @@ NEWS_PROVIDER_URL=https://nfs.faireconomy.media/ff_calendar_thisweek.json
NEWS_PROVIDER_TIMEOUT=15 NEWS_PROVIDER_TIMEOUT=15
NEWS_PROVIDER_REFRESH_SECONDS=300 NEWS_PROVIDER_REFRESH_SECONDS=300
NEWS_PROVIDER_MAX_AGE_SECONDS=900 NEWS_PROVIDER_MAX_AGE_SECONDS=900
NEWS_PROVIDER_CACHE_MAX_AGE_SECONDS=21600
NEWS_PROVIDER_TRADINGECONOMICS_ENABLED=1
NEWS_PROVIDER_TRADINGECONOMICS_BASE_URL=https://api.tradingeconomics.com/calendar
NEWS_PROVIDER_TRADINGECONOMICS_API_KEY=guest:guest
NEWS_EVENT_PRE_WINDOW_MINUTES=15 NEWS_EVENT_PRE_WINDOW_MINUTES=15
NEWS_EVENT_POST_WINDOW_MINUTES=15 NEWS_EVENT_POST_WINDOW_MINUTES=15
NEWS_EVENT_HIGH_IMPACT_ONLY=1 NEWS_EVENT_HIGH_IMPACT_ONLY=1
@@ -0,0 +1,13 @@
create table if not exists news_provider_snapshots (
id bigserial primary key,
provider_name text not null unique,
fetched_ts timestamptz,
freshness_code text not null,
event_count integer not null default 0,
is_active boolean not null default false,
code text not null,
active_events jsonb not null default '[]'::jsonb,
error text,
raw_payload jsonb not null default '{}'::jsonb,
updated_at timestamptz not null default now()
);
+4
View File
@@ -36,6 +36,10 @@ class Settings(BaseSettings):
news_provider_timeout: int = 15 news_provider_timeout: int = 15
news_provider_refresh_seconds: int = 300 news_provider_refresh_seconds: int = 300
news_provider_max_age_seconds: int = 900 news_provider_max_age_seconds: int = 900
news_provider_cache_max_age_seconds: int = 21600
news_provider_tradingeconomics_enabled: bool = True
news_provider_tradingeconomics_base_url: str = "https://api.tradingeconomics.com/calendar"
news_provider_tradingeconomics_api_key: str = "guest:guest"
news_event_pre_window_minutes: int = 15 news_event_pre_window_minutes: int = 15
news_event_post_window_minutes: int = 15 news_event_post_window_minutes: int = 15
news_event_high_impact_only: bool = True news_event_high_impact_only: bool = True
@@ -256,3 +256,86 @@ class MarketStateRepository:
with SessionLocal() as session: with SessionLocal() as session:
rows = session.execute(sql, params).mappings().all() rows = session.execute(sql, params).mappings().all()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def upsert_news_provider_snapshot(
self,
*,
provider_name: str,
fetched_ts,
freshness_code: str,
event_count: int,
is_active: bool,
code: str,
active_events: list,
error: Optional[str],
raw_payload: dict,
) -> int:
sql = text(
"""
insert into news_provider_snapshots (
provider_name,
fetched_ts,
freshness_code,
event_count,
is_active,
code,
active_events,
error,
raw_payload,
updated_at
) values (
:provider_name,
:fetched_ts,
:freshness_code,
:event_count,
:is_active,
:code,
cast(:active_events as jsonb),
:error,
cast(:raw_payload as jsonb),
now()
)
on conflict (provider_name)
do update set
fetched_ts = excluded.fetched_ts,
freshness_code = excluded.freshness_code,
event_count = excluded.event_count,
is_active = excluded.is_active,
code = excluded.code,
active_events = excluded.active_events,
error = excluded.error,
raw_payload = excluded.raw_payload,
updated_at = now()
returning id
"""
)
with SessionLocal.begin() as session:
result = session.execute(
sql,
{
"provider_name": provider_name,
"fetched_ts": fetched_ts,
"freshness_code": freshness_code,
"event_count": event_count,
"is_active": is_active,
"code": code,
"active_events": json.dumps(active_events, default=str),
"error": error,
"raw_payload": json.dumps(raw_payload, default=str),
},
)
return int(result.scalar_one())
def fetch_latest_news_provider_snapshot(self) -> Optional[dict]:
sql = text(
"""
select id, provider_name, fetched_ts, freshness_code, event_count, is_active,
code, active_events, error, raw_payload, updated_at
from news_provider_snapshots
order by fetched_ts desc nulls last, updated_at desc, id desc
limit 1
"""
)
with SessionLocal() as session:
row = session.execute(sql).mappings().first()
return dict(row) if row else None
+40 -15
View File
@@ -21,15 +21,6 @@ from src.services.broker.broker_read_model_service import (
BROKER_STATE_UNAVAILABLE, BROKER_STATE_UNAVAILABLE,
BrokerReadModelService, BrokerReadModelService,
) )
from src.services.broker.live_broker_service import (
LiveBrokerService,
LiveOperationDecision,
build_broker_adapter,
validate_live_dangerous_operation,
validate_live_submit_request,
)
from src.services.broker.live_readiness_service import LiveReadinessService
from src.services.broker.okx_order_adapter import OKXOrderAdapter
__all__ = [ __all__ = [
"BracketOrderRequest", "BracketOrderRequest",
@@ -39,21 +30,55 @@ __all__ = [
"KillSwitchRequest", "KillSwitchRequest",
"LiveOrderEnvelope", "LiveOrderEnvelope",
"LiveOrderLeg", "LiveOrderLeg",
"LiveBrokerService",
"LiveReadinessService",
"LiveOperationDecision",
"OKXOrderAdapter",
"ReconciliationRequest", "ReconciliationRequest",
"build_bracket_semantics",
"BROKER_STATE_OK", "BROKER_STATE_OK",
"BROKER_STATE_UNAVAILABLE", "BROKER_STATE_UNAVAILABLE",
"ALERT_UNKNOWN_OPEN_POSITION", "ALERT_UNKNOWN_OPEN_POSITION",
"ALERT_UNKNOWN_PENDING_ORDER", "ALERT_UNKNOWN_PENDING_ORDER",
"BrokerReadModelService", "BrokerReadModelService",
"build_broker_adapter", "build_bracket_semantics",
"build_live_order_envelope", "build_live_order_envelope",
"build_operator_confirmation_protocol", "build_operator_confirmation_protocol",
"build_reconciliation_semantics", "build_reconciliation_semantics",
"LiveBrokerService",
"LiveOperationDecision",
"OKXOrderAdapter",
"LiveReadinessService",
"build_broker_adapter",
"validate_live_dangerous_operation", "validate_live_dangerous_operation",
"validate_live_submit_request", "validate_live_submit_request",
] ]
def __getattr__(name: str):
if name in {
"LiveBrokerService",
"LiveOperationDecision",
"build_broker_adapter",
"validate_live_dangerous_operation",
"validate_live_submit_request",
}:
from src.services.broker.live_broker_service import (
LiveBrokerService,
LiveOperationDecision,
build_broker_adapter,
validate_live_dangerous_operation,
validate_live_submit_request,
)
return {
"LiveBrokerService": LiveBrokerService,
"LiveOperationDecision": LiveOperationDecision,
"build_broker_adapter": build_broker_adapter,
"validate_live_dangerous_operation": validate_live_dangerous_operation,
"validate_live_submit_request": validate_live_submit_request,
}[name]
if name == "OKXOrderAdapter":
from src.services.broker.okx_order_adapter import OKXOrderAdapter
return OKXOrderAdapter
if name == "LiveReadinessService":
from src.services.broker.live_readiness_service import LiveReadinessService
return LiveReadinessService
raise AttributeError(name)
+2 -3
View File
@@ -4,13 +4,12 @@ import math
from typing import Optional from typing import Optional
from src.repositories.execution_repository import ExecutionRepository from src.repositories.execution_repository import ExecutionRepository
from src.services.broker import ( from src.services.broker.broker_adapter import (
BROKER_STATE_OK,
BrokerReadModelService,
build_bracket_semantics, build_bracket_semantics,
build_operator_confirmation_protocol, build_operator_confirmation_protocol,
build_reconciliation_semantics, build_reconciliation_semantics,
) )
from src.services.broker.broker_read_model_service import BROKER_STATE_OK, BrokerReadModelService
from src.services.backtest import ( from src.services.backtest import (
BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY, BACKTEST_LIFECYCLE_INVALIDATED_BEFORE_ENTRY,
BACKTEST_LIFECYCLE_MISSED, BACKTEST_LIFECYCLE_MISSED,
+3
View File
@@ -37,6 +37,7 @@ from src.services.market_state.market_truth_supervisor import (
build_okx_inst_id, build_okx_inst_id,
) )
from src.services.market_state.news_event_provider import EconomicCalendarProvider, NewsEvent, NewsProviderSnapshot from src.services.market_state.news_event_provider import EconomicCalendarProvider, NewsEvent, NewsProviderSnapshot
from src.services.market_state.news_event_provider import NewsProviderChain, TradingEconomicsCalendarProvider
from src.services.market_state.runtime import get_market_truth_supervisor, start_market_truth_runtime from src.services.market_state.runtime import get_market_truth_supervisor, start_market_truth_runtime
__all__ = [ __all__ = [
@@ -47,6 +48,7 @@ __all__ = [
"DEFAULT_MAX_CANDLE_AGE_SECONDS", "DEFAULT_MAX_CANDLE_AGE_SECONDS",
"DataGapState", "DataGapState",
"EconomicCalendarProvider", "EconomicCalendarProvider",
"NewsProviderChain",
"FreshnessState", "FreshnessState",
"MarketState", "MarketState",
"MarketDataStreamService", "MarketDataStreamService",
@@ -75,4 +77,5 @@ __all__ = [
"get_market_truth_supervisor", "get_market_truth_supervisor",
"start_market_truth_runtime", "start_market_truth_runtime",
"timeframe_to_seconds", "timeframe_to_seconds",
"TradingEconomicsCalendarProvider",
] ]
@@ -19,6 +19,7 @@ from src.services.market_state.market_data_stream_service import (
build_data_gap_state, build_data_gap_state,
) )
from src.services.market_state.news_event_provider import EconomicCalendarProvider, NewsProviderSnapshot from src.services.market_state.news_event_provider import EconomicCalendarProvider, NewsProviderSnapshot
from src.services.market_state.news_event_provider import NewsProviderChain, build_snapshot_from_row
INCIDENT_TYPE_STREAM = "stream" INCIDENT_TYPE_STREAM = "stream"
@@ -55,7 +56,7 @@ class MarketTruthSupervisor:
self.candle_repository = candle_repository or CandleRepository() self.candle_repository = candle_repository or CandleRepository()
self.stream_service = stream_service or MarketDataStreamService() self.stream_service = stream_service or MarketDataStreamService()
self.rest_client = rest_client or OKXClient(timeout=fallback_timeout or settings.market_truth_fallback_timeout) self.rest_client = rest_client or OKXClient(timeout=fallback_timeout or settings.market_truth_fallback_timeout)
self.news_provider = news_provider or EconomicCalendarProvider() self.news_provider = news_provider or NewsProviderChain()
self.enabled = settings.market_truth_supervisor_enabled if enabled is None else enabled self.enabled = settings.market_truth_supervisor_enabled if enabled is None else enabled
self.interval_seconds = interval_seconds or settings.market_truth_supervisor_interval_seconds self.interval_seconds = interval_seconds or settings.market_truth_supervisor_interval_seconds
self.fallback_limit = fallback_limit or settings.market_truth_fallback_limit self.fallback_limit = fallback_limit or settings.market_truth_fallback_limit
@@ -247,8 +248,20 @@ class MarketTruthSupervisor:
if not should_refresh and self._last_news_refresh_ts is not None: if not should_refresh and self._last_news_refresh_ts is not None:
should_refresh = (now - self._last_news_refresh_ts).total_seconds() >= refresh_interval should_refresh = (now - self._last_news_refresh_ts).total_seconds() >= refresh_interval
if should_refresh: if should_refresh:
self._news_snapshot = self.news_provider.fetch_snapshot(now=now) fresh_snapshot = self.news_provider.fetch_snapshot(now=now)
self._last_news_refresh_ts = now self._last_news_refresh_ts = now
if fresh_snapshot.freshness_code != "news_provider_error":
self._news_snapshot = fresh_snapshot
self.repository.upsert_news_provider_snapshot(**fresh_snapshot.to_dict())
elif self._news_snapshot is None:
cached_row = self.repository.fetch_latest_news_provider_snapshot()
self._news_snapshot = build_snapshot_from_row(cached_row)
if self._news_snapshot is not None:
return self.news_provider.evaluate_cached_snapshot(snapshot=self._news_snapshot, now=now)
return fresh_snapshot
return self.news_provider.evaluate_cached_snapshot(snapshot=self._news_snapshot, now=now)
if self._news_snapshot is None:
self._news_snapshot = build_snapshot_from_row(self.repository.fetch_latest_news_provider_snapshot())
return self.news_provider.evaluate_cached_snapshot(snapshot=self._news_snapshot, now=now) return self.news_provider.evaluate_cached_snapshot(snapshot=self._news_snapshot, now=now)
def _record_runtime_incidents( def _record_runtime_incidents(
+207 -72
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from os import getenv from os import getenv
@@ -10,6 +12,8 @@ from src.config.settings import settings
NEWS_CODE_ACTIVE = "news_window_active" NEWS_CODE_ACTIVE = "news_window_active"
NEWS_CODE_INACTIVE = "news_window_inactive" NEWS_CODE_INACTIVE = "news_window_inactive"
NEWS_CODE_PROVIDER_FRESH = "news_provider_fresh"
NEWS_CODE_PROVIDER_CACHED = "news_provider_cached"
NEWS_CODE_PROVIDER_STALE = "news_provider_stale" NEWS_CODE_PROVIDER_STALE = "news_provider_stale"
NEWS_CODE_PROVIDER_ERROR = "news_provider_error" NEWS_CODE_PROVIDER_ERROR = "news_provider_error"
@@ -42,6 +46,7 @@ class NewsProviderSnapshot:
is_active: bool is_active: bool
code: str code: str
error: Optional[str] error: Optional[str]
raw_payload: dict
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -53,16 +58,16 @@ class NewsProviderSnapshot:
"is_active": self.is_active, "is_active": self.is_active,
"code": self.code, "code": self.code,
"error": self.error, "error": self.error,
"raw_payload": dict(self.raw_payload or {}),
} }
class EconomicCalendarProvider: class BaseNewsProvider:
provider_name = "forex_factory_public_feed" provider_name = "base_news_provider"
def __init__( def __init__(
self, self,
*, *,
url: Optional[str] = None,
timeout: Optional[int] = None, timeout: Optional[int] = None,
pre_window_minutes: Optional[int] = None, pre_window_minutes: Optional[int] = None,
post_window_minutes: Optional[int] = None, post_window_minutes: Optional[int] = None,
@@ -70,7 +75,6 @@ class EconomicCalendarProvider:
high_impact_only: Optional[bool] = None, high_impact_only: Optional[bool] = None,
proxy_url: Optional[str] = None, proxy_url: Optional[str] = None,
) -> None: ) -> None:
self.url = url or settings.news_provider_url
self.timeout = timeout or settings.news_provider_timeout self.timeout = timeout or settings.news_provider_timeout
self.pre_window_minutes = pre_window_minutes if pre_window_minutes is not None else settings.news_event_pre_window_minutes self.pre_window_minutes = pre_window_minutes if pre_window_minutes is not None else settings.news_event_pre_window_minutes
self.post_window_minutes = post_window_minutes if post_window_minutes is not None else settings.news_event_post_window_minutes self.post_window_minutes = post_window_minutes if post_window_minutes is not None else settings.news_event_post_window_minutes
@@ -79,9 +83,22 @@ class EconomicCalendarProvider:
self.proxy_url = proxy_url or settings.market_truth_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or "" self.proxy_url = proxy_url or settings.market_truth_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or ""
def fetch_snapshot(self, *, now: Optional[datetime] = None, rows: Optional[list[dict]] = None) -> NewsProviderSnapshot: def fetch_snapshot(self, *, now: Optional[datetime] = None, rows: Optional[list[dict]] = None) -> NewsProviderSnapshot:
now = _ensure_aware(now or datetime.now(timezone.utc)) observed_now = _ensure_aware(now or datetime.now(timezone.utc))
try: try:
source_rows = list(rows) if rows is not None else self._fetch_rows() source_rows = list(rows) if rows is not None else self._fetch_rows()
events = self._parse_events(source_rows)
active_events = [event.to_dict() for event in events if self._event_is_active(event, now=observed_now)]
return NewsProviderSnapshot(
provider_name=self.provider_name,
fetched_ts=observed_now,
freshness_code=NEWS_CODE_PROVIDER_FRESH,
active_events=active_events,
event_count=len(events),
is_active=bool(active_events),
code=NEWS_CODE_ACTIVE if active_events else NEWS_CODE_INACTIVE,
error=None,
raw_payload={"rows": source_rows},
)
except Exception as exc: except Exception as exc:
return NewsProviderSnapshot( return NewsProviderSnapshot(
provider_name=self.provider_name, provider_name=self.provider_name,
@@ -92,62 +109,38 @@ class EconomicCalendarProvider:
is_active=False, is_active=False,
code=NEWS_CODE_PROVIDER_ERROR, code=NEWS_CODE_PROVIDER_ERROR,
error=str(exc), error=str(exc),
raw_payload={},
) )
fetched_ts = now
events = self._parse_events(source_rows)
active_events = [event for event in events if self._event_is_active(event, now=now)]
freshness_code = NEWS_CODE_PROVIDER_STALE if self.max_age_seconds <= 0 else "news_provider_fresh"
return NewsProviderSnapshot(
provider_name=self.provider_name,
fetched_ts=fetched_ts,
freshness_code=freshness_code,
active_events=[event.to_dict() for event in active_events],
event_count=len(events),
is_active=bool(active_events),
code=NEWS_CODE_ACTIVE if active_events else NEWS_CODE_INACTIVE,
error=None,
)
def evaluate_cached_snapshot( def _event_is_active(self, event: NewsEvent, *, now: datetime) -> bool:
self, window_start = event.event_ts - timedelta(minutes=max(self.pre_window_minutes, 0))
*, window_end = event.event_ts + timedelta(minutes=max(self.post_window_minutes, 0))
snapshot: Optional[NewsProviderSnapshot], return window_start <= now <= window_end
now: Optional[datetime] = None,
) -> NewsProviderSnapshot: def _client_kwargs(self) -> dict:
now = _ensure_aware(now or datetime.now(timezone.utc)) kwargs = {"timeout": self.timeout, "follow_redirects": True}
if snapshot is None: if self.proxy_url:
return NewsProviderSnapshot( kwargs["proxy"] = self.proxy_url
provider_name=self.provider_name, kwargs["trust_env"] = False
fetched_ts=None, return kwargs
freshness_code=NEWS_CODE_PROVIDER_STALE,
active_events=[],
event_count=0,
is_active=False,
code=NEWS_CODE_PROVIDER_STALE,
error="snapshot_missing",
)
fetched_ts = snapshot.fetched_ts
age_seconds = (now - fetched_ts).total_seconds() if fetched_ts else None
if age_seconds is None or age_seconds > self.max_age_seconds:
return NewsProviderSnapshot(
provider_name=snapshot.provider_name,
fetched_ts=fetched_ts,
freshness_code=NEWS_CODE_PROVIDER_STALE,
active_events=list(snapshot.active_events),
event_count=snapshot.event_count,
is_active=snapshot.is_active,
code=NEWS_CODE_PROVIDER_STALE,
error=snapshot.error,
)
return snapshot
def _fetch_rows(self) -> list[dict]: def _fetch_rows(self) -> list[dict]:
client_kwargs = {"timeout": self.timeout, "follow_redirects": True} raise NotImplementedError
if self.proxy_url:
client_kwargs["proxy"] = self.proxy_url def _parse_events(self, rows: list[dict]) -> list[NewsEvent]:
client_kwargs["trust_env"] = False raise NotImplementedError
with httpx.Client(**client_kwargs) as client:
response = client.get(self.url, headers={"User-Agent": "ai-ict/market-truth"})
class EconomicCalendarProvider(BaseNewsProvider):
provider_name = "forex_factory_public_feed"
def __init__(self, *, url: Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs)
self.url = url or settings.news_provider_url
def _fetch_rows(self) -> list[dict]:
with httpx.Client(**self._client_kwargs()) as client:
response = client.get(self.url, headers={"User-Agent": "ai-ict/news"})
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
if isinstance(payload, list): if isinstance(payload, list):
@@ -158,36 +151,178 @@ class EconomicCalendarProvider:
raise ValueError("unsupported_news_provider_payload") raise ValueError("unsupported_news_provider_payload")
def _parse_events(self, rows: list[dict]) -> list[NewsEvent]: def _parse_events(self, rows: list[dict]) -> list[NewsEvent]:
events: list[NewsEvent] = [] events = []
for index, row in enumerate(rows): for index, row in enumerate(rows):
impact = str(row.get("impact") or row.get("importance") or row.get("impactTitle") or "").strip() impact = normalize_impact(row.get("impact") or row.get("importance") or row.get("impactTitle"))
if self.high_impact_only and impact.lower() not in {"high", "high impact", "red"}: if self.high_impact_only and impact != "High":
continue continue
event_ts = _parse_event_datetime(row) event_ts = _parse_event_datetime(row)
if event_ts is None: if event_ts is None:
continue continue
event_id = str(row.get("id") or row.get("eventId") or f"event:{index}")
title = str(row.get("title") or row.get("event") or row.get("name") or "macro_event")
country = str(row.get("country") or row.get("currency") or "unknown")
events.append( events.append(
NewsEvent( NewsEvent(
event_id=event_id, event_id=str(row.get("id") or row.get("eventId") or f"ff:{index}"),
title=title, title=str(row.get("title") or row.get("event") or row.get("name") or "macro_event"),
country=country, country=str(row.get("country") or row.get("currency") or "unknown"),
impact=impact or "unknown", impact=impact or "Unknown",
event_ts=event_ts, event_ts=event_ts,
) )
) )
return events return events
def _event_is_active(self, event: NewsEvent, *, now: datetime) -> bool:
window_start = event.event_ts - timedelta(minutes=max(self.pre_window_minutes, 0)) class TradingEconomicsCalendarProvider(BaseNewsProvider):
window_end = event.event_ts + timedelta(minutes=max(self.post_window_minutes, 0)) provider_name = "tradingeconomics_snapshot"
return window_start <= now <= window_end
def __init__(self, *, base_url: Optional[str] = None, api_key: Optional[str] = None, **kwargs) -> None:
super().__init__(**kwargs)
self.base_url = (base_url or settings.news_provider_tradingeconomics_base_url).rstrip("/")
self.api_key = str(api_key or settings.news_provider_tradingeconomics_api_key or "guest:guest").strip()
def _fetch_rows(self) -> list[dict]:
params = {
"c": self.api_key,
"f": "json",
}
if self.high_impact_only:
params["importance"] = "3"
with httpx.Client(**self._client_kwargs()) as client:
response = client.get(self.base_url, params=params, headers={"User-Agent": "ai-ict/news"})
response.raise_for_status()
payload = response.json()
if isinstance(payload, list):
return [dict(item) for item in payload if isinstance(item, dict)]
raise ValueError("unsupported_tradingeconomics_payload")
def _parse_events(self, rows: list[dict]) -> list[NewsEvent]:
events = []
for index, row in enumerate(rows):
impact = normalize_impact(row.get("Importance") or row.get("importance"))
if self.high_impact_only and impact != "High":
continue
event_ts = _parse_event_datetime(row)
if event_ts is None:
continue
events.append(
NewsEvent(
event_id=str(row.get("CalendarId") or row.get("EventID") or f"te:{index}"),
title=str(row.get("Event") or row.get("Category") or "macro_event"),
country=str(row.get("Country") or row.get("Currency") or "unknown"),
impact=impact or "Unknown",
event_ts=event_ts,
)
)
return events
class NewsProviderChain:
def __init__(
self,
providers: Optional[list[BaseNewsProvider]] = None,
*,
cache_max_age_seconds: Optional[int] = None,
) -> None:
self.providers = list(providers or build_default_news_providers())
self.cache_max_age_seconds = cache_max_age_seconds or settings.news_provider_cache_max_age_seconds
def fetch_snapshot(self, *, now: Optional[datetime] = None) -> NewsProviderSnapshot:
observed_now = _ensure_aware(now or datetime.now(timezone.utc))
errors = []
for provider in self.providers:
snapshot = provider.fetch_snapshot(now=observed_now)
if snapshot.freshness_code != NEWS_CODE_PROVIDER_ERROR:
return snapshot
errors.append(f"{provider.provider_name}:{snapshot.error}")
return NewsProviderSnapshot(
provider_name="news_provider_chain",
fetched_ts=None,
freshness_code=NEWS_CODE_PROVIDER_ERROR,
active_events=[],
event_count=0,
is_active=False,
code=NEWS_CODE_PROVIDER_ERROR,
error=" | ".join(errors),
raw_payload={},
)
def evaluate_cached_snapshot(self, *, snapshot: Optional[NewsProviderSnapshot], now: Optional[datetime] = None) -> NewsProviderSnapshot:
observed_now = _ensure_aware(now or datetime.now(timezone.utc))
if snapshot is None or snapshot.fetched_ts is None:
return NewsProviderSnapshot(
provider_name="news_provider_chain",
fetched_ts=None,
freshness_code=NEWS_CODE_PROVIDER_STALE,
active_events=[],
event_count=0,
is_active=False,
code=NEWS_CODE_PROVIDER_STALE,
error="snapshot_missing",
raw_payload={},
)
age_seconds = max((observed_now - snapshot.fetched_ts).total_seconds(), 0.0)
if age_seconds <= max(self.cache_max_age_seconds, 1):
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 {}),
)
return NewsProviderSnapshot(
provider_name=snapshot.provider_name,
fetched_ts=snapshot.fetched_ts,
freshness_code=NEWS_CODE_PROVIDER_STALE,
active_events=list(snapshot.active_events),
event_count=snapshot.event_count,
is_active=snapshot.is_active,
code=NEWS_CODE_PROVIDER_STALE,
error=snapshot.error,
raw_payload=dict(snapshot.raw_payload or {}),
)
def build_default_news_providers() -> list[BaseNewsProvider]:
providers: list[BaseNewsProvider] = []
if settings.news_provider_tradingeconomics_enabled:
providers.append(TradingEconomicsCalendarProvider())
providers.append(EconomicCalendarProvider())
return providers
def build_snapshot_from_row(row: Optional[dict]) -> Optional[NewsProviderSnapshot]:
if not row:
return None
fetched_ts = row.get("fetched_ts")
return NewsProviderSnapshot(
provider_name=str(row.get("provider_name") or "unknown"),
fetched_ts=_ensure_aware(fetched_ts) if fetched_ts else None,
freshness_code=str(row.get("freshness_code") or NEWS_CODE_PROVIDER_STALE),
active_events=list(row.get("active_events") or []),
event_count=int(row.get("event_count") or 0),
is_active=bool(row.get("is_active")),
code=str(row.get("code") or NEWS_CODE_PROVIDER_STALE),
error=row.get("error"),
raw_payload=dict(row.get("raw_payload") or {}),
)
def normalize_impact(value) -> str:
text = str(value or "").strip().lower()
if text in {"3", "high", "high impact", "red"}:
return "High"
if text in {"2", "medium", "orange"}:
return "Medium"
if text in {"1", "low", "yellow"}:
return "Low"
return "Unknown"
def _parse_event_datetime(row: dict) -> Optional[datetime]: def _parse_event_datetime(row: dict) -> Optional[datetime]:
for key in ("timestamp", "dateUtc", "date", "datetime", "eventTs"): for key in ("timestamp", "dateUtc", "date", "datetime", "eventTs", "Date"):
value = row.get(key) value = row.get(key)
if value in (None, ""): if value in (None, ""):
continue continue
+147 -1
View File
@@ -13,11 +13,13 @@ from src.services.market_state import (
MarketStateService, MarketStateService,
MarketTruthRuntimeState, MarketTruthRuntimeState,
MarketTruthSupervisor, MarketTruthSupervisor,
NewsProviderChain,
STREAM_CODE_CONNECTED, STREAM_CODE_CONNECTED,
STREAM_CODE_DISCONNECTED, STREAM_CODE_DISCONNECTED,
TradingEconomicsCalendarProvider,
build_data_gap_state, build_data_gap_state,
) )
from src.services.market_state.news_event_provider import NewsProviderSnapshot 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): class MarketDataStreamServiceTests(unittest.TestCase):
@@ -100,6 +102,35 @@ class EconomicCalendarProviderTests(unittest.TestCase):
self.assertEqual(snapshot.code, "news_window_active") self.assertEqual(snapshot.code, "news_window_active")
self.assertEqual(snapshot.event_count, 1) 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): class MarketTruthSupervisorTests(unittest.TestCase):
def test_supervisor_runs_rest_fallback_and_records_incident(self) -> None: def test_supervisor_runs_rest_fallback_and_records_incident(self) -> None:
@@ -139,6 +170,34 @@ class MarketTruthSupervisorTests(unittest.TestCase):
self.assertEqual(state.stream_state["code"], STREAM_CODE_DISCONNECTED) self.assertEqual(state.stream_state["code"], STREAM_CODE_DISCONNECTED)
self.assertTrue(any(item["incident_code"] == "rest_backfill_failed" for item in repository.incidents)) 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: def test_market_state_service_uses_supervisor_runtime_state(self) -> None:
now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc) now = datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
repository = _SupervisorRepository(now=now) repository = _SupervisorRepository(now=now)
@@ -167,6 +226,7 @@ class _SupervisorRepository:
"close": 64010.0, "close": 64010.0,
} }
self.incidents: list[dict] = [] self.incidents: list[dict] = []
self.cached_news_snapshot = None
def fetch_active_instruments_by_symbols(self, symbols: list[str]) -> 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 [] return [{"id": 1, "symbol": "BTC-USDT", "active": True}] if "BTC-USDT" in symbols else []
@@ -190,6 +250,13 @@ class _SupervisorRepository:
self.incidents.append(dict(kwargs)) self.incidents.append(dict(kwargs))
return len(self.incidents) 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: class _FallbackCandleRepository:
def __init__(self, repository: _SupervisorRepository) -> None: def __init__(self, repository: _SupervisorRepository) -> None:
@@ -290,12 +357,90 @@ class _InactiveNewsProvider:
is_active=False, is_active=False,
code="news_window_inactive", code="news_window_inactive",
error=None, error=None,
raw_payload={},
) )
def evaluate_cached_snapshot(self, *, snapshot, now: datetime): def evaluate_cached_snapshot(self, *, snapshot, now: datetime):
return snapshot 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: class _StaticSupervisor:
def __init__(self, now: datetime) -> None: def __init__(self, now: datetime) -> None:
self.now = now self.now = now
@@ -336,6 +481,7 @@ class _StaticSupervisor:
is_active=False, is_active=False,
code="news_window_inactive", code="news_window_inactive",
error=None, error=None,
raw_payload={},
), ),
) )