Files
fxhnt/tests/unit/test_vrp_book.py
jgrusewski 38f5dfa784 fix(vrp): single-source model mark for backtest + live exec profit-take
Review follow-ups on the VRP model-mark fix.

MINOR-1 (reconcile the live exec twin): vrp_exec_record still marked the
spread as raw short-long for its 50%-profit-take signal — the same
single-leg mark noise removed from the backtest — so a noisy far-OTM
long-leg spike could spuriously trip a live close on the paper account.
Extract the model-mark valuation into a shared single-source module
vrp_marking.py (forward_and_atm_iv, model_spread_value, settlement_value);
both vrp_book.VrpStrategy and vrp_exec_record now call it. plan_vrp_ladder
takes exec_marks (actual fill basis) AND signal_marks (model); the
profit-take decision uses only the model signal, while the close order
net limit and realized P&L stay mark-to-actual.

Also fixes a latent bug: the freeze band stores near-ATM calls only for
DTE[20,45], so a spread aged below 20 DTE has no calls at its own expiry.
The prior forward required calls at the spread's expiry -> would have
prematurely closed every spread ~20 DTE before expiry. The shared forward
now falls back to the globally cleanest ATM pair across expiries (parity
forward is spot, common across expiries under r=0), markable at any age.

MINOR-2 (efficiency): memoize (F, IV) per (day, expiry) — advance threads
a per-day day_cache; plan_and_record_vrp threads a signal_cache — so
rungs sharing an expiry don't re-query the chain.

MINOR-3 (doc): the absolute defined-risk bound [-width*100, +width*100]
holds unconditionally; the tight [-(width-credit)*100, +credit*100] is the
clean-BS-surface case (entry model value == credit).

Tests: uv run pytest -q -> 1997 passed. New: noisy-actual-mark doesn't
trip the exec profit-take; backtest and shared marking are single-source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:49:45 +02:00

321 lines
19 KiB
Python

