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:
Claude
2026-10-15 05:26:28 +00:00
parent 1615dd6751
commit fb2b15c596
10 changed files with 674 additions and 2 deletions
+1
View File
@@ -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
+256
View File
@@ -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
View File
@@ -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("/")
+77
View File
@@ -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
+83
View File
@@ -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
+3
View File
@@ -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)
+250
View File
@@ -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 &amp; 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>
)
}
+2
View File
@@ -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