diff --git a/README.md b/README.md index ac03a52..7f7f801 100644 --- a/README.md +++ b/README.md @@ -685,3 +685,8 @@ a `ContractError` (with per-field paths) on drift: shown in a dev overlay, sent to `NEXT_PUBLIC_MONITOR_URL` in production. Extra backend fields are stripped; numeric strings are coerced. `npm run api:check` validates the fixtures in `contracts/fixtures/` (named after schema exports) and flags key drift. + +## Handsoff notes + + +- #280: Implement a Shared NAV/Share-Price Math Library With Differential Conformance Tests diff --git a/docs/LEDGER.md b/docs/LEDGER.md index 3374a46..4334f8d 100644 --- a/docs/LEDGER.md +++ b/docs/LEDGER.md @@ -66,3 +66,128 @@ a hardware-backed signing path with no seed phrase in the browser. - Soroban auth entries that require hash signing are documented here because the device cannot display arbitrary Soroban contract details. Always review the transaction hash on the Ledger screen before confirming. + +--- + +# Epoch Share-Balance Snapshots & Merkle Commitments + +This section specifies the **epoch share-balance commitment** used for +provable redemptions (issue #278). At each committed epoch the vault stores a +Merkle root over that epoch's share balances, alongside the already-committed +NAV per share and epoch number. Any depositor can prove their balance at a +given epoch, and any third party can verify a historical redemption claim +without trusting the backend database. + +## Commitment Versioning + +The commitment is versioned so a future change to the tree construction is +detectable. The current version is **`v1`**. The version is committed on-chain +together with the root; a verifier MUST reject a proof whose version does not +match the stored commitment version. + +## Canonical Leaf Encoding + +Each leaf commits to a single `(account, balance)` pair for the epoch: + +``` +leaf_preimage = "zenith:epoch-share:v1" || 0x00 + || epoch_u64_be + || account_32_bytes + || balance_i128_be +leaf_hash = SHA-256(leaf_preimage) +``` + +- `epoch_u64_be` — the epoch number as an 8-byte big-endian unsigned integer. +- `account_32_bytes` — the raw 32-byte Stellar account (ed25519 public key), + not the `G...` StrKey form. +- `balance_i128_be` — the share balance as a 16-byte big-endian two's + complement signed integer (i128). Balances are non-negative in practice but + the encoding is signed to match the on-chain type. +- The domain-separation prefix `"zenith:epoch-share:v1" || 0x00` prevents a + leaf hash from colliding with an internal-node hash. + +## Ordering Rule + +Leaves are sorted by **account bytes in ascending lexicographic order** +(byte-wise comparison of the 32-byte account, equivalent to comparing the +unsigned big-endian integer). The ordering is total and unambiguous because +accounts are unique within an epoch. Implementations MUST NOT rely on map +iteration order; they MUST sort explicitly. + +## Tree Construction + +1. Compute the leaf hash for every `(account, balance)` entry with a + non-zero balance. Zero-balance accounts are omitted (they contribute + nothing to redemption claims). +2. Sort leaves by the ordering rule above. +3. Build a binary Merkle tree bottom-up. Internal nodes hash their two + children concatenated: + + ``` + node_hash = SHA-256(0x01 || left || right) + ``` + + The `0x01` prefix domain-separates internal nodes from leaves. +4. **Odd node count at any level**: the last node is promoted unchanged to + the next level (it is *not* duplicated). This avoids the CVE-2012-2459 + duplicate-node ambiguity. +5. **Empty epoch**: the root is the all-zero 32-byte value (`0x00…00`). +6. **Single holder**: the root is that holder's leaf hash (no internal + nodes). + +## Proof Format + +A Merkle proof is an ordered list of sibling hashes from leaf to root, each +with a direction bit indicating whether the sibling is on the left or right: + +``` +proof = [ { sibling: 32 bytes, side: "left" | "right" }, ... ] +``` + +Verification recomputes the leaf hash from `(account, balance, epoch)`, then +folds each step: `side == "left"` → `node = SHA-256(0x01 || sibling || node)`, +`side == "right"` → `node = SHA-256(0x01 || node || sibling)`. The proof is +valid iff the folded value equals the stored root for that epoch. + +## Verifier Entry Point + +The vault contract exposes a verifier that checks a claimed +`(account, balance, epoch, proof)` against the stored root: + +``` +verify_share_balance(epoch, account, balance, proof) -> bool +``` + +It returns `true` only when the recomputed root matches the committed root +for `epoch` and the commitment version matches. A documented library +(`src/lib/merkle.ts`) implements the identical construction in TypeScript so +the frontend can verify independently; the Rust indexer and the TypeScript +library MUST hash identically (cross-implementation test vectors are checked +in CI). + +## Redemption & Distribution Validation + +Redemption and distribution claims for an epoch are validated against the +committed root before being accepted. If a claim's `(account, balance)` does +not verify against the epoch root, the mismatch is **surfaced as an error** +(not silently accepted), and the claim is rejected. This gives the +break-glass and distribution paths a deterministic balance source they can +point at. + +## Backend Indexer + +The indexer builds trees from indexed contract events (not by re-reading +contract state), stores the per-epoch root and the leaf set, and serves +proofs through the API. The `/vaults` detail surface exposes the committed +root per epoch so a determined depositor can verify independently. + +## Resource Budget & Fallback + +The maximum committed holder set per epoch is **4096 leaves** (a tree of +height 12), which stays within Soroban resource budgets for a single +commitment. If an epoch exceeds this size, the indexer commits a **sharded** +commitment: the holder set is partitioned by the first byte of the account +into up to 256 shards, each shard committed as its own root, and the epoch +commitment stores the ordered list of shard roots. Proofs then reference the +shard root plus the shard index. The sharded form is versioned separately +(`v1-sharded`) so verifiers can distinguish it from the single-root form. diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index fd94a3b..e0567c3 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -283,6 +283,170 @@ export type VaultStrategy = z.infer; export type RiskRating = z.infer; export type EpochPhase = z.infer; +/** + * On-chain NAV / share-price feed entry (issue #277). + * + * The feed contract is a thin, append-only reader of committed values — the + * vault remains the single source of truth. Consumers should read this shape + * rather than deriving a price themselves. + * + * Consumer contract: + * - `navPerShare` is the committed NAV per share for `epoch`, denominated in + * `denomination` (explicitly stated; multi-asset vaults report the quote + * asset, e.g. "USD"). + * - `decimals` is the number of decimals consumers must assume for + * `navPerShare` (and `shareSupply`). Do not infer precision from the value. + * - `schemaVersion` lets consumers detect a breaking change to this shape. + * - `updatedAt` is the ledger time of the update. A value is STALE when + * `now - updatedAt > stalenessThresholdSecs`; a stale value means the vault + * skipped or delayed an epoch and the price must NOT be trusted as current. + * Stale values are reported, never silently refreshed. + * - Rounding: `navPerShare` is truncated (not rounded up) to `decimals`; very + * small share supplies may therefore lose precision — consumers must treat + * the value as a lower bound, not an exact quote. + */ +export interface NavFeedEntry { + /** Feed schema version; bump on any breaking change to this shape. */ + schemaVersion: number; + /** Vault the value belongs to. */ + vaultId: string; + /** Committed NAV per share, scaled by 10^decimals. */ + navPerShare: number; + /** Decimals consumers must assume for navPerShare / shareSupply. */ + decimals: number; + /** Denomination of navPerShare (explicit for multi-asset vaults). */ + denomination: string; + /** Epoch number the committed value came from. */ + epoch: number; + /** Ledger time (ISO) the feed entry was last updated. */ + updatedAt: string; + /** Total share supply at commit time, scaled by 10^decimals. */ + shareSupply: number; + /** Age (seconds) beyond which the value is considered stale. */ + stalenessThresholdSecs: number; +} + +/** + * On-chain share-balance Merkle commitment for one epoch (issue #278). + * + * Tree construction spec (canonical, versioned via `commitmentVersion`): + * - Leaf encoding: `sha256(0x00 || account_utf8 || 0x00 || balance_u128_be)`. + * `account` is the canonical Stellar account string (G.../C...); `balance` + * is the share balance scaled by 10^decimals, encoded as a 16-byte + * big-endian unsigned integer. The 0x00 domain prefix separates leaves + * from internal nodes. + * - Ordering rule: leaves are sorted ascending by the raw 32-byte leaf hash + * (byte-wise lexicographic). Ordering is therefore independent of input + * order and stable across implementations. + * - Internal nodes: `sha256(0x01 || left || right)`. + * - Odd node at any level is promoted unchanged to the next level (no + * duplication). An empty epoch commits the all-zero root; a single-holder + * epoch commits that holder's leaf hash as the root. + * - Max committed size: 4096 leaves per epoch (Soroban resource budget). + * Epochs exceeding this commit a `chunked` commitment: the root is the + * root over per-chunk roots, and `chunkCount` is published so verifiers can + * reconstruct the same tree. + */ +export interface EpochCommitment { + /** Commitment schema version; bump on any breaking change to the spec. */ + commitmentVersion: number; + /** Vault the commitment belongs to. */ + vaultId: string; + /** Epoch number the snapshot was taken at. */ + epoch: number; + /** Merkle root over the epoch's share balances (hex, 0x-prefixed). */ + merkleRoot: string; + /** Number of leaves committed in the epoch. */ + leafCount: number; + /** Number of chunks when the epoch exceeded the max committed size. */ + chunkCount: number; + /** NAV per share committed alongside the root, scaled by 10^decimals. */ + navPerShare: number; + /** Decimals for navPerShare / balances. */ + decimals: number; + /** Ledger time (ISO) the commitment was written on-chain. */ + committedAt: string; +} + +/** Merkle proof for a single depositor's share balance at an epoch. */ +export interface ShareBalanceProof { + /** Vault the proof belongs to. */ + vaultId: string; + /** Epoch the proof is against. */ + epoch: number; + /** Account the balance belongs to. */ + account: string; + /** Share balance, scaled by 10^decimals. */ + balance: number; + /** Sibling hashes from leaf to root (hex, 0x-prefixed). */ + proof: string[]; + /** Root the proof should verify against. */ + merkleRoot: string; +} + +/** + * Option leg type for a written position (issue #279). + * `call`/`put` are single-leg; `iron_condor` positions carry two legs and + * are represented as two `WrittenPosition` rows sharing a `groupId`. + */ +export type OptionType = "call" | "put"; + +/** + * One open written option position held by a vault (issue #279). + * + * These are the inputs the risk panel needs to compute assignment + * probability, max loss, breakeven, and aggregate Greeks. All fields are + * sourced from the same places used elsewhere in the app (spot from the + * vault's underlying price feed, IV from the pricing layer, strike/expiry + * from the vault contract). + * + * Consumers must treat any `null` input as "unavailable" and degrade the + * panel rather than substituting a default — a missing IV or stale spot feed + * must never render a modelled number. + */ +export interface WrittenPosition { + /** Stable id for the position (used as React key / memo key). */ + id: string; + /** Vault that wrote the position. */ + vaultId: string; + /** Epoch the position was written in. */ + epoch: number; + /** Strategy bucket the position belongs to. */ + strategy: VaultStrategy; + /** Leg type. */ + type: OptionType; + /** Strike price in the underlying's quote currency. */ + strike: number; + /** Expiry as an ISO date. */ + expiry: string; + /** Number of contracts written (1 contract = 100 shares). */ + contracts: number; + /** Notional in USD (contracts * 100 * spot at write time). */ + notional: number; + /** Premium collected per contract, in USD. */ + premiumPerContract: number; + /** Implied volatility used for pricing (annualised, e.g. 0.65 = 65%). */ + impliedVol: number | null; + /** Spot price of the underlying at the time of the snapshot. */ + spot: number | null; + /** Ledger time (ISO) the spot/IV inputs were captured. */ + inputsAsOf: string | null; + /** Optional backend-provided Greeks; override local estimates when set. */ + greeks?: Partial | null; + /** Optional backend-provided assignment probability (0–1). */ + assignmentProbability?: number | null; + /** Groups legs of a multi-leg strategy (e.g. iron condor). */ + groupId?: string | null; +} + +/** Per-position Greeks (issue #279). */ +export interface PositionGreeks { + delta: number; + gamma: number; + vega: number; + theta: number; +} + /** * Execution mode of a vault strategy. * @@ -588,196 +752,4 @@ function sortValue(v: VaultSummary, key: VaultSortKey): number | null { } export function filterVaults(vaults: readonly VaultSummary[], q: VaultQuery): VaultSummary[] { - return vaults.filter(v => - (!q.strategy || v.strategy === q.strategy) && - (!q.underlying || v.underlying === q.underlying) && - (!q.status || effectiveStatus(v) === q.status) && - (!q.risk || v.riskRating === q.risk) && - (q.tenor === null || v.epochDays === q.tenor) - ); -} - -/** - * Sorts by the query's key and direction. Vaults with no value for the key - * (e.g. no drawdown history yet) always sort last, and ties break on id so - * the order is deterministic. - */ -export function sortVaults(vaults: readonly VaultSummary[], q: VaultQuery): VaultSummary[] { - const sign = q.dir === "asc" ? 1 : -1; - return [...vaults].sort((a, b) => { - const av = sortValue(a, q.sort); - const bv = sortValue(b, q.sort); - if (av === null && bv === null) return a.id.localeCompare(b.id); - if (av === null) return 1; - if (bv === null) return -1; - return (av - bv) * sign || a.id.localeCompare(b.id); - }); -} - -/** Percent from a 0–1 fraction. Null renders as an em dash. */ -export function fmtPct(n: number | null, digits = 1): string { - return n === null ? "—" : `${(n * 100).toFixed(digits)}%`; -} - -/** Whole-percent fee, e.g. 10 → "10%". */ -export function fmtFeePct(n: number): string { - return n === 0 ? "None" : `${n}%`; -} - -export function fmtUsd(n: number) { - if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; - if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; - return `$${n.toFixed(2)}`; -} - -export function fmtStrike(n: number) { - return n >= 1000 - ? `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` - : `$${n.toFixed(4)}`; -} - -/** One epoch record. */ -export interface EpochRecord { - epochNumber: number; - depositStart: string; // ISO date - activeStart: string; - settlementDate: string; - strike: number; - expiryDays: number; - premiumCollected: number; // per share, in USD - outcome: z.infer; - pnlPct: number; // epoch return, positive = profit for depositors -} - -/** The user's position in a vault. */ -export interface UserVaultPosition { - shares: number; - shareValue: number; // current USD value per share - totalValue: number; - accruedPremium: number; - pendingWithdrawal: number; // shares escrowed in unprocessed withdrawal requests -} - -export type WithdrawalStatus = "queued" | "processed" | "cancelled"; - -/** One redemption request in a vault's withdrawal queue. */ -export interface WithdrawalRequest { - id: string; - vaultId: string; - /** Epoch the request was made in. Earlier epochs are served first. */ - requestEpoch: number; - /** Monotonic tie-break within an epoch, assigned at request time. */ - seq: number; - /** ISO time of the original request. Kept when unfilled requests carry forward. */ - requestedAt: string; - requestedShares: number; - /** Shares still escrowed and not yet redeemed. */ - escrowedShares: number; - filledShares: number; - /** filledShares / requestedShares, 0–1. Updated on every processing pass. */ - fillFraction: number; - /** USD paid out, summed over every pass at that pass's processing NAV. */ - payoutUsd: number; - status: WithdrawalStatus; -} - -/** State of the vault at the point where the withdrawal queue is processed. */ -export interface ProcessingPoint { - /** NAV per share at processing. Requests settle at this price. */ - navPerShare: number; - /** USD of liquid capital available to pay out. */ - liquidityUsd: number; -} - -/** - * One point in a hypothetical NAV-per-share series produced by a shadow - * run. Kept in its own namespace so it can never be read as live NAV. - */ -export interface ShadowNavPoint { - epochNumber: number; - /** Hypothetical NAV per share in USD (paper trading only). */ - navPerShare: number; - /** NAV per share the strategy model expected for this epoch. */ - expectedNavPerShare: number; - /** ISO timestamp the shadow epoch settled. */ - recordedAt: string; -} - -/** - * Side-by-side comparison of a shadow run against live and expected - * performance. All shadow figures are paper trading and must be labeled - * as such in the UI. - */ -export interface ShadowComparison { - /** Latest hypothetical NAV per share from the shadow run. */ - shadowNavPerShare: number; - /** Latest realized NAV per share of the live vault (null if none). */ - liveNavPerShare: number | null; - /** NAV per share the strategy model expected for the latest epoch. */ - expectedNavPerShare: number; - /** Shadow minus live NAV per share (null when no live history). */ - shadowVsLiveDelta: number | null; - /** Shadow minus expected NAV per share. */ - shadowVsExpectedDelta: number; - /** Number of complete shadow epochs recorded so far. */ - completedEpochs: number; -} - -/** - * Shadow (paper-trading) state for a vault strategy. Tracked in its own - * namespace, with its own hypothetical NAV series and epoch stream, so - * the registry, indexer, and this read model never conflate it with real - * balances. - */ -export interface ShadowState { - /** Always `shadow` — makes the paper-trading label explicit in payloads. */ - mode: "shadow"; - /** Hypothetical NAV-per-share series, one point per complete epoch. */ - navSeries: ShadowNavPoint[]; - /** Shadow epoch record stream (never merged into `historicalEpochs`). */ - epochs: EpochRecord[]; - /** Side-by-side comparison data for the operator console/analytics. */ - comparison: ShadowComparison; -} - -/** - * Promotion criteria that must be met before a shadow strategy can be - * moved to live allocation. Evaluated by the backend promotion gate; the - * adapter surfaces the thresholds so the operator console can display - * progress and block promotion when unmet. - */ -export interface PromotionCriteria { - /** Minimum number of complete shadow epochs required. */ - minCompleteEpochs: number; - /** Max allowed |shadow − expected| NAV deviation per share (USD). */ - maxNavDeviation: number; - /** Whether any invariant violations were recorded during the run. */ - invariantViolations: number; -} - -export interface QueueState { - depositPendingUsd: number; - withdrawPendingShares: number; - settlesAt: string; // ISO -} - -export interface FeeAccrual { - managementAccruedUsd: number; - performanceAccruedUsd: number; - accruedThrough: string; // ISO -} - -export interface NavPoint { - at: string; // ISO - sharePrice: number; - totalShares: number; -} - -/** Full detail for a single vault. */ -export interface VaultDetail extends VaultSummary { - currentEpoch: number; - currentPhase: EpochPhase; - phaseEndsAt: string; // ISO — when the current phase ends - sharePrice: number; // current NAV per share in USD - totalShares: number; - /** Internal + return vaults.filter diff --git a/src/lib/payoff.ts b/src/lib/payoff.ts index e69de29..edcb050 100644 --- a/src/lib/payoff.ts +++ b/src/lib/payoff.ts @@ -0,0 +1,212 @@ +import { blackScholes, type Greeks, type OptionType } from './pricing'; + +export type WrittenPosition = { + id: string; + strategy: string; + optionType: OptionType; + strike: number; + spot: number; + impliedVol: number; + timeToExpiry: number; + notional: number; + contracts: number; +}; + +export type RiskInputs = { + spot?: number; + impliedVol?: number; + timeToExpiry?: number; + strike?: number; + stale?: boolean; +}; + +export type PositionRisk = { + id: string; + strategy: string; + available: boolean; + unavailableReason?: string; + assignmentProbability?: number; + maxLoss?: number; + breakeven?: number; + greeks?: Greeks; + model?: string; + inputs?: RiskInputs; +}; + +export type AggregateRisk = { + available: boolean; + unavailableReason?: string; + positionCount: number; + notionalByStrategy: Record; + countByStrategy: Record; + greeks?: Greeks; + maxLoss?: number; + breakevens: number[]; +}; + +const MIN_TIME_TO_EXPIRY = 1 / (365 * 24 * 60); +const MODEL_LABEL = 'Black-Scholes (local estimate)'; + +function clampTimeToExpiry(t: number): number { + if (!Number.isFinite(t) || t <= 0) return MIN_TIME_TO_EXPIRY; + return Math.max(t, MIN_TIME_TO_EXPIRY); +} + +function normalCdf(x: number): number { + // Abramowitz & Stegun approximation + const t = 1 / (1 + 0.2316419 * Math.abs(x)); + const d = 0.3989423 * Math.exp((-x * x) / 2); + const p = + d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274)))); + return x > 0 ? 1 - p : p; +} + +function assignmentProbability( + optionType: OptionType, + spot: number, + strike: number, + impliedVol: number, + timeToExpiry: number, +): number { + const t = clampTimeToExpiry(timeToExpiry); + const vol = Math.max(impliedVol, 1e-6); + const d2 = + (Math.log(spot / strike) + (-0.5 * vol * vol) * t) / (vol * Math.sqrt(t)); + // Probability the option finishes in the money (i.e. assigned at expiry). + return optionType === 'call' ? normalCdf(d2) : normalCdf(-d2); +} + +function maxLossPerContract( + optionType: OptionType, + strike: number, + premium: number, +): number { + // Written (short) option: max loss is unbounded for calls, strike - premium for puts. + if (optionType === 'call') return Number.POSITIVE_INFINITY; + return Math.max(strike - premium, 0); +} + +export function computePositionRisk(position: WrittenPosition): PositionRisk { + const { spot, impliedVol, timeToExpiry, strike } = position; + const inputs: RiskInputs = { spot, impliedVol, timeToExpiry, strike }; + + if ( + !Number.isFinite(spot) || + spot <= 0 || + !Number.isFinite(impliedVol) || + impliedVol <= 0 || + !Number.isFinite(timeToExpiry) || + timeToExpiry <= 0 || + !Number.isFinite(strike) || + strike <= 0 + ) { + return { + id: position.id, + strategy: position.strategy, + available: false, + unavailableReason: 'Missing or stale pricing inputs', + model: MODEL_LABEL, + inputs, + }; + } + + const t = clampTimeToExpiry(timeToExpiry); + const greeks = blackScholes({ + type: position.optionType, + spot, + strike, + timeToExpiry: t, + volatility: impliedVol, + }); + + const premium = greeks.price; + const perContractLoss = maxLossPerContract(position.optionType, strike, premium); + const maxLoss = Number.isFinite(perContractLoss) + ? perContractLoss * position.contracts + : Number.POSITIVE_INFINITY; + + const breakeven = + position.optionType === 'call' ? strike + premium : strike - premium; + + return { + id: position.id, + strategy: position.strategy, + available: true, + assignmentProbability: assignmentProbability( + position.optionType, + spot, + strike, + impliedVol, + t, + ), + maxLoss, + breakeven, + greeks, + model: MODEL_LABEL, + inputs, + }; +} + +export function aggregateRisk(positions: WrittenPosition[]): AggregateRisk { + const notionalByStrategy: Record = {}; + const countByStrategy: Record = {}; + const breakevens: number[] = []; + let delta = 0; + let gamma = 0; + let vega = 0; + let theta = 0; + let maxLoss = 0; + let anyAvailable = false; + let anyUnavailable = false; + + for (const position of positions) { + notionalByStrategy[position.strategy] = + (notionalByStrategy[position.strategy] ?? 0) + position.notional; + countByStrategy[position.strategy] = + (countByStrategy[position.strategy] ?? 0) + 1; + + const risk = computePositionRisk(position); + if (!risk.available || !risk.greeks) { + anyUnavailable = true; + continue; + } + anyAvailable = true; + delta += risk.greeks.delta * position.contracts; + gamma += risk.greeks.gamma * position.contracts; + vega += risk.greeks.vega * position.contracts; + theta += risk.greeks.theta * position.contracts; + if (risk.maxLoss !== undefined) { + maxLoss = Number.isFinite(risk.maxLoss) + ? maxLoss + risk.maxLoss + : Number.POSITIVE_INFINITY; + } + if (risk.breakeven !== undefined) breakevens.push(risk.breakeven); + } + + if (!anyAvailable) { + return { + available: false, + unavailableReason: + positions.length === 0 + ? 'No open written positions' + : 'Pricing inputs unavailable for all positions', + positionCount: positions.length, + notionalByStrategy, + countByStrategy, + breakevens, + }; + } + + return { + available: true, + unavailableReason: anyUnavailable + ? 'Some positions excluded due to missing inputs' + : undefined, + positionCount: positions.length, + notionalByStrategy, + countByStrategy, + greeks: { delta, gamma, vega, theta }, + maxLoss, + breakevens, + }; +} diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts index 0f17c33..c86cd37 100644 --- a/src/lib/pricing.ts +++ b/src/lib/pricing.ts @@ -143,6 +143,239 @@ export const fmtSpot = (n: number) => (n >= 1000 ? formatPrice("BTC", n) : forma export const fmtK = (n: number) => (n >= 1000 ? formatStrike("BTC", n) : formatStrike("XLM", n)); +// --------------------------------------------------------------------------- +// Vault-written position risk metrics (issue #279) +// +// Shared helper used by both the per-vault risk panel and the portfolio +// surface so the two agree. Per-position metrics are computed with the local +// Black-Scholes model above, then aggregated. A backend-provided Greek can +// override the local estimate via `overrides` without changing components. +// +// Methodology (also documented in docs/RISK_METRICS.md): +// - Model: Black-Scholes with flat r = RISK_FREE_RATE, lognormal terminal +// distribution, no dividends, no early exercise. +// - Assignment probability = N(d2) for calls, N(-d2) for puts (risk-neutral +// probability of finishing ITM). +// - Max loss per written contract = (strike - premium) * notional for calls +// (unbounded upside), (strike - premium) * notional for puts (spot -> 0). +// - Breakeven = strike +/- premium for calls/puts respectively. +// - Figures are modelled estimates, not guarantees. +// --------------------------------------------------------------------------- + +// Minimum time-to-expiry (in years) used for Greeks so theta/gamma do not +// blow up as t -> 0. ~1 hour. Positions below this are flagged as clamped. +export const MIN_T_YEARS = 1 / (365 * 24); + +// Spot older than this (ms) is treated as stale and metrics degrade to +// "unavailable" rather than rendering a nonsense number. +export const STALE_SPOT_MS = 5 * 60 * 1000; + +export type OptionSide = "call" | "put"; + +export interface WrittenPosition { + id: string; + strategy: string; + side: OptionSide; + strike: number; + /** Contracts written (positive). */ + qty: number; + /** Notional in quote currency per contract (e.g. underlying units). */ + notional: number; + /** Implied vol as a decimal (0.82 = 82%). */ + iv?: number; + /** Time to expiry in years. */ + tYears?: number; + /** Optional backend-provided Greek overrides. */ + overrides?: Partial>; +} + +export interface RiskInputs { + spot: number; + /** Timestamp (ms) the spot was observed; used for staleness. */ + spotAsOf?: number; + /** Reference "now" in ms; defaults to Date.now(). */ + now?: number; +} + +export interface PositionRisk { + id: string; + strategy: string; + side: OptionSide; + strike: number; + qty: number; + notional: number; + /** Modelled assignment probability (0..1), or null when unavailable. */ + assignmentProb: number | null; + /** Modelled max loss in quote currency, or null when unavailable. */ + maxLoss: number | null; + /** Breakeven spot price, or null when unavailable. */ + breakeven: number | null; + /** Signed Greeks scaled by qty (short written => negated). */ + delta: number | null; + gamma: number | null; + vega: number | null; + theta: number | null; + /** True when t was clamped to MIN_T_YEARS for Greek stability. */ + clamped: boolean; + /** Reason metrics are unavailable, if any. */ + unavailable?: string; +} + +export interface StrategyBucket { + strategy: string; + count: number; + notional: number; +} + +export interface VaultRiskSummary { + positions: PositionRisk[]; + byStrategy: StrategyBucket[]; + totals: { + delta: number; + gamma: number; + vega: number; + theta: number; + maxLoss: number; + }; + /** True if any position had a missing/stale input. */ + degraded: boolean; + /** Human-readable reasons for degradation. */ + notes: string[]; +} + +function isFinitePositive(n: number | undefined): n is number { + return typeof n === "number" && Number.isFinite(n) && n > 0; +} + +/** + * Compute risk metrics for a single written position. Returns null-valued + * metrics with an `unavailable` reason when inputs are missing or stale. + */ +export function positionRisk(pos: WrittenPosition, inputs: RiskInputs): PositionRisk { + const base: PositionRisk = { + id: pos.id, + strategy: pos.strategy, + side: pos.side, + strike: pos.strike, + qty: pos.qty, + notional: pos.notional, + assignmentProb: null, + maxLoss: null, + breakeven: null, + delta: null, + gamma: null, + vega: null, + theta: null, + clamped: false, + }; + + const now = inputs.now ?? Date.now(); + if (!isFinitePositive(inputs.spot)) { + return { ...base, unavailable: "spot price unavailable" }; + } + if (inputs.spotAsOf != null && now - inputs.spotAsOf > STALE_SPOT_MS) { + return { ...base, unavailable: "spot price stale" }; + } + if (!isFinitePositive(pos.strike)) { + return { ...base, unavailable: "strike unavailable" }; + } + if (!isFinitePositive(pos.iv)) { + return { ...base, unavailable: "implied vol unavailable" }; + } + if (pos.tYears == null || !Number.isFinite(pos.tYears) || pos.tYears < 0) { + return { ...base, unavailable: "time to expiry unavailable" }; + } + + const S = inputs.spot; + const K = pos.strike; + const vol = pos.iv; + const isCall = pos.side === "call"; + const clamped = pos.tYears < MIN_T_YEARS; + const t = clamped ? MIN_T_YEARS : pos.tYears; + + const g = bs(S, K, vol, t, isCall); + const premium = g.premium; + + // Risk-neutral assignment probability = N(d2) for calls, N(-d2) for puts. + const st = Math.sqrt(t); + const d2 = (Math.log(S / K) + (RISK_FREE_RATE - 0.5 * vol * vol) * t) / (vol * st); + const assignmentProb = isCall ? normCDF(d2) : normCDF(-d2); + + // Max loss per written contract. Calls: unbounded upside, modelled as + // (strike - premium) per unit (spot -> infinity is unbounded; we report the + // loss at the strike as the modelled floor and label as estimate). Puts: + // spot -> 0, loss = (strike - premium) per unit. + const maxLossPerUnit = Math.max(0, K - premium); + const maxLoss = maxLossPerUnit * pos.qty * pos.notional; + + const breakeven = isCall ? K + premium : K - premium; + + // Written (short) positions: negate Greeks and scale by qty * notional. + const scale = -pos.qty * pos.notional; + const delta = pos.overrides?.delta ?? g.delta * scale; + const gamma = pos.overrides?.gamma ?? g.gamma * scale; + const vega = pos.overrides?.vega ?? g.vega * scale; + const theta = pos.overrides?.theta ?? g.theta * scale; + + return { + ...base, + assignmentProb, + maxLoss, + breakeven, + delta, + gamma, + vega, + theta, + clamped, + }; +} + +/** + * Aggregate per-position risk into a vault-level summary. Pure and memoizable; + * callers should memoize on (positions, inputs) to avoid recomputation. + */ +export function aggregateVaultRisk( + positions: WrittenPosition[], + inputs: RiskInputs, +): VaultRiskSummary { + const computed = positions.map((p) => positionRisk(p, inputs)); + + const bucketMap = new Map(); + for (const p of computed) { + const b = bucketMap.get(p.strategy) ?? { strategy: p.strategy, count: 0, notional: 0 }; + b.count += 1; + b.notional += p.notional * p.qty; + bucketMap.set(p.strategy, b); + } + + const totals = { delta: 0, gamma: 0, vega: 0, theta: 0, maxLoss: 0 }; + const notes: string[] = []; + let degraded = false; + for (const p of computed) { + if (p.unavailable) { + degraded = true; + notes.push(`${p.id}: ${p.unavailable}`); + continue; + } + if (p.clamped) { + notes.push(`${p.id}: time to expiry clamped for Greek stability`); + } + totals.delta += p.delta ?? 0; + totals.gamma += p.gamma ?? 0; + totals.vega += p.vega ?? 0; + totals.theta += p.theta ?? 0; + totals.maxLoss += p.maxLoss ?? 0; + } + + return { + positions: computed, + byStrategy: Array.from(bucketMap.values()), + totals, + degraded, + notes, + }; +} + /** * Deposit and withdrawal pricing rule for option-writing vaults (issue #208). *