From b0e33e16064f91986737c0f14813f95a3a7a656f Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sat, 18 Jul 2026 13:03:22 +0200 Subject: [PATCH] fix: align production defaults and close review parity gaps Ship greenfield min_rr=2.0 and conf=0, read-only Structural S/R, indicator cache invalidation, and UI/gate language that treats GTL as screening not exit. Align strategy_rank missing-vol fallback live vs backtest, single-source PRIMARY_TARGET_MIN_RR, expand prod parity tests, and drop dead FE clients. --- app/routers/trades.py | 12 +- app/services/admin_service.py | 4 +- app/services/backtest_service.py | 30 +++- app/services/momentum_service.py | 75 ++++----- app/services/outcome_service.py | 12 +- app/services/price_service.py | 5 +- app/services/recommendation_service.py | 4 + app/services/rr_scanner_service.py | 15 +- app/services/sr_service.py | 15 +- frontend/src/api/admin.ts | 6 - frontend/src/api/trades.ts | 4 - .../components/admin/ActivationSettings.tsx | 5 +- frontend/src/components/charts/horizon.tsx | 8 +- .../src/components/scanner/TradeTable.tsx | 52 ++++-- .../src/components/signals/SetupsPanel.tsx | 18 ++- .../components/signals/TrackRecordPanel.tsx | 16 +- .../components/ticker/RecommendationPanel.tsx | 42 +++-- frontend/src/components/ticker/SROverlay.tsx | 32 ---- frontend/src/lib/qualification.ts | 6 +- tests/unit/test_activation_settings.py | 4 +- tests/unit/test_momentum_service.py | 17 +- tests/unit/test_prod_strategy_parity.py | 150 ++++++++++++++++++ tests/unit/test_rr_scanner_bug_exploration.py | 21 ++- tests/unit/test_rr_scanner_fix_check.py | 39 ++--- tests/unit/test_rr_scanner_integration.py | 58 ++++--- 25 files changed, 429 insertions(+), 221 deletions(-) delete mode 100644 frontend/src/components/ticker/SROverlay.tsx diff --git a/app/routers/trades.py b/app/routers/trades.py index 562868f..1059089 100644 --- a/app/routers/trades.py +++ b/app/routers/trades.py @@ -76,13 +76,13 @@ async def get_trade_performance( _user=Depends(require_access), db: AsyncSession = Depends(get_db), ) -> APIEnvelope: - """Aggregate outcome statistics over evaluated trade setups. + """Aggregate setup-outcome statistics (gate barrier diagnostic). - Outcomes are written by the nightly outcome_evaluator job (win = target - hit first, loss = stop hit first, expired = neither within the window). - With qualified_only, the overall/direction/action breakdowns cover only - setups clearing the activation gate; the confidence breakdown always - covers all setups so the gate can be validated against it. + Outcomes come from the nightly outcome_evaluator: win = gate target first, + loss = stop first, expired = neither in the window. This is **not** the + production ATR-trail book; it checks setup grading plumbing only. + With qualified_only, overall/direction/action cover only gate-clearing + setups; the confidence breakdown always covers all setups. """ config = await admin_service.get_activation_config(db) if qualified_only else None stats = await get_performance_stats(db, config=config) diff --git a/app/services/admin_service.py b/app/services/admin_service.py index fe0cc4e..8353b2a 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -54,7 +54,9 @@ _ACTIVATION_BOOL_KEYS: dict[str, str] = { } ACTIVATION_DEFAULTS: dict[str, float | bool] = { "min_momentum_percentile": 80.0, - "min_rr": 1.2, + # Production floor from the 2026-07-12 min_rr sweep (in-sample and OOS peak). + # 1.2 was the old code default and the trough next to the spike — do not restore. + "min_rr": 2.0, # 0 = off. The July 2026 gate ablation showed the confidence floor added # nothing (identical net/trade with it removed, under both exit models) # while cutting ~25% of qualified trades. diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index f3e9566..c07fa51 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -59,6 +59,7 @@ from app.services.admin_service import get_activation_config, update_setting from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.momentum_service import ( STRATEGY_RANK_MOMENTUM_WEIGHT, + blend_strategy_rank, compute_realized_vol_6m, ) from app.services.outcome_service import ( @@ -77,6 +78,7 @@ from app.services.qualification import ( setup_qualifies, ) from app.services.recommendation_service import ( + PRIMARY_TARGET_MIN_RR, _choose_recommended_action, _classify_by_probability, _prune_floor_pinned_targets, @@ -337,11 +339,11 @@ def _window_setups( targets = _prune_floor_pinned_targets(targets) primary = _select_primary_target( targets, - min_rr=1.5, + min_rr=PRIMARY_TARGET_MIN_RR, ) if primary is None: continue - # Flag the primary so qualification's EV uses the primary target's + # Flag the primary so qualification uses the primary target's # probability (matching production's enhance_trade_setup). for t in targets: t["is_primary"] = t is primary @@ -1265,15 +1267,27 @@ def _assign_weighted_blend( primary_weight: float, secondary_key: str, ) -> None: - secondary_weight = 1.0 - primary_weight + """Blend ranks; fall back to primary when secondary is missing. + + Matches live ``blend_strategy_rank`` for the production 80/20 key: a name + with residual momentum but no vol history keeps its mom percentile instead + of ranking as 0 / None at the bottom of the book. + """ for c in candidates: primary = c.get(primary_key) secondary = c.get(secondary_key) - c[output_key] = ( - primary * primary_weight + secondary * secondary_weight - if primary is not None and secondary is not None - else None - ) + # Production weight path: reuse the shared helper so live/sim cannot drift. + if primary_weight == STRATEGY_RANK_MOMENTUM_WEIGHT: + c[output_key] = blend_strategy_rank( + None if primary is None else float(primary), + None if secondary is None else float(secondary), + momentum_weight=primary_weight, + ) + continue + if primary is not None and secondary is not None: + c[output_key] = primary * primary_weight + secondary * (1.0 - primary_weight) + else: + c[output_key] = primary def _assign_residual_low_vol_blend(candidates: list[dict]) -> None: diff --git a/app/services/momentum_service.py b/app/services/momentum_service.py index 534f637..20758aa 100644 --- a/app/services/momentum_service.py +++ b/app/services/momentum_service.py @@ -34,6 +34,28 @@ STRATEGY_RANK_MOMENTUM_WEIGHT = 0.8 STRATEGY_RANK_VOL_WEIGHT = 1.0 - STRATEGY_RANK_MOMENTUM_WEIGHT +def blend_strategy_rank( + momentum_percentile: float | None, + volatility_percentile: float | None, + *, + momentum_weight: float = STRATEGY_RANK_MOMENTUM_WEIGHT, +) -> float | None: + """80/20 production rank with mom-only fallback when vol is missing. + + Live and backtest must share this policy: missing vol must not send a + residual-qualified name to the bottom of the book (that was the old + backtest behaviour when either leg was None). + """ + if momentum_percentile is not None and volatility_percentile is not None: + vol_weight = 1.0 - momentum_weight + return round( + float(momentum_percentile) * momentum_weight + + float(volatility_percentile) * vol_weight, + 2, + ) + return float(momentum_percentile) if momentum_percentile is not None else None + + def compute_12_1_momentum(closes: list[float]) -> float | None: """Return over the window ending ~1 month ago, starting ~12 months ago. None when there isn't a full year of history.""" @@ -100,41 +122,17 @@ async def _load_activation_benchmark(db: AsyncSession) -> dict[date, float]: async def compute_momentum_percentiles(db: AsyncSession) -> dict[str, float]: - """Compute each ticker's activation momentum rank. + """Momentum leg only — thin view of ``compute_activation_ranks``. - Production uses residual 12-1 momentum when benchmark data is available. If - SPY data is absent, fall back to raw 12-1 momentum rather than disabling the - scanner. Tickers without enough stock/benchmark history are absent. + Prefer ``compute_activation_ranks`` in new code (includes vol + strategy_rank). + Kept so tests/helpers that only need the residual/raw percentile map stay simple. """ - result = await db.execute(select(Ticker).order_by(Ticker.symbol)) - tickers = list(result.scalars().all()) - - benchmark_closes = await _load_activation_benchmark(db) - using_residual = len(benchmark_closes) >= _MOM_LOOKBACK - - values: dict[str, float] = {} - for ticker in tickers: - try: - records = await query_ohlcv(db, ticker.symbol) - except Exception: - logger.exception("Momentum fetch failed for %s", ticker.symbol) - continue - closes = [float(r.close) for r in records] - value = ( - compute_residual_12_1_momentum([r.date for r in records], closes, benchmark_closes) - if using_residual - else compute_12_1_momentum(closes) - ) - if value is not None: - values[ticker.symbol] = value - - percentiles = _percentiles(values) - logger.info(json.dumps({ - "event": "momentum_ranked", - "signal": "residual_12_1" if using_residual else "raw_12_1_fallback", - "tickers": len(percentiles), - })) - return percentiles + ranks = await compute_activation_ranks(db) + return { + sym: float(row["momentum_percentile"]) + for sym, row in ranks.items() + if row.get("momentum_percentile") is not None + } def compute_realized_vol_6m(closes: list[float]) -> float | None: @@ -204,19 +202,10 @@ async def compute_activation_ranks(db: AsyncSession) -> dict[str, dict[str, floa for sym in symbols: momentum_pct = momentum_percentiles.get(sym) vol_pct = vol_percentiles.get(sym) - strategy_rank = ( - round( - momentum_pct * STRATEGY_RANK_MOMENTUM_WEIGHT - + vol_pct * STRATEGY_RANK_VOL_WEIGHT, - 2, - ) - if momentum_pct is not None and vol_pct is not None - else momentum_pct - ) ranks[sym] = { "momentum_percentile": momentum_pct, "volatility_percentile": vol_pct, - "strategy_rank": strategy_rank, + "strategy_rank": blend_strategy_rank(momentum_pct, vol_pct), } logger.info(json.dumps({ diff --git a/app/services/outcome_service.py b/app/services/outcome_service.py index 5bb6a79..0b8cd52 100644 --- a/app/services/outcome_service.py +++ b/app/services/outcome_service.py @@ -1,11 +1,15 @@ """Trade setup outcome evaluation service. -Closes the feedback loop on R:R scanner setups: walks daily OHLCV bars -after detection and records whether the stop or the target was hit first. +Diagnostic barrier resolution for scanner setups: walks daily OHLCV bars +after detection and records whether the gate target or the stop was hit first. + +This is **not** the production exit model. Live paper trades and the portfolio +monitor use ATR trail / max hold and never exit at the gate target. Track-record +stats from this path measure gate-level plumbing, not ATR-trail book expectancy. Outcome semantics (entry is the close at detection time, i.e. market entry): - - target_hit: target reached before the stop - - stop_hit: stop reached before the target + - target_hit: gate target reached before the stop + - stop_hit: stop reached before the gate target - ambiguous: stop AND target both within the same daily bar — with daily granularity the order is unknowable, counted as a loss in stats - expired: neither level hit within ``max_bars`` trading days diff --git a/app/services/price_service.py b/app/services/price_service.py index e9c0ad1..e655bcd 100644 --- a/app/services/price_service.py +++ b/app/services/price_service.py @@ -80,8 +80,9 @@ async def upsert_ohlcv( record = result.scalar_one() - # TODO: Invalidate LRU cache entries for this ticker (Task 7.1) - # TODO: Mark composite score as stale for this ticker (Task 10.1) + from app.cache import indicator_cache + + indicator_cache.invalidate_ticker(ticker.symbol) return record diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index d5e48a5..c5d46b3 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -618,6 +618,10 @@ def build_recommendation_snapshot( # agree on what counts as a probability-backed target. PRIMARY_TARGET_MIN_PROBABILITY = MIN_TARGET_PROBABILITY +# Primary-target selector floor (independent of the live activation min_rr). +# Live scanner and backtest setup replay must share this constant. +PRIMARY_TARGET_MIN_RR = 1.5 + def _prune_floor_pinned_targets(targets: list[dict]) -> list[dict]: """Keep only the nearest target pinned at the probability clamp floor. diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index f3ba0d9..d0ed6dd 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -35,6 +35,7 @@ from app.services.trade_policy import ( observe_reentry_gate_transitions, ) from app.services.recommendation_service import ( + PRIMARY_TARGET_MIN_RR, _risk_level_from_conflicts, build_recommendation_snapshot, enhance_trade_setup, @@ -44,7 +45,6 @@ from app.services.recommendation_service import ( logger = logging.getLogger(__name__) STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1" -PRIMARY_TARGET_MIN_RR = 1.5 # 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 @@ -743,14 +743,15 @@ async def scan_all_tickers( for index, (ticker_id, symbol) in enumerate(ticker_rows): if progress_callback is not None: progress_callback(index, total, symbol) - # Refresh scores first so the scheduled scan works off current data. - # Nothing else marks scores stale, so without this they'd never update - # for tickers the user doesn't manually fetch. A refresh failure still - # scans the ticker: qualification re-gates on live scores at alert - # time, so a stale score is recoverable but a skipped scan is not. + # Refresh Structural S/R once, then scores. get_sr_levels is read-only; + # without this recalculate the score path would see yesterday's zones. + # A refresh failure still scans the ticker: qualification re-gates on + # live scores at alert time, so a stale score is recoverable but a + # skipped scan is not. try: - from app.services import scoring_service + from app.services import scoring_service, sr_service + await sr_service.recalculate_sr_levels(db, symbol) await scoring_service.compute_all_dimensions(db, symbol) await scoring_service.compute_composite_score(db, symbol) await db.commit() diff --git a/app/services/sr_service.py b/app/services/sr_service.py index 116bf7c..440af02 100644 --- a/app/services/sr_service.py +++ b/app/services/sr_service.py @@ -857,8 +857,17 @@ async def get_sr_levels( symbol: str, tolerance: float | None = None, ) -> list[SRLevel]: - """Get S/R levels for a ticker, recalculating on every request (MVP). + """Return persisted Structural S/R levels, strength descending. - Returns levels sorted by strength descending. + Read-only: does not recompute or rewrite. Pipeline/ingestion call + ``recalculate_sr_levels`` to refresh. ``tolerance`` is kept for API + compatibility and ignored on read (it only applies at recalculation). """ - return await recalculate_sr_levels(db, symbol, tolerance) + del tolerance # API compat only; levels were stored at last recalculation + ticker = await _get_ticker(db, symbol) + result = await db.execute( + select(SRLevel) + .where(SRLevel.ticker_id == ticker.id) + .order_by(SRLevel.strength.desc()) + ) + return list(result.scalars().all()) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 4493aa7..117bdd6 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -56,12 +56,6 @@ export function updateSetting(key: string, value: string) { .then((r) => r.data); } -export function updateRegistration(enabled: boolean) { - return apiClient - .put<{ message: string }>('admin/settings/registration', { enabled }) - .then((r) => r.data); -} - export function getRecommendationSettings() { return apiClient .get('admin/settings/recommendations') diff --git a/frontend/src/api/trades.ts b/frontend/src/api/trades.ts index aa7acd7..89f5140 100644 --- a/frontend/src/api/trades.ts +++ b/frontend/src/api/trades.ts @@ -14,7 +14,3 @@ export function list(params?: TradeListParams) { export function bySymbol(symbol: string) { return apiClient.get(`trades/${symbol.toUpperCase()}`).then((r) => r.data); } - -export function history(symbol: string) { - return apiClient.get(`trades/${symbol.toUpperCase()}/history`).then((r) => r.data); -} diff --git a/frontend/src/components/admin/ActivationSettings.tsx b/frontend/src/components/admin/ActivationSettings.tsx index bd00d6e..80acab9 100644 --- a/frontend/src/components/admin/ActivationSettings.tsx +++ b/frontend/src/components/admin/ActivationSettings.tsx @@ -3,10 +3,11 @@ import type { ActivationConfig } from '../../lib/types'; import { useActivationSettings, useUpdateActivationSettings } from '../../hooks/useAdmin'; import { SkeletonTable } from '../ui/Skeleton'; +/** Mirrors app.services.admin_service.ACTIVATION_DEFAULTS — keep in sync. */ const DEFAULTS: ActivationConfig = { min_momentum_percentile: 80, - min_rr: 1.2, - min_confidence: 55, + min_rr: 2.0, + min_confidence: 0, require_high_conviction: false, exclude_conflicts: false, exclude_neutral: true, diff --git a/frontend/src/components/charts/horizon.tsx b/frontend/src/components/charts/horizon.tsx index 1cbc07c..04e3011 100644 --- a/frontend/src/components/charts/horizon.tsx +++ b/frontend/src/components/charts/horizon.tsx @@ -39,7 +39,7 @@ export function RBar({ r, max = 1.6 }: { r: number | null; max?: number }) { } /* ------------------------------------------------------------------ */ -/* PriceRail — stop → entry → now → target laid out spatially */ +/* PriceRail — stop → entry → now → gate level laid out spatially */ /* ------------------------------------------------------------------ */ export function PriceRail({ @@ -69,7 +69,7 @@ export function PriceRail({ const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0; return (
)} -
+
- target + gate {fmt(target)} {rTarget != null && +{fmt(rTarget, 1)}R} diff --git a/frontend/src/components/scanner/TradeTable.tsx b/frontend/src/components/scanner/TradeTable.tsx index 6cc0b0a..665f30d 100644 --- a/frontend/src/components/scanner/TradeTable.tsx +++ b/frontend/src/components/scanner/TradeTable.tsx @@ -4,7 +4,25 @@ 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' | 'primary_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' + | 'strategy_rank' + | 'momentum_percentile' + | 'detected_at'; export type SortDirection = 'asc' | 'desc'; interface TradeTableProps { @@ -16,20 +34,22 @@ interface TradeTableProps { const columns: { key: SortColumn; label: string }[] = [ { key: 'symbol', label: 'Symbol' }, + { key: 'strategy_rank', label: 'Prod rank' }, + { key: 'momentum_percentile', label: 'Mom %ile' }, { key: 'recommended_action', label: 'Recommended Action' }, { key: 'confidence_score', label: 'Confidence' }, { key: 'direction', label: 'Direction' }, { key: 'entry_price', label: 'Entry' }, { key: 'stop_loss', label: 'Stop Loss' }, - { key: 'target', label: 'Target' }, - { key: 'primary_target_probability', label: 'Primary Target' }, + { key: 'target', label: 'Gate level' }, + { key: 'primary_target_probability', label: 'Gate reach' }, { key: 'risk_amount', label: 'Risk $' }, { key: 'reward_amount', label: 'Reward $' }, - { key: 'rr_ratio', label: 'R:R' }, + { key: 'rr_ratio', label: 'Gate R:R' }, { key: 'stop_pct', label: '% to Stop' }, - { key: 'target_pct', label: '% to Target' }, + { key: 'target_pct', label: '% to gate' }, { key: 'risk_level', label: 'Risk' }, - { key: 'composite_score', label: 'Score' }, + { key: 'composite_score', label: 'Composite' }, { key: 'detected_at', label: 'Detected' }, ]; @@ -105,6 +125,12 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT {trade.symbol} + + {trade.strategy_rank != null ? trade.strategy_rank.toFixed(1) : '—'} + + + {trade.momentum_percentile != null ? trade.momentum_percentile.toFixed(0) : '—'} +
{recommendationActionLabel(trade.recommended_action)} @@ -123,15 +149,21 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT {formatPrice(trade.entry_price)} {formatPrice(trade.stop_loss)} - {formatPrice(trade.target)} - {primaryTargetText(trade)} + + {formatPrice(trade.target)} + + + {primaryTargetText(trade)} + {formatPrice(analysis.risk_amount)} {formatPrice(analysis.reward_amount)} - {trade.rr_ratio.toFixed(2)} + + {trade.rr_ratio.toFixed(2)} + {formatPercent(analysis.stop_pct)} {formatPercent(analysis.target_pct)} {trade.risk_level ?? '—'} - + 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}> {Math.round(trade.composite_score)} diff --git a/frontend/src/components/signals/SetupsPanel.tsx b/frontend/src/components/signals/SetupsPanel.tsx index c9ac325..f6f0199 100644 --- a/frontend/src/components/signals/SetupsPanel.tsx +++ b/frontend/src/components/signals/SetupsPanel.tsx @@ -44,6 +44,10 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number { case 'confidence_score': return trade.confidence_score ?? -1; case 'primary_target_probability': return primaryTargetProbability(trade) ?? -1; + case 'strategy_rank': + return trade.strategy_rank ?? trade.momentum_percentile ?? -1; + case 'momentum_percentile': + return trade.momentum_percentile ?? -1; case 'risk_level': if (trade.risk_level === 'Low') return 1; if (trade.risk_level === 'Medium') return 2; @@ -79,6 +83,8 @@ function sortTrades( case 'target_pct': case 'confidence_score': case 'primary_target_probability': + case 'strategy_rank': + case 'momentum_percentile': case 'risk_level': cmp = getComputedValue(a, column) - getComputedValue(b, column); break; @@ -108,7 +114,8 @@ export function SetupsPanel() { const [minConfidence, setMinConfidence] = useState(0); const [directionFilter, setDirectionFilter] = useState('both'); const [actionFilter, setActionFilter] = useState('all'); - const [sortColumn, setSortColumn] = useState('rr_ratio'); + // Production book orders by 80/20 strategy_rank, not raw R:R. + const [sortColumn, setSortColumn] = useState('strategy_rank'); const [sortDirection, setSortDirection] = useState('desc'); // Keep the Min R:R / Min Confidence inputs showing the *effective* floor: when @@ -244,10 +251,11 @@ export function SetupsPanel() {

- The scanner identifies asymmetric risk-reward trade setups by analyzing S/R levels as - price targets and using ATR-based stops to define risk. Click{' '} - Run Scanner to scan all tickers now, - or wait for the scheduled run. + The scanner builds long setups with a 1.5× ATR stop and a Gate Target Ladder proposal used + only for R:R / reach-probability screening — not as a take-profit. Structural chart S/R is + separate. Live exit is the ATR trail / max hold. Click{' '} + Run Scanner to scan all tickers now, or + wait for the scheduled run.

{RECOMMENDATION_ACTION_GLOSSARY.map((item) => ( diff --git a/frontend/src/components/signals/TrackRecordPanel.tsx b/frontend/src/components/signals/TrackRecordPanel.tsx index 76a5e77..5b107ac 100644 --- a/frontend/src/components/signals/TrackRecordPanel.tsx +++ b/frontend/src/components/signals/TrackRecordPanel.tsx @@ -109,19 +109,19 @@ export function TrackRecordPanel() {

- The live check replays every setup against the daily bars after detection: target before stop = - win, stop first = loss (both in one bar counts conservatively as a loss), neither within 30 - trading days = expired at 0R. Only setups whose full window has elapsed count; younger ones are - still maturing (near stops resolve fast, far targets need time, so early numbers skew negative). - The evaluator scores all setups — qualified or not, so - unqualified ones stay a control group — and runs nightly. + Diagnostic only — not production P&L.{' '} + Grades gate-level touch vs stop (the rejected take-profit model). Production exits are + initial stop / ATR trail / max hold — see paper trades and the portfolio monitor above. + Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading + days = expired at 0R. Only matured windows count. Scores{' '} + all setups as a control group; runs nightly.

{/* Diagnostic, not strategy validation: live target/stop outcomes vs the backtest's target/stop model. */}
- Setup-outcome pipeline check + Gate barrier pipeline check Live {fmtR(liveAvgR)} @@ -129,7 +129,7 @@ export function TrackRecordPanel() { Backtest {fmtR(btAvgR)} - {liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · qualified target/stop + {liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · not ATR-trail book
diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 06ddbb8..0040888 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -203,6 +203,31 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele selectedPrice?: number | null; onSelectPrice?: (price: number) => void; }) { + // Hooks must run unconditionally (Rules of Hooks) even when setup is missing. + const createTrade = useCreatePaperTrade(); + const [taking, setTaking] = useState(false); + const [takeShares, setTakeShares] = useState(0); + const [takeEntry, setTakeEntry] = useState(0); + const [takeTarget, setTakeTarget] = useState(0); + const [internalSel, setInternalSel] = useState(null); + + useEffect(() => { + if (!setup) return; + const next = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss); + setTakeShares(next?.shares ?? 0); + setTakeEntry(currentPrice ?? setup.entry_price); + setTakeTarget(setup.target); + }, [setup, currentPrice, risk.accountSize, risk.riskPct]); + + useEffect(() => { + if (!taking) return; + const onKey = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') setTaking(false); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [taking]); + if (!setup) { return (
@@ -222,16 +247,9 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele const exitPlan = deriveExitPlan(setup, exitPolicy); const honorsTarget = exitPlan?.honorsTarget ?? false; - const createTrade = useCreatePaperTrade(); - const [taking, setTaking] = useState(false); - const [takeShares, setTakeShares] = useState(sizing?.shares ?? 0); - const [takeEntry, setTakeEntry] = useState(currentPrice ?? setup.entry_price); - const [takeTarget, setTakeTarget] = useState(setup.target); - // Target choice from the ladder drives the rail, the chips, and the take // flow — the scanner's primary is just the default. Controlled by the page // when provided (so the candlestick overlay follows), else local. - const [internalSel, setInternalSel] = useState(null); const selPrice = selectedPrice !== undefined ? selectedPrice : internalSel; const selectTargetPrice = (p: number) => { if (onSelectPrice) onSelectPrice(p); @@ -242,16 +260,6 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele const activeRR = selected?.rr_ratio ?? setup.rr_ratio; const activeProb = selected?.probability ?? prob; - // Close the take dialog on Escape. - useEffect(() => { - if (!taking) return; - const onKey = (e: globalThis.KeyboardEvent) => { - if (e.key === 'Escape') setTaking(false); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [taking]); - const confirmTake = () => { createTrade.mutate( { diff --git a/frontend/src/components/ticker/SROverlay.tsx b/frontend/src/components/ticker/SROverlay.tsx deleted file mode 100644 index 229b64d..0000000 --- a/frontend/src/components/ticker/SROverlay.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ReferenceLine } from 'recharts'; -import type { SRLevel } from '../../lib/types'; -import { formatPrice } from '../../lib/format'; - -interface SROverlayProps { - levels: SRLevel[]; -} - -export function SROverlay({ levels }: SROverlayProps) { - return ( - <> - {levels.map((level) => { - const isSupport = level.type === 'support'; - return ( - - ); - })} - - ); -} diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index c6414b7..1308320 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -118,9 +118,9 @@ export function disqualifyReason(setup: TradeSetup, config: ActivationConfig): s /** * Symbol of the current single 'top pick' — the #1 row the dashboard highlights: - * the highest residual 12-1 momentum percentile among qualified setups. Returns - * null when there are no actionable setups. Keep in step with the Top Setups - * ranking in DashboardPage. + * highest production strategy_rank (80/20 mom/vol) among qualified setups, + * falling back to residual momentum percentile. Returns null when there are no + * actionable setups. Keep in step with the Top Setups ranking in DashboardPage. */ export function topPickSymbol( trades: TradeSetup[] | undefined, diff --git a/tests/unit/test_activation_settings.py b/tests/unit/test_activation_settings.py index 3c45d87..5a2f36f 100644 --- a/tests/unit/test_activation_settings.py +++ b/tests/unit/test_activation_settings.py @@ -26,7 +26,7 @@ class TestActivationConfig: config = await get_activation_config(session) assert config == { "min_momentum_percentile": 80.0, - "min_rr": 1.2, + "min_rr": 2.0, "min_confidence": 0.0, # off — the July 2026 ablation showed it adds nothing "require_high_conviction": False, "exclude_conflicts": False, @@ -47,7 +47,7 @@ class TestActivationConfig: async def test_partial_update_keeps_other_value(self, session: AsyncSession): await update_activation_config(session, {"min_confidence": 80.0}) config = await get_activation_config(session) - assert config["min_rr"] == 1.2 # default untouched + assert config["min_rr"] == 2.0 # default untouched assert config["min_confidence"] == 80.0 async def test_rejects_out_of_range_momentum_percentile(self, session: AsyncSession): diff --git a/tests/unit/test_momentum_service.py b/tests/unit/test_momentum_service.py index bb9c19c..dab2fc0 100644 --- a/tests/unit/test_momentum_service.py +++ b/tests/unit/test_momentum_service.py @@ -71,10 +71,13 @@ async def test_ranks_universe_into_raw_percentiles_when_benchmark_missing(sessio await _seed(session, "MID", rate=1.002) await _seed(session, "LOW", rate=0.999) # declining → bottom momentum + ranks = await ms.compute_activation_ranks(session) + assert ranks["HIGH"]["momentum_percentile"] == 100.0 + assert ranks["MID"]["momentum_percentile"] == 50.0 + assert ranks["LOW"]["momentum_percentile"] == 0.0 + # Thin momentum-only view stays aligned with the production ranker. pct = await ms.compute_momentum_percentiles(session) - assert pct["HIGH"] == 100.0 - assert pct["MID"] == 50.0 - assert pct["LOW"] == 0.0 + assert pct == {s: ranks[s]["momentum_percentile"] for s in pct} async def test_ranks_universe_into_residual_percentiles_when_benchmark_available(session, monkeypatch): @@ -91,6 +94,10 @@ async def test_ranks_universe_into_residual_percentiles_when_benchmark_available await _seed_closes(session, "BETA", market) await _seed_closes(session, "LAG", [market[i] * (0.9992 ** i) for i in range(n)]) + ranks = await ms.compute_activation_ranks(session) + assert ranks["DRIFT"]["momentum_percentile"] == 100.0 + assert ranks["BETA"]["momentum_percentile"] == 50.0 + assert ranks["LAG"]["momentum_percentile"] == 0.0 pct = await ms.compute_momentum_percentiles(session) assert pct["DRIFT"] == 100.0 assert pct["BETA"] == 50.0 @@ -105,6 +112,9 @@ async def test_short_history_ticker_is_unranked(session, monkeypatch): await _seed(session, "LONG", rate=1.005) await _seed(session, "SHORTHX", rate=1.005, n=100) # < 1y → no momentum + ranks = await ms.compute_activation_ranks(session) + assert "LONG" in ranks and ranks["LONG"]["momentum_percentile"] is not None + assert "SHORTHX" not in ranks or ranks["SHORTHX"]["momentum_percentile"] is None pct = await ms.compute_momentum_percentiles(session) assert "LONG" in pct assert "SHORTHX" not in pct @@ -115,4 +125,5 @@ async def test_empty_universe_returns_empty(session, monkeypatch): return {} monkeypatch.setattr(ms, "_load_activation_benchmark", no_benchmark) + assert await ms.compute_activation_ranks(session) == {} assert await ms.compute_momentum_percentiles(session) == {} diff --git a/tests/unit/test_prod_strategy_parity.py b/tests/unit/test_prod_strategy_parity.py index c715f50..b5cec73 100644 --- a/tests/unit/test_prod_strategy_parity.py +++ b/tests/unit/test_prod_strategy_parity.py @@ -26,7 +26,11 @@ from app.services.backtest_service import ( from app.services.momentum_service import ( STRATEGY_RANK_MOMENTUM_WEIGHT, STRATEGY_RANK_VOL_WEIGHT, + blend_strategy_rank, ) +from app.services.qualification import MIN_TARGET_PROBABILITY +from app.services.recommendation_service import PRIMARY_TARGET_MIN_RR +from app.services import rr_scanner_service def _production_monitor_row() -> dict: @@ -103,3 +107,149 @@ def test_live_gate_equals_the_production_variant_gate() -> None: assert _momentum_qualifies(cand, cutoff) == _qualifies_strategy_variant( cand, entry_cfg ), cand + + +def test_activation_defaults_match_promoted_production_gate() -> None: + """Greenfield Admin must ship the researched gate, not the old trough defaults.""" + assert float(ACTIVATION_DEFAULTS["min_rr"]) == 2.0 + assert float(ACTIVATION_DEFAULTS["min_confidence"]) == 0.0 + assert float(ACTIVATION_DEFAULTS["min_momentum_percentile"]) == 80.0 + assert ACTIVATION_DEFAULTS["exclude_neutral"] is True + + +def test_primary_target_rr_floor_is_single_sourced() -> None: + assert rr_scanner_service.PRIMARY_TARGET_MIN_RR == PRIMARY_TARGET_MIN_RR + assert PRIMARY_TARGET_MIN_RR == 1.5 + assert MIN_TARGET_PROBABILITY == 20.0 + + +def test_strategy_rank_falls_back_to_momentum_when_vol_missing() -> None: + """Live and backtest must not bury a name solely because vol history is short.""" + assert blend_strategy_rank(80.0, 60.0) == 76.0 + assert blend_strategy_rank(80.0, None) == 80.0 + assert blend_strategy_rank(None, 60.0) is None + assert blend_strategy_rank(None, None) is None + + from app.services import backtest_service as bt + + cands = [ + {bt.PRODUCTION_PERCENTILE_KEY: 80.0, bt.VOL_PERCENTILE_KEY: None}, + {bt.PRODUCTION_PERCENTILE_KEY: 70.0, bt.VOL_PERCENTILE_KEY: 50.0}, + ] + bt._assign_residual_high_vol_blend(cands) + assert cands[0][bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] == 80.0 + assert cands[1][bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] == 66.0 + + +@pytest.mark.asyncio +async def test_live_scan_and_backtest_window_share_gtl_primary() -> None: + """Same OHLCV + dims: live scan_ticker primary ≡ backtest _window_setups. + + No gate_levels_override — both paths build the production GTL from bars. + Dimension scores are seeded to the values the backtest window computes so + probability ranking cannot diverge for that reason alone. + """ + from datetime import date, datetime, timedelta, timezone + + from app.models.ohlcv import OHLCVRecord + from app.models.score import DimensionScore + from app.models.ticker import Ticker + from app.services import backtest_service as bt + from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG + from app.services.rr_scanner_service import scan_ticker + from app.services.scoring_service import ( + compute_momentum_from_closes, + compute_technical_from_arrays, + ) + from tests.conftest import _test_session_factory + + n = 120 + base = date(2024, 1, 1) + # Oscillating range so GTL finds traffic-backed proposals above/below spot. + closes: list[float] = [] + highs: list[float] = [] + lows: list[float] = [] + volumes: list[int] = [] + price = 100.0 + for i in range(n): + phase = i % 30 + if phase < 12: + price = price + (94.0 - price) * 0.2 + elif phase < 24: + price = price + (108.0 - price) * 0.2 + else: + price = 100.0 + (i % 5) * 0.3 + high = price + 1.2 + low = price - 1.2 + close = price + closes.append(close) + highs.append(high) + lows.append(low) + volumes.append(100_000 + i * 10) + + tech = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0 + mom = (compute_momentum_from_closes(closes)[0]) or 50.0 + + async with _test_session_factory() as session: + ticker = Ticker(symbol="GTLPAR") + session.add(ticker) + await session.flush() + bars = [ + OHLCVRecord( + ticker_id=ticker.id, + date=base + timedelta(days=i), + open=closes[i] - 0.2, + high=highs[i], + low=lows[i], + close=closes[i], + volume=volumes[i], + ) + for i in range(n) + ] + session.add_all(bars) + now = datetime.now(timezone.utc) + session.add_all([ + DimensionScore( + ticker_id=ticker.id, dimension="technical", score=float(tech), + is_stale=False, computed_at=now, + ), + DimensionScore( + ticker_id=ticker.id, dimension="momentum", score=float(mom), + is_stale=False, computed_at=now, + ), + ]) + await session.commit() + + live = await scan_ticker(session, "GTLPAR", rr_threshold=1.5, atr_multiplier=1.5) + # Re-load bars as plain ORM list for the pure backtest window path. + from sqlalchemy import select + + records = list( + ( + await session.execute( + select(OHLCVRecord) + .where(OHLCVRecord.ticker_id == ticker.id) + .order_by(OHLCVRecord.date.asc()) + ) + ).scalars().all() + ) + + config = dict(DEFAULT_RECOMMENDATION_CONFIG) + activation = dict(ACTIVATION_DEFAULTS) + sim = bt._window_setups(records, config, activation) + + live_by_dir = {s.direction: s for s in live} + sim_by_dir = {s["direction"]: s for s in sim} + assert set(live_by_dir) == set(sim_by_dir), ( + f"direction mismatch live={set(live_by_dir)} sim={set(sim_by_dir)}" + ) + assert live_by_dir, "expected at least one directional setup from GTL" + + for direction, live_setup in live_by_dir.items(): + sim_setup = sim_by_dir[direction] + assert live_setup.target == pytest.approx(float(sim_setup["target"]), abs=0.05), ( + f"{direction}: live target {live_setup.target} != sim {sim_setup['target']}" + ) + assert live_setup.rr_ratio == pytest.approx(float(sim_setup["rr"]), abs=0.05), ( + f"{direction}: live rr {live_setup.rr_ratio} != sim {sim_setup['rr']}" + ) diff --git a/tests/unit/test_rr_scanner_bug_exploration.py b/tests/unit/test_rr_scanner_bug_exploration.py index 21bc14c..933b1b3 100644 --- a/tests/unit/test_rr_scanner_bug_exploration.py +++ b/tests/unit/test_rr_scanner_bug_exploration.py @@ -1,11 +1,8 @@ -"""Bug-condition exploration tests for R:R scanner target quality. +"""Regression: scanner must not headline the most distant (max raw R:R) level. -These tests confirm the bug described in bugfix.md: the old code always selected -the most distant S/R level (highest raw R:R) regardless of strength or proximity. -The fix replaces max-R:R selection with quality-score selection. - -Since the code is already fixed, these tests PASS on the current codebase. -On the unfixed code they would FAIL, confirming the bug. +Historical bug: provisional candidate pick used max R:R / quality only. Production +headline is probability-based primary after enhance_trade_setup — near levels +with real reach-probability beat far lotteries. **Validates: Requirements 1.1, 1.3, 1.4, 2.1, 2.3, 2.4** """ @@ -76,10 +73,7 @@ def _make_ohlcv_bars( @pytest.mark.asyncio async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession): """With a strong nearby resistance and a weak distant resistance, the - scanner should pick the strong nearby one — NOT the most distant. - - On unfixed code this would fail because max-R:R always picks the - farthest level. + probability primary should be the nearby level — NOT the far lottery. """ ticker = Ticker(symbol="EXPLR") scan_session.add(ticker) @@ -126,8 +120,11 @@ async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession "Bug: scanner picked the weak distant level (130) instead of the " "strong nearby level (105)" ) - # It should pick the strong nearby level + # Probability primary should pick the strong nearby level assert selected_target == pytest.approx(105.0, abs=0.01) + primaries = [t for t in long_setups[0].targets if t.get("is_primary")] + assert len(primaries) == 1 + assert primaries[0]["price"] == pytest.approx(105.0, abs=0.01) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_rr_scanner_fix_check.py b/tests/unit/test_rr_scanner_fix_check.py index d3274ee..5ab1e49 100644 --- a/tests/unit/test_rr_scanner_fix_check.py +++ b/tests/unit/test_rr_scanner_fix_check.py @@ -1,8 +1,8 @@ -"""Fix-checking tests for R:R scanner quality-score selection. +"""Fix-checking tests for R:R scanner probability-based primary selection. -Verify that the fixed scan_ticker selects the candidate with the highest -quality score among all candidates meeting the R:R threshold, for both -long and short setups. +Verify that after enhance_trade_setup the headline target is the most likely +worthwhile primary (R:R + probability floors), for both long and short setups. +The pre-enhance quality loop only seeds a provisional target. **Validates: Requirements 2.1, 2.2, 2.3, 2.4** """ @@ -22,9 +22,7 @@ from app.services.rr_scanner_service import scan_ticker def _assert_primary_is_most_likely_worthwhile(setup) -> None: - """The persisted headline target must equal the starred primary in the - targets table, and that primary must be the highest-probability target - with R:R >= 1.5 (fallback: highest R:R).""" + """Headline = starred primary = max(probability, rr) among floor-clearing targets.""" targets = setup.targets assert targets, "expected generated targets" primaries = [t for t in targets if t.get("is_primary")] @@ -32,7 +30,11 @@ def _assert_primary_is_most_likely_worthwhile(setup) -> None: primary = primaries[0] assert setup.target == pytest.approx(primary["price"], abs=0.01) - worthwhile = [t for t in targets if t["rr_ratio"] >= 1.5] + # Mirrors recommendation_service._select_primary_target floors. + worthwhile = [ + t for t in targets + if float(t["rr_ratio"]) >= 1.5 and float(t["probability"]) >= 20.0 + ] pool = worthwhile or targets best = max(pool, key=lambda t: (t["probability"], t["rr_ratio"])) assert primary["price"] == pytest.approx(best["price"], abs=0.01) @@ -122,7 +124,7 @@ def short_candidate_levels(draw: st.DrawFn) -> list[dict]: # --------------------------------------------------------------------------- -# Property test: long setup selects highest quality score candidate +# Property test: long setup selects probability-based primary # --------------------------------------------------------------------------- @pytest.mark.asyncio @@ -132,14 +134,14 @@ def short_candidate_levels(draw: st.DrawFn) -> list[dict]: deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture], ) -async def test_property_long_selects_highest_quality( +async def test_property_long_selects_probability_primary( levels: list[dict], scan_session: AsyncSession, ): """**Validates: Requirements 2.1, 2.3, 2.4** Property: when multiple resistance levels meet the R:R threshold, - the fixed scan_ticker selects the one with the highest quality score. + the headline after enhance is the probability-based primary. """ from tests.conftest import _test_engine, _test_session_factory from app.database import Base @@ -183,7 +185,7 @@ async def test_property_long_selects_highest_quality( # --------------------------------------------------------------------------- -# Property test: short setup selects highest quality score candidate +# Property test: short setup selects probability-based primary # --------------------------------------------------------------------------- @pytest.mark.asyncio @@ -193,14 +195,14 @@ async def test_property_long_selects_highest_quality( deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture], ) -async def test_property_short_selects_highest_quality( +async def test_property_short_selects_probability_primary( levels: list[dict], scan_session: AsyncSession, ): """**Validates: Requirements 2.2, 2.3, 2.4** Property: when multiple support levels meet the R:R threshold, - the fixed scan_ticker selects the one with the highest quality score. + the headline after enhance is the probability-based primary. """ from tests.conftest import _test_engine, _test_session_factory from app.database import Base @@ -303,9 +305,10 @@ async def test_deterministic_long_three_levels(scan_session: AsyncSession): long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "Expected exactly one long setup" - # Level A (105, strength=90) should win with highest quality + _assert_primary_is_most_likely_worthwhile(long_setups[0]) + # Near/strong level A wins on reach-probability over far lottery C. assert long_setups[0].target == pytest.approx(105.0, abs=0.01), ( - f"Expected target=105.0 (highest quality), got {long_setups[0].target}" + f"Expected primary=105.0 (near, high reach-prob), got {long_setups[0].target}" ) @@ -366,7 +369,7 @@ async def test_deterministic_short_three_levels(scan_session: AsyncSession): short_setups = [s for s in setups if s.direction == "short"] assert len(short_setups) == 1, "Expected exactly one short setup" - # Level A (95, strength=85) should win with highest quality + _assert_primary_is_most_likely_worthwhile(short_setups[0]) assert short_setups[0].target == pytest.approx(95.0, abs=0.01), ( - f"Expected target=95.0 (highest quality), got {short_setups[0].target}" + f"Expected primary=95.0 (near, high reach-prob), got {short_setups[0].target}" ) diff --git a/tests/unit/test_rr_scanner_integration.py b/tests/unit/test_rr_scanner_integration.py index 6189b26..3944f16 100644 --- a/tests/unit/test_rr_scanner_integration.py +++ b/tests/unit/test_rr_scanner_integration.py @@ -1,7 +1,8 @@ -"""Integration tests for R:R scanner full flow with quality-based target selection. +"""Integration tests for R:R scanner full flow with probability-based primary. -Verifies the complete scan_ticker pipeline: quality-based S/R level selection, -correct TradeSetup field population, and database persistence. +Verifies scan_ticker → enhance_trade_setup: headline target is the primary +selected by probability floors (not the pre-enhance quality candidate loop), +TradeSetup fields, and persistence. **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 3.4** """ @@ -63,35 +64,52 @@ def _make_ohlcv_bars( # =========================================================================== -# 8.1 Integration test: full scan_ticker flow with quality-based selection, +# 8.1 Integration test: full scan_ticker flow with probability primary, # correct TradeSetup fields, and database persistence # =========================================================================== +def _assert_headline_is_probability_primary(setup: TradeSetup) -> None: + """Headline target/rr must match the starred primary from _select_primary_target.""" + targets = setup.targets or [] + assert targets, "expected generated targets after enhance" + primaries = [t for t in targets if t.get("is_primary")] + assert len(primaries) == 1, "exactly one primary target expected" + primary = primaries[0] + assert setup.target == pytest.approx(float(primary["price"]), abs=0.01) + assert setup.rr_ratio == pytest.approx(float(primary["rr_ratio"]), abs=0.01) + worthwhile = [ + t for t in targets + if float(t.get("rr_ratio", 0.0)) >= 1.5 and float(t.get("probability", 0.0)) >= 20.0 + ] + pool = worthwhile or targets + best = max(pool, key=lambda t: (float(t["probability"]), float(t["rr_ratio"]))) + assert primary["price"] == pytest.approx(float(best["price"]), abs=0.01) + + @pytest.mark.asyncio -async def test_scan_ticker_full_flow_quality_selection_and_persistence( +async def test_scan_ticker_full_flow_probability_primary_and_persistence( scan_session: AsyncSession, ): - """Integration test for the complete scan_ticker pipeline. + """Integration test for the complete scan_ticker → enhance pipeline. Scenario: - Entry ≈ 100, ATR ≈ 2.0, risk ≈ 3.0 (atr_multiplier=1.5) - 3 resistance levels above (long candidates): - A: price=105, strength=90 (strong, near) → highest quality + A: price=105, strength=90 (strong, near) → typically highest reach-prob B: price=115, strength=40 (medium, mid) - C: price=135, strength=5 (weak, far) + C: price=135, strength=5 (weak, far / lottery) - 3 support levels below (short candidates): - D: price=95, strength=85 (strong, near) → highest quality + D: price=95, strength=85 (strong, near) E: price=85, strength=35 (medium, mid) F: price=65, strength=8 (weak, far) - CompositeScore: 72.5 Verifies: 1. Both long and short setups are produced - 2. Long target = Level A (highest quality, not most distant) - 3. Short target = Level D (highest quality, not most distant) - 4. All TradeSetup fields are correct and rounded to 4 decimals - 5. rr_ratio is the actual R:R of the selected level - 6. Old setups are deleted, new ones persisted + 2. Headline is the probability-based primary (not a distant lottery) + 3. Near/strong levels win over far/weak when they clear floors + 4. rr_ratio matches the selected primary's R:R + 5. Old setups are deleted, new ones persisted """ # -- Setup: create ticker -- ticker = Ticker(symbol="INTEG") @@ -172,16 +190,14 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence( long_setup = long_setups[0] short_setup = short_setups[0] - # -- Assert: long target is Level A (highest quality, not most distant) -- - # Level A: price=105 (strong, near) should beat Level C: price=135 (weak, far) + # -- Assert: headline is probability primary; near/strong beats far lottery -- + _assert_headline_is_probability_primary(long_setup) + _assert_headline_is_probability_primary(short_setup) assert long_setup.target == pytest.approx(105.0, abs=0.01), ( - f"Long target should be 105.0 (highest quality), got {long_setup.target}" + f"Long primary should be 105.0 (near, high reach-prob), got {long_setup.target}" ) - - # -- Assert: short target is Level D (highest quality, not most distant) -- - # Level D: price=95 (strong, near) should beat Level F: price=65 (weak, far) assert short_setup.target == pytest.approx(95.0, abs=0.01), ( - f"Short target should be 95.0 (highest quality), got {short_setup.target}" + f"Short primary should be 95.0 (near, high reach-prob), got {short_setup.target}" ) # -- Assert: entry_price is the last close (≈ 100) --