The Testing Pyramid for Trading
Most trading bot tutorials skip testing entirely. That is a mistake. A trading system has more failure modes than a typical web app, and each failure can cost real money.
[Paper Trading] ← Production-like, no real money
/ \
[Integration Tests] ← API calls, data flow, timing
/ \
[Unit Tests] ← Signal logic, calculations, edge cases Level 1: Unit Tests
Unit tests verify your signal logic in isolation. No API calls, no network, no databases. Pure inputs and outputs.
import pytest
# The function under test
def evaluate_rsi_signal(rsi: float, direction: str = "long") -> str:
if direction == "long":
if rsi < 30: return "BUY"
if rsi > 70: return "AVOID"
elif direction == "short":
if rsi > 70: return "SELL"
if rsi < 30: return "AVOID"
return "NEUTRAL"
# Unit tests
class TestRSISignal:
def test_oversold_gives_buy(self):
assert evaluate_rsi_signal(25.0, "long") == "BUY"
def test_overbought_gives_avoid_for_long(self):
assert evaluate_rsi_signal(75.0, "long") == "AVOID"
def test_neutral_zone(self):
assert evaluate_rsi_signal(50.0, "long") == "NEUTRAL"
def test_exact_boundary_30(self):
# At exactly 30, not oversold
assert evaluate_rsi_signal(30.0, "long") == "NEUTRAL"
def test_short_direction(self):
assert evaluate_rsi_signal(75.0, "short") == "SELL"
assert evaluate_rsi_signal(25.0, "short") == "AVOID"
def test_extreme_values(self):
assert evaluate_rsi_signal(0.0, "long") == "BUY"
assert evaluate_rsi_signal(100.0, "short") == "SELL" Unit Testing Position Sizing
def calculate_position_size(
balance: float, risk_pct: float,
entry: float, stop_loss: float, pip_value: float = 10.0
) -> float:
risk_amount = balance * (risk_pct / 100)
stop_distance = abs(entry - stop_loss)
pips = stop_distance * 10000 # for non-JPY pairs
if pips == 0:
return 0.0
lots = risk_amount / (pips * pip_value)
return round(lots, 2)
class TestPositionSizing:
def test_standard_calculation(self):
lots = calculate_position_size(10000, 1.0, 1.0850, 1.0800)
assert lots == 0.2 # $100 risk / (50 pips * $10)
def test_zero_stop_distance(self):
lots = calculate_position_size(10000, 1.0, 1.0850, 1.0850)
assert lots == 0.0 # Should not divide by zero
def test_larger_account(self):
lots = calculate_position_size(50000, 1.0, 1.0850, 1.0800)
assert lots == 1.0
def test_tight_stop(self):
lots = calculate_position_size(10000, 1.0, 1.0850, 1.0840)
assert lots == 1.0 # $100 risk / (10 pips * $10) Level 2: Integration Tests
Integration tests verify that your code works correctly with the real API. Use them to catch response format changes, authentication issues, and network handling.
import requests
import os
API_KEY = os.environ.get("CLAW_API_KEY")
BASE_URL = "https://tickatlas.com/v1"
@pytest.mark.integration
class TestAPIIntegration:
def test_indicator_returns_valid_rsi(self):
resp = requests.get(
f"{BASE_URL}/indicator",
headers={"X-API-Key": API_KEY},
params={"symbol": "EURUSD", "indicator": "RSI_14", "timeframe": "H4"},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
rsi = data["data"]["value"]
assert 0 <= rsi <= 100
def test_invalid_symbol_returns_error(self):
resp = requests.get(
f"{BASE_URL}/indicator",
headers={"X-API-Key": API_KEY},
params={"symbol": "INVALID", "indicator": "RSI_14", "timeframe": "H4"},
)
data = resp.json()
assert data["success"] is False
def test_missing_api_key_returns_401(self):
resp = requests.get(
f"{BASE_URL}/indicator",
params={"symbol": "EURUSD", "indicator": "RSI_14", "timeframe": "H4"},
)
assert resp.status_code == 401 Level 3: Paper Trading
Paper trading runs your full strategy with real market data but simulated execution. It is the final validation before risking real money.
class PaperTrader:
"""Simulated trading for strategy validation."""
def __init__(self, initial_balance: float = 10000):
self.balance = initial_balance
self.positions = []
self.trade_history = []
def open_position(self, symbol: str, direction: str,
lots: float, entry: float, sl: float, tp: float):
position = {
"symbol": symbol, "direction": direction,
"lots": lots, "entry": entry, "sl": sl, "tp": tp,
"opened_at": datetime.utcnow().isoformat(),
}
self.positions.append(position)
logger.info(f"PAPER: Opened {direction} {lots} {symbol} @ {entry}")
def check_positions(self, symbol: str, current_price: float):
for pos in self.positions[:]:
if pos["symbol"] != symbol:
continue
if pos["direction"] == "long":
if current_price <= pos["sl"]:
self._close(pos, pos["sl"], "stop_loss")
elif current_price >= pos["tp"]:
self._close(pos, pos["tp"], "take_profit")
elif pos["direction"] == "short":
if current_price >= pos["sl"]:
self._close(pos, pos["sl"], "stop_loss")
elif current_price <= pos["tp"]:
self._close(pos, pos["tp"], "take_profit")
def _close(self, position: dict, exit_price: float, reason: str):
pnl = self._calculate_pnl(position, exit_price)
self.balance += pnl
position["exit"] = exit_price
position["pnl"] = pnl
position["reason"] = reason
self.trade_history.append(position)
self.positions.remove(position)
logger.info(f"PAPER: Closed {position['symbol']} P/L: {pnl:.2f}")
def report(self):
wins = [t for t in self.trade_history if t["pnl"] > 0]
losses = [t for t in self.trade_history if t["pnl"] <= 0]
total = len(self.trade_history)
print(f"Total trades: {total}")
print(f"Win rate: {len(wins)/total:.1%}" if total else "No trades")
print(f"Final balance: \${self.balance:.2f}") When to Move to Live
Unit tests pass with 100% coverage on signal logic
Every edge case in your signal evaluation and position sizing must be tested.
Integration tests confirm API contract
Run these weekly to catch any API changes before they break your live system.
Paper trading for minimum 2 weeks
Two weeks gives you enough trades across different market conditions to validate the strategy's behavior.
Paper results match backtest within tolerance
If paper trading shows a 60% win rate but your backtest showed 80%, something is wrong. Investigate before going live.
Run this against live data.
Every account starts pay-as-you-go with $2.50 of credit and no card. Paste the key into the samples above and the requests work unchanged.