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 barif position == "LONG" and bar["l"] <= stop_loss: place_order(ACCOUNT_ID, CONTRACT_ID, 1, 1) # CloseThe 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
- Entry order fills — Your market order fills at, say, 21,500.00.
- 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).
- One child fills — If price hits 21,510.00, the TP LIMIT order fills and the SL STOP order is canceled automatically.
- Position is flat — Your position is closed and the engine tracks the P&L.
Tick Size Reference
| Instrument | Tick Size | Points per Tick | Dollar Value per Tick |
|---|---|---|---|
| NQ (Nasdaq) | 0.25 | 0.25 pts | $5.00 |
| ES (S&P 500) | 0.25 | 0.25 pts | $12.50 |
| YM (Dow) | 1.00 | 1.00 pts | $5.00 |
| RTY (Russell) | 0.10 | 0.10 pts | $5.00 |
Example for NQ:
sl_ticks=20= 20 x 0.25 = 5 points = $100 risk per contracttp_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 = NoneWhy 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_tradeThis approach works regardless of whether the SL or TP triggered, and it captures the exact fill price.
Risk-Reward Ratio Examples
SL_TICKS = 20TP_TICKS = 20 # Same distance for SL and TP# Need >50% win rate to be profitableSL_TICKS = 20TP_TICKS = 40 # TP is 2x the SL distance# Need >33% win rate to be profitableSL_TICKS = 20TP_TICKS = 60 # TP is 3x the SL distance# Need >25% win rate to be profitableCalculating 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.0TRAIL_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 = NoneBreakeven 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 profitbreakeven_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 = Noneprev_slow = Noneposition = Noneentry_price = 0.0balance_before = 50000trade_count = 0wins = 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
- ICT Smart Money Strategy — see bracket orders used in a full institutional-style strategy
- Parameter Optimization — optimize your SL/TP ticks and R:R ratio
- From TestMax to Live Trading — bracket orders work the same way on the real TopStep API