A qualified setup's primary target must now clear MIN_TARGET_PROBABILITY
(20%), shared with the primary-selection floor in recommendation_service
and mirrored in the frontend gate. Closes the read-time hole where a
stale pre-c7a198b row starring a far lottery target (probability pinned
at the 3% clamp floor, R:R inflated by the same distance) qualified
forever: the scanner emits no replacement row and live R:R never decays.
A/B backtest vs c7a198b baseline (same July-3 snapshot): 7 of 1096
qualified setups removed; qualified net avg R 0.202 -> 0.207, hold
Sharpe 2.00 -> 2.02, CAGR +48.8% -> +49.6%, max DD unchanged at -15.8%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
5.9 KiB
Python
131 lines
5.9 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
|
|
residual cross-sectional momentum: a setup's ticker must rank in the top
|
|
``min_momentum_percentile`` of the universe by beta-adjusted 12-1 month momentum.
|
|
R:R and confidence remain as floors, and conviction/conflict survive as optional
|
|
tighteners (off by default). Qualified setups must also have a primary target
|
|
with at least ``MIN_TARGET_PROBABILITY`` reach probability: a primary below the
|
|
floor is a lottery target whose distance inflates R:R, so it would otherwise
|
|
game the min_rr gate (the model clamps probabilities at 3%, and far targets pin
|
|
there while their live R:R stays high forever).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
|
|
|
|
# Floor for the primary target's reach probability, shared with the primary
|
|
# target selection in recommendation_service and mirrored in the frontend
|
|
# (qualification.ts). Under the two-barrier model a fair-race 1.5:1 target sits
|
|
# near ~34% before drift adjustments, so 20% only excludes targets the model
|
|
# itself considers long shots.
|
|
MIN_TARGET_PROBABILITY = 20.0
|
|
|
|
|
|
def _action_direction(action: str | None) -> str:
|
|
if not action or action == "NEUTRAL":
|
|
return "neutral"
|
|
if action.startswith("LONG"):
|
|
return "long"
|
|
if action.startswith("SHORT"):
|
|
return "short"
|
|
return "neutral"
|
|
|
|
|
|
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 primary_target_probability(setup: Any) -> float | None:
|
|
"""Probability of the primary/headline target, falling back to best target."""
|
|
targets = getattr(setup, "targets", None) or []
|
|
for target in targets:
|
|
if not isinstance(target, dict) or not target.get("is_primary"):
|
|
continue
|
|
probability = target.get("probability")
|
|
return float(probability) if probability is not None else None
|
|
best = best_target_probability(setup)
|
|
return best if best > 0 else None
|
|
|
|
|
|
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), target probability, confidence
|
|
floor, momentum percentile (the core selection), then optional conviction /
|
|
conflict 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
|
|
target_probability = primary_target_probability(setup)
|
|
if target_probability is None or target_probability < MIN_TARGET_PROBABILITY:
|
|
return False
|
|
if (setup.confidence_score or 0.0) < config["min_confidence"]:
|
|
return False
|
|
# Residual cross-sectional momentum: the core selection. A setup's ticker
|
|
# must rank in the top ``min_momentum_percentile`` of the universe by
|
|
# beta-adjusted 12-1 momentum. The validated edge is long-only, so while the
|
|
# gate is active shorts (which fight the trend) never qualify. Missing ranks
|
|
# do not qualify because the production edge depends on this cross-sectional
|
|
# selection.
|
|
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 None or momentum_percentile < min_pct:
|
|
return False
|
|
# A setup is actionable only when the live ticker action points in the same
|
|
# direction. NEUTRAL means no clear signal; an opposite action means the
|
|
# setup is counter-bias. ``exclude_neutral`` defaults on; callers that omit
|
|
# it keep legacy floor-only behavior.
|
|
if config.get("exclude_neutral"):
|
|
action_direction = _action_direction(getattr(setup, "recommended_action", None))
|
|
setup_direction = (getattr(setup, "direction", "long") or "long").lower()
|
|
if action_direction == "neutral" or action_direction != setup_direction:
|
|
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
|
|
return True
|