mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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("/")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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 & 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 "will gold hit X" 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 ("Dual Investment" on Binance/OKX/Bybit/KuCoin, "Dual Currency Deposit" 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'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={<>
|
||||
"I kill you later": 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&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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user