diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py
index a7b6e49..82562ba 100644
--- a/app/services/backtest_service.py
+++ b/app/services/backtest_service.py
@@ -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
diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py
index d8dcd75..6099c59 100644
--- a/app/services/recommendation_service.py
+++ b/app/services/recommendation_service.py
@@ -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
diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py
index 5944801..e2fd188 100644
--- a/app/services/rr_scanner_service.py
+++ b/app/services/rr_scanner_service.py
@@ -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())
diff --git a/frontend/src/components/scanner/TradeTable.tsx b/frontend/src/components/scanner/TradeTable.tsx
index 95ee87f..6cc0b0a 100644
--- a/frontend/src/components/scanner/TradeTable.tsx
+++ b/frontend/src/components/scanner/TradeTable.tsx
@@ -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
{formatPrice(trade.entry_price)} |
{formatPrice(trade.stop_loss)} |
{formatPrice(trade.target)} |
- {bestTargetText(trade)} |
+ {primaryTargetText(trade)} |
{formatPrice(analysis.risk_amount)} |
{formatPrice(analysis.reward_amount)} |
{trade.rr_ratio.toFixed(2)} |
diff --git a/frontend/src/components/signals/SetupsPanel.tsx b/frontend/src/components/signals/SetupsPanel.tsx
index 5f5badf..c9ac325 100644
--- a/frontend/src/components/signals/SetupsPanel.tsx
+++ b/frontend/src/components/signals/SetupsPanel.tsx
@@ -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;
diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts
index d0ccf36..14a6b44 100644
--- a/frontend/src/lib/qualification.ts
+++ b/frontend/src/lib/qualification.ts
@@ -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). */
diff --git a/tests/unit/test_recommendation_service.py b/tests/unit/test_recommendation_service.py
index 87fde30..1ade99e 100644
--- a/tests/unit/test_recommendation_service.py
+++ b/tests/unit/test_recommendation_service.py
@@ -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},
diff --git a/tests/unit/test_rr_scanner_preservation.py b/tests/unit/test_rr_scanner_preservation.py
index 5967f38..d7e45f4 100644
--- a/tests/unit/test_rr_scanner_preservation.py
+++ b/tests/unit/test_rr_scanner_preservation.py
@@ -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 96–104 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()