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 |
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 |
sharpe_ratio | 2 | Calculate Sharpe ratio (excess return per unit of total risk). | returns: 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 |
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).
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 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
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
}
@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
}
@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"}]}
}
@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}
}
@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
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
}
@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
}
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
}
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
}
@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"}
]
}}
}
@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
}