Drop over-progressed setups via live R:R; refresh trades on fetch
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:
@@ -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
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user