From fdc49d0e281c8c0824f2b5ab9bbe89c2028b5521 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 11 Jul 2026 10:06:34 +0200 Subject: [PATCH] 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 --- app/services/backtest_service.py | 4 + app/services/recommendation_service.py | 31 ++++++- app/services/rr_scanner_service.py | 20 ++++- .../src/components/scanner/TradeTable.tsx | 17 ++-- .../src/components/signals/SetupsPanel.tsx | 8 +- frontend/src/lib/qualification.ts | 15 ++-- tests/unit/test_recommendation_service.py | 30 +++++++ tests/unit/test_rr_scanner_preservation.py | 87 ++++++++++++++----- 8 files changed, 170 insertions(+), 42 deletions(-) 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()