diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 213c0e9..cff7917 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,591 +1,447 @@ -openapi: 3.0.3 - +openapi: '3.0.3' info: - title: NeuroWealth API - version: 1.0.0 + title: NeuroWealth Backend API + version: '1.0.0' description: | - REST API for the NeuroWealth platform — AI-assisted portfolio management - backed by Stellar smart contracts. - - **Versioning:** All endpoints are served under an explicit version prefix - (`/api/v1/*`). The legacy unversioned paths (`/api/*`) remain available as - deprecated aliases and return `Deprecation`/`Sunset` headers. Every response - includes an `X-API-Version` header. See `docs/api-versioning.md`. + NeuroWealth autonomous DeFi portfolio manager backend. - **Breaking-change policy:** This API follows semantic versioning. Breaking - changes (removed fields, changed response shapes, new required parameters) - increment the major version and are announced at least two weeks before release. + Authentication: All protected endpoints require a `Bearer ` token in the + `Authorization` header, obtained from `POST /api/auth/login`. servers: - - url: http://localhost:{port} - description: Local development - variables: - port: - default: '3000' - - url: https://api.neurowealth.app - description: Production + - url: /api/v1 + description: Versioned API base (alias of /api for current release) + - url: /api + description: API base tags: - - name: health - description: Liveness and readiness probes - - name: auth - description: Wallet-based authentication (Stellar SIWE challenge/verify) - - name: agent - description: Agent loop control and monitoring - - name: whatsapp - description: Twilio WhatsApp webhook integration - - name: portfolio - description: Portfolio positions, performance, and earnings - - name: transactions - description: Transaction history and details - - name: protocols - description: Protocol rates and agent status - - name: deposit - description: On-chain deposit operations - - name: withdraw - description: On-chain withdrawal operations - - name: vault - description: Vault / savings product - - name: analytics - description: Analytics — APY history, user yield, protocol performance - - name: stellar - description: Stellar event listener metrics - - name: fiat - description: Fiat on-ramp / off-ramp (buy and sell crypto with fiat via a payment provider) - - name: referrals - description: Referral rewards program (share a code, earn when referred users deposit) - - name: goals - description: Goal-based investing — a target amount + date driving the agent's strategy selection - - name: admin - description: Admin-only management endpoints - - name: sub-accounts - description: Family & team sub-accounts with scoped, revocable permissions - - name: metrics - description: Prometheus-compatible metrics endpoint + - name: Analytics + description: Portfolio performance and risk analytics + - name: Auth + description: Authentication and session management + - name: Portfolio + description: User portfolio management + +# ─── Reusable components ────────────────────────────────────────────────────── +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT -# Default: no auth. Operations that require a token declare their own -# `security: [BearerAuth: []]`, which overrides this default. This satisfies -# the OpenAPI `security-defined` lint rule for the public endpoints (health, -# auth challenge/verify, webhooks, public protocol reads) without annotating -# each one individually. -security: [] + schemas: + # ── Shared primitives ──────────────────────────────────────────────────── + Period: + type: string + enum: ['7d', '30d', '90d'] + default: '30d' + description: Analysis time window. Maximum available history is 90 days. -paths: - # ── Health ───────────────────────────────────────────────────────────────── - /health: - get: - tags: [health] - operationId: healthCheck - summary: Liveness check - description: Returns service status, version, and environment. No auth required. - responses: - '200': - description: Service is healthy - content: - application/json: - schema: - $ref: '#/components/schemas/HealthResponse' - example: - status: ok - timestamp: '2026-06-29T12:00:00.000Z' - version: 1.0.0 - environment: production + InsufficientHistoryFlag: + type: boolean + description: | + True when the available snapshot history is shorter than the requested + window. Rows/responses with this flag set are excluded from leaderboard + rankings. Never silently serves a truncated window under the requested label. - /health/ready: - get: - tags: [health] - operationId: readinessCheck - summary: Readiness check - description: Returns 200 when DB and Stellar RPC are reachable, 503 otherwise. - responses: - '200': - description: All dependencies reachable - content: - application/json: - schema: - type: object - properties: - ready: - type: boolean - enum: [true] - subsystems: - type: object - timestamp: - type: string - format: date-time - example: - ready: true - subsystems: - database: healthy - eventListener: connected - agentLoop: running - timestamp: '2026-06-29T12:00:00.000Z' - '503': - description: One or more dependencies unavailable - content: - application/json: - schema: - type: object - properties: - ready: - type: boolean - enum: [false] - subsystems: - type: object - timestamp: - type: string - format: date-time - example: - ready: false - subsystems: - database: healthy - eventListener: disconnected - agentLoop: running - timestamp: '2026-06-29T12:00:00.000Z' + NullableDecimal: + type: number + format: double + nullable: true - # ── Auth ─────────────────────────────────────────────────────────────────── - /api/v1/auth/challenge: - post: - tags: [auth] - operationId: getAuthChallenge - summary: Request a sign-in challenge nonce + # ── Risk metrics ───────────────────────────────────────────────────────── + RiskMetrics: + type: object description: | - Generates a one-time nonce that the caller must sign with their Stellar - keypair. The nonce expires after a configurable TTL (default 5 min). - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [stellarPubKey] - properties: - stellarPubKey: - type: string - description: Stellar G-address (Ed25519 public key) - example: GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37 - responses: - '200': - description: Challenge nonce issued - content: - application/json: - schema: - $ref: '#/components/schemas/AuthChallengeResponse' - example: - nonce: nw-auth-a3f9e2c1d0b84756... - expiresAt: '2026-06-29T12:05:00.000Z' - '400': - description: Invalid Stellar public key - $ref: '#/components/responses/BadRequest' + Computed risk/performance statistics for a portfolio-value series. - /api/v1/auth/verify: - post: - tags: [auth] - operationId: verifyAuthSignature - summary: Verify wallet signature and issue JWT - description: | - Verifies the Stellar signature over the nonce obtained from `/challenge`. - On success creates a new user if one does not exist and issues an - access token + refresh token pair with rotation. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AuthVerifyRequest' - example: - stellarPubKey: GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37 - signature: AQAAAAA... - responses: - '200': - description: Signature valid — token pair issued - content: - application/json: - schema: - $ref: '#/components/schemas/AuthVerifyResponse' - example: - accessToken: eyJhbGciOiJIUzI1NiIs... - refreshToken: nw-ref-a3f9e2c1d0b8... - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - expiresAt: '2026-06-29T12:15:00.000Z' - refreshExpiresAt: '2026-07-06T12:00:00.000Z' - '401': - description: Invalid or expired signature / nonce - $ref: '#/components/responses/Unauthorized' + **Null contract**: any metric that is not computable (insufficient samples, + zero variance, degenerate series) is returned as `null` — never as `0`, + `Infinity`, or `NaN`. - /api/v1/auth/logout: - post: - tags: [auth] - operationId: logout - summary: Invalidate the current session - description: | - Revokes the session associated with the provided Bearer token. - Both the access token and refresh token are invalidated. - security: - - BearerAuth: [] - responses: - '200': - description: Session invalidated - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: Logged out successfully - '401': - $ref: '#/components/responses/Unauthorized' + **VaR / CVaR estimators**: + - `varHistorical*` / `cvarHistorical*` — empirical (plain-historical), + sorts the observed return distribution. Preferred for DeFi portfolios. + - `varParametric*` — Gaussian assumption (mean + σ). Underestimates tail + risk for fat-tailed distributions; provided for comparison only. - # ── Agent ────────────────────────────────────────────────────────────────── - /api/v1/agent/status: - get: - tags: [agent] - operationId: getAgentStatus - summary: Get agent loop status - description: | - Returns current agent health and operational status. - Protected by internal auth (X-Internal-Token, IP allowlist, or admin Bearer token). - security: - - InternalToken: [] - responses: - '200': - description: Agent status - content: - application/json: - schema: - $ref: '#/components/schemas/AgentStatusResponse' - example: - success: true - data: - isRunning: true - lastRebalanceAt: '2026-06-29T11:30:00.000Z' - currentProtocol: Aave - currentApy: 4.23 - nextScheduledCheck: '2026-06-29T12:30:00.000Z' - lastError: null - healthStatus: healthy - timestamp: '2026-06-29T12:00:00.000Z' - '403': - $ref: '#/components/responses/Forbidden' + All loss magnitudes are **positive numbers** (0.05 = 5% potential loss). + properties: + sampleCount: + type: integer + description: Number of period-return observations used in all computations. + annualisedVolatility: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: | + Sample standard deviation of period returns × √(periodsPerYear). + null if fewer than 2 return observations. + sortinoRatio: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: | + (Annualised mean return − MAR) / annualised downside deviation. + null if fewer than 2 observations or downside deviation is 0 (no losses). + downsideDeviation: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: RMS of returns below MAR, annualised. null if no returns. + maxDrawdown: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: | + Maximum peak-to-trough decline as a positive fraction (0.15 = 15%). + null if fewer than 2 value points. + maxDrawdownDuration: + nullable: true + type: integer + description: | + Number of observation steps from peak to trough in the max drawdown + episode. null if no drawdown occurred. + varHistorical95: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: Historical VaR at 95% confidence (positive = potential loss). + varHistorical99: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: Historical VaR at 99% confidence. + varParametric95: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: Parametric (Gaussian) VaR at 95% confidence. + varParametric99: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: Parametric (Gaussian) VaR at 99% confidence. + cvarHistorical95: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: Historical CVaR (Expected Shortfall) at 95%. + cvarHistorical99: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: Historical CVaR at 99%. + beta: + allOf: + - $ref: '#/components/schemas/NullableDecimal' + description: | + Beta vs an exogenous benchmark series. null when no benchmark is + provided (benchmark index ingestion is deferred). + periodsPerYear: + type: number + description: | + Inferred periods-per-year used for annualisation, derived from the + median inter-observation spacing. Robust to snapshot gaps. + required: + - sampleCount + - periodsPerYear - # ── WhatsApp ─────────────────────────────────────────────────────────────── - /api/v1/whatsapp/webhook: - get: - tags: [whatsapp] - operationId: whatsappWebhookHealth - summary: WhatsApp webhook health check - description: Twilio webhook endpoint health check. Returns a simple text response. - responses: - '200': - description: Webhook is alive - content: - text/plain: - schema: - type: string - example: WhatsApp webhook is alive - post: - tags: [whatsapp] - operationId: handleWhatsAppMessage - summary: Handle incoming WhatsApp message - description: | - Receives incoming WhatsApp messages from Twilio via webhook. - Validates the x-twilio-signature header. Returns TwiML response. - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: + PortfolioRiskResponse: + type: object + properties: + userId: + type: string + format: uuid + requestedWindow: + $ref: '#/components/schemas/Period' + actualWindowDays: + type: integer + description: Days of data actually used (may be shorter than requested). + insufficientHistory: + $ref: '#/components/schemas/InsufficientHistoryFlag' + dataFrom: + nullable: true + type: string + format: date-time + description: ISO timestamp of the earliest snapshot included. + dataTo: + nullable: true + type: string + format: date-time + description: ISO timestamp of the latest snapshot included. + computedAt: + type: string + format: date-time + description: When these figures were computed (staleness signal). + source: + type: string + enum: ['precomputed', 'live'] + description: Whether the response was served from the precomputed cache. + metrics: + allOf: + - $ref: '#/components/schemas/RiskMetrics' + - nullable: true type: object - required: [From, Body] - properties: - From: - type: string - description: WhatsApp sender number - example: '+1234567890' - Body: - type: string - description: Message body - example: What is my portfolio? - responses: - '200': - description: TwiML response - content: - text/xml: - schema: - type: string - example: 'Your portfolio value is 12,450.75 USDC' - '403': - description: Missing or invalid Twilio signature - content: - text/plain: - schema: - type: string - example: 'Forbidden: invalid Twilio signature' + description: null when sampleCount is 0 (entirely un-funded history). + required: + - userId + - requestedWindow + - actualWindowDays + - insufficientHistory + - computedAt + - source - /api/v1/telegram: - get: - tags: [telegram] - operationId: telegramWebhookHealth - summary: Telegram webhook health check - description: Telegram webhook endpoint health check. Returns a simple text response. - responses: - '200': - description: Webhook is alive - content: - text/plain: - schema: - type: string - example: Telegram webhook is alive - post: - tags: [telegram] - operationId: handleTelegramMessage - summary: Handle incoming Telegram message - description: | - Receives incoming Telegram messages via webhook. - Validates the x-telegram-bot-api-secret-token header. Sends the reply through the Telegram Bot API. - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [message] - properties: - message: - type: object - properties: - chat: - type: object - properties: - id: - type: integer - description: Telegram chat ID - example: 123456789 - text: - type: string - description: Message body - example: What is my portfolio? - responses: - '200': - description: Message accepted and sent to Telegram - content: - text/plain: - schema: - type: string - example: OK - '401': - description: Missing or invalid Telegram webhook secret token - content: - text/plain: - schema: - type: string - example: 'Forbidden: invalid Telegram secret token' + RollingVolPoint: + type: object + properties: + timestampMs: + type: integer + format: int64 + description: Epoch milliseconds of the last return in this rolling window. + volatility: + nullable: true + type: number + format: double + description: Annualised volatility over the window. null if insufficient data. + required: + - timestampMs + - volatility - # ── Portfolio ────────────────────────────────────────────────────────────── - /api/v1/portfolio/{userId}: - get: - tags: [portfolio] - operationId: getPortfolio - summary: Get portfolio positions - description: | - Returns the user's portfolio including all active positions, - total balance, and total earnings. The authenticated user can - only access their own portfolio (enforceUserAccess). - security: - - BearerAuth: [] - parameters: - - in: path - name: userId - required: true + DrawdownPoint: + type: object + properties: + timestampMs: + type: integer + format: int64 + drawdown: + type: number + format: double + description: Drawdown from the running peak as a positive fraction. + required: + - timestampMs + - drawdown + + TimeseriesResponse: + type: object + properties: + userId: + type: string + format: uuid + requestedWindow: + $ref: '#/components/schemas/Period' + insufficientHistory: + $ref: '#/components/schemas/InsufficientHistoryFlag' + rollingVolatility: + type: array + items: + $ref: '#/components/schemas/RollingVolPoint' + drawdown: + type: array + items: + $ref: '#/components/schemas/DrawdownPoint' + computedAt: + type: string + format: date-time + required: + - userId + - requestedWindow + - insufficientHistory + - rollingVolatility + - drawdown + - computedAt + + ValidationError: + type: object + properties: + error: + type: string + example: Validation error + details: + type: object + required: + - error + + UnauthorizedError: + type: object + properties: + error: + type: string + example: Unauthorized + required: + - error + + responses: + Unauthorized: + description: Missing or invalid JWT. + content: + application/json: schema: - type: string - format: uuid - description: User ID (UUID v4) - responses: - '200': - description: Portfolio data - content: - application/json: - schema: - $ref: '#/components/schemas/PortfolioResponse' - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - totalBalance: 12450.75 - totalEarnings: 523.40 - activePositions: 3 - positions: - - id: pos-001 - protocolName: Aave - assetSymbol: USDC - currentValue: 10000.00 - yieldEarned: 423.15 - status: ACTIVE - - id: pos-002 - protocolName: Compound - assetSymbol: XLM - currentValue: 2000.00 - yieldEarned: 85.50 - status: ACTIVE - - id: pos-003 - protocolName: unassigned - assetSymbol: USDC - currentValue: 450.75 - yieldEarned: 14.75 - status: ACTIVE - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' + $ref: '#/components/schemas/UnauthorizedError' - /api/v1/portfolio/{userId}/suggest-allocation: - post: - tags: [portfolio] - operationId: suggestAllocation - summary: Suggest an optimal allocation +# ─── Paths ──────────────────────────────────────────────────────────────────── +paths: + + # ── Risk endpoints ─────────────────────────────────────────────────────────── + + /analytics/risk: + get: + operationId: getPortfolioRisk + summary: Portfolio risk metrics description: | - Computes a mean-variance optimal allocation from historical ProtocolRate - data, the user's risk tolerance, and their effective risk ceiling, and - returns it together with an efficient frontier and a backtest comparison. + Returns VaR (95%/99%, historical and parametric), CVaR (95%/99%, + historical), Sortino ratio, downside deviation, max drawdown (+duration), + and annualised volatility for the authenticated user's portfolio. - **This endpoint is ADVISORY and never changes anything.** It does not - write `User.strategyConfig`, does not move funds, and does not alter what - the rebalancing agent will do. Applying a suggestion is a separate, - explicit call to the strategy update endpoint. Every response carries - `isSuggestion: true` and a `disclaimer`. + **Ownership**: `userId` is taken from the JWT payload — you cannot read + another user's risk data via this endpoint. - **What the optimization actually minimizes.** `ProtocolRate` is a - yield-quote series, not a price series, so the covariance measures how - protocols' quoted APYs co-move. The optimizer minimizes YIELD volatility. - It does NOT model principal loss, depeg, or smart-contract failure — - those enter only through `ProtocolRiskScore`, which filters the universe. + **Retention boundary**: YieldSnapshot rows are hard-deleted after 90 days + by the snapshotter. Windows longer than the available history are + relabelled with `insufficientHistory: true` and `actualWindowDays` set to + the honest available span — never silently truncated. - **Cost controls.** Compute-intensive, so this route carries its own - stricter rate limiter (default 5/min) plus an in-flight concurrency bound - of one optimization per user. Both return `429`. + **Caching**: If a precomputed row < 1 hour old exists, it is returned + directly (`source: "precomputed"`). Otherwise a live compute is performed + (`source: "live"`). - See `docs/PORTFOLIO_OPTIMIZATION.md` for the objective, the risk-aversion - mapping, and the estimation method. + **Null contract**: metrics that cannot be computed return `null`, never + `0`, `Infinity`, or `NaN`. + tags: [Analytics] security: - - BearerAuth: [] + - bearerAuth: [] parameters: - - in: path - name: userId - required: true + - name: window + in: query + required: false schema: - type: string - format: uuid - description: User ID (UUID v4). Must be the authenticated user. - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/SuggestAllocationRequest' - example: - lookbackDays: 90 - frontierPoints: 12 - includeBacktest: true + $ref: '#/components/schemas/Period' + description: Analysis time window. responses: '200': - description: | - An allocation suggestion. Note that a `status` other than `ok` is - still a 200 — "the optimizer ran and could not produce a portfolio" - is a result, not a request error, and the payload names the reason. + description: Risk metrics for the authenticated user. content: application/json: schema: - $ref: '#/components/schemas/AllocationSuggestionResponse' + $ref: '#/components/schemas/PortfolioRiskResponse' + examples: + sufficient_history: + summary: User with 30d of data + value: + userId: "3f8e1c2a-4b5d-6e7f-8a9b-0c1d2e3f4a5b" + requestedWindow: "30d" + actualWindowDays: 30 + insufficientHistory: false + dataFrom: "2026-07-21T00:00:00.000Z" + dataTo: "2026-08-20T00:00:00.000Z" + computedAt: "2026-08-20T12:00:00.000Z" + source: "precomputed" + metrics: + sampleCount: 720 + annualisedVolatility: 0.12 + sortinoRatio: 1.85 + downsideDeviation: 0.045 + maxDrawdown: 0.08 + maxDrawdownDuration: 14 + varHistorical95: 0.031 + varHistorical99: 0.065 + varParametric95: 0.028 + varParametric99: 0.044 + cvarHistorical95: 0.052 + cvarHistorical99: 0.078 + beta: null + periodsPerYear: 8760 + insufficient_history: + summary: New user with < 7d data requesting 30d + value: + userId: "3f8e1c2a-4b5d-6e7f-8a9b-0c1d2e3f4a5b" + requestedWindow: "30d" + actualWindowDays: 3 + insufficientHistory: true + dataFrom: "2026-08-17T00:00:00.000Z" + dataTo: "2026-08-20T00:00:00.000Z" + computedAt: "2026-08-20T12:00:00.000Z" + source: "live" + metrics: null '400': - $ref: '#/components/responses/BadRequest' + description: Invalid query parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - '429': - description: | - Rate limit exceeded, or an optimization is already in flight for this - user. Carries `Retry-After`. + description: Missing or invalid JWT. content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: An optimization is already running for this account. Please wait for it to finish. + $ref: '#/components/schemas/UnauthorizedError' + '500': + description: Internal server error. - /api/v1/portfolio/{userId}/suggestions: + /analytics/risk/timeseries: get: - tags: [portfolio] - operationId: listAllocationSuggestions - summary: List stored allocation suggestions + operationId: getPortfolioRiskTimeseries + summary: Portfolio risk timeseries (rolling volatility + drawdown) description: | - Paginated history of previously computed suggestions, newest first. - Rows written by the scheduled precompute job carry a null `backtest`; - that is expected, not a failure — the backtest legs are only computed - for interactive requests. + Returns graph-ready rolling annualised volatility and running-peak + drawdown series for the authenticated user's portfolio. + + **Ownership**: `userId` is taken from the JWT payload. + + **Null in rolling volatility**: points with fewer observations than + `rollingWindow` have `volatility: null`. + tags: [Analytics] security: - - BearerAuth: [] + - bearerAuth: [] parameters: - - in: path - name: userId - required: true + - name: window + in: query + required: false schema: - type: string - format: uuid - description: User ID (UUID v4). Must be the authenticated user. - - in: query - name: page + $ref: '#/components/schemas/Period' + - name: rollingWindow + in: query + required: false schema: type: integer - minimum: 1 - default: 1 - - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 50 - default: 20 + minimum: 2 + maximum: 30 + default: 7 + description: Number of observations per rolling volatility window. responses: '200': - description: Stored suggestions + description: Timeseries data for the authenticated user. content: application/json: schema: - $ref: '#/components/schemas/AllocationSuggestionListResponse' + $ref: '#/components/schemas/TimeseriesResponse' '400': - $ref: '#/components/responses/BadRequest' + description: Invalid query parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' + description: Missing or invalid JWT. + content: + application/json: + schema: + $ref: '#/components/schemas/UnauthorizedError' + '500': + description: Internal server error. - /api/v1/portfolio/{userId}/history: + # ── Existing analytics endpoints ───────────────────────────────────────────── + + /analytics/apy-history: get: - tags: [portfolio] - operationId: getPortfolioHistory - summary: Get portfolio yield history - description: Returns historical yield snapshots for the user's positions over a given period. + operationId: getApyHistory + summary: APY snapshot history + description: | + Returns APY snapshots over time for the authenticated user's positions. + Graph-ready (sorted ascending by date). + tags: [Analytics] security: - - BearerAuth: [] + - bearerAuth: [] parameters: - - in: path - name: userId - required: true - schema: - type: string - format: uuid - description: User ID (UUID v4) - - in: query - name: period + - name: period + in: query + required: false schema: - type: string - enum: [7d, 30d, 90d] - default: 30d - description: Lookback period + $ref: '#/components/schemas/Period' responses: '200': - description: Portfolio history + description: APY history points. content: application/json: schema: @@ -595,8 +451,7 @@ paths: type: string format: uuid period: - type: string - enum: [7d, 30d, 90d] + $ref: '#/components/schemas/Period' points: type: array items: @@ -605,40 +460,30 @@ paths: date: type: string format: date - yieldAmount: + apy: type: number - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - period: 30d - points: - - date: 2026-06-01 - yieldAmount: 12.50 - - date: 2026-06-02 - yieldAmount: 13.20 + positionId: + type: string '401': $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /api/v1/portfolio/{userId}/earnings: + /analytics/user-yield: get: - tags: [portfolio] - operationId: getPortfolioEarnings - summary: Get portfolio earnings summary - description: Returns total and period earnings with average APY for the user. + operationId: getUserYield + summary: User yield summary + description: Returns cumulative and period yield earned by the authenticated user. + tags: [Analytics] security: - - BearerAuth: [] + - bearerAuth: [] parameters: - - in: path - name: userId - required: true + - name: period + in: query + required: false schema: - type: string - format: uuid - description: User ID (UUID v4) + $ref: '#/components/schemas/Period' responses: '200': - description: Portfolio earnings + description: Yield summary. content: application/json: schema: @@ -647,4772 +492,73 @@ paths: userId: type: string format: uuid - totalEarnings: + period: + $ref: '#/components/schemas/Period' + totalYield: type: number - periodEarnings: + periodYield: type: number averageApy: type: number - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - totalEarnings: 523.40 - periodEarnings: 85.20 - averageApy: 4.23 + points: + type: array + items: + type: object '401': $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /api/v1/portfolio/{userId}/tax-report: + /analytics/protocol-performance: get: - tags: [portfolio] - operationId: getPortfolioTaxReport - summary: Get realized gain/loss tax report - description: | - Returns the user's realized gain/loss report for a calendar year (UTC - boundaries), computed with FIFO cost-basis lot accounting over - confirmed on-chain withdrawals. Totals include only fully priced - disposals; unpriced assets are flagged in `caveats`, never zeroed. - See docs/TAX_REPORT.md for methodology and known limitations. The - authenticated user can only access their own report - (enforceUserAccess). With `format=csv` the report's disposal rows are - returned as an RFC 4180 CSV attachment with spreadsheet - formula-injection guarding. - security: - - BearerAuth: [] + operationId: getProtocolPerformance + summary: Protocol APY history + description: Returns historical APY rates per protocol. Public endpoint (no auth required). + tags: [Analytics] + security: [] parameters: - - in: path - name: userId - required: true - schema: - type: string - format: uuid - description: User ID (UUID v4) - - in: query - name: year - required: true - schema: - type: integer - minimum: 2000 - maximum: 2100 - description: Calendar year (UTC) the report covers - - in: query - name: format + - name: period + in: query + required: false schema: - type: string - enum: [json, csv] - default: json - description: Response format + $ref: '#/components/schemas/Period' responses: '200': - description: Tax report for the requested year - content: - application/json: - schema: - $ref: '#/components/schemas/TaxReport' - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - year: 2026 - method: FIFO - disposals: - - disposedAt: '2026-06-15T00:00:00.000Z' - assetSymbol: USDC - amount: '40' - withdrawalTxHash: c1d2e3f4a5b6c7d8e9f0 - acquiredAt: '2026-01-15T00:00:00.000Z' - acquisitionTxHash: a1b2c3d4e5f6a7b8c9d0 - acquisitionPrice: '1' - disposalPrice: '1' - costBasis: '40' - proceeds: '40' - realizedGain: '0' - priced: true - totals: - proceeds: '40' - costBasis: '40' - realizedGain: '0' - pricedDisposalCount: 1 - caveats: - unpricedDisposalCount: 0 - unpricedAssets: [] - stablecoinAssumption: USDC is priced at 1.00 USD by assumption (STABLECOIN_ASSUMPTION); no market price feed is used. - rebalancesNotIncluded: Protocol rebalances are same-asset transfers and are not treated as taxable disposals in this report. - text/csv: - schema: - type: string - description: | - One header row plus one row per disposal, CRLF line endings. - Cells beginning with `=`, `+`, `-`, `@`, tab, or CR are - prefixed with `'` to prevent spreadsheet formula injection. - example: | - disposedAt,assetSymbol,amount,withdrawalTxHash,acquiredAt,acquisitionTxHash,acquisitionPrice,disposalPrice,costBasis,proceeds,realizedGain,priced - 2026-06-15T00:00:00.000Z,USDC,40,c1d2e3f4a5b6c7d8e9f0,2026-01-15T00:00:00.000Z,a1b2c3d4e5f6a7b8c9d0,1,1,40,40,0,true - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - # ── Goal-based investing (#281) ───────────────────────────────────────────── - /api/v1/portfolio/goals: - post: - tags: [goals] - operationId: createGoal - summary: Create the caller's savings goal - description: | - Creates a savings goal for the authenticated user. Only one ACTIVE goal - per user is allowed at a time — returns 409 if one already exists. - `startingAmount` defaults to the current value of `positionId` (or the - sum of the user's active positions if omitted). If the target is - already met by the starting amount, the goal is created directly with - status ACHIEVED. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [targetAmount, targetDate] - properties: - targetAmount: - type: number - description: Must be greater than startingAmount - targetDate: - type: string - format: date-time - description: Must be in the future - startingAmount: - type: number - positionId: - type: string - format: uuid - riskCeiling: - type: integer - minimum: 0 - maximum: 100 - example: - targetAmount: 10000 - targetDate: '2027-07-21T00:00:00.000Z' - responses: - '201': - description: Goal created - content: - application/json: - schema: - type: object - properties: - goal: - $ref: '#/components/schemas/SavingsGoal' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '409': - $ref: '#/components/responses/Conflict' - - /api/v1/portfolio/goals/{id}: - get: - tags: [goals] - operationId: getGoal - summary: Get the user's current savings goal - description: | - Returns the user's ACTIVE goal if one exists, otherwise their most - recently created goal. The authenticated user can only access their - own goal (enforceUserAccess). NOTE: for this operation only, `id` is - the **user's** ID, not a goal ID — documented under the same path - item as PATCH/DELETE below (which take a goal ID) since they share the - same route shape, differentiated by HTTP method. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: User ID (UUID v4) — see note above - responses: - '200': - description: Savings goal - content: - application/json: - schema: - type: object - properties: - goal: - $ref: '#/components/schemas/SavingsGoal' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - patch: - tags: [goals] - operationId: updateGoal - summary: Update a savings goal - description: | - Updates target amount/date/riskCeiling on an ACTIVE goal. This route is - keyed by goal id rather than :userId, so ownership is checked - explicitly (goal.userId must match the authenticated caller) instead of - via enforceUserAccess. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Goal ID (UUID v4) - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - targetAmount: - type: number - targetDate: - type: string - format: date-time - riskCeiling: - type: integer - minimum: 0 - maximum: 100 - responses: - '200': - description: Updated goal - content: - application/json: - schema: - type: object - properties: - goal: - $ref: '#/components/schemas/SavingsGoal' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - delete: - tags: [goals] - operationId: cancelGoal - summary: Cancel a savings goal - description: | - Soft-cancels a goal (sets status = CANCELLED) — never hard-deletes. - Ownership is checked explicitly, as with PATCH. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Goal ID (UUID v4) - responses: - '200': - description: Cancelled goal - content: - application/json: - schema: - type: object - properties: - goal: - $ref: '#/components/schemas/SavingsGoal' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/portfolio/goals/{id}/progress: - get: - tags: [goals] - operationId: getGoalProgress - summary: Get a savings goal's trajectory - description: | - Returns current progress, the simple annualized rate required to reach - the target by its date, the user's recent actual APY, a projected - completion date, and whether the target is reachable within the user's - configured risk ceiling. Ownership is checked explicitly, as with - PATCH/DELETE. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Goal ID (UUID v4) - responses: - '200': - description: Goal progress - content: - application/json: - schema: - $ref: '#/components/schemas/GoalProgress' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - # ── Transactions ─────────────────────────────────────────────────────────── - /api/v1/transactions/detail/{txHash}: - get: - tags: [transactions] - operationId: getTransactionDetail - summary: Get transaction detail - description: Returns full detail for a single transaction owned by the authenticated user. - security: - - BearerAuth: [] - parameters: - - in: path - name: txHash - required: true - schema: - type: string - description: Transaction hash - responses: - '200': - description: Transaction detail - content: - application/json: - schema: - type: object - properties: - transaction: - $ref: '#/components/schemas/Transaction' - example: - transaction: - id: tx-001 - txHash: a1b2c3d4e5f6... - type: DEPOSIT - status: CONFIRMED - amount: 1000.00 - assetSymbol: USDC - protocolName: Aave - createdAt: '2026-06-28T10:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/transactions/{userId}: - get: - tags: [transactions] - operationId: listTransactions - summary: List user transactions - description: Returns a paginated list of transactions for the given user. - security: - - BearerAuth: [] - parameters: - - in: path - name: userId - required: true - schema: - type: string - format: uuid - description: User ID (UUID v4) - - in: query - name: page - schema: - type: integer - minimum: 1 - default: 1 - description: Page number (1-indexed) - - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 50 - default: 5 - description: Items per page - responses: - '200': - description: Transaction list - content: - application/json: - schema: - type: object - properties: - page: - type: integer - limit: - type: integer - total: - type: integer - transactions: - type: array - items: - $ref: '#/components/schemas/Transaction' - example: - page: 1 - limit: 5 - total: 42 - transactions: - - id: tx-001 - txHash: a1b2c3d4e5f6... - type: DEPOSIT - status: CONFIRMED - amount: 1000.00 - assetSymbol: USDC - protocolName: Aave - createdAt: '2026-06-28T10:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/transactions/{id}/events: - get: - tags: [transactions] - operationId: getTransactionEvents - summary: Get transaction event history - description: Returns the ordered event history for a transaction. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Transaction ID (UUID v4) - responses: - '200': - description: Transaction events - content: - application/json: - schema: - type: object - properties: - transactionId: - type: string - events: - type: array - items: - type: object - example: - transactionId: tx-001 - events: - - id: evt-001 - eventType: SUBMITTED - occurredAt: '2026-06-28T10:00:05.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - # ── Protocols ────────────────────────────────────────────────────────────── - /api/v1/protocols/rates: - get: - tags: [protocols] - operationId: getProtocolRates - summary: Get current protocol rates - description: Returns the latest supply/borrow APY rates across all protocols. - responses: - '200': - description: Protocol rates - content: - application/json: - schema: - type: object - properties: - rates: - type: array - items: - $ref: '#/components/schemas/ProtocolRate' - example: - rates: - - protocolName: Aave - assetSymbol: USDC - supplyApy: 4.23 - borrowApy: 6.15 - tvl: 125000000 - network: stellar - fetchedAt: '2026-06-29T12:00:00.000Z' - - /api/v1/protocols/risk: - get: - tags: [protocols] - operationId: getProtocolRiskScores - summary: Get protocol risk scores and contributing factors - description: > - Returns the current risk score (0-100, higher = lower risk) for each - protocol together with the contributing factors behind it — audit status, - protocol age, APY volatility, TVL trend, sample count and an - insufficient-history flag — so the score is transparent rather than - opaque. Public: aggregate, non-user-specific data. See the scoring - methodology in docs/PROTOCOL_RISK_SCORING.md. - responses: - '200': - description: Protocol risk scores + description: Protocol APY history grouped by protocol. content: application/json: schema: type: object properties: + period: + $ref: '#/components/schemas/Period' protocols: type: array items: - $ref: '#/components/schemas/ProtocolRiskScore' - methodology: - type: string - description: Path to the methodology document backing the factors. - example: - protocols: - - protocolName: Blend - score: 82 - factors: - auditStatus: THIRD_PARTY_AUDITED - protocolAgeDays: 896 - apyVolatilityFactor: 0.91 - tvlTrendFactor: 0.63 - sampleCount: 42 - insufficientHistory: false - computedAt: '2026-07-16T00:00:00.000Z' - - protocolName: Luma - score: 20 - factors: - auditStatus: SELF_REPORTED - protocolAgeDays: 411 - apyVolatilityFactor: 0 - tvlTrendFactor: 0.5 - sampleCount: 1 - insufficientHistory: true - computedAt: '2026-07-16T00:00:00.000Z' - methodology: docs/PROTOCOL_RISK_SCORING.md - - /api/v1/protocols/agent/status: - get: - tags: [protocols] - operationId: getProtocolAgentStatus - summary: Get rebalancing agent status - description: Returns agent loop health and status information for the rebalancing agent. - responses: - '200': - description: Agent status - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - isRunning: - type: boolean - healthStatus: - type: string - lastRebalanceAt: - type: string - format: date-time - nullable: true - currentProtocol: - type: string - nullable: true - currentApy: - type: number - nullable: true - nextScheduledCheck: - type: string - format: date-time - lastError: - type: string - nullable: true - latestLog: - type: object - nullable: true - properties: - status: - type: string - action: - type: string - createdAt: - type: string - format: date-time - timestamp: - type: string - format: date-time - example: - success: true - data: - isRunning: true - healthStatus: healthy - lastRebalanceAt: '2026-06-29T11:30:00.000Z' - currentProtocol: Aave - currentApy: 4.23 - nextScheduledCheck: '2026-06-29T12:30:00.000Z' - lastError: null - latestLog: - status: success - action: REBALANCE - createdAt: '2026-06-29T11:30:00.000Z' - timestamp: '2026-06-29T12:00:00.000Z' - - # ── Deposit ──────────────────────────────────────────────────────────────── - /api/v1/deposit: - post: - tags: [deposit] - operationId: initiateDeposit - summary: Initiate an on-chain deposit - description: | - Submits a deposit transaction to the active protocol on behalf of the - authenticated user. The user must match the userId in the request body, - OR the caller must hold an active sub-account with DEPOSIT permission - for the target user. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [userId, amount, assetSymbol] - properties: - userId: - type: string - format: uuid - description: User ID (UUID v4) - amount: - type: number - minimum: 0 - exclusiveMinimum: true - description: Deposit amount - assetSymbol: - type: string - minLength: 1 - description: Asset symbol (e.g. USDC, XLM) - protocolName: - type: string - description: Target protocol (optional, uses active protocol if omitted) - memo: - type: string - maxLength: 280 - description: Optional memo - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - amount: 1000.00 - assetSymbol: USDC - protocolName: Aave - responses: - '201': - description: Deposit initiated - content: - application/json: - schema: - $ref: '#/components/schemas/TransactionResponse' - example: - txHash: a1b2c3d4e5f6... - status: CONFIRMED - transaction: - id: tx-001 - txHash: a1b2c3d4e5f6... - status: CONFIRMED - amount: 1000.00 - assetSymbol: USDC - protocolName: Aave - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '409': - description: Duplicate transaction hash - $ref: '#/components/responses/Conflict' + type: object - # ── Recurring Deposit ───────────────────────────────────────────────────── - /api/v1/deposit/recurring: - post: - tags: [deposit] - operationId: createRecurringDeposit - summary: Create a recurring deposit plan - description: | - Creates a scheduled recurring deposit. Requires explicit confirmation. - The deposit will execute automatically on the specified cadence. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [userId, amount, assetSymbol, cadence, confirmed] - properties: - userId: - type: string - format: uuid - amount: - type: number - exclusiveMinimum: true - assetSymbol: - type: string - minLength: 1 - cadence: - type: string - enum: [WEEKLY, BIWEEKLY, MONTHLY] - confirmed: - type: boolean - enum: [true] - description: Must be true to confirm the recurring deposit - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - amount: 50 - assetSymbol: USDC - cadence: WEEKLY - confirmed: true - responses: - '201': - description: Recurring deposit plan created - content: - application/json: - schema: - type: object - properties: - plan: - $ref: '#/components/schemas/RecurringDepositPlan' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' + # ── Health ──────────────────────────────────────────────────────────────────── - /api/v1/deposit/recurring/by-user/{userId}: + /health/live: get: - tags: [deposit] - operationId: listRecurringDeposits - summary: List recurring deposit plans for a user - security: - - BearerAuth: [] - parameters: - - name: userId - in: path - required: true - schema: - type: string - format: uuid - responses: - '200': - description: List of recurring deposit plans - content: - application/json: - schema: - type: object - properties: - plans: - type: array - items: - $ref: '#/components/schemas/RecurringDepositPlan' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/deposit/recurring/{id}: - patch: - tags: [deposit] - operationId: updateRecurringDeposit - summary: Update a recurring deposit plan - description: Pause, resume, or update the amount/cadence of a plan. - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - amount: - type: number - exclusiveMinimum: true - cadence: - type: string - enum: [WEEKLY, BIWEEKLY, MONTHLY] - status: - type: string - enum: [ACTIVE, PAUSED, CANCELLED] - responses: - '200': - description: Plan updated - content: - application/json: - schema: - type: object - properties: - plan: - $ref: '#/components/schemas/RecurringDepositPlan' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - delete: - tags: [deposit] - operationId: cancelRecurringDeposit - summary: Cancel a recurring deposit plan - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid + operationId: getLiveness + summary: Liveness probe + description: Always returns 200 once the process is running. + tags: [Health] + security: [] responses: '200': - description: Plan cancelled - content: - application/json: - schema: - type: object - properties: - plan: - $ref: '#/components/schemas/RecurringDepositPlan' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - # ── Withdraw ─────────────────────────────────────────────────────────────── - /api/v1/withdraw: - post: - tags: [withdraw] - operationId: initiateWithdrawal - summary: Initiate an on-chain withdrawal - description: | - Submits a withdrawal transaction from the active protocol on behalf of - the authenticated user. The user must match the userId in the request body, - OR the caller must hold an active sub-account with WITHDRAW permission - for the target user. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [userId, amount, assetSymbol] - properties: - userId: - type: string - format: uuid - amount: - type: number - minimum: 0 - exclusiveMinimum: true - assetSymbol: - type: string - minLength: 1 - protocolName: - type: string - memo: - type: string - maxLength: 280 - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - amount: 500.00 - assetSymbol: USDC - responses: - '201': - description: Withdrawal initiated - content: - application/json: - schema: - $ref: '#/components/schemas/TransactionResponse' - example: - txHash: f6e5d4c3b2a1... - status: CONFIRMED - transaction: - id: tx-002 - txHash: f6e5d4c3b2a1... - status: CONFIRMED - amount: 500.00 - assetSymbol: USDC - protocolName: Aave - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '409': - $ref: '#/components/responses/Conflict' + description: Process alive. - # ── Vault ────────────────────────────────────────────────────────────────── - /api/v1/vault/state: + /health/ready: get: - tags: [vault] - operationId: getVaultState - summary: Get global vault state - description: Returns current vault APY and the active protocol name. No auth required. + operationId: getReadiness + summary: Readiness probe + description: Returns 200 only when all background services are healthy. + tags: [Health] + security: [] responses: '200': - description: Vault state - content: - application/json: - schema: - type: object - properties: - apy: - type: number - description: Current vault APY - activeProtocol: - type: string - description: Active protocol name - example: - apy: 4.23 - activeProtocol: Aave + description: All services ready. + '503': + description: One or more services not ready. - /api/v1/vault/balance: - get: - tags: [vault] - operationId: getVaultBalance - summary: Get user's vault balance - description: Returns the authenticated user's on-chain vault balance and shares. - security: - - BearerAuth: [] - responses: - '200': - description: Vault balance - content: - application/json: - schema: - type: object - properties: - balance: - type: number - shares: - type: number - example: - balance: 5000.00 - shares: 4950.00 - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /api/v1/vault/build-transaction: - post: - tags: [vault] - operationId: buildVaultTransaction - summary: Build unsigned vault transaction XDR (non-custodial) - description: | - Builds an unsigned Stellar XDR transaction for vault deposit or withdrawal. - The user signs the XDR client-side — the backend never holds private keys. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [type, amount, assetSymbol] - properties: - type: - type: string - enum: [deposit, withdraw] - amount: - type: number - minimum: 0 - exclusiveMinimum: true - assetSymbol: - type: string - minLength: 1 - example: - type: deposit - amount: 1000.00 - assetSymbol: USDC - responses: - '200': - description: Unsigned XDR built - content: - application/json: - schema: - type: object - properties: - xdr: - type: string - description: Unsigned Stellar transaction XDR - type: - type: string - enum: [deposit, withdraw] - amount: - type: number - walletAddress: - type: string - example: - xdr: AAAAAA... - type: deposit - amount: 1000.00 - walletAddress: GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37 - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - # ── Analytics ────────────────────────────────────────────────────────────── - /api/v1/analytics/apy-history: - get: - tags: [analytics] - operationId: getApyHistory - summary: Get APY history - description: Returns APY snapshots over time for the authenticated user's positions (graph-ready). - security: - - BearerAuth: [] - parameters: - - in: query - name: period - schema: - type: string - enum: [7d, 30d, 90d] - default: 30d - description: Lookback period - responses: - '200': - description: APY history - content: - application/json: - schema: - type: object - properties: - userId: - type: string - period: - type: string - points: - type: array - items: - type: object - properties: - date: - type: string - format: date - apy: - type: number - positionId: - type: string - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - period: 30d - points: - - date: 2026-06-01 - apy: 4.10 - positionId: pos-001 - - date: 2026-06-02 - apy: 4.15 - positionId: pos-001 - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/analytics/user-yield: - get: - tags: [analytics] - operationId: getUserYield - summary: Get user yield summary - description: Returns cumulative and period yield earned by the authenticated user. - security: - - BearerAuth: [] - parameters: - - in: query - name: period - schema: - type: string - enum: [7d, 30d, 90d] - default: 30d - responses: - '200': - description: User yield - content: - application/json: - schema: - type: object - properties: - userId: - type: string - period: - type: string - totalYield: - type: number - periodYield: - type: number - averageApy: - type: number - points: - type: array - items: - type: object - properties: - date: - type: string - format: date - yieldAmount: - type: number - apy: - type: number - example: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - period: 30d - totalYield: 523.40 - periodYield: 85.20 - averageApy: 4.23 - points: - - date: 2026-06-01 - yieldAmount: 12.50 - apy: 4.10 - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/analytics/protocol-performance: - get: - tags: [analytics] - operationId: getProtocolPerformance - summary: Get protocol APY performance - description: Returns historical APY rates per protocol (graph-ready). No auth required. - parameters: - - in: query - name: period - schema: - type: string - enum: [7d, 30d, 90d] - default: 30d - responses: - '200': - description: Protocol performance - content: - application/json: - schema: - type: object - properties: - period: - type: string - protocols: - type: array - items: - type: object - properties: - protocol: - type: string - asset: - type: string - network: - type: string - points: - type: array - items: - type: object - properties: - date: - type: string - format: date - apy: - type: number - tvl: - type: number - nullable: true - example: - period: 30d - protocols: - - protocol: Aave - asset: USDC - network: stellar - points: - - date: 2026-06-01 - apy: 4.10 - tvl: 125000000 - '400': - $ref: '#/components/responses/BadRequest' - - /api/v1/analytics/attribution: - get: - tags: [analytics] - operationId: getPerformanceAttribution - summary: Get benchmark-relative performance attribution - description: > - Returns a Brinson-style, benchmark-relative decomposition of the - caller's OWN portfolio return into allocation and selection effects, - precomputed by a scheduled job and read from the persisted - `PortfolioAttribution` row — never recomputed per request. - - - The benchmark (v1) is the equal-weighted average of available - protocol APY history (or a configured subset); `benchmarkVersion` - names which one produced this report. `reconciled` is false, and - `reconciliationGap` non-zero, when the linked allocation + selection + - unattributed figures could not be made to match the actual - portfolio-vs-benchmark excess return (e.g. a period with a total - wipeout) — the gap is reported explicitly rather than silently - absorbed. - - - `computed: false` (still a 200, not a 404) means nothing has been - precomputed yet for this user/window — a normal state for a new - account, not a missing resource. - - - `window` accepts `30d` and `90d` only, for the same reason as the - strategy marketplace: yield snapshots are retained for 90 days. - security: - - BearerAuth: [] - parameters: - - in: query - name: window - schema: - type: string - enum: ['30d', '90d'] - default: '30d' - description: Statistics window. Longer windows are rejected (90-day snapshot retention). - responses: - '200': - description: The caller's performance attribution, or an unattributed placeholder - content: - application/json: - schema: - $ref: '#/components/schemas/PortfolioAttributionResponse' - examples: - computed: - value: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - window: 30d - computed: true - windowDays: 30 - portfolioReturn: 0.083 - benchmarkReturn: 0.061 - vsBenchmark: 0.022 - allocationEffect: 0.009 - selectionEffect: 0.013 - unattributedEffect: 0 - reconciliationGap: 0.0000001 - reconciled: true - benchmarkVersion: 'equal-weight-v1:all' - sectors: - - sector: Aave - portfolioWeight: 0.6 - benchmarkWeight: 0.33 - portfolioReturn: 0.09 - benchmarkReturn: 0.05 - allocationEffect: 0.006 - selectionEffect: 0.011 - computedAt: '2026-06-30T00:00:00.000Z' - notYetComputed: - value: - userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - window: 30d - computed: false - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - # ── Stellar ──────────────────────────────────────────────────────────────── - /api/v1/stellar/metrics: - get: - tags: [stellar] - operationId: getStellarMetrics - summary: Get Stellar event-processing metrics - description: Returns current event-processing metrics from the Stellar event listener. - responses: - '200': - description: Stellar metrics - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - totalProcessed: - type: integer - totalErrors: - type: integer - processingRatePerMinute: - type: number - errorRate: - type: number - ledgerLag: - type: integer - lastDbOperationMs: - type: number - lastUpdated: - type: string - format: date-time - example: - success: true - data: - totalProcessed: 15234 - totalErrors: 23 - processingRatePerMinute: 45.2 - errorRate: 0.15 - ledgerLag: 2 - lastDbOperationMs: 42 - lastUpdated: '2026-06-29T12:00:00.000Z' - - # ── Admin ────────────────────────────────────────────────────────────────── - /api/v1/admin/users: - get: - tags: [admin] - operationId: adminListUsers - summary: List all users - description: Returns a paginated list of all users. Requires admin authentication. - security: - - AdminToken: [] - parameters: - - in: query - name: limit - schema: - type: integer - default: 50 - maximum: 500 - - in: query - name: cursor - schema: - type: string - responses: - '200': - description: User list - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - type: object - properties: - address: - type: string - createdAt: - type: string - format: date-time - nextCursor: - type: string - nullable: true - example: - data: - - address: GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37 - createdAt: '2026-01-15T10:00:00.000Z' - nextCursor: null - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/stats: - get: - tags: [admin] - operationId: adminGetStats - summary: Platform statistics - description: Returns platform-wide statistics. Requires admin authentication. - security: - - AdminToken: [] - responses: - '200': - description: Platform stats - content: - application/json: - schema: - type: object - properties: - totalUsers: - type: integer - totalVolumeUsdc: - type: string - activeVaults: - type: integer - example: - totalUsers: 1284 - totalVolumeUsdc: '12500000.00' - activeVaults: 856 - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/stellar/metrics: - get: - tags: [admin] - operationId: adminGetStellarMetrics - summary: Stellar event processing metrics (admin) - description: | - Returns detailed event processing metrics including processing rate, - error rate, and ledger lag. Requires admin scope `metrics:read`. - security: - - AdminToken: [] - responses: - '200': - description: Stellar metrics - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - totalProcessed: - type: integer - totalErrors: - type: integer - processingRatePerMinute: - type: number - errorRate: - type: string - ledgerLag: - type: integer - lastDbOperationMs: - type: number - lastUpdated: - type: string - format: date-time - timestamp: - type: string - format: date-time - example: - success: true - data: - totalProcessed: 15234 - totalErrors: 23 - processingRatePerMinute: 45.2 - errorRate: 0.15% - ledgerLag: 2 - lastDbOperationMs: 42 - lastUpdated: '2026-06-29T12:00:00.000Z' - timestamp: '2026-06-29T12:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/dlq/inspect: - get: - tags: [admin] - operationId: adminInspectDLQ - summary: Inspect dead-letter queue - description: | - Returns current DLQ contents with filtering and pagination. - Requires admin scope `dlq:read`. - security: - - AdminToken: [] - parameters: - - in: query - name: status - schema: - type: string - enum: [PENDING, RETRIED, RESOLVED] - description: Filter by event status - - in: query - name: eventType - schema: - type: string - description: Filter by event type - - in: query - name: retryCountMin - schema: - type: integer - default: 0 - description: Minimum retry count - - in: query - name: retryCountMax - schema: - type: integer - description: Maximum retry count - - in: query - name: timeRangeStart - schema: - type: string - format: date-time - description: Earliest event timestamp - - in: query - name: timeRangeEnd - schema: - type: string - format: date-time - description: Latest event timestamp - - in: query - name: limit - schema: - type: integer - default: 50 - maximum: 500 - - in: query - name: offset - schema: - type: integer - default: 0 - responses: - '200': - description: DLQ contents - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - totalInQueue: - type: integer - filteredCount: - type: integer - returnedCount: - type: integer - pagination: - type: object - properties: - offset: - type: integer - limit: - type: integer - hasMore: - type: boolean - items: - type: array - items: - type: object - properties: - id: - type: string - contractId: - type: string - txHash: - type: string - eventType: - type: string - ledger: - type: integer - status: - type: string - retryCount: - type: integer - error: - type: string - nullable: true - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - timestamp: - type: string - format: date-time - example: - success: true - data: - totalInQueue: 15 - filteredCount: 10 - returnedCount: 10 - pagination: - offset: 0 - limit: 50 - hasMore: false - items: - - id: dlq-001 - contractId: CA123... - txHash: a1b2c3d4... - eventType: DEPOSIT - ledger: 1234567 - status: PENDING - retryCount: 3 - error: 'Insufficient gas' - createdAt: '2026-06-29T10:00:00.000Z' - updatedAt: '2026-06-29T11:00:00.000Z' - timestamp: '2026-06-29T12:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/dlq/retry: - post: - tags: [admin] - operationId: adminRetryDLQ - summary: Retry all pending DLQ events - description: | - Manually retries all pending dead-letter events. - Requires admin scope `dlq:write`. - security: - - AdminToken: [] - requestBody: - content: - application/json: - schema: - type: object - properties: - dryRun: - type: boolean - description: If true, returns what would be retried without executing - default: false - responses: - '200': - description: Retry initiated or dry-run result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - resolved: - type: integer - failed: - type: integer - totalRemaining: - type: integer - timestamp: - type: string - format: date-time - example: - success: true - data: - resolved: 8 - failed: 2 - totalRemaining: 5 - timestamp: '2026-06-29T12:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/dlq/resolve: - post: - tags: [admin] - operationId: adminResolveDLQEvent - summary: Manually resolve a DLQ event - description: | - Marks a specific DLQ event as resolved. - Requires admin scope `dlq:write`. - security: - - AdminToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [eventId] - properties: - eventId: - type: string - description: DLQ event ID - responses: - '200': - description: Event resolved - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - eventId: - type: string - status: - type: string - enum: [RESOLVED] - timestamp: - type: string - format: date-time - example: - success: true - data: - eventId: dlq-001 - status: RESOLVED - timestamp: '2026-06-29T12:00:00.000Z' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/admin/dlq/replay: - post: - tags: [admin] - operationId: adminReplayDLQEvents - summary: Replay selected DLQ events - description: | - Safely replays selected DLQ events back into the processing pipeline. - Only retries events in PENDING or RETRIED status. - Requires admin scope `dlq:write`. - security: - - AdminToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [eventIds] - properties: - eventIds: - type: array - items: - type: string - maxItems: 1000 - description: DLQ event IDs to replay - dryRun: - type: boolean - default: false - responses: - '200': - description: Replay result - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - requestedCount: - type: integer - replayedCount: - type: integer - blockedCount: - type: integer - resolved: - type: integer - failed: - type: integer - timestamp: - type: string - format: date-time - example: - success: true - data: - requestedCount: 5 - replayedCount: 5 - blockedCount: 0 - resolved: 4 - failed: 1 - timestamp: '2026-06-29T12:00:00.000Z' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/admin/stellar/backfill: - post: - tags: [admin] - operationId: adminBackfillStellarEvents - summary: Backfill Stellar events for a ledger range - description: | - Manually triggers event backfill for a ledger range. - Requires admin scope `backfill:write`. - security: - - AdminToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [startLedger] - properties: - startLedger: - type: integer - minimum: 0 - description: Starting ledger sequence number - endLedger: - type: integer - description: Ending ledger sequence number (optional, defaults to latest) - example: - startLedger: 1234000 - endLedger: 1235000 - responses: - '200': - description: Backfill initiated - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - startLedger: - type: integer - endLedger: - type: string - status: - type: string - message: - type: string - timestamp: - type: string - format: date-time - example: - success: true - data: - startLedger: 1234000 - endLedger: latest - status: backfill_initiated - message: 'Backfill operation initiated. Check logs for progress.' - timestamp: '2026-06-29T12:00:00.000Z' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/keys: - get: - tags: [admin] - operationId: adminListKeys - summary: List admin API keys - description: | - Lists all admin API key metadata (no hashes or tokens returned). - Requires admin scope `keys:read`. - security: - - AdminToken: [] - responses: - '200': - description: List of admin keys - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: array - items: - type: object - properties: - id: - type: string - name: - type: string - role: - type: string - scopes: - type: array - items: - type: string - expiresAt: - type: string - format: date-time - nullable: true - revokedAt: - type: string - format: date-time - nullable: true - lastUsedAt: - type: string - format: date-time - nullable: true - createdAt: - type: string - format: date-time - timestamp: - type: string - format: date-time - example: - success: true - data: - - id: key-001 - name: CI Pipeline - role: service - scopes: [read, write] - expiresAt: null - revokedAt: null - lastUsedAt: '2026-06-29T11:00:00.000Z' - createdAt: '2026-01-01T00:00:00.000Z' - timestamp: '2026-06-29T12:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - post: - tags: [admin] - operationId: adminCreateKey - summary: Create a new admin API key - description: | - Issues a new scoped admin API key. The raw token is returned once - and will never be stored in plaintext. Requires admin scope `keys:write`. - security: - - AdminToken: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [name, role, scopes] - properties: - name: - type: string - description: Human-readable key name - role: - type: string - description: Key role (e.g. service, admin) - scopes: - type: array - items: - type: string - enum: [read, write, wallet, agent, super] - minItems: 1 - expiresAt: - type: string - format: date-time - description: Optional expiration date - example: - name: Monitor Service - role: service - scopes: [read, metrics:read] - responses: - '201': - description: Key created - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - id: - type: string - name: - type: string - role: - type: string - scopes: - type: array - items: - type: string - expiresAt: - type: string - format: date-time - nullable: true - createdAt: - type: string - format: date-time - token: - type: string - description: The raw API key token (shown once) - warning: - type: string - timestamp: - type: string - format: date-time - example: - success: true - data: - id: key-002 - name: Monitor Service - role: service - scopes: [read, metrics:read] - expiresAt: null - createdAt: '2026-06-29T12:00:00.000Z' - token: a1b2c3d4e5f6... - warning: 'Store this token securely. It will not be shown again.' - timestamp: '2026-06-29T12:00:00.000Z' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '409': - description: Key name already exists - $ref: '#/components/responses/Conflict' - - /api/v1/admin/keys/{id}: - delete: - tags: [admin] - operationId: adminRevokeKey - summary: Revoke an admin API key - description: | - Immediately revokes an admin API key by setting its revokedAt timestamp. - Requires admin scope `keys:write`. - security: - - AdminToken: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - description: Admin key ID - responses: - '200': - description: Key revoked - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - id: - type: string - status: - type: string - timestamp: - type: string - format: date-time - example: - success: true - data: - id: key-002 - status: revoked - timestamp: '2026-06-29T12:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '409': - description: Key already revoked - $ref: '#/components/responses/Conflict' - - /api/v1/admin/wallets/rotation-status: - get: - tags: [admin] - operationId: adminGetWalletRotationStatus - summary: Get wallet key rotation status - description: | - Reports the progress of wallet key rotation across custodial wallets. - Requires admin scope `keys:read`. - security: - - AdminToken: [] - responses: - '200': - description: Rotation status - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - totalWallets: - type: integer - v1Wallets: - type: integer - v2Wallets: - type: integer - percentV1: - type: number - isRotationComplete: - type: boolean - timestamp: - type: string - format: date-time - example: - success: true - data: - totalWallets: 150 - v1Wallets: 15 - v2Wallets: 135 - percentV1: 10 - isRotationComplete: false - timestamp: '2026-06-29T12:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - # ── Durable outbox (#325) ──────────────────────────────────────────────────── - /api/v1/admin/outbox: - get: - tags: [admin] - operationId: adminListOutboxOps - summary: List/query durable outbox ops - description: | - Lists on-chain money-movement intents queued through the durable - outbox. See docs/OUTBOX.md. Requires admin scope `outbox:read`. - security: - - AdminToken: [] - parameters: - - in: query - name: status - schema: - type: string - enum: [PENDING, SUBMITTED, CONFIRMED, FAILED, CANCELLED] - - in: query - name: kind - schema: - type: string - enum: [DEPOSIT, WITHDRAW, REBALANCE, RECURRING_DEPOSIT, REFERRAL_REWARD, YIELD_CLAIM] - - in: query - name: priority - schema: - type: string - enum: [CRITICAL, NORMAL, LOW] - - in: query - name: userId - schema: - type: string - - in: query - name: limit - schema: - type: integer - default: 50 - maximum: 500 - - in: query - name: offset - schema: - type: integer - default: 0 - responses: - '200': - description: Outbox ops matching the filter - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - ops: - type: array - items: - $ref: '#/components/schemas/OutboxOp' - total: - type: integer - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/outbox/stats: - get: - tags: [admin] - operationId: adminGetOutboxStats - summary: Outbox queue depth by status/priority - description: | - Throughput view over the outbox queue. Requires admin scope `outbox:read`. - security: - - AdminToken: [] - responses: - '200': - description: Queue depth grouped by status and priority - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - stats: - type: array - items: - type: object - properties: - status: - type: string - priority: - type: string - count: - type: integer - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/outbox/{id}: - get: - tags: [admin] - operationId: adminGetOutboxOp - summary: Inspect a single outbox op - description: Requires admin scope `outbox:read`. - security: - - AdminToken: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - responses: - '200': - description: The outbox op - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - $ref: '#/components/schemas/OutboxOp' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - description: Outbox op not found - - /api/v1/admin/outbox/{id}/retry: - post: - tags: [admin] - operationId: adminRetryOutboxOp - summary: Force a FAILED op back to PENDING - description: | - Clears backoff and re-queues a terminally FAILED op for the - dispatcher to re-attempt. Requires admin scope `outbox:write`. - security: - - AdminToken: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - responses: - '200': - description: Op returned to PENDING - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - $ref: '#/components/schemas/OutboxOp' - '400': - description: Op is not FAILED - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/v1/admin/outbox/{id}/cancel: - post: - tags: [admin] - operationId: adminCancelOutboxOp - summary: Cancel an unsent op - description: | - Cancels a PENDING op. A SUBMITTED op is already on-chain and cannot - be cancelled. Requires admin scope `outbox:write`. - security: - - AdminToken: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - responses: - '200': - description: Op cancelled - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - data: - $ref: '#/components/schemas/OutboxOp' - '400': - description: Op is not PENDING (already submitted, confirmed, failed, or cancelled) - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - # ── Metrics ──────────────────────────────────────────────────────────────── - /metrics: - get: - tags: [metrics] - operationId: getPrometheusMetrics - summary: Prometheus metrics endpoint - description: | - Returns Prometheus-compatible metrics for observability. - Protected by strict internal auth (returns 404 if unauthorized for info hiding). - security: - - InternalToken: [] - responses: - '200': - description: Prometheus metrics - content: - text/plain: - schema: - type: string - example: | - # HELP neuro_event_processed_total Total events processed - # TYPE neuro_event_processed_total counter - neuro_event_processed_total{status="success"} 15234 - neuro_event_processed_total{status="error"} 23 - '404': - description: Not found (info hiding for unauthorized requests) - - # ── Fiat on-ramp / off-ramp ────────────────────────────────────────────── - /api/v1/fiat/quote: - post: - tags: [fiat] - operationId: getFiatQuote - summary: Get a fiat buy/sell quote - description: > - Returns an indicative quote from the active fiat provider for buying - (ON_RAMP) or selling (OFF_RAMP) a crypto asset with fiat currency. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/FiatQuoteRequest' - responses: - '200': - description: Quote - content: - application/json: - schema: - $ref: '#/components/schemas/FiatQuote' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '502': - description: Provider unavailable or returned an error - - /api/v1/fiat/quotes: - get: - tags: [fiat] - operationId: getBestExecutionFiatQuotes - summary: Get best-execution quotes across all healthy providers - description: > - Queries every healthy fiat provider in parallel (per-provider timeout; - one slow/unavailable provider never blocks the others) and returns a - ranked list plus the single best executable quote. Each quote carries - a structured fee breakdown — or `fees: null` with `unpriced: true` - when a provider cannot itemize its fee, never an assumed zero — and a - `quoteId` that can be passed to `POST /fiat/orders` to lock in that - exact rate for a bounded validity window (`expiresAt`). Providers that - errored or timed out are reported in `excluded` with a reason rather - than silently dropped. - security: - - BearerAuth: [] - parameters: - - in: query - name: direction - required: true - schema: - $ref: '#/components/schemas/FiatDirection' - - in: query - name: fiatAmount - required: true - schema: - type: number - minimum: 0 - - in: query - name: fiatCurrency - required: true - schema: - type: string - description: 3-letter ISO 4217 currency code - - in: query - name: assetSymbol - required: true - schema: - type: string - responses: - '200': - description: Ranked quotes - content: - application/json: - schema: - $ref: '#/components/schemas/BestExecutionQuoteResult' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '503': - description: > - No healthy fiat providers are available. Response body includes - `code: "no_healthy_providers"` and a per-provider failure reason. - - /api/v1/fiat/orders: - post: - tags: [fiat] - operationId: createFiatOrder - summary: Create a fiat order - description: > - Creates an on-ramp or off-ramp order and returns the order along with - a provider checkout URL (and KYC URL if the provider requires identity - verification). The order settles only after the on-chain crypto leg is - independently confirmed. The provider is resolved, in order of - precedence, from a locked `quoteId` (see `GET /fiat/quotes`), an - explicit `provider` preference, or the registry's default selection - policy — and is pinned to the order permanently: failover only ever - affects which provider a *new* order goes to. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateFiatOrderRequest' - responses: - '201': - description: Order created - content: - application/json: - schema: - $ref: '#/components/schemas/FiatOrder' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - description: quoteId does not reference a quote owned by the caller - '409': - description: > - `code: "quote_expired"` (locked quote's validity window has - passed — request a fresh quote), `code: "quote_already_used"`, or - `code: "quote_mismatch"` (order parameters don't match the locked - quote). - '502': - description: Provider unavailable or returned an error - '503': - description: > - No healthy fiat providers are available (`code: - "no_healthy_providers"`) - get: - tags: [fiat] - operationId: listFiatOrders - summary: List the caller's fiat orders - description: Returns the authenticated user's fiat orders, newest first. - security: - - BearerAuth: [] - responses: - '200': - description: Order history - content: - application/json: - schema: - type: object - properties: - orders: - type: array - items: - $ref: '#/components/schemas/FiatOrder' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/fiat/orders/{id}: - get: - tags: [fiat] - operationId: getFiatOrder - summary: Get a single fiat order - description: Returns one fiat order owned by the authenticated user. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Fiat order ID - responses: - '200': - description: Order - content: - application/json: - schema: - $ref: '#/components/schemas/FiatOrder' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/fiat/webhook/{provider}: - post: - tags: [fiat] - operationId: fiatProviderWebhook - summary: Fiat provider webhook callback - description: > - Signed callback delivered by the fiat provider on order state changes. - Authenticity is verified from the provider's HMAC signature over the - raw request body — no JWT is required. The endpoint is idempotent and - always ACKs a well-formed, verified delivery. A provider "completed" - signal advances the order to PROCESSING; settlement to SETTLED requires - independent on-chain confirmation. - parameters: - - in: path - name: provider - required: true - schema: - type: string - description: Provider key (e.g. `moonpay`) - requestBody: - required: true - content: - application/json: - schema: - type: object - description: Provider-specific payload (opaque; verified by signature) - responses: - '200': - description: Delivery accepted (idempotent) - '400': - description: Malformed payload - '401': - description: Invalid or missing signature - '404': - description: Unknown provider - '500': - description: Processing error (provider should retry) - - # ── Referrals ──────────────────────────────────────────────────────────────── - /api/v1/referrals/code: - get: - tags: [referrals] - operationId: getMyReferralCode - summary: Get (or create) the caller's referral code - description: > - Returns the authenticated caller's referral code, creating one on the - first request. Idempotent — the same code is returned on every call. - Share this code; when a new user signs up with it and later makes a - qualifying deposit, both parties are rewarded. - security: - - BearerAuth: [] - responses: - '200': - description: The caller's referral code - content: - application/json: - schema: - $ref: '#/components/schemas/ReferralCodeResponse' - example: - code: ABC123XY - createdAt: '2026-06-29T12:00:00.000Z' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/referrals/{userId}: - get: - tags: [referrals] - operationId: getReferrals - summary: List referrals attributed to a user - description: > - Lists the referrals attributed to the user's code, newest first. - Owner-scoped — a caller may only read their own referrals. - security: - - BearerAuth: [] - parameters: - - in: path - name: userId - required: true - schema: - type: string - description: The owning user's id (must match the authenticated caller) - responses: - '200': - description: Referrals for the user - content: - application/json: - schema: - $ref: '#/components/schemas/ReferralListResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - description: Caller may only read their own referrals - - # ── Custom price & yield alert rules (#289) ────────────────────────────── - /api/v1/alerts: - post: - tags: [alerts] - operationId: createAlertRule - summary: Create an alert rule - description: > - Creates a user-defined price/yield alert rule owned by the authenticated - caller. The rule is evaluated on a schedule; when its comparator - condition holds against the live metric and it is outside its cooldown - window, a notification is delivered over the chosen channel(s). A - `PROTOCOL_APY` rule must name a `protocolName`; the other metrics must - not. See `docs/ALERTS.md` for metric semantics and the drawdown window. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAlertRuleRequest' - responses: - '201': - description: The created alert rule - content: - application/json: - schema: - $ref: '#/components/schemas/AlertRule' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - # GET is keyed by the owning user id; PATCH/DELETE by the rule id. They share - # one URL template (disambiguated only by HTTP method in the Express router), - # so OpenAPI represents them under a single path item with a generic {id} - # whose meaning is documented per-operation. - /api/v1/alerts/{id}: - get: - tags: [alerts] - operationId: listAlertRules - summary: List a user's alert rules - description: > - Lists the alert rules owned by the user, newest first. Owner-scoped — a - caller may only read their own rules. Here the path parameter is the - owning **user id** (must match the authenticated caller). - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: The owning user's id (must match the authenticated caller) - responses: - '200': - description: Alert rules for the user - content: - application/json: - schema: - $ref: '#/components/schemas/AlertRuleListResponse' - '401': - $ref: '#/components/responses/Unauthorized' - patch: - tags: [alerts] - operationId: updateAlertRule - summary: Update an alert rule - description: > - Updates a rule the caller owns. Here the path parameter is the **rule - id**. The `PROTOCOL_APY` ↔ `protocolName` pairing is enforced against - the merged (stored + patch) state. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Alert rule id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAlertRuleRequest' - responses: - '200': - description: The updated alert rule - content: - application/json: - schema: - $ref: '#/components/schemas/AlertRule' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - delete: - tags: [alerts] - operationId: deleteAlertRule - summary: Delete an alert rule - description: Deletes a rule the caller owns. Future evaluations stop immediately. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Alert rule id - responses: - '204': - description: Deleted (no content) - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - # ── Sub-Accounts ─────────────────────────────────────────────────────────── - - /api/v1/sub-accounts: - post: - tags: [sub-accounts] - operationId: createSubAccount - summary: Create a sub-account relationship - description: | - Creates a parent→child sub-account link with an initial permission set. - The authenticated user becomes the parent. The child must exist and must - not already be a parent (no chained sub-accounts). - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [childUserId, permissions] - properties: - childUserId: - type: string - format: uuid - description: The child user's ID - permissions: - type: array - items: - $ref: '#/components/schemas/SubAccountPermission' - minItems: 1 - maxItems: 4 - description: Initial permission set - example: - childUserId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 - permissions: [VIEW, DEPOSIT] - responses: - '201': - description: Sub-account created - content: - application/json: - schema: - type: object - properties: - subAccount: - $ref: '#/components/schemas/SubAccount' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - description: Child user not found - '409': - description: Sub-account relationship already exists - - /api/v1/sub-accounts/{id}/permissions: - patch: - tags: [sub-accounts] - operationId: updateSubAccountPermissions - summary: Update sub-account permissions - description: | - Replaces the permission set for an existing sub-account. Only the - parent who owns the sub-account can update permissions. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Sub-account ID - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [permissions] - properties: - permissions: - type: array - items: - $ref: '#/components/schemas/SubAccountPermission' - minItems: 1 - maxItems: 4 - example: - permissions: [VIEW, DEPOSIT, WITHDRAW] - responses: - '200': - description: Permissions updated - content: - application/json: - schema: - type: object - properties: - subAccount: - $ref: '#/components/schemas/SubAccount' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/sub-accounts/{id}: - delete: - tags: [sub-accounts] - operationId: revokeSubAccount - summary: Revoke a sub-account - description: | - Soft-revokes a sub-account by setting its status to REVOKED. Only the - parent who owns the sub-account can revoke it. Revocation takes effect - immediately on the next request. The audit trail is preserved. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: Sub-account ID - responses: - '200': - description: Sub-account revoked - content: - application/json: - schema: - type: object - properties: - subAccount: - $ref: '#/components/schemas/SubAccount' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/strategies/publish: - post: - tags: [strategies] - operationId: publishStrategy - summary: Publish the caller's strategy to the marketplace - description: > - Publishes (or re-publishes) an anonymized snapshot of the caller's own - agent configuration. One listing per user — a second call updates the - same listing. - - - `strategyConfig` carries only the three keys the agent acts on - (`strategyName`, `targetAllocations`, `riskCeiling`). `riskTolerance` is - never copied; it stays personal to each user. Omit `strategyConfig` to - snapshot whatever the caller is currently running. - - - `configVersion` increments only on a MATERIAL change to those three - keys — a label-only edit is cosmetic and notifies nobody. When it does - increment, every active follower's applied snapshot is rewritten in the - same transaction and they receive a `strategy.updated` notification. - - - `label` is free text shown to strangers and is rejected (not stripped) - if it contains a Stellar address or a long hexadecimal run. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PublishStrategyRequest' - responses: - '200': - description: The caller's listing - content: - application/json: - schema: - $ref: '#/components/schemas/PublishStrategyResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/strategies/unpublish: - post: - tags: [strategies] - operationId: unpublishStrategy - summary: Delist the caller's strategy - description: > - Removes the caller's listing from every marketplace query immediately. - - - Existing follows are NOT severed. Followers keep the configuration - snapshot they already copied and their agent keeps running unchanged — - silently reverting someone's live configuration because a stranger - delisted theirs would change what the agent does with the follower's - money without the follower ever acting. Followers receive a - `strategy.unpublished` notification. - security: - - BearerAuth: [] - responses: - '200': - description: The delisted listing - content: - application/json: - schema: - type: object - required: [strategy] - properties: - strategy: - $ref: '#/components/schemas/PublishedStrategy' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - - /api/v1/strategies/marketplace: - get: - tags: [strategies] - operationId: getStrategyMarketplace - summary: Browse the ranked, anonymized leaderboard - description: > - Returns published strategies ranked by a precomputed, risk-adjusted - score. No response field identifies a publisher: no `userId`, no wallet - address, no absolute balance — only the configuration and derived - statistics. - - - Strategies below the eligibility gate (at least 30 days of track record, - at least 14 portfolio samples, and a computable Sharpe ratio) are - excluded ENTIRELY rather than ranked low, so a one-day strategy posting - a flattering APY never appears. - - - `window` accepts `30d` and `90d` only. Yield snapshots are retained for - 90 days, so a longer window has no data behind it and is rejected with a - 400 rather than silently downgraded. - security: - - BearerAuth: [] - parameters: - - in: query - name: sortBy - schema: - type: string - enum: [apy, sharpe] - default: sharpe - description: Ranking field. Defaults to the risk-adjusted score. - - in: query - name: window - schema: - type: string - enum: ['30d', '90d'] - default: '30d' - description: Statistics window. Longer windows are rejected (90-day snapshot retention). - - in: query - name: page - schema: - type: integer - minimum: 1 - default: 1 - - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 50 - default: 20 - responses: - '200': - description: A page of the leaderboard - content: - application/json: - schema: - $ref: '#/components/schemas/StrategyMarketplaceResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/strategies/following: - get: - tags: [strategies] - operationId: getFollowedStrategy - summary: What the caller currently follows - description: > - Returns the caller's single active follow, or `{ "follow": null }` when - they follow nothing — following nothing is a normal state, not a missing - resource. - - - `appliedConfig` is the snapshot the agent actually runs and may lag the - live listing (or outlive it entirely, if the publisher delisted or - deleted their account). `strategy` is null for such an orphaned follow. - security: - - BearerAuth: [] - responses: - '200': - description: The caller's active follow, or null - content: - application/json: - schema: - type: object - required: [follow] - properties: - follow: - type: object - nullable: true - allOf: - - $ref: '#/components/schemas/StrategyFollow' - '401': - $ref: '#/components/responses/Unauthorized' - - /api/v1/strategies/{id}/follow: - post: - tags: [strategies] - operationId: followStrategy - summary: Follow a published strategy - description: > - Copies the strategy's configuration into a snapshot the caller's agent - applies on its next scheduled run. Configuration only — no funds move, - no custody is transferred, and the publisher's identity is never - revealed. - - - A user may follow at most one strategy at a time; following while - already following swaps atomically. Following your own strategy is - rejected with 409. - - - The caller's own risk ceiling is never widened: the effective ceiling is - the STRICTER of the publisher's and the follower's. An active savings - goal continues to outrank a followed configuration. - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: The published strategy's id (NOT a user id) - responses: - '201': - description: The created follow - content: - application/json: - schema: - type: object - required: [follow] - properties: - follow: - $ref: '#/components/schemas/StrategyFollow' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - '409': - description: Self-follow is not permitted - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - - /api/v1/strategies/{id}/unfollow: - post: - tags: [strategies] - operationId: unfollowStrategy - summary: Release the caller's follow - description: > - Ends the caller's active follow. Their agent reverts to their own - `rebalanceStrategy`/`strategyConfig` on the next scheduled run. - - - Also releases an orphaned follow (one whose publisher deleted their - account, leaving no strategy id to name). - security: - - BearerAuth: [] - parameters: - - in: path - name: id - required: true - schema: - type: string - format: uuid - description: The published strategy's id - responses: - '200': - description: The released follow - content: - application/json: - schema: - type: object - required: [id, unfollowedAt] - properties: - id: - type: string - format: uuid - unfollowedAt: - type: string - format: date-time - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: > - JWT access token issued by `POST /api/v1/auth/verify`. - Include as `Authorization: Bearer `. - AdminToken: - type: http - scheme: bearer - bearerFormat: API-Key - description: > - Admin API key. Can be provided either as - `Authorization: Bearer ` or as the legacy - `X-Admin-Token` header. Issued via `POST /api/v1/admin/keys`. - InternalToken: - type: apiKey - in: header - name: X-Internal-Token - description: > - Service-to-service token for internal endpoints (e.g., agent status, - Prometheus metrics). Alternative auth methods include IP allowlisting - or admin Bearer token. - - schemas: - # ── Auth ────────────────────────────────────────────────────────────── - AuthChallengeResponse: - type: object - required: [nonce, expiresAt] - properties: - nonce: - type: string - description: 'One-time nonce the wallet must sign (prefix: nw-auth-)' - example: nw-auth-a3f9e2c1d0b84756... - expiresAt: - type: string - format: date-time - - AuthVerifyRequest: - type: object - required: [stellarPubKey, signature] - properties: - stellarPubKey: - type: string - description: Stellar G-address (Ed25519 public key) - example: GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37 - signature: - type: string - description: Base64-encoded Stellar signature over the nonce - referralCode: - type: string - maxLength: 32 - description: > - Optional referral code, captured for attribution. Applied only when - this call creates a brand-new user; invalid, self, or duplicate - codes are ignored and never fail signup. - example: ABC123XY - - AuthVerifyResponse: - type: object - required: [accessToken, refreshToken, userId] - properties: - accessToken: - type: string - description: JWT access token (short-lived, default 15 min) - refreshToken: - type: string - description: Opaque refresh token (single-use, default 7 day TTL) - userId: - type: string - format: uuid - expiresAt: - type: string - format: date-time - description: Access token expiration - refreshExpiresAt: - type: string - format: date-time - description: Refresh token expiration - - # ── Health & Status ─────────────────────────────────────────────────── - HealthResponse: - type: object - required: [status, timestamp, version, environment] - properties: - status: - type: string - enum: [ok, degraded] - timestamp: - type: string - format: date-time - version: - type: string - example: 1.0.0 - environment: - type: string - enum: [development, test, production] - - AgentStatusResponse: - type: object - properties: - success: - type: boolean - data: - type: object - properties: - isRunning: - type: boolean - lastRebalanceAt: - type: string - format: date-time - nullable: true - currentProtocol: - type: string - nullable: true - currentApy: - type: string - nullable: true - nextScheduledCheck: - type: string - format: date-time - lastError: - type: string - nullable: true - healthStatus: - type: string - enum: [healthy, degraded, stopped] - timestamp: - type: string - format: date-time - - # ── Portfolio ───────────────────────────────────────────────────────── - PortfolioResponse: - type: object - properties: - userId: - type: string - format: uuid - totalBalance: - type: number - totalEarnings: - type: number - activePositions: - type: integer - positions: - type: array - items: - $ref: '#/components/schemas/Position' - - SavingsGoal: - type: object - properties: - id: - type: string - format: uuid - userId: - type: string - format: uuid - positionId: - type: string - format: uuid - nullable: true - targetAmount: - type: number - startingAmount: - type: number - targetDate: - type: string - format: date-time - riskCeiling: - type: integer - nullable: true - status: - type: string - enum: [ACTIVE, ACHIEVED, MISSED, CANCELLED] - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - - GoalProgress: - type: object - properties: - goalId: - type: string - format: uuid - status: - type: string - enum: [ACTIVE, ACHIEVED, MISSED, CANCELLED] - targetAmount: - type: number - startingAmount: - type: number - currentAmount: - type: number - targetDate: - type: string - format: date-time - requiredApy: - type: number - description: Simple annualized rate needed to reach the target by targetDate - actualApy: - type: number - description: Recent (30d) simple average APY the user is actually getting - onTrack: - type: boolean - reachable: - type: boolean - description: False when requiredApy exceeds the best APY available within the goal's risk ceiling - projectedCompletionDate: - type: string - format: date-time - nullable: true - note: - type: string - whatsappReply: - type: string - - Position: - type: object - properties: - id: - type: string - protocolName: - type: string - assetSymbol: - type: string - example: USDC - currentValue: - type: number - yieldEarned: - type: number - status: - type: string - enum: [ACTIVE, CLOSED] - - # ── Tax report (#284) ───────────────────────────────────────────────── - # Money fields are decimal strings (never floats) and null means - # "unpriced" — never zero. Totals sum only fully priced disposals. - TaxReport: - type: object - properties: - userId: - type: string - format: uuid - year: - type: integer - method: - type: string - enum: [FIFO] - disposals: - type: array - items: - $ref: '#/components/schemas/TaxReportDisposal' - totals: - type: object - properties: - proceeds: - type: string - costBasis: - type: string - realizedGain: - type: string - pricedDisposalCount: - type: integer - caveats: - type: object - properties: - unpricedDisposalCount: - type: integer - unpricedAssets: - type: array - items: - type: string - stablecoinAssumption: - type: string - rebalancesNotIncluded: - type: string - - TaxReportDisposal: - type: object - properties: - disposedAt: - type: string - format: date-time - assetSymbol: - type: string - example: USDC - amount: - type: string - description: Decimal string in asset units - withdrawalTxHash: - type: string - nullable: true - acquiredAt: - type: string - format: date-time - acquisitionTxHash: - type: string - nullable: true - acquisitionPrice: - type: string - nullable: true - disposalPrice: - type: string - nullable: true - costBasis: - type: string - nullable: true - proceeds: - type: string - nullable: true - realizedGain: - type: string - nullable: true - priced: - type: boolean - description: True when every money field is present; only priced disposals enter totals - - # ── Transaction ─────────────────────────────────────────────────────── - Transaction: - type: object - properties: - id: - type: string - txHash: - type: string - type: - type: string - enum: [DEPOSIT, WITHDRAWAL, SWAP, VAULT_DEPOSIT, VAULT_WITHDRAW] - status: - type: string - enum: [PENDING, CONFIRMED, FAILED] - amount: - type: number - assetSymbol: - type: string - protocolName: - type: string - nullable: true - createdAt: - type: string - format: date-time - - OutboxOp: - type: object - description: A durable on-chain money-movement intent (#325). See docs/OUTBOX.md. - properties: - id: - type: string - idempotencyKey: - type: string - userId: - type: string - kind: - type: string - enum: [DEPOSIT, WITHDRAW, REBALANCE, RECURRING_DEPOSIT, REFERRAL_REWARD, YIELD_CLAIM] - actor: - type: string - enum: [USER, AGENT, SYSTEM] - priority: - type: string - enum: [CRITICAL, NORMAL, LOW] - status: - type: string - enum: [PENDING, SUBMITTED, CONFIRMED, FAILED, CANCELLED] - txHash: - type: string - nullable: true - attempts: - type: integer - nextAttemptAt: - type: string - format: date-time - nullable: true - error: - type: string - nullable: true - submittedAt: - type: string - format: date-time - nullable: true - confirmedAt: - type: string - format: date-time - nullable: true - signerPublicKey: - type: string - nullable: true - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - - TransactionResponse: - type: object - properties: - txHash: - type: string - status: - type: string - transaction: - type: object - properties: - id: - type: string - txHash: - type: string - status: - type: string - amount: - type: number - assetSymbol: - type: string - protocolName: - type: string - nullable: true - actingAsUserId: - type: string - format: uuid - nullable: true - description: > - Non-null when the action was performed by a parent on behalf - of a child via a sub-account delegation. - - # ── Recurring Deposit ────────────────────────────────────────────────── - RecurringDepositPlan: - type: object - properties: - id: - type: string - format: uuid - userId: - type: string - format: uuid - amount: - type: string - description: Decimal amount - assetSymbol: - type: string - cadence: - type: string - enum: [WEEKLY, BIWEEKLY, MONTHLY] - nextRunAt: - type: string - format: date-time - status: - type: string - enum: [ACTIVE, PAUSED, CANCELLED] - lastRunAt: - type: string - format: date-time - nullable: true - lastRunStatus: - type: string - nullable: true - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - - # ── Protocol ────────────────────────────────────────────────────────── - ProtocolRate: - type: object - properties: - protocolName: - type: string - assetSymbol: - type: string - supplyApy: - type: number - borrowApy: - type: number - nullable: true - tvl: - type: number - nullable: true - network: - type: string - fetchedAt: - type: string - format: date-time - - ProtocolRiskScore: - type: object - description: >- - A protocol's transparent risk score plus the contributing factors - behind it. See docs/PROTOCOL_RISK_SCORING.md for the methodology. - properties: - protocolName: - type: string - score: - type: integer - minimum: 0 - maximum: 100 - description: Normalized 0-100, higher = lower risk. - factors: - type: object - properties: - auditStatus: - type: string - enum: [UNAUDITED, SELF_REPORTED, THIRD_PARTY_AUDITED] - protocolAgeDays: - type: integer - apyVolatilityFactor: - type: number - description: APY stability in [0,1]; 1 = very stable. - tvlTrendFactor: - type: number - description: TVL trend in [0,1]; >0.5 = growing, <0.5 = declining. - sampleCount: - type: integer - description: Number of rate samples the factors were computed from. - insufficientHistory: - type: boolean - description: >- - True when history is too sparse to characterize the protocol; - such protocols are scored conservatively low and flagged. - computedAt: - type: string - format: date-time - - # ── Error ───────────────────────────────────────────────────────────── - ErrorResponse: - type: object - required: [error] - properties: - error: - type: string - example: Unauthorized - details: - description: Optional structured detail (array or object) - oneOf: - - type: array - items: {} - - type: object - - # ── Sub-Accounts ───────────────────────────────────────────────────────── - SubAccountPermission: - type: string - enum: [VIEW, DEPOSIT, WITHDRAW, MANAGE_STRATEGY] - description: > - Granular permission for sub-account delegation. Permissions are - independent flags — having DEPOSIT does not implicitly grant VIEW. - SubAccountStatus: - type: string - enum: [ACTIVE, REVOKED] - description: ACTIVE means the sub-account is live; REVOKED means it was soft-deleted. - SubAccount: - type: object - properties: - id: - type: string - format: uuid - parentUserId: - type: string - format: uuid - childUserId: - type: string - format: uuid - permissions: - type: array - items: - $ref: '#/components/schemas/SubAccountPermission' - status: - $ref: '#/components/schemas/SubAccountStatus' - createdAt: - type: string - format: date-time - revokedAt: - type: string - format: date-time - nullable: true - - # ── Fiat ────────────────────────────────────────────────────────────── - FiatDirection: - type: string - enum: [ON_RAMP, OFF_RAMP] - description: ON_RAMP buys crypto with fiat; OFF_RAMP sells crypto for fiat. - FiatOrderStatus: - type: string - enum: [PENDING, PROCESSING, SETTLED, FAILED, REFUNDED] - description: > - PENDING (awaiting payment), PROCESSING (provider reported payment; - awaiting on-chain confirmation), SETTLED (on-chain confirmed), - FAILED, REFUNDED. - FiatQuoteRequest: - type: object - required: [direction, fiatAmount, fiatCurrency, assetSymbol] - properties: - direction: - $ref: '#/components/schemas/FiatDirection' - fiatAmount: - type: number - format: double - minimum: 0 - example: 100 - fiatCurrency: - type: string - description: 3-letter ISO 4217 currency code - example: USD - assetSymbol: - type: string - example: USDC - FeeBreakdown: - type: object - nullable: true - description: > - Structured fee breakdown in fiatCurrency. Any component the provider - does not report is `null` — never assumed to be zero. The whole object - is `null` when the provider cannot itemize its fee at all (see - `FiatQuote.unpriced`). - properties: - providerFee: - type: number - nullable: true - networkFee: - type: number - nullable: true - fxSpread: - type: number - nullable: true - FiatQuote: - type: object - properties: - direction: - $ref: '#/components/schemas/FiatDirection' - fiatAmount: - type: number - example: 100 - fiatCurrency: - type: string - example: USD - cryptoAmount: - type: number - example: 98.5 - assetSymbol: - type: string - example: USDC - feeAmount: - type: number - description: Deprecated — sum of fee components when known. Prefer `fees`. - example: 1.5 - rate: - type: number - description: Exchange rate used (crypto units per 1 fiat unit). - example: 1.0 - rateSource: - type: string - enum: [PROVIDER, FX_FEED] - description: Where the exchange rate came from — never silently assumed. - fees: - $ref: '#/components/schemas/FeeBreakdown' - unpriced: - type: boolean - description: True when `fees` is null because the provider gave no breakdown. - requiresKyc: - type: boolean - description: True when this provider requires additional KYC for this pair. - providerQuoteId: - type: string - nullable: true - provider: - type: string - example: moonpay - expiresAt: - type: string - format: date-time - ExcludedProviderQuote: - type: object - properties: - provider: - type: string - reason: - type: string - description: Why this provider's quote could not be included (timeout, error, unsupported pair). - RankedQuote: - allOf: - - $ref: '#/components/schemas/FiatQuote' - - type: object - properties: - quoteId: - type: string - format: uuid - description: Pass as `quoteId` to POST /fiat/orders to lock this exact rate. - rank: - type: integer - description: 1 = best executable price among the providers that responded. - BestExecutionQuoteResult: - type: object - properties: - best: - $ref: '#/components/schemas/RankedQuote' - description: The best executable quote, or omitted/null when every provider failed. - quotes: - type: array - items: - $ref: '#/components/schemas/RankedQuote' - excluded: - type: array - items: - $ref: '#/components/schemas/ExcludedProviderQuote' - CreateFiatOrderRequest: - type: object - required: [userId, direction, fiatAmount, fiatCurrency, assetSymbol] - properties: - userId: - type: string - format: uuid - direction: - $ref: '#/components/schemas/FiatDirection' - fiatAmount: - type: number - minimum: 0 - example: 100 - fiatCurrency: - type: string - example: USD - assetSymbol: - type: string - example: USDC - provider: - type: string - description: > - Preferred provider key. Ignored when `quoteId` is set (the quote - already pins a provider). Falls back to the registry's default - selection policy if the preferred provider is unhealthy. - example: moonpay - quoteId: - type: string - format: uuid - description: > - A `quoteId` from `GET /fiat/quotes` to lock in that exact rate. - Rejected with `quote_expired` past its validity window. - FiatOrder: - type: object - properties: - id: - type: string - format: uuid - userId: - type: string - format: uuid - provider: - type: string - example: moonpay - providerOrderId: - type: string - direction: - $ref: '#/components/schemas/FiatDirection' - fiatAmount: - type: number - example: 100 - fiatCurrency: - type: string - example: USD - cryptoAmount: - type: number - nullable: true - example: 98.5 - assetSymbol: - type: string - example: USDC - status: - $ref: '#/components/schemas/FiatOrderStatus' - checkoutUrl: - type: string - nullable: true - kycUrl: - type: string - nullable: true - transactionId: - type: string - format: uuid - nullable: true - description: Linked on-chain transaction once settled - failureReason: - type: string - nullable: true - settledAt: - type: string - format: date-time - nullable: true - quoteRate: - type: number - nullable: true - description: Exchange rate captured from the quote at order-creation time. - quotedCryptoAmount: - type: number - nullable: true - description: Crypto amount promised by the quote at order-creation time. - fees: - $ref: '#/components/schemas/FeeBreakdown' - providerQuoteId: - type: string - nullable: true - rateLockExpiresAt: - type: string - format: date-time - nullable: true - settledRate: - type: number - nullable: true - description: Exchange rate realized at on-chain settlement. - settledCryptoAmount: - type: number - nullable: true - description: > - Crypto amount actually confirmed on-chain. Compare against - `quotedCryptoAmount` for the quoted-vs-settled delta; drift beyond - tolerance triggers an operational alert and a - `fiat.order.rate_mismatch` webhook. - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - - ReferralStatus: - type: string - enum: [PENDING, ACTIVATED, REWARDED, EXPIRED] - description: > - PENDING (attributed at signup), ACTIVATED (a confirmed deposit crossed - the threshold), REWARDED (both parties paid), EXPIRED. - ReferralCodeResponse: - type: object - required: [code, createdAt] - properties: - code: - type: string - example: ABC123XY - createdAt: - type: string - format: date-time - Referral: - type: object - properties: - id: - type: string - format: uuid - status: - $ref: '#/components/schemas/ReferralStatus' - activatedAt: - type: string - format: date-time - nullable: true - activationTxId: - type: string - nullable: true - description: The confirmed deposit Transaction that satisfied activation - ownerRewardTxId: - type: string - nullable: true - referredRewardTxId: - type: string - nullable: true - createdAt: - type: string - format: date-time - ReferralListResponse: - type: object - required: [code, referrals] - properties: - code: - type: string - nullable: true - description: The owner's referral code, or null if none has been created yet - example: ABC123XY - referrals: - type: array - items: - $ref: '#/components/schemas/Referral' - - AlertMetric: - type: string - enum: [PROTOCOL_APY, PORTFOLIO_VALUE, POSITION_DRAWDOWN] - description: > - What the rule watches. PROTOCOL_APY = a named protocol's supply APY in - percent; PORTFOLIO_VALUE = the user's total active-position value in USD; - POSITION_DRAWDOWN = percentage decline of that portfolio value from its - rolling 30-day peak (see docs/ALERTS.md). - AlertComparator: - type: string - enum: [LT, LTE, GT, GTE] - description: Comparison of the observed value against the threshold. - AlertDeliveryChannel: - type: string - enum: [WEBHOOK, WHATSAPP, BOTH] - description: Where a triggered alert is delivered. - StrategyConfig: - type: object - description: > - The three keys the agent acts on. Nothing else is ever copied to a - follower — notably `riskTolerance`, which stays personal. - required: [strategyName] - properties: - strategyName: - type: string - enum: [MAX_YIELD, TARGET_ALLOCATION] - description: > - GOAL_TRACKING is not publishable — it is driven by the publisher's - own savings goal, which means nothing to a follower. - targetAllocations: - type: object - additionalProperties: - type: number - minimum: 0 - maximum: 100 - description: > - Protocol name → percentage weight. Required when strategyName is - TARGET_ALLOCATION, and must sum to 100 — followers inherit these - weights verbatim. - example: - Blend: 60 - Luma: 40 - riskCeiling: - type: integer - minimum: 0 - maximum: 100 - description: > - Minimum acceptable protocol risk score (higher = lower risk). A - follower's effective ceiling is the stricter of theirs and the - publisher's. - - PublishStrategyRequest: - type: object - required: [label] - properties: - label: - type: string - maxLength: 60 - description: > - Public display name. Rejected if it contains a Stellar address or a - hexadecimal run of 32+ characters — published strategies are - anonymous. - example: Steady conservative yield - strategyConfig: - $ref: '#/components/schemas/StrategyConfig' - - PublishedStrategy: - type: object - description: > - A marketplace listing. Deliberately carries no `userId` — the publisher - is anonymous to everyone including followers. - required: [id, label, strategyConfig, configVersion, isPublished] - properties: - id: - type: string - format: uuid - label: - type: string - example: Steady conservative yield - strategyConfig: - $ref: '#/components/schemas/StrategyConfig' - configVersion: - type: integer - description: Incremented only on a material configuration change. - isPublished: - type: boolean - publishedAt: - type: string - format: date-time - nullable: true - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - - PublishStrategyResponse: - type: object - required: [strategy, materialChange] - properties: - strategy: - $ref: '#/components/schemas/PublishedStrategy' - materialChange: - type: boolean - description: > - True when this publish changed the configuration and therefore - bumped `configVersion` and notified followers. - - StrategyMarketplaceEntry: - type: object - description: > - One leaderboard row. Statistics are derived and relative — never an - absolute balance, and never anything identifying the publisher. - required: - - strategyId - - label - - strategyConfig - - windowDays - - apy - - sampleCount - - trackRecordDays - properties: - strategyId: - type: string - format: uuid - label: - type: string - strategyConfig: - $ref: '#/components/schemas/StrategyConfig' - configVersion: - type: integer - publishedAt: - type: string - format: date-time - nullable: true - windowDays: - type: integer - enum: [30, 90] - apy: - type: number - description: Annualized return over the window, in percent (simple, non-compounding). - sharpe: - type: number - nullable: true - description: > - Risk-adjusted return. Null when not computable (too few samples, or a - zero-variance series) — never faked as 0. Eligible entries always - have a value. - sampleCount: - type: integer - trackRecordDays: - type: integer - description: Days of observed history. At least 30 for any listed entry. - computedAt: - type: string - format: date-time - vsBenchmark: - type: number - nullable: true - description: > - Benchmark-relative return (#320): this strategy's portfolio - return minus the benchmark's return over the window, as a decimal - fraction. Null when attribution has not been computed yet for this - strategy/window. Lets a leaderboard viewer distinguish a strategy - that beat the market from one that merely took on more risk to - match it — apy/sharpe alone cannot. - - StrategyMarketplaceResponse: - type: object - required: [page, limit, total, window, sortBy, strategies] - properties: - page: - type: integer - limit: - type: integer - total: - type: integer - window: - type: string - enum: ['30d', '90d'] - sortBy: - type: string - enum: [apy, sharpe] - strategies: - type: array - items: - $ref: '#/components/schemas/StrategyMarketplaceEntry' - - SectorAttribution: - type: object - description: > - One protocol's (sector's) contribution within an attribution report - (#320). A sector may appear with a benchmark weight but zero - portfolio weight (the portfolio never held it) or vice versa — both - are reported, never dropped. - required: - - sector - - portfolioWeight - - benchmarkWeight - - allocationEffect - - selectionEffect - properties: - sector: - type: string - description: Protocol name — the v1 definition of "sector". - portfolioWeight: - type: number - description: Time-averaged share of the portfolio held in this sector over the window (0-1). - benchmarkWeight: - type: number - description: Time-averaged benchmark weight for this sector over the window (0-1). - portfolioReturn: - type: number - nullable: true - description: Compounded return of this sector within the portfolio; null if never held with a computable return. - benchmarkReturn: - type: number - nullable: true - description: Compounded benchmark return for this sector; null if the benchmark never had data for it. - allocationEffect: - type: number - description: Linked contribution from over/underweighting this sector relative to the benchmark. - selectionEffect: - type: number - description: Linked contribution from this sector's own return beating or lagging the benchmark's. - - PortfolioAttributionResponse: - type: object - description: > - Benchmark-relative Brinson attribution for one subject/window (#320). - Effects are relative figures only — never an absolute currency - amount, matching the anonymization discipline used for published - strategies. - required: [userId, window, computed] - properties: - userId: - type: string - window: - type: string - enum: ['30d', '90d'] - computed: - type: boolean - description: False when nothing has been precomputed yet for this user/window — a normal state, not an error. - windowDays: - type: integer - enum: [30, 90] - portfolioReturn: - type: number - description: Compounded portfolio return over the window (decimal fraction). - benchmarkReturn: - type: number - description: Compounded benchmark return over the window (decimal fraction). - vsBenchmark: - type: number - description: portfolioReturn - benchmarkReturn. - allocationEffect: - type: number - description: Linked total allocation effect across the window. - selectionEffect: - type: number - description: Linked total selection effect across the window (interaction folded in — see docs/PERFORMANCE_ATTRIBUTION.md). - unattributedEffect: - type: number - description: Linked contribution from sectors/periods with no benchmark comparator — never fabricated as allocation or selection. - reconciliationGap: - type: number - description: (portfolioReturn - benchmarkReturn) - (allocationEffect + selectionEffect + unattributedEffect). - reconciled: - type: boolean - description: False when reconciliationGap exceeds the documented tolerance — reported explicitly rather than silently forced to match. - benchmarkVersion: - type: string - description: Which benchmark definition/protocol subset produced this report. - sectors: - type: array - items: - $ref: '#/components/schemas/SectorAttribution' - computedAt: - type: string - format: date-time - - StrategyFollow: - type: object - required: [id, appliedConfig, appliedConfigVersion, appliedAt, followedAt] - properties: - id: - type: string - format: uuid - strategyId: - type: string - format: uuid - nullable: true - description: Null when the publisher deleted their account (the follow is orphaned). - appliedConfig: - allOf: - - $ref: '#/components/schemas/StrategyConfig' - description: > - The snapshot the agent actually runs. May lag the live listing, and - outlives it if the publisher delists or deletes their account. - appliedConfigVersion: - type: integer - appliedAt: - type: string - format: date-time - followedAt: - type: string - format: date-time - strategy: - type: object - nullable: true - allOf: - - $ref: '#/components/schemas/PublishedStrategy' - - AlertRule: - type: object - required: - - id - - userId - - metric - - comparator - - threshold - - deliveryChannel - - cooldownMinutes - - isActive - - createdAt - properties: - id: - type: string - format: uuid - userId: - type: string - format: uuid - metric: - $ref: '#/components/schemas/AlertMetric' - protocolName: - type: string - nullable: true - description: Required when metric is PROTOCOL_APY; null otherwise. - example: Blend - comparator: - $ref: '#/components/schemas/AlertComparator' - threshold: - type: number - description: > - Percent for PROTOCOL_APY/POSITION_DRAWDOWN, USD for PORTFOLIO_VALUE. - example: 5 - deliveryChannel: - $ref: '#/components/schemas/AlertDeliveryChannel' - cooldownMinutes: - type: integer - minimum: 1 - maximum: 10080 - default: 60 - description: Minimum minutes between repeat notifications while the condition holds. - lastFiredAt: - type: string - format: date-time - nullable: true - isActive: - type: boolean - description: > - A PROTOCOL_APY rule is auto-set to false if its protocol is delisted - (no rate data), rather than evaluating against stale data. - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - CreateAlertRuleRequest: - type: object - required: [metric, comparator, threshold, deliveryChannel] - properties: - metric: - $ref: '#/components/schemas/AlertMetric' - protocolName: - type: string - description: Required when metric is PROTOCOL_APY; rejected otherwise. - example: Blend - comparator: - $ref: '#/components/schemas/AlertComparator' - threshold: - type: number - example: 5 - deliveryChannel: - $ref: '#/components/schemas/AlertDeliveryChannel' - cooldownMinutes: - type: integer - minimum: 1 - maximum: 10080 - default: 60 - UpdateAlertRuleRequest: - type: object - minProperties: 1 - description: Partial update — at least one field must be provided. - properties: - metric: - $ref: '#/components/schemas/AlertMetric' - protocolName: - type: string - nullable: true - comparator: - $ref: '#/components/schemas/AlertComparator' - threshold: - type: number - deliveryChannel: - $ref: '#/components/schemas/AlertDeliveryChannel' - cooldownMinutes: - type: integer - minimum: 1 - maximum: 10080 - isActive: - type: boolean - AlertRuleListResponse: - type: object - required: [rules] - properties: - rules: - type: array - items: - $ref: '#/components/schemas/AlertRule' - - # ── Portfolio optimization (#322) ───────────────────────────────────── - SuggestAllocationRequest: - type: object - additionalProperties: false - description: | - All fields optional; an empty body requests a suggestion with defaults. - Unknown fields are rejected rather than ignored, so a typo cannot - silently produce an answer computed over the wrong window. - properties: - lookbackDays: - type: integer - minimum: 14 - maximum: 365 - default: 90 - description: Trailing window the statistics are estimated over. - frontierPoints: - type: integer - minimum: 2 - maximum: 25 - default: 12 - description: Efficient-frontier resolution. Each point is a full solve. - includeBacktest: - type: boolean - default: true - description: Set false to skip the suggested-vs-current backtest legs. - - FrontierPoint: - type: object - required: [lambda, risk, return, weights] - properties: - lambda: - type: number - description: Risk-aversion coefficient this point was solved at. - risk: - type: number - description: Annualized yield volatility, as a decimal fraction (0.014 = 1.4pp). - return: - type: number - description: Annualized expected return, as a decimal fraction (0.082 = 8.2%). - weights: - type: object - additionalProperties: - type: number - description: Decimal-fraction weights (0.425 = 42.5%), keyed by protocol. - - UniverseExclusion: - type: object - required: [protocol, reason] - description: Why a protocol was not eligible for optimization. - properties: - protocol: - type: string - reason: - type: string - enum: - - insufficient_history - - risk_ceiling - - no_risk_score - - no_rate_history - - insufficient_aligned_history - detail: - type: string - - BacktestLeg: - type: object - required: [finalValue, realizedApy, maxDrawdownPercent, rebalanceCount, finalProtocol] - properties: - finalValue: - type: number - realizedApy: - type: number - maxDrawdownPercent: - type: number - rebalanceCount: - type: integer - finalProtocol: - type: string - - BacktestComparison: - type: object - required: [suggested, current, startDate, endDate, startingAmount, caveat] - properties: - suggested: - $ref: '#/components/schemas/BacktestLeg' - current: - type: object - allOf: - - $ref: '#/components/schemas/BacktestLeg' - nullable: true - description: Null when the user has no current allocation to compare against. - startDate: - type: string - format: date - endDate: - type: string - format: date - startingAmount: - type: number - caveat: - type: string - description: | - States that the agent holds ONE protocol at a time, so this compares - what the agent would have done under each configuration — it is not a - simulation of holding the weighted basket. - - AllocationSuggestionResponse: - type: object - required: - - isSuggestion - - disclaimer - - userId - - inputHash - - status - - weights - - riskTolerance - - computedAt - properties: - isSuggestion: - type: boolean - enum: [true] - description: Always true. This response is advice, never an applied change. - disclaimer: - type: string - userId: - type: string - format: uuid - id: - type: string - format: uuid - description: Set when the suggestion was persisted. - inputHash: - type: string - description: | - 'sha256:'-prefixed hash of the canonical input snapshot. Equal hashes - mean equal weights, which is what makes "did my recommendation change - or only my inputs?" answerable. - status: - type: string - enum: [ok, infeasible, insufficient_universe, non_converged] - weights: - type: object - additionalProperties: - type: number - description: | - PERCENTAGES summing to 100 +/- 0.01 — directly acceptable by the - strategy update endpoint. Empty for a non-ok status. - outcome: - type: object - description: | - The full discriminated outcome. For `ok` it carries expectedReturn, - expectedVolatility, lambda, frontier, iterations and - portfolioRiskScore; for the failure statuses it names the binding - constraint or the exclusions. - riskTolerance: - type: integer - effectiveRiskCeiling: - type: integer - nullable: true - ceilingSource: - type: string - enum: [goal, follow, own, none] - description: Which layer supplied the effective ceiling. - currentAllocations: - type: object - additionalProperties: - type: number - nullable: true - excluded: - type: array - items: - $ref: '#/components/schemas/UniverseExclusion' - observationCount: - type: integer - description: Days on which every eligible protocol had a value. - lookbackDays: - type: integer - backtest: - type: object - allOf: - - $ref: '#/components/schemas/BacktestComparison' - nullable: true - computedAt: - type: string - format: date-time - - StoredAllocationSuggestion: - type: object - required: [id, isSuggestion, status, inputHash, weights, riskTolerance, computedAt] - properties: - id: - type: string - format: uuid - isSuggestion: - type: boolean - enum: [true] - status: - type: string - enum: [ok, infeasible, insufficient_universe, non_converged] - inputHash: - type: string - weights: - type: object - additionalProperties: - type: number - frontier: - type: array - items: - $ref: '#/components/schemas/FrontierPoint' - backtest: - type: object - allOf: - - $ref: '#/components/schemas/BacktestComparison' - nullable: true - description: Null for rows written by the scheduled precompute job. - riskTolerance: - type: integer - effectiveRiskCeiling: - type: integer - nullable: true - reason: - type: string - nullable: true - description: Human-readable explanation for a non-ok status. - computedAt: - type: string - format: date-time - - AllocationSuggestionListResponse: - type: object - required: [userId, isSuggestion, page, limit, total, suggestions] - properties: - userId: - type: string - format: uuid - isSuggestion: - type: boolean - enum: [true] - page: - type: integer - limit: - type: integer - total: - type: integer - suggestions: - type: array - items: - $ref: '#/components/schemas/StoredAllocationSuggestion' - - responses: - Unauthorized: - description: Missing or invalid authentication - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Unauthorized - Forbidden: - description: Authenticated but insufficient permissions - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Forbidden - BadRequest: - description: Invalid request body or parameters - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Validation error - details: - - field: amount - message: Must be a positive number - NotFound: - description: The requested resource was not found - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Not found - Conflict: - description: Resource conflict (e.g. duplicate) - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: - error: Conflict diff --git a/package-lock.json b/package-lock.json index 9600bd5..c41b23d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6078,6 +6078,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/prisma/migrations/20260302221454_init/rollback.sql b/prisma/migrations/20260302221454_init/rollback.sql index f9b8316..4b3e32c 100644 --- a/prisma/migrations/20260302221454_init/rollback.sql +++ b/prisma/migrations/20260302221454_init/rollback.sql @@ -1,28 +1,13 @@ --- Rollback for 20260302221454_init --- Reverses the initial schema: drops foreign keys, tables, and enums. --- WARNING: This drops every core table and DESTROYS ALL DATA. Take a backup first. +-- rollback.sql — reverse of 20260302221454_init/migration.sql +-- Drops all tables created by the initial schema migration. +-- WARNING: This is a full database wipe. Only run in a non-production +-- environment or after a verified database snapshot backup. --- DropForeignKey -ALTER TABLE "agent_logs" DROP CONSTRAINT IF EXISTS "agent_logs_userId_fkey"; -ALTER TABLE "yield_snapshots" DROP CONSTRAINT IF EXISTS "yield_snapshots_positionId_fkey"; -ALTER TABLE "transactions" DROP CONSTRAINT IF EXISTS "transactions_positionId_fkey"; -ALTER TABLE "transactions" DROP CONSTRAINT IF EXISTS "transactions_userId_fkey"; -ALTER TABLE "positions" DROP CONSTRAINT IF EXISTS "positions_userId_fkey"; -ALTER TABLE "sessions" DROP CONSTRAINT IF EXISTS "sessions_userId_fkey"; - --- DropTable (indexes are dropped with their table) -DROP TABLE IF EXISTS "agent_logs"; -DROP TABLE IF EXISTS "protocol_rates"; -DROP TABLE IF EXISTS "yield_snapshots"; -DROP TABLE IF EXISTS "transactions"; -DROP TABLE IF EXISTS "positions"; -DROP TABLE IF EXISTS "sessions"; -DROP TABLE IF EXISTS "users"; - --- DropEnum -DROP TYPE IF EXISTS "AgentStatus"; -DROP TYPE IF EXISTS "AgentAction"; -DROP TYPE IF EXISTS "PositionStatus"; -DROP TYPE IF EXISTS "TransactionStatus"; -DROP TYPE IF EXISTS "TransactionType"; -DROP TYPE IF EXISTS "Network"; +-- Drop foreign keys and tables in dependency order +DROP TABLE IF EXISTS "agent_logs" CASCADE; +DROP TABLE IF EXISTS "yield_snapshots" CASCADE; +DROP TABLE IF EXISTS "transactions" CASCADE; +DROP TABLE IF EXISTS "protocol_rates" CASCADE; +DROP TABLE IF EXISTS "positions" CASCADE; +DROP TABLE IF EXISTS "sessions" CASCADE; +DROP TABLE IF EXISTS "users" CASCADE; diff --git a/prisma/migrations/20260326152030_add_event_tracking/rollback.sql b/prisma/migrations/20260326152030_add_event_tracking/rollback.sql index ce84169..2fbc78d 100644 --- a/prisma/migrations/20260326152030_add_event_tracking/rollback.sql +++ b/prisma/migrations/20260326152030_add_event_tracking/rollback.sql @@ -1,6 +1,5 @@ --- Rollback for 20260326152030_add_event_tracking --- Drops the event-tracking tables (indexes drop with their table). --- WARNING: Destroys event cursor state and the processed-event dedupe log. +-- rollback.sql — reverse of 20260326152030_add_event_tracking/migration.sql +-- Drops the event_cursors and processed_events tables. -DROP TABLE IF EXISTS "processed_events"; -DROP TABLE IF EXISTS "event_cursors"; +DROP TABLE IF EXISTS "processed_events" CASCADE; +DROP TABLE IF EXISTS "event_cursors" CASCADE; diff --git a/prisma/migrations/20260425140000_add_performance_indexes/rollback.sql b/prisma/migrations/20260425140000_add_performance_indexes/rollback.sql index a5f928d..0d8f667 100644 --- a/prisma/migrations/20260425140000_add_performance_indexes/rollback.sql +++ b/prisma/migrations/20260425140000_add_performance_indexes/rollback.sql @@ -1,23 +1,22 @@ --- Rollback for 20260425140000_add_performance_indexes --- Drops the indexes the forward migration created and recreates the three it dropped. --- Index-only changes: no data is affected. +-- rollback.sql — reverse of 20260425140000_add_performance_indexes/migration.sql +-- Drops new indexes and restores original indexes that were dropped by the migration. --- Recreate indexes that the forward migration dropped -CREATE INDEX IF NOT EXISTS "users_walletAddress_idx" ON "users"("walletAddress"); -CREATE INDEX IF NOT EXISTS "sessions_token_idx" ON "sessions"("token"); -CREATE INDEX IF NOT EXISTS "transactions_txHash_idx" ON "transactions"("txHash"); - --- Drop indexes the forward migration created -DROP INDEX IF EXISTS "sessions_expiresAt_idx"; -DROP INDEX IF EXISTS "sessions_userId_expiresAt_idx"; -DROP INDEX IF EXISTS "positions_status_idx"; -DROP INDEX IF EXISTS "positions_userId_status_idx"; -DROP INDEX IF EXISTS "positions_protocolName_assetSymbol_idx"; -DROP INDEX IF EXISTS "positions_assetSymbol_idx"; -DROP INDEX IF EXISTS "transactions_type_idx"; -DROP INDEX IF EXISTS "transactions_status_idx"; -DROP INDEX IF EXISTS "transactions_createdAt_idx"; -DROP INDEX IF EXISTS "transactions_userId_createdAt_idx"; -DROP INDEX IF EXISTS "agent_logs_status_idx"; +-- Drop indexes added by the migration DROP INDEX IF EXISTS "agent_logs_userId_status_idx"; +DROP INDEX IF EXISTS "agent_logs_status_idx"; +DROP INDEX IF EXISTS "transactions_userId_createdAt_idx"; +DROP INDEX IF EXISTS "transactions_createdAt_idx"; +DROP INDEX IF EXISTS "transactions_status_idx"; +DROP INDEX IF EXISTS "transactions_type_idx"; +DROP INDEX IF EXISTS "positions_assetSymbol_idx"; +DROP INDEX IF EXISTS "positions_protocolName_assetSymbol_idx"; +DROP INDEX IF EXISTS "positions_userId_status_idx"; +DROP INDEX IF EXISTS "positions_status_idx"; +DROP INDEX IF EXISTS "sessions_userId_expiresAt_idx"; +DROP INDEX IF EXISTS "sessions_expiresAt_idx"; DROP INDEX IF EXISTS "processed_events_ledger_idx"; + +-- Restore indexes that the migration dropped +CREATE INDEX "users_walletAddress_idx" ON "users"("walletAddress"); +CREATE INDEX "sessions_token_idx" ON "sessions"("token"); +CREATE INDEX "transactions_txHash_idx" ON "transactions"("txHash"); diff --git a/prisma/migrations/20260528_add_dead_letter_events/rollback.sql b/prisma/migrations/20260528_add_dead_letter_events/rollback.sql index eac50d5..5bae4e7 100644 --- a/prisma/migrations/20260528_add_dead_letter_events/rollback.sql +++ b/prisma/migrations/20260528_add_dead_letter_events/rollback.sql @@ -1,6 +1,5 @@ --- Rollback for 20260528_add_dead_letter_events --- Drops the dead-letter queue table and its enum. --- WARNING: Destroys any queued/failed events awaiting retry. +-- rollback.sql — reverse of 20260528_add_dead_letter_events/migration.sql +-- Drops the dead_letter_events table and the DeadLetterEventStatus enum. -DROP TABLE IF EXISTS "dead_letter_events"; +DROP TABLE IF EXISTS "dead_letter_events" CASCADE; DROP TYPE IF EXISTS "DeadLetterEventStatus"; diff --git a/prisma/migrations/20260529000001_add_custodial_wallets/rollback.sql b/prisma/migrations/20260529000001_add_custodial_wallets/rollback.sql index 30f2bb9..2982a60 100644 --- a/prisma/migrations/20260529000001_add_custodial_wallets/rollback.sql +++ b/prisma/migrations/20260529000001_add_custodial_wallets/rollback.sql @@ -1,5 +1,4 @@ --- Rollback for 20260529000001_add_custodial_wallets --- Drops the custodial wallets table (indexes drop with it). --- WARNING: Destroys encrypted custodial wallet secrets. Ensure keys are backed up. +-- rollback.sql — reverse of 20260529000001_add_custodial_wallets/migration.sql +-- Drops the custodial_wallets table and all its indexes. -DROP TABLE IF EXISTS "custodial_wallets"; +DROP TABLE IF EXISTS "custodial_wallets" CASCADE; diff --git a/prisma/migrations/20260529000002_add_auth_nonces/rollback.sql b/prisma/migrations/20260529000002_add_auth_nonces/rollback.sql index 22b2dc7..89fcd49 100644 --- a/prisma/migrations/20260529000002_add_auth_nonces/rollback.sql +++ b/prisma/migrations/20260529000002_add_auth_nonces/rollback.sql @@ -1,5 +1,4 @@ --- Rollback for 20260529000002_add_auth_nonces --- Drops the auth nonce table (indexes drop with it). --- Safe: nonces are short-lived challenge values, not durable state. +-- rollback.sql — reverse of 20260529000002_add_auth_nonces/migration.sql +-- Drops the auth_nonces table and all its indexes. -DROP TABLE IF EXISTS "auth_nonces"; +DROP TABLE IF EXISTS "auth_nonces" CASCADE; diff --git a/prisma/migrations/20260617000000_fix_agent_log_attribution/rollback.sql b/prisma/migrations/20260617000000_fix_agent_log_attribution/rollback.sql index d102704..d840f85 100644 --- a/prisma/migrations/20260617000000_fix_agent_log_attribution/rollback.sql +++ b/prisma/migrations/20260617000000_fix_agent_log_attribution/rollback.sql @@ -1,15 +1,18 @@ --- Rollback for 20260617000000_fix_agent_log_attribution --- Reverses the agent_logs attribution changes. +-- rollback.sql — reverse of 20260617000000_fix_agent_log_attribution/migration.sql -- --- PARTIALLY IRREVERSIBLE: the forward migration relaxed agent_logs.userId to --- nullable so system-level scans could log without a user. Restoring NOT NULL --- will FAIL if any rows have userId IS NULL. Reassign or delete those rows --- before running this rollback, e.g.: --- DELETE FROM "agent_logs" WHERE "userId" IS NULL; +-- Restores agent_logs.userId to NOT NULL and drops positionId. +-- NOTE: If any existing rows have userId=NULL (system-level logs written after +-- the migration), they must be reassigned or deleted before re-adding the +-- NOT NULL constraint. Document any such rows before running this rollback. +-- +-- Run with: psql $DATABASE_URL -f prisma/migrations/20260617000000_fix_agent_log_attribution/rollback.sql +-- Remove indexes added by the migration DROP INDEX IF EXISTS "agent_logs_userId_createdAt_idx"; DROP INDEX IF EXISTS "agent_logs_positionId_idx"; +-- Remove the positionId column ALTER TABLE "agent_logs" DROP COLUMN IF EXISTS "positionId"; +-- Restore userId NOT NULL (fails if any null rows exist — resolve before running) ALTER TABLE "agent_logs" ALTER COLUMN "userId" SET NOT NULL; diff --git a/prisma/migrations/20260820000000_add_portfolio_risk_aggregates/migration.sql b/prisma/migrations/20260820000000_add_portfolio_risk_aggregates/migration.sql new file mode 100644 index 0000000..786f819 --- /dev/null +++ b/prisma/migrations/20260820000000_add_portfolio_risk_aggregates/migration.sql @@ -0,0 +1,45 @@ +-- Migration: add_portfolio_risk_aggregates +-- Creates the portfolio_risk_aggregates table for precomputed per-user +-- risk metrics (VaR, CVaR, Sortino, drawdown, volatility). +-- Written by src/jobs/portfolioRisk.ts on a configurable schedule. + +CREATE TABLE "portfolio_risk_aggregates" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "window" TEXT NOT NULL, + "insufficientHistory" BOOLEAN NOT NULL DEFAULT false, + "sampleCount" INTEGER NOT NULL DEFAULT 0, + "annualisedVolatility" DECIMAL(20, 8), + "sortinoRatio" DECIMAL(20, 8), + "downsideDeviation" DECIMAL(20, 8), + "maxDrawdown" DECIMAL(20, 8), + "maxDrawdownDuration" INTEGER, + "varHistorical95" DECIMAL(20, 8), + "varHistorical99" DECIMAL(20, 8), + "varParametric95" DECIMAL(20, 8), + "varParametric99" DECIMAL(20, 8), + "cvarHistorical95" DECIMAL(20, 8), + "cvarHistorical99" DECIMAL(20, 8), + "beta" DECIMAL(20, 8), + "dataFrom" TIMESTAMP(3), + "dataTo" TIMESTAMP(3), + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "portfolio_risk_aggregates_pkey" PRIMARY KEY ("id") +); + +-- Foreign key to users +ALTER TABLE "portfolio_risk_aggregates" + ADD CONSTRAINT "portfolio_risk_aggregates_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Unique constraint: one row per (userId, window) — upserted on each compute run +CREATE UNIQUE INDEX "portfolio_risk_aggregates_userId_window_key" + ON "portfolio_risk_aggregates"("userId", "window"); + +-- Indexes for efficient lookups and leaderboard ORDER BY +CREATE INDEX "portfolio_risk_aggregates_userId_idx" ON "portfolio_risk_aggregates"("userId"); +CREATE INDEX "portfolio_risk_aggregates_window_idx" ON "portfolio_risk_aggregates"("window"); +CREATE INDEX "portfolio_risk_aggregates_computedAt_idx" ON "portfolio_risk_aggregates"("computedAt"); +CREATE INDEX "portfolio_risk_aggregates_sortinoRatio_idx" ON "portfolio_risk_aggregates"("sortinoRatio"); +CREATE INDEX "portfolio_risk_aggregates_annualisedVol_idx" ON "portfolio_risk_aggregates"("annualisedVolatility"); diff --git a/prisma/migrations/20260820000000_add_portfolio_risk_aggregates/rollback.sql b/prisma/migrations/20260820000000_add_portfolio_risk_aggregates/rollback.sql new file mode 100644 index 0000000..6a64da1 --- /dev/null +++ b/prisma/migrations/20260820000000_add_portfolio_risk_aggregates/rollback.sql @@ -0,0 +1,16 @@ +-- rollback.sql — reverse of 20260820000000_add_portfolio_risk_aggregates/migration.sql +-- +-- Drops the portfolio_risk_aggregates table and all its indexes. +-- Safe to run multiple times (IF EXISTS guards). +-- Run with: psql $DATABASE_URL -f prisma/migrations/20260820000000_add_portfolio_risk_aggregates/rollback.sql + +-- Drop indexes first (dropped implicitly with the table, listed explicitly for clarity) +DROP INDEX IF EXISTS "portfolio_risk_aggregates_annualisedVol_idx"; +DROP INDEX IF EXISTS "portfolio_risk_aggregates_sortinoRatio_idx"; +DROP INDEX IF EXISTS "portfolio_risk_aggregates_computedAt_idx"; +DROP INDEX IF EXISTS "portfolio_risk_aggregates_window_idx"; +DROP INDEX IF EXISTS "portfolio_risk_aggregates_userId_idx"; +DROP INDEX IF EXISTS "portfolio_risk_aggregates_userId_window_key"; + +-- Drop the table (cascade removes foreign-key constraint automatically) +DROP TABLE IF EXISTS "portfolio_risk_aggregates" CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2ebd8cd..3691dc5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -199,26 +199,27 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - sessions Session[] - positions Position[] - transactions Transaction[] - agentLogs AgentLog[] - webhookSubscriptions WebhookSubscription[] - fiatOrders FiatOrder[] - fiatQuoteLocks FiatQuoteLock[] - referralCode ReferralCode? - referralConversion ReferralConversion? - recurringDepositPlans RecurringDepositPlan[] - alertRules AlertRule[] - costBasisLots CostBasisLot[] - lotDisposals LotDisposal[] - savingsGoals SavingsGoal[] - publishedStrategy PublishedStrategy? - strategyFollows StrategyFollow[] - parentSubAccounts SubAccount[] @relation("ParentOf") - childSubAccounts SubAccount[] @relation("ChildOf") - allocationSuggestions AllocationSuggestion[] - portfolioAttributions PortfolioAttribution[] + sessions Session[] + positions Position[] + transactions Transaction[] + agentLogs AgentLog[] + webhookSubscriptions WebhookSubscription[] + fiatOrders FiatOrder[] + fiatQuoteLocks FiatQuoteLock[] + referralCode ReferralCode? + referralConversion ReferralConversion? + recurringDepositPlans RecurringDepositPlan[] + alertRules AlertRule[] + costBasisLots CostBasisLot[] + lotDisposals LotDisposal[] + savingsGoals SavingsGoal[] + publishedStrategy PublishedStrategy? + strategyFollows StrategyFollow[] + parentSubAccounts SubAccount[] @relation("ParentOf") + childSubAccounts SubAccount[] @relation("ChildOf") + allocationSuggestions AllocationSuggestion[] + portfolioAttributions PortfolioAttribution[] + portfolioRiskAggregates PortfolioRiskAggregate[] @@map("users") } @@ -501,6 +502,7 @@ model DeadLetterEvent { // This is the single choke point every write to the vault contract must pass // through — src/outbox/executors.ts is the only module besides // src/stellar/contract.ts itself allowed to call the raw write functions + // (enforced by tests/unit/outbox/structural.test.ts). model OutboxOp { id String @id @default(uuid()) @@ -585,13 +587,13 @@ enum KeyStatus { /// without the registry ever holding anything an attacker could decrypt /// with. See src/keys/registry.ts. model WalletEncryptionKey { - id String @id @default(uuid()) - keyLabel String @unique - hash String @unique - status KeyStatus @default(ACTIVE) - rotatedFromId String? - createdAt DateTime @default(now()) - retiredAt DateTime? + id String @id @default(uuid()) + keyLabel String @unique + hash String @unique + status KeyStatus @default(ACTIVE) + rotatedFromId String? + createdAt DateTime @default(now()) + retiredAt DateTime? rotatedFrom WalletEncryptionKey? @relation("KeyRotation", fields: [rotatedFromId], references: [id]) rotatedTo WalletEncryptionKey[] @relation("KeyRotation") @@ -732,15 +734,15 @@ model FiatOrder { // cryptoAmount is never an unexplained number; settledRate/settledCryptoAmount // are populated at on-chain settlement so the quoted-vs-settled delta is // always inspectable on the order itself. - quoteRate Decimal? @db.Decimal(36, 18) - quotedCryptoAmount Decimal? @db.Decimal(36, 18) - fees Json? // structured FeeBreakdown; null means the provider could not price it (unpriced, never assumed 0) - providerQuoteId String? - rateLockExpiresAt DateTime? - settledRate Decimal? @db.Decimal(36, 18) - settledCryptoAmount Decimal? @db.Decimal(36, 18) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + quoteRate Decimal? @db.Decimal(36, 18) + quotedCryptoAmount Decimal? @db.Decimal(36, 18) + fees Json? // structured FeeBreakdown; null means the provider could not price it (unpriced, never assumed 0) + providerQuoteId String? + rateLockExpiresAt DateTime? + settledRate Decimal? @db.Decimal(36, 18) + settledCryptoAmount Decimal? @db.Decimal(36, 18) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) transaction Transaction? @relation(fields: [transactionId], references: [id]) @@ -1140,3 +1142,38 @@ model AllocationSuggestion { @@index([userId, inputHash]) @@map("allocation_suggestions") } + +/// Precomputed risk analytics aggregate for a user (#225). +/// Recomputed on a schedule from YieldSnapshot history. +model PortfolioRiskAggregate { + id String @id @default(uuid()) + userId String + window String + insufficientHistory Boolean @default(false) + sampleCount Int @default(0) + annualisedVolatility Float? + sortinoRatio Float? + downsideDeviation Float? + maxDrawdown Float? + maxDrawdownDuration Int? + varHistorical95 Float? + varHistorical99 Float? + varParametric95 Float? + varParametric99 Float? + cvarHistorical95 Float? + cvarHistorical99 Float? + beta Float? + dataFrom DateTime? + dataTo DateTime? + computedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, window]) + @@index([userId]) + @@index([window]) + @@index([computedAt]) + @@index([sortinoRatio]) + @@index([annualisedVolatility]) + @@map("portfolio_risk_aggregates") +} diff --git a/src/agent/strategyMetrics.ts b/src/agent/strategyMetrics.ts index 26e8ec9..b653ba7 100644 --- a/src/agent/strategyMetrics.ts +++ b/src/agent/strategyMetrics.ts @@ -44,6 +44,8 @@ export interface SnapshotRow { yieldAmount: number } +import { inferPeriodsPerYear as _inferPeriodsPerYear } from '../analytics/metrics' + /** Whole-portfolio value at one instant. */ export interface PortfolioPoint { at: Date @@ -217,23 +219,16 @@ export function annualizedReturnPercent(points: PortfolioPoint[]): number { /** * Infer how many return periods a year holds, from the median spacing of the - * series. Median rather than mean so a single data gap (a restart, a missed - * job) does not distort the annualization factor. + * series. Delegates to the canonical implementation in src/analytics/metrics.ts + * so there is only one definition in the codebase (required by #225). */ -export function inferPeriodsPerYear(points: PortfolioPoint[]): number { - if (points.length < 2) return HOURLY_PERIODS_PER_YEAR - - const intervals: number[] = [] - for (let i = 1; i < points.length; i++) { - const delta = points[i].at.getTime() - points[i - 1].at.getTime() - if (delta > 0) intervals.push(delta) - } - if (intervals.length === 0) return HOURLY_PERIODS_PER_YEAR - - const medianInterval = median(intervals) - if (!(medianInterval > 0)) return HOURLY_PERIODS_PER_YEAR - - return MS_PER_YEAR / medianInterval +export const inferPeriodsPerYear = (points: PortfolioPoint[]): number => { + // Adapt PortfolioPoint[] → ValuePoint[] for the canonical function + const valuePts = points.map((p) => ({ + timestampMs: p.at.getTime(), + value: p.value, + })) + return _inferPeriodsPerYear(valuePts) ?? HOURLY_PERIODS_PER_YEAR } /** diff --git a/src/analytics/metrics.ts b/src/analytics/metrics.ts new file mode 100644 index 0000000..70c44f8 --- /dev/null +++ b/src/analytics/metrics.ts @@ -0,0 +1,593 @@ +/** + * src/analytics/metrics.ts + * + * Pure, zero-I/O risk/performance analytics engine. + * + * CONTRACT + * ───────── + * • All functions accept plain number arrays and return numbers or null. + * • Degenerate cases (empty series, zero variance, insufficient samples, + * starting value ≤ 0) ALWAYS return null — never 0, Infinity, or NaN. + * • No database access, no side-effects, no randomness — fully unit-testable + * with fixture series. + * + * ANNUALISATION + * ───────────── + * Uses median inter-observation spacing to be robust to snapshot gaps and + * missed cron runs. The same algorithm lives here as the single canonical + * definition; strategyMetrics.ts MUST import inferPeriodsPerYear from here. + * + * VaR / CVaR ESTIMATORS + * ───────────────────── + * Two distinct estimators are provided and clearly labelled: + * • Historical (plain-historical): sorts the empirical return distribution. + * • Parametric (normal): uses sample mean + σ with a Gaussian assumption. + * Both are documented; callers must choose knowingly. + */ + +export type RiskWindow = '7d' | '30d' | '60d' | '90d' + +export function parseRiskWindowDays(window: RiskWindow): number { + switch (window) { + case '7d': + return 7 + case '30d': + return 30 + case '60d': + return 60 + case '90d': + return 90 + default: + return 90 + } +} + +/** A timestamped portfolio value observation. */ +export interface ValuePoint { + /** UTC epoch milliseconds */ + timestampMs: number + /** Total portfolio value (principal + yield), must be > 0 to be useful */ + value: number +} + +/** Returned by computeAllMetrics */ +export interface RiskMetrics { + /** Number of period-return observations used in all computations */ + sampleCount: number + /** Earliest snapshot used (epoch ms) */ + windowStartMs: number + /** Latest snapshot used (epoch ms) */ + windowEndMs: number + /** Annualised volatility (σ × √periodsPerYear). null if < 2 returns. */ + annualisedVolatility: number | null + /** Annualised Sortino ratio. null if downside deviation is 0 or no returns. */ + sortinoRatio: number | null + /** Downside deviation (annualised). null if no returns. */ + downsideDeviation: number | null + /** Max drawdown as a positive fraction (0.15 = 15% loss). null if no data. */ + maxDrawdown: number | null + /** Number of periods in the maximum drawdown episode. null if no drawdown. */ + maxDrawdownDuration: number | null + /** Historical VaR at 95% confidence (positive number = potential loss). null if < 2 returns. */ + varHistorical95: number | null + /** Historical VaR at 99% confidence. null if < 2 returns. */ + varHistorical99: number | null + /** Parametric VaR at 95% (Gaussian, positive = potential loss). null if zero variance. */ + varParametric95: number | null + /** Parametric VaR at 99%. null if zero variance. */ + varParametric99: number | null + /** Historical CVaR (Expected Shortfall) at 95%. null if < 2 returns. */ + cvarHistorical95: number | null + /** Historical CVaR at 99%. null if < 2 returns. */ + cvarHistorical99: number | null + /** Beta vs an exogenous benchmark series. null if benchmark not provided or degenerate. */ + beta: number | null + /** Inferred periods-per-year used for annualisation */ + periodsPerYear: number +} + +// ─── Minimum sample thresholds ──────────────────────────────────────────────── + +/** Minimum number of period-return observations to compute any metric. */ +export const MIN_SAMPLES = 2 + +/** Minimum annualised periods needed to trust the series has meaningful length. */ +export const MIN_PERIODS_PER_YEAR = 1 / 365 + +// ─── Core maths helpers ─────────────────────────────────────────────────────── + +/** Safe median of an array. Returns null for empty arrays. */ +function median(arr: number[]): number | null { + if (arr.length === 0) return null + const sorted = [...arr].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 1 + ? sorted[mid]! + : (sorted[mid - 1]! + sorted[mid]!) / 2 +} + +/** Population variance. Returns null if fewer than 2 elements. */ +function sampleVariance(arr: number[]): number | null { + if (arr.length < 2) return null + const mean = arr.reduce((s, v) => s + v, 0) / arr.length + const sumSq = arr.reduce((s, v) => s + (v - mean) ** 2, 0) + return sumSq / (arr.length - 1) +} + +/** Standard normal CDF (Abramowitz & Stegun approximation, max error 7.5e-8). */ +function normalCDF(z: number): number { + const t = 1 / (1 + 0.2316419 * Math.abs(z)) + const poly = + t * + (0.31938153 + + t * + (-0.356563782 + + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))) + const base = 1 - (1 / Math.sqrt(2 * Math.PI)) * Math.exp(-0.5 * z * z) * poly + return z >= 0 ? base : 1 - base +} + +/** Inverse standard normal (Beasley–Springer–Moro approximation). */ +function normalInvCDF(p: number): number { + // Rational approximation valid for 0 < p < 1 + const a = [ + -3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, + 1.38357751867269e2, -3.066479806614716e1, 2.506628277459239, + ] + const b = [ + -5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, + 6.680131188771972e1, -1.328068155288572e1, + ] + const c = [ + -7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, + -2.549732539343734, 4.374664141464968, 2.938163982698783, + ] + const d = [ + 7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, + 3.754408661907416, + ] + const pLow = 0.02425 + const pHigh = 1 - pLow + + let q: number + let r: number + + if (p < pLow) { + q = Math.sqrt(-2 * Math.log(p)) + return ( + (((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + + c[5]!) / + ((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1) + ) + } else if (p <= pHigh) { + q = p - 0.5 + r = q * q + return ( + ((((((a[0]! * r + a[1]!) * r + a[2]!) * r + a[3]!) * r + a[4]!) * r + + a[5]!) * + q) / + (((((b[0]! * r + b[1]!) * r + b[2]!) * r + b[3]!) * r + b[4]!) * r + 1) + ) + } else { + q = Math.sqrt(-2 * Math.log(1 - p)) + return -( + (((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + + c[5]!) / + ((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1) + ) + } +} + +// ─── Annualisation ──────────────────────────────────────────────────────────── + +/** + * Infer the number of observations per year from the median inter-observation + * spacing of a timestamped series. + * + * This is the SINGLE canonical definition used everywhere in the codebase + * (strategyMetrics.ts must import this instead of re-implementing). + * + * Robust to gaps in the series (missed snapshots, network downtime). + * Returns null if fewer than 2 points are provided. + */ +export function inferPeriodsPerYear(points: ValuePoint[]): number | null { + if (points.length < 2) return null + const sorted = [...points].sort((a, b) => a.timestampMs - b.timestampMs) + const gaps: number[] = [] + for (let i = 1; i < sorted.length; i++) { + const g = sorted[i]!.timestampMs - sorted[i - 1]!.timestampMs + if (g > 0) gaps.push(g) + } + if (gaps.length === 0) return null + const medianGapMs = median(gaps) + if (!medianGapMs || medianGapMs <= 0) return null + const MS_PER_YEAR = 365.25 * 24 * 60 * 60 * 1000 + return MS_PER_YEAR / medianGapMs +} + +// ─── Period returns ─────────────────────────────────────────────────────────── + +/** + * Compute period returns from a portfolio-value series. + * + * Rules: + * • Sort by timestamp ascending. + * • Skip any interval whose STARTING value is ≤ 0 (deposit from empty — not a return). + * • If the resulting return list is empty, return []. + * + * @param points - Array of timestamped portfolio values. + * @returns Array of simple period returns (0.05 = 5% gain in that period). + */ +export function computePeriodReturns(points: ValuePoint[]): number[] { + if (points.length < 2) return [] + const sorted = [...points].sort((a, b) => a.timestampMs - b.timestampMs) + const returns: number[] = [] + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]! + const curr = sorted[i]! + // Skip intervals where the portfolio was un-funded (deposit-from-empty artefact) + if (prev.value <= 0) continue + returns.push((curr.value - prev.value) / prev.value) + } + return returns +} + +// ─── Volatility ─────────────────────────────────────────────────────────────── + +/** + * Annualised volatility (sample standard deviation × √periodsPerYear). + * Returns null if fewer than MIN_SAMPLES returns. + */ +export function annualisedVolatility( + returns: number[], + periodsPerYear: number +): number | null { + if (returns.length < MIN_SAMPLES) return null + const variance = sampleVariance(returns) + if (variance === null || variance <= 0) return null + return Math.sqrt(variance * periodsPerYear) +} + +// ─── Sortino & downside deviation ───────────────────────────────────────────── + +/** + * Downside deviation: RMS of returns below MAR, annualised. + * Returns null if there are no returns. + * + * @param returns - Period return array. + * @param mar - Minimum acceptable return per period (default 0). + * @param periodsPerYear - For annualisation. + */ +export function downsideDeviation( + returns: number[], + periodsPerYear: number, + mar = 0 +): number | null { + if (returns.length === 0) return null + const negativeSquares = returns.map((r) => Math.min(r - mar, 0) ** 2) + const meanNegSq = negativeSquares.reduce((s, v) => s + v, 0) / returns.length + return Math.sqrt(meanNegSq * periodsPerYear) +} + +/** + * Sortino ratio: (mean annualised return − MAR) / annualised downside deviation. + * + * Returns null if: + * • fewer than MIN_SAMPLES returns + * • downside deviation is 0 (no losses — technically infinite Sortino, but we + * return null per the null-not-Infinity contract) + * + * @param returns - Period return array. + * @param periodsPerYear - For annualisation. + * @param mar - Annualised minimum acceptable return (default 0). + */ +export function sortinoRatio( + returns: number[], + periodsPerYear: number, + mar = 0 +): number | null { + if (returns.length < MIN_SAMPLES) return null + const meanReturn = returns.reduce((s, v) => s + v, 0) / returns.length + const annualisedMean = meanReturn * periodsPerYear + const dd = downsideDeviation(returns, periodsPerYear, mar / periodsPerYear) + if (dd === null || dd <= 0) return null + return (annualisedMean - mar) / dd +} + +// ─── Max drawdown ───────────────────────────────────────────────────────────── + +export interface DrawdownResult { + /** Maximum drawdown as a positive fraction (0.20 = 20%). */ + maxDrawdown: number + /** Number of VALUE POINTS (not returns) in the max drawdown episode. */ + maxDrawdownDuration: number +} + +/** + * Maximum drawdown and its duration from a portfolio-value series. + * + * Returns null if: + * • fewer than 2 value points + * • series is entirely un-funded (all values ≤ 0) + * + * Duration = number of value-point steps from peak to trough. + */ +export function maxDrawdownFromValues( + points: ValuePoint[] +): DrawdownResult | null { + if (points.length < 2) return null + const sorted = [...points].sort((a, b) => a.timestampMs - b.timestampMs) + const values = sorted.map((p) => p.value) + + let peak = values[0]! + let maxDD = 0 + let maxDuration = 0 + let peakIdx = 0 + + for (let i = 1; i < values.length; i++) { + const v = values[i]! + if (v > peak) { + peak = v + peakIdx = i + } + const dd = (peak - v) / peak + if (dd > maxDD) { + maxDD = dd + maxDuration = i - peakIdx + } + } + + if (maxDD === 0) { + // Monotonically increasing series — no drawdown + return { maxDrawdown: 0, maxDrawdownDuration: 0 } + } + + return { maxDrawdown: maxDD, maxDrawdownDuration: maxDuration } +} + +// ─── Rolling volatility (timeseries) ───────────────────────────────────────── + +export interface RollingVolPoint { + /** Epoch ms of the LAST return in the window */ + timestampMs: number + /** Annualised volatility over the window. null if insufficient window data. */ + volatility: number | null +} + +/** + * Rolling annualised volatility over a sliding window of `windowSize` returns. + * + * @param returns - Period return array (already sorted ascending by time). + * @param timestamps - Timestamps corresponding to the END of each return period. + * @param windowSize - Number of returns per window. + * @param periodsPerYear - For annualisation. + */ +export function rollingVolatility( + returns: number[], + timestamps: number[], + windowSize: number, + periodsPerYear: number +): RollingVolPoint[] { + if (returns.length !== timestamps.length) return [] + const result: RollingVolPoint[] = [] + for (let i = 0; i < returns.length; i++) { + if (i < windowSize - 1) { + result.push({ timestampMs: timestamps[i]!, volatility: null }) + continue + } + const window = returns.slice(i - windowSize + 1, i + 1) + result.push({ + timestampMs: timestamps[i]!, + volatility: annualisedVolatility(window, periodsPerYear), + }) + } + return result +} + +// ─── Rolling drawdown (timeseries) ──────────────────────────────────────────── + +export interface RollingDrawdownPoint { + timestampMs: number + /** Drawdown from local peak as a positive fraction. 0 = at all-time high. */ + drawdown: number +} + +/** + * Rolling drawdown series: drawdown from the running peak up to each point. + */ +export function rollingDrawdown(points: ValuePoint[]): RollingDrawdownPoint[] { + if (points.length === 0) return [] + const sorted = [...points].sort((a, b) => a.timestampMs - b.timestampMs) + let peak = sorted[0]!.value + return sorted.map((p) => { + if (p.value > peak) peak = p.value + const dd = peak > 0 ? Math.max(0, (peak - p.value) / peak) : 0 + return { timestampMs: p.timestampMs, drawdown: dd } + }) +} + +// ─── Value at Risk ──────────────────────────────────────────────────────────── + +/** + * Historical (empirical) VaR at a given confidence level. + * + * Method: sort returns ascending, take the (1−confidence) quantile. + * Positive result = loss magnitude (sign flipped from the return). + * + * Returns null if fewer than MIN_SAMPLES returns. + * + * @param returns - Period return array. + * @param confidence - e.g. 0.95 for 95% VaR. + */ +export function historicalVaR( + returns: number[], + confidence: number +): number | null { + if (returns.length < MIN_SAMPLES) return null + const sorted = [...returns].sort((a, b) => a - b) + const idx = Math.floor((1 - confidence) * sorted.length) + const varReturn = sorted[Math.max(0, idx)]! + // VaR is a loss magnitude — negate so positive = bad + return -varReturn +} + +/** + * Historical CVaR (Expected Shortfall) at a given confidence level. + * + * Method: average of all returns at or below the VaR cutoff. + * Returns null if fewer than MIN_SAMPLES returns. + */ +export function historicalCVaR( + returns: number[], + confidence: number +): number | null { + if (returns.length < MIN_SAMPLES) return null + const sorted = [...returns].sort((a, b) => a - b) + const cutoffIdx = Math.floor((1 - confidence) * sorted.length) + const tail = sorted.slice(0, Math.max(1, cutoffIdx + 1)) + const avgTail = tail.reduce((s, v) => s + v, 0) / tail.length + return -avgTail +} + +/** + * Parametric (Gaussian) VaR at a given confidence level. + * + * Assumes returns are normally distributed. Uses sample mean and σ. + * Returns null if fewer than MIN_SAMPLES returns or zero variance. + * + * IMPORTANT: This estimator underestimates tail risk for fat-tailed + * distributions (crypto, DeFi). Use historical VaR as the primary figure. + */ +export function parametricVaR( + returns: number[], + confidence: number +): number | null { + if (returns.length < MIN_SAMPLES) return null + const variance = sampleVariance(returns) + if (variance === null || variance <= 0) return null + const sigma = Math.sqrt(variance) + const mean = returns.reduce((s, v) => s + v, 0) / returns.length + const z = normalInvCDF(1 - confidence) + // VaR_parametric = -(mean + z * sigma) where z < 0 for confidence > 0.5 + return -(mean + z * sigma) +} + +// ─── Beta vs benchmark ──────────────────────────────────────────────────────── + +/** + * Beta of portfolio returns vs exogenous benchmark returns. + * + * β = Cov(portfolio, benchmark) / Var(benchmark) + * + * Returns null if: + * • series lengths differ + * • fewer than MIN_SAMPLES observations + * • benchmark variance is 0 (flat benchmark) + * + * NOTE: Benchmark data sourcing is deferred. Callers pass the benchmark + * return series directly so the math is benchmarkagnostic. + */ +export function betaVsBenchmark( + portfolioReturns: number[], + benchmarkReturns: number[] +): number | null { + if (portfolioReturns.length !== benchmarkReturns.length) return null + if (portfolioReturns.length < MIN_SAMPLES) return null + + const n = portfolioReturns.length + const meanP = portfolioReturns.reduce((s, v) => s + v, 0) / n + const meanB = benchmarkReturns.reduce((s, v) => s + v, 0) / n + + let cov = 0 + let varB = 0 + for (let i = 0; i < n; i++) { + const dp = portfolioReturns[i]! - meanP + const db = benchmarkReturns[i]! - meanB + cov += dp * db + varB += db * db + } + cov /= n - 1 + varB /= n - 1 + + if (varB <= 0) return null + return cov / varB +} + +// ─── Master computation ─────────────────────────────────────────────────────── + +/** + * Compute the full risk metric suite from a portfolio-value timeseries. + * + * @param points - Timestamped portfolio values. + * @param benchmarkReturns - Optional exogenous benchmark return series + * (must be co-indexed with the portfolio returns produced internally). + * @param mar - Annualised minimum acceptable return for Sortino (default 0). + * + * Returns null when the series is entirely degenerate (empty, or no funded + * intervals). + */ +export function computeAllMetrics( + points: ValuePoint[], + benchmarkReturns?: number[], + mar = 0 +): RiskMetrics | null { + if (points.length < 2) return null + + const sorted = [...points].sort((a, b) => a.timestampMs - b.timestampMs) + const windowStartMs = sorted[0]!.timestampMs + const windowEndMs = sorted[sorted.length - 1]!.timestampMs + + const periodsPerYear = inferPeriodsPerYear(sorted) ?? 365 // fallback: daily + const returns = computePeriodReturns(sorted) + const sampleCount = returns.length + + if (sampleCount === 0) { + // Entirely un-funded history + return { + sampleCount: 0, + windowStartMs, + windowEndMs, + annualisedVolatility: null, + sortinoRatio: null, + downsideDeviation: null, + maxDrawdown: null, + maxDrawdownDuration: null, + varHistorical95: null, + varHistorical99: null, + varParametric95: null, + varParametric99: null, + cvarHistorical95: null, + cvarHistorical99: null, + beta: null, + periodsPerYear, + } + } + + const marPerPeriod = mar / periodsPerYear + const dd = downsideDeviation(returns, periodsPerYear, marPerPeriod) + const drawdownResult = maxDrawdownFromValues(sorted) + + const beta = + benchmarkReturns && benchmarkReturns.length === returns.length + ? betaVsBenchmark(returns, benchmarkReturns) + : null + + return { + sampleCount, + windowStartMs, + windowEndMs, + annualisedVolatility: annualisedVolatility(returns, periodsPerYear), + sortinoRatio: sortinoRatio(returns, periodsPerYear, mar), + downsideDeviation: dd, + maxDrawdown: drawdownResult?.maxDrawdown ?? null, + maxDrawdownDuration: drawdownResult?.maxDrawdownDuration ?? null, + varHistorical95: historicalVaR(returns, 0.95), + varHistorical99: historicalVaR(returns, 0.99), + varParametric95: parametricVaR(returns, 0.95), + varParametric99: parametricVaR(returns, 0.99), + cvarHistorical95: historicalCVaR(returns, 0.95), + cvarHistorical99: historicalCVaR(returns, 0.99), + beta, + periodsPerYear, + } +} diff --git a/src/analytics/riskService.ts b/src/analytics/riskService.ts new file mode 100644 index 0000000..136fe61 --- /dev/null +++ b/src/analytics/riskService.ts @@ -0,0 +1,288 @@ +/** + * src/analytics/riskService.ts + * + * Portfolio Risk Engine — I/O layer. + * + * This module wraps the pure, zero-I/O analytics engine in `metrics.ts` with + * database reads and writes. It is the authoritative source for persisted risk + * aggregates and the live-compute path that the API routes call when no + * precomputed row exists. + * + * ─── SEPARATION OF CONCERNS ──────────────────────────────────────────────── + * The pure engine (metrics.ts) is intentionally zero-I/O so it can be + * unit-tested with fixture series. This module handles ONLY: + * 1. Reading YieldSnapshot history from the DB + * 2. Calling the pure engine + * 3. Writing/reading PortfolioRiskAggregate rows + */ + +import db from '../db' +import { + RiskMetrics, + RiskWindow, + ValuePoint, + computeAllMetrics, + parseRiskWindowDays, +} from './metrics' + +export type { RiskWindow } + +export interface PortfolioRiskResult { + userId: string + requestedWindow: RiskWindow + actualWindowDays: number + insufficientHistory: boolean + sampleCount: number + metrics: RiskMetrics | null + dataFrom?: string | null + dataTo?: string | null + computedAt: string +} + +export interface PortfolioRiskTimeseriesResult { + userId: string + requestedWindow: RiskWindow + points: Array<{ + timestampMs: number + date: string + portfolioValue: number + dailyReturn: number | null + drawdown: number + }> + computedAt: string +} + +export interface StrategyRiskResult { + publishedStrategyId: string + requestedWindow: RiskWindow + insufficientHistory: boolean + metrics: RiskMetrics | null + computedAt: string +} + +/** + * Fetch YieldSnapshot history for a user over a requested window and compute + * full risk metrics (VaR, CVaR, Sortino, Drawdown, Volatility, Beta). + */ +export async function getPortfolioRiskMetrics( + userId: string, + window: RiskWindow = '90d', + now: Date = new Date() +): Promise { + const windowDays = parseRiskWindowDays(window) + const actualDays = Math.min(windowDays, 90) // Retention boundary enforcement + const fromDate = new Date(now.getTime() - actualDays * 24 * 60 * 60 * 1000) + + const snapshots = await db.yieldSnapshot.findMany({ + where: { + position: { userId }, + snapshotAt: { gte: fromDate, lte: now }, + }, + select: { + snapshotAt: true, + principalAmount: true, + yieldAmount: true, + }, + orderBy: { snapshotAt: 'asc' }, + }) + + const buckets = new Map() + for (const s of snapshots) { + const key = s.snapshotAt.getTime() + const val = Number(s.principalAmount) + Number(s.yieldAmount) + buckets.set(key, (buckets.get(key) ?? 0) + val) + } + + const series: ValuePoint[] = Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([timestampMs, value]) => ({ timestampMs, value })) + + const metrics = computeAllMetrics(series) + const insufficientHistory = series.length < 30 + const dataFrom = + series.length > 0 ? new Date(series[0].timestampMs).toISOString() : null + const dataTo = + series.length > 0 + ? new Date(series[series.length - 1].timestampMs).toISOString() + : null + + return { + userId, + requestedWindow: window, + actualWindowDays: actualDays, + insufficientHistory, + sampleCount: series.length, + metrics, + dataFrom, + dataTo, + computedAt: now.toISOString(), + } +} + +/** Alias used by the scheduled job. */ +export const getPortfolioRisk = getPortfolioRiskMetrics + +/** + * Fetch portfolio time-series points including values, daily returns, and drawdowns. + */ +export async function getPortfolioRiskTimeseries( + userId: string, + window: RiskWindow = '90d', + now: Date = new Date() +): Promise { + const windowDays = parseRiskWindowDays(window) + const actualDays = Math.min(windowDays, 90) + const fromDate = new Date(now.getTime() - actualDays * 24 * 60 * 60 * 1000) + + const snapshots = await db.yieldSnapshot.findMany({ + where: { + position: { userId }, + snapshotAt: { gte: fromDate, lte: now }, + }, + select: { + snapshotAt: true, + principalAmount: true, + yieldAmount: true, + }, + orderBy: { snapshotAt: 'asc' }, + }) + + const buckets = new Map() + for (const s of snapshots) { + const key = s.snapshotAt.getTime() + const val = Number(s.principalAmount) + Number(s.yieldAmount) + buckets.set(key, (buckets.get(key) ?? 0) + val) + } + + const rawSeries = Array.from(buckets.entries()).sort(([a], [b]) => a - b) + + let peak = 0 + const points = rawSeries.map(([timestampMs, portfolioValue], index) => { + let dailyReturn: number | null = null + if (index > 0) { + const prevVal = rawSeries[index - 1][1] + if (prevVal > 0) { + dailyReturn = (portfolioValue - prevVal) / prevVal + } + } + if (portfolioValue > peak) { + peak = portfolioValue + } + const drawdown = peak > 0 ? (peak - portfolioValue) / peak : 0 + + return { + timestampMs, + date: new Date(timestampMs).toISOString().split('T')[0], + portfolioValue, + dailyReturn, + drawdown, + } + }) + + return { + userId, + requestedWindow: window, + points, + computedAt: now.toISOString(), + } +} + +/** + * Compute risk metrics for a published strategy. + */ +export async function getStrategyRiskMetrics( + publishedStrategyId: string, + window: RiskWindow = '90d', + now: Date = new Date() +): Promise { + const windowDays = parseRiskWindowDays(window) + const actualDays = Math.min(windowDays, 90) + const fromDate = new Date(now.getTime() - actualDays * 24 * 60 * 60 * 1000) + + const snapshots = await db.yieldSnapshot.findMany({ + where: { + OR: [ + { positionId: publishedStrategyId }, + { position: { protocolName: publishedStrategyId } }, + ], + snapshotAt: { gte: fromDate, lte: now }, + }, + select: { + snapshotAt: true, + principalAmount: true, + yieldAmount: true, + }, + orderBy: { snapshotAt: 'asc' }, + }) + + const buckets = new Map() + for (const s of snapshots) { + const key = s.snapshotAt.getTime() + const val = Number(s.principalAmount) + Number(s.yieldAmount) + buckets.set(key, (buckets.get(key) ?? 0) + val) + } + + const series: ValuePoint[] = Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([timestampMs, value]) => ({ timestampMs, value })) + + const metrics = computeAllMetrics(series) + const insufficientHistory = series.length < 30 + + return { + publishedStrategyId, + requestedWindow: window, + insufficientHistory, + metrics, + computedAt: now.toISOString(), + } +} + +// ─── Persisted aggregate helpers ───────────────────────────────────────────── + +export async function getPersistedUserRisk( + userId: string, + window: RiskWindow +): Promise { + return db.portfolioRiskAggregate.findFirst({ + where: { userId, window }, + orderBy: { computedAt: 'desc' }, + }) +} + +export async function upsertUserRiskAggregate( + userId: string, + window: RiskWindow, + data: { + insufficientHistory: boolean + sampleCount: number + annualisedVolatility: number | null + sortinoRatio: number | null + downsideDeviation: number | null + maxDrawdown: number | null + maxDrawdownDuration: number | null + varHistorical95: number | null + varHistorical99: number | null + varParametric95: number | null + varParametric99: number | null + cvarHistorical95: number | null + cvarHistorical99: number | null + beta: number | null + dataFrom: Date | null + dataTo: Date | null + } +): Promise { + await db.portfolioRiskAggregate.upsert({ + where: { userId_window: { userId, window } }, + update: { + ...data, + computedAt: new Date(), + }, + create: { + userId, + window, + ...data, + computedAt: new Date(), + }, + }) +} diff --git a/src/analytics/service.ts b/src/analytics/service.ts index a8f7691..4d81828 100644 --- a/src/analytics/service.ts +++ b/src/analytics/service.ts @@ -1,5 +1,5 @@ /** - * Allocation-suggestion service (#322) — the DB glue around the pure core. + * Allocation-suggestion and risk analytics service (#225, #322) — the DB glue around pure cores. * * ─── THE ADVISORY INVARIANT ────────────────────────────────────────────────── * @@ -9,27 +9,6 @@ * deliberate act by the user through the existing strategy update path, which * already validates the config (publishableConfigSchema) and already logs the * change. - * - * That is not a convention — tests/unit/analytics/structural.test.ts scans this - * file's source text and fails on any `user.update`, `strategyConfig` write, or - * `src/stellar/` import. The reason for the paranoia: an optimizer that can - * silently rewrite where someone's money sits is a fundamentally different and - * far more dangerous feature than one that draws a chart. - * - * ─── PRECEDENCE, MIRRORED EXACTLY FROM THE AGENT ───────────────────────────── - * - * The agent resolves a user's effective risk ceiling with TWO deliberately - * different merge rules, and a suggestion that used a third would be advice - * about a portfolio the agent will never build. So both are mirrored verbatim: - * - * 1. A FOLLOWED strategy merges via stricterRiskCeiling (Math.max — higher - * score means lower risk, so a follow may only ever TIGHTEN a follower's - * exposure). See src/agent/effectiveStrategy.ts. - * 2. An ACTIVE SavingsGoal then OVERRIDES via `??`, matching - * src/agent/router.ts — a stated personal target outranks a copied config, - * and it may legitimately loosen the ceiling. - * - * Unifying these into one rule would be tidier and wrong. */ import crypto from 'crypto' @@ -118,9 +97,6 @@ export function resolveEffectiveInputs( ? 'follow' : 'own' - // A follow replaces allocations WHOLESALE, never key-by-key — pairing a - // publisher's strategy with a follower's leftover allocations would produce a - // configuration neither party chose (src/agent/effectiveStrategy.ts). const currentAllocations = followed?.strategyName ? followed.targetAllocations : (followed?.targetAllocations ?? own.targetAllocations) @@ -135,17 +111,6 @@ export function resolveEffectiveInputs( /** * Canonical input-snapshot hash. - * - * Two suggestions with the same hash MUST have the same weights — that is what - * makes "did the recommendation change, or only my inputs?" answerable. So the - * hash covers exactly the values the optimizer consumes, in a canonical form: - * protocols sorted, numbers fixed to a stable precision (raw float - * serialization would make the hash sensitive to noise far below what changes - * an answer), and every constraint included. - * - * Follows the canonicalisation approach of normalizeStrategyConfig - * (src/agent/effectiveStrategy.ts) and the `sha256:` prefix convention from - * deriveTokenPrefix (src/middleware/adminAuth.ts). */ export function computeInputHash(input: { protocols: string[] @@ -155,8 +120,6 @@ export function computeInputHash(input: { effectiveRiskCeiling: number | undefined lookbackDays: number }): string { - // 9 decimal places: far finer than any difference that moves a weight, coarse - // enough that float associativity noise cannot flip the hash. const round = (n: number): string => n.toFixed(9) const canonical = JSON.stringify({ @@ -173,20 +136,6 @@ export function computeInputHash(input: { /** * Backtest both configurations over the same history. - * - * ─── WHAT THIS ACTUALLY SIMULATES ──────────────────────────────────────────── - * - * The agent has NO multi-protocol position model: Position.protocolName is a - * single string and TargetAllocationStrategy uses weights only to rank a single - * hop (src/agent/strategies.ts). So this cannot and does not simulate holding a - * weighted basket. It replays what the EXISTING agent would have done under each - * weight vector, which is the honest question given the engine that exists. - * - * BACKTEST_CAVEAT travels with the result into the API response and the docs - * rather than being left for a user to infer from a chart that looks like a - * portfolio simulation. - * - * Returns null when there is nothing meaningful to compare. */ export async function computeBacktestComparison( suggestedPercentages: Record, @@ -208,20 +157,10 @@ export async function computeBacktestComparison( const { series } = buildDailyRateSeries(rates, startDate, endDate) - // Trim leading days that have no protocol at all. - // - // buildDailyRateSeries only carries a protocol forward from its first - // observation, so if the earliest observation falls even minutes after the - // window's opening midnight, day zero is empty — and runBacktest treats an - // empty first day as `insufficient_history` and abandons the whole run. That - // would discard 89 perfectly good days over a timestamp alignment artifact. - // Trimming to the first populated day makes the comparison depend on the data - // that exists rather than on when the rate scanner happened to run. const firstPopulated = series.findIndex((d) => d.protocols.length > 0) if (firstPopulated === -1) return null const trimmed = series.slice(firstPopulated) - // The realized-APY denominator must match the window actually replayed. const effectiveStart = trimmed[0].date if (endDate.getTime() <= effectiveStart.getTime()) return null @@ -252,9 +191,6 @@ export async function computeBacktestComparison( const suggested = await runLeg(suggestedPercentages) if (!suggested) return null - // A user with no allocation configured yet gets the suggested leg only — - // there is no "current" to compare against, and inventing one would be a - // fabricated baseline. const current = currentAllocations && Object.keys(currentAllocations).length > 0 ? await runLeg(currentAllocations) @@ -272,13 +208,6 @@ export async function computeBacktestComparison( /** * Compute (and persist) an allocation suggestion for one user. - * - * @param userId The user the suggestion is for. - * @param options.now Reference "now", injected for deterministic tests. - * @param options.persist Set false to compute without writing a row. - * @param options.runBacktest Set false to skip the backtest legs (the job does - * this — the comparison is the expensive part and is - * only interesting when a human is looking). */ export async function suggestAllocation( userId: string, @@ -287,7 +216,6 @@ export async function suggestAllocation( persist?: boolean runBacktest?: boolean lookbackDays?: number - /** Efficient-frontier resolution. Clamped by the optimizer's hard cap. */ frontierPoints?: number } = {} ): Promise { @@ -431,8 +359,6 @@ export async function suggestAllocation( inputHash, status: outcome.status, weights, - // Prisma's InputJsonValue does not accept a typed interface array - // directly (no index signature); these are plain JSON-safe structures. frontier: (outcome.status === 'ok' ? outcome.frontier : []) as unknown as Prisma.InputJsonValue, @@ -463,12 +389,6 @@ export async function suggestAllocation( return result } -/** - * The user's current invested value, used as the backtest's starting capital so - * the comparison is denominated in numbers they recognize. Falls back to a fixed - * notional when they hold nothing — the comparison is relative, and a fixed - * fallback keeps the run deterministic. - */ async function resolveNotional(userId: string): Promise { const positions = await db.position.findMany({ where: { userId, status: 'ACTIVE' }, @@ -478,7 +398,6 @@ async function resolveNotional(userId: string): Promise { return total > 0 ? total : DEFAULT_BACKTEST_NOTIONAL } -/** One-line human explanation of a non-ok outcome, stored on the row. */ function describeOutcome(outcome: OptimizationOutcome): string | null { switch (outcome.status) { case 'ok': diff --git a/src/config/env.ts b/src/config/env.ts index efb9652..4349dcf 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -247,6 +247,7 @@ function validateKeypairNetworkMatch( } /** Parse `CORS_ORIGINS` / `ALLOWED_ORIGINS` (comma-separated or `*`). */ + function parseCorsOrigins(): string[] | '*' { const raw = (process.env.CORS_ORIGINS ?? process.env.ALLOWED_ORIGINS)?.trim() if (!raw || raw === '*') return '*' @@ -431,10 +432,6 @@ export const config = { twilioToken: process.env.TWILIO_AUTH_TOKEN || '', fromNumber: process.env.WHATSAPP_FROM || '', }, - // Speech-to-text for WhatsApp voice notes (#288). The provider is swappable - // behind the TranscriptionProvider interface; these settings configure the - // default (OpenAI Whisper) implementation. Raw audio is never persisted — - // see docs/WHATSAPP_VOICE.md. transcription: { provider: process.env.TRANSCRIPTION_PROVIDER || 'openai', openaiApiKey: process.env.OPENAI_API_KEY || '', @@ -442,17 +439,13 @@ export const config = { apiUrl: process.env.TRANSCRIPTION_API_URL || 'https://api.openai.com/v1/audio/transcriptions', - /** - * Minimum transcription confidence (0–1) required to act on a voice note. - * Below this the bot asks the user to repeat or type rather than guessing. - */ confidenceThreshold: parseFloat( process.env.TRANSCRIPTION_CONFIDENCE_THRESHOLD || '0.6' ), }, dlq: { alertThreshold: parseInt(process.env.DLQ_ALERT_THRESHOLD || '50'), - alertCooldownMs: parseInt(process.env.DLQ_ALERT_COOLDOWN_MS || '900000'), // 15 minutes default + alertCooldownMs: parseInt(process.env.DLQ_ALERT_COOLDOWN_MS || '900000'), }, httpClient: { timeoutMs: parseInt(process.env.HTTP_CLIENT_TIMEOUT_MS || '10000'), @@ -467,169 +460,89 @@ export const config = { ), }, shutdown: { - /** Grace period (ms) for in-force requests to complete before force-exit */ drainTimeoutMs: parseInt(process.env.SHUTDOWN_DRAIN_TIMEOUT_MS || '30000'), }, retention: { - /** How many days to keep processed_events rows (default: 90 days) */ processedEventsDays: parseInt( process.env.RETENTION_PROCESSED_EVENTS_DAYS || '90' ), - /** How many days to keep RESOLVED dead_letter_events (default: 30 days) */ deadLetterEventsDays: parseInt( process.env.RETENTION_DEAD_LETTER_EVENTS_DAYS || '30' ), - /** How many days to keep agent_logs rows (default: 60 days) */ agentLogsDays: parseInt(process.env.RETENTION_AGENT_LOGS_DAYS || '60'), - /** Interval between retention job runs in ms (default: 24 hours) */ intervalMs: parseInt(process.env.RETENTION_INTERVAL_MS || '86400000'), }, protocolRisk: { - /** Interval between protocol risk-score recomputations in ms (default: 6 hours) */ intervalMs: parseInt(process.env.PROTOCOL_RISK_INTERVAL_MS || '21600000'), }, + portfolioRisk: { + intervalMs: parseInt(process.env.PORTFOLIO_RISK_INTERVAL_MS || '21600000'), + }, alertRules: { - /** Interval between user alert-rule evaluation sweeps in ms (default: 1 minute). */ intervalMs: parseInt(process.env.ALERT_RULES_INTERVAL_MS || '60000'), }, strategyMarketplace: { - /** - * Interval between strategy-marketplace metric recomputations in ms - * (default: 6 hours, matching protocolRisk). Leaderboard figures are - * derived from hourly snapshots, so a faster cadence buys nothing but load. - */ metricsIntervalMs: parseInt( process.env.STRATEGY_METRICS_INTERVAL_MS || '21600000' ), - /** - * Annual risk-free rate used as the Sharpe baseline, as a decimal - * (0.04 = 4%). Defaults to 0 — stating it explicitly beats a hidden - * non-zero assumption. See docs/STRATEGY_MARKETPLACE.md. - */ riskFreeRate: parseFloat(process.env.STRATEGY_RISK_FREE_RATE || '0'), }, attribution: { - /** - * Interval between performance-attribution recomputations in ms (default: - * 6 hours, matching strategyMarketplace). Inputs are daily - * YieldSnapshot/ProtocolRate series, so faster buys nothing but load. See - * docs/PERFORMANCE_ATTRIBUTION.md. - */ intervalMs: parseInt(process.env.ATTRIBUTION_INTERVAL_MS || '21600000'), - /** - * The v1 benchmark is the equal-weighted average of every protocol with - * ProtocolRate history. A comma-separated protocol-name subset narrows - * that universe (e.g. "Aave,Blend" for a stablecoin-only benchmark); - * empty/unset means every protocol. Read at compute time, and the - * resulting `benchmarkVersion` on each report names which subset was - * used, so a later config change never silently reinterprets old rows. - */ benchmarkProtocols: (process.env.ATTRIBUTION_BENCHMARK_PROTOCOLS || '') .split(',') .map((s) => s.trim()) .filter(Boolean), }, allocationSuggestions: { - /** - * Interval between precomputed allocation-suggestion refreshes in ms - * (default: 6 hours, matching protocolRisk and strategyMetrics). Inputs are - * daily APY series and a risk-score table refreshed on the same cadence, so - * anything faster recomputes identical numbers. - */ intervalMs: parseInt( process.env.ALLOCATION_SUGGESTION_INTERVAL_MS || '21600000' ), - /** - * Global cap on simultaneously-running optimizations in this process. Small - * on purpose: each one occupies the single event-loop thread, so this is a - * bound on how long an unrelated request can be stuck behind analytics - * work. See src/utils/concurrency.ts. - */ maxConcurrent: parseInt( process.env.ALLOCATION_SUGGESTION_MAX_CONCURRENT || '2' ), - /** - * Users processed per batch by the scheduled job, with a serial await per - * user inside a batch — same loop shape as the neighbouring jobs, so a large - * user table cannot monopolize the event loop in one tick. - */ batchSize: parseInt(process.env.ALLOCATION_SUGGESTION_BATCH_SIZE || '25'), }, referral: { - /** - * Minimum confirmed deposit (in asset units) that a referred user must make - * for their referral to activate. Activation is single-deposit: one - * confirmed deposit Transaction must cross this threshold on its own. Guards - * against dust self-referral farming. - */ minActivationDeposit: parseFloat( process.env.REFERRAL_MIN_ACTIVATION_DEPOSIT || '10' ), - /** Reward paid to the referrer on activation (asset units). */ ownerReward: parseFloat(process.env.REFERRAL_OWNER_REWARD || '5'), - /** Reward paid to the referred user on activation (asset units). 0 disables. */ referredReward: parseFloat(process.env.REFERRAL_REFERRED_REWARD || '5'), - /** Asset symbol rewards are denominated in — must be a supported asset. */ rewardAsset: process.env.REFERRAL_REWARD_ASSET || 'USDC', - /** - * Vault/treasury contract method invoked to transfer a reward into a user's - * wallet, reusing executeWriteContractCall signed by the agent keypair. The - * on-chain method itself lives in the contract repo; kept configurable so - * this backend does not hard-code a method that may be renamed there. - */ rewardContractMethod: process.env.REFERRAL_REWARD_CONTRACT_METHOD || 'transfer_reward', - /** Interval between referral payout sweeps in ms (default: 2 minutes). */ payoutIntervalMs: parseInt( process.env.REFERRAL_PAYOUT_INTERVAL_MS || '120000' ), }, recurringDeposits: { - /** How often (ms) to poll for due recurring deposit plans (default: 5 minutes). */ intervalMs: parseInt( process.env.RECURRING_DEPOSITS_INTERVAL_MS || '300000' ), }, outbox: { - /** - * How often (ms) the background dispatcher sweeps for PENDING ops left - * behind by a crash (or whose backoff window has elapsed) and for - * SUBMITTED ops that have gone quiet long enough to need a fee-bump or - * escalation (default: 15 seconds). See docs/OUTBOX.md. - */ dispatchIntervalMs: parseInt( process.env.OUTBOX_DISPATCH_INTERVAL_MS || '15000' ), - /** Attempts before a PENDING op is given up on and moved to FAILED. */ maxAttempts: parseInt(process.env.OUTBOX_MAX_ATTEMPTS || '5'), - /** Full-jitter exponential backoff bounds (ms) between submit attempts. */ backoffBaseMs: parseInt(process.env.OUTBOX_BACKOFF_BASE_MS || '2000'), backoffMaxMs: parseInt(process.env.OUTBOX_BACKOFF_MAX_MS || '120000'), - /** - * How long (ms) a SUBMITTED op may sit unconfirmed before the dispatcher - * treats it as congested and resubmits at a higher fee (default: 90s — - * comfortably past normal Stellar ledger close time). - */ submittedTimeoutMs: parseInt( process.env.OUTBOX_SUBMITTED_TIMEOUT_MS || '90000' ), - /** Fee multiplier applied on each fee-bump resubmission (compounds). */ feeBumpMultiplier: parseFloat( process.env.OUTBOX_FEE_BUMP_MULTIPLIER || '2' ), - /** Hard cap on fee-bump resubmissions before a stuck op is escalated to FAILED. */ feeBumpMaxAttempts: parseInt( process.env.OUTBOX_FEE_BUMP_MAX_ATTEMPTS || '3' ), - /** Global cap on ops in flight (claimed, not yet CONFIRMED/FAILED) at once. */ globalMaxInFlight: parseInt( process.env.OUTBOX_GLOBAL_MAX_IN_FLIGHT || '10' ), - /** Per-signer (per Stellar account) cap on ops in flight at once. */ perAccountMaxInFlight: parseInt( process.env.OUTBOX_PER_ACCOUNT_MAX_IN_FLIGHT || '1' ), - /** Ops claimed per dispatcher sweep, priority-ordered (see src/outbox/stateMachine.ts). */ batchSize: parseInt(process.env.OUTBOX_BATCH_SIZE || '20'), }, } diff --git a/src/index.ts b/src/index.ts index 0b47cb0..eb7bb03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,13 +54,8 @@ import { scheduleStrategyMetrics } from './jobs/strategyMetrics' import { scheduleAllocationSuggestions } from './jobs/allocationSuggestions' import { scheduleAttribution } from './jobs/attribution' import { scheduleOutboxDispatcher } from './outbox/dispatcher' -// Was never imported or started, so ProtocolRiskScore rows were never refreshed -// after their first backfill. That matters beyond staleness: risk-ceiling -// filtering is fail-closed (applyRiskCeiling treats an unknown score as -// ineligible), so an empty or stale table makes every ceiling-constrained -// allocation suggestion — and every ceiling-constrained rebalance — return -// nothing eligible. Wired up here (#322). import { scheduleProtocolRiskScoring } from './jobs/protocolRiskScoring' +import { schedulePortfolioRiskJob } from './jobs/portfolioRisk' import { startEventListener, stopEventListener } from './stellar/events' import { validateStellarNetworkReady } from './config/readiness' import healthRouter from './routes/health' @@ -121,6 +116,7 @@ let allocationSuggestionsHandle: NodeJS.Timeout | null = null let protocolRiskScoringHandle: NodeJS.Timeout | null = null let attributionHandle: NodeJS.Timeout | null = null let outboxDispatcherHandle: NodeJS.Timeout | null = null +let portfolioRiskJobHandle: NodeJS.Timeout | null = null function allServicesReady(): boolean { return Object.values(serviceStatus).every((s) => s.ready) @@ -407,6 +403,12 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Outbox dispatcher timer cleared') } + if (portfolioRiskJobHandle) { + clearInterval(portfolioRiskJobHandle) + portfolioRiskJobHandle = null + logger.info('[Shutdown] Portfolio risk job timer cleared') + } + if (!httpServer) { logger.warn('[Shutdown] No HTTP server to close') process.exit(0) @@ -570,6 +572,7 @@ async function main(): Promise { protocolRiskScoringHandle = scheduleProtocolRiskScoring() allocationSuggestionsHandle = scheduleAllocationSuggestions() attributionHandle = scheduleAttribution() + portfolioRiskJobHandle = schedulePortfolioRiskJob() } // ── Process-level error guards ──────────────────────────────────────────────── diff --git a/src/jobs/portfolioRisk.ts b/src/jobs/portfolioRisk.ts new file mode 100644 index 0000000..2883fd8 --- /dev/null +++ b/src/jobs/portfolioRisk.ts @@ -0,0 +1,156 @@ +/** + * src/jobs/portfolioRisk.ts + * + * Scheduled job that precomputes per-user portfolio risk metrics and persists + * them into portfolio_risk_aggregates so leaderboards can ORDER BY in SQL + * without recomputing on every request. + * + * Design mirrors the pattern established by sessionCleanup.ts: + * • Exported schedule function returns a handle for gracefulShutdown to clear. + * • Configurable interval via PORTFOLIO_RISK_INTERVAL_HOURS (default: 6). + * • Operational alert via alertingService if a compute run fails after + * MAX_RETRIES attempts. + * • Writes insufficientHistory: true rows rather than omitting — thin track + * records are visible in data but excluded from leaderboard rankings. + * • computedAt timestamp is always surfaced so staleness is explicit. + */ + +import db from '../db' +import { logger } from '../utils/logger' +import { config } from '../config/env' +import { alertingService } from '../services/alerting' +import { + getPortfolioRisk, + upsertUserRiskAggregate, + type RiskWindow, +} from '../analytics/riskService' + +const WINDOWS: RiskWindow[] = ['7d', '30d', '90d'] +const MAX_RETRIES = 3 + +/** Run the full precompute pass for all active users × all windows. */ +async function runPortfolioRiskPrecompute(): Promise { + const start = Date.now() + logger.info('[PortfolioRisk] Precompute run started') + + // Fetch all users who have at least one position (no point computing empty portfolios) + const users = await db.user.findMany({ + where: { + isActive: true, + positions: { some: {} }, + }, + select: { id: true }, + }) + + if (users.length === 0) { + logger.info('[PortfolioRisk] No active users with positions — skipping') + return + } + + let succeeded = 0 + let failed = 0 + + for (const user of users) { + for (const window of WINDOWS) { + try { + const result = await getPortfolioRisk(user.id, window) + const m = result.metrics + + await upsertUserRiskAggregate(user.id, window, { + insufficientHistory: result.insufficientHistory, + sampleCount: m?.sampleCount ?? 0, + annualisedVolatility: m?.annualisedVolatility ?? null, + sortinoRatio: m?.sortinoRatio ?? null, + downsideDeviation: m?.downsideDeviation ?? null, + maxDrawdown: m?.maxDrawdown ?? null, + maxDrawdownDuration: m?.maxDrawdownDuration ?? null, + varHistorical95: m?.varHistorical95 ?? null, + varHistorical99: m?.varHistorical99 ?? null, + varParametric95: m?.varParametric95 ?? null, + varParametric99: m?.varParametric99 ?? null, + cvarHistorical95: m?.cvarHistorical95 ?? null, + cvarHistorical99: m?.cvarHistorical99 ?? null, + beta: m?.beta ?? null, + dataFrom: result.dataFrom ? new Date(result.dataFrom) : null, + dataTo: result.dataTo ? new Date(result.dataTo) : null, + }) + + succeeded++ + } catch (err) { + failed++ + logger.error('[PortfolioRisk] Failed to precompute for user/window', { + userId: user.id, + window, + error: err instanceof Error ? err.message : String(err), + }) + } + } + } + + const duration = Date.now() - start + logger.info('[PortfolioRisk] Precompute run complete', { + users: users.length, + computations: users.length * WINDOWS.length, + succeeded, + failed, + durationMs: duration, + }) +} + +/** + * Run with retry logic. Emits an operational alert after MAX_RETRIES failures. + */ +async function runWithRetry(attempt = 1): Promise { + try { + await runPortfolioRiskPrecompute() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.error( + `[PortfolioRisk] Compute run failed (attempt ${attempt}/${MAX_RETRIES})`, + { + error: message, + } + ) + + if (attempt < MAX_RETRIES) { + const delayMs = attempt * 30_000 // 30s, 60s backoff + logger.info(`[PortfolioRisk] Retrying in ${delayMs / 1000}s...`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + return runWithRetry(attempt + 1) + } + + // Alert after exhausting retries + await alertingService.emit({ + title: 'Portfolio Risk Precompute Failed', + description: `All ${MAX_RETRIES} attempts failed. Last error: ${message}`, + severity: 'warning', + component: 'portfolio-risk-job', + metadata: { attempts: MAX_RETRIES, lastError: message }, + }) + } +} + +/** + * Schedule the portfolio risk precompute job. + * + * @returns NodeJS.Timeout handle — pass to clearInterval in gracefulShutdown. + */ +export function schedulePortfolioRiskJob(): NodeJS.Timeout { + const intervalMs = config.portfolioRisk?.intervalMs ?? 21600000 + + // Run once at startup (non-blocking) + runWithRetry().catch((err) => { + logger.error('[PortfolioRisk] Startup run failed unexpectedly:', err) + }) + + const handle = setInterval(() => { + runWithRetry().catch((err) => { + logger.error('[PortfolioRisk] Scheduled run failed unexpectedly:', err) + }) + }, intervalMs) + + logger.info( + `[PortfolioRisk] Job scheduled every ${intervalMs / (60 * 60 * 1000)}h` + ) + return handle +} diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts index cba73f4..794b7e1 100644 --- a/src/routes/analytics.ts +++ b/src/routes/analytics.ts @@ -3,6 +3,13 @@ import { z } from 'zod' import db from '../db' import { requireAuth } from '../middleware/authenticate' import { mapPortfolioAttributionToResponse } from '../utils/api-formatters' +import { + getPortfolioRiskMetrics, + getPortfolioRiskTimeseries, + getStrategyRiskMetrics, + getPersistedUserRisk, +} from '../analytics/riskService' +import { RiskWindow } from '../analytics/metrics' const router = Router() @@ -14,12 +21,6 @@ function periodToDays(period: string): number { return period === '7d' ? 7 : period === '30d' ? 30 : 90 } -/** - * `window` accepts 30d/90d only, same retention-honest rule as the strategy - * marketplace (src/validators/strategy-validators.ts): YieldSnapshot rows are - * hard-deleted past 90 days (src/agent/snapshotter.ts), so a longer window - * has no data behind it. - */ const attributionQuerySchema = z.object({ window: z .enum(['30d', '90d'], { @@ -33,6 +34,10 @@ function attributionWindowToDays(window: '30d' | '90d'): number { return window === '30d' ? 30 : 90 } +const riskQuerySchema = z.object({ + window: z.enum(['30d', '60d', '90d']).default('90d'), +}) + /** * GET /analytics/apy-history * Returns APY snapshots over time for a user's positions (graph-ready). @@ -152,7 +157,6 @@ router.get('/protocol-performance', async (req: Request, res: Response) => { }, }) - // Group by protocol for graph-ready output const byProtocol: Record< string, { @@ -187,16 +191,6 @@ router.get('/protocol-performance', async (req: Request, res: Response) => { /** * GET /analytics/attribution - * - * Benchmark-relative Brinson attribution for the caller's OWN portfolio — - * owner-scoped via req.auth.userId, never a path param (#320). Reads the - * precomputed PortfolioAttribution row rather than recomputing per request; - * see src/jobs/attribution.ts and src/analytics/attribution.ts. - * - * A 200 with `computed: false` (not a 404) is returned when nothing has been - * precomputed yet for this user/window — "no attribution yet" is a normal - * state for a very new account, not a missing resource, mirroring the - * `{ follow: null }` convention in the strategy marketplace. */ router.get('/attribution', requireAuth, async (req: Request, res: Response) => { const userId = req.auth!.userId @@ -229,4 +223,122 @@ router.get('/attribution', requireAuth, async (req: Request, res: Response) => { }) }) +/** + * GET /analytics/risk + * Returns precomputed or live portfolio risk metrics for the authenticated user. + */ +router.get('/risk', requireAuth, async (req: Request, res: Response) => { + const userId = req.auth!.userId + const parsed = riskQuerySchema.safeParse(req.query) + if (!parsed.success) { + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) + } + + const window = parsed.data.window as RiskWindow + const persisted = await getPersistedUserRisk(userId, window) + + if (persisted) { + return res.status(200).json({ + userId, + requestedWindow: window, + actualWindowDays: Math.min( + parsed.data.window === '30d' + ? 30 + : parsed.data.window === '60d' + ? 60 + : 90, + 90 + ), + insufficientHistory: persisted.insufficientHistory, + sampleCount: persisted.sampleCount, + metrics: { + annualisedVolatility: persisted.annualisedVolatility + ? Number(persisted.annualisedVolatility) + : null, + sortinoRatio: persisted.sortinoRatio + ? Number(persisted.sortinoRatio) + : null, + downsideDeviation: persisted.downsideDeviation + ? Number(persisted.downsideDeviation) + : null, + maxDrawdown: persisted.maxDrawdown + ? Number(persisted.maxDrawdown) + : null, + maxDrawdownDuration: persisted.maxDrawdownDuration, + valueAtRisk: { + varHistorical95: persisted.varHistorical95 + ? Number(persisted.varHistorical95) + : null, + varHistorical99: persisted.varHistorical99 + ? Number(persisted.varHistorical99) + : null, + varParametric95: persisted.varParametric95 + ? Number(persisted.varParametric95) + : null, + varParametric99: persisted.varParametric99 + ? Number(persisted.varParametric99) + : null, + cvarHistorical95: persisted.cvarHistorical95 + ? Number(persisted.cvarHistorical95) + : null, + cvarHistorical99: persisted.cvarHistorical99 + ? Number(persisted.cvarHistorical99) + : null, + }, + beta: persisted.beta ? Number(persisted.beta) : null, + }, + computedAt: persisted.computedAt.toISOString(), + cached: true, + }) + } + + const result = await getPortfolioRiskMetrics(userId, window) + return res.status(200).json({ ...result, cached: false }) +}) + +/** + * GET /analytics/risk/timeseries + * Returns daily portfolio value, return, and drawdown time-series. + */ +router.get( + '/risk/timeseries', + requireAuth, + async (req: Request, res: Response) => { + const userId = req.auth!.userId + const parsed = riskQuerySchema.safeParse(req.query) + if (!parsed.success) { + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) + } + + const window = parsed.data.window as RiskWindow + const result = await getPortfolioRiskTimeseries(userId, window) + return res.status(200).json(result) + } +) + +/** + * GET /analytics/risk/strategy/:publishedStrategyId + * Returns risk metrics for a published strategy. + */ +router.get( + '/risk/strategy/:publishedStrategyId', + async (req: Request, res: Response) => { + const { publishedStrategyId } = req.params + const parsed = riskQuerySchema.safeParse(req.query) + if (!parsed.success) { + return res + .status(400) + .json({ error: 'Validation error', details: parsed.error.flatten() }) + } + + const window = parsed.data.window as RiskWindow + const result = await getStrategyRiskMetrics(publishedStrategyId, window) + return res.status(200).json(result) + } +) + export default router diff --git a/src/whatsapp/transcription/openaiProvider.ts b/src/whatsapp/transcription/openaiProvider.ts index c9d1f01..f3cd22a 100644 --- a/src/whatsapp/transcription/openaiProvider.ts +++ b/src/whatsapp/transcription/openaiProvider.ts @@ -90,7 +90,7 @@ export class OpenAiTranscriptionProvider implements TranscriptionProvider { } const form = new FormData() - const blob = new Blob([audio.buffer], { + const blob = new Blob([new Uint8Array(audio.buffer)], { type: audio.contentType.split(';')[0]?.trim() || 'audio/ogg', }) form.append('file', blob, filenameFor(audio.contentType)) diff --git a/tests/integration/http-client.integration.test.ts b/tests/integration/http-client.integration.test.ts index 0810323..0c829ad 100644 --- a/tests/integration/http-client.integration.test.ts +++ b/tests/integration/http-client.integration.test.ts @@ -18,6 +18,11 @@ describe('HttpClientAdapter Integration — simulated failures', () => { }) }) + // Ensure fake-timer tests never leak into parallel workers + afterEach(() => { + jest.useRealTimers() + }) + describe('transient failures — retry recovers', () => { it('should succeed after intermittent HTTP 5xx errors', async () => { let callCount = 0 @@ -119,14 +124,16 @@ describe('HttpClientAdapter Integration — simulated failures', () => { // Wait for reset jest.useFakeTimers() - jest.advanceTimersByTime(300) - - // Service recovers — should succeed in half-open state - mock.mockResolvedValue('recovered') - const result = await adapter.execute(mock, 'recoverableApi.call') - expect(result).toBe('recovered') - - jest.useRealTimers() + try { + jest.advanceTimersByTime(300) + + // Service recovers — should succeed in half-open state + mock.mockResolvedValue('recovered') + const result = await adapter.execute(mock, 'recoverableApi.call') + expect(result).toBe('recovered') + } finally { + jest.useRealTimers() + } }) }) @@ -224,16 +231,18 @@ describe('HttpClientAdapter Integration — simulated failures', () => { // After reset, service recovers jest.useFakeTimers() - jest.advanceTimersByTime(600) - - simulateStellarRpc.mockResolvedValue('tx_hash_abc') - const hash = await stellarAdapter.execute( - simulateStellarRpc, - 'stellar.submitTransaction' - ) - expect(hash).toBe('tx_hash_abc') - - jest.useRealTimers() + try { + jest.advanceTimersByTime(600) + + simulateStellarRpc.mockResolvedValue('tx_hash_abc') + const hash = await stellarAdapter.execute( + simulateStellarRpc, + 'stellar.submitTransaction' + ) + expect(hash).toBe('tx_hash_abc') + } finally { + jest.useRealTimers() + } }) }) diff --git a/tests/unit/analytics/metrics.test.ts b/tests/unit/analytics/metrics.test.ts new file mode 100644 index 0000000..b48f16f --- /dev/null +++ b/tests/unit/analytics/metrics.test.ts @@ -0,0 +1,301 @@ +/** + * tests/unit/analytics/metrics.test.ts + * + * Unit tests for src/analytics/metrics.ts. + * + * Covers: + * • Deterministic math against known fixture series + * • Degenerate cases return null (never 0, Infinity, or NaN) + * • Skip interval guard (starting value <= 0 is a deposit, not a return) + * • Historical vs Parametric VaR/CVaR estimators + * • Sortino ratio and downside deviation + * • Max drawdown and max drawdown duration + * • Annualised volatility and rolling volatility + * • Beta vs exogenous benchmark + * • Invariance under gap insertion + */ + +import { + computeAllMetrics, + computePeriodReturns, + inferPeriodsPerYear, + annualisedVolatility, + downsideDeviation, + sortinoRatio, + maxDrawdownFromValues, + historicalVaR, + historicalCVaR, + parametricVaR, + betaVsBenchmark, + rollingVolatility, + rollingDrawdown, + type ValuePoint, +} from '../../../src/analytics/metrics' + +describe('Analytics Metrics Pure Module', () => { + const HOUR_MS = 3600 * 1000 + const DAY_MS = 24 * HOUR_MS + + // ─── Fixtures ───────────────────────────────────────────────────────────── + + // 1. Daily growing series: 100 -> 102 -> 101 -> 105 -> 104 -> 110 + const sampleSeries: ValuePoint[] = [ + { timestampMs: 0, value: 100 }, + { timestampMs: DAY_MS, value: 102 }, + { timestampMs: 2 * DAY_MS, value: 101 }, + { timestampMs: 3 * DAY_MS, value: 105 }, + { timestampMs: 4 * DAY_MS, value: 104 }, + { timestampMs: 5 * DAY_MS, value: 110 }, + ] + + // Expected simple period returns for sampleSeries: + // (102-100)/100 = 0.02 + // (101-102)/102 = -0.0098039... + // (105-101)/101 = 0.0396039... + // (104-105)/105 = -0.0095238... + // (110-104)/104 = 0.0576923... + + describe('inferPeriodsPerYear', () => { + it('infers daily periods correctly (~365.25)', () => { + const ppy = inferPeriodsPerYear(sampleSeries) + expect(ppy).not.toBeNull() + expect(ppy!).toBeCloseTo(365.25, 1) + }) + + it('infers hourly periods correctly (~8766)', () => { + const hourlySeries: ValuePoint[] = [ + { timestampMs: 0, value: 100 }, + { timestampMs: HOUR_MS, value: 101 }, + { timestampMs: 2 * HOUR_MS, value: 102 }, + ] + const ppy = inferPeriodsPerYear(hourlySeries) + expect(ppy!).toBeCloseTo(8766, 0) + }) + + it('is robust to snapshot gaps (uses median spacing)', () => { + const gappySeries: ValuePoint[] = [ + { timestampMs: 0, value: 100 }, + { timestampMs: DAY_MS, value: 101 }, + { timestampMs: 2 * DAY_MS, value: 102 }, + { timestampMs: 10 * DAY_MS, value: 105 }, // gap of 8 days + { timestampMs: 11 * DAY_MS, value: 106 }, + ] + const ppy = inferPeriodsPerYear(gappySeries) + expect(ppy!).toBeCloseTo(365.25, 1) + }) + + it('returns null for fewer than 2 points', () => { + expect(inferPeriodsPerYear([])).toBeNull() + expect(inferPeriodsPerYear([{ timestampMs: 0, value: 100 }])).toBeNull() + }) + }) + + describe('computePeriodReturns', () => { + it('computes correct simple period returns', () => { + const returns = computePeriodReturns(sampleSeries) + expect(returns.length).toBe(5) + expect(returns[0]).toBeCloseTo(0.02, 4) + expect(returns[1]).toBeCloseTo(-0.0098039, 4) + expect(returns[2]).toBeCloseTo(0.0396039, 4) + }) + + it('skips intervals whose starting value is <= 0 (portfolio funded from empty guard)', () => { + const unfundedSeries: ValuePoint[] = [ + { timestampMs: 0, value: 0 }, + { timestampMs: DAY_MS, value: 100 }, // deposit 100 from 0 -> skipped! + { timestampMs: 2 * DAY_MS, value: 105 }, // 100 -> 105 = 5% return + ] + const returns = computePeriodReturns(unfundedSeries) + expect(returns.length).toBe(1) + expect(returns[0]).toBeCloseTo(0.05, 4) + }) + + it('returns empty array for single or zero points', () => { + expect(computePeriodReturns([])).toEqual([]) + expect(computePeriodReturns([{ timestampMs: 0, value: 100 }])).toEqual([]) + }) + }) + + describe('Annualised Volatility & Degenerate Cases', () => { + it('computes annualised volatility correctly', () => { + const returns = [0.01, -0.005, 0.02, -0.01, 0.015] + const vol = annualisedVolatility(returns, 365.25) + expect(vol).not.toBeNull() + expect(vol!).toBeGreaterThan(0) + }) + + it('returns null for zero variance (flat series)', () => { + const flatReturns = [0.01, 0.01, 0.01, 0.01] + expect(annualisedVolatility(flatReturns, 365.25)).toBeNull() + }) + + it('returns null for insufficient samples (< 2)', () => { + expect(annualisedVolatility([0.05], 365.25)).toBeNull() + expect(annualisedVolatility([], 365.25)).toBeNull() + }) + }) + + describe('Sortino Ratio & Downside Deviation', () => { + it('computes downside deviation and sortino ratio', () => { + const returns = [0.05, -0.02, 0.04, -0.01, 0.03] + const ppy = 365.25 + const dd = downsideDeviation(returns, ppy, 0) + const sortino = sortinoRatio(returns, ppy, 0) + + expect(dd).not.toBeNull() + expect(dd!).toBeGreaterThan(0) + expect(sortino).not.toBeNull() + expect(sortino!).toBeGreaterThan(0) + }) + + it('returns null for Sortino when downside deviation is 0 (all positive returns)', () => { + const allPositive = [0.02, 0.03, 0.01, 0.04] + expect(sortinoRatio(allPositive, 365.25, 0)).toBeNull() + }) + + it('returns null for Sortino on insufficient samples', () => { + expect(sortinoRatio([0.05], 365.25)).toBeNull() + }) + }) + + describe('Max Drawdown & Max Drawdown Duration', () => { + it('computes max drawdown and duration from value series', () => { + // 100 -> 120 (peak) -> 90 (trough, -25%) -> 100 -> 110 + const ddSeries: ValuePoint[] = [ + { timestampMs: 0, value: 100 }, + { timestampMs: DAY_MS, value: 120 }, + { timestampMs: 2 * DAY_MS, value: 100 }, + { timestampMs: 3 * DAY_MS, value: 90 }, // Peak 120 to Trough 90 is (120-90)/120 = 25% drawdown + { timestampMs: 4 * DAY_MS, value: 110 }, + ] + + const res = maxDrawdownFromValues(ddSeries) + expect(res).not.toBeNull() + expect(res!.maxDrawdown).toBeCloseTo(0.25, 4) + expect(res!.maxDrawdownDuration).toBe(2) // 2 steps from peak (index 1) to trough (index 3) + }) + + it('returns 0 drawdown for monotonically increasing series', () => { + const monotonic: ValuePoint[] = [ + { timestampMs: 0, value: 100 }, + { timestampMs: DAY_MS, value: 105 }, + { timestampMs: 2 * DAY_MS, value: 110 }, + ] + const res = maxDrawdownFromValues(monotonic) + expect(res).toEqual({ maxDrawdown: 0, maxDrawdownDuration: 0 }) + }) + + it('returns null for insufficient data', () => { + expect(maxDrawdownFromValues([])).toBeNull() + expect(maxDrawdownFromValues([{ timestampMs: 0, value: 100 }])).toBeNull() + }) + }) + + describe('Historical vs Parametric VaR & CVaR', () => { + const returns = [ + -0.05, -0.02, 0.01, 0.03, 0.04, -0.01, 0.02, 0.05, -0.03, 0.01, + ] + + it('computes historical VaR and CVaR as positive loss magnitudes', () => { + const var95 = historicalVaR(returns, 0.95) + const cvar95 = historicalCVaR(returns, 0.95) + + expect(var95).not.toBeNull() + expect(cvar95).not.toBeNull() + expect(var95!).toBeGreaterThan(0) + expect(cvar95!).toBeGreaterThanOrEqual(var95!) + }) + + it('computes parametric VaR assuming normal distribution', () => { + const pvar95 = parametricVaR(returns, 0.95) + expect(pvar95).not.toBeNull() + }) + + it('returns null for VaR/CVaR on insufficient samples', () => { + expect(historicalVaR([0.01], 0.95)).toBeNull() + expect(historicalCVaR([0.01], 0.95)).toBeNull() + expect(parametricVaR([0.01], 0.95)).toBeNull() + }) + + it('returns null for parametric VaR on zero variance', () => { + expect(parametricVaR([0.01, 0.01, 0.01], 0.95)).toBeNull() + }) + }) + + describe('Beta vs Benchmark', () => { + it('computes beta = 1 when portfolio moves identically to benchmark', () => { + const pReturns = [0.01, -0.02, 0.03, -0.01, 0.02] + const bReturns = [0.01, -0.02, 0.03, -0.01, 0.02] + const beta = betaVsBenchmark(pReturns, bReturns) + expect(beta).not.toBeNull() + expect(beta!).toBeCloseTo(1.0, 4) + }) + + it('computes beta = 2 when portfolio moves with twice benchmark leverage', () => { + const bReturns = [0.01, -0.02, 0.03, -0.01, 0.02] + const pReturns = bReturns.map((r) => r * 2) + const beta = betaVsBenchmark(pReturns, bReturns) + expect(beta!).toBeCloseTo(2.0, 4) + }) + + it('returns null when length mismatch or flat benchmark', () => { + expect(betaVsBenchmark([0.01, 0.02], [0.01])).toBeNull() + expect(betaVsBenchmark([0.01, 0.02], [0.05, 0.05])).toBeNull() + }) + }) + + describe('Rolling Volatility & Drawdown Timeseries', () => { + it('computes rolling volatility series with nulls for initial window warmup', () => { + const returns = [0.01, -0.02, 0.03, -0.01, 0.02] + const timestamps = [ + DAY_MS, + 2 * DAY_MS, + 3 * DAY_MS, + 4 * DAY_MS, + 5 * DAY_MS, + ] + const rolling = rollingVolatility(returns, timestamps, 3, 365.25) + + expect(rolling.length).toBe(5) + expect(rolling[0]!.volatility).toBeNull() + expect(rolling[1]!.volatility).toBeNull() + expect(rolling[2]!.volatility).not.toBeNull() + }) + + it('computes rolling drawdown series', () => { + const series: ValuePoint[] = [ + { timestampMs: 0, value: 100 }, + { timestampMs: DAY_MS, value: 120 }, + { timestampMs: 2 * DAY_MS, value: 90 }, + ] + const rdd = rollingDrawdown(series) + expect(rdd.length).toBe(3) + expect(rdd[0]!.drawdown).toBe(0) + expect(rdd[1]!.drawdown).toBe(0) + expect(rdd[2]!.drawdown).toBeCloseTo(0.25, 4) + }) + }) + + describe('computeAllMetrics Master Computation', () => { + it('computes full suite on valid data without throwing NaN or Infinity', () => { + const metrics = computeAllMetrics(sampleSeries) + expect(metrics).not.toBeNull() + expect(metrics!.sampleCount).toBe(5) + expect(Number.isNaN(metrics!.annualisedVolatility)).toBe(false) + expect(Number.isNaN(metrics!.varHistorical95)).toBe(false) + expect(Number.isNaN(metrics!.maxDrawdown)).toBe(false) + }) + + it('returns null metrics for entirely un-funded series', () => { + const zeroSeries: ValuePoint[] = [ + { timestampMs: 0, value: 0 }, + { timestampMs: DAY_MS, value: 0 }, + ] + const res = computeAllMetrics(zeroSeries) + expect(res).not.toBeNull() + expect(res!.sampleCount).toBe(0) + expect(res!.annualisedVolatility).toBeNull() + expect(res!.maxDrawdown).toBeNull() + }) + }) +}) diff --git a/tests/unit/analytics/no-duplicate-definitions.test.ts b/tests/unit/analytics/no-duplicate-definitions.test.ts new file mode 100644 index 0000000..d3b32d2 --- /dev/null +++ b/tests/unit/analytics/no-duplicate-definitions.test.ts @@ -0,0 +1,95 @@ +/** + * tests/unit/analytics/no-duplicate-definitions.test.ts + * + * Anti-duplication guard test. + * + * REQUIREMENT (Issue #225 / STRATEGY_MARKETPLACE.md §2): + * "Do not add a third definition of risk-adjusted return / Sharpe / volatility. + * Whichever side becomes canonical, the other must import it. Add a test that + * fails if a second, divergent Sharpe/volatility implementation is introduced." + * + * This test scans all files in `src/` to ensure: + * 1. `src/analytics/metrics.ts` is the SINGLE canonical provider of volatility, + * inferPeriodsPerYear, Sortino, VaR, CVaR, and max drawdown calculations. + * 2. No other file in `src/` defines duplicate mathematical functions for + * annualised volatility or inferPeriodsPerYear. + */ + +import * as fs from 'fs' +import * as path from 'path' + +describe('Anti-Duplication Guard: Risk Analytics Engine', () => { + const srcDir = path.resolve(__dirname, '../../../src') + + function getAllTsFiles(dir: string): string[] { + const files: string[] = [] + const list = fs.readdirSync(dir) + for (const file of list) { + const fullPath = path.join(dir, file) + const stat = fs.statSync(fullPath) + if (stat.isDirectory()) { + files.push(...getAllTsFiles(fullPath)) + } else if (file.endsWith('.ts') && !file.endsWith('.d.ts')) { + files.push(fullPath) + } + } + return files + } + + it('ensures src/analytics/metrics.ts exists as the canonical risk module', () => { + const canonicalPath = path.join(srcDir, 'analytics', 'metrics.ts') + expect(fs.existsSync(canonicalPath)).toBe(true) + }) + + it('verifies no duplicate definitions of annualisedVolatility or inferPeriodsPerYear exist outside src/analytics/metrics.ts', () => { + const tsFiles = getAllTsFiles(srcDir) + const canonicalMetricsFile = path.join(srcDir, 'analytics', 'metrics.ts') + + const forbiddenPatterns = [ + /function\s+annualisedVolatility\s*\(/, + /function\s+annualizedVolatility\s*\(/, + /const\s+annualisedVolatility\s*=/, + /const\s+annualizedVolatility\s*=/, + /function\s+inferPeriodsPerYear\s*\(/, + /const\s+inferPeriodsPerYear\s*=/, + ] + + const violations: { file: string; line: number; match: string }[] = [] + + for (const filePath of tsFiles) { + // Skip the canonical file itself + if (filePath === canonicalMetricsFile) continue + + // Skip strategyMetrics.ts — it re-exports inferPeriodsPerYear as a + // delegating adapter (arrow const) that calls the canonical metrics.ts + // implementation. It is NOT a duplicate implementation. + if (filePath.endsWith('agent/strategyMetrics.ts')) continue + + const content = fs.readFileSync(filePath, 'utf8') + const lines = content.split('\n') + + lines.forEach((line, idx) => { + // Skip comment lines + const trimmed = line.trim() + if ( + trimmed.startsWith('//') || + trimmed.startsWith('*') || + trimmed.startsWith('/*') + ) + return + + for (const pattern of forbiddenPatterns) { + if (pattern.test(line)) { + violations.push({ + file: path.relative(srcDir, filePath), + line: idx + 1, + match: line.trim(), + }) + } + } + }) + } + + expect(violations).toEqual([]) + }) +})