ZenQuant.Risk (zen_quant v0.11.2)

Copy Markdown View Source

Position risk analysis for trading systems.

Pure functions for analyzing portfolio concentration, position limits, and risk metrics.

Example

positions = [
  %{symbol: "BTC/USDT", value: 50_000},
  %{symbol: "ETH/USDT", value: 30_000},
  %{symbol: "SOL/USDT", value: 20_000}
]

ZenQuant.Risk.concentration(positions)
# => %{max: 0.5, hhi: 0.38, top3: 1.0}

API Functions

FunctionArityDescriptionParam Kinds
exposure_by_asset1Group positions by base asset and compute delta-weighted exposure per asset.positions: value
portfolio_delta1Calculate net delta exposure across all positions.positions: value
calmar_ratio2Calculate Calmar ratio (annualized return / max drawdown).returns: value, periods_per_year: value
max_drawdown2Calculate maximum drawdown from equity curve or returns.values: value
sortino_ratio3Calculate Sortino ratio (excess return per unit of downside risk).returns: value, risk_free_rate: value, target_return: value
sharpe_ratio2Calculate Sharpe ratio (excess return per unit of total risk).returns: value, risk_free_rate: value
rolling_correlation3Pearson correlation over a caller-supplied sliding window of two return series.returns_a: value, returns_b: value, window: value
correlation2Pearson correlation of two equal-length return series.returns_a: value, returns_b: value
beta2Calculate portfolio beta to benchmark.portfolio_returns: value, benchmark_returns: value
var4Calculate parametric Value at Risk (VaR) assuming normal distribution.position_value: value, volatility: value, confidence: value, days: value
stress_test3Apply caller-supplied asset price shocks to portfolio positions.positions: value, scenarios: value, opts: value
liquidation_headroom2Calculate liquidation-price and maintenance-margin headroom.position: value, margin_inputs: value
check_limits2Check if positions comply with risk limits.positions: value, limits: value
max_position_size2Calculate maximum position size based on risk limits.account_size: value
concentration1Calculate portfolio concentration metrics (max weight, HHI, top-3).positions: value

Summary

Types

Concentration metrics for portfolio analysis

Position with delta exposure for directional risk calculations

Caller-supplied liquidation position inputs

Caller-supplied collateral, maintenance, and alert threshold inputs

Price shock applied to one asset

Aggregated baseline, stressed exposure, and P&L metrics

Position accepted by stress_test/3

Position with value for risk calculations

Risk limit violation tuple

Functions

Calculate portfolio beta to benchmark.

Calculate Calmar ratio (annualized return / max drawdown).

Check if positions comply with risk limits.

Calculate portfolio concentration metrics (max weight, HHI, top-3).

Pearson correlation of two equal-length return series.

Group positions by base asset and compute delta-weighted exposure per asset.

Calculate liquidation-price and maintenance-margin headroom.

Calculate maximum drawdown from equity curve or returns.

Calculate maximum position size based on risk limits.

Calculate net delta exposure across all positions.

Pearson correlation over a caller-supplied sliding window of two return series.

Calculate Sharpe ratio (excess return per unit of total risk).

Calculate Sortino ratio (excess return per unit of downside risk).

Apply caller-supplied asset price shocks to portfolio positions.

Calculate parametric Value at Risk (VaR) assuming normal distribution.

Types

concentration_metrics()

@type concentration_metrics() :: %{max: float(), hhi: float(), top3: float()}

Concentration metrics for portfolio analysis

delta_position()

@type delta_position() :: %{
  optional(:symbol) => String.t() | nil,
  optional(:delta) => number() | nil,
  optional(:notional) => number() | nil
}

Position with delta exposure for directional risk calculations

liquidation_position()

@type liquidation_position() :: %{
  side: :long | :short,
  margin_mode: :isolated | :cross,
  mark_price: number(),
  liquidation_price: number()
}

Caller-supplied liquidation position inputs

margin_inputs()

@type margin_inputs() :: %{
  :collateral => number(),
  :maintenance_requirement => number(),
  optional(:margin_mode) => :isolated | :cross,
  optional(:liquidation_threshold_pct) => number(),
  optional(:maintenance_threshold_pct) => number()
}

Caller-supplied collateral, maintenance, and alert threshold inputs

shock_scenario()

@type shock_scenario() :: %{
  asset: term(),
  type: :relative | :absolute,
  value: number(),
  units: :fraction | :price
}

Price shock applied to one asset

stress_metrics()

