Make live signal reads non-mutating
This commit is contained in:
@@ -11,13 +11,17 @@ Zero-candidate and single-candidate scenarios must produce identical results.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings, HealthCheck, strategies as st
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.signal_context_snapshot import SignalContextSnapshot
|
||||
from app.models.sr_level import SRLevel
|
||||
from app.models.ticker import Ticker
|
||||
from app.models.trade_setup import TradeSetup
|
||||
@@ -568,3 +572,253 @@ async def test_live_recommendation_filters_apply_to_live_values(
|
||||
live_recommendation=True,
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def _seed_two_direction_setup(db_session: AsyncSession) -> None:
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
ticker = Ticker(symbol="BOTH")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
|
||||
db_session.add_all([
|
||||
TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=112.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=30.0,
|
||||
detected_at=current,
|
||||
confidence_score=25.0,
|
||||
recommended_action="NEUTRAL",
|
||||
risk_level="Low",
|
||||
),
|
||||
TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="short",
|
||||
entry_price=100.0,
|
||||
stop_loss=105.0,
|
||||
target=88.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=30.0,
|
||||
detected_at=current,
|
||||
confidence_score=90.0,
|
||||
recommended_action="SHORT_HIGH",
|
||||
risk_level="Low",
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="technical",
|
||||
score=10.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="momentum",
|
||||
score=10.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="fundamental",
|
||||
score=10.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=30.0,
|
||||
is_stale=False,
|
||||
weights_json="{}",
|
||||
computed_at=current,
|
||||
),
|
||||
SentimentScore(
|
||||
ticker_id=ticker.id,
|
||||
classification="bearish",
|
||||
confidence=90,
|
||||
source="test",
|
||||
timestamp=current,
|
||||
reasoning="",
|
||||
citations_json="[]",
|
||||
),
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_recommendation_action_independent_of_direction_filter(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
await _seed_two_direction_setup(db_session)
|
||||
|
||||
all_rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="BOTH",
|
||||
live_recommendation=True,
|
||||
)
|
||||
filtered_rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="BOTH",
|
||||
direction="long",
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
long_from_all = next(row for row in all_rows if row["direction"] == "long")
|
||||
assert len(filtered_rows) == 1
|
||||
assert long_from_all["recommended_action"] == "SHORT_HIGH"
|
||||
assert filtered_rows[0]["recommended_action"] == "SHORT_HIGH"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_overlay_preserves_setup_specific_risk_and_context(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
ticker = Ticker(symbol="RISK")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
db_session.add_all([
|
||||
TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=112.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=50.0,
|
||||
detected_at=current,
|
||||
confidence_score=50.0,
|
||||
recommended_action="NEUTRAL",
|
||||
risk_level="Medium",
|
||||
conflict_flags_json=json.dumps([
|
||||
"target-availability: Fewer than 3 valid S/R targets available"
|
||||
]),
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="technical",
|
||||
score=50.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
DimensionScore(
|
||||
ticker_id=ticker.id,
|
||||
dimension="momentum",
|
||||
score=50.0,
|
||||
is_stale=False,
|
||||
computed_at=current,
|
||||
),
|
||||
CompositeScore(
|
||||
ticker_id=ticker.id,
|
||||
score=50.0,
|
||||
is_stale=False,
|
||||
weights_json="{}",
|
||||
computed_at=current,
|
||||
),
|
||||
SentimentScore(
|
||||
ticker_id=ticker.id,
|
||||
classification="neutral",
|
||||
confidence=50,
|
||||
source="test",
|
||||
timestamp=current,
|
||||
reasoning="",
|
||||
citations_json="[]",
|
||||
),
|
||||
OHLCVRecord(
|
||||
ticker_id=ticker.id,
|
||||
date=date(2026, 7, 3),
|
||||
open=101.0,
|
||||
high=102.0,
|
||||
low=100.0,
|
||||
close=101.0,
|
||||
volume=1000,
|
||||
created_at=current,
|
||||
),
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="RISK",
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["risk_level"] == "Medium"
|
||||
assert row["conflict_flags"] == [
|
||||
"target-availability: Fewer than 3 valid S/R targets available"
|
||||
]
|
||||
assert row["current_price"] == pytest.approx(101.0)
|
||||
assert row["context_as_of"]["score_computed_at"] == current
|
||||
assert row["context_as_of"]["sentiment_at"] == current
|
||||
assert row["context_as_of"]["price_date"] == date(2026, 7, 3)
|
||||
assert row["context_as_of"]["price_updated_at"] == current
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_trade_setup_read_does_not_recompute_scores(db_session: AsyncSession):
|
||||
await _seed_stale_setup_with_current_scores(db_session)
|
||||
|
||||
with patch(
|
||||
"app.services.scoring_service.compute_all_dimensions",
|
||||
new=AsyncMock(side_effect=AssertionError("GET must not recompute dimensions")),
|
||||
), patch(
|
||||
"app.services.scoring_service.compute_composite_score",
|
||||
new=AsyncMock(side_effect=AssertionError("GET must not recompute composite")),
|
||||
):
|
||||
rows = await get_trade_setups(
|
||||
db_session,
|
||||
symbol="TTWO",
|
||||
live_recommendation=True,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intraday_price_update_changes_live_price_without_new_signal_rows(
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
current = datetime(2026, 7, 3, tzinfo=timezone.utc)
|
||||
ticker = Ticker(symbol="LIVEP")
|
||||
db_session.add(ticker)
|
||||
await db_session.flush()
|
||||
setup = TradeSetup(
|
||||
ticker_id=ticker.id,
|
||||
direction="long",
|
||||
entry_price=100.0,
|
||||
stop_loss=95.0,
|
||||
target=112.0,
|
||||
rr_ratio=2.4,
|
||||
composite_score=50.0,
|
||||
detected_at=current,
|
||||
)
|
||||
price = OHLCVRecord(
|
||||
ticker_id=ticker.id,
|
||||
date=date(2026, 7, 3),
|
||||
open=100.0,
|
||||
high=101.0,
|
||||
low=99.0,
|
||||
close=100.0,
|
||||
volume=1000,
|
||||
created_at=current,
|
||||
)
|
||||
db_session.add_all([setup, price])
|
||||
await db_session.flush()
|
||||
|
||||
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
|
||||
assert rows[0]["current_price"] == pytest.approx(100.0)
|
||||
|
||||
price.close = 102.0
|
||||
await db_session.flush()
|
||||
|
||||
rows = await get_trade_setups(db_session, symbol="LIVEP", live_recommendation=True)
|
||||
assert rows[0]["current_price"] == pytest.approx(102.0)
|
||||
setup_count = await db_session.scalar(select(func.count()).select_from(TradeSetup))
|
||||
snapshot_count = await db_session.scalar(select(func.count()).select_from(SignalContextSnapshot))
|
||||
assert setup_count == 1
|
||||
assert snapshot_count == 0
|
||||
|
||||
Reference in New Issue
Block a user