diff --git a/contracts/fixtures/AggregateGreeksSchema.json b/contracts/fixtures/AggregateGreeksSchema.json index f6355ec9..1aeffa3a 100644 --- a/contracts/fixtures/AggregateGreeksSchema.json +++ b/contracts/fixtures/AggregateGreeksSchema.json @@ -1 +1,34 @@ -{"delta":0.5,"gamma":0.01,"theta":-0.02,"vega":0.1} +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AggregateGreeks", + "description": "Vault-level aggregate Greeks computed from open written positions via the shared pricing library. Consumed by the delta-hedging policy to decide whether exposure has drifted outside the configured band.", + "type": "object", + "required": ["delta", "gamma", "theta", "vega"], + "properties": { + "delta": { + "type": "number", + "description": "Aggregate portfolio delta (net directional exposure)." + }, + "gamma": { + "type": "number", + "description": "Aggregate portfolio gamma (delta sensitivity to underlying moves)." + }, + "theta": { + "type": "number", + "description": "Aggregate portfolio theta (time decay)." + }, + "vega": { + "type": "number", + "description": "Aggregate portfolio vega (volatility sensitivity)." + } + }, + "additionalProperties": false, + "examples": [ + { + "delta": 0.5, + "gamma": 0.01, + "theta": -0.02, + "vega": 0.1 + } + ] +} diff --git a/docs/LEDGER.md b/docs/LEDGER.md index 3374a463..3e8d2618 100644 --- a/docs/LEDGER.md +++ b/docs/LEDGER.md @@ -66,3 +66,69 @@ 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. + +## Vault Data Warehouse & Analytics ETL + +The vault analytics warehouse is a separate, read-only analytical store. It +is populated by scheduled ETL jobs that extract from the vault event indexer, +the historical epoch ledger, and the vault registry, then transform into +modeled marts. Dashboards and reporting exports query the warehouse — never +the operational database. + +### Schemas + +| Table | Grain | Key columns | +| --- | --- | --- | +| `raw_events` | one row per indexer event | `event_id`, `ledger`, `cursor`, `contract`, `topic`, `payload`, `ingested_at` | +| `vault_registry_snapshots` | vault × snapshot time | `vault_id`, `snapshot_at`, `strategy`, `strike_bucket`, `status` | +| `epoch_facts` | one row per epoch | `vault_id`, `epoch_id`, `opened_at`, `closed_at`, `nav_open`, `nav_close`, `premium_collected`, `fees`, `realized_pnl` | +| `flow_facts` | one row per deposit/withdrawal/queue event | `flow_id`, `vault_id`, `epoch_id`, `kind` (`deposit`/`withdrawal`/`queue_enter`/`queue_exit`), `amount`, `shares`, `occurred_at` | +| `share_price_series` | vault × timestamp | `vault_id`, `observed_at`, `share_price` | +| `redemption_queue_depth` | vault × timestamp | `vault_id`, `observed_at`, `depth`, `pending_shares` | + +### ETL & Watermarking + +- Jobs are **idempotent and replayable**. Each run reads the persisted + watermark (`etl_watermarks.job`, `etl_watermarks.cursor`) and processes + events strictly after it, upserting by natural key (`event_id`, `epoch_id`, + `flow_id`) so replays never double count. +- The watermark advances only after a successful transform + data-quality + gate. A failed gate leaves the watermark untouched so the next run retries + the same window. +- **Backfills and restatements**: pass an explicit `--from-cursor` to replay + a historical window. Admin epoch corrections are accepted as restatements — + the affected `epoch_facts` row is replaced (not appended) and downstream + marts are recomputed for that epoch. +- **Reorgs / cursor regressions**: if the indexer cursor moves backwards + (chain restart), the job rewinds the watermark to the earliest affected + ledger and reprocesses; upserts make this safe. + +### Data-Quality Gates + +Each transform run asserts, and **fails loudly** (non-zero exit, no mart +writes) when any check fails: + +- **Row counts**: raw events consumed > 0 for a non-empty window; no epoch + with zero flows when NAV moved. +- **Monotonic NAV timestamps**: `nav_close` timestamps strictly increase per + vault; out-of-order snapshots abort the run. +- **Flow-to-epoch reconciliation**: sum of `flow_facts.amount` per epoch + reconciles against the on-chain ledger totals for that epoch within + tolerance; mismatches abort rather than writing bad aggregates. + +### Backfill Runbook + +1. Identify the target window (cursor range or epoch range). +2. Run `etl --job --from-cursor ` against a staging warehouse. +3. Inspect data-quality gate output; fix source data or transform if it fails. +4. Re-run against production; the watermark advances only on success. +5. For restatements, replay the affected epoch and confirm `epoch_facts` + and dependent marts reflect the corrected values. + +### Access & PII + +- Dashboards and exports use a **read-only** warehouse role; ETL uses a + separate write role. Credentials live in the job runner's secret store, + never in the app runtime. +- No depositor-identifying fields are stored beyond what the data retention + policy permits; flows are keyed by opaque `flow_id`. diff --git a/src/env.ts b/src/env.ts index b31c5db1..af25ec74 100644 --- a/src/env.ts +++ b/src/env.ts @@ -21,6 +21,10 @@ const parsed = parseEnv({ NEXT_PUBLIC_CONTRACT_TIMELOCK_MAINNET: process.env.NEXT_PUBLIC_CONTRACT_TIMELOCK_MAINNET, NEXT_PUBLIC_CONTRACT_DEPLOYER_TESTNET: process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER_TESTNET, NEXT_PUBLIC_CONTRACT_DEPLOYER_MAINNET: process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER_MAINNET, + OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_SERVICE_NAME: process.env.OTEL_SERVICE_NAME, + OTEL_TRACES_SAMPLER: process.env.OTEL_TRACES_SAMPLER, + OTEL_TRACES_SAMPLER_ARG: process.env.OTEL_TRACES_SAMPLER_ARG, SOROBAN_RPC_URLS: process.env.SOROBAN_RPC_URLS, SOROBAN_RPC_MAX_CONCURRENCY: process.env.SOROBAN_RPC_MAX_CONCURRENCY, SOROBAN_RPC_MAX_ATTEMPTS: process.env.SOROBAN_RPC_MAX_ATTEMPTS, @@ -101,6 +105,30 @@ export function contractEnvVarName(key: string): string { return `NEXT_PUBLIC_CONTRACT_${key.toUpperCase()}_${stellarNetworkSuffix}`; } +/** + * Distributed tracing configuration (issue #299). + * + * Traces are exported to the existing observability backend via OTLP. The + * sampler is parent-based with a ratio so that error traces are never dropped: + * the tail-sampling guarantee is enforced by the collector, while the head + * sampler here keeps the hot read path cheap. `OTEL_TRACES_SAMPLER_ARG` is the + * ratio for non-error spans; error spans are always recorded. + */ +export const tracingConfig = { + /** OTLP collector endpoint; unset disables export (spans still created locally). */ + exporterEndpoint: parsed.OTEL_EXPORTER_OTLP_ENDPOINT, + /** Service name reported to the trace backend. */ + serviceName: parsed.OTEL_SERVICE_NAME ?? "vault-frontend", + /** Head sampler strategy; defaults to parent-based ratio sampling. */ + sampler: parsed.OTEL_TRACES_SAMPLER ?? "parentbased_traceidratio", + /** Ratio for non-error spans (0–1). Error traces are always sampled. */ + samplerArg: Number.parseFloat(parsed.OTEL_TRACES_SAMPLER_ARG ?? "0.1"), + /** Header carrying the correlation ID across BFF → backend → indexer hops. */ + correlationHeader: "x-correlation-id", + /** Maximum accepted length of an inbound correlation ID (bytes). */ + maxCorrelationIdLength: 128, +} as const; + /** * Soroban RPC resilience configuration (issue #282). * diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index fd94a3b3..6fdd5a5f 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -43,6 +43,11 @@ import { submitTransactionXdr } from "./stellar"; * POST /api/v1/vaults/{id}/withdraw { shares: number } → { tx_id: string; queued: boolean; request_id: string } * POST /api/v1/vaults/{id}/withdrawals/{request_id}/cancel → { returned_shares: number } * + * GET /api/v1/vaults/{id}/orders → VaultOrder[] + * POST /api/v1/vaults/{id}/orders { side, shares, price, partial, expiresAt } → VaultOrder + * POST /api/v1/vaults/{id}/orders/{orderId}/fill { shares } → { tx_id: string } + * POST /api/v1/vaults/{id}/orders/{orderId}/cancel → { tx_id: string } + * * GET /api/v1/vaults/positions * → VaultPositionWire[] (one row per vault the wallet holds; `error` set when that vault failed) * @@ -283,6 +288,134 @@ export type VaultStrategy = z.infer; export type RiskRating = z.infer; export type EpochPhase = z.infer; +/** Instrument used to hedge a vault's directional exposure. */ +export type HedgeInstrument = "underlying" | "perp" | "futures"; + +/** + * Per-vault delta-hedging policy (issue #297). + * + * The policy is enforced on-chain; the keeper service only proposes hedge + * trades. A disabled policy or an engaged kill switch must result in zero + * hedge trades. `maxHedgeNotional` is a hard cap in USD that the contract + * clamps against so an off-chain bug cannot overshoot. + */ +export interface HedgePolicy { + /** Master enable flag. When false, no hedge trades are ever executed. */ + enabled: boolean; + /** Hard kill switch — overrides `enabled` and halts all hedging. */ + killSwitch: boolean; + /** Target portfolio delta (e.g. 0 = delta-neutral). */ + targetDelta: number; + /** Allowed drift around the target before a rehedge is triggered. */ + deltaTolerance: number; + /** Instrument used to execute hedge trades. */ + hedgeInstrument: HedgeInstrument; + /** Maximum absolute hedge notional in USD, enforced in-contract. */ + maxHedgeNotional: number; + /** Minimum seconds between rehedges to rate-limit hedge churn. */ + rehedgeIntervalSecs: number; +} + +/** Current hedge status surfaced for a vault. */ +export interface HedgeStatus { + policy: HedgePolicy; + /** Aggregate portfolio delta of open written positions. */ + portfolioDelta: number; + /** Aggregate portfolio gamma (informational). */ + portfolioGamma: number; + /** Aggregate portfolio vega (informational). */ + portfolioVega: number; + /** Current hedge position notional in USD (signed). */ + hedgeNotional: number; + /** Whether the current delta is outside the configured band. */ + outsideBand: boolean; + /** Timestamp of the last executed hedge trade, if any. */ + lastHedgeAt: string | null; + /** True when the price feed is stale and hedging is failing safe. */ + staleOracle: boolean; +} + +/** A single hedge trade recorded in the epoch ledger. */ +export interface HedgeTrade { + epochNumber: number; + timestamp: string; // ISO + instrument: HedgeInstrument; + /** Signed notional in USD (positive = long hedge, negative = short). */ + notional: number; + /** Realized hedge P&L in USD, attributed separately from option premium. */ + hedgePnl: number; + /** Portfolio delta before and after the hedge. */ + deltaBefore: number; + deltaAfter: number; +} + +// --------------------------------------------------------------------------- +// Secondary transfer facility (issue #300) +// --------------------------------------------------------------------------- + +/** Side of a secondary-market order for vault LP shares. */ +export type VaultOrderSide = "bid" | "offer"; + +/** Lifecycle state of a secondary-market order. */ +export type VaultOrderStatus = + | "open" + | "partially_filled" + | "filled" + | "cancelled" + | "expired"; + +/** + * A resting or immediately-fillable order for vault LP shares. + * + * Price is denominated in the vault's share-price terms (USD per share) and + * must sit inside the configurable band around the latest attested NAV. The + * contract re-verifies price band, expiry, nonce and parties at settlement, + * so the off-chain store is only a discovery surface. + */ +export interface VaultOrder { + id: string; + vaultId: string; + /** Tranche identifier when the vault is tranched; null for single-tranche. */ + tranche: string | null; + side: VaultOrderSide; + /** Maker (order owner) address. */ + maker: string; + /** Remaining shares available to fill. */ + shares: number; + /** Original order size in shares. */ + originalShares: number; + /** Limit price in USD per share. */ + price: number; + /** Whether the order may be partially filled. */ + partial: boolean; + status: VaultOrderStatus; + /** ISO timestamp after which the order can no longer be filled. */ + expiresAt: string; + createdAt: string; + /** Replay-protection nonce signed into the order parameters. */ + nonce: number; +} + +/** + * Configurable pricing band around the latest attested NAV for a vault's + * secondary transfer facility. Prices outside `[nav*(1-discount), nav*(1+premium)]` + * are rejected at order creation and at settlement. + */ +export interface VaultOrderPricingBand { + /** Maximum premium over NAV, e.g. 0.05 = +5%. */ + maxPremiumPct: number; + /** Maximum discount under NAV, e.g. 0.05 = -5%. */ + maxDiscountPct: number; + /** Latest attested NAV per share used as the band anchor. */ + attestedNav: number; + /** ISO timestamp of the NAV attestation. */ + attestedAt: string; + /** True when the attestation is stale or under dispute; settlement is blocked. */ + stale: boolean; + /** True when a NAV dispute is open; settlement is blocked. */ + disputed: boolean; +} + /** * Execution mode of a vault strategy. * @@ -632,152 +765,4 @@ export function fmtUsd(n: number) { 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 + ? `$${ diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts index 0f17c333..af8fa289 100644 --- a/src/lib/pricing.ts +++ b/src/lib/pricing.ts @@ -143,6 +143,131 @@ 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 delta-hedging (issue #297) +// +// Per-vault hedging policy plus vault-level Greeks aggregation and the +// band-trigger / fail-safe logic that the keeper service drives. On-chain +// enforcement (max notional, kill switch, cadence) is authoritative; these +// helpers mirror it so the keeper and UI agree on what a hedge should be. +// --------------------------------------------------------------------------- + +export type HedgeInstrument = "underlying" | "perp" | "futures"; + +export interface HedgePolicy { + /** Master enable flag. When false the keeper performs zero hedge trades. */ + enabled: boolean; + /** Hard kill switch — overrides `enabled` and blocks all hedge execution. */ + killSwitch: boolean; + /** Desired portfolio delta (in underlying units). */ + targetDelta: number; + /** Allowed drift around `targetDelta` before a rehedge is triggered. */ + tolerance: number; + /** Venue used to place hedge orders. */ + instrument: HedgeInstrument; + /** Absolute cap on hedge notional; enforced in-contract. */ + maxHedgeNotional: number; + /** Minimum seconds between rehedges to rate-limit churn. */ + rehedgeIntervalSec: number; +} + +export const DEFAULT_HEDGE_POLICY: HedgePolicy = { + enabled: false, + killSwitch: false, + targetDelta: 0, + tolerance: 0.1, + instrument: "underlying", + maxHedgeNotional: 0, + rehedgeIntervalSec: 3600, +}; + +/** A single open written option position contributing to vault Greeks. */ +export interface WrittenPosition { + /** Signed contract quantity: negative for written (short) options. */ + qty: number; + /** Contract multiplier (underlying units per contract). */ + multiplier: number; + spot: number; + strike: number; + vol: number; + /** Time to expiry in years. */ + t: number; + isCall: boolean; +} + +export interface VaultGreeks { + delta: number; + gamma: number; + vega: number; + theta: number; +} + +/** + * Aggregate vault-level Greeks from open written positions using the shared + * Black-Scholes engine. Delta/gamma/vega/theta are summed in underlying units + * (per-contract Greeks scaled by qty * multiplier). + */ +export function vaultGreeks(positions: WrittenPosition[]): VaultGreeks { + const agg: VaultGreeks = { delta: 0, gamma: 0, vega: 0, theta: 0 }; + for (const p of positions) { + if (!Number.isFinite(p.spot) || p.spot <= 0 || !Number.isFinite(p.vol) || p.vol <= 0) continue; + const g = bs(p.spot, p.strike, p.vol, p.t, p.isCall); + const scale = p.qty * p.multiplier; + agg.delta += g.delta * scale; + agg.gamma += g.gamma * scale; + agg.vega += g.vega * scale; + agg.theta += g.theta * scale; + } + return agg; +} + +export interface HedgeDecision { + /** Whether a hedge trade should be executed this cycle. */ + shouldHedge: boolean; + /** Signed hedge size in underlying units (0 when no trade). */ + hedgeQty: number; + /** Signed hedge notional in quote currency (0 when no trade). */ + hedgeNotional: number; + /** Human-readable reason for the decision (for logs / analytics). */ + reason: string; +} + +/** + * Decide whether to rehedge given the current vault Greeks, policy, and the + * last rehedge timestamp. Fails safe (no trade) on stale/missing prices, a + * disabled policy, an engaged kill switch, or a cadence violation. The hedge + * size is clamped to `maxHedgeNotional` so an off-chain bug cannot overshoot. + */ +export function evaluateHedge( + greeks: VaultGreeks, + policy: HedgePolicy, + spot: number, + nowSec: number, + lastHedgeSec: number +): HedgeDecision { + const none = (reason: string): HedgeDecision => ({ shouldHedge: false, hedgeQty: 0, hedgeNotional: 0, reason }); + + if (!policy.enabled) return none("policy disabled"); + if (policy.killSwitch) return none("kill switch engaged"); + if (!Number.isFinite(spot) || spot <= 0) return none("stale or missing price feed"); + if (!Number.isFinite(greeks.delta)) return none("invalid greeks"); + if (nowSec - lastHedgeSec < policy.rehedgeIntervalSec) return none("rehedge cadence not elapsed"); + + const drift = greeks.delta - policy.targetDelta; + if (Math.abs(drift) <= policy.tolerance) return none("within delta band"); + + // Hedge by trading against the drift to bring delta back to target. + let hedgeQty = -drift; + let hedgeNotional = Math.abs(hedgeQty) * spot; + if (policy.maxHedgeNotional > 0 && hedgeNotional > policy.maxHedgeNotional) { + const clamped = policy.maxHedgeNotional / spot; + hedgeQty = Math.sign(hedgeQty) * clamped; + hedgeNotional = policy.maxHedgeNotional; + } + + return { shouldHedge: true, hedgeQty, hedgeNotional, reason: "delta breach" }; +} + /** * Deposit and withdrawal pricing rule for option-writing vaults (issue #208). *