@type stress_metrics() :: %{
  baseline_exposure: float(),
  stressed_exposure: float(),
  exposure_change: float(),
  pnl: float()
}

Aggregated baseline, stressed exposure, and P&L metrics

stress_position()

@type stress_position() :: %{
  :asset => term(),
  :venue => term(),
  :kind => atom(),
  optional(:quantity) => number(),
  optional(:mark_price) => number()
}

Position accepted by stress_test/3

valued_position()

@type valued_position() :: %{optional(:symbol) => String.t(), value: number()}

Position with value for risk calculations

violation()

@type violation() ::
  {:max_position, String.t() | nil, number(), number()}
  | {:max_concentration, float(), float()}
  | {:max_total_exposure, number(), number()}

Risk limit violation tuple

Functions

beta(portfolio_returns, benchmark_returns)

@spec beta([number()], [number()]) :: float() | nil

Calculate portfolio beta to benchmark.

Parameters

  • portfolio_returns - List of portfolio returns (value)
  • benchmark_returns - List of benchmark returns (same length) (value)

Returns

Beta coefficient (1.5 = moves 1.5x benchmark), or nil if insufficient data (float)

Example

1.1445783132530118
# descripex:contract
%{
  params: %{
    portfolio_returns: %{description: "List of portfolio returns", kind: :value},
    benchmark_returns: %{
      description: "List of benchmark returns (same length)",
      kind: :value
    }
  },
  returns: %{
    type: :float,
    description: "Beta coefficient (1.5 = moves 1.5x benchmark), or nil if insufficient data"
  },
  returns_example: 1.1445783132530118
}

calmar_ratio(returns, periods_per_year \\ 365)

@spec calmar_ratio([number()], pos_integer()) :: float() | nil

Calculate Calmar ratio (annualized return / max drawdown).

Parameters

  • returns - List of period returns (value)
  • periods_per_year - Periods per year for annualization (default: 365, value)

Returns

Calmar ratio, or nil if insufficient data or zero drawdown (float)

Example

78.21428571428574
# descripex:contract
%{
  params: %{
    returns: %{description: "List of period returns", kind: :value},
    periods_per_year: %{
      default: 365,
      description: "Periods per year for annualization",
      kind: :value
    }
  },
  returns: %{
    type: :float,
    description: "Calmar ratio, or nil if insufficient data or zero drawdown"
  },
  returns_example: 78.21428571428574
}

check_limits(positions, limits)

@spec check_limits([valued_position()], keyword()) ::
  {:ok, [valued_position()]} | {:error, [violation()]}

Check if positions comply with risk limits.

Parameters

  • positions - List of positions with :value field (value)
  • limits - Keyword list with :max_position, :max_concentration, :max_total_exposure (value)

Returns

{:ok, positions} if compliant, {:error, violations} with list of violated limits (tuple)

Example

{:ok, [%{value: 50000.0, symbol: "BTC/USDT"}]}

Errors

  • :max_position
  • :max_concentration
  • :max_total_exposure
# descripex:contract
%{
  params: %{
    positions: %{
      description: "List of positions with :value field",
      kind: :value
    },
    limits: %{
      description: "Keyword list with :max_position, :max_concentration, :max_total_exposure",
      kind: :value
    }
  },
  errors: [:max_position, :max_concentration, :max_total_exposure],
  returns: %{
    type: :tuple,
    description: "{:ok, positions} if compliant, {:error, violations} with list of violated limits"
  },
  returns_example: {:ok, [%{value: 50000.0, symbol: "BTC/USDT"}]}
}

concentration(positions)

@spec concentration([valued_position()]) :: concentration_metrics() | nil

Calculate portfolio concentration metrics (max weight, HHI, top-3).

Parameters

  • positions - List of positions with :value field (absolute value) (value)

Returns

Map with :max (largest weight), :hhi (Herfindahl-Hirschman), :top3 (top 3 combined), or nil (map)

Example

%{max: 0.6, hhi: 0.445, top3: 1.0}
# descripex:contract
%{
  params: %{
    positions: %{
      description: "List of positions with :value field (absolute value)",
      kind: :value
    }
  },
  returns: %{
    type: :map,
    description: "Map with :max (largest weight), :hhi (Herfindahl-Hirschman), :top3 (top 3 combined), or nil"
  },
  returns_example: %{max: 0.6, hhi: 0.445, top3: 1.0}
}

correlation(returns_a, returns_b)

@spec correlation([number()], [number()]) :: float() | nil

Pearson correlation of two equal-length return series.

Parameters

  • returns_a - Return series (not prices) (value)
  • returns_b - Return series of the same length (value)

