refactor(vrp): delete VRP application modules + black_scholes + their tests

This commit is contained in:
2026-07-20 23:08:46 +00:00
parent a263b676de
commit 59cc06e594
20 changed files with 0 additions and 4044 deletions

View File

@@ -1,59 +0,0 @@
"""A tiny, dependency-light Black-Scholes European option pricer (r=0, no dividends — crypto convention).
Used by the DEFINED-RISK VRP evaluator to price the legs of a short-straddle-plus-protective-wings spread
from the DVOL implied-vol index (we have NO historical option chains, so we synthesize prices via BS from the
ATM IV — the standard vol-backtest method; the modelling caveats are documented in `vrp_defined_risk_eval`).
Conventions
-----------
* European options, r = 0 (crypto has no risk-free carry to speak of; forward ≈ spot), no dividends.
* `sigma` is the ANNUALISED vol (a fraction, e.g. 0.60 for 60%); `T` is the time to expiry in YEARS.
* The standard-normal CDF uses `math.erf` (no external dependency) — exact to float precision. (scipy's
`norm.cdf` agrees to ~1e-15; we deliberately avoid the import so the pricer is pure-stdlib.)
The only formulas:
d1 = (ln(S/K) + 0.5·σ²·T) / (σ·√T) (r=0)
d2 = d1 σ·√T
call = S·N(d1) K·N(d2)
put = K·N(d2) S·N(d1) (= call S + K, put-call parity at r=0)
Degenerate inputs (T≤0 or σ≤0) collapse to intrinsic value max(SK,0) / max(KS,0), which is the correct
zero-time / zero-vol limit and keeps the pricer total (never raises on an expiring/zero-vol leg)."""
from __future__ import annotations
import math
def _norm_cdf(x: float) -> float:
"""Standard-normal CDF via the error function: N(x) = 0.5·(1 + erf(x/√2)). Pure stdlib, no scipy."""
return 0.5 * (1.0 + math.erf(float(x) / math.sqrt(2.0)))
def _d1_d2(s: float, k: float, t: float, sigma: float) -> tuple[float, float]:
"""The Black-Scholes d1, d2 (r=0). Assumes s,k>0 and t,sigma>0 (callers handle the degenerate cases)."""
vol_sqrt_t = sigma * math.sqrt(t)
d1 = (math.log(s / k) + 0.5 * sigma * sigma * t) / vol_sqrt_t
d2 = d1 - vol_sqrt_t
return d1, d2
def bs_call(*, s: float, k: float, t: float, sigma: float) -> float:
"""Black-Scholes European CALL price (r=0). Degenerate T≤0 or σ≤0 → intrinsic max(SK, 0). Pure."""
s, k, t, sigma = float(s), float(k), float(t), float(sigma)
if s <= 0.0 or k <= 0.0:
return 0.0
if t <= 0.0 or sigma <= 0.0:
return max(s - k, 0.0)
d1, d2 = _d1_d2(s, k, t, sigma)
return s * _norm_cdf(d1) - k * _norm_cdf(d2)
def bs_put(*, s: float, k: float, t: float, sigma: float) -> float:
"""Black-Scholes European PUT price (r=0). Degenerate T≤0 or σ≤0 → intrinsic max(KS, 0). Pure."""
s, k, t, sigma = float(s), float(k), float(t), float(sigma)
if s <= 0.0 or k <= 0.0:
return 0.0
if t <= 0.0 or sigma <= 0.0:
return max(k - s, 0.0)
d1, d2 = _d1_d2(s, k, t, sigma)
return k * _norm_cdf(-d2) - s * _norm_cdf(-d1)

View File

@@ -1,167 +0,0 @@
"""The defined-risk XSP put-credit-spread VRP sleeve (recompute mode). Recomputes the daily book ENTIRELY off
frozen `xsp_option_bars` rows — put-call-parity forward -> IV inversion -> ~20Δ short strike, long a fixed
width below -> smooth MODEL-mark daily P&L per dollar of defined-risk margin (the daily value is rebuilt from
the day's forward + ATM IV via the shared `vrp_marking` valuation — the same one the live exec twin uses for
its profit-take signal — NOT a difference of two individually-noisy raw leg quotes). Never calls OPRA. Mirrors
how MultiStratStrategy produces both the full-history ref (advance(None,{})) and the nightly forward window."""
from __future__ import annotations
import datetime as dt
from typing import Any
from fxhnt.adapters.persistence.xsp_option_bars import XspOptionBarsRepo
from fxhnt.application import vrp_marking, vrp_pricing
class VrpStrategy:
def __init__(self, bars: XspOptionBarsRepo, *, dte_lo: int = 20, dte_hi: int = 45, dte_target: int = 30,
short_delta: float = 0.20, width_points: float = 5.0, ladder: int = 5,
profit_take: float = 0.5, entry_weekday: int = 0) -> None:
self._bars = bars
self._dte_lo, self._dte_hi, self._dte_target = dte_lo, dte_hi, dte_target
self._short_delta, self._width, self._ladder = short_delta, width_points, ladder
self._profit_take, self._entry_weekday = profit_take, entry_weekday
self.margin = width_points * 100.0
@staticmethod
def _dte(day: str, expiry: dt.date) -> int:
return (expiry - dt.date.fromisoformat(day)).days
def _select_spread(self, day: str) -> dict | None:
"""Pick the ~30-DTE, ~20Δ short put + long a width below, priced from frozen marks on `day`."""
chain = self._bars.chain_asof(day, self._dte_lo, self._dte_hi)
if not chain:
return None
# nearest expiry to dte_target; ties broken by lexicographic date order (deterministic across
# processes) rather than `set` iteration order, which is PYTHONHASHSEED-salted.
expiries = sorted(dict.fromkeys(c.expiry for c in chain))
exp = min(expiries, key=lambda e: abs(self._dte(day, e) - self._dte_target))
puts = sorted((c for c in chain if c.expiry == exp), key=lambda c: c.strike)
tau = max(self._dte(day, exp), 1) / 365.0
marks = self._bars.bars_for([c.osi_symbol for c in puts], day, day)
priced = [(c, marks.get(c.osi_symbol, {}).get(day)) for c in puts]
priced = [(c, p) for c, p in priced if p is not None and p > 0.0]
if len(priced) < 2:
return None
forward, atm_iv = vrp_marking.forward_and_atm_iv(self._bars, day, exp, tau)
if forward is None:
# FALLBACK: no common strike with both a priced put AND a priced call for this expiry
# (degenerate/missing data) — approximate the forward with the highest surviving put strike,
# the closest available proxy to ATM.
forward = priced[-1][0].strike
# choose short strike whose |delta| is nearest short_delta
best = None
for c, price in priced:
iv = vrp_pricing.implied_vol_put(price, forward, c.strike, tau)
if iv is None:
continue
dlt = vrp_pricing.put_delta(forward, c.strike, iv, tau)
score = abs(dlt - self._short_delta)
if best is None or score < best[0]:
best = (score, c, price, iv)
if best is None:
return None
_, short_c, short_price, short_iv = best
long_strike = short_c.strike - self._width
long_c = next((c for c, _ in priced if abs(c.strike - long_strike) < 1e-6), None)
long_price = None
if long_c is not None:
long_price = self._bars.bars_for([long_c.osi_symbol], day, day).get(long_c.osi_symbol, {}).get(day)
if long_c is None or long_price is None:
return None
credit = short_price - long_price
if credit <= 0.0:
return None
# seed the mark IV with a clean ATM read (fall back to the short-leg IV when the ATM inversion failed);
# `_spread_value` re-derives F+IV each day but carries this on a degenerate day.
return dict(entry=day, expiry=exp.isoformat(), short_osi=short_c.osi_symbol, long_osi=long_c.osi_symbol,
short_strike=short_c.strike, long_strike=long_c.strike, credit=credit,
last_iv=(atm_iv if atm_iv is not None else short_iv))
def _spread_value(self, sp: dict, day: str, day_cache: dict | None = None) -> float | None:
"""MODEL value (cost to close) of the open spread on `day`, via the shared `vrp_marking` valuation the
live exec twin also uses (single source): smooth Black-Scholes from the day's forward + ATM IV, clipped
to the defined-risk bound [0, width]. None only when the expiry chain has aged out (caller closes — no
zombie). `day_cache` memoizes (F, IV) across spreads sharing an expiry in one day's pass."""
return vrp_marking.model_spread_value(self._bars, sp, day, self._width, day_cache)
def _settlement_value(self, sp: dict, day: str, day_cache: dict | None = None) -> float | None:
"""Terminal settlement value clip(K_short F, 0, width) from `day`'s forward — the shared
`vrp_marking.settlement_value`, realized on the close day (DTE≤1) instead of the theta model mark."""
return vrp_marking.settlement_value(self._bars, sp, day, self._width, day_cache)
def advance(self, last_date: str | None, extra: dict[str, Any]) -> tuple[list[tuple[str, float]], dict[str, Any]]:
opens: list[dict] = list(extra.get("open_spreads", []))
rows: list[tuple[str, float]] = []
# Replay is read-heavy over every trading day after `last_date`; preload that whole slice in ONE
# query so the per-day chain_asof/bars_for below serve from memory instead of a round-trip each
# (the N+1 that made a full ~1600-day recompute issue thousands of queries). Scoped to `last_date`
# so an incremental from-anchor recompute only loads the days it will actually touch.
preload = getattr(self._bars, "preload", None)
if callable(preload):
preload(after=last_date)
for day in self._bars.trading_days(last_date):
# 1) mark open spreads -> daily defined-risk return
pnl = 0.0
marked = 0 # spreads that produced a mark today (pre-closure)
still: list[dict] = []
day_cache: dict = {} # memoize (F, IV) per (day, expiry) across rungs
for sp in opens:
val = self._spread_value(sp, day, day_cache)
if val is None:
# the expiry's chain has aged/moved out of the frozen slice (no clean forward) -- CLOSE
# rather than zombie-hold: don't re-append to `still` and don't count it in `marked`, so a
# spread whose marks stop no longer permanently occupies a ladder slot.
#
# INTENTIONAL ref-vs-live ASYMMETRY (documented, not a bug): this pure-model backtest's
# ONLY mark IS the model value, so model==None => the rung has no mark at all => drop it.
# The live exec twin (`vrp_exec_record.plan_vrp_ladder`) legitimately DIFFERS on the same
# degenerate slice — a slice where a rung's own put legs price (real `_mark_spread` actual
# mark) but no ATM call/put pair prices anywhere (model==None): the twin HOLDS such a rung
# and manages it on the real fill mark until DTE<=1 or the model recovers, because it has
# strictly MORE information (an actual close price) than this recompute has. Making the two
# consistent would mean either giving the backtest a raw-leg fallback (re-introducing the
# exact mark noise this whole fix removed) or making the twin discard a real fill mark it
# holds — both regressions. The asymmetry is correct; the twin never has LESS info.
continue
marked += 1
# 2) management: 50% profit (on the smooth model_val) or expiry-1. On the expiry close day,
# realize the terminal SETTLEMENT (intrinsic in-the-moneyness capped at width) rather than the
# theta-model mark -- the put-credit-spread's true P&L at expiry.
expiry = dt.date.fromisoformat(sp["expiry"])
closing = val <= (1.0 - self._profit_take) * sp["credit"] or self._dte(day, expiry) <= 1
if self._dte(day, expiry) <= 1:
settle = self._settlement_value(sp, day, day_cache)
if settle is not None:
val = settle
prev = sp.get("last_val", sp["credit"]) # baseline = entry-day model value (see entry below)
# DEFINED-RISK BOUND: value is clipped to [0, width] (in `_spread_value`) or is the settlement
# intrinsic clip(K_shortF, 0, width). The daily increments telescope to a per-spread cumulative
# P&L of (entry_model_value value)·100 dollars; the ABSOLUTE defined-risk bound [width·100,
# +width·100] holds unconditionally, and on a clean BS surface (entry model value == credit) it
# tightens to exactly [(widthcredit)·100, +credit·100]. No day (nor the total) breaches it.
# option marks are per-share; x100 = the contract multiplier, converting the point-change
# into DOLLARS so the numerator matches self.margin's dollar basis (width_points*100) --
# short-vol: value falling = profit.
pnl += (prev - val) * 100.0
sp["last_val"] = val
if closing:
continue # closed (drop from book)
still.append(sp)
opens = still
# denominator = spreads MARKED this day (before closure), matching pnl's numerator basis —
# NOT the post-closure survivor count, which would asymmetrically inflate the return on
# closure days.
rows.append((day, pnl / (max(1, marked) * self.margin)))
# 3) weekly entry. Baseline the P&L at the MODEL value on the entry day (not the raw
# short_closelong_close credit): every daily return is then the change in the SMOOTH model value,
# so the first mark day can't inject the noisy far-OTM leg marks that the raw credit carries. The
# raw `credit` is still the profit-take threshold and the defined-risk reference; on a clean
# (BS-consistent) surface the entry model value equals the credit, so the bound is unchanged.
if dt.date.fromisoformat(day).weekday() == self._entry_weekday and len(opens) < self._ladder:
sp = self._select_spread(day)
if sp is not None:
entry_val = self._spread_value(sp, day, day_cache)
sp["last_val"] = entry_val if entry_val is not None else sp["credit"]
opens.append(sp)
return rows, {"open_spreads": opens}

View File

