ZenQuant.Risk (zen_quant v0.2.0)

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
max_drawdown2Calculate maximum drawdown from equity curve or returns.values: value
sortino_ratio3Calculate Sortino ratio (excess return per unit of downside risk).returns: value
sharpe_ratio2Calculate Sharpe ratio (excess return per unit of total risk).returns: 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
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).

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.

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

0.1095
# 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: 0.1095
}

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)

Options

  • periods_per_year - Periods per year for annualization (default: 365)

Returns

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

Example

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

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: %{
    limits: %{
      description: "Keyword list with :max_position, :max_concentration, :max_total_exposure",
      kind: :value
    },
    positions: %{
      description: "List of positions with :value field",
      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: 1.0, hhi: 1.0, 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: 1.0, hhi: 1.0, top3: 1.0}
}

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

0.1095
# 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: 0.1095
}

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

0.1095
# 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: 0.1095
}

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)

Options

  • risk_free_rate - Risk-free rate per period (default: 0)

Returns

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

Example

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

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)

Options

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

Returns

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

Example

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

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}} (default: [], value)

Returns

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

Example

{:ok,
 %{
   portfolio: %{
     baseline_exposure: 100.0,
     pnl: -10.0,
     stressed_exposure: 90.0,
     exposure_change: -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: %{
      default: [],
      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,
       pnl: -10.0,
       stressed_exposure: 90.0,
       exposure_change: -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)

Options

  • confidence - Confidence level (0-1) (default: 0.95)
  • days - Time horizon in days (default: 1)

Returns

Maximum expected loss at given confidence level (float)

Example

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