Single-ticker fetch now attaches residual-momentum ranks so setups do not silently fail the activation gate. Exit plan is a timeline, chart labels move left of the price scale, and missing ranks surface explicitly.
152 lines
4.4 KiB
Python
152 lines
4.4 KiB
Python
"""Single-ticker activation ranks: manual refresh must attach residual ranks.
|
|
|
|
A daily ``scan_all`` path ranks the universe and stamps each setup. Manual
|
|
``/ingestion/fetch`` re-scans one symbol; without the same stamps the new
|
|
setup has ``momentum_percentile=None`` and fails the activation gate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from app.models.ticker import Ticker
|
|
from app.models.trade_setup import TradeSetup
|
|
from app.services import rr_scanner_service
|
|
from tests.conftest import _test_session_factory # type: ignore
|
|
|
|
|
|
@pytest.fixture
|
|
async def session():
|
|
async with _test_session_factory() as s:
|
|
yield s
|
|
|
|
|
|
async def test_resolve_ranks_uses_fresh_universe_rank(session, monkeypatch):
|
|
session.add(Ticker(symbol="AAA"))
|
|
await session.commit()
|
|
|
|
async def _fake_ranks(db):
|
|
return {
|
|
"AAA": {
|
|
"momentum_percentile": 91.0,
|
|
"strategy_rank": 88.5,
|
|
"volatility_percentile": 70.0,
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.momentum_service.compute_activation_ranks",
|
|
_fake_ranks,
|
|
)
|
|
|
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "aaa")
|
|
assert ranks["momentum_percentile"] == 91.0
|
|
assert ranks["strategy_rank"] == 88.5
|
|
assert ranks["volatility_percentile"] == 70.0
|
|
|
|
|
|
async def test_resolve_ranks_falls_back_to_previous_setup(session, monkeypatch):
|
|
ticker = Ticker(symbol="BBB")
|
|
session.add(ticker)
|
|
await session.flush()
|
|
session.add(
|
|
TradeSetup(
|
|
ticker_id=ticker.id,
|
|
direction="long",
|
|
entry_price=100.0,
|
|
stop_loss=95.0,
|
|
target=110.0,
|
|
rr_ratio=2.0,
|
|
composite_score=60.0,
|
|
momentum_percentile=84.0,
|
|
strategy_rank=80.0,
|
|
volatility_percentile=55.0,
|
|
detected_at=datetime.now(timezone.utc),
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
async def _empty_ranks(db):
|
|
return {}
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.momentum_service.compute_activation_ranks",
|
|
_empty_ranks,
|
|
)
|
|
|
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "BBB")
|
|
assert ranks["momentum_percentile"] == 84.0
|
|
assert ranks["strategy_rank"] == 80.0
|
|
assert ranks["volatility_percentile"] == 55.0
|
|
|
|
|
|
async def test_resolve_ranks_skips_null_prior_setups(session, monkeypatch):
|
|
"""Broken prior setups (null percentile) must not block a still-older ranked row."""
|
|
ticker = Ticker(symbol="CCC")
|
|
session.add(ticker)
|
|
await session.flush()
|
|
now = datetime.now(timezone.utc)
|
|
session.add_all([
|
|
TradeSetup(
|
|
ticker_id=ticker.id,
|
|
direction="long",
|
|
entry_price=100.0,
|
|
stop_loss=95.0,
|
|
target=110.0,
|
|
rr_ratio=2.0,
|
|
composite_score=60.0,
|
|
momentum_percentile=93.0,
|
|
strategy_rank=90.0,
|
|
volatility_percentile=40.0,
|
|
detected_at=now.replace(year=now.year - 1) if now.year > 2000 else now,
|
|
),
|
|
TradeSetup(
|
|
ticker_id=ticker.id,
|
|
direction="long",
|
|
entry_price=101.0,
|
|
stop_loss=96.0,
|
|
target=111.0,
|
|
rr_ratio=2.1,
|
|
composite_score=61.0,
|
|
momentum_percentile=None,
|
|
strategy_rank=None,
|
|
volatility_percentile=None,
|
|
detected_at=now,
|
|
),
|
|
])
|
|
await session.commit()
|
|
|
|
async def _empty_ranks(db):
|
|
return {}
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.momentum_service.compute_activation_ranks",
|
|
_empty_ranks,
|
|
)
|
|
|
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "CCC")
|
|
assert ranks["momentum_percentile"] == 93.0
|
|
assert ranks["strategy_rank"] == 90.0
|
|
|
|
|
|
async def test_resolve_ranks_returns_empty_when_unavailable(session, monkeypatch):
|
|
session.add(Ticker(symbol="DDD"))
|
|
await session.commit()
|
|
|
|
async def _boom(db):
|
|
raise RuntimeError("ranker down")
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.momentum_service.compute_activation_ranks",
|
|
_boom,
|
|
)
|
|
|
|
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "DDD")
|
|
assert ranks == {
|
|
"momentum_percentile": None,
|
|
"strategy_rank": None,
|
|
"volatility_percentile": None,
|
|
}
|