11import type { PricesMap , RiskHeatmap } from '../types/index.js'
22import { assetRegistryService } from './assetRegistryService.js'
33import { logger } from '../utils/logger.js'
4+ import { notificationService } from './notificationService.js'
5+ import { buildNotificationPayload } from './notificationTemplates.js'
6+ import { portfolioStorage } from './portfolioStorage.js'
47
58export 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+
4870type ReturnPoint = { value : number , timestamp : number }
4971type 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