---
title: "HJB Market Making"
description: "Avellaneda-Stoikov and Gueant-Lehalle-Fernandez-Tapia HJB-based optimal market making with inventory control. Rust-native finite-difference solver with Python pipeline integration."
icon: "chess-knight"
---

<Warning>
  **Ultra Feature.** Requires an Ultra subscription. [Get started at api.mathematicalcompany.com](https://api.mathematicalcompany.com)
</Warning>

<Tip>
  **What is this?** Most market makers post symmetric quotes around fair value, but this ignores inventory risk. The HJB approach solves for the mathematically optimal bid and ask offsets given your current inventory, time horizon, and risk aversion. When you're long, it tightens the ask and widens the bid to encourage selling. Use it for inventory-aware automated market making.
</Tip>

# HJB Market Making

Classical market making (post symmetric quotes around a fair value) ignores a critical variable: your current inventory. As inventory grows, your risk increases, and optimal quotes should shift to encourage mean reversion. The Avellaneda-Stoikov (2008) and Gueant-Lehalle-Fernandez-Tapia (2012) frameworks solve this rigorously using the Hamilton-Jacobi-Bellman equation. Horizon implements the full finite-difference backward induction solver in Rust.

<CardGroup cols={2}>
  <Card title="HJB Solver" icon="calculator">
    `hz.solve_hjb()` solves the HJB PDE on a discretized grid via backward induction, producing the full value function and optimal quote schedule.
  </Card>
  <Card title="Quote Generation" icon="arrows-left-right">
    `hz.hjb_quote()` evaluates the solution at a given inventory and time to produce optimal bid/ask spreads.
  </Card>
  <Card title="Arrival Rate Model" icon="chart-scatter">
    `hz.hjb_arrival_rate()` computes the expected fill rate as a function of quote depth, calibrated to market microstructure.
  </Card>
  <Card title="Pipeline Integration" icon="plug">
    `hz.hjb_market_maker()` runs the full HJB market maker each cycle: solve, quote, and manage inventory.
  </Card>
</CardGroup>

---

## hz.solve_hjb

Solve the HJB partial differential equation for optimal market making on a discretized (inventory, time) grid. The solver uses finite differences with backward induction from the terminal condition.

```python
import horizon as hz

config = hz.HJBConfig(
    sigma=0.30,          # mid-price volatility (annualized)
    gamma=0.1,           # risk aversion parameter
    kappa=1.5,           # order arrival rate decay
    alpha=0.01,          # order arrival rate baseline shift
    max_inventory=10,    # maximum inventory (absolute value)
    t_horizon=1.0,       # trading horizon in days
    n_time_steps=100,    # time grid resolution
    terminal_penalty=0.5, # penalty for holding inventory at horizon end
)

solution = hz.solve_hjb(config)
print(f"Grid size: {solution.n_inventory} x {solution.n_time}")
print(f"Value at (inventory=0, t=0): {solution.value_at(0, 0.0):.4f}")
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `config` | `HJBConfig` | Configuration object with all model parameters |

### HJBConfig Type

| Field | Type | Description |
|-------|------|-------------|
| `sigma` | `float` | Mid-price volatility (annualized). Controls how fast the mid-price diffuses |
| `gamma` | `float` | Risk aversion coefficient. Higher gamma produces tighter inventory control |
| `kappa` | `float` | Order arrival rate decay parameter. Controls how quickly fill probability decreases with quote depth |
| `alpha` | `float` | Order arrival rate baseline shift. Additive constant in the arrival rate model |
| `max_inventory` | `int` | Maximum absolute inventory the solver considers. Grid spans [-max_inventory, +max_inventory] |
| `t_horizon` | `float` | Trading horizon in days. The terminal condition penalizes open inventory at this time |
| `n_time_steps` | `int` | Number of time steps in the finite-difference grid. More steps = higher accuracy but slower |
| `terminal_penalty` | `float` | Per-unit penalty for inventory remaining at the horizon. Models liquidation cost |

### HJBSolution Type

| Field | Type | Description |
|-------|------|-------------|
| `value_function` | `list[list[float]]` | Value function V(q, t) on the discretized grid |
| `optimal_bid_depth` | `list[list[float]]` | Optimal bid depth delta_b(q, t) on the grid |
| `optimal_ask_depth` | `list[list[float]]` | Optimal ask depth delta_a(q, t) on the grid |
| `n_inventory` | `int` | Number of inventory grid points (2 * max_inventory + 1) |
| `n_time` | `int` | Number of time grid points |
| `config` | `HJBConfig` | Configuration used to produce this solution |

<Note>
  The solver runs in O(max_inventory * n_time_steps) time. For max_inventory=10 and n_time_steps=100, this is ~2000 grid points and completes in under 1ms. Larger grids (max_inventory=50, n_time_steps=1000) still solve in under 50ms.
</Note>

---

## hz.hjb_quote

Evaluate the HJB solution at a specific inventory level and time fraction to produce optimal bid and ask quotes.

```python
import horizon as hz

config = hz.HJBConfig(
    sigma=0.30, gamma=0.1, kappa=1.5, alpha=0.01,
    max_inventory=10, t_horizon=1.0, n_time_steps=100,
    terminal_penalty=0.5,
)
solution = hz.solve_hjb(config)

# Current state: inventory = 3 (long 3 contracts), 40% through the horizon
quote = hz.hjb_quote(solution, inventory=3, time_fraction=0.4)

print(f"Bid depth: {quote.bid_depth:.4f}")   # distance below mid
print(f"Ask depth: {quote.ask_depth:.4f}")   # distance above mid
print(f"Bid skew: {quote.bid_skew:.4f}")     # how much bid is shifted
print(f"Ask skew: {quote.ask_skew:.4f}")     # how much ask is shifted
print(f"Reservation price: {quote.reservation_price:.4f}")  # inventory-adjusted fair value

# Apply to a mid price
mid = 0.55
bid = mid - quote.bid_depth
ask = mid + quote.ask_depth
print(f"Bid: {bid:.4f}, Ask: {ask:.4f}, Spread: {ask - bid:.4f}")
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `solution` | `HJBSolution` | Pre-computed HJB solution from `solve_hjb()` |
| `inventory` | `int` | Current net inventory (positive = long, negative = short) |
| `time_fraction` | `float` | Fraction of the trading horizon elapsed (0.0 = start, 1.0 = end) |

### HJBQuote Type

| Field | Type | Description |
|-------|------|-------------|
| `bid_depth` | `float` | Optimal distance of the bid below the mid-price |
| `ask_depth` | `float` | Optimal distance of the ask above the mid-price |
| `bid_skew` | `float` | Inventory-induced shift to the bid. Positive when long (bid moves down to discourage further buying) |
| `ask_skew` | `float` | Inventory-induced shift to the ask. Positive when long (ask moves down to encourage selling) |
| `reservation_price` | `float` | The inventory-adjusted fair value: mid - gamma * sigma^2 * inventory * (T - t). When long, this is below mid; when short, above |
| `spread` | `float` | Total quoted spread (bid_depth + ask_depth) |

---

## hz.hjb_arrival_rate

Compute the expected order arrival rate (fill probability per unit time) as a function of quote depth. This is the Poisson intensity model used internally by the HJB solver.

```python
import horizon as hz

# How quickly will orders arrive at different depths?
for delta in [0.01, 0.02, 0.05, 0.10, 0.20]:
    rate = hz.hjb_arrival_rate(delta=delta, kappa=1.5, alpha=0.01)
    print(f"Depth={delta:.2f}  Arrival rate={rate:.4f}")
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `delta` | `float` | Quote depth (distance from mid-price) |
| `kappa` | `float` | Decay parameter (higher = faster decay with depth) |
| `alpha` | `float` | Baseline shift (additive constant) |

Returns `float`: the Poisson arrival intensity lambda(delta) = alpha + exp(-kappa * delta). Higher depth means lower arrival rate (quotes farther from mid fill less frequently).

---

## Inventory Dynamics

The key insight of HJB market making is that optimal quotes depend on inventory:

```python
import horizon as hz

config = hz.HJBConfig(
    sigma=0.30, gamma=0.1, kappa=1.5, alpha=0.01,
    max_inventory=10, t_horizon=1.0, n_time_steps=100,
    terminal_penalty=0.5,
)
solution = hz.solve_hjb(config)

print("Inventory | Bid Depth | Ask Depth | Spread  | Reservation")
print("-" * 60)

for q in range(-5, 6):
    quote = hz.hjb_quote(solution, inventory=q, time_fraction=0.5)
    print(f"    {q:+3d}    |  {quote.bid_depth:.4f}  |  {quote.ask_depth:.4f}  | "
          f"{quote.spread:.4f} | {quote.reservation_price:+.4f}")
```

When inventory is positive (long), the ask depth decreases (ask moves closer to mid) and the bid depth increases (bid moves away from mid). This encourages selling to reduce inventory. The reverse happens when inventory is negative.

---

## Pipeline Integration

The `hz.hjb_market_maker()` pipeline function runs the full HJB market maker: solve the PDE (once or periodically), evaluate quotes each cycle based on current inventory and time, and submit orders.

```python
import horizon as hz

def post_trade_logic(ctx):
    """Optional: additional logic after HJB quotes are generated."""
    hjb = ctx.params.get("hjb_quotes")
    if hjb is None:
        return []
    # HJB already generates quotes; this function can add filters
    if hjb.spread > 0.10:
        return []  # skip if spread is too wide
    return ctx.params.get("hjb_orders", [])

hz.run(
    name="hjb-mm",
    markets=["election"],
    pipeline=[
        hz.hjb_market_maker(
            feed="poly",
            config=hz.HJBConfig(
                sigma=0.30,
                gamma=0.1,
                kappa=1.5,
                alpha=0.01,
                max_inventory=10,
                t_horizon=1.0,
                n_time_steps=100,
                terminal_penalty=0.5,
            ),
            size=10.0,              # base order size
            recompute_every=1000,   # re-solve HJB every 1000 cycles
        ),
        post_trade_logic,
    ],
    feeds={"poly": hz.PolymarketBook(token_id="0x123...")},
    interval=1.0,
)
```

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `feed` | `str` | required | Feed name to read mid-price from |
| `config` | `HJBConfig` | required | HJB solver configuration |
| `size` | `float` | `10.0` | Base order size in contracts |
| `recompute_every` | `int` | `1000` | Re-solve the HJB PDE every N cycles (to incorporate updated volatility) |

The pipeline injects `ctx.params["hjb_quotes"]` (an `HJBQuote` object) and `ctx.params["hjb_orders"]` (a list of `OrderRequest` objects) each cycle.

---

## Finite-Difference Backward Induction

The HJB solver discretizes the (inventory, time) space and works backward from the terminal condition.

**Terminal condition** at t = T:

`V(q, T) = -terminal_penalty * abs(q)`

This penalizes open inventory at the end of the horizon.

**Backward step** from t+dt to t:

At each (q, t), the solver computes the optimal bid depth delta_b and ask depth delta_a that maximize the expected infinitesimal gain:

`max [lambda_b * (delta_b + V(q+1, t) - V(q, t)) + lambda_a * (delta_a + V(q-1, t) - V(q, t)) - 0.5 * gamma * sigma^2 * q^2 * dt]`

where `lambda_b = alpha * exp(-kappa * delta_b)` and `lambda_a = alpha * exp(-kappa * delta_a)`.

The first-order conditions yield closed-form expressions for the optimal depths at each grid point, which are then used to update the value function.

---

## Mathematical Background

<AccordionGroup>
  <Accordion title="Avellaneda-Stoikov Framework">
    Avellaneda and Stoikov (2008) formulated optimal market making as a stochastic control problem. The market maker maximizes expected utility of terminal wealth: `max E[-exp(-gamma * W_T)]`, where W_T is terminal wealth. The mid-price follows a Brownian motion: `dS = sigma * dW`. The market maker controls bid and ask depths, which determine fill rates via a Poisson process. The resulting HJB equation is a PDE in (inventory, time) that can be solved analytically in simple cases or numerically via finite differences.
  </Accordion>
  <Accordion title="Gueant-Lehalle-Fernandez-Tapia Extension">
    Gueant, Lehalle, and Fernandez-Tapia (2012) extended the framework to include a terminal penalty for open inventory and an exponential arrival rate model. They showed that the optimal bid and ask depths are:

    `delta_b = (1/gamma) * ln(1 + gamma/kappa) + (gamma * sigma^2 * (T-t) * (2q + 1)) / 2`

    `delta_a = (1/gamma) * ln(1 + gamma/kappa) - (gamma * sigma^2 * (T-t) * (2q - 1)) / 2`

    The first term is the "base spread" (independent of inventory), and the second term is the "skew" (linear in inventory). The finite-difference solver in Horizon generalizes this to handle the terminal penalty and boundary conditions exactly.
  </Accordion>
  <Accordion title="Arrival Rate Model">
    Order arrivals follow a Poisson process with intensity `lambda(delta) = alpha + exp(-kappa * delta)`, where delta is the distance from mid. This captures the empirical observation that quotes closer to mid fill more frequently. The parameter kappa controls the sensitivity: higher kappa means the arrival rate drops faster with depth. The parameter alpha provides a floor to prevent zero arrival rates at wide spreads.
  </Accordion>
  <Accordion title="Risk Aversion and Inventory Control">
    The parameter gamma controls the trade-off between profit and risk. At gamma = 0, the market maker is risk-neutral and posts quotes to maximize expected profit without regard to inventory. As gamma increases, the market maker becomes more aggressive in mean-reverting inventory, widening the spread on the side that would increase inventory and tightening it on the side that reduces inventory. In practice, gamma should be calibrated to match the desired maximum inventory holding period.
  </Accordion>
</AccordionGroup>

<Warning>
  The HJB solution assumes the mid-price follows a Brownian motion with constant volatility. In prediction markets, prices are bounded in [0, 1] and volatility is not constant. The solution is most accurate when the current price is far from the boundaries and the trading horizon is short relative to the market's remaining life. Re-solve the PDE periodically (via `recompute_every`) to adapt to changing conditions.
</Warning>
