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
- Simulated positions beat a custom backtest framework for quick iteration.
- Include slippage assumptions in your TP/SL levels (shift TP down by 0.05%, SL up by 0.05%).
- Compute max drawdown and Sharpe, not just win rate.
- Test across multiple pairs, not just BTCUSDT.
- 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.