Skip to main content

Horizon Execution Algorithms

Horizon ships with three execution algorithms for splitting large orders into smaller child orders: TWAP, VWAP, and Iceberg. All are implemented in pure Python, use engine.submit_order() internally, and do not participate in cancel-before-requote.
Execution algorithms are designed for situations where you need to fill a large order with minimal market impact. They manage their own child orders and should not be mixed with manual order management for the same market.

Overview

TWAP

Time-Weighted Average Price. Splits the order into equal slices submitted at regular intervals over a duration.

VWAP

Volume-Weighted Average Price. Slices the order proportionally based on a volume profile (historical or predicted).

Iceberg

Shows only a small visible portion of the full order. Automatically replenishes when the visible portion fills.

Base Class: ExecAlgo

All execution algorithms inherit from ExecAlgo and share a common interface.
from horizon.algos import ExecAlgo

class ExecAlgo:
    def start(self, request: OrderRequest) -> None:
        """Begin execution of the given order request."""
        ...

    def on_tick(self, current_price: float, timestamp: float) -> None:
        """Called each tick to advance the algorithm."""
        ...

    @property
    def is_complete(self) -> bool:
        """Whether the full order has been executed."""
        ...

    @property
    def child_order_ids(self) -> list[str]:
        """IDs of all child orders submitted so far."""
        ...

    @property
    def total_filled(self) -> float:
        """Total size filled across all child orders."""
        ...

Usage Pattern

Every algo follows the same lifecycle:
1

Create the algo with engine reference and parameters

algo = TWAP(engine, duration_secs=300, num_slices=10)
2

Start with an OrderRequest

algo.start(request)
3

Call on_tick() each cycle

algo.on_tick(current_price=0.55, timestamp=time.time())
4

Check completion

if algo.is_complete:
    print(f"Done! Filled {algo.total_filled} across {len(algo.child_order_ids)} orders")

TWAP (Time-Weighted Average Price)

Splits the total order into num_slices equal pieces and submits one slice at each interval over the specified duration.
from horizon.algos import TWAP

Constructor

TWAP(
    engine: Engine,
    duration_secs: float,
    num_slices: int,
)
ParameterTypeDescription
engineEngineThe Horizon engine instance
duration_secsfloatTotal execution window in seconds
num_slicesintNumber of equal child orders

How It Works

  1. The total size is divided into num_slices equal parts.
  2. The interval between slices is duration_secs / num_slices.
  3. On each on_tick(), if enough time has elapsed since the last slice, a new child order is submitted at the current market price.
  4. The algo is complete when all slices have been submitted.

Full Example

import time
import horizon as hz
from horizon import Side, OrderSide, OrderRequest
from horizon.algos import TWAP

engine = hz.Engine()

# We want to buy 500 contracts over 5 minutes, in 10 slices
twap = TWAP(engine, duration_secs=300, num_slices=10)

request = OrderRequest(
    market_id="election-winner",
    side=Side.Yes,
    order_side=OrderSide.Buy,
    price=0.55,
    size=500.0,  # Total size
)

twap.start(request)

# Simulation loop
start_time = time.time()
while not twap.is_complete:
    current_time = time.time()
    current_price = 0.55  # In practice, get from feed

    twap.on_tick(current_price, current_time)

    print(f"Filled: {twap.total_filled} / 500.0")
    print(f"Child orders: {len(twap.child_order_ids)}")

    time.sleep(1)

print(f"TWAP complete. Total filled: {twap.total_filled}")
print(f"All child order IDs: {twap.child_order_ids}")
Choose num_slices based on market liquidity. More slices means smaller individual orders but more time in the market. For thin prediction markets, 5-10 slices is usually sufficient.

VWAP (Volume-Weighted Average Price)

Splits the total order proportionally according to a volume profile. Heavier slices are placed during high-volume periods.
from horizon.algos import VWAP

Constructor

VWAP(
    engine: Engine,
    duration_secs: float,
    volume_profile: list[float],
)
ParameterTypeDescription
engineEngineThe Horizon engine instance
duration_secsfloatTotal execution window in seconds
volume_profilelist[float]Relative volume weights per slice (auto-normalized)

How It Works

  1. The volume_profile is normalized so weights sum to 1.0.
  2. Each slice’s size is total_size * normalized_weight[i].
  3. Slices are submitted at regular intervals (duration_secs / len(volume_profile)).
  4. Heavier slices are placed during time windows with higher expected volume.

Full Example

import time
import horizon as hz
from horizon import Side, OrderSide, OrderRequest
from horizon.algos import VWAP

engine = hz.Engine()

# Volume profile: heavier trading at open and close
# These weights are automatically normalized
volume_profile = [
    3.0,  # Slice 1: heavy (market open)
    2.0,  # Slice 2: moderate
    1.0,  # Slice 3: light (midday)
    1.0,  # Slice 4: light
    2.0,  # Slice 5: moderate
    4.0,  # Slice 6: heaviest (market close)
]

