Files
fxhnt/tests/integration/test_paper_cost_model.py
jgrusewski b5ade841b6 feat(paper): realistic trading-cost + funding model (toggleable, default off)
Add a research-grounded cost model so the /paper 2-edge book curve can be shown
NET of costs (turnover ~0.42x/day = ~154x/yr, so costs are material). Toggleable;
default OFF = current gross behavior, bit-identical and fully backward-compatible.

- trading_cost / funding_pnl: two pure fns in paper_book.py. Per-side cost =
  taker_fee + spread + slippage, tiered by sleeve liquidity (Binance USDT-M perps,
  square-root impact law): crypto_tstrend 8bp, stablecoin_rotation 6bp, unlock 35bp,
  default 15bp (module-level _COST_RATE_BY_SLEEVE + _DEFAULT_COST_RATE).
- persist_paper_book: cost_rates/funding_by_symbol params (both None -> legacy gross,
  bit-identical). funding applied to equity_pre before sizing; trading cost subtracted
  after. derive_and_persist now returns the persisted target (no re-read).
- backfill_paper_book: costs_enabled toggle (default False); CLI paper-backfill gains
  --costs/--no-costs (default --no-costs), builds funding_by_date from the warehouse
  funding panel when on.

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

171 lines
7.3 KiB
Python