@@ -1,430 +0,0 @@
"""READ-ONLY, TAIL-HONEST evaluator for the DEFINED-RISK VRP — the *deployable* form of the vol-risk premium.
Why this module exists (the naked VRP failed the tail)
------------------------------------------------------
`vrp_eval.py` confirmed a REAL gross VRP edge (a short-variance swap: Sharpe ~1, OOS positive) but it FAILS
the deploy gate because the NAKED short-vol payoff has an UNBOUNDED left tail — a vol spike drove the modelled
maxDD to ~74% and realistic OPTION costs (50100bp) ate the rest. Selling naked variance is uninsurable.
The textbook fix is to CAP the tail: sell the at-the-money straddle (collect the premium) but BUY out-of-the-
money WINGS (a protective strangle) so the maximum loss is BOUNDED — an iron-condor / straddle-with-wings.
This module prices that spread from DVOL via Black-Scholes and re-runs the SAME tail-aware harness as the
naked evaluator, so the two are directly comparable. The whole point is the maxDD: the wings should keep the
defined-risk curve well above the 40% floor (ideally < 20%) where the naked 74% failed.
The construction (defined-risk short-vol, per asset, rolling `swap_days`=30, entered day t)
------------------------------------------------------------------------------------------
* Spot S = close_t. ATM IV = DVOL_t/100 (annualised, CAUSAL — known at entry). T = swap_days/365. r=0.
* SELL the ATM straddle: 1 call + 1 put struck at K≈S, priced BS(S, S, T, IV) → COLLECT this premium.
* BUY OTM wings (a strangle) at K_up = S·(1+w), K_dn = S·(1w), where `w` = wing width. `w` may be given
directly as a fraction (`wing_width`) OR derived from the ATM std-move (`wing_in_atm_std`·IV·√T) — the
latter is the natural "N sigma" placement. PAY the wing premium BS at the wing strikes.
* NET PREMIUM (the defined-risk credit) = straddle_premium strangle_premium (per 1 unit of spot notional,
expressed as a FRACTION of S so it composes with the daily-return ladder).
* AT EXPIRY (t+swap_days), realized move m = close_{t+swap_days}/S 1. The position P&L (as a fraction of S):
pnl = net_premium |m| (the short straddle pays the realized |move|)
+ max(|m| w, 0) (the wings refund everything BEYOND the wing distance w)
so the loss is BOUNDED below at net_premium w (= (w net_premium), the DEFINED max loss). A huge
move can NEVER lose more than the wing distance net of the credit — that is the entire thesis.
Skew (crypto puts are richer) — the honesty caveat
--------------------------------------------------
We have only the ATM DVOL, not a full smile. Crypto put wings trade at a HIGHER IV than ATM (put skew), so a
short-condor pays MORE for its put wing than flat-ATM pricing implies. We model this with a `put_skew_vol`
bump (in vol points, default +5): the put legs (short ATM put + long put wing) are priced at IV + bump/100.
The long put wing costs more (reduces our credit, conservative) AND the short ATM put collects more (raises
credit). The NET sign of a uniform put-IV bump on a vertical is small; the bigger honesty point is the
OPPOSITE choice: pricing the put wing at FLAT ATM IV (skew bump = 0) UNDER-prices the protection we buy →
OVER-states the credit → the modelled result is then mildly OPTIMISTIC. We therefore default to a non-zero
put-skew bump and DOCUMENT that bump=0 is the optimistic bound. Either way the price is a BS approximation
from a single ATM vol, not a real chain — a modelling caveat the deploy decision must weigh alongside the
cross-venue caveat (signal = Deribit DVOL; execution = Bybit options).
Reuse
-----
The book ladder, vol-target sizing, cost model, verify / walk-forward / look-ahead / correlation-vs-4-edges /
per-year / worst-day / maxDD / tail-aware PASS-FAIL gate are REUSED from `vrp_eval`: the `_defined_risk_book`
context manager temporarily binds `vrp_eval._book_short_var` to the capped-spread ladder (with the wing params
closed over), so the entire harness runs verbatim on the defined-risk daily P&L instead of the variance-swap
P&L — the defined-risk verdict is computed by the exact same judge as the naked one.
READ-ONLY: no store.write_* — the live paper_* tables are never touched.
"""
from __future__ import annotations
import contextlib
import math
import statistics as st
from typing import Any
from fxhnt.application import vrp_eval
from fxhnt.application.black_scholes import bs_call, bs_put
from fxhnt.application.vrp_eval import (
_DIRECTIONS,
_VRP_SYMBOLS,
_daily_close_returns,
_FeatureStore,
)
_DEFAULT_SWAP_DAYS = vrp_eval._DEFAULT_SWAP_DAYS
_DEFAULT_TARGET_VOL = vrp_eval._DEFAULT_TARGET_VOL
_DEFAULT_WING_WIDTH = 0.15 # default wing distance: ±15% of spot (a defined-risk condor width)
_DEFAULT_PUT_SKEW_VOL = 5.0 # crypto put-skew bump (vol points) added to the put-leg IV; 0 = optimistic
_N_LEGS = 4 # legs per defined-risk condor: sell call, sell put, buy call-wing, buy put-wing
# --- 0. the TURNOVER / COST model — the amortized daily roll (NOT full-ladder-daily) -------------
#
# THE BUG (now fixed): the reused naked harness sizes the curve with `vrp_eval._sized_series`, whose cost model
# is `haircut = k · cost_bps/1e4` charged EVERY day. That is the NAKED short-variance model — a delta-hedged
# straddle re-established/re-hedged daily, so the WHOLE k-leverage book turns over each day. It is WRONG for the
# defined-risk CONDOR LADDER, which holds each 4-leg spread ~`swap_days` (=30) days to EXPIRY. In that ladder:
#
# * exactly ONE new spread is OPENED per day (its n_legs legs are traded — the entry round-trip);
# * exactly ONE spread EXPIRES per day — options held to expiry SETTLE, there is NO closing trade/cost;
# * so the daily TRADED notional is (n_legs / swap_days) of the gross book's option notional — NOT the whole
# ladder re-traded. Charging the full ladder every day over-charges turnover by swap_days/n_legs (= 30/4 ≈
# 7.5x), which is what made the defined-risk curve falsely collapse (5.96 @ 5.5bp, 86 @ 50bp).
#
# Equivalently: charge each spread its entry leg cost ONCE (n_legs · cost_bps · leg_notional, held to expiry, no
# exit), then amortize across the swap's life — one spread opens per day, so the per-day haircut on the sized
# (leverage-k) book is k · (n_legs / swap_days) · cost_bps/1e4. The realized per-year drag at leverage 1 is then
# n_legs · cost_bps · (365/swap_days) ≈ 24%/yr at 50bp, ~2-3%/yr at 5.5bp — proportionate, not destroying.
def _defined_risk_daily_cost(*, k: float, cost_bps: float, swap_days: int = _DEFAULT_SWAP_DAYS) -> float:
"""The AMORTIZED daily-roll haircut for the defined-risk condor ladder, as a per-day return drag on the
vol-targeted (leverage-`k`) book: `k · (n_legs / swap_days) · cost_bps/1e4`.
One spread (n_legs legs) is OPENED per day and one EXPIRES (settles — no closing trade), so the daily
traded notional is (n_legs / swap_days) of the gross book, not the whole ladder. cost_bps is applied to the
OPTION leg notional, consistent with the premium/P&L scaling (both fractions of spot ⇒ the leg notional that
the credit is collected on). `swap_days<=0` ⇒ 0. Pure."""
if not cost_bps or swap_days <= 0:
return 0.0
return float(k) * (_N_LEGS / float(swap_days)) * (float(cost_bps) / 1e4)
def _defined_risk_sized_series(raw: dict[int, float], *, k: float, cost_bps: float,
swap_days: int = _DEFAULT_SWAP_DAYS) -> dict[int, float]:
"""`{day: net_ret}` = k·raw the AMORTIZED daily-roll haircut. Mirrors `vrp_eval._sized_series` but replaces
its full-ladder-daily cost with the defined-risk daily roll (`_defined_risk_daily_cost`). Pure."""
haircut = _defined_risk_daily_cost(k=k, cost_bps=cost_bps, swap_days=swap_days)
out: dict[int, float] = {}
for d in sorted(raw):
v = k * raw[d] - haircut
if math.isfinite(v):
out[int(d)] = float(v)
return out
# --- 1. the defined-risk spread: credit at entry + bounded payoff at expiry ----------------------
def defined_risk_credit(*, s: float, iv: float, t: float, wing_width: float,
put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL) -> float:
"""The NET PREMIUM collected at entry for the defined-risk short-vol spread, as a FRACTION of spot S.
credit = [ short ATM straddle ] [ long OTM strangle wings ]
= ( call(S,S) + put(S,S) ) ( call(S, S·(1+w)) + put(S, S·(1w)) ) (all ÷ S)
The PUT legs (short ATM put + long put wing) are priced at IV + put_skew_vol/100 (crypto put skew); the
CALL legs at the flat ATM IV. `iv` is the annualised ATM vol fraction (DVOL/100); `t` years; `w`=wing_width
a positive fraction of S. Returned per 1 unit of spot notional (÷S) so it composes with the return ladder.
A WIDER wing → CHEAPER wing → LARGER credit (but a larger max loss); the naked limit is w→∞ (no wing cost,
no cap). Pure."""
iv = float(iv)
put_iv = iv + float(put_skew_vol) / 100.0
k_up = s * (1.0 + wing_width)
k_dn = s * (1.0 - wing_width)
short_straddle = bs_call(s=s, k=s, t=t, sigma=iv) + bs_put(s=s, k=s, t=t, sigma=put_iv)
long_wings = bs_call(s=s, k=k_up, t=t, sigma=iv) + bs_put(s=s, k=k_dn, t=t, sigma=put_iv)
return (short_straddle - long_wings) / s
def defined_risk_payoff(*, credit: float, realized_move: float, wing_width: float,
direction: str = "short_vol") -> float:
"""The defined-risk short-vol P&L at expiry, as a FRACTION of spot. `realized_move` m = S_T/S 1.
pnl = credit |m| + max(|m| w, 0) (short_vol)
The short straddle pays the realized |move|; the long wings REFUND everything beyond the wing distance w,
so the loss is BOUNDED below at credit w. A calm expiry (|m| ≤ ... ) keeps (most of) the credit; a huge
move loses AT MOST w credit (the DEFINED risk), never more. `direction="long_vol"` negates the whole
structure (buy the straddle, sell the wings — capped GAIN, the mirror). Pure."""
if direction not in _DIRECTIONS:
raise ValueError(f"direction must be one of {_DIRECTIONS}, got {direction!r}")
m = abs(float(realized_move))
short_vol_pnl = float(credit) - m + max(m - float(wing_width), 0.0)
sign = 1.0 if direction == "short_vol" else -1.0
return sign * short_vol_pnl
def defined_risk_max_loss(*, credit: float, wing_width: float) -> float:
"""The DEFINED (bounded) worst-case loss of the short-vol spread, as a fraction of spot: w credit
(a positive number = the most we can lose). This is the whole point — the naked short-straddle's loss is
unbounded; the wings cap it here. Pure."""
return float(wing_width) - float(credit)
# --- 2. the per-asset rolling ladder of defined-risk spreads ------------------------------------
def _asset_defined_risk_ladder(cser: dict[int, float], dser: dict[int, float], *, direction: str,
swap_days: int, wing_width: float, put_skew_vol: float,
wing_in_atm_std: float | None) -> dict[int, float]:
"""The rolling defined-risk-spread LADDER for ONE asset → `{day: daily_pnl}` (UNSIZED, fraction of spot).
For every entry day s with a known close S_s and DVOL_s (causal): price the spread credit at entry, look
forward `swap_days` to the expiry close, compute the bounded expiry P&L from the realized move, and SPREAD
that single expiry P&L over the spread's life (÷ its length) — accruing to each day it covers, exactly like
the variance-swap ladder. The day-t book P&L is the AVERAGE of all in-flight spreads' per-day accruals. If
`wing_in_atm_std` is set, the wing distance is `wing_in_atm_std · IV · √T` (an N-sigma placement) instead
of the fixed `wing_width`. CAUSAL: credit + wing placement use entry-day IV; the realized move is strictly
forward of entry. Pure."""
rets = _daily_close_returns(cser)
if not rets:
return {}
ret_days = sorted(rets)
closes = cser
pnl_sum: dict[int, float] = {}
pnl_cnt: dict[int, int] = {}
t_years = swap_days / 365.0
for idx, s_day in enumerate(ret_days[:-1] if len(ret_days) > 1 else ret_days):
window_days = ret_days[idx + 1: idx + 1 + swap_days]
if not window_days:
continue
spot = closes.get(s_day)
dvol_entry = dser.get(s_day)
expiry_close = closes.get(window_days[-1])
if spot is None or dvol_entry is None or expiry_close is None:
continue
if not (math.isfinite(spot) and spot > 0 and math.isfinite(expiry_close)):
continue
iv = float(dvol_entry) / 100.0
w = wing_in_atm_std * iv * math.sqrt(t_years) if wing_in_atm_std is not None else wing_width
if not (0.0 < w < 1.0):
w = min(max(w, 1e-6), 0.999) # keep wing strikes strictly between 0 and 2·S
credit = defined_risk_credit(s=spot, iv=iv, t=t_years, wing_width=w, put_skew_vol=put_skew_vol)
realized_move = expiry_close / spot - 1.0
payoff = defined_risk_payoff(credit=credit, realized_move=realized_move, wing_width=w,
direction=direction)
per_day = payoff / len(window_days)
for d in window_days:
pnl_sum[d] = pnl_sum.get(d, 0.0) + per_day
pnl_cnt[d] = pnl_cnt.get(d, 0) + 1
return {d: pnl_sum[d] / pnl_cnt[d] for d in pnl_sum}
def _book_defined_risk(store: _FeatureStore, *, direction: str, swap_days: int = _DEFAULT_SWAP_DAYS,
wing_width: float = _DEFAULT_WING_WIDTH, put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL,
wing_in_atm_std: float | None = None) -> dict[int, float]:
"""`{day: raw_defined_risk_ret}` — the BTC+ETH EQUAL-WEIGHT (mean over assets present) raw, UNSIZED (k=1)
rolling defined-risk-spread daily return. CAUSAL + READ-ONLY (pure off panels). Empty when no overlapping
dvol+close data. Mirrors `vrp_eval._book_short_var` but uses the BS-priced capped spread ladder."""
close = store.read_panel(list(_VRP_SYMBOLS), "close")
dvol = store.read_panel(list(_VRP_SYMBOLS), "dvol")
close = {s: v for s, v in close.items() if v}
dvol = {s: v for s, v in dvol.items() if v}
if not close or not dvol:
return {}
per_day: dict[int, list[float]] = {}
for sym in _VRP_SYMBOLS:
cser = close.get(sym, {})
dser = dvol.get(sym, {})
if not cser or not dser:
continue
ladder = _asset_defined_risk_ladder(cser, dser, direction=direction, swap_days=swap_days,
wing_width=wing_width, put_skew_vol=put_skew_vol,
wing_in_atm_std=wing_in_atm_std)
for d, v in ladder.items():
if math.isfinite(v):
per_day.setdefault(int(d), []).append(v)
return {d: st.mean(vals) for d, vals in per_day.items() if vals}
# --- 3. reuse the naked harness by swapping in the defined-risk raw book ------------------------
#
# vrp_eval's verify / walk-forward / metrics all build their raw series by calling `_book_short_var`. We run
# them against the DEFINED-RISK book by temporarily binding `vrp_eval._book_short_var` to our capped builder
# (with the wing params closed over) for the duration of the call. This REUSES the entire tail-aware harness
# — vol-target sizing, cost grid, per-year, worst-day, maxDD, corr-vs-4-edges, look-ahead audit, PASS/FAIL
# gate — verbatim, so the defined-risk verdict is computed by the exact same judge as the naked one.
@contextlib.contextmanager
def _defined_risk_book(*, wing_width: float, put_skew_vol: float, wing_in_atm_std: float | None,
swap_days: int = _DEFAULT_SWAP_DAYS):
"""Bind `vrp_eval._book_short_var` (+ leaked control) to the defined-risk builders AND `vrp_eval._sized_series`
to the defined-risk AMORTIZED-ROLL cost model, for the duration of the `with` block. The cost rebind is the
turnover FIX: the naked harness's `_sized_series` charges the full ladder every day (k·cost_bps), which
over-charges a held-to-expiry 30-day condor ladder by ~swap_days/n_legs; the defined-risk series charges only
the one-spread-per-day roll (`_defined_risk_sized_series`). Restores the originals on exit (even on error)."""
orig = vrp_eval._book_short_var
orig_leaked = vrp_eval._book_short_var_leaked
orig_sized = vrp_eval._sized_series
def _capped(store, *, direction, swap_days=swap_days, _leak=False):
if _leak:
return _book_defined_risk_leaked(store, direction=direction, swap_days=swap_days,
wing_width=wing_width, put_skew_vol=put_skew_vol,
wing_in_atm_std=wing_in_atm_std)
return _book_defined_risk(store, direction=direction, swap_days=swap_days, wing_width=wing_width,
put_skew_vol=put_skew_vol, wing_in_atm_std=wing_in_atm_std)
def _capped_leaked(store, *, direction):
return _book_defined_risk_leaked(store, direction=direction, wing_width=wing_width,
put_skew_vol=put_skew_vol, wing_in_atm_std=wing_in_atm_std)
def _capped_sized(raw, *, k, cost_bps):
return _defined_risk_sized_series(raw, k=k, cost_bps=cost_bps, swap_days=swap_days)
vrp_eval._book_short_var = _capped
vrp_eval._book_short_var_leaked = _capped_leaked
vrp_eval._sized_series = _capped_sized
try:
yield
finally:
vrp_eval._book_short_var = orig
vrp_eval._book_short_var_leaked = orig_leaked
vrp_eval._sized_series = orig_sized
def _book_defined_risk_leaked(store: _FeatureStore, *, direction: str, swap_days: int = _DEFAULT_SWAP_DAYS,
wing_width: float = _DEFAULT_WING_WIDTH,
put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL,
wing_in_atm_std: float | None = None) -> dict[int, float]:
"""NEGATIVE-CONTROL defined-risk book: prices each spread's credit/wings off the EXPIRY-day IV (knowable
only at maturity), a look-ahead leak the causality audit must catch. Mirrors the variance-swap leak."""
close = store.read_panel(list(_VRP_SYMBOLS), "close")
dvol = store.read_panel(list(_VRP_SYMBOLS), "dvol")
close = {s: v for s, v in close.items() if v}
dvol = {s: v for s, v in dvol.items() if v}
if not close or not dvol:
return {}
t_years = swap_days / 365.0
per_day: dict[int, list[float]] = {}
for sym in _VRP_SYMBOLS:
cser = close.get(sym, {})
dser = dvol.get(sym, {})
if not cser or not dser:
continue
rets = _daily_close_returns(cser)
if not rets:
continue
ret_days = sorted(rets)
for idx, s_day in enumerate(ret_days[:-1] if len(ret_days) > 1 else ret_days):
window_days = ret_days[idx + 1: idx + 1 + swap_days]
if not window_days:
continue
spot = cser.get(s_day)
expiry_close = cser.get(window_days[-1])
dvol_leak = dser.get(window_days[-1]) # leak: IV dated at expiry (look-ahead)
if spot is None or expiry_close is None or dvol_leak is None:
continue
if not (math.isfinite(spot) and spot > 0 and math.isfinite(expiry_close)):
continue
iv = float(dvol_leak) / 100.0
w = (wing_in_atm_std * iv * math.sqrt(t_years)) if wing_in_atm_std is not None else wing_width
if not (0.0 < w < 1.0):
w = min(max(w, 1e-6), 0.999)
credit = defined_risk_credit(s=spot, iv=iv, t=t_years, wing_width=w, put_skew_vol=put_skew_vol)
payoff = defined_risk_payoff(credit=credit, realized_move=expiry_close / spot - 1.0,
wing_width=w, direction=direction)
per_day_pnl = payoff / len(window_days)
for d in window_days:
per_day.setdefault(int(d), []).append(per_day_pnl)
# average over in-flight spreads, mirroring the causal ladder's per-day mean over assets+swaps
return {d: st.mean(vals) for d, vals in per_day.items() if vals}
# --- 4. public entry points (mirror vrp_eval, with the wing params) ----------------------------
def defined_risk_returns_from_store(store: _FeatureStore, *, cost_bps: float = 5.5,
direction: str = "short_vol",
target_ann_vol: float = _DEFAULT_TARGET_VOL,
wing_width: float = _DEFAULT_WING_WIDTH,
put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL,
wing_in_atm_std: float | None = None) -> dict[int, float]:
"""READ-ONLY per-day DEFINED-RISK VRP return series `{epoch_day: net_ret}`. BTC+ETH equal-weight capped
short-vol spread, vol-targeted, net of cost. Writes NOTHING."""
with _defined_risk_book(wing_width=wing_width, put_skew_vol=put_skew_vol, wing_in_atm_std=wing_in_atm_std):
return vrp_eval.vrp_returns_from_store(store, cost_bps=cost_bps, direction=direction,
target_ann_vol=target_ann_vol)
def defined_risk_metrics_from_store(store: _FeatureStore, *, cost_bps: float = 5.5,
direction: str = "short_vol",
target_ann_vol: float = _DEFAULT_TARGET_VOL,
wing_width: float = _DEFAULT_WING_WIDTH,
put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL,
wing_in_atm_std: float | None = None) -> dict[str, Any]:
"""READ-ONLY DEFINED-RISK VRP metrics (the standard curve metrics + tail diagnostics). READ-ONLY."""
with _defined_risk_book(wing_width=wing_width, put_skew_vol=put_skew_vol, wing_in_atm_std=wing_in_atm_std):
m = vrp_eval.vrp_metrics_from_store(store, cost_bps=cost_bps, direction=direction,
target_ann_vol=target_ann_vol)
m["wing_width"] = wing_width
m["put_skew_vol"] = put_skew_vol
return m
def verify_defined_risk_edge(store: _FeatureStore, *, cost_bps: float = 5.5,
cost_grid: tuple[float, ...] = (5.5, 11.0, 22.0, 50.0, 100.0),
direction: str = "short_vol",
target_ann_vol: float = _DEFAULT_TARGET_VOL,
wing_width: float = _DEFAULT_WING_WIDTH,
put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL,
wing_in_atm_std: float | None = None,
universe: set[str] | None = None,
unlock_events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""READ-ONLY adversarial verification of the DEFINED-RISK VRP (both directions) — the naked `verify`
harness fed the capped spread book (cost grid incl realistic option costs, per-year, worst-day, maxDD,
corr-vs-4-edges). Adds `wing_width`/`put_skew_vol` to the report. READ-ONLY."""
with _defined_risk_book(wing_width=wing_width, put_skew_vol=put_skew_vol, wing_in_atm_std=wing_in_atm_std):
rep = vrp_eval.verify_vrp_edge(store, cost_bps=cost_bps, cost_grid=cost_grid, direction=direction,
target_ann_vol=target_ann_vol, universe=universe,
unlock_events=unlock_events)
if rep.get("available"):
rep["wing_width"] = wing_width
rep["put_skew_vol"] = put_skew_vol
rep["construction_note"] = ("DEFINED-RISK: short ATM straddle + long OTM wings (BS-priced from DVOL); "
"max loss BOUNDED at wing_width credit")
return rep
def look_ahead_audit_defined_risk(store: _FeatureStore, *, direction: str = "short_vol",
_leak: bool = False, wing_width: float = _DEFAULT_WING_WIDTH,
put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL,
wing_in_atm_std: float | None = None) -> dict[str, Any]:
"""READ-ONLY strict-causality audit of the DEFINED-RISK book: prove each spread prices its credit/wings off
the ENTRY-day IV, never the expiry IV. Reuses `vrp_eval.look_ahead_audit_vrp` against the capped builder.
Returns its `{causal, leak_days, n_days_compared}` shape."""
with _defined_risk_book(wing_width=wing_width, put_skew_vol=put_skew_vol, wing_in_atm_std=wing_in_atm_std):
return vrp_eval.look_ahead_audit_vrp(store, direction=direction, _leak=_leak)
def look_ahead_audit_defined_risk_ok(store: _FeatureStore, *, direction: str = "short_vol",
_leak: bool = False, **kw: Any) -> bool:
"""Convenience boolean: True iff the defined-risk credit/wing pricing is strictly causal (no look-ahead)."""
return bool(look_ahead_audit_defined_risk(store, direction=direction, _leak=_leak, **kw)["causal"])
def walk_forward_defined_risk(store: _FeatureStore, *, cost_bps: float = 5.5, window_days: int = 180,
train_frac: float = 0.6, target_ann_vol: float = _DEFAULT_TARGET_VOL,
max_dd_floor: float = -0.40, wing_width: float = _DEFAULT_WING_WIDTH,
put_skew_vol: float = _DEFAULT_PUT_SKEW_VOL,
wing_in_atm_std: float | None = None,
universe: set[str] | None = None) -> dict[str, Any]:
"""READ-ONLY, TAIL-AWARE OOS / WALK-FORWARD validation of the DEFINED-RISK VRP — the DEPLOY GATE — via the
naked `walk_forward_vrp` harness fed the capped book. The verdict is the SAME tail-aware PASS/FAIL: OOS
Sharpe>0, positive in >70% of windows, maxDD (full AND OOS) above the floor, look-ahead clean. The wings
are meant to make the `tail_survivable` check PASS where the naked book failed (74%). READ-ONLY."""
with _defined_risk_book(wing_width=wing_width, put_skew_vol=put_skew_vol, wing_in_atm_std=wing_in_atm_std):
rep = vrp_eval.walk_forward_vrp(store, cost_bps=cost_bps, window_days=window_days,
train_frac=train_frac, target_ann_vol=target_ann_vol,
max_dd_floor=max_dd_floor, universe=universe)
if rep.get("available"):
rep["wing_width"] = wing_width
rep["put_skew_vol"] = put_skew_vol
rep["construction_note"] = ("DEFINED-RISK: short ATM straddle + long OTM wings (BS-priced from DVOL); "
"max loss BOUNDED at wing_width credit (caps the naked 74% tail)")
return rep

View File

@@ -1,554 +0,0 @@
"""READ-ONLY, TAIL-HONEST evaluator for the VRP (volatility-risk-premium) edge — a genuinely DIFFERENT edge
type (a vol premium, uncorrelated with the directional/flow edges already in the book).
Economic thesis
---------------
Deribit's DVOL is a 30-day annualised IMPLIED-vol index off the BTC/ETH options book — the market's expected
vol. It systematically exceeds the REALIZED vol of the underlying (the volatility risk premium), so SELLING
variance ("short-vol") earns the spread. The catch: the payoff has a fat LEFT tail — when a vol spike makes
RV >> IV, the short-vol position takes a large loss that can wipe out many days of premium. VRP looks great
until a spike; this evaluator is built to make the tail VISIBLE and to judge the edge NET of it.
The VRP return — a PROPER ROLLING VARIANCE SWAP (per asset, per day — CAUSAL)
----------------------------------------------------------------------------
The old construction paid a SINGLE day's `ret_t²` as the realized variance — a wildly noisy/fat-tailed RV
estimate whose spikes blew through the vol-target (the walk-forward showed Sharpe 40 / 96% per 180d window,
which is a CONSTRUCTION ARTIFACT, not a verdict). We replace it with the textbook variance swap.
A SHORT variance swap entered at day t with maturity `swap_days` (default 30):
* STRIKE (implied annual variance, CAUSAL — known at t): K_t = (DVOL_t / 100)²
DVOL_t is the annualised implied vol in vol POINTS (e.g. 50 → (0.50)² = 0.25 annual variance).
* REALIZED annual variance over (t, t+swap_days]: RV²_t = (365/swap_days)·Σ_{i=t+1..t+swap_days} ret_i²
the annualised realized variance from daily close-to-close returns over the swap's life.
* SHORT-VAR SWAP PAYOFF (per unit variance notional): K_t RV²_t
POSITIVE when implied>realized (the premium harvested), NEGATIVE on a vol-spike window (the tail).
Daily series via a rolling LADDER (`_book_short_var`)
----------------------------------------------------
Each day the book holds `swap_days` overlapping short-variance swaps — one ENTERED on each of the prior
`swap_days` days. On day t exactly ONE swap (the one entered at tswap_days) MATURES; its realized window
payoff `K_{tswap_days} RV²_{tswap_days}` is recognised, SPREAD over the swap's life (÷swap_days) so the
ladder produces ONE smooth daily P&L per day rather than a single raw `ret²`. Because RV² is a swap_days-window
AVERAGE of daily ret², the daily series is far LESS noisy than the old single-day proxy — a properly
vol-targeted curve must NOT lose ~everything in 180d. (Mathematically the ladder's day-t P&L equals the
average daily accrual of all in-flight swaps; the maturing-increment form is the simplest exactly-equivalent
expression.) `direction="long_vol"` negates (buy variance). `k` is the SIZING constant (below).
The book is BTC + ETH EQUAL-WEIGHT (`_book_short_var`), then vol-TARGETED.
Sizing (vol-target — matters, a naive short-variance has meaningless scale)
---------------------------------------------------------------------------
The raw average short-var series has an arbitrary scale (variance units). We VOL-TARGET it: pick `k` so the
full-sample annualised vol of `k · raw` equals `target_ann_vol` (default 0.15 = 15%/yr), making Sharpe/DD
comparable to the other edges. `k` is reported as the (in-sample) sizing constant and the implied
`avg_leverage`/exposure. CAVEAT: `k` here is full-sample in-sample (a known overfit-of-scale — the walk-
forward sizes `k` on TRAIN only). Costs scale with the leverage (a short-straddle is re-established/re-hedged
daily, so the per-day cost ≈ leverage × cost_bps).
READ-ONLY + cross-venue caveat
------------------------------
No store.write_* — the live paper_* tables are never touched. The SIGNAL is Deribit's DVOL; live EXECUTION
would be short Bybit option straddles (delta-hedged) — a cross-venue caveat the deploy decision must weigh.
"""
from __future__ import annotations
import datetime as dt
import math
import statistics as st
from typing import Any, Protocol
from fxhnt.application.bybit_book_eval import sleeve_returns_from_store
from fxhnt.application.bybit_carry_revalidate import _curve_metrics
_PERIODS_PER_YEAR = 365
_SQRT_YEAR = math.sqrt(_PERIODS_PER_YEAR)
_EXISTING_EDGES = ("tstrend", "unlock", "xsfunding", "positioning")
_VRP_SYMBOLS = ("BTCUSDT", "ETHUSDT") # the assets with a Deribit DVOL index (BTC/ETH)
_DIRECTIONS = ("short_vol", "long_vol")
_DEFAULT_TARGET_VOL = 0.15 # 15%/yr vol-target (sane, comparable to the other edges)
_DEFAULT_SWAP_DAYS = 30 # variance-swap maturity (DVOL is a 30-day implied-vol index)
class _FeatureStore(Protocol):
"""The panel reads the VRP pipeline needs (TimescaleFeatureStore satisfies this). READ-ONLY."""
def crypto_close_panel(self, *, suffix: str = "USDT") -> dict[str, dict[int, float]]: ...
def read_panel(self, symbols: list[str], feature: str) -> dict[str, dict[int, float]]: ...
def _epoch_day_to_iso(day: int) -> str:
return (dt.date(1970, 1, 1) + dt.timedelta(days=int(day))).isoformat()
def _year_of_day(day: int) -> str:
return str((dt.date(1970, 1, 1) + dt.timedelta(days=int(day))).year)
# --- 1. the VRP return formulas ----------------------------------------------------------------
def short_var_return(*, dvol_prev: float, ret: float, k: float = 1.0,
direction: str = "short_vol") -> float:
"""The per-asset, per-day SINGLE-DAY short-variance increment:
short_var_ret = k * [ (dvol_prev/√365/100)² ret² ]
`dvol_prev` is the IV (DVOL index, vol points) known at the START of the day; `ret` is the close-to-close
return realized OVER the day. POSITIVE when calm (implied daily variance > realized), big-NEGATIVE on a
vol spike. `direction="long_vol"` negates (buy variance). Pure.
NOTE: this single-day proxy is the OLD noisy construction — kept only as the per-day accrual building
block / the look-ahead audit's negative control. The book series now uses the rolling variance swap
(`variance_swap_payoff` + the ladder in `_book_short_var`)."""
if direction not in _DIRECTIONS:
raise ValueError(f"direction must be one of {_DIRECTIONS}, got {direction!r}")
implied_daily_var = (float(dvol_prev) / _SQRT_YEAR / 100.0) ** 2
realized_daily_var = float(ret) ** 2
sign = 1.0 if direction == "short_vol" else -1.0
return float(k) * sign * (implied_daily_var - realized_daily_var)
def variance_swap_payoff(*, dvol_entry: float, fwd_rets: list[float], swap_days: int = _DEFAULT_SWAP_DAYS,
k: float = 1.0, direction: str = "short_vol") -> float:
"""The textbook SHORT variance-swap payoff (per unit variance notional) over one swap's life:
payoff = k * sign * [ K RV² ]
K = (dvol_entry/100)² (annualised IMPLIED variance, strike)
RV² = (365/swap_days) · Σ_{i=1..swap_days} fwd_rets[i]² (annualised REALIZED variance, the window)
`dvol_entry` is the DVOL (vol points, annualised) known at swap ENTRY (causal). `fwd_rets` is the list of
the `swap_days` daily close-to-close returns realized OVER the swap's life (forward of entry); fewer than
`swap_days` returns annualises by however many are present (a partial/truncated final swap). POSITIVE when
implied>realized (the premium), big-NEGATIVE on a vol-spike window (the tail). `direction="long_vol"`
negates (buy variance). Pure."""
if direction not in _DIRECTIONS:
raise ValueError(f"direction must be one of {_DIRECTIONS}, got {direction!r}")
n = len(fwd_rets)
if n == 0:
return 0.0
strike = (float(dvol_entry) / 100.0) ** 2
realized = (_PERIODS_PER_YEAR / n) * sum(float(r) ** 2 for r in fwd_rets)
sign = 1.0 if direction == "short_vol" else -1.0
return float(k) * sign * (strike - realized)
# --- 2. the BTC+ETH equal-weight raw book + vol-target sizing ----------------------------------
def _daily_close_returns(cser: dict[int, float]) -> dict[int, float]:
"""`{day: close-to-close ret}` for the days with a finite, non-zero prior close. Pure."""
days = sorted(cser)
out: dict[int, float] = {}
for i in range(1, len(days)):
d0, d1 = days[i - 1], days[i]
p0, p1 = cser[d0], cser[d1]
if p0 and math.isfinite(p0) and math.isfinite(p1):
out[int(d1)] = p1 / p0 - 1.0
return out
def _asset_swap_ladder(cser: dict[int, float], dser: dict[int, float], *, direction: str,
swap_days: int, leak: bool = False) -> dict[int, float]:
"""The rolling variance-swap LADDER for ONE asset → `{day: daily_swap_pnl}` (UNSIZED, per unit notional).
For every entry day s with a known DVOL_s (causal), open a short-variance swap whose strike is
K_s=(DVOL_s/100)² and whose realized window is the next `swap_days` daily returns (forward of s). The
swap's window payoff `K_s RV²_s` is SPREAD over the swap's life (÷ its realized length) and accrued to
each day it covers; the day-t book P&L is the AVERAGE of the per-day accruals of all swaps in flight on t
(a smooth `swap_days`-window-averaged quantity, NOT a single raw ret²). `leak=True` is the negative
control: it dates the strike by the END of the realized window (DVOL_{s+swap_days}, knowable only at
maturity) — a look-ahead the audit must catch. Pure."""
rets = _daily_close_returns(cser)
if not rets:
return {}
ret_days = sorted(rets)
pnl_sum: dict[int, float] = {}
pnl_cnt: dict[int, int] = {}
for idx, s in enumerate(ret_days[:-1] if len(ret_days) > 1 else ret_days):
# forward window of up to swap_days realized daily returns AFTER entry day s
window_days = ret_days[idx + 1: idx + 1 + swap_days]
if not window_days:
continue
fwd = [rets[d] for d in window_days]
strike_day = window_days[-1] if leak else s # leak: DVOL dated at maturity (look-ahead)
dvol_entry = dser.get(strike_day)
if dvol_entry is None:
continue
payoff = variance_swap_payoff(dvol_entry=dvol_entry, fwd_rets=fwd,
swap_days=swap_days, direction=direction)
per_day = payoff / len(fwd) # spread the window payoff over the swap's life
for d in window_days: # accrue to each day the swap covers
pnl_sum[d] = pnl_sum.get(d, 0.0) + per_day
pnl_cnt[d] = pnl_cnt.get(d, 0) + 1
return {d: pnl_sum[d] / pnl_cnt[d] for d in pnl_sum} # average over in-flight swaps that day
def _book_short_var(store: _FeatureStore, *, direction: str,
swap_days: int = _DEFAULT_SWAP_DAYS, _leak: bool = False,
) -> dict[int, float]:
"""`{day: raw_short_var_ret}` — the BTC+ETH EQUAL-WEIGHT (mean over the assets present that day) raw,
UNSIZED (k=1) ROLLING VARIANCE-SWAP daily return (the `_asset_swap_ladder` per asset). CAUSAL: each swap's
strike DVOL is known at entry and its realized window is strictly FORWARD of entry. READ-ONLY. Pure off
panels. `_leak=True` builds the look-ahead negative control. Empty when no overlapping dvol+close data."""
close = store.read_panel(list(_VRP_SYMBOLS), "close")
dvol = store.read_panel(list(_VRP_SYMBOLS), "dvol")
close = {s: v for s, v in close.items() if v}
dvol = {s: v for s, v in dvol.items() if v}
if not close or not dvol:
return {}
per_day: dict[int, list[float]] = {}
for sym in _VRP_SYMBOLS:
cser = close.get(sym, {})
dser = dvol.get(sym, {})
if not cser or not dser:
continue
ladder = _asset_swap_ladder(cser, dser, direction=direction, swap_days=swap_days, leak=_leak)
for d, v in ladder.items():
if math.isfinite(v):
per_day.setdefault(int(d), []).append(v)
return {d: st.mean(vals) for d, vals in per_day.items() if vals}
def _vol_target_k(raw: dict[int, float], *, target_ann_vol: float,
day_lo: int | None = None, day_hi: int | None = None) -> float:
"""The sizing constant `k` so the annualised vol of `k·raw` over [day_lo, day_hi] == `target_ann_vol`.
`k = target_ann_vol / (pstdev(raw) · √365)`; 0 when the raw series has <2 obs or zero vol. Pure."""
rets = [raw[d] for d in sorted(raw)
if (day_lo is None or d >= day_lo) and (day_hi is None or d <= day_hi)]
if len(rets) < 2:
return 0.0
sd = st.pstdev(rets)
if sd <= 0.0:
return 0.0
return float(target_ann_vol) / (sd * _SQRT_YEAR)
def _sized_series(raw: dict[int, float], *, k: float, cost_bps: float) -> dict[int, float]:
"""`{day: net_ret}` = k·raw per-day cost. A short-straddle is re-established/re-hedged daily, so the
per-day cost ≈ leverage(k) × cost_bps (a constant daily haircut on the sized notional). Pure."""
haircut = k * (cost_bps / 1e4) if cost_bps else 0.0
out: dict[int, float] = {}
for d in sorted(raw):
v = k * raw[d] - haircut
if math.isfinite(v):
out[int(d)] = float(v)
return out
def vrp_returns_from_store(store: _FeatureStore, *, cost_bps: float = 5.5,
direction: str = "short_vol",
target_ann_vol: float = _DEFAULT_TARGET_VOL) -> dict[int, float]:
"""READ-ONLY per-day VRP RETURN SERIES `{epoch_day: net_ret}` — the book-level seam an N-th sleeve would
consume. BTC+ETH equal-weight short-variance (or long-vol negation), vol-TARGETED to `target_ann_vol`
(full-sample k), net of the per-day cost. Writes NOTHING."""
raw = _book_short_var(store, direction=direction)
if len(raw) < 2:
return {}
k = _vol_target_k(raw, target_ann_vol=target_ann_vol)
return _sized_series(raw, k=k, cost_bps=cost_bps)
def _metrics_from_series(series: dict[int, float], *, k: float = 0.0) -> dict[str, Any]:
"""`_curve_metrics` over a `{day: ret}` series, PLUS the tail diagnostics: `worst_day` (the single most
negative day — the vol-spike loss), `sum_ret`, and `avg_leverage` (= k, the vol-target exposure)."""
daily = [(d, series[d]) for d in sorted(series)]
meta = {"symbols": len(_VRP_SYMBOLS),
"first_day": _epoch_day_to_iso(daily[0][0]) if daily else None,
"last_day": _epoch_day_to_iso(daily[-1][0]) if daily else None}
m = _curve_metrics(daily, days_meta=meta)
m["sum_ret"] = sum(series.values())
m["worst_day"] = min(series.values()) if series else 0.0
m["avg_leverage"] = float(k)
return m
def vrp_metrics_from_store(store: _FeatureStore, *, cost_bps: float = 5.5,
direction: str = "short_vol",
target_ann_vol: float = _DEFAULT_TARGET_VOL) -> dict[str, Any]:
"""READ-ONLY VRP metrics (the standard `_curve_metrics` shape + tail diagnostics `worst_day`/`max_dd` +
`avg_leverage`). `available=False` (+ `reason`) when there is no overlapping dvol+close data / too few
days. READ-ONLY."""
raw = _book_short_var(store, direction=direction)
if len(raw) < 2:
return {"cagr": 0.0, "sharpe": 0.0, "max_dd": 0.0, "total_return": 0.0, "ann_return": 0.0,
"days": 0, "symbols": 0, "first_day": None, "last_day": None,
"worst_day": 0.0, "avg_leverage": 0.0, "available": False,
"reason": "VRP curve has <2 days (run `fxhnt ingest-dvol` + `fxhnt bybit-ingest-klines` first)"}
k = _vol_target_k(raw, target_ann_vol=target_ann_vol)
series = _sized_series(raw, k=k, cost_bps=cost_bps)
m = _metrics_from_series(series, k=k)
m["available"] = True
return m
# --- 3. correlation vs the existing edges ------------------------------------------------------
def _correlation(a_series: dict[int, float], b_series: dict[int, float]) -> float | None:
"""Pearson correlation over the two series' COMMON days; None if <2 common days or zero variance. Pure."""
common = sorted(set(a_series) & set(b_series))
if len(common) < 2:
return None
a = [a_series[d] for d in common]
b = [b_series[d] for d in common]
sa, sb = st.pstdev(a), st.pstdev(b)
if sa <= 0.0 or sb <= 0.0:
return None
ma, mb = st.mean(a), st.mean(b)
cov = sum((x - ma) * (y - mb) for x, y in zip(a, b, strict=True)) / len(common)
return cov / (sa * sb)
def _existing_edge_series(store: _FeatureStore, *, universe: set[str] | None, cost_bps: float,
unlock_events: list[dict[str, Any]] | None,
) -> dict[str, dict[int, float]]:
"""The per-day return series for each EXISTING edge (tstrend/unlock/xsfunding/positioning) via the live
sleeve runners. unlock needs the injected calendar; absent → empty. READ-ONLY."""
out: dict[str, dict[int, float]] = {}
for edge in _EXISTING_EDGES:
out[edge] = sleeve_returns_from_store(
store, edge, universe=universe, cost_bps=cost_bps, unlock_events=unlock_events)
return out
# --- 4. adversarial verification ---------------------------------------------------------------
def verify_vrp_edge(store: _FeatureStore, *, cost_bps: float = 5.5,
cost_grid: tuple[float, ...] = (5.5, 11.0, 22.0, 50.0, 100.0),
direction: str = "short_vol",
target_ann_vol: float = _DEFAULT_TARGET_VOL,
universe: set[str] | None = None,
unlock_events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""READ-ONLY adversarial verification of the VRP edge in BOTH directions (short_vol / long_vol).
Per direction:
* `net_cost[dir][bps]` — `_curve_metrics` (+ `sum_ret`/`worst_day`/`avg_leverage`) net of cost at EACH
bps in `cost_grid`. The grid spans the cheap perp-style 5.5/11/22 AND the REALISTIC OPTION costs
50/100 bp (selling options isn't 5.5bp), so the cost sensitivity is honest. Net is RE-RUN per cost.
* `worst_day[dir]` — the single most-negative day (the vol-spike tail loss);
* `max_dd[dir]` — the maxDD at the headline cost (the tail);
* `per_year[dir]` — `{YEAR: {sharpe, sum_ret, max_dd, days}}` net at `cost_bps` (all-one-year ⇒ overfit);
* `corr_edges[dir]` — `{tstrend, unlock, xsfunding, positioning: corr|None}` of the (net @ `cost_bps`)
series with EACH existing edge (the additive/uncorrelated check vs the whole 4-edge book);
* `avg_leverage[dir]` — the vol-target exposure constant k.
The k (vol-target) is the FULL-SAMPLE constant (the walk-forward re-sizes on TRAIN). `available=False`
(+ reason) when there is no data. READ-ONLY."""
grid = tuple(sorted(set(cost_grid) | {cost_bps}))
edge_series = _existing_edge_series(store, universe=universe, cost_bps=cost_bps,
unlock_events=unlock_events)
net_cost: dict[str, dict[float, dict[str, Any]]] = {}
per_year: dict[str, dict[str, dict[str, Any]]] = {}
worst_day: dict[str, float] = {}
max_dd: dict[str, float] = {}
avg_leverage: dict[str, float] = {}
corr_edges: dict[str, dict[str, float | None]] = {}
n_days = 0
for d in _DIRECTIONS:
raw = _book_short_var(store, direction=d)
if len(raw) < 2:
continue
k = _vol_target_k(raw, target_ann_vol=target_ann_vol)
avg_leverage[d] = k
net_cost[d] = {}
net_at_headline: dict[int, float] = {}
for bps in grid:
ser = _sized_series(raw, k=k, cost_bps=bps)
net_cost[d][bps] = _metrics_from_series(ser, k=k)
if bps == cost_bps:
net_at_headline = ser
if not net_at_headline:
net_at_headline = _sized_series(raw, k=k, cost_bps=cost_bps)
n_days = max(n_days, len(net_at_headline))
worst_day[d] = min(net_at_headline.values()) if net_at_headline else 0.0
max_dd[d] = _metrics_from_series(net_at_headline, k=k)["max_dd"]
by_year: dict[str, dict[int, float]] = {}
for day, r in net_at_headline.items():
by_year.setdefault(_year_of_day(day), {})[day] = r
per_year[d] = {}
for yr, ser in sorted(by_year.items()):
ym = _metrics_from_series(ser, k=k)
per_year[d][yr] = {"sharpe": ym["sharpe"], "sum_ret": ym["sum_ret"],
"max_dd": ym["max_dd"], "days": ym["days"]}
corr_edges[d] = {edge: _correlation(net_at_headline, edge_series.get(edge, {}))
for edge in _EXISTING_EDGES}
if n_days < 2:
return {"available": False,
"reason": "VRP curve has <2 days (run `fxhnt ingest-dvol` + `fxhnt bybit-ingest-klines` first)"}
return {"available": True, "cost_bps": cost_bps, "cost_grid": list(grid),
"net_cost": net_cost, "per_year": per_year, "worst_day": worst_day, "max_dd": max_dd,
"avg_leverage": avg_leverage, "corr_edges": corr_edges,
"cross_venue_note": "SIGNAL = Deribit DVOL; EXECUTION would be short Bybit option straddles"}
# --- 5. look-ahead audit -----------------------------------------------------------------------
def _book_short_var_leaked(store: _FeatureStore, *, direction: str) -> dict[int, float]:
"""The NEGATIVE-CONTROL book: dates each swap's STRIKE DVOL at the END of its realized window (knowable
only at maturity, not at entry) — a look-ahead leak. Used ONLY by the audit to prove it bites. Pure."""
return _book_short_var(store, direction=direction, _leak=True)
def look_ahead_audit_vrp(store: _FeatureStore, *, direction: str = "short_vol",
_leak: bool = False) -> dict[str, Any]:
"""Assert the variance-swap strike→realized-window mapping is STRICTLY CAUSAL: a swap's strike DVOL must
be the value known at ENTRY, never one dated at the end of the realized window (knowable only at maturity).
Builds two books from the SAME days: the CAUSAL book (strike = DVOL at entry — the live mapping) and a
LEAKED book (strike = DVOL at the window END — a look-ahead negative control). The candidate is the causal
book unless `_leak=True` forces the leaked one. `leak_days` counts days where the candidate's return
equals the leaked book's — the live causal mapping uses the entry-day DVOL, which differs from the
window-end DVOL whenever DVOL varies, so a clean (causal) candidate is DISTINCT from the leaked book on
EVERY overlapping day, while a leaked candidate is IDENTICAL to it on every day. `leak_days` counts the
days the candidate equals the leaked book; `causal` iff `leak_days == 0` (no day silently uses the
look-ahead strike). A leaked candidate matches on all days → not causal. READ-ONLY."""
causal = _book_short_var(store, direction=direction)
leaked = _book_short_var_leaked(store, direction=direction)
candidate = leaked if _leak else causal
common = sorted(set(candidate) & set(leaked))
leak_days = sum(1 for d in common if abs(candidate[d] - leaked[d]) <= 1e-15)
return {"causal": bool(common) and leak_days == 0,
"leak_days": leak_days, "n_days_compared": len(common)}
# --- 6. OOS / WALK-FORWARD validation (the TAIL-AWARE DEPLOY GATE) ------------------------------
def _sharpe(series: dict[int, float]) -> float:
rets = [series[d] for d in sorted(series)]
if len(rets) < 2:
return 0.0
sd = st.pstdev(rets)
return (st.mean(rets) / sd * _SQRT_YEAR) if sd > 0 else 0.0
def _max_dd(series: dict[int, float]) -> float:
equity, peak, mdd = 1.0, 1.0, 0.0
for d in sorted(series):
equity *= (1.0 + series[d])
peak = max(peak, equity)
if peak > 0:
mdd = min(mdd, equity / peak - 1.0)
return mdd
def _rolling_windows(net: dict[int, float], *, window_days: int) -> dict[str, Any]:
"""Consecutive non-overlapping `window_days` windows; per window Sharpe + total + maxDD + days, and the
fraction of windows with positive total return. A real edge is positive in MOST windows, not one regime."""
if not net:
return {"n_windows": 0, "fraction_positive": 0.0, "windows": []}
days = sorted(net)
lo, hi = days[0], days[-1]
windows: list[dict[str, Any]] = []
start = lo
while start <= hi:
end = start + window_days - 1
ser = {d: net[d] for d in days if start <= d <= end}
if len(ser) >= 2:
total = math.prod(1.0 + ser[d] for d in sorted(ser)) - 1.0
windows.append({"start": _epoch_day_to_iso(start), "end": _epoch_day_to_iso(min(end, hi)),
"sharpe": _sharpe(ser), "total_return": total, "max_dd": _max_dd(ser),
"days": len(ser)})
start = end + 1
n = len(windows)
frac = (sum(1 for w in windows if w["total_return"] > 0.0) / n) if n else 0.0
return {"n_windows": n, "fraction_positive": frac, "windows": windows}
def _train_holdout(store: _FeatureStore, *, cost_bps: float, target_ann_vol: float,
train_frac: float = 0.6) -> dict[str, Any]:
"""The KEY OOS check, TAIL-AWARE. Split time into TRAIN (first `train_frac`) and TEST (the rest). On
TRAIN: pick the winning DIRECTION (short_vol vs long_vol by TRAIN Sharpe) AND size k on TRAIN ONLY (so
the vol-target scale is not fit on the held-out data). Apply THAT direction + THAT k to TEST. Report the
chosen direction, TRAIN Sharpe, TEST (OOS) Sharpe, and the TEST maxDD (the OOS tail)."""
raws = {d: _book_short_var(store, direction=d) for d in _DIRECTIONS}
days = sorted(set().union(*[set(r) for r in raws.values()]))
if len(days) < 4:
return {"train_direction": None, "train_sharpe": 0.0, "test_sharpe": 0.0,
"test_max_dd": 0.0, "train_days": 0, "test_days": 0, "available": False}
split_idx = max(1, min(len(days) - 1, int(len(days) * train_frac)))
train_hi = days[split_idx - 1]
test_lo = days[split_idx]
best_dir, best_sharpe, best_k = "short_vol", -math.inf, 0.0
for d in _DIRECTIONS:
raw = raws[d]
k = _vol_target_k(raw, target_ann_vol=target_ann_vol, day_hi=train_hi)
tr = _sized_series({day: raw[day] for day in raw if day <= train_hi}, k=k, cost_bps=cost_bps)
s = _sharpe(tr)
if s > best_sharpe:
best_dir, best_sharpe, best_k = d, s, k
raw = raws[best_dir]
test = _sized_series({day: raw[day] for day in raw if day >= test_lo}, k=best_k, cost_bps=cost_bps)
return {"train_direction": best_dir, "train_sharpe": best_sharpe, "test_sharpe": _sharpe(test),
"test_max_dd": _max_dd(test), "test_total_return": (math.prod(
1.0 + test[d] for d in sorted(test)) - 1.0) if test else 0.0,
"train_days": split_idx, "test_days": len(days) - split_idx,
"split_at": _epoch_day_to_iso(test_lo), "available": True}
def walk_forward_vrp(store: _FeatureStore, *, cost_bps: float = 5.5, window_days: int = 180,
train_frac: float = 0.6, target_ann_vol: float = _DEFAULT_TARGET_VOL,
max_dd_floor: float = -0.40, universe: set[str] | None = None) -> dict[str, Any]:
"""READ-ONLY, TAIL-AWARE OOS / WALK-FORWARD validation of the VRP edge — the DEPLOY GATE.
Computes (all off the same return + `_curve_metrics`-style accounting as the in-sample verify):
1. `train_holdout` — TRAIN picks the DIRECTION and sizes k (TRAIN-only); applied to the never-looked-at
TEST. Reports `train_sharpe`, `test_sharpe` (OOS), `test_max_dd` (the OOS tail);
2. `rolling_windows` — consecutive non-overlapping windows (per-window Sharpe/total/maxDD) +
`fraction_positive` (a real edge is positive in MOST windows, not one calm regime);
3. `look_ahead` — the strict-causality audit;
4. `full` — the full-sample metrics at the TRAIN-chosen direction (for the headline + the full maxDD);
5. `verdict` — a TAIL-AWARE PASS/FAIL. Deployable ONLY if:
(a) OOS (TEST) Sharpe > 0 at the (realistic-option) cost,
(b) positive in >1 rolling window (fraction_positive > 0.5) AND >70% positive,
(c) the TAIL is SURVIVABLE: full-sample maxDD AND OOS-test maxDD are both above `max_dd_floor`
(default 40%) — VRP looks great until a spike, so a catastrophic DD fails even if Sharpe>0,
(d) look-ahead clean.
A clean "no / too-tail-heavy" is a VALID outcome (FAIL). READ-ONLY; `available=False` (+ reason) when
there is no dvol+close data / too few days."""
raw_probe = _book_short_var(store, direction="short_vol")
if len(raw_probe) < 4:
return {"available": False,
"reason": "VRP curve has <4 usable days for a walk-forward split "
"(run `fxhnt ingest-dvol` + `fxhnt bybit-ingest-klines` first)"}
train_holdout = _train_holdout(store, cost_bps=cost_bps, target_ann_vol=target_ann_vol,
train_frac=train_frac)
direction = train_holdout["train_direction"] or "short_vol"
raw = _book_short_var(store, direction=direction)
k_full = _vol_target_k(raw, target_ann_vol=target_ann_vol)
full_net = _sized_series(raw, k=k_full, cost_bps=cost_bps)
full = _metrics_from_series(full_net, k=k_full)
rolling = _rolling_windows(full_net, window_days=window_days)
audit = look_ahead_audit_vrp(store, direction=direction)
# --- TAIL-AWARE PASS/FAIL verdict ---------------------------------------------------------
oos_pos = train_holdout["available"] and train_holdout["test_sharpe"] > 0.0
rw_pos = rolling["fraction_positive"] > 0.70
tail_survivable = (full["max_dd"] > max_dd_floor
and train_holdout.get("test_max_dd", 0.0) > max_dd_floor)
causal = audit["causal"]
checks = {
"oos_direction_positive": oos_pos,
"rolling_windows_positive": rw_pos,
"tail_survivable": tail_survivable,
"look_ahead_clean": causal,
}
deployable = all(checks.values())
return {"available": True, "cost_bps": cost_bps, "window_days": window_days, "direction": direction,
"target_ann_vol": target_ann_vol, "max_dd_floor": max_dd_floor,
"avg_leverage": k_full, "full": full, "rolling_windows": rolling,
"train_holdout": train_holdout, "look_ahead": audit,
"verdict": {"deployable": deployable, "checks": checks},
"cross_venue_note": "SIGNAL = Deribit DVOL; EXECUTION would be short Bybit option straddles"}

View File

@@ -1,331 +0,0 @@
"""Executed VRP twin (sub-project C-style): place the atomic XSP put-credit-spread on IBKR and record the
executed-vs-modeled reconciliation row. SUSPENDED until the account has options permission (no live OPRA in
CI either, so the runtime path is not testable here).
Maintains a LADDER of open rungs in the `vrp_exec_pos` book-state (mirrors `VrpStrategy.advance`'s ladder
discipline in `vrp_book.py`, not a single overwritten position): each run marks every open rung against
today's frozen slice, closes any rung that hit 50% profit-take or DTE<=1 (placing the reverse combo), and
opens AT MOST ONE new rung — sized at `envelope / ladder`, never the full envelope — when there is a free
ladder slot on the weekly entry day. The 50%-profit-take DECISION uses the SAME smooth `vrp_marking` model
value the backtest uses (single source) — not the raw shortlong leg difference — so a noisy far-OTM long-leg
quote cannot spuriously trip a live close; realized P&L and close order limits stay on the actual fill-basis
mark. This bounds total open notional at ~envelope regardless of how many
rungs are simultaneously open; armed weekly, it can never stack N spreads at full size.
`build_combo_legs`/`build_close_legs`, `refuse_if_live`, `plan_vrp_ladder`, `rung_notional`,
`compute_spread_exec_return`/`compute_ladder_exec_return` and `build_pos_state` are pure and unit-tested.
`plan_and_record_vrp` mirrors `multistrat_exec_record.plan_and_record`'s guard structure but sizes a LADDER
of defined-risk spreads (not a continuous weight vector) — there is no B2a allocation for VRP yet, so new
entries only happen when `paper_envelope > 0`; existing rungs still mark/close on a de-funded envelope
(they cannot be silently stranded), matching the convention elsewhere that a de-funded envelope stops new
risk but does not orphan positions."""
from __future__ import annotations
import datetime as dt
from typing import Any
def build_combo_legs(*, short_osi: str, long_osi: str, short_price: float, long_price: float,
qty: int) -> tuple[list[tuple[str, str, int]], float]:
"""Defined-risk put-credit-spread OPEN: SELL the short put, BUY the wing. Net limit is negative (a
credit) — see `IbkrBroker.place_combo` for the parent-order sign convention this feeds."""
legs = [(short_osi, "SELL", qty), (long_osi, "BUY", qty)]
net = -(short_price - long_price) # credit received -> negative limit
return legs, round(net, 2)
def build_close_legs(*, short_osi: str, long_osi: str, qty: int,
mark_today: float) -> tuple[list[tuple[str, str, int]], float]:
"""Reverse of `build_combo_legs` to FLATTEN a held rung: BUY back the short (was sold), SELL the long
(was bought). `net_limit` is POSITIVE — the debit paid to close, equal to `mark_today` (`_mark_spread`'s
short-minus-long convention)."""
legs = [(short_osi, "BUY", qty), (long_osi, "SELL", qty)]
return legs, round(float(mark_today), 2)
def refuse_if_live(paper_envelope: float, account_is_paper: bool) -> None:
"""The real-capital exposure guard, factored out pure so it is testable without a live broker/repo: a
PAPER-validation envelope (`paper_envelope > 0`) NEVER runs against a non-paper account. Must be the
FIRST thing `plan_and_record_vrp` does — before any repo/broker/OPRA call — so a refusal places (and
reads) exactly nothing."""
if paper_envelope > 0.0 and not account_is_paper:
raise ValueError("paper-envelope refused: account is not paper (real-capital exposure guard)")
def _mark_spread(bars: Any, spread: dict[str, Any], day: str) -> float | None:
"""ACTUAL cost to close `spread` on `day` from the frozen slice (short - long), or None if either leg has
no mark today (aged/moved out of the frozen band). This is the real fill-basis mark — used for the
executed-vs-modeled realized P&L and as the close order's net limit (the debit truly paid). The
profit-take DECISION does NOT use this (it uses the smooth `vrp_marking` model mark) — a noisy far-OTM
long-leg quote must not be able to spuriously trip a live close."""
marks = bars.bars_for([spread["short_osi"], spread["long_osi"]], day, day)
short_val = marks.get(spread["short_osi"], {}).get(day)
long_val = marks.get(spread["long_osi"], {}).get(day)
if short_val is None or long_val is None:
return None
return short_val - long_val
def rung_notional(envelope: float, ladder: int) -> float:
"""The per-rung dollar target: the envelope split evenly across the ladder's slots. Sizing every new
entry at this (instead of the full envelope) bounds total open notional at ~envelope regardless of how
many rungs are simultaneously open."""
return envelope / ladder if ladder > 0 else 0.0
def plan_vrp_ladder(open_spreads: list[dict[str, Any]], exec_marks: dict[str, float | None],
signal_marks: dict[str, float | None], today: str, *,
ladder: int, profit_take: float = 0.5, entry_weekday: int = 0
) -> tuple[list[tuple[dict[str, Any], float]], bool, int]:
"""Pure ladder-discipline decision (mirrors `VrpStrategy.advance`'s close-then-open management, for the
single-day-per-run exec cadence). Both mark dicts are pre-fetched by the caller (keyed by `short_osi`) so
this stays I/O-free:
- `exec_marks` — the ACTUAL shortlong fill-basis mark (`_mark_spread`); the close order's net limit
and the HOLD gate (a rung with no actual price cannot be closed).
- `signal_marks` — the SMOOTH `vrp_marking` model value (same valuation the backtest uses); the ONLY
input to the 50%-profit-take decision, so a noisy far-OTM long-leg quote can't trip
a spurious close.
Returns:
to_close — [(spread, exec_mark)] rungs to close THIS run: 50%-profit-take
(signal <= (1-profit_take)*credit) or DTE<=1. A rung with NO actual mark today (aged out of
the frozen band) is a HOLD, not a close — it cannot be closed without a price (mirrors
`compute_spread_exec_return`'s None-not-phantom-zero convention). The paired value is the
ACTUAL mark (the real debit to close), NOT the model signal.
may_open — True iff today is the weekly entry weekday AND the ladder has a free slot once this run's
closes are applied (len(open_spreads) - len(to_close) < ladder).
open_slots — 0 or 1 (a once-daily run opens at most one new rung, mirroring `VrpStrategy.advance`)."""
to_close: list[tuple[dict[str, Any], float]] = []
for sp in open_spreads:
exec_mark = exec_marks.get(sp["short_osi"])
if exec_mark is None:
continue # no actual price -> cannot close (HOLD)
expiry = dt.date.fromisoformat(sp["expiry"])
dte = (expiry - dt.date.fromisoformat(today)).days
signal = signal_marks.get(sp["short_osi"])
# INTENTIONAL ref-vs-live ASYMMETRY (documented, not a bug): on a degenerate slice where this rung's
# own put legs price (so `exec_mark` is a REAL actual close mark) but no ATM call/put pair prices at any
# expiry (so `signal` / `vrp_marking.model_spread_value` == None), the pure-model backtest
# (`vrp_book.advance`) DROPS the rung — its only mark IS the model value. The live twin instead HOLDS:
# `take_profit` is False when `signal is None` (never profit-take on a missing model signal — a noisy or
# absent model must NOT trip a live close), so the rung stays managed on its real fill mark until DTE<=1
# or the model recovers. Correct: the twin has strictly MORE information than the recompute (a real
# actual mark), so it need not discard a live position the way the info-poorer backtest must.
take_profit = signal is not None and signal <= (1.0 - profit_take) * sp["credit"]
if take_profit or dte <= 1:
to_close.append((sp, exec_mark)) # close at the ACTUAL mark, decide on the model signal
remaining = len(open_spreads) - len(to_close)
is_entry_day = dt.date.fromisoformat(today).weekday() == entry_weekday
may_open = is_entry_day and remaining < ladder
return to_close, may_open, (1 if may_open else 0)
def compute_spread_exec_return(prior_val: float, mark_today: float | None, qty: int,
envelope: float) -> float | None:
"""Envelope-basis executed return of ONE rung since its last mark: the mark-to-market P&L, as a fraction
of the shared ladder envelope. Short-vol: value falling = profit. The $100/contract multiplier converts
the frozen per-share option marks into real dollars. None on a de-funded envelope or when today's freeze
is missing a mark for the held legs (a HOLD, not a phantom zero)."""
if envelope <= 0.0 or mark_today is None:
return None
pnl = (prior_val - mark_today) * 100.0 * qty
return pnl / envelope
def compute_ladder_exec_return(open_spreads_prior: list[dict[str, Any]], marks_today: dict[str, float | None],
envelope_prior: float) -> float | None:
"""Sum `compute_spread_exec_return` across every rung held since the last run (mirrors
`multistrat_exec_record.compute_exec_return`'s book-level pattern, one rung at a time). None on
inception (no prior rungs), a de-funded prior envelope, or when NOT ONE rung produced a mark today (an
all-HOLD run — not a phantom zero); a rung with no mark today simply contributes 0 to the sum, same as
`VrpStrategy.advance`'s per-day loop skipping unmarked spreads."""
if not open_spreads_prior or envelope_prior <= 0.0:
return None
total = 0.0
any_marked = False
for sp in open_spreads_prior:
mark = marks_today.get(sp["short_osi"])
r = compute_spread_exec_return(float(sp.get("last_val", sp["credit"])), mark, int(sp.get("qty", 0)),
envelope_prior)
if r is not None:
total += r
any_marked = True
return total if any_marked else None
def build_pos_state(open_spreads: list[dict[str, Any]], envelope: float, venue: str,
today: str) -> dict[str, Any]:
return {"open_spreads": [dict(sp) for sp in open_spreads], "envelope": float(envelope), "venue": str(venue),
"last_run_date": today}
class _VrpExecBookingStrategy:
"""A trivial observed ForwardStrategy for run_track: books today's pre-computed executed return (or
nothing on inception/no-mark). Mirrors `multistrat_exec_record._ExecBookingStrategy`."""
def __init__(self, exec_return: float | None) -> None:
self._r = exec_return
def advance(self, last_date: str | None, extra: dict[str, Any]) -> tuple[list[tuple[str, float]], dict[str, Any]]:
if self._r is None:
return [], extra
return [(str(extra.get("_today")), float(self._r))], extra
def _record(repo: Any, exec_return: float | None, *, open_spreads: list[dict[str, Any]], envelope: float,
venue: str, today: str, at: dt.datetime) -> None:
from fxhnt.application.forward_engine import run_track
run_track(repo, "vrp_exec", _VrpExecBookingStrategy(exec_return), today=today, at=at)
repo.set_book_state("vrp_exec_pos", build_pos_state(open_spreads, envelope, venue, today), at=at)
def plan_and_record_vrp(repo: Any, *, dbn: Any, broker: Any, nlv: float, today: str, at: dt.datetime,
paper_envelope: float, account_is_paper: bool, venue: str, execute: bool) -> dict[str, Any]:
"""Mark every PRIOR held rung (if any) against today's frozen slice, apply the ladder discipline (close
rungs at 50% profit / DTE<=1, cap entries at `ladder`, size a new rung at `envelope/ladder`), and — when
`execute` — place the closing/opening combos and record the observed `vrp_exec` return.
Guards (mirrors `multistrat_exec_record.plan_and_record` guard-for-guard):
1. `refuse_if_live` — paper-envelope on a non-paper account raises BEFORE any repo/broker/OPRA call
(zero orders placed, zero rows read/written).
2. per-day idempotency — a `vrp_exec_pos` book-state already stamped `today` is a no-op re-run.
3. a `broker.place_combo` failure (open OR close) becomes a structured entry in `errors` (never a
crash); a failed CLOSE re-adds the rung UNCHANGED to the ladder (mirrors the bybit per-order gap-row
+ kill-switch discipline: a failed order must not silently advance the position basis). The OPEN is
gated on the ACTUAL post-close ladder occupancy (not the pre-execution `may_open` plan), so a failed
close that leaves the ladder full skips the new entry instead of breaching the cap.
4. recording is skipped entirely when `execute` is False (dry-run plans only, same as multistrat)."""
refuse_if_live(paper_envelope, account_is_paper)
pos_prior = repo.get_book_state("vrp_exec_pos")
if pos_prior.get("last_run_date") == today:
return {"noop": True, "reason": f"already ran on {today}", "exec_return": None, "placed": 0,
"closed": 0, "spread": None}
open_spreads_prior: list[dict[str, Any]] = list(pos_prior.get("open_spreads", []))
envelope_prior = float(pos_prior.get("envelope", 0.0))
envelope = min(float(paper_envelope), float(nlv)) if paper_envelope > 0.0 else 0.0
from fxhnt.adapters.orchestration.assets import _freeze_latest_session
from fxhnt.adapters.persistence.xsp_option_bars import XspOptionBarsRepo
from fxhnt.config import get_settings
dsn = getattr(repo, "_dsn", None) or get_settings().operational_dsn
bars = XspOptionBarsRepo(dsn)
bars.migrate()
# TWO dates, deliberately distinct: OPRA publishes on a ~1-session lag, so `today`'s slice is NEVER
# available intraday — freezing `today` crashes a live run with DatabentoRangeUnavailable before it ever
# reaches the broker. Freeze/mark against the last COMPLETE OPRA session (`freeze_date`, T-1, via the same
# robust bounded step-back the nightly `vrp_nav` uses); use `today` (calendar) for all date-LOGIC — DTE,
# entry-weekday, the idempotency key, `run_track` booking and the pos-state stamp. Only the DATA date lags.
freeze_date = _freeze_latest_session(dbn, bars, at=at) # last complete OPRA session (data lag), ISO date
from fxhnt.application.vrp_book import VrpStrategy
from fxhnt.registry import STRATEGY_REGISTRY
ladder = int(STRATEGY_REGISTRY["vrp"]["definition"]["params"]["ladder"])
strat = VrpStrategy(bars, ladder=ladder)
from fxhnt.application import vrp_marking
# ACTUAL fill-basis marks (shortlong) for the realized exec P&L and the close order net limits — read at
# the frozen data session, not `today` (whose slice OPRA hasn't published yet -> would come back None).
marks_today = {sp["short_osi"]: _mark_spread(bars, sp, freeze_date) for sp in open_spreads_prior}
exec_return = compute_ladder_exec_return(open_spreads_prior, marks_today, envelope_prior)
# SMOOTH model marks (the SAME `vrp_marking` valuation the backtest uses) drive the 50%-profit-take
# decision, so a noisy far-OTM long-leg quote can't spuriously trip a live close. Memoized per expiry.
signal_cache: dict[tuple[str, str], tuple[float | None, float | None]] = {}
signal_marks = {sp["short_osi"]: vrp_marking.model_spread_value(bars, sp, freeze_date, strat.margin / 100.0,
signal_cache)
for sp in open_spreads_prior}
to_close, may_open, _open_slots = plan_vrp_ladder(
open_spreads_prior, marks_today, signal_marks, today, ladder=ladder,
profit_take=strat._profit_take, entry_weekday=strat._entry_weekday) # noqa: SLF001 — composition
# root reaches into the sim's private ladder-management params so the exec twin and the backtest
# never drift apart.
close_osi = {sp["short_osi"] for sp, _mark in to_close}
kept: list[dict[str, Any]] = []
for sp in open_spreads_prior:
if sp["short_osi"] in close_osi:
continue
m = marks_today.get(sp["short_osi"])
kept.append(dict(sp, last_val=m) if m is not None else sp)
result: dict[str, Any] = {"exec_return": exec_return, "placed": 0, "closed": 0, "to_close": len(to_close),
"errors": [], "notes": [], "spread": None}
candidate: dict[str, Any] | None = None
qty_new = 0
legs_new: list[tuple[str, str, int]] | None = None
net_new: float | None = None
if envelope <= 0.0:
result["notes"].append("no envelope (paper-envelope off, no B2a allocation for vrp_exec yet) — no new rungs")
elif may_open:
# select + price the new rung off the FROZEN data session (freeze_date), not `today` (unpublished).
candidate = strat._select_spread(freeze_date) # noqa: SLF001 — composition root reaches into the selector
if candidate is None:
result["notes"].append("no candidate spread selected for today")
else:
marks = bars.bars_for([candidate["short_osi"], candidate["long_osi"]], freeze_date, freeze_date)
short_price = marks.get(candidate["short_osi"], {}).get(freeze_date)
long_price = marks.get(candidate["long_osi"], {}).get(freeze_date)
if short_price is None or long_price is None:
raise RuntimeError(f"vrp_exec: selected spread has no frozen mark for {freeze_date}")
rung_target = rung_notional(envelope, ladder)
qty_new = int(rung_target // strat.margin)
if qty_new < 1:
result["notes"].append(
f"rung target ${rung_target:,.0f} (envelope/{ladder}) < margin ${strat.margin:,.0f}/contract"
" — no new rung")
candidate = None
else:
legs_new, net_new = build_combo_legs(
short_osi=candidate["short_osi"], long_osi=candidate["long_osi"],
short_price=short_price, long_price=long_price, qty=qty_new)
result.update(spread=candidate, legs=legs_new, net=net_new, qty=qty_new)
else:
result["notes"].append("ladder full or off entry day — no new rung")
if not execute:
return result
open_spreads_next = list(kept)
for sp, mark in to_close:
close_legs, close_net = build_close_legs(short_osi=sp["short_osi"], long_osi=sp["long_osi"],
qty=int(sp["qty"]), mark_today=mark)
try:
broker.place_combo(close_legs, close_net)
result["closed"] += 1
except Exception as e: # a failed CLOSE must not silently drop the rung -- re-add it UNCHANGED.
result["errors"].append(f"close {sp['short_osi']}: {type(e).__name__}: {str(e)[:120]}")
open_spreads_next.append(sp)
if candidate is not None and legs_new is not None and net_new is not None:
# Gate the OPEN on the ACTUAL post-close occupancy, not the pre-execution plan: `may_open` above
# was computed from `to_close` before any order was placed. If a CLOSE above failed, its rung was
# re-added to `open_spreads_next` unchanged, so the ladder may still be at/over cap here even though
# `may_open` was True. Re-checking `len(open_spreads_next) < ladder` at this point makes a failed
# close naturally back-pressure new entries instead of breaching the cap (and, since DTE only
# decreases, prevents the un-closable rung from re-freeing a phantom slot every subsequent run).
if len(open_spreads_next) >= ladder:
result["errors"].append(
f"open skipped: ladder full after close failures ({len(open_spreads_next)}/{ladder} occupied)")
result["spread"] = None
else:
try:
order_id = broker.place_combo(legs_new, net_new)
result["placed"] = 1
result["order_id"] = order_id
opened = dict(candidate, qty=qty_new, last_val=candidate["credit"])
open_spreads_next.append(opened)
result["spread"] = opened
except Exception as e: # a failed OPEN just means no new rung this run -- ladder stays as-is.
result["errors"].append(f"open: {type(e).__name__}: {str(e)[:120]}")
result["spread"] = None
_record(repo, exec_return, open_spreads=open_spreads_next, envelope=envelope, venue=venue, today=today, at=at)
result["open_spreads"] = len(open_spreads_next)
return result

View File

@@ -1,116 +0,0 @@
"""SINGLE SOURCE of the VRP defined-risk spread's MODEL mark — the smooth Black-Scholes value used as the
50%-profit-take management SIGNAL by BOTH the backtest (`vrp_book.VrpStrategy`) and the live exec twin
(`vrp_exec_record`). Rebuilt each day from the put-call-parity forward + a robust ATM implied vol (a single
CLEAN near-the-money read), then clipped to the defined-risk bound [0, width] — never the difference of two
individually-noisy raw OPRA leg quotes (a ~$1 spike in a far-OTM long leg swung that raw difference 50-100%
and could spuriously trip a profit-take close).
Only the management SIGNAL uses this model value; realized/entry P&L on real fills stays mark-to-actual.
The forward is derived over a WIDE DTE window and, because the parity forward is spot (common across
expiries to first order under the r≈0 forward measure), falls back to the globally cleanest ATM call/put pair
when the spread's OWN expiry has no frozen calls — the daily freeze band freezes near-ATM calls only for
DTE∈[20,45], so a spread aged below 20 DTE has puts (band DTE∈[1,45]) but no calls at its expiry. Without
this fallback such a spread could not be marked and would be spuriously closed."""
from __future__ import annotations
import datetime as dt
from typing import Any
from fxhnt.application import vrp_pricing
# Wide DTE window for MARKING an already-open spread (any age, incl. below the selection dte_lo). Large
# enough to cover the longest listed expiry; callers filter to the relevant expiry.
MARK_DTE_HI = 3650
def forward_and_atm_iv(bars: Any, day: str, exp: dt.date, tau: float) -> tuple[float | None, float | None]:
"""(forward F, ATM IV) for expiry `exp` on `day`, derived purely from the frozen slice.
forward = put-call parity at the ATM strike (smallest |C P|, ties → lowest strike), preferring the
spread's OWN expiry and falling back to the globally cleanest ATM pair across all frozen expiries (spot is
common). Rejects an implausible forward. ATM IV = `implied_vol_put` inverted from `exp`'s put nearest the
forward (puts are frozen at any age). Returns (None, None) when no clean ATM pair exists; (F, None) when
the forward is clean but IV inversion fails (the caller carries the last good IV)."""
puts = bars.chain_asof(day, 0, MARK_DTE_HI, right="P")
calls = bars.chain_asof(day, 0, MARK_DTE_HI, right="C")
if not puts or not calls:
return None, None
put_marks = bars.bars_for([c.osi_symbol for c in puts], day, day)
call_marks = bars.bars_for([c.osi_symbol for c in calls], day, day)
# priced ATM candidates keyed by (expiry, strike): both a positive put AND call mark at the same strike.
put_by = {(c.expiry, c.strike): p for c in puts
if (p := put_marks.get(c.osi_symbol, {}).get(day)) is not None and p > 0.0}
call_by = {(c.expiry, c.strike): p for c in calls
if (p := call_marks.get(c.osi_symbol, {}).get(day)) is not None and p > 0.0}
common = set(put_by) & set(call_by)
# prefer the spread's own expiry (entry-time behaviour, preserved); else any expiry (spot is common).
same_exp = [ek for ek in common if ek[0] == exp]
candidates = same_exp or list(common)
if not candidates:
return None, None
e_atm, k_atm = min(candidates, key=lambda ek: (abs(call_by[ek] - put_by[ek]), ek[1], ek[0]))
forward = vrp_pricing.parity_forward(call_atm=call_by[(e_atm, k_atm)], put_atm=put_by[(e_atm, k_atm)],
k_atm=k_atm)
if not (0.2 * k_atm < forward < 5.0 * k_atm): # implausible parity forward -> never mark off it
return None, None
# ATM IV from the spread's OWN expiry put nearest the forward (present at any age).
exp_puts = {k: p for (e, k), p in put_by.items() if e == exp}
if not exp_puts:
return forward, None
k_iv = min(exp_puts, key=lambda k: abs(k - forward))
iv = vrp_pricing.implied_vol_put(exp_puts[k_iv], forward, k_iv, tau)
return forward, iv
def model_spread_value(bars: Any, spread: dict[str, Any], day: str, width: float,
day_cache: dict[tuple[str, str], tuple[float | None, float | None]] | None = None
) -> float | None:
"""Smooth MODEL value (cost to close) of `spread` on `day`, clipped to the defined-risk bound [0, width].
Carries the last good IV on `spread['last_iv']`. Returns None when the strikes are unknown or the expiry
has no clean forward (caller CLOSES — no zombie). `day_cache` (optional) memoizes (F, IV) per
(day, expiry) so spreads sharing an expiry in one pass don't re-query the chain."""
ks, kl = spread.get("short_strike"), spread.get("long_strike")
if ks is None or kl is None:
return None # unknown strikes -> no model signal (safe: no close)
exp = dt.date.fromisoformat(spread["expiry"])
tau = max((exp - dt.date.fromisoformat(day)).days, 1) / 365.0 # tau > 0 guard
key = (day, spread["expiry"])
if day_cache is not None and key in day_cache:
forward, iv = day_cache[key]
else:
forward, iv = forward_and_atm_iv(bars, day, exp, tau)
if day_cache is not None:
day_cache[key] = (forward, iv)
if forward is None:
return None # expiry chain gone -> close (not zombie)
if iv is None:
iv = spread.get("last_iv") # carry the last good IV on a degenerate mark day
if iv is None:
return None
spread["last_iv"] = iv
model = vrp_pricing.bs_put(forward, ks, iv, tau) - vrp_pricing.bs_put(forward, kl, iv, tau)
return min(max(model, 0.0), width) # hard defined-risk clip [0, width]
def settlement_value(bars: Any, spread: dict[str, Any], day: str, width: float,
day_cache: dict[tuple[str, str], tuple[float | None, float | None]] | None = None
) -> float | None:
"""Terminal settlement value from `day`'s forward: the put-credit-spread's loss is the short strike's
in-the-moneyness capped at the width — clip(K_short F, 0, width). Realized on the close day (DTE≤1)
instead of the theta model mark."""
ks = spread.get("short_strike")
if ks is None:
return None
exp = dt.date.fromisoformat(spread["expiry"])
tau = max((exp - dt.date.fromisoformat(day)).days, 1) / 365.0
key = (day, spread["expiry"])
if day_cache is not None and key in day_cache:
forward, _iv = day_cache[key]
else:
forward, _iv = forward_and_atm_iv(bars, day, exp, tau)
if day_cache is not None:
day_cache[key] = (forward, _iv)
if forward is None:
return None
return min(max(ks - forward, 0.0), width)

View File

@@ -1,57 +0,0 @@
"""Pure BlackScholes helpers for the defined-risk XSP VRP sleeve — priced under the FORWARD measure so
the underlying comes from put-call parity on the option chain (no external index feed; OPRA is options-only).
No I/O: every input is a real OPRA mark or a frozen bar. r is folded into `disc = e^{-rT}` and the forward F."""
from __future__ import annotations
import math
_SQRT2 = math.sqrt(2.0)
def _norm_cdf(x: float) -> float:
return 0.5 * (1.0 + math.erf(x / _SQRT2))
def bs_put(F: float, K: float, sigma: float, tau: float, disc: float = 1.0) -> float:
"""European put under the forward measure: disc*(K*N(-d2) - F*N(-d1)). disc = e^{-rT} (1.0 for r≈0)."""
if tau <= 0.0 or sigma <= 0.0 or F <= 0.0 or K <= 0.0:
return max(0.0, disc * (K - F)) # intrinsic (degenerate day)
vol = sigma * math.sqrt(tau)
d1 = (math.log(F / K) + 0.5 * sigma * sigma * tau) / vol
d2 = d1 - vol
return disc * (K * _norm_cdf(-d2) - F * _norm_cdf(-d1))
def put_delta(F: float, K: float, sigma: float, tau: float) -> float:
"""|Δ| of the put under the forward measure = N(-d1) in [0,1]. Used for ~20-delta strike selection."""
if tau <= 0.0 or sigma <= 0.0 or F <= 0.0 or K <= 0.0:
return 1.0 if K > F else 0.0
vol = sigma * math.sqrt(tau)
d1 = (math.log(F / K) + 0.5 * sigma * sigma * tau) / vol
return _norm_cdf(-d1)
def implied_vol_put(price: float, F: float, K: float, tau: float, disc: float = 1.0) -> float | None:
"""Invert bs_put for sigma via bisection. Returns None when `price` is below intrinsic / above the bound
(no arb-free vol) — the caller skips that contract for the day."""
if tau <= 0.0 or price <= 0.0:
return None
intrinsic = max(0.0, disc * (K - F))
upper_bound = disc * K # put <= disc*K (sigma -> inf)
if price < intrinsic - 1e-9 or price > upper_bound + 1e-9:
return None
lo, hi = 1e-4, 5.0
if bs_put(F, K, hi, tau, disc) < price: # unreachable even at 500% vol
return None
for _ in range(80):
mid = 0.5 * (lo + hi)
if bs_put(F, K, mid, tau, disc) < price:
lo = mid
else:
hi = mid
return 0.5 * (lo + hi)
def parity_forward(call_atm: float, put_atm: float, k_atm: float, disc: float = 1.0) -> float:
"""Implied forward from put-call parity: C - P = disc*(F - K) => F = K + (C - P)/disc."""
return k_atm + (call_atm - put_atm) / disc

View File

@@ -1,116 +0,0 @@
"""Equity-index Volatility Risk Premium (VRP) — a STRUCTURAL risk premium (not momentum): implied vol
exceeds subsequent realized vol on average because option sellers are paid to bear crash risk. Short vol
harvests IV RV. The catch (and why the deflated Sharpe matters): VRP returns are negatively skewed and
fat-tailed (vol spikes = big losses), so a raw Sharpe overstates it — the skew/kurtosis-adjusted deflated
Sharpe is the honest measure. Built on XSP (mini-SPX) options, 2013-2026, internal put-call-parity (vrp3).
"""
from __future__ import annotations
import datetime as dt
import glob
import math
import os
from dataclasses import dataclass
import numpy as np
from fxhnt.domain.validation import deflated_sharpe_ratio, excess_kurtosis, sharpe_ratio, skewness
_DAY_NS = 86_400 * 10**9
def _to_epoch_day(yymmdd: str) -> int:
y, m, d = 2000 + int(yymmdd[:2]), int(yymmdd[2:4]), int(yymmdd[4:6])
return (dt.date(y, m, d) - dt.date(1970, 1, 1)).days
def _load_xsp(xsp_dir: str): # type: ignore[no-untyped-def]
import databento as db
import pandas as pd
rows = []
for p in sorted(glob.glob(os.path.join(xsp_dir, "*.dbn"))):
try:
df = db.DBNStore.from_file(p).to_df().reset_index()
except Exception:
continue
if df.empty or "symbol" not in df.columns:
continue
df = df[df["close"] > 0]
rows.append(df[["ts_event", "symbol", "close"]])
d = pd.concat(rows, ignore_index=True)
s = d["symbol"].astype(str).str.replace(" ", "", regex=False) # XSP240119C00450000
d["right"] = s.str[-9]
d["strike"] = s.str[-8:].astype(float) / 1000.0
d["expiry"] = s.str[-15:-9]
d["day"] = d["ts_event"].astype("int64") // _DAY_NS
return d
@dataclass
class VrpResult:
n_periods: int
mean_vrp: float
sharpe: float
skew: float
excess_kurt: float
deflated_sharpe: float # skew/kurtosis-adjusted (the honest measure)
dsr_pvalue: float
is_sharpe: float
oos_sharpe: float
max_drawdown: float
def evaluate(xsp_dir: str, *, holding_days: int = 21, n_trials: int = 5, oos_fraction: float = 0.40) -> VrpResult:
import pandas as pd
d = _load_xsp(xsp_dir)
d["exp_day"] = d["expiry"].map(_to_epoch_day)
d["ttm"] = d["exp_day"] - d["day"]
d = d[(d["ttm"] >= 20) & (d["ttm"] <= 45)]
forward, iv = {}, {}
for day, g in d.groupby("day"):
exp = g.iloc[(g["ttm"] - 30).abs().argsort()].iloc[0]["exp_day"]
ge = g[g["exp_day"] == exp]
calls = ge[ge["right"] == "C"].groupby("strike")["close"].mean()
puts = ge[ge["right"] == "P"].groupby("strike")["close"].mean()
common = np.array(sorted(set(calls.index) & set(puts.index)))
if len(common) < 5:
continue
rough = common[np.abs(calls[common].values - puts[common].values).argmin()]
near = common[np.argsort(np.abs(common - rough))[:5]]
f = float(np.median([k + float(calls[k]) - float(puts[k]) for k in near]))
katm = common[np.abs(common - f).argmin()]
straddle = float(calls[katm] + puts[katm])
t_yrs = float(ge["ttm"].iloc[0]) / 365.0
if f <= 0 or straddle <= 0 or t_yrs <= 0:
continue
forward[int(day)] = f
iv[int(day)] = straddle / (0.8 * f * math.sqrt(t_yrs)) # ATM-straddle implied-vol approximation
days = np.array(sorted(forward))
fwd = np.array([forward[x] for x in days])
iv_arr = np.array([iv[x] for x in days])
logret = np.zeros(len(days))
logret[1:] = np.clip(np.log(fwd[1:] / fwd[:-1]), -0.25, 0.25)
# NON-OVERLAPPING short-vol periods: enter, hold `holding_days`, P&L proxy = IV_entry RV_realized
returns = []
step = holding_days
for t in range(0, len(days) - step, step):
rv = float(np.std(logret[t + 1:t + 1 + step]) * math.sqrt(252))
returns.append(iv_arr[t] - rv)
r = np.array(returns)
if len(r) < 8:
raise ValueError("not enough VRP periods")
sk, ek = skewness(r), excess_kurtosis(r)
dsr = deflated_sharpe_ratio(sharpe_ratio(r), n_trials, 0.0, sk, ek, len(r))
split = int((1.0 - oos_fraction) * len(r))
eq = np.cumprod(1.0 + r / 100.0) # scale vol-points to a rough equity curve
mdd = float((eq / np.maximum.accumulate(eq) - 1.0).min())
return VrpResult(
n_periods=len(r), mean_vrp=float(r.mean()), sharpe=sharpe_ratio(r), skew=sk, excess_kurt=ek,
deflated_sharpe=1.0 - dsr.pvalue, dsr_pvalue=dsr.pvalue,
is_sharpe=sharpe_ratio(r[:split]), oos_sharpe=sharpe_ratio(r[split:]), max_drawdown=mdd)

View File

@@ -1,26 +0,0 @@
import datetime as dt
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from fxhnt.adapters.persistence.cockpit_models import ForwardSummaryRow
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.web.app import create_app
def test_archived_vrp_detail_is_hidden(tmp_path):
"""vrp is ARCHIVED/FALSIFIED (shelved 2026-07-15): even with a persisted forward_summary row in the DB,
a direct `/strategy/vrp` hit must 404 (detail() returns None) and the page must never leak the VRP label
or its "Options income (put spreads)" alias. The code + OPRA data are kept; only the cockpit hides it."""
repo = ForwardNavRepo(f"sqlite:///{tmp_path/'c.db'}")
repo.migrate()
at = dt.datetime(2026, 7, 12)
with Session(repo._engine) as s:
s.add(ForwardSummaryRow(strategy_id="vrp", as_of="2026-07-12", days=8, nav=1.004,
total_return=0.004, sharpe=0.9, maxdd=-0.03, gate_status="WAIT",
gate_reason="building 8/21", src_updated_at=at))
s.commit()
r = TestClient(create_app(repo)).get("/strategy/vrp")
assert r.status_code == 404
assert "Equity VRP" not in r.text
assert "Options income" not in r.text

View File

@@ -1,452 +0,0 @@
"""READ-ONLY, TAIL-HONEST evaluator for the DEFINED-RISK VRP — the deployable form of the vol-risk premium.
The naked short-variance VRP has a real gross edge but FAILS the deploy gate on an UNBOUNDED left tail
(modelled maxDD ~74%). The fix: sell the ATM straddle but BUY OTM wings so the max loss is BOUNDED (an iron
condor). The legs are priced from DVOL via Black-Scholes (we have no option chains — documented caveat).
Tests assert:
* the BOUNDED MAX LOSS — a huge move loses ≤ the defined max (wing_width credit), NOT unbounded;
* a calm expiry keeps (most of) the credit; the wing cost reduces the credit vs a naked straddle;
* the capped curve has a DRAMATICALLY shallower maxDD than the naked variance swap on the SAME spike fixture
(the wings work — this is the whole point);
* verify is cost-monotone (incl 50/100bp option costs), per-year, worst-day, maxDD, corr-vs-4-edges;
* walk-forward OOS + look-ahead-clean; tail-survivable PASSES;
* READ-ONLY (a write-tripwire never trips);
* the CLI smoke prints metrics + verify + walk-forward (in-memory store, NO network).
"""
from __future__ import annotations
import math
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application import vrp_eval
from fxhnt.application.vrp_defined_risk_eval import (
_N_LEGS,
_defined_risk_daily_cost,
_defined_risk_sized_series,
defined_risk_credit,
defined_risk_max_loss,
defined_risk_metrics_from_store,
defined_risk_payoff,
defined_risk_returns_from_store,
look_ahead_audit_defined_risk_ok,
verify_defined_risk_edge,
walk_forward_defined_risk,
)
_DAY = 86_400
_START = 19_358 # 2023-01-01 epoch day
class _WriteTripwireStore:
"""Read-only proxy: forwards reads, but ANY write/persist call trips an assertion."""
_FORBIDDEN = frozenset({
"write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
"replace_positions", "upsert_nav", "upsert_sleeve_ret", "replace_shadow_positions",
"replace_trades", "_create_schema", "_bulk_upsert",
})
def __init__(self, inner: TimescaleFeatureStore) -> None:
object.__setattr__(self, "_inner", inner)
def __getattr__(self, name: str):
if name in _WriteTripwireStore._FORBIDDEN:
raise AssertionError(f"READ-ONLY violation: evaluator called write method {name!r}")
return getattr(self._inner, name)
# --- 1. the defined-risk spread: credit + BOUNDED payoff ---------------------------------------
def test_credit_is_positive_and_a_fraction_of_spot() -> None:
# selling the ATM straddle collects more than the OTM wings cost -> a positive credit, sane in magnitude.
c = defined_risk_credit(s=30_000.0, iv=0.60, t=30.0 / 365.0, wing_width=0.15)
assert 0.0 < c < 0.20 # a fraction of spot, smaller than the wing distance
def test_wing_cost_reduces_credit_vs_naked_straddle() -> None:
# the naked straddle credit (no wings) = w -> infinity limit; buying wings (finite w) costs premium, so the
# defined-risk credit is STRICTLY LESS than the naked straddle premium. A WIDER wing is cheaper -> larger
# credit (approaching the naked premium).
s, iv, t = 30_000.0, 0.60, 30.0 / 365.0
narrow = defined_risk_credit(s=s, iv=iv, t=t, wing_width=0.10)
wide = defined_risk_credit(s=s, iv=iv, t=t, wing_width=0.30)
naked = defined_risk_credit(s=s, iv=iv, t=t, wing_width=0.95) # ~no wing cost -> ~naked straddle premium
assert narrow < wide < naked
def test_max_loss_is_bounded_the_core_property() -> None:
# THE CORE PROPERTY: a catastrophic move loses AT MOST the defined max (wing_width credit), NOT unbounded.
credit = defined_risk_credit(s=30_000.0, iv=0.60, t=30.0 / 365.0, wing_width=0.15)
defined_max = defined_risk_max_loss(credit=credit, wing_width=0.15)
# a 10%, 50%, 90%, 99% crash all lose the SAME bounded amount once past the wing.
for move in (-0.50, -0.90, -0.99, +0.95):
pnl = defined_risk_payoff(credit=credit, realized_move=move, wing_width=0.15)
assert pnl >= -defined_max - 1e-12 # never worse than the defined max loss
assert math.isclose(pnl, -defined_max, abs_tol=1e-9) # past the wing -> exactly the cap
def test_naked_straddle_loss_is_unbounded_but_capped_is_not() -> None:
# contrast: with NO wings the loss grows with the move (unbounded); with wings it plateaus at the cap.
credit = 0.05
capped_small = defined_risk_payoff(credit=credit, realized_move=-0.20, wing_width=0.15)
capped_huge = defined_risk_payoff(credit=credit, realized_move=-0.80, wing_width=0.15)
assert math.isclose(capped_small, capped_huge, abs_tol=1e-12) # capped: same loss past the wing
# the implicit naked short straddle (credit |m|) keeps getting worse.
naked_small = credit - 0.20
naked_huge = credit - 0.80
assert naked_huge < naked_small # naked: unbounded
def test_calm_expiry_keeps_the_credit() -> None:
# a small realized move (well inside the wings) -> pnl ≈ credit |m|, still positive when |m| < credit.
credit = 0.05
pnl = defined_risk_payoff(credit=credit, realized_move=0.01, wing_width=0.15)
assert math.isclose(pnl, credit - 0.01, abs_tol=1e-12)
assert pnl > 0.0
def test_long_vol_direction_negates() -> None:
credit = 0.04
short = defined_risk_payoff(credit=credit, realized_move=-0.30, wing_width=0.12, direction="short_vol")
long_ = defined_risk_payoff(credit=credit, realized_move=-0.30, wing_width=0.12, direction="long_vol")
assert math.isclose(long_, -short, rel_tol=1e-12)
# --- 2. fixtures (mirror the naked evaluator's, with a tradeable defined-risk premium) ----------
def _seed_calm_premium(store: TimescaleFeatureStore, *, days: int = 400, dvol: float = 60.0,
daily_move: float = 0.004) -> None:
"""Calm: IV high (dvol=60), tiny realized oscillation -> the defined-risk condor harvests its credit."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
sign = 1.0 if i % 2 == 0 else -1.0
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol})])
px[s] *= (1.0 + sign * daily_move)
def _seed_with_spike(store: TimescaleFeatureStore, *, days: int = 400, spike_at: int = 200,
crash: float = 0.45) -> None:
"""Calm condor premium most days, but ONE catastrophic crash that would blow up a NAKED short straddle;
the wings cap the defined-risk loss at the spread width. Used for the naked-vs-defined tail comparison."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": 50.0})])
if i == spike_at:
px[s] *= (1.0 - crash)
else:
px[s] *= (1.0 + (0.003 if i % 2 == 0 else -0.003))
def _seed_tail_survivable(store: TimescaleFeatureStore, *, days: int = 700) -> None:
"""A defined-risk premium that survives its tails: calm harvest, a moderate spike every ~90 days. DVOL
varies day to day (so the causality audit can distinguish prior-day from same-day IV)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 60.0 + 8.0 * math.sin(i * 0.11)
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol, "turnover": 5e8})])
if i % 90 == 45:
px[s] *= (1.0 - 0.06)
else:
px[s] *= (1.0 + (0.004 if i % 2 == 0 else -0.004))
def _seed_varying_dvol(store: TimescaleFeatureStore, *, days: int = 120) -> None:
"""Premium fixture with monotone time-varying DVOL so the causal and leaked credit-pricing mappings are
distinguishable on every day (the look-ahead audit can only bite when entry-day and expiry IV differ)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 50.0 + 0.137 * i
sign = 1.0 if i % 2 == 0 else -1.0
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol})])
px[s] *= (1.0 + sign * 0.004)
# --- 3. the book series: harvest, sizing, read-only --------------------------------------------
def test_defined_risk_returns_positive_on_calm_premium() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
series = defined_risk_returns_from_store(store, cost_bps=0.0, direction="short_vol")
store.close()
assert len(series) > 2
assert sum(series.values()) > 0.0 # the condor credit is harvested gross
def test_defined_risk_metrics_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(inner)
store = _WriteTripwireStore(inner)
m = defined_risk_metrics_from_store(store, cost_bps=5.5)
inner.close()
assert m["available"] is True
assert m["wing_width"] > 0.0
def test_defined_risk_metrics_reports_tail() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
m = defined_risk_metrics_from_store(store, cost_bps=0.0)
store.close()
assert m["available"] is True
assert m["max_dd"] <= 0.0
assert "worst_day" in m and "avg_leverage" in m
# --- 4. THE TAIL COMPARISON: defined-risk maxDD << naked maxDD on the SAME spike ---------------
def test_defined_risk_maxdd_dramatically_shallower_than_naked_on_spike() -> None:
"""THE WHOLE POINT. On the SAME catastrophic-spike fixture, the defined-risk (wings) curve must have a
DRAMATICALLY shallower maxDD than the naked variance-swap curve — the wings cap the tail the naked book
cannot. Both are vol-targeted+costed by the identical harness; only the construction differs."""
from fxhnt.application.vrp_eval import vrp_metrics_from_store
naked_store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(naked_store)
naked = vrp_metrics_from_store(naked_store, cost_bps=5.5, direction="short_vol")
naked_store.close()
capped_store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(capped_store)
capped = defined_risk_metrics_from_store(capped_store, cost_bps=5.5, direction="short_vol",
wing_width=0.15)
capped_store.close()
assert naked["available"] and capped["available"]
# the defined-risk drawdown is materially shallower than the naked one (the wings work).
assert capped["max_dd"] > naked["max_dd"]
assert capped["max_dd"] > 1.3 * naked["max_dd"] # at least ~30% shallower (maxDD is negative)
# --- 4b. THE TURNOVER / COST-ACCOUNTING MODEL (the amortized daily roll, not full-ladder-daily) -
def test_daily_cost_is_one_spreads_legs_not_the_whole_ladder() -> None:
"""THE CORE TURNOVER FIX. A 30-day-roll condor ladder opens exactly ONE new spread per day (n_legs legs)
and lets one expire (settles — NO closing trade). So the daily traded notional is (n_legs / swap_days) of
the gross book, NOT the whole k-leverage ladder re-traded every day. The defined-risk daily haircut must be
k · (n_legs / swap_days) · cost_bps/1e4 — far smaller than the naked daily-rehedge haircut k · cost_bps/1e4."""
k, cost_bps, swap_days = 3.0, 50.0, 30
dr = _defined_risk_daily_cost(k=k, cost_bps=cost_bps, swap_days=swap_days)
expected = k * (_N_LEGS / swap_days) * (cost_bps / 1e4)
assert math.isclose(dr, expected, rel_tol=1e-12)
# it is exactly (n_legs / swap_days) of the naked full-ladder-daily haircut.
naked_full_ladder_daily = k * (cost_bps / 1e4)
assert math.isclose(dr, naked_full_ladder_daily * (_N_LEGS / swap_days), rel_tol=1e-12)
# with 4 legs over a 30-day roll that is ~7.5x cheaper than charging the whole ladder daily.
assert dr < naked_full_ladder_daily / 7.0
def test_old_full_ladder_daily_overcharging_fails_the_turnover_assertion() -> None:
"""REGRESSION GUARD. The OLD behavior charged the FULL ladder every day (k · cost_bps/1e4 — the naked
daily-rehedge model). On a 30-day-roll 4-leg condor that over-charges turnover by swap_days/n_legs = 7.5x;
the corrected daily roll must be strictly, materially smaller — proving the bug is fixed, not re-introduced."""
k, cost_bps, swap_days = 3.0, 50.0, 30
old_full_ladder_daily = k * (cost_bps / 1e4) # the buggy model
corrected = _defined_risk_daily_cost(k=k, cost_bps=cost_bps, swap_days=swap_days)
over_charge_factor = old_full_ladder_daily / corrected
assert math.isclose(over_charge_factor, swap_days / _N_LEGS, rel_tol=1e-9)
assert over_charge_factor > 7.0 # ~30x/4legs ≈ 7.5x over-charge
def test_defined_risk_sized_series_uses_amortized_roll_cost() -> None:
"""The defined-risk sized series subtracts the AMORTIZED daily-roll haircut from each k·raw day — NOT the
naked full-ladder haircut the reused vrp_eval._sized_series applies."""
raw = {1: 0.001, 2: -0.002, 3: 0.0015}
k, cost_bps, swap_days = 2.0, 50.0, 30
dr = _defined_risk_sized_series(raw, k=k, cost_bps=cost_bps, swap_days=swap_days)
haircut = _defined_risk_daily_cost(k=k, cost_bps=cost_bps, swap_days=swap_days)
for d in raw:
assert math.isclose(dr[d], k * raw[d] - haircut, rel_tol=1e-12, abs_tol=1e-15)
# and it is strictly less punitive than the naked full-ladder series on every day (cost-only difference).
naked = vrp_eval._sized_series(raw, k=k, cost_bps=cost_bps)
for d in raw:
assert dr[d] > naked[d]
def test_per_year_cost_drag_is_proportionate_not_strategy_destroying() -> None:
"""SANITY on the realized cost drag. The per-YEAR cost of the amortized roll is
n_legs · cost_bps · (365/swap_days) · 1 (per unit notional, leverage 1). At 50bp on a 4-leg 30-day roll
that is 4·0.005·(365/30) ≈ 24%/yr at leverage 1 — a meaningful but NON-catastrophic drag, NOT the ~7.5x
inflated number the old full-ladder-daily model implied (which would be ~180%/yr)."""
cost_bps, swap_days = 50.0, 30
per_year_drag = _N_LEGS * (cost_bps / 1e4) * 365 / swap_days
assert 0.20 < per_year_drag < 0.30 # ~24%/yr at leverage 1 — bounded
# at the cheap 5.5bp the per-year drag is tiny (~2-3%/yr at leverage 1).
cheap = _N_LEGS * (5.5 / 1e4) * 365 / swap_days
assert cheap < 0.04
def test_cost_sensitivity_is_proportionate_5p5_close_to_gross_50_bounded() -> None:
"""END-TO-END SANITY: the corrected cost makes the strategy COST-PROPORTIONATE, not cost-destroyed. On the
tail-survivable fixture: at 5.5bp the net Sharpe is CLOSE to gross (small drag, NO sign flip); at 50bp the
drag is meaningful but BOUNDED — Sharpe stays in the same ballpark and maxDD nowhere near 100%."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
gross = defined_risk_metrics_from_store(store, cost_bps=0.0, direction="short_vol")
cheap = defined_risk_metrics_from_store(store, cost_bps=5.5, direction="short_vol")
pricey = defined_risk_metrics_from_store(store, cost_bps=50.0, direction="short_vol")
store.close()
assert gross["available"] and cheap["available"] and pricey["available"]
assert gross["sharpe"] > 0.0
# 5.5bp: a SMALL drag, no sign flip, close to gross.
assert cheap["sharpe"] > 0.0
assert cheap["sharpe"] > 0.80 * gross["sharpe"]
# 50bp: meaningful but proportionate — still a sizeable fraction of gross, NOT a catastrophic collapse.
assert pricey["sharpe"] > 0.40 * gross["sharpe"]
# the tail stays survivable (the wings cap it) — nowhere near 100%.
assert pricey["max_dd"] > -0.40
# --- 5. adversarial verify ---------------------------------------------------------------------
def test_verify_cost_monotone_incl_option_costs() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_defined_risk_edge(store, cost_bps=5.5, cost_grid=(5.5, 11.0, 22.0, 50.0, 100.0))
store.close()
sv = rep["net_cost"]["short_vol"]
grid = sorted(sv)
for a, b in zip(grid, grid[1:], strict=False):
assert sv[a]["sharpe"] >= sv[b]["sharpe"]
assert 50.0 in sv and 100.0 in sv
def test_verify_reports_correlation_with_all_four_edges() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_defined_risk_edge(store, cost_bps=5.5)
store.close()
for d in ("short_vol", "long_vol"):
for edge in ("tstrend", "unlock", "xsfunding", "positioning"):
assert edge in rep["corr_edges"][d]
v = rep["corr_edges"][d][edge]
assert v is None or (-1.0 - 1e-9 <= v <= 1.0 + 1e-9)
def test_verify_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(inner)
store = _WriteTripwireStore(inner)
rep = verify_defined_risk_edge(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
assert rep["wing_width"] > 0.0
# --- 6. look-ahead audit -----------------------------------------------------------------------
def test_look_ahead_clean_on_causal_signal() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
ok = look_ahead_audit_defined_risk_ok(store)
store.close()
assert ok is True
def test_look_ahead_audit_catches_leaked_mapping() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
ok = look_ahead_audit_defined_risk_ok(store, _leak=True)
store.close()
assert ok is False
# --- 7. walk-forward / OOS deploy gate ---------------------------------------------------------
def test_walk_forward_runs_and_reports_verdict() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
rep = walk_forward_defined_risk(store, cost_bps=5.5, window_days=180)
store.close()
assert rep["available"] is True
assert "deployable" in rep["verdict"]
assert rep["look_ahead"]["causal"] is True
assert rep["wing_width"] > 0.0
def test_walk_forward_tail_survivable_passes_tail_check() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
rep = walk_forward_defined_risk(store, cost_bps=5.5, window_days=180)
store.close()
# the wings make the tail survivable (the defining property of the defined-risk form).
assert rep["verdict"]["checks"]["tail_survivable"] is True
def test_walk_forward_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(inner)
store = _WriteTripwireStore(inner)
rep = walk_forward_defined_risk(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
# --- 8. CLI ------------------------------------------------------------------------------------
def test_cli_defined_risk_prints_metrics(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-defined-risk-eval"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "defined" in out or "wing" in out
assert "maxdd" in out or "max dd" in out
assert "worst" in out
def test_cli_defined_risk_verify(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(store, days=400)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-defined-risk-eval", "--verify"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "short" in out and "long" in out
assert "100" in out
assert "tstrend" in out and "positioning" in out
def test_cli_defined_risk_walk_forward(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-defined-risk-eval", "--walk-forward", "--cost-bps", "50"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "walk-forward" in out or "walk forward" in out
assert "verdict" in out
assert "pass" in out or "fail" in out

View File

@@ -1,493 +0,0 @@
"""READ-ONLY, TAIL-HONEST evaluator for the NEW VRP (volatility-risk-premium) edge.
Thesis: Deribit DVOL (implied vol) systematically exceeds realized vol, so SELLING variance (short-vol)
earns the spread — but with fat LEFT tails on vol spikes (RV >> IV). The signal/backtest uses DVOL (IV) +
close-to-close RV; live execution would be short Bybit option straddles (a cross-venue caveat).
VRP return (per asset, per day, CAUSAL):
short_var_ret_t = k * [ (DVOL_{t-1}/sqrt(365)/100)^2 - ret_t^2 ]
DVOL_{t-1} is the IV known at the START of day t (no look-ahead); ret_t = close-to-close (t-1 -> t). The book
is BTC+ETH equal-weight, vol-targeted to a sane annual vol (k). direction="short_vol" harvests; "long_vol" is
the negation.
Tests assert:
* the VRP return is POSITIVE on a calm-IV-high fixture and BIG-NEGATIVE on a spike fixture (the tail shows);
* sizing (vol-target) scales the series; direction flips the sign;
* the evaluator is READ-ONLY (a write-tripwire never trips);
* verify is cost-monotone (incl 50/100bp option costs), per-year, reports worst-day + maxDD, and the
correlation vs ALL FOUR existing edges (tstrend/unlock/xsfunding/positioning);
* walk-forward train/test OOS + look-ahead-clean; a tail-survivable fixture PASSES, a tail-dominated one
FAILS;
* the CLI smoke prints metrics + verify + walk-forward (mocked in-memory store, NO network).
"""
from __future__ import annotations
import math
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.vrp_eval import (
look_ahead_audit_vrp,
short_var_return,
variance_swap_payoff,
verify_vrp_edge,
vrp_metrics_from_store,
vrp_returns_from_store,
walk_forward_vrp,
)
_DAY = 86_400
_START = 19_358 # 2023-01-01 epoch day
class _WriteTripwireStore:
"""Read-only proxy: forwards reads, but ANY write/persist call trips an assertion."""
_FORBIDDEN = frozenset({
"write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
"replace_positions", "upsert_nav", "upsert_sleeve_ret", "replace_shadow_positions",
"replace_trades", "_create_schema", "_bulk_upsert",
})
def __init__(self, inner: TimescaleFeatureStore) -> None:
object.__setattr__(self, "_inner", inner)
def __getattr__(self, name: str):
if name in _WriteTripwireStore._FORBIDDEN:
raise AssertionError(f"READ-ONLY violation: evaluator called write method {name!r}")
return getattr(self._inner, name)
# --- 1. short_var_return (the formula) ---------------------------------------------------------
def test_short_var_positive_when_calm_iv_high() -> None:
# IV (DVOL) = 80 vol pts; realized daily move tiny -> RV << IV -> short-vol HARVESTS (positive).
r = short_var_return(dvol_prev=80.0, ret=0.001)
assert r > 0.0
def test_short_var_big_negative_on_spike() -> None:
# IV = 40 vol pts (calm implied), but realized move is a -20% spike -> RV >> IV -> big NEGATIVE (the tail).
calm = short_var_return(dvol_prev=40.0, ret=0.005)
spike = short_var_return(dvol_prev=40.0, ret=-0.20)
assert spike < 0.0
assert spike < calm
# the spike loss dwarfs a calm-day gain in magnitude (fat left tail).
assert abs(spike) > 5.0 * abs(calm)
def test_short_var_implied_daily_variance_is_iv_squared() -> None:
# On a zero-realized-move day the harvest equals the implied daily variance (DVOL/sqrt(365)/100)^2.
iv_daily = 60.0 / math.sqrt(365) / 100.0
r = short_var_return(dvol_prev=60.0, ret=0.0)
assert math.isclose(r, iv_daily ** 2, rel_tol=1e-12)
def test_long_vol_is_negation_of_short_vol() -> None:
assert math.isclose(short_var_return(dvol_prev=50.0, ret=0.03, direction="long_vol"),
-short_var_return(dvol_prev=50.0, ret=0.03, direction="short_vol"),
rel_tol=1e-12)
# --- 1b. variance_swap_payoff (the PROPER variance swap: implied strike vs realized-WINDOW variance) ----
def test_variance_swap_payoff_positive_when_calm_window() -> None:
# IV (DVOL) = 60 vol pts (strike K=0.36 annual var); realized window is tiny ±0.2% daily moves -> RV << K
# -> short-var swap HARVESTS the premium (positive).
fwd = [0.002 if i % 2 == 0 else -0.002 for i in range(30)]
assert variance_swap_payoff(dvol_entry=60.0, fwd_rets=fwd, swap_days=30) > 0.0
def test_variance_swap_payoff_negative_on_spike_window() -> None:
# IV = 40 vol pts (K=0.16); the realized window contains a -25% crash -> RV >> K -> NEGATIVE (the tail).
fwd = [0.001] * 29 + [-0.25]
assert variance_swap_payoff(dvol_entry=40.0, fwd_rets=fwd, swap_days=30) < 0.0
def test_variance_swap_payoff_annualization_zero_when_rv_equals_iv() -> None:
# Constant-vol synthetic: pick a daily move whose annualised realized variance EXACTLY equals the strike
# K=(DVOL/100)^2 -> the short-var swap payoff is ~0 (the annualisation 365/swap_days is correct).
dvol = 50.0
k = (dvol / 100.0) ** 2 # annual implied variance = 0.25
daily_var = k / 365.0 # per-day variance so (365/n)*n*daily_var == k
move = math.sqrt(daily_var)
fwd = [move if i % 2 == 0 else -move for i in range(30)]
payoff = variance_swap_payoff(dvol_entry=dvol, fwd_rets=fwd, swap_days=30)
assert abs(payoff) < 1e-12
def test_variance_swap_payoff_long_is_negation() -> None:
fwd = [0.01, -0.02, 0.015] * 10
assert math.isclose(variance_swap_payoff(dvol_entry=55.0, fwd_rets=fwd, direction="long_vol"),
-variance_swap_payoff(dvol_entry=55.0, fwd_rets=fwd, direction="short_vol"),
rel_tol=1e-12)
def test_variance_swap_payoff_empty_window_is_zero() -> None:
assert variance_swap_payoff(dvol_entry=60.0, fwd_rets=[]) == 0.0
# --- 2. vrp_returns_from_store / sizing --------------------------------------------------------
def _seed_calm_premium(store: TimescaleFeatureStore, *, days: int = 260, dvol: float = 60.0,
daily_move: float = 0.004) -> None:
"""Seed BTC+ETH so IV (dvol=60) comfortably exceeds RV (a tiny ±daily_move oscillation): short-vol earns
a steady premium. close oscillates so RV is small + bounded; dvol flat-high. Spans ~9 months."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
sign = 1.0 if i % 2 == 0 else -1.0
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol})])
px[s] *= (1.0 + sign * daily_move)
def test_vrp_returns_positive_on_calm_premium() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
series = vrp_returns_from_store(store, cost_bps=0.0, direction="short_vol")
store.close()
assert len(series) > 2
assert sum(series.values()) > 0.0 # the premium is harvested gross
def test_vrp_sizing_targets_annual_vol() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
s15 = vrp_returns_from_store(store, cost_bps=0.0, target_ann_vol=0.15)
s30 = vrp_returns_from_store(store, cost_bps=0.0, target_ann_vol=0.30)
store.close()
import statistics as st
v15 = st.pstdev(s15.values()) * math.sqrt(365)
v30 = st.pstdev(s30.values()) * math.sqrt(365)
# vol-target hits (roughly) the requested annual vol, and doubling the target ~doubles realized vol.
assert abs(v15 - 0.15) < 0.03
assert v30 > 1.8 * v15
def test_vrp_constant_premium_series_is_low_noise_and_sane() -> None:
"""THE KEY FIX. A constant-premium synthetic (IV steadily > RV every day) must give a SMOOTH, LOW-noise,
SANE-Sharpe daily series — NOT the 40 Sharpe / blow-up the old single-day-ret^2 proxy produced. The
rolling variance-swap ladder averages RV over a 30-day window, so the day-to-day series is far less noisy
than a raw ret^2. We assert the GROSS (pre-vol-target) ladder series has a high, positive Sharpe and tiny
relative dispersion — i.e. it is well-behaved enough that vol-targeting it can't lose ~everything."""
import statistics as st
from fxhnt.application.vrp_eval import _book_short_var
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store, days=400, dvol=60.0, daily_move=0.004)
raw = _book_short_var(store, direction="short_vol")
store.close()
vals = [raw[d] for d in sorted(raw)]
assert len(vals) > 30
mean, sd = st.mean(vals), st.pstdev(vals)
assert mean > 0.0 # a steady harvested premium
gross_sharpe = mean / sd * math.sqrt(365)
# the ladder series is sane: a strongly POSITIVE Sharpe (the old single-day proxy gave -40 / blew up).
assert gross_sharpe > 3.0
# and genuinely low-noise: the daily dispersion is small relative to the mean (a smooth window-averaged
# quantity, not a spiky raw ret^2).
assert sd < mean # coefficient of variation < 1 (the smoothing fix)
def test_vrp_vol_targeted_constant_premium_does_not_blow_up_drawdown() -> None:
"""The corrected + vol-targeted constant-premium curve must be SANE: no catastrophic drawdown. The old
proxy's single-day spikes blew through the vol-target to 96%/180d; the window-averaged ladder must not."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store, days=400, dvol=60.0, daily_move=0.004)
m = vrp_metrics_from_store(store, cost_bps=5.5, target_ann_vol=0.15)
store.close()
assert m["available"] is True
assert m["sharpe"] > 1.0 # a real, sane edge on the calm fixture
assert m["max_dd"] > -0.40 # NOT a 96% blow-up
def test_vrp_direction_flips_sign() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
short = vrp_returns_from_store(store, cost_bps=0.0, direction="short_vol")
long_ = vrp_returns_from_store(store, cost_bps=0.0, direction="long_vol")
store.close()
assert sum(short.values()) > 0.0 > sum(long_.values())
def test_vrp_metrics_na_when_no_dvol() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
store.write_features("BTCUSDT", [(0, {"close": 30_000.0})]) # close but no dvol
m = vrp_metrics_from_store(store)
store.close()
assert m["available"] is False
assert "reason" in m and m["reason"]
def test_vrp_metrics_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(inner)
store = _WriteTripwireStore(inner)
m = vrp_metrics_from_store(store, cost_bps=5.5)
inner.close()
assert m["available"] is True
def test_vrp_metrics_reports_tail() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
m = vrp_metrics_from_store(store, cost_bps=0.0)
store.close()
assert m["available"] is True
assert m["max_dd"] <= 0.0 # maxDD is a (non-positive) drawdown
assert "worst_day" in m # the single worst vol-spike day loss
assert "avg_leverage" in m # exposure from the vol-target
# --- 3. tail handling: a spike fixture must show the loss ---------------------------------------
def _seed_with_spike(store: TimescaleFeatureStore, *, days: int = 260, spike_at: int = 130) -> None:
"""Calm short-vol premium most days, but ONE catastrophic vol-spike day (a -35% move with calm prior IV)
that drives RV >> IV -> a huge negative VRP return on that day (the tail the backtest must capture)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": 55.0})])
if i == spike_at:
px[s] *= (1.0 - 0.35) # crash: realized vol explodes past implied
else:
px[s] *= (1.0 + (0.003 if i % 2 == 0 else -0.003))
def test_spike_day_is_the_worst_day_and_dominates_maxdd() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(store)
m = vrp_metrics_from_store(store, cost_bps=0.0)
series = vrp_returns_from_store(store, cost_bps=0.0)
store.close()
worst = min(series.values())
assert worst < 0.0
assert math.isclose(m["worst_day"], worst, rel_tol=1e-9)
# the spike produces a material drawdown — the tail is captured, not smoothed away.
assert m["max_dd"] < -0.05
# --- 4. adversarial verify ---------------------------------------------------------------------
def test_verify_cost_monotone_incl_option_costs() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_vrp_edge(store, cost_bps=5.5, cost_grid=(5.5, 11.0, 22.0, 50.0, 100.0))
store.close()
sv = rep["net_cost"]["short_vol"]
grid = sorted(sv)
for a, b in zip(grid, grid[1:], strict=False):
assert sv[a]["sharpe"] >= sv[b]["sharpe"] # higher cost -> lower Sharpe
assert 50.0 in sv and 100.0 in sv # realistic option-cost sensitivity shown
def test_verify_reports_worst_day_and_per_year() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(store, days=400) # spans 2023 and 2024
rep = verify_vrp_edge(store, cost_bps=5.5)
store.close()
assert rep["available"] is True
assert rep["worst_day"]["short_vol"] < 0.0
py = rep["per_year"]["short_vol"]
assert len(py) >= 2
def test_verify_reports_correlation_with_all_four_edges() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_vrp_edge(store, cost_bps=5.5)
store.close()
for d in ("short_vol", "long_vol"):
corrs = rep["corr_edges"][d]
for edge in ("tstrend", "unlock", "xsfunding", "positioning"):
assert edge in corrs
v = corrs[edge]
assert v is None or (-1.0 - 1e-9 <= v <= 1.0 + 1e-9)
def test_verify_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(inner)
store = _WriteTripwireStore(inner)
rep = verify_vrp_edge(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
# --- 5. look-ahead audit -----------------------------------------------------------------------
def _seed_varying_dvol(store: TimescaleFeatureStore, *, days: int = 120) -> None:
"""A premium fixture with NON-PERIODIC TIME-VARYING DVOL so the causal (strike=DVOL at swap entry) and
leaked (strike=DVOL at the window END) mappings are genuinely distinguishable on EVERY day — the
look-ahead audit can only bite when the entry-day and window-end IV differ. A slow irrational-step ramp
guarantees no two days share a DVOL value (no coincidental entry/window-end collision)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 50.0 + 0.137 * i # strictly monotone, distinct every day (no collisions)
sign = 1.0 if i % 2 == 0 else -1.0
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol})])
px[s] *= (1.0 + sign * 0.004)
def test_look_ahead_audit_passes_on_causal_signal() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
rep = look_ahead_audit_vrp(store)
store.close()
assert rep["causal"] is True
assert rep["leak_days"] == 0
def test_look_ahead_audit_catches_a_deliberately_leaked_mapping() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
rep = look_ahead_audit_vrp(store, _leak=True) # pair the SAME-day DVOL with the day's return
store.close()
assert rep["causal"] is False
assert rep["leak_days"] > 0
# --- 6. walk-forward / OOS deploy gate ---------------------------------------------------------
def _seed_tail_survivable(store: TimescaleFeatureStore, *, days: int = 700) -> None:
"""A short-vol premium that SURVIVES its occasional tails: calm harvest most days (RV well below the
varying IV), a moderate spike every ~90 days whose loss is recouped within the next window. Positive in
most windows + OOS, drawdown bounded above the 40% floor. DVOL varies day to day (so the causality audit
can distinguish the prior-day from the same-day IV)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 60.0 + 8.0 * math.sin(i * 0.11) # non-periodic-on-30d drift in 52..68 (no collisions)
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol, "turnover": 5e8})])
if i % 90 == 45:
px[s] *= (1.0 - 0.06) # moderate, recoverable spike
else:
px[s] *= (1.0 + (0.004 if i % 2 == 0 else -0.004))
def _seed_tail_dominated(store: TimescaleFeatureStore, *, days: int = 700) -> None:
"""A short-vol book the gate must FAIL: thin implied premium (low DVOL), but FREQUENT, LARGE spikes whose
realized variance dwarfs the premium. After vol-targeting the rare-but-huge spike days produce a
CATASTROPHIC drawdown (worse than the 40% floor) — VRP looks fine on the calm days and then a cluster of
spikes wipes it out. DVOL varies day to day (so the causality audit can distinguish prior vs same day)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 25.0 + 4.0 * math.sin(i * 0.11) # thin implied vol ~21..29, non-periodic-on-30d
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol, "turnover": 5e8})])
# spikes cluster late (after the train window) so the short-vol harvest that "worked" on TRAIN
# is wiped in TEST -> OOS negative AND catastrophic drawdown.
late_cluster = i > int(days * 0.6) and (i % 9 == 0)
if late_cluster:
px[s] *= (1.0 - 0.22) # frequent, large, late, unrecouped spikes
else:
px[s] *= (1.0 + (0.0008 if i % 2 == 0 else -0.0008))
def test_walk_forward_pass_on_tail_survivable() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
rep = walk_forward_vrp(store, cost_bps=5.5, window_days=180)
store.close()
v = rep["verdict"]
assert v["deployable"] is True
assert v["checks"]["oos_direction_positive"] is True
assert v["checks"]["tail_survivable"] is True
assert v["checks"]["look_ahead_clean"] is True
def test_walk_forward_fail_on_tail_dominated() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_dominated(store)
rep = walk_forward_vrp(store, cost_bps=5.5, window_days=180)
store.close()
v = rep["verdict"]
assert v["deployable"] is False
# it fails because the tail is NOT survivable (and/or OOS is negative).
assert v["checks"]["tail_survivable"] is False or v["checks"]["oos_direction_positive"] is False
def test_walk_forward_at_realistic_option_costs_judges_net_of_tail() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_dominated(store)
# at a realistic OPTION cost (selling options isn't 5.5bp) the dominated book is plainly undeployable.
rep = walk_forward_vrp(store, cost_bps=50.0, window_days=180)
store.close()
assert rep["verdict"]["deployable"] is False
def test_walk_forward_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(inner)
store = _WriteTripwireStore(inner)
rep = walk_forward_vrp(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
# --- 7. CLI ------------------------------------------------------------------------------------
def test_cli_vrp_eval_prints_metrics(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-eval"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "vrp" in out or "volatility risk premium" in out
assert "maxdd" in out or "max dd" in out
assert "worst" in out
def test_cli_vrp_eval_verify_prints_diagnostics(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(store, days=400)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-eval", "--verify"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "short" in out and "long" in out
assert "worst" in out
assert "100" in out # the 100bp option-cost column
assert "tstrend" in out and "positioning" in out
def test_cli_vrp_eval_walk_forward_prints_verdict(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-eval", "--walk-forward"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "walk-forward" in out or "walk forward" in out
assert "verdict" in out
assert "pass" in out or "fail" in out

View File

@@ -1,291 +0,0 @@
import datetime as dt
from fxhnt.adapters.orchestration.assets import (
_freeze_day,
_freeze_latest_session,
_freeze_xsp_slice,
_is_degenerate_forward_error,
)
from fxhnt.adapters.persistence.xsp_option_bars import XspOptionBarsRepo
def test_vrp_nav_survives_freeze_failure(tmp_path, monkeypatch):
"""C1b: a freeze failure (bad-data day / still-unavailable OPRA window / cost-cap trip) must NOT kill
vrp_nav — it logs a WARNING and CONTINUES to `_run_paper_tracker`, which recomputes off the FROZEN
xsp_option_bars table. Guarantees vrp always produces a forward row/anchor/ref off whatever IS frozen and
never skips the downstream gate (`cockpit_forward`)."""
from dagster import build_op_context
import fxhnt.adapters.orchestration.assets as assets
from fxhnt.adapters.data.databento import DatabentoRangeUnavailable
from fxhnt.config import Settings
monkeypatch.setattr("fxhnt.config.get_settings",
lambda: Settings(operational_dsn=f"sqlite:///{tmp_path / 'op.db'}"))
def _boom(*a, **k): # the freeze entry point exhausts its bounded step-back → catchable range error
raise DatabentoRangeUnavailable("no complete OPRA session within the step-back bound")
monkeypatch.setattr(assets, "_freeze_latest_session", _boom)
ran = {"tracker": False}
def _fake_tracker(context, name, build_strategy, *, persist_backtest_ref=False):
ran["tracker"] = True
return {"forward_days": 0, "last_date": None, "reinception": False}
monkeypatch.setattr(assets, "_run_paper_tracker", _fake_tracker)
out = assets.vrp_nav(build_op_context())
assert isinstance(out, dict) # returned a dict, did NOT raise out of the asset
assert ran["tracker"] # freeze died, but the tracker STILL recomputed off the frozen table
class _FakeDbn:
def __init__(self):
self.chain_calls = 0
def fetch_option_chain(self, parent, as_of):
from fxhnt.domain.models import OptionContract
self.chain_calls += 1
puts = [OptionContract(f"XSP240816P00{k}000", dt.date(2024, 8, 16), float(k), "P")
for k in range(440, 475, 5)]
calls = [OptionContract(f"XSP240816C00{k}000", dt.date(2024, 8, 16), float(k), "C")
for k in range(460, 481, 5)]
return puts + calls
def fetch_option_bars(self, osi, start, end):
return {s: {start: 1.0} for s in osi}
def test_freeze_xsp_slice_writes_puts_and_atm_calls(tmp_path):
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'o.db'}")
bars.migrate()
_freeze_xsp_slice(_FakeDbn(), bars, as_of="2024-07-20", forward=470.0, at=dt.datetime(2024, 7, 20))
puts = bars.chain_asof("2024-07-20", 20, 45, right="P")
calls = bars.chain_asof("2024-07-20", 20, 45, right="C")
# puts moneyness [0.70,1.20]*470 = [329,564] (wide band so held legs stay frozen) -> all seeded puts qualify
assert {c.strike for c in puts} == {float(k) for k in range(440, 475, 5)}
# calls moneyness [0.96,1.04]*470 = [451.2,488.8] -> 460..480 qualify (so parity has a common strike)
assert {c.strike for c in calls} == {460.0, 465.0, 470.0, 475.0, 480.0}
class _RangeFakeDbn:
"""Fake for the BATCHED backfill: one range-definition + one range-ohlcv call per month, marks across days."""
def __init__(self):
from fxhnt.domain.models import OptionContract
exp = dt.date(2024, 8, 5)
self.puts = [OptionContract(f"XSP240805P00{k}000", exp, float(k), "P") for k in range(455, 481, 5)]
self.calls = [OptionContract(f"XSP240805C00{k}000", exp, float(k), "C") for k in range(460, 481, 5)]
self.days = ["2024-07-01", "2024-07-02", "2024-07-03"]
self.chain_range_calls = 0
self.bar_calls = 0
def fetch_option_chain_range(self, parent, start, end):
self.chain_range_calls += 1
return self.puts + self.calls
def fetch_option_bars(self, osis, start, end):
self.bar_calls += 1
return {osi: {d: 1.0 for d in self.days} for osi in osis}
def test_backfill_month_batched_processes_days_locally(tmp_path):
"""_backfill_month must do ONE range-chain + ONE range-ohlcv fetch for the whole month, then process each
trading day LOCALLY (parity forward + band + upsert) — the batched win over ~6-8 API calls/day."""
from fxhnt.adapters.orchestration.assets import _backfill_month
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'b.db'}")
bars.migrate()
fake = _RangeFakeDbn()
ok, fail = _backfill_month(fake, bars, month_start="2024-07-01", month_end="2024-07-31",
at=dt.datetime(2024, 7, 1))
assert fake.chain_range_calls == 1 and fake.bar_calls == 1 # ONE bulk fetch each, not per-day
assert ok == 3 and fail == 0 # 3 trading days processed locally
puts = bars.chain_asof("2024-07-01", 20, 45, right="P")
assert puts # band frozen from the shared bulk data
got = bars.bars_for([puts[0].osi_symbol], "2024-07-01", "2024-07-03")
assert len(next(iter(got.values()))) == 3 # marks for all 3 days landed
def test_freeze_day_fetches_chain_once(tmp_path):
"""The nightly asset + backfill call _freeze_day, which must fetch the option chain (definition) exactly
ONCE per day and share it between the parity-forward estimate and the band freeze — the old code fetched
it twice (in _xsp_forward_estimate AND _freeze_xsp_slice), doubling OPRA definition cost."""
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'o.db'}")
bars.migrate()
fake = _FakeDbn()
fwd = _freeze_day(fake, bars, as_of="2024-07-20", at=dt.datetime(2024, 7, 20))
assert fake.chain_calls == 1 # ONE chain fetch/day, not two
assert 440.0 <= fwd <= 480.0 # a sane parity forward derived from the shared chain
assert bars.chain_asof("2024-07-20", 20, 45, right="P") # the band was frozen off that single chain
class _StepBackDbn:
"""Fake provider for the freeze step-back: `last_available_option_date()` returns a partial (still-settling)
today; `fetch_option_chain` raises DatabentoRangeUnavailable for any as_of NOT in `good_dates` (models a
partial session's `data_schema_not_fully_available`, already normalized by the provider). Records the
candidate order attempted so tests can assert weekend-skipping and step order."""
def __init__(self, available_date: dt.date, good_dates: set[str]) -> None:
self._available = available_date
self._good = good_dates
self.attempts: list[str] = []
def last_available_option_date(self) -> dt.date:
return self._available
def fetch_option_chain(self, parent: str, as_of: str):
from fxhnt.adapters.data.databento import DatabentoRangeUnavailable
from fxhnt.domain.models import OptionContract
self.attempts.append(as_of)
if as_of not in self._good:
raise DatabentoRangeUnavailable(f"session {as_of} not fully available")
d = dt.date.fromisoformat(as_of)
exp = d + dt.timedelta(days=30) # ~30-DTE expiry relative to the candidate
puts = [OptionContract(f"XSP{as_of}P{k}", exp, float(k), "P") for k in range(440, 475, 5)]
calls = [OptionContract(f"XSP{as_of}C{k}", exp, float(k), "C") for k in range(460, 481, 5)]
return puts + calls
def fetch_option_bars(self, osis, start, end):
return {s: {start: 1.0} for s in osis}
def test_freeze_latest_session_steps_back_past_partial_today(tmp_path):
"""Robust freeze: today's OPRA session is partial (raises), so the entry point steps back ONE trading day
to yesterday's COMPLETE session, freezes it, and RETURNS that date. Deterministic regardless of run time."""
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'o.db'}")
bars.migrate()
# Wed 2026-07-15 (today, partial) → Tue 2026-07-14 (complete)
fake = _StepBackDbn(dt.date(2026, 7, 15), good_dates={"2026-07-14"})
frozen = _freeze_latest_session(fake, bars, at=dt.datetime(2026, 7, 15))
assert frozen == "2026-07-14" # landed on the last COMPLETE session
assert fake.attempts == ["2026-07-15", "2026-07-14"] # tried today first, then stepped back one day
assert bars.chain_asof("2026-07-14", 20, 45, right="P") # the band was frozen for that session
def test_freeze_latest_session_skips_weekend(tmp_path):
"""The step-back is by TRADING day: a Monday candidate steps to the prior FRIDAY, never Sat/Sun."""
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'w.db'}")
bars.migrate()
# Mon 2026-07-13 (partial) → skip Sun 12 / Sat 11 → Fri 2026-07-10 (complete)
fake = _StepBackDbn(dt.date(2026, 7, 13), good_dates={"2026-07-10"})
frozen = _freeze_latest_session(fake, bars, at=dt.datetime(2026, 7, 13))
assert frozen == "2026-07-10"
assert fake.attempts == ["2026-07-13", "2026-07-10"] # Sat 11 / Sun 12 skipped entirely
def test_freeze_latest_session_raises_when_no_complete_session_in_bound(tmp_path):
"""No complete session within the bounded step-back → raise DatabentoRangeUnavailable (LOUD). The C1b
wrapper in vrp_nav then recomputes off frozen and the health axis keeps vrp STALE — never a silent no-op."""
from fxhnt.adapters.data.databento import DatabentoRangeUnavailable
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'n.db'}")
bars.migrate()
fake = _StepBackDbn(dt.date(2026, 7, 15), good_dates=set()) # every candidate fails
try:
_freeze_latest_session(fake, bars, at=dt.datetime(2026, 7, 15), max_steps=5)
raise AssertionError("expected DatabentoRangeUnavailable when the bound is exhausted")
except DatabentoRangeUnavailable:
pass
assert len(fake.attempts) == 6 # start candidate + 5 bounded step-backs
# --- MINOR-2: step back on a PUBLISHED-but-DEGENERATE session (not only an unavailable one) ---------------
class _DegenerateDbn:
"""A published session whose chain cannot form a parity forward — `fetch_option_chain` RETURNS contracts
(no DatabentoRangeUnavailable), but for a DEGENERATE date the only expiry is far outside the [20,45]-DTE
forward window, so `_xsp_forward_estimate` raises `RuntimeError('no XSP chain … cannot estimate forward')`.
A GOOD date returns a proper ~30-DTE chain with a matched ATM call/put pair. Models a broken published
session that must be stepped over, not crashed on."""
def __init__(self, available_date: dt.date, good_dates: set[str]) -> None:
self._available = available_date
self._good = good_dates
self.attempts: list[str] = []
def last_available_option_date(self) -> dt.date:
return self._available
def fetch_option_chain(self, _parent: str, as_of: str):
from fxhnt.domain.models import OptionContract
self.attempts.append(as_of)
d = dt.date.fromisoformat(as_of)
if as_of in self._good:
exp = d + dt.timedelta(days=30) # in [20,45] -> forward derivable
puts = [OptionContract(f"XSP{as_of}P{k}", exp, float(k), "P") for k in range(440, 475, 5)]
calls = [OptionContract(f"XSP{as_of}C{k}", exp, float(k), "C") for k in range(460, 481, 5)]
return puts + calls
exp = d + dt.timedelta(days=100) # OUTSIDE [20,45] -> `near` empty -> RuntimeError
return [OptionContract(f"XSP{as_of}P{k}", exp, float(k), "P") for k in range(440, 475, 5)]
def fetch_option_bars(self, osis, start, _end):
return {s: {start: 1.0} for s in osis}
def test_freeze_latest_session_steps_back_past_degenerate_published_session(tmp_path):
"""A PUBLISHED but degenerate today (forward not derivable -> RuntimeError, NOT DatabentoRangeUnavailable)
must step back to the prior USABLE session, not hard-crash the caller."""
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'deg.db'}")
bars.migrate()
fake = _DegenerateDbn(dt.date(2026, 7, 15), good_dates={"2026-07-14"}) # Wed degenerate -> Tue usable
frozen = _freeze_latest_session(fake, bars, at=dt.datetime(2026, 7, 15))
assert frozen == "2026-07-14"
assert fake.attempts == ["2026-07-15", "2026-07-14"] # tried the broken published session, then stepped back
assert bars.chain_asof("2026-07-14", 20, 45, right="P") # the usable session was frozen
def test_freeze_latest_session_raises_when_all_degenerate_in_bound(tmp_path):
"""Every candidate within the bound is a degenerate published session -> raise DatabentoRangeUnavailable
(LOUD), never silently return a bar-less date."""
from fxhnt.adapters.data.databento import DatabentoRangeUnavailable
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'alldeg.db'}")
bars.migrate()
fake = _DegenerateDbn(dt.date(2026, 7, 15), good_dates=set()) # every session degenerate
try:
_freeze_latest_session(fake, bars, at=dt.datetime(2026, 7, 15), max_steps=5)
raise AssertionError("expected DatabentoRangeUnavailable when every candidate is degenerate")
except DatabentoRangeUnavailable:
pass
assert len(fake.attempts) == 6 # start candidate + 5 bounded step-backs
class _UnrelatedErrorDbn:
"""A session whose chain is fine but whose bar fetch fails with an UNRELATED RuntimeError (e.g. a DB
outage). `_freeze_latest_session` must NOT swallow this as a degenerate-session step-back — it must
propagate, so a real infra failure is never masked as 'no session available'."""
def __init__(self) -> None:
self.attempts: list[str] = []
def last_available_option_date(self) -> dt.date:
return dt.date(2026, 7, 15)
def fetch_option_chain(self, _parent: str, as_of: str):
from fxhnt.domain.models import OptionContract
self.attempts.append(as_of)
exp = dt.date.fromisoformat(as_of) + dt.timedelta(days=30)
puts = [OptionContract(f"XSP{as_of}P{k}", exp, float(k), "P") for k in range(440, 475, 5)]
calls = [OptionContract(f"XSP{as_of}C{k}", exp, float(k), "C") for k in range(460, 481, 5)]
return puts + calls
def fetch_option_bars(self, _osis, _start, _end):
raise RuntimeError("db connection lost") # unrelated to forward estimation
def test_freeze_latest_session_propagates_unrelated_runtime_error(tmp_path):
bars = XspOptionBarsRepo(f"sqlite:///{tmp_path/'unrel.db'}")
bars.migrate()
fake = _UnrelatedErrorDbn()
try:
_freeze_latest_session(fake, bars, at=dt.datetime(2026, 7, 15))
raise AssertionError("expected the unrelated RuntimeError to propagate")
except RuntimeError as e:
assert "db connection lost" in str(e) # propagated, not swallowed as a step-back
assert fake.attempts == ["2026-07-15"] # did NOT step back on an unrelated error
def test_is_degenerate_forward_error_matches_forward_messages_only():
# the three forward-estimation messages `_xsp_forward_estimate`/`_forward_from_marks` raise -> step-back.
assert _is_degenerate_forward_error(RuntimeError("no XSP chain for 2026-07-15 — cannot estimate forward"))
assert _is_degenerate_forward_error(RuntimeError("no matched call/put strike for 2026-07-15 forward"))
assert _is_degenerate_forward_error(RuntimeError("no ATM call/put pair with marks for 2026-07-15"))
# anything else (e.g. a DB/infra error) is NOT a step-back trigger.
assert not _is_degenerate_forward_error(RuntimeError("db connection lost"))
assert not _is_degenerate_forward_error(ValueError("no XSP chain")) # wrong type is irrelevant here; msg-only

View File

@@ -1,80 +0,0 @@
"""Black-Scholes pricer (r=0, crypto convention) — known-value + invariant tests.
The pricer is used to synthesize the legs of the defined-risk VRP spread from DVOL (we have no historical
option chains). These tests pin the standard BS identities so the spread pricing is trustworthy:
* the erf-based normal CDF is correct (matches scipy / known values);
* an ATM call ≈ 0.4·S·σ·√T (the textbook small-T approximation);
* put-call parity at r=0: call put = S K;
* prices are monotone increasing in IV (vega > 0) and bounded by intrinsic from below;
* degenerate (T=0 / σ=0) legs collapse to intrinsic value.
"""
from __future__ import annotations
import math
from fxhnt.application.black_scholes import _norm_cdf, bs_call, bs_put
def test_norm_cdf_known_values() -> None:
assert math.isclose(_norm_cdf(0.0), 0.5, abs_tol=1e-12)
# N(1.96) ≈ 0.975, N(-1.96) ≈ 0.025 (the textbook two-sided 95% points).
assert math.isclose(_norm_cdf(1.96), 0.9750021, abs_tol=1e-6)
assert math.isclose(_norm_cdf(-1.96), 0.0249979, abs_tol=1e-6)
# symmetry: N(x) + N(-x) = 1.
assert math.isclose(_norm_cdf(0.73) + _norm_cdf(-0.73), 1.0, abs_tol=1e-12)
def test_norm_cdf_matches_scipy_if_available() -> None:
try:
from scipy.stats import norm
except ImportError:
return
for x in (-2.5, -1.0, -0.1, 0.0, 0.3, 1.5, 3.0):
assert math.isclose(_norm_cdf(x), float(norm.cdf(x)), abs_tol=1e-12)
def test_atm_call_approx_zero_point_four_s_sigma_sqrt_t() -> None:
# The standard small-T ATM approximation: C_atm ≈ 0.3989·S·σ·√T ≈ 0.4·S·σ·√T.
s, sigma, t = 30_000.0, 0.60, 30.0 / 365.0
approx = 0.3989 * s * sigma * math.sqrt(t)
price = bs_call(s=s, k=s, t=t, sigma=sigma)
assert math.isclose(price, approx, rel_tol=0.02) # within 2% of the textbook approximation
def test_put_call_parity_r_zero() -> None:
# At r=0 (and no dividends): call put = S K, for any K.
s, t, sigma = 2_000.0, 45.0 / 365.0, 0.75
for k in (1_800.0, 2_000.0, 2_300.0):
c = bs_call(s=s, k=k, t=t, sigma=sigma)
p = bs_put(s=s, k=k, t=t, sigma=sigma)
assert math.isclose(c - p, s - k, abs_tol=1e-6)
def test_prices_monotone_increasing_in_iv() -> None:
s, k, t = 30_000.0, 30_000.0, 30.0 / 365.0
lo = bs_call(s=s, k=k, t=t, sigma=0.30)
hi = bs_call(s=s, k=k, t=t, sigma=0.90)
assert hi > lo > 0.0
plo = bs_put(s=s, k=k, t=t, sigma=0.30)
phi = bs_put(s=s, k=k, t=t, sigma=0.90)
assert phi > plo > 0.0
def test_price_bounded_below_by_intrinsic() -> None:
s, t, sigma = 30_000.0, 30.0 / 365.0, 0.60
# an ITM call (K below S) is worth at least its intrinsic value S K.
c = bs_call(s=s, k=27_000.0, t=t, sigma=sigma)
assert c >= (s - 27_000.0)
# an ITM put (K above S) is worth at least K S.
p = bs_put(s=s, k=33_000.0, t=t, sigma=sigma)
assert p >= (33_000.0 - s)
def test_degenerate_collapses_to_intrinsic() -> None:
# T=0: call = max(SK,0), put = max(KS,0).
assert bs_call(s=100.0, k=90.0, t=0.0, sigma=0.5) == 10.0
assert bs_call(s=100.0, k=110.0, t=0.0, sigma=0.5) == 0.0
assert bs_put(s=100.0, k=110.0, t=0.0, sigma=0.5) == 10.0
# σ=0: same intrinsic limit (a zero-vol option is just its forward intrinsic).
assert bs_call(s=100.0, k=90.0, t=0.5, sigma=0.0) == 10.0
assert bs_put(s=100.0, k=110.0, t=0.5, sigma=0.0) == 10.0

