"""Attention filtering service — shared by BacktestRunner and PaperTradingEngine. Fetches event attention data (Wikipedia pageviews, GDELT news, entity resolver) from Stock Oracle and applies engine-level gates + rescoring. """ from __future__ import annotations import datetime as dt from typing import Any import requests from libs.backtest.domain import Candidate, SignalConfig from libs.backtest.selector import rank_candidates from libs.common.logging import get_logger logger = get_logger(__name__) # Lazy import to avoid circular deps EventAttentionResponse = None def _get_attention_response_class(): global EventAttentionResponse if EventAttentionResponse is None: from libs.oracle_client.models import EventAttentionResponse as _cls EventAttentionResponse = _cls return EventAttentionResponse class AttentionFilterService: """Fetches and caches event attention data, applies gates and rescoring.""" def __init__(self, oracle_url: str, scoring_model: str, timeout: float = 30.0) -> None: self._base_url = oracle_url.rstrip("/") if oracle_url else "" self._scoring_model = scoring_model self._timeout = float(timeout) self._cache: dict[tuple[str, dt.date | None], Any] = {} self._session: requests.Session | None = None self._service_unavailable = False if self._base_url: self._session = requests.Session() def close(self) -> None: if self._session: self._session.close() self._session = None # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def engine_requires_attention(self, engine: Any) -> bool: """Return True if this engine needs attention data for filtering/rescoring.""" _ATTENTION_MODELS = { "return_max_long_v1", "return_max_long_v2", "return_max_long_v3", "return_max_long_v4", "return_max_long_v5", "return_max_long_v6", "return_max_long_v7", "return_max_long_v8", "return_max_long_v9", "return_max_long_v9g", "return_max_long_v10", } if self._scoring_model in _ATTENTION_MODELS and engine.direction != "short_only": return True return any( value is not None for value in ( engine.attention_min_wiki_spike_10d, engine.attention_min_wiki_zscore_20d, engine.attention_max_wiki_spike_10d, engine.attention_max_wiki_zscore_20d, engine.attention_min_article_count_3d, engine.attention_min_us_article_count_3d, engine.attention_min_resolver_confidence, ) ) def engine_requires_attention_data(self, engine: Any) -> bool: """Return True when the engine has minimum-style gates (need actual data).""" return any( value is not None for value in ( engine.attention_min_wiki_spike_10d, engine.attention_min_wiki_zscore_20d, engine.attention_min_article_count_3d, engine.attention_min_us_article_count_3d, engine.attention_min_resolver_confidence, ) ) def apply_filters( self, candidates: list[Candidate], engine: Any, signal_config: SignalConfig, ) -> list[Candidate]: """Apply attention gates and rescoring — matches BacktestRunner._apply_attention_filters.""" if not candidates or not self.engine_requires_attention(engine): return candidates[: signal_config.max_candidates_per_day] filtered: list[Candidate] = [] requires_data = self.engine_requires_attention_data(engine) threshold = ( engine.score_threshold_override if engine.score_threshold_override is not None else signal_config.score_threshold ) for candidate in candidates: attention = self._get_event_attention(candidate) if attention is None: if not requires_data: filtered.append(candidate) continue if not self._passes_filters(engine, attention): continue enriched = self._attach_features(candidate, attention) enriched = self._maybe_rescore(enriched) if enriched.score >= threshold: filtered.append(enriched) filtered = rank_candidates(filtered, signal_config.ranking_fields) return filtered[: signal_config.max_candidates_per_day] # ------------------------------------------------------------------ # Internal # ------------------------------------------------------------------ def _get_event_attention(self, candidate: Candidate) -> Any: event_date = candidate.event_date or candidate.reaction_date cache_key = (candidate.symbol, event_date) if cache_key in self._cache: return self._cache[cache_key] if self._service_unavailable: self._cache[cache_key] = None return None if not self._base_url or self._session is None: self._cache[cache_key] = None return None cls = _get_attention_response_class() try: response = self._session.get( f"{self._base_url}/api/v1/attention/event/{candidate.symbol}", params={"event_date": event_date.isoformat() if event_date else ""}, timeout=self._timeout, ) if response.status_code >= 400: logger.debug( "attention_fetch_failed", symbol=candidate.symbol, status_code=response.status_code, ) self._cache[cache_key] = None return None payload = cls.model_validate(response.json()) except requests.RequestException as exc: logger.warning( "attention_fetch_error", symbol=candidate.symbol, error=str(exc), ) self._service_unavailable = True logger.info( "attention_service_disabled_for_run", error_type=type(exc).__name__, ) self._cache[cache_key] = None return None except Exception as exc: logger.warning( "attention_fetch_error", symbol=candidate.symbol, error=str(exc), ) self._cache[cache_key] = None return None self._cache[cache_key] = payload return payload def _passes_filters(self, engine: Any, attention: Any) -> bool: if ( engine.attention_min_wiki_spike_10d is not None and (attention.wiki.spike_10d is None or attention.wiki.spike_10d < engine.attention_min_wiki_spike_10d) ): return False if ( engine.attention_min_wiki_zscore_20d is not None and (attention.wiki.zscore_20d is None or attention.wiki.zscore_20d < engine.attention_min_wiki_zscore_20d) ): return False if ( engine.attention_max_wiki_spike_10d is not None and attention.wiki.spike_10d is not None and attention.wiki.spike_10d > engine.attention_max_wiki_spike_10d ): return False if ( engine.attention_max_wiki_zscore_20d is not None and attention.wiki.zscore_20d is not None and attention.wiki.zscore_20d > engine.attention_max_wiki_zscore_20d ): return False if ( engine.attention_min_article_count_3d is not None and attention.news.article_count_3d < engine.attention_min_article_count_3d ): return False if ( engine.attention_min_us_article_count_3d is not None and attention.news.us_article_count_3d < engine.attention_min_us_article_count_3d ): return False if ( engine.attention_min_resolver_confidence is not None and attention.entity.resolver_confidence < engine.attention_min_resolver_confidence ): return False return True def _attach_features(self, candidate: Candidate, attention: Any) -> Candidate: features = dict(candidate.features) features.update({ "attention_wiki_spike_10d": attention.wiki.spike_10d, "attention_wiki_zscore_20d": attention.wiki.zscore_20d, "attention_article_count_3d": attention.news.article_count_3d, "attention_us_article_count_3d": attention.news.us_article_count_3d, "attention_gdelt_status": attention.news.gdelt_status, "attention_resolver_confidence": attention.entity.resolver_confidence, }) return candidate.model_copy(update={"features": features}) def _maybe_rescore(self, candidate: Candidate) -> Candidate: _RESCORE_MODELS = { "return_max_long_v1", "return_max_long_v2", "return_max_long_v3", "return_max_long_v4", "return_max_long_v5", "return_max_long_v6", "return_max_long_v7", "return_max_long_v8", } if self._scoring_model not in _RESCORE_MODELS: return candidate from libs.backtest.scoring import ( compute_return_max_long_score, compute_return_max_long_score_v2, compute_return_max_long_score_v3, compute_return_max_long_score_v4, compute_return_max_long_score_v5, compute_return_max_long_score_v6, compute_return_max_long_score_v7, compute_return_max_long_score_v8, ) rescored_features = dict(candidate.features) rescored_features.update({ "event_type": candidate.event_type, "event_direction": rescored_features.get("event_direction"), }) _dispatch = { "return_max_long_v8": compute_return_max_long_score_v8, "return_max_long_v7": compute_return_max_long_score_v7, "return_max_long_v6": compute_return_max_long_score_v6, "return_max_long_v5": compute_return_max_long_score_v5, "return_max_long_v4": compute_return_max_long_score_v4, "return_max_long_v3": compute_return_max_long_score_v3, "return_max_long_v2": compute_return_max_long_score_v2, "return_max_long_v1": compute_return_max_long_score, } scoring_fn = _dispatch.get(self._scoring_model, compute_return_max_long_score) score = scoring_fn(rescored_features) return candidate.model_copy(update={"score": score})