Files
fxhnt/tests/integration/test_paper_hook.py
jgrusewski 696915fb6d feat(paper): cap realized gross exposure (max_gross, default 3x equity)
Add `max_gross: float = 3.0` to PaperBookService.derive_and_persist and
persist_paper_book. After building the target positions, compute
gross = Σ|qty·entry_price|; if gross > max_gross·equity, scale every
position's qty by (max_gross·equity)/gross (dataclasses.replace on the frozen
Position). This caps REALIZED gross exposure (not the vol-target multiplier),
accounting for market-neutral legs automatically. Equity base is unchanged.

Per the foundation audit (D2, belt-and-suspenders against gross stacking).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:00:42 +02:00

110 lines
4.8 KiB
Python

"""T7 — hook PaperBookService.derive_and_persist into the daily book rebalance.
STATUS (honest): the daily forward/book advance path carries ONLY daily fractional returns
(`ForwardStrategy.advance(...) -> list[(date, return)]`); it never exposes the per-sleeve
allocation weights, per-symbol target weights, or the day's prices that `derive_and_persist`
requires. So the *automatic* hook from the live advance is BLOCKED on the book code exposing
those three things (see the test module docstring in the report).
What IS wired + tested here:
* a `paper_enabled` config guard (so the hook can be toggled off), and
* `persist_paper_book(...)` — the one honest call site: given the three inputs the book WOULD
need to expose, it runs `derive_and_persist` and populates `paper_repo.latest_positions()`.
This is the exact code the advance path will call once the book exposes weights+prices.
"""
from __future__ import annotations
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.application.paper_book import persist_paper_book
from fxhnt.config import Settings
def test_paper_enabled_default_true() -> None:
assert Settings().paper_enabled is True
def test_persist_paper_book_populates_positions() -> None:
repo = PaperRepo("sqlite://")
repo.migrate()
n = persist_paper_book(
repo,
capital=100_000.0,
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),
)
pos = repo.latest_positions()
assert n == 1
assert pos and pos[0].symbol == "USDTUSD"
assert pos[0].qty == -25_000.0
def test_persist_paper_book_disabled_returns_zero() -> None:
repo = PaperRepo("sqlite://")
repo.migrate()
n = persist_paper_book(
repo,
capital=100_000.0,
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),
enabled=False,
)
assert n == 0
assert repo.latest_positions() == []
def test_persist_paper_book_leverage_scales_notional():
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.application.paper_book import persist_paper_book
repo = PaperRepo("sqlite://"); repo.migrate()
at = dt.datetime(2026, 1, 1, tzinfo=dt.UTC)
kw = dict(capital=100_000.0, run_date="2026-01-01",
sleeve_weights={"ts": 1.0}, symbol_weights_by_sleeve={"ts": {"BTCUSDT": 1.0}},
prices={"BTCUSDT": 100.0}, at=at)
persist_paper_book(repo, **kw, leverage=2.0)
pos = repo.positions_at("2026-01-01")
assert len(pos) == 1 and pos[0].qty == 2.0 * 100_000.0 / 100.0 # notional doubled
nav = repo.nav_history()[-1]
assert nav.equity == 100_000.0 # equity base unscaled (no pnl yet)
def test_persist_paper_book_caps_realized_gross():
"""Raw gross 5x equity (leverage=5) is scaled down so realized gross == max_gross x equity (3x)."""
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.application.paper_book import persist_paper_book
repo = PaperRepo("sqlite://"); repo.migrate()
at = dt.datetime(2026, 1, 1, tzinfo=dt.UTC)
kw = dict(capital=100_000.0, run_date="2026-01-01",
sleeve_weights={"ts": 1.0}, symbol_weights_by_sleeve={"ts": {"BTCUSDT": 1.0}},
prices={"BTCUSDT": 100.0}, at=at)
persist_paper_book(repo, **kw, leverage=5.0, max_gross=3.0)
pos = repo.positions_at("2026-01-01")
gross = sum(abs(p.qty * p.entry_price) for p in pos)
assert gross == 3.0 * 100_000.0 # capped to 3x equity
nav = repo.nav_history()[-1]
assert nav.equity == 100_000.0 # equity base unchanged
def test_persist_paper_book_normal_gross_untouched():
"""A book with gross <= max_gross x equity (leverage=2, gross=2x) is NOT scaled."""
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.application.paper_book import persist_paper_book
repo = PaperRepo("sqlite://"); repo.migrate()
at = dt.datetime(2026, 1, 1, tzinfo=dt.UTC)
kw = dict(capital=100_000.0, run_date="2026-01-01",
sleeve_weights={"ts": 1.0}, symbol_weights_by_sleeve={"ts": {"BTCUSDT": 1.0}},
prices={"BTCUSDT": 100.0}, at=at)
persist_paper_book(repo, **kw, leverage=2.0, max_gross=3.0)
pos = repo.positions_at("2026-01-01")
assert len(pos) == 1 and pos[0].qty == 2.0 * 100_000.0 / 100.0 # untouched