mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 22:36:58 +08:00
Dev backtest (#1134)
This commit is contained in:
250
backtest/account.go
Normal file
250
backtest/account.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const epsilon = 1e-8
|
||||
|
||||
type position struct {
|
||||
Symbol string
|
||||
Side string
|
||||
Quantity float64
|
||||
EntryPrice float64
|
||||
Leverage int
|
||||
Margin float64
|
||||
Notional float64
|
||||
LiquidationPrice float64
|
||||
OpenTime int64
|
||||
}
|
||||
|
||||
type BacktestAccount struct {
|
||||
initialBalance float64
|
||||
cash float64
|
||||
feeRate float64
|
||||
slippageRate float64
|
||||
positions map[string]*position
|
||||
realizedPnL float64
|
||||
}
|
||||
|
||||
func NewBacktestAccount(initialBalance, feeBps, slippageBps float64) *BacktestAccount {
|
||||
return &BacktestAccount{
|
||||
initialBalance: initialBalance,
|
||||
cash: initialBalance,
|
||||
feeRate: feeBps / 10000.0,
|
||||
slippageRate: slippageBps / 10000.0,
|
||||
positions: make(map[string]*position),
|
||||
}
|
||||
}
|
||||
|
||||
func positionKey(symbol, side string) string {
|
||||
return strings.ToUpper(symbol) + ":" + side
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) ensurePosition(symbol, side string) *position {
|
||||
key := positionKey(symbol, side)
|
||||
if pos, ok := acc.positions[key]; ok {
|
||||
return pos
|
||||
}
|
||||
pos := &position{Symbol: strings.ToUpper(symbol), Side: side}
|
||||
acc.positions[key] = pos
|
||||
return pos
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) removePosition(pos *position) {
|
||||
key := positionKey(pos.Symbol, pos.Side)
|
||||
delete(acc.positions, key)
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Open(symbol, side string, quantity float64, leverage int, price float64, ts int64) (*position, float64, float64, error) {
|
||||
if quantity <= 0 {
|
||||
return nil, 0, 0, fmt.Errorf("quantity must be positive")
|
||||
}
|
||||
if leverage <= 0 {
|
||||
return nil, 0, 0, fmt.Errorf("leverage must be positive")
|
||||
}
|
||||
|
||||
execPrice := applySlippage(price, acc.slippageRate, side, true)
|
||||
notional := execPrice * quantity
|
||||
margin := notional / float64(leverage)
|
||||
fee := notional * acc.feeRate
|
||||
|
||||
if margin+fee > acc.cash+epsilon {
|
||||
return nil, 0, 0, fmt.Errorf("insufficient cash: need %.2f", margin+fee)
|
||||
}
|
||||
|
||||
acc.cash -= margin + fee
|
||||
|
||||
pos := acc.ensurePosition(symbol, side)
|
||||
|
||||
if pos.Quantity < epsilon {
|
||||
pos.Quantity = quantity
|
||||
pos.EntryPrice = execPrice
|
||||
pos.Leverage = leverage
|
||||
pos.Margin = margin
|
||||
pos.Notional = notional
|
||||
pos.OpenTime = ts
|
||||
pos.LiquidationPrice = computeLiquidation(execPrice, leverage, side)
|
||||
} else {
|
||||
if leverage != pos.Leverage {
|
||||
// 采用权重平均杠杆(近似)
|
||||
weightedMargin := pos.Margin + margin
|
||||
pos.Leverage = int(math.Round((pos.Notional + notional) / weightedMargin))
|
||||
}
|
||||
pos.Notional += notional
|
||||
pos.Margin += margin
|
||||
pos.EntryPrice = ((pos.EntryPrice * pos.Quantity) + execPrice*quantity) / (pos.Quantity + quantity)
|
||||
pos.Quantity += quantity
|
||||
pos.LiquidationPrice = computeLiquidation(pos.EntryPrice, pos.Leverage, side)
|
||||
}
|
||||
|
||||
return pos, fee, execPrice, nil
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price float64) (float64, float64, float64, error) {
|
||||
key := positionKey(symbol, side)
|
||||
pos, ok := acc.positions[key]
|
||||
if !ok || pos.Quantity <= epsilon {
|
||||
return 0, 0, 0, fmt.Errorf("no active %s position for %s", side, symbol)
|
||||
}
|
||||
|
||||
if quantity <= 0 || quantity > pos.Quantity+epsilon {
|
||||
if math.Abs(quantity) <= epsilon {
|
||||
quantity = pos.Quantity
|
||||
} else {
|
||||
return 0, 0, 0, fmt.Errorf("invalid close quantity")
|
||||
}
|
||||
}
|
||||
|
||||
execPrice := applySlippage(price, acc.slippageRate, side, false)
|
||||
notional := execPrice * quantity
|
||||
fee := notional * acc.feeRate
|
||||
|
||||
realized := realizedPnL(pos, quantity, execPrice)
|
||||
|
||||
marginPortion := pos.Margin * (quantity / pos.Quantity)
|
||||
acc.cash += marginPortion + realized - fee
|
||||
acc.realizedPnL += realized - fee
|
||||
|
||||
pos.Quantity -= quantity
|
||||
pos.Notional -= notional
|
||||
pos.Margin -= marginPortion
|
||||
|
||||
if pos.Quantity <= epsilon {
|
||||
acc.removePosition(pos)
|
||||
}
|
||||
|
||||
return realized, fee, execPrice, nil
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) TotalEquity(priceMap map[string]float64) (float64, float64, map[string]float64) {
|
||||
unrealized := 0.0
|
||||
margin := 0.0
|
||||
perSymbol := make(map[string]float64)
|
||||
for _, pos := range acc.positions {
|
||||
price := priceMap[pos.Symbol]
|
||||
pnl := unrealizedPnL(pos, price)
|
||||
unrealized += pnl
|
||||
margin += pos.Margin
|
||||
perSymbol[pos.Symbol+":"+pos.Side] = pnl
|
||||
}
|
||||
return acc.cash + margin + unrealized, unrealized, perSymbol
|
||||
}
|
||||
|
||||
func applySlippage(price float64, rate float64, side string, isOpen bool) float64 {
|
||||
if rate <= 0 {
|
||||
return price
|
||||
}
|
||||
adjust := 1.0
|
||||
if side == "long" {
|
||||
if isOpen {
|
||||
adjust += rate
|
||||
} else {
|
||||
adjust -= rate
|
||||
}
|
||||
} else {
|
||||
if isOpen {
|
||||
adjust -= rate
|
||||
} else {
|
||||
adjust += rate
|
||||
}
|
||||
}
|
||||
return price * adjust
|
||||
}
|
||||
|
||||
func computeLiquidation(entry float64, leverage int, side string) float64 {
|
||||
if leverage <= 0 {
|
||||
return 0
|
||||
}
|
||||
lev := float64(leverage)
|
||||
if side == "long" {
|
||||
return entry * (1.0 - 1.0/lev)
|
||||
}
|
||||
return entry * (1.0 + 1.0/lev)
|
||||
}
|
||||
|
||||
func realizedPnL(pos *position, qty, price float64) float64 {
|
||||
if pos.Side == "long" {
|
||||
return (price - pos.EntryPrice) * qty
|
||||
}
|
||||
return (pos.EntryPrice - price) * qty
|
||||
}
|
||||
|
||||
func unrealizedPnL(pos *position, price float64) float64 {
|
||||
if pos.Side == "long" {
|
||||
return (price - pos.EntryPrice) * pos.Quantity
|
||||
}
|
||||
return (pos.EntryPrice - price) * pos.Quantity
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Positions() []*position {
|
||||
list := make([]*position, 0, len(acc.positions))
|
||||
for _, pos := range acc.positions {
|
||||
list = append(list, pos)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) positionLeverage(symbol, side string) int {
|
||||
key := positionKey(symbol, side)
|
||||
if pos, ok := acc.positions[key]; ok && pos.Quantity > epsilon {
|
||||
return pos.Leverage
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) Cash() float64 {
|
||||
return acc.cash
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) InitialBalance() float64 {
|
||||
return acc.initialBalance
|
||||
}
|
||||
|
||||
func (acc *BacktestAccount) RealizedPnL() float64 {
|
||||
return acc.realizedPnL
|
||||
}
|
||||
|
||||
// RestoreFromSnapshots 用于从检查点恢复账户状态。
|
||||
func (acc *BacktestAccount) RestoreFromSnapshots(cash float64, realized float64, snaps []PositionSnapshot) {
|
||||
acc.cash = cash
|
||||
acc.realizedPnL = realized
|
||||
acc.positions = make(map[string]*position)
|
||||
for _, snap := range snaps {
|
||||
pos := &position{
|
||||
Symbol: snap.Symbol,
|
||||
Side: snap.Side,
|
||||
Quantity: snap.Quantity,
|
||||
EntryPrice: snap.AvgPrice,
|
||||
Leverage: snap.Leverage,
|
||||
Margin: snap.MarginUsed,
|
||||
Notional: snap.Quantity * snap.AvgPrice,
|
||||
LiquidationPrice: snap.LiquidationPrice,
|
||||
OpenTime: snap.OpenTime,
|
||||
}
|
||||
key := positionKey(pos.Symbol, pos.Side)
|
||||
acc.positions[key] = pos
|
||||
}
|
||||
}
|
||||
71
backtest/ai_client.go
Normal file
71
backtest/ai_client.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
// configureMCPClient 根据配置创建/克隆 MCP 客户端(返回 mcp.AIClient 接口)。
|
||||
// 说明:mcp.New() 返回接口类型,这里统一转为具体实现再做拷贝,避免并发共享状态。
|
||||
func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, error) {
|
||||
provider := strings.ToLower(strings.TrimSpace(cfg.AICfg.Provider))
|
||||
|
||||
// DeepSeek
|
||||
if provider == "" || provider == "inherit" || provider == "default" {
|
||||
client := cloneBaseClient(base)
|
||||
if cfg.AICfg.APIKey != "" || cfg.AICfg.BaseURL != "" || cfg.AICfg.Model != "" {
|
||||
client.SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "deepseek":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("deepseek provider requires api key")
|
||||
}
|
||||
ds := mcp.NewDeepSeekClientWithOptions()
|
||||
ds.(*mcp.DeepSeekClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return ds, nil
|
||||
case "qwen":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("qwen provider requires api key")
|
||||
}
|
||||
qc := mcp.NewQwenClientWithOptions()
|
||||
qc.(*mcp.QwenClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return qc, nil
|
||||
case "custom":
|
||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||
}
|
||||
client := cloneBaseClient(base)
|
||||
client.SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return client, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported ai provider %s", cfg.AICfg.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// cloneBaseClient 复制基础客户端以避免共享可变状态。
|
||||
func cloneBaseClient(base mcp.AIClient) *mcp.Client {
|
||||
// 优先尝试复用传入的基础客户端(深拷贝)
|
||||
switch c := base.(type) {
|
||||
case *mcp.Client:
|
||||
cp := *c
|
||||
return &cp
|
||||
case *mcp.DeepSeekClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.QwenClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
// 回退到新的默认客户端
|
||||
return mcp.NewClient().(*mcp.Client)
|
||||
}
|
||||
168
backtest/aicache.go
Normal file
168
backtest/aicache.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"nofx/decision"
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
type cachedDecision struct {
|
||||
Key string `json:"key"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Decision *decision.FullDecision `json:"decision"`
|
||||
}
|
||||
|
||||
// AICache 持久化 AI 决策,便于重复回测或重放。
|
||||
type AICache struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
Entries map[string]cachedDecision `json:"entries"`
|
||||
}
|
||||
|
||||
func LoadAICache(path string) (*AICache, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("ai cache path is empty")
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cache := &AICache{
|
||||
path: path,
|
||||
Entries: make(map[string]cachedDecision),
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cache, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return cache, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, cache); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cache.Entries == nil {
|
||||
cache.Entries = make(map[string]cachedDecision)
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (c *AICache) Path() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.path
|
||||
}
|
||||
|
||||
func (c *AICache) Get(key string) (*decision.FullDecision, bool) {
|
||||
if c == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
c.mu.RLock()
|
||||
entry, ok := c.Entries[key]
|
||||
c.mu.RUnlock()
|
||||
if !ok || entry.Decision == nil {
|
||||
return nil, false
|
||||
}
|
||||
return cloneDecision(entry.Decision), true
|
||||
}
|
||||
|
||||
func (c *AICache) Put(key string, variant string, ts int64, decision *decision.FullDecision) error {
|
||||
if c == nil || key == "" || decision == nil {
|
||||
return nil
|
||||
}
|
||||
entry := cachedDecision{
|
||||
Key: key,
|
||||
PromptVariant: variant,
|
||||
Timestamp: ts,
|
||||
Decision: cloneDecision(decision),
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.Entries[key] = entry
|
||||
c.mu.Unlock()
|
||||
return c.save()
|
||||
}
|
||||
|
||||
func (c *AICache) save() error {
|
||||
if c == nil || c.path == "" {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
c.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(c.path, data, 0o644)
|
||||
}
|
||||
|
||||
func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var dst decision.FullDecision
|
||||
if err := json.Unmarshal(data, &dst); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &dst
|
||||
}
|
||||
|
||||
func computeCacheKey(ctx *decision.Context, variant string, ts int64) (string, error) {
|
||||
if ctx == nil {
|
||||
return "", fmt.Errorf("context is nil")
|
||||
}
|
||||
payload := struct {
|
||||
Variant string `json:"variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
Account decision.AccountInfo `json:"account"`
|
||||
Positions []decision.PositionInfo `json:"positions"`
|
||||
CandidateCoins []decision.CandidateCoin `json:"candidate_coins"`
|
||||
MarketData map[string]market.Data `json:"market"`
|
||||
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||
Runtime int `json:"runtime_minutes"`
|
||||
CallCount int `json:"call_count"`
|
||||
}{
|
||||
Variant: variant,
|
||||
Timestamp: ts,
|
||||
CurrentTime: ctx.CurrentTime,
|
||||
Account: ctx.Account,
|
||||
Positions: ctx.Positions,
|
||||
CandidateCoins: ctx.CandidateCoins,
|
||||
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||||
Runtime: ctx.RuntimeMinutes,
|
||||
CallCount: ctx.CallCount,
|
||||
MarketData: make(map[string]market.Data, len(ctx.MarketDataMap)),
|
||||
}
|
||||
|
||||
for symbol, data := range ctx.MarketDataMap {
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
payload.MarketData[symbol] = *data
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(bytes)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
178
backtest/config.go
Normal file
178
backtest/config.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
// AIConfig 定义回测中使用的 AI 客户端配置。
|
||||
type AIConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"key"`
|
||||
SecretKey string `json:"secret_key,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
|
||||
type LeverageConfig struct {
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
}
|
||||
|
||||
// BacktestConfig 描述一次回测运行的输入配置。
|
||||
type BacktestConfig struct {
|
||||
RunID string `json:"run_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
AIModelID string `json:"ai_model_id,omitempty"`
|
||||
Symbols []string `json:"symbols"`
|
||||
Timeframes []string `json:"timeframes"`
|
||||
DecisionTimeframe string `json:"decision_timeframe"`
|
||||
DecisionCadenceNBars int `json:"decision_cadence_nbars"`
|
||||
StartTS int64 `json:"start_ts"`
|
||||
EndTS int64 `json:"end_ts"`
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
FeeBps float64 `json:"fee_bps"`
|
||||
SlippageBps float64 `json:"slippage_bps"`
|
||||
FillPolicy string `json:"fill_policy"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
PromptTemplate string `json:"prompt_template"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_prompt"`
|
||||
CacheAI bool `json:"cache_ai"`
|
||||
ReplayOnly bool `json:"replay_only"`
|
||||
|
||||
AICfg AIConfig `json:"ai"`
|
||||
Leverage LeverageConfig `json:"leverage"`
|
||||
|
||||
SharedAICachePath string `json:"ai_cache_path,omitempty"`
|
||||
CheckpointIntervalBars int `json:"checkpoint_interval_bars,omitempty"`
|
||||
CheckpointIntervalSeconds int `json:"checkpoint_interval_seconds,omitempty"`
|
||||
ReplayDecisionDir string `json:"replay_decision_dir,omitempty"`
|
||||
}
|
||||
|
||||
// Validate 对配置进行合法性检查并填充默认值。
|
||||
func (cfg *BacktestConfig) Validate() error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
cfg.RunID = strings.TrimSpace(cfg.RunID)
|
||||
if cfg.RunID == "" {
|
||||
return fmt.Errorf("run_id cannot be empty")
|
||||
}
|
||||
cfg.UserID = strings.TrimSpace(cfg.UserID)
|
||||
if cfg.UserID == "" {
|
||||
cfg.UserID = "default"
|
||||
}
|
||||
cfg.AIModelID = strings.TrimSpace(cfg.AIModelID)
|
||||
|
||||
if len(cfg.Symbols) == 0 {
|
||||
return fmt.Errorf("at least one symbol is required")
|
||||
}
|
||||
for i, sym := range cfg.Symbols {
|
||||
cfg.Symbols[i] = market.Normalize(sym)
|
||||
}
|
||||
|
||||
if len(cfg.Timeframes) == 0 {
|
||||
cfg.Timeframes = []string{"3m", "15m", "4h"}
|
||||
}
|
||||
normTF := make([]string, 0, len(cfg.Timeframes))
|
||||
for _, tf := range cfg.Timeframes {
|
||||
normalized, err := market.NormalizeTimeframe(tf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timeframe '%s': %w", tf, err)
|
||||
}
|
||||
normTF = append(normTF, normalized)
|
||||
}
|
||||
cfg.Timeframes = normTF
|
||||
|
||||
if cfg.DecisionTimeframe == "" {
|
||||
cfg.DecisionTimeframe = cfg.Timeframes[0]
|
||||
}
|
||||
normalizedDecision, err := market.NormalizeTimeframe(cfg.DecisionTimeframe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid decision_timeframe: %w", err)
|
||||
}
|
||||
cfg.DecisionTimeframe = normalizedDecision
|
||||
|
||||
if cfg.DecisionCadenceNBars <= 0 {
|
||||
cfg.DecisionCadenceNBars = 20
|
||||
}
|
||||
|
||||
if cfg.StartTS <= 0 || cfg.EndTS <= 0 || cfg.EndTS <= cfg.StartTS {
|
||||
return fmt.Errorf("invalid start_ts/end_ts")
|
||||
}
|
||||
|
||||
if cfg.InitialBalance <= 0 {
|
||||
cfg.InitialBalance = 1000
|
||||
}
|
||||
|
||||
if cfg.FillPolicy == "" {
|
||||
cfg.FillPolicy = FillPolicyNextOpen
|
||||
}
|
||||
if err := validateFillPolicy(cfg.FillPolicy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.CheckpointIntervalBars <= 0 {
|
||||
cfg.CheckpointIntervalBars = 20
|
||||
}
|
||||
if cfg.CheckpointIntervalSeconds <= 0 {
|
||||
cfg.CheckpointIntervalSeconds = 2
|
||||
}
|
||||
|
||||
cfg.PromptVariant = strings.TrimSpace(cfg.PromptVariant)
|
||||
if cfg.PromptVariant == "" {
|
||||
cfg.PromptVariant = "baseline"
|
||||
}
|
||||
cfg.PromptTemplate = strings.TrimSpace(cfg.PromptTemplate)
|
||||
if cfg.PromptTemplate == "" {
|
||||
cfg.PromptTemplate = "default"
|
||||
}
|
||||
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
|
||||
|
||||
if cfg.AICfg.Provider == "" {
|
||||
cfg.AICfg.Provider = "inherit"
|
||||
}
|
||||
if cfg.AICfg.Temperature == 0 {
|
||||
cfg.AICfg.Temperature = 0.4
|
||||
}
|
||||
|
||||
if cfg.Leverage.BTCETHLeverage <= 0 {
|
||||
cfg.Leverage.BTCETHLeverage = 5
|
||||
}
|
||||
if cfg.Leverage.AltcoinLeverage <= 0 {
|
||||
cfg.Leverage.AltcoinLeverage = 5
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Duration 返回回测区间时长。
|
||||
func (cfg *BacktestConfig) Duration() time.Duration {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
return time.Unix(cfg.EndTS, 0).Sub(time.Unix(cfg.StartTS, 0))
|
||||
}
|
||||
|
||||
const (
|
||||
// FillPolicyNextOpen 使用下一根 K 线的开盘价成交。
|
||||
FillPolicyNextOpen = "next_open"
|
||||
// FillPolicyBarVWAP 采用当前 K 线的近似 VWAP 成交。
|
||||
FillPolicyBarVWAP = "bar_vwap"
|
||||
// FillPolicyMidPrice 采用 (high+low)/2 的中间价成交。
|
||||
FillPolicyMidPrice = "mid"
|
||||
)
|
||||
|
||||
func validateFillPolicy(policy string) error {
|
||||
switch policy {
|
||||
case FillPolicyNextOpen, FillPolicyBarVWAP, FillPolicyMidPrice:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported fill_policy '%s'", policy)
|
||||
}
|
||||
}
|
||||
194
backtest/datafeed.go
Normal file
194
backtest/datafeed.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
type timeframeSeries struct {
|
||||
klines []market.Kline
|
||||
closeTimes []int64
|
||||
}
|
||||
|
||||
type symbolSeries struct {
|
||||
byTF map[string]*timeframeSeries
|
||||
}
|
||||
|
||||
// DataFeed 管理历史K线数据,为回测提供按时间推进的快照。
|
||||
type DataFeed struct {
|
||||
cfg BacktestConfig
|
||||
symbols []string
|
||||
timeframes []string
|
||||
symbolSeries map[string]*symbolSeries
|
||||
decisionTimes []int64
|
||||
primaryTF string
|
||||
longerTF string
|
||||
}
|
||||
|
||||
func NewDataFeed(cfg BacktestConfig) (*DataFeed, error) {
|
||||
df := &DataFeed{
|
||||
cfg: cfg,
|
||||
symbols: make([]string, len(cfg.Symbols)),
|
||||
timeframes: append([]string(nil), cfg.Timeframes...),
|
||||
symbolSeries: make(map[string]*symbolSeries),
|
||||
primaryTF: cfg.DecisionTimeframe,
|
||||
}
|
||||
copy(df.symbols, cfg.Symbols)
|
||||
|
||||
if err := df.loadAll(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return df, nil
|
||||
}
|
||||
|
||||
func (df *DataFeed) loadAll() error {
|
||||
start := time.Unix(df.cfg.StartTS, 0)
|
||||
end := time.Unix(df.cfg.EndTS, 0)
|
||||
|
||||
// longest timeframe用于辅助指标
|
||||
var longestDur time.Duration
|
||||
for _, tf := range df.timeframes {
|
||||
dur, err := market.TFDuration(tf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dur > longestDur {
|
||||
longestDur = dur
|
||||
df.longerTF = tf
|
||||
}
|
||||
}
|
||||
|
||||
for _, symbol := range df.symbols {
|
||||
ss := &symbolSeries{byTF: make(map[string]*timeframeSeries)}
|
||||
for _, tf := range df.timeframes {
|
||||
dur, _ := market.TFDuration(tf)
|
||||
buffer := dur * 200
|
||||
fetchStart := start.Add(-buffer)
|
||||
if fetchStart.Before(time.Unix(0, 0)) {
|
||||
fetchStart = time.Unix(0, 0)
|
||||
}
|
||||
fetchEnd := end.Add(dur)
|
||||
|
||||
klines, err := market.GetKlinesRange(symbol, tf, fetchStart, fetchEnd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch klines for %s %s: %w", symbol, tf, err)
|
||||
}
|
||||
if len(klines) == 0 {
|
||||
return fmt.Errorf("no klines for %s %s", symbol, tf)
|
||||
}
|
||||
|
||||
series := &timeframeSeries{
|
||||
klines: klines,
|
||||
closeTimes: make([]int64, len(klines)),
|
||||
}
|
||||
for i, k := range klines {
|
||||
series.closeTimes[i] = k.CloseTime
|
||||
}
|
||||
ss.byTF[tf] = series
|
||||
}
|
||||
df.symbolSeries[symbol] = ss
|
||||
}
|
||||
|
||||
// 以第一个符号的主周期生成回测进度时间轴
|
||||
firstSymbol := df.symbols[0]
|
||||
primarySeries := df.symbolSeries[firstSymbol].byTF[df.primaryTF]
|
||||
startMs := start.UnixMilli()
|
||||
endMs := end.UnixMilli()
|
||||
for _, ts := range primarySeries.closeTimes {
|
||||
if ts < startMs {
|
||||
continue
|
||||
}
|
||||
if ts > endMs {
|
||||
break
|
||||
}
|
||||
df.decisionTimes = append(df.decisionTimes, ts)
|
||||
// 对齐其他符号,如果缺数据则提前报错
|
||||
for _, symbol := range df.symbols[1:] {
|
||||
if _, ok := df.symbolSeries[symbol].byTF[df.primaryTF]; !ok {
|
||||
return fmt.Errorf("symbol %s missing timeframe %s", symbol, df.primaryTF)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(df.decisionTimes) == 0 {
|
||||
return fmt.Errorf("no decision bars in range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (df *DataFeed) DecisionBarCount() int {
|
||||
return len(df.decisionTimes)
|
||||
}
|
||||
|
||||
func (df *DataFeed) DecisionTimestamp(index int) int64 {
|
||||
return df.decisionTimes[index]
|
||||
}
|
||||
|
||||
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
|
||||
series := df.symbolSeries[symbol].byTF[tf]
|
||||
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||
return series.closeTimes[i] > ts
|
||||
})
|
||||
if idx <= 0 {
|
||||
return nil
|
||||
}
|
||||
return series.klines[:idx]
|
||||
}
|
||||
|
||||
func (df *DataFeed) BuildMarketData(ts int64) (map[string]*market.Data, map[string]map[string]*market.Data, error) {
|
||||
result := make(map[string]*market.Data, len(df.symbols))
|
||||
multi := make(map[string]map[string]*market.Data, len(df.symbols))
|
||||
|
||||
for _, symbol := range df.symbols {
|
||||
perTF := make(map[string]*market.Data, len(df.timeframes))
|
||||
for _, tf := range df.timeframes {
|
||||
series := df.sliceUpTo(symbol, tf, ts)
|
||||
if len(series) == 0 {
|
||||
continue
|
||||
}
|
||||
var longer []market.Kline
|
||||
if df.longerTF != "" && df.longerTF != tf {
|
||||
longer = df.sliceUpTo(symbol, df.longerTF, ts)
|
||||
}
|
||||
data, err := market.BuildDataFromKlines(symbol, series, longer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
perTF[tf] = data
|
||||
if tf == df.primaryTF {
|
||||
result[symbol] = data
|
||||
}
|
||||
}
|
||||
if _, ok := perTF[df.primaryTF]; !ok {
|
||||
return nil, nil, fmt.Errorf("no primary data for %s at %d", symbol, ts)
|
||||
}
|
||||
multi[symbol] = perTF
|
||||
}
|
||||
return result, multi, nil
|
||||
}
|
||||
|
||||
func (df *DataFeed) decisionBarSnapshot(symbol string, ts int64) (*market.Kline, *market.Kline) {
|
||||
ss, ok := df.symbolSeries[symbol]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
series, ok := ss.byTF[df.primaryTF]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||
return series.closeTimes[i] >= ts
|
||||
})
|
||||
if idx >= len(series.closeTimes) || series.closeTimes[idx] != ts {
|
||||
return nil, nil
|
||||
}
|
||||
curr := &series.klines[idx]
|
||||
var next *market.Kline
|
||||
if idx+1 < len(series.klines) {
|
||||
next = &series.klines[idx+1]
|
||||
}
|
||||
return curr, next
|
||||
}
|
||||
95
backtest/equity.go
Normal file
95
backtest/equity.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
// ResampleEquity 根据时间周期重采样资金曲线。
|
||||
func ResampleEquity(points []EquityPoint, timeframe string) ([]EquityPoint, error) {
|
||||
if timeframe == "" {
|
||||
return points, nil
|
||||
}
|
||||
dur, err := market.TFDuration(timeframe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return points, nil
|
||||
}
|
||||
|
||||
durMs := dur.Milliseconds()
|
||||
if durMs <= 0 {
|
||||
return points, nil
|
||||
}
|
||||
|
||||
bucketMap := make(map[int64]EquityPoint)
|
||||
bucketKeys := make([]int64, 0)
|
||||
for _, pt := range points {
|
||||
bucket := (pt.Timestamp / durMs) * durMs
|
||||
if _, exists := bucketMap[bucket]; !exists {
|
||||
bucketKeys = append(bucketKeys, bucket)
|
||||
}
|
||||
bucketPoint := pt
|
||||
bucketPoint.Timestamp = bucket
|
||||
bucketMap[bucket] = bucketPoint
|
||||
}
|
||||
|
||||
sort.Slice(bucketKeys, func(i, j int) bool {
|
||||
return bucketKeys[i] < bucketKeys[j]
|
||||
})
|
||||
|
||||
resampled := make([]EquityPoint, 0, len(bucketKeys))
|
||||
for _, key := range bucketKeys {
|
||||
resampled = append(resampled, bucketMap[key])
|
||||
}
|
||||
|
||||
return resampled, nil
|
||||
}
|
||||
|
||||
// LimitEquityPoints 将数据点数量限制在给定范围内(均匀抽样)。
|
||||
func LimitEquityPoints(points []EquityPoint, limit int) []EquityPoint {
|
||||
if limit <= 0 || len(points) <= limit {
|
||||
return points
|
||||
}
|
||||
|
||||
step := float64(len(points)) / float64(limit)
|
||||
result := make([]EquityPoint, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
idx := int(math.Round(step * float64(i)))
|
||||
if idx >= len(points) {
|
||||
idx = len(points) - 1
|
||||
}
|
||||
result = append(result, points[idx])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// LimitTradeEvents 同样对交易事件按均匀抽样。
|
||||
func LimitTradeEvents(events []TradeEvent, limit int) []TradeEvent {
|
||||
if limit <= 0 || len(events) <= limit {
|
||||
return events
|
||||
}
|
||||
|
||||
step := float64(len(events)) / float64(limit)
|
||||
result := make([]TradeEvent, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
idx := int(math.Round(step * float64(i)))
|
||||
if idx >= len(events) {
|
||||
idx = len(events) - 1
|
||||
}
|
||||
result = append(result, events[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AlignEquityTimestamps 确保时间戳按升序排列。
|
||||
func AlignEquityTimestamps(points []EquityPoint) []EquityPoint {
|
||||
sort.Slice(points, func(i, j int) bool {
|
||||
return points[i].Timestamp < points[j].Timestamp
|
||||
})
|
||||
return points
|
||||
}
|
||||
100
backtest/lock.go
Normal file
100
backtest/lock.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
lockFileName = "lock"
|
||||
lockHeartbeatInterval = 2 * time.Second
|
||||
lockStaleAfter = 10 * time.Second
|
||||
)
|
||||
|
||||
// RunLockInfo 表示回测运行的锁文件结构。
|
||||
type RunLockInfo struct {
|
||||
RunID string `json:"run_id"`
|
||||
PID int `json:"pid"`
|
||||
Host string `json:"host"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||
}
|
||||
|
||||
func lockFilePath(runID string) string {
|
||||
return filepath.Join(runDir(runID), lockFileName)
|
||||
}
|
||||
|
||||
func loadRunLock(runID string) (*RunLockInfo, error) {
|
||||
path := lockFilePath(runID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info RunLockInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func saveRunLock(info *RunLockInfo) error {
|
||||
if info == nil {
|
||||
return fmt.Errorf("lock info nil")
|
||||
}
|
||||
return writeJSONAtomic(lockFilePath(info.RunID), info)
|
||||
}
|
||||
|
||||
func deleteRunLock(runID string) error {
|
||||
err := os.Remove(lockFilePath(runID))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockIsStale(info *RunLockInfo) bool {
|
||||
if info == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(info.LastHeartbeat) > lockStaleAfter
|
||||
}
|
||||
|
||||
func acquireRunLock(runID string) (*RunLockInfo, error) {
|
||||
if err := ensureRunDir(runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existing, err := loadRunLock(runID); err == nil {
|
||||
if !lockIsStale(existing) {
|
||||
return nil, fmt.Errorf("run %s is locked by pid %d", runID, existing.PID)
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, _ := os.Hostname()
|
||||
info := &RunLockInfo{
|
||||
RunID: runID,
|
||||
PID: os.Getpid(),
|
||||
Host: host,
|
||||
StartedAt: time.Now().UTC(),
|
||||
LastHeartbeat: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := saveRunLock(info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func updateRunLockHeartbeat(info *RunLockInfo) error {
|
||||
if info == nil {
|
||||
return fmt.Errorf("lock info nil")
|
||||
}
|
||||
info.LastHeartbeat = time.Now().UTC()
|
||||
return saveRunLock(info)
|
||||
}
|
||||
493
backtest/manager.go
Normal file
493
backtest/manager.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
runners map[string]*Runner
|
||||
metadata map[string]*RunMetadata
|
||||
cancels map[string]context.CancelFunc
|
||||
mcpClient mcp.AIClient
|
||||
aiResolver AIConfigResolver
|
||||
}
|
||||
|
||||
type AIConfigResolver func(*BacktestConfig) error
|
||||
|
||||
func NewManager(defaultClient mcp.AIClient) *Manager {
|
||||
return &Manager{
|
||||
runners: make(map[string]*Runner),
|
||||
metadata: make(map[string]*RunMetadata),
|
||||
cancels: make(map[string]context.CancelFunc),
|
||||
mcpClient: defaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SetAIResolver(resolver AIConfigResolver) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.aiResolver = resolver
|
||||
}
|
||||
|
||||
func (m *Manager) Start(ctx context.Context, cfg BacktestConfig) (*Runner, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.resolveAIConfig(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.runners[cfg.RunID]; ok {
|
||||
state := existing.Status()
|
||||
if state == RunStateRunning || state == RunStatePaused {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("run %s is already active", cfg.RunID)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
persistCfg := cfg
|
||||
persistCfg.AICfg.APIKey = ""
|
||||
if err := SaveConfig(cfg.RunID, &persistCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runner, err := NewRunner(cfg, m.client())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
m.mu.Lock()
|
||||
if _, exists := m.runners[cfg.RunID]; exists {
|
||||
m.mu.Unlock()
|
||||
cancel()
|
||||
return nil, fmt.Errorf("run %s is already active", cfg.RunID)
|
||||
}
|
||||
m.runners[cfg.RunID] = runner
|
||||
m.cancels[cfg.RunID] = cancel
|
||||
meta := runner.CurrentMetadata()
|
||||
m.metadata[cfg.RunID] = meta
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := runner.Start(runCtx); err != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.runners, cfg.RunID)
|
||||
delete(m.cancels, cfg.RunID)
|
||||
delete(m.metadata, cfg.RunID)
|
||||
m.mu.Unlock()
|
||||
runner.releaseLock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.storeMetadata(cfg.RunID, meta)
|
||||
m.launchWatcher(cfg.RunID, runner)
|
||||
return runner, nil
|
||||
}
|
||||
|
||||
func (m *Manager) client() mcp.AIClient {
|
||||
if m.mcpClient != nil {
|
||||
return m.mcpClient
|
||||
}
|
||||
return mcp.New()
|
||||
}
|
||||
|
||||
func (m *Manager) GetRunner(runID string) (*Runner, bool) {
|
||||
m.mu.RLock()
|
||||
runner, ok := m.runners[runID]
|
||||
m.mu.RUnlock()
|
||||
return runner, ok
|
||||
}
|
||||
|
||||
func (m *Manager) ListRuns() ([]*RunMetadata, error) {
|
||||
m.mu.RLock()
|
||||
localCopy := make(map[string]*RunMetadata, len(m.metadata))
|
||||
for k, v := range m.metadata {
|
||||
localCopy[k] = v
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
runIDs, err := LoadRunIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ordered := make([]string, 0, len(runIDs))
|
||||
if entries, err := listIndexEntries(); err == nil {
|
||||
seen := make(map[string]bool, len(runIDs))
|
||||
for _, entry := range entries {
|
||||
if contains(runIDs, entry.RunID) {
|
||||
ordered = append(ordered, entry.RunID)
|
||||
seen[entry.RunID] = true
|
||||
}
|
||||
}
|
||||
for _, id := range runIDs {
|
||||
if !seen[id] {
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ordered = append(ordered, runIDs...)
|
||||
}
|
||||
|
||||
metas := make([]*RunMetadata, 0, len(runIDs))
|
||||
for _, runID := range ordered {
|
||||
if meta, ok := localCopy[runID]; ok {
|
||||
metas = append(metas, meta)
|
||||
continue
|
||||
}
|
||||
meta, err := LoadRunMetadata(runID)
|
||||
if err == nil {
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
|
||||
})
|
||||
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
func contains(list []string, target string) bool {
|
||||
for _, item := range list {
|
||||
if item == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) Pause(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return fmt.Errorf("run %s not found", runID)
|
||||
}
|
||||
runner.Pause()
|
||||
m.refreshMetadata(runID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Resume(runID string) error {
|
||||
if runID == "" {
|
||||
return fmt.Errorf("run_id is required")
|
||||
}
|
||||
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
runner.Resume()
|
||||
m.refreshMetadata(runID)
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfgCopy := *cfg
|
||||
if err := cfgCopy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.resolveAIConfig(&cfgCopy); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
restored, err := NewRunner(cfgCopy, m.client())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := restored.RestoreFromCheckpoint(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
m.mu.Lock()
|
||||
if _, exists := m.runners[runID]; exists {
|
||||
m.mu.Unlock()
|
||||
cancel()
|
||||
return fmt.Errorf("run %s is already active", runID)
|
||||
}
|
||||
m.runners[runID] = restored
|
||||
m.cancels[runID] = cancel
|
||||
m.metadata[runID] = restored.CurrentMetadata()
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := restored.Start(ctx); err != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.runners, runID)
|
||||
delete(m.cancels, runID)
|
||||
delete(m.metadata, runID)
|
||||
m.mu.Unlock()
|
||||
restored.releaseLock()
|
||||
return err
|
||||
}
|
||||
|
||||
m.storeMetadata(runID, restored.CurrentMetadata())
|
||||
m.launchWatcher(runID, restored)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
runner.Stop()
|
||||
err := runner.Wait()
|
||||
m.refreshMetadata(runID)
|
||||
return err
|
||||
}
|
||||
meta, err := m.LoadMetadata(runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.State == RunStateStopped || meta.State == RunStateCompleted {
|
||||
return nil
|
||||
}
|
||||
meta.State = RunStateStopped
|
||||
m.storeMetadata(runID, meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Wait(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return fmt.Errorf("run %s not found", runID)
|
||||
}
|
||||
err := runner.Wait()
|
||||
m.refreshMetadata(runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateLabel(runID, label string) (*RunMetadata, error) {
|
||||
meta, err := m.LoadMetadata(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clean := strings.TrimSpace(label)
|
||||
metaCopy := *meta
|
||||
metaCopy.Label = clean
|
||||
m.storeMetadata(runID, &metaCopy)
|
||||
return &metaCopy, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(runID string) error {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
runner.Stop()
|
||||
_ = runner.Wait()
|
||||
}
|
||||
m.mu.Lock()
|
||||
if cancel, ok := m.cancels[runID]; ok {
|
||||
cancel()
|
||||
delete(m.cancels, runID)
|
||||
}
|
||||
delete(m.runners, runID)
|
||||
delete(m.metadata, runID)
|
||||
m.mu.Unlock()
|
||||
if err := removeFromRunIndex(runID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteRunLock(runID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadMetadata(runID string) (*RunMetadata, error) {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if ok {
|
||||
meta := runner.CurrentMetadata()
|
||||
m.storeMetadata(runID, meta)
|
||||
return meta, nil
|
||||
}
|
||||
meta, err := LoadRunMetadata(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.storeMetadata(runID, meta)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadEquity(runID string, timeframe string, limit int) ([]EquityPoint, error) {
|
||||
points, err := LoadEquityPoints(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeframe != "" {
|
||||
points, err = ResampleEquity(points, timeframe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
points = AlignEquityTimestamps(points)
|
||||
points = LimitEquityPoints(points, limit)
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadTrades(runID string, limit int) ([]TradeEvent, error) {
|
||||
events, err := LoadTradeEvents(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LimitTradeEvents(events, limit), nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetMetrics(runID string) (*Metrics, error) {
|
||||
return LoadMetrics(runID)
|
||||
}
|
||||
|
||||
func (m *Manager) Cleanup(runID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.runners, runID)
|
||||
if cancel, ok := m.cancels[runID]; ok {
|
||||
cancel()
|
||||
delete(m.cancels, runID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status(runID string) *StatusPayload {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
payload := runner.StatusPayload()
|
||||
m.storeMetadata(runID, runner.CurrentMetadata())
|
||||
return &payload
|
||||
}
|
||||
|
||||
func (m *Manager) launchWatcher(runID string, runner *Runner) {
|
||||
go func() {
|
||||
if err := runner.Wait(); err != nil {
|
||||
log.Printf("backtest run %s finished with error: %v", runID, err)
|
||||
}
|
||||
runner.PersistMetadata()
|
||||
meta := runner.CurrentMetadata()
|
||||
m.storeMetadata(runID, meta)
|
||||
|
||||
m.mu.Lock()
|
||||
if cancel, ok := m.cancels[runID]; ok {
|
||||
cancel()
|
||||
delete(m.cancels, runID)
|
||||
}
|
||||
delete(m.runners, runID)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) refreshMetadata(runID string) {
|
||||
runner, ok := m.GetRunner(runID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
meta := runner.CurrentMetadata()
|
||||
m.storeMetadata(runID, meta)
|
||||
}
|
||||
|
||||
func (m *Manager) storeMetadata(runID string, meta *RunMetadata) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.metadata[runID]; ok {
|
||||
if meta.Label == "" && existing.Label != "" {
|
||||
meta.Label = existing.Label
|
||||
}
|
||||
if meta.LastError == "" && existing.LastError != "" {
|
||||
meta.LastError = existing.LastError
|
||||
}
|
||||
}
|
||||
m.metadata[runID] = meta
|
||||
m.mu.Unlock()
|
||||
_ = SaveRunMetadata(meta)
|
||||
if err := updateRunIndex(meta, nil); err != nil {
|
||||
log.Printf("failed to update run index for %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) resolveAIConfig(cfg *BacktestConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("ai config missing")
|
||||
}
|
||||
provider := strings.TrimSpace(cfg.AICfg.Provider)
|
||||
apiKey := strings.TrimSpace(cfg.AICfg.APIKey)
|
||||
if provider != "" && !strings.EqualFold(provider, "inherit") && apiKey != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
resolver := m.aiResolver
|
||||
m.mu.RUnlock()
|
||||
if resolver == nil {
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("AI配置缺少密钥且未配置解析器")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return resolver(cfg)
|
||||
}
|
||||
|
||||
func (m *Manager) GetTrace(runID string, cycle int) (*logger.DecisionRecord, error) {
|
||||
return LoadDecisionTrace(runID, cycle)
|
||||
}
|
||||
|
||||
func (m *Manager) ExportRun(runID string) (string, error) {
|
||||
return CreateRunExport(runID)
|
||||
}
|
||||
|
||||
// RestoreRunsFromDisk 扫描 backtests 目录并恢复现有 run 的元数据(服务重启场景)。
|
||||
func (m *Manager) RestoreRuns() error {
|
||||
runIDs, err := LoadRunIDs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, runID := range runIDs {
|
||||
meta, err := LoadRunMetadata(runID)
|
||||
if err != nil {
|
||||
log.Printf("skip run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
if meta.State == RunStateRunning {
|
||||
lock, err := loadRunLock(runID)
|
||||
if err != nil || lockIsStale(lock) {
|
||||
if err := deleteRunLock(runID); err != nil {
|
||||
log.Printf("failed to cleanup lock for %s: %v", runID, err)
|
||||
}
|
||||
meta.State = RunStatePaused
|
||||
if err := SaveRunMetadata(meta); err != nil {
|
||||
log.Printf("failed to mark %s paused: %v", runID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.metadata[runID] = meta
|
||||
m.mu.Unlock()
|
||||
if err := updateRunIndex(meta, nil); err != nil {
|
||||
log.Printf("failed to sync index for %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreRunsFromDisk 保留旧方法名,兼容历史调用。
|
||||
func (m *Manager) RestoreRunsFromDisk() error {
|
||||
return m.RestoreRuns()
|
||||
}
|
||||
225
backtest/metrics.go
Normal file
225
backtest/metrics.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CalculateMetrics 读取已有日志并计算汇总指标。state 可选,用于补充尚未落盘的信息。
|
||||
func CalculateMetrics(runID string, cfg *BacktestConfig, state *BacktestState) (*Metrics, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
points, err := LoadEquityPoints(runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equity points: %w", err)
|
||||
}
|
||||
|
||||
events, err := LoadTradeEvents(runID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load trade events: %w", err)
|
||||
}
|
||||
|
||||
metrics := &Metrics{
|
||||
SymbolStats: make(map[string]SymbolMetrics),
|
||||
}
|
||||
|
||||
metrics.Liquidated = determineLiquidation(events, state)
|
||||
|
||||
initialBalance := cfg.InitialBalance
|
||||
if initialBalance <= 0 {
|
||||
initialBalance = 1
|
||||
}
|
||||
|
||||
lastEquity := initialBalance
|
||||
if len(points) > 0 && points[len(points)-1].Equity > 0 {
|
||||
lastEquity = points[len(points)-1].Equity
|
||||
} else if state != nil && state.Equity > 0 {
|
||||
lastEquity = state.Equity
|
||||
}
|
||||
metrics.TotalReturnPct = ((lastEquity - initialBalance) / initialBalance) * 100
|
||||
|
||||
metrics.MaxDrawdownPct = maxDrawdown(points, state)
|
||||
metrics.SharpeRatio = sharpeRatio(points)
|
||||
|
||||
fillTradeMetrics(metrics, events)
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func determineLiquidation(events []TradeEvent, state *BacktestState) bool {
|
||||
if state != nil && state.Liquidated {
|
||||
return true
|
||||
}
|
||||
for i := len(events) - 1; i >= 0; i-- {
|
||||
if events[i].LiquidationFlag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
|
||||
if len(points) == 0 {
|
||||
if state != nil {
|
||||
return state.MaxDrawdownPct
|
||||
}
|
||||
return 0
|
||||
}
|
||||
peak := points[0].Equity
|
||||
if peak <= 0 {
|
||||
peak = 1
|
||||
}
|
||||
maxDD := 0.0
|
||||
for _, pt := range points {
|
||||
if pt.Equity > peak {
|
||||
peak = pt.Equity
|
||||
}
|
||||
if peak <= 0 {
|
||||
continue
|
||||
}
|
||||
dd := (peak - pt.Equity) / peak * 100
|
||||
if dd > maxDD {
|
||||
maxDD = dd
|
||||
}
|
||||
}
|
||||
if state != nil && state.MaxDrawdownPct > maxDD {
|
||||
maxDD = state.MaxDrawdownPct
|
||||
}
|
||||
return maxDD
|
||||
}
|
||||
|
||||
func sharpeRatio(points []EquityPoint) float64 {
|
||||
if len(points) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
returns := make([]float64, 0, len(points)-1)
|
||||
prev := points[0].Equity
|
||||
for i := 1; i < len(points); i++ {
|
||||
curr := points[i].Equity
|
||||
if prev <= 0 {
|
||||
prev = curr
|
||||
continue
|
||||
}
|
||||
ret := (curr - prev) / prev
|
||||
returns = append(returns, ret)
|
||||
prev = curr
|
||||
}
|
||||
if len(returns) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
mean := 0.0
|
||||
for _, r := range returns {
|
||||
mean += r
|
||||
}
|
||||
mean /= float64(len(returns))
|
||||
|
||||
variance := 0.0
|
||||
for _, r := range returns {
|
||||
diff := r - mean
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(returns))
|
||||
|
||||
std := math.Sqrt(variance)
|
||||
if std == 0 {
|
||||
if mean > 0 {
|
||||
return 999
|
||||
}
|
||||
if mean < 0 {
|
||||
return -999
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return mean / std
|
||||
}
|
||||
|
||||
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||
if metrics == nil {
|
||||
return
|
||||
}
|
||||
|
||||
totalTrades := 0
|
||||
winTrades := 0
|
||||
lossTrades := 0
|
||||
totalWinAmount := 0.0
|
||||
totalLossAmount := 0.0
|
||||
|
||||
for _, evt := range events {
|
||||
include := evt.LiquidationFlag || strings.HasPrefix(evt.Action, "close")
|
||||
if evt.RealizedPnL != 0 {
|
||||
include = true
|
||||
}
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
totalTrades++
|
||||
|
||||
stats := metrics.SymbolStats[evt.Symbol]
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += evt.RealizedPnL
|
||||
|
||||
if evt.RealizedPnL > 0 {
|
||||
winTrades++
|
||||
totalWinAmount += evt.RealizedPnL
|
||||
stats.WinningTrades++
|
||||
} else if evt.RealizedPnL < 0 {
|
||||
lossTrades++
|
||||
totalLossAmount += -evt.RealizedPnL
|
||||
stats.LosingTrades++
|
||||
}
|
||||
|
||||
metrics.SymbolStats[evt.Symbol] = stats
|
||||
}
|
||||
|
||||
metrics.Trades = totalTrades
|
||||
if totalTrades > 0 {
|
||||
metrics.WinRate = (float64(winTrades) / float64(totalTrades)) * 100
|
||||
}
|
||||
if winTrades > 0 {
|
||||
metrics.AvgWin = totalWinAmount / float64(winTrades)
|
||||
}
|
||||
if lossTrades > 0 {
|
||||
metrics.AvgLoss = -(totalLossAmount / float64(lossTrades))
|
||||
}
|
||||
if totalLossAmount > 0 {
|
||||
metrics.ProfitFactor = totalWinAmount / totalLossAmount
|
||||
} else if totalWinAmount > 0 {
|
||||
metrics.ProfitFactor = 999
|
||||
}
|
||||
|
||||
bestSymbol := ""
|
||||
bestPnL := math.Inf(-1)
|
||||
worstSymbol := ""
|
||||
worstPnL := math.Inf(1)
|
||||
|
||||
for symbol, stats := range metrics.SymbolStats {
|
||||
if stats.TotalTrades > 0 {
|
||||
if stats.TotalPnL > bestPnL {
|
||||
bestPnL = stats.TotalPnL
|
||||
bestSymbol = symbol
|
||||
}
|
||||
if stats.TotalPnL < worstPnL {
|
||||
worstPnL = stats.TotalPnL
|
||||
worstSymbol = symbol
|
||||
}
|
||||
|
||||
stats.AvgPnL = stats.TotalPnL / float64(stats.TotalTrades)
|
||||
stats.WinRate = (float64(stats.WinningTrades) / float64(stats.TotalTrades)) * 100
|
||||
}
|
||||
metrics.SymbolStats[symbol] = stats
|
||||
}
|
||||
|
||||
metrics.BestSymbol = bestSymbol
|
||||
if math.IsInf(bestPnL, -1) {
|
||||
metrics.BestSymbol = ""
|
||||
}
|
||||
metrics.WorstSymbol = worstSymbol
|
||||
if math.IsInf(worstPnL, 1) {
|
||||
metrics.WorstSymbol = ""
|
||||
}
|
||||
}
|
||||
16
backtest/persistence_db.go
Normal file
16
backtest/persistence_db.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
var persistenceDB *sql.DB
|
||||
|
||||
// UseDatabase enables database-backed persistence for all backtest storage operations.
|
||||
func UseDatabase(db *sql.DB) {
|
||||
persistenceDB = db
|
||||
}
|
||||
|
||||
func usingDB() bool {
|
||||
return persistenceDB != nil
|
||||
}
|
||||
160
backtest/registry.go
Normal file
160
backtest/registry.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const runIndexFile = "index.json"
|
||||
|
||||
type RunIndexEntry struct {
|
||||
RunID string `json:"run_id"`
|
||||
State RunState `json:"state"`
|
||||
Symbols []string `json:"symbols"`
|
||||
DecisionTF string `json:"decision_tf"`
|
||||
StartTS int64 `json:"start_ts"`
|
||||
EndTS int64 `json:"end_ts"`
|
||||
EquityLast float64 `json:"equity_last"`
|
||||
MaxDrawdownPct float64 `json:"max_dd_pct"`
|
||||
CreatedAtISO string `json:"created_at"`
|
||||
UpdatedAtISO string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type RunIndex struct {
|
||||
Runs map[string]RunIndexEntry `json:"runs"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func runIndexPath() string {
|
||||
return filepath.Join(backtestsRootDir, runIndexFile)
|
||||
}
|
||||
|
||||
func loadRunIndex() (*RunIndex, error) {
|
||||
if usingDB() {
|
||||
entries, err := listIndexEntriesDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := &RunIndex{
|
||||
Runs: make(map[string]RunIndexEntry),
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
idx.Runs[entry.RunID] = entry
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
path := runIndexPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return &RunIndex{Runs: make(map[string]RunIndexEntry)}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var idx RunIndex
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idx.Runs == nil {
|
||||
idx.Runs = make(map[string]RunIndexEntry)
|
||||
}
|
||||
return &idx, nil
|
||||
}
|
||||
|
||||
func saveRunIndex(idx *RunIndex) error {
|
||||
if usingDB() {
|
||||
return nil
|
||||
}
|
||||
if idx == nil {
|
||||
return fmt.Errorf("index is nil")
|
||||
}
|
||||
idx.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return writeJSONAtomic(runIndexPath(), idx)
|
||||
}
|
||||
|
||||
func updateRunIndex(meta *RunMetadata, cfg *BacktestConfig) error {
|
||||
if usingDB() {
|
||||
enforceRetention(maxCompletedRuns)
|
||||
return nil
|
||||
}
|
||||
if meta == nil {
|
||||
return fmt.Errorf("meta nil")
|
||||
}
|
||||
if cfg == nil {
|
||||
var err error
|
||||
cfg, err = LoadConfig(meta.RunID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := RunIndexEntry{
|
||||
RunID: meta.RunID,
|
||||
State: meta.State,
|
||||
Symbols: append([]string(nil), cfg.Symbols...),
|
||||
DecisionTF: meta.Summary.DecisionTF,
|
||||
StartTS: cfg.StartTS,
|
||||
EndTS: cfg.EndTS,
|
||||
EquityLast: meta.Summary.EquityLast,
|
||||
MaxDrawdownPct: meta.Summary.MaxDrawdownPct,
|
||||
CreatedAtISO: meta.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAtISO: meta.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if idx.Runs == nil {
|
||||
idx.Runs = make(map[string]RunIndexEntry)
|
||||
}
|
||||
idx.Runs[meta.RunID] = entry
|
||||
if err := saveRunIndex(idx); err != nil {
|
||||
return err
|
||||
}
|
||||
enforceRetention(maxCompletedRuns)
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeFromRunIndex(runID string) error {
|
||||
if usingDB() {
|
||||
if err := deleteRunDB(runID); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(runDir(runID))
|
||||
}
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if idx.Runs == nil {
|
||||
return nil
|
||||
}
|
||||
delete(idx.Runs, runID)
|
||||
return saveRunIndex(idx)
|
||||
}
|
||||
|
||||
func listIndexEntries() ([]RunIndexEntry, error) {
|
||||
if usingDB() {
|
||||
return listIndexEntriesDB()
|
||||
}
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]RunIndexEntry, 0, len(idx.Runs))
|
||||
for _, entry := range idx.Runs {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].UpdatedAtISO > entries[j].UpdatedAtISO
|
||||
})
|
||||
return entries, nil
|
||||
}
|
||||
101
backtest/retention.go
Normal file
101
backtest/retention.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxCompletedRuns = 100
|
||||
|
||||
func enforceRetention(maxRuns int) {
|
||||
if maxRuns <= 0 {
|
||||
return
|
||||
}
|
||||
if usingDB() {
|
||||
enforceRetentionDB(maxRuns)
|
||||
return
|
||||
}
|
||||
idx, err := loadRunIndex()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type wrapped struct {
|
||||
entry RunIndexEntry
|
||||
updated time.Time
|
||||
}
|
||||
finalStates := map[RunState]bool{
|
||||
RunStateCompleted: true,
|
||||
RunStateStopped: true,
|
||||
RunStateFailed: true,
|
||||
RunStateLiquidated: true,
|
||||
}
|
||||
|
||||
candidates := make([]wrapped, 0)
|
||||
for _, entry := range idx.Runs {
|
||||
if !finalStates[entry.State] {
|
||||
continue
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, entry.UpdatedAtISO)
|
||||
if err != nil {
|
||||
ts = time.Now()
|
||||
}
|
||||
candidates = append(candidates, wrapped{entry: entry, updated: ts})
|
||||
}
|
||||
if len(candidates) <= maxRuns {
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].updated.Before(candidates[j].updated)
|
||||
})
|
||||
|
||||
toRemove := len(candidates) - maxRuns
|
||||
for i := 0; i < toRemove; i++ {
|
||||
runID := candidates[i].entry.RunID
|
||||
if err := os.RemoveAll(runDir(runID)); err != nil {
|
||||
log.Printf("failed to prune run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
delete(idx.Runs, runID)
|
||||
}
|
||||
if err := saveRunIndex(idx); err != nil {
|
||||
log.Printf("failed to save index after pruning: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func enforceRetentionDB(maxRuns int) {
|
||||
finalStates := []RunState{
|
||||
RunStateCompleted,
|
||||
RunStateStopped,
|
||||
RunStateFailed,
|
||||
RunStateLiquidated,
|
||||
}
|
||||
query := `
|
||||
SELECT run_id FROM backtest_runs
|
||||
WHERE state IN (?, ?, ?, ?)
|
||||
ORDER BY datetime(updated_at) DESC
|
||||
LIMIT -1 OFFSET ?
|
||||
`
|
||||
rows, err := persistenceDB.Query(query,
|
||||
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var runID string
|
||||
if err := rows.Scan(&runID); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := deleteRunDB(runID); err != nil {
|
||||
log.Printf("failed to remove run %s: %v", runID, err)
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(runDir(runID)); err != nil {
|
||||
log.Printf("failed to remove run dir %s: %v", runID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
1361
backtest/runner.go
Normal file
1361
backtest/runner.go
Normal file
File diff suppressed because it is too large
Load Diff
561
backtest/storage.go
Normal file
561
backtest/storage.go
Normal file
@@ -0,0 +1,561 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
backtestsRootDir = "backtests"
|
||||
)
|
||||
|
||||
type progressPayload struct {
|
||||
BarIndex int `json:"bar_index"`
|
||||
Equity float64 `json:"equity"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
UpdatedAtISO string `json:"updated_at_iso"`
|
||||
}
|
||||
|
||||
func runDir(runID string) string {
|
||||
return filepath.Join(backtestsRootDir, runID)
|
||||
}
|
||||
|
||||
func ensureRunDir(runID string) error {
|
||||
dir := runDir(runID)
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
|
||||
func checkpointPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "checkpoint.json")
|
||||
}
|
||||
|
||||
func runMetadataPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "run.json")
|
||||
}
|
||||
|
||||
func equityLogPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "equity.jsonl")
|
||||
}
|
||||
|
||||
func tradesLogPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "trades.jsonl")
|
||||
}
|
||||
|
||||
func metricsPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "metrics.json")
|
||||
}
|
||||
|
||||
func progressPath(runID string) string {
|
||||
return filepath.Join(runDir(runID), "progress.json")
|
||||
}
|
||||
|
||||
func decisionLogDir(runID string) string {
|
||||
return filepath.Join(runDir(runID), "decision_logs")
|
||||
}
|
||||
|
||||
func writeJSONAtomic(path string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func appendJSONLine(path string, payload any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
writer := bufio.NewWriter(f)
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.WriteByte('\n'); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
// SaveCheckpoint 将检查点写入磁盘。
|
||||
func SaveCheckpoint(runID string, ckpt *Checkpoint) error {
|
||||
if ckpt == nil {
|
||||
return fmt.Errorf("checkpoint is nil")
|
||||
}
|
||||
if usingDB() {
|
||||
return saveCheckpointDB(runID, ckpt)
|
||||
}
|
||||
return writeJSONAtomic(checkpointPath(runID), ckpt)
|
||||
}
|
||||
|
||||
// LoadCheckpoint 读取最近一次检查点。
|
||||
func LoadCheckpoint(runID string) (*Checkpoint, error) {
|
||||
if usingDB() {
|
||||
return loadCheckpointDB(runID)
|
||||
}
|
||||
path := checkpointPath(runID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ckpt Checkpoint
|
||||
if err := json.Unmarshal(data, &ckpt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ckpt, nil
|
||||
}
|
||||
|
||||
// SaveRunMetadata 写入 run.json。
|
||||
func SaveRunMetadata(meta *RunMetadata) error {
|
||||
if meta == nil {
|
||||
return fmt.Errorf("run metadata is nil")
|
||||
}
|
||||
if meta.Version == 0 {
|
||||
meta.Version = 1
|
||||
}
|
||||
if meta.CreatedAt.IsZero() {
|
||||
meta.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
meta.UpdatedAt = time.Now().UTC()
|
||||
if usingDB() {
|
||||
return saveRunMetadataDB(meta)
|
||||
}
|
||||
return writeJSONAtomic(runMetadataPath(meta.RunID), meta)
|
||||
}
|
||||
|
||||
// LoadRunMetadata 读取 run.json。
|
||||
func LoadRunMetadata(runID string) (*RunMetadata, error) {
|
||||
if usingDB() {
|
||||
return loadRunMetadataDB(runID)
|
||||
}
|
||||
path := runMetadataPath(runID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta RunMetadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func appendEquityPoint(runID string, point EquityPoint) error {
|
||||
if usingDB() {
|
||||
return appendEquityPointDB(runID, point)
|
||||
}
|
||||
return appendJSONLine(equityLogPath(runID), point)
|
||||
}
|
||||
|
||||
func appendTradeEvent(runID string, event TradeEvent) error {
|
||||
if usingDB() {
|
||||
return appendTradeEventDB(runID, event)
|
||||
}
|
||||
return appendJSONLine(tradesLogPath(runID), event)
|
||||
}
|
||||
|
||||
func saveMetrics(runID string, metrics *Metrics) error {
|
||||
if metrics == nil {
|
||||
return fmt.Errorf("metrics is nil")
|
||||
}
|
||||
if usingDB() {
|
||||
return saveMetricsDB(runID, metrics)
|
||||
}
|
||||
return writeJSONAtomic(metricsPath(runID), metrics)
|
||||
}
|
||||
|
||||
func saveProgress(runID string, state *BacktestState, cfg *BacktestConfig) error {
|
||||
if state == nil || cfg == nil {
|
||||
return fmt.Errorf("state or config nil")
|
||||
}
|
||||
dur := cfg.Duration()
|
||||
progress := 0.0
|
||||
if dur > 0 {
|
||||
current := time.UnixMilli(state.BarTimestamp)
|
||||
start := time.Unix(cfg.StartTS, 0)
|
||||
if current.After(start) {
|
||||
elapsed := current.Sub(start)
|
||||
progress = float64(elapsed) / float64(dur)
|
||||
}
|
||||
}
|
||||
payload := progressPayload{
|
||||
BarIndex: state.BarIndex,
|
||||
Equity: state.Equity,
|
||||
ProgressPct: progress * 100,
|
||||
Liquidated: state.Liquidated,
|
||||
|
||||
UpdatedAtISO: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if usingDB() {
|
||||
return saveProgressDB(runID, payload)
|
||||
}
|
||||
return writeJSONAtomic(progressPath(runID), payload)
|
||||
}
|
||||
|
||||
func SaveConfig(runID string, cfg *BacktestConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
persist := *cfg
|
||||
persist.AICfg.APIKey = ""
|
||||
if usingDB() {
|
||||
return saveConfigDB(runID, &persist)
|
||||
}
|
||||
if err := ensureRunDir(runID); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSONAtomic(filepath.Join(runDir(runID), "config.json"), &persist)
|
||||
}
|
||||
|
||||
func LoadConfig(runID string) (*BacktestConfig, error) {
|
||||
if usingDB() {
|
||||
return loadConfigDB(runID)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(runDir(runID), "config.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg BacktestConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func LoadEquityPoints(runID string) ([]EquityPoint, error) {
|
||||
if usingDB() {
|
||||
return loadEquityPointsDB(runID)
|
||||
}
|
||||
points, err := loadJSONLines[EquityPoint](equityLogPath(runID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(points, func(i, j int) bool {
|
||||
return points[i].Timestamp < points[j].Timestamp
|
||||
})
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func LoadTradeEvents(runID string) ([]TradeEvent, error) {
|
||||
if usingDB() {
|
||||
return loadTradeEventsDB(runID)
|
||||
}
|
||||
events, err := loadJSONLines[TradeEvent](tradesLogPath(runID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].Timestamp == events[j].Timestamp {
|
||||
return events[i].Symbol < events[j].Symbol
|
||||
}
|
||||
return events[i].Timestamp < events[j].Timestamp
|
||||
})
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func LoadMetrics(runID string) (*Metrics, error) {
|
||||
if usingDB() {
|
||||
return loadMetricsDB(runID)
|
||||
}
|
||||
data, err := os.ReadFile(metricsPath(runID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var metrics Metrics
|
||||
if err := json.Unmarshal(data, &metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
func LoadRunIDs() ([]string, error) {
|
||||
if usingDB() {
|
||||
return loadRunIDsDB()
|
||||
}
|
||||
entries, err := os.ReadDir(backtestsRootDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
runIDs := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
runIDs = append(runIDs, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(runIDs)
|
||||
return runIDs, nil
|
||||
}
|
||||
|
||||
func loadJSONLines[T any](path string) ([]T, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []T{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
|
||||
var result []T
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var item T
|
||||
if err := json.Unmarshal(line, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
func PersistMetrics(runID string, metrics *Metrics) error {
|
||||
return saveMetrics(runID, metrics)
|
||||
}
|
||||
|
||||
func LoadDecisionTrace(runID string, cycle int) (*logger.DecisionRecord, error) {
|
||||
if usingDB() {
|
||||
return loadDecisionTraceDB(runID, cycle)
|
||||
}
|
||||
dir := decisionLogDir(runID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type candidate struct {
|
||||
path string
|
||||
info os.DirEntry
|
||||
}
|
||||
cands := make([]candidate, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "decision_") || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, candidate{path: filepath.Join(dir, name), info: entry})
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool {
|
||||
infoI, _ := cands[i].info.Info()
|
||||
infoJ, _ := cands[j].info.Info()
|
||||
if infoI == nil || infoJ == nil {
|
||||
return cands[i].path > cands[j].path
|
||||
}
|
||||
return infoI.ModTime().After(infoJ.ModTime())
|
||||
})
|
||||
|
||||
for _, cand := range cands {
|
||||
data, err := os.ReadFile(cand.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var record logger.DecisionRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
if cycle <= 0 || record.CycleNumber == cycle {
|
||||
return &record, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("decision trace not found for run %s cycle %d", runID, cycle)
|
||||
}
|
||||
|
||||
func LoadDecisionRecords(runID string, limit, offset int) ([]*logger.DecisionRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if usingDB() {
|
||||
return loadDecisionRecordsDB(runID, limit, offset)
|
||||
}
|
||||
dir := decisionLogDir(runID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []*logger.DecisionRecord{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
type fileEntry struct {
|
||||
path string
|
||||
info os.DirEntry
|
||||
}
|
||||
files := make([]fileEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "decision_") || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
files = append(files, fileEntry{path: filepath.Join(dir, name), info: entry})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
infoI, _ := files[i].info.Info()
|
||||
infoJ, _ := files[j].info.Info()
|
||||
if infoI == nil || infoJ == nil {
|
||||
return files[i].path > files[j].path
|
||||
}
|
||||
return infoI.ModTime().After(infoJ.ModTime())
|
||||
})
|
||||
if offset >= len(files) {
|
||||
return []*logger.DecisionRecord{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(files) {
|
||||
end = len(files)
|
||||
}
|
||||
records := make([]*logger.DecisionRecord, 0, end-offset)
|
||||
for _, file := range files[offset:end] {
|
||||
data, err := os.ReadFile(file.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var record logger.DecisionRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, &record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func CreateRunExport(runID string) (string, error) {
|
||||
if usingDB() {
|
||||
return createRunExportDB(runID)
|
||||
}
|
||||
root := runDir(runID)
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.zip", runID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(tmpFile)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = rel
|
||||
header.Method = zip.Deflate
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(writer, src); err != nil {
|
||||
src.Close()
|
||||
return err
|
||||
}
|
||||
src.Close()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
zipWriter.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
func persistDecisionRecord(runID string, record *logger.DecisionRecord) {
|
||||
if !usingDB() || record == nil {
|
||||
return
|
||||
}
|
||||
_ = saveDecisionRecordDB(runID, record)
|
||||
}
|
||||
499
backtest/storage_db_impl.go
Normal file
499
backtest/storage_db_impl.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package backtest
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
|
||||
data, err := json.Marshal(ckpt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCheckpointDB(runID string) (*Checkpoint, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var ckpt Checkpoint
|
||||
if err := json.Unmarshal(payload, &ckpt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ckpt, nil
|
||||
}
|
||||
|
||||
func saveConfigDB(runID string, cfg *BacktestConfig) error {
|
||||
persist := *cfg
|
||||
persist.AICfg.APIKey = ""
|
||||
data, err := json.Marshal(&persist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template := cfg.PromptTemplate
|
||||
if template == "" {
|
||||
template = "default"
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
userID := cfg.UserID
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE run_id = ?
|
||||
`, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadConfigDB(runID string) (*BacktestConfig, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("config missing for %s", runID)
|
||||
}
|
||||
var cfg BacktestConfig
|
||||
if err := json.Unmarshal(payload, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func saveRunMetadataDB(meta *RunMetadata) error {
|
||||
created := meta.CreatedAt.UTC().Format(time.RFC3339)
|
||||
updated := meta.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
userID := meta.UserID
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
if _, err := persistenceDB.Exec(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := persistenceDB.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadRunMetadataDB(runID string) (*RunMetadata, error) {
|
||||
var (
|
||||
userID string
|
||||
state string
|
||||
label string
|
||||
lastErr string
|
||||
symbolCount int
|
||||
decisionTF string
|
||||
processedBars int
|
||||
progressPct float64
|
||||
equityLast float64
|
||||
maxDD float64
|
||||
liquidated bool
|
||||
liquidationNote string
|
||||
createdISO string
|
||||
updatedISO string
|
||||
)
|
||||
err := persistenceDB.QueryRow(`
|
||||
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at
|
||||
FROM backtest_runs WHERE run_id = ?
|
||||
`, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := &RunMetadata{
|
||||
RunID: runID,
|
||||
UserID: userID,
|
||||
Version: 1,
|
||||
State: RunState(state),
|
||||
Label: label,
|
||||
LastError: lastErr,
|
||||
Summary: RunSummary{
|
||||
SymbolCount: symbolCount,
|
||||
DecisionTF: decisionTF,
|
||||
ProcessedBars: processedBars,
|
||||
ProgressPct: progressPct,
|
||||
EquityLast: equityLast,
|
||||
MaxDrawdownPct: maxDD,
|
||||
Liquidated: liquidated,
|
||||
LiquidationNote: liquidationNote,
|
||||
},
|
||||
}
|
||||
if meta.UserID == "" {
|
||||
meta.UserID = "default"
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, createdISO); err == nil {
|
||||
meta.CreatedAt = t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, updatedISO); err == nil {
|
||||
meta.UpdatedAt = t
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func loadRunIDsDB() ([]string, error) {
|
||||
rows, err := persistenceDB.Query(`SELECT run_id FROM backtest_runs ORDER BY datetime(updated_at) DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var runID string
|
||||
if err := rows.Scan(&runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, runID)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func appendEquityPointDB(runID string, point EquityPoint) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
|
||||
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
points := make([]EquityPoint, 0)
|
||||
for rows.Next() {
|
||||
var point EquityPoint
|
||||
if err := rows.Scan(&point.Timestamp, &point.Equity, &point.Available, &point.PnL, &point.PnLPct, &point.DrawdownPct, &point.Cycle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
return points, rows.Err()
|
||||
}
|
||||
|
||||
func appendTradeEventDB(runID string, event TradeEvent) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note
|
||||
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
events := make([]TradeEvent, 0)
|
||||
for rows.Next() {
|
||||
var event TradeEvent
|
||||
if err := rows.Scan(&event.Timestamp, &event.Symbol, &event.Action, &event.Side, &event.Quantity, &event.Price, &event.Fee, &event.Slippage, &event.OrderValue, &event.RealizedPnL, &event.Leverage, &event.Cycle, &event.PositionAfter, &event.LiquidationFlag, &event.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func saveMetricsDB(runID string, metrics *Metrics) error {
|
||||
data, err := json.Marshal(metrics)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadMetricsDB(runID string) (*Metrics, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var metrics Metrics
|
||||
if err := json.Unmarshal(payload, &metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
func saveProgressDB(runID string, payload progressPayload) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
UPDATE backtest_runs
|
||||
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionTraceDB(runID string, cycle int) (*logger.DecisionRecord, error) {
|
||||
query := `SELECT payload FROM backtest_decisions WHERE run_id = ?`
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if cycle > 0 {
|
||||
rows, err = persistenceDB.Query(query+` AND cycle = ? ORDER BY datetime(created_at) DESC LIMIT 1`, runID, cycle)
|
||||
} else {
|
||||
rows, err = persistenceDB.Query(query+` ORDER BY datetime(created_at) DESC LIMIT 1`, runID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, fmt.Errorf("decision trace not found for %s", runID)
|
||||
}
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var record logger.DecisionRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func saveDecisionRecordDB(runID string, record *logger.DecisionRecord) error {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`, runID, record.CycleNumber, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*logger.DecisionRecord, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT payload FROM backtest_decisions
|
||||
WHERE run_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, runID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
records := make([]*logger.DecisionRecord, 0, limit)
|
||||
for rows.Next() {
|
||||
var payload []byte
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var record logger.DecisionRecord
|
||||
if err := json.Unmarshal(payload, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, &record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func createRunExportDB(runID string) (string, error) {
|
||||
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.zip", runID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(tmpFile)
|
||||
defer zipWriter.Close()
|
||||
|
||||
if meta, err := loadRunMetadataDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "run.json", meta); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if cfg, err := loadConfigDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "config.json", cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if ckpt, err := loadCheckpointDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "checkpoint.json", ckpt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if metrics, err := loadMetricsDB(runID); err == nil {
|
||||
if err := writeJSONToZip(zipWriter, "metrics.json", metrics); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if points, err := loadEquityPointsDB(runID); err == nil && len(points) > 0 {
|
||||
if err := writeJSONLinesToZip(zipWriter, "equity.jsonl", points); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if trades, err := loadTradeEventsDB(runID); err == nil && len(trades) > 0 {
|
||||
if err := writeJSONLinesToZip(zipWriter, "trades.jsonl", trades); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := writeDecisionLogsToZip(zipWriter, runID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tmpFile.Name(), nil
|
||||
}
|
||||
|
||||
func writeJSONToZip(z *zip.Writer, name string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := z.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeJSONLinesToZip[T any](z *zip.Writer, name string, items []T) error {
|
||||
w, err := z.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte("\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT id, cycle, payload FROM backtest_decisions
|
||||
WHERE run_id = ? ORDER BY id ASC
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
cycle int
|
||||
payload []byte
|
||||
)
|
||||
if err := rows.Scan(&id, &cycle, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
name := fmt.Sprintf("decision_logs/decision_%d_cycle%d.json", id, cycle)
|
||||
w, err := z.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func listIndexEntriesDB() ([]RunIndexEntry, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
SELECT run_id, state, symbol_count, decision_tf, equity_last, max_drawdown_pct, created_at, updated_at, config_json
|
||||
FROM backtest_runs
|
||||
ORDER BY datetime(updated_at) DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var entries []RunIndexEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry RunIndexEntry
|
||||
createdISO string
|
||||
updatedISO string
|
||||
cfgJSON []byte
|
||||
symbolCnt int
|
||||
)
|
||||
if err := rows.Scan(&entry.RunID, &entry.State, &symbolCnt, &entry.DecisionTF, &entry.EquityLast, &entry.MaxDrawdownPct, &createdISO, &updatedISO, &cfgJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.CreatedAtISO = createdISO
|
||||
entry.UpdatedAtISO = updatedISO
|
||||
entry.Symbols = make([]string, 0, symbolCnt)
|
||||
var cfg BacktestConfig
|
||||
if len(cfgJSON) > 0 && json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
entry.Symbols = append([]string(nil), cfg.Symbols...)
|
||||
entry.StartTS = cfg.StartTS
|
||||
entry.EndTS = cfg.EndTS
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func deleteRunDB(runID string) error {
|
||||
_, err := persistenceDB.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID)
|
||||
return err
|
||||
}
|
||||
164
backtest/types.go
Normal file
164
backtest/types.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package backtest
|
||||
|
||||
import "time"
|
||||
|
||||
// RunState 表示回测运行当前状态。
|
||||
type RunState string
|
||||
|
||||
const (
|
||||
RunStateCreated RunState = "created"
|
||||
RunStateRunning RunState = "running"
|
||||
RunStatePaused RunState = "paused"
|
||||
RunStateStopped RunState = "stopped"
|
||||
RunStateCompleted RunState = "completed"
|
||||
RunStateFailed RunState = "failed"
|
||||
RunStateLiquidated RunState = "liquidated"
|
||||
)
|
||||
|
||||
// PositionSnapshot 表示当前持仓的核心数据,用于回测状态与持久化。
|
||||
type PositionSnapshot struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
AvgPrice float64 `json:"avg_price"`
|
||||
Leverage int `json:"leverage"`
|
||||
LiquidationPrice float64 `json:"liquidation_price"`
|
||||
MarginUsed float64 `json:"margin_used"`
|
||||
OpenTime int64 `json:"open_time"`
|
||||
}
|
||||
|
||||
// BacktestState 表示执行过程中的实时状态(内存态)。
|
||||
type BacktestState struct {
|
||||
BarIndex int
|
||||
BarTimestamp int64
|
||||
DecisionCycle int
|
||||
|
||||
Cash float64
|
||||
Equity float64
|
||||
UnrealizedPnL float64
|
||||
RealizedPnL float64
|
||||
MaxEquity float64
|
||||
MinEquity float64
|
||||
MaxDrawdownPct float64
|
||||
Positions map[string]PositionSnapshot
|
||||
LastUpdate time.Time
|
||||
Liquidated bool
|
||||
LiquidationNote string
|
||||
}
|
||||
|
||||
// EquityPoint 表示资金曲线中的单个节点。
|
||||
type EquityPoint struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Equity float64 `json:"equity"`
|
||||
Available float64 `json:"available"`
|
||||
PnL float64 `json:"pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
DrawdownPct float64 `json:"dd_pct"`
|
||||
Cycle int `json:"cycle"`
|
||||
}
|
||||
|
||||
// TradeEvent 记录一次交易执行结果或特殊事件(如爆仓)。
|
||||
type TradeEvent struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"`
|
||||
Side string `json:"side,omitempty"`
|
||||
Quantity float64 `json:"qty"`
|
||||
Price float64 `json:"price"`
|
||||
Fee float64 `json:"fee"`
|
||||
Slippage float64 `json:"slippage"`
|
||||
OrderValue float64 `json:"order_value"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
Leverage int `json:"leverage,omitempty"`
|
||||
Cycle int `json:"cycle"`
|
||||
PositionAfter float64 `json:"position_after"`
|
||||
LiquidationFlag bool `json:"liquidation"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Metrics 汇总回测表现指标。
|
||||
type Metrics struct {
|
||||
TotalReturnPct float64 `json:"total_return_pct"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
SharpeRatio float64 `json:"sharpe_ratio"`
|
||||
ProfitFactor float64 `json:"profit_factor"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
Trades int `json:"trades"`
|
||||
AvgWin float64 `json:"avg_win"`
|
||||
AvgLoss float64 `json:"avg_loss"`
|
||||
BestSymbol string `json:"best_symbol"`
|
||||
WorstSymbol string `json:"worst_symbol"`
|
||||
SymbolStats map[string]SymbolMetrics `json:"symbol_stats"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
}
|
||||
|
||||
// SymbolMetrics 记录单个标的的表现。
|
||||
type SymbolMetrics struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
LosingTrades int `json:"losing_trades"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
}
|
||||
|
||||
// Checkpoint 表示磁盘保存的检查点信息,用于暂停、恢复与崩溃恢复。
|
||||
type Checkpoint struct {
|
||||
BarIndex int `json:"bar_index"`
|
||||
BarTimestamp int64 `json:"bar_ts"`
|
||||
Cash float64 `json:"cash"`
|
||||
Equity float64 `json:"equity"`
|
||||
MaxEquity float64 `json:"max_equity"`
|
||||
MinEquity float64 `json:"min_equity"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
Positions []PositionSnapshot `json:"positions"`
|
||||
DecisionCycle int `json:"decision_cycle"`
|
||||
IndicatorsState map[string]map[string]any `json:"indicators_state,omitempty"`
|
||||
RNGSeed int64 `json:"rng_seed,omitempty"`
|
||||
AICacheRef string `json:"ai_cache_ref,omitempty"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
LiquidationNote string `json:"liquidation_note,omitempty"`
|
||||
}
|
||||
|
||||
// RunMetadata 记录 run.json 所需摘要。
|
||||
type RunMetadata struct {
|
||||
RunID string `json:"run_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Version int `json:"version"`
|
||||
State RunState `json:"state"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Summary RunSummary `json:"summary"`
|
||||
}
|
||||
|
||||
// RunSummary 为 run.json 中的 summary 字段。
|
||||
type RunSummary struct {
|
||||
SymbolCount int `json:"symbol_count"`
|
||||
DecisionTF string `json:"decision_tf"`
|
||||
ProcessedBars int `json:"processed_bars"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
EquityLast float64 `json:"equity_last"`
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
Liquidated bool `json:"liquidated"`
|
||||
LiquidationNote string `json:"liquidation_note,omitempty"`
|
||||
}
|
||||
|
||||
// StatusPayload 用于 /status API 的响应。
|
||||
type StatusPayload struct {
|
||||
RunID string `json:"run_id"`
|
||||
State RunState `json:"state"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
ProcessedBars int `json:"processed_bars"`
|
||||
CurrentTime int64 `json:"current_time"`
|
||||
DecisionCycle int `json:"decision_cycle"`
|
||||
Equity float64 `json:"equity"`
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
Note string `json:"note,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastUpdatedIso string `json:"last_updated_iso"`
|
||||
}
|
||||
Reference in New Issue
Block a user