Skip to content

feat: Regime-aware risk policy for watchdog thresholds #30

@felipecsl

Description

@felipecsl

Summary

Currently, watchdog thresholds (triggerHF, targetHF, minResultingHF, maxGasGwei) are static — set once via config and unchanged regardless of market conditions. A fixed triggerHF = 1.65 implicitly assumes market risk is constant. It isn't.

Goal: Add a regime-aware risk policy that modestly adjusts bot automation thresholds based on market volatility regime. Display zones for human monitoring stay fixed — the automation layer adapts in the background.

This is not "dynamic HF zones." It's a bounded regime multiplier on the rescue policy, preserving operator clarity while improving capital efficiency in calm markets and safety margins in stressed ones.

Why this makes sense

Liquidation risk is a function of: current HF, asset volatility, correlation structure, HF compression speed, rescue path reliability, and gas/chain conditions. A fixed threshold ignores all of these.

  • In a calm, grinding market, triggerHF = 1.65 may be overly conservative — leaving yield on the table
  • In a violent, gap-prone market, triggerHF = 1.65 may be too loose — not enough buffer

Since our rescue mechanism is collateral top-up (not flash-loan delever), this matters even more: top-up is less efficient per dollar, so in high-vol markets you need more buffer and more time to react.

Where this helps most

Capital efficiency during long benign periods. Right now the same safety margin is carried in sleepy markets, panic markets, trend crashes, and high-gamma chop. A regime multiplier lets you:

  • Run tighter in calm conditions
  • Widen safety margins when risk regime worsens

The main danger: false precision

Volatility is useful but is not the same as liquidation risk. You can have low realized vol then sudden gap risk, declining vol during complacency before a break, or cross-asset divergence that hurts HF more than the vol metric suggests.

So volatility is a small regime-adjustment input, not the boss. The system uses modest bounded multipliers, not continuous optimization.


Design: Bounded Regime Multiplier

Three regimes (not continuous)

Regime Condition Multiplier Range Behavior
calm Annualized vol < calmThreshold (default 40%) 0.97 - 0.99 Tighter thresholds, better capital efficiency
normal Between calm and stressed thresholds 1.00 Base thresholds unchanged
stressed Annualized vol > stressedThreshold (default 80%) 1.03 - 1.08 Wider margins, earlier triggers, looser gas caps

Three discrete regimes are more robust than trying to continuously optimize. Operators can reason about them clearly.

What changes per regime

Example with base triggerHF = 1.70, targetHF = 1.95:

Param Calm Normal Stressed
triggerHF 1.65 (×0.97) 1.70 1.78 (×1.05)
targetHF 1.90 (×0.97) 1.95 2.05 (×1.05)
minResultingHF 1.80 (×0.97) 1.85 1.94 (×1.05)
maxGasGwei 50 (base) 50 75 (×1.50)

These are bounded — multipliers are capped and can never push thresholds below hard safety floors.

What stays fixed

Display zones stay fixed. Humans build intuition around stable ranges. Dynamic zones cause confusion, harder post-event analysis, and operator mistrust.

You still think in: safe / comfort / watch / alert / action / critical — with stable boundaries.

The automation layer (trigger, target, gas) adapts modestly underneath.

Regime transitions: slow and hysteretic

  • Slow-moving vol measure: 24h rolling window, computed from 5-minute price samples
  • Hysteresis band: 5% around regime thresholds to prevent flapping (e.g. must cross 40% to enter calm, must exceed 45% to exit)
  • Minimum dwell time: 30 minutes in a regime before transitioning
  • Rescue system should be boring: no rapid oscillation between regimes

Implementation Plan

Step 1: Core volatility computation

New file: packages/aave-core/src/volatility.ts

export type VolatilityRegime = 'calm' | 'normal' | 'stressed';

export type PriceSample = {
  timestamp: number;
  prices: Record<string, number>;  // symbol -> USD
};

export type VolatilitySnapshot = {
  regime: VolatilityRegime;
  annualizedVol: number;
  sampleCount: number;
  windowMinutes: number;
  updatedAt: number;
};

export type RegimeMultipliers = {
  calm: number;      // e.g. 0.97
  normal: number;    // always 1.0
  stressed: number;  // e.g. 1.05
};