View File

@@ -1,20 +0,0 @@
from fxhnt.registry import STRATEGY_REGISTRY
def test_vrp_registry_entry_shape():
e = STRATEGY_REGISTRY["vrp"]
assert e["venue"] == "ibkr-xsp"
assert e["sleeve"] == "equity-vol"
assert e["tier"] == "research"
assert e["execution"] == "paper"
assert e["gate_spec"] == {"gate_type": "reconciliation", "min_forward_days": 21}
assert e["definition"]["record_mode"] == "recompute"
assert e["promote_on_pass"] is False
def test_vrp_exec_twin_is_observed():
from fxhnt.registry import STRATEGY_REGISTRY
e = STRATEGY_REGISTRY["vrp_exec"]
assert e["venue"] == "ibkr-xsp"
assert e["definition"]["record_mode"] == "observed"
assert e["gate_spec"]["gate_type"] == "reconciliation"

View File

@@ -1,320 +0,0 @@
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

View File

@@ -1,80 +0,0 @@
import pytest
from fxhnt.application.vrp_exec_record import (
build_pos_state,
compute_ladder_exec_return,
compute_spread_exec_return,
)
def test_compute_spread_exec_return_envelope_zero_is_none():
assert compute_spread_exec_return(prior_val=1.0, mark_today=0.8, qty=2, envelope=0.0) is None
def test_compute_spread_exec_return_no_mark_today_is_hold_not_phantom_zero():
assert compute_spread_exec_return(prior_val=1.0, mark_today=None, qty=2, envelope=200_000.0) is None
def test_compute_spread_exec_return_winning_spread_is_positive_credit_decay():
# short-vol: credit decayed from 1.00 -> 0.60 = profit. qty=2, $100/contract multiplier.
r = compute_spread_exec_return(prior_val=1.00, mark_today=0.60, qty=2, envelope=200_000.0)
assert r == pytest.approx((1.00 - 0.60) * 100.0 * 2 / 200_000.0)
assert r > 0.0
def test_compute_spread_exec_return_losing_spread_is_negative_and_bounded_by_width():
# a defined-risk put-credit-spread's worst mark is bounded by the wing width ($5 here = $500/contract);
# the value rising toward that cap is a loss, correctly signed negative.
r = compute_spread_exec_return(prior_val=1.00, mark_today=4.50, qty=2, envelope=200_000.0)
assert r == pytest.approx((1.00 - 4.50) * 100.0 * 2 / 200_000.0)
assert r < 0.0
def test_compute_ladder_exec_return_inception_no_prior_rungs_is_none():
assert compute_ladder_exec_return([], {}, 200_000.0) is None
def test_compute_ladder_exec_return_definanced_envelope_is_none():
prior = [{"short_osi": "S1", "long_osi": "L1", "credit": 1.0, "last_val": 1.0, "qty": 2}]
assert compute_ladder_exec_return(prior, {"S1": 0.5}, 0.0) is None
def test_compute_ladder_exec_return_sums_only_marked_rungs():
prior = [
{"short_osi": "S1", "long_osi": "L1", "credit": 1.0, "last_val": 1.0, "qty": 2},
{"short_osi": "S2", "long_osi": "L2", "credit": 1.0, "last_val": 1.0, "qty": 2},
]
marks = {"S1": 0.60, "S2": None} # S2 has no mark today -> HOLD, contributes 0 (not a phantom zero)
r = compute_ladder_exec_return(prior, marks, 200_000.0)
assert r == pytest.approx((1.00 - 0.60) * 100.0 * 2 / 200_000.0) # only S1's marked pnl
def test_compute_ladder_exec_return_all_holds_is_none():
prior = [{"short_osi": "S1", "long_osi": "L1", "credit": 1.0, "last_val": 1.0, "qty": 2}]
assert compute_ladder_exec_return(prior, {"S1": None}, 200_000.0) is None
def test_build_pos_state_round_trip():
opens = [{"entry": "2026-07-06", "expiry": "2026-08-15", "short_osi": "S1", "long_osi": "L1",
"credit": 1.0, "last_val": 0.6, "qty": 2}]
st = build_pos_state(opens, envelope=200_000.0, venue="ibkr-paper", today="2026-07-13")
assert st["open_spreads"] == opens
assert st["envelope"] == 200_000.0
assert st["venue"] == "ibkr-paper"
assert st["last_run_date"] == "2026-07-13"
def test_build_pos_state_defensively_copies_rungs():
opens = [{"entry": "2026-07-06", "expiry": "2026-08-15", "short_osi": "S1", "long_osi": "L1",
"credit": 1.0, "last_val": 0.6, "qty": 2}]
st = build_pos_state(opens, envelope=200_000.0, venue="ibkr-paper", today="2026-07-13")
opens.append({"short_osi": "S2"}) # mutate the caller's list after the call
opens[0]["last_val"] = 999.0 # mutate a rung dict in the caller's list
assert len(st["open_spreads"]) == 1
assert st["open_spreads"][0]["last_val"] == 0.6
def test_build_pos_state_empty_ladder():
st = build_pos_state([], envelope=0.0, venue="", today="2026-07-13")
assert st["open_spreads"] == []
assert st["envelope"] == 0.0

