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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user