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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user