Running a Backtest

Backtesting lets you test a strategy against historical market data before risking real capital. This guide walks you through running a backtest, checking the results, and iterating on parameters to improve performance.

Prerequisites

export TOKEN="your-jwt-token-here"

Step 1: Start a backtest

The backtesting endpoint takes your strategy configuration, a date range, and risk parameters. It runs synchronously and returns results in the response.
Backtests run one at a time. The engine processes a single synchronous backtest per worker at a time. If you fire many backtests in parallel, only one runs immediately; the others queue, and a request that waits too long is rejected with a 503 (see Concurrency and limits). Run backtests sequentially, or use the bulk endpoint to test many strategies in a single request.
This example backtests an ETH strategy using the MACD bullish cross + SMA filter, looking back 12 months of 1-hour data:
curl -s -X POST "https://api.mangrovedeveloper.ai/api/v1/backtesting/backtest" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "asset": "ETH",
    "interval": "1h",
    "lookback_months": 12,
    "initial_balance": 10000.0,
    "min_balance_threshold": 0.1,
    "min_trade_amount": 25.0,
    "max_open_positions": 5,
    "max_trades_per_day": 50,
    "max_risk_per_trade": 0.01,
    "max_units_per_trade": 100.0,
    "max_trade_amount": 10000.0,
    "volatility_window": 24,
    "target_volatility": 0.02,
    "volatility_mode": "stddev",
    "enable_volatility_adjustment": false,
    "cooldown_bars": 24,
    "daily_momentum_limit": 3.0,
    "weekly_momentum_limit": 3.0,
    "strategy_json": "{\"name\": \"ETH MACD Trend Strategy\", \"asset\": \"ETH\", \"entry\": [{\"name\": \"macd_bullish_cross\", \"timeframe\": \"1h\", \"signal_type\": \"TRIGGER\", \"params\": {\"window_fast\": 12, \"window_slow\": 26, \"window_sign\": 9}}, {\"name\": \"is_above_sma\", \"timeframe\": \"1h\", \"signal_type\": \"FILTER\", \"params\": {\"window\": 50}}], \"exit\": [{\"name\": \"macd_bearish_cross\", \"timeframe\": \"1h\", \"signal_type\": \"TRIGGER\", \"params\": {\"window_fast\": 12, \"window_slow\": 26, \"window_sign\": 9}}]}",
    "execution_config": {
      "atr_period": 14,
      "atr_volatility_factor": 2.0
    }
  }' | python3 -m json.tool
The strategy_json field must be a stringified JSON object, not a nested object. Use JSON.stringify() in JavaScript or json.dumps() in Python.

Reuse a saved strategy by ID

If you already created a strategy and have its strategy_id, you can backtest it directly instead of re-supplying the full strategy_json. Pass strategy_id and the run parameters (date range, account, and risk settings); the API loads the strategy’s definition for you and defaults asset and execution_config from the saved strategy when you omit them.
curl -s -X POST "https://api.mangrovedeveloper.ai/api/v1/backtesting/backtest" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "strategy_id": "550e8400-e29b-41d4-a716-446655440000",
    "interval": "1h",
    "lookback_months": 12,
    "initial_balance": 10000.0,
    "min_balance_threshold": 0.1,
    "min_trade_amount": 25.0,
    "max_open_positions": 5,
    "max_trades_per_day": 50,
    "max_risk_per_trade": 0.01,
    "max_units_per_trade": 100.0,
    "max_trade_amount": 10000.0,
    "volatility_window": 24,
    "target_volatility": 0.02,
    "volatility_mode": "stddev",
    "enable_volatility_adjustment": false,
    "cooldown_bars": 24,
    "daily_momentum_limit": 3.0,
    "weekly_momentum_limit": 3.0
  }' | python3 -m json.tool
Provide either strategy_id or strategy_json, not both. Sending both returns a 400; an unknown or inaccessible strategy_id returns a 404. To override the saved strategy’s market, include asset (and execution_config) explicitly alongside strategy_id.

Step 2: Choose your date range

The backtesting API supports four modes for specifying the time window:
ModeParametersExample
Recent historylookback_monthsLast 12 months from now
From a start date to nowstart_dateJune 1, 2025 to present
Explicit rangestart_date + end_dateJan 1 to Jun 30, 2025
Lookback from end dateend_date + lookback_months6 months back from Dec 31, 2025
To use an explicit date range instead of lookback, replace lookback_months with:
{
  "start_date": "2025-01-01",
  "end_date": "2025-06-30"
}
You must provide at least one date parameter. The API will reject requests with no date range specified. Dates use ISO format: YYYY-MM-DD.
Data availability is the effective floor on the window. The requested range is clipped to what the upstream market-data provider (CoinAPI) actually has for the asset and interval. If the provider only holds, say, the last few months for a symbol, then lookback_months: 12 and an equivalent start_date resolve to the same backtest — identical trade count and metrics — because both are bounded by the available history, not by the parameter you sent. Treat the date parameters as an upper bound on the window, not a guarantee of its length, and check the returned date range / bar count to see what was actually used.

Step 3: Review the results

A successful backtest returns performance metrics and a trade count:
{
  "success": true,
  "metrics": {
    "sharpe_ratio": 1.23,
    "sortino_ratio": 1.10,
    "calmar_ratio": 0.80,
    "irr_annualized": 0.25,
    "max_drawdown": 0.15,
    "max_drawdown_duration": 42,
    "win_rate": 0.55
  },
  "execution_time_seconds": 3.21,
  "trade_count": 12
}
Here is how to read the key metrics:
MetricWhat it meansGood values
sharpe_ratioRisk-adjusted return (annualized). Higher is better.> 1.0
sortino_ratioLike Sharpe but only penalizes downside volatility.> 1.0
calmar_ratioAnnual return divided by max drawdown.> 0.5
irr_annualizedAnnualized internal rate of return.Positive
max_drawdownLargest peak-to-trough equity decline (as a decimal).< 0.20
max_drawdown_durationLongest drawdown in number of bars.Depends on timeframe
win_rateFraction of profitable trades.> 0.45
trade_countTotal trades executed.Varies (too few = insufficient data)
# The result is already in the response from Step 1.
# Parse with jq:
echo "$RESULT" | jq '.metrics'
A backtest with very few trades (under 10) may not be statistically meaningful. If you see a low trade count, try a longer date range or adjust your signal parameters to generate more entries.

