Replay Control
The Replay Control endpoints are the heart of TestMax’s backtesting engine. They let you start a historical replay session, step through bars one at a time (or in batches), control playback speed, jump to specific timestamps, and end the session to get final results.
Replay session lifecycle
A typical backtest session follows this flow:
- Start —
Replay/startinitializes a session with an instrument, date, and timeframe - Loop —
Replay/stepadvances bars one at a time; your strategy analyzes data and places orders - End —
Replay/endfinalizes the session and returns performance results
You can also use Replay/play for auto-playback, Replay/pause to stop auto-play, and Replay/jumpTo to skip to a specific point in time.
POST Replay/start
Initialize a new backtest replay session. This loads historical data for the specified instrument and date range and prepares the session for bar-by-bar replay.
URL: POST /api/sim/Replay/start
Authentication: Bearer token required.
Request body
{ "instrumentSymbol": "NQ", "startDate": "2025-01-15", "timeframe": "5m"}| Field | Type | Required | Description |
|---|---|---|---|
instrumentSymbol | string | Yes* | Instrument symbol (e.g., "NQ", "ES", "GC", "CL", "EURUSD") |
contractId | string | Yes* | Alternative to instrumentSymbol — a contract ID from Contract/search |
startDate | string | Yes | Session start date in YYYY-MM-DD format |
endDate | string | No | Upper bound of the loaded range, YYYY-MM-DD. Must be after startDate and no more than 2 years past it. Omit it to load from startDate forward — pass it when you know your backtest window and only that window is read |
timeframe | string | No | Bar timeframe (default "1s") |
accountId | number | No | An existing Challenge Mode account — the new session inherits its challenge rules and account size |
* Provide either instrumentSymbol or contractId.
Timeframe values
A timeframe is a positive count followed by a unit letter, matching
^([1-9]\d{0,4})(s|m|h|d|w|M|y)$. Unit letters are case-sensitive — M is
months, m is minutes:
| Unit | Meaning | Bucketing | Maximum |
|---|---|---|---|
s | seconds | Fixed width, epoch grid | 30 days |
m | minutes | Fixed width, epoch grid | 30 days |
h | hours | Fixed width, epoch grid | 30 days |
d | days | Fixed width, epoch grid | 30 days |
w | weeks | Calendar, ISO Monday weeks in UTC | 52 |
M | months | Calendar, UTC calendar months | 12 |
y | years | Calendar, UTC calendar years | 5 |
These 12 values are the presets the platform UI offers on every plan:
| Value | Description |
|---|---|
"1s" | 1-second bars (default) |
"5s" | 5-second bars |
"10s" | 10-second bars |
"15s" | 15-second bars |
"30s" | 30-second bars |
"1m" | 1-minute bars |
"5m" | 5-minute bars |
"15m" | 15-minute bars |
"30m" | 30-minute bars |
"1h" | 1-hour bars |
"4h" | 4-hour bars |
"1d" | Daily bars |
Any other value the grammar accepts — "7m", "90m", "2h", "1w", "1M" —
is a custom timeframe. Customs sit behind the same platform release switch
on this endpoint as they do in the UI, and that switch is currently off: while
it is off the request answers errorCode 2,
"Custom timeframes are currently disabled", whatever your plan. Build against
the 12 preset values above unless we have told you the switch is on for your
account.
Timeframes are canonicalized to the largest unit that divides them evenly before
they are used or echoed back — "60m" becomes "1h", "120s" becomes "2m",
"12M" becomes "1y" — and promotion never crosses the fixed/calendar
boundary ("7d" is not "1w"). Bounds are checked on the canonical form, so
"24M" is accepted as "2y" while "13M" is rejected. Read the timeframe back
from the session response rather than assuming the string you sent.
Bars are aggregated up from each instrument’s stored resolution, so a timeframe
is only available when it lines up with that resolution: a fixed timeframe must
be an exact multiple of the stored resolution in seconds, and calendar
timeframes need a stored resolution that divides a whole day evenly. "30s"
works on 10-second instruments such as Gold (GC), "15s" does not.
Response
{ "success": true, "errorCode": 0, "errorMessage": null, "accountId": 1000000001, "sessionId": "8b6f6a2e-...", "totalBars": 1440, "historyBars": 144, "instrument": { "name": "NQ", "contractId": "CON.F.US.ENQ.N26", "tickSize": 0.25, "tickValue": 5.00 }}| Field | Type | Description |
|---|---|---|
accountId | number | The account ID to use for orders and positions in this session |
sessionId | string | Internal session UUID (informational) |
totalBars | number | Total number of bars available in the session |
historyBars | number | Number of bars pre-loaded as chart history before the replay start |
instrument.name | string | Instrument symbol |
instrument.contractId | string | The resolved contract ID — use this as contractId in order endpoints |
instrument.tickSize | number | Minimum price increment |
instrument.tickValue | number | Dollar value per tick per contract |
The session starts with a $50,000 balance, unless it inherits a challenge config via accountId (then the challenge’s account size is used).
Limits
Replay/start is throttled to 20 calls per minute per account (HTTP 429),
and one account may hold at most 10 live replay sessions at a time — the
11th is refused with errorCode: 4 until you call Replay/end. A session with
no API activity for an hour is finalized and released automatically.
Example
# Start a 5-minute replay session on NQresult = api("Replay/start", { "instrumentSymbol": "NQ", "startDate": "2025-01-15", "timeframe": "5m"})
if result["success"]: ACCOUNT_ID = result["accountId"] CONTRACT_ID = result["instrument"]["contractId"] TOTAL_BARS = result["totalBars"] print(f"Session started: {TOTAL_BARS} bars available") print(f"Account: {ACCOUNT_ID}, Contract: {CONTRACT_ID}")else: print(f"Failed to start: {result['errorMessage']}")curl -X POST https://api.test-max.com/api/sim/Replay/start \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{ "instrumentSymbol": "NQ", "startDate": "2025-01-15", "timeframe": "5m" }'Error responses
| Scenario | errorCode | errorMessage |
|---|---|---|
Neither instrumentSymbol nor contractId given | 1 | "instrumentSymbol or contractId required" |
| Unknown instrument | 1 | "Instrument not found" |
Missing startDate | 2 | "startDate required" |
timeframe is a custom while the release switch is off | 2 | "Custom timeframes are currently disabled" |
| Session setup failed (e.g., no data for the date) | 7 | The failure reason |
POST Replay/step
Advance the replay by one or more bars. This is the primary way to move through historical data in your strategy’s main loop.
URL: POST /api/sim/Replay/step
Authentication: Bearer token required.
Request body
{ "accountId": 12345, "steps": 1}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
accountId | number | Yes | — | Account ID from Replay/start |
steps | number | No | 1 | Number of bars to advance (1–100 per call) |
Response
{ "success": true, "errorCode": 0, "errorMessage": null, "bars": [ { "t": "2025-01-15T14:30:00.000Z", "o": 21500.25, "h": 21505.50, "l": 21498.75, "c": 21503.00, "v": 150 } ], "barsProcessed": 1, "currentIndex": 42, "totalBars": 1440, "account": { "balance": 50000, "equity": 50120.5, "unrealizedPnl": 120.5, "marginUsed": 1500, "positions": 1, "pendingOrders": 0 }, "propFirm": null, "filledOrders": [], "violations": []}| Field | Type | Description |
|---|---|---|
bars | array | Array of bar objects, one per step. See Bar Data Format. |
bars[].t | string | ISO 8601 timestamp |
bars[].o / h / l / c | number | Open / high / low / close price |
bars[].v | number | Volume |
barsProcessed | number | How many bars actually advanced — 0 means the replay reached the end of the data |
currentIndex | number | Index of the next bar to be played |
totalBars | number | Total bars in the session |
account | object or null | Current account snapshot: balance, equity, unrealizedPnl, marginUsed, positions, pendingOrders |
propFirm | object or null | Challenge snapshot (status, isFunded, currentPhase, tradingDaysCount, drawdownLocked), or null for non-challenge sessions |
filledOrders | array | Orders filled during these bars: {orderId, fillPrice, fillQty} |
violations | array | Challenge rule violations triggered during these bars (stepping halts on a violation or pass, and the session is finalized) |
Example
# Step through bars one at a time (typical strategy loop)for i in range(TOTAL_BARS): result = api("Replay/step", { "accountId": ACCOUNT_ID, "steps": 1 })
if not result["success"] or result["barsProcessed"] == 0: break
bar = result["bars"][0] close = bar["c"] timestamp = bar["t"]
# Your strategy logic here print(f"Bar {result['currentIndex']}/{result['totalBars']}: Close={close}")Stepping multiple bars at once
You can advance multiple bars in a single call. This is useful when you want to skip ahead quickly without processing each bar individually.
# Skip forward 10 barsresult = api("Replay/step", { "accountId": ACCOUNT_ID, "steps": 10})
# Process all returned barsfor bar in result["bars"]: # Each bar in the batch print(f"{bar['t']}: O={bar['o']} H={bar['h']} L={bar['l']} C={bar['c']}")Throttling and loading
Replay/step can answer without bars:
| Status | Meaning | What to do |
|---|---|---|
503 + Retry-After: 1 | The session is loading its next data window, or another operation (a jump, a reload after a data update) holds it. | Wait and retry. |
429 + Retry-After | The per-second egress rate or the daily streaming allowance is exhausted. | Sleep for Retry-After seconds, then retry. |
Two Replay/step calls on the same session at the same time are not allowed — the second answers 503. A 200 with barsProcessed: 0 still means the session reached its end.
POST Replay/jumpTo
Jump forward to a specific point in time within the replay session. Every bar between the current position and the target is processed instantly — pending orders fill and challenge rules are checked, you just don’t receive the bars in the response. Useful for skipping past the market open or fast-forwarding to a known event.
URL: POST /api/sim/Replay/jumpTo
Authentication: Bearer token required.
Request body
{ "accountId": 12345, "targetTime": "2025-01-15T15:00:00Z"}| Field | Type | Required | Description |
|---|---|---|---|
accountId | number | Yes | Account ID |
targetTime | string | Yes | ISO 8601 timestamp to jump to. Must be later than the current position — jumps are forward-only (a past target is a no-op with barsProcessed: 0). |
Response
{ "success": true, "errorCode": 0, "errorMessage": null, "barsProcessed": 138, "currentIndex": 180, "totalBars": 1440, "account": { "balance": 50000, "equity": 50000, "unrealizedPnl": 0, "marginUsed": 0, "positions": 0, "pendingOrders": 0 }, "propFirm": null, "filledOrders": [], "violations": []}Same response shape as Replay/step (minus bars): barsProcessed, currentIndex, totalBars, account, propFirm, filledOrders, and violations.
Example
# Skip to the US market open (9:30 AM ET = 14:30 UTC)result = api("Replay/jumpTo", { "accountId": ACCOUNT_ID, "targetTime": "2025-01-15T14:30:00Z"})
if result["success"]: print(f"Jumped to bar {result['currentIndex']}") for fill in result["filledOrders"]: print(f" Filled during jump: order {fill['orderId']} @ {fill['fillPrice']}")else: print(f"Jump failed: {result['errorMessage']}")Errors and throttling
- A missing or unparseable
targetTimeanswerssuccess: false, errorCode: 2, errorMessage: "Invalid targetTime"— nothing moves. - The same
503/429answers asReplay/stepapply (session busy or loading; streaming allowance exhausted). Retry with a bounded backoff. - A jump that reaches a challenge outcome stops at the bar that decided it; the response carries the terminal event.
POST Replay/play
Start auto-playing the replay at a specified speed multiplier. The session will advance automatically, simulating real-time market progression at the given speed.
URL: POST /api/sim/Replay/play
Authentication: Bearer token required.
Request body
{ "accountId": 12345, "speed": 10}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
accountId | number | Yes | — | Account ID |
speed | number | No | 1 | Playback speed multiplier (1 to 50). 1 = two bars per second, 50 = 100 bars per second. |
Response
{ "success": true, "errorCode": 0, "errorMessage": null, "playing": true, "speed": 10}During auto-play, bars are processed exactly like Replay/step — orders fill and challenge rules apply. Playback pauses automatically on a violation or pass.
Example
# Start auto-play at 10x speedresult = api("Replay/play", { "accountId": ACCOUNT_ID, "speed": 10})
print("Auto-play started at 10x speed")POST Replay/pause
Pause auto-playback. The session remains active and can be resumed with Replay/play or advanced manually with Replay/step.
URL: POST /api/sim/Replay/pause
Authentication: Bearer token required.
Request body
{ "accountId": 12345}| Field | Type | Required | Description |
|---|---|---|---|
accountId | number | Yes | Account ID |
Response
{ "success": true, "errorCode": 0, "errorMessage": null, "paused": true, "account": { "balance": 50000, "equity": 50000, "unrealizedPnl": 0, "marginUsed": 0, "positions": 0, "pendingOrders": 0 }, "propFirm": null}Example
# Pause auto-playresult = api("Replay/pause", {"accountId": ACCOUNT_ID})print(f"Paused — equity: {result['account']['equity']}")POST Replay/end
End the replay session. This is the last call in a backtest — it persists the final balance and tears down the session. The response carries the final account snapshot.
URL: POST /api/sim/Replay/end
Authentication: Bearer token required.
Request body
{ "accountId": 12345}| Field | Type | Required | Description |
|---|---|---|---|
accountId | number | Yes | Account ID |
Response
{ "success": true, "errorCode": 0, "errorMessage": null, "ended": true, "account": { "balance": 51250.00, "equity": 51250.00, "unrealizedPnl": 0, "marginUsed": 0, "positions": 0, "pendingOrders": 0 }}| Field | Type | Description |
|---|---|---|
ended | boolean | Always true on success |
account | object or null | Final account snapshot: balance, equity, unrealizedPnl, marginUsed, positions, pendingOrders |
Example
# Flatten, then end the session and print the final balanceapi("Position/closeContract", {"accountId": ACCOUNT_ID, "contractId": CONTRACT_ID})result = api("Replay/end", {"accountId": ACCOUNT_ID})
if result["success"]: acct = result["account"] print(f"\n=== Backtest Complete ===") print(f"Final Balance: ${acct['balance']:,.2f}") print(f"Net P&L: ${acct['balance'] - 50000:+,.2f}")curl -X POST https://api.test-max.com/api/sim/Replay/end \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{"accountId": 12345}'Complete backtest example
Here is a minimal end-to-end example that starts a replay, steps through bars, and ends the session:
import urllib.requestimport urllib.errorimport jsonimport osimport time
API_URL = "https://api.test-max.com/api/sim"
def api(path, body=None, retries=10): data = json.dumps(body).encode() if body else None req = urllib.request.Request( f"{API_URL}/{path}", data=data, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}" } ) for _ in range(retries): try: return json.loads(urllib.request.urlopen(req).read()) except urllib.error.HTTPError as e: # 429 = rate/streaming allowance, 503 = session busy or loading: # both are retryable after Retry-After seconds. if e.code not in (429, 503): raise time.sleep(min(int(e.headers.get("Retry-After", "1") or "1"), 5)) raise RuntimeError(f"{path} kept answering 429/503")
# 1. Authenticatelogin = api("Auth/loginKey", { "userName": os.environ["TESTMAX_EMAIL"], "apiKey": os.environ["TESTMAX_API_KEY"]})TOKEN = login["token"]
# 2. Start replaysession = api("Replay/start", { "instrumentSymbol": "NQ", "startDate": "2025-01-15", "timeframe": "5m"})ACCOUNT_ID = session["accountId"]CONTRACT_ID = session["instrument"]["contractId"]
# 3. Step through barstry: for i in range(session["totalBars"]): result = api("Replay/step", {"accountId": ACCOUNT_ID, "steps": 1}) if not result["success"] or result["barsProcessed"] == 0: break
bar = result["bars"][0]
# Simple strategy: buy on first bar, sell on last if i == 0: api("Order/place", { "accountId": ACCOUNT_ID, "contractId": CONTRACT_ID, "type": 2, "side": 0, "size": 1 }) elif i == session["totalBars"] - 2: api("Position/closeContract", { "accountId": ACCOUNT_ID, "contractId": CONTRACT_ID })
finally: # 4. Always end the session results = api("Replay/end", {"accountId": ACCOUNT_ID}) if results["success"] and results["account"]: print(f"P&L: ${results['account']['balance'] - 50000:+,.2f}")