View File

@@ -1,245 +0,0 @@
"""Composition test for the ladder-cap-breach-under-close-failure defect: `plan_and_record_vrp` used to
decide whether to open a new rung from `plan_vrp_ladder`'s PRE-EXECUTION plan (`may_open`), computed before
any order was placed. If a planned CLOSE then failed at the broker (e.g. an illiquid near-expiry contract
rejected), the closing rung was re-added to `open_spreads_next` UNCHANGED, but the OPEN still went ahead
because it was gated on the stale `may_open` plan -- breaching the ladder cap (6 open rungs instead of 5).
Worse, since DTE only decreases, the un-closable rung would be re-planned-for-close (and re-free a phantom
slot) every subsequent run, growing the ladder unbounded behind the broker-reject.
This test drives the REAL `plan_and_record_vrp` composition: the real `ForwardNavRepo`/`XspOptionBarsRepo`/
`VrpStrategy` against a throwaway sqlite file, a fake OPRA `dbn` source, and a fake broker whose CLOSE raises
but whose OPEN would otherwise succeed -- then asserts the post-run ladder never exceeds its cap."""
import datetime as dt
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.vrp_exec_record import plan_and_record_vrp
from fxhnt.application.vrp_pricing import bs_put
from fxhnt.domain.models import OptionContract
LADDER = 5 # matches STRATEGY_REGISTRY["vrp"]["definition"]["params"]["ladder"]
TODAY = "2026-07-13" # a Monday -> VrpStrategy's default entry_weekday=0
NEAR_EXPIRY = (dt.date.fromisoformat(TODAY) + dt.timedelta(days=1)).isoformat() # DTE=1 -> planned close
NEW_EXPIRY = (dt.date.fromisoformat(TODAY) + dt.timedelta(days=30)).isoformat() # ~30 DTE -> new candidate
_FORWARD = 470.0
_TAU = 30 / 365.0
_SIGMA = 0.20 # flat-vol BS put marks -> monotonic in strike (real credit, real ~20-delta pick)
class _TestRepo(ForwardNavRepo):
"""The real forward-nav repo (backing `run_track`'s anchor/book-state/record machinery), pointed at a
throwaway sqlite file and stamped with `_dsn` so `plan_and_record_vrp` never falls back to
`get_settings().operational_dsn` (no live Postgres in this test)."""
def __init__(self, dsn: str) -> None:
super().__init__(dsn)
self._dsn = dsn
class _FakeDbn:
"""OPRA stand-in: a DTE=1 spread (the rung about to be closed) plus a ~30-DTE put ladder + ATM call (the
new-candidate universe). The 30-DTE puts are priced off real Black-Scholes (`bs_put`, flat 20% vol) so
they are monotonically increasing in strike -- a genuine (K_short, K_short-5) vertical has POSITIVE
credit and a real ~20-delta short strike, unlike a hand-waved linear-in-strike toy price."""
def last_available_option_date(self) -> dt.date:
# OPRA's available-end == TODAY here (this fake's slice IS complete for TODAY), so
# `_freeze_latest_session` freezes TODAY on its first candidate -> freeze_date == today.
return dt.date.fromisoformat(TODAY)
def fetch_option_chain(self, _parent: str, _as_of: str) -> list[OptionContract]:
contracts = [
OptionContract("XSPNEARP00460000", dt.date.fromisoformat(NEAR_EXPIRY), 460.0, "P"),
OptionContract("XSPNEARP00455000", dt.date.fromisoformat(NEAR_EXPIRY), 455.0, "P"),
OptionContract("XSPNEWC00470000", dt.date.fromisoformat(NEW_EXPIRY), 470.0, "C"),
]
for k in range(420, 475, 5):
contracts.append(OptionContract(f"XSPNEWP{k*1000:08d}", dt.date.fromisoformat(NEW_EXPIRY), float(k), "P"))
return contracts
def fetch_option_bars(self, osi_symbols: list[str], start: str, _end: str) -> dict[str, dict[str, float]]:
atm_put = round(bs_put(_FORWARD, _FORWARD, _SIGMA, _TAU), 4) # K==F -> C==P at the forward (parity)
marks = {"XSPNEARP00460000": 1.0, "XSPNEARP00455000": 0.5, "XSPNEWC00470000": atm_put}
for k in range(420, 475, 5):
marks[f"XSPNEWP{k*1000:08d}"] = round(bs_put(_FORWARD, float(k), _SIGMA, _TAU), 4)
return {osi: {start: marks[osi]} for osi in osi_symbols if osi in marks}
class _CapFakeBroker:
"""Distinguishes CLOSE vs OPEN by `build_close_legs`/`build_combo_legs`'s documented sign convention
(close BUYS the short back first; open SELLs the short first): the CLOSE always raises (an illiquid
near-expiry contract rejected by the broker); an OPEN would otherwise succeed."""
def __init__(self) -> None:
self.close_calls = 0
self.open_calls = 0
def place_combo(self, legs: list[tuple[str, str, int]], _net_limit: float) -> str:
first_action = legs[0][1]
if first_action == "BUY":
self.close_calls += 1
raise RuntimeError("broker reject: illiquid near-expiry contract")
self.open_calls += 1
return "ORDER-OPEN-1"
def _held_rung(i: int) -> dict:
"""A held rung far from expiry/profit-take, with an osi that is never frozen -> `_mark_spread` returns
None -> plan_vrp_ladder HOLDs it (not a close), regardless of its (irrelevant) credit/expiry values."""
return {"entry": "2026-06-01", "expiry": "2026-12-01", "short_osi": f"HELD-S{i}", "long_osi": f"HELD-L{i}",
"credit": 1.0, "last_val": 1.0, "qty": 1}
def _near_expiry_rung() -> dict:
return {"entry": "2026-07-06", "expiry": NEAR_EXPIRY, "short_osi": "XSPNEARP00460000",
"long_osi": "XSPNEARP00455000", "credit": 1.0, "last_val": 1.0, "qty": 1}
def _seed_full_ladder(repo: ForwardNavRepo, at: dt.datetime) -> None:
open_spreads = [_near_expiry_rung()] + [_held_rung(i) for i in range(4)]
assert len(open_spreads) == LADDER # ladder starts FULL (5/5)
repo.set_book_state("vrp_exec_pos", {"open_spreads": open_spreads, "envelope": 1_000_000.0,
"venue": "ibkr-xsp", "last_run_date": "2026-07-06"}, at=at)
def test_ladder_cap_not_breached_when_close_fails(tmp_path):
dsn = f"sqlite:///{tmp_path / 'ladder_cap.db'}"
repo = _TestRepo(dsn)
repo.migrate()
at = dt.datetime(2026, 7, 13)
_seed_full_ladder(repo, at)
broker = _CapFakeBroker()
result = plan_and_record_vrp(
repo, dbn=_FakeDbn(), broker=broker, nlv=1_000_000.0, today=TODAY, at=at,
paper_envelope=1_000_000.0, account_is_paper=True, venue="ibkr-xsp", execute=True)
# Sanity: the planner DID plan to close the near-expiry rung (reproducing the exact setup the defect
# requires -- may_open was True from the pre-execution plan, since remaining < ladder once this run's
# close is applied).
assert result["to_close"] == 1
# The CLOSE was attempted and failed at the broker.
assert broker.close_calls == 1
# THE FIX: the OPEN must be gated on actual post-close occupancy. The failed close re-added its rung
# unchanged, so the ladder is still full (5/5) -- the new rung must NOT be opened.
assert broker.open_calls == 0, "OPEN must not be placed when a failed close leaves the ladder at cap"
# The persisted book-state must never exceed the ladder cap.
persisted = repo.get_book_state("vrp_exec_pos")
assert len(persisted["open_spreads"]) == LADDER
assert result["open_spreads"] == LADDER
# The skip must be visible in the result, not silently dropped.
assert any("ladder full" in e for e in result["errors"])
assert result["spread"] is None
def test_ladder_opens_normally_when_close_succeeds(tmp_path):
"""Companion sanity check: with a broker that never rejects, the same setup DOES free a slot and open
the new rung -- proving the fix only back-pressures on an actual close FAILURE, not on every close."""
class _AlwaysSucceedsBroker:
def __init__(self) -> None:
self.close_calls = 0
self.open_calls = 0
def place_combo(self, legs, _net_limit):
if legs[0][1] == "BUY":
self.close_calls += 1
else:
self.open_calls += 1
return "ORDER-OK"
dsn = f"sqlite:///{tmp_path / 'ladder_ok.db'}"
repo = _TestRepo(dsn)
repo.migrate()
at = dt.datetime(2026, 7, 13)
_seed_full_ladder(repo, at)
broker = _AlwaysSucceedsBroker()
result = plan_and_record_vrp(
repo, dbn=_FakeDbn(), broker=broker, nlv=1_000_000.0, today=TODAY, at=at,
paper_envelope=1_000_000.0, account_is_paper=True, venue="ibkr-xsp", execute=True)
assert broker.close_calls == 1
assert broker.open_calls == 1
assert result["open_spreads"] == LADDER # one closed, one opened -> still 5, not 6
# --- OPRA publish-lag: freeze the last COMPLETE session (T-1), not today's unpublished slice --------------
_FREEZE_DATE = "2026-07-10" # _prev_trading_day("2026-07-13" Monday) -> Friday (T-1 complete session)
_LAG_EXPIRY = "2026-08-19" # ~40 DTE from the frozen session -> in the put band + calls band
class _LagDbn:
"""OPRA publishes on a ~1-session lag: TODAY's slice is still settling, so a freeze for TODAY raises
`DatabentoRangeUnavailable` (exactly the live 07:42 UTC crash), while the prior COMPLETE session freezes
fine. `last_available_option_date` reports TODAY (the naive available-end); `_freeze_latest_session` must
step back one trading day to _FREEZE_DATE. A put ladder + ATM call priced off flat-vol Black-Scholes."""
def __init__(self) -> None:
self.chain_calls: list[str] = []
def last_available_option_date(self) -> dt.date:
return dt.date.fromisoformat(TODAY)
def fetch_option_chain(self, _parent: str, as_of: str) -> list[OptionContract]:
self.chain_calls.append(as_of)
if as_of == TODAY: # today's session is not published yet -> unavailable
from fxhnt.adapters.data.databento import DatabentoRangeUnavailable
raise DatabentoRangeUnavailable(f"requested start {as_of} >= available end {_FREEZE_DATE}")
contracts = [OptionContract("XSPLAGC00470000", dt.date.fromisoformat(_LAG_EXPIRY), 470.0, "C")]
for k in range(420, 475, 5):
contracts.append(OptionContract(f"XSPLAGP{k*1000:08d}", dt.date.fromisoformat(_LAG_EXPIRY),
float(k), "P"))
return contracts
def fetch_option_bars(self, osi_symbols: list[str], start: str, _end: str) -> dict[str, dict[str, float]]:
tau = 40 / 365.0
atm_put = round(bs_put(470.0, 470.0, 0.20, tau), 4)
marks = {"XSPLAGC00470000": atm_put}
for k in range(420, 475, 5):
marks[f"XSPLAGP{k*1000:08d}"] = round(bs_put(470.0, float(k), 0.20, tau), 4)
return {osi: {start: marks[osi]} for osi in osi_symbols if osi in marks}
class _NoopBroker:
def place_combo(self, _legs, _net): # never called: held rung, no new entry (envelope 0)
raise AssertionError("broker must not be called in the lag test")
def test_freeze_uses_last_complete_session_not_today(tmp_path):
"""The live production bug: `plan_and_record_vrp` froze `today`, whose OPRA slice is NEVER published
intraday -> DatabentoRangeUnavailable before the run reached the broker. FIX: freeze the last COMPLETE
session (T-1) via `_freeze_latest_session`, but keep `today` for DTE / booking. Assert the run does not
crash, freezes T-1 (marks present at _FREEZE_DATE, absent at TODAY), and still marks + records under today."""
dsn = f"sqlite:///{tmp_path / 'lag.db'}"
repo = _TestRepo(dsn)
repo.migrate()
at = dt.datetime(2026, 7, 13)
# a prior held rung whose legs are real band contracts (frozen at T-1) -> it marks -> exec_return records.
held = {"entry": "2026-06-15", "expiry": _LAG_EXPIRY, "short_osi": "XSPLAGP00450000",
"long_osi": "XSPLAGP00445000", "credit": 1.0, "last_val": 1.0, "qty": 1}
repo.set_book_state("vrp_exec_pos", {"open_spreads": [held], "envelope": 1_000_000.0,
"venue": "ibkr-xsp", "last_run_date": "2026-07-06"}, at=at)
dbn = _LagDbn()
result = plan_and_record_vrp(
repo, dbn=dbn, broker=_NoopBroker(), nlv=1_000_000.0, today=TODAY, at=at,
paper_envelope=0.0, account_is_paper=True, venue="ibkr-xsp", execute=True) # envelope 0 -> no new entry
# TODAY was attempted (raised) and the run stepped back to the last complete session.
assert TODAY in dbn.chain_calls
assert _FREEZE_DATE in dbn.chain_calls
# marks were frozen at T-1, NOT at today (today's slice is unpublished).
from fxhnt.adapters.persistence.xsp_option_bars import XspOptionBarsRepo
bars = XspOptionBarsRepo(dsn)
assert bars.bars_for(["XSPLAGP00450000"], _FREEZE_DATE, _FREEZE_DATE) # frozen at the complete session
assert not bars.bars_for(["XSPLAGP00450000"], TODAY, TODAY) # nothing under today
# the prior rung still marked and the exec return was recorded under `today` (booking/calendar date).
assert result["exec_return"] is not None
persisted = repo.get_book_state("vrp_exec_pos")
assert persisted["last_run_date"] == TODAY
assert len(persisted["open_spreads"]) == 1 # held (not closed, not stacked)

