This document describes the complete technical implementation of the NOFX backtest module, including configuration, historical data loading, simulation engine, AI decision making, performance metrics calculation, and result storage.
┌─────────────────────────────────────────────────────────────────┐
│ Backtest Execution Flow │
└─────────────────────────────────────────────────────────────────┘
1. API Request: /backtest/start
↓
2. Manager.Start()
├─ Validate config
├─ Parse AI model
├─ Create Runner instance
└─ Start runner.Start() (goroutine)
↓
3. Runner.Start() → Runner.loop()
└─ Iterate each decision time point:
├─ DataFeed.BuildMarketData() [Build market data]
├─ Check decision trigger [Every N bars]
├─ buildDecisionContext() [Build decision context]
├─ invokeAIWithRetry() [Call AI + cache]
├─ executeDecision() [Execute trades]
├─ checkLiquidation() [Check liquidation]
├─ updateState() [Update state]
├─ appendEquityPoint() [Record equity]
├─ appendTradeEvent() [Record trades]
├─ maybeCheckpoint() [Save checkpoint]
└─ persistMetrics() [Persist metrics]
↓
4. Complete/Failed
├─ Calculate final metrics
├─ Persist all results
└─ Release lock
↓
5. API Query: /backtest/metrics, /backtest/equity, /backtest/trades
└─ Load and return results
Core File: backtest/config.go
| Parameter | Type | Default | Description |
|---|---|---|---|
RunID |
string | (required) | Unique backtest run ID |
UserID |
string | "default" | User ID |
Symbols |
[]string | (required) | Trading symbols list |
Timeframes |
[]string | ["3m", "15m", "4h"] | K-line timeframes |
DecisionTimeframe |
string | Symbols[0] | Primary decision timeframe |
DecisionCadenceNBars |
int | 20 | Trigger decision every N bars |
StartTS, EndTS |
int64 | (required) | Backtest time range (Unix timestamp) |
InitialBalance |
float64 | 1000 | Initial balance (USD) |
FeeBps |
float64 | 5 | Trading fee (basis points) |
SlippageBps |
float64 | 2 | Slippage (basis points) |
FillPolicy |
string | "next_open" | Fill policy |
PromptVariant |
string | "baseline" | AI prompt variant |
CacheAI |
bool | false | Cache AI decisions |
Leverage |
LeverageConfig | BTC/ETH:5, Altcoin:5 | Leverage settings |
// backtest/config.go:163-179
switch fillPolicy {
case "next_open": // Next bar open price
case "bar_vwap": // Current bar VWAP
case "mid": // Current bar (High+Low)/2
default: // Mark Price
}cfg := backtest.BacktestConfig{
RunID: "bt_20231215_150405",
Symbols: []string{"BTCUSDT", "ETHUSDT"},
Timeframes: []string{"3m", "15m", "4h"},
DecisionTimeframe: "3m",
DecisionCadenceNBars: 20,
StartTS: 1702566000,
EndTS: 1702652400,
InitialBalance: 10000,
FeeBps: 5,
SlippageBps: 2,
FillPolicy: "next_open",
}Core File: backtest/datafeed.go
1. NewDataFeed() - Initialize
↓
2. loadAll() - Load all historical data
├─ Calculate buffer (200 bars before StartTS)
├─ Call market.GetKlinesRange() to fetch data
├─ Store in symbolSeries map
└─ Build decision timeline from primary timeframe
↓
3. BuildMarketData() - Build market data snapshot
├─ Slice K-line data to current timestamp
├─ Calculate technical indicators (EMA, MACD, RSI, ATR)
└─ Return market.Data structure
// DataFeed core structure
type DataFeed struct {
decisionTimes []int64 // Decision time points list
symbolSeries map[string]*symbolSeries // Data stored by symbol
}
// Single symbol time series
type symbolSeries struct {
timeframes map[string]*timeframeSeries // Stored by timeframe
}
// Single timeframe data
type timeframeSeries struct {
klines []market.Kline // K-line data
closeTimes []int64 // Close time index
}- Data fetching:
backtest/datafeed.go:48-93 - Timeline generation:
backtest/datafeed.go:96-115 - Market data assembly:
backtest/datafeed.go:141-171
Core File: backtest/runner.go
// backtest/runner.go:232-264
func (r *Runner) loop() {
for _, ts := range r.feed.DecisionTimes() {
if r.isPaused() {
break
}
r.stepOnce(ts)
}
}// backtest/runner.go:266-471
func (r *Runner) stepOnce(ts int64) {
// 1. Get current bar timestamp
// 2. Build market data
// 3. Check decision trigger (every N bars)
// 4. Execute decision cycle (if triggered)
// 5. Check liquidation
// 6. Update state and record
}// backtest/types.go:31-47
type BacktestState struct {
BarIndex int // Current bar index
Cash float64 // Available balance
Equity float64 // Total equity
UnrealizedPnL float64 // Unrealized PnL
RealizedPnL float64 // Realized PnL
MaxEquity float64 // Peak equity
MinEquity float64 // Trough equity
MaxDrawdownPct float64 // Max drawdown
Positions map[string]*position // Positions
}Core File: backtest/runner.go
// backtest/runner.go:473-532
func (r *Runner) buildDecisionContext() *decision.Context {
return &decision.Context{
CurrentTime: "2023-12-15 10:30:00 UTC",
RuntimeMinutes: elapsed,
CallCount: cycleNumber,
Account: {
TotalEquity, AvailableBalance, TotalPnL, MarginUsedPct
},
Positions: []PositionInfo{...},
CandidateCoins: []string{symbols...},
MarketDataMap: map[symbol]*market.Data{...},
MultiTFMarket: map[symbol]map[timeframe]*market.Data{...},
}
}// backtest/runner.go:544-563
func (r *Runner) invokeAIWithRetry() (*decision.FullDecision, error) {
// Max 3 retries
// Exponential backoff: 500ms, 1000ms, 1500ms
// Uses decision.GetFullDecisionWithStrategy() for unified prompt generation
}// backtest/aicache.go:127-168
// Cache key: SHA256(context payload)
// Contains: variant, timestamp, account, positions, market data| Model | Client File |
|---|---|
| DeepSeek | mcp/deepseek_client.go |
| Qwen | mcp/qwen_client.go |
| Claude | mcp/claude_client.go |
| Gemini | mcp/gemini_client.go |
| Grok | mcp/grok_client.go |
| OpenAI | mcp/openai_client.go |
| Kimi | mcp/kimi_client.go |
Core File: backtest/metrics.go
| Metric | Formula | Code Location |
|---|---|---|
| Total Return | (Final Equity - Initial) / Initial × 100 | metrics.go:36-42 |
| Max Drawdown | max((Peak - Current) / Peak × 100) | metrics.go:64-91 |
| Sharpe Ratio | Avg Return / Return StdDev | metrics.go:94-138 |
| Win Rate | Winning Trades / Total Trades × 100 | metrics.go:180-181 |
| Profit Factor | Total Profit / Total Loss | metrics.go:189-193 |
// backtest/metrics.go:141-225
type TradeMetrics struct {
TotalTrades int
WinningTrades int
LosingTrades int
AvgWin float64
AvgLoss float64
BestSymbol string
WorstSymbol string
SymbolStats map[string]*SymbolStat
}Core File: backtest/equity.go
{
"ts": 1702566000000,
"equity": 10500.50,
"available": 8000.00,
"pnl": 500.50,
"pnl_pct": 5.005,
"dd_pct": 2.34,
"cycle": 42
}// backtest/runner.go:829-872
func (r *Runner) updateState() {
// 1. Calculate total equity: cash + margin + unrealized PnL
// 2. Track peak (MaxEquity)
// 3. Track trough (MinEquity)
// 4. Recalculate drawdown: (MaxEquity - Equity) / MaxEquity × 100
}// backtest/equity.go:10-50
func ResampleEquity(points []EquityPoint, timeframe string) []EquityPoint {
// Bucket by timeframe
// Keep last point in each bucket
}Core Files: backtest/storage.go, store/backtest.go
backtests/
├── <run_id>/
│ ├── run.json # Run metadata
│ ├── checkpoint.json # Checkpoint (for resume)
│ ├── equity.jsonl # Equity curve (line-delimited JSON)
│ ├── trades.jsonl # Trade records (line-delimited JSON)
│ ├── metrics.json # Performance metrics
│ ├── progress.json # Progress info
│ ├── ai_cache.json # AI decision cache
│ └── decision_logs/ # Decision logs
│ ├── 0.json
│ ├── 1.json
│ └── ...
-- Backtest run metadata
CREATE TABLE backtest_runs (
run_id TEXT PRIMARY KEY,
user_id TEXT,
config_json TEXT,
state TEXT, -- pending, running, completed, failed
processed_bars INTEGER,
progress_pct REAL,
equity_last REAL,
max_drawdown_pct REAL,
liquidated BOOLEAN,
ai_provider TEXT,
ai_model TEXT,
created_at DATETIME,
updated_at DATETIME
);
-- Equity curve
CREATE TABLE backtest_equity (
id INTEGER PRIMARY KEY,
run_id TEXT,
ts INTEGER,
equity REAL,
available REAL,
pnl REAL,
pnl_pct REAL,
dd_pct REAL,
cycle INTEGER
);
-- Trade records
CREATE TABLE backtest_trades (
id INTEGER PRIMARY KEY,
run_id TEXT,
ts INTEGER,
symbol TEXT,
action TEXT,
side TEXT,
qty REAL,
price REAL,
fee REAL,
slippage REAL,
realized_pnl REAL,
leverage INTEGER,
liquidation BOOLEAN
);
-- Performance metrics
CREATE TABLE backtest_metrics (
run_id TEXT PRIMARY KEY,
payload BLOB,
updated_at DATETIME
);
-- Checkpoints (pause/resume)
CREATE TABLE backtest_checkpoints (
run_id TEXT PRIMARY KEY,
payload BLOB,
updated_at DATETIME
);Core File: api/backtest.go
| Endpoint | Method | Description |
|---|---|---|
/backtest/start |
POST | Start backtest |
/backtest/pause |
POST | Pause backtest |
/backtest/resume |
POST | Resume backtest |
/backtest/stop |
POST | Stop backtest |
/backtest/status |
GET | Get status |
/backtest/runs |
GET | List all backtests |
/backtest/equity |
GET | Get equity curve |
/backtest/trades |
GET | Get trade records |
/backtest/metrics |
GET | Get performance metrics |
/backtest/trace |
GET | Get decision logs |
/backtest/export |
GET | Export ZIP |
/backtest/delete |
POST | Delete backtest |
# Start backtest
POST /backtest/start
{
"config": {
"run_id": "bt_20231215",
"symbols": ["BTCUSDT", "ETHUSDT"],
"timeframes": ["3m", "15m", "4h"],
"start_ts": 1702566000,
"end_ts": 1702652400,
"initial_balance": 10000,
"ai_model_id": "model_001"
}
}
# Get equity curve
GET /backtest/equity?run_id=bt_20231215&tf=1h&limit=1000
# Get metrics
GET /backtest/metrics?run_id=bt_20231215// Status response
{
"run_id": "bt_20231215",
"state": "running",
"progress_pct": 45.5,
"processed_bars": 1234,
"equity": 10234.50,
"unrealized_pnl": 234.50
}
// Metrics response
{
"total_return_pct": 12.34,
"max_drawdown_pct": 5.67,
"sharpe_ratio": 1.89,
"profit_factor": 2.34,
"win_rate": 65.5,
"trades": 123
}Core File: backtest/account.go
type position struct {
Symbol string
Side string // "long" or "short"
Quantity float64
EntryPrice float64
Leverage int
Margin float64 // Margin
Notional float64 // Notional value
LiquidationPrice float64 // Liquidation price
OpenTime int64
}// backtest/account.go:61-104
func (a *BacktestAccount) Open(symbol, side string, qty, price float64, leverage int) {
// 1. Apply slippage
// 2. Calculate notional value (qty × price)
// 3. Calculate margin (notional / leverage)
// 4. Deduct margin + fees
// 5. Create/add to position
// 6. Calculate liquidation price
}// backtest/account.go:106-140
func (a *BacktestAccount) Close(symbol, side string, qty, price float64) {
// 1. Verify position exists
// 2. Apply slippage (reverse direction)
// 3. Calculate realized PnL
// long: (exit - entry) × qty
// short: (entry - exit) × qty
// 4. Return margin + PnL - fees
// 5. Update/delete position
}// backtest/account.go:177-186
func computeLiquidation(entry float64, leverage int, side string) float64 {
if side == "long" {
return entry * (1 - 1.0/float64(leverage)) // Long: liquidate on drop
}
return entry * (1 + 1.0/float64(leverage)) // Short: liquidate on rise
}Core File: backtest/runner.go
{
"bar_index": 1234,
"bar_ts": 1702609200000,
"cash": 8000.00,
"equity": 10234.50,
"max_equity": 10500.00,
"max_drawdown_pct": 5.67,
"positions": [...],
"decision_cycle": 62,
"liquidated": false
}// backtest/runner.go:874-898
func (r *Runner) maybeCheckpoint() {
// Save every N bars
// Or save every N seconds
}func (r *Runner) RestoreFromCheckpoint() {
// 1. Load checkpoint
// 2. Restore account state
// 3. Restore bar index (continue from next bar)
// 4. Restore equity curve, trade records
}| Module | File | Key Methods |
|---|---|---|
| Config | backtest/config.go |
BacktestConfig, Validate() |
| Data Loading | backtest/datafeed.go |
NewDataFeed(), loadAll(), BuildMarketData() |
| Sim Engine | backtest/runner.go |
Start(), loop(), stepOnce() |
| Decision | backtest/runner.go |
buildDecisionContext(), invokeAIWithRetry() |
| Execution | backtest/runner.go |
executeDecision() |
| Account | backtest/account.go |
Open(), Close(), TotalEquity() |
| Metrics | backtest/metrics.go |
CalculateMetrics() |
| Equity | backtest/equity.go |
ResampleEquity(), LimitEquityPoints() |
| Storage | backtest/storage.go |
SaveCheckpoint(), appendEquityPoint() |
| Database | store/backtest.go |
Schema and CRUD operations |
| API | api/backtest.go |
HTTP handlers |
| AI Cache | backtest/aicache.go |
Get(), Put(), save() |
Document Version: 1.0.0 Last Updated: 2025-01-15