Fix paper trader / backtester consistency: DB-first features + config-aware scoring

Three critical inconsistencies between BacktestRunner and PaperTradingEngine
that caused gate fixes to not work in paper trading:

1. DB feature values now take priority over Oracle recalculation
   - Previously: Oracle bars always recomputed reaction_day_return etc.
   - Now: if DB feature_json has the value, Oracle fallback is skipped
   - Root cause of PII bug: DB had react=-5.3% but Oracle recomputed +13.9%
     due to different date alignment, bypassing engine reaction_min gate

2. Scoring now uses config's scoring_model (v5/v8/v9/v10 etc.)
   - Previously: always used compute_entry_score() regardless of config
   - Now: _compute_score() dispatches to the correct scoring function
   - Ensures hard gates and weights match between backtest and paper trading

3. volume_ratio_20d field name consistency (from prior commit)

These fixes ensure paper trading results match backtester behavior,
making engine gate changes (reaction_min, close_min, etc.) effective
in both systems.

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

@ -28,6 +28,35 @@ class EventDetector:
self._db_unavailable: bool = False # circuit breaker: skip after first failure
self._screener_unavailable: bool = False # circuit breaker for screener API
@staticmethod
def _compute_score(row: dict[str, Any], config: BacktestConfig) -> float:
"""Compute score using the config's scoring_model — matches backtester."""
model = config.signal.scoring_model
if model == "return_max_long_v5":
from libs.backtest.scoring import compute_return_max_long_score_v5
return compute_return_max_long_score_v5(row)
elif model == "return_max_long_v7":
from libs.backtest.scoring import compute_return_max_long_score_v7
return compute_return_max_long_score_v7(row)
elif model == "return_max_long_v8":
from libs.backtest.scoring import compute_return_max_long_score_v8
return compute_return_max_long_score_v8(row)
elif model == "return_max_long_v9":
from libs.backtest.scoring import compute_return_max_long_score_v9
return compute_return_max_long_score_v9(row)
elif model == "return_max_long_v9g":
from libs.backtest.scoring import compute_return_max_long_score_v9g
return compute_return_max_long_score_v9g(row)
elif model == "return_max_long_v10":
from libs.backtest.scoring import compute_return_max_long_score_v10
return compute_return_max_long_score_v10(row)
elif model == "pead":
from libs.backtest.scoring import compute_pead_score
return compute_pead_score(row)
else:
from libs.backtest.scoring import compute_entry_score
return compute_entry_score(row)
async def get_candidates_for_date(
self,
execution_date: dt.date,
@ -102,11 +131,15 @@ class EventDetector:
if not enriched.get("reaction_day_high"):
enriched["reaction_day_high"] = sym_bars[rd].get("high")
# Compute market features from Oracle bars if missing in DB feature_json.
# These can be None when the feature builder ran before reaction-day bars settled.
# Compute market features from Oracle bars ONLY if missing in DB
# feature_json. DB values are authoritative because they were computed
# by the feature_builder at event time with the correct reaction_date
# and base price. Oracle bars can produce different values due to
# non-deterministic data or different date alignment.
_db_has_reaction = enriched.get("reaction_day_return") is not None
sym_bars = bars_by_symbol.get(sym, {})
rd = _parse_date(enriched.get("reaction_date"))
if rd and rd in sym_bars:
if rd and rd in sym_bars and not _db_has_reaction:
sorted_dates = sorted(sym_bars.keys())
try:
rd_idx = sorted_dates.index(rd)
@ -117,12 +150,11 @@ class EventDetector:
prev_bar = sym_bars[sorted_dates[rd_idx - 1]]
reaction_bar = sym_bars[rd]
if not enriched.get("volume_ratio_20d") and not enriched.get("volume_ratio") and prior_dates:
if not enriched.get("volume_ratio_20d") and prior_dates:
avg_vol = sum(sym_bars[d]["volume"] for d in prior_dates) / len(prior_dates)
if avg_vol > 0:
vr = reaction_bar["volume"] / avg_vol
enriched["volume_ratio_20d"] = vr
enriched["volume_ratio"] = vr # backward compat
if not enriched.get("reaction_day_return") and prev_bar["close"]:
enriched["reaction_day_return"] = (
@ -179,10 +211,10 @@ class EventDetector:
)
continue
# Compute score if missing
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)
# 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)
enriched_rows.append(enriched)

Loading…
Cancel
Save