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
+6 -6
View File
@@ -76,13 +76,13 @@ async def get_trade_performance(
_user=Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Aggregate outcome statistics over evaluated trade setups.
"""Aggregate setup-outcome statistics (gate barrier diagnostic).
Outcomes are written by the nightly outcome_evaluator job (win = target
hit first, loss = stop hit first, expired = neither within the window).
With qualified_only, the overall/direction/action breakdowns cover only
setups clearing the activation gate; the confidence breakdown always
covers all setups so the gate can be validated against it.
Outcomes come from the nightly outcome_evaluator: win = gate target first,
loss = stop first, expired = neither in the window. This is **not** the
production ATR-trail book; it checks setup grading plumbing only.
With qualified_only, overall/direction/action cover only gate-clearing
setups; the confidence breakdown always covers all setups.
"""
config = await admin_service.get_activation_config(db) if qualified_only else None
stats = await get_performance_stats(db, config=config)
+3 -1
View File
@@ -54,7 +54,9 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = {
}
ACTIVATION_DEFAULTS: dict[str, float | bool] = {
"min_momentum_percentile": 80.0,
"min_rr": 1.2,
# Production floor from the 2026-07-12 min_rr sweep (in-sample and OOS peak).
# 1.2 was the old code default and the trough next to the spike — do not restore.
"min_rr": 2.0,
# 0 = off. The July 2026 gate ablation showed the confidence floor added
# nothing (identical net/trade with it removed, under both exit models)
# while cutting ~25% of qualified trades.
+22 -8
View File
@@ -59,6 +59,7 @@ from app.services.admin_service import get_activation_config, update_setting
from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.momentum_service import (
STRATEGY_RANK_MOMENTUM_WEIGHT,
blend_strategy_rank,
compute_realized_vol_6m,
)
from app.services.outcome_service import (
@@ -77,6 +78,7 @@ from app.services.qualification import (
setup_qualifies,
)
from app.services.recommendation_service import (
PRIMARY_TARGET_MIN_RR,
_choose_recommended_action,
_classify_by_probability,
_prune_floor_pinned_targets,
@@ -337,11 +339,11 @@ def _window_setups(
targets = _prune_floor_pinned_targets(targets)
primary = _select_primary_target(
targets,
min_rr=1.5,
min_rr=PRIMARY_TARGET_MIN_RR,
)
if primary is None:
continue
# Flag the primary so qualification's EV uses the primary target's
# Flag the primary so qualification uses the primary target's
# probability (matching production's enhance_trade_setup).
for t in targets:
t["is_primary"] = t is primary
@@ -1265,15 +1267,27 @@ def _assign_weighted_blend(
primary_weight: float,
secondary_key: str,
) -> None:
secondary_weight = 1.0 - primary_weight
"""Blend ranks; fall back to primary when secondary is missing.
Matches live ``blend_strategy_rank`` for the production 80/20 key: a name
with residual momentum but no vol history keeps its mom percentile instead
of ranking as 0 / None at the bottom of the book.
"""
for c in candidates:
primary = c.get(primary_key)
secondary = c.get(secondary_key)
c[output_key] = (
primary * primary_weight + secondary * secondary_weight
if primary is not None and secondary is not None
else None
)
# Production weight path: reuse the shared helper so live/sim cannot drift.
if primary_weight == STRATEGY_RANK_MOMENTUM_WEIGHT:
c[output_key] = blend_strategy_rank(
None if primary is None else float(primary),
None if secondary is None else float(secondary),
momentum_weight=primary_weight,
)
continue
if primary is not None and secondary is not None:
c[output_key] = primary * primary_weight + secondary * (1.0 - primary_weight)
else:
c[output_key] = primary
def _assign_residual_low_vol_blend(candidates: list[dict]) -> None:
+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({
+8 -4
View File
@@ -1,11 +1,15 @@
"""Trade setup outcome evaluation service.
Closes the feedback loop on R:R scanner setups: walks daily OHLCV bars
after detection and records whether the stop or the target was hit first.
Diagnostic barrier resolution for scanner setups: walks daily OHLCV bars
after detection and records whether the gate target or the stop was hit first.
This is **not** the production exit model. Live paper trades and the portfolio
monitor use ATR trail / max hold and never exit at the gate target. Track-record
stats from this path measure gate-level plumbing, not ATR-trail book expectancy.
Outcome semantics (entry is the close at detection time, i.e. market entry):
- target_hit: target reached before the stop
- stop_hit: stop reached before the target
- target_hit: gate target reached before the stop
- stop_hit: stop reached before the gate target
- ambiguous: stop AND target both within the same daily bar — with daily
granularity the order is unknowable, counted as a loss in stats
- expired: neither level hit within ``max_bars`` trading days
+3 -2
View File
@@ -80,8 +80,9 @@ async def upsert_ohlcv(
record = result.scalar_one()
# TODO: Invalidate LRU cache entries for this ticker (Task 7.1)
# TODO: Mark composite score as stale for this ticker (Task 10.1)
from app.cache import indicator_cache
indicator_cache.invalidate_ticker(ticker.symbol)
return record
+4
View File
@@ -618,6 +618,10 @@ def build_recommendation_snapshot(
# agree on what counts as a probability-backed target.
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY
# Primary-target selector floor (independent of the live activation min_rr).
# Live scanner and backtest setup replay must share this constant.
PRIMARY_TARGET_MIN_RR = 1.5
def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]:
"""Keep only the nearest target pinned at the probability clamp floor.
+8 -7
View File
@@ -35,6 +35,7 @@ from app.services.trade_policy import (
observe_reentry_gate_transitions,
)
from app.services.recommendation_service import (
PRIMARY_TARGET_MIN_RR,
_risk_level_from_conflicts,
build_recommendation_snapshot,
enhance_trade_setup,
@@ -44,7 +45,6 @@ from app.services.recommendation_service import (
logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
PRIMARY_TARGET_MIN_RR = 1.5
# A setup counts as live only while the daily scan keeps re-emitting it. The
# scan runs every day (07:00 UTC cron), so anything older than this was NOT
@@ -743,14 +743,15 @@ async def scan_all_tickers(
for index, (ticker_id, symbol) in enumerate(ticker_rows):
if progress_callback is not None:
progress_callback(index, total, symbol)
# Refresh scores first so the scheduled scan works off current data.
# Nothing else marks scores stale, so without this they'd never update
# for tickers the user doesn't manually fetch. A refresh failure still
# scans the ticker: qualification re-gates on live scores at alert
# time, so a stale score is recoverable but a skipped scan is not.
# Refresh Structural S/R once, then scores. get_sr_levels is read-only;
# without this recalculate the score path would see yesterday's zones.
# A refresh failure still scans the ticker: qualification re-gates on
# live scores at alert time, so a stale score is recoverable but a
# skipped scan is not.
try:
from app.services import scoring_service
from app.services import scoring_service, sr_service
await sr_service.recalculate_sr_levels(db, symbol)
await scoring_service.compute_all_dimensions(db, symbol)
await scoring_service.compute_composite_score(db, symbol)
await db.commit()
+12 -3
View File
@@ -857,8 +857,17 @@ async def get_sr_levels(
symbol: str,
tolerance: float | None = None,
) -> list[SRLevel]:
"""Get S/R levels for a ticker, recalculating on every request (MVP).
"""Return persisted Structural S/R levels, strength descending.
Returns levels sorted by strength descending.
Read-only: does not recompute or rewrite. Pipeline/ingestion call
``recalculate_sr_levels`` to refresh. ``tolerance`` is kept for API
compatibility and ignored on read (it only applies at recalculation).
"""
return await recalculate_sr_levels(db, symbol, tolerance)
del tolerance # API compat only; levels were stored at last recalculation
ticker = await _get_ticker(db, symbol)
result = await db.execute(
select(SRLevel)
.where(SRLevel.ticker_id == ticker.id)
.order_by(SRLevel.strength.desc())
)
return list(result.scalars().all())