feat: Exotics Desk — barriers (BGK), touches, smile digitals, DCI audit, accumulators

Reiner-Rubinstein/Haug single barriers with rebates and Broadie-Glasserman-Kou
discrete-monitoring correction, touch ladder, smile-consistent digitals on the
vanna-volga smile, Dual Currency Investment fair-APR ladder with offer audit
(platform margin, implied vol paid), and a KO accumulator Monte Carlo with
zero-cost strike and optional Merton jumps. Validated against Haug Table 4-13
and discrete Monte Carlo.

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-09 05:28:52 +00:00
parent bb70b10a6c
commit 78cd5c2ef7
11 changed files with 957 additions and 2 deletions
+1
View File
@@ -43,6 +43,7 @@ NEXUS TERMINAL is a Bloomberg-style web application for professional forex optio
| **Hedge Lab** | Monte Carlo delta-hedging / gamma-scalping P&L of your portfolio: realised vs implied vol, GBM / Merton jumps / historical block bootstrap, weekend gaps for 24/5 markets, time or delta-band hedging, transaction costs and carry, with Derman-Kamal and Leland benchmarks and a hedge-frequency risk-vs-cost sweep |
| **Cross-Asset Desk** | Gold · silver · BTC · ETH · EURUSD · DXY · US10Y · WTI correlation matrices (30D/90D/1Y/EWMA) with a correlation-break monitor; gold/silver ratio z-scores, OU half-life, Engle-Granger cointegration and Kalman hedge ratio; Margrabe outperformance-option pricer with correlation sensitivity |
| **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 |
| **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 |
### Live Intelligence
+267
View File
@@ -0,0 +1,267 @@
"""
exotics.py — Exotics & structured products for FX, metals and crypto:
single barriers, touches, smile-consistent digitals, Dual Currency Investments
(crypto "Dual Investment") and accumulators.
Conventions: Garman-Kohlhagen with r = r_d (domestic / quote currency) and
cost of carry b = r_d − r_f (r_f = foreign rate, metal lease rate, ≈0 for crypto).
Prices are in quote currency per unit of base.
1. Single barriers — Reiner & Rubinstein (1991) closed forms in Haug's A–F notation
(all eight in/out × up/down × call/put, with rebates: knock-ins pay the rebate at
expiry if never knocked in, knock-outs pay it at the hit). Discrete monitoring
via the Broadie-Glasserman-Kou (1997) shift H → H·e^{±0.5826·σ√Δt}
(away from spot).
2. Touches: one-touch paying at hit (Haug's F with unit rebate), one-touch paying at
expiry and no-touch (from the knock-in rebate term E).
3. Digitals: cash-or-nothing e^{−rT}N(±d₂), smile-consistent version
−∂C/∂K = BS digital ∓ vega·∂σ/∂K (Breeden-Litzenberger), with σ(K) from a
vanna-volga smile built on ATM / 25Δ RR / 25Δ BF quotes.
4. Dual Currency Investment: deposit + short option. "Sell high" = deposit base,
short call struck K (converts to quote if S_T ≥ K); "buy low" = deposit quote,
short put (converts to base if S_T ≤ K). Fair APR = deposit rate + premium/T.
5. Accumulator (KO forward accumulation): buy q units at strike K on every fixing,
2q (gearing) when the fixing is below K, the contract knocks out when a fixing
is ≥ KO. Monte Carlo under GBM or Merton jumps; zero-cost strike by root search.
References:
Reiner, E. & Rubinstein, M. (1991). "Breaking down the barriers." Risk 4(8).
Haug, E. (2007). The Complete Guide to Option Pricing Formulas, 2nd ed., §4.17.
Broadie, M., Glasserman, P. & Kou, S. (1997). Mathematical Finance, 7(4), 325–349.
Breeden, D. & Litzenberger, R. (1978). Journal of Business, 51(4), 621–651.
Wystup, U. (2017). FX Options and Structured Products, 2nd ed. (DCDs, accumulators).
"""
import numpy as np
from scipy.optimize import brentq
from scipy.stats import norm
BGK_BETA = 0.5826 # −ζ(1/2)/√(2π)
# ─── vanilla & building blocks ────────────────────────────────────────────────
def gk_vanilla(S, K, T, r_d, r_f, sigma, is_call=True):
sq = sigma * np.sqrt(T)
d1 = (np.log(S / K) + (r_d - r_f + 0.5 * sigma ** 2) * T) / sq
d2 = d1 - sq
if is_call:
return S * np.exp(-r_f * T) * norm.cdf(d1) - K * np.exp(-r_d * T) * norm.cdf(d2)
return K * np.exp(-r_d * T) * norm.cdf(-d2) - S * np.exp(-r_f * T) * norm.cdf(-d1)
def bgk_barrier(H, S, sigma, n_obs_per_year):
"""Continuous-equivalent barrier for discrete monitoring (shifted away from spot)."""
if not n_obs_per_year:
return H
shift = np.exp(BGK_BETA * sigma * np.sqrt(1.0 / n_obs_per_year))
return H * shift if H > S else H / shift
def _terms(S, X, H, T, r, b, sigma, phi, eta, rebate):
sq = sigma * np.sqrt(T)
mu = (b - 0.5 * sigma ** 2) / sigma ** 2
lam = np.sqrt(mu ** 2 + 2 * r / sigma ** 2)
x1 = np.log(S / X) / sq + (1 + mu) * sq
x2 = np.log(S / H) / sq + (1 + mu) * sq
y1 = np.log(H * H / (S * X)) / sq + (1 + mu) * sq
y2 = np.log(H / S) / sq + (1 + mu) * sq
z = np.log(H / S) / sq + lam * sq
fwd = S * np.exp((b - r) * T)
dk = X * np.exp(-r * T)
hs = H / S
N = norm.cdf
A = phi * fwd * N(phi * x1) - phi * dk * N(phi * x1 - phi * sq)
B = phi * fwd * N(phi * x2) - phi * dk * N(phi * x2 - phi * sq)
C = phi * fwd * hs ** (2 * (mu + 1)) * N(eta * y1) - phi * dk * hs ** (2 * mu) * N(eta * y1 - eta * sq)
D = phi * fwd * hs ** (2 * (mu + 1)) * N(eta * y2) - phi * dk * hs ** (2 * mu) * N(eta * y2 - eta * sq)
E = rebate * np.exp(-r * T) * (N(eta * x2 - eta * sq) - hs ** (2 * mu) * N(eta * y2 - eta * sq))
F = rebate * (hs ** (mu + lam) * N(eta * z) + hs ** (mu - lam) * N(eta * z - 2 * eta * lam * sq))
return A, B, C, D, E, F
def barrier_price(S, K, H, T, r_d, r_f, sigma, is_call=True, direction="down", knock="out",
rebate=0.0, monitoring_per_year=None):
"""
Reiner-Rubinstein single barrier (Haug A–F). direction: "down"|"up", knock: "in"|"out".
Already-breached barriers return the vanilla (knock-in) or the rebate (knock-out).
monitoring_per_year: e.g. 252 for daily fixings (BGK correction); None = continuous.
"""
up = direction == "up"
if (up and S >= H) or (not up and S <= H):
if knock == "in":
return float(gk_vanilla(S, K, T, r_d, r_f, sigma, is_call))
return float(rebate)
Hc = bgk_barrier(H, S, sigma, monitoring_per_year)
r, b = r_d, r_d - r_f
phi = 1 if is_call else -1
eta = -1 if up else 1
A, B, C, D, E, F = _terms(S, K, Hc, T, r, b, sigma, phi, eta, rebate)
hi = K > Hc
table = {
("call", "down", "in"): C + E if hi else A - B + D + E,
("call", "up", "in"): A + E if hi else B - C + D + E,
("put", "down", "in"): B - C + D + E if hi else A + E,
("put", "up", "in"): A - B + D + E if hi else C + E,
("call", "down", "out"): A - C + F if hi else B - D + F,
("call", "up", "out"): F if hi else A - B + C - D + F,
("put", "down", "out"): A - B + C - D + F if hi else F,
("put", "up", "out"): B - D + F if hi else A - C + F,
}
return float(max(table[("call" if is_call else "put", direction, knock)], 0.0))
def touch_prices(S, H, T, r_d, r_f, sigma, payout=1.0, monitoring_per_year=None):
"""One-touch (paid at hit / at expiry) and no-touch on barrier H, plus hit probability."""
up = H > S
Hc = bgk_barrier(H, S, sigma, monitoring_per_year)
eta = -1 if up else 1
*_, E, F = _terms(S, S, Hc, T, r_d, r_d - r_f, sigma, 1, eta, payout)
df = np.exp(-r_d * T)
no_touch = float(max(E, 0.0))
return {"one_touch_at_hit": float(F), "one_touch_at_expiry": float(payout * df - no_touch),
"no_touch": no_touch, "prob_touch": float(1 - no_touch / (payout * df)),
"barrier_used": float(Hc)}
def digital_prices(S, K, T, r_d, r_f, sigma, smile_vol=None, smile_slope=0.0, payout=1.0):
"""Cash-or-nothing call/put: flat-vol and smile-consistent (−∂C/∂K) values."""
vol = smile_vol if smile_vol else sigma
sq = vol * np.sqrt(T)
d1 = (np.log(S / K) + (r_d - r_f + 0.5 * vol ** 2) * T) / sq
d2 = d1 - sq
df = np.exp(-r_d * T)
bs_call = df * norm.cdf(d2)
vega = S * np.exp(-r_f * T) * norm.pdf(d1) * np.sqrt(T)
call_smile = bs_call - vega * smile_slope # dC/dK = −digital + vega·σ'(K)
sq0 = sigma * np.sqrt(T)
flat = df * norm.cdf((np.log(S / K) + (r_d - r_f - 0.5 * sigma ** 2) * T) / sq0)
return {"call_flat": float(payout * flat), "put_flat": float(payout * (df - flat)),
"call_smile": float(payout * call_smile), "put_smile": float(payout * (df - call_smile)),
"smile_adjustment": float(payout * (call_smile - flat))}
# ─── Dual Currency Investment ─────────────────────────────────────────────────
def dci_quote(S, K, T, r_d, r_f, sigma, side="sell_high", offered_apr=None):
"""
Fair APR of a DCI and the conversion odds.
sell_high: deposit 1 unit of base (e.g. 1 BTC), short a call at K (paid in base terms).
buy_low: deposit K units of quote (e.g. USD), short a put at K, paid in quote terms.
"""
if side == "sell_high":
prem = gk_vanilla(S, K, T, r_d, r_f, sigma, True) / S # base-currency premium per 1 base
base_rate = r_f
conv = gk_prob(S, K, T, r_d, r_f, sigma, above=True)
else:
prem = gk_vanilla(S, K, T, r_d, r_f, sigma, False) / K # quote premium per 1 quote deposited
base_rate = r_d
conv = gk_prob(S, K, T, r_d, r_f, sigma, above=False)
fair_apr = base_rate + prem / T
out = {"premium_pct": float(prem * 100), "deposit_rate": float(base_rate),
"fair_apr": float(fair_apr), "prob_conversion": float(conv),
"breakeven": float(K * (1 + prem) if side == "sell_high" else K * (1 - prem))}
if offered_apr is not None:
out["offered_apr"] = offered_apr
out["margin_apr"] = float(fair_apr - offered_apr)
# Vol at which the offered APR would be fair (the vol the platform is paying you).
def gap(v):
return dci_quote(S, K, T, r_d, r_f, v, side)["fair_apr"] - offered_apr
try:
out["implied_vol_paid"] = float(brentq(gap, 1e-3, 5.0))
except ValueError:
out["implied_vol_paid"] = None
return out
def gk_prob(S, K, T, r_d, r_f, sigma, above=True):
d2 = (np.log(S / K) + (r_d - r_f - 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
return float(norm.cdf(d2) if above else norm.cdf(-d2))
# ─── Accumulator ──────────────────────────────────────────────────────────────
def simulate_paths(S, T, r_d, r_f, sigma, n_steps, n_paths, rng, jump=None):
dt = T / n_steps
mu = r_d - r_f
comp, jumps, sd = 0.0, 0.0, sigma
if jump and jump.get("lambda", 0) > 0:
lam, mj, sj = jump["lambda"], jump["mean"], jump["sd"]
comp = lam * (np.exp(mj + 0.5 * sj ** 2) - 1)
counts = rng.poisson(lam * dt, (n_paths, n_steps))
jumps = counts * mj + np.sqrt(counts) * sj * rng.standard_normal((n_paths, n_steps))
z = rng.standard_normal((n_paths, n_steps))
x = (mu - comp - 0.5 * sd ** 2) * dt + sd * np.sqrt(dt) * z + jumps
return S * np.exp(np.cumsum(x, axis=1))
def accumulator_value(paths, K, KO, T, r_d, qty=1.0, gearing=2.0):
"""PV to the client (long) per path, accumulated quantity, KO flags/times."""
n_paths, n = paths.shape
t = np.arange(1, n + 1) * T / n
df = np.exp(-r_d * t)
ko_hit = paths >= KO
first = np.where(ko_hit.any(axis=1), ko_hit.argmax(axis=1), n) # index of KO fixing
alive = np.arange(n)[None, :] < first[:, None] # fixings strictly before KO
q = np.where(paths < K, gearing * qty, qty) * alive
pv = (q * (paths - K) * df[None, :]).sum(axis=1)
return pv, q.sum(axis=1), first < n, np.where(first < n, (first + 1) * T / n, np.nan)
def accumulator_report(S, T, r_d, r_f, sigma, ko_pct=105.0, strike_pct=None, qty=1.0,
gearing=2.0, fixings_per_year=252, n_paths=20000, seed=11, jump=None):
n = max(1, int(round(T * fixings_per_year)))
rng = np.random.default_rng(seed)
paths = simulate_paths(S, T, r_d, r_f, sigma, n, n_paths, rng, jump)
KO = S * ko_pct / 100
def pv_mean(K):
return accumulator_value(paths, K, KO, T, r_d, qty, gearing)[0].mean()
zero_cost_K = brentq(pv_mean, S * 0.3, KO * 0.999)
K = S * strike_pct / 100 if strike_pct else zero_cost_K
pv, qsum, ko, t_ko = accumulator_value(paths, K, KO, T, r_d, qty, gearing)
# Client P&L at maturity if the accumulated units are marked at the final fixing.
final_mtm = np.zeros(n_paths)
t = np.arange(1, n + 1) * T / n
first = np.where(paths >= KO, 1, 0).argmax(axis=1)
first = np.where((paths >= KO).any(axis=1), first, n)
alive = np.arange(n)[None, :] < first[:, None]
q = np.where(paths < K, gearing * qty, qty) * alive
end_px = np.where(first < n, paths[np.arange(n_paths), np.minimum(first, n - 1)], paths[:, -1])
final_mtm = (q * (end_px[:, None] - K)).sum(axis=1)
hist_c, hist_e = np.histogram(final_mtm, bins=40)
sample = paths[:6, :: max(1, n // 120)]
return {
"n_fixings": n, "ko_level": float(KO), "zero_cost_strike": float(zero_cost_K),
"zero_cost_strike_pct": float(zero_cost_K / S * 100), "strike": float(K),
"pv_to_client": float(pv.mean()), "pv_se": float(pv.std() / np.sqrt(n_paths)),
"expected_units": float(qsum.mean()), "max_units": float(gearing * qty * n),
"prob_knock_out": float(ko.mean()),
"expected_ko_days": float(np.nanmean(t_ko) * 365) if ko.any() else None,
"pnl": {"mean": float(final_mtm.mean()), "p05": float(np.quantile(final_mtm, 0.05)),
"p01": float(np.quantile(final_mtm, 0.01)),
"es05": float(final_mtm[final_mtm <= np.quantile(final_mtm, 0.05)].mean()),
"prob_loss": float((final_mtm < 0).mean())},
"histogram": {"centers": (0.5 * (hist_e[1:] + hist_e[:-1])).tolist(), "counts": hist_c.tolist()},
"sample_paths": sample.tolist(),
}
def barrier_mc(S, K, H, T, r_d, r_f, sigma, is_call, direction, knock, rebate=0.0,
n_steps=252, n_paths=200_000, seed=3):
"""Monte Carlo check with discrete monitoring at n_steps fixings (knock-out rebate at hit)."""
rng = np.random.default_rng(seed)
paths = simulate_paths(S, T, r_d, r_f, sigma, n_steps, n_paths, rng)
hit = (paths >= H) if direction == "up" else (paths <= H)
touched = hit.any(axis=1)
ST = paths[:, -1]
pay = np.maximum(ST - K, 0) if is_call else np.maximum(K - ST, 0)
df = np.exp(-r_d * T)
if knock == "out":
idx = hit.argmax(axis=1)
t_hit = (idx + 1) * T / n_steps
v = np.where(touched, rebate * np.exp(-r_d * t_hit), pay * df)
else:
v = np.where(touched, pay * df, rebate * df)
return float(v.mean()), float(v.std() / np.sqrt(n_paths))
+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
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics
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]:
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics]:
app.include_router(router.router, prefix="/api")
@app.get("/")
+182
View File
@@ -0,0 +1,182 @@
"""
exotics.py — Exotics & Structured Products desk: single barriers (continuous and
BGK discrete), touch ladders, smile-consistent digitals, Dual Currency Investment
(crypto "Dual Investment") fair-APR ladders and accumulators.
"""
import numpy as np
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from ..core.exotics import (accumulator_report, barrier_price, dci_quote, digital_prices,
gk_prob, gk_vanilla, touch_prices)
from ..core.vanna_volga import pillars, vv_vol
from .forex import PAIRS
router = APIRouter(prefix="/exotics", tags=["exotics"])
KINDS = [(cp, d, k) for cp in ("call", "put") for d in ("down", "up") for k in ("out", "in")]
class Market(BaseModel):
pair: str = "XAUUSD"
S: float = Field(..., gt=0)
T: float = Field(..., gt=0, le=3, description="Years")
r_d: float = Field(default=0.04, ge=-0.05, le=0.5)
r_f: float = Field(default=0.0, ge=-0.05, le=0.5)
sigma: float = Field(..., gt=0.005, lt=3)
class BarrierRequest(Market):
K: float = Field(..., gt=0)
H_down: float = Field(..., gt=0)
H_up: float = Field(..., gt=0)
rebate: float = Field(default=0.0, ge=0)
monitoring_per_year: int | None = Field(default=252, ge=1, le=100000)
is_call: bool = True
direction: str = Field(default="up", pattern="^(up|down)$")
knock: str = Field(default="out", pattern="^(in|out)$")
rr25: float | None = Field(default=None, gt=-1, lt=1)
bf25: float | None = Field(default=None, gt=-0.5, lt=1)
class DciRequest(Market):
side: str = Field(default="sell_high", pattern="^(sell_high|buy_low)$")
strikes_pct: list[float] = Field(default=[90, 94, 97, 103, 106, 110], min_length=1, max_length=30)
offered_apr: float | None = Field(default=None, ge=0, le=20, description="Platform APR (decimal) at strike_pct_offer")
strike_pct_offer: float | None = Field(default=None, gt=10, lt=500)
class AccumulatorRequest(Market):
ko_pct: float = Field(default=105.0, gt=100, lt=200)
strike_pct: float | None = Field(default=None, gt=10, lt=200)
qty: float = Field(default=1.0, gt=0)
gearing: float = Field(default=2.0, ge=1, le=4)
fixings_per_year: int = Field(default=252, ge=12, le=365)
n_paths: int = Field(default=20000, ge=1000, le=60000)
jump_intensity: float = Field(default=0.0, ge=0, le=100)
jump_mean: float = Field(default=0.0, gt=-0.8, lt=0.5)
jump_sd: float = Field(default=0.0, ge=0, lt=1)
def _check(pair):
if pair.upper() not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
def _smile_vol(req, K):
"""σ(K) and ∂σ/∂K from a vanna-volga smile when RR/BF are supplied, else flat."""
if req.rr25 is None or req.bf25 is None:
return req.sigma, 0.0
pl = pillars(req.S, req.T, req.r_d, req.r_f, req.sigma, req.rr25, req.bf25)
h = K * 1e-3
v = float(vv_vol(K, req.S, req.T, req.r_d, req.r_f, pl))
vu = float(vv_vol(K + h, req.S, req.T, req.r_d, req.r_f, pl))
vd = float(vv_vol(K - h, req.S, req.T, req.r_d, req.r_f, pl))
return v, (vu - vd) / (2 * h)
@router.post("/barrier")
def barrier_desk(req: BarrierRequest):
"""All eight single barriers, touches, digitals and the selected barrier's spot profile."""
_check(req.pair)
if not req.H_down < req.S < req.H_up:
raise HTTPException(422, "Need H_down < spot < H_up.")
a = (req.S, req.K)
m = (req.T, req.r_d, req.r_f, req.sigma)
rows = []
for cp, d, k in KINDS:
H = req.H_up if d == "up" else req.H_down
cont = barrier_price(*a, H, *m, cp == "call", d, k, req.rebate)
disc = barrier_price(*a, H, *m, cp == "call", d, k, req.rebate, req.monitoring_per_year)
van = gk_vanilla(*a, *m, cp == "call")
rows.append({"type": cp, "direction": d, "knock": k, "barrier": H, "continuous": cont,
"discrete": disc, "vanilla": float(van),
"pct_of_vanilla": float(disc / van * 100) if van > 1e-12 else None})
# Spot profile of the selected barrier: price and delta through the barrier.
H = req.H_up if req.direction == "up" else req.H_down
lo, hi = min(req.H_down, req.K, req.S) * 0.9, max(req.H_up, req.K, req.S) * 1.1
spots = np.linspace(lo, hi, 121)
prof = [barrier_price(s, req.K, H, *m, req.is_call, req.direction, req.knock, req.rebate,
req.monitoring_per_year) for s in spots]
van = [float(gk_vanilla(s, req.K, *m, req.is_call)) for s in spots]
delta = np.gradient(prof, spots)
touches = []
for Hb in (req.H_down, req.H_up):
t = touch_prices(req.S, Hb, req.T, req.r_d, req.r_f, req.sigma, 1.0, req.monitoring_per_year)
t["barrier"] = Hb
t["prob_finish_beyond"] = gk_prob(req.S, Hb, req.T, req.r_d, req.r_f, req.sigma, above=Hb > req.S)
touches.append(t)
# Touch ladder: hit probability vs barrier distance (in ATM-sd units).
sd = req.sigma * np.sqrt(req.T)
ladder = []
for z in np.linspace(0.25, 3.0, 12):
for sgn in (-1, 1):
Hb = req.S * np.exp(sgn * z * sd)
t = touch_prices(req.S, Hb, req.T, req.r_d, req.r_f, req.sigma, 1.0, req.monitoring_per_year)
ladder.append({"barrier": float(Hb), "sd": float(sgn * z), "prob_touch": t["prob_touch"],
"one_touch": t["one_touch_at_expiry"]})
ladder.sort(key=lambda r: r["barrier"])
vol_k, slope = _smile_vol(req, req.K)
dig = digital_prices(req.S, req.K, req.T, req.r_d, req.r_f, req.sigma, vol_k, slope)
dig.update({"strike": req.K, "smile_vol": vol_k, "smile_slope_per_1pct": slope * req.K * 0.01})
return _clean({"pair": req.pair.upper(), "rows": rows, "selected": {
"type": "call" if req.is_call else "put", "direction": req.direction, "knock": req.knock,
"barrier": H, "spots": spots.tolist(), "price": prof, "vanilla": van, "delta": delta.tolist()},
"touches": touches, "touch_ladder": ladder, "digital": dig,
"bgk_shift_pct": float((np.exp(0.5826 * req.sigma * np.sqrt(1 / req.monitoring_per_year)) - 1) * 100)
if req.monitoring_per_year else 0.0})
@router.post("/dci")
def dci_ladder(req: DciRequest):
"""Fair APR / conversion-odds ladder for a Dual Currency Investment, plus offer audit."""
_check(req.pair)
out = []
for p in sorted(req.strikes_pct):
if (req.side == "sell_high") != (p > 100):
continue # DCIs are struck out-of-the-money
q = dci_quote(req.S, req.S * p / 100, req.T, req.r_d, req.r_f, req.sigma, req.side)
out.append({"strike_pct": p, "strike": req.S * p / 100, **q})
if not out:
raise HTTPException(422, "No out-of-the-money strikes for this side "
"(sell_high needs strikes above 100%, buy_low below).")
audit = None
if req.offered_apr is not None and req.strike_pct_offer:
audit = dci_quote(req.S, req.S * req.strike_pct_offer / 100, req.T, req.r_d, req.r_f,
req.sigma, req.side, req.offered_apr)
audit["strike_pct"] = req.strike_pct_offer
audit["vol_haircut"] = (req.sigma - audit["implied_vol_paid"]) if audit.get("implied_vol_paid") else None
return _clean({"pair": req.pair.upper(), "side": req.side, "tenor_days": req.T * 365,
"ladder": out, "audit": audit})
@router.post("/accumulator")
def accumulator(req: AccumulatorRequest):
"""Zero-cost strike, KO odds and the client P&L distribution of a KO accumulator."""
_check(req.pair)
jump = None
if req.jump_intensity > 0 and req.jump_sd > 0:
jump = {"lambda": req.jump_intensity, "mean": req.jump_mean, "sd": req.jump_sd}
try:
rep = accumulator_report(req.S, req.T, req.r_d, req.r_f, req.sigma, req.ko_pct, req.strike_pct,
req.qty, req.gearing, req.fixings_per_year, req.n_paths, jump=jump)
except ValueError as e:
raise HTTPException(422, f"No zero-cost strike: {e}")
return _clean({"pair": req.pair.upper(), "spot": req.S, **rep})
def _clean(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 {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
+96
View File
@@ -0,0 +1,96 @@
"""Exotics desk: Haug reference values, parity, BGK vs Monte Carlo, touches, DCI, accumulator."""
import numpy as np
import pytest
from fastapi.testclient import TestClient
from app.core.exotics import (accumulator_report, barrier_mc, barrier_price, dci_quote,
digital_prices, gk_vanilla, simulate_paths, touch_prices)
from app.main import app
S, T, R, Q, V = 100.0, 0.5, 0.08, 0.04, 0.25
client = TestClient(app)
@pytest.mark.parametrize("cp,d,k,K,H,ref", [
# Haug (2007) Table 4-13, rebate 3.
("call", "down", "out", 90, 95, 9.0246), ("call", "down", "in", 90, 95, 7.7627),
("call", "up", "out", 90, 105, 2.6789), ("call", "up", "in", 90, 105, 14.1112),
("put", "down", "out", 90, 95, 2.2798), ("put", "down", "in", 90, 95, 2.9586),
("put", "up", "out", 90, 105, 3.7760), ("put", "up", "in", 90, 105, 1.4653),
])
def test_haug_table(cp, d, k, K, H, ref):
assert barrier_price(S, K, H, T, R, Q, V, cp == "call", d, k, 3.0) == pytest.approx(ref, abs=1e-4)
def test_in_out_parity_and_breached():
for cp in (True, False):
for d, H in (("down", 95), ("up", 105)):
for K in (90, 100, 110):
tot = barrier_price(S, K, H, T, R, Q, V, cp, d, "in") + barrier_price(S, K, H, T, R, Q, V, cp, d, "out")
assert tot == pytest.approx(gk_vanilla(S, K, T, R, Q, V, cp), abs=1e-9)
assert barrier_price(S, 100, 101, T, R, Q, V, True, "down", "out", 2.0) == 2.0
assert barrier_price(S, 100, 99, T, R, Q, V, True, "up", "in") == pytest.approx(gk_vanilla(S, 100, T, R, Q, V))
@pytest.mark.parametrize("K,H,c,d,k", [(100, 95, True, "down", "out"), (100, 105, False, "up", "in")])
def test_bgk_matches_discrete_mc(K, H, c, d, k):
mc, se = barrier_mc(S, K, H, T, R, Q, V, c, d, k, n_steps=126, n_paths=100_000)
bgk = barrier_price(S, K, H, T, R, Q, V, c, d, k, monitoring_per_year=252)
cont = barrier_price(S, K, H, T, R, Q, V, c, d, k)
assert abs(bgk - mc) < 4 * se + 0.01
assert abs(cont - mc) > abs(bgk - mc)
def test_touches_vs_mc():
t = touch_prices(S, 110, T, R, Q, V, monitoring_per_year=252)
p = simulate_paths(S, T, R, Q, V, 126, 100_000, np.random.default_rng(1))
nt = np.exp(-R * T) * (~(p >= 110).any(axis=1)).mean()
assert t["no_touch"] == pytest.approx(nt, abs=0.006)
assert t["no_touch"] + t["one_touch_at_expiry"] == pytest.approx(np.exp(-R * T))
assert t["one_touch_at_hit"] > t["one_touch_at_expiry"] # paid earlier when r > 0
def test_smile_digital_is_minus_dC_dK():
"""With a skewed σ(K) the digital equals the finite-difference −∂C/∂K."""
sig = lambda K: 0.25 - 0.002 * (K - 100)
K, h = 102.0, 1e-3
fd = -(gk_vanilla(S, K + h, T, R, Q, sig(K + h)) - gk_vanilla(S, K - h, T, R, Q, sig(K - h))) / (2 * h)
d = digital_prices(S, K, T, R, Q, 0.25, smile_vol=sig(K), smile_slope=-0.002)
assert d["call_smile"] == pytest.approx(fd, abs=1e-6)
assert d["call_smile"] > d["call_flat"] # negative skew richens call digitals
def test_dci_fair_apr_and_implied_vol():
q = dci_quote(60000, 65000, 7 / 365, 0.04, 0.0, 0.5, "sell_high")
assert 0 < q["prob_conversion"] < 0.5 and q["fair_apr"] > 0
audit = dci_quote(60000, 65000, 7 / 365, 0.04, 0.0, 0.5, "sell_high", offered_apr=q["fair_apr"] * 0.8)
assert audit["margin_apr"] > 0 and audit["implied_vol_paid"] < 0.5
hi = dci_quote(60000, 65000, 7 / 365, 0.04, 0.0, 0.8, "sell_high")
assert hi["fair_apr"] > q["fair_apr"]
def test_accumulator_zero_cost():
rep = accumulator_report(S, 0.5, 0.04, 0.0, 0.3, ko_pct=105, n_paths=5000)
assert abs(rep["pv_to_client"]) < 1e-6
assert rep["zero_cost_strike"] < S and 0 < rep["prob_knock_out"] < 1
j = accumulator_report(S, 0.5, 0.04, 0.0, 0.3, ko_pct=105, n_paths=5000,
jump={"lambda": 4, "mean": -0.05, "sd": 0.05})
assert j["pnl"]["p01"] < rep["pnl"]["p01"] # crash jumps fatten the loss tail
def test_endpoints():
base = {"pair": "XAUUSD", "S": 2650, "T": 0.25, "r_d": 0.0525, "r_f": 0.0, "sigma": 0.18}
r = client.post("/api/exotics/barrier", json={**base, "K": 2650, "H_down": 2450, "H_up": 2850,
"rr25": 0.02, "bf25": 0.005})
assert r.status_code == 200, r.text
js = r.json()
assert len(js["rows"]) == 8 and len(js["touch_ladder"]) == 24
assert js["digital"]["smile_adjustment"] != 0
assert client.post("/api/exotics/barrier", json={**base, "K": 2650, "H_down": 2700, "H_up": 2850}).status_code == 422
r = client.post("/api/exotics/dci", json={**base, "pair": "BTCUSD", "S": 60000, "T": 7 / 365, "sigma": 0.5,
"offered_apr": 0.3, "strike_pct_offer": 105})
assert r.status_code == 200 and all(x["strike_pct"] > 100 for x in r.json()["ladder"])
r = client.post("/api/exotics/accumulator", json={**base, "n_paths": 2000})
assert r.status_code == 200 and r.json()["zero_cost_strike"] < 2650
assert client.post("/api/exotics/dci", json={**base, "pair": "NOPE"}).status_code == 404
+5
View File
@@ -46,3 +46,8 @@ export const fetchInstitutional = (pair, weeks) =>
// Economic calendar (Forex Factory)
export const fetchCalendar = () =>
api.get('/news/calendar').then(r => r.data)
// Exotics Desk
export const priceBarriers = (p) => api.post('/exotics/barrier', p).then(r=>r.data)
export const priceDci = (p) => api.post('/exotics/dci', p).then(r=>r.data)
export const priceAccumulator = (p) => api.post('/exotics/accumulator', p).then(r=>r.data)
+402
View File
@@ -0,0 +1,402 @@
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 { priceBarriers, priceDci, priceAccumulator } 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 dpOf = (pair) => PAIR_META[pair]?.dp ?? (PAIR_META[pair]?.pip === 0.01 ? 3 : 5)
const roundTo = (x, dp) => +Number(x).toFixed(dp)
// Debounced POST keyed by the request body; stale responses are dropped.
function useQuote(fn, body, enabled = true) {
const key = JSON.stringify(body)
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?' }) })
}, 250)
return () => { stale = true; clearTimeout(t) }
}, [fn, key, enabled])
return { ...res, loading: res.key !== key }
}
export default function ExoticsDesk() {
const { pair } = usePortfolioStore()
const [tab, setTab] = useState('barrier')
return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'flex-start', gap:12, marginBottom:12, flexWrap:'wrap' }}>
<div>
<div className="section-title">Exotics Desk — barriers, touches, Dual Investment &amp; accumulators ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Price what clients are actually sold: KO/KI and touch structures, crypto/gold DCI yields, KO accumulators
</div>
</div>
<div style={{ display:'flex', gap:4, flexWrap:'wrap' }}>
<Chip active={tab === 'barrier'} onClick={() => setTab('barrier')}>Barriers &amp; touches</Chip>
<Chip active={tab === 'dci'} onClick={() => setTab('dci')}>Dual Investment (DCI)</Chip>
<Chip active={tab === 'acc'} onClick={() => setTab('acc')}>Accumulator</Chip>
</div>
</div>
{tab === 'barrier' && <Barriers key={pair} pair={pair} />}
{tab === 'dci' && <Dci key={pair} pair={pair} />}
{tab === 'acc' && <Accumulator key={pair} pair={pair} />}
</div>
)
}
const Chip = ({ active, onClick, children }) => (
<button onClick={onClick} className="font-mono" style={{ padding:'3px 9px', borderRadius:4, fontSize:9, cursor:'pointer',
border:'1px solid ' + (active ? 'var(--cyan)' : 'var(--border)'), background: active ? 'rgba(0,212,255,0.1)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)' }}>{children}</button>
)
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>
)
}
function ErrorBox({ msg }) {
return <div style={{ padding:8, borderRadius:6, fontSize:11, marginBottom:10, background:'rgba(255,61,90,0.1)', border:'1px solid #ff3d5a', color:'#ff3d5a' }}>{msg}</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 }) {
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} onChange={e => onChange(+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>
)
}
const Panel = ({ children, note }) => (
<div className="glass" style={{ borderRadius:8, padding:10, marginBottom:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fill, minmax(120px, 1fr))', gap:10, alignItems:'end' }}>{children}</div>
{note && <div style={{ fontSize:9, color:'var(--text-muted)', marginTop:6, lineHeight:1.5 }}>{note}</div>}
</div>
)
const MONITORING = [['0', 'Continuous'], ['252', 'Daily close (252/y)'], ['365', 'Daily, 24/7 (365/y)'], ['8760', 'Hourly (8760/y)']]
// ─── Barriers, touches & digitals ─────────────────────────────────────────────
function Barriers({ pair }) {
const { S, sigma, T, r_d, r_f, smileQuotes } = usePortfolioStore()
const dp = dpOf(pair)
const [p, setP] = useState(() => {
const sd = sigma * Math.sqrt(T)
return { K: roundTo(S, dp), H_down: roundTo(S * Math.exp(-sd), dp), H_up: roundTo(S * Math.exp(sd), dp),
T: +T, sigma: +(sigma * 100).toFixed(2), rebate: 0, monitoring: isCrypto(pair) ? '365' : '252',
product: 'call|up|out', useSmile: !!smileQuotes }
})
const set = (k) => (v) => setP(q => ({ ...q, [k]: v }))
const [cp, direction, knock] = p.product.split('|')
const smile = p.useSmile && smileQuotes
const body = { pair, S, T: p.T, r_d, r_f, sigma: (smile ? smileQuotes.atm : p.sigma) / 100, K: p.K, H_down: p.H_down, H_up: p.H_up,
rebate: p.rebate, monitoring_per_year: +p.monitoring || null, is_call: cp === 'call', direction, knock,
rr25: smile ? smileQuotes.rr25 / 100 : null, bf25: smile ? smileQuotes.bf25 / 100 : null }
const { data: d, error, loading } = useQuote(priceBarriers, body)
const sel = d?.rows.find(r => r.type === cp && r.direction === direction && r.knock === knock)
const [tDown, tUp] = d?.touches ?? []
return (
<div>
<Panel note={<>
Reiner–Rubinstein closed forms (Haug A–F) with rebates; discrete fixings via the Broadie–Glasserman–Kou shift
H·e<sup>±0.5826σ√Δt</sup>{d ? ` (${d.bgk_shift_pct.toFixed(2)}% here)` : ''}. Crypto barriers monitor 24/7 — use 365/y.
{smileQuotes ? ' Digitals use the vanna-volga smile from Vol Smile / Vol Lab when "smile" is on.' : ' Load RR/BF quotes in Vol Smile or Vol Lab to get smile-consistent digitals.'}
</>}>
<Select label="Structure" value={p.product} onChange={set('product')} options={[
['call|up|out', 'Up-and-out call'], ['call|up|in', 'Up-and-in call'], ['call|down|out', 'Down-and-out call'], ['call|down|in', 'Down-and-in call'],
['put|down|out', 'Down-and-out put'], ['put|down|in', 'Down-and-in put'], ['put|up|out', 'Up-and-out put'], ['put|up|in', 'Up-and-in put']]} />
<Field label="Strike K" value={p.K} onChange={set('K')} step={PAIR_META[pair]?.strikeStep ?? 0.001} />
<Field label="Lower barrier" value={p.H_down} onChange={set('H_down')} step={PAIR_META[pair]?.strikeStep ?? 0.001} />
<Field label="Upper barrier" value={p.H_up} onChange={set('H_up')} step={PAIR_META[pair]?.strikeStep ?? 0.001} />
<Field label="Tenor (years)" value={p.T} onChange={set('T')} step={0.05} />
<Field label={smile ? 'σ (ATM from smile, %)' : 'σ (%)'} value={smile ? smileQuotes.atm : p.sigma} onChange={set('sigma')} step={0.5} />
<Field label="Rebate" value={p.rebate} onChange={set('rebate')} step={0.1} hint="Knock-out: paid at hit · knock-in: paid at expiry if never triggered" />
<Select label="Monitoring" value={p.monitoring} onChange={set('monitoring')} options={MONITORING} />
{smileQuotes && <Select label="Digital vol" value={p.useSmile ? 'smile' : 'flat'} onChange={v => set('useSmile')(v === 'smile')}
options={[['smile', `Smile (RR ${smileQuotes.rr25}, BF ${smileQuotes.bf25})`], ['flat', 'Flat σ']]} />}
</Panel>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
{ label: sel ? `${knock === 'out' ? 'KO' : 'KI'} price (discrete)` : 'Price', value: sel ? num(sel.discrete, dp) : '—',
sub: sel ? `continuous ${num(sel.continuous, dp)}` : null },
{ label:'% of vanilla', value: sel?.pct_of_vanilla == null ? '—' : sel.pct_of_vanilla.toFixed(1) + '%', sub: sel ? `vanilla ${num(sel.vanilla, dp)}` : null,
hint:'Cost saving of the barrier vs the plain option' },
{ label:`P(touch ${num(tUp.barrier, dp)})`, value:pct(tUp.prob_touch), color:'var(--green)',
sub:`finish above: ${pct(tUp.prob_finish_beyond)}`, hint:'Reflection rule of thumb: touch ≈ 2 × finish-beyond' },
{ label:`P(touch ${num(tDown.barrier, dp)})`, value:pct(tDown.prob_touch), color:'var(--red)', sub:`finish below: ${pct(tDown.prob_finish_beyond)}` },
{ label:'Double no-touch proxy', value:pct(Math.max(0, 1 - tUp.prob_touch - tDown.prob_touch)),
hint:'Lower bound 1 − P(up) − P(down); a true DNT is slightly richer' },
{ label:`Digital call @ ${num(d.digital.strike, dp)}`, value:pct(d.digital.call_smile),
sub:`flat ${pct(d.digital.call_flat)} · skew ${(d.digital.smile_adjustment * 100).toFixed(2)}pp`,
color: d.digital.smile_adjustment >= 0 ? 'var(--green)' : 'var(--red)', hint:'Price per 1 unit paid if S_T > K (−∂C/∂K under the smile)' },
]} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title={`${knock === 'out' ? 'Knock-out' : 'Knock-in'} ${cp} vs vanilla across spot`}>
<Plot data={[
{ x:d.selected.spots, y:d.selected.vanilla, type:'scatter', mode:'lines', name:'Vanilla', line:{ color:'rgba(255,255,255,0.35)', dash:'dash', width:1.2 } },
{ x:d.selected.spots, y:d.selected.price, type:'scatter', mode:'lines', name:'Barrier', line:{ color:'#00d4ff', width:2.5 } },
{ x:d.selected.spots, y:d.selected.delta, type:'scatter', mode:'lines', name:'Barrier Δ (rhs)', yaxis:'y2', line:{ color:'#c084fc', width:1.3 } },
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Spot'), yaxis:axis('Price'),
yaxis2:{ overlaying:'y', side:'right', color:'#c084fc', showgrid:false, zeroline:false, title:{ text:'Δ' } },
shapes:[
{ type:'line', x0:d.selected.barrier, x1:d.selected.barrier, yref:'paper', y0:0, y1:1, line:{ color:'#ff3d5a', dash:'dot', width:1.5 } },
{ type:'line', x0:S, x1:S, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,215,0,0.5)', dash:'dot', width:1 } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
Red = barrier, gold = spot. Watch Δ near the barrier: a reverse KO (barrier in the money) has a delta gap — the pin risk dealers hedge with barrier shifts.
</div>
</Card>
<Card title="Touch ladder — hit probability by barrier (daily fixings)">
<Plot data={[
{ x:d.touch_ladder.map(r => r.barrier), y:d.touch_ladder.map(r => r.prob_touch * 100), type:'bar', name:'P(touch)',
marker:{ color:d.touch_ladder.map(r => r.barrier > S ? 'rgba(0,255,136,0.55)' : 'rgba(255,61,90,0.55)') },
customdata:d.touch_ladder.map(r => r.sd.toFixed(2)), hovertemplate:'H %{x}<br>%{customdata} sd<br>P(touch) %{y:.1f}%<extra></extra>' },
]}
layout={{ ...LAYOUT, showlegend:false, xaxis:axis('Barrier'), yaxis:axis('P(touch before expiry) %', { range:[0, 100] }),
shapes:[{ type:'line', x0:S, x1:S, yref:'paper', y0:0, y1:1, line:{ color:'rgba(255,215,0,0.5)', dash:'dot', width:1 } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
Risk-neutral odds of a level trading before expiry — compare with prediction-market &quot;will gold hit X&quot; contracts.
</div>
</Card>
</div>
<BarrierTable rows={d.rows} dp={dp} sel={sel} />
</div>
)}
</div>
)
}
function BarrierTable({ rows, dp, sel }) {
const name = r => `${r.direction === 'up' ? 'Up' : 'Down'}-and-${r.knock} ${r.type}`
return (
<Card title="All eight single barriers (same K, lower/upper barrier)">
<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' }}>Structure</th><th style={{ padding:'4px 8px' }}>Barrier</th>
<th style={{ padding:'4px 8px' }}>Continuous</th><th style={{ padding:'4px 8px' }}>Discrete (BGK)</th>
<th style={{ padding:'4px 8px' }}>Vanilla</th><th style={{ padding:'4px 8px' }}>% vanilla</th></tr></thead>
<tbody>{rows.map(r => (
<tr key={name(r)} style={{ borderTop:'1px solid var(--border)', background: r === sel ? 'rgba(0,212,255,0.06)' : 'transparent' }}>
<td style={{ padding:'5px 8px' }}>{name(r)}</td>
{[r.barrier, r.continuous, r.discrete, r.vanilla].map((v, i) => (
<td key={i} className="font-mono" style={{ padding:'5px 8px', textAlign:'right', color: i === 2 ? 'var(--cyan)' : undefined }}>{num(v, dp)}</td>))}
<td className="font-mono" style={{ padding:'5px 8px', textAlign:'right' }}>{r.pct_of_vanilla == null ? '—' : r.pct_of_vanilla.toFixed(1) + '%'}</td>
</tr>))}</tbody>
</table>
</div>
</Card>
)
}
// ─── Dual Currency Investment ────────────────────────────────────────────────
function Dci({ pair }) {
const { S, sigma, r_d, r_f } = usePortfolioStore()
const dp = dpOf(pair)
const base = pair.slice(0, 3), quote = pair.slice(3)
const [p, setP] = useState({ side:'sell_high', days:7, sigma:+(sigma * 100).toFixed(2), offered:'', strikeOffer:'' })
const set = (k) => (v) => setP(q => ({ ...q, [k]: v }))
const strikes = p.side === 'sell_high' ? [101, 102, 103, 104, 105, 106, 108, 110, 115] : [85, 90, 92, 94, 95, 96, 97, 98, 99]
const audit = p.offered !== '' && p.strikeOffer !== ''
const body = { pair, S, T: p.days / 365, r_d, r_f, sigma: p.sigma / 100, side: p.side, strikes_pct: strikes,
offered_apr: audit ? +p.offered / 100 : null, strike_pct_offer: audit ? +p.strikeOffer : null }
const { data: d, error, loading } = useQuote(priceDci, body, p.days > 0)
const a = d?.audit
return (
<div>
<Panel note={<>
A DCI (&quot;Dual Investment&quot; on Binance/OKX/Bybit/KuCoin, &quot;Dual Currency Deposit&quot; at banks) is a deposit plus a
short option. Sell-high: deposit {base}, short a {base} call — converts to {quote} at K if spot finishes above. Buy-low: deposit {quote},
short a put — you buy {base} at K. Fair APR = deposit rate + option premium ÷ tenor. Enter a platform&apos;s APR to see the vol it pays you.
</>}>
<Select label="Product" value={p.side} onChange={set('side')} options={[['sell_high', `Sell high (deposit ${base})`], ['buy_low', `Buy low (deposit ${quote})`]]} />
<Field label="Tenor (days)" value={p.days} onChange={set('days')} step={1} />
<Field label="σ (%)" value={p.sigma} onChange={set('sigma')} step={1} hint="Use the Deribit ATM vol from Vol Lab for BTC/ETH" />
<Field label="Offered APR (%)" value={p.offered} onChange={v => set('offered')(Number.isFinite(v) ? v : '')} step={1} />
<Field label="…at strike (% spot)" value={p.strikeOffer} onChange={v => set('strikeOffer')(Number.isFinite(v) ? v : '')} step={1} />
</Panel>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
{a && <Kpis items={[
{ label:`Fair APR @ ${a.strike_pct}%`, value:pct(a.fair_apr, 1) },
{ label:'Offered APR', value:pct(a.offered_apr, 1) },
{ label:'Platform margin', value:`${(a.margin_apr * 100).toFixed(1)}% APR`, color: a.margin_apr > 0 ? 'var(--red)' : 'var(--green)',
hint:'Fair − offered: the yield the platform keeps' },
{ label:'Vol you are paid', value:pct(a.implied_vol_paid, 1), sub: a.vol_haircut != null ? `${(a.vol_haircut * 100).toFixed(1)} vol below σ` : null,
color:'var(--gold, #ffd700)', hint:'Implied vol at which the offered APR is fair' },
{ label:'P(conversion)', value:pct(a.prob_conversion, 1), color:'var(--red)' },
{ label:'Breakeven', value:num(a.breakeven, dp) },
]} />}
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title={`Fair APR vs conversion odds · ${p.days}d ${p.side === 'sell_high' ? 'sell-high' : 'buy-low'}`}>
<Plot data={[
{ x:d.ladder.map(r => r.strike), y:d.ladder.map(r => r.fair_apr * 100), type:'bar', name:'Fair APR %', marker:{ color:'rgba(0,212,255,0.55)' } },
{ x:d.ladder.map(r => r.strike), y:d.ladder.map(r => r.prob_conversion * 100), type:'scatter', mode:'lines+markers', name:'P(conversion) %',
yaxis:'y2', line:{ color:'#ff3d5a', width:2 } },
...(a ? [{ x:[S * a.strike_pct / 100], y:[a.offered_apr * 100], type:'scatter', mode:'markers', name:'Offered',
marker:{ color:'#ffd700', size:12, symbol:'diamond' } }] : []),
]}
layout={{ ...LAYOUT, hovermode:'x unified', xaxis:axis('Target price (strike)'), yaxis:axis('APR %'),
yaxis2:{ overlaying:'y', side:'right', color:'#ff3d5a', showgrid:false, zeroline:false, title:{ text:'P(conv) %' } } }}
config={CFG} style={{ width:'100%', height:300 }} />
</Card>
<Card title="Strike ladder">
<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={{ padding:'4px 8px' }}>Strike</th><th style={{ padding:'4px 8px' }}>% spot</th><th style={{ padding:'4px 8px' }}>Premium</th>
<th style={{ padding:'4px 8px' }}>Fair APR</th><th style={{ padding:'4px 8px' }}>P(conv)</th><th style={{ padding:'4px 8px' }}>Breakeven</th></tr></thead>
<tbody>{d.ladder.map(r => (
<tr key={r.strike_pct} style={{ borderTop:'1px solid var(--border)', textAlign:'right' }} className="font-mono">
<td style={{ padding:'5px 8px' }}>{num(r.strike, dp)}</td><td style={{ padding:'5px 8px' }}>{r.strike_pct}%</td>
<td style={{ padding:'5px 8px' }}>{r.premium_pct.toFixed(3)}%</td>
<td style={{ padding:'5px 8px', color:'var(--cyan)' }}>{pct(r.fair_apr, 1)}</td>
<td style={{ padding:'5px 8px', color:'var(--red)' }}>{pct(r.prob_conversion, 1)}</td>
<td style={{ padding:'5px 8px' }}>{num(r.breakeven, dp)}</td>
</tr>))}</tbody>
</table>
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'6px 8px' }}>
Breakeven: the spot at which converting at K leaves you level with simply holding, after the premium.
</div>
</Card>
</div>
</div>
)}
</div>
)
}
// ─── Accumulator ──────────────────────────────────────────────────────────────
function Accumulator({ pair }) {
const { S, sigma, r_d, r_f, hedgeJump } = usePortfolioStore()
const dp = dpOf(pair)
const [p, setP] = useState(() => ({ months:6, sigma:+(sigma * 100).toFixed(2), ko:105, strike:'', gearing:2, qty:1,
fixings: isCrypto(pair) ? 365 : 252, jumps: !!hedgeJump || isCrypto(pair),
jump: hedgeJump ?? (isCrypto(pair) ? { intensity:12, mean:-2, sd:6 } : { intensity:2, mean:-5, sd:6 }) }))
const set = (k) => (v) => setP(q => ({ ...q, [k]: v }))
const setJ = (k) => (v) => setP(q => ({ ...q, jump:{ ...q.jump, [k]: v } }))
const body = { pair, S, T: p.months / 12, r_d, r_f, sigma: p.sigma / 100, ko_pct: p.ko, strike_pct: p.strike === '' ? null : +p.strike,
qty: p.qty, gearing: p.gearing, fixings_per_year: p.fixings, n_paths: 20000,
jump_intensity: p.jumps ? p.jump.intensity : 0, jump_mean: p.jumps ? p.jump.mean / 100 : 0, jump_sd: p.jumps ? p.jump.sd / 100 : 0 }
const { data: d, error, loading } = useQuote(priceAccumulator, body, p.months > 0)
return (
<div>
<Panel note={<>
&quot;I kill you later&quot;: the client buys {p.qty} unit(s) at K every fixing, {p.gearing}× when the fixing is below K, until a fixing
trades ≥ KO. Leaving the strike blank solves the zero-cost strike by Monte Carlo (20k paths, common random numbers).
Jumps come from Jump Lab when sent there. P&amp;L marks accumulated units at the KO / final fixing.
</>}>
<Field label="Tenor (months)" value={p.months} onChange={set('months')} step={1} />
<Field label="σ (%)" value={p.sigma} onChange={set('sigma')} step={1} />
<Field label="Knock-out (% spot)" value={p.ko} onChange={set('ko')} step={0.5} />
<Field label="Strike (% spot, blank = zero-cost)" value={p.strike} onChange={v => set('strike')(Number.isFinite(v) && v > 0 ? v : '')} step={0.5} />
<Field label="Gearing" value={p.gearing} onChange={set('gearing')} step={0.5} />
<Field label="Units / fixing" value={p.qty} onChange={set('qty')} step={1} />
<Select label="Fixings / year" value={String(p.fixings)} onChange={v => set('fixings')(+v)} options={[['252', '252 (trading days)'], ['365', '365 (24/7 crypto)'], ['52', '52 (weekly)']]} />
<Select label="Dynamics" value={p.jumps ? 'jump' : 'gbm'} onChange={v => set('jumps')(v === 'jump')} options={[['gbm', 'GBM'], ['jump', 'Merton jumps']]} />
{p.jumps && <>
<Field label="Jumps / year" value={p.jump.intensity} onChange={setJ('intensity')} step={1} />
<Field label="Mean jump (%)" value={p.jump.mean} onChange={setJ('mean')} step={0.5} />
<Field label="Jump sd (%)" value={p.jump.sd} onChange={setJ('sd')} step={0.5} />
</>}
</Panel>
{error && <ErrorBox msg={error} />}
{d && (
<div style={{ opacity: loading ? 0.6 : 1, transition:'opacity .2s' }}>
<Kpis items={[
{ label:'Zero-cost strike', value:num(d.zero_cost_strike, dp), sub:`${d.zero_cost_strike_pct.toFixed(2)}% of spot` },
{ label:'Strike used', value:num(d.strike, dp), sub:`KO ${num(d.ko_level, dp)}` },
{ label:'PV to client', value:num(d.pv_to_client, dp), sub:`± ${num(d.pv_se, dp)} (MC s.e.)`,
color: d.pv_to_client >= 0 ? 'var(--green)' : 'var(--red)' },
{ label:'P(knock-out)', value:pct(d.prob_knock_out), sub: d.expected_ko_days ? `avg after ${d.expected_ko_days.toFixed(0)} days` : null },
{ label:'Expected units', value:num(d.expected_units, 1), sub:`max ${num(d.max_units, 0)}` },
{ label:'P(loss)', value:pct(d.pnl.prob_loss), color:'var(--red)' },
{ label:'1% worst P&L', value:num(d.pnl.p01, dp), sub:`ES5% ${num(d.pnl.es05, dp)}`, color:'var(--red)' },
]} />
<div style={{ display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(420px, 1fr))', gap:12 }}>
<Card title="Client P&L distribution at the end of the contract">
<Plot data={[{ x:d.histogram.centers, y:d.histogram.counts, type:'bar', name:'Paths',
marker:{ color:d.histogram.centers.map(c => c < 0 ? 'rgba(255,61,90,0.6)' : 'rgba(0,255,136,0.55)') } }]}
layout={{ ...LAYOUT, showlegend:false, bargap:0.05, xaxis:axis(`P&L (${pair.slice(3)})`), yaxis:axis('Paths'),
shapes:[{ type:'line', x0:d.pnl.p01, x1:d.pnl.p01, yref:'paper', y0:0, y1:1, line:{ color:'#ff3d5a', dash:'dot', width:1.5 } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>
Capped upside (knocked out early), uncapped geared downside — the asymmetry behind the 2008 and 2022 accumulator blow-ups.
</div>
</Card>
<Card title="Sample paths vs strike and knock-out">
<Plot data={d.sample_paths.map((y, i) => ({ y, type:'scatter', mode:'lines', name:`path ${i + 1}`, line:{ width:1.2 } }))}
layout={{ ...LAYOUT, showlegend:false, xaxis:axis('Fixing (sampled)'), yaxis:axis('Spot'),
shapes:[
{ type:'line', xref:'paper', x0:0, x1:1, y0:d.ko_level, y1:d.ko_level, line:{ color:'#00ff88', dash:'dash', width:1.5 } },
{ type:'line', xref:'paper', x0:0, x1:1, y0:d.strike, y1:d.strike, line:{ color:'#ff3d5a', dash:'dash', width:1.5 } }] }}
config={CFG} style={{ width:'100%', height:300 }} />
<div style={{ fontSize:9, color:'var(--text-muted)', padding:'0 8px 4px' }}>Green = KO level, red = strike (below it the client buys {p.gearing}×).</div>
</Card>
</div>
</div>
)}
</div>
)
}
+2
View File
@@ -14,6 +14,7 @@ import VolForecast from '../components/VolForecast'
import HedgeLab from '../components/HedgeLab'
import CrossAsset from '../components/CrossAsset'
import JumpLab from '../components/JumpLab'
import ExoticsDesk from '../components/ExoticsDesk'
const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap'))
@@ -48,6 +49,7 @@ const TABS = [
{ id:'volforecast', icon:'🤖', label:'Vol Forecast', component: VolForecast },
{ id:'hedgelab', icon:'⚖', label:'Hedge Lab', component: HedgeLab },
{ id:'jumps', icon:'⚡', label:'Jump Lab', component: JumpLab },
{ id:'exotics', icon:'🧩', label:'Exotics Desk', component: ExoticsDesk },
{ 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: 433 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB