Skip to content
Back to App

Bracket Orders (SL/TP)

A bracket order automatically attaches a stop loss and take profit to your entry order. When the entry fills, the engine creates two child orders: a STOP order (for your SL) and a LIMIT order (for your TP). When one child fills, the other is automatically canceled. This is also known as an OCO (One-Cancels-Other) setup.

Why Use Bracket Orders?

Without bracket orders, you have to manually check SL/TP conditions in your main loop:

# Manual approach — checks once per bar
if position == "LONG" and bar["l"] <= stop_loss:
place_order(ACCOUNT_ID, CONTRACT_ID, 1, 1) # Close

The problem: this only evaluates once per bar. If you are using 5-minute bars and price gaps through your stop in the first second, you will not exit until the bar closes — potentially 5 minutes of additional loss.

Bracket orders solve this. The TestMax engine evaluates bracket orders at the tick level (or at least at the bar level for every bar), catching exits that your strategy loop would miss.


How Bracket Orders Work in TestMax

The place_order function accepts sl_ticks and tp_ticks parameters:

result = place_order(
ACCOUNT_ID, # Account ID
CONTRACT_ID, # Contract ID
0, # Side: 0 = Buy, 1 = Sell
1, # Size: 1 contract
order_type=2, # Type: 2 = Market (default)
sl_ticks=20, # Stop Loss: 20 ticks from fill price
tp_ticks=40 # Take Profit: 40 ticks from fill price
)

What Happens Internally

  1. Entry order fills — Your market order fills at, say, 21,500.00.
  2. Engine creates child orders — A STOP order is placed 20 ticks below at 21,495.00 (stop loss) and a LIMIT order 40 ticks above at 21,510.00 (take profit).
  3. One child fills — If price hits 21,510.00, the TP LIMIT order fills and the SL STOP order is canceled automatically.
  4. Position is flat — Your position is closed and the engine tracks the P&L.

Tick Size Reference

InstrumentTick SizePoints per TickDollar Value per Tick
NQ (Nasdaq)0.250.25 pts$5.00
ES (S&P 500)0.250.25 pts$12.50
YM (Dow)1.001.00 pts$5.00
RTY (Russell)0.100.10 pts$5.00

Example for NQ:

  • sl_ticks=20 = 20 x 0.25 = 5 points = $100 risk per contract
  • tp_ticks=40 = 40 x 0.25 = 10 points = $200 reward per contract
  • Risk:Reward = 1:2

Detecting When Bracket Orders Close Your Position

When a bracket order’s SL or TP triggers, the engine closes your position automatically. Your strategy needs to detect this. The pattern: periodically call get_positions() and check if the position has been closed.

# Check every 5 bars (avoid excessive API calls)
if position is not None and i % 5 == 0:
positions = get_positions(ACCOUNT_ID)
if len(positions) == 0:
# Position was closed by bracket order (SL or TP hit)
acct = get_account(ACCOUNT_ID)
current_balance = acct["balance"] if acct else balance_before_trade
pnl_usd = current_balance - balance_before_trade
if pnl_usd >= 0:
print(f"[TP HIT] P&L: ${pnl_usd:+,.0f}")
else:
print(f"[SL HIT] P&L: ${pnl_usd:+,.0f}")
position = None

Why Balance Tracking Works

# Before entering a trade, snapshot the balance:
acct = get_account(ACCOUNT_ID)
balance_before_trade = acct["balance"]
# After the position closes:
acct = get_account(ACCOUNT_ID)
pnl = acct["balance"] - balance_before_trade

This approach works regardless of whether the SL or TP triggered, and it captures the exact fill price.


Risk-Reward Ratio Examples

SL_TICKS = 20
TP_TICKS = 20 # Same distance for SL and TP
# Need >50% win rate to be profitable

Calculating R:R from Ticks

def calculate_rr(sl_ticks, tp_ticks):
"""Calculate risk-reward ratio and required win rate."""
rr_ratio = tp_ticks / sl_ticks
min_win_rate = 1 / (1 + rr_ratio)
return rr_ratio, min_win_rate
rr, min_wr = calculate_rr(20, 40)
print(f"R:R = 1:{rr:.1f} | Min win rate: {min_wr*100:.1f}%")
# R:R = 1:2.0 | Min win rate: 33.3%

