Skip to content
Back to App

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:

  1. StartReplay/start initializes a session with an instrument, date, and timeframe
  2. LoopReplay/step advances bars one at a time; your strategy analyzes data and places orders
  3. EndReplay/end finalizes 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"
}
FieldTypeRequiredDescription
instrumentSymbolstringYes*Instrument symbol (e.g., "NQ", "ES", "GC", "CL", "EURUSD")
contractIdstringYes*Alternative to instrumentSymbol — a contract ID from Contract/search
startDatestringYesSession start date in YYYY-MM-DD format
endDatestringNoUpper 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
timeframestringNoBar timeframe (default "1s")
accountIdnumberNoAn 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-sensitiveM is months, m is minutes:

UnitMeaningBucketingMaximum
ssecondsFixed width, epoch grid30 days
mminutesFixed width, epoch grid30 days
hhoursFixed width, epoch grid30 days
ddaysFixed width, epoch grid30 days
wweeksCalendar, ISO Monday weeks in UTC52
MmonthsCalendar, UTC calendar months12
yyearsCalendar, UTC calendar years5

These 12 values are the presets the platform UI offers on every plan:

ValueDescription
"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
}
}
FieldTypeDescription
accountIdnumberThe account ID to use for orders and positions in this session
sessionIdstringInternal session UUID (informational)
totalBarsnumberTotal number of bars available in the session
historyBarsnumberNumber of bars pre-loaded as chart history before the replay start
instrument.namestringInstrument symbol
instrument.contractIdstringThe resolved contract ID — use this as contractId in order endpoints
instrument.tickSizenumberMinimum price increment
instrument.tickValuenumberDollar 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 NQ
result = 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']}")

Error responses

ScenarioerrorCodeerrorMessage
Neither instrumentSymbol nor contractId given1"instrumentSymbol or contractId required"
Unknown instrument1"Instrument not found"
Missing startDate2"startDate required"
timeframe is a custom while the release switch is off2"Custom timeframes are currently disabled"
Session setup failed (e.g., no data for the date)7The 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
}
FieldTypeRequiredDefaultDescription
accountIdnumberYesAccount ID from Replay/start
stepsnumberNo1Number 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": []
}
FieldTypeDescription
barsarrayArray of bar objects, one per step. See Bar Data Format.
bars[].tstringISO 8601 timestamp
bars[].o / h / l / cnumberOpen / high / low / close price
bars[].vnumberVolume
barsProcessednumberHow many bars actually advanced — 0 means the replay reached the end of the data
currentIndexnumberIndex of the next bar to be played
totalBarsnumberTotal bars in the session
accountobject or nullCurrent account snapshot: balance, equity, unrealizedPnl, marginUsed, positions, pendingOrders
propFirmobject or nullChallenge snapshot (status, isFunded, currentPhase, tradingDaysCount, drawdownLocked), or null for non-challenge sessions
filledOrdersarrayOrders filled during these bars: {orderId, fillPrice, fillQty}
violationsarrayChallenge 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 bars
result = api("Replay/step", {
"accountId": ACCOUNT_ID,
"steps": 10
})
# Process all returned bars
for 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:

StatusMeaningWhat to do
503 + Retry-After: 1The 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-AfterThe 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"
}
FieldTypeRequiredDescription
accountIdnumberYesAccount ID
targetTimestringYesISO 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 targetTime answers success: false, errorCode: 2, errorMessage: "Invalid targetTime" — nothing moves.
  • The same 503/429 answers as Replay/step apply (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
}
FieldTypeRequiredDefaultDescription
accountIdnumberYesAccount ID
speednumberNo1Playback 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 speed
result = 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
}
FieldTypeRequiredDescription
accountIdnumberYesAccount 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-play
result = 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
}
FieldTypeRequiredDescription
accountIdnumberYesAccount 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
}
}
FieldTypeDescription
endedbooleanAlways true on success
accountobject or nullFinal account snapshot: balance, equity, unrealizedPnl, marginUsed, positions, pendingOrders

Example

# Flatten, then end the session and print the final balance
api("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}")

Complete backtest example

Here is a minimal end-to-end example that starts a replay, steps through bars, and ends the session:

import urllib.request
import urllib.error
import json
import os
import 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. Authenticate
login = api("Auth/loginKey", {
"userName": os.environ["TESTMAX_EMAIL"],
"apiKey": os.environ["TESTMAX_API_KEY"]
})
TOKEN = login["token"]
# 2. Start replay
session = api("Replay/start", {
"instrumentSymbol": "NQ",
"startDate": "2025-01-15",
"timeframe": "5m"
})
ACCOUNT_ID = session["accountId"]
CONTRACT_ID = session["instrument"]["contractId"]
# 3. Step through bars
try:
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}")