Add trade setup outcome tracking and performance stats
Deploy / lint (push) Successful in 25s
Deploy / test (push) Successful in 1m7s
Deploy / deploy (push) Successful in 25s

Closes the feedback loop on R:R scanner signals:

- Nightly outcome_evaluator job replays unresolved setups against daily
  OHLCV bars: target_hit / stop_hit / ambiguous (same-bar, counted as
  loss) / expired after OUTCOME_EVALUATION_MAX_BARS (default 30)
- Migration 004: evaluated_at + outcome_date on trade_setups
- GET /trades/performance: hit rate, expectancy (avg R), total R with
  breakdowns by direction, recommended action, and confidence bucket
- New Performance page (stat cards, breakdown tables, Evaluate Now,
  methodology disclosure) wired into sidebar and mobile nav
- 17 new unit tests for evaluation logic and stats aggregation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:23:57 +02:00
parent d69df5df27
commit 21ed83c56c
20 changed files with 859 additions and 5 deletions
+272
View File
@@ -0,0 +1,272 @@
"""Unit tests for the trade setup outcome evaluation service."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services.outcome_service import (
OUTCOME_AMBIGUOUS,
OUTCOME_EXPIRED,
OUTCOME_STOP_HIT,
OUTCOME_TARGET_HIT,
Bar,
evaluate_pending_setups,
evaluate_setup_against_bars,
get_performance_stats,
)
@pytest.fixture
async def outcome_session() -> AsyncSession:
"""DB session compatible with evaluate_pending_setups (which commits)."""
from tests.conftest import _test_session_factory
async with _test_session_factory() as session:
yield session
def _bars(*hl: tuple[float, float], start: date = date(2026, 1, 5)) -> list[Bar]:
return [
Bar(date=start + timedelta(days=i), high=high, low=low)
for i, (high, low) in enumerate(hl)
]
# ---------------------------------------------------------------------------
# evaluate_setup_against_bars — pure logic
# ---------------------------------------------------------------------------
class TestEvaluateSetupAgainstBars:
def test_long_target_hit(self):
# entry ~100, stop 95, target 110
bars = _bars((105, 99), (111, 104))
outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars)
assert outcome == OUTCOME_TARGET_HIT
assert outcome_date == bars[1].date
def test_long_stop_hit(self):
bars = _bars((105, 99), (103, 94))
outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars)
assert outcome == OUTCOME_STOP_HIT
assert outcome_date == bars[1].date
def test_long_stop_before_target_across_bars(self):
# Stop hit on bar 0, target would hit on bar 1 — stop wins (first bar decides)
bars = _bars((100, 94), (112, 100))
outcome, _ = evaluate_setup_against_bars("long", 95, 110, bars)
assert outcome == OUTCOME_STOP_HIT
def test_short_target_hit(self):
# short: entry ~100, stop 105, target 90
bars = _bars((102, 96), (98, 89))
outcome, outcome_date = evaluate_setup_against_bars("short", 105, 90, bars)
assert outcome == OUTCOME_TARGET_HIT
assert outcome_date == bars[1].date
def test_short_stop_hit(self):
bars = _bars((102, 96), (106, 98))
outcome, _ = evaluate_setup_against_bars("short", 105, 90, bars)
assert outcome == OUTCOME_STOP_HIT
def test_ambiguous_when_both_levels_in_same_bar(self):
# one giant bar spans both stop (95) and target (110)
bars = _bars((112, 94))
outcome, _ = evaluate_setup_against_bars("long", 95, 110, bars)
assert outcome == OUTCOME_AMBIGUOUS
def test_pending_when_not_enough_bars(self):
bars = _bars((105, 99), (104, 98))
outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars, max_bars=30)
assert outcome is None
assert outcome_date is None
def test_expired_after_max_bars(self):
bars = _bars(*[(105, 99)] * 10)
outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars, max_bars=10)
assert outcome == OUTCOME_EXPIRED
assert outcome_date == bars[9].date
def test_hit_beyond_max_bars_is_ignored(self):
# target hit on bar 11 but window is 10 — expired
bars = _bars(*[(105, 99)] * 10, (115, 105))
outcome, _ = evaluate_setup_against_bars("long", 95, 110, bars, max_bars=10)
assert outcome == OUTCOME_EXPIRED
def test_no_bars_is_pending(self):
outcome, _ = evaluate_setup_against_bars("long", 95, 110, [])
assert outcome is None
# ---------------------------------------------------------------------------
# evaluate_pending_setups — DB integration
# ---------------------------------------------------------------------------
async def _make_ticker(db: AsyncSession, symbol: str = "AAPL") -> Ticker:
ticker = Ticker(symbol=symbol)
db.add(ticker)
await db.flush()
return ticker
def _make_setup(
ticker: Ticker,
direction: str = "long",
entry: float = 100.0,
stop: float = 95.0,
target: float = 110.0,
rr: float = 2.0,
detected: datetime | None = None,
**kwargs,
) -> TradeSetup:
return TradeSetup(
ticker_id=ticker.id,
direction=direction,
entry_price=entry,
stop_loss=stop,
target=target,
rr_ratio=rr,
composite_score=50.0,
detected_at=detected or datetime(2026, 1, 2, 21, 0, tzinfo=timezone.utc),
**kwargs,
)
def _add_bars(db: AsyncSession, ticker: Ticker, *hl: tuple[float, float], start: date = date(2026, 1, 5)):
for i, (high, low) in enumerate(hl):
db.add(OHLCVRecord(
ticker_id=ticker.id,
date=start + timedelta(days=i),
open=(high + low) / 2,
high=high,
low=low,
close=(high + low) / 2,
volume=1_000_000,
))
class TestEvaluatePendingSetups:
async def test_writes_outcome_and_metadata(self, outcome_session: AsyncSession):
ticker = await _make_ticker(outcome_session)
setup = _make_setup(ticker)
outcome_session.add(setup)
_add_bars(outcome_session, ticker, (105, 99), (111, 104))
await outcome_session.flush()
summary = await evaluate_pending_setups(outcome_session)
assert summary["evaluated"] == 1
assert summary["by_outcome"] == {OUTCOME_TARGET_HIT: 1}
result = await outcome_session.execute(select(TradeSetup))
stored = result.scalar_one()
assert stored.actual_outcome == OUTCOME_TARGET_HIT
assert stored.outcome_date == date(2026, 1, 6)
assert stored.evaluated_at is not None
async def test_undecided_setup_stays_pending(self, outcome_session: AsyncSession):
ticker = await _make_ticker(outcome_session)
outcome_session.add(_make_setup(ticker))
_add_bars(outcome_session, ticker, (105, 99)) # no level hit, < max_bars
await outcome_session.flush()
summary = await evaluate_pending_setups(outcome_session)
assert summary["evaluated"] == 0
assert summary["still_pending"] == 1
result = await outcome_session.execute(select(TradeSetup))
assert result.scalar_one().actual_outcome is None
async def test_only_bars_after_detection_are_used(self, outcome_session: AsyncSession):
ticker = await _make_ticker(outcome_session)
# bar on the detection date itself would hit the stop — must be ignored
_add_bars(outcome_session, ticker, (100, 90), start=date(2026, 1, 2))
_add_bars(outcome_session, ticker, (111, 104), start=date(2026, 1, 5))
outcome_session.add(_make_setup(ticker))
await outcome_session.flush()
await evaluate_pending_setups(outcome_session)
result = await outcome_session.execute(select(TradeSetup))
assert result.scalar_one().actual_outcome == OUTCOME_TARGET_HIT
async def test_already_evaluated_setups_are_skipped(self, outcome_session: AsyncSession):
ticker = await _make_ticker(outcome_session)
outcome_session.add(_make_setup(ticker, actual_outcome=OUTCOME_STOP_HIT))
await outcome_session.flush()
summary = await evaluate_pending_setups(outcome_session)
assert summary["evaluated"] == 0
assert summary["still_pending"] == 0
# ---------------------------------------------------------------------------
# get_performance_stats
# ---------------------------------------------------------------------------
class TestGetPerformanceStats:
async def test_empty_database(self, db_session: AsyncSession):
stats = await get_performance_stats(db_session)
assert stats["overall"]["total"] == 0
assert stats["overall"]["hit_rate"] is None
assert stats["pending"] == 0
async def test_aggregation(self, db_session: AsyncSession):
ticker = await _make_ticker(db_session)
db_session.add(_make_setup(
ticker, direction="long", rr=3.0,
actual_outcome=OUTCOME_TARGET_HIT, confidence_score=80.0,
recommended_action="LONG_HIGH",
))
db_session.add(_make_setup(
ticker, direction="long", rr=2.0,
actual_outcome=OUTCOME_STOP_HIT, confidence_score=55.0,
recommended_action="LONG_MODERATE",
))
db_session.add(_make_setup(
ticker, direction="short", rr=2.5,
actual_outcome=OUTCOME_EXPIRED, confidence_score=40.0,
recommended_action="NEUTRAL",
))
db_session.add(_make_setup(ticker, direction="short")) # pending
await db_session.flush()
stats = await get_performance_stats(db_session)
overall = stats["overall"]
assert overall["total"] == 3
assert overall["wins"] == 1
assert overall["losses"] == 1
assert overall["expired"] == 1
assert overall["hit_rate"] == 50.0
# realized: +3.0 (win), -1.0 (loss), 0.0 (expired) → avg 0.667
assert overall["avg_r"] == pytest.approx(0.667, abs=0.001)
assert overall["total_r"] == pytest.approx(2.0)
assert stats["pending"] == 1
assert stats["by_direction"]["long"]["total"] == 2
assert stats["by_direction"]["short"]["total"] == 1
assert stats["by_action"]["LONG_HIGH"]["wins"] == 1
assert stats["by_confidence"]["≥70%"]["wins"] == 1
assert stats["by_confidence"]["50-70%"]["losses"] == 1
assert stats["by_confidence"]["<50%"]["expired"] == 1
async def test_ambiguous_counts_as_loss(self, db_session: AsyncSession):
ticker = await _make_ticker(db_session)
db_session.add(_make_setup(ticker, actual_outcome=OUTCOME_AMBIGUOUS))
await db_session.flush()
stats = await get_performance_stats(db_session)
assert stats["overall"]["losses"] == 1
assert stats["overall"]["hit_rate"] == 0.0
assert stats["overall"]["avg_r"] == -1.0