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_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(
+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.
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:
+24
View File
@@ -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,
+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
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.
+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-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):
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(
+13
View File
@@ -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)]
+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])
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"
+24 -4
View File
@@ -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"
+66 -1
View File
@@ -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)
+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)
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, (
+3
View File
@@ -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 == []