Files
fxhnt/tests/integration/test_paper_book.py
2026-06-22 10:17:46 +02:00

125 lines
6.7 KiB
Python

import datetime as dt
import pytest
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.ports.live_price import FakeLivePrice
from fxhnt.application.paper_book import PaperBookService, persist_paper_book
def test_derive_persist_and_view():
repo = PaperRepo("sqlite://")
repo.migrate()
svc = PaperBookService(repo, capital=100_000.0)
# one sleeve, weight 0.5, short USDTUSD weight -1.0 at price 2.0
svc.derive_and_persist(run_date="2026-06-21",
sleeve_weights={"stablecoin_rotation": 0.5},
symbol_weights_by_sleeve={"stablecoin_rotation": {"USDTUSD": -1.0}},
prices={"USDTUSD": 2.0}, at=dt.datetime(2026, 6, 21, tzinfo=dt.UTC))
view = svc.view(live=FakeLivePrice({"USDTUSD": 1.9})) # price dropped -> short profits
assert view.capital == 100_000.0
assert view.equity > 100_000.0 # short gained as price fell
assert any(p.symbol == "USDTUSD" for p in view.positions)
assert view.sleeves["stablecoin_rotation"].pnl > 0
def _derive(svc, run_date, weight, prices, day):
svc.derive_and_persist(run_date=run_date,
sleeve_weights={"s": 1.0},
symbol_weights_by_sleeve={"s": {"BTC": weight}},
prices=prices, at=dt.datetime.fromisoformat(day + "T00:00:00+00:00"))
def test_derive_and_persist_is_idempotent_per_run_date():
repo = PaperRepo("sqlite://")
repo.migrate()
svc = PaperBookService(repo, capital=100_000.0)
# First run from an empty book generates an entry trade for run_date 2026-06-21.
_derive(svc, "2026-06-21", 1.0, {"BTC": 100.0}, "2026-06-21")
n1 = len(repo.recent_trades(100))
assert n1 > 0
# Re-run the SAME run_date (e.g. cron retry). The append-only path would record the entry trade a
# second time (prior book is replaced first, so diff is empty on the *second* derive — but the
# idempotent contract must hold regardless): the run_date's trade count must not grow.
_derive(svc, "2026-06-21", 1.0, {"BTC": 100.0}, "2026-06-21")
n2 = len(repo.recent_trades(100))
assert n2 == n1 # same count, NOT doubled
def test_realized_pnl_on_full_exit():
# Compounding MTM model: the FULL held-book gain (not just a realized sliver) flows into the nav curve,
# so equity reflects it even after a full exit to cash.
repo = PaperRepo("sqlite://")
repo.migrate()
at = dt.datetime(2026, 6, 22, tzinfo=dt.UTC)
# day 1: long BTC full 100k at 100 -> qty 1000 (sized off equity = capital)
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-21", sleeve_weights={"s": 1.0},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}}, prices={"BTC": 100.0}, at=at)
# day 2: fully exit (sleeve weight 0) at 110 -> prior book marked: gain 1000*(110-100)=10_000 -> equity 110k, flat
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-22", sleeve_weights={"s": 0.0},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}}, prices={"BTC": 110.0}, at=at)
view = PaperBookService(repo, capital=100_000.0).view(live=FakeLivePrice({})) # no open positions
assert not view.positions
assert view.equity == pytest.approx(110_000.0) # full book gain captured by the compounding nav curve
def test_realized_pnl_on_partial_reduction():
# Compounding MTM model: holding the full book into the up-day captures the whole gain in the nav curve;
# the reduction still records a realized sliver (now display-only, not the equity driver).
repo = PaperRepo("sqlite://")
repo.migrate()
at = dt.datetime(2026, 6, 22, tzinfo=dt.UTC)
# day 1: long BTC full 100k at 100 -> qty 1000
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-21", sleeve_weights={"s": 1.0},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}}, prices={"BTC": 100.0}, at=at)
# day 2: reduce to half sleeve weight at 110 -> prior full book (qty 1000) marked: gain 1000*(110-100)=10_000
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-22", sleeve_weights={"s": 0.5},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}}, prices={"BTC": 110.0}, at=at)
assert repo.realized_pnl() > 0.0 # reduction still records a realized sliver (display-only)
view = PaperBookService(repo, capital=100_000.0).view(live=FakeLivePrice({"BTC": 110.0}))
# equity = compounding nav curve (110k, full book gain) + intraday (0: surviving lot entry=110, marked @110)
assert view.equity == pytest.approx(110_000.0)
def test_persist_paper_book_writes_nav():
repo = PaperRepo("sqlite://")
repo.migrate()
at = dt.datetime(2026, 6, 21, tzinfo=dt.UTC)
# long BTC full 100k at 100 -> qty 1000; mark date close 120 -> unrealized = 1000*(120-100) = 20_000
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-21",
sleeve_weights={"s": 1.0},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}},
prices={"BTC": 100.0}, at=at)
# Recompute the nav at the SAME run_date but with the mark-date close in `prices`.
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-21",
sleeve_weights={"s": 1.0},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}},
prices={"BTC": 100.0}, at=at)
hist = repo.nav_history()
assert hist[-1].run_date == "2026-06-21"
# entry == close here (positions priced at run-date close), so unrealized 0, realized 0, equity = capital.
assert hist[-1].equity == pytest.approx(100_000.0)
assert hist[-1].unrealized == pytest.approx(0.0)
def test_persist_paper_book_nav_unrealized_against_mark():
repo = PaperRepo("sqlite://")
repo.migrate()
at = dt.datetime(2026, 6, 21, tzinfo=dt.UTC)
# Open at 100 on day 1.
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-20",
sleeve_weights={"s": 1.0},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}},
prices={"BTC": 100.0}, at=at)
# Day 2 same target weight; price 110 -> positions re-priced to 110 (entry==mark) so unrealized 0,
# but realized booked on the rebalance of qty (target qty shrinks as price rises).
persist_paper_book(repo, capital=100_000.0, run_date="2026-06-21",
sleeve_weights={"s": 1.0},
symbol_weights_by_sleeve={"s": {"BTC": 1.0}},
prices={"BTC": 110.0}, at=at)
hist = repo.nav_history()
assert [h.run_date for h in hist] == ["2026-06-20", "2026-06-21"]
# equity must equal capital + realized + unrealized as computed from persisted positions.
last = hist[-1]
assert last.equity == pytest.approx(100_000.0 + last.realized + last.unrealized)