View File

@@ -1,136 +0,0 @@
import pytest
from fxhnt.application.vrp_exec_record import (
build_close_legs,
build_combo_legs,
plan_vrp_ladder,
refuse_if_live,
rung_notional,
)
def test_build_combo_legs_is_defined_risk_credit():
legs, net = build_combo_legs(short_osi="XSP..P00470000", long_osi="XSP..P00465000",
short_price=1.20, long_price=0.40, qty=1)
assert ("XSP..P00470000", "SELL", 1) in legs
assert ("XSP..P00465000", "BUY", 1) in legs
assert net == pytest.approx(-0.80) # negative limit = net credit received
def test_build_close_legs_reverses_open_legs_with_positive_debit():
legs, net = build_close_legs(short_osi="XSP..P00470000", long_osi="XSP..P00465000", qty=1, mark_today=0.30)
assert ("XSP..P00470000", "BUY", 1) in legs # BUY back the short
assert ("XSP..P00465000", "SELL", 1) in legs # SELL the long
assert net == pytest.approx(0.30) # positive limit = debit paid to close
def _rung(*, short_osi="S1", long_osi="L1", credit=1.0, qty=2, expiry="2026-09-01"):
return {"entry": "2026-07-06", "expiry": expiry, "short_osi": short_osi, "long_osi": long_osi,
"credit": credit, "last_val": credit, "qty": qty}
def test_plan_vrp_ladder_no_stacking_when_ladder_is_full():
# 5 rungs already open, none hitting profit-take/DTE -> no closes -> ladder stays full -> may NOT open.
opens = [_rung(short_osi=f"S{i}") for i in range(5)]
marks = {sp["short_osi"]: 0.9 for sp in opens} # well above the 50% profit-take line, DTE far out
to_close, may_open, slots = plan_vrp_ladder(opens, marks, marks, "2026-07-06", ladder=5, entry_weekday=0)
assert to_close == []
assert may_open is False
assert slots == 0
def test_plan_vrp_ladder_closes_rung_at_dte_le_1():
sp = _rung(expiry="2026-07-07") # DTE = 1 on "2026-07-06"
# signal above the profit line: the close is driven by DTE<=1, not profit-take; paired with the ACTUAL mark.
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": 0.9}, {"S1": 0.9}, "2026-07-06", ladder=5,
entry_weekday=0)
assert len(to_close) == 1
assert to_close[0][0] is sp
assert to_close[0][1] == 0.9 # paired value is the ACTUAL mark (the debit to close)
def test_plan_vrp_ladder_closes_rung_at_profit_take_on_model_signal():
sp = _rung(credit=1.0)
# signal_mark <= (1 - profit_take) * credit == 0.50 -> closes; paired with the actual mark (here also 0.40).
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": 0.40}, {"S1": 0.40}, "2026-07-06", ladder=5,
profit_take=0.5, entry_weekday=0)
assert len(to_close) == 1
def test_plan_vrp_ladder_noisy_actual_mark_does_not_trip_profit_take():
"""THE FIX (MINOR-1): a noisy far-OTM long-leg spike drags the RAW shortlong actual mark down under the
50%-profit line, but the SMOOTH model signal stays above it — so no spurious close. The close decision must
use the model signal, not the actual mark."""
sp = _rung(credit=1.0)
actual = {"S1": 0.30} # raw shortlong, dragged low by a long-leg quote spike -> would false-trip
signal = {"S1": 0.90} # smooth model value, well above the 0.50 profit line
to_close, _may_open, _slots = plan_vrp_ladder([sp], actual, signal, "2026-07-06", ladder=5,
profit_take=0.5, entry_weekday=0)
assert to_close == [] # model signal governs -> no spurious profit-take close
# and when the MODEL genuinely shows 50% profit, it DOES close (at the actual mark).
to_close2, _m, _s = plan_vrp_ladder([sp], {"S1": 0.55}, {"S1": 0.45}, "2026-07-06", ladder=5,
profit_take=0.5, entry_weekday=0)
assert len(to_close2) == 1 and to_close2[0][1] == 0.55
def test_plan_vrp_ladder_holds_rung_with_no_actual_mark_today():
sp = _rung()
# no ACTUAL mark -> HOLD (cannot close without a real price), regardless of the model signal.
to_close, _may_open, _slots = plan_vrp_ladder([sp], {"S1": None}, {"S1": 0.10}, "2026-07-06", ladder=5,
entry_weekday=0)
assert to_close == []
def test_plan_vrp_ladder_may_open_on_entry_day_with_free_slot():
to_close, may_open, slots = plan_vrp_ladder([], {}, {}, "2026-07-06", ladder=5, entry_weekday=0) # Monday
assert to_close == []
assert may_open is True
assert slots == 1
def test_plan_vrp_ladder_no_open_off_entry_day():
to_close, may_open, slots = plan_vrp_ladder([], {}, {}, "2026-07-07", ladder=5, entry_weekday=0) # Tuesday
assert may_open is False
assert slots == 0
def test_plan_vrp_ladder_open_frees_up_when_a_close_makes_room():
# ladder full (5/5), but one rung closes this run (DTE<=1) -> a slot opens for a new entry.
opens = [_rung(short_osi=f"S{i}", expiry="2026-07-07") if i == 0 else _rung(short_osi=f"S{i}")
for i in range(5)]
marks = {sp["short_osi"]: 0.9 for sp in opens}
to_close, may_open, slots = plan_vrp_ladder(opens, marks, marks, "2026-07-06", ladder=5, entry_weekday=0)
assert len(to_close) == 1
assert may_open is True
assert slots == 1
def test_rung_notional_splits_envelope_across_ladder_and_bounds_total():
per_rung = rung_notional(1_000_000.0, 5)
assert per_rung == pytest.approx(200_000.0)
assert per_rung * 5 == pytest.approx(1_000_000.0) # total open notional bounded at ~envelope
class _RaisingBroker:
"""A fake broker whose place_combo must NEVER be called by a refused (non-paper, real-capital) run."""
def __init__(self) -> None:
self.calls = 0
def place_combo(self, legs, net_limit): # noqa: ANN001 - test double
self.calls += 1
raise AssertionError("place_combo must not be called when the paper-envelope guard refuses")
def test_refuse_if_live_blocks_non_paper_account_and_places_zero_orders():
broker = _RaisingBroker()
with pytest.raises(ValueError, match="refused"):
refuse_if_live(paper_envelope=1_000_000, account_is_paper=False)
assert broker.calls == 0
def test_refuse_if_live_allows_paper_account():
refuse_if_live(paper_envelope=1_000_000, account_is_paper=True) # must not raise
def test_refuse_if_live_allows_zero_envelope_on_any_account():
refuse_if_live(paper_envelope=0.0, account_is_paper=False) # off = no real-capital exposure guard

