fix: align production defaults and close review parity gaps

Ship greenfield min_rr=2.0 and conf=0, read-only Structural S/R, indicator
cache invalidation, and UI/gate language that treats GTL as screening not exit.
Align strategy_rank missing-vol fallback live vs backtest, single-source
PRIMARY_TARGET_MIN_RR, expand prod parity tests, and drop dead FE clients.
This commit is contained in:
2026-07-18 13:03:22 +02:00
parent e07da0f8f0
commit b0e33e1606
25 changed files with 429 additions and 221 deletions
+32 -43
View File
@@ -34,6 +34,28 @@ STRATEGY_RANK_MOMENTUM_WEIGHT = 0.8
STRATEGY_RANK_VOL_WEIGHT = 1.0 - STRATEGY_RANK_MOMENTUM_WEIGHT
def blend_strategy_rank(
momentum_percentile: float | None,
volatility_percentile: float | None,
*,
momentum_weight: float = STRATEGY_RANK_MOMENTUM_WEIGHT,
) -> float | None:
"""80/20 production rank with mom-only fallback when vol is missing.
Live and backtest must share this policy: missing vol must not send a
residual-qualified name to the bottom of the book (that was the old
backtest behaviour when either leg was None).
"""
if momentum_percentile is not None and volatility_percentile is not None:
vol_weight = 1.0 - momentum_weight
return round(
float(momentum_percentile) * momentum_weight
+ float(volatility_percentile) * vol_weight,
2,
)
return float(momentum_percentile) if momentum_percentile is not None else None
def compute_12_1_momentum(closes: list[float]) -> float | None:
"""Return over the window ending ~1 month ago, starting ~12 months ago.
None when there isn't a full year of history."""
@@ -100,41 +122,17 @@ async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]:
async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]:
"""Compute each ticker's activation momentum rank.
"""Momentum leg only — thin view of ``compute_activation_ranks``.
Production uses residual 12-1 momentum when benchmark data is available. If
SPY data is absent, fall back to raw 12-1 momentum rather than disabling the
scanner. Tickers without enough stock/benchmark history are absent.
Prefer ``compute_activation_ranks`` in new code (includes vol + strategy_rank).
Kept so tests/helpers that only need the residual/raw percentile map stay simple.
"""
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all())
benchmark_closes = await _load_activation_benchmark(db)
using_residual = len(benchmark_closes) >= _MOM_LOOKBACK
values: dict[str, float] = {}
for ticker in tickers:
try:
records = await query_ohlcv(db, ticker.symbol)
except Exception:
logger.exception("Momentum fetch failed for %s", ticker.symbol)
continue
closes = [float(r.close) for r in records]
value = (
compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes)
if using_residual
else compute_12_1_momentum(closes)
)
if value is not None:
values[ticker.symbol] = value
percentiles = _percentiles(values)
logger.info(json.dumps({
"event": "momentum_ranked",
"signal": "residual_12_1" if using_residual else "raw_12_1_fallback",
"tickers": len(percentiles),
}))
return percentiles
ranks = await compute_activation_ranks(db)
return {
sym: float(row["momentum_percentile"])
for sym, row in ranks.items()
if row.get("momentum_percentile") is not None
}
def compute_realized_vol_6m(closes: list[float]) -> float | None:
@@ -204,19 +202,10 @@ async def compute_activation_ranks(db: AsyncSession) -> dict[str, dict[str, floa
for sym in symbols:
momentum_pct = momentum_percentiles.get(sym)
vol_pct = vol_percentiles.get(sym)
strategy_rank = (
round(
momentum_pct * STRATEGY_RANK_MOMENTUM_WEIGHT
+ vol_pct * STRATEGY_RANK_VOL_WEIGHT,
2,
)
if momentum_pct is not None and vol_pct is not None
else momentum_pct
)
ranks[sym] = {
"momentum_percentile": momentum_pct,
"volatility_percentile": vol_pct,
"strategy_rank": strategy_rank,
"strategy_rank": blend_strategy_rank(momentum_pct, vol_pct),
}
logger.info(json.dumps({