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
| Function | Arity | Description | Param Kinds |
|---|---|---|---|
exposure_by_asset | 1 | Group positions by base asset and compute delta-weighted exposure per asset. | positions: value |
portfolio_delta | 1 | Calculate net delta exposure across all positions. | positions: value |
calmar_ratio | 2 | Calculate Calmar ratio (annualized return / max drawdown). | returns: value, periods_per_year: value |
max_drawdown | 2 | Calculate maximum drawdown from equity curve or returns. | values: value |
sortino_ratio | 3 | Calculate Sortino ratio (excess return per unit of downside risk). | returns: value, risk_free_rate: value, target_return: value |
sharpe_ratio | 2 | Calculate Sharpe ratio (excess return per unit of total risk). | returns: value, risk_free_rate: value |
rolling_correlation | 3 | Pearson correlation over a caller-supplied sliding window of two return series. | returns_a: value, returns_b: value, window: value |
correlation | 2 | Pearson correlation of two equal-length return series. | returns_a: value, returns_b: value |
beta | 2 | Calculate portfolio beta to benchmark. | portfolio_returns: value, benchmark_returns: value |
var | 4 | Calculate parametric Value at Risk (VaR) assuming normal distribution. | position_value: value, volatility: value, confidence: value, days: value |
stress_test | 3 | Apply caller-supplied asset price shocks to portfolio positions. | positions: value, scenarios: value, opts: value |
liquidation_headroom | 2 | Calculate liquidation-price and maintenance-margin headroom. | position: value, margin_inputs: value |
check_limits | 2 | Check if positions comply with risk limits. | positions: value, limits: value |
max_position_size | 2 | Calculate maximum position size based on risk limits. | account_size: value |
concentration | 1 | Calculate 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 for portfolio analysis
@type delta_position() :: %{ optional(:symbol) => String.t() | nil, optional(:delta) => number() | nil, optional(:notional) => number() | nil }
Position with delta exposure for directional risk calculations
@type liquidation_position() :: %{ side: :long | :short, margin_mode: :isolated | :cross, mark_price: number(), liquidation_price: number() }
Caller-supplied liquidation position 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
@type shock_scenario() :: %{ asset: term(), type: :relative | :absolute, value: number(), units: :fraction | :price }
Price shock applied to one asset
@type stress_metrics() :: %{ baseline_exposure: float(), stressed_exposure: float(), exposure_change: float(), pnl: float() }
Aggregated baseline, stressed exposure, and P&L metrics
@type stress_position() :: %{ :asset => term(), :venue => term(), :kind => atom(), optional(:quantity) => number(), optional(:mark_price) => number() }
Position accepted by stress_test/3
Position with value for risk calculations
@type violation() :: {:max_position, String.t() | nil, number(), number()} | {:max_concentration, float(), float()} | {:max_total_exposure, number(), number()}
Risk limit violation tuple
Functions
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
}
@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
}
@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"}]}
}
@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}
}
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
}
@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}
}
@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: []
}}
}
@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}
}
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
}
@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
}
@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]
}
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
}
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
}
@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"}
]
}}
}
@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
}