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:
2026-07-11 10:06:34 +02:00
co-authored by Claude Fable 5
parent 8f411435ee
commit fdc49d0e28
8 changed files with 170 additions and 42 deletions
+65 -22
View File
@@ -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 96104 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()