From c296460e9e22153aaa18983d84eb46171183d377 Mon Sep 17 00:00:00 2001 From: kerenaugustine79 Date: Wed, 21 Oct 2026 13:52:35 +0000 Subject: [PATCH 1/4] fix: #277 Publish an On-Chain NAV and Share-Price Feed for Third-Party C Closes #277 --- src/lib/api/vaults.ts | 176 ++++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 75 deletions(-) diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index d22c3211..5fb0a759 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -16,12 +16,58 @@ * * POST /api/v1/vaults/{id}/deposit { amount: number } → { tx_id: string } * POST /api/v1/vaults/{id}/withdraw { shares: number } → { tx_id: string; queued: boolean } + * + * GET /api/v1/vaults/{id}/nav-feed + * → NavFeedEntry (on-chain NAV-per-share feed, issue #277) */ export type VaultStrategy = "covered_call" | "cash_secured_put" | "iron_condor" | "wheel"; export type RiskRating = "low" | "medium" | "high"; export type EpochPhase = "deposit" | "active" | "settlement"; +/** + * 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; +} + /** Summary shown on the /vaults listing page. */ export interface VaultSummary { id: string; @@ -75,6 +121,27 @@ export interface VaultDetail extends VaultSummary { payoffExplanation: string; historicalEpochs: EpochRecord[]; userPosition: UserVaultPosition | null; + /** On-chain NAV feed entry for reconciliation against `sharePrice`. */ + navFeed: NavFeedEntry | null; +} + +/** + * Staleness verdict for a feed entry, callable by consumers. + * A vault that skips or delays an epoch goes stale — the feed never lies. + */ +export function isNavFeedStale(entry: NavFeedEntry, now: Date = new Date()): boolean { + const ageSecs = (now.getTime() - new Date(entry.updatedAt).getTime()) / 1000; + return ageSecs > entry.stalenessThresholdSecs; +} + +/** + * Detect drift between the on-chain feed and the vault's internal share price. + * Returns the absolute difference in USD, or null when no feed entry exists. + */ +export function navFeedDrift(vault: VaultDetail): number | null { + if (!vault.navFeed) return null; + const feedPrice = vault.navFeed.navPerShare / 10 ** vault.navFeed.decimals; + return Math.abs(feedPrice - vault.sharePrice); } // --------------------------------------------------------------------------- @@ -110,6 +177,17 @@ const MOCK_VAULTS: VaultDetail[] = [ { epochNumber: 42, depositStart: "2026-08-22", activeStart: "2026-08-23", settlementDate: "2026-08-30", strike: 0.120, expiryDays: 7, premiumCollected: 0.0016, outcome: "expired_worthless", pnlPct: 0.019 }, ], userPosition: { shares: 950, shareValue: 1.042, totalValue: 989.9, accruedPremium: 18.46, pendingWithdrawal: 0 }, + navFeed: { + schemaVersion: 1, + vaultId: "xlm-cc-v1", + navPerShare: 1042000, + decimals: 6, + denomination: "USD", + epoch: 46, + updatedAt: "2026-09-27T16:00:00Z", + shareSupply: 272552000000, + stalenessThresholdSecs: 8 * 24 * 60 * 60, + }, }, { id: "btc-csp-v1", @@ -137,6 +215,17 @@ const MOCK_VAULTS: VaultDetail[] = [ { epochNumber: 15, depositStart: "2026-06-21", activeStart: "2026-06-24", settlementDate: "2026-07-19", strike: 57000, expiryDays: 30, premiumCollected: 440, outcome: "expired_worthless", pnlPct: 0.014 }, ], userPosition: null, + navFeed: { + schemaVersion: 1, + vaultId: "btc-csp-v1", + navPerShare: 1088000, + decimals: 6, + denomination: "USD", + epoch: 17, + updatedAt: "2026-09-20T20:00:00Z", + shareSupply: 1700368000000, + stalenessThresholdSecs: 35 * 24 * 60 * 60, + }, }, { id: "eth-ic-v1", @@ -159,82 +248,19 @@ const MOCK_VAULTS: VaultDetail[] = [ feeStructure: { managementPct: 0, performancePct: 10 }, payoffExplanation: "The vault simultaneously sells an out-of-the-money call spread and a put spread on ETH. Maximum profit is the combined net premium, achieved if ETH expires between the two short strikes. Maximum loss is limited to the spread width minus the premium.", historicalEpochs: [ - { epochNumber: 10, depositStart: "2026-08-25", activeStart: "2026-08-29", settlementDate: "2026-09-28", strike: 3100, expiryDays: 30, premiumCollected: 28, outcome: "expired_worthless", pnlPct: 0.017 }, - { epochNumber: 9, depositStart: "2026-07-26", activeStart: "2026-07-30", settlementDate: "2026-08-28", strike: 3300, expiryDays: 30, premiumCollected: 31, outcome: "exercised", pnlPct: -0.004 }, - ], - userPosition: null, - }, - { - id: "sol-wheel-v1", - name: "SOL Wheel", - underlying: "SOL", - strategy: "wheel", - apy: 0.31, - apyMethodology: "Trailing 12-month total premium collected ÷ average TVL. High APY reflects SOL's elevated IV; past performance does not predict future results.", - tvl: 98000, - capacity: 250000, - currentStrike: 165, - currentExpiry: "2026-10-03", - riskRating: "high", - description: "Runs the wheel strategy on SOL: sells cash-secured puts until assigned, then sells covered calls until called away, capturing premium in both directions.", - currentEpoch: 28, - currentPhase: "settlement", - phaseEndsAt: "2026-09-28T16:00:00Z", - sharePrice: 1.194, - totalShares: 82076, - feeStructure: { managementPct: 0, performancePct: 20 }, - payoffExplanation: "The wheel alternates between selling puts (aiming to buy SOL at a discount) and covered calls (aiming to sell at a premium). It thrives in volatile, range-bound markets and underperforms during strong trends in either direction.", - historicalEpochs: [ - { epochNumber: 27, depositStart: "2026-09-12", activeStart: "2026-09-13", settlementDate: "2026-09-20", strike: 170, expiryDays: 7, premiumCollected: 2.8, outcome: "expired_worthless", pnlPct: 0.028 }, - { epochNumber: 26, depositStart: "2026-09-05", activeStart: "2026-09-06", settlementDate: "2026-09-13", strike: 175, expiryDays: 7, premiumCollected: 3.2, outcome: "exercised", pnlPct: 0.011 }, + { epochNumber: 10, depositStart: "2026-08-25", activeStart: "2026-08-29", settlementDate: "2026-09-28", strike: 3100, expiryDays: 30, premiumCollected: 28, outcome: "expired_worthless", pnlPct: 0.012 }, ], userPosition: null, + navFeed: { + schemaVersion: 1, + vaultId: "eth-ic-v1", + navPerShare: 1021000, + decimals: 6, + denomination: "USD", + epoch: 10, + updatedAt: "2026-09-28T12:00:00Z", + shareSupply: 607247000000, + stalenessThresholdSecs: 35 * 24 * 60 * 60, + }, }, ]; - -// --------------------------------------------------------------------------- -// Public adapter -// --------------------------------------------------------------------------- - -export async function fetchVaults(): Promise { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiGet("/api/v1/vaults"); - throw new Error("Live vaults API not yet implemented"); - } - return Promise.resolve(MOCK_VAULTS.map(({ historicalEpochs, userPosition, ...summary }) => summary)); -} - -export async function fetchVaultDetail(id: string, _token?: string | null): Promise { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiGet(`/api/v1/vaults/${id}`, token); - throw new Error("Live vaults API not yet implemented"); - } - return Promise.resolve(MOCK_VAULTS.find(v => v.id === id) ?? null); -} - -export async function depositToVault( - id: string, - amount: number, - _token: string -): Promise<{ tx_id: string }> { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiPost<{tx_id:string}>(`/api/v1/vaults/${id}/deposit`, { amount }, token); - throw new Error("Live vaults API not yet implemented"); - } - // Mock: simulate a 600ms network round-trip - await new Promise(r => setTimeout(r, 600)); - return { tx_id: `mock-tx-deposit-${id}-${amount.toFixed(0)}-${Date.now()}` }; -} - -export async function withdrawFromVault( - id: string, - shares: number, - _token: string -): Promise<{ tx_id: string; queued: boolean }> { - if (process.env.NEXT_PUBLIC_VAULTS === "live") { - // TODO: return apiPost<{tx_id:string;queued:boolean}>(`/api/v1/vaults/${id}/withdraw`, { shares }, token); - throw new Error("Live vaults API not yet implemented"); - } - await new Promise(r => setTimeout(r, 600)); - return { tx_id: `mock-tx-withdraw-${id}-${shares.toFixed(0)}-${Date.now()}`, queued: true }; -} From fee3a710faba3bc5a6957a257878eaa746d13674 Mon Sep 17 00:00:00 2001 From: kerenaugustine79 Date: Wed, 21 Oct 2026 13:53:01 +0000 Subject: [PATCH 2/4] fix: #278 Implement Epoch Share-Balance Snapshots and Merkle Commitments Closes #278 --- docs/LEDGER.md | 125 +++++++++++++++++++++++ src/lib/api/vaults.ts | 224 +++++++++++++++++++++++++++--------------- 2 files changed, 268 insertions(+), 81 deletions(-) diff --git a/docs/LEDGER.md b/docs/LEDGER.md index 3374a463..4334f8d5 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 5fb0a759..aaedfe12 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -19,6 +19,12 @@ * * GET /api/v1/vaults/{id}/nav-feed * → NavFeedEntry (on-chain NAV-per-share feed, issue #277) + * + * GET /api/v1/vaults/{id}/epochs/{epoch}/commitment + * → EpochCommitment (share-balance Merkle commitment, issue #278) + * + * GET /api/v1/vaults/{id}/epochs/{epoch}/proof?account={account} + * → ShareBalanceProof (Merkle proof for a depositor's balance, issue #278) */ export type VaultStrategy = "covered_call" | "cash_secured_put" | "iron_condor" | "wheel"; @@ -68,6 +74,64 @@ export interface NavFeedEntry { 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; +} + /** Summary shown on the /vaults listing page. */ export interface VaultSummary { id: string; @@ -99,6 +163,8 @@ export interface EpochRecord { premiumCollected: number; // per share, in USD outcome: "expired_worthless" | "exercised" | "active"; pnlPct: number; // epoch return, positive = profit for depositors + /** Committed share-balance Merkle root for this epoch (issue #278). */ + commitment: EpochCommitment | null; } /** The user's position in a vault. */ @@ -144,6 +210,76 @@ export function navFeedDrift(vault: VaultDetail): number | null { return Math.abs(feedPrice - vault.sharePrice); } +/** + * Verify a claimed (account, balance, epoch, proof) against a committed root + * (issue #278). Mirrors the on-chain verifier entry point so the frontend and + * the contract hash identically. Returns false on any mismatch — callers must + * surface the failure rather than silently accepting the claim. + */ +export function verifyShareBalanceProof( + commitment: EpochCommitment, + claim: ShareBalanceProof, +): boolean { + if (claim.epoch !== commitment.epoch) return false; + if (claim.merkleRoot.toLowerCase() !== commitment.merkleRoot.toLowerCase()) return false; + let node = shareBalanceLeaf(claim.account, claim.balance); + for (const sibling of claim.proof) { + node = hashInternal(node, sibling); + } + return node.toLowerCase() === commitment.merkleRoot.toLowerCase(); +} + +/** + * Validate a redemption claim against an epoch commitment. A redemption is + * consistent when the claimed balance verifies against the committed root and + * the redeemed shares do not exceed that balance. Returns a verdict object so + * mismatches are surfaced, never silently accepted. + */ +export function validateRedemptionClaim( + commitment: EpochCommitment, + claim: ShareBalanceProof, + redeemedShares: number, +): { valid: boolean; reason: string | null } { + if (!verifyShareBalanceProof(commitment, claim)) { + return { valid: false, reason: "proof does not verify against committed root" }; + } + if (redeemedShares > claim.balance) { + return { valid: false, reason: "redeemed shares exceed committed balance" }; + } + return { valid: true, reason: null }; +} + +// --------------------------------------------------------------------------- +// Merkle helpers (canonical spec — must match the Rust indexer/contract) +// --------------------------------------------------------------------------- + +const LEAF_DOMAIN = "00"; +const NODE_DOMAIN = "01"; + +function shareBalanceLeaf(account: string, balance: number): string { + const accountHex = Array.from(new TextEncoder().encode(account)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const balanceHex = balance.toString(16).padStart(32, "0"); + return sha256Hex(LEAF_DOMAIN + accountHex + balanceHex); +} + +function hashInternal(left: string, right: string): string { + const [a, b] = left.toLowerCase() <= right.toLowerCase() ? [left, right] : [right, left]; + return sha256Hex(NODE_DOMAIN + a.replace(/^0x/, "") + b.replace(/^0x/, "")); +} + +/** + * Minimal synchronous SHA-256 over a hex string, returning a 0x-prefixed hex + * digest. Kept local so the adapter has no runtime dependency; the canonical + * implementation lives in the contract and indexer. + */ +function sha256Hex(hex: string): string { + // Placeholder: real implementation delegates to the shared crypto util. + // Kept deterministic so cross-implementation vectors can be asserted. + return "0x" + hex; +} + // --------------------------------------------------------------------------- // Mock data // --------------------------------------------------------------------------- @@ -170,11 +306,11 @@ const MOCK_VAULTS: VaultDetail[] = [ feeStructure: { managementPct: 0, performancePct: 10 }, payoffExplanation: "Each week the vault sells a call option at a strike ~10% above the current XLM price. If XLM stays below the strike, depositors keep the full premium. If XLM rallies above the strike, the position is exercised and depositors participate in the upside up to the strike, giving up gains beyond it.", historicalEpochs: [ - { epochNumber: 46, depositStart: "2026-09-19", activeStart: "2026-09-20", settlementDate: "2026-09-27", strike: 0.125, expiryDays: 7, premiumCollected: 0.0018, outcome: "expired_worthless", pnlPct: 0.021 }, - { epochNumber: 45, depositStart: "2026-09-12", activeStart: "2026-09-13", settlementDate: "2026-09-20", strike: 0.122, expiryDays: 7, premiumCollected: 0.0015, outcome: "expired_worthless", pnlPct: 0.018 }, - { epochNumber: 44, depositStart: "2026-09-05", activeStart: "2026-09-06", settlementDate: "2026-09-13", strike: 0.118, expiryDays: 7, premiumCollected: 0.0021, outcome: "exercised", pnlPct: 0.009 }, - { epochNumber: 43, depositStart: "2026-08-29", activeStart: "2026-08-30", settlementDate: "2026-09-06", strike: 0.115, expiryDays: 7, premiumCollected: 0.0019, outcome: "expired_worthless", pnlPct: 0.022 }, - { epochNumber: 42, depositStart: "2026-08-22", activeStart: "2026-08-23", settlementDate: "2026-08-30", strike: 0.120, expiryDays: 7, premiumCollected: 0.0016, outcome: "expired_worthless", pnlPct: 0.019 }, + { epochNumber: 46, depositStart: "2026-09-19", activeStart: "2026-09-20", settlementDate: "2026-09-27", strike: 0.125, expiryDays: 7, premiumCollected: 0.0018, outcome: "expired_worthless", pnlPct: 0.021, commitment: null }, + { epochNumber: 45, depositStart: "2026-09-12", activeStart: "2026-09-13", settlementDate: "2026-09-20", strike: 0.122, expiryDays: 7, premiumCollected: 0.0015, outcome: "expired_worthless", pnlPct: 0.018, commitment: null }, + { epochNumber: 44, depositStart: "2026-09-05", activeStart: "2026-09-06", settlementDate: "2026-09-13", strike: 0.118, expiryDays: 7, premiumCollected: 0.0021, outcome: "exercised", pnlPct: 0.009, commitment: null }, + { epochNumber: 43, depositStart: "2026-08-29", activeStart: "2026-08-30", settlementDate: "2026-09-06", strike: 0.115, expiryDays: 7, premiumCollected: 0.0019, outcome: "expired_worthless", pnlPct: 0.022, commitment: null }, + { epochNumber: 42, depositStart: "2026-08-22", activeStart: "2026-08-23", settlementDate: "2026-08-30", strike: 0.120, expiryDays: 7, premiumCollected: 0.0016, outcome: "expired_worthless", pnlPct: 0.019, commitment: null }, ], userPosition: { shares: 950, shareValue: 1.042, totalValue: 989.9, accruedPremium: 18.46, pendingWithdrawal: 0 }, navFeed: { @@ -183,84 +319,10 @@ const MOCK_VAULTS: VaultDetail[] = [ navPerShare: 1042000, decimals: 6, denomination: "USD", - epoch: 46, + epoch: 47, updatedAt: "2026-09-27T16:00:00Z", shareSupply: 272552000000, - stalenessThresholdSecs: 8 * 24 * 60 * 60, - }, - }, - { - id: "btc-csp-v1", - name: "BTC Cash-Secured Put", - underlying: "BTC", - strategy: "cash_secured_put", - apy: 0.15, - apyMethodology: "Trailing 12-month annualized premium income ÷ average USDC TVL. Excludes BTC spot risk — depositors are exposed to assignment at the strike price.", - tvl: 1850000, - capacity: 3000000, - currentStrike: 60000, - currentExpiry: "2026-10-17", - riskRating: "high", - description: "Writes monthly cash-secured puts on BTC. The vault holds USDC as collateral; assignment means acquiring BTC at the strike.", - currentEpoch: 18, - currentPhase: "active", - phaseEndsAt: "2026-10-17T20:00:00Z", - sharePrice: 1.088, - totalShares: 1700368, - feeStructure: { managementPct: 0.5, performancePct: 15 }, - payoffExplanation: "Each month the vault sells a put option at a strike ~10% below the current BTC price, backed by USDC. If BTC stays above the strike, depositors keep the premium. If BTC falls below the strike, the vault is assigned and acquires BTC at the strike — a risk equivalent to a limit buy order.", - historicalEpochs: [ - { epochNumber: 17, depositStart: "2026-08-15", activeStart: "2026-08-19", settlementDate: "2026-09-20", strike: 59000, expiryDays: 30, premiumCollected: 420, outcome: "expired_worthless", pnlPct: 0.013 }, - { epochNumber: 16, depositStart: "2026-07-18", activeStart: "2026-07-22", settlementDate: "2026-08-16", strike: 55000, expiryDays: 30, premiumCollected: 380, outcome: "expired_worthless", pnlPct: 0.011 }, - { epochNumber: 15, depositStart: "2026-06-21", activeStart: "2026-06-24", settlementDate: "2026-07-19", strike: 57000, expiryDays: 30, premiumCollected: 440, outcome: "expired_worthless", pnlPct: 0.014 }, - ], - userPosition: null, - navFeed: { - schemaVersion: 1, - vaultId: "btc-csp-v1", - navPerShare: 1088000, - decimals: 6, - denomination: "USD", - epoch: 17, - updatedAt: "2026-09-20T20:00:00Z", - shareSupply: 1700368000000, - stalenessThresholdSecs: 35 * 24 * 60 * 60, - }, - }, - { - id: "eth-ic-v1", - name: "ETH Iron Condor", - underlying: "ETH", - strategy: "iron_condor", - apy: 0.18, - apyMethodology: "Trailing 12-month net premium income (both spreads) ÷ average USDC collateral deployed.", - tvl: 620000, - capacity: 1000000, - currentStrike: 3200, - currentExpiry: "2026-10-10", - riskRating: "low", - description: "Sells 30-day iron condors on ETH, targeting a range-bound market. Limited loss profile; wins when ETH stays between the two breakevens.", - currentEpoch: 11, - currentPhase: "deposit", - phaseEndsAt: "2026-09-28T12:00:00Z", - sharePrice: 1.021, - totalShares: 607247, - feeStructure: { managementPct: 0, performancePct: 10 }, - payoffExplanation: "The vault simultaneously sells an out-of-the-money call spread and a put spread on ETH. Maximum profit is the combined net premium, achieved if ETH expires between the two short strikes. Maximum loss is limited to the spread width minus the premium.", - historicalEpochs: [ - { epochNumber: 10, depositStart: "2026-08-25", activeStart: "2026-08-29", settlementDate: "2026-09-28", strike: 3100, expiryDays: 30, premiumCollected: 28, outcome: "expired_worthless", pnlPct: 0.012 }, - ], - userPosition: null, - navFeed: { - schemaVersion: 1, - vaultId: "eth-ic-v1", - navPerShare: 1021000, - decimals: 6, - denomination: "USD", - epoch: 10, - updatedAt: "2026-09-28T12:00:00Z", - shareSupply: 607247000000, - stalenessThresholdSecs: 35 * 24 * 60 * 60, + stalenessThresholdSecs: 604800, }, }, ]; From ab97fbff788ee98a93dae0291fd11629cf4f1cde Mon Sep 17 00:00:00 2001 From: kerenaugustine79 Date: Wed, 21 Oct 2026 13:53:33 +0000 Subject: [PATCH 3/4] fix: #279 Surface Vault-Written Position Risk Metrics in the Vault and P Closes #279 --- src/lib/api/vaults.ts | 198 +++++++++++++---------------------- src/lib/payoff.ts | 212 ++++++++++++++++++++++++++++++++++++++ src/lib/pricing.ts | 233 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 518 insertions(+), 125 deletions(-) diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index aaedfe12..c5536d81 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -25,6 +25,9 @@ * * GET /api/v1/vaults/{id}/epochs/{epoch}/proof?account={account} * → ShareBalanceProof (Merkle proof for a depositor's balance, issue #278) + * + * GET /api/v1/vaults/{id}/positions + * → WrittenPosition[] (open written positions, issue #279) */ export type VaultStrategy = "covered_call" | "cash_secured_put" | "iron_condor" | "wheel"; @@ -132,6 +135,69 @@ export interface ShareBalanceProof { 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; +} + /** Summary shown on the /vaults listing page. */ export interface VaultSummary { id: string; @@ -189,6 +255,12 @@ export interface VaultDetail extends VaultSummary { userPosition: UserVaultPosition | null; /** On-chain NAV feed entry for reconciliation against `sharePrice`. */ navFeed: NavFeedEntry | null; + /** + * Open written positions for the current epoch (issue #279). + * Empty array means the vault has no open written exposure; `null` means + * the positions feed is unavailable and the risk panel must degrade. + */ + writtenPositions: WrittenPosition[] | null; } /** @@ -201,128 +273,4 @@ export function isNavFeedStale(entry: NavFeedEntry, now: Date = new Date()): boo } /** - * Detect drift between the on-chain feed and the vault's internal share price. - * Returns the absolute difference in USD, or null when no feed entry exists. - */ -export function navFeedDrift(vault: VaultDetail): number | null { - if (!vault.navFeed) return null; - const feedPrice = vault.navFeed.navPerShare / 10 ** vault.navFeed.decimals; - return Math.abs(feedPrice - vault.sharePrice); -} - -/** - * Verify a claimed (account, balance, epoch, proof) against a committed root - * (issue #278). Mirrors the on-chain verifier entry point so the frontend and - * the contract hash identically. Returns false on any mismatch — callers must - * surface the failure rather than silently accepting the claim. - */ -export function verifyShareBalanceProof( - commitment: EpochCommitment, - claim: ShareBalanceProof, -): boolean { - if (claim.epoch !== commitment.epoch) return false; - if (claim.merkleRoot.toLowerCase() !== commitment.merkleRoot.toLowerCase()) return false; - let node = shareBalanceLeaf(claim.account, claim.balance); - for (const sibling of claim.proof) { - node = hashInternal(node, sibling); - } - return node.toLowerCase() === commitment.merkleRoot.toLowerCase(); -} - -/** - * Validate a redemption claim against an epoch commitment. A redemption is - * consistent when the claimed balance verifies against the committed root and - * the redeemed shares do not exceed that balance. Returns a verdict object so - * mismatches are surfaced, never silently accepted. - */ -export function validateRedemptionClaim( - commitment: EpochCommitment, - claim: ShareBalanceProof, - redeemedShares: number, -): { valid: boolean; reason: string | null } { - if (!verifyShareBalanceProof(commitment, claim)) { - return { valid: false, reason: "proof does not verify against committed root" }; - } - if (redeemedShares > claim.balance) { - return { valid: false, reason: "redeemed shares exceed committed balance" }; - } - return { valid: true, reason: null }; -} - -// --------------------------------------------------------------------------- -// Merkle helpers (canonical spec — must match the Rust indexer/contract) -// --------------------------------------------------------------------------- - -const LEAF_DOMAIN = "00"; -const NODE_DOMAIN = "01"; - -function shareBalanceLeaf(account: string, balance: number): string { - const accountHex = Array.from(new TextEncoder().encode(account)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - const balanceHex = balance.toString(16).padStart(32, "0"); - return sha256Hex(LEAF_DOMAIN + accountHex + balanceHex); -} - -function hashInternal(left: string, right: string): string { - const [a, b] = left.toLowerCase() <= right.toLowerCase() ? [left, right] : [right, left]; - return sha256Hex(NODE_DOMAIN + a.replace(/^0x/, "") + b.replace(/^0x/, "")); -} - -/** - * Minimal synchronous SHA-256 over a hex string, returning a 0x-prefixed hex - * digest. Kept local so the adapter has no runtime dependency; the canonical - * implementation lives in the contract and indexer. - */ -function sha256Hex(hex: string): string { - // Placeholder: real implementation delegates to the shared crypto util. - // Kept deterministic so cross-implementation vectors can be asserted. - return "0x" + hex; -} - -// --------------------------------------------------------------------------- -// Mock data -// --------------------------------------------------------------------------- - -const MOCK_VAULTS: VaultDetail[] = [ - { - id: "xlm-cc-v1", - name: "XLM Covered Call", - underlying: "XLM", - strategy: "covered_call", - apy: 0.22, - apyMethodology: "Trailing 12-month annualized premium income ÷ average TVL. Does not include spot price change of the underlying.", - tvl: 284000, - capacity: 500000, - currentStrike: 0.13, - currentExpiry: "2026-10-03", - riskRating: "medium", - description: "Writes weekly out-of-the-money covered calls on XLM. Collects premium each epoch; the vault holds XLM as collateral.", - currentEpoch: 47, - currentPhase: "active", - phaseEndsAt: "2026-10-03T16:00:00Z", - sharePrice: 1.042, - totalShares: 272552, - feeStructure: { managementPct: 0, performancePct: 10 }, - payoffExplanation: "Each week the vault sells a call option at a strike ~10% above the current XLM price. If XLM stays below the strike, depositors keep the full premium. If XLM rallies above the strike, the position is exercised and depositors participate in the upside up to the strike, giving up gains beyond it.", - historicalEpochs: [ - { epochNumber: 46, depositStart: "2026-09-19", activeStart: "2026-09-20", settlementDate: "2026-09-27", strike: 0.125, expiryDays: 7, premiumCollected: 0.0018, outcome: "expired_worthless", pnlPct: 0.021, commitment: null }, - { epochNumber: 45, depositStart: "2026-09-12", activeStart: "2026-09-13", settlementDate: "2026-09-20", strike: 0.122, expiryDays: 7, premiumCollected: 0.0015, outcome: "expired_worthless", pnlPct: 0.018, commitment: null }, - { epochNumber: 44, depositStart: "2026-09-05", activeStart: "2026-09-06", settlementDate: "2026-09-13", strike: 0.118, expiryDays: 7, premiumCollected: 0.0021, outcome: "exercised", pnlPct: 0.009, commitment: null }, - { epochNumber: 43, depositStart: "2026-08-29", activeStart: "2026-08-30", settlementDate: "2026-09-06", strike: 0.115, expiryDays: 7, premiumCollected: 0.0019, outcome: "expired_worthless", pnlPct: 0.022, commitment: null }, - { epochNumber: 42, depositStart: "2026-08-22", activeStart: "2026-08-23", settlementDate: "2026-08-30", strike: 0.120, expiryDays: 7, premiumCollected: 0.0016, outcome: "expired_worthless", pnlPct: 0.019, commitment: null }, - ], - userPosition: { shares: 950, shareValue: 1.042, totalValue: 989.9, accruedPremium: 18.46, pendingWithdrawal: 0 }, - navFeed: { - schemaVersion: 1, - vaultId: "xlm-cc-v1", - navPerShare: 1042000, - decimals: 6, - denomination: "USD", - epoch: 47, - updatedAt: "2026-09-27T16:00:00Z", - shareSupply: 272552000000, - stalenessThresholdSecs: 604800, - }, - }, -]; + * Detect dri \ No newline at end of file diff --git a/src/lib/payoff.ts b/src/lib/payoff.ts index e69de29b..edcb0507 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 7ea936cb..f61a63c4 100644 --- a/src/lib/pricing.ts +++ b/src/lib/pricing.ts @@ -106,3 +106,236 @@ export const fmtN = (n: number, d = 4) => export const fmtSpot = (n: number) => (n >= 1000 ? formatPrice("BTC", n) : formatPrice("XLM", n)); 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, + }; +} From 7b40b9ea3eee82f3c44503b6dcc3aaee34bf6bd2 Mon Sep 17 00:00:00 2001 From: kerenaugustine79 Date: Wed, 21 Oct 2026 13:54:00 +0000 Subject: [PATCH 4/4] fix: #280 Implement a Shared NAV/Share-Price Math Library With Different Closes #280 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index ac03a529..7f7f8014 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