Separate chart S/R from gate target ladder

This commit is contained in:
2026-07-13 11:17:03 +02:00
parent 995a0380c3
commit 8161c352a0
12 changed files with 277 additions and 58 deletions
+7 -4
View File
@@ -82,7 +82,12 @@ from app.services.scoring_service import (
compute_momentum_from_closes, compute_momentum_from_closes,
compute_technical_from_arrays, 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__) logger = logging.getLogger(__name__)
@@ -375,12 +380,10 @@ def _window_setups(
highs, lows, closes, volumes, include_pivots=False highs, lows, closes, volumes, include_pivots=False
) )
elif sr_variant == EXPLICIT_TARGET_LADDER_VARIANT: elif sr_variant == EXPLICIT_TARGET_LADDER_VARIANT:
detected_levels = detect_sr_levels_legacy( detected_levels = detect_gate_target_ladder(
highs, highs,
lows, lows,
closes, closes,
volumes,
explicit_range_grid=True,
) )
elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}: elif sr_variant in {"legacy_range_grid_touch", "legacy_range_grid_neutral"}:
detected_levels = detect_sr_levels_legacy( detected_levels = detect_sr_levels_legacy(
+50 -32
View File
@@ -1,9 +1,8 @@
"""R:R Scanner service. """R:R scanner service.
Scans tracked tickers for asymmetric risk-reward trade setups. Scans tracked tickers for asymmetric risk-reward trade setups. Candidate
Long: target = nearest SR above, stop = entry - ATR × multiplier. targets come from a transient, volume-free proposal ladder; persisted S/R is
Short: target = nearest SR below, stop = entry + ATR × multiplier. reserved for human-facing charts and alerts. Stops remain ATR-based.
Filters by configurable R:R threshold (default 1.5).
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,6 +11,8 @@ import json
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from types import SimpleNamespace
from typing import Any
from sqlalchemy import and_, func, select, update from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession 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.score import CompositeScore, DimensionScore
from app.models.sentiment import SentimentScore from app.models.sentiment import SentimentScore
from app.models.signal_context_snapshot import SignalContextSnapshot from app.models.signal_context_snapshot import SignalContextSnapshot
from app.models.sr_level import SRLevel
from app.models.ticker import Ticker from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup from app.models.trade_setup import TradeSetup
from app.services.indicator_service import _extract_ohlcv, compute_atr from app.services.indicator_service import _extract_ohlcv, compute_atr
from app.services.price_service import query_ohlcv from app.services.price_service import query_ohlcv
from app.services.sr_service import detect_gate_target_ladder
from app.services.recommendation_service import ( from app.services.recommendation_service import (
_risk_level_from_conflicts, _risk_level_from_conflicts,
build_recommendation_snapshot, build_recommendation_snapshot,
@@ -38,6 +39,7 @@ from app.services.recommendation_service import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
STRATEGY_VERSION = "residual_highvol_80_20_atr_trail3_v1" 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 # 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 # 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 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: async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper() normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised)) result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
@@ -413,22 +437,25 @@ async def scan_ticker(
strategy_rank: float | None = None, strategy_rank: float | None = None,
volatility_percentile: float | None = None, volatility_percentile: float | None = None,
primary_min_rr: float | None = None, primary_min_rr: float | None = None,
gate_levels_override: list[Any] | None = None,
) -> list[TradeSetup]: ) -> list[TradeSetup]:
"""Scan a single ticker for trade setups meeting the R:R threshold. """Scan a single ticker for trade setups meeting the R:R threshold.
``momentum_percentile`` is the ticker's residual 12-1 momentum activation ``momentum_percentile`` is the ticker's residual 12-1 momentum activation
rank across the universe (computed by the caller), stored on each setup so 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 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) ticker = await _get_ticker(db, symbol)
if primary_min_rr is None: if primary_min_rr is None:
# Direct single-ticker scans still use the same activation threshold as primary_min_rr = PRIMARY_TARGET_MIN_RR
# 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))
records = await query_ohlcv(db, symbol) records = await query_ohlcv(db, symbol)
if not records or len(records) < 15: 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) logger.info("Skipping %s: ATR is zero or negative", symbol)
return [] return []
sr_result = await db.execute( gate_levels = (
select(SRLevel).where(SRLevel.ticker_id == ticker.id) 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: if not gate_levels:
logger.info("Skipping %s: no SR levels available", symbol) logger.info("Skipping %s: no gate target levels available", symbol)
return [] return []
levels_above = sorted( 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, key=lambda lv: lv.price_level,
) )
levels_below = sorted( 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, key=lambda lv: lv.price_level,
reverse=True, reverse=True,
) )
@@ -564,7 +592,7 @@ async def scan_ticker(
ticker=ticker, ticker=ticker,
setup=setup, setup=setup,
dimension_scores=dimension_scores, dimension_scores=dimension_scores,
sr_levels=sr_levels, sr_levels=gate_levels,
sentiment_classification=sentiment_classification, sentiment_classification=sentiment_classification,
atr_value=atr_value, atr_value=atr_value,
primary_min_rr=primary_min_rr, primary_min_rr=primary_min_rr,
@@ -620,16 +648,6 @@ async def scan_all_tickers(
logger.exception("Activation ranking refresh failed") logger.exception("Activation ranking refresh failed")
ranks = {} 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] = [] all_setups: list[TradeSetup] = []
for index, symbol in enumerate(symbols): for index, symbol in enumerate(symbols):
if progress_callback is not None: if progress_callback is not None:
@@ -661,7 +679,7 @@ async def scan_all_tickers(
momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"), momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"),
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"), strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"), 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) all_setups.extend(setups)
except Exception: except Exception:
+24
View File
@@ -522,6 +522,30 @@ def detect_sr_levels_legacy(
return merged 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( def _merge_levels(
levels: list[dict], levels: list[dict],
tolerance: float = DEFAULT_TOLERANCE, tolerance: float = DEFAULT_TOLERANCE,
+17
View File
@@ -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 Failing parity means the supposedly irrelevant volume pass still affects an
edge case and must be located before any architectural change. 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 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 promotion holdout. These arms can isolate mechanism, but neither may ship without
new future data or a separately pre-registered walk-forward protocol. new future data or a separately pre-registered walk-forward protocol.
+5 -1
View File
@@ -53,4 +53,8 @@ The explicit gate-ladder parity run writes:
- `sr-explicit-target-ladder-comparison.json` - `sr-explicit-target-ladder-comparison.json`
- `sr-explicit-target-ladder-cohorts.csv` - `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.
+12 -4
View File
@@ -942,12 +942,16 @@ def test_window_setups_routes_explicit_range_grid(
def test_window_setups_routes_full_explicit_target_ladder(monkeypatch): def test_window_setups_routes_full_explicit_target_ladder(monkeypatch):
captured = {} captured = {}
def fake_detector(*args, **kwargs): def fake_detector(highs, lows, closes):
captured.update(kwargs) captured.update({
"highs": highs,
"lows": lows,
"closes": closes,
})
return [] return []
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.EXPLICIT_TARGET_LADDER_VARIANT) 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 = [ records = [
SimpleNamespace( SimpleNamespace(
date=date(2024, 1, 1) + timedelta(days=i), 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 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( @pytest.mark.parametrize(
+13
View File
@@ -11,6 +11,7 @@ from app.services.sr_service import (
_merge_levels, _merge_levels,
_round_number_candidates, _round_number_candidates,
_strength_from_respects, _strength_from_respects,
detect_gate_target_ladder,
detect_sr_levels, detect_sr_levels,
detect_sr_levels_legacy, detect_sr_levels_legacy,
) )
@@ -301,6 +302,18 @@ class TestDetectSrLevels:
assert any("range_grid" in level["sources"] for level in explicit) assert any("range_grid" in level["sources"] for level in explicit)
assert all("volume_profile" not 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): def test_explicit_range_grid_is_volume_independent(self):
highs, lows, closes, volumes = _make_series(n=500) highs, lows, closes, volumes = _make_series(n=500)
shifted_volumes = [volume * (i + 1) for i, volume in enumerate(volumes)] shifted_volumes = [volume * (i + 1) for i, volume in enumerate(volumes)]
+18 -3
View File
@@ -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]) scan_session.add_all([near_level, far_level])
await scan_session.flush() 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"] long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup" 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]) scan_session.add_all([near_level, far_level])
await scan_session.flush() 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"] short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, "Expected exactly one short setup" 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]) session.add_all([near_level, far_level])
await session.commit() 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"] long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup" assert len(long_setups) == 1, "Expected exactly one long setup"
+24 -4
View File
@@ -169,7 +169,12 @@ async def test_property_long_selects_highest_quality(
session.add_all(sr_levels) session.add_all(sr_levels)
await session.commit() 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"] long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup" 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) session.add_all(sr_levels)
await session.commit() 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"] short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, "Expected exactly one short setup" 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]) scan_session.add_all([level_a, level_b, level_c])
await scan_session.flush() 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"] long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, "Expected exactly one long setup" 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]) scan_session.add_all([level_a, level_b, level_c])
await scan_session.flush() 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"] short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, "Expected exactly one short setup" assert len(short_setups) == 1, "Expected exactly one short setup"
+66 -1
View File
@@ -8,6 +8,7 @@ correct TradeSetup field population, and database persistence.
from __future__ import annotations from __future__ import annotations
import json
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
import pytest 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" assert len(pre_setups) == 1, "Dummy old setup should exist before scan"
# -- Act: run scan_ticker -- # -- 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: both directions produced --
assert len(setups) == 2, f"Expected 2 setups (long + short), got {len(setups)}" 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.entry_price == short_setup.entry_price
assert persisted_short.stop_loss == short_setup.stop_loss assert persisted_short.stop_loss == short_setup.stop_loss
assert persisted_short.composite_score == short_setup.composite_score 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)
+38 -9
View File
@@ -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) bars = _make_ohlcv_bars(ticker.id, num_bars=20, base_close=100.0)
session.add_all(bars) session.add_all(bars)
gate_levels = []
for lv_data in scenario.get("levels", []): for lv_data in scenario.get("levels", []):
session.add(SRLevel( level = SRLevel(
ticker_id=ticker.id, ticker_id=ticker.id,
price_level=lv_data["price"], price_level=lv_data["price"],
type=lv_data["type"], type=lv_data["type"],
strength=lv_data["strength"], strength=lv_data["strength"],
detection_method="volume_profile", detection_method="volume_profile",
)) )
session.add(level)
gate_levels.append(level)
await session.commit() 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 == [], ( assert setups == [], (
f"Expected no setups for zero-candidate scenario " f"Expected no setups for zero-candidate scenario "
@@ -247,16 +255,22 @@ async def test_property_single_candidate_selected_unchanged(
session.add_all(bars) session.add_all(bars)
lv = scenario["level"] lv = scenario["level"]
session.add(SRLevel( level = SRLevel(
ticker_id=ticker.id, ticker_id=ticker.id,
price_level=lv["price"], price_level=lv["price"],
type=lv["type"], type=lv["type"],
strength=lv["strength"], strength=lv["strength"],
detection_method="volume_profile", detection_method="volume_profile",
)) )
session.add(level)
await session.commit() 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"] direction = scenario["direction"]
dir_setups = [s for s in setups if s.direction == 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) scan_session.add_all(bars)
await scan_session.flush() 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 == [], ( assert setups == [], (
f"Expected no setups when no SR levels exist, got {len(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) scan_session.add(level)
await scan_session.flush() 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"] long_setups = [s for s in setups if s.direction == "long"]
assert len(long_setups) == 1, ( assert len(long_setups) == 1, (
@@ -366,7 +390,12 @@ async def test_single_support_below_threshold_selected(scan_session: AsyncSessio
scan_session.add(level) scan_session.add(level)
await scan_session.flush() 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"] short_setups = [s for s in setups if s.direction == "short"]
assert len(short_setups) == 1, ( assert len(short_setups) == 1, (
+3
View File
@@ -31,9 +31,11 @@ async def test_scan_proceeds_when_score_refresh_fails(session, monkeypatch):
raise RuntimeError("scoring unavailable") raise RuntimeError("scoring unavailable")
scanned: list[str] = [] scanned: list[str] = []
primary_floors: list[float] = []
async def _fake_scan_ticker(db, symbol, *args, **kwargs): async def _fake_scan_ticker(db, symbol, *args, **kwargs):
scanned.append(symbol) scanned.append(symbol)
primary_floors.append(kwargs["primary_min_rr"])
return [] return []
monkeypatch.setattr(scoring_service, "compute_all_dimensions", _boom) 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) setups = await rr_scanner_service.scan_all_tickers(session)
assert scanned == ["AAA"] assert scanned == ["AAA"]
assert primary_floors == [rr_scanner_service.PRIMARY_TARGET_MIN_RR]
assert setups == [] assert setups == []