---
title: "Horizon Bracket Orders"
description: "Protect positions with stop-loss, take-profit, and bracket orders using OCO linking."
icon: "shield-halved"
---

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

```python
"""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:

1. **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.

2. **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.

3. **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:

```python
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:

```python
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
)
```

<Note>
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.
</Note>

## 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:

```python
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:

```python
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}")
```

<Tip>
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.
</Tip>

## 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:

```python
"""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. |
