605f95098c
Part 1 — long-only. The momentum edge is long top-momentum; the gate was
qualifying shorts on high-momentum names (fighting the trend), which showed as
the -0.13R Short(qual.) drag. While the gate is active, shorts no longer qualify
(backend qualification, backtest _momentum_qualifies, and the frontend mirror).
Part 2 — production wiring. Live setups now carry a real momentum rank, so the
dashboard, the Track Record's qualified stats, and outcome evaluation all gate on
the same value instead of deferring to floors:
- new momentum_service.compute_momentum_percentiles: 12-1 momentum per ticker,
ranked across the universe into a {symbol: percentile} map.
- the daily R:R scan ranks the universe up front and stores each setup's
percentile (new trade_setups.momentum_percentile column, migration 010).
- enhance_trade_setup mutates the same row, so the percentile is preserved;
_trade_setup_to_dict + TradeSetupResponse expose it to the API.
Until a fresh scan runs, pre-existing setups have a null percentile and the gate
falls back to floors for them (longs) / excludes them (shorts) — they fill in on
the next scan. 341 backend tests pass; frontend build clean.
Needs the alembic upgrade (migration 010) on deploy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
4.1 KiB
Python
92 lines
4.1 KiB
Python
"""Shared definition of a 'qualified' (actionable) trade setup.
|
|
|
|
A single predicate, driven by the admin activation config, used by the
|
|
performance stats (server) and mirrored on the frontend. The core selection is
|
|
cross-sectional momentum: a setup's ticker must rank in the top
|
|
``min_momentum_percentile`` of the universe by 12-1 month momentum — the one
|
|
signal the backtest showed actually sorts forward returns. R:R and confidence
|
|
remain as floors, and conviction/conflict/target-probability survive as optional
|
|
tighteners (off by default). The momentum percentile is computed across the
|
|
universe and attached to each setup upstream; when it's absent the gate falls
|
|
back to the floors.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
|
|
|
|
|
def best_target_probability(setup: Any) -> float:
|
|
"""Highest probability among a setup's targets, 0 if none."""
|
|
targets = getattr(setup, "targets", None) or []
|
|
probs = [float(t.get("probability", 0.0)) for t in targets if isinstance(t, dict)]
|
|
return max(probs, default=0.0)
|
|
|
|
|
|
def live_risk_reward(setup: Any, current_price: float) -> float | None:
|
|
"""R:R recomputed from the CURRENT price, not the (possibly stale) entry.
|
|
|
|
Returns None / a low value when the setup is no longer actionable: price
|
|
already at/past the target (no reward left) or through the stop. This is how
|
|
over-progressed setups get filtered without a separate 'max progress' knob.
|
|
"""
|
|
if setup.direction == "long":
|
|
reward = setup.target - current_price
|
|
risk = current_price - setup.stop_loss
|
|
else:
|
|
reward = current_price - setup.target
|
|
risk = setup.stop_loss - current_price
|
|
if reward <= 0 or risk <= 0:
|
|
return 0.0
|
|
return reward / risk
|
|
|
|
|
|
def setup_qualifies(setup: Any, config: dict) -> bool:
|
|
"""Whether a setup clears the activation gate.
|
|
|
|
``setup`` is duck-typed: any object exposing rr_ratio, confidence_score,
|
|
recommended_action, risk_level and a ``targets`` list of dicts.
|
|
|
|
Gate order: R:R floor → freshness (live R:R) → confidence floor → momentum
|
|
percentile (the core selection) → optional conviction / conflict /
|
|
target-probability tighteners. ``min_momentum_percentile`` defaults to 0 (off)
|
|
for callers that pass a legacy config without the key.
|
|
"""
|
|
if setup.rr_ratio < config["min_rr"]:
|
|
return False
|
|
# Live R:R from the current price: drops setups whose price has already run
|
|
# toward the target (reward consumed) or through the stop. Only applied when
|
|
# a current price is attached (live list); skipped for historical setups.
|
|
current_price = getattr(setup, "current_price", None)
|
|
if current_price is not None:
|
|
live_rr = live_risk_reward(setup, float(current_price))
|
|
if live_rr is not None and live_rr < config["min_rr"]:
|
|
return False
|
|
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
|
return False
|
|
# Cross-sectional momentum: the core selection. A setup's ticker must rank in
|
|
# the top ``min_momentum_percentile`` of the universe by 12-1 momentum. The
|
|
# validated edge is long-only, so while the gate is active shorts (which fight
|
|
# the trend) never qualify. The percentile floor is only enforced when a
|
|
# percentile is attached (live setups / backtest); callers that don't attach
|
|
# it defer to the floors above.
|
|
min_pct = float(config.get("min_momentum_percentile", 0.0))
|
|
if min_pct > 0:
|
|
if (getattr(setup, "direction", "long") or "long") == "short":
|
|
return False
|
|
momentum_percentile = getattr(setup, "momentum_percentile", None)
|
|
if momentum_percentile is not None and momentum_percentile < min_pct:
|
|
return False
|
|
if config.get("require_high_conviction"):
|
|
if (setup.recommended_action or "") not in HIGH_CONVICTION_ACTIONS:
|
|
return False
|
|
if config.get("exclude_conflicts"):
|
|
if (setup.risk_level or "") != "Low":
|
|
return False
|
|
min_tp = float(config.get("min_target_probability", 0.0))
|
|
if min_tp > 0 and best_target_probability(setup) < min_tp:
|
|
return False
|
|
return True
|