ZenQuant.Portfolio (zen_quant v0.2.0)

Copy Markdown View Source

Portfolio-level aggregation helpers for positions.

Pure functions for calculating total exposure, unrealized PnL, and multi-venue position exposure summaries. Works with plain maps containing position data — no account fetch or retained state.

Example

positions = [
  %{symbol: "BTC/USDT:USDT", notional: 50_000.0, unrealized_pnl: 1000.0},
  %{symbol: "BTC/USDT:USDT", notional: 25_000.0, unrealized_pnl: -500.0},
  %{symbol: "ETH/USDT:USDT", notional: 10_000.0, unrealized_pnl: 200.0}
]

ZenQuant.Portfolio.total_exposure(positions)
# => %{"BTC" => 75_000.0, "ETH" => 10_000.0}

ZenQuant.Portfolio.unrealized_pnl(positions)
# => 700.0

API Functions

FunctionArityDescriptionParam Kinds
position_exposure_summary2Aggregate normalized multi-venue positions into portfolio, per-venue, and per-asset exposure.positions: value
realized_pnl1Calculate total realized PnL across all positions.positions: exchange_data
unrealized_pnl1Calculate total unrealized PnL across all positions.positions: exchange_data
total_exposure1Calculate total notional exposure grouped by base asset.positions: exchange_data

Summary

Types

Per-asset slice including contract-type labels present in the group

Concentration metrics (max weight, HHI, top-3) over asset gross notionals

Contract type label for linear vs inverse products

Structured error reasons from position_exposure_summary/2

Normalized position for multi-venue exposure aggregation.

Gross/net exposure slice with quantity, notional, P&L, and margin

Full multi-venue position exposure summary

Leverage inputs derived from portfolio equity when supplied

Venue identity (exchange name or account label)

Functions

Aggregate normalized, pre-fetched positions into one exposure summary.

Calculate total realized PnL across all positions.

Calculate total notional exposure grouped by base asset.

Calculate total unrealized PnL across all positions.

Types

asset_slice()

@type asset_slice() :: %{
  gross_quantity: float(),
  net_quantity: float(),
  gross_notional: float(),
  net_notional: float(),
  unrealized_pnl: float(),
  margin_used: float(),
  contract_types: [contract_type()],
  quantity_unit: String.t()
}

Per-asset slice including contract-type labels present in the group

concentration_metrics()

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

Concentration metrics (max weight, HHI, top-3) over asset gross notionals

contract_type()

@type contract_type() :: :linear | :inverse

Contract type label for linear vs inverse products

exposure_error()

@type exposure_error() ::
  {:missing_field, atom()}
  | :missing_mark
  | :missing_notional
  | :invalid_mark
  | :invalid_quantity
  | :invalid_quantity_unit
  | :invalid_notional
  | :invalid_contract_type
  | :invalid_unrealized_pnl
  | :invalid_margin
  | :missing_reporting_currency
  | {:missing_fx_rate, String.t()}
  | {:incompatible_units, String.t(), [String.t()]}
  | :invalid_equity
  | :invalid_fx_rate

Structured error reasons from position_exposure_summary/2

exposure_position()

@type exposure_position() :: %{
  :venue => venue(),
  :asset => String.t(),
  :quantity => number(),
  :settlement_currency => String.t(),
  optional(:mark) => number(),
  optional(:notional) => number(),
  optional(:contract_type) => contract_type(),
  optional(:unrealized_pnl) => number(),
  optional(:margin) => number(),
  optional(:quantity_unit) => String.t(),
  optional(:symbol) => String.t()
}

Normalized position for multi-venue exposure aggregation.

Required fields

  • :venue — venue identity (atom or string); kept in venue breakdowns
  • :asset — base asset id (string), e.g. "BTC"; aggregation key for per-asset nets
  • :quantity — signed quantity in base-asset units (positive = long, negative = short)
  • :settlement_currency — currency of notional / unrealized P&L / margin for this row

