← Back to Blog
April 5, 2026python

Backtesting with Simulated Positions: An End-to-End Example

By APIndicators

Most retail algo traders struggle with the same problem: they have a strategy idea, but wiring up a backtest framework takes a week. Then the framework's assumptions leak into the results (instant fills, zero slippage, no funding), and the real-world performance diverges.

APIndicators' simulated-positions endpoint is a lightweight alternative. You post a signal, we track it against real price movement, and you pull back a PnL series. No framework, no infrastructure. This walkthrough builds a complete backtest in one Python script: fetch signals from your strategy, create simulated positions, aggregate results.

The Simulated Positions Endpoint

POST /v1/simulated-positions accepts an entry, take-profit, stop-loss, and leverage, then tracks the position using real market data until one of the exits is hit (or a time limit expires).

import requests
import os

API_KEY = os.environ["APINDICATORS_API_KEY"]
BASE = "https://api.apindicators.com/v1"

def create_position(symbol, side, entry, tp, sl, leverage=5):
    url = f"{BASE}/simulated-positions"
    headers = {"Authorization": f"Bearer {API_KEY}"}
    payload = {
        "symbol": symbol,
        "side": side,
        "entry_price": entry,
        "take_profit_at": tp,
        "stop_loss_at": sl,
        "leverage": leverage,
    }
    r = requests.post(url, headers=headers, json=payload, timeout=10)
    r.raise_for_status()
    return r.json()

position = create_position("BTCUSDT", "BUY", 65000, 66300, 63700)
print(position["id"], position["status"])

The response includes the position ID, current status, and eventual exit_price / pnl_pct once resolved.

Step 1: Define a Strategy

A simple RSI mean-reversion entry, 2% TP, 1% SL:

def strategy_signal(indicators):
    rsi = indicators["rsi_14"]
    ema_20 = indicators["ema_20"]
    ema_50 = indicators["ema_50"]
    close = indicators["close"]

    if rsi < 32 and ema_20 > ema_50:
        return {
            "side": "BUY",
            "entry": close,
            "tp": close * 1.02,
            "sl": close * 0.99,
        }
    if rsi > 68 and ema_20 < ema_50:
        return {
            "side": "SELL",
            "entry": close,
            "tp": close * 0.98,
            "sl": close * 1.01,
        }
    return None

Step 2: Iterate Historical Candles and Generate Signals

APIndicators exposes historical indicator snapshots:

def get_historical_indicators(symbol, interval, limit=500):
    url = f"{BASE}/pairs/{symbol}/indicators/history"
    headers = {"Authorization": f"Bearer {API_KEY}"}
    params = {"interval": interval, "limit": limit}
    r = requests.get(url, headers=headers, params=params, timeout=30)
    return r.json()["data"]

history = get_historical_indicators("BTCUSDT", "1h", limit=720)

720 one-hour candles = 30 days of data. For each candle, check if the strategy fires:

import time

positions = []
for snapshot in history:
    signal = strategy_signal(snapshot)
    if signal is None:
        continue

    pos = create_position(
        symbol="BTCUSDT",
        side=signal["side"],
        entry=signal["entry"],
        tp=signal["tp"],
        sl=signal["sl"],
    )
    positions.append(pos)
    time.sleep(0.1)

print(f"Created {len(positions)} simulated positions")

Step 3: Resolve Positions and Collect Results

Simulated positions resolve asynchronously as APIndicators tracks them against real price data. Poll until they resolve:

def get_position(position_id):
    url = f"{BASE}/simulated-positions/{position_id}"
    headers = {"Authorization": f"Bearer {API_KEY}"}
    r = requests.get(url, headers=headers, timeout=10)
    return r.json()

resolved = []
for pos in positions:
    current = get_position(pos["id"])
    if current["status"] in ("tp_hit", "sl_hit", "expired"):
        resolved.append(current)

print(f"{len(resolved)}/{len(positions)} resolved")

For a fast backtest, use historical entries with past timestamps — positions resolve immediately since the price path is already known.

Step 4: Compute Performance Metrics

import numpy as np

def analyze_backtest(results):
    returns = [p["pnl_pct"] for p in results]
    wins = [r for r in returns if r > 0]
    losses = [r for r in returns if r <= 0]

    win_rate = len(wins) / len(returns) if returns else 0
    avg_return = np.mean(returns) if returns else 0
    avg_win = np.mean(wins) if wins else 0
    avg_loss = np.mean(losses) if losses else 0

    cumulative = np.cumsum(returns)
    peak = np.maximum.accumulate(cumulative)
    drawdown = cumulative - peak
    max_dd = drawdown.min() if len(drawdown) > 0 else 0

    sharpe = (np.mean(returns) / np.std(returns) * np.sqrt(252)) if np.std(returns) > 0 else 0

    return {
        "n_trades": len(returns),
        "win_rate": win_rate,
        "avg_return_pct": avg_return,
        "avg_win_pct": avg_win,
        "avg_loss_pct": avg_loss,
        "max_drawdown_pct": max_dd,
        "sharpe_ratio": sharpe,
        "cumulative_return_pct": cumulative[-1] if len(cumulative) > 0 else 0,
    }

stats = analyze_backtest(resolved)
for k, v in stats.items():
    print(f"  {k}: {v:.3f}" if isinstance(v, float) else f"  {k}: {v}")

Typical output for a decent strategy:

n_trades: 42
win_rate: 0.548
avg_return_pct: 0.187
avg_win_pct: 1.42
avg_loss_pct: -0.95
max_drawdown_pct: -3.21
sharpe_ratio: 1.34
cumulative_return_pct: 7.84

Step 5: Iterate

Backtesting is not a one-shot activity. Once you have a baseline, start varying parameters systematically:

for rsi_low, rsi_high in [(28, 72), (30, 70), (32, 68), (35, 65)]:
    results = run_backtest(rsi_low, rsi_high)
    stats = analyze_backtest(results)
    print(f"RSI band ({rsi_low}, {rsi_high}): WR={stats['win_rate']:.3f}, Sharpe={stats['sharpe_ratio']:.2f}")

Watch for two red flags:

  • Parameters cluster on one end of the range. If 28/72 wins and 35/65 loses, you may be edge-fitting.
  • Results collapse on out-of-sample data. Always hold out the last 30% of data, do not tune on it.

Practical Takeaways

  1. Simulated positions beat a custom backtest framework for quick iteration.
  2. Include slippage assumptions in your TP/SL levels (shift TP down by 0.05%, SL up by 0.05%).
  3. Compute max drawdown and Sharpe, not just win rate.
  4. Test across multiple pairs, not just BTCUSDT.
  5. Hold out data for honest validation.

The full simulated positions schema is at apindicators.com/docs. Ship your first backtest today — sign up at apindicators.com/pricing.