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 ( from app.services.recommendation_service import (
_choose_recommended_action, _choose_recommended_action,
_classify_by_probability, _classify_by_probability,
_prune_floor_pinned_targets,
_risk_level_from_conflicts, _risk_level_from_conflicts,
_select_primary_target, _select_primary_target,
_zone_representative_levels, _zone_representative_levels,
@@ -179,6 +180,9 @@ def _window_setups(
t, dim_scores, None, direction, config t, dim_scores, None, direction, config
) )
t["classification"] = _classify_by_probability(t["probability"]) 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) primary = _select_primary_target(targets)
if primary is None: if primary is None:
continue 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. # the same tolerance the chart and alerts use, so S/R is one model app-wide.
_SR_ZONE_TOLERANCE = 0.02 _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: def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value)) return max(low, min(high, value))
@@ -408,7 +414,7 @@ class ProbabilityEstimator:
elif opposed: elif opposed:
probability -= signal_weight * 100.0 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() signal_conflict_detector = SignalConflictDetector()
@@ -582,6 +588,26 @@ PRIMARY_TARGET_MIN_RR = 1.5
PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY 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( def _select_primary_target(
targets: list[dict], targets: list[dict],
min_rr: float = PRIMARY_TARGET_MIN_RR, 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. # Label follows from the reach-probability: high prob = Conservative.
target["classification"] = _classify_by_probability(target["probability"]) 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 # Primary target = most-likely target with real asymmetry (see
# _select_primary_target), not the old quality-score pick that ignored # _select_primary_target), not the old quality-score pick that ignored
# probability. Sync the setup's headline target/rr_ratio so the chart, gate # 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 json
import logging import logging
from collections.abc import Callable 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 import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,6 +39,15 @@ logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1" 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: async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper() normalised = symbol.strip().upper()
@@ -602,10 +611,17 @@ async def get_trade_setups(
live_recommendation: bool = False, live_recommendation: bool = False,
exclude_open_trade_tickers: bool = False, exclude_open_trade_tickers: bool = False,
) -> list[dict]: ) -> 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 = ( stmt = (
select(TradeSetup, Ticker.symbol) select(TradeSetup, Ticker.symbol)
.join(Ticker, TradeSetup.ticker_id == Ticker.id) .join(Ticker, TradeSetup.ticker_id == Ticker.id)
.where(TradeSetup.detected_at >= cutoff)
) )
if direction is not None: if direction is not None:
stmt = stmt.where(TradeSetup.direction == direction.lower()) stmt = stmt.where(TradeSetup.direction == direction.lower())
+10 -7
View File
@@ -1,9 +1,10 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { TradeSetup } from '../../lib/types'; import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent, formatDateTime } from '../../lib/format'; import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
import { primaryTarget } from '../../lib/qualification';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation'; 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'; export type SortDirection = 'asc' | 'desc';
interface TradeTableProps { interface TradeTableProps {
@@ -21,7 +22,7 @@ const columns: { key: SortColumn; label: string }[] = [
{ key: 'entry_price', label: 'Entry' }, { key: 'entry_price', label: 'Entry' },
{ key: 'stop_loss', label: 'Stop Loss' }, { key: 'stop_loss', label: 'Stop Loss' },
{ key: 'target', label: 'Target' }, { key: 'target', label: 'Target' },
{ key: 'best_target_probability', label: 'Best Target' }, { key: 'primary_target_probability', label: 'Primary Target' },
{ key: 'risk_amount', label: 'Risk $' }, { key: 'risk_amount', label: 'Risk $' },
{ key: 'reward_amount', label: 'Reward $' }, { key: 'reward_amount', label: 'Reward $' },
{ key: 'rr_ratio', label: 'R:R' }, { key: 'rr_ratio', label: 'R:R' },
@@ -65,10 +66,12 @@ function riskLevelClass(riskLevel: TradeSetup['risk_level']) {
return 'text-gray-400'; return 'text-gray-400';
} }
function bestTargetText(trade: TradeSetup) { // The starred primary — the same target the Overview and ticker details
if (!trade.targets || trade.targets.length === 0) return '—'; // headline, so every view agrees on which target a setup is "about".
const best = [...trade.targets].sort((a, b) => b.probability - a.probability)[0]; function primaryTargetText(trade: TradeSetup) {
return `${formatPrice(best.price)} (${best.probability.toFixed(0)}%)`; const primary = primaryTarget(trade);
if (!primary) return '—';
return `${formatPrice(primary.price)} (${primary.probability.toFixed(0)}%)`;
} }
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) { 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.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.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">{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.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 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> <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 { useMutation, useQueryClient } from '@tanstack/react-query';
import { useActivation } from '../../hooks/useActivation'; import { useActivation } from '../../hooks/useActivation';
import { useTrades } from '../../hooks/useTrades'; 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 { TradeTable, type SortColumn, type SortDirection, computeTradeAnalysis } from '../scanner/TradeTable';
import { SkeletonTable } from '../ui/Skeleton'; import { SkeletonTable } from '../ui/Skeleton';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
@@ -42,8 +42,8 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number {
case 'stop_pct': return analysis.stop_pct; case 'stop_pct': return analysis.stop_pct;
case 'target_pct': return analysis.target_pct; case 'target_pct': return analysis.target_pct;
case 'confidence_score': return trade.confidence_score ?? -1; case 'confidence_score': return trade.confidence_score ?? -1;
case 'best_target_probability': case 'primary_target_probability':
return trade.targets?.length ? Math.max(...trade.targets.map((t) => t.probability)) : -1; return primaryTargetProbability(trade) ?? -1;
case 'risk_level': case 'risk_level':
if (trade.risk_level === 'Low') return 1; if (trade.risk_level === 'Low') return 1;
if (trade.risk_level === 'Medium') return 2; if (trade.risk_level === 'Medium') return 2;
@@ -78,7 +78,7 @@ function sortTrades(
case 'stop_pct': case 'stop_pct':
case 'target_pct': case 'target_pct':
case 'confidence_score': case 'confidence_score':
case 'best_target_probability': case 'primary_target_probability':
case 'risk_level': case 'risk_level':
cmp = getComputedValue(a, column) - getComputedValue(b, column); cmp = getComputedValue(a, column) - getComputedValue(b, column);
break; 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']); const HIGH_CONVICTION_ACTIONS = new Set(['LONG_HIGH', 'SHORT_HIGH']);
@@ -16,15 +16,18 @@ function actionDirection(action: TradeSetup['recommended_action']): 'long' | 'sh
return 'neutral'; return 'neutral';
} }
export function bestTargetProbability(setup: TradeSetup): number { /** The starred primary target (the one the headline R:R refers to), falling
return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0; * 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). */ /** Probability of the starred primary target (the one the headline R:R refers to). */
export function primaryTargetProbability(setup: TradeSetup): number | null { export function primaryTargetProbability(setup: TradeSetup): number | null {
const primary = setup.targets?.find((t) => t.is_primary); return primaryTarget(setup)?.probability ?? null;
if (primary) return primary.probability;
return setup.targets?.length ? bestTargetProbability(setup) : null;
} }
/** R:R recomputed from the current price (0 if no reward/risk left). */ /** 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 ( from app.services.recommendation_service import (
_build_reasoning, _build_reasoning,
_choose_recommended_action, _choose_recommended_action,
_prune_floor_pinned_targets,
_select_primary_target, _select_primary_target,
direction_analyzer, direction_analyzer,
probability_estimator, probability_estimator,
@@ -154,6 +155,35 @@ def test_primary_target_requires_probability_floor():
assert primary["price"] == 112.0 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(): def test_detects_sentiment_technical_conflict():
conflicts = signal_conflict_detector.detect_conflicts( conflicts = signal_conflict_detector.detect_conflicts(
dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0}, 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.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore from app.models.sentiment import SentimentScore
from app.models.user import User 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: def _as_utc(value: datetime) -> datetime:
@@ -69,11 +73,11 @@ def _make_ohlcv_bars(
num_bars: int = 20, num_bars: int = 20,
base_close: float = 100.0, base_close: float = 100.0,
) -> list[OHLCVRecord]: ) -> 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] = [] bars: list[OHLCVRecord] = []
start = date(2024, 1, 1) start = date(2024, 1, 1)
for i in range(num_bars): 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( bars.append(OHLCVRecord(
ticker_id=ticker_id, ticker_id=ticker_id,
date=start + timedelta(days=i), 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 but all below the R:R threshold for their respective directions
- Levels in the right direction but below R:R threshold - 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 the price_level is above or below entry. So "wrong side" means all levels
are clustered near entry and below threshold in both directions. 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": []} return {"variant": variant, "levels": []}
else: # below_threshold else: # below_threshold
# All levels close to entry so R:R < 1.5 with risk 3 # All levels close to entry so R:R < 1.5 with risk ≈ 3
# For longs: reward < 4.5 price < 104.5 # For longs: reward < 4.5 → price < 104.5
# For shorts: reward < 4.5 price > 95.5 # For shorts: reward < 4.5 → price > 95.5
# Place all levels in the 96104 band (below threshold both ways) # Place all levels in the 96–104 band (below threshold both ways)
num = draw(st.integers(min_value=1, max_value=3)) num = draw(st.integers(min_value=1, max_value=3))
levels = [] levels = []
for _ in range(num): for _ in range(num):
@@ -139,7 +143,7 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
def single_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. """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"])) 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** """**Validates: Requirements 3.1, 3.2**
Property: when zero candidate S/R levels exist (no levels, wrong side, 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. original behavior.
""" """
from tests.conftest import _test_engine, _test_session_factory 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** """**Validates: Requirements 3.3**
Property: when exactly one candidate S/R level meets the R:R threshold, 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 tests.conftest import _test_engine, _test_session_factory
from app.database import Base 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 @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 @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, When exactly one resistance level above entry meets the R:R threshold,
it should be selected as the long setup target. 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") ticker = Ticker(symbol="SINGL")
scan_session.add(ticker) 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, When exactly one support level below entry meets the R:R threshold,
it should be selected as the short setup target. 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") ticker = Ticker(symbol="SINGS")
scan_session.add(ticker) 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 @pytest.mark.asyncio
async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades( async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
db_session: AsyncSession, 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: async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context """Stored setup frozen at scan time (conf 82, neutral) vs. current context
(bullish sentiment, composite 96) that yields live confidence 97.""" (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) 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 = ( old_reasoning = (
"LONG (high confidence): 82% with aligned signals " "LONG (high confidence): 82% with aligned signals "
"(technical=88, momentum=60, sentiment=neutral)." "(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.""" """min_confidence must judge the overlaid live confidence, not the stored one."""
await _seed_stale_setup_with_current_scores(db_session) 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. # Live confidence is 97, so it must pass.
rows = await get_trade_setups( rows = await get_trade_setups(
db_session, 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: 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") ticker = Ticker(symbol="BOTH")
db_session.add(ticker) db_session.add(ticker)
await db_session.flush() 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( async def test_live_overlay_preserves_setup_specific_risk_and_context(
db_session: AsyncSession, db_session: AsyncSession,
): ):
current = datetime(2026, 7, 3, tzinfo=timezone.utc) current = datetime.now(timezone.utc)
ticker = Ticker(symbol="RISK") ticker = Ticker(symbol="RISK")
db_session.add(ticker) db_session.add(ticker)
await db_session.flush() 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( async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
db_session: AsyncSession, db_session: AsyncSession,
): ):
current = datetime(2026, 7, 3, tzinfo=timezone.utc) current = datetime.now(timezone.utc)
ticker = Ticker(symbol="LIVEP") ticker = Ticker(symbol="LIVEP")
db_session.add(ticker) db_session.add(ticker)
await db_session.flush() await db_session.flush()