export type RegimePolicyConfig = {
  enabled: boolean;
  // Vol measurement
  windowMinutes: number;          // default 1440 (24h)
  minSamples: number;             // default 12 (~1h before activation)
  // Regime thresholds (annualized vol)
  calmThreshold: number;          // default 0.40
  stressedThreshold: number;      // default 0.80
  hysteresisBand: number;         // default 0.05
  minDwellMs: number;             // default 30 * 60 * 1000
  // Bounded multipliers
  hfMultipliers: RegimeMultipliers;   // applied to triggerHF, targetHF, minResultingHF
  gasMultipliers: RegimeMultipliers;  // applied to maxGasGwei
};

Pure functions:

  • computeAnnualizedVolatility(samples, windowMinutes) — std dev of log returns, annualized
  • classifyRegime(vol, config, currentRegime) — with hysteresis
  • applyRegimeMultiplier(baseValue, regime, multipliers) — bounded application
  • clampThreshold(value, floor) — hard safety floor enforcement

Step 2: Server-side regime tracker

New file: packages/server/src/regimeTracker.ts

Stateful RegimeTracker class:

  • Circular buffer of ~300 price samples (25h at 5min)
  • Persists to data/regime-history.json for crash recovery
  • Tracks current regime with dwell time enforcement
  • Exposes getSnapshot(), getEffectiveWatchdogConfig(baseConfig)

Step 3: Config schema changes

  • packages/aave-core/src/constants.ts — add DEFAULT_REGIME_POLICY_CONFIG
  • packages/aave-core/src/types.ts — add RegimePolicyConfig type
  • packages/server/src/storage.ts — add regimePolicy to AlertConfig
  • packages/server/src/configSchema.ts — add Zod schema

Feature is off by default (enabled: false) — zero impact on existing behavior.

Step 4: Monitor integration

File: packages/server/src/monitor.ts

  • Constructor takes optional RegimeTracker
  • In checkWallet(), after fetchUsdPrices(), call tracker.recordPrices(prices)
  • Zone hydration unchanged — display zones stay fixed
  • Log current regime per poll cycle for observability

Step 5: Watchdog integration

File: packages/server/src/watchdog.ts

  • Add RegimeTracker reference
  • New getEffectiveConfig(): applies regime multipliers to triggerHF, targetHF, minResultingHF, maxGasGwei
  • All other watchdog params unchanged
  • Log effective thresholds when they differ from base

Step 6: API & observability

File: packages/server/src/index.ts

  • GET /api/volatility — current VolatilitySnapshot + effective watchdog thresholds
  • GET /api/status — include volatility field
  • GET/PUT /api/config — include regimePolicy

Step 7: Telegram

File: packages/server/src/statusMessage.ts

  • /status shows current regime and effective trigger/target
  • Example: Regime: calm | Trigger HF: 1.65 (base 1.70 × 0.97)

Files to create/modify

File Action Description
packages/aave-core/src/volatility.ts Create Vol computation, regime classification, multiplier application
packages/aave-core/src/index.ts Modify Re-export volatility module
packages/aave-core/src/constants.ts Modify Add DEFAULT_REGIME_POLICY_CONFIG
packages/aave-core/src/types.ts Modify Add RegimePolicyConfig, VolatilitySnapshot types
packages/server/src/regimeTracker.ts Create Stateful tracker with persistence
packages/server/src/storage.ts Modify Add regimePolicy to config
packages/server/src/configSchema.ts Modify Add Zod schema
packages/server/src/monitor.ts Modify Wire in tracker, log regime
packages/server/src/watchdog.ts Modify Apply regime multipliers to effective config
packages/server/src/index.ts Modify Add /api/volatility endpoint
packages/server/src/statusMessage.ts Modify Show regime in /status
packages/server/test/volatility.test.ts Create Unit tests

Key design principles

  1. Regime-aware policy, not dynamic zones — display zones are fixed for operator clarity; only bot thresholds adapt
  2. Bounded multipliers — small range (0.97-1.08), never below hard safety floors
  3. Three discrete regimes — calm/normal/stressed; more robust than continuous optimization
  4. Slow-moving and hysteretic — 24h vol window, 5% hysteresis band, 30min dwell time
  5. Boring rescue system — modest adaptation, clear bounds, human-readable behavior
  6. Opt-inenabled: false by default, zero impact on existing behavior
  7. Gas policy included — stressed regime loosens gas caps (strong candidate for regime-awareness)

Verification

  • Unit tests: vol computation with known series, regime classification with hysteresis, multiplier bounds, safety floor clamping
  • Integration: simulate price samples, verify regime transitions, verify effective config differs from base
  • Manual: yarn dev:server with regime policy enabled, check logs + Telegram /status
  • Safety: confirm floors enforced, confirm behavior identical when disabled, yarn test passes

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions