refactor(vrp): delete VRP application modules + black_scholes + their tests
This commit is contained in:
@@ -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(S−K,0) / max(K−S,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(S−K, 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(K−S, 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)
|
||||
@@ -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_short−F, 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 [−(width−credit)·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_close−long_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}
|
||||
@@ -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 (50–100bp) 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·(1−w), 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·(1−w)) ) (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
|
||||
@@ -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 t−swap_days) MATURES; its realized window
|
||||
payoff `K_{t−swap_days} − RV²_{t−swap_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"}
|
||||
@@ -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 short−long 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 short−long 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 (short−long) 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
|
||||
@@ -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)
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Pure Black–Scholes 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(S−K,0), put = max(K−S,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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 short−long 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 short−long, 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user