Setup views: primary-target column, floor-target prune, liveness cutoff
Three follow-ups to the gate probability floor (8f41143):
- Signals table shows the starred primary target (shared primaryTarget
helper) instead of an independently computed max-probability best,
so Overview, Signals and ticker details agree by construction.
- Targets pinned at the 3% probability clamp floor collapse to the
nearest one (enhance_trade_setup + backtest candidates in parity):
floor-pinned levels are indistinguishable to the model, so farther
ones were duplicate 3% rows inviting lottery headlines.
- get_trade_setups only returns setups re-emitted within
LIVE_SETUP_MAX_AGE_DAYS (3): an older latest row means the daily
scan no longer confirms the setup, and such rows otherwise surface
forever on Overview/Signals/ticker/alerts. History endpoints keep
full history.
Backtest on the Jul-3 snapshot is metric-identical to the gate-floor
run on all qualified stats (1089 qualified, Sharpe 2.02, CAGR +49.6%,
DD -15.8%): the prune only removes noise the gate already rejected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,11 @@ from app.models.trade_setup import TradeSetup
|
||||
from app.models.score import CompositeScore, DimensionScore
|
||||
from app.models.sentiment import SentimentScore
|
||||
from app.models.user import User
|
||||
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
|
||||
from app.services.rr_scanner_service import (
|
||||
LIVE_SETUP_MAX_AGE_DAYS,
|
||||
get_trade_setups,
|
||||
scan_ticker,
|
||||
)
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
@@ -69,11 +73,11 @@ def _make_ohlcv_bars(
|
||||
num_bars: int = 20,
|
||||
base_close: float = 100.0,
|
||||
) -> list[OHLCVRecord]:
|
||||
"""Generate OHLCV bars closing around base_close with ATR ≈ 2.0."""
|
||||
"""Generate OHLCV bars closing around base_close with ATR ≈ 2.0."""
|
||||
bars: list[OHLCVRecord] = []
|
||||
start = date(2024, 1, 1)
|
||||
for i in range(num_bars):
|
||||
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
|
||||
close = base_close + (i % 3 - 1) * 0.5 # oscillate ±0.5
|
||||
bars.append(OHLCVRecord(
|
||||
ticker_id=ticker_id,
|
||||
date=start + timedelta(days=i),
|
||||
@@ -101,7 +105,7 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
|
||||
but all below the R:R threshold for their respective directions
|
||||
- Levels in the right direction but below R:R threshold
|
||||
|
||||
Note: scan_ticker does NOT filter by SR level type — it only checks whether
|
||||
Note: scan_ticker does NOT filter by SR level type — it only checks whether
|
||||
the price_level is above or below entry. So "wrong side" means all levels
|
||||
are clustered near entry and below threshold in both directions.
|
||||
"""
|
||||
@@ -111,10 +115,10 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
|
||||
return {"variant": variant, "levels": []}
|
||||
|
||||
else: # below_threshold
|
||||
# All levels close to entry so R:R < 1.5 with risk ≈ 3
|
||||
# For longs: reward < 4.5 → price < 104.5
|
||||
# For shorts: reward < 4.5 → price > 95.5
|
||||
# Place all levels in the 96–104 band (below threshold both ways)
|
||||
# All levels close to entry so R:R < 1.5 with risk ≈ 3
|
||||
# For longs: reward < 4.5 → price < 104.5
|
||||
# For shorts: reward < 4.5 → price > 95.5
|
||||
# Place all levels in the 96–104 band (below threshold both ways)
|
||||
num = draw(st.integers(min_value=1, max_value=3))
|
||||
levels = []
|
||||
for _ in range(num):
|
||||
@@ -139,7 +143,7 @@ def zero_candidate_scenario(draw: st.DrawFn) -> dict:
|
||||
def single_candidate_scenario(draw: st.DrawFn) -> dict:
|
||||
"""Generate a scenario with exactly one S/R level that meets the R:R threshold.
|
||||
|
||||
For longs: one resistance above entry with R:R >= 1.5 (price >= 104.5 with risk ≈ 3).
|
||||
For longs: one resistance above entry with R:R >= 1.5 (price >= 104.5 with risk ≈ 3).
|
||||
"""
|
||||
direction = draw(st.sampled_from(["long", "short"]))
|
||||
|
||||
@@ -175,7 +179,7 @@ async def test_property_zero_candidates_produce_no_setup(
|
||||
"""**Validates: Requirements 3.1, 3.2**
|
||||
|
||||
Property: when zero candidate S/R levels exist (no levels, wrong side,
|
||||
or below threshold), scan_ticker produces no setup — unchanged from
|
||||
or below threshold), scan_ticker produces no setup — unchanged from
|
||||
original behavior.
|
||||
"""
|
||||
from tests.conftest import _test_engine, _test_session_factory
|
||||
@@ -225,7 +229,7 @@ async def test_property_single_candidate_selected_unchanged(
|
||||
"""**Validates: Requirements 3.3**
|
||||
|
||||
Property: when exactly one candidate S/R level meets the R:R threshold,
|
||||
scan_ticker selects it — same as the original code would.
|
||||
scan_ticker selects it — same as the original code would.
|
||||
"""
|
||||
from tests.conftest import _test_engine, _test_session_factory
|
||||
from app.database import Base
|
||||
@@ -270,7 +274,7 @@ async def test_property_single_candidate_selected_unchanged(
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7.2 Unit test: no S/R levels → no setup produced
|
||||
# 7.2 Unit test: no S/R levels → no setup produced
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -296,7 +300,7 @@ async def test_no_sr_levels_produces_no_setup(scan_session: AsyncSession):
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7.3 Unit test: single candidate meets threshold → selected
|
||||
# 7.3 Unit test: single candidate meets threshold → selected
|
||||
# ===========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -306,7 +310,7 @@ async def test_single_resistance_above_threshold_selected(scan_session: AsyncSes
|
||||
When exactly one resistance level above entry meets the R:R threshold,
|
||||
it should be selected as the long setup target.
|
||||
|
||||
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Resistance at 110 → R:R ≈ 3.33 (>= 1.5).
|
||||
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Resistance at 110 → R:R ≈ 3.33 (>= 1.5).
|
||||
"""
|
||||
ticker = Ticker(symbol="SINGL")
|
||||
scan_session.add(ticker)
|
||||
@@ -343,7 +347,7 @@ async def test_single_support_below_threshold_selected(scan_session: AsyncSessio
|
||||
When exactly one support level below entry meets the R:R threshold,
|
||||
it should be selected as the short setup target.
|
||||
|
||||
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Support at 90 → R:R ≈ 3.33 (>= 1.5).
|
||||
Entry ≈ 100, ATR ≈ 2, risk ≈ 3. Support at 90 → R:R ≈ 3.33 (>= 1.5).
|
||||
"""
|
||||
ticker = Ticker(symbol="SINGS")
|
||||
scan_session.add(ticker)
|
||||
@@ -444,6 +448,42 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trade_setups_excludes_stale_rows(db_session: AsyncSession):
|
||||
"""A "latest" row older than LIVE_SETUP_MAX_AGE_DAYS means the daily scan
|
||||
stopped re-emitting the setup (nothing clears the R:R threshold from the
|
||||
current price) — it must not surface on the live views."""
|
||||
now = datetime.now(timezone.utc)
|
||||
ticker_fresh = Ticker(symbol="FRESH")
|
||||
ticker_stale = Ticker(symbol="STALE")
|
||||
db_session.add_all([ticker_fresh, ticker_stale])
|
||||
await db_session.flush()
|
||||
|
||||
db_session.add_all([
|
||||
TradeSetup(
|
||||
ticker_id=ticker_fresh.id, direction="long",
|
||||
entry_price=100.0, stop_loss=97.0, target=109.0,
|
||||
rr_ratio=3.0, composite_score=50.0,
|
||||
detected_at=now - timedelta(days=1),
|
||||
),
|
||||
TradeSetup(
|
||||
ticker_id=ticker_stale.id, direction="long",
|
||||
entry_price=100.0, stop_loss=97.0, target=109.0,
|
||||
rr_ratio=3.0, composite_score=50.0,
|
||||
detected_at=now - timedelta(days=LIVE_SETUP_MAX_AGE_DAYS, hours=1),
|
||||
),
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
results = await get_trade_setups(db_session)
|
||||
symbols = [r["symbol"] for r in results]
|
||||
assert symbols == ["FRESH"], f"Stale setup must be excluded, got {symbols}"
|
||||
|
||||
# The per-symbol view applies the same liveness rule.
|
||||
stale_rows = await get_trade_setups(db_session, symbol="STALE")
|
||||
assert stale_rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
||||
db_session: AsyncSession,
|
||||
@@ -540,9 +580,12 @@ async def test_get_trade_setups_can_exclude_tickers_with_open_paper_trades(
|
||||
|
||||
async def _seed_stale_setup_with_current_scores(db_session: AsyncSession) -> TradeSetup:
|
||||
"""Stored setup frozen at scan time (conf 82, neutral) vs. current context
|
||||
(bullish sentiment, composite 96) that yields live confidence 97."""
|
||||
old_scan = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
(bullish sentiment, composite 96) that yields live confidence 97.
|
||||
|
||||
The scan date stays inside the LIVE_SETUP_MAX_AGE_DAYS liveness window —
|
||||
these tests exercise the live overlay on a still-live row, not staleness."""
|
||||
current = datetime.now(timezone.utc)
|
||||
old_scan = current - timedelta(days=2)
|
||||
old_reasoning = (
|
||||
"LONG (high confidence): 82% with aligned signals "
|
||||
"(technical=88, momentum=60, sentiment=neutral)."
|
||||
@@ -653,7 +696,7 @@ async def test_live_recommendation_filters_apply_to_live_values(
|
||||
"""min_confidence must judge the overlaid live confidence, not the stored one."""
|
||||
await _seed_stale_setup_with_current_scores(db_session)
|
||||
|
||||
# Stored confidence is 82 — a stored-column filter would drop this row.
|
||||
# Stored confidence is 82 — a stored-column filter would drop this row.
|
||||
# Live confidence is 97, so it must pass.
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
@@ -675,7 +718,7 @@ async def test_live_recommendation_filters_apply_to_live_values(
|
||||
|
||||
|
||||
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
current = datetime.now(timezone.utc)
|
||||
ticker = Ticker(symbol="BOTH")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
@@ -776,7 +819,7 @@ async def test_live_recommendation_action_independent_of_direction_filter(
|
||||
async def test_live_overlay_preserves_setup_specific_risk_and_context(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
current = datetime.now(timezone.utc)
|
||||
ticker = Ticker(symbol="RISK")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
@@ -883,7 +926,7 @@ async def test_live_trade_setup_read_does_not_recompute_scores(db_session: Async
|
||||
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
current = datetime.now(timezone.utc)
|
||||
ticker = Ticker(symbol="LIVEP")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
|
||||
Reference in New Issue
Block a user