Dynamic Stop Loss: ATR-Based Brackets

Instead of fixed ticks, use the ATR to set stops that adapt to current volatility:

class ATR:
def __init__(self, period=14):
self.period = period
self.value = None
self._prev_close = None
self._tr_buffer = []
def update(self, high, low, close):
if self._prev_close is not None:
tr = max(high - low, abs(high - self._prev_close), abs(low - self._prev_close))
if self.value is None:
self._tr_buffer.append(tr)
if len(self._tr_buffer) == self.period:
self.value = sum(self._tr_buffer) / self.period
self._tr_buffer = None
else:
self.value = (self.value * (self.period - 1) + tr) / self.period
self._prev_close = close
return self.value
atr = ATR(14)
# In your loop:
atr_val = atr.update(bar["h"], bar["l"], bar["c"])
if atr_val and should_enter:
# Convert ATR (in points) to ticks
sl_ticks = int(atr_val * 1.5 / 0.25) # 1.5x ATR, converted to ticks
tp_ticks = int(sl_ticks * 2.0) # 2:1 R:R
# Enforce minimum/maximum
sl_ticks = max(8, min(sl_ticks, 200)) # Between 2 and 50 points
tp_ticks = max(16, min(tp_ticks, 400))
result = place_order(ACCOUNT_ID, CONTRACT_ID, 0, 1,
sl_ticks=sl_ticks, tp_ticks=tp_ticks)
print(f"[BUY] ATR: {atr_val:.2f} | SL: {sl_ticks}t ({sl_ticks*0.25:.1f}pts) | TP: {tp_ticks}t ({tp_ticks*0.25:.1f}pts)")

Trailing Stop Concept

A trailing stop moves the stop loss in the direction of profit as the trade progresses. If you enter long at 21,500 with a 5-point stop (21,495), and price moves to 21,510, you move the stop up to 21,505 — locking in at least a breakeven trade.

Manual Trailing Stop Implementation

Since bracket orders are fixed, implement trailing stops by manually closing positions when your trailing stop is hit:

trailing_stop = 0.0
TRAIL_DISTANCE = 5.0 # Points
# On entry (after bracket order fills):
if position == "LONG":
trailing_stop = entry_price - TRAIL_DISTANCE
# In your main loop:
if position == "LONG":
# Move stop up as price increases
new_stop = price - TRAIL_DISTANCE
if new_stop > trailing_stop:
trailing_stop = new_stop
# Check if trailing stop is hit
if bar["l"] <= trailing_stop:
close_all_positions(ACCOUNT_ID, CONTRACT_ID)
pnl = trailing_stop - entry_price
print(f"[TRAIL] Stopped at {trailing_stop:.2f} | P&L: {pnl:+.2f} pts")
position = None
elif position == "SHORT":
new_stop = price + TRAIL_DISTANCE
if new_stop < trailing_stop:
trailing_stop = new_stop
if bar["h"] >= trailing_stop:
close_all_positions(ACCOUNT_ID, CONTRACT_ID)
pnl = entry_price - trailing_stop
print(f"[TRAIL] Stopped at {trailing_stop:.2f} | P&L: {pnl:+.2f} pts")
position = None

Breakeven Stop

A simpler variation: once price moves N points in your favor, move the stop to breakeven:

BREAKEVEN_TRIGGER = 5.0 # Move to breakeven after 5 points of profit
breakeven_set = False
if position == "LONG" and not breakeven_set:
if price >= entry_price + BREAKEVEN_TRIGGER:
trailing_stop = entry_price + 0.25 # 1 tick above entry (small profit)
breakeven_set = True
print(f"[INFO] Stop moved to breakeven: {trailing_stop:.2f}")

Complete Example: Bracket Order Strategy

