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>
321 lines
19 KiB
Python
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
|