Returns

Pearson coefficient in [-1, 1], or nil when lengths differ, either series is empty, or either series has zero variance (float)

Example

-0.42
# descripex:contract
%{
  params: %{
    returns_a: %{description: "Return series (not prices)", kind: :value},
    returns_b: %{description: "Return series of the same length", kind: :value}
  },
  returns: %{
    type: :float,
    description: "Pearson coefficient in [-1, 1], or nil when lengths differ, either series is empty, or either series has zero variance"
  },
  returns_example: -0.42
}

exposure_by_asset(positions)

@spec exposure_by_asset([delta_position()]) :: %{required(String.t()) => float()}

Group positions by base asset and compute delta-weighted exposure per asset.

Parameters

  • positions - List of maps with optional :symbol, :delta, :notional fields (value)

Returns

Map of %{asset => net_delta_exposure} (map)

Example

%{"BTC" => 42500.0, "ETH" => -12000.0}
# descripex:contract
%{
  params: %{
    positions: %{
      description: "List of maps with optional :symbol, :delta, :notional fields",
      kind: :value
    }
  },
  returns: %{type: :map, description: "Map of %{asset => net_delta_exposure}"},
  returns_example: %{"BTC" => 42500.0, "ETH" => -12000.0}
}

liquidation_headroom(position, margin_inputs)

@spec liquidation_headroom(liquidation_position(), margin_inputs()) ::
  {:ok, map()} | {:error, {atom(), atom()}}

Calculate liquidation-price and maintenance-margin headroom.

Parameters

  • position - Map with :side, :margin_mode, :mark_price, and venue-supplied :liquidation_price (value)
  • margin_inputs - Map with :collateral, venue-supplied :maintenance_requirement, and optional percentage thresholds (value)

Returns

{:ok, metrics} with liquidation/maintenance absolute and percentage headroom, status, and threshold breaches (tuple)

Example

{:ok,
 %{
   status: :healthy,
   margin_mode: :isolated,
   liquidation: %{absolute_headroom: 20.0, percentage_headroom: 0.2},
   maintenance: %{
     requirement: 250.0,
     absolute_headroom: 750.0,
     percentage_headroom: 0.75
   },
   threshold_breaches: []
 }}

Errors

  • :missing_field - A required position or margin field is absent
  • :invalid_input - A field has an unsupported type or value
  • :non_positive_input - A price, collateral, or maintenance requirement is not positive
  • :inconsistent_input - Position and margin-input margin modes disagree
# descripex:contract
%{
  params: %{
    position: %{
      description: "Map with :side, :margin_mode, :mark_price, and venue-supplied :liquidation_price",
      kind: :value
    },
    margin_inputs: %{
      description: "Map with :collateral, venue-supplied :maintenance_requirement, and optional percentage thresholds",
      kind: :value
    }
  },
  errors: [
    missing_field: "A required position or margin field is absent",
    invalid_input: "A field has an unsupported type or value",
    non_positive_input: "A price, collateral, or maintenance requirement is not positive",
    inconsistent_input: "Position and margin-input margin modes disagree"
  ],
  returns: %{
    type: :tuple,
    description: "{:ok, metrics} with liquidation/maintenance absolute and percentage headroom, status, and threshold breaches"
  },
  returns_example: {:ok,
   %{
     status: :healthy,
     margin_mode: :isolated,
     liquidation: %{absolute_headroom: 20.0, percentage_headroom: 0.2},
     maintenance: %{
       requirement: 250.0,
       absolute_headroom: 750.0,
       percentage_headroom: 0.75
     },
     threshold_breaches: []
   }}
}

max_drawdown(values, opts \\ [])

@spec max_drawdown([number()], keyword()) ::
  %{
    max_drawdown: float(),
    peak_index: non_neg_integer(),
    trough_index: non_neg_integer()
  }
  | nil

Calculate maximum drawdown from equity curve or returns.

Parameters

  • values - List of portfolio values or cumulative returns (value)

Options

  • type - :values (equity curve) or :returns (will convert) (default: :values)

Returns

Map with :max_drawdown (decimal), :peak_index, :trough_index, or nil (map)

Example

%{max_drawdown: 0.18, peak_index: 12, trough_index: 27}
# descripex:contract
%{
  opts: %{
    type: %{
      default: :values,
      type: :atom,
      description: ":values (equity curve) or :returns (will convert)"
    }
  },
  params: %{
    values: %{
      description: "List of portfolio values or cumulative returns",
      kind: :value
    }
  },
  returns: %{
    type: :map,
    description: "Map with :max_drawdown (decimal), :peak_index, :trough_index, or nil"
  },
  returns_example: %{max_drawdown: 0.18, peak_index: 12, trough_index: 27}
}

