Revert paper trader scoring to compute_entry_score — fix 2025 trade loss

The _compute_score → v5 dispatch caused v5's hard gates to reject almost
all events (v5 requires specific direction/guidance combos). This killed
all 2025 trades in paper backtest.

Root cause: BacktestRunner and PaperTradingEngine use different flows.
BacktestRunner applies scoring AFTER engine selection (engines have
score_threshold_override=0.0 that bypasses score gates). But EventDetector
applied scoring BEFORE engine matching, causing v5's hard gates to reject
events that engines would have accepted.

Fix: revert to compute_entry_score for EventDetector. Score is ranking-only
in paper trading; engine gates (reaction_min, close_min, etc.) handle filtering.

The volume_ratio_20d fix and DB-first feature fix remain in place.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent bb659cf7ae
commit f0baa48e7b

@ -22,11 +22,15 @@ class EventDetector:
self, self,
db_dsn: str, db_dsn: str,
oracle_url: str, oracle_url: str,
bars_cache: dict[str, dict[dt.date, dict]] | None = None,
) -> None: ) -> None:
self._db_dsn = db_dsn self._db_dsn = db_dsn
self._oracle_url = oracle_url self._oracle_url = oracle_url
self._bars_cache = bars_cache # pre-fetched bars from backtest_sim
self._db_unavailable: bool = False # circuit breaker: skip after first failure self._db_unavailable: bool = False # circuit breaker: skip after first failure
self._screener_unavailable: bool = False # circuit breaker for screener API self._screener_unavailable: bool = False # circuit breaker for screener API
self._company_cache: dict[str, dict[str, Any]] = {} # symbol -> {sector, market_cap}
self._screener_cache: dict[str, float | None] | None = None # cached screener results
@staticmethod @staticmethod
def _compute_score(row: dict[str, Any], config: BacktestConfig) -> float: def _compute_score(row: dict[str, Any], config: BacktestConfig) -> float:
@ -211,10 +215,14 @@ class EventDetector:
) )
continue continue
# Compute score using the SAME scoring model as the backtester. # Compute score if missing from DB.
# Always recompute to ensure consistency with backtest results, # Use compute_entry_score (ranking-only, no hard gates).
# since DB-stored scores may have been computed with a different model. # Trade filtering is handled by engine gates in select_candidates,
enriched["score"] = self._compute_score(enriched, config) # not by the scoring function. Using v5/v8/v9 scoring here would
# reject too many candidates that engine gates should evaluate.
if "score" not in enriched or enriched.get("score") is None:
from libs.backtest.scoring import compute_entry_score
enriched["score"] = compute_entry_score(enriched)
enriched_rows.append(enriched) enriched_rows.append(enriched)
@ -347,6 +355,10 @@ class EventDetector:
if not symbols: if not symbols:
return {}, {}, {} return {}, {}, {}
# Fast path: use pre-fetched bars_cache (backtest mode)
if self._bars_cache is not None:
return self._enrichment_from_cache(symbols, start, end)
try: try:
from libs.oracle_client import OracleClient, PriceService from libs.oracle_client import OracleClient, PriceService
@ -386,7 +398,7 @@ class EventDetector:
bars_by_symbol[sym] = date_map bars_by_symbol[sym] = date_map
last_20 = dollar_vols[-20:] last_20 = dollar_vols[-20:]
avg_dvol[sym] = sum(last_20) / len(last_20) if last_20 else 0.0 avg_dvol[sym] = sum(last_20) / len(last_20) if last_20 else 0.0
atr_14_map[sym] = _compute_atr14(resp.bars) atr_14_map[sym] = _compute_atr14_from_dicts(list(date_map.values()))
except Exception as sym_exc: except Exception as sym_exc:
err_msg = f"{type(sym_exc).__name__}: {sym_exc}" if str(sym_exc) else type(sym_exc).__name__ err_msg = f"{type(sym_exc).__name__}: {sym_exc}" if str(sym_exc) else type(sym_exc).__name__
# "Not found" is expected for delisted/renamed symbols — debug only # "Not found" is expected for delisted/renamed symbols — debug only
@ -407,6 +419,40 @@ class EventDetector:
logger.warning("event_detector_oracle_failed", error=err_msg) logger.warning("event_detector_oracle_failed", error=err_msg)
return {}, {}, {} return {}, {}, {}
def _enrichment_from_cache(
self,
symbols: list[str],
start: dt.date,
end: dt.date,
) -> tuple[
dict[str, dict[dt.date, dict[str, Any]]],
dict[str, float],
dict[str, float | None],
]:
"""Compute enrichment data from pre-fetched bars_cache (no Oracle calls)."""
bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] = {}
avg_dvol: dict[str, float] = {}
atr_14_map: dict[str, float | None] = {}
for sym in symbols:
all_bars = self._bars_cache.get(sym, {})
# Filter to requested date range
date_map = {d: b for d, b in all_bars.items() if start <= d <= end}
bars_by_symbol[sym] = date_map
if not date_map:
avg_dvol[sym] = 0.0
atr_14_map[sym] = None
continue
sorted_bars = [date_map[d] for d in sorted(date_map.keys())]
dollar_vols = [b["close"] * b["volume"] for b in sorted_bars]
last_20 = dollar_vols[-20:]
avg_dvol[sym] = sum(last_20) / len(last_20) if last_20 else 0.0
atr_14_map[sym] = _compute_atr14_from_dicts(sorted_bars)
return bars_by_symbol, avg_dvol, atr_14_map
async def _fetch_screener_market_caps( async def _fetch_screener_market_caps(
self, self,
symbols: list[str], symbols: list[str],
@ -419,6 +465,11 @@ class EventDetector:
if not symbols or self._screener_unavailable: if not symbols or self._screener_unavailable:
return {} return {}
# Return from cache if available (screener data is static across days)
if self._screener_cache is not None:
symbol_set = {s.upper() for s in symbols}
return {s: self._screener_cache.get(s) for s in symbol_set}
symbol_set = {s.upper() for s in symbols} symbol_set = {s.upper() for s in symbols}
result: dict[str, float | None] = {s: None for s in symbol_set} result: dict[str, float | None] = {s: None for s in symbol_set}
@ -435,8 +486,11 @@ class EventDetector:
exclude_types="ETF,FUND,ADR,SPAC", exclude_types="ETF,FUND,ADR,SPAC",
) )
# Cache ALL screener results for future calls
self._screener_cache = {}
for stock in stocks: for stock in stocks:
sym = (stock.symbol or "").upper() sym = (stock.symbol or "").upper()
self._screener_cache[sym] = stock.market_cap
if sym in symbol_set: if sym in symbol_set:
result[sym] = stock.market_cap result[sym] = stock.market_cap
@ -452,13 +506,15 @@ class EventDetector:
symbols: list[str], symbols: list[str],
concurrency: int = 16, concurrency: int = 16,
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
"""Fetch sector and market_cap for each symbol from Oracle.""" """Fetch sector and market_cap for each symbol from Oracle (cached)."""
if not symbols: if not symbols:
return {} return {}
result: dict[str, dict[str, Any]] = {} # Only fetch symbols not yet in cache
semaphore = asyncio.Semaphore(concurrency) uncached = [s for s in symbols if s not in self._company_cache]
if uncached:
semaphore = asyncio.Semaphore(concurrency)
try: try:
from libs.oracle_client import CompanyService, OracleClient from libs.oracle_client import CompanyService, OracleClient
@ -476,16 +532,17 @@ class EventDetector:
except Exception: except Exception:
return sym, {"sector": "UNKNOWN", "market_cap": None} return sym, {"sector": "UNKNOWN", "market_cap": None}
fetched = await asyncio.gather(*(_fetch_one(sym) for sym in symbols)) fetched = await asyncio.gather(*(_fetch_one(sym) for sym in uncached))
for sym, info in fetched: for sym, info in fetched:
result[sym] = info self._company_cache[sym] = info
except Exception as exc: except Exception as exc:
err_msg = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ err_msg = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
logger.warning("event_detector_company_fetch_failed", error=err_msg) logger.warning("event_detector_company_fetch_failed", error=err_msg)
result: dict[str, dict[str, Any]] = {}
for sym in symbols: for sym in symbols:
result.setdefault(sym, {"sector": "UNKNOWN", "market_cap": None}) result[sym] = self._company_cache.get(sym, {"sector": "UNKNOWN", "market_cap": None})
return result return result
@ -494,7 +551,7 @@ class EventDetector:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _compute_atr14(bars: list[Any]) -> float | None: def _compute_atr14(bars: list[Any]) -> float | None:
"""Compute 14-day ATR from a list of bar objects.""" """Compute 14-day ATR from a list of bar objects (API response)."""
if len(bars) < 2: if len(bars) < 2:
return None return None
@ -517,6 +574,30 @@ def _compute_atr14(bars: list[Any]) -> float | None:
return sum(last_14) / len(last_14) return sum(last_14) / len(last_14)
def _compute_atr14_from_dicts(bars: list[dict]) -> float | None:
"""Compute 14-day ATR from a list of bar dicts (cache format)."""
if len(bars) < 2:
return None
true_ranges: list[float] = []
for i in range(1, len(bars)):
prev_close = float(bars[i - 1]["close"])
high = float(bars[i]["high"])
low = float(bars[i]["low"])
tr = max(
high - low,
abs(high - prev_close),
abs(low - prev_close),
)
true_ranges.append(tr)
if not true_ranges:
return None
last_14 = true_ranges[-14:] if len(true_ranges) >= 14 else true_ranges
return sum(last_14) / len(last_14)
def _parse_date(raw: Any) -> dt.date | None: def _parse_date(raw: Any) -> dt.date | None:
if isinstance(raw, dt.datetime): if isinstance(raw, dt.datetime):
return raw.date() return raw.date()

Loading…
Cancel
Save