mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
feat: TARF Desk — target redemption forwards under flat vol, SSVI-Dupire local vol and jumps
Importer/exporter TARFs with leverage and full/exact/no-payout targets, priced by Monte Carlo on the forward under Black, Dupire local vol from an arbitrage-free SSVI surface fitted to ATM/RR/BF, and Merton jumps: zero-cost strike, model-risk spread, KO timing, expected cash flows, client P&L tail and Greeks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpiqUMAyo4RL5DdQHMWbwE
This commit is contained in:
@@ -46,6 +46,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
|
||||
| **Jump Lab** | Merton and Bates (Heston + jumps, COS pricing) calibrated to every live Deribit BTC/ETH expiry: model-vs-market smiles, fit RMSE, jump intensity/size, jump share of variance, crash-jump odds, 30-day risk-neutral density vs lognormal; manual Merton for metals/FX event and gap risk; one-click hand-off of jump parameters to Hedge Lab |
|
||||
| **Event Vol** | Event & weekend "vol time" term structure: strips FOMC / CPI / NFP variance lumps and the weekend weight out of the ATM curve (live Deribit fit for BTC/ETH; assumptions or pasted broker quotes for XAU/XAG/EUR/any pair), event-implied moves and straddle breakevens, forward vols and calendar-arbitrage flags, fair vol for any expiry incl. 0DTE/1-day, built-in 2026 FOMC/CPI/NFP calendar merged with live Forex Factory events, realised day-of-week / weekend-weight / FOMC-lump study |
|
||||
| **Exotics Desk** | Structured-products pricer: all eight single barriers (Reiner-Rubinstein/Haug, rebates) with Broadie-Glasserman-Kou discrete-monitoring correction, barrier price/delta profile through the barrier, touch ladder of hit probabilities, smile-consistent digitals (−∂C/∂K on the vanna-volga smile); Dual Currency Investment / crypto "Dual Investment" fair-APR ladder with an offer audit (platform margin, implied vol you are paid); KO accumulator Monte Carlo with zero-cost strike, KO odds and geared-loss tail, optional Merton jumps |
|
||||
| **TARF Desk** | Target redemption forwards (importer buys / exporter sells, leverage, full / exact / no-payout target) priced side by side under flat vol, Dupire local vol from an arbitrage-free SSVI surface calibrated to ATM / RR / BF, and Merton jumps: zero-cost strike, model-risk spread, knock-out timing and survival, expected cash flows per fixing, client P&L distribution and ES vs the capped gain, delta / vega |
|
||||
|
||||
### Live Intelligence
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
tarf.py — Target Redemption Forwards (TARFs) for FX, gold and crypto, priced under three
|
||||
smile models so the model risk the dealer is running is visible.
|
||||
|
||||
Product (per fixing i = 1..n, notional N, strike K, leverage L, target G):
|
||||
buy (importer buys base): gain_i = S_i − K if S_i > K else 0 ; loss_i = L·(K − S_i) if S_i < K
|
||||
sell (exporter sells base): gain_i = K − S_i if S_i < K else 0 ; loss_i = L·(S_i − K) if S_i > K
|
||||
The accrued intrinsic A_i = Σ gain_j. The trade knocks out at the first fixing where
|
||||
A_i ≥ G. At that fixing the client receives, by term-sheet convention,
|
||||
"full": the whole gain_i; "exact": only G − A_{i−1} (the target is paid exactly);
|
||||
"none": nothing for the KO fixing.
|
||||
Profit is capped by the target; losses are geared and uncapped — the asymmetry behind the
|
||||
2015–16 USD/CNH TRF losses in Taiwan and the recurring mis-selling cases.
|
||||
|
||||
Models (all simulated on the forward, so the vanilla forward strip is matched exactly):
|
||||
BS — flat implied vol.
|
||||
LV — Dupire local vol from an SSVI implied-vol surface (Gatheral & Jacquier 2014),
|
||||
calibrated to ATM / 25Δ RR / 25Δ BF; local variance from Gatheral's formula in
|
||||
total variance w(k, T). SSVI with η(1+|ρ|) ≤ 2 is free of static arbitrage.
|
||||
Jumps — Merton (1976) lognormal jumps with the diffusion vol chosen so the ATM total
|
||||
variance matches.
|
||||
TARF value differs across models because the KO and the leveraged leg depend on the
|
||||
distribution of the path (forward skew), not just on vanilla prices — see Clarus FT
|
||||
"TARF pricing with the Dupire local volatility model" and ION's LV note.
|
||||
|
||||
References:
|
||||
Gatheral, J. & Jacquier, A. (2014). "Arbitrage-free SVI volatility surfaces",
|
||||
Quantitative Finance 14(1), 59–71.
|
||||
Gatheral, J. (2006). The Volatility Surface, ch. 1–2 (Dupire in total variance).
|
||||
Dupire, B. (1994). "Pricing with a smile", Risk 7(1).
|
||||
Wystup, U. (2017). FX Options and Structured Products, 2nd ed., §2.7 (TARFs).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import brentq, least_squares
|
||||
from scipy.stats import norm
|
||||
|
||||
|
||||
# ─── SSVI surface & Dupire local vol ─────────────────────────────────────────
|
||||
|
||||
def ssvi_w(k, theta, rho, eta, gamma=0.5):
|
||||
"""SSVI total implied variance w(k, θ) with power-law φ(θ) = η / θ^γ."""
|
||||
phi = eta / np.power(theta, gamma)
|
||||
pk = phi * k
|
||||
return 0.5 * theta * (1 + rho * pk + np.sqrt((pk + rho) ** 2 + 1 - rho ** 2))
|
||||
|
||||
|
||||
def _k_forward_delta(theta, rho, eta, target, call=True):
|
||||
"""Log-moneyness with forward delta = target (0.25 call / 0.25 put) on the SSVI smile."""
|
||||
def f(k):
|
||||
w = ssvi_w(k, theta, rho, eta)
|
||||
d1 = (-k + 0.5 * w) / np.sqrt(w)
|
||||
return (norm.cdf(d1) - target) if call else (norm.cdf(-d1) - target)
|
||||
return brentq(f, -6 * np.sqrt(theta) - 1, 6 * np.sqrt(theta) + 1)
|
||||
|
||||
|
||||
def fit_ssvi(atm, rr25, bf25, T):
|
||||
"""(ρ, η) reproducing the 25Δ risk reversal and butterfly at tenor T (ATM vol fixed)."""
|
||||
theta = atm ** 2 * T
|
||||
|
||||
def resid(p):
|
||||
rho, eta = p
|
||||
kc = _k_forward_delta(theta, rho, eta, 0.25, True)
|
||||
kp = _k_forward_delta(theta, rho, eta, 0.25, False)
|
||||
vc, vp = np.sqrt(ssvi_w(kc, theta, rho, eta) / T), np.sqrt(ssvi_w(kp, theta, rho, eta) / T)
|
||||
return [(vc - vp) - rr25, 0.5 * (vc + vp) - atm - bf25]
|
||||
|
||||
best = None
|
||||
for r0 in (-0.3, 0.0, 0.3):
|
||||
for e0 in (0.3, 1.0):
|
||||
try:
|
||||
sol = least_squares(resid, [r0, e0], bounds=([-0.99, 1e-4], [0.99, 4.0]))
|
||||
except ValueError:
|
||||
continue
|
||||
if best is None or sol.cost < best.cost:
|
||||
best = sol
|
||||
rho, eta = best.x
|
||||
# Enforce the Gatheral-Jacquier no-butterfly-arbitrage condition η(1+|ρ|) ≤ 2.
|
||||
eta = min(eta, 2 / (1 + abs(rho)) - 1e-6)
|
||||
return {"rho": float(rho), "eta": float(eta), "fit_error": float(np.sqrt(2 * best.cost)),
|
||||
"arb_free": bool(eta * (1 + abs(rho)) <= 2)}
|
||||
|
||||
|
||||
def ssvi_implied_vol(k, T, atm, rho, eta):
|
||||
return np.sqrt(ssvi_w(k, atm ** 2 * T, rho, eta) / T)
|
||||
|
||||
|
||||
def local_vol_grid(atm, rho, eta, T_max, n_t=200, k_span=6.0):
|
||||
"""
|
||||
Dupire local vol σ_loc(t, k) on a grid, with a flat ATM term structure θ_t = atm²·t:
|
||||
σ_loc² = (∂w/∂T) / (1 − (k/w)∂_k w + ¼(−¼ − 1/w + k²/w²)(∂_k w)² + ½ ∂²_k w).
|
||||
"""
|
||||
ts = np.linspace(T_max / n_t, T_max, n_t)
|
||||
sd = atm * np.sqrt(T_max)
|
||||
ks = np.linspace(-k_span * sd, k_span * sd, 241)
|
||||
hk, out = ks[1] - ks[0], np.empty((n_t, len(ks)))
|
||||
for i, t in enumerate(ts):
|
||||
th = atm ** 2 * t
|
||||
dth = atm ** 2 * t * 1e-4
|
||||
w = ssvi_w(ks, th, rho, eta)
|
||||
wT = (ssvi_w(ks, th + dth, rho, eta) - ssvi_w(ks, th - dth, rho, eta)) / (2 * dth) * atm ** 2
|
||||
wk = np.gradient(w, hk)
|
||||
wkk = np.gradient(wk, hk)
|
||||
den = 1 - ks / w * wk + 0.25 * (-0.25 - 1 / w + ks ** 2 / w ** 2) * wk ** 2 + 0.5 * wkk
|
||||
lv2 = wT / np.maximum(den, 1e-6)
|
||||
out[i] = np.sqrt(np.clip(lv2, (0.05 * atm) ** 2, (8 * atm) ** 2))
|
||||
return ts, ks, out
|
||||
|
||||
|
||||
# ─── path simulation on the forward ──────────────────────────────────────────
|
||||
|
||||
def simulate(S, r_d, r_f, fix_times, model, sigma, n_paths, rng, ssvi=None, jump=None, substeps=2):
|
||||
"""Spot at the fixing times. The forward drift r_d − r_f is applied exactly."""
|
||||
fix_times = np.asarray(fix_times, float)
|
||||
grid = np.unique(np.concatenate([np.linspace(0, fix_times[-1], len(fix_times) * substeps + 1), fix_times]))
|
||||
dts = np.diff(grid)
|
||||
x = np.zeros(n_paths) # log(S_t / F(0, t))
|
||||
out = np.empty((n_paths, len(fix_times)))
|
||||
j = 0
|
||||
if model == "lv":
|
||||
ts, ks, lv = local_vol_grid(sigma, ssvi["rho"], ssvi["eta"], fix_times[-1])
|
||||
if model == "jump":
|
||||
lam, mj, sj = jump["lambda"], jump["mean"], jump["sd"]
|
||||
comp = lam * (np.exp(mj + 0.5 * sj ** 2) - 1)
|
||||
diff_var = max(sigma ** 2 - lam * (mj ** 2 + sj ** 2), (0.2 * sigma) ** 2)
|
||||
sd_diff = np.sqrt(diff_var)
|
||||
for step, dt in enumerate(dts):
|
||||
t0 = grid[step]
|
||||
z = rng.standard_normal(n_paths)
|
||||
if model == "bs":
|
||||
x += -0.5 * sigma ** 2 * dt + sigma * np.sqrt(dt) * z
|
||||
elif model == "lv":
|
||||
it = min(np.searchsorted(ts, t0 + 0.5 * dt), len(ts) - 1)
|
||||
s_loc = np.interp(x, ks, lv[it])
|
||||
x += -0.5 * s_loc ** 2 * dt + s_loc * np.sqrt(dt) * z
|
||||
else:
|
||||
n = rng.poisson(lam * dt, n_paths)
|
||||
jmp = n * mj + np.sqrt(n) * sj * rng.standard_normal(n_paths)
|
||||
x += (-comp - 0.5 * diff_var) * dt + sd_diff * np.sqrt(dt) * z + jmp
|
||||
t1 = grid[step + 1]
|
||||
while j < len(fix_times) and np.isclose(t1, fix_times[j]):
|
||||
out[:, j] = S * np.exp((r_d - r_f) * t1 + x)
|
||||
j += 1
|
||||
if model == "lv":
|
||||
# Martingale correction: Euler on local vol leaves a small drift — re-centre each column.
|
||||
F = S * np.exp((r_d - r_f) * fix_times)
|
||||
out *= (F / out.mean(axis=0))[None, :]
|
||||
return out
|
||||
|
||||
|
||||
# ─── TARF payoff ─────────────────────────────────────────────────────────────
|
||||
|
||||
def tarf_cashflows(paths, K, target, side="buy", leverage=2.0, notional=1.0, target_type="full"):
|
||||
"""Per-path, per-fixing client cash flows, KO flags and KO fixing index (n = never)."""
|
||||
n_paths, n = paths.shape
|
||||
sign = 1.0 if side == "buy" else -1.0
|
||||
raw = sign * (paths - K) # client intrinsic per unit
|
||||
gain = np.maximum(raw, 0)
|
||||
loss = np.minimum(raw, 0) * leverage
|
||||
acc = np.cumsum(gain, axis=1)
|
||||
hit = acc >= target
|
||||
ko = np.where(hit.any(axis=1), hit.argmax(axis=1), n)
|
||||
idx = np.arange(n)[None, :]
|
||||
alive = idx < ko[:, None]
|
||||
at_ko = idx == ko[:, None]
|
||||
cf = (gain + loss) * alive
|
||||
prev_acc = np.where(ko > 0, acc[np.arange(n_paths), np.maximum(ko - 1, 0)], 0.0)
|
||||
prev_acc = np.where(ko == 0, 0.0, prev_acc)
|
||||
if target_type == "full":
|
||||
ko_pay = gain
|
||||
elif target_type == "exact":
|
||||
ko_pay = np.broadcast_to((target - prev_acc)[:, None], gain.shape)
|
||||
else:
|
||||
ko_pay = np.zeros_like(gain)
|
||||
cf = cf + ko_pay * at_ko
|
||||
return cf * notional, ko
|
||||
|
||||
|
||||
def tarf_value(paths, fix_times, r_d, K, target, **kw):
|
||||
cf, ko = tarf_cashflows(paths, K, target, **kw)
|
||||
df = np.exp(-r_d * np.asarray(fix_times))
|
||||
pv = (cf * df[None, :]).sum(axis=1)
|
||||
return pv, cf, ko
|
||||
|
||||
|
||||
def zero_cost_strike(paths, fix_times, r_d, target, side, **kw):
|
||||
def f(K):
|
||||
return tarf_value(paths, fix_times, r_d, K, target, side=side, **kw)[0].mean()
|
||||
F = paths.mean(axis=0)
|
||||
lo, hi = F.min() * 0.5, F.max() * 1.5
|
||||
return float(brentq(f, lo, hi, xtol=1e-10 * F.mean()))
|
||||
|
||||
|
||||
def tarf_report(S, r_d, r_f, sigma, side="buy", n_fix=12, freq_per_year=12, K=None, target_pct=None,
|
||||
leverage=2.0, notional=1.0, target_type="full", rr25=None, bf25=None, jump=None,
|
||||
n_paths=20000, seed=17, models=("bs", "lv", "jump")):
|
||||
"""Price one TARF under several models; the strike defaults to zero-cost under LV (or BS)."""
|
||||
fix = np.arange(1, n_fix + 1) / freq_per_year
|
||||
T = fix[-1]
|
||||
ssvi = None
|
||||
if rr25 is not None and bf25 is not None:
|
||||
ssvi = fit_ssvi(sigma, rr25, bf25, min(max(T / 2, 1 / 12), 1.0))
|
||||
if jump is None:
|
||||
jump = {"lambda": 3.0, "mean": -0.04 if side == "buy" else 0.04, "sd": 0.05}
|
||||
run = [m for m in models if not (m == "lv" and ssvi is None)]
|
||||
# Default target ≈ 3 one-fixing standard deviations of spot: an expected life of a few fixings.
|
||||
# (Smaller targets cap the client's gains harder and push the zero-cost strike further away.)
|
||||
target = (target_pct / 100 * S) if target_pct else 3.0 * S * sigma * np.sqrt(1 / freq_per_year)
|
||||
sims = {}
|
||||
for m in run:
|
||||
rng = np.random.default_rng(seed)
|
||||
sims[m] = simulate(S, r_d, r_f, fix, m, sigma, n_paths, rng, ssvi=ssvi, jump=jump)
|
||||
ref = "lv" if "lv" in sims else "bs"
|
||||
kw = dict(leverage=leverage, notional=notional, target_type=target_type)
|
||||
K0 = zero_cost_strike(sims[ref], fix, r_d, target, side, **kw)
|
||||
K = K if K else K0
|
||||
rows, detail = {}, None
|
||||
for m, p in sims.items():
|
||||
pv, cf, ko = tarf_value(p, fix, r_d, K, target, side=side, **kw)
|
||||
try:
|
||||
k_m = zero_cost_strike(p, fix, r_d, target, side, **kw)
|
||||
except ValueError:
|
||||
k_m = None
|
||||
dist = cf.sum(axis=1)
|
||||
rows[m] = {"pv": float(pv.mean()), "pv_se": float(pv.std() / np.sqrt(len(pv))),
|
||||
"zero_cost_strike": k_m, "prob_ko": float((ko < n_fix).mean()),
|
||||
"expected_fixings": float(np.minimum(ko + 1, n_fix).mean()),
|
||||
"client_es_5": float(-np.sort(dist)[:max(1, len(dist) // 20)].mean()),
|
||||
"prob_loss": float((dist < 0).mean())}
|
||||
if m == ref:
|
||||
hazard = np.bincount(np.minimum(ko, n_fix), minlength=n_fix + 1)[:n_fix] / len(ko)
|
||||
hist = np.histogram(dist, bins=50)
|
||||
fav = (side == "buy") * (p > K) + (side == "sell") * (p < K)
|
||||
detail = {"ko_by_fixing": hazard.tolist(), "survival": (1 - np.cumsum(hazard)).tolist(),
|
||||
"expected_cf": (cf.mean(axis=0)).tolist(),
|
||||
"prob_favourable": fav.mean(axis=0).tolist(),
|
||||
"pnl_hist": {"centers": (0.5 * (hist[1][1:] + hist[1][:-1])).tolist(), "counts": hist[0].tolist()},
|
||||
"pnl_quantiles": {q: float(np.quantile(dist, q)) for q in (0.01, 0.05, 0.5, 0.95)},
|
||||
"sample_paths": p[:8].tolist()}
|
||||
# Greeks of the client position under the reference model (common random numbers).
|
||||
def pv_at(spot=S, vol=sigma):
|
||||
rng = np.random.default_rng(seed)
|
||||
pp = simulate(spot, r_d, r_f, fix, ref, vol, n_paths, rng, ssvi=ssvi, jump=jump)
|
||||
return tarf_value(pp, fix, r_d, K, target, side=side, **kw)[0].mean()
|
||||
h = 0.005 * S
|
||||
base = rows[ref]["pv"]
|
||||
delta = (pv_at(spot=S + h) - pv_at(spot=S - h)) / (2 * h)
|
||||
vega = (pv_at(vol=sigma + 0.01) - pv_at(vol=sigma - 0.01)) / 2
|
||||
fwd = S * np.exp((r_d - r_f) * fix)
|
||||
strip = float((np.exp(-r_d * fix) * (1 if side == "buy" else -1) * (fwd - K)).sum() * notional)
|
||||
return {"fixing_times": fix.tolist(), "forwards": fwd.tolist(), "strike": float(K),
|
||||
"zero_cost_strike": K0, "target": float(target), "target_pct": float(target / S * 100),
|
||||
"reference_model": ref, "models": rows, "ssvi": ssvi, "jump": jump,
|
||||
"greeks": {"delta": float(delta), "vega_1pt": float(vega), "value": base},
|
||||
"forward_strip_pv": strip, "detail": detail,
|
||||
"max_gain": float(target * notional), "n_fix": n_fix}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk
|
||||
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf
|
||||
|
||||
app = FastAPI(title="QuantRisk FX Terminal API",
|
||||
description="Forex options risk analytics — Garman-Kohlhagen model.",
|
||||
@@ -9,7 +9,7 @@ app = FastAPI(title="QuantRisk FX Terminal API",
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
||||
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk]:
|
||||
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf]:
|
||||
app.include_router(router.router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
tarf.py — TARF desk: target redemption forwards on FX, gold, silver and crypto priced under
|
||||
flat vol, SSVI-Dupire local vol and Merton jumps; zero-cost strike, knock-out timing,
|
||||
client tail risk and model-risk spread.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..core.tarf import tarf_report
|
||||
from .forex import PAIRS
|
||||
|
||||
router = APIRouter(prefix="/tarf", tags=["tarf"])
|
||||
|
||||
# Typical 3-6M 25Δ RR / BF when the trader has not loaded live quotes (vol points as decimals).
|
||||
DEFAULT_SMILE = {"crypto": (-0.04, 0.015), "metal": (0.01, 0.004), "fx": (-0.006, 0.002)}
|
||||
|
||||
|
||||
class TarfRequest(BaseModel):
|
||||
pair: str = "XAUUSD"
|
||||
S: float = Field(..., gt=0)
|
||||
sigma: float = Field(..., gt=0.01, lt=3, description="ATM vol")
|
||||
r_d: float = 0.04
|
||||
r_f: float = 0.0
|
||||
side: str = Field(default="buy", pattern="^(buy|sell)$")
|
||||
n_fix: int = Field(default=12, ge=2, le=52)
|
||||
freq_per_year: int = Field(default=12, ge=4, le=52)
|
||||
K: float | None = Field(default=None, gt=0, description="Blank = zero-cost strike")
|
||||
target_pct: float | None = Field(default=None, gt=0.05, lt=100, description="Target in % of spot")
|
||||
leverage: float = Field(default=2.0, ge=1, le=4)
|
||||
notional: float = Field(default=1.0, gt=0)
|
||||
target_type: str = Field(default="full", pattern="^(full|exact|none)$")
|
||||
rr25: float | None = Field(default=None, gt=-0.5, lt=0.5)
|
||||
bf25: float | None = Field(default=None, gt=-0.05, lt=0.5)
|
||||
jump_intensity: float = Field(default=3.0, ge=0.01, le=50)
|
||||
jump_mean: float | None = Field(default=None, gt=-0.5, lt=0.5)
|
||||
jump_sd: float = Field(default=0.05, ge=0.001, lt=0.5)
|
||||
n_paths: int = Field(default=15000, ge=2000, le=40000)
|
||||
|
||||
|
||||
@router.post("/price")
|
||||
def price(req: TarfRequest):
|
||||
pair = req.pair.upper()
|
||||
if pair not in PAIRS:
|
||||
raise HTTPException(404, f"Unknown pair '{pair}'.")
|
||||
cls = PAIRS[pair]["asset_class"]
|
||||
smile_src = "quotes"
|
||||
rr, bf = req.rr25, req.bf25
|
||||
if rr is None or bf is None:
|
||||
rr, bf = DEFAULT_SMILE.get(cls, DEFAULT_SMILE["fx"])
|
||||
smile_src = "assumed"
|
||||
# Jumps against the client's favourable side by default (crash for a buyer, spike for a seller).
|
||||
jm = req.jump_mean if req.jump_mean is not None else (-0.04 if req.side == "buy" else 0.04)
|
||||
jump = {"lambda": req.jump_intensity, "mean": jm, "sd": req.jump_sd}
|
||||
try:
|
||||
rep = tarf_report(req.S, req.r_d, req.r_f, req.sigma, side=req.side, n_fix=req.n_fix,
|
||||
freq_per_year=req.freq_per_year, K=req.K, target_pct=req.target_pct,
|
||||
leverage=req.leverage, notional=req.notional, target_type=req.target_type,
|
||||
rr25=rr, bf25=bf, jump=jump, n_paths=req.n_paths)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, f"Could not price this TARF: {e}")
|
||||
return _clean({"pair": pair, "asset_class": cls, "smile": {"rr25": rr, "bf25": bf, "source": smile_src},
|
||||
"side": req.side, "leverage": req.leverage, "target_type": req.target_type, **rep})
|
||||
|
||||
|
||||
def _clean(obj):
|
||||
if isinstance(obj, (bool, np.bool_)):
|
||||
return bool(obj)
|
||||
if isinstance(obj, (float, np.floating)):
|
||||
x = float(obj)
|
||||
return round(x, 8) if np.isfinite(x) else None
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _clean(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple, np.ndarray)):
|
||||
return [_clean(v) for v in obj]
|
||||
return obj
|
||||
@@ -0,0 +1,83 @@
|
||||
"""TARF desk: SSVI fit, Dupire consistency, payoff mechanics, limits, model spread, endpoint."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.exotics import gk_vanilla
|
||||
from app.core.tarf import (fit_ssvi, simulate, ssvi_implied_vol, tarf_cashflows, tarf_report)
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("atm,rr,bf,T", [(0.18, 0.02, 0.004, 0.5), (0.55, -0.06, 0.02, 0.25), (0.08, -0.005, 0.002, 0.5)])
|
||||
def test_ssvi_reproduces_quotes_and_is_arbitrage_free(atm, rr, bf, T):
|
||||
f = fit_ssvi(atm, rr, bf, T)
|
||||
assert f["fit_error"] < 1e-6 and f["arb_free"]
|
||||
assert ssvi_implied_vol(0.0, T, atm, f["rho"], f["eta"]) == pytest.approx(atm, abs=1e-12)
|
||||
assert np.sign(f["rho"]) == np.sign(rr)
|
||||
|
||||
|
||||
def test_local_vol_reprices_vanillas():
|
||||
S, rd, rf, T, atm = 62000.0, 0.04, 0.0, 0.5, 0.55
|
||||
f = fit_ssvi(atm, -0.06, 0.02, T)
|
||||
p = simulate(S, rd, rf, [T], "lv", atm, 120000, np.random.default_rng(2), ssvi=f, substeps=100)[:, 0]
|
||||
F = S * np.exp(rd * T)
|
||||
assert p.mean() == pytest.approx(F, rel=1e-9) # forward matched exactly
|
||||
for k in (-0.3, 0.0, 0.3):
|
||||
K = F * np.exp(k)
|
||||
call = k >= 0
|
||||
mc = np.exp(-rd * T) * (np.maximum(p - K, 0) if call else np.maximum(K - p, 0)).mean()
|
||||
bs = gk_vanilla(S, K, T, rd, rf, ssvi_implied_vol(k, T, atm, f["rho"], f["eta"]), call)
|
||||
assert mc == pytest.approx(bs, rel=0.04)
|
||||
|
||||
|
||||
def test_payoff_mechanics():
|
||||
paths = np.array([[110, 95, 120, 130.0], # gains 10, -2·5, 20 → acc 30 ≥ 25 at fixing 3
|
||||
[90, 90, 90, 90.0]]) # never gains: loses 2·10 each fixing
|
||||
for typ, ko_pay in (("full", 20), ("exact", 15), ("none", 0)):
|
||||
cf, ko = tarf_cashflows(paths, 100, 25, side="buy", leverage=2, target_type=typ)
|
||||
assert list(ko) == [2, 4]
|
||||
assert cf[0].tolist() == [10, -10, ko_pay, 0]
|
||||
assert cf[1].tolist() == [-20, -20, -20, -20]
|
||||
cf, ko = tarf_cashflows(paths, 100, 25, side="sell", leverage=2) # seller gains when spot < K
|
||||
assert list(ko) == [4, 2]
|
||||
assert cf[0].tolist() == [-20, 5, -40, -60] and cf[1].tolist() == [10, 10, 10, 0]
|
||||
|
||||
|
||||
def test_forward_strip_limit_and_model_spread():
|
||||
rep = tarf_report(4400, 0.0525, 0.0, 0.18, side="buy", K=4400, target_pct=1e4 - 1, leverage=1.0,
|
||||
rr25=0.02, bf25=0.004, n_paths=20000)
|
||||
for m, v in rep["models"].items(): # no target, no gearing → forward strip
|
||||
assert v["pv"] == pytest.approx(rep["forward_strip_pv"], abs=4 * v["pv_se"] + 1), m
|
||||
assert v["prob_ko"] == 0
|
||||
std = tarf_report(4400, 0.0525, 0.0, 0.18, side="buy", rr25=0.02, bf25=0.004, n_paths=10000)
|
||||
assert abs(std["models"]["lv"]["pv"]) < 1e-4 * 4400 # zero-cost under the reference model (PV is piecewise in K)
|
||||
assert std["zero_cost_strike"] < std["forwards"][0] # gearing buys the client a better strike
|
||||
assert 0 < std["models"]["bs"]["prob_ko"] <= 1
|
||||
assert std["greeks"]["delta"] > 0 # importer is long the underlying
|
||||
assert std["models"]["bs"]["client_es_5"] > std["max_gain"] # losses dwarf the capped gain
|
||||
|
||||
|
||||
def test_no_target_matches_analytic_leveraged_forward_strip():
|
||||
"""With no target, the zero-cost strike solves Σ C(K) = L·Σ P(K) over the fixings."""
|
||||
from scipy.optimize import brentq
|
||||
S, s, rd = 4400.0, 0.22, 0.0525
|
||||
r = tarf_report(S, rd, 0.0, s, side="buy", target_pct=9999, target_type="exact", rr25=0.01, bf25=0.004,
|
||||
n_paths=20000, models=("bs",))
|
||||
fix = np.arange(1, 13) / 12
|
||||
Ka = brentq(lambda K: sum(gk_vanilla(S, K, t, rd, 0, s, True) - 2 * gk_vanilla(S, K, t, rd, 0, s, False)
|
||||
for t in fix), 3000, 5000)
|
||||
assert r["zero_cost_strike"] == pytest.approx(Ka, rel=2e-3)
|
||||
|
||||
|
||||
def test_endpoint():
|
||||
body = {"pair": "BTCUSD", "S": 62000, "sigma": 0.55, "r_d": 0.04, "r_f": 0.0, "side": "sell",
|
||||
"n_fix": 8, "freq_per_year": 52, "n_paths": 4000}
|
||||
r = client.post("/api/tarf/price", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
js = r.json()
|
||||
assert js["smile"]["source"] == "assumed" and set(js["models"]) == {"bs", "lv", "jump"}
|
||||
assert len(js["detail"]["ko_by_fixing"]) == 8
|
||||
assert client.post("/api/tarf/price", json={**body, "pair": "NOPE"}).status_code == 404
|
||||
@@ -60,3 +60,6 @@ export const priceEventVol = (p) => api.post('/eventvol/model', p).th
|
||||
// Risk Desk
|
||||
export const fetchBookRisk = (p) => api.post('/risk/book', p).then(r=>r.data)
|
||||
export const explainPnl = (p) => api.post('/risk/explain', p).then(r=>r.data)
|
||||
|
||||
// TARF Desk
|
||||
export const priceTarf = (p) => api.post('/tarf/price', p).then(r=>r.data)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import _Plot from 'react-plotly.js'
|
||||
const Plot = _Plot?.default ?? _Plot
|
||||
import { usePortfolioStore, isCrypto, PAIR_META } from '../store/portfolio'
|
||||
import { priceTarf } from '../api/client'
|
||||
|
||||
const LAYOUT = {
|
||||
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
|
||||
font:{ color:'#94a3b8', size:11 },
|
||||
margin:{ l:56, r:16, t:12, b:42 },
|
||||
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h', x:0, y:-0.22 },
|
||||
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
|
||||
}
|
||||
const CFG = { responsive:true, displayModeBar:false }
|
||||
const axis = (title, extra = {}) => ({ title:{ text:title }, gridcolor:'#1e293b', color:'#475569', zeroline:false, ...extra })
|
||||
const pct = (v, d = 1) => (v == null ? '—' : (v * 100).toFixed(d) + '%')
|
||||
const num = (v, d = 2) => (v == null ? '—' : Number(v).toLocaleString(undefined, { minimumFractionDigits:d, maximumFractionDigits:d }))
|
||||
const MODEL_NAME = { bs:'Flat vol (Black)', lv:'Local vol (SSVI-Dupire)', jump:'Merton jumps' }
|
||||
const MODEL_COLOR = { bs:'#94a3b8', lv:'#00d4ff', jump:'#c084fc' }
|
||||
const FREQ = [['12', 'Monthly'], ['52', 'Weekly'], ['26', 'Bi-weekly']]
|
||||
|
||||
function useLoad(fn, key, enabled = true) {
|
||||
const [res, setRes] = useState({ key:null, data:null, error:null })
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
let stale = false
|
||||
const t = setTimeout(() => {
|
||||
fn(JSON.parse(key))
|
||||
.then(data => { if (!stale) setRes({ key, data, error:null }) })
|
||||
.catch(e => { if (!stale) setRes({ key, data:null, error: e?.response?.data?.detail?.toString() ?? 'Pricing failed — is the backend up?' }) })
|
||||
}, 400)
|
||||
return () => { stale = true; clearTimeout(t) }
|
||||
}, [fn, key, enabled])
|
||||
return { ...res, loading: res.key !== key }
|
||||
}
|
||||
|
||||
function Card({ title, right, children }) {
|
||||
return (
|
||||
<div className="glass" style={{ borderRadius:8, padding:8 }}>
|
||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', margin:'2px 6px 4px', gap:8, flexWrap:'wrap' }}>
|
||||
<span className="section-title" style={{ fontSize:10 }}>{title}</span>{right}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kpis({ items }) {
|
||||
return (
|
||||
<div style={{ display:'flex', gap:8, marginBottom:12, flexWrap:'wrap' }}>
|
||||
{items.map(k => (
|
||||
<div key={k.label} className="glass" title={k.hint} style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
|
||||
<div style={{ color:'var(--text-muted)', fontSize:9, letterSpacing:'0.05em' }}>{k.label}</div>
|
||||
<div className="font-mono" style={{ color: k.color ?? 'var(--cyan)', fontWeight:700, fontSize:14 }}>{k.value}</div>
|
||||
{k.sub && <div className="font-mono" style={{ fontSize:9, color:'var(--text-muted)' }}>{k.sub}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle = { background:'var(--bg3)', border:'1px solid var(--border)', borderRadius:4, color:'var(--cyan)', fontSize:11,
|
||||
padding:'4px 6px', fontFamily:'var(--font-mono)', width:'100%' }
|
||||
|
||||
function Field({ label, value, onChange, step, hint, placeholder }) {
|
||||
return (
|
||||
<label title={hint} style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
|
||||
<input type="number" value={value} step={step} placeholder={placeholder}
|
||||
onChange={e => onChange(e.target.value === '' ? '' : +e.target.value)} style={inputStyle} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ label, value, onChange, options }) {
|
||||
return (
|
||||
<label style={{ display:'flex', flexDirection:'column', gap:3, fontSize:9, color:'var(--text-muted)' }}>{label}
|
||||
<select value={value} onChange={e => onChange(e.target.value)} style={inputStyle}>
|
||||
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TarfDesk() {
|
||||
const { pair } = usePortfolioStore()
|
||||
return <Desk key={pair} pair={pair} />
|
||||
}
|
||||
|
||||
function Desk({ pair }) {
|
||||
const { S, sigma, r_d, r_f, smileQuotes, hedgeJump } = usePortfolioStore()
|
||||
const dp = PAIR_META[pair]?.dp ?? 4
|
||||
const crypto = isCrypto(pair)
|
||||
const [p, setP] = useState(() => ({
|
||||
side:'buy', n_fix: crypto ? 8 : 12, freq: crypto ? '52' : '12', K:'', target_pct:'', leverage:2, target_type:'exact',
|
||||
sigma:+(sigma * 100).toFixed(1),
|
||||
rr25: smileQuotes ? smileQuotes.rr25 : '', bf25: smileQuotes ? smileQuotes.bf25 : '',
|
||||
jump_intensity: hedgeJump?.intensity ?? (crypto ? 10 : 3), jump_sd: hedgeJump?.sd ?? (crypto ? 6 : 4),
|
||||
}))
|
||||
const set = (k) => (v) => setP(q => ({ ...q, [k]: v }))
|
||||
const body = { pair, S, sigma:p.sigma / 100, r_d, r_f, side:p.side, n_fix:p.n_fix, freq_per_year:+p.freq,
|
||||
K: p.K === '' ? null : p.K, target_pct: p.target_pct === '' ? null : p.target_pct, leverage:p.leverage,
|
||||
target_type:p.target_type, rr25: p.rr25 === '' ? null : p.rr25 / 100, bf25: p.bf25 === '' ? null : p.bf25 / 100,
|
||||
jump_intensity:p.jump_intensity, jump_sd:p.jump_sd / 100, n_paths:15000 }
|
||||
const valid = p.sigma > 1 && p.n_fix >= 2 && p.leverage >= 1
|
||||
const { data:d, error, loading } = useLoad(priceTarf, JSON.stringify(body), valid)
|
||||
const ref = d?.models?.[d?.reference_model]
|
||||
const sideWord = p.side === 'buy' ? 'buys' : 'sells'
|
||||
|
||||
return (
|
||||
<div style={{ padding:16 }}>
|
||||
<div style={{ marginBottom:12 }}>
|
||||
<div className="section-title">TARF Desk — target redemption forwards under three smile models ({pair})</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
|
||||
Zero-cost strike · knock-out timing · capped gain vs geared loss · model risk between flat vol, local vol and jumps
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(112px, 1fr))', gap:10, alignItems:'end' }}>
|
||||
<Select label="Client" value={p.side} onChange={set('side')} options={[['buy', `Buys ${pair.slice(0, 3)} (importer)`], ['sell', `Sells ${pair.slice(0, 3)} (exporter)`]]} />
|
||||
<Field label="Fixings" value={p.n_fix} onChange={set('n_fix')} step={1} />
|
||||
<Select label="Frequency" value={p.freq} onChange={set('freq')} options={FREQ} />
|
||||
<Field label="Strike (blank = zero-cost)" value={p.K} onChange={set('K')} step={PAIR_META[pair]?.strikeStep ?? 0.001} />
|
||||
<Field label="Target (% spot, blank = auto)" value={p.target_pct} onChange={set('target_pct')} step={0.5}
|
||||
hint="Accrued favourable intrinsic at which the trade knocks out" />
|
||||
<Field label="Leverage" value={p.leverage} onChange={set('leverage')} step={0.5} />
|
||||
<Select label="KO fixing pays" value={p.target_type} onChange={set('target_type')}
|
||||
options={[['full', 'Full gain'], ['exact', 'Exact (capped at target)'], ['none', 'Nothing']]} />
|
||||
<Field label="ATM vol (%)" value={p.sigma} onChange={set('sigma')} step={0.5} />
|
||||
<Field label="25Δ RR (vol pts)" value={p.rr25} onChange={set('rr25')} step={0.1} placeholder="auto" />
|
||||
<Field label="25Δ BF (vol pts)" value={p.bf25} onChange={set('bf25')} step={0.1} placeholder="auto" />
|
||||
<Field label="Jumps / year" value={p.jump_intensity} onChange={set('jump_intensity')} step={1} />
|
||||
<Field label="Jump sd (%)" value={p.jump_sd} onChange={set('jump_sd')} step={0.5} />
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6, lineHeight:1.5 }}>
|
||||
Each fixing the client {sideWord} 1 unit at K when it pays, and {p.leverage}× units when it costs; the trade dies once the accrued gain reaches
|
||||
the target. RR/BF {smileQuotes ? 'pre-filled from Vol Smile / Vol Lab' : 'blank → typical values for the asset class'}; jumps
|
||||
{hedgeJump ? ' from Jump Lab' : ' default to moves against the client'}.
|
||||
</div>
|
||||
</div>
|
||||
{error && <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{error}</div>}
|
||||
{!d && !error && <div style={{ fontSize:11, color:'var(--text-muted)', padding:12 }}>Simulating 3 models × 15,000 paths…</div>}
|
||||
{d && ref && (
|
||||
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
|
||||
<Kpis items={[
|
||||
{ label:'Zero-cost strike', value:num(d.zero_cost_strike, dp),
|
||||
sub:`vs 1st forward ${num(d.forwards[0], dp)} (${(((d.zero_cost_strike / d.forwards[0]) - 1) * 100).toFixed(2)}%)`,
|
||||
hint:`Under ${MODEL_NAME[d.reference_model]}` },
|
||||
{ label:'Target', value:num(d.target, dp), sub:`${d.target_pct.toFixed(2)}% of spot · max client gain` },
|
||||
{ label:'P(knock-out)', value:pct(ref.prob_ko), sub:`avg ${ref.expected_fixings.toFixed(1)} of ${d.n_fix} fixings` },
|
||||
{ label:'P(net loss)', value:pct(ref.prob_loss), color:'var(--red)' },
|
||||
{ label:'Client ES 5%', value:num(-ref.client_es_5, dp), color:'var(--red)',
|
||||
sub:`${(ref.client_es_5 / Math.max(d.max_gain, 1e-12)).toFixed(1)}× the max gain`, hint:'Average of the worst 5% outcomes, undiscounted' },
|
||||
{ label:'Client Δ / vega (1 vol)', value:`${num(d.greeks.delta, 2)} / ${num(d.greeks.vega_1pt, dp)}`,
|
||||
color: d.greeks.vega_1pt < 0 ? 'var(--red)' : 'var(--cyan)', hint:'Client is short vol: higher vol costs them' },
|
||||
{ label:'Smile', value:`RR ${(d.smile.rr25 * 100).toFixed(2)} · BF ${(d.smile.bf25 * 100).toFixed(2)}`,
|
||||
sub: d.ssvi ? `SSVI ρ ${d.ssvi.rho.toFixed(2)} η ${d.ssvi.eta.toFixed(2)} · ${d.smile.source}` : d.smile.source },
|
||||
]} />
|
||||
<ModelTable d={d} dp={dp} />
|
||||
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12, marginTop:12 }}>
|
||||
<Card title="When does it knock out? (reference model)">
|
||||
<Plot data={[
|
||||
{ x:d.detail.ko_by_fixing.map((_, i) => i + 1), y:d.detail.ko_by_fixing.map(v => v * 100), type:'bar', name:'P(KO at fixing) %',
|
||||
marker:{ color:'rgba(0,212,255,0.55)' } },
|
||||
{ x:d.detail.survival.map((_, i) => i + 1), y:d.detail.survival.map(v => v * 100), type:'scatter', mode:'lines+markers',
|
||||
name:'Still alive after fixing %', line:{ color:'#ffd700', width:2 } },
|
||||
{ x:d.detail.prob_favourable.map((_, i) => i + 1), y:d.detail.prob_favourable.map(v => v * 100), type:'scatter', mode:'lines',
|
||||
name:'P(fixing favourable) %', line:{ color:'#00ff88', width:1.2, dash:'dot' } },
|
||||
]}
|
||||
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Fixing', { dtick:1 }), yaxis:axis('%', { range:[0, 100] }) }}
|
||||
config={CFG} style={{ width:'100%', height:290 }} />
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
|
||||
Good outcomes end the hedge early; bad ones keep it alive at {p.leverage}× — the client is left hedged only when they did not need it.
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Client P&L over the life of the trade">
|
||||
<Plot data={[{ x:d.detail.pnl_hist.centers, y:d.detail.pnl_hist.counts, type:'bar',
|
||||
marker:{ color:d.detail.pnl_hist.centers.map(c => c < 0 ? 'rgba(255,61,90,0.6)' : 'rgba(0,255,136,0.55)') } }]}
|
||||
layout={{ ...LAYOUT, showlegend:false, bargap:0.04, xaxis:axis(`P&L (${pair.slice(3)}, per unit notional)`), yaxis:axis('Paths'),
|
||||
shapes:[{ type:'line', x0:d.max_gain, x1:d.max_gain, yref:'paper', y0:0, y1:1, line:{ color:'#00ff88', dash:'dot', width:1.5 } }],
|
||||
annotations:[{ x:d.max_gain, y:1, yref:'paper', text:'target cap', showarrow:false, font:{ size:9, color:'#00ff88' }, yanchor:'bottom' }] }}
|
||||
config={CFG} style={{ width:'100%', height:290 }} />
|
||||
<div className="font-mono" style={{ fontSize:10, color:'var(--text-muted)', padding:'0 8px 4px' }}>
|
||||
1%: <span style={{ color:'var(--red)' }}>{num(d.detail.pnl_quantiles['0.01'], dp)}</span> · 5%: {num(d.detail.pnl_quantiles['0.05'], dp)} ·
|
||||
median: {num(d.detail.pnl_quantiles['0.5'], dp)} · 95%: <span style={{ color:'var(--green)' }}>{num(d.detail.pnl_quantiles['0.95'], dp)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Sample paths vs strike">
|
||||
<Plot data={[
|
||||
...d.detail.sample_paths.map((y, i) => ({ x:y.map((_, j) => j + 1), y, type:'scatter', mode:'lines+markers', name:`path ${i + 1}`,
|
||||
line:{ width:1 }, marker:{ size:3 }, showlegend:false })),
|
||||
{ x:d.forwards.map((_, j) => j + 1), y:d.forwards, type:'scatter', mode:'lines', name:'Forward', line:{ color:'#ffd700', dash:'dash' } },
|
||||
]}
|
||||
layout={{ ...LAYOUT, xaxis:axis('Fixing', { dtick:1 }), yaxis:axis('Spot'),
|
||||
shapes:[{ type:'line', xref:'paper', x0:0, x1:1, y0:d.strike, y1:d.strike, line:{ color:'#ff3d5a', width:1.5, dash:'dot' } }] }}
|
||||
config={CFG} style={{ width:'100%', height:290 }} />
|
||||
</Card>
|
||||
<Card title="Expected cash flow per fixing (client)">
|
||||
<Plot data={[{ x:d.detail.expected_cf.map((_, i) => i + 1), y:d.detail.expected_cf, type:'bar',
|
||||
marker:{ color:d.detail.expected_cf.map(v => v < 0 ? 'rgba(255,61,90,0.6)' : 'rgba(0,255,136,0.55)') } }]}
|
||||
layout={{ ...LAYOUT, showlegend:false, xaxis:axis('Fixing', { dtick:1 }), yaxis:axis('E[cash flow]') }}
|
||||
config={CFG} style={{ width:'100%', height:290 }} />
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
|
||||
Early fixings carry the gains, late fixings the geared losses — the zero-cost strike balances them in present value.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:10, lineHeight:1.5 }}>
|
||||
Models are simulated on the forward (vanilla forwards matched exactly). Local vol: Dupire from an SSVI surface (Gatheral & Jacquier 2014)
|
||||
calibrated to ATM/RR/BF with a flat ATM term structure; Merton jumps keep the ATM total variance. Greeks by common-random-number bumps.
|
||||
Reference: Wystup (2017) §2.7; Clarus FT and ION on TARFs under local vol.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelTable({ d, dp }) {
|
||||
const ks = Object.values(d.models).map(m => m.zero_cost_strike).filter(v => v != null)
|
||||
const spread = ks.length > 1 ? Math.max(...ks) - Math.min(...ks) : null
|
||||
return (
|
||||
<Card title={`Model risk at strike ${num(d.strike, dp)}`}
|
||||
right={spread != null && <span className="font-mono" style={{ fontSize:10, color:'#ffd700' }}>
|
||||
zero-cost strike spread {num(spread, dp)} ({(spread / d.forwards[0] * 1e4).toFixed(0)} bp)</span>}>
|
||||
<div style={{ overflowX:'auto' }}>
|
||||
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:11 }}>
|
||||
<thead><tr style={{ color:'var(--text-muted)', fontSize:9, textAlign:'right' }}>
|
||||
<th style={{ textAlign:'left', padding:'4px 8px' }}>Model</th><th style={{ padding:'4px 8px' }}>Client PV</th>
|
||||
<th style={{ padding:'4px 8px' }}>Zero-cost K</th><th style={{ padding:'4px 8px' }}>P(KO)</th>
|
||||
<th style={{ padding:'4px 8px' }}>E[fixings]</th><th style={{ padding:'4px 8px' }}>P(loss)</th><th style={{ padding:'4px 8px' }}>Client ES 5%</th></tr></thead>
|
||||
<tbody>{Object.entries(d.models).map(([m, v]) => (
|
||||
<tr key={m} className="font-mono" style={{ borderTop:'1px solid var(--border)', textAlign:'right',
|
||||
background: m === d.reference_model ? 'rgba(0,212,255,0.06)' : 'transparent' }}>
|
||||
<td style={{ textAlign:'left', padding:'5px 8px', color:MODEL_COLOR[m] }}>{MODEL_NAME[m]}{m === d.reference_model ? ' · ref' : ''}</td>
|
||||
<td style={{ padding:'5px 8px', color: v.pv < 0 ? 'var(--red)' : 'var(--green)' }}>{num(v.pv, dp)} <span style={{ color:'var(--text-muted)', fontSize:9 }}>±{num(v.pv_se, dp)}</span></td>
|
||||
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>{num(v.zero_cost_strike, dp)}</td>
|
||||
<td style={{ padding:'5px 8px' }}>{pct(v.prob_ko)}</td>
|
||||
<td style={{ padding:'5px 8px' }}>{v.expected_fixings.toFixed(2)}</td>
|
||||
<td style={{ padding:'5px 8px' }}>{pct(v.prob_loss)}</td>
|
||||
<td style={{ padding:'5px 8px', color:'var(--red)' }}>{num(-v.client_es_5, dp)}</td>
|
||||
</tr>))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'6px 8px' }}>
|
||||
All three match the same vanilla forwards; differences come from the path distribution (forward skew, jumps) that the knock-out and the geared leg are exposed to.
|
||||
A dealer quoting off flat vol mis-marks the trade by the PV gap.
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import JumpLab from '../components/JumpLab'
|
||||
import ExoticsDesk from '../components/ExoticsDesk'
|
||||
import EventVol from '../components/EventVol'
|
||||
import RiskDesk from '../components/RiskDesk'
|
||||
import TarfDesk from '../components/TarfDesk'
|
||||
|
||||
const IVSurface = lazy(() => import('../components/IVSurface'))
|
||||
const WorldMap = lazy(() => import('../components/WorldMap'))
|
||||
@@ -54,6 +55,7 @@ const TABS = [
|
||||
{ id:'risk', icon:'🛡', label:'Risk Desk', component: RiskDesk },
|
||||
{ id:'jumps', icon:'⚡', label:'Jump Lab', component: JumpLab },
|
||||
{ id:'exotics', icon:'🧩', label:'Exotics Desk', component: ExoticsDesk },
|
||||
{ id:'tarf', icon:'🎯', label:'TARF Desk', component: TarfDesk },
|
||||
{ id:'xasset', icon:'🔗', label:'Cross-Asset', component: CrossAsset },
|
||||
{ id:'smile', icon:'〰', label:'Vol Smile', component: VolSmile },
|
||||
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 408 KiB |
Reference in New Issue
Block a user