Skip to content

Commit 5389dbc

Browse files
authored
Merge pull request #1566 from AlienScroll78/feature/cvar-var-auto-pause
feat(risk): add configurable CVaR/VaR auto-pause with notifications
2 parents f605f6b + f80e17a commit 5389dbc

5 files changed

Lines changed: 460 additions & 5 deletions

File tree

backend/src/queue/workers/portfolioCheckWorker.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ export async function processPortfolioCheckJob(
7676

7777
const stellarService = new StellarService();
7878
for (const p of portfolios) {
79+
// Skip portfolios that have been auto-paused due to a CVaR/VaR breach
80+
if (p.riskPausedUntil && new Date(p.riskPausedUntil) > new Date()) {
81+
logger.info(
82+
"[WORKER:portfolio-check] Skipping risk-paused portfolio",
83+
{
84+
jobId: job.id,
85+
portfolioId: p.id,
86+
riskPausedUntil: p.riskPausedUntil,
87+
correlationId,
88+
},
89+
);
90+
continue;
91+
}
92+
7993
const needed = await stellarService.checkRebalanceNeeded(p.id);
8094
if (!needed) continue;
8195

backend/src/services/notificationTemplates.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ export interface RiskChangeData {
2727
portfolioId: string
2828
oldLevel: string
2929
newLevel: string
30+
/** Optional: breach details when auto-pause was triggered by a CVaR/VaR threshold. */
31+
autoPause?: {
32+
reason: 'VAR_BREACH' | 'CVAR_BREACH'
33+
/** Measured value as a percentage string e.g. "14.23%" */
34+
measuredValue: string
35+
/** Threshold as a percentage string e.g. "12.00%" */
36+
threshold: string
37+
/** ISO string of when the pause expires */
38+
pausedUntil: string
39+
}
3040
}
3141

3242
export type NotificationTemplateData =
@@ -65,9 +75,21 @@ const templates: {
6575
message: (d) => `${d.asset} has ${d.direction} by ${d.priceChange}%.`,
6676
},
6777
riskChange: {
68-
title: () => 'Portfolio Risk Level Changed',
69-
message: (d) =>
70-
`Your portfolio risk level changed from ${d.oldLevel} to ${d.newLevel}.`,
78+
title: (d) => d.autoPause ? 'Rebalancing Auto-Paused: Risk Threshold Breached' : 'Portfolio Risk Level Changed',
79+
message: (d) => {
80+
if (d.autoPause) {
81+
const metricLabel = d.autoPause.reason === 'VAR_BREACH' ? 'VaR(95)' : 'CVaR(95)'
82+
const pauseDate = new Date(d.autoPause.pausedUntil).toLocaleString('en-US', { timeZoneName: 'short' })
83+
return (
84+
`Scheduled rebalancing for portfolio ${d.portfolioId} has been automatically paused because the ` +
85+
`${metricLabel} limit was exceeded (measured: ${d.autoPause.measuredValue}, threshold: ${d.autoPause.threshold}). ` +
86+
`Rebalancing is suspended until ${pauseDate}. ` +
87+
`Risk level: ${d.oldLevel}${d.newLevel}. ` +
88+
`Review your portfolio and market conditions before resuming.`
89+
)
90+
}
91+
return `Your portfolio risk level changed from ${d.oldLevel} to ${d.newLevel}.`
92+
},
7193
},
7294
}
7395

backend/src/services/riskManagements.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { PricesMap, RiskHeatmap } from '../types/index.js'
22
import { assetRegistryService } from './assetRegistryService.js'
33
import { logger } from '../utils/logger.js'
4+
import { notificationService } from './notificationService.js'
5+
import { buildNotificationPayload } from './notificationTemplates.js'
6+
import { portfolioStorage } from './portfolioStorage.js'
47

58
export interface StatisticalRiskMetrics {
69
ewmaVolatility: number
@@ -45,6 +48,25 @@ export type RiskDecisionReasonCode =
4548
| 'STAT_MODEL_CVAR_BREACH'
4649
| 'STAT_MODEL_DRAWDOWN_BREACH'
4750

51+
export interface AutoPauseThresholds {
52+
/** VaR(95) level that triggers an auto-pause. Expressed as a decimal fraction (e.g. 0.12 = 12%). */
53+
var95?: number
54+
/** CVaR(95) level that triggers an auto-pause. Expressed as a decimal fraction (e.g. 0.16 = 16%). */
55+
cvar95?: number
56+
/** How many milliseconds to pause the portfolio for once a breach is detected. Default: 24 hours. */
57+
pauseDurationMs?: number
58+
}
59+
60+
export interface CVaRVaRCheckResult {
61+
portfolioId: string
62+
breached: boolean
63+
breachType?: 'VAR_BREACH' | 'CVAR_BREACH'
64+
measuredValue?: number
65+
threshold?: number
66+
paused: boolean
67+
riskMetrics: ReturnType<RiskManagementService['analyzePortfolioRisk']>
68+
}
69+
4870
type ReturnPoint = { value: number, timestamp: number }
4971
type PricePoint = { price: number, timestamp: number }
5072

@@ -68,7 +90,16 @@ export class RiskManagementService {
6890
private readonly CIRCUIT_BREAKER_THRESHOLD = 0.20
6991
private readonly CIRCUIT_BREAKER_COOLDOWN = 300000 // 5 minutes
7092

71-
constructor() {
93+
/** Configurable CVaR/VaR auto-pause thresholds (fall back to class defaults when not set). */
94+
private readonly autoPauseVar95Threshold: number
95+
private readonly autoPauseCvar95Threshold: number
96+
private readonly autoPauseDurationMs: number
97+
98+
constructor(autoPauseThresholds: AutoPauseThresholds = {}) {
99+
this.autoPauseVar95Threshold = autoPauseThresholds.var95 ?? this.VAR95_BLOCK_THRESHOLD
100+
this.autoPauseCvar95Threshold = autoPauseThresholds.cvar95 ?? this.CVAR95_BLOCK_THRESHOLD
101+
this.autoPauseDurationMs = autoPauseThresholds.pauseDurationMs ?? 24 * 60 * 60 * 1000 // 24 h
102+
72103
const symbols = assetRegistryService.getSymbols(true)
73104
const assets = symbols.length > 0 ? symbols : ['XLM', 'BTC', 'ETH', 'USDC']
74105
assets.forEach(asset => {
@@ -334,6 +365,137 @@ export class RiskManagementService {
334365
}
335366
}
336367

368+
/**
369+
* Evaluates CVaR(95) and VaR(95) against the configured auto-pause thresholds.
370+
*
371+
* When either metric exceeds its threshold:
372+
* - Marks the portfolio as paused until `now + autoPauseDurationMs` by setting
373+
* `riskPausedUntil` on the portfolio record.
374+
* - Sends a `riskChange` notification to `userId` explaining the auto-pause.
375+
*
376+
* When neither metric is breached the portfolio is left unchanged and no
377+
* notification is sent.
378+
*
379+
* @param portfolioId The portfolio to evaluate and potentially pause.
380+
* @param allocations Current target allocations (weights or percentages).
381+
* @param prices Latest price data used for risk calculation.
382+
* @param userId Stellar address of the portfolio owner – used for notifications.
383+
* @returns A `CVaRVaRCheckResult` describing the outcome.
384+
*/
385+
async checkCVaRVaRThresholdsAndAutoPause(
386+
portfolioId: string,
387+
allocations: Record<string, number>,
388+
prices: PricesMap,
389+
userId: string
390+
): Promise<CVaRVaRCheckResult> {
391+
const riskMetrics = this.analyzePortfolioRisk(allocations, prices)
392+
393+
// Only run the threshold check once we have enough data for reliable stats
394+
if (riskMetrics.sampleSize < this.MIN_RETURNS_FOR_STATS) {
395+
logger.debug('[RISK] Skipping CVaR/VaR auto-pause check – insufficient sample size', {
396+
portfolioId,
397+
sampleSize: riskMetrics.sampleSize,
398+
minRequired: this.MIN_RETURNS_FOR_STATS
399+
})
400+
return { portfolioId, breached: false, paused: false, riskMetrics }
401+
}
402+
403+
// Determine which metric (if any) is breached – VaR is checked first
404+
let breachType: 'VAR_BREACH' | 'CVAR_BREACH' | undefined
405+
let measuredValue: number | undefined
406+
let threshold: number | undefined
407+
408+
if (riskMetrics.var95 > this.autoPauseVar95Threshold) {
409+
breachType = 'VAR_BREACH'
410+
measuredValue = riskMetrics.var95
411+
threshold = this.autoPauseVar95Threshold
412+
} else if (riskMetrics.cvar95 > this.autoPauseCvar95Threshold) {
413+
breachType = 'CVAR_BREACH'
414+
measuredValue = riskMetrics.cvar95
415+
threshold = this.autoPauseCvar95Threshold
416+
}
417+
418+
if (!breachType) {
419+
// All clear – no action needed
420+
return { portfolioId, breached: false, paused: false, riskMetrics }
421+
}
422+
423+
// ── Breach detected ──────────────────────────────────────────────────────
424+
const pausedUntil = new Date(Date.now() + this.autoPauseDurationMs).toISOString()
425+
426+
logger.warn('[RISK] CVaR/VaR threshold breached – auto-pausing portfolio', {
427+
portfolioId,
428+
breachType,
429+
measuredValue,
430+
threshold,
431+
pausedUntil
432+
})
433+
434+
// 1. Persist the pause flag on the portfolio
435+
try {
436+
const portfolio = await portfolioStorage.getPortfolio(portfolioId)
437+
if (portfolio) {
438+
await portfolioStorage.updatePortfolio(portfolioId, { riskPausedUntil: pausedUntil })
439+
} else {
440+
logger.warn('[RISK] Portfolio not found for auto-pause', { portfolioId })
441+
}
442+
} catch (err) {
443+
logger.error('[RISK] Failed to persist auto-pause on portfolio', {
444+
portfolioId,
445+
error: err instanceof Error ? err.message : String(err)
446+
})
447+
}
448+
449+
// 2. Send a notification explaining the pause
450+
try {
451+
const metricLabel = breachType === 'VAR_BREACH' ? 'VaR(95)' : 'CVaR(95)'
452+
const measuredPct = `${(measuredValue! * 100).toFixed(2)}%`
453+
const thresholdPct = `${(threshold! * 100).toFixed(2)}%`
454+
455+
const payload = buildNotificationPayload(userId, {
456+
eventType: 'riskChange',
457+
data: {
458+
portfolioId,
459+
oldLevel: riskMetrics.overallRiskLevel,
460+
newLevel: 'critical',
461+
autoPause: {
462+
reason: breachType,
463+
measuredValue: measuredPct,
464+
threshold: thresholdPct,
465+
pausedUntil
466+
}
467+
}
468+
})
469+
470+
await notificationService.notify(payload)
471+
472+
logger.info('[RISK] Auto-pause notification sent', {
473+
portfolioId,
474+
userId,
475+
metricLabel,
476+
measuredValue: measuredPct,
477+
threshold: thresholdPct
478+
})
479+
} catch (err) {
480+
// Notification failure must never prevent the pause from being recorded
481+
logger.error('[RISK] Failed to send auto-pause notification', {
482+
portfolioId,
483+
userId,
484+
error: err instanceof Error ? err.message : String(err)
485+
})
486+
}
487+
488+
return {
489+
portfolioId,
490+
breached: true,
491+
breachType,
492+
measuredValue,
493+
threshold,
494+
paused: true,
495+
riskMetrics
496+
}
497+
}
498+
337499
getCircuitBreakerStatus(): Record<string, CircuitBreakerStatus> {
338500
const status: Record<string, CircuitBreakerStatus> = {}
339501

0 commit comments

Comments
 (0)