View File

@@ -1,43 +0,0 @@
import math
import pytest
from fxhnt.application.vrp_pricing import bs_put, implied_vol_put, put_delta, parity_forward
def test_bs_put_atm_positive_and_bounded():
# ATM put (F=K) has value between 0 and K; ~0.4*F*sigma*sqrt(tau) for small tau
p = bs_put(F=100.0, K=100.0, sigma=0.20, tau=30 / 365)
assert 0.0 < p < 100.0
assert p == pytest.approx(0.4 * 100.0 * 0.20 * math.sqrt(30 / 365), rel=0.05)
def test_bs_put_monotone_in_strike():
lo = bs_put(F=100.0, K=90.0, sigma=0.2, tau=0.1)
hi = bs_put(F=100.0, K=110.0, sigma=0.2, tau=0.1)
assert hi > lo # higher strike put is worth more
def test_implied_vol_round_trip():
price = bs_put(F=100.0, K=95.0, sigma=0.27, tau=45 / 365)
iv = implied_vol_put(price=price, F=100.0, K=95.0, tau=45 / 365)
assert iv == pytest.approx(0.27, abs=1e-4)
def test_implied_vol_returns_none_on_arbitrage():
# a price below intrinsic (K-F, discounted) has no arb-free IV
assert implied_vol_put(price=0.0001, F=100.0, K=130.0, tau=0.1) is None
def test_put_delta_otm_near_target():
# ~20-delta OTM put sits below spot; delta magnitude in (0,0.5)
d = put_delta(F=100.0, K=94.0, sigma=0.20, tau=30 / 365)
assert 0.0 < d < 0.5
def test_parity_forward_recovers_forward():
F = 100.0
tau = 0.1
sigma = 0.2
# build synthetic ATM call/put at K=100 consistent with forward F via parity: C - P = F - K
p = bs_put(F=F, K=100.0, sigma=sigma, tau=tau)
c = p + (F - 100.0)
assert parity_forward(call_atm=c, put_atm=p, k_atm=100.0) == pytest.approx(F, abs=1e-6)

View File

@@ -1,28 +0,0 @@
"""Task 7: the VRP CronJob YAML must parse and ship SUSPENDED — it places live-adjacent combo orders and the
IBKR account has no options permission yet, so it must not be armable by an accidental merge."""
from __future__ import annotations
from pathlib import Path
import yaml
_REPO_ROOT = Path(__file__).resolve().parents[2]
_CRONJOB_PATH = _REPO_ROOT / "infra" / "k8s" / "jobs" / "fxhnt-vrp-rebalancer.yaml"
_BACKFILL_PATH = _REPO_ROOT / "infra" / "k8s" / "jobs" / "fxhnt-opra-backfill.yaml"
def test_vrp_rebalancer_cronjob_suspended():
docs = list(yaml.safe_load_all(_CRONJOB_PATH.read_text()))
cronjobs = [d for d in docs if d and d.get("kind") == "CronJob"]
assert len(cronjobs) == 1
cj = cronjobs[0]
assert cj["metadata"]["name"] == "fxhnt-vrp-rebalancer"
assert cj["spec"]["suspend"] is True
def test_opra_backfill_yaml_is_valid_and_is_a_bare_job():
docs = [d for d in yaml.safe_load_all(_BACKFILL_PATH.read_text()) if d]
jobs = [d for d in docs if d.get("kind") == "Job"]
assert len(jobs) == 1
assert jobs[0]["metadata"]["name"] == "fxhnt-opra-backfill"
assert "suspend" not in jobs[0]["spec"] # bare Jobs have no suspend gate — manual apply only