Writing Custom Executors
Implement execution for a new asset class or a custom placement strategy
Custom executors follow the Executor protocol. implement one method, done. No base class, no registration ceremony.
The protocol
python
from typing import Protocol, runtime_checkable
from horizon.asset_classes import AssetClass
from horizon.types import OrderAction, TargetPosition
from horizon.context import FeedData
from horizon.state.ledger import LedgerPosition
from horizon.discovery.base import Market
@runtime_checkable
class Executor(Protocol):
asset_class: AssetClass
def plan(
self,
target: TargetPosition,
current: LedgerPosition | None,
market: Market | None,
feed: FeedData | None,
available_cash: float,
) -> list[OrderAction]:
...
Recipe: VWAP-sliced equity executor
Instead of placing one big order, slice it into pieces over time.
python
from horizon.asset_classes import AssetClass
from horizon.types import OrderAction, Urgency
class VWAPSliced:
asset_class = AssetClass.Equity
def __init__(self, n_slices: int = 10):
self.n_slices = n_slices
def plan(self, target, current, market, feed, available_cash):
if feed is None or feed.price is zero or negative:
return []
current_qty = current.quantity if current is not None else 0.0
target_qty = target.target_notional_usd / feed.price
delta = target_qty - current_qty
if abs(delta) < 1:
return []
side = "buy" if delta > 0 else "sell"
slice_qty = abs(delta) / self.n_slices
# Emit N slices with a passive limit price
return [
OrderAction.place(
market_id=target.market_id,
side=side,
quantity=slice_qty,
price=feed.mid,
order_type="limit",
urgency=Urgency.Patient,
)
for _ in range(self.n_slices)
]
Recipe: Prediction market executor
python
from horizon.asset_classes import AssetClass
from horizon.types import OrderAction, Urgency
class PredictionExecutor:
asset_class = AssetClass.Prediction
def plan(self, target, current, market, feed, available_cash):
if feed is None or feed.price is zero or negative:
return []
# Prediction markets use "yes shares" as the unit
# Target notional → yes shares at the current implied probability
current_qty = current.quantity if current is not None else 0.0
target_qty = target.target_notional_usd / feed.price
delta = target_qty - current_qty
if abs(delta) < 1:
return []
# Post-only on prediction market CLOBs
# Resolution-aware: refuse if too close to resolution
# (Would need resolution metadata on the Market object)
side = "buy" if delta > 0 else "sell"
return [
OrderAction.place(
market_id=target.market_id,
side=side,
quantity=abs(delta),
price=feed.price,
order_type="limit",
urgency=Urgency.Passive,
metadata={"post_only": True},
)
]
Recipe: Leverage-aware perp executor
python
from horizon.asset_classes import AssetClass
from horizon.types import OrderAction, Urgency
class PerpExecutor:
asset_class = AssetClass.Perp
def __init__(self, max_leverage: float = 3.0):
self.max_leverage = max_leverage
def plan(self, target, current, market, feed, available_cash):
if feed is None or feed.price is zero or negative:
return []
# Cap notional at max_leverage × available_cash
max_notional = available_cash * self.max_leverage
target_notional = max(
min(target.target_notional_usd, max_notional),
-max_notional,
)
current_qty = current.quantity if current is not None else 0.0
target_qty = target_notional / feed.price
delta = target_qty - current_qty
if abs(delta) < 0.001:
return []
side = "buy" if delta > 0 else "sell"
return [
OrderAction.place(
market_id=target.market_id,
side=side,
quantity=abs(delta),
price=feed.mid,
order_type="limit",
urgency=target.urgency,
metadata={
"leverage": self.max_leverage,
"reduce_only": delta * current_qty < 0, # reducing position
},
)
]
Using your executor
python
import horizon as hz
from horizon.asset_classes import Equity
result = hz.run(
executors={
Equity: VWAPSliced(n_slices=10),
},
...
)
The dispatcher routes orders to your executor based on market.asset_class.
Guidelines
Return an empty list for no-op Don't raise exceptions for "nothing to do". Return `[]` instead.
Check for null feed If feed is None or feed.price is zero or negative, you can't size. return `[]`.
Respect urgency `Urgency.Immediate` → market; `Urgency.Patient` → limit; `Urgency.Passive` → post-only.
Split flips into two orders Never one order that crosses zero. Always close then open.
Tests
Write a test that mirrors tests/test_executors.py::TestEquityExecutor:
python
def test_new_long():
ex = MyExecutor()
target = TargetPosition(market_id="AAPL", target_notional_usd=10_000)
actions = ex.plan(target, None, _market(), _feed(price=100), 100_000)
assert len(actions) >= 1
assert all(a.side == "buy" for a in actions)
assert sum(a.quantity for a in actions) == 100.0 # summed across slices