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

Market making fundamentals — pure functions for pricing, inventory skew,
spread trend analysis, and fill-rate diagnostics.

Provides:

  * **Micro-price** (`fair_value/3`) — Volume-weighted mid-price from orderbook
    imbalance. When one side of the book is heavier, the fair value shifts
    toward the opposite side (anticipating where price will move).

  * **Optimal spread** (`spread_calculator/2`) — Simplified Avellaneda-Stoikov
    spread calculation incorporating volatility and inventory risk. Returns
    asymmetric bid/ask offsets that encourage inventory rebalancing.

  * **Spread trend** (`spread_trend/2`) — Widening/tightening summary and
    cross-venue comparison from caller-supplied timestamped BBO snapshots.

  * **Inventory skew** (`inventory_skew/3`) — Symmetric, bounded bid/ask
    shifts from fair value using explicit inventory, target, limit, and
    skew coefficient (no venue/regime constants).

  * **Fill rate** (`fill_rate/3`) — Count- and quantity-based fill rates with
    latency distributions, grouped by venue/cohort, with observation-window
    censoring.

## Terminology

  * **Micro-price** — Fair value estimate that weights the mid by order
    imbalance. Heavier bids → price likely to rise → shift toward ask.
  * **Avellaneda-Stoikov** — Academic market-making model (2008) that derives
    optimal bid/ask quotes from volatility, inventory, and risk aversion.
  * **Inventory skew** — Adjusting quotes to reduce directional exposure.
    Long inventory → lower both quotes (encourage selling, discourage buying).
  * **BBO** — Best bid and offer (top-of-book) snapshot.
  * **Censoring** — Orders still open at the observation-window end contribute
    to fill ratios but never to latency distributions as zero-time fills.

## Example

    orderbook = %{
      bids: [[100.0, 5.0], [99.0, 3.0]],
      asks: [[101.0, 2.0], [102.0, 1.0]]
    }

    ZenQuant.MM.fair_value(orderbook, 2)
    # => 100.46...  (bid-heavy → shifts toward ask)

    ZenQuant.MM.spread_calculator(0.02, inventory: 1.0)
    # => %{spread: ..., bid_offset: ..., ask_offset: ..., inventory_adjustment: ...}

## API Functions
| Function | Arity | Description | Param Kinds |
| --- | --- | --- | --- |
| `fill_rate` | 3 | Compute count/quantity fill rates and latency stats by venue and cohort. | `records: value`, `window: value`, `opts: value` |
| `inventory_skew` | 3 | Shift bid/ask around fair value from inventory deviation, limit, and skew coefficient. | `fair_value: value`, `base_spread: value`, `opts: value` |
| `spread_trend` | 2 | Summarize widening/tightening and cross-venue BBO spread differences. | `snapshots: value`, `opts: value` |
| `spread_calculator` | 2 | Calculate optimal bid-ask spread using simplified Avellaneda-Stoikov model. | `volatility: value` |
| `fair_value` | 3 | Calculate micro-price from orderbook imbalance. | `orderbook: exchange_data`, `depth: value`, `opts: value` |

# `bbo_snapshot`

```elixir
@type bbo_snapshot() :: %{
  venue: atom() | String.t(),
  timestamp: DateTime.t() | integer(),
  bid: number(),
  ask: number()
}
```

Timestamped best-bid/offer snapshot for `spread_trend/2`.

