Files
signal-platform/app/services/qualification.py
T
dennisthiessen f48d8705de
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 39s
Deploy / deploy (push) Successful in 24s
remove min_target_probability gate + add chart time-range presets
min_target_probability is gone: it filtered on the probability model the
calibration has repeatedly shown to be weak and overconfident, it was redundant
with the momentum gate, and as an off-by-default knob it just invited bad tuning.
Removed from the backend gate, activation config/schema, the frontend mirror
(qualifiesSetup / activationSummary), and ActivationSettings. The probability
model stays where it does real work (primary-target selection + display).

Charts: with multi-year history the all-bars default was unreadable. Added
time-range presets (1M / 3M / 6M / YTD / 1Y / 3Y / 5Y / All), defaulting to 1Y;
clicking a preset always re-applies (snaps back after a manual zoom). Y-axis
autoscale and wheel-zoom / drag-pan were already there.

339 backend tests pass; frontend build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:24:35 +02:00

88 lines
3.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
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 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 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
return True