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
outcome_date: date | None = None
evaluated_at: datetime | None = None
current_price: float | 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)
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"):
+35 -4
View File
@@ -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,
}