Skip to main content
Ultra Feature. Requires an Ultra subscription. Get started at api.mathematicalcompany.com
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.

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.

HJB Solver

hz.solve_hjb() solves the HJB PDE on a discretized grid via backward induction, producing the full value function and optimal quote schedule.

Quote Generation

hz.hjb_quote() evaluates the solution at a given inventory and time to produce optimal bid/ask spreads.

Arrival Rate Model

hz.hjb_arrival_rate() computes the expected fill rate as a function of quote depth, calibrated to market microstructure.

Pipeline Integration

hz.hjb_market_maker() runs the full HJB market maker each cycle: solve, quote, and manage inventory.

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.
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}")
ParameterTypeDescription
configHJBConfigConfiguration object with all model parameters

HJBConfig Type

FieldTypeDescription
sigmafloatMid-price volatility (annualized). Controls how fast the mid-price diffuses
gammafloatRisk aversion coefficient. Higher gamma produces tighter inventory control
kappafloatOrder arrival rate decay parameter. Controls how quickly fill probability decreases with quote depth
alphafloatOrder arrival rate baseline shift. Additive constant in the arrival rate model
max_inventoryintMaximum absolute inventory the solver considers. Grid spans [-max_inventory, +max_inventory]
t_horizonfloatTrading horizon in days. The terminal condition penalizes open inventory at this time
n_time_stepsintNumber of time steps in the finite-difference grid. More steps = higher accuracy but slower
terminal_penaltyfloatPer-unit penalty for inventory remaining at the horizon. Models liquidation cost

HJBSolution Type

FieldTypeDescription
value_functionlist[list[float]]Value function V(q, t) on the discretized grid
optimal_bid_depthlist[list[float]]Optimal bid depth delta_b(q, t) on the grid
optimal_ask_depthlist[list[float]]Optimal ask depth delta_a(q, t) on the grid
n_inventoryintNumber of inventory grid points (2 * max_inventory + 1)
n_timeintNumber of time grid points
configHJBConfigConfiguration used to produce this solution
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.

hz.hjb_quote

Evaluate the HJB solution at a specific inventory level and time fraction to produce optimal bid and ask quotes.
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}")
ParameterTypeDescription
solutionHJBSolutionPre-computed HJB solution from solve_hjb()
inventoryintCurrent net inventory (positive = long, negative = short)
time_fractionfloatFraction of the trading horizon elapsed (0.0 = start, 1.0 = end)

HJBQuote Type

FieldTypeDescription
bid_depthfloatOptimal distance of the bid below the mid-price
ask_depthfloatOptimal distance of the ask above the mid-price
bid_skewfloatInventory-induced shift to the bid. Positive when long (bid moves down to discourage further buying)
ask_skewfloatInventory-induced shift to the ask. Positive when long (ask moves down to encourage selling)
reservation_pricefloatThe inventory-adjusted fair value: mid - gamma * sigma^2 * inventory * (T - t). When long, this is below mid; when short, above
spreadfloatTotal 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.
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}")
ParameterTypeDescription
deltafloatQuote depth (distance from mid-price)
kappafloatDecay parameter (higher = faster decay with depth)
alphafloatBaseline 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:
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.
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

ParameterTypeDefaultDescription
feedstrrequiredFeed name to read mid-price from
configHJBConfigrequiredHJB solver configuration
sizefloat10.0Base order size in contracts
recompute_everyint1000Re-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

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.
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)) / 2delta_a = (1/gamma) * ln(1 + gamma/kappa) - (gamma * sigma^2 * (T-t) * (2q - 1)) / 2The 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.
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.
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.
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.