125 lines
6.7 KiB
Python
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)
|