--- name: backtest-debug description: > Debug banbot backtest issues: why backtests don't run, produce errors, place no orders, stop early, or behave differently than strategy code expects. Use this skill whenever the user reports a backtest problem, asks why their strategy isn't trading, sees unexpected backtest results, or encounters errors during backtest execution. Also trigger when the user mentions "backtest" together with words like "not working", "no orders", "error", "wrong", "empty", "zero trades", "stops early", "low funds", or "doesn't enter". --- # Backtest Debugging Guide for banbot This skill helps you systematically diagnose and fix backtest problems in banbot. It covers the full backtest lifecycle — from CLI invocation through data loading, strategy execution, order placement, and result collection. ## Architecture at a Glance ``` CLI: go run . backtest └─ entry.RunBackTest(args) └─ core.SetRunMode(RunModeBackTest) └─ biz.SetupComsExg(args) ← config, DB, exchange init └─ runBackTest(outDir) └─ biz.ResetVars() ← wipe global state └─ opt.NewBackTest() └─ biz.InitFakeWallets() ← simulated balance └─ data.NewHistProvider() ← kline data provider └─ biz.InitLocalOrderMgr() ← simulated order execution └─ BackTest.Run() └─ BackTest.Init() └─ btime.CurTimeMS = config.TimeRange.StartMS └─ RefreshPairJobs() └─ goods.RefreshPairList() ← pair filters └─ strat.CalcPairTfScores() ← kline quality └─ strat.LoadStratJobs() ← create StratJobs └─ dp.SubWarmPairs() ← download + warm up └─ dp.LoopMain() ← THE MAIN LOOP (feeds bars) └─ CleanUp + Collect ← force-close, compute stats ``` ## Per-Bar Execution Flow Understanding this flow is essential — most "no orders" bugs stem from a break in this chain: ``` HistProvider delivers bar(pair, tf) └─ BackTest.FeedKLine(bar) └─ Trader.FeedKline(bar) ├─ LocalOrderMgr.UpdateByBar() ← fill pending orders from PREVIOUS bar ├─ env.OnBar() ← update indicators (ta.BarEnv) └─ for each account: for each StratJob at (pair, tf): ├─ job.InitBar() ← reset Entrys/Exits slices ├─ strat.OnBar(job) ← STRATEGY CODE RUNS HERE ├─ CheckCustomExits(job) ← OnCheckExit + drawdown └─ odMgr.ProcessOrders(job) ← execute Entrys/Exits ``` Key insight: orders created in bar N are **filled in bar N+1** during `UpdateByBar()`. A strategy never sees its own order filled in the same bar it was placed. ## Diagnostic Decision Tree Start here when a user reports a backtest problem: ### Problem: "Backtest doesn't start / init error" Check these in order: 1. **`BanDataDir` not set** — error: `"-datadir or env BanDataDir is required"` - Fix: set `BanDataDir` env var or pass `--datadir` flag 2. **Config missing or invalid** — `biz.SetupComsExg()` fails - Check `$BanDataDir/config.yml` exists and is valid YAML - Check `config.Accounts` has the `DefAcc` entry (panic: `"default Account invalid!"`) 3. **No strategy registered** — `strat.New(pol)` returns nil - The `Name` in `RunPolicy` doesn't match any factory in `strat.StratMake` - Strategy code might not be compiled in (check `BanStratDir`, external strats need import) 4. **No pairs found** — `"pairs and tfScores are required for LoadStratJobs"` - `goods.RefreshPairList()` returned empty — pair filters too strict - Exchange not configured in `config.Exchange.Items` 5. **No timeframe selected** — `pickTimeFrame` returns `""` - `MinTfScore` too high (default 0.8) — kline quality too low for all TFs - No kline data in QuestDB for the date range — check with DB query 6. **DB connection fails** — QuestDB not running or wrong connection string - QuestDB runs on port 8812 (Postgres wire) and 9000 (HTTP) ### Problem: "Backtest runs but places zero orders" This is the most common issue. Orders pass through **7 layers of gates** before execution: ``` strategy calls s.OpenOrder(req) │ ├─[L1] CanOpen() — direction capacity (MaxOpenLong/Short) ├─[L2] Input validation — NaN fields, empty Tag, bad SL/TP direction ├─[L3] IsWarmUp — silently drops all orders during warm-up │ └─► append to job.Entrys │ ▼ ProcessOrders() ├─[L4] allowOrderEnter() gates: │ ├─ BanPairsUntil[pair] — pair temporarily banned │ ├─ RunMode == RunModeOther — wrong mode │ ├─ NoEnterUntil[account] — account entry freeze │ ├─ MaxOpenOrders (global) — total open orders cap │ ├─ MaxSimulOpen (global) — opens per bar cap │ ├─ pol.MaxOpen — strategy open orders cap │ └─ pol.MaxSimulOpen — strategy opens per bar cap │ └─► InOutOrder created (status=Init, pending) │ ▼ Next bar: fillPendingOrders() └─[L5] Fill simulation: ├─ Limit price outside bar range — not reached ├─ Stop price outside bar range — not triggered ├─ Wallet insufficient funds (ErrLowFunds) — force exit └─ PrecAmount rounds to 0 — stake too small for lot size ``` **First diagnostic step**: Check the backtest log output for `"fail open tag nums:"` — this is printed at backtest end by `strat.DumpAccFailOpens()` and shows exactly which gate blocked orders and how many times. Common fail tags and their meaning: | Fail Tag | Meaning | Fix | |----------|---------|-----| | `BanPair` | Pair in `core.BanPairsUntil` | Check pair filter config | | `AccNoEntry` | Account entry freeze (after low funds) | Increase initial balance or reduce stake | | `OpenTooMuch` | Global `MaxOpenOrders` cap hit | Increase cap or reduce concurrent positions | | `NumLimit` | Same as above (variant) | Same | | `NumLimitPol` | Strategy-level `MaxOpen` cap hit | Increase `pol.MaxOpen` | | `CostTooLess` | Order cost below `MinStakeAmount` | Increase `StakeAmount` or `CostRate` | | `BadDirtOrLimit` | Short on spot market, or invalid limit direction | Check order direction logic | **If no fail tags appear** (strategy never even calls `OpenOrder`): 1. **All bars are warm-up** — `WarmupNum` >= number of bars in time range. Orders silently dropped during warm-up. 2. **Strategy condition never met** — add logging inside `OnBar` to verify your entry conditions fire 3. **Wrong pair/TF** — strategy might be running on a different pair or timeframe than expected. Check `strat.AccJobs` after `LoadStratJobs`. 4. **OnBar not called** — no kline data for the pair+TF in QuestDB. Verify data exists. ### Problem: "Backtest stops early" | Log message | Cause | Fix | |-------------|-------|-----| | `"wallet low funds, no open orders, stop backTest.."` | Balance hit zero, no open positions | Increase initial balance, reduce stake, check for large losses | | `"wallet X BOMB at Y, exit"` | Liquidation + `ChargeOnBomb=false` | Set `ChargeOnBomb: true` to continue after liquidation, or reduce leverage | | `"series too many"` panic | Strategy creates new `ta.Series` each bar (memory leak) | Use `Series.To` for indicator chaining, not new allocations | | Silent stop (no error) | `core.BotRunning` set to false | Check for `core.StopAll()` calls triggered by low funds | ### Problem: "Orders fill at wrong price / unexpected behavior" - **Market orders**: Fill price is simulated by `simMarketPrice()` using `config.BTNetCost` (network delay). Higher delay = fill price further from bar open. Default is small but non-zero. - **Limit orders**: Only fill if the limit price is within the bar's [Low, High] range. If limit > bar.Open (for buys), fills at Open (better price). - **Stop orders**: Trigger only when stop price is within [Low, High]. Then fill at simulated market price after trigger. - **SL/TP not triggering**: Verify `od.GetStopLoss()` / `od.GetTakeProfit()` return non-nil values. The stop/take-profit price must fall within the bar's range. Also check that the order is fully entered (status = FullEnter) before SL/TP can trigger. - **`RefineTF` setting**: This subdivides the bar for order matching. E.g., `"3-6"` divides the TF by 3-6x. Changes which sub-bars are used for fill simulation — can significantly affect results. ### Problem: "Results differ between runs" Backtest should be deterministic. If results vary: 1. **`StakePct` mode** — dynamic sizing based on current balance means order sequence matters. Small floating-point differences compound. Use fixed `StakeAmount` for reproducibility. 2. **`ShuffleFilter` in pair filters** — uses random seed. Set explicit seed or remove. 3. **`config.PairMgr.UseLatest`** — uses real wall-clock time for pair selection instead of backtest time. Set to false. 4. **Concurrent pair rotation** — if `PairMgr.Cron` is set, pair list changes mid-backtest. Results depend on which pairs are selected at each rotation. ## Data Pipeline Deep Dive All kline data comes from **QuestDB** (tables: `kline_1m`, `kline_5m`, `kline_15m`, `kline_1h`, `kline_1d`). Non-standard timeframes (4h, 3d, etc.) are aggregated on-the-fly from the closest stored sub-timeframe. ### Data loading sequence: ``` SubWarmPairs() ├─ DownIfNeed() — checks sranges coverage table, downloads missing ranges from exchange ├─ WarmTfs() — feed WarmupNum bars with IsWarmUp=true (indicators accumulate, no trading) └─ SetSeek() — position cursor at warm-up end for main loop ``` ### Common data issues: - **No data in DB** — `SubWarmPairs` silently succeeds but feeds 0 bars. Strategy never fires. - Diagnosis: check QuestDB for rows: `SELECT count() FROM kline_1h WHERE symbol = 'BTCUSDT' AND ts BETWEEN '2024-01-01' AND '2024-06-01'` - Fix: run `go run . kline down` to download data first - **Gaps in data** — bars simply skip those timestamps. No interpolation. The synthetic clock jumps forward. This can cause strategies that count consecutive bars to misfire. - **Wrong exchange** — kline tables are per-exchange. Check `config.Exchange.Name` matches the data you downloaded. ### Time simulation: The backtest uses a synthetic clock (`btime.CurTimeMS`) that advances to `bar.Time + tfMSecs` (bar close time) with each bar. Strategies always see a "completed" bar. The clock never goes backwards. ## Strategy Loading Details `strat.LoadStratJobs()` is where strategies get paired with symbols and timeframes: ``` For each RunPolicy: 1. strat.New(pol) ← instantiate strategy (must match registered factory) 2. getPolicyPairs(pol, pairs) ← apply pair filters (whitelist, blacklist, goods pipeline) 3. CallStratSymbols(stgy, pairs) ← strategy's OnSymbols() can add/remove pairs 4. For each pair: pickTimeFrame(pair, tfScores) ← select TF by kline quality score initBarEnv(pair, tf) ← create indicator environment ensureStratJob(stgy, tf, pair) ← create StratJob per account ``` **If LoadStratJobs produces zero jobs**, no orders will ever be placed. Check: - Is the strategy `Name` registered? (`strat.StratMake[name]` exists?) - Did `getPolicyPairs` return any pairs? (filters too strict?) - Did `pickTimeFrame` find a valid TF? (kline quality too low? `MinTfScore` too high?) - Is `config.Accounts[DefAcc].NoTrade` set to `true`? ## Order Lifecycle Summary ``` CREATED (status=Init) ← enterOrder() in ProcessOrders │ ▼ next bar FILLED (status=FullEnter) ← fillPendingEnter() in UpdateByBar │ wallets.EnterOd() deducts funds │ wallets.ConfirmOdEnter() locks position │ ├─ SL/TP triggered? ← tryFillTriggers() checks same bar remainder │ ▼ strategy calls CloseOrders or exit condition met EXIT PENDING ← exitOrder() sets exit tag │ ▼ next bar EXITED (status=FullExit) ← fillPendingExit() in UpdateByBar │ wallets.ExitOd() returns funds │ CalcJobScores() updates strategy perf │ ▼ end of backtest FORCE CLOSED ← CleanUp() exits all remaining with ExitTagBotStop ``` ## Key Config Fields That Affect Backtesting | Field | Impact | Default | |-------|--------|---------| | `timerange` | Backtest date window. **Required.** Format: `"20230101-20240101"` | none | | `run_policy[].name` | Strategy name. Must match registered factory. | required | | `run_policy[].pairs` | Override pair list for this strategy | all pairs | | `run_policy[].run_timeframes` | Allowed TFs | all | | `run_policy[].max_open` | Max concurrent open orders for this strategy | 0 (unlimited) | | `run_policy[].max_simul_open` | Max new orders per bar | 0 (unlimited) | | `run_policy[].warmup_num` | Bars for indicator warm-up | strategy default | | `stake_amount` | Fixed capital per order (USDT) | config default | | `stake_pct` | Dynamic sizing as % of balance (non-deterministic!) | 0 | | `max_open_orders` | Global max concurrent orders | 0 (unlimited) | | `bt_net_cost` | Simulated network delay (seconds) | small value | | `order_type` | `market` or `limit` | `market` | | `charge_on_bomb` | Continue after liquidation? | false (stops) | | `pair_mgr.cron` | Schedule for pair rotation | none | | `accounts[].no_trade` | Disable trading for account | false | | `relay_sim_unfinish` | Replay positions on pair rotation | false | ## Debugging Checklist When investigating a backtest issue, work through this systematically: ``` [ ] 1. Read the backtest log — look for error messages and "fail open tag nums" [ ] 2. Verify config: timerange set, strategy name matches, accounts configured [ ] 3. Verify data: kline data exists in QuestDB for pair + TF + date range [ ] 4. Check LoadStratJobs output: are StratJobs created? (check strat.AccJobs) [ ] 5. Check WarmupNum vs available bars — if warmup >= data bars, no trading occurs [ ] 6. Check order gates: MaxOpenOrders, MaxSimulOpen, StakeAmount vs MinStakeAmount [ ] 7. Check wallet: initial balance sufficient? Look for "low funds" or "BOMB" messages [ ] 8. For wrong fills: check order type, RefineTF, BTNetCost settings [ ] 9. For early stop: look for liquidation or wallet depletion messages [ ] 10. Add debug logging in OnBar to verify strategy conditions are being evaluated ``` ## Live vs Backtest Differences The `biz/trader.go` code path is **100% shared** between live and backtest. The only differences are: | Aspect | Backtest | Live | |--------|----------|------| | Order manager | `LocalOrderMgr` (simulated fills) | `LiveOrderMgr` (exchange fills) | | Data source | `HistProvider` (QuestDB) | `LiveFeeder` (WebSocket) | | Clock | Synthetic `btime.CurTimeMS` | Real wall clock | | Wallet | `InitFakeWallets()` (simulated) | Real exchange balances | | Bar expiry | Disabled (all bars accepted) | Enabled (late bars skip trading) | A bug in strategy logic (OnBar) will manifest identically in both modes. A bug in order fills is mode-specific — check `odmgr_local.go` for backtest, `odmgr_live.go` for live.