From 8161c352a04cca133e179727da7c5e421a94890c Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Mon, 13 Jul 2026 11:17:03 +0200 Subject: [PATCH] Separate chart S/R from gate target ladder --- app/services/backtest_service.py | 11 ++- app/services/rr_scanner_service.py | 82 +++++++++++-------- app/services/sr_service.py | 24 ++++++ docs/research/sr-levels-and-exits.md | 17 ++++ reports/README.md | 6 +- tests/unit/test_backtest_service.py | 16 +++- tests/unit/test_detect_sr_levels.py | 13 +++ tests/unit/test_rr_scanner_bug_exploration.py | 21 ++++- tests/unit/test_rr_scanner_fix_check.py | 28 ++++++- tests/unit/test_rr_scanner_integration.py | 67 ++++++++++++++- tests/unit/test_rr_scanner_preservation.py | 47 +++++++++-- tests/unit/test_rr_scanner_scan_all.py | 3 + 12 files changed, 277 insertions(+), 58 deletions(-) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 6462d14..7b2b601 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -82,7 +82,12 @@ from app.services.scoring_service import ( compute_momentum_from_closes, compute_technical_from_arrays, ) -from app.services.sr_service import MAX_LEVELS, detect_sr_levels, detect_sr_levels_legacy +from app.services.sr_service import ( + MAX_LEVELS, + detect_gate_target_ladder, + detect_sr_levels, + detect_sr_levels_legacy, +) logger = logging.getLogger(__name__) @@ -375,12 +380,10 @@ def _window_setups( highs, lows, closes, volumes, include_pivots=False ) elif sr_variant == EXPLICIT_TARGET_LADDER_VARIANT: - detected_levels = detect_sr_levels_legacy( + detected_levels = detect_gate_target_ladder( highs, lows, closes, - volumes, - explicit_range_grid=True, ) elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}: detected_levels = detect_sr_levels_legacy( diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index ce68d09..cf6f857 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -1,9 +1,8 @@ -"""R:R Scanner service. +"""R:R scanner service. -Scans tracked tickers for asymmetric risk-reward trade setups. -Long: target = nearest SR above, stop = entry - ATR × multiplier. -Short: target = nearest SR below, stop = entry + ATR × multiplier. -Filters by configurable R:R threshold (default 1.5). +Scans tracked tickers for asymmetric risk-reward trade setups. Candidate +targets come from a transient, volume-free proposal ladder; persisted S/R is +reserved for human-facing charts and alerts. Stops remain ATR-based. """ from __future__ import annotations @@ -12,6 +11,8 @@ import json import logging from collections.abc import Callable from datetime import date, datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -23,11 +24,11 @@ from app.models.paper_trade import PaperTrade from app.models.score import CompositeScore, DimensionScore from app.models.sentiment import SentimentScore from app.models.signal_context_snapshot import SignalContextSnapshot -from app.models.sr_level import SRLevel from app.models.ticker import Ticker from app.models.trade_setup import TradeSetup from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.price_service import query_ohlcv +from app.services.sr_service import detect_gate_target_ladder from app.services.recommendation_service import ( _risk_level_from_conflicts, build_recommendation_snapshot, @@ -38,6 +39,7 @@ 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 @@ -49,6 +51,28 @@ STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1" LIVE_SETUP_MAX_AGE_DAYS = 3 +def _materialize_gate_target_levels( + highs: list[float], + lows: list[float], + closes: list[float], +) -> list[Any]: + """Create transient level objects for target generation, never persistence.""" + detected = detect_gate_target_ladder(highs, lows, closes) + return [ + SimpleNamespace( + id=-(index + 1), + price_level=float(level["price_level"]), + type=str(level["type"]), + strength=int(level["strength"]), + detection_method=str(level.get("detection_method", "range_grid")), + sources=list(level.get("sources") or ["range_grid"]), + rejection_count=int(level.get("rejection_count", 0) or 0), + last_rejection_age=level.get("last_rejection_age"), + ) + for index, level in enumerate(detected) + ] + + async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker: normalised = symbol.strip().upper() result = await db.execute(select(Ticker).where(Ticker.symbol == normalised)) @@ -413,22 +437,25 @@ async def scan_ticker( strategy_rank: float | None = None, volatility_percentile: float | None = None, primary_min_rr: float | None = None, + gate_levels_override: list[Any] | None = None, ) -> list[TradeSetup]: """Scan a single ticker for trade setups meeting the R:R threshold. ``momentum_percentile`` is the ticker's residual 12-1 momentum activation rank across the universe (computed by the caller), stored on each setup so the activation gate can select the top slice. ``strategy_rank`` is the - production ordering score used for top-pick ranking.""" + production ordering score used for top-pick ranking. + + ``primary_min_rr`` controls target selection only. Its 1.5 default is + intentionally independent of the later activation floor (2.0 in the live + Admin configuration). ``gate_levels_override`` is dependency injection for + deterministic scanner tests; production builds the transient ladder from + the ticker's OHLCV window. + """ ticker = await _get_ticker(db, symbol) if primary_min_rr is None: - # Direct single-ticker scans still use the same activation threshold as - # qualification. scan_all_tickers resolves this once for the universe. - from app.services.admin_service import get_activation_config - - activation = await get_activation_config(db) - primary_min_rr = float(activation.get("min_rr", rr_threshold)) + primary_min_rr = PRIMARY_TARGET_MIN_RR records = await query_ohlcv(db, symbol) if not records or len(records) < 15: @@ -452,21 +479,22 @@ async def scan_ticker( logger.info("Skipping %s: ATR is zero or negative", symbol) return [] - sr_result = await db.execute( - select(SRLevel).where(SRLevel.ticker_id == ticker.id) + gate_levels = ( + list(gate_levels_override) + if gate_levels_override is not None + else _materialize_gate_target_levels(highs, lows, closes) ) - sr_levels = list(sr_result.scalars().all()) - if not sr_levels: - logger.info("Skipping %s: no SR levels available", symbol) + if not gate_levels: + logger.info("Skipping %s: no gate target levels available", symbol) return [] levels_above = sorted( - [lv for lv in sr_levels if lv.price_level > entry_price], + [lv for lv in gate_levels if lv.price_level > entry_price], key=lambda lv: lv.price_level, ) levels_below = sorted( - [lv for lv in sr_levels if lv.price_level < entry_price], + [lv for lv in gate_levels if lv.price_level < entry_price], key=lambda lv: lv.price_level, reverse=True, ) @@ -564,7 +592,7 @@ async def scan_ticker( ticker=ticker, setup=setup, dimension_scores=dimension_scores, - sr_levels=sr_levels, + sr_levels=gate_levels, sentiment_classification=sentiment_classification, atr_value=atr_value, primary_min_rr=primary_min_rr, @@ -620,16 +648,6 @@ async def scan_all_tickers( logger.exception("Activation ranking refresh failed") ranks = {} - try: - from app.services.admin_service import get_activation_config - - activation = await get_activation_config(db) - primary_min_rr = float(activation.get("min_rr", rr_threshold)) - except Exception: - await db.rollback() - logger.exception("Activation config load failed; using scanner R:R floor") - primary_min_rr = rr_threshold - all_setups: list[TradeSetup] = [] for index, symbol in enumerate(symbols): if progress_callback is not None: @@ -661,7 +679,7 @@ async def scan_all_tickers( momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"), strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"), volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"), - primary_min_rr=primary_min_rr, + primary_min_rr=PRIMARY_TARGET_MIN_RR, ) all_setups.extend(setups) except Exception: diff --git a/app/services/sr_service.py b/app/services/sr_service.py index 20e5819..b6ac7c7 100644 --- a/app/services/sr_service.py +++ b/app/services/sr_service.py @@ -522,6 +522,30 @@ def detect_sr_levels_legacy( return merged +def detect_gate_target_ladder( + highs: list[float], + lows: list[float], + closes: list[float], + tolerance: float = DEFAULT_TOLERANCE, +) -> list[dict]: + """Build the scanner's internal, volume-free target proposal ladder. + + This is intentionally not human-facing support/resistance. It preserves + the production gate's broad 20-bin range grid, unfiltered pivots, touch + strength, and merge geometry without performing or claiming a volume + profile calculation. The returned levels are transient and must not be + persisted as chart S/R. + """ + return detect_sr_levels_legacy( + highs, + lows, + closes, + [0] * len(closes), + tolerance, + explicit_range_grid=True, + ) + + def _merge_levels( levels: list[dict], tolerance: float = DEFAULT_TOLERANCE, diff --git a/docs/research/sr-levels-and-exits.md b/docs/research/sr-levels-and-exits.md index fe0c344..3f8a2bf 100644 --- a/docs/research/sr-levels-and-exits.md +++ b/docs/research/sr-levels-and-exits.md @@ -706,6 +706,23 @@ clean structure for charts and alerts, explicit target ladder for the gate. Failing parity means the supposedly irrelevant volume pass still affects an edge case and must be located before any architectural change. +Result: **exact parity**. Both arms produce 1,086 qualified setups from 202,765 +candidates, with 1,086 retained, zero added, and zero removed. Both production +books have Sharpe 2.03, CAGR 50.0%, max drawdown 21.4%, and 321 trades. The +retained cohort is +0.2086R net average and +0.0492R after removing the top 5%. +This proves that neither volume nor the `volume_profile` interpretation is part +of the deployed edge. + +Implementation decision: keep the clean `detect_sr_levels` output persisted as +human-facing S/R for charts and alerts. The scanner instead builds +`detect_gate_target_ladder` directly from its OHLCV window, materializes it only +for the current scan, and never writes those proposal levels to `SRLevel`. The +primary-target selector retains its independently researched 1.5 floor; the +later live activation gate remains 2.0, and the ATR-trailing exit is unchanged. +The `explicit_target_ladder` backtest arm calls the same pure helper as the live +scanner, so the final full-period rerun is an implementation-parity check rather +than another detector experiment. + The post-2024 window has been opened and is now analysis data, not a valid final promotion holdout. These arms can isolate mechanism, but neither may ship without new future data or a separately pre-registered walk-forward protocol. diff --git a/reports/README.md b/reports/README.md index d634121..da2baca 100644 --- a/reports/README.md +++ b/reports/README.md @@ -53,4 +53,8 @@ The explicit gate-ladder parity run writes: - `sr-explicit-target-ladder-comparison.json` - `sr-explicit-target-ladder-cohorts.csv` -Retain these only if they establish the final parity decision. +These establish the final architectural decision: the volume-free explicit +ladder retains all 1,086 qualified setups and exactly reproduces the production +book (Sharpe 2.03, CAGR 50.0%, max drawdown 21.4%, 321 trades). A final rerun +after scanner integration verifies that the shared helper still preserves this +parity before anything is deployed. diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index d9eaa36..8047788 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -942,12 +942,16 @@ def test_window_setups_routes_explicit_range_grid( def test_window_setups_routes_full_explicit_target_ladder(monkeypatch): captured = {} - def fake_detector(*args, **kwargs): - captured.update(kwargs) + def fake_detector(highs, lows, closes): + captured.update({ + "highs": highs, + "lows": lows, + "closes": closes, + }) return [] monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.EXPLICIT_TARGET_LADDER_VARIANT) - monkeypatch.setattr(bt, "detect_sr_levels_legacy", fake_detector) + monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector) records = [ SimpleNamespace( date=date(2024, 1, 1) + timedelta(days=i), @@ -961,7 +965,11 @@ def test_window_setups_routes_full_explicit_target_ladder(monkeypatch): ] assert bt._window_setups(records, {}, {}) == [] - assert captured == {"explicit_range_grid": True} + assert captured == { + "highs": [101.0] * bt.MIN_LOOKBACK, + "lows": [99.0] * bt.MIN_LOOKBACK, + "closes": [100.0] * bt.MIN_LOOKBACK, + } @pytest.mark.parametrize( diff --git a/tests/unit/test_detect_sr_levels.py b/tests/unit/test_detect_sr_levels.py index 906218f..3f8676a 100644 --- a/tests/unit/test_detect_sr_levels.py +++ b/tests/unit/test_detect_sr_levels.py @@ -11,6 +11,7 @@ from app.services.sr_service import ( _merge_levels, _round_number_candidates, _strength_from_respects, + detect_gate_target_ladder, detect_sr_levels, detect_sr_levels_legacy, ) @@ -301,6 +302,18 @@ class TestDetectSrLevels: assert any("range_grid" in level["sources"] for level in explicit) assert all("volume_profile" not in level["sources"] for level in explicit) + def test_gate_target_ladder_is_the_explicit_volume_free_detector(self): + highs, lows, closes, volumes = _make_series(n=500) + expected = detect_sr_levels_legacy( + highs, + lows, + closes, + volumes, + explicit_range_grid=True, + ) + + assert detect_gate_target_ladder(highs, lows, closes) == expected + def test_explicit_range_grid_is_volume_independent(self): highs, lows, closes, volumes = _make_series(n=500) shifted_volumes = [volume * (i + 1) for i, volume in enumerate(volumes)] diff --git a/tests/unit/test_rr_scanner_bug_exploration.py b/tests/unit/test_rr_scanner_bug_exploration.py index 95db8ba..21bc14c 100644 --- a/tests/unit/test_rr_scanner_bug_exploration.py +++ b/tests/unit/test_rr_scanner_bug_exploration.py @@ -110,7 +110,12 @@ async def test_long_prefers_strong_near_over_weak_far(scan_session: AsyncSession scan_session.add_all([near_level, far_level]) await scan_session.flush() - setups = await scan_ticker(scan_session, "EXPLR", rr_threshold=1.5) + setups = await scan_ticker( + scan_session, + "EXPLR", + rr_threshold=1.5, + gate_levels_override=[near_level, far_level], + ) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "Expected exactly one long setup" @@ -162,7 +167,12 @@ async def test_short_prefers_strong_near_over_weak_far(scan_session: AsyncSessio scan_session.add_all([near_level, far_level]) await scan_session.flush() - setups = await scan_ticker(scan_session, "EXPLS", rr_threshold=1.5) + setups = await scan_ticker( + scan_session, + "EXPLS", + rr_threshold=1.5, + gate_levels_override=[near_level, far_level], + ) short_setups = [s for s in setups if s.direction == "short"] assert len(short_setups) == 1, "Expected exactly one short setup" @@ -256,7 +266,12 @@ async def test_property_scanner_does_not_always_pick_most_distant( session.add_all([near_level, far_level]) await session.commit() - setups = await scan_ticker(session, "PROP", rr_threshold=1.5) + setups = await scan_ticker( + session, + "PROP", + rr_threshold=1.5, + gate_levels_override=[near_level, far_level], + ) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "Expected exactly one long setup" diff --git a/tests/unit/test_rr_scanner_fix_check.py b/tests/unit/test_rr_scanner_fix_check.py index 72a83b1..d3274ee 100644 --- a/tests/unit/test_rr_scanner_fix_check.py +++ b/tests/unit/test_rr_scanner_fix_check.py @@ -169,7 +169,12 @@ async def test_property_long_selects_highest_quality( session.add_all(sr_levels) await session.commit() - setups = await scan_ticker(session, "FIXL", rr_threshold=1.5) + setups = await scan_ticker( + session, + "FIXL", + rr_threshold=1.5, + gate_levels_override=sr_levels, + ) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "Expected exactly one long setup" @@ -225,7 +230,12 @@ async def test_property_short_selects_highest_quality( session.add_all(sr_levels) await session.commit() - setups = await scan_ticker(session, "FIXS", rr_threshold=1.5) + setups = await scan_ticker( + session, + "FIXS", + rr_threshold=1.5, + gate_levels_override=sr_levels, + ) short_setups = [s for s in setups if s.direction == "short"] assert len(short_setups) == 1, "Expected exactly one short setup" @@ -283,7 +293,12 @@ async def test_deterministic_long_three_levels(scan_session: AsyncSession): scan_session.add_all([level_a, level_b, level_c]) await scan_session.flush() - setups = await scan_ticker(scan_session, "DET3L", rr_threshold=1.5) + setups = await scan_ticker( + scan_session, + "DET3L", + rr_threshold=1.5, + gate_levels_override=[level_a, level_b, level_c], + ) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, "Expected exactly one long setup" @@ -341,7 +356,12 @@ async def test_deterministic_short_three_levels(scan_session: AsyncSession): scan_session.add_all([level_a, level_b, level_c]) await scan_session.flush() - setups = await scan_ticker(scan_session, "DET3S", rr_threshold=1.5) + setups = await scan_ticker( + scan_session, + "DET3S", + rr_threshold=1.5, + gate_levels_override=[level_a, level_b, level_c], + ) short_setups = [s for s in setups if s.direction == "short"] assert len(short_setups) == 1, "Expected exactly one short setup" diff --git a/tests/unit/test_rr_scanner_integration.py b/tests/unit/test_rr_scanner_integration.py index cfb983e..6189b26 100644 --- a/tests/unit/test_rr_scanner_integration.py +++ b/tests/unit/test_rr_scanner_integration.py @@ -8,6 +8,7 @@ correct TradeSetup field population, and database persistence. from __future__ import annotations +import json from datetime import date, datetime, timedelta, timezone import pytest @@ -152,7 +153,13 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence( assert len(pre_setups) == 1, "Dummy old setup should exist before scan" # -- Act: run scan_ticker -- - setups = await scan_ticker(scan_session, "INTEG", rr_threshold=1.5, atr_multiplier=1.5) + setups = await scan_ticker( + scan_session, + "INTEG", + rr_threshold=1.5, + atr_multiplier=1.5, + gate_levels_override=sr_levels, + ) # -- Assert: both directions produced -- assert len(setups) == 2, f"Expected 2 setups (long + short), got {len(setups)}" @@ -255,3 +262,61 @@ async def test_scan_ticker_full_flow_quality_selection_and_persistence( assert persisted_short.entry_price == short_setup.entry_price assert persisted_short.stop_loss == short_setup.stop_loss assert persisted_short.composite_score == short_setup.composite_score + + +@pytest.mark.asyncio +async def test_scan_ticker_uses_transient_ladder_not_persisted_chart_levels( + scan_session: AsyncSession, + monkeypatch, +): + ticker = Ticker(symbol="DUAL") + scan_session.add(ticker) + await scan_session.flush() + scan_session.add_all(_make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)) + scan_session.add(SRLevel( + ticker_id=ticker.id, + price_level=130.0, + type="resistance", + strength=100, + detection_method="pivot_point", + )) + await scan_session.commit() + + ladder = [ + { + "price_level": 105.0, + "type": "resistance", + "strength": 90, + "detection_method": "range_grid", + "sources": ["range_grid"], + "rejection_count": 5, + "last_rejection_age": None, + }, + { + "price_level": 95.0, + "type": "support", + "strength": 85, + "detection_method": "range_grid", + "sources": ["range_grid"], + "rejection_count": 4, + "last_rejection_age": None, + }, + ] + monkeypatch.setattr( + "app.services.rr_scanner_service.detect_gate_target_ladder", + lambda highs, lows, closes: ladder, + ) + + setups = await scan_ticker( + scan_session, + "DUAL", + rr_threshold=1.5, + ) + + long_setup = next(setup for setup in setups if setup.direction == "long") + assert long_setup.target == pytest.approx(105.0, abs=0.01) + assert long_setup.target != pytest.approx(130.0, abs=0.01) + targets = json.loads(long_setup.targets_json or "[]") + assert targets + assert all(target["sr_level_id"] < 0 for target in targets) + assert all(target["sr_sources"] == ["range_grid"] for target in targets) diff --git a/tests/unit/test_rr_scanner_preservation.py b/tests/unit/test_rr_scanner_preservation.py index 58b57b7..b271a4c 100644 --- a/tests/unit/test_rr_scanner_preservation.py +++ b/tests/unit/test_rr_scanner_preservation.py @@ -197,17 +197,25 @@ async def test_property_zero_candidates_produce_no_setup( bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0) session.add_all(bars) + gate_levels = [] for lv_data in scenario.get("levels", []): - session.add(SRLevel( + level = SRLevel( ticker_id=ticker.id, price_level=lv_data["price"], type=lv_data["type"], strength=lv_data["strength"], detection_method="volume_profile", - )) + ) + session.add(level) + gate_levels.append(level) await session.commit() - setups = await scan_ticker(session, "PRSV0", rr_threshold=1.5) + setups = await scan_ticker( + session, + "PRSV0", + rr_threshold=1.5, + gate_levels_override=gate_levels, + ) assert setups == [], ( f"Expected no setups for zero-candidate scenario " @@ -247,16 +255,22 @@ async def test_property_single_candidate_selected_unchanged( session.add_all(bars) lv = scenario["level"] - session.add(SRLevel( + level = SRLevel( ticker_id=ticker.id, price_level=lv["price"], type=lv["type"], strength=lv["strength"], detection_method="volume_profile", - )) + ) + session.add(level) await session.commit() - setups = await scan_ticker(session, "PRSV1", rr_threshold=1.5) + setups = await scan_ticker( + session, + "PRSV1", + rr_threshold=1.5, + gate_levels_override=[level], + ) direction = scenario["direction"] dir_setups = [s for s in setups if s.direction == direction] @@ -292,7 +306,12 @@ async def test_no_sr_levels_produces_no_setup(scan_session: AsyncSession): scan_session.add_all(bars) await scan_session.flush() - setups = await scan_ticker(scan_session, "NOSRL", rr_threshold=1.5) + setups = await scan_ticker( + scan_session, + "NOSRL", + rr_threshold=1.5, + gate_levels_override=[], + ) assert setups == [], ( f"Expected no setups when no SR levels exist, got {len(setups)}" @@ -329,7 +348,12 @@ async def test_single_resistance_above_threshold_selected(scan_session: AsyncSes scan_session.add(level) await scan_session.flush() - setups = await scan_ticker(scan_session, "SINGL", rr_threshold=1.5) + setups = await scan_ticker( + scan_session, + "SINGL", + rr_threshold=1.5, + gate_levels_override=[level], + ) long_setups = [s for s in setups if s.direction == "long"] assert len(long_setups) == 1, ( @@ -366,7 +390,12 @@ async def test_single_support_below_threshold_selected(scan_session: AsyncSessio scan_session.add(level) await scan_session.flush() - setups = await scan_ticker(scan_session, "SINGS", rr_threshold=1.5) + setups = await scan_ticker( + scan_session, + "SINGS", + rr_threshold=1.5, + gate_levels_override=[level], + ) short_setups = [s for s in setups if s.direction == "short"] assert len(short_setups) == 1, ( diff --git a/tests/unit/test_rr_scanner_scan_all.py b/tests/unit/test_rr_scanner_scan_all.py index 89a2997..27f6775 100644 --- a/tests/unit/test_rr_scanner_scan_all.py +++ b/tests/unit/test_rr_scanner_scan_all.py @@ -31,9 +31,11 @@ async def test_scan_proceeds_when_score_refresh_fails(session, monkeypatch): raise RuntimeError("scoring unavailable") scanned: list[str] = [] + primary_floors: list[float] = [] async def _fake_scan_ticker(db, symbol, *args, **kwargs): scanned.append(symbol) + primary_floors.append(kwargs["primary_min_rr"]) return [] monkeypatch.setattr(scoring_service, "compute_all_dimensions", _boom) @@ -42,6 +44,7 @@ async def test_scan_proceeds_when_score_refresh_fails(session, monkeypatch): setups = await rr_scanner_service.scan_all_tickers(session) assert scanned == ["AAA"] + assert primary_floors == [rr_scanner_service.PRIMARY_TARGET_MIN_RR] assert setups == []