Separate chart S/R from gate target ladder
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, (
|
||||||
|
|||||||
@@ -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 == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user