Serve live recommendation context on trade setup APIs and alerts
Stored TradeSetup rows are point-in-time snapshots from the RR scan, so
the ticker page could show stale confidence/reasoning/composite (e.g.
sentiment=neutral in the setup card while the sentiment panel showed
bullish). Overlay current score/sentiment context onto the API payload
for GET /trades and GET /trades/{symbol}, gate and format Telegram
qualified-setup alerts on the same live values, and apply the
min_confidence/recommended_action filters after the overlay so they
judge what the caller actually sees. Stored setups stay frozen for
outcome analysis and backtests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ async def list_trade_setups(
|
|||||||
direction=direction,
|
direction=direction,
|
||||||
min_confidence=min_confidence,
|
min_confidence=min_confidence,
|
||||||
recommended_action=recommended_action,
|
recommended_action=recommended_action,
|
||||||
|
live_recommendation=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = []
|
data = []
|
||||||
@@ -92,7 +93,12 @@ async def get_ticker_trade_setups(
|
|||||||
_user=Depends(require_access),
|
_user=Depends(require_access),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> APIEnvelope:
|
) -> APIEnvelope:
|
||||||
rows = await get_trade_setups(db, symbol=symbol)
|
rows = await get_trade_setups(
|
||||||
|
db,
|
||||||
|
symbol=symbol,
|
||||||
|
live_recommendation=True,
|
||||||
|
recompute_scores=True,
|
||||||
|
)
|
||||||
data = []
|
data = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
summary = RecommendationSummaryResponse(
|
summary = RecommendationSummaryResponse(
|
||||||
|
|||||||
@@ -254,7 +254,9 @@ async def _watchlist_tickers(db: AsyncSession) -> list[tuple[int, str]]:
|
|||||||
|
|
||||||
|
|
||||||
async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
async def _qualified_setups(db: AsyncSession) -> list[dict]:
|
||||||
setups = await get_trade_setups(db)
|
# live_recommendation: gate and format on current score/sentiment context,
|
||||||
|
# not the values frozen into the setup at scan time.
|
||||||
|
setups = await get_trade_setups(db, live_recommendation=True)
|
||||||
config = await get_activation_config(db)
|
config = await get_activation_config(db)
|
||||||
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
return [s for s in setups if setup_qualifies(SimpleNamespace(**s), config)]
|
||||||
|
|
||||||
|
|||||||
@@ -524,6 +524,56 @@ def _build_reasoning(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_recommendation_snapshot(
|
||||||
|
dimension_scores: dict[str, float],
|
||||||
|
sentiment_classification: str | None,
|
||||||
|
config: dict[str, float],
|
||||||
|
available_directions: set[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build the ticker-level recommendation from the supplied live context."""
|
||||||
|
conflicts = signal_conflict_detector.detect_conflicts(
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
long_confidence = direction_analyzer.calculate_confidence(
|
||||||
|
direction="long",
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
conflicts=conflicts,
|
||||||
|
)
|
||||||
|
short_confidence = direction_analyzer.calculate_confidence(
|
||||||
|
direction="short",
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
conflicts=conflicts,
|
||||||
|
)
|
||||||
|
|
||||||
|
action = _choose_recommended_action(
|
||||||
|
long_confidence, short_confidence, config, available_directions
|
||||||
|
)
|
||||||
|
reasoning = _build_reasoning(
|
||||||
|
action=action,
|
||||||
|
long_confidence=long_confidence,
|
||||||
|
short_confidence=short_confidence,
|
||||||
|
conflicts=conflicts,
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment_classification,
|
||||||
|
config=config,
|
||||||
|
available_directions=available_directions,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"reasoning": reasoning,
|
||||||
|
"risk_level": _risk_level_from_conflicts(conflicts),
|
||||||
|
"long_confidence": long_confidence,
|
||||||
|
"short_confidence": short_confidence,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
PRIMARY_TARGET_MIN_RR = 1.5
|
PRIMARY_TARGET_MIN_RR = 1.5
|
||||||
|
|
||||||
|
|
||||||
@@ -559,24 +609,15 @@ async def enhance_trade_setup(
|
|||||||
) -> TradeSetup:
|
) -> TradeSetup:
|
||||||
config = await get_recommendation_config(db)
|
config = await get_recommendation_config(db)
|
||||||
|
|
||||||
conflicts = signal_conflict_detector.detect_conflicts(
|
snapshot = build_recommendation_snapshot(
|
||||||
dimension_scores=dimension_scores,
|
dimension_scores=dimension_scores,
|
||||||
sentiment_classification=sentiment_classification,
|
sentiment_classification=sentiment_classification,
|
||||||
config=config,
|
config=config,
|
||||||
|
available_directions=available_directions,
|
||||||
)
|
)
|
||||||
|
conflicts = list(snapshot["conflicts"])
|
||||||
long_confidence = direction_analyzer.calculate_confidence(
|
long_confidence = float(snapshot["long_confidence"])
|
||||||
direction="long",
|
short_confidence = float(snapshot["short_confidence"])
|
||||||
dimension_scores=dimension_scores,
|
|
||||||
sentiment_classification=sentiment_classification,
|
|
||||||
conflicts=conflicts,
|
|
||||||
)
|
|
||||||
short_confidence = direction_analyzer.calculate_confidence(
|
|
||||||
direction="short",
|
|
||||||
dimension_scores=dimension_scores,
|
|
||||||
sentiment_classification=sentiment_classification,
|
|
||||||
conflicts=conflicts,
|
|
||||||
)
|
|
||||||
|
|
||||||
direction = setup.direction.lower()
|
direction = setup.direction.lower()
|
||||||
confidence = long_confidence if direction == "long" else short_confidence
|
confidence = long_confidence if direction == "long" else short_confidence
|
||||||
@@ -622,19 +663,8 @@ async def enhance_trade_setup(
|
|||||||
|
|
||||||
# Action and reasoning are ticker-level: they consider both directions and
|
# Action and reasoning are ticker-level: they consider both directions and
|
||||||
# which directions are actually tradeable, and are identical on every setup.
|
# which directions are actually tradeable, and are identical on every setup.
|
||||||
action = _choose_recommended_action(
|
action = str(snapshot["action"])
|
||||||
long_confidence, short_confidence, config, available_directions
|
reasoning = str(snapshot["reasoning"])
|
||||||
)
|
|
||||||
reasoning = _build_reasoning(
|
|
||||||
action=action,
|
|
||||||
long_confidence=long_confidence,
|
|
||||||
short_confidence=short_confidence,
|
|
||||||
conflicts=conflicts,
|
|
||||||
dimension_scores=dimension_scores,
|
|
||||||
sentiment_classification=sentiment_classification,
|
|
||||||
config=config,
|
|
||||||
available_directions=available_directions,
|
|
||||||
)
|
|
||||||
|
|
||||||
setup.confidence_score = round(confidence, 2)
|
setup.confidence_score = round(confidence, 2)
|
||||||
setup.targets_json = json.dumps(targets)
|
setup.targets_json = json.dumps(targets)
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ from app.models.ticker import Ticker
|
|||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
from app.services.indicator_service import _extract_ohlcv, compute_atr
|
||||||
from app.services.price_service import query_ohlcv
|
from app.services.price_service import query_ohlcv
|
||||||
from app.services.recommendation_service import enhance_trade_setup
|
from app.services.recommendation_service import (
|
||||||
|
build_recommendation_snapshot,
|
||||||
|
enhance_trade_setup,
|
||||||
|
get_recommendation_config,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -80,6 +84,110 @@ async def _get_latest_sentiment(db: AsyncSession, ticker_id: int) -> str | None:
|
|||||||
return row.classification if row else None
|
return row.classification if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_score_context_for_symbols(
|
||||||
|
db: AsyncSession,
|
||||||
|
symbols: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Refresh provider-free scores so live recommendation summaries match the page."""
|
||||||
|
if not symbols:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.services import scoring_service
|
||||||
|
|
||||||
|
refreshed = False
|
||||||
|
for symbol in sorted(symbols):
|
||||||
|
try:
|
||||||
|
await scoring_service.compute_all_dimensions(db, symbol)
|
||||||
|
await scoring_service.compute_composite_score(db, symbol)
|
||||||
|
refreshed = True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error refreshing live score context for %s", symbol)
|
||||||
|
|
||||||
|
if refreshed:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_live_recommendation_context(
|
||||||
|
db: AsyncSession,
|
||||||
|
setup_rows: list[tuple[TradeSetup, str]],
|
||||||
|
rows: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Decorate latest setup rows with current score/sentiment recommendation data.
|
||||||
|
|
||||||
|
This intentionally updates only the API payload. Stored trade setups and
|
||||||
|
history remain point-in-time records for outcome analysis.
|
||||||
|
"""
|
||||||
|
if not rows or not setup_rows:
|
||||||
|
return rows
|
||||||
|
|
||||||
|
ticker_ids = {setup.ticker_id for setup, _ in setup_rows}
|
||||||
|
setups_by_id = {setup.id: setup for setup, _ in setup_rows}
|
||||||
|
|
||||||
|
directions_by_ticker: dict[int, set[str]] = {}
|
||||||
|
for setup, _ in setup_rows:
|
||||||
|
directions_by_ticker.setdefault(setup.ticker_id, set()).add(setup.direction.lower())
|
||||||
|
|
||||||
|
dim_result = await db.execute(
|
||||||
|
select(DimensionScore).where(DimensionScore.ticker_id.in_(ticker_ids))
|
||||||
|
)
|
||||||
|
dims_by_ticker: dict[int, dict[str, float]] = {}
|
||||||
|
for ds in dim_result.scalars().all():
|
||||||
|
dims_by_ticker.setdefault(ds.ticker_id, {})[ds.dimension] = float(ds.score)
|
||||||
|
|
||||||
|
comp_result = await db.execute(
|
||||||
|
select(CompositeScore)
|
||||||
|
.where(CompositeScore.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(CompositeScore.ticker_id, CompositeScore.computed_at.desc())
|
||||||
|
)
|
||||||
|
composites: dict[int, CompositeScore] = {}
|
||||||
|
for comp in comp_result.scalars().all():
|
||||||
|
composites.setdefault(comp.ticker_id, comp)
|
||||||
|
|
||||||
|
sent_result = await db.execute(
|
||||||
|
select(SentimentScore)
|
||||||
|
.where(SentimentScore.ticker_id.in_(ticker_ids))
|
||||||
|
.order_by(SentimentScore.ticker_id, SentimentScore.timestamp.desc())
|
||||||
|
)
|
||||||
|
sentiments: dict[int, SentimentScore] = {}
|
||||||
|
for sent in sent_result.scalars().all():
|
||||||
|
sentiments.setdefault(sent.ticker_id, sent)
|
||||||
|
|
||||||
|
config = await get_recommendation_config(db)
|
||||||
|
live_rows: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
setup = setups_by_id.get(row["id"])
|
||||||
|
if setup is None:
|
||||||
|
live_rows.append(row)
|
||||||
|
continue
|
||||||
|
|
||||||
|
ticker_id = setup.ticker_id
|
||||||
|
live_row = dict(row)
|
||||||
|
|
||||||
|
comp = composites.get(ticker_id)
|
||||||
|
if comp is not None:
|
||||||
|
live_row["composite_score"] = float(comp.score)
|
||||||
|
|
||||||
|
dimension_scores = dims_by_ticker.get(ticker_id)
|
||||||
|
if dimension_scores:
|
||||||
|
sentiment = sentiments.get(ticker_id)
|
||||||
|
snapshot = build_recommendation_snapshot(
|
||||||
|
dimension_scores=dimension_scores,
|
||||||
|
sentiment_classification=sentiment.classification if sentiment else None,
|
||||||
|
config=config,
|
||||||
|
available_directions=directions_by_ticker.get(ticker_id),
|
||||||
|
)
|
||||||
|
direction = setup.direction.lower()
|
||||||
|
confidence_key = "long_confidence" if direction == "long" else "short_confidence"
|
||||||
|
live_row["confidence_score"] = round(float(snapshot[confidence_key]), 2)
|
||||||
|
live_row["recommended_action"] = snapshot["action"]
|
||||||
|
live_row["reasoning"] = snapshot["reasoning"]
|
||||||
|
live_row["risk_level"] = snapshot["risk_level"]
|
||||||
|
|
||||||
|
live_rows.append(live_row)
|
||||||
|
|
||||||
|
return live_rows
|
||||||
|
|
||||||
|
|
||||||
def _json_default(value):
|
def _json_default(value):
|
||||||
if isinstance(value, (datetime, date)):
|
if isinstance(value, (datetime, date)):
|
||||||
return value.isoformat()
|
return value.isoformat()
|
||||||
@@ -441,6 +549,8 @@ async def get_trade_setups(
|
|||||||
min_confidence: float | None = None,
|
min_confidence: float | None = None,
|
||||||
recommended_action: str | None = None,
|
recommended_action: str | None = None,
|
||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
|
live_recommendation: bool = False,
|
||||||
|
recompute_scores: bool = False,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get latest stored trade setups, optionally filtered."""
|
"""Get latest stored trade setups, optionally filtered."""
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -451,9 +561,11 @@ async def get_trade_setups(
|
|||||||
stmt = stmt.where(TradeSetup.direction == direction.lower())
|
stmt = stmt.where(TradeSetup.direction == direction.lower())
|
||||||
if symbol is not None:
|
if symbol is not None:
|
||||||
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
|
stmt = stmt.where(Ticker.symbol == symbol.strip().upper())
|
||||||
if min_confidence is not None:
|
# With live_recommendation these fields are overlaid with current values
|
||||||
|
# below, so filtering happens there instead of against the stored columns.
|
||||||
|
if min_confidence is not None and not live_recommendation:
|
||||||
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
stmt = stmt.where(TradeSetup.confidence_score >= min_confidence)
|
||||||
if recommended_action is not None:
|
if recommended_action is not None and not live_recommendation:
|
||||||
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
stmt = stmt.where(TradeSetup.recommended_action == recommended_action)
|
||||||
|
|
||||||
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
stmt = stmt.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
|
||||||
@@ -477,11 +589,38 @@ async def get_trade_setups(
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if recompute_scores:
|
||||||
|
await _refresh_score_context_for_symbols(
|
||||||
|
db, {ticker_symbol for _, ticker_symbol in latest_rows}
|
||||||
|
)
|
||||||
|
|
||||||
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows})
|
prices = await _latest_closes(db, {s.ticker_id for s, _ in latest_rows})
|
||||||
return [
|
rows_out = [
|
||||||
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
_trade_setup_to_dict(setup, ticker_symbol, prices.get(setup.ticker_id))
|
||||||
for setup, ticker_symbol in latest_rows
|
for setup, ticker_symbol in latest_rows
|
||||||
]
|
]
|
||||||
|
if live_recommendation:
|
||||||
|
rows_out = await _apply_live_recommendation_context(db, latest_rows, rows_out)
|
||||||
|
if min_confidence is not None:
|
||||||
|
rows_out = [
|
||||||
|
row for row in rows_out
|
||||||
|
if row["confidence_score"] is not None
|
||||||
|
and row["confidence_score"] >= min_confidence
|
||||||
|
]
|
||||||
|
if recommended_action is not None:
|
||||||
|
rows_out = [
|
||||||
|
row for row in rows_out
|
||||||
|
if row["recommended_action"] == recommended_action
|
||||||
|
]
|
||||||
|
rows_out.sort(
|
||||||
|
key=lambda row: (
|
||||||
|
row["confidence_score"] if row["confidence_score"] is not None else -1.0,
|
||||||
|
row["rr_ratio"],
|
||||||
|
row["composite_score"],
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return rows_out
|
||||||
|
|
||||||
|
|
||||||
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
async def _latest_closes(db: AsyncSession, ticker_ids: set[int]) -> dict[int, float]:
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ from app.models.ohlcv import OHLCVRecord
|
|||||||
from app.models.sr_level import SRLevel
|
from app.models.sr_level import SRLevel
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.models.trade_setup import TradeSetup
|
from app.models.trade_setup import TradeSetup
|
||||||
from app.models.score import CompositeScore
|
from app.models.score import CompositeScore, DimensionScore
|
||||||
|
from app.models.sentiment import SentimentScore
|
||||||
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
|
from app.services.rr_scanner_service import scan_ticker, get_trade_setups
|
||||||
|
|
||||||
|
|
||||||
@@ -431,3 +432,139 @@ async def test_get_trade_setups_sorting_rr_desc_composite_desc(db_session: Async
|
|||||||
f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
|
f"Expected symbol order ['SORTD', 'SORTC', 'SORTB', 'SORTA'], "
|
||||||
f"got {symbols}"
|
f"got {symbols}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
old_reasoning = (
|
||||||
|
"LONG (high confidence): 82% with aligned signals "
|
||||||
|
"(technical=88, momentum=60, sentiment=neutral)."
|
||||||
|
)
|
||||||
|
|
||||||
|
ticker = Ticker(symbol="TTWO")
|
||||||
|
db_session.add(ticker)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
stale_setup = TradeSetup(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
direction="long",
|
||||||
|
entry_price=235.0,
|
||||||
|
stop_loss=220.0,
|
||||||
|
target=265.0,
|
||||||
|
rr_ratio=2.0,
|
||||||
|
composite_score=71.8,
|
||||||
|
detected_at=old_scan,
|
||||||
|
confidence_score=82.0,
|
||||||
|
recommended_action="LONG_HIGH",
|
||||||
|
reasoning=old_reasoning,
|
||||||
|
risk_level="High",
|
||||||
|
)
|
||||||
|
db_session.add(stale_setup)
|
||||||
|
|
||||||
|
db_session.add_all([
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="technical",
|
||||||
|
score=88.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="momentum",
|
||||||
|
score=60.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="fundamental",
|
||||||
|
score=95.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
DimensionScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
dimension="sentiment",
|
||||||
|
score=85.0,
|
||||||
|
is_stale=False,
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
CompositeScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
score=96.0,
|
||||||
|
is_stale=False,
|
||||||
|
weights_json="{}",
|
||||||
|
computed_at=current,
|
||||||
|
),
|
||||||
|
SentimentScore(
|
||||||
|
ticker_id=ticker.id,
|
||||||
|
classification="bullish",
|
||||||
|
confidence=85,
|
||||||
|
source="test",
|
||||||
|
timestamp=current,
|
||||||
|
reasoning="",
|
||||||
|
citations_json="[]",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
await db_session.flush()
|
||||||
|
return stale_setup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_recommendation_payload_uses_current_score_and_sentiment(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Latest setup payload should not show stale scan text when score context moved."""
|
||||||
|
stale_setup = await _seed_stale_setup_with_current_scores(db_session)
|
||||||
|
old_reasoning = stale_setup.reasoning
|
||||||
|
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="TTWO",
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["composite_score"] == pytest.approx(96.0)
|
||||||
|
assert row["confidence_score"] == pytest.approx(97.0)
|
||||||
|
assert row["recommended_action"] == "LONG_HIGH"
|
||||||
|
assert "sentiment=bullish" in row["reasoning"]
|
||||||
|
assert "sentiment=neutral" not in row["reasoning"]
|
||||||
|
|
||||||
|
persisted = await db_session.get(TradeSetup, stale_setup.id)
|
||||||
|
assert persisted is not None
|
||||||
|
assert persisted.composite_score == pytest.approx(71.8)
|
||||||
|
assert persisted.reasoning == old_reasoning
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_recommendation_filters_apply_to_live_values(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""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.
|
||||||
|
# Live confidence is 97, so it must pass.
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="TTWO",
|
||||||
|
min_confidence=90.0,
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["confidence_score"] == pytest.approx(97.0)
|
||||||
|
|
||||||
|
# And a floor above the live value must drop it.
|
||||||
|
rows = await get_trade_setups(
|
||||||
|
db_session,
|
||||||
|
symbol="TTWO",
|
||||||
|
min_confidence=98.0,
|
||||||
|
live_recommendation=True,
|
||||||
|
)
|
||||||
|
assert rows == []
|
||||||
|
|||||||
Reference in New Issue
Block a user