From f0baa48e7b66b9ef58eac71f237f691d6d4e6a1d Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Tue, 24 Mar 2026 04:27:11 -0700 Subject: [PATCH] =?UTF-8?q?Revert=20paper=20trader=20scoring=20to=20comput?= =?UTF-8?q?e=5Fentry=5Fscore=20=E2=80=94=20fix=202025=20trade=20loss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/paper_trader/event_detector.py | 145 ++++++++++++++++++++++------ 1 file changed, 113 insertions(+), 32 deletions(-) diff --git a/apps/paper_trader/event_detector.py b/apps/paper_trader/event_detector.py index a7244bb..6772cab 100644 --- a/apps/paper_trader/event_detector.py +++ b/apps/paper_trader/event_detector.py @@ -22,11 +22,15 @@ class EventDetector: self, db_dsn: str, oracle_url: str, + bars_cache: dict[str, dict[dt.date, dict]] | None = None, ) -> None: self._db_dsn = db_dsn 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._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 def _compute_score(row: dict[str, Any], config: BacktestConfig) -> float: @@ -211,10 +215,14 @@ class EventDetector: ) continue - # Compute score using the SAME scoring model as the backtester. - # Always recompute to ensure consistency with backtest results, - # since DB-stored scores may have been computed with a different model. - enriched["score"] = self._compute_score(enriched, config) + # Compute score if missing from DB. + # Use compute_entry_score (ranking-only, no hard gates). + # Trade filtering is handled by engine gates in select_candidates, + # 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) @@ -347,6 +355,10 @@ class EventDetector: if not symbols: 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: from libs.oracle_client import OracleClient, PriceService @@ -386,7 +398,7 @@ class EventDetector: bars_by_symbol[sym] = date_map 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(resp.bars) + atr_14_map[sym] = _compute_atr14_from_dicts(list(date_map.values())) except Exception as sym_exc: 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 @@ -407,6 +419,40 @@ class EventDetector: logger.warning("event_detector_oracle_failed", error=err_msg) 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( self, symbols: list[str], @@ -419,6 +465,11 @@ class EventDetector: if not symbols or self._screener_unavailable: 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} result: dict[str, float | None] = {s: None for s in symbol_set} @@ -435,8 +486,11 @@ class EventDetector: exclude_types="ETF,FUND,ADR,SPAC", ) + # Cache ALL screener results for future calls + self._screener_cache = {} for stock in stocks: sym = (stock.symbol or "").upper() + self._screener_cache[sym] = stock.market_cap if sym in symbol_set: result[sym] = stock.market_cap @@ -452,40 +506,43 @@ class EventDetector: symbols: list[str], concurrency: int = 16, ) -> 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: return {} - result: dict[str, dict[str, Any]] = {} - semaphore = asyncio.Semaphore(concurrency) - - try: - from libs.oracle_client import CompanyService, OracleClient + # Only fetch symbols not yet in cache + uncached = [s for s in symbols if s not in self._company_cache] - async with OracleClient(base_url=self._oracle_url) as client: - company_svc = CompanyService(client) + if uncached: + semaphore = asyncio.Semaphore(concurrency) + try: + from libs.oracle_client import CompanyService, OracleClient + + async with OracleClient(base_url=self._oracle_url) as client: + company_svc = CompanyService(client) + + async def _fetch_one(sym: str) -> tuple[str, dict[str, Any]]: + async with semaphore: + try: + info = await company_svc.get_company(sym) + return sym, { + "sector": info.sector or "UNKNOWN", + "market_cap": info.market_cap, + } + except Exception: + return sym, {"sector": "UNKNOWN", "market_cap": None} - async def _fetch_one(sym: str) -> tuple[str, dict[str, Any]]: - async with semaphore: - try: - info = await company_svc.get_company(sym) - return sym, { - "sector": info.sector or "UNKNOWN", - "market_cap": info.market_cap, - } - except Exception: - return sym, {"sector": "UNKNOWN", "market_cap": None} - - fetched = await asyncio.gather(*(_fetch_one(sym) for sym in symbols)) - for sym, info in fetched: - result[sym] = info + fetched = await asyncio.gather(*(_fetch_one(sym) for sym in uncached)) + for sym, info in fetched: + self._company_cache[sym] = info - except Exception as exc: - err_msg = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ - logger.warning("event_detector_company_fetch_failed", error=err_msg) + except Exception as exc: + err_msg = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ + logger.warning("event_detector_company_fetch_failed", error=err_msg) + result: dict[str, dict[str, Any]] = {} 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 @@ -494,7 +551,7 @@ class EventDetector: # ------------------------------------------------------------------ # 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: return None @@ -517,6 +574,30 @@ def _compute_atr14(bars: list[Any]) -> float | None: 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: if isinstance(raw, dt.datetime): return raw.date()