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 |
Summary
Types
Timestamped best-bid/offer snapshot for spread_trend/2.
Cross-venue spread difference from each venue's latest included snapshot
Fill-rate metrics for one venue/cohort group
Inventory-skewed quote result with diagnostics
Latency distribution over observed (non-censored) samples
Optimal spread calculation result
Per-venue spread trend summary
Functions
Calculate micro-price from orderbook imbalance.
Analyze fill rates from caller-supplied order lifecycle records.
Calculate inventory-aware bid/ask quotes around fair value.
Calculate optimal bid-ask spread using simplified Avellaneda-Stoikov model.
Summarize absolute/relative spread levels and changes from BBO snapshots.
Types
@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 forBTC/USDT):ask— best ask price in the same price units as:bid
@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
@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
@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
@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
@type spread_result() :: %{ spread: float(), bid_offset: float(), ask_offset: float(), inventory_adjustment: float() }
Optimal spread calculation result
@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
Functions
@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
0.1095Errors
:invalid_orderbook_level
# 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
}
@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 benilif 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 (DateTimeor 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 < quantityat 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_completedistributions (count,mean_ms,min_ms,max_ms,p50_ms,p95_ms) over non-censored observed samples only
@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 pricebase_spread— full width (must be> 0)- opts:
:inventory— current inventory (positive = long):target_inventory— desired inventory (default0.0):inventory_limit— positive bound used to normalize deviation; deviations beyond ±limit are clamped to ±1:skew_coefficient— non-negative strength in[0, ∞);1.0shifts 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_shiftLong 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).
@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
%{spread: 18.5, bid_offset: 9.6, ask_offset: 8.9, inventory_adjustment: 0.7}# 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
}
}
@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—DateTimeor 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 thanwindow_end_ms - max_age_msare counted as:staleand excluded from spread math.:include_crossed— whentrue(default), crossed books (ask < bid) keep a signed absolute spread (ask - bid< 0) and contribute to trend math. Whenfalse, 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:unchangeddirection. - 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)/2when mid ≠ 0, elsenil. - 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.