Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,49 @@ OPENAI_API_KEY=your-openai-key-here
LOG_LEVEL=info
LOG_FORMAT=json

# PagerDuty integration key for DLQ alerts
# Generate from: https://developer.pagerduty.com/
PAGERDUTY_ROUTING_KEY=

# Admin dashboard URL for DLQ inspection links in alerts
ADMIN_DASHBOARD_URL=https://admin.neurowealth.io

# Graceful shutdown
# Grace period (ms) for in-flight requests to complete before force-exit
SHUTDOWN_DRAIN_TIMEOUT_MS=30000

# Data retention (all optional — defaults shown)
# Days to retain processed_events rows before deletion (default: 90)
RETENTION_PROCESSED_EVENTS_DAYS=90
# Days to retain RESOLVED dead_letter_events rows before deletion (default: 30)
RETENTION_DEAD_LETTER_EVENTS_DAYS=30
# Days to retain agent_logs rows before deletion (default: 60)
RETENTION_AGENT_LOGS_DAYS=60
# Interval between retention job runs in ms (default: 24 hours)
RETENTION_INTERVAL_MS=86400000

# ── Internal Endpoints Authentication ──────────────────────────────────────────
#
# Protect /metrics and /api/agent/status from public access
#
# Choose ONE or more authentication methods:
# 1. X-Internal-Token header (for monitoring services)
# 2. IP whitelist (for internal Kubernetes/Docker networks)
# 3. ADMIN_API_TOKEN (existing admin bearer token)

# Internal service token for /metrics and /api/agent/status
# Used via header: X-Internal-Token: <value>
INTERNAL_SERVICE_TOKEN=your-secure-internal-token-here

# Comma-separated IP allowlist for internal endpoints
# Example: "127.0.0.1,10.0.0.0/8,172.17.0.0/16"
# Use for Kubernetes services, Docker internal networks
INTERNAL_IP_WHITELIST=127.0.0.1,::1

# Analytics
# Risk-free rate (annual, decimal, e.g. 0.02 for 2%). Default 0
RISK_FREE_RATE=0
ANALYTICS_RISK_FREE_RATE=0
# Server
SERVER_TIMEOUT=30000
MAX_REQUEST_SIZE=10mb
Expand Down
38 changes: 38 additions & 0 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,43 @@ Response 404:

---

## Analytics

### GET /api/analytics

- Auth: required (requireAuth)
- Description: Returns portfolio analytics metrics computed from YieldSnapshot history.
- Query params:
- period: enum('7d', '30d', '90d', '1y'), default '30d'

Response 200:
{
"userId": "550e8400-e29b-41d4-a716-446655440001",
"period": "30d",
"realizedAPY": 18.42,
"sharpeRatio": 1.35,
"maxDrawdown": 6.75,
"protocolAllocation": [
{ "protocol": "Blend", "percentage": 65.2, "value": 6520 },
{ "protocol": "Soroswap", "percentage": 34.8, "value": 3480 }
],
"snapshotCount": 12,
"startDate": "2026-05-27",
"endDate": "2026-06-26",
"totalValue": 10000
}

Response 400:
{
"error": "Validation error",
"details": { ... }
}

Response 401:
{
"error": "Unauthorized"
}

## Endpoint Coverage Checklist (src/routes)

- health.ts: GET /health
Expand All @@ -724,3 +761,4 @@ Response 404:
- deposit.ts: POST /api/deposit
- withdraw.ts: POST /api/withdraw
- vault.ts: GET /api/vault/state, GET /api/vault/balance
- analytics.ts: GET /api/analytics (new), plus legacy /apy-history, /user-yield, /protocol-performance
73 changes: 73 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ tags:
description: Vault / savings product
- name: admin
description: Admin-only management endpoints
- name: analytics
description: Portfolio performance analytics (APY, Sharpe, Drawdown, Allocations)

# ── Security schemes ─────────────────────────────────────────────────────────
components:
Expand Down Expand Up @@ -639,6 +641,77 @@ paths:
'403':
$ref: '#/components/responses/Forbidden'

