Backtest System — Dependency Map
This document maps every dependency in the backtest pipeline: what data is required, how the simulation works per entry strategy, how SL/TP levels are derived, and what the output means. Read alongside Trading System — Dependency Map for live trading context.
1. System Overview
Admin UI ──► POST /api/ohlc/optimise (mi-casa) ──► POST /backtest (ohlc-service)
│
backtestService.runBacktest
│
┌────────────────────┴──────────────────────┐
│ │
saved_patterns walk_forward
(ohlcPatternRepo) (walkForwardScanner)
│ │
createBacktestService createWalkForwardBacktestService
│ │
simulateTrade ◄─────── calculateSlTp ◄─── config.multipliers
│
aggregateStats
│
BacktestResult (SSE stream)
Two backtest engines exist, selected by optimisationMode:
saved_patterns(default) — uses patterns already stored inohlc_patternsDB table.walk_forward— re-scans candle history with a sliding window; no DB patterns required.
Both engines share simulateTrade and calculateSlTp.
2. Prerequisites for a Backtest to Run
| # | Condition | Source |
|---|---|---|
| 1 | OHLC_SERVICE_URL and OHLC_SERVICE_API_KEY env vars set | optimisationController.triggerOptimisation |
| 2 | Caller is authenticated and has admin role | optimisationRoutes.ts — all three routes guard with requireRole('admin') |
| 3 | Valid symbol, timeframe in request body | Zod schema in backtestRoute.ts |
| 4 | ohlc_patterns table has data for symbol+timeframe (saved_patterns mode) | ohlcPatternRepository.getBySymbolAndTimeframeWithDetectionTime |
| 5 | ohlc_candles table has data for symbol+timeframe (walk_forward + prz_touch/candle_close) | ohlcCandleRepository.getByRange / getFrom |
3. Entry Strategy Simulation Logic
Each pattern produces one BacktestTrade. The entry price, SL/TP anchor, and candles used for simulation depend on tradingConfig.entryStrategy:
3a. swing_confirmation (default)
| Step | Detail |
|---|---|
| SL/TP anchor | D-point (dPrice) |
| Entry candle | Fixed: futureCandles[PIVOT_LOOKBACK + 1] — the 6th candle after detectedAt; user entryLagCandles is ignored for this strategy |
| Entry price | futureCandles[6].open (or dPrice if fewer than 7 future candles exist) |
| Simulation starts | From detectedAt + 1ms |
| Outcome when no candles | 'open' |
3b. prz_touch
| Step | Detail |
|---|---|
| PRZ band | Prefer pattern.przLow/High; fall back to SL-derived band from calculateSlTp |
| Touch detection | findPrzTouchCandle(candlesFromCPoint, przLow, przHigh, isBullish) — first candle where low ≤ przHigh (bullish) or high ≥ przLow (bearish) |
| Entry price | Touch candle’s low (bullish) or high (bearish) — the extreme that entered the PRZ |
| SL/TP anchor | przTouchPrice (overrides dPrice) |
| Simulation starts | Candles strictly after touch candle |
| Outcome when no touch | 'open' — PRZ never reached |
3c. candle_close
Same as prz_touch for touch detection, then:
| Step | Detail |
|---|---|
| Entry price | Open of the candle AFTER the touch candle |
| Simulation | Passes entryLagCandles: 1 to simulateTrade, candles from touch candle onward |
| Outcome when no candle after touch | Uses przTouchPrice as fallback entry |
3d. abcd (emerging ABC path)
ABCD backtests use the emerging ABC path, not swing_confirmation: they validate a consecutive XABC sequence, wait for the first PRZ touch after C, and enter at the touch price. swing_confirmation remains the behavior for the other harmonic strategies only.
4. SL/TP Calculation
calculateSlTp(input, config?) in slTpCalculator.ts:
- Try candlestick pattern formulas (
computeCandlestickSlTp) - Fall back to pattern-type specific formulas (harmonic ratios, head-and-shoulders, cypher, etc.)
- Apply multipliers from
config:sl *= slMultiplier,tp1 *= tp1Multiplier, etc. (scaled relative to D-point)
The returned SlTpLevels = { sl, tp1, tp2, tp3 } are all absolute prices.
5. Trade Simulation (simulateTrade)
Runs candle-by-candle after entry. Uses these config fields:
| Config field | Effect |
|---|---|
entryLagCandles | Skip N candles; use candlesAfterD[N].open as entry price |
tpTargetMode | 'tp1' closes at TP1; 'tp1_tp2' closes at TP2; 'tp1_tp2_tp3' closes at TP3 |
tpCount | Legacy alias — mapped to tpTargetMode (1→tp1, 2→tp1_tp2, 3→tp1_tp2_tp3) |
partialExitEnabled | When true: close partialExitTp1Percent% at TP1, move SL to breakeven |
partialExitTp1Percent | % of position to exit at TP1 (default: 33.3) |
partialClose | Legacy boolean alias for partialExitEnabled |
Resolution rules (non-partial path)
On each candle c:
- SL hit:
isBullish ? c.low ≤ sl : c.high ≥ sl— always wins on the same candle as TP - TP hit:
isBullish ? c.high ≥ targetTp : c.low ≤ targetTp
No candle hit → outcome 'open'.
Partial-exit path (3 phases)
| Phase | Condition | Action |
|---|---|---|
| Phase 1 | Pre-TP1 | SL beats TP1 on same candle; TP1 hit → partial exit + SL → breakeven |
| Phase 2 | Post-TP1, pre-TP2 | SL (at breakeven) → remainder exits at 0; TP2 hit → close all (tp1_tp2) or become milestone (tp1_tp2_tp3) |
| Phase 3 | Post-TP2 | SL (at breakeven) → remainder exits at 0; TP3 hit → close all |
6. Signal Score Filtering
When tradingConfig.minSignalScore is set:
filteredPatterns = allPatterns.filter(p =>
p.signalScore == null ← null scores PASS through
|| parseFloat(p.signalScore) >= minSignalScore
)
⚠️ Bug XXX: Patterns without a
signalScore(null) always pass through the filter even whenminSignalScore > 0. In live trading, events with null/lowconfidenceare blocked by the engine’sminSignalScoreguard. Backtest results for configs with a non-zerominSignalScoreare therefore over-optimistic: they include patterns the live engine would reject.
7. Walk-Forward Mode
createWalkForwardScanner slides a window of WALK_FORWARD_WINDOW_SIZE = 300 candles:
- At each position, run
detectEmergingPatternson the window. - Deduplicate by
patternType:direction:x.time:c.time:fibLabel— only first occurrence (withfirstEmergingAt) is kept. - After scan, resolve
przTouchTime/Pricefor each pattern from candles afterfirstEmergingAt.
The walk-forward backtest then runs simulateTrade on each scanned pattern with every config combination (50 grid configs per pattern).
8. Backtest Result Shape
BacktestResult {
symbol, timeframe, patternType,
totalPatterns, // all patterns (including open)
completedTrades, // trades with outcome !== 'open'
openTrades, // trades with outcome === 'open'
wins, // completed with outcome !== 'sl'
losses, // completed with outcome === 'sl'
winRate, // wins / completedTrades
avgWinPips, avgLossPips,
profitFactor, // null when no losses
totalPnlPips,
maxDrawdownPips, maxDrawdownPct, // from sorted-by-entryTime pnl sequence
trades: BacktestTrade[]
}Note: maxDrawdown is computed on a totalPnlPips equity curve sorted by entryTime. If entryTime is inaccurate (see Bug above), drawdown ordering may be wrong.
9. API
POST /api/ohlc/backtest/strategy (proxied from mi-casa)
Auth: JWT + markets access
Body: { strategyType, symbol, timeframe, from, to?, params?, entryLagCandles?, tradingConfig? }
Response: SSE stream of { type: 'progress' | 'result' | 'error', ... } events
The legacy pattern backtest route and saved-pattern engine (
createBacktestService.ts) were removed in issues #920 and #921. Backtest and optimisation are now strategy-only (harmonic patterns: cypher, gartley, bat, butterfly, crab, shark, abcd).
POST /api/ohlc/optimise (proxied from mi-casa)
Auth: JWT + admin role
Triggers optimisation which calls runBacktest 50 times internally (see Optimisation System — Dependency Map)
10. Known Bugs / Limitations
| ID | Summary | Severity |
|---|---|---|
| #461 | entryPrice in output is lag=0 open even when entryLagCandles > 0 — fixed for swing_confirmation (lag hardcoded to 6; entryPrice set to lagCandle.open) | Medium |
| #460 | minSignalScore filter: patterns with null signalScore always pass | Medium |
| By design | walk_forward patterns pre-compute SL/TP from PRZ midpoint; actual simulation may use different anchor (przTouchPrice) | Low / cosmetic |