Drop over-progressed setups via live R:R; refresh trades on fetch
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 25s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 14:02:10 +02:00
parent a32f09c8ba
commit da83f027e1
7 changed files with 97 additions and 4 deletions
+1
View File
@@ -47,4 +47,5 @@ class TradeSetupResponse(BaseModel):
actual_outcome: str | None = None actual_outcome: str | None = None
outcome_date: date | None = None outcome_date: date | None = None
evaluated_at: datetime | None = None evaluated_at: datetime | None = None
current_price: float | None = None
recommendation_summary: RecommendationSummaryResponse | None = None recommendation_summary: RecommendationSummaryResponse | None = None
+26
View File
@@ -20,6 +20,24 @@ def best_target_probability(setup: Any) -> float:
return max(probs, default=0.0) 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: def setup_qualifies(setup: Any, config: dict) -> bool:
"""Whether a setup clears the activation gate. """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"]: if setup.rr_ratio < config["min_rr"]:
return False 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"]: if (setup.confidence_score or 0.0) < config["min_confidence"]:
return False return False
if config.get("require_high_conviction"): if config.get("require_high_conviction"):
+35 -4
View File
@@ -12,10 +12,11 @@ import json
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError from app.exceptions import NotFoundError
from app.models.ohlcv import OHLCVRecord
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.sr_level import SRLevel from app.models.sr_level import SRLevel
@@ -308,7 +309,32 @@ async def get_trade_setups(
reverse=True, 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( async def get_trade_setup_history(
@@ -325,10 +351,14 @@ async def get_trade_setup_history(
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() 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] = [] targets: list[dict] = []
conflicts: list[str] = [] conflicts: list[str] = []
@@ -367,4 +397,5 @@ def _trade_setup_to_dict(setup: TradeSetup, symbol: str) -> dict:
"actual_outcome": setup.actual_outcome, "actual_outcome": setup.actual_outcome,
"outcome_date": setup.outcome_date, "outcome_date": setup.outcome_date,
"evaluated_at": setup.evaluated_at, "evaluated_at": setup.evaluated_at,
"current_price": current_price,
} }
+3
View File
@@ -40,6 +40,9 @@ export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) {
queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] }); queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] });
queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] }); queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] });
queryClient.invalidateQueries({ queryKey: ['scores', 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) { if (invalidatePipelineReadiness) {
queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] });
+13
View File
@@ -13,12 +13,25 @@ export function primaryTargetProbability(setup: TradeSetup): number | null {
return setup.targets?.length ? bestTargetProbability(setup) : 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 * Whether a setup clears the activation gate. Mirrors the backend predicate in
* app/services/qualification.py — keep the two in sync. * app/services/qualification.py — keep the two in sync.
*/ */
export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean { export function qualifiesSetup(setup: TradeSetup, config: ActivationConfig): boolean {
if (setup.rr_ratio < config.min_rr) return false; 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 ((setup.confidence_score ?? 0) < config.min_confidence) return false;
if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) { if (config.require_high_conviction && !HIGH_CONVICTION_ACTIONS.has(setup.recommended_action ?? '')) {
return false; return false;
+1
View File
@@ -130,6 +130,7 @@ export interface TradeSetup {
actual_outcome: string | null; actual_outcome: string | null;
outcome_date: string | null; outcome_date: string | null;
evaluated_at: string | null; evaluated_at: string | null;
current_price: number | null;
recommendation_summary?: RecommendationSummary; recommendation_summary?: RecommendationSummary;
} }
+18
View File
@@ -56,6 +56,24 @@ class TestSetupQualifies:
def test_no_targets_fails_when_probability_required(self): def test_no_targets_fails_when_probability_required(self):
assert setup_qualifies(_setup(targets=[]), FULL_GATE) is False 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): def test_conviction_filters_can_be_disabled(self):
relaxed = { relaxed = {
"min_rr": 2.0, "min_rr": 2.0,