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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user