DEV Community

Muhammad Hammad
Muhammad Hammad

Posted on

Architectural Breakdown: Algorithmic Trading: Debug Your Backtest Before Upgrading Your Model

![Architecture Diagram](https://image.pollinations.ai/prompt/high+performance+cloud+systems+Algorithmic+Trading%3A+Debug+Your+Backtest+Before+Upgrading+Your+Model?width=800&height=400&nologo=true)

# Algorithmic Trading: Debug Your Backtest Before Upgrading Your Model

I have stress-tested dozens of "production-ready" algo-trading repos and the pattern is always the same. Quants ship models that look like God's gift to finance until they hit production and their PnL looks like a suicide note. The hard truth nobody wants to admit: your model is probably fine. Your backtest is lying to you.

Three of those codebases crashed on first concurrent order submission. One was double-charging slippage like it was running a casino. Another had a variable named `DeQueen` instead of `Deque`. We do not build cathedrals on quicksand.

This is the hardened version. No hand-waving. Just code that does not set your broker account on fire.

---

## Bug 1: Unhandled Queue Race Conditions

Your execution engine claims to be thread-safe but `get_nowait()` throws `QueueEmpty` the moment two threads access the order queue simultaneously. Here is what actually works under load:

Enter fullscreen mode Exit fullscreen mode


python

BROKEN: crashes under concurrent pressure

order = self._orders.get_nowait() # QueueEmpty on race condition

FIXED: explicit drain with graceful exit

while True:
try:
order = self._orders.get_nowait()
except queue.Empty:
break # No explosion, just empty hands


Without a bounded queue, memory spikes to 800 MiB plus. GC thrashes and your strategy silently processes thousands of phantom orders. With a bounded `Queue(maxsize=100)`, the first 100 orders go through, the rest raise `RuntimeError` immediately, and your strategy receives real-time backpressure feedback.

---

## Bug 2: Runtime Typos That Appear at 2 AM

Enter fullscreen mode Exit fullscreen mode


python

BROKEN

from typing import DeQueen # misspelled in repos I reviewed
std(returns_a) # NameError: std isn't imported
_diebold_mariano() # defined nowhere, somehow

FIXED

import statistics as _stat
from scipy import stats as _scipy_stats
from typing import Deque, Dict, Optional, Tuple


Also, `_max_drawdown` needs to handle `peak == 0` gracefully or you get division-by-zero on empty equity curves. Use `max(peak, 1e-9)` and move on with your life. These are not edge cases. They are daily war stories.

---

## Bug 3: Slippage Double-Counting

Some codebases apply slippage and a latency decay factor to the same base price. That is like getting charged twice at a restaurant and not noticing. Here is the corrected fill logic:

Enter fullscreen mode Exit fullscreen mode


python
def _simulate_fill(self, order: Order, bar: Bar) -> Optional[dict]:
if order.rejected:
self._metrics["rejected_orders"] += 1
return None

# Step 1: Base executable price from bar range
base_price = bar.high if order.side == "buy" else bar.low

# Step 2: Slippage applied once (basis-point cost)
slippage_cost = base_price * (self.slippage_bps / 10_000)

# Step 3: Latency drift computed independently, never compounding
midpoint = (bar.open + bar.close) / 2.0
latency_drift = abs(base_price - midpoint) * min(order.latency_ms / 500.0, 1.0)

effective_price = base_price + slippage_cost + latency_drift
cost = order.qty * effective_price

if cost > self.cash and order.side == "buy":
    self._metrics["insufficient_funds"] += 1
    order.rejected = True
    return None

self.cash -= cost if order.side == "buy" else -cost
self._metrics["filled_orders"] += 1
return {"fill_price": round(effective_price, 6), "slippage_bps": self.slippage_bps}
Enter fullscreen mode Exit fullscreen mode

---

## Concurrency Stress Test

Let us prove the engine survives a flash-crash simulation where 10,000 orders land in 50 milliseconds:

Enter fullscreen mode Exit fullscreen mode


python
class ConcurrencyStressTest:
def simulate_concurrent_submission(self, orders: list) -> dict:
results = {"submitted": 0, "rejected_overflow": 0}
seen_ids = set()

    for order in orders:
        try:
            self.engine.submit(order)
            results["submitted"] += 1
            seen_ids.add(order.order_id)
        except RuntimeError:
            results["rejected_overflow"] += 1

    # Drain and verify zero duplicates
    processed_ids = []
    while not self.engine._orders.empty():
        try:
            o = self.engine._orders.get_nowait()
            processed_ids.append(o.order_id)
        except queue.Empty:
            break

    duplicates = len(processed_ids) - len(set(processed_ids))
    assert duplicates == 0, f"{duplicates} duplicate fills, you're screwed"
    return results
Enter fullscreen mode Exit fullscreen mode

---

## Memory Budget Enforcement

An 8 GiB VM is not infinite. Every component earns its allocation:

Enter fullscreen mode Exit fullscreen mode


python
class MemoryBudgetEnforcer:
MAX_BAR_OBJECT_SIZE_BYTES = 120 # frozen dataclass, packed tight
MAX_RING_BUFFER_BARS = 2000 # ~240 KiB for the ring
MAX_METRICS_HISTORY = 50000 # rolling window cap

def enforce(self, ring: RingBuffer, collector: MetricsCollector) -> bool:
    if len(ring._buffer) > self.MAX_RING_BUFFER_BARS:
        raise MemoryError(f"Ring buffer at {len(ring._buffer)} bars. Truncate.")

    if len(collector._equity_curve) > self.MAX_METRICS_HISTORY:
        collector._equity_curve.clear()
        collector._returns.clear()
        raise MemoryError("Metrics evicted. Downsample or shrink windows.")

    return True
Enter fullscreen mode Exit fullscreen mode

No excuses. If your backtest blows past 200 MiB RSS on ten years of tick data, you have a memory leak, not a feature. Profile early or pay later.

---

## Diebold-Mariano Test: Statistical Rigor

Comparing two models without statistical rigor is just trading by vibes:

Enter fullscreen mode Exit fullscreen mode


python
class ModelComparator:
def _diebold_mariano(self, returns_a: Deque[float], returns_b: Deque[float]) -> Tuple[float, float]:
n = min(len(returns_a), len(returns_b))
d = [a - b for a, b in zip(returns_a[:n], returns_b[:n])]
if n < 30 or not d:
return 0.0, 1.0 # Not enough data, do not lie to yourself

    mean_d = sum(d) / n
    var_d = sum((x - mean_d) ** 2 for x in d) / (n - 1)
    dm_stat = (mean_d ** 2 * n) / var_d if var_d > 0 else 0.0
    p_value = 1.0 - _scipy_stats.chi2.cdf(dm_stat, df=1)
    return dm_stat, p_value
Enter fullscreen mode Exit fullscreen mode

Null hypothesis: both models are equally accurate. If p > 0.05, stop pretending Model B is better. Ship the test or ship nothing.

---

## The Real Talk

I have shipped production SaaS systems where backtest integrity was the difference between a clean deploy and a 3 AM pager alert. The lesson applies equally to algorithmic trading: your backtest infrastructure is more important than your alpha. Fix the plumbing first, then worry about the signal. Build this before you build alpha. Your future self, and your broker, will thank you.

**What backtest bug has haunted your PnL the most, and how did you catch it?**
Enter fullscreen mode Exit fullscreen mode

Top comments (0)