Step 4: Iterate on parameters

If the results are not satisfactory, adjust your strategy and re-run. Here are common levers to pull:

Adjust signal parameters

Try different windows or thresholds for your signals:
{
  "params": {"window_fast": 8, "window_slow": 21, "window_sign": 9}
}
Shorter MACD windows (8/21) react faster, producing more trades but potentially more false signals. Longer windows (19/39) react slower with fewer but higher-conviction trades.

Adjust risk parameters

Change how much the strategy risks per trade or how long it holds positions. These go in the execution_config:
{
  "execution_config": {
    "atr_period": 14,
    "atr_volatility_factor": 2.0
  }
}
A higher atr_volatility_factor (e.g., 3.0) gives each trade more room to breathe with a wider stop-loss. A lower value (e.g., 1.5) tightens the stop. See the Risk Management guide for details.

Change the timeframe

Switch from 1h to 4h or 1d bars. Longer timeframes produce fewer but potentially stronger signals:
{
  "asset": "ETH",
  "interval": "4h",
  "lookback_months": 12
}

Compare multiple strategies at once

Use the bulk backtest endpoint to test several strategies in a single request. Market data is fetched once per unique (asset, timeframe) pair and shared across all strategies:
curl -s -X POST "https://api.mangrovedeveloper.ai/api/v1/backtesting/backtest/bulk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "strategy_configs": [
      {
        "name": "MACD 12/26",
        "asset": "ETH",
        "entry": [
          {"name": "macd_bullish_cross", "timeframe": "1h", "signal_type": "TRIGGER", "params": {"window_fast": 12, "window_slow": 26, "window_sign": 9}},
          {"name": "is_above_sma", "timeframe": "1h", "signal_type": "FILTER", "params": {"window": 50}}
        ],
        "exit": []
      },
      {
        "name": "MACD 8/21",
        "asset": "ETH",
        "entry": [
          {"name": "macd_bullish_cross", "timeframe": "1h", "signal_type": "TRIGGER", "params": {"window_fast": 8, "window_slow": 21, "window_sign": 9}},
          {"name": "is_above_sma", "timeframe": "1h", "signal_type": "FILTER", "params": {"window": 50}}
        ],
        "exit": []
      }
    ],
    "start_date": "2025-01-01",
    "end_date": "2025-12-31",
    "initial_balance": 10000.0,
    "min_balance_threshold": 0.1,
    "min_trade_amount": 25.0,
    "max_open_positions": 5,
    "max_trades_per_day": 50,
    "max_risk_per_trade": 0.01,
    "max_units_per_trade": 100.0,
    "max_trade_amount": 10000.0,
    "volatility_window": 24,
    "target_volatility": 0.02,
    "volatility_mode": "stddev",
    "enable_volatility_adjustment": false,
    "cooldown_bars": 24,
    "daily_momentum_limit": 3.0,
    "weekly_momentum_limit": 3.0
  }' | python3 -m json.tool
Both strategies share the same ETH/1h data, so only 1 market data API call is made regardless of how many strategies you include.

Retrieving a saved backtest

Backtests run through the AI Copilot are persisted with a backtest_id. You can retrieve them later:
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://api.mangrovedeveloper.ai/api/v1/backtesting/backtest/550e8400-e29b-41d4-a716-446655440000" \
  | python3 -m json.tool
The standalone backtesting endpoint (POST /api/v1/backtesting/backtest) returns results directly but does not create a persistent backtest_runs record. Only backtests initiated through the AI Copilot are persisted and retrievable by ID.

Concurrency and limits

The synchronous backtest endpoint runs one backtest at a time per worker. This keeps results deterministic, but it means you cannot speed things up by firing many single-backtest requests in parallel — they serialize behind the running one.
BehaviorWhat happens
One request at a timeRuns immediately and returns results in the response.
A second request while one is runningWaits briefly for the first to finish. If it completes in time, your request then runs normally.
Too many in parallel / long-running run ahead of youYour request is rejected with 503 Service Unavailable and a Retry-After header instead of hanging until the gateway times out (504).
To run many backtests efficiently:
  • Run sequentially — wait for each response before sending the next request.
  • Honor Retry-After — on a 503, wait the indicated number of seconds and retry (an exponential backoff also works). Official SDKs do this automatically.
  • Use the bulk endpoint to evaluate many strategies in a single request — market data is fetched once and shared, which is both faster and avoids contention.
A 503 here is transient and means “busy, try again” — it is not a problem with your strategy or parameters.

Common errors

ErrorCauseFix
Missing required fieldsRisk parameters like max_risk_per_trade were omittedInclude all required fields (see Step 1)
Invalid strategy formatstrategy_json is malformed or missing entry/exitVerify the JSON structure has entry with 1 TRIGGER + 1 FILTER
Invalid date formatDate not in YYYY-MM-DDUse ISO format: "2025-01-01"
Market data unavailableCoinAPI has no data for the requested rangeTry a different date range or asset
503 engine busyAnother backtest was already running (you sent requests in parallel)Wait for the Retry-After delay and retry, run sequentially, or use the bulk endpoint. See Concurrency and limits.

Next steps