Bracket orders let you attach a stop-loss and take-profit to every entry. When one fires, the other is automatically canceled (OCO, One Cancels Other). Horizon manages the full lifecycle in Rust for zero-latency trigger checks.
Full Code
"""Bracket order example: entry + stop-loss + take-profit with OCO."""
from horizon import Engine, OrderRequest, Side, OrderSide, RiskConfig
engine = Engine(risk_config=RiskConfig(max_position_per_market=1000, max_order_size=1000))
# Submit a bracket order: buy entry + stop-loss + take-profit
entry_id, sl_id, tp_id = engine.submit_bracket(
request=OrderRequest(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Buy,
size=10.0,
price=0.55,
),
stop_trigger=0.45, # Stop-loss at 0.45
take_profit_trigger=0.70, # Take-profit at 0.70
)
print(f"Entry: {entry_id}")
print(f"Stop-loss: {sl_id}")
print(f"Take-profit: {tp_id}")
# Check pending contingent orders
pending = engine.pending_contingent_orders()
print(f"Pending contingent orders: {len(pending)}")
# Simulate: fill the entry order
engine.tick("btc-100k", 0.55)
# Simulate: price drops to 0.45, stop-loss triggers
triggered = engine.check_contingent_triggers("btc-100k", 0.45)
print(f"Triggered: {triggered}") # 1
# Take-profit was auto-canceled (OCO)
pending = engine.pending_contingent_orders()
print(f"Remaining contingent: {len(pending)}") # 0
How It Works
The bracket order workflow has three phases:
-
Entry submission:
submit_bracket() sends a regular limit order to the paper exchange and creates two contingent orders (stop-loss and take-profit) linked as an OCO pair.
-
Trigger monitoring: On each tick (or when you call
check_contingent_triggers()), the engine checks all pending contingent orders for the given market. If the current price crosses a trigger level, the contingent order is converted into a real order and submitted.
-
OCO cancellation: When one side of an OCO pair triggers, the partner is automatically canceled. This prevents conflicting exit orders from both firing.
Entry filled at 0.55
|
|---> Stop-loss watches for price <= 0.45
| |
| +--> If triggered: sell 10 @ 0.45, cancel take-profit
|
|---> Take-profit watches for price >= 0.70
|
+--> If triggered: sell 10 @ 0.70, cancel stop-loss
Standalone Stop-Loss
Add a stop-loss to an existing position without a take-profit:
from horizon import Engine, Side, OrderSide, RiskConfig
engine = Engine(risk_config=RiskConfig(max_position_per_market=1000, max_order_size=1000))
# Assume you already have a long position...
# Add a standalone stop-loss
sl_id = engine.add_stop_loss(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Sell, # Exit side (opposite of position)
size=10.0,
trigger_price=0.45,
)
print(f"Stop-loss ID: {sl_id}")
# Check triggers on each price update
triggered = engine.check_contingent_triggers("btc-100k", 0.46)
print(f"Triggered at 0.46: {triggered}") # 0 (not yet)
triggered = engine.check_contingent_triggers("btc-100k", 0.44)
print(f"Triggered at 0.44: {triggered}") # 1 (stop hit!)
Standalone Take-Profit
Take-profit orders can trigger on price or PnL:
from horizon import Engine, Side, OrderSide, RiskConfig
engine = Engine(risk_config=RiskConfig(max_position_per_market=1000, max_order_size=1000))
# Price-based take-profit
tp_price_id = engine.add_take_profit(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Sell,
size=10.0,
trigger_price=0.70,
)
# PnL-based take-profit (triggers when unrealized PnL >= $5.00)
tp_pnl_id = engine.add_take_profit(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Sell,
size=10.0,
trigger_price=0.70,
trigger_pnl=5.0, # Triggers on PnL OR price, whichever comes first
)
When trigger_pnl is set, the take-profit fires if either the price condition or the PnL condition is met. The PnL is computed from the engine’s position tracker as unrealized PnL for that market.
Manual OCO Linking
You can manually link any two contingent orders as OCO. This is useful when you want custom trigger logic beyond simple bracket orders:
from horizon import Engine, Side, OrderSide, RiskConfig
engine = Engine(risk_config=RiskConfig(max_position_per_market=1000, max_order_size=1000))
# Create two independent contingent orders
sl_id = engine.add_stop_loss(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Sell,
size=10.0,
trigger_price=0.40,
)
tp_id = engine.add_take_profit(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Sell,
size=10.0,
trigger_price=0.80,
)
# Link them. When one fires, the other is auto-canceled
# (submit_bracket does this automatically, but you can do it manually)
# The OCO link is established internally by the contingent order manager.
# Cancel a contingent order manually
canceled = engine.cancel_contingent(sl_id)
print(f"SL canceled: {canceled}")
# Remaining orders
pending = engine.pending_contingent_orders()
print(f"Pending: {len(pending)}")
Amending Contingent Orders
To move a stop-loss or take-profit, cancel the old one and create a new one:
from horizon import Engine, Side, OrderSide, RiskConfig
engine = Engine(risk_config=RiskConfig(max_position_per_market=1000, max_order_size=1000))
# Original stop-loss at 0.45
sl_id = engine.add_stop_loss(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Sell,
size=10.0,
trigger_price=0.45,
)
# Price moved in our favor, trail the stop up to 0.50
engine.cancel_contingent(sl_id)
new_sl_id = engine.add_stop_loss(
market_id="btc-100k",
side=Side.Yes,
order_side=OrderSide.Sell,
size=10.0,
trigger_price=0.50, # Tighter stop
)
print(f"Trailed stop from 0.45 to 0.50: {new_sl_id}")
This cancel-and-replace pattern is the building block for trailing stops. In a pipeline function, you can trail the stop on every tick based on the current price or unrealized PnL.
Integration with hz.run()
When using hz.run(), contingent triggers are checked automatically each cycle. You set up brackets inside your pipeline and the engine handles the rest:
"""Bracket orders inside a live hz.run() pipeline."""
import horizon as hz
from horizon.context import FeedData
# Track bracket state
_brackets: dict[str, tuple[str, str, str]] = {}
def fair_value(ctx: hz.Context) -> float:
feed = ctx.feeds.get("default", FeedData())
return feed.price if feed.price > 0 else 0.50
def quoter(ctx: hz.Context, fair: float) -> list[hz.Quote]:
"""Only quote if we don't already have a bracketed position."""
if ctx.inventory.net != 0:
return [] # Already positioned, let brackets manage the exit
return hz.quotes(fair, spread=0.04, size=5)
hz.run(
name="bracket_mm",
markets=["btc-100k"],
pipeline=[fair_value, quoter],
risk=hz.Risk(max_position=50, max_drawdown_pct=5),
interval=1.0,
mode="paper",
)
For full bracket integration, use the Engine directly inside a custom run loop where you call engine.check_contingent_triggers(market_id, current_price) each cycle after ticking the paper exchange.
API Reference
| Method | Description |
|---|
engine.submit_bracket(request, stop_trigger, take_profit_trigger) | Submit entry + SL + TP as OCO. Returns (entry_id, sl_id, tp_id). |
engine.add_stop_loss(market_id, side, order_side, size, trigger_price) | Add a standalone stop-loss. Returns contingent order ID. |
engine.add_take_profit(market_id, side, order_side, size, trigger_price, trigger_pnl=None) | Add a take-profit (price and/or PnL trigger). Returns contingent order ID. |
engine.check_contingent_triggers(market_id, current_price) | Check all pending contingent orders for the market. Returns number triggered. |
engine.cancel_contingent(contingent_id) | Cancel a contingent order. Returns True if found and canceled. |
engine.pending_contingent_orders() | List all pending (untriggered) contingent orders. |