From da83f027e17203a1e9163d706ceef9a862a1664b Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 14 Jun 2026 14:02:10 +0200 Subject: [PATCH] Drop over-progressed setups via live R:R; refresh trades on fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "why does a too-far-progressed setup still show": setups are only recalculated by the scheduled R:R scan and manual fetch; at creation entry == current price (0% progress), so over-progression is a between-scans drift effect and must be judged at read time. - /trades now attaches current_price (latest close per ticker). - Qualification drops setups whose R:R recomputed from the current price falls below min_rr — i.e. price already ran toward target (reward consumed) or through the stop. Reuses the existing min_rr threshold instead of a separate progress %; far cleaner (a 3:1 is already ~1:1 by 33% progress). Skipped for historical setups (no current_price). - Fix: useFetchSymbolData now invalidates the trades queries, so a fetch/ recompute actually refreshes confidence/setups in the UI (was the cause of the stale 100% confidence lingering after recompute). Co-Authored-By: Claude Fable 5 --- app/schemas/trade_setup.py | 1 + app/services/qualification.py | 26 ++++++++++++++++ app/services/rr_scanner_service.py | 39 +++++++++++++++++++++--- frontend/src/hooks/useFetchSymbolData.ts | 3 ++ frontend/src/lib/qualification.ts | 13 ++++++++ frontend/src/lib/types.ts | 1 + tests/unit/test_qualification.py | 18 +++++++++++ 7 files changed, 97 insertions(+), 4 deletions(-) diff --git a/app/schemas/trade_setup.py b/app/schemas/trade_setup.py index 9ac1ccb..4445b6a 100644 --- a/app/schemas/trade_setup.py +++ b/app/schemas/trade_setup.py @@ -47,4 +47,5 @@ class TradeSetupResponse(BaseModel): actual_outcome: str | None = None outcome_date: date | None = None evaluated_at: datetime | None = None + current_price: float | None = None recommendation_summary: RecommendationSummaryResponse | None = None diff --git a/app/services/qualification.py b/app/services/qualification.py index 582dd0c..8413f25 100644 --- a/app/services/qualification.py +++ b/app/services/qualification.py @@ -20,6 +20,24 @@ def best_target_probability(setup: Any) -> float: return max(probs, default=0.0) +def live_risk_reward(setup: Any, current_price: float) -> float | None: + """R:R recomputed from the CURRENT price, not the (possibly stale) entry. + + Returns None / a low value when the setup is no longer actionable: price + already at/past the target (no reward left) or through the stop. This is how + over-progressed setups get filtered without a separate 'max progress' knob. + """ + if setup.direction == "long": + reward = setup.target - current_price + risk = current_price - setup.stop_loss + else: + reward = current_price - setup.target + risk = setup.stop_loss - current_price + if reward <= 0 or risk <= 0: + return 0.0 + return reward / risk + + def setup_qualifies(setup: Any, config: dict) -> bool: """Whether a setup clears the activation gate. @@ -28,6 +46,14 @@ def setup_qualifies(setup: Any, config: dict) -> bool: """ if setup.rr_ratio < config["min_rr"]: return False + # Live R:R from the current price: drops setups whose price has already run + # toward the target (reward consumed) or through the stop. Only applied when + # a current price is attached (live list); skipped for historical setups. + current_price = getattr(setup, "current_price", None) + if current_price is not None: + live_rr = live_risk_reward(setup, float(current_price)) + if live_rr is not None and live_rr < config["min_rr"]: + return False if (setup.confidence_score or 0.0) < config["min_confidence"]: return False if config.get("require_high_conviction"): diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index 95e3a83..0d320a8 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -12,10 +12,11 @@ import json import logging from datetime import datetime, timezone -from sqlalchemy import select +from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.exceptions import NotFoundError +from app.models.ohlcv import OHLCVRecord from app.models.score import CompositeScore, DimensionScore from app.models.sentiment import SentimentScore from app.models.sr_level import SRLevel @@ -308,7 +309,32 @@ async def get_trade_setups( reverse=True, ) - return [_trade_setup_to_dict(setup, ticker_symbol) for setup, ticker_symbol in latest_rows] + prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows}) + return [ + _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) + for setup, ticker_symbol in latest_rows + ] + + +async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]: + """Most recent close per ticker — used to judge a setup's current relevance.""" + if not ticker_ids: + return {} + latest = ( + select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("md")) + .where(OHLCVRecord.ticker_id.in_(ticker_ids)) + .group_by(OHLCVRecord.ticker_id) + .subquery() + ) + stmt = select(OHLCVRecord.ticker_id, OHLCVRecord.close).join( + latest, + and_( + OHLCVRecord.ticker_id == latest.c.ticker_id, + OHLCVRecord.date == latest.c.md, + ), + ) + result = await db.execute(stmt) + return {tid: float(close) for tid, close in result.all()} async def get_trade_setup_history( @@ -325,10 +351,14 @@ async def get_trade_setup_history( result = await db.execute(stmt) rows = result.all() - return [_trade_setup_to_dict(setup, ticker_symbol) for setup, ticker_symbol in rows] + prices = await _latest_closes(db, {s.ticker_id for s, _ in rows}) + return [ + _trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id)) + for setup, ticker_symbol in rows + ] -def _trade_setup_to_dict(setup: TradeSetup, symbol: str) -> dict: +def _trade_setup_to_dict(setup: TradeSetup, symbol: str, current_price: float | None = None) -> dict: targets: list[dict] = [] conflicts: list[str] = [] @@ -367,4 +397,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str) -> dict: "actual_outcome": setup.actual_outcome, "outcome_date": setup.outcome_date, "evaluated_at": setup.evaluated_at, + "current_price": current_price, } diff --git a/frontend/src/hooks/useFetchSymbolData.ts b/frontend/src/hooks/useFetchSymbolData.ts index 1f7c54e..2e70b4b 100644 --- a/frontend/src/hooks/useFetchSymbolData.ts +++ b/frontend/src/hooks/useFetchSymbolData.ts @@ -40,6 +40,9 @@ export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) { queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] }); queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] }); queryClient.invalidateQueries({ queryKey: ['scores', symbol] }); + // Fetch re-runs the scanner → setups/confidence change. Refresh both the + // per-ticker trades (['trades', symbol]) and the Overview list (['trades']). + queryClient.invalidateQueries({ queryKey: ['trades'] }); if (invalidatePipelineReadiness) { queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] }); diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index c58e5ec..5944a3e 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -13,12 +13,25 @@ export function primaryTargetProbability(setup: TradeSetup): number | null { return setup.targets?.length ? bestTargetProbability(setup) : null; } +/** R:R recomputed from the current price (0 if no reward/risk left). */ +export function liveRiskReward(setup: TradeSetup, currentPrice: number): number { + const reward = setup.direction === 'long' ? setup.target - currentPrice : currentPrice - setup.target; + const risk = setup.direction === 'long' ? currentPrice - setup.stop_loss : setup.stop_loss - currentPrice; + if (reward <= 0 || risk <= 0) return 0; + return reward / risk; +} + /** * Whether a setup clears the activation gate. Mirrors the backend predicate in * app/services/qualification.py — keep the two in sync. */ export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean { if (setup.rr_ratio < config.min_rr) return false; + // Live R:R from current price — drops setups whose price has already run + // toward target (reward consumed) or through the stop. + if (setup.current_price != null && liveRiskReward(setup, setup.current_price) < config.min_rr) { + return false; + } if ((setup.confidence_score ?? 0) < config.min_confidence) return false; if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) { return false; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d57519b..8b9f12e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -130,6 +130,7 @@ export interface TradeSetup { actual_outcome: string | null; outcome_date: string | null; evaluated_at: string | null; + current_price: number | null; recommendation_summary?: RecommendationSummary; } diff --git a/tests/unit/test_qualification.py b/tests/unit/test_qualification.py index 989d9e7..fd37b1c 100644 --- a/tests/unit/test_qualification.py +++ b/tests/unit/test_qualification.py @@ -56,6 +56,24 @@ class TestSetupQualifies: def test_no_targets_fails_when_probability_required(self): assert setup_qualifies(_setup(targets=[]), FULL_GATE) is False + def test_over_progressed_setup_fails_on_live_rr(self): + # long target 120, stop 95; price already at 117 → live R:R ≈ 0.14 + s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=117.0) + assert setup_qualifies(s, FULL_GATE) is False + + def test_fresh_setup_passes_live_rr(self): + # price near entry (100): live R:R ≈ 3.2, well above min + s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=101.0) + assert setup_qualifies(s, FULL_GATE) is True + + def test_past_stop_fails_live_rr(self): + s = _setup(direction="long", target=120.0, stop_loss=95.0, current_price=94.0) + assert setup_qualifies(s, FULL_GATE) is False + + def test_no_current_price_skips_live_check(self): + # Historical setups have no current_price → live check skipped + assert setup_qualifies(_setup(), FULL_GATE) is True + def test_conviction_filters_can_be_disabled(self): relaxed = { "min_rr": 2.0,