ZenQuant.MM (zen_quant v0.2.0)

Copy Markdown View Source

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

FunctionArityDescriptionParam Kinds
fill_rate3Compute count/quantity fill rates and latency stats by venue and cohort.records: value, window: value, opts: value
inventory_skew3Shift bid/ask around fair value from inventory deviation, limit, and skew coefficient.fair_value: value, base_spread: value, opts: value
spread_trend2Summarize widening/tightening and cross-venue BBO spread differences.snapshots: value, opts: value
spread_calculator2Calculate optimal bid-ask spread using simplified Avellaneda-Stoikov model.volatility: value
fair_value3Calculate 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

bbo_snapshot()

@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)
  • :timestampDateTime.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()

@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()

@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()

@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()

@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()

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

Optimal spread calculation result

venue_spread_trend()

@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

fair_value(orderbook, depth, opts \\ [])

@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.1095

Errors

  • :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
}

fill_rate(records, window, opts \\ [])

@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.
  • Partial0 < filled_quantity < quantity at window end (or cancelled partial).
  • Filledfilled_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(fair_value, base_spread, opts \\ [])

@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(volatility, opts \\ [])

@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
  }
}

spread_trend(snapshots, opts \\ [])

@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
  • :timestampDateTime 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.