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>
110 lines
4.8 KiB
Python
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
|