"""Performance comparison: per-book series, R-multiples, and the start-date window.""" from __future__ import annotations from datetime import date, datetime, timedelta, timezone from types import SimpleNamespace import pytest from app.services import paper_trade_service as pts from app.services.trade_policy import MANUAL_BOOK, SHADOW_BOOK def _trade(*, book, entry=100.0, stop=95.0, close=None, shares=10.0, opened_days_ago=5): now = datetime.now(timezone.utc) return SimpleNamespace( ticker_id=1, direction="long", entry_price=entry, stop_loss=stop, shares=shares, book=book, status="closed" if close is not None else "open", close_price=close, opened_at=now - timedelta(days=opened_days_ago), closed_at=now if close is not None else None, ) class TestRMultiple: def test_winner_measured_in_units_of_initial_risk(self): # Entry 100, stop 95 → 5 of risk. Exit 115 → +15 → +3R. trade = _trade(book=SHADOW_BOOK, close=115.0) assert pts.trade_r_multiple(trade, None) == pytest.approx(3.0) def test_full_stop_is_minus_one_r(self): trade = _trade(book=SHADOW_BOOK, close=95.0) assert pts.trade_r_multiple(trade, None) == pytest.approx(-1.0) def test_open_trade_marks_to_the_latest_close(self): trade = _trade(book=SHADOW_BOOK) assert pts.trade_r_multiple(trade, 110.0) == pytest.approx(2.0) def test_no_risk_distance_has_no_r(self): trade = _trade(book=SHADOW_BOOK, entry=100.0, stop=100.0, close=120.0) assert pts.trade_r_multiple(trade, None) is None class TestBookStats: def test_r_is_independent_of_position_size(self): """The whole point: a 10-share and a 1000-share book compare equally.""" small = pts.book_stats([_trade(book=SHADOW_BOOK, close=115.0, shares=10)], {}) large = pts.book_stats([_trade(book=MANUAL_BOOK, close=115.0, shares=1000)], {}) assert small["total_r"] == large["total_r"] == pytest.approx(3.0) def test_counts_and_win_rate(self): trades = [ _trade(book=SHADOW_BOOK, close=115.0), _trade(book=SHADOW_BOOK, close=95.0), _trade(book=SHADOW_BOOK), ] stats = pts.book_stats(trades, {1: 110.0}) assert stats["trades"] == 3 assert stats["closed"] == 2 assert stats["open"] == 1 # +3R, -1R, +2R marked → 2 of 3 positive. assert stats["win_rate"] == pytest.approx(66.7) assert stats["total_r"] == pytest.approx(4.0) class TestPerformanceStartDate: @pytest.fixture async def session(self): from tests.conftest import _test_session_factory async with _test_session_factory() as session: yield session @pytest.mark.asyncio async def test_unset_means_all_history(self, session): assert await pts.get_performance_start(session) is None @pytest.mark.asyncio async def test_reads_an_iso_date(self, session): await pts.settings_store.upsert_setting( session, pts.KEY_PERFORMANCE_START, "2026-07-20" ) assert await pts.get_performance_start(session) == date(2026, 7, 20) @pytest.mark.asyncio async def test_garbage_falls_back_to_all_history(self, session): """A bad setting must not blank the whole performance card.""" await pts.settings_store.upsert_setting( session, pts.KEY_PERFORMANCE_START, "not-a-date" ) assert await pts.get_performance_start(session) is None @pytest.mark.asyncio async def test_empty_string_means_all_history(self, session): await pts.settings_store.upsert_setting(session, pts.KEY_PERFORMANCE_START, "") assert await pts.get_performance_start(session) is None