"""Trading-cost + funding model for the paper book (toggleable, default OFF = gross).
Research-grounded per-side cost rates (Binance USDT-M perps; square-root impact law tiers the
spread/slippage by sleeve liquidity):
crypto_tstrend 0.0008 (8 bp — 5 bp taker fee + ~3 bp spread/slippage; liquid majors)
stablecoin_rotation 0.0006 (6 bp — liquid stablecoins)
unlock 0.0035 (35 bp — 5 bp fee + ~30 bp spread/slippage; illiquid small-cap shorts)
default 0.0015 (any other sleeve)
"""
from __future__ import annotations
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.application.paper_book import (
_COST_RATE_BY_SLEEVE,
_DEFAULT_COST_RATE,
funding_pnl,
persist_paper_book,
trading_cost,
)
from fxhnt.domain.paper import Position
def _pos(sleeve, symbol, qty, px, date="2026-01-01"):
return Position(sleeve=sleeve, symbol=symbol, qty=qty, entry_price=px, entry_date=date)
# --------------------------------------------------------------------------- trading_cost
def test_trading_cost_single_sleeve_rebalance():
"""BTC 1 -> 2 units @ 50000, crypto_tstrend: traded 1 * 50000 * 0.0008 = 40."""
prev = [_pos("crypto_tstrend", "BTC", 1.0, 50_000.0)]
target = [_pos("crypto_tstrend", "BTC", 2.0, 50_000.0)]
prices = {"BTC": 50_000.0}
assert trading_cost(prev, target, prices) == 40.0
def test_trading_cost_zero_turnover_is_zero():
prev = [_pos("crypto_tstrend", "BTC", 2.0, 50_000.0)]
target = [_pos("crypto_tstrend", "BTC", 2.0, 50_000.0)]
assert trading_cost(prev, target, {"BTC": 50_000.0}) == 0.0
def test_trading_cost_open_from_flat_uses_target_qty():
"""No prior position -> traded = |target_qty| (prev side = 0)."""
target = [_pos("crypto_tstrend", "BTC", 2.0, 50_000.0)]
# 2 * 50000 * 0.0008 = 80
assert trading_cost([], target, {"BTC": 50_000.0}) == 80.0
def test_trading_cost_full_exit_uses_prev_qty():
"""Position closed to flat -> traded = |prev_qty|, priced at the day's close."""
prev = [_pos("crypto_tstrend", "BTC", 3.0, 40_000.0)]
# priced at today's close 50000: 3 * 50000 * 0.0008 = 120
assert trading_cost(prev, [], {"BTC": 50_000.0}) == 120.0
def test_trading_cost_multi_sleeve_with_unlock_rate():
"""crypto_tstrend (8bp) + unlock (35bp) summed; unlock short opens from flat."""
prev = [_pos("crypto_tstrend", "BTC", 1.0, 50_000.0)]
target = [
_pos("crypto_tstrend", "BTC", 2.0, 50_000.0), # traded 1*50000*0.0008 = 40
_pos("unlock", "ALT", -100.0, 10.0), # traded 100*10*0.0035 = 3.5
]
prices = {"BTC": 50_000.0, "ALT": 10.0}
assert trading_cost(prev, target, prices) == 40.0 + 3.5
def test_trading_cost_default_rate_for_unknown_sleeve():
target = [_pos("mystery_sleeve", "FOO", 10.0, 100.0)]
# 10 * 100 * 0.0015 = 1.5
assert trading_cost([], target, {"FOO": 100.0}) == 10.0 * 100.0 * _DEFAULT_COST_RATE
def test_trading_cost_missing_price_falls_back_to_entry():
target = [_pos("crypto_tstrend", "BTC", 1.0, 50_000.0)]
# no price -> fallback to entry_price 50000: 1*50000*0.0008 = 40
assert trading_cost([], target, {}) == 40.0
def test_trading_cost_rates_dict_values():
assert _COST_RATE_BY_SLEEVE["crypto_tstrend"] == 0.0008
assert _COST_RATE_BY_SLEEVE["stablecoin_rotation"] == 0.0006
assert _COST_RATE_BY_SLEEVE["unlock"] == 0.0035
assert _DEFAULT_COST_RATE == 0.0015
# --------------------------------------------------------------------------- funding_pnl
def test_funding_pnl_long_pays_positive_funding():
"""Long (qty>0) with funding +0.0001 -> negative cash flow (holder pays)."""
pos = [_pos("crypto_tstrend", "BTC", 2.0, 50_000.0)]
# notional = 2*50000 = 100000; flow = -100000 * 0.0001 = -10
assert funding_pnl(pos, {"BTC": 0.0001}, {"BTC": 50_000.0}) == -10.0
def test_funding_pnl_short_receives_positive_funding():
"""Short (qty<0) with funding +0.0001 -> positive cash flow (holder receives)."""
pos = [_pos("crypto_tstrend", "BTC", -2.0, 50_000.0)]
# notional = -2*50000 = -100000; flow = -(-100000)*0.0001 = +10
assert funding_pnl(pos, {"BTC": 0.0001}, {"BTC": 50_000.0}) == 10.0
def test_funding_pnl_unknown_symbol_zero():
pos = [_pos("crypto_tstrend", "BTC", 2.0, 50_000.0)]
assert funding_pnl(pos, {}, {"BTC": 50_000.0}) == 0.0
def test_funding_pnl_missing_price_uses_entry():
pos = [_pos("crypto_tstrend", "BTC", 2.0, 50_000.0)]
# notional from entry 50000 -> -2*50000*0.0001 = -10
assert funding_pnl(pos, {"BTC": 0.0001}, {}) == -10.0
def test_funding_pnl_empty_positions_zero():
assert funding_pnl([], {"BTC": 0.0001}, {"BTC": 50_000.0}) == 0.0
# --------------------------------------------------------------------------- persist backward-compat
def _seq(repo, lev=1.0, **extra):
"""Two-day book: open BTC long day 1, rebalance day 2."""
at = dt.datetime(2026, 1, 1, tzinfo=dt.UTC)
common = dict(capital=100_000.0, sleeve_weights={"crypto_tstrend": 1.0},
symbol_weights_by_sleeve={"crypto_tstrend": {"BTC": 1.0}}, at=at, leverage=lev)
persist_paper_book(repo, run_date="2026-01-01", prices={"BTC": 100.0}, **common, **extra)
persist_paper_book(repo, run_date="2026-01-02", prices={"BTC": 110.0}, **common, **extra)
def test_persist_gross_when_both_none_is_identical():
"""cost_rates=None AND funding_by_symbol=None -> identical nav to the legacy path."""
base = PaperRepo("sqlite://"); base.migrate()
_seq(base) # legacy default
explicit = PaperRepo("sqlite://"); explicit.migrate()
_seq(explicit, cost_rates=None, funding_by_symbol=None) # explicit-off
assert [(n.run_date, n.equity) for n in base.nav_history()] == \
[(n.run_date, n.equity) for n in explicit.nav_history()]
def test_persist_with_costs_reduces_equity_by_trading_cost():
gross = PaperRepo("sqlite://"); gross.migrate()
_seq(gross)
net = PaperRepo("sqlite://"); net.migrate()
_seq(net, cost_rates=_COST_RATE_BY_SLEEVE)
g = gross.nav_history()
nt = net.nav_history()
# day 0: opens BTC from flat. equity sized off 100000, qty=1000 @100 -> notional 100000.
# tcost = 100000 * 0.0008 = 80. net equity = 100000 - 80 = 99920.
assert abs(g[0].equity - 100_000.0) < 1e-6
assert abs(nt[0].equity - (100_000.0 - 80.0)) < 1e-6
# net equity is strictly below gross on every date.
for ge, ne in zip(g, nt):
assert ne.equity < ge.equity
def test_persist_with_funding_only_adjusts_equity():
"""funding_by_symbol set (cost_rates None) -> equity moves by funding, no trading cost."""
r = PaperRepo("sqlite://"); r.migrate()
at = dt.datetime(2026, 1, 1, tzinfo=dt.UTC)
common = dict(capital=100_000.0, sleeve_weights={"crypto_tstrend": 1.0},
symbol_weights_by_sleeve={"crypto_tstrend": {"BTC": 1.0}}, at=at)
persist_paper_book(r, run_date="2026-01-01", prices={"BTC": 100.0}, **common)
# day 2: prior long BTC qty=1000 @100; funding +0.0001 on BTC at price 110 -> notional 1000*110=110000
# fund = -110000 * 0.0001 = -11. gain = 1000*(110-100)=+10000. equity = 100000+10000-11 = 109989.
persist_paper_book(r, run_date="2026-01-02", prices={"BTC": 110.0},
funding_by_symbol={"BTC": 0.0001}, **common)
nav = r.nav_history()[-1]
assert abs(nav.equity - (100_000.0 + 10_000.0 - 11.0)) < 1e-6