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())
+10 -7
View File
@@ -1,9 +1,10 @@
import { Link } from 'react-router-dom';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
import { primaryTarget } from '../../lib/qualification';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
export type SortColumn = 'symbol' | 'direction' | 'recommended_action' | 'confidence_score' | 'entry_price' | 'stop_loss' | 'target' | 'best_target_probability' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'risk_level' | 'composite_score' | 'detected_at';
export type SortColumn = 'symbol' | 'direction' | 'recommended_action' | 'confidence_score' | 'entry_price' | 'stop_loss' | 'target' | 'primary_target_probability' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'risk_level' | 'composite_score' | 'detected_at';
export type SortDirection = 'asc' | 'desc';
interface TradeTableProps {
@@ -21,7 +22,7 @@ const columns: { key: SortColumn; label: string }[] = [
{ key: 'entry_price', label: 'Entry' },
{ key: 'stop_loss', label: 'Stop Loss' },
{ key: 'target', label: 'Target' },
{ key: 'best_target_probability', label: 'Best Target' },
{ key: 'primary_target_probability', label: 'Primary Target' },
{ key: 'risk_amount', label: 'Risk $' },
{ key: 'reward_amount', label: 'Reward $' },
{ key: 'rr_ratio', label: 'R:R' },
@@ -65,10 +66,12 @@ function riskLevelClass(riskLevel: TradeSetup['risk_level']) {
return 'text-gray-400';
}
function bestTargetText(trade: TradeSetup) {
if (!trade.targets || trade.targets.length === 0) return '—';
const best = [...trade.targets].sort((a, b) => b.probability - a.probability)[0];
return `${formatPrice(best.price)} (${best.probability.toFixed(0)}%)`;
// The starred primary — the same target the Overview and ticker details
// headline, so every view agrees on which target a setup is "about".
function primaryTargetText(trade: TradeSetup) {
const primary = primaryTarget(trade);
if (!primary) return '—';
return `${formatPrice(primary.price)} (${primary.probability.toFixed(0)}%)`;
}
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
@@ -121,7 +124,7 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{bestTargetText(trade)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{primaryTargetText(trade)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.risk_amount)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.reward_amount)}</td>
<td className={`px-4 py-3.5 font-mono font-semibold ${rrColorClass(trade.rr_ratio)}`}>{trade.rr_ratio.toFixed(2)}</td>
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation';
import { useTrades } from '../../hooks/useTrades';
import { qualifiesSetup, activationSummary } from '../../lib/qualification';
import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../../lib/qualification';
import { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
import { SkeletonTable } from '../ui/Skeleton';
import { useToast } from '../ui/Toast';
@@ -42,8 +42,8 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number {
case 'stop_pct': return analysis.stop_pct;
case 'target_pct': return analysis.target_pct;
case 'confidence_score': return trade.confidence_score ?? -1;
case 'best_target_probability':
return trade.targets?.length ? Math.max(...trade.targets.map((t) => t.probability)) : -1;
case 'primary_target_probability':
return primaryTargetProbability(trade) ?? -1;
case 'risk_level':
if (trade.risk_level === 'Low') return 1;
if (trade.risk_level === 'Medium') return 2;
@@ -78,7 +78,7 @@ function sortTrades(
case 'stop_pct':
case 'target_pct':
case 'confidence_score':
case 'best_target_probability':
case 'primary_target_probability':
case 'risk_level':
cmp = getComputedValue(a, column) - getComputedValue(b, column);
break;
+9 -6
View File
@@ -1,4 +1,4 @@
import type { ActivationConfig, TradeSetup } from './types';
import type { ActivationConfig, TradeSetup, TradeTarget } from './types';
const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
@@ -16,15 +16,18 @@ function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'sh
return 'neutral';
}
export function bestTargetProbability(setup: TradeSetup): number {
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0;
/** The starred primary target (the one the headline R:R refers to), falling
* back to the most likely target when no star is stored. */
export function primaryTarget(setup: TradeSetup): TradeTarget | null {
const starred = setup.targets?.find((t) => t.is_primary);
if (starred) return starred;
if (!setup.targets?.length) return null;
return [...setup.targets].sort((a, b) => b.probability - a.probability)[0];
}
/** Probability of the starred primary target (the one the headline R:R refers to). */
export function primaryTargetProbability(setup: TradeSetup): number | null {
const primary = setup.targets?.find((t) => t.is_primary);
if (primary) return primary.probability;
return setup.targets?.length ? bestTargetProbability(setup) : null;
return primaryTarget(setup)?.probability ?? null;
}
/** R:R recomputed from the current price (0 if no reward/risk left). */
+30
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from app.services.recommendation_service import (
_build_reasoning,
_choose_recommended_action,
_prune_floor_pinned_targets,
_select_primary_target,
direction_analyzer,
probability_estimator,
@@ -154,6 +155,35 @@ def test_primary_target_requires_probability_floor():
assert primary["price"] == 112.0
def test_prune_keeps_only_nearest_floor_pinned_target():
# Two targets pinned at the 3% clamp floor are indistinguishable to the
# model — only the nearest survives; farther ones are duplicate noise.
targets = [
{"price": 204.0, "rr_ratio": 0.7, "probability": 25.6},
{"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
{"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
]
pruned = _prune_floor_pinned_targets(targets)
assert [t["price"] for t in pruned] == [204.0, 241.0]
def test_prune_leaves_targets_above_floor_untouched():
targets = [
{"price": 110.0, "rr_ratio": 2.0, "probability": 65.0},
{"price": 120.0, "rr_ratio": 3.5, "probability": 20.0},
]
assert _prune_floor_pinned_targets(targets) == targets
def test_prune_all_floor_pinned_keeps_nearest_only():
targets = [
{"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
{"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
]
pruned = _prune_floor_pinned_targets(targets)
assert [t["price"] for t in pruned] == [241.0]
def test_detects_sentiment_technical_conflict():
conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
+65 -22
View File
@@ -29,7 +29,11 @@ from app.models.trade_setup import TradeSetup
from app.models.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore
from app.models.user import User
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
from app.services.rr_scanner_service import (
LIVE_SETUP_MAX_AGE_DAYS,
get_trade_setups,
scan_ticker,
)
def _as_utc(value: datetime) -> datetime:
@@ -69,11 +73,11 @@ def _make_ohlcv_bars(
num_bars: int = 20,
base_close: float = 100.0,
) -> list[OHLCVRecord]:
"""Generate OHLCV bars closing around base_close with ATR 2.0."""
"""Generate OHLCV bars closing around base_close with ATR ≈ 2.0."""
bars: list[OHLCVRecord] = []
start = date(2024, 1, 1)
for i in range(num_bars):
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
bars.append(OHLCVRecord(
ticker_id=ticker_id,
date=start + timedelta(days=i),
@@ -101,7 +105,7 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
but all below the R:R threshold for their respective directions
- Levels in the right direction but below R:R threshold
Note: scan_ticker does NOT filter by SR level type it only checks whether
Note: scan_ticker does NOT filter by SR level type — it only checks whether
the price_level is above or below entry. So "wrong side" means all levels
are clustered near entry and below threshold in both directions.
"""
@@ -111,10 +115,10 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
return {"variant": variant, "levels": []}
else: # below_threshold
# All levels close to entry so R:R < 1.5 with risk 3
# For longs: reward < 4.5 price < 104.5
# For shorts: reward < 4.5 price > 95.5
# Place all levels in the 96104 band (below threshold both ways)
# All levels close to entry so R:R < 1.5 with risk ≈ 3
# For longs: reward < 4.5 → price < 104.5
# For shorts: reward < 4.5 → price > 95.5
# Place all levels in the 96–104 band (below threshold both ways)
num = draw(st.integers(min_value=1, max_value=3))
levels = []
for _ in range(num):
@@ -139,7 +143,7 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
def single_candidate_scenario(draw: st.DrawFn) -> dict:
"""Generate a scenario with exactly one S/R level that meets the R:R threshold.
For longs: one resistance above entry with R:R >= 1.5 (price >= 104.5 with risk 3).
For longs: one resistance above entry with R:R >= 1.5 (price >= 104.5 with risk ≈ 3).
"""
direction = draw(st.sampled_from(["long", "short"]))
@@ -175,7 +179,7 @@ async def test_property_zero_candidates_produce_no_setup(
"""**Validates: Requirements 3.1, 3.2**
Property: when zero candidate S/R levels exist (no levels, wrong side,
or below threshold), scan_ticker produces no setup unchanged from
or below threshold), scan_ticker produces no setup — unchanged from
original behavior.
"""
from tests.conftest import _test_engine, _test_session_factory
@@ -225,7 +229,7 @@ async def test_property_single_candidate_selected_unchanged(
"""**Validates: Requirements 3.3**
Property: when exactly one candidate S/R level meets the R:R threshold,
scan_ticker selects it same as the original code would.
scan_ticker selects it — same as the original code would.
"""
from tests.conftest import _test_engine, _test_session_factory
from app.database import Base
@@ -270,7 +274,7 @@ async def test_property_single_candidate_selected_unchanged(
# ===========================================================================
# 7.2 Unit test: no S/R levels no setup produced
# 7.2 Unit test: no S/R levels → no setup produced
# ===========================================================================
@pytest.mark.asyncio
@@ -296,7 +300,7 @@ async def test_no_sr_levels_produces_no_setup(scan_session: AsyncSession):
# ===========================================================================
# 7.3 Unit test: single candidate meets threshold selected
# 7.3 Unit test: single candidate meets threshold → selected
# ===========================================================================
@pytest.mark.asyncio
@@ -306,7 +310,7 @@ async def test_single_resistance_above_threshold_selected(scan_session: AsyncSes
When exactly one resistance level above entry meets the R:R threshold,
it should be selected as the long setup target.
Entry 100, ATR 2, risk 3. Resistance at 110 R:R 3.33 (>= 1.5).
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Resistance at 110 → R:R ≈ 3.33 (>= 1.5).
"""
ticker = Ticker(symbol="SINGL")
scan_session.add(ticker)
@@ -343,7 +347,7 @@ async def test_single_support_below_threshold_selected(scan_session: AsyncSessio
When exactly one support level below entry meets the R:R threshold,
it should be selected as the short setup target.
Entry 100, ATR 2, risk 3. Support at 90 R:R 3.33 (>= 1.5).
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Support at 90 → R:R ≈ 3.33 (>= 1.5).
"""
ticker = Ticker(symbol="SINGS")
scan_session.add(ticker)
@@ -444,6 +448,42 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
)
@pytest.mark.asyncio
async def test_get_trade_setups_excludes_stale_rows(db_session: AsyncSession):
"""A "latest" row older than LIVE_SETUP_MAX_AGE_DAYS means the daily scan
stopped re-emitting the setup (nothing clears the R:R threshold from the
current price) — it must not surface on the live views."""
now = datetime.now(timezone.utc)
ticker_fresh = Ticker(symbol="FRESH")
ticker_stale = Ticker(symbol="STALE")
db_session.add_all([ticker_fresh, ticker_stale])
await db_session.flush()
db_session.add_all([
TradeSetup(
ticker_id=ticker_fresh.id, direction="long",
entry_price=100.0, stop_loss=97.0, target=109.0,
rr_ratio=3.0, composite_score=50.0,
detected_at=now - timedelta(days=1),
),
TradeSetup(
ticker_id=ticker_stale.id, direction="long",
entry_price=100.0, stop_loss=97.0, target=109.0,
rr_ratio=3.0, composite_score=50.0,
detected_at=now - timedelta(days=LIVE_SETUP_MAX_AGE_DAYS, hours=1),
),
])
await db_session.flush()
results = await get_trade_setups(db_session)
symbols = [r["symbol"] for r in results]
assert symbols == ["FRESH"], f"Stale setup must be excluded, got {symbols}"
# The per-symbol view applies the same liveness rule.
stale_rows = await get_trade_setups(db_session, symbol="STALE")
assert stale_rows == []
@pytest.mark.asyncio
async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
db_session: AsyncSession,
@@ -540,9 +580,12 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
(bullish sentiment, composite 96) that yields live confidence 97."""
old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc)
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
(bullish sentiment, composite 96) that yields live confidence 97.
The scan date stays inside the LIVE_SETUP_MAX_AGE_DAYS liveness window —
these tests exercise the live overlay on a still-live row, not staleness."""
current = datetime.now(timezone.utc)
old_scan = current - timedelta(days=2)
old_reasoning = (
"LONG (high confidence): 82% with aligned signals "
"(technical=88, momentum=60, sentiment=neutral)."
@@ -653,7 +696,7 @@ async def test_live_recommendation_filters_apply_to_live_values(
"""min_confidence must judge the overlaid live confidence, not the stored one."""
await _seed_stale_setup_with_current_scores(db_session)
# Stored confidence is 82 a stored-column filter would drop this row.
# Stored confidence is 82 — a stored-column filter would drop this row.
# Live confidence is 97, so it must pass.
rows = await get_trade_setups(
db_session,
@@ -675,7 +718,7 @@ async def test_live_recommendation_filters_apply_to_live_values(
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
current = datetime.now(timezone.utc)
ticker = Ticker(symbol="BOTH")
db_session.add(ticker)
await db_session.flush()
@@ -776,7 +819,7 @@ async def test_live_recommendation_action_independent_of_direction_filter(
async def test_live_overlay_preserves_setup_specific_risk_and_context(
db_session: AsyncSession,
):
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
current = datetime.now(timezone.utc)
ticker = Ticker(symbol="RISK")
db_session.add(ticker)
await db_session.flush()
@@ -883,7 +926,7 @@ async def test_live_trade_setup_read_does_not_recompute_scores(db_session: Async
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
db_session: AsyncSession,
):
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
current = datetime.now(timezone.utc)
ticker = Ticker(symbol="LIVEP")
db_session.add(ticker)
await db_session.flush()