import datetime as dt
from fxhnt.adapters.persistence.xsp_option_bars import XspOptionBarsRepo
from fxhnt.application import vrp_book, vrp_marking, vrp_pricing
from fxhnt.application.allocation_engine import cvar_daily
from fxhnt.application.vrp_book import VrpStrategy
def _seed(tmp_path, days, contracts):
"""contracts: list of (osi, expiry, strike, right, {iso_date: close}). Returns a repo."""
r = XspOptionBarsRepo(f"sqlite:///{tmp_path/'v.db'}")
r.migrate()
at = dt.datetime(2024, 1, 1)
rows = []
for osi, expiry, strike, right, closes in contracts:
for d, c in closes.items():
rows.append(dict(osi_symbol=osi, date=d, expiry=expiry, strike=strike, right=right, close=c))
r.upsert_bars(rows, at)
return r
def _put_mark(k: float, day_index: int) -> float:
"""Realistic put mark: OTM puts (low strike) are cheaper than near-ATM puts (high strike, richer
extrinsic value), rising monotonically with strike toward the forward (470) -- e.g. strike 440 ~= 0.15,
465 ~= 0.90, 470 ~= 1.05. Decays ~3%/day (theta): since it's a PERCENTAGE decay, the higher-dollar
near-ATM leg loses more in absolute terms per day than the cheaper far-OTM leg, which is exactly the
source of a short put-credit-spread's daily theta P&L (spread value = short - long shrinks over time)."""
base = 0.15 + 0.03 * (k - 440.0)
return base * (0.97 ** day_index)
def _straight_chain(start="2024-07-01", n=10, expiry="2024-07-31", forward=470.0):
"""A flat-vol synthetic put chain across strikes (OTM puts cheaper, correctly rising toward the
forward), decaying ~3%/day, plus an ATM call tracking the ATM put 1:1 so parity_forward(K=470) recovers
~470 every day. A short-higher-strike/long-lower-strike credit spread's credit is always positive here
since put price increases monotonically with strike (short leg is worth more than the long leg)."""
days = [(dt.date.fromisoformat(start) + dt.timedelta(days=i)).isoformat() for i in range(n)]
contracts = []
for k in range(440, 475, 5): # put strikes below/at forward
osi = f"XSP{expiry.replace('-','')[2:]}P{k*1000:08d}"
closes = {d: _put_mark(float(k), i) for i, d in enumerate(days)}
contracts.append((osi, expiry, float(k), "P", closes))
# ATM call tracks the ATM (470) put mark exactly so parity_forward(K=470) recovers ~470 every day.
contracts.append((f"XSP{expiry.replace('-','')[2:]}C{470*1000:08d}", expiry, 470.0, "C",
{d: _put_mark(470.0, i) for i, d in enumerate(days)}))
return days, contracts
def test_advance_full_history_is_deterministic(tmp_path):
days, contracts = _straight_chain()
r = _seed(tmp_path, days, contracts)
# a spread must actually open on the entry day -- proves the fixture's marks are realistic (positive
# credit), not vacuously producing an empty series (see module docstring history: inverted put pricing
# used to make every credit <= 0 and _select_spread always return None).
opened = VrpStrategy(r, entry_weekday=0)._select_spread(days[0])
assert opened is not None and opened["credit"] > 0.0
a, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
b, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert a == b # same frozen input -> identical series
assert all(isinstance(d, str) and isinstance(x, float) for d, x in a)
assert any(abs(x) > 1e-12 for _, x in a) # a real, non-empty return stream -- not an empty ladder
def test_defined_risk_daily_loss_is_bounded(tmp_path):
# a crash day: the short put mark spikes; the spread loss per day cannot exceed width/margin = 1.0
days, contracts = _straight_chain(n=6)
# overwrite the last day's marks with a crash spike on all puts
crash = days[-1]
for _osi, _expiry, _strike, right, closes in contracts:
if right == "P":
closes[crash] = closes[crash] + 50.0
r = _seed(tmp_path, days, contracts)
series, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert series, "series should not be empty"
# a spread must be open and marked before the crash day, so the bound below is tested against a REAL
# marked position (theta-decay P&L), not an empty ladder that trivially satisfies min() >= -1.0.
assert any(abs(x) > 1e-12 for d, x in series if d != crash)
assert min(x for _, x in series) >= -1.0 - 1e-9 # defined-risk: daily loss per margin bounded
assert max(abs(x) for _, x in series) < 5.0 # sane magnitude
def test_atm_pair_selected_by_min_abs_c_minus_p(tmp_path, monkeypatch):
"""Multiple call strikes exist on the same grid as the puts. The true ATM by min|C-P| is 470 (put and
call both mark 1.0 -> diff=0); 460 has a much larger |C-P| (20.0) that would imply a forward ~10 points
off if it were wrongly picked instead. Spy on `parity_forward` to assert the pairing selects k_atm=470
-- proving selection by min|C-P| over sorted common strikes, not the old minimal-strike-distance
cross-product (which ties at diff==0 for every same-strike pair and silently depended on whatever
order `chain_asof` happened to return rows in)."""
expiry, day = "2024-08-16", "2024-07-15"
contracts = [
("P440", expiry, 440.0, "P", {day: 3.0}),
("P450", expiry, 450.0, "P", {day: 2.0}),
("P460", expiry, 460.0, "P", {day: 2.0}),
("P470", expiry, 470.0, "P", {day: 1.0}),
("C460", expiry, 460.0, "C", {day: 22.0}), # |C-P| = 20 at 460 -- would give forward 480 if picked
("C470", expiry, 470.0, "C", {day: 1.0}), # |C-P| = 0 at 470 -- the true ATM
]
r = _seed(tmp_path, [day], contracts)
strat = VrpStrategy(r)
seen: list[dict] = []
real_parity_forward = vrp_pricing.parity_forward
def spy(**kwargs):
seen.append(kwargs)
return real_parity_forward(**kwargs)
monkeypatch.setattr(vrp_book.vrp_pricing, "parity_forward", spy)
strat._select_spread(day)
assert seen, "parity_forward should have been called"
assert seen[0]["k_atm"] == 470.0
assert seen[0]["call_atm"] == 1.0 and seen[0]["put_atm"] == 1.0
def test_advance_deterministic_with_multiple_common_call_strikes(tmp_path):
"""Guard against the old minimal-strike-distance ATM pairing in the full advance() loop: add a second
call strike (460) that shares a strike with a put but whose mark is far off (|C-P|=20 vs 0 at the true
ATM, 470) -- if it were wrongly picked as ATM, the forward and downstream short-strike selection would
differ. Two fresh `advance(None, {})` runs must stay byte-identical."""
days, contracts = _straight_chain(n=10)
# a second call strike (460) whose |C-P| is always 20 (way off the true ATM's ~0) -- tracks the actual
# decaying 460 put mark each day via `_put_mark` so the offset holds on every day, not just day 0.
contracts.append(("XSP240731C00460000", "2024-07-31", 460.0, "C",
{d: _put_mark(460.0, i) + 20.0 for i, d in enumerate(days)}))
r = _seed(tmp_path, days, contracts)
a, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
b, _ = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert a == b
assert all(isinstance(d, str) and isinstance(x, float) for d, x in a)
assert any(abs(x) > 1e-12 for _, x in a) # canary: a spread actually opened (not a vacuous empty series)
def test_advance_closes_spread_on_missing_marks_not_zombie(tmp_path):
"""A held spread whose legs' marks disappear (e.g. aged/moved out of the frozen slice) must be CLOSED,
not zombie-held forever via the `val is None -> still.append(sp)` branch -- otherwise it permanently
occupies a ladder slot and starves new entries. Truncate all put marks after day 3 so the spread
entered on day 0 (a Monday) has no mark from day 4 onward; assert it is dropped from `open_spreads`
(not carried forward) and the series stays finite."""
days, contracts = _straight_chain(n=10)
stale_after = days[3] # put marks stop appearing after this day
new_contracts = []
for osi, expiry, strike, right, closes in contracts:
if right == "P":
closes = {d: c for d, c in closes.items() if d <= stale_after}
new_contracts.append((osi, expiry, strike, right, closes))
r = _seed(tmp_path, days, new_contracts)
series, extra = VrpStrategy(r, entry_weekday=0).advance(None, {})
assert series, "series should not be empty"
# a spread must actually have OPENED and been marked while the puts were still fresh (days 1-3), so
# dropping it below proves a real held position was closed -- not that an empty ladder stayed empty.
assert any(abs(x) > 1e-12 for d, x in series if d <= stale_after)
assert all(isinstance(x, float) and x == x and abs(x) != float("inf") for _, x in series)
assert extra["open_spreads"] == [] # closed on missing marks, not zombied
def _bs_chain(days, expiry, forward, sigma, strikes):
"""A Black-Scholes-CONSISTENT put+call chain: put mark = bs_put(F, K, sigma, tau), call mark via
put-call parity C = P + (F - K). Parity recovers F exactly and `implied_vol_put` recovers `sigma` at
every strike -> a clean, smooth vol surface where the MODEL marks reproduce the marks themselves (so a
spread's entry credit equals its model value at entry: no synthetic model-vs-raw basis jump). `forward`
may be a float (constant) or a dict {iso_day: F} for a moving-spot path."""
exp_d = dt.date.fromisoformat(expiry)
fwd_of = (lambda _d: forward) if not isinstance(forward, dict) else (lambda d: forward[d])
contracts = []
for k in strikes:
pcloses, ccloses = {}, {}
for d in days:
tau = max((exp_d - dt.date.fromisoformat(d)).days, 1) / 365.0
f = fwd_of(d)
p = vrp_pricing.bs_put(f, float(k), sigma, tau)
c = p + (f - float(k)) # parity: C - P = F - K
if p > 0.0:
pcloses[d] = p
if c > 0.0:
ccloses[d] = c
tag = expiry.replace("-", "")[2:]
contracts.append((f"XSP{tag}P{int(k * 1000):08d}", expiry, float(k), "P", pcloses))
contracts.append((f"XSP{tag}C{int(k * 1000):08d}", expiry, float(k), "C", ccloses))
return contracts
def _weekdays(start, n):
return [(dt.date.fromisoformat(start) + dt.timedelta(days=i)).isoformat() for i in range(n)]
def test_advance_dollar_basis_is_dollars_over_margin(tmp_path):
"""Regression for the FIX-2 100x-too-small bug, adapted to MODEL marking: `pnl`'s numerator must be in
DOLLARS (per-share model value x100 = the contract multiplier) to match `self.margin`'s dollar basis
(width_points*100). The booked daily return must equal the MODEL value change / width -- NOT that change
/ (width*100) (the old points/dollars basis, 100x smaller).
One spread (ladder=1) opens on day0 of a BS-consistent chain; on day1 the smooth model value has moved by
theta. Assert the booked return equals (credit - model_val_day1)/width and is ~100x the pre-fix basis."""
days = _weekdays("2024-07-01", 2) # Mon, Tue
contracts = _bs_chain(days, "2024-07-31", forward=470.0, sigma=0.20, strikes=range(380, 481, 5))
r = _seed(tmp_path, days, contracts)
strat = VrpStrategy(r, entry_weekday=0, ladder=1)
sp = strat._select_spread(days[0])
assert sp is not None and sp["credit"] > 0.0
# the model value on day1 (independent instance; _spread_value mutates last_iv, so pass a copy)
val1 = VrpStrategy(r, entry_weekday=0, ladder=1)._spread_value(dict(sp), days[1])
assert val1 is not None
series, _ = strat.advance(None, {})
by_day = dict(series)
assert days[1] in by_day
width = strat._width # default 5.0
expected = (sp["credit"] - val1) / width # (Δpts * 100) / (width * 100) = Δpts / width
old_buggy = expected / 100.0 # pre-fix basis: Δpts / (width * 100)
assert abs(by_day[days[1]] - expected) < 1e-9
assert abs(by_day[days[1]] - old_buggy) > 50 * abs(old_buggy) # ~100x larger than the old bug
def test_model_value_bounded_and_smooth_despite_noisy_single_leg(tmp_path):
"""(a) The MODEL spread value is CLIPPED to [0, width] and SMOOTH: it is rebuilt from the day's ATM
forward + ATM IV via Black-Scholes, so corrupting ONE leg's raw OPRA mark by a huge spike (the exact
mark-noise that made the OLD short_close-long_close difference swing 50-100%) does NOT move it -- the ATM
parity/IV are read off the clean at-the-money strike, not the corrupted leg."""
days = _weekdays("2024-07-01", 5)
strikes = list(range(380, 481, 5))
contracts = _bs_chain(days, "2024-08-05", forward=470.0, sigma=0.20, strikes=strikes) # ~35 DTE
strat = VrpStrategy(_seed(tmp_path, days, contracts), entry_weekday=0)
sp = strat._select_spread(days[0])
assert sp is not None
clean_vals = [strat._spread_value(dict(sp), d) for d in days]
for v in clean_vals:
assert v is not None and 0.0 <= v <= strat._width + 1e-12 # hard defined-risk clip
# corrupt ONE non-ATM leg's raw put mark by a +50 spike on day2 (would blow up a raw short-long diff).
long_osi = sp["long_osi"]
l_exp = dt.date.fromisoformat(sp["expiry"])
corrupt = [dict(osi_symbol=long_osi, date=days[2], expiry=l_exp.isoformat(),
strike=sp["long_strike"], right="P", close=999.0)]
r2 = _seed(tmp_path, days, contracts)
r2.upsert_bars(corrupt, dt.datetime(2024, 1, 1))
strat2 = VrpStrategy(r2, entry_weekday=0)
noisy_vals = [strat2._spread_value(dict(sp), d) for d in days]
for v in noisy_vals:
assert v is not None and 0.0 <= v <= strat2._width + 1e-12
# the model value is UNCHANGED by the corrupted leg (parity/IV come from the clean ATM strike).
for cv, nv in zip(clean_vals, noisy_vals, strict=True):
assert abs(cv - nv) < 1e-9
# and the clean path is smooth: no day-over-day jump anywhere near the width.
for a, b in zip(clean_vals, clean_vals[1:], strict=False):
assert abs(a - b) < 0.25 * strat._width
def test_settlement_value_is_clipped_intrinsic(tmp_path):
"""(d) Terminal settlement = clip(K_short - F, 0, width): OTM -> 0, partially ITM -> the in-the-moneyness,
deep ITM -> the width cap. Derived from that day's parity forward (spot), never unbounded."""
day, expiry = "2024-07-30", "2024-07-31" # DTE = 1
short_strike, long_strike, width = 460.0, 455.0, 5.0
for forward, expected in [(470.0, 0.0), (458.0, 2.0), (450.0, 5.0)]:
contracts = _bs_chain([day], expiry, forward=forward, sigma=0.20, strikes=range(400, 501, 5))
strat = VrpStrategy(_seed(tmp_path, [day], contracts), width_points=width)
sp = dict(short_strike=short_strike, long_strike=long_strike, expiry=expiry)
settle = strat._settlement_value(sp, day)
assert settle is not None and abs(settle - expected) < 1e-6
def test_per_spread_cumulative_pnl_bounded_to_defined_risk_max_loss(tmp_path):
"""(b) Per-spread cumulative P&L is bounded to the defined-risk envelope [-(width-credit)*100,
+credit*100] dollars. A spot that crashes deep below both strikes by expiry drives the spread to its MAX
LOSS: with ladder=1 the summed daily returns * margin == the spread's total dollar P&L, which must sit at
(not below) the lower bound -(width-credit)*100."""
days = _weekdays("2024-07-01", 31) # Mon entry, ~30 DTE, held to expiry
expiry = "2024-07-31"
# forward glides 470 -> 400 (deep below any 20Δ short strike & its 5-wide long) by expiry.
fwd = {d: 470.0 - 70.0 * (i / (len(days) - 1)) for i, d in enumerate(days)}
contracts = _bs_chain(days, expiry, forward=fwd, sigma=0.20, strikes=range(360, 481, 5))
strat = VrpStrategy(_seed(tmp_path, days, contracts), entry_weekday=0, ladder=1)
sp0 = strat._select_spread(days[0])
assert sp0 is not None
credit, width = sp0["credit"], strat._width
series, _ = strat.advance(None, {})
total_dollars = sum(x for _, x in series) * strat.margin # ladder=1 -> return = pnl/margin
lo, hi = -(width - credit) * 100.0, credit * 100.0
assert lo - 1e-6 <= total_dollars <= hi + 1e-6 # inside the defined-risk envelope
assert total_dollars < 0.0 # a crash to deep-ITM is a real loss
assert abs(total_dollars - lo) < 1.0 # and it is AT the max-loss bound
def test_no_daily_return_exceeds_plausible_bound_on_clean_vol_path(tmp_path):
"""(c) On a synthetic CLEAN constant-vol path the whole ladder's daily returns are tiny (theta decay),
never the >15%/day mark-noise artifacts (single days of -50%, +59.6%) the raw-diff marks produced."""
days = _weekdays("2024-06-03", 30) # several Monday entries in the [20,45] DTE window
contracts = _bs_chain(days, "2024-07-15", forward=470.0, sigma=0.20, strikes=range(360, 481, 5))
strat = VrpStrategy(_seed(tmp_path, days, contracts), entry_weekday=0)
series, _ = strat.advance(None, {})
assert series and any(abs(x) > 1e-12 for _, x in series) # a real, non-empty return stream
assert max(abs(x) for _, x in series) < 0.10 # no |daily return| > 10%
def test_backtest_and_shared_marking_are_single_source(tmp_path):
"""The backtest (`VrpStrategy._spread_value`) and the live exec twin (`vrp_exec_record`) both value the
spread through the SAME `vrp_marking.model_spread_value` — no drift between the ref and the live
profit-take signal. Assert the strategy's mark equals the shared helper's on the same frozen slice."""
days = _weekdays("2024-07-01", 4)
contracts = _bs_chain(days, "2024-08-05", forward=470.0, sigma=0.20, strikes=range(380, 481, 5))
repo = _seed(tmp_path, days, contracts)
strat = VrpStrategy(repo, entry_weekday=0)
sp = strat._select_spread(days[0])
assert sp is not None
for d in days:
via_strategy = strat._spread_value(dict(sp), d)
via_shared = vrp_marking.model_spread_value(repo, dict(sp), d, strat._width)
assert via_strategy is not None and via_shared is not None
assert abs(via_strategy - via_shared) < 1e-12
def test_fat_left_tail_triggers_cvar_floor():
# A VRP-shaped series: small positive credit-decay most days (net drift ~0), plus occasional deep
# defined-risk losses. cvar_daily averages the worst ceil(0.10*n) returns, so the tail must actually
# be filled with losses: n=50 -> 5 tail slots -> 5 genuine losses. Such a fat-LEFT series has
# cvar_daily/std ABOVE the Gaussian ES-10% multiplier (1.755), which is exactly what makes the merged
# CVaR floor de-lever it (K_cvar < K_vol) where a Gaussian book would be a no-op.
series = [0.06] * 45 + [-0.5] * 5
returns = {str(i): x for i, x in enumerate(series)}
mean = sum(series) / len(series)
std = (sum((x - mean) ** 2 for x in series) / len(series)) ** 0.5
ratio = cvar_daily(returns, 0.10) / std
assert ratio > 1.755 # fatter than Gaussian ES-10% -> CVaR floor bites