vwap = VWAP(engine, duration_secs=600, volume_profile=volume_profile)

request = OrderRequest(
    market_id="fed-rate-decision",
    side=Side.Yes,
    order_side=OrderSide.Buy,
    price=0.62,
    size=1000.0,  # Total size to fill
)

# With the profile above, slice sizes will be approximately:
# Slice 1: 230.8 (3/13 * 1000)
# Slice 2: 153.8 (2/13 * 1000)
# Slice 3:  76.9 (1/13 * 1000)
# Slice 4:  76.9 (1/13 * 1000)
# Slice 5: 153.8 (2/13 * 1000)
# Slice 6: 307.7 (4/13 * 1000)

vwap.start(request)

while not vwap.is_complete:
    current_time = time.time()
    current_price = 0.62

    vwap.on_tick(current_price, current_time)
    time.sleep(1)

print(f"VWAP complete. Total filled: {vwap.total_filled}")
You can derive volume_profile from historical trade data. Use hz.backtest() results or exchange trade history to build a realistic intraday volume curve.

Iceberg

Shows only a small portion (show_size) of the total order at any time. When the visible portion is filled, a new visible order is automatically submitted until the full size is complete.
from horizon.algos import Iceberg

Constructor

Iceberg(
    engine: Engine,
    show_size: float,
)
ParameterTypeDescription
engineEngineThe Horizon engine instance
show_sizefloatMaximum visible size per child order

How It Works

  1. A child order of size min(show_size, remaining_size) is submitted.
  2. On each on_tick(), the algo checks if the current visible order has been filled.
  3. If filled, a new visible order is submitted with the next chunk.
  4. The algo is complete when the entire original size has been filled.

Full Example

import time
import horizon as hz
from horizon import Side, OrderSide, OrderRequest
from horizon.algos import Iceberg

engine = hz.Engine()

# We want to buy 200 contracts but only show 15 at a time
iceberg = Iceberg(engine, show_size=15.0)

request = OrderRequest(
    market_id="btc-above-100k",
    side=Side.Yes,
    order_side=OrderSide.Buy,
    price=0.70,
    size=200.0,
)

iceberg.start(request)

while not iceberg.is_complete:
    current_time = time.time()
    current_price = 0.70

    iceberg.on_tick(current_price, current_time)

    print(f"Filled: {iceberg.total_filled} / 200.0")
    print(f"Visible orders placed: {len(iceberg.child_order_ids)}")

    time.sleep(0.5)

print(f"Iceberg complete. Total filled: {iceberg.total_filled}")
The iceberg algo relies on fills being drained from the exchange. Make sure the engine’s fill polling is active. In hz.run(), this happens automatically. In manual loops, call engine.tick() or engine.drain_fills() before on_tick().

Using Algos in hz.run()

Execution algorithms work best when managed inside a pipeline function that has access to the engine.
import time
import horizon as hz
from horizon import Side, OrderSide, OrderRequest
from horizon.algos import TWAP

active_algo = None

def model(ctx):
    fair_value = 0.60  # Your model logic here
    return fair_value

def algo_manager(ctx, fair):
    """Manage a TWAP algo to accumulate a position."""
    global active_algo
    engine = ctx.params["engine"]

    # Start a TWAP if we don't have one running
    if active_algo is None or active_algo.is_complete:
        positions = engine.positions()
        current_pos = next((p for p in positions if p.market_id == ctx.market_id), None)
        current_size = current_pos.size if current_pos else 0.0

        # Target 100 contracts; start TWAP if below target
        if current_size < 100.0:
            request = OrderRequest(
                market_id=ctx.market_id,
                side=Side.Yes,
                order_side=OrderSide.Buy,
                price=fair,
                size=100.0 - current_size,
            )
            active_algo = TWAP(engine, duration_secs=120, num_slices=6)
            active_algo.start(request)

    # Advance the algo each tick
    if active_algo and not active_algo.is_complete:
        active_algo.on_tick(ctx.feed.price, time.time())

hz.run(
    name="twap-accumulator",
    markets=["election-winner"],
    feeds={"election-winner": "polymarket_book"},
    pipeline=[model, algo_manager],
)

Comparison

Use TWAP when you want to spread execution evenly over time and don’t have a strong opinion about volume patterns. Good for markets with consistent liquidity throughout the day.
Use VWAP when you have historical volume data and want to minimize market impact by trading more during high-volume periods. Best for markets with predictable volume patterns.
Use Iceberg when you want to hide the full size of your order from other participants. Best for markets where showing a large order would move the price against you.
FeatureTWAPVWAPIceberg
Execution timingFixed intervalsFixed intervalsOn fill
Size distributionEqual slicesWeighted slicesFixed visible size
Requires volume dataNoYesNo
Hides order sizePartiallyPartiallyYes
Best forSteady marketsVolume-patterned marketsThin orderbooks