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:
@@ -21,7 +21,8 @@ from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
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
|
||||
|
||||
|
||||
@@ -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"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