## Required fields

  * `:venue` — venue identity (atom or string)
  * `:timestamp` — `DateTime.t()` or Unix epoch milliseconds (integer)
  * `:bid` — best bid price in **quote currency price units** (same units as
    the instrument's mark/last; e.g. USDT for `BTC/USDT`)
  * `:ask` — best ask price in the same price units as `:bid`

# `cross_venue_spread`

```elixir
@type cross_venue_spread() :: %{
  venue_a: atom() | String.t(),
  venue_b: atom() | String.t(),
  absolute_diff: float(),
  relative_diff: float() | nil
}
```

Cross-venue spread difference from each venue's latest included snapshot

# `fill_rate_group`

```elixir
@type fill_rate_group() :: %{
  venue: atom() | String.t() | nil,
  cohort: term(),
  eligible_count: non_neg_integer(),
  filled_count: non_neg_integer(),
  partial_count: non_neg_integer(),
  cancelled_count: non_neg_integer(),
  unfilled_count: non_neg_integer(),
  censored_count: non_neg_integer(),
  eligible_quantity: float(),
  filled_quantity: float(),
  fill_ratio_count: float() | nil,
  fill_ratio_quantity: float() | nil,
  time_to_first_fill: latency_stats(),
  time_to_complete: latency_stats()
}
```

Fill-rate metrics for one venue/cohort group

# `inventory_skew_result`

```elixir
@type inventory_skew_result() :: %{
  fair_value: float(),
  base_spread: float(),
  half_spread: float(),
  inventory: float(),
  target_inventory: float(),
  inventory_limit: float(),
  skew_coefficient: float(),
  deviation: float(),
  normalized_deviation: float(),
  skew_shift: float(),
  bid: float(),
  ask: float(),
  adjusted_spread: float(),
  clamped?: boolean()
}
```

Inventory-skewed quote result with diagnostics

# `latency_stats`

```elixir
@type latency_stats() :: %{
  count: non_neg_integer(),
  mean_ms: float() | nil,
  min_ms: float() | nil,
  max_ms: float() | nil,
  p50_ms: float() | nil,
  p95_ms: float() | nil
}
```

Latency distribution over observed (non-censored) samples

# `spread_result`

```elixir
@type spread_result() :: %{
  spread: float(),
  bid_offset: float(),
  ask_offset: float(),
  inventory_adjustment: float()
}
```

Optimal spread calculation result

# `venue_spread_trend`

```elixir
@type venue_spread_trend() :: %{
  venue: atom() | String.t(),
  snapshot_count: non_neg_integer(),
  crossed_count: non_neg_integer(),
  stale_count: non_neg_integer(),
  absolute_spread: float(),
  relative_spread: float() | nil,
  absolute_change: float(),
  relative_change: float() | nil,
  direction: :widening | :tightening | :unchanged,
  first_timestamp_ms: integer(),
  last_timestamp_ms: integer()
}
```

Per-venue spread trend summary

# `fair_value`

```elixir
@spec fair_value(map(), pos_integer(), keyword()) ::
  float() | nil | ZenQuant.OrderBook.level_error()
```

Calculate micro-price from orderbook imbalance.

## Parameters

  * `orderbook` - Map with bids and asks levels following the ZenQuant.OrderBook contract (exchange_data)
  * `depth` - Number of levels to include from each side (value)
  * `opts` - Reserved for future use (default: `[]`, value)

## Returns

Micro-price, or nil if book is empty/one-sided/invalid (`float`)

### Example

```elixir
0.1095
```

## Errors

  * `:invalid_orderbook_level`

```elixir
# descripex:contract
%{
  params: %{
    opts: %{default: [], description: "Reserved for future use", kind: :value},
    depth: %{
      description: "Number of levels to include from each side",
      kind: :value
    },
    orderbook: %{
      description: "Map with bids and asks levels following the ZenQuant.OrderBook contract",
      source: "fetch_order_book(symbol)",
      kind: :exchange_data
    }
  },
  errors: [:invalid_orderbook_level],
  returns: %{
    type: :float,
    description: "Micro-price, or nil if book is empty/one-sided/invalid"
  },
  returns_example: 0.1095
}
```

# `fill_rate`

```elixir
@spec fill_rate([map()], map(), keyword()) ::
  {:ok,
   %{
     groups: [fill_rate_group()],
     window: %{start_ms: integer(), end_ms: integer()},
     order_count: non_neg_integer()
   }}
  | {:error, :invalid_window | :invalid_record}
```

Analyze fill rates from caller-supplied order lifecycle records.

## Input contract

Accepts either **order-level** maps or **event rows** (deduped by `order_id` +
`event` + `timestamp` + `quantity`).

### Order-level fields (preferred)

Order-level maps are state snapshots already censored to `window.end`: quantities
and statuses must describe what was known at that boundary. Use event rows when
reconstructing a historical window from a longer lifecycle.

  * `:order_id` (required)
  * `:venue` (required for grouping; may be `nil` if using other cohorts only)
  * `:cohort` (optional; key configurable via `:cohort_key`)
  * `:quantity` — eligible size (`> 0`)
  * `:filled_quantity` — filled size (`>= 0`, capped at quantity)
  * `:submitted_at` — submission time (`DateTime` or Unix ms)
  * `:first_fill_at` — optional first fill time
  * `:completed_at` — optional full-fill completion time
  * `:cancelled_at` — optional cancel time
  * `:status` — optional `:open | :partial | :filled | :cancelled` (derived when absent)

### Event rows

Maps with `:order_id`, `:venue`, optional cohort, `:event` in
`:submitted | :fill | :cancel | :complete`, `:timestamp`, and `:quantity`
(order size on submit, fill size on fill). Duplicate identical events are ignored.
Events after `window.end` are excluded.

## Eligibility, partials, cancels, censoring

  * **Eligible** — order submitted inside `[window.start, window.end]` with positive quantity.
  * **Partial** — `0 < filled_quantity < quantity` at window end (or cancelled partial).
  * **Filled** — `filled_quantity >= quantity` (within tolerance) by window end.
  * **Cancelled** — cancel observed; remaining unfilled qty stays unfilled (partial cancel ok).
  * **Unfilled** — zero fills and not cancelled (still open) or cancelled with zero fills.
  * **Censored** — still open (not fully filled, not cancelled) at `window.end`. Censored orders
    count toward eligible/filled quantity ratios but **never** enter latency stats as zero-time
    fills. Time-to-first-fill uses only orders with a real first fill timestamp; time-to-complete
    uses only fully completed (non-censored) fills with a real completion timestamp. Missing
    timestamps are never synthesized as zero-latency fills.

## Output

Groups by venue + cohort (configurable). Each group separates:

  * count-based `fill_ratio_count` = fully-filled count / eligible count
  * quantity-based `fill_ratio_quantity` = filled_quantity / eligible_quantity
  * `time_to_first_fill` / `time_to_complete` distributions (`count`, `mean_ms`,
    `min_ms`, `max_ms`, `p50_ms`, `p95_ms`) over non-censored observed samples only

# `inventory_skew`

```elixir
@spec inventory_skew(number(), number(), keyword()) ::
  {:ok, inventory_skew_result()}
  | {:error,
     :invalid_fair_value
     | :invalid_spread
     | :invalid_inventory
     | :invalid_target
     | :invalid_limit
     | :invalid_skew_coefficient
     | :missing_option}
```

Calculate inventory-aware bid/ask quotes around fair value.

## Inputs (all explicit — no venue/regime constants)

  * `fair_value` — center price
  * `base_spread` — full width (must be `> 0`)
  * opts:
    * `:inventory` — current inventory (positive = long)
    * `:target_inventory` — desired inventory (default `0.0`)
    * `:inventory_limit` — positive bound used to normalize deviation; deviations
      beyond ±limit are **clamped** to ±1
    * `:skew_coefficient` — non-negative strength in `[0, ∞)`; `1.0` shifts by a
      full half-spread at the inventory limit

## Skew rule (symmetric, ordered)

    deviation = inventory - target_inventory
    normalized = clamp(deviation / inventory_limit, -1, 1)
    skew_shift = skew_coefficient * normalized * (base_spread / 2)
    bid = fair_value - half_spread - skew_shift
    ask = fair_value + half_spread - skew_shift

Long excess (`deviation > 0`) produces positive `skew_shift`: both quotes move
down → selling more attractive (lower ask), buying less attractive (lower bid).
Short excess does the inverse. At zero deviation the base quote is unchanged.

Adjusted quotes always keep `ask - bid == base_spread` (symmetric width preserved).

# `spread_calculator`

```elixir
@spec spread_calculator(
  number(),
  keyword()
) :: spread_result() | nil
```

Calculate optimal bid-ask spread using simplified Avellaneda-Stoikov model.

## Parameters

  * `volatility` - Current volatility estimate (>= 0) (value)

## Options

  * `inventory` - Net position, positive = long (default: `0.0`)
  * `risk_aversion` - Higher = wider spreads (default: `1.0`)
  * `time_horizon` - Same units as volatility (default: `1.0`)
  * `gamma` - Inventory penalty sensitivity (default: `0.1`)

## Returns

Map with :spread, :bid_offset, :ask_offset, :inventory_adjustment, or nil if invalid opts (`map`)

### Example

```elixir
%{spread: 18.5, bid_offset: 9.6, ask_offset: 8.9, inventory_adjustment: 0.7}
```

```elixir
# descripex:contract
%{
  opts: %{
    gamma: %{
      default: 0.1,
      type: :float,
      description: "Inventory penalty sensitivity"
    },
    inventory: %{
      default: 0.0,
      type: :float,
      description: "Net position, positive = long"
    },
    risk_aversion: %{
      default: 1.0,
      type: :float,
      description: "Higher = wider spreads"
    },
    time_horizon: %{
      default: 1.0,
      type: :float,
      description: "Same units as volatility"
    }
  },
  params: %{
    volatility: %{
      description: "Current volatility estimate (>= 0)",
      kind: :value
    }
  },
  returns: %{
    type: :map,
    description: "Map with :spread, :bid_offset, :ask_offset, :inventory_adjustment, or nil if invalid opts"
  },
  returns_example: %{
    spread: 18.5,
    bid_offset: 9.6,
    ask_offset: 8.9,
    inventory_adjustment: 0.7
  }
}
```

# `spread_trend`

```elixir
@spec spread_trend(
  [bbo_snapshot()],
  keyword()
) ::
  {:ok,
   %{
     venues: [venue_spread_trend()],
     cross_venue: [cross_venue_spread()],
     window: %{start_ms: integer(), end_ms: integer()},
     reordered?: boolean()
   }}
  | {:error, :insufficient_snapshots | :invalid_snapshot | :invalid_window}
```

Summarize absolute/relative spread levels and changes from BBO snapshots.

## Input contract

Each snapshot is a plain map (atom or string keys):

  * `:venue` — venue id
  * `:timestamp` — `DateTime` or Unix ms integer
  * `:bid` / `:ask` — best bid/ask in **quote-currency price units** (same units
    as exchange top-of-book prices; not bps, not ticks)

The caller owns collection, retention, and ordering. Snapshots may mix venues.

## Window options

  * `:window_start_ms` / `:window_end_ms` — inclusive analysis window in Unix ms.
    Defaults to the min/max timestamp among valid snapshots.
  * `:max_age_ms` — optional staleness bound relative to the window end. Snapshots
    older than `window_end_ms - max_age_ms` are counted as `:stale` and excluded
    from spread math.
  * `:include_crossed` — when `true` (default), crossed books (`ask < bid`) keep a
    signed absolute spread (`ask - bid` < 0) and contribute to trend math. When
    `false`, crossed snapshots are dropped from math but still counted.

## Documented edge behavior

  * **Unordered** — timestamps out of order are sorted ascending; result includes
    `reordered?: true`.
  * **Insufficient** — fewer than two valid (non-stale, included) snapshots overall
    → `{:error, :insufficient_snapshots}`. A venue with a single snapshot reports
    zero change and `:unchanged` direction.
  * **Sparse** — venues are independent; a venue may appear with one snapshot while
    another has many. Cross-venue diffs use each venue's latest included snapshot.
  * **Crossed** — signed spread; relative spread uses `mid = (bid+ask)/2` when mid ≠ 0,
    else `nil`.
  * **Invalid snapshot fields** — `{:error, :invalid_snapshot}`.

## Output

`{:ok, result}` where each venue entry reports latest absolute/relative spread,
absolute/relative change over that venue's first→last included snapshot, and
`:widening | :tightening | :unchanged`. `cross_venue` lists pairwise absolute
(and relative when both defined) latest-spread differences.

---

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