Setup views: primary-target column, floor-target prune, liveness cutoff

Three follow-ups to the gate probability floor (8f41143):

- Signals table shows the starred primary target (shared primaryTarget
  helper) instead of an independently computed max-probability best,
  so Overview, Signals and ticker details agree by construction.
- Targets pinned at the 3% probability clamp floor collapse to the
  nearest one (enhance_trade_setup + backtest candidates in parity):
  floor-pinned levels are indistinguishable to the model, so farther
  ones were duplicate 3% rows inviting lottery headlines.
- get_trade_setups only returns setups re-emitted within
  LIVE_SETUP_MAX_AGE_DAYS (3): an older latest row means the daily
  scan no longer confirms the setup, and such rows otherwise surface
  forever on Overview/Signals/ticker/alerts. History endpoints keep
  full history.

Backtest on the Jul-3 snapshot is metric-identical to the gate-floor
run on all qualified stats (1089 qualified, Sharpe 2.02, CAGR +49.6%,
DD -15.8%): the prune only removes noise the gate already rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 10:06:34 +02:00
co-authored by Claude Fable 5
parent 8f411435ee
commit fdc49d0e28
8 changed files with 170 additions and 42 deletions
+4
View File
@@ -65,6 +65,7 @@ from app.services.qualification import (
from app.services.recommendation_service import (
_choose_recommended_action,
_classify_by_probability,
_prune_floor_pinned_targets,
_risk_level_from_conflicts,
_select_primary_target,
_zone_representative_levels,
@@ -179,6 +180,9 @@ def _window_setups(
t, dim_scores, None, direction, config
)
t["classification"] = _classify_by_probability(t["probability"])
# Collapse duplicate floor-pinned lottery targets (parity with
# enhance_trade_setup).
targets = _prune_floor_pinned_targets(targets)
primary = _select_primary_target(targets)
if primary is None:
continue
+30 -1
View File
@@ -45,6 +45,12 @@ _MODERATE_MAX_ATR = 4.6
# the same tolerance the chart and alerts use, so S/R is one model app-wide.
_SR_ZONE_TOLERANCE = 0.02
# Reach-probability estimates are clamped to this band; a target at the floor
# means "the model considers it essentially unreachable" and floor-pinned
# targets are mutually indistinguishable.
_PROBABILITY_CLAMP_LOW = 3.0
_PROBABILITY_CLAMP_HIGH = 95.0
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
@@ -408,7 +414,7 @@ class ProbabilityEstimator:
elif opposed:
probability -= signal_weight * 100.0
return round(_clamp(probability, 3.0, 95.0), 2)
return round(_clamp(probability, _PROBABILITY_CLAMP_LOW, _PROBABILITY_CLAMP_HIGH), 2)
signal_conflict_detector = SignalConflictDetector()
@@ -582,6 +588,26 @@ PRIMARY_TARGET_MIN_RR = 1.5
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY
def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]:
"""Keep only the nearest target pinned at the probability clamp floor.
Floor-pinned targets are indistinguishable to the model (true probability
at/below the clamp), so farther ones add no information — they just fill
the table with duplicate "3%" rows whose inflated R:R invites lottery
picks. ``targets`` is distance-sorted by the generator, so the first
floor-pinned entry is the nearest (most reachable) representative.
"""
pruned: list[dict] = []
seen_floor = False
for target in targets:
if float(target.get("probability", 0.0)) <= _PROBABILITY_CLAMP_LOW:
if seen_floor:
continue
seen_floor = True
pruned.append(target)
return pruned
def _select_primary_target(
targets: list[dict],
min_rr: float = PRIMARY_TARGET_MIN_RR,
@@ -665,6 +691,9 @@ async def enhance_trade_setup(
# Label follows from the reach-probability: high prob = Conservative.
target["classification"] = _classify_by_probability(target["probability"])
# Collapse duplicate floor-pinned lottery targets to the nearest one.
targets = _prune_floor_pinned_targets(targets)
# Primary target = most-likely target with real asymmetry (see
# _select_primary_target), not the old quality-score pick that ignored
# probability. Sync the setup's headline target/rr_ratio so the chart, gate
+18 -2
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Callable
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,6 +39,15 @@ logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1"
# 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
# re-confirmed — typically because no level clears the R:R threshold from the
# current price anymore. Without this cutoff such rows stay "latest" forever
# (the scanner never writes a replacement) and keep surfacing on the live
# views. 3 days buffers a missed pipeline run or two; history endpoints are
# unaffected.
LIVE_SETUP_MAX_AGE_DAYS = 3
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
@@ -602,10 +611,17 @@ async def get_trade_setups(
live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False,
) -> list[dict]:
"""Get latest stored trade setups, optionally filtered."""
"""Get latest stored trade setups, optionally filtered.
Only setups the daily scan re-emitted within ``LIVE_SETUP_MAX_AGE_DAYS``
are returned — an older "latest" row means the scanner no longer finds a
valid setup for that ticker, so it must not surface as current.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=LIVE_SETUP_MAX_AGE_DAYS)
stmt = (
select(TradeSetup, Ticker.symbol)
.join(Ticker, TradeSetup.ticker_id == Ticker.id)
.where(TradeSetup.detected_at >= cutoff)
)
if direction is not None:
stmt = stmt.where(TradeSetup.direction == direction.lower())