# `ZenQuant.Portfolio`
[🔗](https://github.com/ZenHive/zen_quant/blob/v0.8.1/lib/zen_quant/portfolio.ex#L1)

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
| Function | Arity | Description | Param Kinds |
| --- | --- | --- | --- |
| `position_exposure_summary` | 2 | Aggregate normalized multi-venue positions into portfolio, per-venue, and per-asset exposure. | `positions: value` |
| `realized_pnl` | 1 | Calculate total realized PnL across all positions. | `positions: exchange_data` |
| `unrealized_pnl` | 1 | Calculate total unrealized PnL across all positions. | `positions: exchange_data` |
| `total_exposure` | 1 | Calculate total notional exposure grouped by base asset. | `positions: exchange_data` |

# `asset_slice`

```elixir
@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`

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

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

# `contract_type`

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

Contract type label for linear vs inverse products

# `exposure_error`

```elixir
@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`

```elixir
@type exposure_position() :: %{
  :venue =&gt; venue(),
  :asset =&gt; String.t(),
  :quantity =&gt; number(),
  :settlement_currency =&gt; String.t(),
  optional(:mark) =&gt; number(),
  optional(:notional) =&gt; number(),
  optional(:contract_type) =&gt; contract_type(),
  optional(:unrealized_pnl) =&gt; number(),
  optional(:margin) =&gt; number(),
  optional(:quantity_unit) =&gt; String.t(),
  optional(:symbol) =&gt; 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`

```elixir
@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`

```elixir
@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()) =&gt; map()},
  by_asset: %{required(String.t()) =&gt; asset_slice()},
  leverage: leverage_inputs(),
  concentration: concentration_metrics() | nil
}
```

Full multi-venue position exposure summary

# `leverage_inputs`

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

Leverage inputs derived from portfolio equity when supplied

# `venue`

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

Venue identity (exchange name or account label)

# `position_exposure_summary`

```elixir
@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`

```elixir
@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

```elixir
0.1095
```

```elixir
# 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`

```elixir
@spec total_exposure([map()]) :: %{required(String.t()) =&gt; 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

```elixir
%{"BTC" => 75000.0, "ETH" => 10000.0}
```

```elixir
# 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`

```elixir
@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

```elixir
0.1095
```

```elixir
# 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
}
```

---

*Consult [api-reference.md](api-reference.md) for complete listing*
