Unify BacktestRunner and PaperTradingEngine trade decision logic
Phase 1-4 of engine unification to eliminate research/live divergence. Phase 1 — Scoring (event_detector.py): EventDetector now uses config's scoring_model (v5/v9 etc.) when event_v1 features are present (parse_confidence_overall not null). Falls back to compute_entry_score only for incomplete events. Phase 2 — Execution config (execution.py): Extracted build_effective_execution_config() as shared function. BacktestRunner delegates to it. PaperTradingEngine can now use identical per-engine overrides, adaptive exit, tiered targets. Phase 3 — Attention filtering (attention.py): New AttentionFilterService class extracted from BacktestRunner. Provides: engine_requires_attention, apply_filters, rescoring. BacktestRunner now delegates to this service. PaperTradingEngine can import and use the same service. Phase 4 — Gap cap (execution.py): check_next_open_gap_cap() shared function for next-open gap rejection. All 450 unit tests pass. Paper backtest verified working. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>main
parent
c747d5e4f0
commit
fb4fec7dac
@ -0,0 +1,252 @@
|
||||
"""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) -> None:
|
||||
self._base_url = oracle_url.rstrip("/") if oracle_url else ""
|
||||
self._scoring_model = scoring_model
|
||||
self._cache: dict[tuple[str, dt.date | None], Any] = {}
|
||||
self._session: requests.Session | None = None
|
||||
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 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=30,
|
||||
)
|
||||
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 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})
|
||||
Loading…
Reference in New Issue