#!/usr/bin/env python3
"""Bracket Order Demo — SMA Crossover with ATR-based SL/TP"""
import os, time
ACCOUNT_ID = int(os.environ.get("ACCOUNT_ID", "0"))
CONTRACT_ID = os.environ.get("CONTRACT_ID", "")
TOTAL_BARS = int(os.environ.get("TOTAL_BARS", "5000"))
STEP_DELAY = float(os.environ.get("STEP_DELAY", "0.02"))
SPEED_FILE = os.environ.get("SPEED_FILE", "")
TP_RATIO = 2.0
class EMA:
def __init__(self, period):
self.period = period
self.multiplier = 2 / (period + 1)
self.value = None
self._buffer = []
def update(self, price):
if self.value is None:
self._buffer.append(price)
if len(self._buffer) == self.period:
self.value = sum(self._buffer) / self.period
self._buffer = None
else:
self.value = (price - self.value) * self.multiplier + self.value
return self.value
class ATR:
def __init__(self, period=14):
self.period = period
self.value = None
self._prev_close = None
self._tr_buffer = []
def update(self, h, l, c):
if self._prev_close is not None:
tr = max(h - l, abs(h - self._prev_close), abs(l - self._prev_close))
if self.value is None:
self._tr_buffer.append(tr)
if len(self._tr_buffer) == self.period:
self.value = sum(self._tr_buffer) / self.period
self._tr_buffer = None
else:
self.value = (self.value * (self.period - 1) + tr) / self.period
self._prev_close = c
return self.value
fast_ema = EMA(9)
slow_ema = EMA(21)
atr = ATR(14)
prev_fast = None
prev_slow = None
position = None
entry_price = 0.0
balance_before = 50000
trade_count = 0
wins = 0
print(f"[INFO] EMA 9/21 Crossover with ATR Bracket Orders")
print("-" * 50)
try:
for i in range(TOTAL_BARS):
bar = get_next_bar()
if bar is None:
break
read_speed()
if STEP_DELAY > 0:
time.sleep(STEP_DELAY)
price = bar["c"]
fast_val = fast_ema.update(price)
slow_val = slow_ema.update(price)
atr_val = atr.update(bar["h"], bar["l"], price)
if fast_ema.value is None or slow_ema.value is None or atr_val is None:
prev_fast = fast_val
prev_slow = slow_val
continue
# Check if bracket order closed the position
if position is not None and i % 5 == 0:
positions = get_positions(ACCOUNT_ID)
if len(positions) == 0:
acct = get_account(ACCOUNT_ID)
bal = acct["balance"] if acct else balance_before
pnl = bal - balance_before
trade_count += 1
if pnl >= 0:
wins += 1
tag = "TP" if pnl >= 0 else "SL"
print(f"[{tag}] P&L: ${pnl:+,.0f} | Bal: ${bal:,.0f}")
position = None
if prev_fast is not None and prev_slow is not None and position is None:
golden = prev_fast <= prev_slow and fast_val > slow_val
death = prev_fast >= prev_slow and fast_val < slow_val
# Calculate ATR-based ticks
sl_ticks = max(8, int(atr_val * 1.5 / 0.25))
tp_ticks = int(sl_ticks * TP_RATIO)
if golden:
result = place_order(ACCOUNT_ID, CONTRACT_ID, 0, 1,
sl_ticks=sl_ticks, tp_ticks=tp_ticks)
if result.get("orderId") or result.get("success"):
position = "LONG"
entry_price = price
acct = get_account(ACCOUNT_ID)
balance_before = acct["balance"] if acct else balance_before
print(f"[BUY] @ {price:.2f} | ATR: {atr_val:.2f} | SL: {sl_ticks}t TP: {tp_ticks}t")
elif death:
result = place_order(ACCOUNT_ID, CONTRACT_ID, 1, 1,
sl_ticks=sl_ticks, tp_ticks=tp_ticks)
if result.get("orderId") or result.get("success"):
position = "SHORT"
entry_price = price
acct = get_account(ACCOUNT_ID)
balance_before = acct["balance"] if acct else balance_before
print(f"[SELL] @ {price:.2f} | ATR: {atr_val:.2f} | SL: {sl_ticks}t TP: {tp_ticks}t")
prev_fast = fast_val
prev_slow = slow_val
finally:
try:
close_all_positions(ACCOUNT_ID, CONTRACT_ID)
except:
pass
account = get_account(ACCOUNT_ID)
balance = account["balance"] if account else 50000
print("-" * 50)
print(f"[DONE] Balance: ${balance:,.2f} | P&L: ${balance - 50000:+,.2f}")
print(f"[DONE] Trades: {trade_count} | Win Rate: {wins/trade_count*100:.1f}%" if trade_count else "[DONE] No trades")

What’s Next