Advanced portfolio risk analytics engine - #333
Merged
robertocarlous merged 8 commits intoAug 20, 2026
Merged
Conversation
- Add portfolio_risk_aggregates table with all VaR/CVaR/Sortino/drawdown fields and an insufficientHistory flag - Unique constraint on (userId, window) enables safe upserts per compute run - Indexes on sortinoRatio and annualisedVolatility for leaderboard ORDER BY - Cascade delete on userId foreign key - Add PortfolioRiskAggregate relation to User model in schema.prisma Closes Neurowealth#225 (schema prerequisite)
src/analytics/metrics.ts — zero-I/O mathematical engine: - Historical VaR/CVaR at 95% and 99% confidence levels - Parametric (Gaussian) VaR at 95% and 99% - Sortino ratio, downside deviation (annualised) - Max drawdown + max drawdown duration - Annualised volatility (sample σ × √periodsPerYear) - inferPeriodsPerYear via median inter-observation spacing — single canonical definition; all other files must import from here - Null contract: every metric returns null (never 0/Infinity/NaN) for insufficient data, flat series, or undefined results src/analytics/service.ts — I/O layer: - Aggregates YieldSnapshot records into portfolio value series - Enforces 90-day retention bound; sets insufficientHistory flag when available history is shorter than the requested window — never silently truncates to a shorter unlabelled window - Exposes computePortfolioRisk(), computePortfolioRiskTimeseries(), and computeStrategyRisk() src/config/env.ts: - Add PORTFOLIO_RISK_INTERVAL_HOURS config (default: 6) validated at startup Addresses Neurowealth#225
src/jobs/portfolioRisk.ts: - Periodic job (PORTFOLIO_RISK_INTERVAL_HOURS, default 6 h) that iterates active users and upserts risk aggregates into portfolio_risk_aggregates — enabling leaderboard ORDER BY in SQL without recomputing per-request - Automatic retries with alertingService notification on failure src/routes/analytics.ts — two new authenticated endpoints: - GET /api/v1/analytics/risk Returns VaR (95/99, historical+parametric), CVaR (95/99), Sortino, downside deviation, max drawdown (+duration), and annualised volatility. Serves precomputed row when < 1 h old (source: 'precomputed'), otherwise live compute (source: 'live'). - GET /api/v1/analytics/risk/timeseries Returns graph-ready rolling volatility + running-peak drawdown. Both endpoints extract userId from the verified JWT payload only — no path/query userId accepted (ownership enforced). src/index.ts: - Wire schedulePortfolioRiskJob into startup sequence - Register job handle for clean clearInterval on graceful shutdown Closes Neurowealth#225
….json
tests/unit/analytics/metrics.test.ts (18 tests):
- Covers all estimators against fixture series
- Validates degenerate cases: flat series, single observation,
empty array, all-positive returns (no downside)
- Verifies gap invariance of inferPeriodsPerYear
tests/unit/analytics/no-duplicate-definitions.test.ts (2 tests):
- Anti-duplication guard: scans entire src/ tree and fails if any
file outside src/analytics/metrics.ts defines annualisedVolatility
or inferPeriodsPerYear — enforces single canonical source of truth
tests/integration/http-client.integration.test.ts (bugfix):
- jest.useFakeTimers() calls were not wrapped in try/finally, causing
timer state to leak into sibling Jest workers during parallel runs
- Fix: added afterEach(() => jest.useRealTimers()) at top-level describe
and wrapped each fake-timer block in try/finally { jest.useRealTimers() }
- All 10 http-client tests now pass consistently in parallel (exit 0)
docs/openapi.yaml:
- Full schemas: RiskMetrics, PortfolioRiskResponse, TimeseriesResponse,
RollingVolPoint, DrawdownPoint with null-contract documentation
- Complete path specs for GET /analytics/risk and /analytics/risk/timeseries
including worked examples (sufficient_history, insufficient_history)
- Fix: responses reusable-component block was placed outside components:
(moved inside — caused swagger-cli to error with 'Token responses does not exist')
- Fix: NullableDecimal used oneOf [{type: null}] (OpenAPI 3.1-only syntax);
changed to nullable: true on number type (compliant with 3.0.3)
package.json:
- Add validate:spec script (npx swagger-cli validate docs/openapi.yaml)
- Remove duplicate prisma:generate key
All 79 tests across 7 suites pass; tsc --noEmit clean; spec validates.
api-contract (redocly lint) — fixed 4 errors: - metrics field in PortfolioRiskResponse: moved nullable into allOf companion schema instead of at sibling level (nullable-type-sibling rule) - /analytics/protocol-performance, /health/live, /health/ready: added security: [] to explicitly declare these as public endpoints (security-defined rule — lint requires every operation to declare security or inherit from root level) Redocly now exits 0 with warnings only (5 warnings, 0 errors). migration-rollback-check: added rollback.sql for all 8 migrations. The workflow requires every migration directory that has a migration.sql to also ship a rollback.sql documenting the recovery path. Files added: - prisma/migrations/20260302221454_init/rollback.sql - prisma/migrations/20260326152030_add_event_tracking/rollback.sql - prisma/migrations/20260425140000_add_performance_indexes/rollback.sql - prisma/migrations/20260528_add_dead_letter_events/rollback.sql - prisma/migrations/20260529000001_add_custodial_wallets/rollback.sql - prisma/migrations/20260529000002_add_auth_nonces/rollback.sql - prisma/migrations/20260617000000_fix_agent_log_attribution/rollback.sql - prisma/migrations/20260820000000_add_portfolio_risk_aggregates/rollback.sql Local verification: bash scripts/check-migration-rollback.sh → ✓ All migrations have a rollback.sql. redocly lint docs/openapi.yaml → Woohoo! Your API description is valid.
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Summary
The platform reported returns (APY history, user yield, strategy-marketplace Sharpe) but had no risk analytics layer. Users could not answer the two questions that determine whether they should move money: "How much can I lose in a bad month?" and "Is this yield actually worth the volatility?".
docs/STRATEGY_MARKETPLACE.md explicitly flagged this gap : "has not landed — there is no analytics service in src/"
What Was Delivered
Zero-I/O mathematical engine implementing:
Null contract enforced: every metric returns null (never 0, Infinity, or NaN) when data is insufficient, the series is flat, or the result is mathematically undefined.
API Surface — src/routes/analytics.ts
Ownership enforced: userId is extracted exclusively from the verified JWT payload — no path/query-param userId is accepted.
Database
New model: PortfolioRiskAggregate in prisma/schema.prisma
New migration: prisma/migrations/20260820000000_add_portfolio_risk_aggregates/migration.sql
Key design decisions:
Validated at startup via
src/config/env.tsTests
All 79 tests across 7 suites pass (exit 0).
Also fixed a pre-existing timer-leak bug in http-client.integration.test.ts where jest.useFakeTimers() was not wrapped in try/finally, causing flaky failures in parallel Jest workers.
OpenAPI Spec — docs/openapi.yaml
Files changed
Closes #312