Valuation

  • :mark — positive mark price in settlement_currency per quantity_unit. A linear row may omit :notional, in which case notional is abs(quantity) * mark.
  • :notional — absolute notional magnitude in settlement_currency. Required for inverse rows because normalized base-asset quantity does not retain the contract face value or multiplier needed to derive inverse settlement notional.

Optional fields

  • :contract_type:linear (default) or :inverse label
  • :unrealized_pnl — unrealized P&L in settlement currency (default 0.0)
  • :margin — margin used in settlement currency (default 0.0)
  • :quantity_unit — unit label for quantity; defaults to asset. Positions for the same asset must share one unit or aggregation returns {:incompatible_units, asset, units}
  • :symbol — optional display symbol; not used for aggregation keys

exposure_slice()

@type exposure_slice() :: %{
  gross_quantity: float(),
  net_quantity: float(),
  gross_notional: float(),
  net_notional: float(),
  unrealized_pnl: float(),
  margin_used: float()
}

Gross/net exposure slice with quantity, notional, P&L, and margin

exposure_summary()

@type exposure_summary() :: %{
  reporting_currency: String.t() | nil,
  portfolio:
    exposure_slice()
    | %{
        gross_notional: float(),
        net_notional: float(),
        unrealized_pnl: float(),
        margin_used: float()
      },
  by_venue: %{required(String.t()) => map()},
  by_asset: %{required(String.t()) => asset_slice()},
  leverage: leverage_inputs(),
  concentration: concentration_metrics() | nil
}

Full multi-venue position exposure summary

leverage_inputs()

@type leverage_inputs() :: %{
  equity: float() | nil,
  gross_leverage: float() | nil,
  net_leverage: float() | nil
}

Leverage inputs derived from portfolio equity when supplied

venue()

@type venue() :: atom() | String.t()

Venue identity (exchange name or account label)

Functions

position_exposure_summary(positions, opts \\ [])

@spec position_exposure_summary(
  [map()],
  keyword()
) :: {:ok, exposure_summary()} | {:error, exposure_error()}

Aggregate normalized, pre-fetched positions into one exposure summary.

Does not fetch or retain account state. Opposing positions offset net exposure (quantity and signed notional) but remain fully visible in gross exposure and in per-venue breakdowns.

See the module @type exposure_position for the full input contract (units, settlement currency, marks, venue identity).

realized_pnl(positions)

@spec realized_pnl([map()]) :: float()

Calculate total realized PnL across all positions.

Parameters

  • positions - List of maps with :realized_pnl field (exchange_data)

Returns

Sum of realized PnL across all positions (float)

Example

0.1095
# descripex:contract
%{
  params: %{
    positions: %{
      description: "List of maps with :realized_pnl field",
      source: "fetch_positions()",
      kind: :exchange_data
    }
  },
  returns: %{
    type: :float,
    description: "Sum of realized PnL across all positions"
  },
  returns_example: 0.1095
}

total_exposure(positions)

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

Calculate total notional exposure grouped by base asset.

Parameters

  • positions - List of maps with :symbol and :notional fields (exchange_data)

Returns

Map of %{base_asset => total_notional} (map)

Example

%{"BTC" => 75000.0, "ETH" => 10000.0}
# descripex:contract
%{
  params: %{
    positions: %{
      description: "List of maps with :symbol and :notional fields",
      source: "fetch_positions()",
      kind: :exchange_data
    }
  },
  returns: %{type: :map, description: "Map of %{base_asset => total_notional}"},
  returns_example: %{"BTC" => 75000.0, "ETH" => 10000.0}
}

unrealized_pnl(positions)

@spec unrealized_pnl([map()]) :: float()

Calculate total unrealized PnL across all positions.

Parameters

  • positions - List of maps with :unrealized_pnl field (exchange_data)

Returns

Sum of unrealized PnL across all positions (float)

Example

0.1095
# descripex:contract
%{
  params: %{
    positions: %{
      description: "List of maps with :unrealized_pnl field",
      source: "fetch_positions()",
      kind: :exchange_data
    }
  },
  returns: %{
    type: :float,
    description: "Sum of unrealized PnL across all positions"
  },
  returns_example: 0.1095
}