# ── Analytics ──────────────────────────────────────────────────────────────
/api/analytics:
get:
tags: [portfolio]
operationId: getAnalytics
summary: Get portfolio analytics metrics (Realized APY, Sharpe, Max Drawdown, Protocol Allocation)
description: |
Computes historical performance metrics using YieldSnapshot data.
Supports periods: 7d, 30d, 90d, 1y (default 30d).
Requires authentication.
security:
- BearerAuth: []
parameters:
- in: query
name: period
schema:
type: string
enum: ['7d', '30d', '90d', '1y']
default: '30d'
description: Analysis period
responses:
'200':
description: Analytics metrics
content:
application/json:
schema:
type: object
properties:
userId:
type: string
period:
type: string
example: '30d'
realizedAPY:
type: number
description: Annualised return from history (CAGR)
example: 12.45
sharpeRatio:
type: number
description: Risk-adjusted return (Sharpe)
example: 1.82
maxDrawdown:
type: number
description: Largest peak-to-trough decline (%)
example: 8.75
protocolAllocation:
type: array
items:
type: object
properties:
protocol:
type: string
percentage:
type: number
value:
type: number
snapshotCount:
type: integer
startDate:
type: string
nullable: true
endDate:
type: string
nullable: true
totalValue:
type: number
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'

# ── Reusable responses ────────────────────────────────────────────────────
responses: {}

Expand Down
6 changes: 6 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,4 +361,10 @@ export const config = {
/** Interval between retention job runs in ms (default: 24 hours) */
intervalMs: parseInt(process.env.RETENTION_INTERVAL_MS || '86400000'),
},
analytics: {
/** Risk-free rate used for Sharpe ratio calculation (annual, decimal e.g. 0.02 for 2%). Default 0. */
riskFreeRate: parseFloat(process.env.RISK_FREE_RATE || process.env.ANALYTICS_RISK_FREE_RATE || '0'),
},
}
}

89 changes: 87 additions & 2 deletions src/routes/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ import { Router, Request, Response } from 'express'
import { z } from 'zod'
import db from '../db'
import { requireAuth } from '../middleware/authenticate'
import { config } from '../config/env'
import {
computeAnalyticsMetrics,
periodToDays as utilPeriodToDays,
SnapshotData,
} from '../utils/analytics'

const router = Router()

const periodSchema = z.object({
period: z.enum(['7d', '30d', '90d']).default('30d'),
period: z.enum(['7d', '30d', '90d', '1y']).default('30d'),
})

function periodToDays(period: string): number {
return period === '7d' ? 7 : period === '30d' ? 30 : 90
return utilPeriodToDays(period)
}

/**
Expand Down Expand Up @@ -129,4 +135,83 @@ router.get('/protocol-performance', async (req: Request, res: Response) => {
return res.status(200).json({ period: parsed.data.period, protocols: Object.values(byProtocol) })
})

/**
* GET /api/analytics
* Returns portfolio analytics metrics computed from YieldSnapshot history.
* Query: period=30d|90d|1y (default 30d)
*/
router.get('/', requireAuth, async (req: Request, res: Response) => {
const userId = req.auth!.userId
const parsed = periodSchema.safeParse(req.query)
if (!parsed.success) {
return res.status(400).json({ error: 'Validation error', details: parsed.error.flatten() })
}

const period = parsed.data.period
const days = periodToDays(period)
const fromDate = new Date(Date.now() - days * 86400_000)

// Fetch snapshots for the user in period + positions for allocation
const [snapshotsRaw, positionsRaw] = await Promise.all([
db.yieldSnapshot.findMany({
where: {
position: { userId },
snapshotAt: { gte: fromDate },
},
orderBy: { snapshotAt: 'asc' },
select: {
snapshotAt: true,
principalAmount: true,
yieldAmount: true,
apy: true,
positionId: true,
position: {
select: { protocolName: true },
},
},
}),
db.position.findMany({
where: { userId, status: 'ACTIVE' },
select: { protocolName: true, currentValue: true },
}),
])

// Map to SnapshotData shape
const snapshots: SnapshotData[] = snapshotsRaw.map((s: any) => ({
snapshotAt: s.snapshotAt,
principalAmount: s.principalAmount,
yieldAmount: s.yieldAmount,
apy: s.apy,
positionId: s.positionId,
position: s.position,
}))

const positions = positionsRaw.map((p: any) => ({
protocolName: p.protocolName,
currentValue: p.currentValue,
}))

const rfRate = config.analytics?.riskFreeRate ?? 0

const metrics = await computeAnalyticsMetrics(
snapshots,
positions,
period,
rfRate
)

const { period: _metricsPeriod, ...restMetrics } = metrics
return res.status(200).json({
userId,
period,
...restMetrics,
// Include a simple totalValue for convenience
totalValue: snapshots.length > 0
? snapshots[snapshots.length - 1]
? (Number(snapshots[snapshots.length - 1].principalAmount) + Number(snapshots[snapshots.length - 1].yieldAmount))
: 0
: 0,
})
})

export default router
Loading