max_position_size(account_size, opts \\ [])

@spec max_position_size(number(), keyword()) :: float()

Calculate maximum position size based on risk limits.

Parameters

  • account_size - Total account equity (value)

Options

  • max_position_pct - Max single position as fraction of account (default: 0.2)
  • max_loss_pct - Max loss per position as fraction of account (default: 0.02)
  • expected_drawdown - Expected max drawdown fraction (default: 0.2)

Returns

Maximum position value in account currency (conservative of two limits) (float)

Example

10000.0
# descripex:contract
%{
  opts: %{
    max_position_pct: %{
      default: 0.2,
      type: :float,
      description: "Max single position as fraction of account"
    },
    max_loss_pct: %{
      default: 0.02,
      type: :float,
      description: "Max loss per position as fraction of account"
    },
    expected_drawdown: %{
      default: 0.2,
      type: :float,
      description: "Expected max drawdown fraction"
    }
  },
  params: %{account_size: %{description: "Total account equity", kind: :value}},
  returns: %{
    type: :float,
    description: "Maximum position value in account currency (conservative of two limits)"
  },
  returns_example: 10000.0
}

portfolio_delta(positions)

@spec portfolio_delta([delta_position()]) :: float()

Calculate net delta exposure across all positions.

Parameters

  • positions - List of maps with optional :delta and :notional fields (value)

Returns

Net delta exposure (positive = net long, negative = net short) (float)

Example

33600.00000000001
# descripex:contract
%{
  params: %{
    positions: %{
      description: "List of maps with optional :delta and :notional fields",
      kind: :value
    }
  },
  returns: %{
    type: :float,
    description: "Net delta exposure (positive = net long, negative = net short)"
  },
  returns_example: 33600.00000000001
}

rolling_correlation(returns_a, returns_b, window)

@spec rolling_correlation([number()], [number()], pos_integer()) ::
  [float() | nil] | nil

Pearson correlation over a caller-supplied sliding window of two return series.

Parameters

  • returns_a - Return series (not prices), chronological oldest-first (value)
  • returns_b - Return series of the same length (value)
  • window - Window length in observations; caller-supplied, no default (value)

Returns

Compacted right-aligned coefficients: result[i] is Pearson of both series on [i, i+window), i.e. the window ending at input index i+window-1. Length is n-window+1. Plot against dates[i+window-1] (Enum.drop(dates, window-1)). nil when lengths differ; [] when n < window; nil at an index when that window has zero variance (list)

Example

[0.82, 0.71, -0.15]
# descripex:contract
%{
  params: %{
    window: %{
      description: "Window length in observations; caller-supplied, no default",
      kind: :value
    },
    returns_a: %{
      description: "Return series (not prices), chronological oldest-first",
      kind: :value
    },
    returns_b: %{description: "Return series of the same length", kind: :value}
  },
  returns: %{
    type: :list,
    description: "Compacted right-aligned coefficients: result[i] is Pearson of both series on [i, i+window), i.e. the window ending at input index i+window-1. Length is n-window+1. Plot against dates[i+window-1] (Enum.drop(dates, window-1)). nil when lengths differ; [] when n < window; nil at an index when that window has zero variance"
  },
  returns_example: [0.82, 0.71, -0.15]
}

sharpe_ratio(returns, risk_free_rate \\ 0)

@spec sharpe_ratio([number()], number()) :: float() | nil

Calculate Sharpe ratio (excess return per unit of total risk).

Parameters

  • returns - List of period returns (value)
  • risk_free_rate - Risk-free rate per period (default: 0, value)

Returns

Sharpe ratio, or nil if insufficient data or zero volatility (float)

Example

0.5897678246195885
# descripex:contract
%{
  params: %{
    returns: %{description: "List of period returns", kind: :value},
    risk_free_rate: %{
      default: 0,
      description: "Risk-free rate per period",
      kind: :value
    }
  },
  returns: %{
    type: :float,
    description: "Sharpe ratio, or nil if insufficient data or zero volatility"
  },
  returns_example: 0.5897678246195885
}

sortino_ratio(returns, risk_free_rate \\ 0, target_return \\ 0)

@spec sortino_ratio([number()], number(), number()) :: float() | nil

Calculate Sortino ratio (excess return per unit of downside risk).

