diff --git a/src/fxhnt/application/black_scholes.py b/src/fxhnt/application/black_scholes.py deleted file mode 100644 index 173e7c3..0000000 --- a/src/fxhnt/application/black_scholes.py +++ /dev/null @@ -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) diff --git a/src/fxhnt/application/vrp_book.py b/src/fxhnt/application/vrp_book.py deleted file mode 100644 index 22377d8..0000000 --- a/src/fxhnt/application/vrp_book.py +++ /dev/null @@ -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} diff --git a/src/fxhnt/application/vrp_defined_risk_eval.py b/src/fxhnt/application/vrp_defined_risk_eval.py deleted file mode 100644 index a3ba8d2..0000000 --- a/src/fxhnt/application/vrp_defined_risk_eval.py +++ /dev/null @@ -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 diff --git a/src/fxhnt/application/vrp_eval.py b/src/fxhnt/application/vrp_eval.py deleted file mode 100644 index d938483..0000000 --- a/src/fxhnt/application/vrp_eval.py +++ /dev/null @@ -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"} diff --git a/src/fxhnt/application/vrp_exec_record.py b/src/fxhnt/application/vrp_exec_record.py deleted file mode 100644 index 3954554..0000000 --- a/src/fxhnt/application/vrp_exec_record.py +++ /dev/null @@ -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 diff --git a/src/fxhnt/application/vrp_marking.py b/src/fxhnt/application/vrp_marking.py deleted file mode 100644 index 01a0d87..0000000 --- a/src/fxhnt/application/vrp_marking.py +++ /dev/null @@ -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) diff --git a/src/fxhnt/application/vrp_pricing.py b/src/fxhnt/application/vrp_pricing.py deleted file mode 100644 index 9351b93..0000000 --- a/src/fxhnt/application/vrp_pricing.py +++ /dev/null @@ -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 diff --git a/src/fxhnt/application/vrp_research.py b/src/fxhnt/application/vrp_research.py deleted file mode 100644 index d01b969..0000000 --- a/src/fxhnt/application/vrp_research.py +++ /dev/null @@ -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) diff --git a/tests/integration/test_cockpit_vrp_render.py b/tests/integration/test_cockpit_vrp_render.py deleted file mode 100644 index 6d13c39..0000000 --- a/tests/integration/test_cockpit_vrp_render.py +++ /dev/null @@ -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 diff --git a/tests/integration/test_vrp_defined_risk_eval.py b/tests/integration/test_vrp_defined_risk_eval.py deleted file mode 100644 index 442f00e..0000000 --- a/tests/integration/test_vrp_defined_risk_eval.py +++ /dev/null @@ -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 diff --git a/tests/integration/test_vrp_eval.py b/tests/integration/test_vrp_eval.py deleted file mode 100644 index 346b93f..0000000 --- a/tests/integration/test_vrp_eval.py +++ /dev/null @@ -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 diff --git a/tests/integration/test_vrp_nav_asset.py b/tests/integration/test_vrp_nav_asset.py deleted file mode 100644 index d25a637..0000000 --- a/tests/integration/test_vrp_nav_asset.py +++ /dev/null @@ -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 diff --git a/tests/unit/test_black_scholes.py b/tests/unit/test_black_scholes.py deleted file mode 100644 index d4e5ca0..0000000 --- a/tests/unit/test_black_scholes.py +++ /dev/null @@ -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 diff --git a/tests/unit/test_registry_vrp.py b/tests/unit/test_registry_vrp.py deleted file mode 100644 index 4c6edba..0000000 --- a/tests/unit/test_registry_vrp.py +++ /dev/null @@ -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" diff --git a/tests/unit/test_vrp_book.py b/tests/unit/test_vrp_book.py deleted file mode 100644 index eaeea54..0000000 --- a/tests/unit/test_vrp_book.py +++ /dev/null @@ -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 diff --git a/tests/unit/test_vrp_exec_helpers.py b/tests/unit/test_vrp_exec_helpers.py deleted file mode 100644 index 343b085..0000000 --- a/tests/unit/test_vrp_exec_helpers.py +++ /dev/null @@ -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 diff --git a/tests/unit/test_vrp_exec_ladder_cap.py b/tests/unit/test_vrp_exec_ladder_cap.py deleted file mode 100644 index e2a975d..0000000 --- a/tests/unit/test_vrp_exec_ladder_cap.py +++ /dev/null @@ -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) diff --git a/tests/unit/test_vrp_exec_record.py b/tests/unit/test_vrp_exec_record.py deleted file mode 100644 index 557981e..0000000 --- a/tests/unit/test_vrp_exec_record.py +++ /dev/null @@ -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 diff --git a/tests/unit/test_vrp_pricing.py b/tests/unit/test_vrp_pricing.py deleted file mode 100644 index 5d755fa..0000000 --- a/tests/unit/test_vrp_pricing.py +++ /dev/null @@ -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) diff --git a/tests/unit/test_vrp_rebalancer_yaml.py b/tests/unit/test_vrp_rebalancer_yaml.py deleted file mode 100644 index cc3495e..0000000 --- a/tests/unit/test_vrp_rebalancer_yaml.py +++ /dev/null @@ -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