Skip to content

Advanced portfolio risk analytics engine - #333

Merged
robertocarlous merged 8 commits into
Neurowealth:mainfrom
bakarezainab:Advanced-Portfolio-Risk-Analytics-Engine
Aug 20, 2026
Merged

Advanced portfolio risk analytics engine#333
robertocarlous merged 8 commits into
Neurowealth:mainfrom
bakarezainab:Advanced-Portfolio-Risk-Analytics-Engine

Conversation

@bakarezainab

Copy link
Copy Markdown
Contributor

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

  1. Pure Analytics Module — src/analytics/metrics.ts
    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.

  1. I/O & Service Layer — src/analytics/service.ts
  • Sole consumer of YieldSnapshot Prisma records; aggregates portfolio values by exact timestamp.
  • 90-day retention boundary — requests beyond available history are clearly flagged (insufficientHistory: true, actualWindowDays set to honest span).
  • Exposes computePortfolioRisk(), computePortfolioRiskTimeseries(), and computeStrategyRisk() — keeping all math in metrics.ts.
  1. Scheduled Persistence — src/jobs/portfolioRisk.ts
  • Periodic job (default: 6 h, controlled by PORTFOLIO_RISK_INTERVAL_HOURS env var) that iterates active users and upserts risk aggregates into portfolio_risk_aggregates.
  • Enables efficient ORDER BY sortinoRatio, ORDER BY annualisedVolatility for leaderboards without recomputing on every request.
  • Includes automatic retries and operational alerting via alertingService.
  • Wired into src/index.ts startup and graceful shutdown.
  1. API Surface — src/routes/analytics.ts
    Ownership enforced: userId is extracted exclusively from the verified JWT payload — no path/query-param userId is accepted.

  2. Database
    New model: PortfolioRiskAggregate in prisma/schema.prisma
    New migration: prisma/migrations/20260820000000_add_portfolio_risk_aggregates/migration.sql

Key design decisions:

  • Unique constraint on (userId, window) → safe upserts on every compute run.
  • Indexes on sortinoRatio and annualisedVolatility for leaderboard ORDER BY.
  • insufficientHistory flag stored so dashboards can exclude under-funded rows without re-querying raw snapshots.
  1. New Environment Variable
PORTFOLIO_RISK_INTERVAL_HOURS=6   # default; controls scheduled job frequency

Validated at startup via src/config/env.ts

  1. Tests
    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.

  2. OpenAPI Spec — docs/openapi.yaml

  • Added complete schemas: RiskMetrics, PortfolioRiskResponse, TimeseriesResponse, RollingVolPoint, DrawdownPoint.
  • Added full path definitions for GET /analytics/risk and GET /analytics/risk/timeseries with examples.
  • Fixed a pre-existing structural bug: the responses reusable-component block was placed outside components:, causing swagger-cli validate to fail with Token "responses" does not exist.
  • Fixed NullableDecimal schema to use OpenAPI 3.0.3-compliant nullable: true (removing the oneOf: [{type: 'null'}] construct that is only valid in 3.1).
  • Added validate:spec npm script (npx swagger-cli validate docs/openapi.yaml) — spec validates clean

Files changed

prisma/schema.prisma                                            (PortfolioRiskAggregate model + User relation)
prisma/migrations/20260820000000_add_portfolio_risk_aggregates/ (new migration)
src/analytics/metrics.ts                                        (new — canonical risk engine)
src/analytics/service.ts                                        (new — I/O layer)
src/jobs/portfolioRisk.ts                                       (new — scheduled persistence)
src/routes/analytics.ts                                         (new /risk and /risk/timeseries endpoints)
src/config/env.ts                                               (PORTFOLIO_RISK_INTERVAL_HOURS config)
src/index.ts                                                    (job wiring + shutdown)
tests/unit/analytics/metrics.test.ts                            (new — 18 unit tests)
tests/unit/analytics/no-duplicate-definitions.test.ts           (new — anti-duplication guard)
tests/integration/http-client.integration.test.ts               (bugfix — timer leak)
docs/openapi.yaml                                               (new endpoints + schema fixes)
package.json                                                    (validate:spec script; removed duplicate key)

Closes #312

- 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.
@robertocarlous
robertocarlous merged commit aac5028 into Neurowealth:main Aug 20, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Advanced Portfolio Risk Analytics Engine (VaR, CVaR, Sortino & Drawdown)

2 participants