Parameters

  • returns - List of period returns (value)
  • risk_free_rate - Risk-free rate per period (default: 0, value)
  • target_return - Minimum acceptable return for downside calc (default: 0, value)

Returns

Sortino ratio, or nil if insufficient data or zero downside deviation (float)

Example

1.2649110640673518
# descripex:contract
%{
  params: %{
    returns: %{description: "List of period returns", kind: :value},
    risk_free_rate: %{
      default: 0,
      description: "Risk-free rate per period",
      kind: :value
    },
    target_return: %{
      default: 0,
      description: "Minimum acceptable return for downside calc",
      kind: :value
    }
  },
  returns: %{
    type: :float,
    description: "Sortino ratio, or nil if insufficient data or zero downside deviation"
  },
  returns_example: 1.2649110640673518
}

stress_test(positions, scenarios, opts)

@spec stress_test([stress_position()], [shock_scenario()], keyword()) ::
  {:ok,
   %{
     scenarios: [shock_scenario()],
     by_asset: %{required(term()) => stress_metrics()},
     by_venue: %{required(term()) => stress_metrics()},
     portfolio: stress_metrics()
   }}
  | {:error, term()}

Apply caller-supplied asset price shocks to portfolio positions.

Parameters

  • positions - Positions with :asset, :venue, and :kind. Linear positions also require :quantity and :mark_price. (value)
  • scenarios - Shocks with :asset, :type (:relative or :absolute), :value, and :units (:fraction or :price) (value)
  • opts - Optional :repricer arity-2 function returning {:ok, %{baseline_exposure: number, stressed_exposure: number}} (value)

Returns

{:ok, result} with scenarios and P&L/exposure grouped by asset, venue, and portfolio (tuple)

Example

{:ok,
 %{
   portfolio: %{
     baseline_exposure: 100.0,
     stressed_exposure: 90.0,
     exposure_change: -10.0,
     pnl: -10.0
   },
   scenarios: [%{type: :relative, value: -0.1, units: :fraction, asset: "BTC"}]
 }}

Errors

  • :missing_field - A required position or scenario field is absent
  • :invalid_input - A position or scenario field is invalid
  • :inconsistent_input - More than one shock targets the same asset
  • :repricing_required - A nonlinear position needs a caller-supplied repricer
# descripex:contract
%{
  params: %{
    opts: %{
      description: "Optional :repricer arity-2 function returning {:ok, %{baseline_exposure: number, stressed_exposure: number}}",
      kind: :value
    },
    positions: %{
      description: "Positions with :asset, :venue, and :kind. Linear positions also require :quantity and :mark_price.",
      kind: :value
    },
    scenarios: %{
      description: "Shocks with :asset, :type (:relative or :absolute), :value, and :units (:fraction or :price)",
      kind: :value
    }
  },
  errors: [
    missing_field: "A required position or scenario field is absent",
    invalid_input: "A position or scenario field is invalid",
    inconsistent_input: "More than one shock targets the same asset",
    repricing_required: "A nonlinear position needs a caller-supplied repricer"
  ],
  returns: %{
    type: :tuple,
    description: "{:ok, result} with scenarios and P&L/exposure grouped by asset, venue, and portfolio"
  },
  returns_example: {:ok,
   %{
     portfolio: %{
       baseline_exposure: 100.0,
       stressed_exposure: 90.0,
       exposure_change: -10.0,
       pnl: -10.0
     },
     scenarios: [
       %{type: :relative, value: -0.1, units: :fraction, asset: "BTC"}
     ]
   }}
}

var(position_value, volatility, confidence \\ 0.95, days \\ 1)

@spec var(number(), number(), float(), pos_integer()) :: float()

Calculate parametric Value at Risk (VaR) assuming normal distribution.

Parameters

  • position_value - Total position value (value)
  • volatility - Daily volatility (standard deviation) as decimal (value)
  • confidence - Confidence level (0-1) (default: 0.95, value)
  • days - Time horizon in days (default: 1, value)

Returns

Maximum expected loss at given confidence level (float)

Example

3290.42288028763
# descripex:contract
%{
  params: %{
    days: %{default: 1, description: "Time horizon in days", kind: :value},
    volatility: %{
      description: "Daily volatility (standard deviation) as decimal",
      kind: :value
    },
    position_value: %{description: "Total position value", kind: :value},
    confidence: %{
      default: 0.95,
      description: "Confidence level (0-1)",
      kind: :value
    }
  },
  returns: %{
    type: :float,
    description: "Maximum expected loss at given confidence level"
  },
  returns_example: 3290.42288028763
}