Debugging Strategies
When a backtest produces unexpected results — no trades, wrong signals, or poor metrics — use these patterns to diagnose the issue.
No Trades Generated
The most common problem. Your strategy runs but places zero orders.
Check 1: Indicators returning NaN
If your warm-up is too short, indicators may be NaN for the entire run:
function onBar(ctx, i) { // Add temporary logging if (i < 5) { console.log('Bar', i, 'EMA:', ctx.ind.ema[i], 'RSI:', ctx.ind.rsi[i]); } }
Fix: Increase warmupBars to at least max(all indicator periods) + 1.
Check 2: Conditions never met simultaneously
Multiple filters may be individually reasonable but never true at the same time:
// These might never all be true at once if (rsi < 30 && close > ema200 && volume > avgVol * 3 && atr < threshold) { // Too many conditions }
Fix: Test each condition independently. Remove filters one at a time to see which one blocks all entries.
Check 3: Position check prevents re-entry
// If you close and re-enter on the same bar, the position // might not be 0 when you expect it to be if (pos.qty === 0 && buySignal) { ctx.order.market('ASSET', 1, { signal: 'buy' }); }
Fix: Remember that orders fill on the next bar's open. Position state reflects fills, not pending orders.
Unexpected Entry/Exit Timing
Order Fill Timing
All market orders fill at the next bar's open, not the current bar's close:
Bar 100: Signal detected (close = $50,000) Bar 101: Order fills (open = $50,150) <-- actual entry price
This is by design — it prevents look-ahead bias. If you see entries at "wrong" prices, check that you're comparing against the correct bar.
Crossover Fires Once
q.crossOver() returns true only on the bar where the crossing happens, not while one series remains above the other:
// This fires once (correct) if (q.crossOver(fastEma, slowEma, i)) { ... } // This fires every bar while fast > slow (different behavior) if (fastEma[i] > slowEma[i]) { ... }
Excessive Trading
If your strategy generates too many trades with high turnover:
Choppy Signal Filter
Add a minimum bars-between-trades cooldown:
function onBar(ctx, i) { const pos = ctx.position('ASSET'); // Simple cooldown: don't re-enter within 5 bars of an exit if (pos.qty === 0 && pos.lastExitBar && i - pos.lastExitBar < 5) { return; } }
Trend Filter
Only trade in the direction of the larger trend:
function init(ctx) { ctx.addIndicator('fastEma', 'EMA', { period: 10 }); ctx.addIndicator('slowEma', 'EMA', { period: 50 }); ctx.addIndicator('trendEma', 'EMA', { period: 200 }); } function onBar(ctx, i) { const inUptrend = ctx.series.close[i] > ctx.ind.trendEma[i]; // Only take long entries in an uptrend if (inUptrend && q.crossOver(ctx.ind.fastEma, ctx.ind.slowEma, i)) { ctx.order.market('ASSET', 1, { signal: 'buy' }); } }
Poor Performance Metrics
High Drawdown
- Position sizing too aggressive — reduce quantity from 1 to 0.5 or less
- No stop loss — add ATR-based or percentage-based stops
- Trading against the trend — add a trend filter
Low Win Rate
- Entry signals may be noisy — add confirmation filters
- Stops too tight — widen stop distance (e.g. 2x ATR instead of 1x)
- Wrong timeframe — some strategies work better on higher timeframes
High Fee Drag
- Check total fees in the performance metrics
- Default fee is 0.1% per side (0.2% round trip)
- Tight stop-losses in choppy markets generate many small losing trades where fees dominate
Strategy Checklist
Before running a backtest, verify:
- All indicators declared in
init()with correct parameters -
warmupBars>= max indicator period + 1 -
q.isNaN()guards on indicator values - Position check (
pos.qty === 0) before entries - Both entry AND exit logic defined
- Signal names are descriptive
- Quantity is reasonable (0.1 to 1.0)
Related
- Warm-up Periods — Indicator initialization
- Order Fill Timing — How orders execute
- Performance Metrics — Understanding results