Files
signal-platform/app/services/qualification.py
T
dennisthiessenandClaude Opus 4.8 65d2dae62a Revert blue-sky projection; keep played-out setup UX
A local backtest (offline prod snapshot, 506 tickers) evaluated blue-sky
projected targets under the PRODUCTION exit (3x ATR trailing + 30d max hold,
paper_trade_service DEFAULT_EXIT_MODE="atr_trailing"). Blue-sky setups are
dilutive: the qualified book scored 328% return / Sharpe 1.84 / DD -21.0%
WITHOUT them vs 300% / 1.58 / -18.7% WITH them. They rank high on momentum by
construction, so they grab slots from S/R setups that catch bigger runs under
a trailing-stop exit (only ~2pp worse drawdown doesn't justify the lost return
and Sharpe).

Reverts the scanner/TargetGenerator measured-move projection, the stricter
projected activation gate, the frontend qualification mirror, the `projected`
type field, and the projected tests -- all backend files are now byte-identical
to the pre-blue-sky commit.

Keeps the played-out "No current setup" UX (RecommendationPanel): when price
has run past the target (played out) or through the stop (invalidated), the
panel shows a plain no-setup state instead of a stale actionable card. This is
frontend-only (reads last close + existing setup fields) and is what actually
fixes the reported stale-below-price bug -- no backend change or rescan needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:03:25 +02:00

121 lines
5.3 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 probability-backed
target; otherwise a mathematically high R:R can be driven by a fragile target
with no independent validation.
"""
from __future__ import annotations
from typing import Any
HIGH_CONVICTION_ACTIONS = {"LONG_HIGH", "SHORT_HIGH"}
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
if primary_target_probability(setup) is None:
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