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 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
- 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 |
| MNQ (Micro Nasdaq) | 0.25 | 0.25 pts | $0.50 |
| MES (Micro S&P 500) | 0.25 | 0.25 pts | $1.25 |
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.
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 = NoneWhy 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_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_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.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
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 = 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 = 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
- 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