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 against each bar’s full high–low range as the bar arrives — at the bar’s open if price gaps through — instead of waiting for your loop to see the close.


How Bracket Orders Work in TestMax

The Playground’s place_order() helper has no bracket arguments, so send the order through api() and attach stopLossBracket and takeProfitBracket, each set in ticks. A small helper keeps the calls short:

def place_bracket_order(side, size, sl_ticks, tp_ticks):
"""Market order with a stop loss and take profit attached."""
return api("Order/place", {
"accountId": ACCOUNT_ID, # Account ID
"contractId": CONTRACT_ID, # Contract ID
"type": 2, # Type: 2 = Market
"side": side, # Side: 0 = Buy, 1 = Sell
"size": size, # Size: number of contracts
"stopLossBracket": {"ticks": sl_ticks}, # Stop Loss: ticks from the entry price
"takeProfitBracket": {"ticks": tp_ticks}, # Take Profit: ticks from the entry price
})
result = place_bracket_order(0, 1, sl_ticks=20, tp_ticks=40)

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
MNQ (Micro Nasdaq)0.250.25 pts$0.50
MES (Micro S&P 500)0.250.25 pts$1.25

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.

To measure the result, read the balance from the account snapshot that Replay/step returns with every bar. get_account() does not work here: it looks the ID up through Account/search, which lists only Challenge Mode accounts, so it returns None in a Playground run. Step with this helper instead of get_next_bar():

balance = 50000.0
def next_bar():
"""Like get_next_bar(), but also records the balance Replay/step reports."""
global balance
for attempt in range(11):
result = api("Replay/step", {"accountId": ACCOUNT_ID, "steps": 1})
error = result
if result.get("errorCode") == 503:
try:
error = json.loads(result.get("errorMessage", "").removeprefix(
"Request outcome is unknown; mutation was not retried: "))
except ValueError:
pass
if (result.get("success", True)
or error != {"success": False, "errorCode": 503,
"errorMessage": "Bars are still loading — retry shortly"}
or attempt == 10):
break
time.sleep(1)
if not result.get("success", True):
print(f"[ERROR] Replay/step failed: {result.get('errorMessage', 'unknown error')}", file=sys.stderr)
sys.exit(1)
balance = (result.get("account") or {}).get("balance", balance)
bars = result.get("bars", [])
if not bars:
return None
bar = bars[0]
if END_DATE and bar.get("t", "") > END_DATE + "T23:59:59":
return None
return bar
# 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)
pnl_usd = 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:
balance_before_trade = balance
# After the position closes (next_bar() keeps `balance` current):
pnl = 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_bracket_order(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
balance = 50000.0
def next_bar():
"""Like get_next_bar(), but also records the balance Replay/step reports."""
global balance
for attempt in range(11):
result = api("Replay/step", {"accountId": ACCOUNT_ID, "steps": 1})
error = result
if result.get("errorCode") == 503:
try:
error = json.loads(result.get("errorMessage", "").removeprefix(
"Request outcome is unknown; mutation was not retried: "))
except ValueError:
pass
if (result.get("success", True)
or error != {"success": False, "errorCode": 503,
"errorMessage": "Bars are still loading — retry shortly"}
or attempt == 10):
break
time.sleep(1)
if not result.get("success", True):
print(f"[ERROR] Replay/step failed: {result.get('errorMessage', 'unknown error')}", file=sys.stderr)
sys.exit(1)
balance = (result.get("account") or {}).get("balance", balance)
bars = result.get("bars", [])
if not bars:
return None
bar = bars[0]
if END_DATE and bar.get("t", "") > END_DATE + "T23:59:59":
return None
return bar
def place_bracket_order(side, size, sl_ticks, tp_ticks):
"""Market order with a stop loss and take profit attached."""
return api("Order/place", {
"accountId": ACCOUNT_ID,
"contractId": CONTRACT_ID,
"type": 2,
"side": side,
"size": size,
"stopLossBracket": {"ticks": sl_ticks},
"takeProfitBracket": {"ticks": tp_ticks},
})
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 = 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:
pnl = balance - 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: ${balance:,.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_bracket_order(0, 1, sl_ticks, tp_ticks)
if result.get("orderId") or result.get("success"):
position = "LONG"
entry_price = price
balance_before = balance
print(f"[BUY] @ {price:.2f} | ATR: {atr_val:.2f} | SL: {sl_ticks}t TP: {tp_ticks}t")
elif death:
result = place_bracket_order(1, 1, sl_ticks, tp_ticks)
if result.get("orderId") or result.get("success"):
position = "SHORT"
entry_price = price
balance_before = balance
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
end = api("Replay/end", {"accountId": ACCOUNT_ID})
balance = (end.get("account") or {}).get("balance", balance)
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