refactor: remove all backtest module code and references

Delete backtest/ engine (19 files), api/backtest.go, store/backtest.go,
web backtest components (7 files), API client, types, docs, screenshot.
Clean all backtest references from main.go, api/server.go, store/store.go,
App.tsx, HeaderBar.tsx, LandingPage.tsx, translations, README and docs.
This commit is contained in:
tinkle-community
2026-03-16 07:38:01 +08:00
parent 1a6b88d77f
commit 21a15f98eb
51 changed files with 37 additions and 11130 deletions
+6 -8
View File
@@ -79,7 +79,6 @@ Also compatible with **[ClawRouter](https://github.com/BlockRunAI/ClawRouter)**
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
| **Backtest Lab** | Historical simulation with equity curves and performance metrics |
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
### Markets
@@ -158,11 +157,11 @@ Crypto · US Stocks · Forex · Metals
</details>
<details>
<summary><b>Competition & Backtest</b></summary>
<summary><b>Competition</b></summary>
| Competition Mode | Backtest Lab |
|:---:|:---:|
| <img src="screenshots/competition-page.png" width="400"/> | <img src="screenshots/backtest-lab.png" width="400"/> |
| Competition Mode |
|:---:|
| <img src="screenshots/competition-page.png" width="400"/> |
</details>
---
@@ -253,8 +252,8 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────┬──────────┬──────────┬────────────────┤
│ Strategy │ Backtest │ Telegram │
│ Engine │ Lab │ Agent │
Strategy Telegram
Engine │ Agent
├──────────┴──────────┴──────────┴────────────────┤
│ MCP AI Client Layer │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
@@ -277,7 +276,6 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|:--|:--|
| [Architecture](docs/architecture/README.md) | System design and module index |
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
| [Backtest Module](docs/architecture/BACKTEST_MODULE.md) | Historical simulation, metrics |
| [FAQ](docs/faq/README.md) | Common questions |
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
-863
View File
@@ -1,863 +0,0 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"nofx/backtest"
"nofx/logger"
"nofx/market"
"nofx/provider/nofxos"
"nofx/store"
"github.com/gin-gonic/gin"
)
func (s *Server) registerBacktestRoutes(router *gin.RouterGroup) {
router.POST("/start", s.handleBacktestStart)
router.POST("/pause", s.handleBacktestPause)
router.POST("/resume", s.handleBacktestResume)
router.POST("/stop", s.handleBacktestStop)
router.POST("/label", s.handleBacktestLabel)
router.POST("/delete", s.handleBacktestDelete)
router.GET("/status", s.handleBacktestStatus)
router.GET("/runs", s.handleBacktestRuns)
router.GET("/equity", s.handleBacktestEquity)
router.GET("/trades", s.handleBacktestTrades)
router.GET("/metrics", s.handleBacktestMetrics)
router.GET("/trace", s.handleBacktestTrace)
router.GET("/decisions", s.handleBacktestDecisions)
router.GET("/export", s.handleBacktestExport)
router.GET("/klines", s.handleBacktestKlines)
}
type backtestStartRequest struct {
Config backtest.BacktestConfig `json:"config"`
}
type runIDRequest struct {
RunID string `json:"run_id"`
}
type labelRequest struct {
RunID string `json:"run_id"`
Label string `json:"label"`
}
func (s *Server) handleBacktestStart(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
var req backtestStartRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
cfg := req.Config
if cfg.RunID == "" {
cfg.RunID = "bt_" + time.Now().UTC().Format("20060102_150405")
}
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
cfg.UserID = normalizeUserID(c.GetString("user_id"))
logger.Infof("📊 Backtest request - symbols from request: %v (count=%d), strategyID: %s",
cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
// Load strategy config if strategy_id is provided
if cfg.StrategyID != "" {
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
if err != nil {
SafeBadRequest(c, "Failed to load strategy")
return
}
if strategy == nil {
SafeBadRequest(c, "Strategy not found")
return
}
var strategyConfig store.StrategyConfig
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
SafeBadRequest(c, "Failed to parse strategy config")
return
}
cfg.SetLoadedStrategy(&strategyConfig)
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
logger.Infof("📊 Strategy coin source: type=%s, use_ai500=%v, use_oi_top=%v, static_coins=%v",
strategyConfig.CoinSource.SourceType,
strategyConfig.CoinSource.UseAI500,
strategyConfig.CoinSource.UseOITop,
strategyConfig.CoinSource.StaticCoins)
// If no symbols provided, fetch from strategy's coin source
if len(cfg.Symbols) == 0 {
symbols, err := s.resolveStrategyCoins(&strategyConfig)
if err != nil {
SafeBadRequest(c, "Failed to resolve coins from strategy")
return
}
cfg.Symbols = symbols
logger.Infof("📊 Resolved %d coins from strategy: %v", len(symbols), symbols)
}
}
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
SafeBadRequest(c, "Failed to configure AI model")
return
}
logger.Infof("📊 Starting backtest with final config: runID=%s, symbols=%v (count=%d), strategyID=%s",
cfg.RunID, cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
runner, err := s.backtestManager.Start(context.Background(), cfg)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
return
}
meta := runner.CurrentMetadata()
c.JSON(http.StatusOK, meta)
}
func (s *Server) handleBacktestPause(c *gin.Context) {
s.handleBacktestControl(c, s.backtestManager.Pause)
}
func (s *Server) handleBacktestResume(c *gin.Context) {
s.handleBacktestControl(c, s.backtestManager.Resume)
}
func (s *Server) handleBacktestStop(c *gin.Context) {
s.handleBacktestControl(c, s.backtestManager.Stop)
}
func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
var req runIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
if req.RunID == "" {
SafeBadRequest(c, "run_id is required")
return
}
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
return
}
if err := fn(req.RunID); err != nil {
SafeError(c, http.StatusBadRequest, "Failed to execute backtest operation", err)
return
}
meta, err := s.backtestManager.LoadMetadata(req.RunID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "ok"})
return
}
c.JSON(http.StatusOK, meta)
}
func (s *Server) handleBacktestLabel(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
var req labelRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
if strings.TrimSpace(req.RunID) == "" {
SafeBadRequest(c, "run_id is required")
return
}
userID := normalizeUserID(c.GetString("user_id"))
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
return
}
meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
if err != nil {
SafeInternalError(c, "Update backtest label", err)
return
}
c.JSON(http.StatusOK, meta)
}
func (s *Server) handleBacktestDelete(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
var req runIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
if strings.TrimSpace(req.RunID) == "" {
SafeBadRequest(c, "run_id is required")
return
}
userID := normalizeUserID(c.GetString("user_id"))
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
return
}
if err := s.backtestManager.Delete(req.RunID); err != nil {
SafeInternalError(c, "Delete backtest run", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func (s *Server) handleBacktestStatus(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
meta, err := s.ensureBacktestRunOwnership(runID, userID)
if writeBacktestAccessError(c, err) {
return
}
status := s.backtestManager.Status(runID)
if status != nil {
c.JSON(http.StatusOK, status)
return
}
payload := backtest.StatusPayload{
RunID: meta.RunID,
State: meta.State,
ProgressPct: meta.Summary.ProgressPct,
ProcessedBars: meta.Summary.ProcessedBars,
CurrentTime: 0,
DecisionCycle: meta.Summary.ProcessedBars,
Equity: meta.Summary.EquityLast,
UnrealizedPnL: 0,
RealizedPnL: 0,
Note: meta.Summary.LiquidationNote,
LastUpdatedIso: meta.UpdatedAt.Format(time.RFC3339),
}
c.JSON(http.StatusOK, payload)
}
func (s *Server) handleBacktestRuns(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
rawUserID := strings.TrimSpace(c.GetString("user_id"))
userID := normalizeUserID(rawUserID)
filterByUser := rawUserID != "" && rawUserID != "admin"
metas, err := s.backtestManager.ListRuns()
if err != nil {
SafeInternalError(c, "List backtest runs", err)
return
}
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state")))
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
limit := queryInt(c, "limit", 50)
offset := queryInt(c, "offset", 0)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
filtered := make([]*backtest.RunMetadata, 0, len(metas))
for _, meta := range metas {
if stateFilter != "" && !strings.EqualFold(string(meta.State), stateFilter) {
continue
}
if search != "" {
target := strings.ToLower(meta.RunID + " " + meta.Summary.DecisionTF + " " + meta.Label + " " + meta.LastError)
if !strings.Contains(target, search) {
continue
}
}
if filterByUser {
owner := strings.TrimSpace(meta.UserID)
if owner != "" && owner != userID {
continue
}
}
filtered = append(filtered, meta)
}
total := len(filtered)
start := offset
if start > total {
start = total
}
end := offset + limit
if end > total {
end = total
}
page := filtered[start:end]
c.JSON(http.StatusOK, gin.H{
"total": total,
"items": page,
})
}
func (s *Server) handleBacktestEquity(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
timeframe := c.Query("tf")
limit := queryInt(c, "limit", 1000)
points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
return
}
c.JSON(http.StatusOK, points)
}
func (s *Server) handleBacktestTrades(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
limit := queryInt(c, "limit", 1000)
events, err := s.backtestManager.LoadTrades(runID, limit)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
return
}
c.JSON(http.StatusOK, events)
}
func (s *Server) handleBacktestMetrics(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
metrics, err := s.backtestManager.GetMetrics(runID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
return
}
SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
return
}
c.JSON(http.StatusOK, metrics)
}
func (s *Server) handleBacktestTrace(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
cycle := queryInt(c, "cycle", 0)
record, err := s.backtestManager.GetTrace(runID, cycle)
if err != nil {
SafeNotFound(c, "Trace record")
return
}
c.JSON(http.StatusOK, record)
}
func (s *Server) handleBacktestDecisions(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
limit := queryInt(c, "limit", 20)
offset := queryInt(c, "offset", 0)
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
if err != nil {
SafeInternalError(c, "Load decision records", err)
return
}
c.JSON(http.StatusOK, records)
}
func (s *Server) handleBacktestExport(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
path, err := s.backtestManager.ExportRun(runID)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
return
}
defer os.Remove(path)
filename := fmt.Sprintf("%s_export.zip", runID)
c.FileAttachment(path, filename)
}
func (s *Server) handleBacktestKlines(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
symbol := c.Query("symbol")
timeframe := c.Query("timeframe")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if symbol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol is required"})
return
}
meta, err := s.ensureBacktestRunOwnership(runID, userID)
if writeBacktestAccessError(c, err) {
return
}
// Load config to get time range
cfg, err := backtest.LoadConfig(runID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "failed to load backtest config"})
return
}
// Use decision timeframe if not specified
if timeframe == "" {
timeframe = cfg.DecisionTimeframe
if timeframe == "" {
timeframe = "15m"
}
}
// Fetch klines for the backtest time range
startTime := time.Unix(cfg.StartTS, 0)
endTime := time.Unix(cfg.EndTS, 0)
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
if err != nil {
SafeInternalError(c, "Fetch klines", err)
return
}
// Convert to response format
type KlineResponse struct {
Time int64 `json:"time"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume float64 `json:"volume"`
}
result := make([]KlineResponse, len(klines))
for i, k := range klines {
result[i] = KlineResponse{
Time: k.OpenTime / 1000, // Convert to seconds for lightweight-charts
Open: k.Open,
High: k.High,
Low: k.Low,
Close: k.Close,
Volume: k.Volume,
}
}
c.JSON(http.StatusOK, gin.H{
"symbol": symbol,
"timeframe": timeframe,
"start_ts": cfg.StartTS,
"end_ts": cfg.EndTS,
"count": len(result),
"klines": result,
"run_id": meta.RunID,
})
}
func queryInt(c *gin.Context, name string, fallback int) int {
if value := c.Query(name); value != "" {
if v, err := strconv.Atoi(value); err == nil {
return v
}
}
return fallback
}
var errBacktestForbidden = errors.New("backtest run forbidden")
func normalizeUserID(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return "default"
}
return id
}
func (s *Server) ensureBacktestRunOwnership(runID, userID string) (*backtest.RunMetadata, error) {
if s.backtestManager == nil {
return nil, fmt.Errorf("backtest manager unavailable")
}
meta, err := s.backtestManager.LoadMetadata(runID)
if err != nil {
return nil, err
}
if userID == "" || userID == "admin" {
return meta, nil
}
owner := strings.TrimSpace(meta.UserID)
if owner == "" {
return meta, nil
}
if owner != userID {
return nil, errBacktestForbidden
}
return meta, nil
}
func writeBacktestAccessError(c *gin.Context, err error) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, errBacktestForbidden):
SafeForbidden(c, "No permission to access this backtest task")
case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows):
SafeNotFound(c, "Backtest task")
default:
SafeInternalError(c, "Access backtest", err)
}
return true
}
// resolveStrategyCoins fetches coins based on strategy's coin source configuration
func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]string, error) {
if strategyConfig == nil {
return nil, fmt.Errorf("strategy config is nil")
}
coinSource := strategyConfig.CoinSource
var symbols []string
symbolSet := make(map[string]bool)
// Handle empty source_type - check flags for backward compatibility
sourceType := coinSource.SourceType
if sourceType == "" {
if coinSource.UseAI500 && coinSource.UseOITop {
sourceType = "mixed"
} else if coinSource.UseAI500 {
sourceType = "ai500"
} else if coinSource.UseOITop {
sourceType = "oi_top"
} else if len(coinSource.StaticCoins) > 0 {
sourceType = "static"
} else {
return nil, fmt.Errorf("strategy has no coin source configured")
}
logger.Infof("📊 Inferred source_type=%s from flags", sourceType)
}
switch sourceType {
case "static":
for _, sym := range coinSource.StaticCoins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
case "ai500":
limit := coinSource.AI500Limit
if limit <= 0 {
limit = 30
}
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
if err != nil {
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
}
logger.Infof("📊 Got %d coins from AI500: %v", len(coins), coins)
for _, sym := range coins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
case "oi_top":
coins, err := nofxos.DefaultClient().GetOITopSymbols()
if err != nil {
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
}
limit := coinSource.OITopLimit
if limit <= 0 || limit > len(coins) {
limit = len(coins)
}
for i, sym := range coins {
if i >= limit {
break
}
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
case "mixed":
// Get from AI500
if coinSource.UseAI500 {
limit := coinSource.AI500Limit
if limit <= 0 {
limit = 30
}
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
if err != nil {
logger.Warnf("Failed to get AI500 coins: %v", err)
} else {
for _, sym := range coins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
}
}
// Get from OI Top
if coinSource.UseOITop {
coins, err := nofxos.DefaultClient().GetOITopSymbols()
if err != nil {
logger.Warnf("Failed to get OI Top coins: %v", err)
} else {
limit := coinSource.OITopLimit
if limit <= 0 || limit > len(coins) {
limit = len(coins)
}
for i, sym := range coins {
if i >= limit {
break
}
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
}
}
// Add static coins
for _, sym := range coinSource.StaticCoins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
default:
return nil, fmt.Errorf("unknown coin source type: %s", sourceType)
}
if len(symbols) == 0 {
return nil, fmt.Errorf("no coins resolved from strategy")
}
logger.Infof("📊 Final resolved symbols: %d coins - %v", len(symbols), symbols)
return symbols, nil
}
func (s *Server) resolveBacktestAIConfig(cfg *backtest.BacktestConfig, userID string) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
if s.store == nil {
return fmt.Errorf("System database not ready, cannot load AI model configuration")
}
cfg.UserID = normalizeUserID(userID)
return s.hydrateBacktestAIConfig(cfg)
}
func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
if s.store == nil {
return fmt.Errorf("System database not ready, cannot load AI model configuration")
}
cfg.UserID = normalizeUserID(cfg.UserID)
modelID := strings.TrimSpace(cfg.AIModelID)
var (
model *store.AIModel
err error
)
if modelID != "" {
model, err = s.store.AIModel().Get(cfg.UserID, modelID)
if err != nil {
return fmt.Errorf("Failed to load AI model: %w", err)
}
} else {
model, err = s.store.AIModel().GetDefault(cfg.UserID)
if err != nil {
return fmt.Errorf("No available AI model found: %w", err)
}
cfg.AIModelID = model.ID
}
if !model.Enabled {
return fmt.Errorf("AI model %s is not enabled yet", model.Name)
}
apiKey := strings.TrimSpace(string(model.APIKey))
if apiKey == "" {
return fmt.Errorf("AI model %s is missing API Key, please configure it in the system first", model.Name)
}
provider := strings.ToLower(strings.TrimSpace(model.Provider))
// Ensure provider is never empty or "inherit" - infer from model name if needed
if provider == "" || provider == "inherit" {
modelNameLower := strings.ToLower(model.Name)
if strings.Contains(modelNameLower, "claude") || strings.Contains(modelNameLower, "anthropic") {
provider = "anthropic"
} else if strings.Contains(modelNameLower, "gpt") || strings.Contains(modelNameLower, "openai") {
provider = "openai"
} else if strings.Contains(modelNameLower, "gemini") || strings.Contains(modelNameLower, "google") {
provider = "google"
} else if strings.Contains(modelNameLower, "deepseek") {
provider = "deepseek"
} else if strings.Contains(modelNameLower, "minimax") {
provider = "minimax"
} else if model.CustomAPIURL != "" {
provider = "custom"
} else {
provider = "openai" // default fallback
}
logger.Infof("📊 Inferred AI provider '%s' from model name '%s'", provider, model.Name)
}
cfg.AICfg.Provider = provider
cfg.AICfg.APIKey = apiKey
cfg.AICfg.BaseURL = strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName)
if cfg.AICfg.Model == "" {
cfg.AICfg.Model = modelName
}
cfg.AICfg.Model = strings.TrimSpace(cfg.AICfg.Model)
if cfg.AICfg.Provider == "custom" {
if cfg.AICfg.BaseURL == "" {
return fmt.Errorf("Custom AI model requires API URL configuration")
}
if cfg.AICfg.Model == "" {
return fmt.Errorf("Custom AI model requires model name configuration")
}
}
return nil
}
+6 -12
View File
@@ -6,7 +6,6 @@ import (
"net"
"net/http"
"nofx/auth"
"nofx/backtest"
"nofx/crypto"
"nofx/logger"
"nofx/manager"
@@ -23,14 +22,13 @@ type Server struct {
traderManager *manager.TraderManager
store *store.Store
cryptoHandler *CryptoHandler
backtestManager *backtest.Manager
httpServer *http.Server
port int
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
}
// NewServer Creates API server
func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoService *crypto.CryptoService, backtestManager *backtest.Manager, port int) *Server {
func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoService *crypto.CryptoService, port int) *Server {
// Set to Release mode (reduce log output)
gin.SetMode(gin.ReleaseMode)
@@ -43,12 +41,11 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
cryptoHandler := NewCryptoHandler(cryptoService)
s := &Server{
router: router,
traderManager: traderManager,
store: st,
cryptoHandler: cryptoHandler,
backtestManager: backtestManager,
port: port,
router: router,
traderManager: traderManager,
store: st,
cryptoHandler: cryptoHandler,
port: port,
}
// Setup routes
@@ -342,9 +339,6 @@ Returns the most recent AI decision for each symbol analyzed in the last scan cy
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
s.handleStatistics)
// Backtest routes
backtest := protected.Group("/backtest")
s.registerBacktestRoutes(backtest)
}
}
}
-267
View File
@@ -1,267 +0,0 @@
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
AccumulatedFee float64 // Total fees paid (opening + any additions)
}
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)
pos.AccumulatedFee = fee // Track opening fee
} else {
if leverage != pos.Leverage {
// Use weighted average leverage (approximate)
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)
pos.AccumulatedFee += fee // Add to accumulated fee for position additions
}
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)
closeNotional := execPrice * quantity // Notional at close price (for fee calculation)
closingFee := closeNotional * acc.feeRate
// Calculate proportional values based on the portion being closed
closePortion := quantity / pos.Quantity
openingFeePortion := pos.AccumulatedFee * closePortion
totalFee := closingFee + openingFeePortion
realized := realizedPnL(pos, quantity, execPrice)
marginPortion := pos.Margin * closePortion
// BUG FIX: Calculate notional portion based on ENTRY price, not close price
// pos.Notional tracks the total notional at entry, so we must subtract proportionally
entryNotionalPortion := pos.Notional * closePortion
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
acc.cash += marginPortion + realized - closingFee
// But for realized P&L tracking, we include both fees
acc.realizedPnL += realized - totalFee
pos.Quantity -= quantity
pos.Notional -= entryNotionalPortion // FIX: Use entry notional portion, not close notional
pos.Margin -= marginPortion
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
if pos.Quantity <= epsilon {
acc.removePosition(pos)
}
// Return total fee (opening + closing) so caller can calculate accurate P&L
return realized, totalFee, 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 restores account state from checkpoint.
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,
AccumulatedFee: snap.AccumulatedFee,
}
key := positionKey(pos.Symbol, pos.Side)
acc.positions[key] = pos
}
}
-72
View File
@@ -1,72 +0,0 @@
package backtest
import (
"fmt"
"strings"
"nofx/mcp"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
)
// configureMCPClient creates/clones an MCP client based on configuration (returns mcp.AIClient interface).
// Note: mcp.New() returns an interface type; here we convert to concrete implementation before copying to avoid concurrent shared state.
func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, error) {
providerName := strings.ToLower(strings.TrimSpace(cfg.AICfg.Provider))
// Inherit base client
if providerName == "" || providerName == "inherit" || providerName == "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
}
// Custom provider uses cloned base
if providerName == "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
}
// Create client via registry
client := mcp.NewAIClientByProvider(providerName)
if client == nil {
return nil, fmt.Errorf("unsupported ai provider %s", cfg.AICfg.Provider)
}
if cfg.AICfg.APIKey == "" {
return nil, fmt.Errorf("%s provider requires api key", providerName)
}
// Payment providers ignore custom URL
switch providerName {
case "blockrun-base", "blockrun-sol", "claw402":
client.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
default:
client.SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
}
return client, nil
}
// cloneBaseClient copies the base client to avoid shared mutable state.
// Uses the ClientEmbedder interface to extract the underlying *mcp.Client
// from any provider type that embeds it.
func cloneBaseClient(base mcp.AIClient) *mcp.Client {
if embedder, ok := base.(mcp.ClientEmbedder); ok {
if inner := embedder.BaseClient(); inner != nil {
cp := *inner
return &cp
}
}
if c, ok := base.(*mcp.Client); ok {
cp := *c
return &cp
}
// Fall back to a new default client
return mcp.NewClient().(*mcp.Client)
}
-168
View File
@@ -1,168 +0,0 @@
package backtest
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"nofx/kernel"
"nofx/market"
)
type cachedDecision struct {
Key string `json:"key"`
PromptVariant string `json:"prompt_variant"`
Timestamp int64 `json:"ts"`
Decision *kernel.FullDecision `json:"decision"`
}
// AICache persists AI decisions for repeated backtesting or replay.
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) (*kernel.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 *kernel.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 *kernel.FullDecision) *kernel.FullDecision {
if src == nil {
return nil
}
data, err := json.Marshal(src)
if err != nil {
return nil
}
var dst kernel.FullDecision
if err := json.Unmarshal(data, &dst); err != nil {
return nil
}
return &dst
}
func computeCacheKey(ctx *kernel.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 kernel.AccountInfo `json:"account"`
Positions []kernel.PositionInfo `json:"positions"`
CandidateCoins []kernel.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
}
-285
View File
@@ -1,285 +0,0 @@
package backtest
import (
"fmt"
"strings"
"time"
"nofx/market"
"nofx/store"
)
// AIConfig defines the AI client configuration used in backtesting.
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 describes the input configuration for a backtest run.
type BacktestConfig struct {
RunID string `json:"run_id"`
UserID string `json:"user_id,omitempty"`
AIModelID string `json:"ai_model_id,omitempty"`
StrategyID string `json:"strategy_id,omitempty"` // Optional: use saved strategy from Strategy Studio
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"`
// Internal: loaded strategy config (set by Manager when StrategyID is provided)
loadedStrategy *store.StrategyConfig `json:"-"`
}
// Validate performs validity checks on the configuration and fills in default values.
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 returns the backtest interval 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 uses the open price of the next bar for execution.
FillPolicyNextOpen = "next_open"
// FillPolicyBarVWAP uses the approximate VWAP of the current bar for execution.
FillPolicyBarVWAP = "bar_vwap"
// FillPolicyMidPrice uses the mid-price (high+low)/2 for execution.
FillPolicyMidPrice = "mid"
)
func validateFillPolicy(policy string) error {
switch policy {
case FillPolicyNextOpen, FillPolicyBarVWAP, FillPolicyMidPrice:
return nil
default:
return fmt.Errorf("unsupported fill_policy '%s'", policy)
}
}
// SetLoadedStrategy sets the loaded strategy config from database.
func (cfg *BacktestConfig) SetLoadedStrategy(strategy *store.StrategyConfig) {
cfg.loadedStrategy = strategy
}
// ToStrategyConfig converts BacktestConfig to StrategyConfig for unified prompt generation.
// This ensures backtest uses the same StrategyEngine logic as live trading.
// If a strategy was loaded from database (via StrategyID), it will be used with overrides.
func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
// If a strategy was loaded from database, use it with some overrides
if cfg.loadedStrategy != nil {
result := *cfg.loadedStrategy // Make a copy
// Override coin source with backtest symbols (backtest-specified pairs take priority)
if len(cfg.Symbols) > 0 {
result.CoinSource.SourceType = "static"
result.CoinSource.StaticCoins = cfg.Symbols
result.CoinSource.UseAI500 = false
result.CoinSource.UseOITop = false
}
// Override timeframes with backtest config
if len(cfg.Timeframes) > 0 {
result.Indicators.Klines.SelectedTimeframes = cfg.Timeframes
result.Indicators.Klines.PrimaryTimeframe = cfg.Timeframes[0]
if len(cfg.Timeframes) > 1 {
result.Indicators.Klines.LongerTimeframe = cfg.Timeframes[len(cfg.Timeframes)-1]
}
result.Indicators.Klines.EnableMultiTimeframe = len(cfg.Timeframes) > 1
}
// Override leverage with backtest config
if cfg.Leverage.BTCETHLeverage > 0 {
result.RiskControl.BTCETHMaxLeverage = cfg.Leverage.BTCETHLeverage
}
if cfg.Leverage.AltcoinLeverage > 0 {
result.RiskControl.AltcoinMaxLeverage = cfg.Leverage.AltcoinLeverage
}
// Override custom prompt if provided in backtest config
if cfg.CustomPrompt != "" {
result.CustomPrompt = cfg.CustomPrompt
}
return &result
}
// Fallback: build strategy config from backtest config (original logic)
primaryTF := "5m"
longerTF := "4h"
if len(cfg.Timeframes) > 0 {
primaryTF = cfg.Timeframes[0]
}
if len(cfg.Timeframes) > 1 {
longerTF = cfg.Timeframes[len(cfg.Timeframes)-1]
}
return &store.StrategyConfig{
CoinSource: store.CoinSourceConfig{
SourceType: "static",
StaticCoins: cfg.Symbols,
UseAI500: false,
AI500Limit: len(cfg.Symbols),
UseOITop: false,
OITopLimit: 0,
},
Indicators: store.IndicatorConfig{
Klines: store.KlineConfig{
PrimaryTimeframe: primaryTF,
PrimaryCount: 30,
LongerTimeframe: longerTF,
LongerCount: 10,
EnableMultiTimeframe: len(cfg.Timeframes) > 1,
SelectedTimeframes: cfg.Timeframes,
},
EnableRawKlines: true,
EnableEMA: true,
EnableMACD: true,
EnableRSI: true,
EnableATR: true,
EnableVolume: true,
EnableOI: true,
EnableFundingRate: true,
EMAPeriods: []int{20, 50},
RSIPeriods: []int{7, 14},
ATRPeriods: []int{14},
},
CustomPrompt: cfg.CustomPrompt,
RiskControl: store.RiskControlConfig{
MaxPositions: 3,
BTCETHMaxLeverage: cfg.Leverage.BTCETHLeverage,
AltcoinMaxLeverage: cfg.Leverage.AltcoinLeverage,
BTCETHMaxPositionValueRatio: 5.0,
AltcoinMaxPositionValueRatio: 1.0,
MaxMarginUsage: 0.9,
MinPositionSize: 12,
MinRiskRewardRatio: 3.0,
MinConfidence: 75,
},
}
}
-206
View File
@@ -1,206 +0,0 @@
package backtest
import (
"fmt"
"sort"
"time"
"nofx/market"
)
type timeframeSeries struct {
klines []market.Kline
closeTimes []int64
}
type symbolSeries struct {
byTF map[string]*timeframeSeries
}
// DataFeed manages historical kline data and provides time-progressive snapshots for backtesting.
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 used for auxiliary indicators
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
}
// Generate backtest progress timeline using the primary timeframe of the first symbol
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)
// Align other symbols; report error early if data is missing
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 {
// Bounds check to prevent panic
if index < 0 || index >= len(df.decisionTimes) {
return 0
}
return df.decisionTimes[index]
}
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
// Nil checks to prevent panic
ss, ok := df.symbolSeries[symbol]
if !ok || ss == nil {
return nil
}
series, ok := ss.byTF[tf]
if !ok || series == nil {
return nil
}
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
View File
@@ -1,95 +0,0 @@
package backtest
import (
"math"
"sort"
"nofx/market"
)
// ResampleEquity resamples equity curve based on timeframe.
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 limits the number of data points within a given range (uniform sampling).
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 applies uniform sampling to trade events.
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 ensures timestamps are sorted in ascending order.
func AlignEquityTimestamps(points []EquityPoint) []EquityPoint {
sort.Slice(points, func(i, j int) bool {
return points[i].Timestamp < points[j].Timestamp
})
return points
}
-100
View File
@@ -1,100 +0,0 @@
package backtest
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
const (
lockFileName = "lock"
lockHeartbeatInterval = 2 * time.Second
lockStaleAfter = 10 * time.Second
)
// RunLockInfo represents the lock file structure for a backtest run.
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
View File
@@ -1,493 +0,0 @@
package backtest
import (
"context"
"errors"
"fmt"
"nofx/logger"
"os"
"sort"
"strings"
"sync"
"nofx/mcp"
"nofx/store"
)
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 {
logger.Infof("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 {
logger.Infof("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 configuration missing key and no resolver configured")
}
return nil
}
return resolver(cfg)
}
func (m *Manager) GetTrace(runID string, cycle int) (*store.DecisionRecord, error) {
return LoadDecisionTrace(runID, cycle)
}
func (m *Manager) ExportRun(runID string) (string, error) {
return CreateRunExport(runID)
}
// RestoreRuns scans the backtests directory and restores metadata for existing runs (service restart scenario).
func (m *Manager) RestoreRuns() error {
runIDs, err := LoadRunIDs()
if err != nil {
return err
}
for _, runID := range runIDs {
meta, err := LoadRunMetadata(runID)
if err != nil {
logger.Infof("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 {
logger.Infof("failed to cleanup lock for %s: %v", runID, err)
}
meta.State = RunStatePaused
if err := SaveRunMetadata(meta); err != nil {
logger.Infof("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 {
logger.Infof("failed to sync index for %s: %v", runID, err)
}
}
return nil
}
// RestoreRunsFromDisk retains the old method name for backward compatibility.
func (m *Manager) RestoreRunsFromDisk() error {
return m.RestoreRuns()
}
-239
View File
@@ -1,239 +0,0 @@
package backtest
import (
"fmt"
"math"
"strings"
)
// CalculateMetrics reads existing logs and calculates summary metrics. state is optional, used to supplement information not yet persisted.
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
}
// sharpeRatio calculates the Sharpe ratio from equity points.
// Uses sample standard deviation (n-1) and annualizes assuming ~252 trading days.
// Returns math.NaN() for edge cases (insufficient data, zero variance).
func sharpeRatio(points []EquityPoint) float64 {
// Need at least 10 data points for meaningful Sharpe calculation
const minDataPoints = 10
if len(points) < minDataPoints {
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) < minDataPoints-1 {
return 0
}
// Calculate mean return
mean := 0.0
for _, r := range returns {
mean += r
}
mean /= float64(len(returns))
// Calculate sample variance (using n-1 for unbiased estimator)
variance := 0.0
for _, r := range returns {
diff := r - mean
variance += diff * diff
}
if len(returns) > 1 {
variance /= float64(len(returns) - 1)
}
std := math.Sqrt(variance)
if std < 1e-10 {
// Zero or near-zero volatility - return 0 instead of infinity/NaN
return 0
}
// Calculate Sharpe ratio (assuming risk-free rate = 0 for crypto)
// Annualize by multiplying by sqrt(periods per year)
// Assuming each equity point represents ~1 hour, we have ~8760 periods/year
// For conservative estimate, use sqrt(252) as if daily returns
periodsPerYear := 252.0
annualizationFactor := math.Sqrt(periodsPerYear)
sharpe := (mean / std) * annualizationFactor
return sharpe
}
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 {
// No losses but have wins - use a high but reasonable cap
metrics.ProfitFactor = 100.0
}
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 = ""
}
}
-40
View File
@@ -1,40 +0,0 @@
package backtest
import (
"database/sql"
"fmt"
"strings"
)
var persistenceDB *sql.DB
var dbIsPostgres bool
// UseDatabase enables database-backed persistence for all backtest storage operations.
// If isPostgres is true, queries will use $1, $2... placeholders instead of ?
func UseDatabase(db *sql.DB) {
persistenceDB = db
}
// UseDatabaseWithType enables database-backed persistence with explicit type.
func UseDatabaseWithType(db *sql.DB, isPostgres bool) {
persistenceDB = db
dbIsPostgres = isPostgres
}
func usingDB() bool {
return persistenceDB != nil
}
// convertQuery converts ? placeholders to $1, $2, etc. for PostgreSQL
func convertQuery(query string) string {
if !dbIsPostgres {
return query
}
result := query
index := 1
for strings.Contains(result, "?") {
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
index++
}
return result
}
-160
View File
@@ -1,160 +0,0 @@
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
View File
@@ -1,101 +0,0 @@
package backtest
import (
"nofx/logger"
"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 {
logger.Infof("failed to prune run %s: %v", runID, err)
continue
}
delete(idx.Runs, runID)
}
if err := saveRunIndex(idx); err != nil {
logger.Infof("failed to save index after pruning: %v", err)
}
}
func enforceRetentionDB(maxRuns int) {
finalStates := []RunState{
RunStateCompleted,
RunStateStopped,
RunStateFailed,
RunStateLiquidated,
}
query := convertQuery(`
SELECT run_id FROM backtest_runs
WHERE state IN (?, ?, ?, ?)
ORDER BY updated_at DESC
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 {
logger.Infof("failed to remove run %s: %v", runID, err)
continue
}
if err := os.RemoveAll(runDir(runID)); err != nil {
logger.Infof("failed to remove run dir %s: %v", runID, err)
}
}
}
-341
View File
@@ -1,341 +0,0 @@
package backtest
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"nofx/kernel"
"nofx/logger"
"nofx/mcp"
)
var (
errBacktestCompleted = errors.New("backtest completed")
errLiquidated = errors.New("account liquidated")
)
const (
metricsWriteInterval = 5 * time.Second
aiDecisionMaxRetries = 3
)
// Runner encapsulates the lifecycle of a single backtest run.
type Runner struct {
cfg BacktestConfig
feed *DataFeed
account *BacktestAccount
strategyEngine *kernel.StrategyEngine
decisionLogDir string
mcpClient mcp.AIClient
statusMu sync.RWMutex
status RunState
stateMu sync.RWMutex
state *BacktestState
pauseCh chan struct{}
resumeCh chan struct{}
stopCh chan struct{}
doneCh chan struct{}
err error
errMu sync.RWMutex
lastError string
lastCheckpoint time.Time
createdAt time.Time
lastMetricsWrite time.Time
aiCache *AICache
cachePath string
lockInfo *RunLockInfo
lockStop chan struct{}
lockStopOnce sync.Once // Ensures lockStop is closed only once
}
// NewRunner constructs a backtest runner.
func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) {
if err := ensureRunDir(cfg.RunID); err != nil {
return nil, err
}
client, err := configureMCPClient(cfg, mcpClient)
if err != nil {
return nil, err
}
feed, err := NewDataFeed(cfg)
if err != nil {
return nil, err
}
if err := os.MkdirAll(decisionLogDir(cfg.RunID), 0o755); err != nil {
return nil, err
}
dLogDir := decisionLogDir(cfg.RunID)
account := NewBacktestAccount(cfg.InitialBalance, cfg.FeeBps, cfg.SlippageBps)
createdAt := time.Now().UTC()
state := &BacktestState{
Positions: make(map[string]PositionSnapshot),
Cash: account.Cash(),
Equity: cfg.InitialBalance,
UnrealizedPnL: 0,
RealizedPnL: 0,
MaxEquity: cfg.InitialBalance,
MinEquity: cfg.InitialBalance,
MaxDrawdownPct: 0,
LastUpdate: createdAt,
}
var (
aiCache *AICache
cachePath string
)
if cfg.CacheAI || cfg.ReplayOnly || cfg.SharedAICachePath != "" {
cachePath = cfg.SharedAICachePath
if cachePath == "" {
cachePath = filepath.Join(runDir(cfg.RunID), "ai_cache.json")
}
cache, err := LoadAICache(cachePath)
if err != nil {
return nil, fmt.Errorf("load ai cache: %w", err)
}
aiCache = cache
}
// Create strategy engine from backtest config for unified prompt generation
strategyConfig := cfg.ToStrategyConfig()
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
r := &Runner{
cfg: cfg,
feed: feed,
account: account,
strategyEngine: strategyEngine,
decisionLogDir: dLogDir,
mcpClient: client,
status: RunStateCreated,
state: state,
pauseCh: make(chan struct{}, 1),
resumeCh: make(chan struct{}, 1),
stopCh: make(chan struct{}, 1),
doneCh: make(chan struct{}),
createdAt: createdAt,
aiCache: aiCache,
cachePath: cachePath,
}
if err := r.initLock(); err != nil {
return nil, err
}
return r, nil
}
func (r *Runner) initLock() error {
if r.cfg.RunID == "" {
return fmt.Errorf("run_id required for lock")
}
info, err := acquireRunLock(r.cfg.RunID)
if err != nil {
return err
}
r.lockInfo = info
r.lockStop = make(chan struct{})
go r.lockHeartbeatLoop()
return nil
}
func (r *Runner) lockHeartbeatLoop() {
ticker := time.NewTicker(lockHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := updateRunLockHeartbeat(r.lockInfo); err != nil {
logger.Infof("failed to update lock heartbeat for %s: %v", r.cfg.RunID, err)
}
case <-r.lockStop:
return
}
}
}
func (r *Runner) releaseLock() {
// Use sync.Once to ensure channel is closed exactly once, preventing panic on double-close
r.lockStopOnce.Do(func() {
if r.lockStop != nil {
close(r.lockStop)
}
})
if err := deleteRunLock(r.cfg.RunID); err != nil {
logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err)
}
r.lockInfo = nil
}
// Start launches the backtest loop.
func (r *Runner) Start(ctx context.Context) error {
r.statusMu.Lock()
if r.status != RunStateCreated && r.status != RunStatePaused {
r.statusMu.Unlock()
return fmt.Errorf("cannot start runner in state %s", r.status)
}
r.status = RunStateRunning
r.statusMu.Unlock()
go r.loop(ctx)
return nil
}
// PersistMetadata writes the current snapshot to run.json.
func (r *Runner) PersistMetadata() {
r.persistMetadata()
}
func (r *Runner) setLastError(err error) {
r.errMu.Lock()
defer r.errMu.Unlock()
if err == nil {
r.lastError = ""
return
}
r.lastError = err.Error()
}
func (r *Runner) lastErrorString() string {
r.errMu.RLock()
defer r.errMu.RUnlock()
return r.lastError
}
// CurrentMetadata returns the metadata corresponding to the current in-memory state.
func (r *Runner) CurrentMetadata() *RunMetadata {
state := r.snapshotState()
meta := r.buildMetadata(state, r.Status())
meta.CreatedAt = r.createdAt
meta.UpdatedAt = state.LastUpdate
return meta
}
func (r *Runner) Pause() {
select {
case r.pauseCh <- struct{}{}:
default:
}
}
func (r *Runner) Resume() {
select {
case r.resumeCh <- struct{}{}:
default:
}
}
func (r *Runner) Stop() {
select {
case r.stopCh <- struct{}{}:
default:
}
}
func (r *Runner) Wait() error {
<-r.doneCh
r.statusMu.RLock()
defer r.statusMu.RUnlock()
return r.err
}
// Status returns the current run state.
func (r *Runner) Status() RunState {
r.statusMu.RLock()
defer r.statusMu.RUnlock()
return r.status
}
// StatusPayload builds the status response for the API.
func (r *Runner) StatusPayload() StatusPayload {
snapshot := r.snapshotState()
progress := progressPercent(snapshot, r.cfg)
// Build position statuses with unrealized P&L
positions := make([]PositionStatus, 0, len(snapshot.Positions))
for _, pos := range snapshot.Positions {
if pos.Quantity <= 0 {
continue
}
// Get mark price from feed if available
markPrice := pos.AvgPrice // fallback to entry price
if r.feed != nil && snapshot.BarTimestamp > 0 {
if md, _, err := r.feed.BuildMarketData(snapshot.BarTimestamp); err == nil {
if data, ok := md[pos.Symbol]; ok {
markPrice = data.CurrentPrice
}
}
}
// Calculate unrealized P&L
var unrealizedPnL float64
if pos.Side == "long" {
unrealizedPnL = (markPrice - pos.AvgPrice) * pos.Quantity
} else {
unrealizedPnL = (pos.AvgPrice - markPrice) * pos.Quantity
}
// Calculate P&L percentage based on margin
pnlPct := 0.0
if pos.MarginUsed > 0 {
pnlPct = (unrealizedPnL / pos.MarginUsed) * 100
}
positions = append(positions, PositionStatus{
Symbol: pos.Symbol,
Side: pos.Side,
Quantity: pos.Quantity,
EntryPrice: pos.AvgPrice,
MarkPrice: markPrice,
Leverage: pos.Leverage,
UnrealizedPnL: unrealizedPnL,
UnrealizedPnLPct: pnlPct,
MarginUsed: pos.MarginUsed,
})
}
payload := StatusPayload{
RunID: r.cfg.RunID,
State: r.Status(),
ProgressPct: progress,
ProcessedBars: snapshot.BarIndex,
CurrentTime: snapshot.BarTimestamp,
DecisionCycle: snapshot.DecisionCycle,
Equity: snapshot.Equity,
UnrealizedPnL: snapshot.UnrealizedPnL,
RealizedPnL: snapshot.RealizedPnL,
Positions: positions,
Note: snapshot.LiquidationNote,
LastError: r.lastErrorString(),
LastUpdatedIso: snapshot.LastUpdate.UTC().Format(time.RFC3339),
}
return payload
}
func (r *Runner) snapshotState() BacktestState {
r.stateMu.RLock()
defer r.stateMu.RUnlock()
copyState := *r.state
copyState.Positions = make(map[string]PositionSnapshot, len(r.state.Positions))
for k, v := range r.state.Positions {
copyState.Positions[k] = v
}
return copyState
}
-563
View File
@@ -1,563 +0,0 @@
package backtest
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
)
func (r *Runner) loop(ctx context.Context) {
defer close(r.doneCh)
for {
select {
case <-ctx.Done():
r.handleStop(fmt.Errorf("context canceled: %w", ctx.Err()))
return
case <-r.stopCh:
r.handleStop(nil)
return
case <-r.pauseCh:
r.handlePause()
<-r.resumeCh
r.resumeFromPause()
default:
}
err := r.stepOnce()
if errors.Is(err, errBacktestCompleted) {
r.handleCompletion()
return
}
if errors.Is(err, errLiquidated) {
r.handleLiquidation()
return
}
if err != nil {
r.handleFailure(err)
return
}
}
}
func (r *Runner) stepOnce() error {
state := r.snapshotState()
if state.BarIndex >= r.feed.DecisionBarCount() {
return errBacktestCompleted
}
ts := r.feed.DecisionTimestamp(state.BarIndex)
marketData, multiTF, err := r.feed.BuildMarketData(ts)
if err != nil {
return err
}
priceMap := make(map[string]float64, len(marketData))
for symbol, data := range marketData {
priceMap[symbol] = data.CurrentPrice
}
callCount := state.DecisionCycle + 1
shouldDecide := r.shouldTriggerDecision(state.BarIndex)
var (
record *store.DecisionRecord
decisionActions []store.DecisionAction
tradeEvents = make([]TradeEvent, 0)
execLog []string
hadError bool
)
decisionAttempted := shouldDecide
if shouldDecide {
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
if err != nil {
// Defensive nil check to prevent panic if buildDecisionContext returns error with nil record
if rec != nil {
rec.Success = false
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
_ = r.logDecision(rec)
}
return err
}
record = rec
var (
fullDecision *kernel.FullDecision
fromCache bool
cacheKey string
)
if r.aiCache != nil {
if key, err := computeCacheKey(ctx, r.cfg.PromptVariant, ts); err == nil {
cacheKey = key
if cached, ok := r.aiCache.Get(cacheKey); ok {
fullDecision = cached
fromCache = true
} else if r.cfg.ReplayOnly {
decisionErr := fmt.Errorf("replay_only enabled but cache miss at %d", ts)
record.Success = false
record.ErrorMessage = fmt.Sprintf("cached decision not found for ts=%d", ts)
_ = r.logDecision(record)
return decisionErr
}
} else {
logger.Infof("failed to compute ai cache key: %v", err)
}
}
if !fromCache {
fd, err := r.invokeAIWithRetry(ctx)
if err != nil {
decisionAttempted = true
hadError = true
record.Success = false
record.ErrorMessage = fmt.Sprintf("AI decision failed: %v", err)
execLog = append(execLog, fmt.Sprintf("⚠️ AI decision failed: %v", err))
r.setLastError(err)
} else {
fullDecision = fd
if r.cfg.CacheAI && r.aiCache != nil && cacheKey != "" {
if err := r.aiCache.Put(cacheKey, r.cfg.PromptVariant, ts, fullDecision); err != nil {
logger.Infof("failed to persist ai cache for %s: %v", r.cfg.RunID, err)
}
}
}
}
if fullDecision != nil {
r.fillDecisionRecord(record, fullDecision)
sorted := sortDecisionsByPriority(fullDecision.Decisions)
prevLogs := execLog
decisionActions = make([]store.DecisionAction, 0, len(sorted))
execLog = make([]string, 0, len(sorted)+len(prevLogs))
if len(prevLogs) > 0 {
execLog = append(execLog, prevLogs...)
}
for _, dec := range sorted {
actionRecord, trades, logEntry, execErr := r.executeDecision(dec, priceMap, ts, callCount)
if execErr != nil {
actionRecord.Success = false
actionRecord.Error = execErr.Error()
hadError = true
execLog = append(execLog, fmt.Sprintf("❌ %s %s: %v", dec.Symbol, dec.Action, execErr))
} else {
actionRecord.Success = true
execLog = append(execLog, fmt.Sprintf("✓ %s %s", dec.Symbol, dec.Action))
}
if len(trades) > 0 {
tradeEvents = append(tradeEvents, trades...)
}
if logEntry != "" {
execLog = append(execLog, logEntry)
}
decisionActions = append(decisionActions, actionRecord)
}
}
}
cycleForLog := state.DecisionCycle
if decisionAttempted {
cycleForLog = callCount
}
liquidationEvents, liquidationNote, err := r.checkLiquidation(ts, priceMap, cycleForLog)
if err != nil {
if record != nil {
record.Success = false
record.ErrorMessage = err.Error()
_ = r.logDecision(record)
}
return err
}
if len(liquidationEvents) > 0 {
hadError = true
tradeEvents = append(tradeEvents, liquidationEvents...)
if record != nil {
execLog = append(execLog, fmt.Sprintf("⚠️ Forced liquidation: %s", liquidationNote))
}
}
if record != nil {
record.Decisions = decisionActions
record.ExecutionLog = execLog
record.Success = !hadError && liquidationNote == ""
if liquidationNote != "" {
record.ErrorMessage = liquidationNote
}
}
equity, unrealized, _ := r.account.TotalEquity(priceMap)
marginUsed := r.totalMarginUsed()
r.updateState(ts, equity, unrealized, marginUsed, priceMap, decisionAttempted)
snapshot := r.snapshotState()
drawdownPct := 0.0
if snapshot.MaxEquity > 0 {
drawdownPct = ((snapshot.MaxEquity - snapshot.Equity) / snapshot.MaxEquity) * 100
}
equityPoint := EquityPoint{
Timestamp: ts,
Equity: snapshot.Equity,
Available: snapshot.Cash,
PnL: snapshot.Equity - r.account.InitialBalance(),
PnLPct: ((snapshot.Equity - r.account.InitialBalance()) / r.account.InitialBalance()) * 100,
DrawdownPct: drawdownPct,
Cycle: snapshot.DecisionCycle,
}
if err := appendEquityPoint(r.cfg.RunID, equityPoint); err != nil {
return err
}
for _, evt := range tradeEvents {
if err := appendTradeEvent(r.cfg.RunID, evt); err != nil {
return err
}
}
if record != nil {
if err := r.logDecision(record); err != nil {
return err
}
}
if err := saveProgress(r.cfg.RunID, &snapshot, &r.cfg); err != nil {
return err
}
if err := r.maybeCheckpoint(); err != nil {
return err
}
r.persistMetadata()
r.persistMetrics(false)
if !hadError && liquidationNote == "" {
r.setLastError(nil)
}
if snapshot.Liquidated {
return errLiquidated
}
return nil
}
func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*kernel.Context, *store.DecisionRecord, error) {
equity, unrealized, _ := r.account.TotalEquity(priceMap)
available := r.account.Cash()
marginUsed := r.totalMarginUsed()
marginPct := 0.0
if equity > 0 {
marginPct = (marginUsed / equity) * 100
}
accountInfo := kernel.AccountInfo{
TotalEquity: equity,
AvailableBalance: available,
TotalPnL: equity - r.account.InitialBalance(),
TotalPnLPct: ((equity - r.account.InitialBalance()) / r.account.InitialBalance()) * 100,
MarginUsed: marginUsed,
MarginUsedPct: marginPct,
PositionCount: len(r.account.Positions()),
}
positions := r.convertPositions(priceMap)
// Get candidate coins from strategy engine (includes source info)
candidateCoins, err := r.strategyEngine.GetCandidateCoins()
if err != nil {
// Fallback to simple list if strategy engine fails
candidateCoins = make([]kernel.CandidateCoin, 0, len(r.cfg.Symbols))
for _, sym := range r.cfg.Symbols {
candidateCoins = append(candidateCoins, kernel.CandidateCoin{Symbol: sym, Sources: []string{"backtest"}})
}
}
runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000)
ctx := &kernel.Context{
CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: runtime,
CallCount: callCount,
Account: accountInfo,
Positions: positions,
CandidateCoins: candidateCoins,
PromptVariant: r.cfg.PromptVariant,
MarketDataMap: marketData,
MultiTFMarket: multiTF,
BTCETHLeverage: r.cfg.Leverage.BTCETHLeverage,
AltcoinLeverage: r.cfg.Leverage.AltcoinLeverage,
Timeframes: r.cfg.Timeframes,
}
// Fetch quantitative data if enabled in strategy (uses current data as approximation)
strategyConfig := r.strategyEngine.GetConfig()
if strategyConfig.Indicators.EnableQuantData {
// Collect symbols to query (candidate coins + position coins)
symbolSet := make(map[string]bool)
for _, sym := range r.cfg.Symbols {
symbolSet[sym] = true
}
for _, pos := range positions {
symbolSet[pos.Symbol] = true
}
symbols := make([]string, 0, len(symbolSet))
for sym := range symbolSet {
symbols = append(symbols, sym)
}
ctx.QuantDataMap = r.strategyEngine.FetchQuantDataBatch(symbols)
if len(ctx.QuantDataMap) > 0 {
logger.Infof("📊 Backtest: fetched quant data for %d symbols", len(ctx.QuantDataMap))
}
}
// Fetch OI ranking data if enabled in strategy (uses current data as approximation)
if strategyConfig.Indicators.EnableOIRanking {
ctx.OIRankingData = r.strategyEngine.FetchOIRankingData()
if ctx.OIRankingData != nil {
logger.Infof("📊 Backtest: OI ranking data ready: %d top, %d low positions",
len(ctx.OIRankingData.TopPositions), len(ctx.OIRankingData.LowPositions))
}
}
// Fetch NetFlow ranking data if enabled in strategy
if strategyConfig.Indicators.EnableNetFlowRanking {
ctx.NetFlowRankingData = r.strategyEngine.FetchNetFlowRankingData()
if ctx.NetFlowRankingData != nil {
logger.Infof("💰 Backtest: NetFlow ranking data ready: inst_in=%d, inst_out=%d",
len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
}
}
// Fetch Price ranking data if enabled in strategy
if strategyConfig.Indicators.EnablePriceRanking {
ctx.PriceRankingData = r.strategyEngine.FetchPriceRankingData()
if ctx.PriceRankingData != nil {
logger.Infof("📈 Backtest: Price ranking data ready for %d durations",
len(ctx.PriceRankingData.Durations))
}
}
record := &store.DecisionRecord{
AccountState: store.AccountSnapshot{
TotalBalance: accountInfo.TotalEquity,
AvailableBalance: accountInfo.AvailableBalance,
TotalUnrealizedProfit: unrealized,
PositionCount: accountInfo.PositionCount,
MarginUsedPct: accountInfo.MarginUsedPct,
},
CandidateCoins: make([]string, 0, len(candidateCoins)),
Positions: r.snapshotPositions(priceMap),
}
for _, coin := range candidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
}
record.Timestamp = time.UnixMilli(ts).UTC()
return ctx, record, nil
}
func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *kernel.FullDecision) {
record.InputPrompt = full.UserPrompt
record.CoTTrace = full.CoTTrace
if len(full.Decisions) > 0 {
if data, err := json.MarshalIndent(full.Decisions, "", " "); err == nil {
record.DecisionJSON = string(data)
}
}
}
func (r *Runner) invokeAIWithRetry(ctx *kernel.Context) (*kernel.FullDecision, error) {
var lastErr error
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine
// This ensures backtest uses the same unified prompt generation as live trading
fd, err := kernel.GetFullDecisionWithStrategy(
ctx,
r.mcpClient,
r.strategyEngine,
r.cfg.PromptVariant,
)
if err == nil {
return fd, nil
}
lastErr = err
delay := time.Duration(attempt+1) * 500 * time.Millisecond
time.Sleep(delay)
}
return nil, lastErr
}
func (r *Runner) shouldTriggerDecision(barIndex int) bool {
if r.cfg.DecisionCadenceNBars <= 1 {
return true
}
if barIndex < 0 {
return true
}
return barIndex%r.cfg.DecisionCadenceNBars == 0
}
func (r *Runner) updateState(ts int64, equity, unrealized, marginUsed float64, priceMap map[string]float64, advancedDecision bool) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
if r.state.MaxEquity == 0 || equity > r.state.MaxEquity {
r.state.MaxEquity = equity
}
if r.state.MinEquity == 0 || equity < r.state.MinEquity {
r.state.MinEquity = equity
}
if r.state.MaxEquity > 0 {
drawdown := ((r.state.MaxEquity - equity) / r.state.MaxEquity) * 100
if drawdown > r.state.MaxDrawdownPct {
r.state.MaxDrawdownPct = drawdown
}
}
positions := make(map[string]PositionSnapshot)
for _, pos := range r.account.Positions() {
key := fmt.Sprintf("%s:%s", pos.Symbol, pos.Side)
positions[key] = PositionSnapshot{
Symbol: pos.Symbol,
Side: pos.Side,
Quantity: pos.Quantity,
AvgPrice: pos.EntryPrice,
Leverage: pos.Leverage,
LiquidationPrice: pos.LiquidationPrice,
MarginUsed: pos.Margin,
OpenTime: pos.OpenTime,
AccumulatedFee: pos.AccumulatedFee,
}
}
r.state.BarTimestamp = ts
r.state.BarIndex++
if advancedDecision {
r.state.DecisionCycle++
}
r.state.Cash = r.account.Cash()
r.state.Equity = equity
r.state.UnrealizedPnL = unrealized
r.state.RealizedPnL = r.account.RealizedPnL()
r.state.Positions = positions
r.state.LastUpdate = time.Now().UTC()
}
func (r *Runner) handleStop(reason error) {
r.forceCheckpoint()
if reason != nil {
r.setLastError(reason)
} else {
r.setLastError(nil)
}
r.statusMu.Lock()
r.err = reason
r.status = RunStateStopped
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func (r *Runner) handlePause() {
r.forceCheckpoint()
r.setLastError(nil)
r.statusMu.Lock()
r.status = RunStatePaused
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
}
func (r *Runner) resumeFromPause() {
r.setLastError(nil)
r.statusMu.Lock()
r.status = RunStateRunning
r.statusMu.Unlock()
r.persistMetadata()
}
func (r *Runner) handleCompletion() {
r.setLastError(nil)
r.statusMu.Lock()
r.status = RunStateCompleted
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func (r *Runner) handleFailure(err error) {
r.forceCheckpoint()
if err != nil {
r.setLastError(err)
}
r.statusMu.Lock()
r.err = err
r.status = RunStateFailed
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func (r *Runner) handleLiquidation() {
r.forceCheckpoint()
r.setLastError(errLiquidated)
r.statusMu.Lock()
r.err = errLiquidated
r.status = RunStateLiquidated
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
if len(decisions) <= 1 {
return decisions
}
priority := func(action string) int {
switch action {
case "close_long", "close_short":
return 1
case "open_long", "open_short":
return 2
case "hold", "wait":
return 3
default:
return 99
}
}
result := make([]kernel.Decision, len(decisions))
copy(result, decisions)
sort.Slice(result, func(i, j int) bool {
pi := priority(result[i].Action)
pj := priority(result[j].Action)
if pi != pj {
return pi < pj
}
return i < j
})
return result
}
-239
View File
@@ -1,239 +0,0 @@
package backtest
import (
"fmt"
"sort"
"time"
"nofx/logger"
"nofx/store"
)
func (r *Runner) persistMetadata() {
state := r.snapshotState()
meta := r.buildMetadata(state, r.Status())
meta.CreatedAt = r.createdAt
if err := SaveRunMetadata(meta); err != nil {
logger.Infof("failed to save run metadata for %s: %v", r.cfg.RunID, err)
} else {
if err := updateRunIndex(meta, &r.cfg); err != nil {
logger.Infof("failed to update index for %s: %v", r.cfg.RunID, err)
}
}
}
func (r *Runner) logDecision(record *store.DecisionRecord) error {
if record == nil {
return nil
}
persistDecisionRecord(r.cfg.RunID, record)
return nil
}
func (r *Runner) persistMetrics(force bool) {
if r.cfg.RunID == "" {
return
}
if !force && !r.lastMetricsWrite.IsZero() {
if time.Since(r.lastMetricsWrite) < metricsWriteInterval {
return
}
}
state := r.snapshotState()
metrics, err := CalculateMetrics(r.cfg.RunID, &r.cfg, &state)
if err != nil {
logger.Infof("failed to compute metrics for %s: %v", r.cfg.RunID, err)
return
}
if metrics == nil {
return
}
if err := PersistMetrics(r.cfg.RunID, metrics); err != nil {
logger.Infof("failed to persist metrics for %s: %v", r.cfg.RunID, err)
return
}
r.lastMetricsWrite = time.Now()
}
func (r *Runner) buildMetadata(state BacktestState, runState RunState) *RunMetadata {
if state.Liquidated && runState != RunStateLiquidated {
runState = RunStateLiquidated
}
progress := progressPercent(state, r.cfg)
summary := RunSummary{
SymbolCount: len(r.cfg.Symbols),
DecisionTF: r.cfg.DecisionTimeframe,
ProcessedBars: state.BarIndex,
ProgressPct: progress,
EquityLast: state.Equity,
MaxDrawdownPct: state.MaxDrawdownPct,
Liquidated: state.Liquidated,
LiquidationNote: state.LiquidationNote,
}
meta := &RunMetadata{
RunID: r.cfg.RunID,
UserID: r.cfg.UserID,
State: runState,
LastError: r.lastErrorString(),
Summary: summary,
}
return meta
}
func progressPercent(state BacktestState, cfg BacktestConfig) float64 {
duration := cfg.Duration()
if duration <= 0 {
return 0
}
if state.BarTimestamp == 0 {
return 0
}
start := time.Unix(cfg.StartTS, 0)
end := time.Unix(cfg.EndTS, 0)
current := time.UnixMilli(state.BarTimestamp)
if !current.After(start) {
return 0
}
if current.After(end) {
return 100
}
elapsed := current.Sub(start)
pct := float64(elapsed) / float64(duration) * 100
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
return pct
}
func (r *Runner) maybeCheckpoint() error {
state := r.snapshotState()
shouldCheckpoint := false
if r.cfg.CheckpointIntervalBars > 0 && state.BarIndex > 0 && state.BarIndex%r.cfg.CheckpointIntervalBars == 0 {
shouldCheckpoint = true
}
interval := time.Duration(r.cfg.CheckpointIntervalSeconds) * time.Second
if interval <= 0 {
interval = 2 * time.Second
}
if time.Since(r.lastCheckpoint) >= interval {
shouldCheckpoint = true
}
if !shouldCheckpoint {
return nil
}
if err := r.saveCheckpoint(state); err != nil {
return err
}
return nil
}
func (r *Runner) snapshotForCheckpoint(state BacktestState) []PositionSnapshot {
res := make([]PositionSnapshot, 0, len(state.Positions))
for _, pos := range state.Positions {
res = append(res, pos)
}
sort.Slice(res, func(i, j int) bool {
if res[i].Symbol == res[j].Symbol {
return res[i].Side < res[j].Side
}
return res[i].Symbol < res[j].Symbol
})
return res
}
func (r *Runner) buildCheckpointFromState(state BacktestState) *Checkpoint {
return &Checkpoint{
BarIndex: state.BarIndex,
BarTimestamp: state.BarTimestamp,
Cash: state.Cash,
Equity: state.Equity,
UnrealizedPnL: state.UnrealizedPnL,
RealizedPnL: state.RealizedPnL,
Positions: r.snapshotForCheckpoint(state),
DecisionCycle: state.DecisionCycle,
Liquidated: state.Liquidated,
LiquidationNote: state.LiquidationNote,
MaxEquity: state.MaxEquity,
MinEquity: state.MinEquity,
MaxDrawdownPct: state.MaxDrawdownPct,
AICacheRef: r.cachePath,
}
}
func (r *Runner) saveCheckpoint(state BacktestState) error {
ckpt := r.buildCheckpointFromState(state)
if ckpt == nil {
return nil
}
if err := SaveCheckpoint(r.cfg.RunID, ckpt); err != nil {
return err
}
r.lastCheckpoint = time.Now()
return nil
}
func (r *Runner) forceCheckpoint() {
state := r.snapshotState()
if err := r.saveCheckpoint(state); err != nil {
logger.Infof("failed to save checkpoint for %s: %v", r.cfg.RunID, err)
}
}
func (r *Runner) RestoreFromCheckpoint() error {
ckpt, err := LoadCheckpoint(r.cfg.RunID)
if err != nil {
return err
}
return r.applyCheckpoint(ckpt)
}
func (r *Runner) applyCheckpoint(ckpt *Checkpoint) error {
if ckpt == nil {
return fmt.Errorf("checkpoint is nil")
}
r.account.RestoreFromSnapshots(ckpt.Cash, ckpt.RealizedPnL, ckpt.Positions)
r.stateMu.Lock()
defer r.stateMu.Unlock()
r.state.BarIndex = ckpt.BarIndex
r.state.BarTimestamp = ckpt.BarTimestamp
r.state.Cash = ckpt.Cash
r.state.Equity = ckpt.Equity
r.state.UnrealizedPnL = ckpt.UnrealizedPnL
r.state.RealizedPnL = ckpt.RealizedPnL
r.state.DecisionCycle = ckpt.DecisionCycle
r.state.Liquidated = ckpt.Liquidated
r.state.LiquidationNote = ckpt.LiquidationNote
r.state.MaxEquity = ckpt.MaxEquity
r.state.MinEquity = ckpt.MinEquity
r.state.MaxDrawdownPct = ckpt.MaxDrawdownPct
r.state.Positions = snapshotsToMap(ckpt.Positions)
r.state.LastUpdate = time.Now().UTC()
r.lastCheckpoint = time.Now()
return nil
}
func snapshotsToMap(snaps []PositionSnapshot) map[string]PositionSnapshot {
positions := make(map[string]PositionSnapshot, len(snaps))
for _, snap := range snaps {
key := fmt.Sprintf("%s:%s", snap.Symbol, snap.Side)
positions[key] = snap
}
return positions
}
-420
View File
@@ -1,420 +0,0 @@
package backtest
import (
"fmt"
"strings"
"time"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
)
func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
symbol := dec.Symbol
if symbol == "" {
return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision")
}
usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
actionRecord := store.DecisionAction{
Action: dec.Action,
Symbol: symbol,
Leverage: usedLeverage,
Timestamp: time.UnixMilli(ts).UTC(),
}
if priceMap == nil {
return actionRecord, nil, "", fmt.Errorf("priceMap is nil")
}
basePrice, ok := priceMap[symbol]
if !ok || basePrice <= 0 {
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s (found=%v, price=%.4f)", symbol, ok, basePrice)
}
fillPrice := r.executionPrice(symbol, basePrice, ts)
switch dec.Action {
case "open_long":
qty := r.determineQuantity(dec, basePrice)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid qty")
}
pos, fee, execPrice, err := r.account.Open(symbol, "long", qty, usedLeverage, fillPrice, ts)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = pos.Leverage
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "long",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: execPrice - basePrice,
OrderValue: execPrice * qty,
RealizedPnL: 0,
Leverage: pos.Leverage,
Cycle: cycle,
PositionAfter: pos.Quantity,
}
return actionRecord, []TradeEvent{trade}, "", nil
case "open_short":
qty := r.determineQuantity(dec, basePrice)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid qty")
}
pos, fee, execPrice, err := r.account.Open(symbol, "short", qty, usedLeverage, fillPrice, ts)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = pos.Leverage
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "short",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: basePrice - execPrice,
OrderValue: execPrice * qty,
RealizedPnL: 0,
Leverage: pos.Leverage,
Cycle: cycle,
PositionAfter: pos.Quantity,
}
return actionRecord, []TradeEvent{trade}, "", nil
case "close_long":
qty := r.determineCloseQuantity(symbol, "long", dec)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid close qty")
}
posLev := r.account.positionLeverage(symbol, "long")
realized, fee, execPrice, err := r.account.Close(symbol, "long", qty, fillPrice)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = posLev
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "long",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: basePrice - execPrice,
OrderValue: execPrice * qty,
RealizedPnL: realized - fee,
Leverage: posLev,
Cycle: cycle,
PositionAfter: r.remainingPosition(symbol, "long"),
}
return actionRecord, []TradeEvent{trade}, "", nil
case "close_short":
qty := r.determineCloseQuantity(symbol, "short", dec)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid close qty")
}
posLev := r.account.positionLeverage(symbol, "short")
realized, fee, execPrice, err := r.account.Close(symbol, "short", qty, fillPrice)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = posLev
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "short",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: execPrice - basePrice,
OrderValue: execPrice * qty,
RealizedPnL: realized - fee,
Leverage: posLev,
Cycle: cycle,
PositionAfter: r.remainingPosition(symbol, "short"),
}
return actionRecord, []TradeEvent{trade}, "", nil
case "hold", "wait":
return actionRecord, nil, fmt.Sprintf("hold position: %s", dec.Action), nil
default:
return actionRecord, nil, "", fmt.Errorf("unsupported action %s", dec.Action)
}
}
// MinPositionSizeUSD is the minimum position size in USD to avoid dust positions
const MinPositionSizeUSD = 10.0
func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
snapshot := r.snapshotState()
equity := snapshot.Equity
if equity <= 0 {
equity = r.account.InitialBalance()
}
// Get leverage for this symbol
leverage := r.resolveLeverage(dec.Leverage, dec.Symbol)
if leverage <= 0 {
leverage = 5
}
// Calculate available margin (leave some buffer for fees)
availableCash := r.account.Cash()
maxMarginToUse := availableCash * 0.9 // Use max 90% of available cash
maxPositionValue := maxMarginToUse * float64(leverage)
sizeUSD := dec.PositionSizeUSD
if sizeUSD <= 0 {
// Default to 5% of equity, but cap to available margin
sizeUSD = 0.05 * equity
}
// Cap position size to what we can actually afford
if sizeUSD > maxPositionValue {
logger.Infof("📊 Backtest: capping position from %.2f to %.2f (available margin: %.2f, leverage: %dx)",
sizeUSD, maxPositionValue, maxMarginToUse, leverage)
sizeUSD = maxPositionValue
}
// Reject positions below minimum size to avoid dust positions
if sizeUSD < MinPositionSizeUSD {
logger.Infof("📊 Backtest: rejecting position size %.2f USD (below minimum %.2f USD)",
sizeUSD, MinPositionSizeUSD)
return 0
}
qty := sizeUSD / price
if qty < 0 {
qty = 0
}
return qty
}
func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision) float64 {
for _, pos := range r.account.Positions() {
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
return pos.Quantity
}
}
return 0
}
func (r *Runner) resolveLeverage(requested int, symbol string) int {
sym := strings.ToUpper(symbol)
isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT"
// Determine configured max leverage for this symbol type
var maxLeverage int
if isBTCETH {
maxLeverage = r.cfg.Leverage.BTCETHLeverage
if maxLeverage <= 0 {
maxLeverage = 10 // Default max for BTC/ETH
}
} else {
maxLeverage = r.cfg.Leverage.AltcoinLeverage
if maxLeverage <= 0 {
maxLeverage = 5 // Default max for altcoins
}
}
// Use requested leverage if provided, otherwise use max as default
leverage := requested
if leverage <= 0 {
leverage = maxLeverage
}
// Enforce max leverage limit
if leverage > maxLeverage {
logger.Infof("📊 Backtest: capping leverage from %dx to %dx for %s",
leverage, maxLeverage, symbol)
leverage = maxLeverage
}
return leverage
}
func (r *Runner) remainingPosition(symbol, side string) float64 {
for _, pos := range r.account.Positions() {
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
return pos.Quantity
}
}
return 0
}
func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.PositionSnapshot {
positions := r.account.Positions()
list := make([]store.PositionSnapshot, 0, len(positions))
for _, pos := range positions {
price := priceMap[pos.Symbol]
list = append(list, store.PositionSnapshot{
Symbol: pos.Symbol,
Side: pos.Side,
PositionAmt: pos.Quantity,
EntryPrice: pos.EntryPrice,
MarkPrice: price,
UnrealizedProfit: unrealizedPnL(pos, price),
Leverage: float64(pos.Leverage),
LiquidationPrice: pos.LiquidationPrice,
})
}
return list
}
func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.PositionInfo {
positions := r.account.Positions()
list := make([]kernel.PositionInfo, 0, len(positions))
for _, pos := range positions {
price := priceMap[pos.Symbol]
pnl := unrealizedPnL(pos, price)
// Calculate P&L percentage based on entry notional (position cost)
pnlPct := 0.0
if pos.Notional > 0 {
pnlPct = (pnl / pos.Notional) * 100
}
list = append(list, kernel.PositionInfo{
Symbol: pos.Symbol,
Side: pos.Side,
EntryPrice: pos.EntryPrice,
MarkPrice: price,
Quantity: pos.Quantity,
Leverage: pos.Leverage,
UnrealizedPnL: pnl,
UnrealizedPnLPct: pnlPct,
LiquidationPrice: pos.LiquidationPrice,
MarginUsed: pos.Margin,
UpdateTime: time.Now().UnixMilli(),
})
}
return list
}
func (r *Runner) executionPrice(symbol string, markPrice float64, ts int64) float64 {
curr, next := r.feed.decisionBarSnapshot(symbol, ts)
switch r.cfg.FillPolicy {
case FillPolicyNextOpen:
if next != nil && next.Open > 0 {
return next.Open
}
case FillPolicyBarVWAP:
if curr != nil {
if vwap := barVWAP(*curr); vwap > 0 {
return vwap
}
}
case FillPolicyMidPrice:
if curr != nil && curr.High > 0 && curr.Low > 0 {
return (curr.High + curr.Low) / 2
}
}
return markPrice
}
func (r *Runner) totalMarginUsed() float64 {
sum := 0.0
for _, pos := range r.account.Positions() {
sum += pos.Margin
}
return sum
}
func (r *Runner) checkLiquidation(ts int64, priceMap map[string]float64, cycle int) ([]TradeEvent, string, error) {
positions := append([]*position(nil), r.account.Positions()...)
events := make([]TradeEvent, 0)
var noteBuilder strings.Builder
for _, pos := range positions {
price := priceMap[pos.Symbol]
liqPrice := pos.LiquidationPrice
trigger := false
execPrice := price
if pos.Side == "long" {
if price <= liqPrice && liqPrice > 0 {
trigger = true
execPrice = liqPrice
}
} else {
if price >= liqPrice && liqPrice > 0 {
trigger = true
execPrice = liqPrice
}
}
if !trigger {
continue
}
realized, fee, finalPrice, err := r.account.Close(pos.Symbol, pos.Side, pos.Quantity, execPrice)
if err != nil {
return nil, "", err
}
noteBuilder.WriteString(fmt.Sprintf("%s %s @ %.4f; ", pos.Symbol, pos.Side, finalPrice))
evt := TradeEvent{
Timestamp: ts,
Symbol: pos.Symbol,
Action: "liquidated",
Side: pos.Side,
Quantity: pos.Quantity,
Price: finalPrice,
Fee: fee,
Slippage: 0,
OrderValue: finalPrice * pos.Quantity,
RealizedPnL: realized - fee,
Leverage: pos.Leverage,
Cycle: cycle,
PositionAfter: 0,
LiquidationFlag: true,
Note: fmt.Sprintf("forced liquidation at %.4f", finalPrice),
}
events = append(events, evt)
}
if len(events) == 0 {
return events, "", nil
}
note := strings.TrimSuffix(noteBuilder.String(), "; ")
r.stateMu.Lock()
r.state.Liquidated = true
r.state.LiquidationNote = note
r.stateMu.Unlock()
return events, note, nil
}
func barVWAP(k market.Kline) float64 {
values := []float64{k.Open, k.High, k.Low, k.Close}
sum := 0.0
count := 0.0
for _, v := range values {
if v > 0 {
sum += v
count++
}
}
if count == 0 {
return 0
}
return sum / count
}
-561
View File
@@ -1,561 +0,0 @@
package backtest
import (
"archive/zip"
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"nofx/store"
)
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 writes the checkpoint to disk.
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 reads the most recent checkpoint.
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 writes to 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 reads 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) (*store.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 store.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) ([]*store.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 []*store.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 []*store.DecisionRecord{}, nil
}
end := offset + limit
if end > len(files) {
end = len(files)
}
records := make([]*store.DecisionRecord, 0, end-offset)
for _, file := range files[offset:end] {
data, err := os.ReadFile(file.path)
if err != nil {
continue
}
var record store.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 *store.DecisionRecord) {
if !usingDB() || record == nil {
return
}
_ = saveDecisionRecordDB(runID, record)
}
-498
View File
@@ -1,498 +0,0 @@
package backtest
import (
"archive/zip"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"time"
"nofx/store"
)
func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
data, err := json.Marshal(ckpt)
if err != nil {
return err
}
_, err = persistenceDB.Exec(convertQuery(`
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(convertQuery(`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(convertQuery(`
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(convertQuery(`
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(convertQuery(`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(convertQuery(`
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(convertQuery(`
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(convertQuery(`
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 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(convertQuery(`
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(convertQuery(`
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(convertQuery(`
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(convertQuery(`
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(convertQuery(`
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(convertQuery(`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(convertQuery(`
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) (*store.DecisionRecord, error) {
var rows *sql.Rows
var err error
if cycle > 0 {
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY created_at DESC LIMIT 1`), runID, cycle)
} else {
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY 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 store.DecisionRecord
if err := json.Unmarshal(payload, &record); err != nil {
return nil, err
}
return &record, nil
}
func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
if record == nil {
return nil
}
data, err := json.Marshal(record)
if err != nil {
return err
}
_, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_decisions (run_id, cycle, payload)
VALUES (?, ?, ?)
`), runID, record.CycleNumber, data)
return err
}
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
rows, err := persistenceDB.Query(convertQuery(`
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([]*store.DecisionRecord, 0, limit)
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
return nil, err
}
var record store.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(convertQuery(`
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 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(convertQuery(`DELETE FROM backtest_runs WHERE run_id = ?`), runID)
return err
}
-179
View File
@@ -1,179 +0,0 @@
package backtest
import "time"
// RunState represents the current state of a backtest run.
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 represents core position data for backtest state and persistence.
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"`
AccumulatedFee float64 `json:"accumulated_fee,omitempty"` // Opening fees accumulated
}
// BacktestState represents the real-time state during execution (in-memory state).
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 represents a single point on the equity curve.
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 records a trade execution result or special event (such as liquidation).
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 summarizes backtest performance 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 records performance for a single symbol.
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 represents checkpoint information saved to disk for pause, resume, and crash recovery.
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 records the summary required for 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 represents the summary field in run.json.
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 is used for /status API responses.
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"`
Positions []PositionStatus `json:"positions,omitempty"`
Note string `json:"note,omitempty"`
LastError string `json:"last_error,omitempty"`
LastUpdatedIso string `json:"last_updated_iso"`
}
// PositionStatus represents a position with unrealized P&L for status display.
type PositionStatus struct {
Symbol string `json:"symbol"`
Side string `json:"side"`
Quantity float64 `json:"quantity"`
EntryPrice float64 `json:"entry_price"`
MarkPrice float64 `json:"mark_price"`
Leverage int `json:"leverage"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
UnrealizedPnLPct float64 `json:"unrealized_pnl_pct"`
MarginUsed float64 `json:"margin_used"`
}
-624
View File
@@ -1,624 +0,0 @@
# NOFX Backtest Module - Technical Documentation
**Language:** [English](BACKTEST_MODULE.md) | [中文](BACKTEST_MODULE.zh-CN.md)
## Overview
This document describes the complete technical implementation of the NOFX backtest module, including configuration, historical data loading, simulation engine, AI decision making, performance metrics calculation, and result storage.
---
## Complete Backtest Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ Backtest Execution Flow │
└─────────────────────────────────────────────────────────────────┘
1. API Request: /backtest/start
2. Manager.Start()
├─ Validate config
├─ Parse AI model
├─ Create Runner instance
└─ Start runner.Start() (goroutine)
3. Runner.Start() → Runner.loop()
└─ Iterate each decision time point:
├─ DataFeed.BuildMarketData() [Build market data]
├─ Check decision trigger [Every N bars]
├─ buildDecisionContext() [Build decision context]
├─ invokeAIWithRetry() [Call AI + cache]
├─ executeDecision() [Execute trades]
├─ checkLiquidation() [Check liquidation]
├─ updateState() [Update state]
├─ appendEquityPoint() [Record equity]
├─ appendTradeEvent() [Record trades]
├─ maybeCheckpoint() [Save checkpoint]
└─ persistMetrics() [Persist metrics]
4. Complete/Failed
├─ Calculate final metrics
├─ Persist all results
└─ Release lock
5. API Query: /backtest/metrics, /backtest/equity, /backtest/trades
└─ Load and return results
```
---
## 1. Configuration
**Core File:** `backtest/config.go`
### 1.1 Config Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `RunID` | string | (required) | Unique backtest run ID |
| `UserID` | string | "default" | User ID |
| `Symbols` | []string | (required) | Trading symbols list |
| `Timeframes` | []string | ["3m", "15m", "4h"] | K-line timeframes |
| `DecisionTimeframe` | string | Symbols[0] | Primary decision timeframe |
| `DecisionCadenceNBars` | int | 20 | Trigger decision every N bars |
| `StartTS`, `EndTS` | int64 | (required) | Backtest time range (Unix timestamp) |
| `InitialBalance` | float64 | 1000 | Initial balance (USD) |
| `FeeBps` | float64 | 5 | Trading fee (basis points) |
| `SlippageBps` | float64 | 2 | Slippage (basis points) |
| `FillPolicy` | string | "next_open" | Fill policy |
| `PromptVariant` | string | "baseline" | AI prompt variant |
| `CacheAI` | bool | false | Cache AI decisions |
| `Leverage` | LeverageConfig | BTC/ETH:5, Altcoin:5 | Leverage settings |
### 1.2 Fill Policy
```go
// backtest/config.go:163-179
switch fillPolicy {
case "next_open": // Next bar open price
case "bar_vwap": // Current bar VWAP
case "mid": // Current bar (High+Low)/2
default: // Mark Price
}
```
### 1.3 Config Example
```go
cfg := backtest.BacktestConfig{
RunID: "bt_20231215_150405",
Symbols: []string{"BTCUSDT", "ETHUSDT"},
Timeframes: []string{"3m", "15m", "4h"},
DecisionTimeframe: "3m",
DecisionCadenceNBars: 20,
StartTS: 1702566000,
EndTS: 1702652400,
InitialBalance: 10000,
FeeBps: 5,
SlippageBps: 2,
FillPolicy: "next_open",
}
```
---
## 2. Data Loading
**Core File:** `backtest/datafeed.go`
### 2.1 Data Loading Flow
```
1. NewDataFeed() - Initialize
2. loadAll() - Load all historical data
├─ Calculate buffer (200 bars before StartTS)
├─ Call market.GetKlinesRange() to fetch data
├─ Store in symbolSeries map
└─ Build decision timeline from primary timeframe
3. BuildMarketData() - Build market data snapshot
├─ Slice K-line data to current timestamp
├─ Calculate technical indicators (EMA, MACD, RSI, ATR)
└─ Return market.Data structure
```
### 2.2 Data Structure
```go
// DataFeed core structure
type DataFeed struct {
decisionTimes []int64 // Decision time points list
symbolSeries map[string]*symbolSeries // Data stored by symbol
}
// Single symbol time series
type symbolSeries struct {
timeframes map[string]*timeframeSeries // Stored by timeframe
}
// Single timeframe data
type timeframeSeries struct {
klines []market.Kline // K-line data
closeTimes []int64 // Close time index
}
```
### 2.3 Key Code References
- Data fetching: `backtest/datafeed.go:48-93`
- Timeline generation: `backtest/datafeed.go:96-115`
- Market data assembly: `backtest/datafeed.go:141-171`
---
## 3. Simulation Engine
**Core File:** `backtest/runner.go`
### 3.1 Main Loop
```go
// backtest/runner.go:232-264
func (r *Runner) loop() {
for _, ts := range r.feed.DecisionTimes() {
if r.isPaused() {
break
}
r.stepOnce(ts)
}
}
```
### 3.2 Single Step Execution
```go
// backtest/runner.go:266-471
func (r *Runner) stepOnce(ts int64) {
// 1. Get current bar timestamp
// 2. Build market data
// 3. Check decision trigger (every N bars)
// 4. Execute decision cycle (if triggered)
// 5. Check liquidation
// 6. Update state and record
}
```
### 3.3 State Management
```go
// backtest/types.go:31-47
type BacktestState struct {
BarIndex int // Current bar index
Cash float64 // Available balance
Equity float64 // Total equity
UnrealizedPnL float64 // Unrealized PnL
RealizedPnL float64 // Realized PnL
MaxEquity float64 // Peak equity
MinEquity float64 // Trough equity
MaxDrawdownPct float64 // Max drawdown
Positions map[string]*position // Positions
}
```
---
## 4. AI Decision Making
**Core File:** `backtest/runner.go`
### 4.1 Decision Context Building
```go
// backtest/runner.go:473-532
func (r *Runner) buildDecisionContext() *decision.Context {
return &decision.Context{
CurrentTime: "2023-12-15 10:30:00 UTC",
RuntimeMinutes: elapsed,
CallCount: cycleNumber,
Account: {
TotalEquity, AvailableBalance, TotalPnL, MarginUsedPct
},
Positions: []PositionInfo{...},
CandidateCoins: []string{symbols...},
MarketDataMap: map[symbol]*market.Data{...},
MultiTFMarket: map[symbol]map[timeframe]*market.Data{...},
}
}
```
### 4.2 AI Invocation
```go
// backtest/runner.go:544-563
func (r *Runner) invokeAIWithRetry() (*decision.FullDecision, error) {
// Max 3 retries
// Exponential backoff: 500ms, 1000ms, 1500ms
// Uses decision.GetFullDecisionWithStrategy() for unified prompt generation
}
```
### 4.3 AI Cache
```go
// backtest/aicache.go:127-168
// Cache key: SHA256(context payload)
// Contains: variant, timestamp, account, positions, market data
```
### 4.4 Supported AI Models
| Model | Client File |
|-------|-------------|
| DeepSeek | `mcp/deepseek_client.go` |
| Qwen | `mcp/qwen_client.go` |
| Claude | `mcp/claude_client.go` |
| Gemini | `mcp/gemini_client.go` |
| Grok | `mcp/grok_client.go` |
| OpenAI | `mcp/openai_client.go` |
| Kimi | `mcp/kimi_client.go` |
---
## 5. Performance Metrics
**Core File:** `backtest/metrics.go`
### 5.1 Metrics Calculation
| Metric | Formula | Code Location |
|--------|---------|---------------|
| **Total Return** | (Final Equity - Initial) / Initial × 100 | metrics.go:36-42 |
| **Max Drawdown** | max((Peak - Current) / Peak × 100) | metrics.go:64-91 |
| **Sharpe Ratio** | Avg Return / Return StdDev | metrics.go:94-138 |
| **Win Rate** | Winning Trades / Total Trades × 100 | metrics.go:180-181 |
| **Profit Factor** | Total Profit / Total Loss | metrics.go:189-193 |
### 5.2 Trade Statistics
```go
// backtest/metrics.go:141-225
type TradeMetrics struct {
TotalTrades int
WinningTrades int
LosingTrades int
AvgWin float64
AvgLoss float64
BestSymbol string
WorstSymbol string
SymbolStats map[string]*SymbolStat
}
```
---
## 6. Equity Curve
**Core File:** `backtest/equity.go`
### 6.1 Equity Point Structure
```json
{
"ts": 1702566000000,
"equity": 10500.50,
"available": 8000.00,
"pnl": 500.50,
"pnl_pct": 5.005,
"dd_pct": 2.34,
"cycle": 42
}
```
### 6.2 Equity Update
```go
// backtest/runner.go:829-872
func (r *Runner) updateState() {
// 1. Calculate total equity: cash + margin + unrealized PnL
// 2. Track peak (MaxEquity)
// 3. Track trough (MinEquity)
// 4. Recalculate drawdown: (MaxEquity - Equity) / MaxEquity × 100
}
```
### 6.3 Data Resampling
```go
// backtest/equity.go:10-50
func ResampleEquity(points []EquityPoint, timeframe string) []EquityPoint {
// Bucket by timeframe
// Keep last point in each bucket
}
```
---
## 7. Result Storage
**Core Files:** `backtest/storage.go`, `store/backtest.go`
### 7.1 File Storage Structure
```
backtests/
├── <run_id>/
│ ├── run.json # Run metadata
│ ├── checkpoint.json # Checkpoint (for resume)
│ ├── equity.jsonl # Equity curve (line-delimited JSON)
│ ├── trades.jsonl # Trade records (line-delimited JSON)
│ ├── metrics.json # Performance metrics
│ ├── progress.json # Progress info
│ ├── ai_cache.json # AI decision cache
│ └── decision_logs/ # Decision logs
│ ├── 0.json
│ ├── 1.json
│ └── ...
```
### 7.2 Database Schema
```sql
-- Backtest run metadata
CREATE TABLE backtest_runs (
run_id TEXT PRIMARY KEY,
user_id TEXT,
config_json TEXT,
state TEXT, -- pending, running, completed, failed
processed_bars INTEGER,
progress_pct REAL,
equity_last REAL,
max_drawdown_pct REAL,
liquidated BOOLEAN,
ai_provider TEXT,
ai_model TEXT,
created_at DATETIME,
updated_at DATETIME
);
-- Equity curve
CREATE TABLE backtest_equity (
id INTEGER PRIMARY KEY,
run_id TEXT,
ts INTEGER,
equity REAL,
available REAL,
pnl REAL,
pnl_pct REAL,
dd_pct REAL,
cycle INTEGER
);
-- Trade records
CREATE TABLE backtest_trades (
id INTEGER PRIMARY KEY,
run_id TEXT,
ts INTEGER,
symbol TEXT,
action TEXT,
side TEXT,
qty REAL,
price REAL,
fee REAL,
slippage REAL,
realized_pnl REAL,
leverage INTEGER,
liquidation BOOLEAN
);
-- Performance metrics
CREATE TABLE backtest_metrics (
run_id TEXT PRIMARY KEY,
payload BLOB,
updated_at DATETIME
);
-- Checkpoints (pause/resume)
CREATE TABLE backtest_checkpoints (
run_id TEXT PRIMARY KEY,
payload BLOB,
updated_at DATETIME
);
```
---
## 8. API Endpoints
**Core File:** `api/backtest.go`
### 8.1 Endpoint List
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/backtest/start` | POST | Start backtest |
| `/backtest/pause` | POST | Pause backtest |
| `/backtest/resume` | POST | Resume backtest |
| `/backtest/stop` | POST | Stop backtest |
| `/backtest/status` | GET | Get status |
| `/backtest/runs` | GET | List all backtests |
| `/backtest/equity` | GET | Get equity curve |
| `/backtest/trades` | GET | Get trade records |
| `/backtest/metrics` | GET | Get performance metrics |
| `/backtest/trace` | GET | Get decision logs |
| `/backtest/export` | GET | Export ZIP |
| `/backtest/delete` | POST | Delete backtest |
### 8.2 Request Examples
```bash
# Start backtest
POST /backtest/start
{
"config": {
"run_id": "bt_20231215",
"symbols": ["BTCUSDT", "ETHUSDT"],
"timeframes": ["3m", "15m", "4h"],
"start_ts": 1702566000,
"end_ts": 1702652400,
"initial_balance": 10000,
"ai_model_id": "model_001"
}
}
# Get equity curve
GET /backtest/equity?run_id=bt_20231215&tf=1h&limit=1000
# Get metrics
GET /backtest/metrics?run_id=bt_20231215
```
### 8.3 Response Examples
```json
// Status response
{
"run_id": "bt_20231215",
"state": "running",
"progress_pct": 45.5,
"processed_bars": 1234,
"equity": 10234.50,
"unrealized_pnl": 234.50
}
// Metrics response
{
"total_return_pct": 12.34,
"max_drawdown_pct": 5.67,
"sharpe_ratio": 1.89,
"profit_factor": 2.34,
"win_rate": 65.5,
"trades": 123
}
```
---
## 9. Account & Position Management
**Core File:** `backtest/account.go`
### 9.1 Position Structure
```go
type position struct {
Symbol string
Side string // "long" or "short"
Quantity float64
EntryPrice float64
Leverage int
Margin float64 // Margin
Notional float64 // Notional value
LiquidationPrice float64 // Liquidation price
OpenTime int64
}
```
### 9.2 Open Position Logic
```go
// backtest/account.go:61-104
func (a *BacktestAccount) Open(symbol, side string, qty, price float64, leverage int) {
// 1. Apply slippage
// 2. Calculate notional value (qty × price)
// 3. Calculate margin (notional / leverage)
// 4. Deduct margin + fees
// 5. Create/add to position
// 6. Calculate liquidation price
}
```
### 9.3 Close Position Logic
```go
// backtest/account.go:106-140
func (a *BacktestAccount) Close(symbol, side string, qty, price float64) {
// 1. Verify position exists
// 2. Apply slippage (reverse direction)
// 3. Calculate realized PnL
// long: (exit - entry) × qty
// short: (entry - exit) × qty
// 4. Return margin + PnL - fees
// 5. Update/delete position
}
```
### 9.4 Liquidation Price Calculation
```go
// backtest/account.go:177-186
func computeLiquidation(entry float64, leverage int, side string) float64 {
if side == "long" {
return entry * (1 - 1.0/float64(leverage)) // Long: liquidate on drop
}
return entry * (1 + 1.0/float64(leverage)) // Short: liquidate on rise
}
```
---
## 10. Checkpoint & Resume
**Core File:** `backtest/runner.go`
### 10.1 Checkpoint Structure
```json
{
"bar_index": 1234,
"bar_ts": 1702609200000,
"cash": 8000.00,
"equity": 10234.50,
"max_equity": 10500.00,
"max_drawdown_pct": 5.67,
"positions": [...],
"decision_cycle": 62,
"liquidated": false
}
```
### 10.2 Checkpoint Trigger
```go
// backtest/runner.go:874-898
func (r *Runner) maybeCheckpoint() {
// Save every N bars
// Or save every N seconds
}
```
### 10.3 Resume Flow
```go
func (r *Runner) RestoreFromCheckpoint() {
// 1. Load checkpoint
// 2. Restore account state
// 3. Restore bar index (continue from next bar)
// 4. Restore equity curve, trade records
}
```
---
## Core File Index
| Module | File | Key Methods |
|--------|------|-------------|
| **Config** | `backtest/config.go` | `BacktestConfig`, `Validate()` |
| **Data Loading** | `backtest/datafeed.go` | `NewDataFeed()`, `loadAll()`, `BuildMarketData()` |
| **Sim Engine** | `backtest/runner.go` | `Start()`, `loop()`, `stepOnce()` |
| **Decision** | `backtest/runner.go` | `buildDecisionContext()`, `invokeAIWithRetry()` |
| **Execution** | `backtest/runner.go` | `executeDecision()` |
| **Account** | `backtest/account.go` | `Open()`, `Close()`, `TotalEquity()` |
| **Metrics** | `backtest/metrics.go` | `CalculateMetrics()` |
| **Equity** | `backtest/equity.go` | `ResampleEquity()`, `LimitEquityPoints()` |
| **Storage** | `backtest/storage.go` | `SaveCheckpoint()`, `appendEquityPoint()` |
| **Database** | `store/backtest.go` | Schema and CRUD operations |
| **API** | `api/backtest.go` | HTTP handlers |
| **AI Cache** | `backtest/aicache.go` | `Get()`, `Put()`, `save()` |
---
**Document Version:** 1.0.0
**Last Updated:** 2025-01-15
-624
View File
@@ -1,624 +0,0 @@
# NOFX 回测模块技术文档
**语言:** [English](BACKTEST_MODULE.md) | [中文](BACKTEST_MODULE.zh-CN.md)
## 概述
本文档详细描述 NOFX 回测模块的完整技术实现,包括配置、历史数据加载、模拟引擎、AI 决策、性能指标计算和结果存储。
---
## 完整回测流程图
```
┌─────────────────────────────────────────────────────────────────┐
│ 回测执行流程 │
└─────────────────────────────────────────────────────────────────┘
1. API 请求: /backtest/start
2. Manager.Start()
├─ 验证配置
├─ 解析 AI 模型
├─ 创建 Runner 实例
└─ 启动 runner.Start() (goroutine)
3. Runner.Start() → Runner.loop()
└─ 遍历每个决策时间点:
├─ DataFeed.BuildMarketData() [构建市场数据]
├─ 检查决策触发条件 [每 N 根 K 线]
├─ buildDecisionContext() [构建决策上下文]
├─ invokeAIWithRetry() [调用 AI + 缓存]
├─ executeDecision() [执行交易]
├─ checkLiquidation() [检查爆仓]
├─ updateState() [更新状态]
├─ appendEquityPoint() [记录权益]
├─ appendTradeEvent() [记录交易]
├─ maybeCheckpoint() [保存检查点]
└─ persistMetrics() [持久化指标]
4. 完成/失败
├─ 计算最终指标
├─ 持久化所有结果
└─ 释放锁
5. API 查询: /backtest/metrics, /backtest/equity, /backtest/trades
└─ 加载并返回结果
```
---
## 1. 回测配置 (Configuration)
**核心文件:** `backtest/config.go`
### 1.1 配置参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `RunID` | string | (必填) | 回测运行唯一标识 |
| `UserID` | string | "default" | 用户 ID |
| `Symbols` | []string | (必填) | 交易币种列表 |
| `Timeframes` | []string | ["3m", "15m", "4h"] | K 线周期 |
| `DecisionTimeframe` | string | Symbols[0] | 主决策周期 |
| `DecisionCadenceNBars` | int | 20 | 每 N 根 K 线触发一次决策 |
| `StartTS`, `EndTS` | int64 | (必填) | 回测时间范围 (Unix 时间戳) |
| `InitialBalance` | float64 | 1000 | 初始资金 (USD) |
| `FeeBps` | float64 | 5 | 手续费 (基点) |
| `SlippageBps` | float64 | 2 | 滑点 (基点) |
| `FillPolicy` | string | "next_open" | 成交策略 |
| `PromptVariant` | string | "baseline" | AI 提示词变体 |
| `CacheAI` | bool | false | 是否缓存 AI 决策 |
| `Leverage` | LeverageConfig | BTC/ETH:5, Altcoin:5 | 杠杆设置 |
### 1.2 成交策略 (Fill Policy)
```go
// backtest/config.go:163-179
switch fillPolicy {
case "next_open": // 下一根 K 线开盘价
case "bar_vwap": // 当前 K 线 VWAP
case "mid": // 当前 K 线 (High+Low)/2
default: // Mark Price
}
```
### 1.3 配置示例
```go
cfg := backtest.BacktestConfig{
RunID: "bt_20231215_150405",
Symbols: []string{"BTCUSDT", "ETHUSDT"},
Timeframes: []string{"3m", "15m", "4h"},
DecisionTimeframe: "3m",
DecisionCadenceNBars: 20,
StartTS: 1702566000,
EndTS: 1702652400,
InitialBalance: 10000,
FeeBps: 5,
SlippageBps: 2,
FillPolicy: "next_open",
}
```
---
## 2. 历史数据加载 (Data Loading)
**核心文件:** `backtest/datafeed.go`
### 2.1 数据加载流程
```
1. NewDataFeed() - 初始化
2. loadAll() - 加载所有历史数据
├─ 计算缓冲区 (StartTS 前 200 根 K 线)
├─ 调用 market.GetKlinesRange() 获取数据
├─ 存储到 symbolSeries map
└─ 从主周期构建决策时间线
3. BuildMarketData() - 构建市场数据快照
├─ 切片 K 线数据到当前时间戳
├─ 计算技术指标 (EMA, MACD, RSI, ATR)
└─ 返回 market.Data 结构
```
### 2.2 数据结构
```go
// DataFeed 核心结构
type DataFeed struct {
decisionTimes []int64 // 决策时间点列表
symbolSeries map[string]*symbolSeries // 按币种存储的数据
}
// 单币种时间序列
type symbolSeries struct {
timeframes map[string]*timeframeSeries // 按周期存储
}
// 单周期数据
type timeframeSeries struct {
klines []market.Kline // K 线数据
closeTimes []int64 // 收盘时间索引
}
```
### 2.3 关键代码引用
- 数据获取: `backtest/datafeed.go:48-93`
- 时间线生成: `backtest/datafeed.go:96-115`
- 市场数据组装: `backtest/datafeed.go:141-171`
---
## 3. 模拟引擎 (Simulation Engine)
**核心文件:** `backtest/runner.go`
### 3.1 主循环
```go
// backtest/runner.go:232-264
func (r *Runner) loop() {
for _, ts := range r.feed.DecisionTimes() {
if r.isPaused() {
break
}
r.stepOnce(ts)
}
}
```
### 3.2 单步执行
```go
// backtest/runner.go:266-471
func (r *Runner) stepOnce(ts int64) {
// 1. 获取当前 K 线时间戳
// 2. 构建市场数据
// 3. 检查决策触发条件 (每 N 根 K 线)
// 4. 执行决策周期 (如果触发)
// 5. 检查爆仓
// 6. 更新状态并记录
}
```
### 3.3 状态管理
```go
// backtest/types.go:31-47
type BacktestState struct {
BarIndex int // 当前 K 线索引
Cash float64 // 可用余额
Equity float64 // 总权益
UnrealizedPnL float64 // 未实现盈亏
RealizedPnL float64 // 已实现盈亏
MaxEquity float64 // 最高权益
MinEquity float64 // 最低权益
MaxDrawdownPct float64 // 最大回撤
Positions map[string]*position // 持仓
}
```
---
## 4. AI 决策 (AI Decision Making)
**核心文件:** `backtest/runner.go`
### 4.1 决策上下文构建
```go
// backtest/runner.go:473-532
func (r *Runner) buildDecisionContext() *decision.Context {
return &decision.Context{
CurrentTime: "2023-12-15 10:30:00 UTC",
RuntimeMinutes: elapsed,
CallCount: cycleNumber,
Account: {
TotalEquity, AvailableBalance, TotalPnL, MarginUsedPct
},
Positions: []PositionInfo{...},
CandidateCoins: []string{symbols...},
MarketDataMap: map[symbol]*market.Data{...},
MultiTFMarket: map[symbol]map[timeframe]*market.Data{...},
}
}
```
### 4.2 AI 调用
```go
// backtest/runner.go:544-563
func (r *Runner) invokeAIWithRetry() (*decision.FullDecision, error) {
// 最多重试 3 次
// 指数退避: 500ms, 1000ms, 1500ms
// 使用 decision.GetFullDecisionWithStrategy() 统一提示词生成
}
```
### 4.3 AI 缓存
```go
// backtest/aicache.go:127-168
// 缓存键: SHA256(context payload)
// 包含: variant, timestamp, account, positions, market data
```
### 4.4 支持的 AI 模型
| 模型 | 客户端文件 |
|------|-----------|
| DeepSeek | `mcp/deepseek_client.go` |
| Qwen | `mcp/qwen_client.go` |
| Claude | `mcp/claude_client.go` |
| Gemini | `mcp/gemini_client.go` |
| Grok | `mcp/grok_client.go` |
| OpenAI | `mcp/openai_client.go` |
| Kimi | `mcp/kimi_client.go` |
---
## 5. 性能指标 (Performance Metrics)
**核心文件:** `backtest/metrics.go`
### 5.1 指标计算
| 指标 | 公式 | 代码位置 |
|------|------|----------|
| **总收益率** | (最终权益 - 初始资金) / 初始资金 × 100 | metrics.go:36-42 |
| **最大回撤** | max((峰值 - 当前) / 峰值 × 100) | metrics.go:64-91 |
| **夏普比率** | 平均收益 / 收益标准差 | metrics.go:94-138 |
| **胜率** | 盈利交易数 / 总交易数 × 100 | metrics.go:180-181 |
| **盈亏比** | 总盈利 / 总亏损 | metrics.go:189-193 |
### 5.2 交易统计
```go
// backtest/metrics.go:141-225
type TradeMetrics struct {
TotalTrades int
WinningTrades int
LosingTrades int
AvgWin float64
AvgLoss float64
BestSymbol string
WorstSymbol string
SymbolStats map[string]*SymbolStat
}
```
---
## 6. 权益曲线 (Equity Curve)
**核心文件:** `backtest/equity.go`
### 6.1 权益点结构
```json
{
"ts": 1702566000000,
"equity": 10500.50,
"available": 8000.00,
"pnl": 500.50,
"pnl_pct": 5.005,
"dd_pct": 2.34,
"cycle": 42
}
```
### 6.2 权益更新
```go
// backtest/runner.go:829-872
func (r *Runner) updateState() {
// 1. 计算总权益: cash + margin + 未实现盈亏
// 2. 追踪峰值 (MaxEquity)
// 3. 追踪谷值 (MinEquity)
// 4. 重新计算回撤: (MaxEquity - Equity) / MaxEquity × 100
}
```
### 6.3 数据重采样
```go
// backtest/equity.go:10-50
func ResampleEquity(points []EquityPoint, timeframe string) []EquityPoint {
// 按时间周期分桶
// 保留每个桶的最后一个点
}
```
---
## 7. 结果存储 (Result Storage)
**核心文件:** `backtest/storage.go`, `store/backtest.go`
### 7.1 文件存储结构
```
backtests/
├── <run_id>/
│ ├── run.json # 运行元数据
│ ├── checkpoint.json # 检查点 (用于恢复)
│ ├── equity.jsonl # 权益曲线 (逐行 JSON)
│ ├── trades.jsonl # 交易记录 (逐行 JSON)
│ ├── metrics.json # 性能指标
│ ├── progress.json # 进度信息
│ ├── ai_cache.json # AI 决策缓存
│ └── decision_logs/ # 决策日志
│ ├── 0.json
│ ├── 1.json
│ └── ...
```
### 7.2 数据库表结构
```sql
-- 回测运行元数据
CREATE TABLE backtest_runs (
run_id TEXT PRIMARY KEY,
user_id TEXT,
config_json TEXT,
state TEXT, -- pending, running, completed, failed
processed_bars INTEGER,
progress_pct REAL,
equity_last REAL,
max_drawdown_pct REAL,
liquidated BOOLEAN,
ai_provider TEXT,
ai_model TEXT,
created_at DATETIME,
updated_at DATETIME
);
-- 权益曲线
CREATE TABLE backtest_equity (
id INTEGER PRIMARY KEY,
run_id TEXT,
ts INTEGER,
equity REAL,
available REAL,
pnl REAL,
pnl_pct REAL,
dd_pct REAL,
cycle INTEGER
);
-- 交易记录
CREATE TABLE backtest_trades (
id INTEGER PRIMARY KEY,
run_id TEXT,
ts INTEGER,
symbol TEXT,
action TEXT,
side TEXT,
qty REAL,
price REAL,
fee REAL,
slippage REAL,
realized_pnl REAL,
leverage INTEGER,
liquidation BOOLEAN
);
-- 性能指标
CREATE TABLE backtest_metrics (
run_id TEXT PRIMARY KEY,
payload BLOB,
updated_at DATETIME
);
-- 检查点 (暂停/恢复)
CREATE TABLE backtest_checkpoints (
run_id TEXT PRIMARY KEY,
payload BLOB,
updated_at DATETIME
);
```
---
## 8. API 接口
**核心文件:** `api/backtest.go`
### 8.1 接口列表
| 接口 | 方法 | 说明 |
|------|------|------|
| `/backtest/start` | POST | 开始回测 |
| `/backtest/pause` | POST | 暂停回测 |
| `/backtest/resume` | POST | 恢复回测 |
| `/backtest/stop` | POST | 停止回测 |
| `/backtest/status` | GET | 获取状态 |
| `/backtest/runs` | GET | 列出所有回测 |
| `/backtest/equity` | GET | 获取权益曲线 |
| `/backtest/trades` | GET | 获取交易记录 |
| `/backtest/metrics` | GET | 获取性能指标 |
| `/backtest/trace` | GET | 获取决策日志 |
| `/backtest/export` | GET | 导出 ZIP |
| `/backtest/delete` | POST | 删除回测 |
### 8.2 请求示例
```bash
# 开始回测
POST /backtest/start
{
"config": {
"run_id": "bt_20231215",
"symbols": ["BTCUSDT", "ETHUSDT"],
"timeframes": ["3m", "15m", "4h"],
"start_ts": 1702566000,
"end_ts": 1702652400,
"initial_balance": 10000,
"ai_model_id": "model_001"
}
}
# 获取权益曲线
GET /backtest/equity?run_id=bt_20231215&tf=1h&limit=1000
# 获取指标
GET /backtest/metrics?run_id=bt_20231215
```
### 8.3 响应示例
```json
// 状态响应
{
"run_id": "bt_20231215",
"state": "running",
"progress_pct": 45.5,
"processed_bars": 1234,
"equity": 10234.50,
"unrealized_pnl": 234.50
}
// 指标响应
{
"total_return_pct": 12.34,
"max_drawdown_pct": 5.67,
"sharpe_ratio": 1.89,
"profit_factor": 2.34,
"win_rate": 65.5,
"trades": 123
}
```
---
## 9. 账户与持仓管理
**核心文件:** `backtest/account.go`
### 9.1 持仓结构
```go
type position struct {
Symbol string
Side string // "long" 或 "short"
Quantity float64
EntryPrice float64
Leverage int
Margin float64 // 保证金
Notional float64 // 名义价值
LiquidationPrice float64 // 爆仓价格
OpenTime int64
}
```
### 9.2 开仓逻辑
```go
// backtest/account.go:61-104
func (a *BacktestAccount) Open(symbol, side string, qty, price float64, leverage int) {
// 1. 应用滑点
// 2. 计算名义价值 (qty × price)
// 3. 计算保证金 (notional / leverage)
// 4. 扣除保证金 + 手续费
// 5. 创建/加仓
// 6. 计算爆仓价格
}
```
### 9.3 平仓逻辑
```go
// backtest/account.go:106-140
func (a *BacktestAccount) Close(symbol, side string, qty, price float64) {
// 1. 验证持仓存在
// 2. 应用滑点 (反向)
// 3. 计算已实现盈亏
// long: (exit - entry) × qty
// short: (entry - exit) × qty
// 4. 返还保证金 + 盈亏 - 手续费
// 5. 更新/删除持仓
}
```
### 9.4 爆仓价格计算
```go
// backtest/account.go:177-186
func computeLiquidation(entry float64, leverage int, side string) float64 {
if side == "long" {
return entry * (1 - 1.0/float64(leverage)) // 做多: 下跌爆仓
}
return entry * (1 + 1.0/float64(leverage)) // 做空: 上涨爆仓
}
```
---
## 10. 检查点与恢复
**核心文件:** `backtest/runner.go`
### 10.1 检查点结构
```json
{
"bar_index": 1234,
"bar_ts": 1702609200000,
"cash": 8000.00,
"equity": 10234.50,
"max_equity": 10500.00,
"max_drawdown_pct": 5.67,
"positions": [...],
"decision_cycle": 62,
"liquidated": false
}
```
### 10.2 检查点触发
```go
// backtest/runner.go:874-898
func (r *Runner) maybeCheckpoint() {
// 每 N 根 K 线保存
// 或每 N 秒保存
}
```
### 10.3 恢复流程
```go
func (r *Runner) RestoreFromCheckpoint() {
// 1. 加载检查点
// 2. 恢复账户状态
// 3. 恢复 K 线索引 (从下一根继续)
// 4. 恢复权益曲线、交易记录
}
```
---
## 核心文件索引
| 模块 | 文件 | 关键方法 |
|------|------|----------|
| **配置** | `backtest/config.go` | `BacktestConfig`, `Validate()` |
| **数据加载** | `backtest/datafeed.go` | `NewDataFeed()`, `loadAll()`, `BuildMarketData()` |
| **模拟引擎** | `backtest/runner.go` | `Start()`, `loop()`, `stepOnce()` |
| **决策** | `backtest/runner.go` | `buildDecisionContext()`, `invokeAIWithRetry()` |
| **执行** | `backtest/runner.go` | `executeDecision()` |
| **账户** | `backtest/account.go` | `Open()`, `Close()`, `TotalEquity()` |
| **指标** | `backtest/metrics.go` | `CalculateMetrics()` |
| **权益** | `backtest/equity.go` | `ResampleEquity()`, `LimitEquityPoints()` |
| **存储** | `backtest/storage.go` | `SaveCheckpoint()`, `appendEquityPoint()` |
| **数据库** | `store/backtest.go` | 表结构和 CRUD 操作 |
| **API** | `api/backtest.go` | HTTP 处理器 |
| **AI 缓存** | `backtest/aicache.go` | `Get()`, `Put()`, `save()` |
---
**文档版本:** 1.0.0
**最后更新:** 2025-01-15
+6 -19
View File
@@ -24,12 +24,12 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
│ NOFX Platform │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
│ │ Strategy │ │ Backtest │ │ Live Trading ││
│ │ Studio │ │ Engine │ │ (Auto Trader) ││
│ └──────┬──────┘ └────────────┘ └──────────┬──────────┘│
│ │ │ │
│ └────────────────┴────────────────────┘ │
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
│ │ Strategy │ │ Live Trading ││
│ │ Studio │ │ (Auto Trader) ││
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
│ │ │
│ └────────────────────────────┘
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Core Services │ │
@@ -57,7 +57,6 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
| Module | Description | Documentation |
|--------|-------------|---------------|
| **Strategy Studio** | Strategy configuration, coin selection, data assembly, AI prompts | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
| **Backtest Engine** | Historical simulation, performance metrics, AI decision replay | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
### Module Overview
@@ -71,16 +70,6 @@ Complete strategy configuration system including:
**[Read Full Documentation →](STRATEGY_MODULE.md)**
#### Backtest Module
Historical trading simulation engine:
- Multi-symbol, multi-timeframe backtesting
- AI decision replay with caching
- Performance metrics (Sharpe, drawdown, win rate)
- Real-time progress streaming via SSE
- Checkpoint and resume support
**[Read Full Documentation →](BACKTEST_MODULE.md)**
---
## Project Structure
@@ -91,7 +80,6 @@ nofx/
├── api/ # HTTP API (Gin framework)
├── trader/ # Trading execution layer
├── strategy/ # Strategy engine
├── backtest/ # Backtest simulation engine
├── market/ # Market data service
├── mcp/ # AI model clients
├── store/ # Database operations
@@ -131,7 +119,6 @@ nofx/
## Quick Links
- [Strategy Module](STRATEGY_MODULE.md) - How strategies work
- [Backtest Module](BACKTEST_MODULE.md) - How backtesting works
- [Getting Started](../getting-started/README.md) - Setup guide
- [FAQ](../faq/README.md) - Frequently asked questions
+6 -19
View File
@@ -24,12 +24,12 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
│ NOFX 平台 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
│ │ 策略 │ │ 回测 实盘交易 ││
│ │ 工作室 │ │ 引擎 │ │ (自动交易员) ││
│ └──────┬──────┘ └────────────┘ └──────────┬──────────┘│
│ │ │ │
│ └────────────────┴────────────────────┘ │
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
│ │ 策略 │ │ 实盘交易 ││
│ │ 工作室 │ │ (自动交易员) ││
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
│ │ │
│ └────────────────────────────┘
│ │ │
│ ┌─────────▼─────────┐ │
│ │ 核心服务 │ │
@@ -57,7 +57,6 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
| 模块 | 描述 | 文档 |
|------|------|------|
| **策略工作室** | 策略配置、币种选择、数据组装、AI 提示词 | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
| **回测引擎** | 历史模拟、性能指标、AI 决策回放 | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
### 模块概览
@@ -71,16 +70,6 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
**[阅读完整文档 →](STRATEGY_MODULE.md)**
#### 回测模块
历史交易模拟引擎:
- 多币种、多时间周期回测
- AI 决策回放与缓存
- 性能指标(夏普比率、最大回撤、胜率)
- SSE 实时进度推送
- 断点续测支持
**[阅读完整文档 →](BACKTEST_MODULE.md)**
---
## 项目结构
@@ -91,7 +80,6 @@ nofx/
├── api/ # HTTP API (Gin 框架)
├── trader/ # 交易执行层
├── strategy/ # 策略引擎
├── backtest/ # 回测模拟引擎
├── market/ # 行情数据服务
├── mcp/ # AI 模型客户端
├── store/ # 数据库操作
@@ -131,7 +119,6 @@ nofx/
## 快速链接
- [策略模块](STRATEGY_MODULE.md) - 策略如何运作
- [回测模块](BACKTEST_MODULE.md) - 回测如何运作
- [快速开始](../getting-started/README.zh-CN.md) - 部署指南
- [常见问题](../faq/README.md) - FAQ
-1
View File
@@ -79,7 +79,6 @@ Tương thích với **[ClawRouter](https://github.com/BlockRunAI/ClawRouter)**
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
| **Telegram Agent** | Chat với trợ lý giao dịch — streaming, gọi công cụ, bộ nhớ |
| **Backtest Lab** | Mô phỏng lịch sử, đường vốn và chỉ số hiệu suất |
| **Dashboard** | Vị thế trực tiếp, P/L, nhật ký quyết định AI với Chain of Thought |
### Thị trường
-1
View File
@@ -191,7 +191,6 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|:--|:--|
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
| [回测模块](../../architecture/BACKTEST_MODULE.md) | 历史模拟、指标计算 |
| [常见问题](../../faq/README.md) | FAQ |
| [快速开始](../../getting-started/README.md) | 部署指南 |
@@ -243,7 +243,6 @@ s.route(protected, "GET", "/statistics", "Trading statistics (?trader_id
Note: keep the existing special-case handlers that don't use `s.route` unchanged:
- `api.Any("/health", ...)` — health check, no need to document
- `api.GET("/crypto/...")` — crypto/encryption routes, bot doesn't need these
- `backtest.*` routes (registered separately) — add descriptions to the backtest group similarly
**Step 3: Build**
+1 -1
View File
@@ -993,7 +993,7 @@ func Start(cfg *config.Config, st *store.Store, tm *manager.TraderManager) {
logger.Infof("🤖 Telegram bot started: @%s", bot.Self.UserName)
// Build the LLM client for intent parsing (use DeepSeek by default, same as backtest)
// Build the LLM client for intent parsing (use DeepSeek by default)
llmClient := mcp.New()
// Configure with whatever key is available in env (intent parsing is lightweight)
// The service layer will use store to get user-configured models for actual trading
@@ -2488,7 +2488,6 @@ KNOWN_ISSUES = [
3. **金融量化**
- Lo, Andrew W. "The Adaptive Markets Hypothesis." Journal of Portfolio Management, 2004.
- Bailey et al. "The Probability of Backtest Overfitting." Journal of Computational Finance, 2014.
### 13.3 代码示例索引
+2 -20
View File
@@ -3,13 +3,11 @@ package main
import (
"nofx/api"
"nofx/auth"
"nofx/backtest"
"nofx/config"
"nofx/crypto"
"nofx/telemetry"
"nofx/logger"
"nofx/manager"
"nofx/mcp"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
@@ -81,7 +79,6 @@ func main() {
logger.Fatalf("❌ Failed to initialize database: %v", err)
}
defer st.Close()
backtest.UseDatabaseWithType(st.DB(), st.DBType() == store.DBTypePostgres)
// Initialize installation ID for experience improvement (anonymous statistics)
initInstallationID(st)
@@ -98,13 +95,8 @@ func main() {
// time.Sleep(500 * time.Millisecond)
logger.Info("📊 Using CoinAnk API for all market data (WebSocket cache disabled)")
// Create TraderManager and BacktestManager
// Create TraderManager
traderManager := manager.NewTraderManager()
mcpClient := newSharedMCPClient()
backtestManager := backtest.NewManager(mcpClient)
if err := backtestManager.RestoreRuns(); err != nil {
logger.Warnf("⚠️ Failed to restore backtest history: %v", err)
}
// Load all traders from database to memory (may auto-start traders with IsRunning=true)
if err := traderManager.LoadTradersFromStore(st); err != nil {
@@ -132,7 +124,7 @@ func main() {
}
// Start API server
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
server := api.NewServer(traderManager, st, cryptoService, cfg.APIServerPort)
// Create hot-reload channel for Telegram bot; wire it to the API server
// so that POST /api/telegram can trigger a bot restart when the token changes.
@@ -163,16 +155,6 @@ func main() {
logger.Info("✅ System shut down safely")
}
// newSharedMCPClient creates a shared MCP AI client (for backtesting)
func newSharedMCPClient() mcp.AIClient {
apiKey := os.Getenv("DEEPSEEK_API_KEY")
if apiKey == "" {
logger.Warn("⚠️ DEEPSEEK_API_KEY not set, AI features will be unavailable")
return nil
}
return mcp.NewAIClientByProvider("deepseek")
}
// initInstallationID initializes the anonymous installation ID for experience improvement
// This ID is persisted in database and used for anonymous usage statistics
func initInstallationID(st *store.Store) {
+1 -1
View File
@@ -602,7 +602,7 @@ func parseFloat(v interface{}) (float64, error) {
}
}
// BuildDataFromKlines constructs market data snapshot from preloaded K-line series (for backtesting/simulation).
// BuildDataFromKlines constructs market data snapshot from preloaded K-line series.
func BuildDataFromKlines(symbol string, primary []Kline, longer []Kline) (*Data, error) {
if len(primary) == 0 {
return nil, fmt.Errorf("primary series is empty")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

-574
View File
@@ -1,574 +0,0 @@
package store
import (
"encoding/json"
"fmt"
"time"
"gorm.io/gorm"
)
// BacktestStore backtest data storage
type BacktestStore struct {
db *gorm.DB
}
// NewBacktestStore creates a new backtest store
func NewBacktestStore(db *gorm.DB) *BacktestStore {
return &BacktestStore{db: db}
}
// isPostgres checks if the database is PostgreSQL
func (s *BacktestStore) isPostgres() bool {
return s.db.Dialector.Name() == "postgres"
}
// RunState backtest state
type RunState string
const (
RunStateCreated RunState = "created"
RunStateRunning RunState = "running"
RunStatePaused RunState = "paused"
RunStateCompleted RunState = "completed"
RunStateFailed RunState = "failed"
)
// RunMetadata backtest metadata
type RunMetadata struct {
RunID string `json:"run_id"`
UserID string `json:"user_id"`
Version int `json:"version"`
State RunState `json:"state"`
Label string `json:"label"`
LastError string `json:"last_error"`
Summary RunSummary `json:"summary"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RunSummary backtest 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"`
}
// EquityPoint equity point
type EquityPoint struct {
Timestamp int64 `json:"timestamp"`
Equity float64 `json:"equity"`
Available float64 `json:"available"`
PnL float64 `json:"pnl"`
PnLPct float64 `json:"pnl_pct"`
DrawdownPct float64 `json:"drawdown_pct"`
Cycle int `json:"cycle"`
}
// TradeEvent trade event
type TradeEvent struct {
Timestamp int64 `json:"timestamp"`
Symbol string `json:"symbol"`
Action string `json:"action"`
Side string `json:"side"`
Quantity float64 `json:"quantity"`
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"`
Cycle int `json:"cycle"`
PositionAfter float64 `json:"position_after"`
LiquidationFlag bool `json:"liquidation_flag"`
Note string `json:"note"`
}
// RunIndexEntry backtest index entry
type RunIndexEntry struct {
RunID string `json:"run_id"`
State string `json:"state"`
Symbols []string `json:"symbols"`
DecisionTF string `json:"decision_tf"`
EquityLast float64 `json:"equity_last"`
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
StartTS int64 `json:"start_ts"`
EndTS int64 `json:"end_ts"`
CreatedAtISO string `json:"created_at"`
UpdatedAtISO string `json:"updated_at"`
}
// BacktestRun GORM model for backtest_runs table
type BacktestRun struct {
RunID string `gorm:"column:run_id;primaryKey"`
UserID string `gorm:"column:user_id;not null;default:''"`
ConfigJSON []byte `gorm:"column:config_json"`
State string `gorm:"column:state;not null;default:created"`
Label string `gorm:"column:label;default:''"`
SymbolCount int `gorm:"column:symbol_count;default:0"`
DecisionTF string `gorm:"column:decision_tf;default:''"`
ProcessedBars int `gorm:"column:processed_bars;default:0"`
ProgressPct float64 `gorm:"column:progress_pct;default:0"`
EquityLast float64 `gorm:"column:equity_last;default:0"`
MaxDrawdownPct float64 `gorm:"column:max_drawdown_pct;default:0"`
Liquidated bool `gorm:"column:liquidated;default:false"`
LiquidationNote string `gorm:"column:liquidation_note;default:''"`
PromptTemplate string `gorm:"column:prompt_template;default:''"`
CustomPrompt string `gorm:"column:custom_prompt;default:''"`
OverridePrompt bool `gorm:"column:override_prompt;default:false"`
AIProvider string `gorm:"column:ai_provider;default:''"`
AIModel string `gorm:"column:ai_model;default:''"`
LastError string `gorm:"column:last_error;default:''"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
}
func (BacktestRun) TableName() string {
return "backtest_runs"
}
// BacktestCheckpoint GORM model
type BacktestCheckpoint struct {
RunID string `gorm:"column:run_id;primaryKey"`
Payload []byte `gorm:"column:payload;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
}
func (BacktestCheckpoint) TableName() string {
return "backtest_checkpoints"
}
// BacktestEquity GORM model
type BacktestEquity struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"`
TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_equity_run_ts"`
Equity float64 `gorm:"column:equity;not null"`
Available float64 `gorm:"column:available;not null"`
PnL float64 `gorm:"column:pnl;not null"`
PnLPct float64 `gorm:"column:pnl_pct;not null"`
DDPct float64 `gorm:"column:dd_pct;not null"`
Cycle int `gorm:"column:cycle;not null"`
}
func (BacktestEquity) TableName() string {
return "backtest_equity"
}
// BacktestTrade GORM model
type BacktestTrade struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"`
TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_trades_run_ts"`
Symbol string `gorm:"column:symbol;not null"`
Action string `gorm:"column:action;not null"`
Side string `gorm:"column:side;default:''"`
Qty float64 `gorm:"column:qty;default:0"`
Price float64 `gorm:"column:price;default:0"`
Fee float64 `gorm:"column:fee;default:0"`
Slippage float64 `gorm:"column:slippage;default:0"`
OrderValue float64 `gorm:"column:order_value;default:0"`
RealizedPnL float64 `gorm:"column:realized_pnl;default:0"`
Leverage int `gorm:"column:leverage;default:0"`
Cycle int `gorm:"column:cycle;default:0"`
PositionAfter float64 `gorm:"column:position_after;default:0"`
Liquidation bool `gorm:"column:liquidation;default:false"`
Note string `gorm:"column:note;default:''"`
}
func (BacktestTrade) TableName() string {
return "backtest_trades"
}
// BacktestMetrics GORM model
type BacktestMetrics struct {
RunID string `gorm:"column:run_id;primaryKey"`
Payload []byte `gorm:"column:payload;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
}
func (BacktestMetrics) TableName() string {
return "backtest_metrics"
}
// BacktestDecision GORM model
type BacktestDecision struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
RunID string `gorm:"column:run_id;not null;index:idx_backtest_decisions_run_cycle"`
Cycle int `gorm:"column:cycle;not null;index:idx_backtest_decisions_run_cycle"`
Payload []byte `gorm:"column:payload;not null"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
}
func (BacktestDecision) TableName() string {
return "backtest_decisions"
}
// initTables initializes backtest related tables
func (s *BacktestStore) initTables() error {
// For PostgreSQL with existing tables, skip AutoMigrate to avoid type conflicts
if s.db.Dialector.Name() == "postgres" {
var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists)
if tableExists > 0 {
// Tables exist - fix column types and ensure indexes exist
// Fix ts column type from INTEGER to BIGINT (timestamps in milliseconds exceed int4 max)
s.db.Exec(`ALTER TABLE backtest_equity ALTER COLUMN ts TYPE BIGINT`)
s.db.Exec(`ALTER TABLE backtest_trades ALTER COLUMN ts TYPE BIGINT`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`)
return nil
}
}
// AutoMigrate all backtest tables
if err := s.db.AutoMigrate(
&BacktestRun{},
&BacktestCheckpoint{},
&BacktestEquity{},
&BacktestTrade{},
&BacktestMetrics{},
&BacktestDecision{},
); err != nil {
return fmt.Errorf("failed to migrate backtest tables: %w", err)
}
return nil
}
// SaveCheckpoint saves checkpoint
func (s *BacktestStore) SaveCheckpoint(runID string, payload []byte) error {
checkpoint := BacktestCheckpoint{
RunID: runID,
Payload: payload,
}
return s.db.Save(&checkpoint).Error
}
// LoadCheckpoint loads checkpoint
func (s *BacktestStore) LoadCheckpoint(runID string) ([]byte, error) {
var checkpoint BacktestCheckpoint
err := s.db.Where("run_id = ?", runID).First(&checkpoint).Error
if err != nil {
return nil, err
}
return checkpoint.Payload, nil
}
// SaveRunMetadata saves run metadata
func (s *BacktestStore) SaveRunMetadata(meta *RunMetadata) error {
run := BacktestRun{
RunID: meta.RunID,
UserID: meta.UserID,
State: string(meta.State),
Label: meta.Label,
LastError: meta.LastError,
SymbolCount: meta.Summary.SymbolCount,
DecisionTF: meta.Summary.DecisionTF,
ProcessedBars: meta.Summary.ProcessedBars,
ProgressPct: meta.Summary.ProgressPct,
EquityLast: meta.Summary.EquityLast,
MaxDrawdownPct: meta.Summary.MaxDrawdownPct,
Liquidated: meta.Summary.Liquidated,
LiquidationNote: meta.Summary.LiquidationNote,
CreatedAt: meta.CreatedAt,
UpdatedAt: meta.UpdatedAt,
}
return s.db.Save(&run).Error
}
// LoadRunMetadata loads run metadata
func (s *BacktestStore) LoadRunMetadata(runID string) (*RunMetadata, error) {
var run BacktestRun
err := s.db.Where("run_id = ?", runID).First(&run).Error
if err != nil {
return nil, err
}
return &RunMetadata{
RunID: run.RunID,
UserID: run.UserID,
Version: 1,
State: RunState(run.State),
Label: run.Label,
LastError: run.LastError,
Summary: RunSummary{
SymbolCount: run.SymbolCount,
DecisionTF: run.DecisionTF,
ProcessedBars: run.ProcessedBars,
ProgressPct: run.ProgressPct,
EquityLast: run.EquityLast,
MaxDrawdownPct: run.MaxDrawdownPct,
Liquidated: run.Liquidated,
LiquidationNote: run.LiquidationNote,
},
CreatedAt: run.CreatedAt,
UpdatedAt: run.UpdatedAt,
}, nil
}
// ListRunIDs lists all run IDs
func (s *BacktestStore) ListRunIDs() ([]string, error) {
var runs []BacktestRun
err := s.db.Order("updated_at DESC").Find(&runs).Error
if err != nil {
return nil, err
}
ids := make([]string, len(runs))
for i, run := range runs {
ids[i] = run.RunID
}
return ids, nil
}
// AppendEquityPoint appends equity point
func (s *BacktestStore) AppendEquityPoint(runID string, point EquityPoint) error {
eq := BacktestEquity{
RunID: runID,
TS: point.Timestamp,
Equity: point.Equity,
Available: point.Available,
PnL: point.PnL,
PnLPct: point.PnLPct,
DDPct: point.DrawdownPct,
Cycle: point.Cycle,
}
return s.db.Create(&eq).Error
}
// LoadEquityPoints loads equity points
func (s *BacktestStore) LoadEquityPoints(runID string) ([]EquityPoint, error) {
var eqs []BacktestEquity
err := s.db.Where("run_id = ?", runID).Order("ts ASC").Find(&eqs).Error
if err != nil {
return nil, err
}
points := make([]EquityPoint, len(eqs))
for i, eq := range eqs {
points[i] = EquityPoint{
Timestamp: eq.TS,
Equity: eq.Equity,
Available: eq.Available,
PnL: eq.PnL,
PnLPct: eq.PnLPct,
DrawdownPct: eq.DDPct,
Cycle: eq.Cycle,
}
}
return points, nil
}
// AppendTradeEvent appends trade event
func (s *BacktestStore) AppendTradeEvent(runID string, event TradeEvent) error {
trade := BacktestTrade{
RunID: runID,
TS: event.Timestamp,
Symbol: event.Symbol,
Action: event.Action,
Side: event.Side,
Qty: event.Quantity,
Price: event.Price,
Fee: event.Fee,
Slippage: event.Slippage,
OrderValue: event.OrderValue,
RealizedPnL: event.RealizedPnL,
Leverage: event.Leverage,
Cycle: event.Cycle,
PositionAfter: event.PositionAfter,
Liquidation: event.LiquidationFlag,
Note: event.Note,
}
return s.db.Create(&trade).Error
}
// LoadTradeEvents loads trade events
func (s *BacktestStore) LoadTradeEvents(runID string) ([]TradeEvent, error) {
var trades []BacktestTrade
err := s.db.Where("run_id = ?", runID).Order("ts ASC").Find(&trades).Error
if err != nil {
return nil, err
}
events := make([]TradeEvent, len(trades))
for i, trade := range trades {
events[i] = TradeEvent{
Timestamp: trade.TS,
Symbol: trade.Symbol,
Action: trade.Action,
Side: trade.Side,
Quantity: trade.Qty,
Price: trade.Price,
Fee: trade.Fee,
Slippage: trade.Slippage,
OrderValue: trade.OrderValue,
RealizedPnL: trade.RealizedPnL,
Leverage: trade.Leverage,
Cycle: trade.Cycle,
PositionAfter: trade.PositionAfter,
LiquidationFlag: trade.Liquidation,
Note: trade.Note,
}
}
return events, nil
}
// SaveMetrics saves metrics
func (s *BacktestStore) SaveMetrics(runID string, payload []byte) error {
metrics := BacktestMetrics{
RunID: runID,
Payload: payload,
}
return s.db.Save(&metrics).Error
}
// LoadMetrics loads metrics
func (s *BacktestStore) LoadMetrics(runID string) ([]byte, error) {
var metrics BacktestMetrics
err := s.db.Where("run_id = ?", runID).First(&metrics).Error
if err != nil {
return nil, err
}
return metrics.Payload, nil
}
// SaveDecisionRecord saves decision record
func (s *BacktestStore) SaveDecisionRecord(runID string, cycle int, payload []byte) error {
decision := BacktestDecision{
RunID: runID,
Cycle: cycle,
Payload: payload,
}
return s.db.Create(&decision).Error
}
// LoadDecisionRecords loads decision records
func (s *BacktestStore) LoadDecisionRecords(runID string, limit, offset int) ([]json.RawMessage, error) {
var decisions []BacktestDecision
err := s.db.Where("run_id = ?", runID).
Order("id DESC").
Limit(limit).
Offset(offset).
Find(&decisions).Error
if err != nil {
return nil, err
}
records := make([]json.RawMessage, len(decisions))
for i, d := range decisions {
records[i] = json.RawMessage(d.Payload)
}
return records, nil
}
// LoadLatestDecision loads latest decision
func (s *BacktestStore) LoadLatestDecision(runID string, cycle int) ([]byte, error) {
var decision BacktestDecision
query := s.db.Where("run_id = ?", runID)
if cycle > 0 {
query = query.Where("cycle = ?", cycle)
}
err := query.Order("created_at DESC").First(&decision).Error
if err != nil {
return nil, err
}
return decision.Payload, nil
}
// UpdateProgress updates progress
func (s *BacktestStore) UpdateProgress(runID string, progressPct, equity float64, barIndex int, liquidated bool) error {
return s.db.Model(&BacktestRun{}).Where("run_id = ?", runID).Updates(map[string]interface{}{
"progress_pct": progressPct,
"equity_last": equity,
"processed_bars": barIndex,
"liquidated": liquidated,
}).Error
}
// ListIndexEntries lists index entries
func (s *BacktestStore) ListIndexEntries() ([]RunIndexEntry, error) {
var runs []BacktestRun
err := s.db.Order("updated_at DESC").Find(&runs).Error
if err != nil {
return nil, err
}
entries := make([]RunIndexEntry, len(runs))
for i, run := range runs {
entry := RunIndexEntry{
RunID: run.RunID,
State: run.State,
DecisionTF: run.DecisionTF,
EquityLast: run.EquityLast,
MaxDrawdownPct: run.MaxDrawdownPct,
CreatedAtISO: run.CreatedAt.Format(time.RFC3339),
UpdatedAtISO: run.UpdatedAt.Format(time.RFC3339),
Symbols: make([]string, 0, run.SymbolCount),
}
if len(run.ConfigJSON) > 0 {
var cfg struct {
Symbols []string `json:"symbols"`
StartTS int64 `json:"start_ts"`
EndTS int64 `json:"end_ts"`
}
if json.Unmarshal(run.ConfigJSON, &cfg) == nil {
entry.Symbols = cfg.Symbols
entry.StartTS = cfg.StartTS
entry.EndTS = cfg.EndTS
}
}
entries[i] = entry
}
return entries, nil
}
// DeleteRun deletes run
func (s *BacktestStore) DeleteRun(runID string) error {
// Delete related records first (cascade may not work in all cases)
s.db.Where("run_id = ?", runID).Delete(&BacktestCheckpoint{})
s.db.Where("run_id = ?", runID).Delete(&BacktestEquity{})
s.db.Where("run_id = ?", runID).Delete(&BacktestTrade{})
s.db.Where("run_id = ?", runID).Delete(&BacktestMetrics{})
s.db.Where("run_id = ?", runID).Delete(&BacktestDecision{})
return s.db.Where("run_id = ?", runID).Delete(&BacktestRun{}).Error
}
// SaveConfig saves config
func (s *BacktestStore) SaveConfig(runID, userID, template, customPrompt, provider, model string, override bool, configJSON []byte) error {
if userID == "" {
userID = "default"
}
run := BacktestRun{
RunID: runID,
UserID: userID,
ConfigJSON: configJSON,
PromptTemplate: template,
CustomPrompt: customPrompt,
OverridePrompt: override,
AIProvider: provider,
AIModel: model,
}
return s.db.Save(&run).Error
}
// LoadConfig loads config
func (s *BacktestStore) LoadConfig(runID string) ([]byte, error) {
var run BacktestRun
err := s.db.Where("run_id = ?", runID).First(&run).Error
if err != nil {
return nil, err
}
return run.ConfigJSON, nil
}
-14
View File
@@ -23,7 +23,6 @@ type Store struct {
exchange *ExchangeStore
trader *TraderStore
decision *DecisionStore
backtest *BacktestStore
position *PositionStore
strategy *StrategyStore
equity *EquityStore
@@ -143,9 +142,6 @@ func (s *Store) initTables() error {
if err := s.Decision().initTables(); err != nil {
return fmt.Errorf("failed to initialize decision log tables: %w", err)
}
if err := s.Backtest().initTables(); err != nil {
return fmt.Errorf("failed to initialize backtest tables: %w", err)
}
if err := s.Position().InitTables(); err != nil {
return fmt.Errorf("failed to initialize position tables: %w", err)
}
@@ -237,16 +233,6 @@ func (s *Store) Decision() *DecisionStore {
return s.decision
}
// Backtest gets backtest data storage
func (s *Store) Backtest() *BacktestStore {
s.mu.Lock()
defer s.mu.Unlock()
if s.backtest == nil {
s.backtest = NewBacktestStore(s.gdb)
}
return s.backtest
}
// Position gets position storage
func (s *Store) Position() *PositionStore {
s.mu.Lock()
-9
View File
@@ -24,7 +24,6 @@ import { t } from './i18n/translations'
import { useSystemConfig } from './hooks/useSystemConfig'
import { OFFICIAL_LINKS } from './constants/branding'
import { BacktestPage } from './components/backtest/BacktestPage'
import type {
SystemStatus,
AccountInfo,
@@ -39,7 +38,6 @@ type Page =
| 'competition'
| 'traders'
| 'trader'
| 'backtest'
| 'strategy'
| 'strategy-market'
| 'data'
@@ -66,7 +64,6 @@ function App() {
const hash = window.location.hash.slice(1) // 去掉 #
if (path === '/traders' || hash === 'traders') return 'traders'
if (path === '/backtest' || hash === 'backtest') return 'backtest'
if (path === '/strategy' || hash === 'strategy') return 'strategy'
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
if (path === '/data' || hash === 'data') return 'data'
@@ -92,7 +89,6 @@ function App() {
'data': '/data',
'traders': '/traders',
'trader': '/dashboard',
'backtest': '/backtest',
'strategy': '/strategy',
'faq': '/faq',
'login': '/login',
@@ -147,8 +143,6 @@ function App() {
if (path === '/traders' || hash === 'traders') {
setCurrentPage('traders')
} else if (path === '/backtest' || hash === 'backtest') {
setCurrentPage('backtest')
} else if (path === '/strategy' || hash === 'strategy') {
setCurrentPage('strategy')
} else if (path === '/strategy-market' || hash === 'strategy-market') {
@@ -410,7 +404,6 @@ function App() {
'strategy-market': '/strategy-market',
'traders': '/traders',
'trader': '/dashboard',
'backtest': '/backtest',
'strategy': '/strategy',
'faq': '/faq',
}
@@ -496,8 +489,6 @@ function App() {
setCurrentPage('trader')
}}
/>
) : currentPage === 'backtest' ? (
<BacktestPage />
) : currentPage === 'strategy' ? (
<StrategyStudioPage />
) : (
@@ -1,434 +0,0 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { motion } from 'framer-motion'
import {
createChart,
ColorType,
CrosshairMode,
CandlestickSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type UTCTimestamp,
type SeriesMarker,
} from 'lightweight-charts'
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceDot,
} from 'recharts'
import {
Clock,
AlertTriangle,
RefreshCw,
CandlestickChart as CandlestickIcon,
} from 'lucide-react'
import { api } from '../../lib/api'
import { t, type Language } from '../../i18n/translations'
import type {
BacktestEquityPoint,
BacktestTradeEvent,
BacktestKlinesResponse,
} from '../../types'
// ============ Equity Chart (Recharts) ============
interface EquityChartProps {
equity: BacktestEquityPoint[]
trades: BacktestTradeEvent[]
}
export function EquityChart({ equity, trades }: EquityChartProps) {
const chartData = useMemo(() => {
return equity.map((point) => ({
time: new Date(point.ts).toLocaleString(),
ts: point.ts,
equity: point.equity,
pnl_pct: point.pnl_pct,
}))
}, [equity])
const tradeMarkers = useMemo(() => {
if (!trades.length || !equity.length) return []
return trades
.filter((t) => t.action.includes('open') || t.action.includes('close'))
.map((trade) => {
const closest = equity.reduce((prev, curr) =>
Math.abs(curr.ts - trade.ts) < Math.abs(prev.ts - trade.ts) ? curr : prev
)
return {
ts: closest.ts,
equity: closest.equity,
action: trade.action,
symbol: trade.symbol,
isOpen: trade.action.includes('open'),
}
})
.slice(-30)
}, [trades, equity])
return (
<div className="w-full h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.4} />
<stop offset="95%" stopColor="#F0B90B" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(43, 49, 57, 0.5)" strokeDasharray="3 3" />
<XAxis
dataKey="time"
tick={{ fill: '#848E9C', fontSize: 10 }}
axisLine={{ stroke: '#2B3139' }}
tickLine={{ stroke: '#2B3139' }}
hide
/>
<YAxis
tick={{ fill: '#848E9C', fontSize: 10 }}
axisLine={{ stroke: '#2B3139' }}
tickLine={{ stroke: '#2B3139' }}
width={60}
domain={['auto', 'auto']}
/>
<Tooltip
contentStyle={{
background: '#1E2329',
border: '1px solid #2B3139',
borderRadius: 8,
color: '#EAECEF',
}}
labelStyle={{ color: '#848E9C' }}
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Equity']}
/>
<Area
type="monotone"
dataKey="equity"
stroke="#F0B90B"
strokeWidth={2}
fill="url(#equityGradient)"
dot={false}
activeDot={{ r: 4, fill: '#F0B90B' }}
/>
{tradeMarkers.map((marker, idx) => (
<ReferenceDot
key={`${marker.ts}-${idx}`}
x={chartData.findIndex((d) => d.ts === marker.ts)}
y={marker.equity}
r={4}
fill={marker.isOpen ? '#0ECB81' : '#F6465D'}
stroke={marker.isOpen ? '#0ECB81' : '#F6465D'}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
)
}
// ============ Candlestick Chart with Trade Markers ============
interface CandlestickChartProps {
runId: string
trades: BacktestTradeEvent[]
language: Language
}
export function CandlestickChartComponent({ runId, trades, language }: CandlestickChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const symbols = useMemo(() => {
const symbolSet = new Set(trades.map((t) => t.symbol))
return Array.from(symbolSet).sort()
}, [trades])
const [selectedSymbol, setSelectedSymbol] = useState<string>(symbols[0] || '')
const [selectedTimeframe, setSelectedTimeframe] = useState<string>('15m')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const CHART_TIMEFRAMES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
useEffect(() => {
if (symbols.length > 0 && !symbols.includes(selectedSymbol)) {
setSelectedSymbol(symbols[0])
}
}, [symbols, selectedSymbol])
const symbolTrades = useMemo(() => {
return trades.filter((t) => t.symbol === selectedSymbol)
}, [trades, selectedSymbol])
useEffect(() => {
if (!chartContainerRef.current || !selectedSymbol || !runId) return
const container = chartContainerRef.current
const chart = createChart(container, {
layout: {
background: { type: ColorType.Solid, color: '#0B0E11' },
textColor: '#848E9C',
},
grid: {
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
},
crosshair: {
mode: CrosshairMode.Normal,
},
rightPriceScale: {
borderColor: '#2B3139',
},
timeScale: {
borderColor: '#2B3139',
timeVisible: true,
secondsVisible: false,
},
width: container.clientWidth,
height: 400,
})
chartRef.current = chart
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#0ECB81',
downColor: '#F6465D',
borderUpColor: '#0ECB81',
borderDownColor: '#F6465D',
wickUpColor: '#0ECB81',
wickDownColor: '#F6465D',
})
candleSeriesRef.current = candleSeries
setIsLoading(true)
setError(null)
api
.getBacktestKlines(runId, selectedSymbol, selectedTimeframe)
.then((data: BacktestKlinesResponse) => {
const klineData: CandlestickData<UTCTimestamp>[] = data.klines.map((k) => ({
time: k.time as UTCTimestamp,
open: k.open,
high: k.high,
low: k.low,
close: k.close,
}))
candleSeries.setData(klineData)
const markers: SeriesMarker<UTCTimestamp>[] = symbolTrades
.map((trade) => {
const tradeTime = Math.floor(trade.ts / 1000)
const closestKline = data.klines.reduce((prev, curr) =>
Math.abs(curr.time - tradeTime) < Math.abs(prev.time - tradeTime) ? curr : prev
)
const isOpen = trade.action.includes('open')
const isLong = trade.side === 'long' || trade.action.includes('long')
const pnl = trade.realized_pnl
let text = ''
let color = '#0ECB81'
if (isOpen) {
if (isLong) {
text = `▲ Long @${trade.price.toFixed(2)}`
color = '#0ECB81'
} else {
text = `▼ Short @${trade.price.toFixed(2)}`
color = '#F6465D'
}
} else {
const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`
text = `${pnlStr}`
color = pnl >= 0 ? '#0ECB81' : '#F6465D'
}
return {
time: closestKline.time as UTCTimestamp,
position: isOpen
? (isLong ? 'belowBar' as const : 'aboveBar' as const)
: (isLong ? 'aboveBar' as const : 'belowBar' as const),
color,
shape: 'circle' as const,
size: 2,
text,
}
})
.sort((a, b) => (a.time as number) - (b.time as number))
createSeriesMarkers(candleSeries, markers)
chart.timeScale().fitContent()
setIsLoading(false)
})
.catch((err) => {
setError(err.message || 'Failed to load klines')
setIsLoading(false)
})
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({ width: chartContainerRef.current.clientWidth })
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chart.remove()
chartRef.current = null
candleSeriesRef.current = null
}
}, [runId, selectedSymbol, selectedTimeframe, symbolTrades])
if (symbols.length === 0) {
return (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{t('backtestChart.noTrades', language)}
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<CandlestickIcon size={16} style={{ color: '#F0B90B' }} />
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('backtestChart.symbol', language)}
</span>
<select
value={selectedSymbol}
onChange={(e) => setSelectedSymbol(e.target.value)}
className="px-3 py-1.5 rounded text-sm"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{symbols.map((sym) => (
<option key={sym} value={sym}>
{sym}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<Clock size={14} style={{ color: '#848E9C' }} />
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('backtestChart.interval', language)}
</span>
<div className="flex rounded overflow-hidden" style={{ border: '1px solid #2B3139' }}>
{CHART_TIMEFRAMES.map((tf) => (
<button
key={tf}
onClick={() => setSelectedTimeframe(tf)}
className="px-2.5 py-1 text-xs font-medium transition-colors"
style={{
background: selectedTimeframe === tf ? '#F0B90B' : '#1E2329',
color: selectedTimeframe === tf ? '#0B0E11' : '#848E9C',
}}
>
{tf}
</button>
))}
</div>
</div>
<span className="text-xs" style={{ color: '#5E6673' }}>
({symbolTrades.length} {t('backtestChart.trades', language)})
</span>
</div>
<div
ref={chartContainerRef}
className="w-full rounded-lg overflow-hidden"
style={{ background: '#0B0E11', minHeight: 400 }}
>
{isLoading && (
<div className="flex items-center justify-center h-[400px]" style={{ color: '#848E9C' }}>
<RefreshCw className="animate-spin mr-2" size={16} />
{t('backtestChart.loadingKline', language)}
</div>
)}
{error && (
<div className="flex items-center justify-center h-[400px]" style={{ color: '#F6465D' }}>
<AlertTriangle className="mr-2" size={16} />
{error}
</div>
)}
</div>
<div className="flex items-center gap-4 text-xs" style={{ color: '#848E9C' }}>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#0ECB81' }} />
<span>{t('backtestChart.openProfit', language)}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#F6465D' }} />
<span>{t('backtestChart.lossClose', language)}</span>
</div>
<span style={{ color: '#5E6673' }}>|</span>
<span> Long · Short · {t('backtestChart.close', language)}</span>
</div>
</div>
)
}
// ============ Chart Tab Content ============
interface BacktestChartTabProps {
equity: BacktestEquityPoint[] | undefined
trades: BacktestTradeEvent[] | undefined
selectedRunId: string
language: Language
tr: (key: string) => string
}
export function BacktestChartTab({
equity,
trades,
selectedRunId,
language,
tr,
}: BacktestChartTabProps) {
return (
<motion.div
key="chart"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-6"
>
<div>
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
{t('backtestChart.equityCurve', language)}
</h4>
{equity && equity.length > 0 ? (
<EquityChart equity={equity} trades={trades ?? []} />
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('charts.equityEmpty')}
</div>
)}
</div>
{selectedRunId && trades && trades.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
{t('backtestChart.candlestickTradeMarkers', language)}
</h4>
<CandlestickChartComponent
runId={selectedRunId}
trades={trades}
language={language}
/>
</div>
)}
</motion.div>
)
}
@@ -1,597 +0,0 @@
import { useMemo, type FormEvent } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
ChevronRight,
ChevronLeft,
RefreshCw,
Zap,
} from 'lucide-react'
import type { AIModel, Strategy } from '../../types'
import { t as globalT } from '../../i18n/translations'
import type { Language } from '../../i18n/translations'
// ============ Types ============
type WizardStep = 1 | 2 | 3
export interface BacktestFormState {
runId: string
symbols: string
timeframes: string[]
decisionTf: string
cadence: number
start: string
end: string
balance: number
fee: number
slippage: number
btcEthLeverage: number
altcoinLeverage: number
fill: string
prompt: string
promptTemplate: string
customPrompt: string
overridePrompt: boolean
cacheAI: boolean
replayOnly: boolean
aiModelId: string
strategyId: string
}
const TIMEFRAME_OPTIONS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
const POPULAR_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT']
// ============ Config Form ============
interface BacktestConfigFormProps {
formState: BacktestFormState
wizardStep: WizardStep
isStarting: boolean
aiModels: AIModel[] | undefined
strategies: Strategy[] | undefined
language: string
tr: (key: string, params?: Record<string, string | number>) => string
onFormChange: (key: string, value: string | number | boolean | string[]) => void
onWizardStepChange: (step: WizardStep) => void
onStart: (event: FormEvent) => void
}
export function BacktestConfigForm({
formState,
wizardStep,
isStarting,
aiModels,
strategies,
language,
tr,
onFormChange,
onWizardStepChange,
onStart,
}: BacktestConfigFormProps) {
const selectedModel = aiModels?.find((m) => m.id === formState.aiModelId)
const selectedStrategy = strategies?.find((s) => s.id === formState.strategyId)
const strategyHasDynamicCoins = useMemo(() => {
const cs = selectedStrategy?.config?.coin_source
if (!cs) return false
const st = cs.source_type as string
if (st === 'ai500' || st === 'oi_top') return true
if (st === 'mixed' && (cs.use_ai500 || cs.use_oi_top)) return true
if (!st && (cs.use_ai500 || cs.use_oi_top)) return true
return false
}, [selectedStrategy])
const coinSourceDescription = useMemo(() => {
const cs = selectedStrategy?.config?.coin_source
if (!cs) return null
let st = cs.source_type as string
if (!st) {
if (cs.use_ai500 && cs.use_oi_top) st = 'mixed'
else if (cs.use_ai500) st = 'ai500'
else if (cs.use_oi_top) st = 'oi_top'
else if (cs.static_coins?.length) st = 'static'
}
switch (st) {
case 'ai500': return { type: 'AI500', limit: cs.ai500_limit || 30 }
case 'oi_top': return { type: 'OI Top', limit: cs.oi_top_limit || 30 }
case 'mixed': {
const parts: string[] = []
if (cs.use_ai500) parts.push(`AI500(${cs.ai500_limit || 30})`)
if (cs.use_oi_top) parts.push(`OI Top(${cs.oi_top_limit || 30})`)
if (cs.static_coins?.length) parts.push(`Static(${cs.static_coins.length})`)
return { type: 'Mixed', desc: parts.join(' + ') }
}
case 'static': return { type: 'Static', coins: cs.static_coins || [] }
default: return null
}
}, [selectedStrategy])
const lang = language as Language
const quickRanges = [
{ label: globalT('backtestConfigForm.quickRange24h', lang), hours: 24 },
{ label: globalT('backtestConfigForm.quickRange3d', lang), hours: 72 },
{ label: globalT('backtestConfigForm.quickRange7d', lang), hours: 168 },
{ label: globalT('backtestConfigForm.quickRange30d', lang), hours: 720 },
]
const applyQuickRange = (hours: number) => {
const end = new Date()
const start = new Date(end.getTime() - hours * 3600 * 1000)
const fmt = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16)
onFormChange('start', fmt(start))
onFormChange('end', fmt(end))
}
return (
<div className="binance-card p-5">
<div className="flex items-center gap-2 mb-4">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<button
onClick={() => onWizardStepChange(step as WizardStep)}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
style={{
background: wizardStep >= step ? '#F0B90B' : '#2B3139',
color: wizardStep >= step ? '#0B0E11' : '#848E9C',
}}
>
{step}
</button>
{step < 3 && (
<div
className="w-8 h-0.5 mx-1"
style={{ background: wizardStep > step ? '#F0B90B' : '#2B3139' }}
/>
)}
</div>
))}
<span className="ml-2 text-xs" style={{ color: '#848E9C' }}>
{wizardStep === 1 ? globalT('backtestConfigForm.selectModel', lang)
: wizardStep === 2 ? globalT('backtestConfigForm.configure', lang)
: globalT('backtestConfigForm.confirmStart', lang)}
</span>
</div>
<form onSubmit={onStart}>
<AnimatePresence mode="wait">
{/* Step 1: Model & Symbols */}
{wizardStep === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.aiModelLabel')}
</label>
<select
className="w-full p-3 rounded-lg text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.aiModelId}
onChange={(e) => onFormChange('aiModelId', e.target.value)}
>
<option value="">{tr('form.selectAiModel')}</option>
{aiModels?.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.provider}) {!m.enabled && '⚠️'}
</option>
))}
</select>
{selectedModel && (
<div className="mt-2 flex items-center gap-2 text-xs">
<span
className="px-2 py-0.5 rounded"
style={{
background: selectedModel.enabled ? 'rgba(14,203,129,0.1)' : 'rgba(246,70,93,0.1)',
color: selectedModel.enabled ? '#0ECB81' : '#F6465D',
}}
>
{selectedModel.enabled ? tr('form.enabled') : tr('form.disabled')}
</span>
</div>
)}
</div>
{/* Strategy Selection (Optional) */}
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{globalT('backtestConfigForm.strategyOptional', lang)}
</label>
<select
className="w-full p-3 rounded-lg text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.strategyId}
onChange={(e) => onFormChange('strategyId', e.target.value)}
>
<option value="">{globalT('backtestConfigForm.noSavedStrategy', lang)}</option>
{strategies?.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {s.is_active && '✓'} {s.is_default && '⭐'}
</option>
))}
</select>
{formState.strategyId && coinSourceDescription && (
<div className="mt-2 p-2 rounded" style={{ background: 'rgba(240,185,11,0.1)', border: '1px solid rgba(240,185,11,0.2)' }}>
<div className="flex items-center gap-2 text-xs">
<span style={{ color: '#F0B90B' }}>
{globalT('backtestConfigForm.coinSource', lang)}
</span>
<span className="font-medium" style={{ color: '#EAECEF' }}>
{coinSourceDescription.type}
{coinSourceDescription.limit && ` (${coinSourceDescription.limit})`}
{coinSourceDescription.desc && ` - ${coinSourceDescription.desc}`}
</span>
</div>
{strategyHasDynamicCoins && (
<div className="text-xs mt-1" style={{ color: '#F0B90B' }}>
{globalT('backtestConfigForm.clearDynamicCoins', lang)}
</div>
)}
</div>
)}
</div>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.symbolsLabel')}
{strategyHasDynamicCoins && (
<span className="ml-2" style={{ color: '#5E6673' }}>
({globalT('backtestConfigForm.optionalCoinSource', lang)})
</span>
)}
</label>
{!strategyHasDynamicCoins && (
<div className="flex flex-wrap gap-1 mb-2">
{POPULAR_SYMBOLS.map((sym) => {
const isSelected = formState.symbols.includes(sym)
return (
<button
key={sym}
type="button"
onClick={() => {
const current = formState.symbols.split(',').map((s) => s.trim()).filter(Boolean)
const updated = isSelected
? current.filter((s) => s !== sym)
: [...current, sym]
onFormChange('symbols', updated.join(','))
}}
className="px-2 py-1 rounded text-xs transition-all"
style={{
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
color: isSelected ? '#F0B90B' : '#848E9C',
}}
>
{sym.replace('USDT', '')}
</button>
)
})}
</div>
)}
<div className="relative">
<textarea
className="w-full p-2 rounded-lg text-xs font-mono"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
value={formState.symbols}
onChange={(e) => onFormChange('symbols', e.target.value)}
rows={2}
placeholder={strategyHasDynamicCoins
? globalT('backtestConfigForm.leavEmptyForStrategy', lang)
: ''
}
/>
{strategyHasDynamicCoins && formState.symbols && (
<button
type="button"
onClick={() => onFormChange('symbols', '')}
className="absolute top-2 right-2 px-2 py-1 rounded text-xs"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{globalT('backtestConfigForm.clearToUseStrategy', lang)}
</button>
)}
</div>
</div>
<button
type="button"
onClick={() => onWizardStepChange(2)}
disabled={!selectedModel?.enabled}
className="w-full py-2.5 rounded-lg font-medium flex items-center justify-center gap-2 transition-all disabled:opacity-50"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{globalT('backtestConfigForm.next', lang)}
<ChevronRight className="w-4 h-4" />
</button>
</motion.div>
)}
{/* Step 2: Parameters */}
{wizardStep === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.timeRangeLabel')}
</label>
<div className="flex flex-wrap gap-1 mb-2">
{quickRanges.map((r) => (
<button
key={r.hours}
type="button"
onClick={() => applyQuickRange(r.hours)}
className="px-3 py-1 rounded text-xs"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{r.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="datetime-local"
className="p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.start}
onChange={(e) => onFormChange('start', e.target.value)}
/>
<input
type="datetime-local"
className="p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.end}
onChange={(e) => onFormChange('end', e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{globalT('backtestConfigForm.timeframes', lang)}
</label>
<div className="flex flex-wrap gap-1">
{TIMEFRAME_OPTIONS.map((tf) => {
const isSelected = formState.timeframes.includes(tf)
return (
<button
key={tf}
type="button"
onClick={() => {
const updated = isSelected
? formState.timeframes.filter((t) => t !== tf)
: [...formState.timeframes, tf]
if (updated.length > 0) onFormChange('timeframes', updated)
}}
className="px-2 py-1 rounded text-xs transition-all"
style={{
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
color: isSelected ? '#F0B90B' : '#848E9C',
}}
>
{tf}
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.initialBalanceLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.balance}
onChange={(e) => onFormChange('balance', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.decisionTfLabel')}
</label>
<select
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.decisionTf}
onChange={(e) => onFormChange('decisionTf', e.target.value)}
>
{formState.timeframes.map((tf) => (
<option key={tf} value={tf}>
{tf}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onWizardStepChange(1)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<ChevronLeft className="w-4 h-4" />
{globalT('backtestConfigForm.back', lang)}
</button>
<button
type="button"
onClick={() => onWizardStepChange(3)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{globalT('backtestConfigForm.next', lang)}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
{/* Step 3: Advanced & Confirm */}
{wizardStep === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.btcEthLeverageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.btcEthLeverage}
onChange={(e) => onFormChange('btcEthLeverage', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.altcoinLeverageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.altcoinLeverage}
onChange={(e) => onFormChange('altcoinLeverage', Number(e.target.value))}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.feeLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.fee}
onChange={(e) => onFormChange('fee', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.slippageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.slippage}
onChange={(e) => onFormChange('slippage', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.cadenceLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.cadence}
onChange={(e) => onFormChange('cadence', Number(e.target.value))}
/>
</div>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{globalT('backtestConfigForm.strategyStyle', lang)}
</label>
<div className="flex flex-wrap gap-1">
{['baseline', 'aggressive', 'conservative', 'scalping'].map((p) => (
<button
key={p}
type="button"
onClick={() => onFormChange('prompt', p)}
className="px-3 py-1.5 rounded text-xs transition-all"
style={{
background: formState.prompt === p ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${formState.prompt === p ? '#F0B90B' : '#2B3139'}`,
color: formState.prompt === p ? '#F0B90B' : '#848E9C',
}}
>
{tr(`form.promptPresets.${p}`)}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-4 text-xs" style={{ color: '#848E9C' }}>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formState.cacheAI}
onChange={(e) => onFormChange('cacheAI', e.target.checked)}
className="accent-[#F0B90B]"
/>
{tr('form.cacheAiLabel')}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formState.replayOnly}
onChange={(e) => onFormChange('replayOnly', e.target.checked)}
className="accent-[#F0B90B]"
/>
{tr('form.replayOnlyLabel')}
</label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onWizardStepChange(2)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<ChevronLeft className="w-4 h-4" />
{globalT('backtestConfigForm.back', lang)}
</button>
<button
type="submit"
disabled={isStarting}
className="flex-1 py-2 rounded-lg font-bold flex items-center justify-center gap-2 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{isStarting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Zap className="w-4 h-4" />
)}
{isStarting ? tr('starting') : tr('start')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</form>
</div>
)
}
export type { WizardStep }
@@ -1,36 +0,0 @@
import { motion } from 'framer-motion'
import { DecisionCard } from '../trader/DecisionCard'
import type { Language } from '../../i18n/translations'
import type { DecisionRecord } from '../../types'
interface BacktestDecisionsTabProps {
decisions: DecisionRecord[] | undefined
language: Language
tr: (key: string) => string
}
export function BacktestDecisionsTab({ decisions, language, tr }: BacktestDecisionsTabProps) {
return (
<motion.div
key="decisions"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-3 max-h-[500px] overflow-y-auto"
>
{decisions && decisions.length > 0 ? (
decisions.map((d) => (
<DecisionCard
key={`${d.cycle_number}-${d.timestamp}`}
decision={d}
language={language}
/>
))
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('decisionTrail.emptyHint')}
</div>
)}
</motion.div>
)
}
@@ -1,325 +0,0 @@
import { motion } from 'framer-motion'
import {
TrendingUp,
TrendingDown,
Activity,
ArrowUpRight,
ArrowDownRight,
} from 'lucide-react'
import { MetricTooltip } from '../common/MetricTooltip'
import { t, type Language } from '../../i18n/translations'
import { EquityChart } from './BacktestChartTab'
import type {
BacktestEquityPoint,
BacktestTradeEvent,
BacktestMetrics,
BacktestPositionStatus,
} from '../../types'
// ============ Stat Card ============
interface StatCardProps {
icon: typeof TrendingUp
label: string
value: string | number
suffix?: string
trend?: 'up' | 'down' | 'neutral'
color?: string
metricKey?: string
language?: string
}
export function StatCard({
icon: Icon,
label,
value,
suffix,
trend,
color = '#EAECEF',
metricKey,
language = 'en',
}: StatCardProps) {
const trendColors = {
up: '#0ECB81',
down: '#F6465D',
neutral: '#848E9C',
}
return (
<div
className="p-4 rounded-xl"
style={{ background: 'rgba(30, 35, 41, 0.6)', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-xs" style={{ color: '#848E9C' }}>
{label}
</span>
{metricKey && (
<MetricTooltip metricKey={metricKey} language={language} size={12} />
)}
</div>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold" style={{ color }}>
{value}
</span>
{suffix && (
<span className="text-xs" style={{ color: '#848E9C' }}>
{suffix}
</span>
)}
{trend && trend !== 'neutral' && (
<span style={{ color: trendColors[trend] }}>
{trend === 'up' ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
</span>
)}
</div>
</div>
)
}
// ============ Progress Ring ============
interface ProgressRingProps {
progress: number
size?: number
}
export function ProgressRing({ progress, size = 120 }: ProgressRingProps) {
const strokeWidth = 8
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#2B3139"
strokeWidth={strokeWidth}
fill="none"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#F0B90B"
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.5 }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-2xl font-bold" style={{ color: '#F0B90B' }}>
{progress.toFixed(0)}%
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
Complete
</span>
</div>
</div>
)
}
// ============ Positions Display ============
interface PositionsDisplayProps {
positions: BacktestPositionStatus[]
language: Language
}
export function PositionsDisplay({ positions, language }: PositionsDisplayProps) {
if (!positions || positions.length === 0) {
return null
}
const totalUnrealizedPnL = positions.reduce((sum, p) => sum + p.unrealized_pnl, 0)
const totalMargin = positions.reduce((sum, p) => sum + p.margin_used, 0)
return (
<div
className="mt-3 p-3 rounded-lg"
style={{ background: 'rgba(30, 35, 41, 0.8)', border: '1px solid #2B3139' }}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{t('backtestOverview.activePositions', language)}
</span>
<span
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: '#F0B90B20', color: '#F0B90B' }}
>
{positions.length}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span style={{ color: '#848E9C' }}>
{t('backtestOverview.margin', language)}: ${totalMargin.toFixed(2)}
</span>
<span
className="font-medium"
style={{ color: totalUnrealizedPnL >= 0 ? '#0ECB81' : '#F6465D' }}
>
{t('backtestOverview.unrealized', language)}: {totalUnrealizedPnL >= 0 ? '+' : ''}
${totalUnrealizedPnL.toFixed(2)}
</span>
</div>
</div>
<div className="space-y-1.5">
{positions.map((pos) => {
const isLong = pos.side === 'long'
const pnlColor = pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D'
return (
<motion.div
key={`${pos.symbol}-${pos.side}`}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center justify-between p-2 rounded"
style={{ background: '#1E2329' }}
>
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ background: isLong ? '#0ECB8120' : '#F6465D20' }}
>
{isLong ? (
<TrendingUp className="w-3.5 h-3.5" style={{ color: '#0ECB81' }} />
) : (
<TrendingDown className="w-3.5 h-3.5" style={{ color: '#F6465D' }} />
)}
</div>
<div>
<div className="flex items-center gap-1.5">
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
{pos.symbol.replace('USDT', '')}
</span>
<span
className="px-1 py-0.5 rounded text-[10px] font-medium"
style={{
background: isLong ? '#0ECB8120' : '#F6465D20',
color: isLong ? '#0ECB81' : '#F6465D',
}}
>
{isLong ? 'LONG' : 'SHORT'} {pos.leverage}x
</span>
</div>
<div className="text-[10px]" style={{ color: '#5E6673' }}>
{t('backtestOverview.qty', language)}: {pos.quantity.toFixed(4)} ·{' '}
{t('backtestOverview.margin', language)}: ${pos.margin_used.toFixed(2)}
</div>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-2 text-xs">
<span style={{ color: '#848E9C' }}>
{t('backtestOverview.entry', language)}: ${pos.entry_price.toFixed(2)}
</span>
<span style={{ color: '#EAECEF' }}>
{t('backtestOverview.mark', language)}: ${pos.mark_price.toFixed(2)}
</span>
</div>
<div className="flex items-center justify-end gap-1.5 mt-0.5">
<span className="font-mono font-bold" style={{ color: pnlColor }}>
{pos.unrealized_pnl >= 0 ? '+' : ''}${pos.unrealized_pnl.toFixed(2)}
</span>
<span
className="px-1 py-0.5 rounded text-[10px] font-medium"
style={{ background: `${pnlColor}20`, color: pnlColor }}
>
{pos.unrealized_pnl_pct >= 0 ? '+' : ''}{pos.unrealized_pnl_pct.toFixed(2)}%
</span>
</div>
</div>
</motion.div>
)
})}
</div>
</div>
)
}
// ============ Overview Tab Content ============
interface BacktestOverviewTabProps {
equity: BacktestEquityPoint[] | undefined
trades: BacktestTradeEvent[] | undefined
metrics: BacktestMetrics | undefined
language: Language
tr: (key: string) => string
}
export function BacktestOverviewTab({
equity,
trades,
metrics,
language,
tr,
}: BacktestOverviewTabProps) {
return (
<motion.div
key="overview"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{equity && equity.length > 0 ? (
<EquityChart equity={equity} trades={trades ?? []} />
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('charts.equityEmpty')}
</div>
)}
{metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
{t('backtestOverview.winRate', language)}
<MetricTooltip metricKey="win_rate" language={language} size={11} />
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{(metrics.win_rate ?? 0).toFixed(1)}%
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
{t('backtestOverview.profitFactor', language)}
<MetricTooltip metricKey="profit_factor" language={language} size={11} />
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{(metrics.profit_factor ?? 0).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('backtestOverview.totalTrades', language)}
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{metrics.trades ?? 0}
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('backtestOverview.bestSymbol', language)}
</div>
<div className="text-lg font-bold" style={{ color: '#0ECB81' }}>
{metrics.best_symbol?.replace('USDT', '') || '-'}
</div>
</div>
</div>
)}
</motion.div>
)
}
@@ -1,579 +0,0 @@
import { useEffect, useMemo, useState, useCallback, type FormEvent } from 'react'
import useSWR from 'swr'
import { motion, AnimatePresence } from 'framer-motion'
import {
Play,
Pause,
Square,
Download,
Trash2,
TrendingUp,
BarChart3,
Brain,
Target,
AlertTriangle,
} from 'lucide-react'
import { DeepVoidBackground } from '../common/DeepVoidBackground'
import { api } from '../../lib/api'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { confirmToast } from '../../lib/notify'
import type {
BacktestStatusPayload,
BacktestEquityPoint,
BacktestTradeEvent,
BacktestMetrics,
DecisionRecord,
AIModel,
Strategy,
} from '../../types'
import {
BacktestConfigForm,
type WizardStep,
type BacktestFormState,
} from './BacktestConfigForm'
import { BacktestRunList, getStateColor, getStateIcon } from './BacktestRunList'
import { StatCard, ProgressRing, PositionsDisplay } from './BacktestOverviewTab'
import { BacktestOverviewTab } from './BacktestOverviewTab'
import { BacktestChartTab } from './BacktestChartTab'
import { BacktestTradesTab } from './BacktestTradesTab'
import { BacktestDecisionsTab } from './BacktestDecisionsTab'
// ============ Types ============
type ViewTab = 'overview' | 'chart' | 'trades' | 'decisions' | 'compare'
const toLocalInput = (date: Date) => {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
return local.toISOString().slice(0, 16)
}
// ============ Main Component ============
export function BacktestPage() {
const { language } = useLanguage()
const tr = useCallback(
(key: string, params?: Record<string, string | number>) => t(`backtestPage.${key}`, language, params),
[language]
)
// State
const now = new Date()
const [wizardStep, setWizardStep] = useState<WizardStep>(1)
const [viewTab, setViewTab] = useState<ViewTab>('overview')
const [selectedRunId, setSelectedRunId] = useState<string>()
const [compareRunIds, setCompareRunIds] = useState<string[]>([])
const [isStarting, setIsStarting] = useState(false)
const [toast, setToast] = useState<{ text: string; tone: 'info' | 'error' | 'success' } | null>(null)
// Form state
const [formState, setFormState] = useState<BacktestFormState>({
runId: '',
symbols: 'BTCUSDT,ETHUSDT,SOLUSDT',
timeframes: ['3m', '15m', '4h'],
decisionTf: '3m',
cadence: 20,
start: toLocalInput(new Date(now.getTime() - 3 * 24 * 3600 * 1000)),
end: toLocalInput(now),
balance: 1000,
fee: 5,
slippage: 2,
btcEthLeverage: 5,
altcoinLeverage: 5,
fill: 'next_open',
prompt: 'baseline',
promptTemplate: 'default',
customPrompt: '',
overridePrompt: false,
cacheAI: true,
replayOnly: false,
aiModelId: '',
strategyId: '',
})
// Data fetching
const { data: runsResp, mutate: refreshRuns } = useSWR(['backtest-runs'], () =>
api.getBacktestRuns({ limit: 100, offset: 0 })
, { refreshInterval: 5000 })
const runs = runsResp?.items ?? []
const { data: aiModels } = useSWR<AIModel[]>('ai-models', api.getModelConfigs, { refreshInterval: 30000 })
const { data: strategies } = useSWR<Strategy[]>('strategies', api.getStrategies, { refreshInterval: 30000 })
const { data: status } = useSWR<BacktestStatusPayload>(
selectedRunId ? ['bt-status', selectedRunId] : null,
() => api.getBacktestStatus(selectedRunId!),
{ refreshInterval: 2000 }
)
const { data: equity } = useSWR<BacktestEquityPoint[]>(
selectedRunId ? ['bt-equity', selectedRunId] : null,
() => api.getBacktestEquity(selectedRunId!, '1m', 2000),
{ refreshInterval: 5000 }
)
const { data: trades } = useSWR<BacktestTradeEvent[]>(
selectedRunId ? ['bt-trades', selectedRunId] : null,
() => api.getBacktestTrades(selectedRunId!, 500),
{ refreshInterval: 5000 }
)
const { data: metrics } = useSWR<BacktestMetrics>(
selectedRunId ? ['bt-metrics', selectedRunId] : null,
() => api.getBacktestMetrics(selectedRunId!),
{ refreshInterval: 10000 }
)
const { data: decisions } = useSWR<DecisionRecord[]>(
selectedRunId ? ['bt-decisions', selectedRunId] : null,
() => api.getBacktestDecisions(selectedRunId!, 30),
{ refreshInterval: 5000 }
)
const selectedRun = runs.find((r) => r.run_id === selectedRunId)
const selectedModel = aiModels?.find((m) => m.id === formState.aiModelId)
const selectedStrategy = strategies?.find((s) => s.id === formState.strategyId)
// Check if selected strategy has dynamic coin source (needed for handleStart)
const strategyHasDynamicCoins = useMemo(() => {
if (!selectedStrategy) return false
const coinSource = selectedStrategy.config?.coin_source
if (!coinSource) return false
if (coinSource.source_type === 'ai500' || coinSource.source_type === 'oi_top') {
return true
}
if (coinSource.source_type === 'mixed' && (coinSource.use_ai500 || coinSource.use_oi_top)) {
return true
}
const srcType = coinSource.source_type as string
if (!srcType && (coinSource.use_ai500 || coinSource.use_oi_top)) {
return true
}
return false
}, [selectedStrategy])
// Auto-select first model
useEffect(() => {
if (!formState.aiModelId && aiModels?.length) {
const enabled = aiModels.find((m) => m.enabled)
if (enabled) setFormState((s) => ({ ...s, aiModelId: enabled.id }))
}
}, [aiModels, formState.aiModelId])
// Auto-select first run
useEffect(() => {
if (!selectedRunId && runs.length > 0) {
setSelectedRunId(runs[0].run_id)
}
}, [runs, selectedRunId])
// Handlers
const handleFormChange = (key: string, value: string | number | boolean | string[]) => {
setFormState((prev) => ({ ...prev, [key]: value }))
}
const handleStart = async (event: FormEvent) => {
event.preventDefault()
if (!selectedModel?.enabled) {
setToast({ text: tr('toasts.selectModel'), tone: 'error' })
return
}
try {
setIsStarting(true)
const start = new Date(formState.start).getTime()
const end = new Date(formState.end).getTime()
if (end <= start) throw new Error(tr('toasts.invalidRange'))
const userSymbols = formState.symbols.split(',').map((s) => s.trim()).filter(Boolean)
const symbolsToSend = (userSymbols.length === 0 && strategyHasDynamicCoins) ? [] : userSymbols
const payload = await api.startBacktest({
run_id: formState.runId.trim() || undefined,
strategy_id: formState.strategyId || undefined,
symbols: symbolsToSend,
timeframes: formState.timeframes,
decision_timeframe: formState.decisionTf,
decision_cadence_nbars: formState.cadence,
start_ts: Math.floor(start / 1000),
end_ts: Math.floor(end / 1000),
initial_balance: formState.balance,
fee_bps: formState.fee,
slippage_bps: formState.slippage,
fill_policy: formState.fill,
prompt_variant: formState.prompt,
prompt_template: formState.promptTemplate,
custom_prompt: formState.customPrompt.trim() || undefined,
override_prompt: formState.overridePrompt,
cache_ai: formState.cacheAI,
replay_only: formState.replayOnly,
ai_model_id: formState.aiModelId,
leverage: {
btc_eth_leverage: formState.btcEthLeverage,
altcoin_leverage: formState.altcoinLeverage,
},
})
setToast({ text: tr('toasts.startSuccess', { id: payload.run_id }), tone: 'success' })
setSelectedRunId(payload.run_id)
setWizardStep(1)
await refreshRuns()
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : tr('toasts.startFailed')
setToast({ text: errMsg, tone: 'error' })
} finally {
setIsStarting(false)
}
}
const handleControl = async (action: 'pause' | 'resume' | 'stop') => {
if (!selectedRunId) return
try {
if (action === 'pause') await api.pauseBacktest(selectedRunId)
if (action === 'resume') await api.resumeBacktest(selectedRunId)
if (action === 'stop') await api.stopBacktest(selectedRunId)
setToast({ text: tr('toasts.actionSuccess', { action, id: selectedRunId }), tone: 'success' })
await refreshRuns()
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : tr('toasts.actionFailed')
setToast({ text: errMsg, tone: 'error' })
}
}
const handleDelete = async () => {
if (!selectedRunId) return
const confirmed = await confirmToast(tr('toasts.confirmDelete', { id: selectedRunId }), {
title: t('backtestPageExtra.confirmDelete', language),
okText: t('backtestPageExtra.delete', language),
cancelText: t('backtestPageExtra.cancel', language),
})
if (!confirmed) return
try {
await api.deleteBacktestRun(selectedRunId)
setToast({ text: tr('toasts.deleteSuccess'), tone: 'success' })
setSelectedRunId(undefined)
await refreshRuns()
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : tr('toasts.deleteFailed')
setToast({ text: errMsg, tone: 'error' })
}
}
const handleExport = async () => {
if (!selectedRunId) return
try {
const blob = await api.exportBacktest(selectedRunId)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${selectedRunId}_export.zip`
link.click()
URL.revokeObjectURL(url)
setToast({ text: tr('toasts.exportSuccess', { id: selectedRunId }), tone: 'success' })
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : tr('toasts.exportFailed')
setToast({ text: errMsg, tone: 'error' })
}
}
const toggleCompare = (runId: string) => {
setCompareRunIds((prev) =>
prev.includes(runId) ? prev.filter((id) => id !== runId) : [...prev, runId].slice(-3)
)
}
// Render
return (
<DeepVoidBackground className="py-8" disableAnimation>
<div className="w-full px-4 md:px-8 space-y-6">
{/* Toast */}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="p-3 rounded-lg text-sm"
style={{
background:
toast.tone === 'error'
? 'rgba(246,70,93,0.15)'
: toast.tone === 'success'
? 'rgba(14,203,129,0.15)'
: 'rgba(240,185,11,0.15)',
color: toast.tone === 'error' ? '#F6465D' : toast.tone === 'success' ? '#0ECB81' : '#F0B90B',
border: `1px solid ${toast.tone === 'error' ? 'rgba(246,70,93,0.3)' : toast.tone === 'success' ? 'rgba(14,203,129,0.3)' : 'rgba(240,185,11,0.3)'}`,
}}
>
{toast.text}
</motion.div>
)}
</AnimatePresence>
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-3" style={{ color: '#EAECEF' }}>
<Brain className="w-7 h-7" style={{ color: '#F0B90B' }} />
{tr('title')}
</h1>
<p className="text-sm mt-1" style={{ color: '#848E9C' }}>
{tr('subtitle')}
</p>
</div>
<button
onClick={() => setWizardStep(1)}
className="px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-all hover:opacity-90"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
<Play className="w-4 h-4" />
{t('backtestPageExtra.newBacktest', language)}
</button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Left Panel - Config / History */}
<div className="space-y-4">
<BacktestConfigForm
formState={formState}
wizardStep={wizardStep}
isStarting={isStarting}
aiModels={aiModels}
strategies={strategies}
language={language}
tr={tr}
onFormChange={handleFormChange}
onWizardStepChange={setWizardStep}
onStart={handleStart}
/>
<BacktestRunList
runs={runs}
selectedRunId={selectedRunId}
compareRunIds={compareRunIds}
language={language}
tr={tr}
onSelectRun={setSelectedRunId}
onToggleCompare={toggleCompare}
/>
</div>
{/* Right Panel - Results */}
<div className="xl:col-span-2 space-y-4">
{!selectedRunId ? (
<div
className="binance-card p-12 text-center"
style={{ color: '#5E6673' }}
>
<Brain className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p>{tr('emptyStates.selectRun')}</p>
</div>
) : (
<>
{/* Status Bar */}
<div className="binance-card p-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<ProgressRing progress={status?.progress_pct ?? selectedRun?.summary.progress_pct ?? 0} size={80} />
<div>
<h2 className="font-mono font-bold" style={{ color: '#EAECEF' }}>
{selectedRunId}
</h2>
<div className="flex items-center gap-2 mt-1">
<span
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium"
style={{
background: `${getStateColor(status?.state ?? selectedRun?.state ?? '')}20`,
color: getStateColor(status?.state ?? selectedRun?.state ?? ''),
}}
>
{getStateIcon(status?.state ?? selectedRun?.state ?? '')}
{tr(`states.${status?.state ?? selectedRun?.state}`)}
</span>
{selectedRun?.summary.decision_tf && (
<span className="text-xs" style={{ color: '#848E9C' }}>
{selectedRun.summary.decision_tf} · {selectedRun.summary.symbol_count} symbols
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{(status?.state === 'running' || selectedRun?.state === 'running') && (
<>
<button
onClick={() => handleControl('pause')}
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
style={{ border: '1px solid #2B3139' }}
title={tr('actions.pause')}
>
<Pause className="w-4 h-4" style={{ color: '#F0B90B' }} />
</button>
<button
onClick={() => handleControl('stop')}
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
style={{ border: '1px solid #2B3139' }}
title={tr('actions.stop')}
>
<Square className="w-4 h-4" style={{ color: '#F6465D' }} />
</button>
</>
)}
{status?.state === 'paused' && (
<button
onClick={() => handleControl('resume')}
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
style={{ border: '1px solid #2B3139' }}
title={tr('actions.resume')}
>
<Play className="w-4 h-4" style={{ color: '#0ECB81' }} />
</button>
)}
<button
onClick={handleExport}
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
style={{ border: '1px solid #2B3139' }}
title={tr('detail.exportLabel')}
>
<Download className="w-4 h-4" style={{ color: '#EAECEF' }} />
</button>
<button
onClick={handleDelete}
className="p-2 rounded-lg transition-all hover:bg-[#2B3139]"
style={{ border: '1px solid #2B3139' }}
title={tr('detail.deleteLabel')}
>
<Trash2 className="w-4 h-4" style={{ color: '#F6465D' }} />
</button>
</div>
</div>
{(status?.note || status?.last_error) && (
<div
className="mt-3 p-2 rounded-lg text-xs flex items-center gap-2"
style={{
background: 'rgba(246,70,93,0.1)',
border: '1px solid rgba(246,70,93,0.3)',
color: '#F6465D',
}}
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
{status?.note || status?.last_error}
</div>
)}
{/* Real-time Positions Display */}
{status?.positions && status.positions.length > 0 && (
<PositionsDisplay positions={status.positions} language={language} />
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard
icon={Target}
label={t('backtestPageExtra.equity', language)}
value={(status?.equity ?? 0).toFixed(2)}
suffix="USDT"
language={language}
/>
<StatCard
icon={TrendingUp}
label={t('backtestPageExtra.totalReturn', language)}
value={`${(metrics?.total_return_pct ?? 0).toFixed(2)}%`}
trend={(metrics?.total_return_pct ?? 0) >= 0 ? 'up' : 'down'}
color={(metrics?.total_return_pct ?? 0) >= 0 ? '#0ECB81' : '#F6465D'}
metricKey="total_return"
language={language}
/>
<StatCard
icon={AlertTriangle}
label={t('backtestPageExtra.maxDD', language)}
value={`${(metrics?.max_drawdown_pct ?? 0).toFixed(2)}%`}
color="#F6465D"
metricKey="max_drawdown"
language={language}
/>
<StatCard
icon={BarChart3}
label={t('backtestPageExtra.sharpe', language)}
value={(metrics?.sharpe_ratio ?? 0).toFixed(2)}
metricKey="sharpe_ratio"
language={language}
/>
</div>
{/* Tabs */}
<div className="binance-card">
<div className="flex border-b" style={{ borderColor: '#2B3139' }}>
{(['overview', 'chart', 'trades', 'decisions'] as ViewTab[]).map((tab) => (
<button
key={tab}
onClick={() => setViewTab(tab)}
className="px-4 py-3 text-sm font-medium transition-all relative"
style={{ color: viewTab === tab ? '#F0B90B' : '#848E9C' }}
>
{tab === 'overview'
? t('backtestPageExtra.tabOverview', language)
: tab === 'chart'
? t('backtestPageExtra.tabChart', language)
: tab === 'trades'
? t('backtestPageExtra.tabTrades', language)
: t('backtestPageExtra.tabDecisions', language)}
{viewTab === tab && (
<motion.div
layoutId="tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5"
style={{ background: '#F0B90B' }}
/>
)}
</button>
))}
</div>
<div className="p-4">
<AnimatePresence mode="wait">
{viewTab === 'overview' && (
<BacktestOverviewTab
equity={equity}
trades={trades}
metrics={metrics}
language={language}
tr={tr}
/>
)}
{viewTab === 'chart' && (
<BacktestChartTab
equity={equity}
trades={trades}
selectedRunId={selectedRunId}
language={language}
tr={tr}
/>
)}
{viewTab === 'trades' && (
<BacktestTradesTab trades={trades} />
)}
{viewTab === 'decisions' && (
<BacktestDecisionsTab
decisions={decisions}
language={language}
tr={tr}
/>
)}
</AnimatePresence>
</div>
</div>
</>
)}
</div>
</div>
</div>
</DeepVoidBackground>
)
}
@@ -1,151 +0,0 @@
import {
Activity,
CheckCircle2,
XCircle,
Pause,
Clock,
Layers,
Eye,
} from 'lucide-react'
import { t, type Language } from '../../i18n/translations'
// ============ Types ============
export interface BacktestRunItem {
run_id: string
state: string
summary: {
progress_pct: number
equity_last: number
decision_tf?: string
symbol_count?: number
}
}
// ============ State Helpers ============
export function getStateColor(state: string) {
switch (state) {
case 'running':
return '#F0B90B'
case 'completed':
return '#0ECB81'
case 'failed':
case 'liquidated':
return '#F6465D'
case 'paused':
return '#848E9C'
default:
return '#848E9C'
}
}
export function getStateIcon(state: string) {
switch (state) {
case 'running':
return <Activity className="w-4 h-4" />
case 'completed':
return <CheckCircle2 className="w-4 h-4" />
case 'failed':
case 'liquidated':
return <XCircle className="w-4 h-4" />
case 'paused':
return <Pause className="w-4 h-4" />
default:
return <Clock className="w-4 h-4" />
}
}
// ============ Run History List ============
interface BacktestRunListProps {
runs: BacktestRunItem[]
selectedRunId: string | undefined
compareRunIds: string[]
language: Language
tr: (key: string, params?: Record<string, string | number>) => string
onSelectRun: (runId: string) => void
onToggleCompare: (runId: string) => void
}
export function BacktestRunList({
runs,
selectedRunId,
compareRunIds,
language,
tr,
onSelectRun,
onToggleCompare,
}: BacktestRunListProps) {
return (
<div className="binance-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<Layers className="w-4 h-4" style={{ color: '#F0B90B' }} />
{tr('runList.title')}
</h3>
<span className="text-xs" style={{ color: '#848E9C' }}>
{runs.length} {t('backtestPageExtra.runs', language)}
</span>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{runs.length === 0 ? (
<div className="py-8 text-center text-sm" style={{ color: '#5E6673' }}>
{tr('emptyStates.noRuns')}
</div>
) : (
runs.map((run) => (
<button
key={run.run_id}
onClick={() => onSelectRun(run.run_id)}
className="w-full p-3 rounded-lg text-left transition-all"
style={{
background: run.run_id === selectedRunId ? 'rgba(240,185,11,0.1)' : '#1E2329',
border: `1px solid ${run.run_id === selectedRunId ? '#F0B90B' : '#2B3139'}`,
}}
>
<div className="flex items-center justify-between">
<span className="font-mono text-xs" style={{ color: '#EAECEF' }}>
{run.run_id.slice(0, 20)}...
</span>
<span
className="flex items-center gap-1 text-xs"
style={{ color: getStateColor(run.state) }}
>
{getStateIcon(run.state)}
{tr(`states.${run.state}`)}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs" style={{ color: '#848E9C' }}>
{run.summary.progress_pct.toFixed(0)}% · ${run.summary.equity_last.toFixed(0)}
</span>
<button
onClick={(e) => {
e.stopPropagation()
onToggleCompare(run.run_id)
}}
className="p-1 rounded"
style={{
background: compareRunIds.includes(run.run_id)
? 'rgba(240,185,11,0.2)'
: 'transparent',
}}
title={t('backtestPageExtra.addToCompare', language)}
>
<Eye
className="w-3 h-3"
style={{
color: compareRunIds.includes(run.run_id) ? '#F0B90B' : '#5E6673',
}}
/>
</button>
</div>
</button>
))
)}
</div>
</div>
)
}
@@ -1,104 +0,0 @@
import { useMemo } from 'react'
import { motion } from 'framer-motion'
import { TrendingUp, TrendingDown } from 'lucide-react'
import type { BacktestTradeEvent } from '../../types'
// ============ Trade Timeline ============
function TradeTimeline({ trades }: { trades: BacktestTradeEvent[] }) {
const recentTrades = useMemo(() => [...trades].slice(-20).reverse(), [trades])
if (recentTrades.length === 0) {
return (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
No trades yet
</div>
)
}
return (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
{recentTrades.map((trade, idx) => {
const isOpen = trade.action.includes('open')
const isLong = trade.action.includes('long')
const bgColor = isOpen ? 'rgba(14, 203, 129, 0.1)' : 'rgba(246, 70, 93, 0.1)'
const borderColor = isOpen ? 'rgba(14, 203, 129, 0.3)' : 'rgba(246, 70, 93, 0.3)'
const iconColor = isOpen ? '#0ECB81' : '#F6465D'
return (
<motion.div
key={`${trade.ts}-${trade.symbol}-${idx}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="p-3 rounded-lg flex items-center gap-3"
style={{ background: bgColor, border: `1px solid ${borderColor}` }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ background: `${iconColor}20` }}
>
{isLong ? (
<TrendingUp className="w-4 h-4" style={{ color: iconColor }} />
) : (
<TrendingDown className="w-4 h-4" style={{ color: iconColor }} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
{trade.symbol.replace('USDT', '')}
</span>
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ background: `${iconColor}20`, color: iconColor }}
>
{trade.action.replace('_', ' ').toUpperCase()}
</span>
{trade.leverage && (
<span className="text-xs" style={{ color: '#848E9C' }}>
{trade.leverage}x
</span>
)}
</div>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{new Date(trade.ts).toLocaleString()} · Qty: {trade.qty.toFixed(4)} · ${trade.price.toFixed(2)}
</div>
</div>
<div className="text-right">
<div
className="font-mono font-bold"
style={{ color: trade.realized_pnl >= 0 ? '#0ECB81' : '#F6465D' }}
>
{trade.realized_pnl >= 0 ? '+' : ''}
{trade.realized_pnl.toFixed(2)}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
USDT
</div>
</div>
</motion.div>
)
})}
</div>
)
}
// ============ Trades Tab Content ============
interface BacktestTradesTabProps {
trades: BacktestTradeEvent[] | undefined
}
export function BacktestTradesTab({ trades }: BacktestTradesTabProps) {
return (
<motion.div
key="trades"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<TradeTimeline trades={trades ?? []} />
</motion.div>
)
}
-3
View File
@@ -9,7 +9,6 @@ type Page =
| 'competition'
| 'traders'
| 'trader'
| 'backtest'
| 'strategy'
| 'strategy-market'
| 'data'
@@ -100,7 +99,6 @@ export default function HeaderBar({
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
]
@@ -342,7 +340,6 @@ export default function HeaderBar({
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
]
+9 -714
View File
@@ -9,7 +9,6 @@ export const translations = {
details: 'Details',
tradingPanel: 'Trading Panel',
competition: 'Competition',
backtest: 'Backtest',
running: 'RUNNING',
stopped: 'STOPPED',
adminMode: 'Admin Mode',
@@ -95,170 +94,6 @@ export const translations = {
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit Fullscreen',
// Backtest Page
backtestPage: {
title: 'Backtest Lab',
subtitle:
'Pick a model + time range to replay the full AI decision loop.',
start: 'Start Backtest',
starting: 'Starting...',
quickRanges: {
h24: '24h',
d3: '3d',
d7: '7d',
},
actions: {
pause: 'Pause',
resume: 'Resume',
stop: 'Stop',
},
states: {
running: 'Running',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
liquidated: 'Liquidated',
},
form: {
aiModelLabel: 'AI Model',
selectAiModel: 'Select AI model',
providerLabel: 'Provider',
statusLabel: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
noModelWarning:
'Please add and enable an AI model on the Model Config page first.',
runIdLabel: 'Run ID',
runIdPlaceholder: 'Leave blank to auto-generate',
decisionTfLabel: 'Decision TF',
cadenceLabel: 'Decision cadence (bars)',
timeRangeLabel: 'Time range',
symbolsLabel: 'Symbols (comma-separated)',
customTfPlaceholder: 'Custom TFs (comma separated, e.g. 2h,6h)',
initialBalanceLabel: 'Initial balance (USDT)',
feeLabel: 'Fee (bps)',
slippageLabel: 'Slippage (bps)',
btcEthLeverageLabel: 'BTC/ETH leverage (x)',
altcoinLeverageLabel: 'Altcoin leverage (x)',
fillPolicies: {
nextOpen: 'Next open',
barVwap: 'Bar VWAP',
midPrice: 'Mid price',
},
promptPresets: {
baseline: 'Baseline',
aggressive: 'Aggressive',
conservative: 'Conservative',
scalping: 'Scalping',
},
cacheAiLabel: 'Reuse AI cache',
replayOnlyLabel: 'Replay only',
overridePromptLabel: 'Use only custom prompt',
customPromptLabel: 'Custom prompt (optional)',
customPromptPlaceholder:
'Append or fully customize the strategy prompt',
},
runList: {
title: 'Runs',
count: 'Total {count} records',
},
filters: {
allStates: 'All states',
searchPlaceholder: 'Run ID / label',
},
tableHeaders: {
runId: 'Run ID',
label: 'Label',
state: 'State',
progress: 'Progress',
equity: 'Equity',
lastError: 'Last Error',
updated: 'Updated',
},
emptyStates: {
noRuns: 'No runs yet',
selectRun: 'Select a run to view details',
},
detail: {
tfAndSymbols: 'TF: {tf} · Symbols {count}',
labelPlaceholder: 'Label note',
saveLabel: 'Save',
deleteLabel: 'Delete',
exportLabel: 'Export',
errorLabel: 'Error',
},
toasts: {
selectModel: 'Please select an AI model first.',
modelDisabled: 'AI model {name} is disabled.',
invalidRange: 'End time must be later than start time.',
startSuccess: 'Backtest {id} started.',
startFailed: 'Failed to start. Please try again later.',
actionSuccess: '{action} {id} succeeded.',
actionFailed: 'Operation failed. Please try again later.',
labelSaved: 'Label updated.',
labelFailed: 'Failed to update label.',
confirmDelete: 'Delete backtest {id}? This action cannot be undone.',
deleteSuccess: 'Backtest record deleted.',
deleteFailed: 'Failed to delete. Please try again later.',
traceFailed: 'Failed to fetch AI trace.',
exportSuccess: 'Exported data for {id}.',
exportFailed: 'Failed to export.',
},
aiTrace: {
title: 'AI Trace',
clear: 'Clear',
cyclePlaceholder: 'Cycle',
fetch: 'Fetch',
prompt: 'Prompt',
cot: 'Chain of thought',
output: 'Output',
cycleTag: 'Cycle #{cycle}',
},
decisionTrail: {
title: 'AI Decision Trail',
subtitle: 'Showing last {count} cycles',
empty: 'No records yet',
emptyHint:
'The AI thought & execution log will appear once the run starts.',
},
charts: {
equityTitle: 'Equity Curve',
equityEmpty: 'No data yet',
},
metrics: {
title: 'Metrics',
totalReturn: 'Total Return %',
maxDrawdown: 'Max Drawdown %',
sharpe: 'Sharpe',
profitFactor: 'Profit Factor',
pending: 'Calculating...',
realized: 'Realized PnL',
unrealized: 'Unrealized PnL',
},
trades: {
title: 'Trade Events',
headers: {
time: 'Time',
symbol: 'Symbol',
action: 'Action',
qty: 'Qty',
leverage: 'Leverage',
pnl: 'PnL',
},
empty: 'No trades yet',
},
metadata: {
title: 'Metadata',
created: 'Created',
updated: 'Updated',
processedBars: 'Processed Bars',
maxDrawdown: 'Max DD',
liquidated: 'Liquidated',
yes: 'Yes',
no: 'No',
},
},
// Competition Page
aiCompetition: 'AI Competition',
traders: 'traders',
@@ -838,7 +673,7 @@ export const translations = {
// ===== GETTING STARTED =====
faqWhatIsNOFX: 'What is NOFX?',
faqWhatIsNOFXAnswer:
'NOFX is an open-source AI-powered trading operating system for cryptocurrency and US stock markets. It uses large language models (LLMs) like DeepSeek, GPT, Claude, Gemini to analyze market data and make autonomous trading decisions. Key features include: multi-AI model support, multi-exchange trading, visual strategy builder, and backtesting.',
'NOFX is an open-source AI-powered trading operating system for cryptocurrency and US stock markets. It uses large language models (LLMs) like DeepSeek, GPT, Claude, Gemini to analyze market data and make autonomous trading decisions. Key features include: multi-AI model support, multi-exchange trading, and visual strategy builder.',
faqHowDoesItWork: 'How does NOFX work?',
faqHowDoesItWorkAnswer:
@@ -846,7 +681,7 @@ export const translations = {
faqIsProfitable: 'Is NOFX profitable?',
faqIsProfitableAnswer:
'AI trading is experimental and NOT guaranteed to be profitable. Cryptocurrency futures are highly volatile and risky. NOFX is designed for educational and research purposes. We strongly recommend: starting with small amounts (10-50 USDT), never investing more than you can afford to lose, thoroughly testing with backtests before live trading, and understanding that past performance does not guarantee future results.',
'AI trading is experimental and NOT guaranteed to be profitable. Cryptocurrency futures are highly volatile and risky. NOFX is designed for educational and research purposes. We strongly recommend: starting with small amounts (10-50 USDT), never investing more than you can afford to lose, thoroughly testing before live trading, and understanding that past performance does not guarantee future results.',
faqSupportedExchanges: 'Which exchanges are supported?',
faqSupportedExchangesAnswer:
@@ -998,10 +833,6 @@ export const translations = {
faqStrategyStudioAnswer:
'Strategy Studio is a visual strategy builder where you configure: 1) Coin Sources - which cryptocurrencies to trade (static list, AI500 top coins, OI ranking); 2) Technical Indicators - EMA, MACD, RSI, ATR, Volume, Open Interest, Funding Rate; 3) Risk Controls - leverage limits, position sizing, margin caps; 4) Custom Prompts - specific instructions for AI. No coding required.',
faqBacktestLab: 'What is Backtest Lab?',
faqBacktestLabAnswer:
'Backtest Lab tests your strategy against historical data without risking real funds. Features: 1) Configure AI model, date range, initial balance; 2) Watch real-time progress with equity curve; 3) View metrics: Return %, Max Drawdown, Sharpe Ratio, Win Rate; 4) Analyze individual trades and AI reasoning. Essential for validating strategies before live trading.',
faqCompetitionMode: 'What is Competition Mode?',
faqCompetitionModeAnswer:
'Competition page shows a real-time leaderboard of all your traders. Compare: ROI, P&L, Sharpe ratio, win rate, number of trades. Use this to A/B test different AI models, strategies, or configurations. Traders can be marked as "Show in Competition" to appear on the leaderboard.',
@@ -1025,7 +856,7 @@ export const translations = {
faqCompareAIModels: 'How do I compare different AI models?',
faqCompareAIModelsAnswer:
'Create multiple traders with different AI models but same strategy/exchange. Run them simultaneously and compare on Competition page. Metrics to watch: ROI, win rate, Sharpe ratio, max drawdown. Alternatively, use Backtest Lab to test models against same historical data.',
'Create multiple traders with different AI models but same strategy/exchange. Run them simultaneously and compare on Competition page. Metrics to watch: ROI, win rate, Sharpe ratio, max drawdown.',
// ===== CONTRIBUTING =====
faqHowToContribute: 'How can I contribute to NOFX?',
@@ -1256,53 +1087,6 @@ export const translations = {
runAiTestHint: 'Click to run AI test',
},
// Backtest Page (additional keys)
backtestPageExtra: {
newBacktest: 'New Backtest',
confirmDelete: 'Confirm Delete',
delete: 'Delete',
cancel: 'Cancel',
equity: 'Equity',
totalReturn: 'Return',
maxDD: 'Max DD',
sharpe: 'Sharpe',
tabOverview: 'Overview',
tabChart: 'Chart',
tabTrades: 'Trades',
tabDecisions: 'Decisions',
addToCompare: 'Add to compare',
runs: 'runs',
runCount: 'runs',
},
// Backtest Overview Tab
backtestOverview: {
activePositions: 'Active Positions',
margin: 'Margin',
unrealized: 'Unrealized',
qty: 'Qty',
entry: 'Entry',
mark: 'Mark',
winRate: 'Win Rate',
profitFactor: 'Profit Factor',
totalTrades: 'Total Trades',
bestSymbol: 'Best Symbol',
},
// Backtest Chart Tab
backtestChart: {
noTrades: 'No trades to display',
symbol: 'Symbol',
interval: 'Interval',
trades: 'trades',
loadingKline: 'Loading kline data...',
openProfit: 'Open/Profit',
lossClose: 'Loss Close',
close: 'Close',
equityCurve: 'Equity Curve',
candlestickTradeMarkers: 'Candlestick & Trade Markers',
},
// Metric Tooltip
metricTooltip: {
formula: 'Formula',
@@ -1314,10 +1098,9 @@ export const translations = {
accessDenied: 'ACCESS DENIED',
subtitleWithFeature: 'Module "{featureName}" requires elevated privileges',
subtitleDefault: 'Authorization required for this module',
description: 'Initialize authentication protocol to unlock full system capabilities: AI Trader configuration, Strategy Market data streams, and Backtest Simulation core.',
description: 'Initialize authentication protocol to unlock full system capabilities: AI Trader configuration and Strategy Market data streams.',
benefit1: 'AI Trader Control',
benefit2: 'HFT Strategy Market',
benefit3: 'Historical Backtest Engine',
benefit4: 'Full System Visualization',
loginButton: 'EXECUTE LOGIN',
registerButton: 'REGISTER NEW ID',
@@ -1552,28 +1335,6 @@ export const translations = {
no: 'No',
},
// BacktestConfigForm
backtestConfigForm: {
selectModel: 'Select Model',
configure: 'Configure',
confirmStart: 'Confirm',
strategyOptional: 'Strategy (Optional)',
noSavedStrategy: 'No saved strategy',
coinSource: 'Coin Source:',
clearDynamicCoins: 'Clear the symbols field below to use strategy\'s dynamic coins',
optionalCoinSource: 'Optional - strategy has coin source',
leavEmptyForStrategy: 'Leave empty to use strategy coin source',
clearToUseStrategy: 'Clear to use strategy',
next: 'Next',
timeframes: 'Timeframes',
back: 'Back',
strategyStyle: 'Strategy Style',
quickRange24h: '24h',
quickRange3d: '3d',
quickRange7d: '7d',
quickRange30d: '30d',
},
},
zh: {
// Header
@@ -1583,7 +1344,6 @@ export const translations = {
details: '详情',
tradingPanel: '交易面板',
competition: '竞赛',
backtest: '回测',
running: '运行中',
stopped: '已停止',
adminMode: '管理员模式',
@@ -1669,166 +1429,6 @@ export const translations = {
fullscreen: '全屏',
exitFullscreen: '退出全屏',
// Backtest Page
backtestPage: {
title: '回测实验室',
subtitle: '选择模型与时间范围,快速复盘 AI 决策链路。',
start: '启动回测',
starting: '启动中...',
quickRanges: {
h24: '24小时',
d3: '3天',
d7: '7天',
},
actions: {
pause: '暂停',
resume: '恢复',
stop: '停止',
},
states: {
running: '运行中',
paused: '已暂停',
completed: '已完成',
failed: '失败',
liquidated: '已爆仓',
},
form: {
aiModelLabel: 'AI 模型',
selectAiModel: '选择AI模型',
providerLabel: 'Provider',
statusLabel: '状态',
enabled: '已启用',
disabled: '未启用',
noModelWarning: '请先在「模型配置」页面添加并启用AI模型。',
runIdLabel: 'Run ID',
runIdPlaceholder: '留空则自动生成',
decisionTfLabel: '决策周期',
cadenceLabel: '决策节奏(根数)',
timeRangeLabel: '时间范围',
symbolsLabel: '交易标的(逗号分隔)',
customTfPlaceholder: '自定义周期(逗号分隔,例如 2h,6h)',
initialBalanceLabel: '初始资金 (USDT)',
feeLabel: '手续费 (bps)',
slippageLabel: '滑点 (bps)',
btcEthLeverageLabel: 'BTC/ETH 杠杆 (倍)',
altcoinLeverageLabel: '山寨币杠杆 (倍)',
fillPolicies: {
nextOpen: '下一根开盘价',
barVwap: 'K线 VWAP',
midPrice: '中间价',
},
promptPresets: {
baseline: '基础版',
aggressive: '激进版',
conservative: '稳健版',
scalping: '剥头皮',
},
cacheAiLabel: '复用AI缓存',
replayOnlyLabel: '仅回放记录',
overridePromptLabel: '仅使用自定义提示词',
customPromptLabel: '自定义提示词(可选)',
customPromptPlaceholder: '追加或完全自定义策略提示词',
},
runList: {
title: '运行列表',
count: '共 {count} 条记录',
},
filters: {
allStates: '全部状态',
searchPlaceholder: 'Run ID / 标签',
},
tableHeaders: {
runId: 'Run ID',
label: '标签',
state: '状态',
progress: '进度',
equity: '净值',
lastError: '最后错误',
updated: '更新时间',
},
emptyStates: {
noRuns: '暂无记录',
selectRun: '请选择一个运行查看详情',
},
detail: {
tfAndSymbols: '周期: {tf} · 币种 {count}',
labelPlaceholder: '备注标签',
saveLabel: '保存',
deleteLabel: '删除',
exportLabel: '导出',
errorLabel: '错误',
},
toasts: {
selectModel: '请先选择一个AI模型。',
modelDisabled: 'AI模型 {name} 尚未启用。',
invalidRange: '结束时间必须晚于开始时间。',
startSuccess: '回测 {id} 已启动。',
startFailed: '启动失败,请稍后再试。',
actionSuccess: '{action} {id} 成功。',
actionFailed: '操作失败,请稍后再试。',
labelSaved: '标签已更新。',
labelFailed: '更新标签失败。',
confirmDelete: '确认删除回测 {id} 吗?该操作不可恢复。',
deleteSuccess: '回测记录已删除。',
deleteFailed: '删除失败,请稍后再试。',
traceFailed: '获取AI思维链失败。',
exportSuccess: '已导出 {id} 的数据。',
exportFailed: '导出失败。',
},
aiTrace: {
title: 'AI 思维链',
clear: '清除',
cyclePlaceholder: '循环编号',
fetch: '获取',
prompt: '提示词',
cot: '思考链',
output: '输出',
cycleTag: '周期 #{cycle}',
},
decisionTrail: {
title: 'AI 决策轨迹',
subtitle: '展示最近 {count} 次循环',
empty: '暂无记录',
emptyHint: '回测运行后将自动记录每次 AI 思考与执行',
},
charts: {
equityTitle: '净值曲线',
equityEmpty: '暂无数据',
},
metrics: {
title: '指标',
totalReturn: '总收益率 %',
maxDrawdown: '最大回撤 %',
sharpe: '夏普比率',
profitFactor: '盈亏因子',
pending: '计算中...',
realized: '已实现盈亏',
unrealized: '未实现盈亏',
},
trades: {
title: '交易事件',
headers: {
time: '时间',
symbol: '币种',
action: '操作',
qty: '数量',
leverage: '杠杆',
pnl: '盈亏',
},
empty: '暂无交易',
},
metadata: {
title: '元信息',
created: '创建时间',
updated: '更新时间',
processedBars: '已处理K线',
maxDrawdown: '最大回撤',
liquidated: '是否爆仓',
yes: '是',
no: '否',
},
},
// Competition Page
aiCompetition: 'AI竞赛',
traders: '交易员',
@@ -2521,10 +2121,6 @@ export const translations = {
faqStrategyStudioAnswer:
'策略工作室是可视化策略构建器,您可以配置:1)币种来源 - 交易哪些加密货币(静态列表、AI500 热门币、OI 排行);2)技术指标 - EMA、MACD、RSI、ATR、成交量、持仓量、资金费率;3)风控 - 杠杆限制、仓位大小、保证金上限;4)自定义提示词 - AI 的特定指令。无需编程。',
faqBacktestLab: '什么是回测实验室?',
faqBacktestLabAnswer:
'回测实验室用历史数据测试您的策略,无需冒真金风险。功能:1)配置 AI 模型、日期范围、初始余额;2)实时观看进度和权益曲线;3)查看指标:收益率、最大回撤、夏普比率、胜率;4)分析单笔交易和 AI 推理。实盘交易前验证策略的必备工具。',
faqCompetitionMode: '什么是竞赛模式?',
faqCompetitionModeAnswer:
'竞赛页面显示所有交易员的实时排行榜。比较:ROI、盈亏、夏普比率、胜率、交易次数。用于 A/B 测试不同 AI 模型、策略或配置。交易员可标记为"在竞赛中显示"以出现在排行榜上。',
@@ -2548,7 +2144,7 @@ export const translations = {
faqCompareAIModels: '如何比较不同 AI 模型?',
faqCompareAIModelsAnswer:
'创建多个交易员,使用不同 AI 模型但相同策略/交易所。同时运行并在竞赛页面比较。关注指标:ROI、胜率、夏普比率、最大回撤。或者使用回测实验室用相同历史数据测试模型。',
'创建多个交易员,使用不同 AI 模型但相同策略/交易所。同时运行并在竞赛页面比较。关注指标:ROI、胜率、夏普比率、最大回撤。',
// ===== 参与贡献 =====
faqHowToContribute: '如何为 NOFX 做贡献?',
@@ -2772,53 +2368,6 @@ export const translations = {
runAiTestHint: '点击运行 AI 测试',
},
// Backtest Page (additional keys)
backtestPageExtra: {
newBacktest: '新建回测',
confirmDelete: '确认删除',
delete: '删除',
cancel: '取消',
equity: '当前净值',
totalReturn: '总收益率',
maxDD: '最大回撤',
sharpe: '夏普比率',
tabOverview: '概览',
tabChart: '图表',
tabTrades: '交易',
tabDecisions: 'AI决策',
addToCompare: '添加到对比',
runs: '条',
runCount: '条',
},
// Backtest Overview Tab
backtestOverview: {
activePositions: '当前持仓',
margin: '保证金',
unrealized: '浮盈',
qty: '数量',
entry: '开仓',
mark: '现价',
winRate: '胜率',
profitFactor: '盈亏因子',
totalTrades: '总交易数',
bestSymbol: '最佳币种',
},
// Backtest Chart Tab
backtestChart: {
noTrades: '没有交易记录',
symbol: '币种',
interval: '周期',
trades: '笔交易',
loadingKline: '加载K线数据...',
openProfit: '开仓/盈利',
lossClose: '亏损平仓',
close: '平仓',
equityCurve: '资金曲线',
candlestickTradeMarkers: 'K线图 & 交易标记',
},
// Metric Tooltip
metricTooltip: {
formula: '计算公式',
@@ -2830,10 +2379,9 @@ export const translations = {
accessDenied: '访问被拒绝',
subtitleWithFeature: '访问「{featureName}」需要更高权限',
subtitleDefault: '此模块需要授权访问',
description: '初始化身份验证协议以解锁完整系统功能:AI 交易员配置、策略市场数据流、回测模拟核心。',
description: '初始化身份验证协议以解锁完整系统功能:AI 交易员配置、策略市场数据流。',
benefit1: 'AI 交易员控制权',
benefit2: '高频策略核心市场',
benefit3: '历史数据回测引擎',
benefit4: '全系统数据可视化',
loginButton: '执行登录指令',
registerButton: '注册新用户 ID',
@@ -3062,27 +2610,6 @@ export const translations = {
no: '否',
},
backtestConfigForm: {
selectModel: '选择模型',
configure: '配置参数',
confirmStart: '确认启动',
strategyOptional: '策略配置(可选)',
noSavedStrategy: '不使用保存的策略',
coinSource: '币种来源:',
clearDynamicCoins: '⚡ 清空下方币种输入框即可使用策略的动态币种',
optionalCoinSource: '可选 - 策略已配置币种来源',
leavEmptyForStrategy: '留空将使用策略配置的币种来源',
clearToUseStrategy: '清空使用策略币种',
next: '下一步',
timeframes: '时间周期',
back: '上一步',
strategyStyle: '策略风格',
quickRange24h: '24小时',
quickRange3d: '3天',
quickRange7d: '7天',
quickRange30d: '30天',
},
},
id: {
// Header
@@ -3092,7 +2619,6 @@ export const translations = {
details: 'Detail',
tradingPanel: 'Panel Trading',
competition: 'Kompetisi',
backtest: 'Backtest',
running: 'BERJALAN',
stopped: 'BERHENTI',
adminMode: 'Mode Admin',
@@ -3178,166 +2704,6 @@ export const translations = {
fullscreen: 'Layar Penuh',
exitFullscreen: 'Keluar Layar Penuh',
// Backtest Page
backtestPage: {
title: 'Lab Backtest',
subtitle: 'Pilih model + rentang waktu untuk memutar ulang alur keputusan AI.',
start: 'Mulai Backtest',
starting: 'Memulai...',
quickRanges: {
h24: '24j',
d3: '3h',
d7: '7h',
},
actions: {
pause: 'Jeda',
resume: 'Lanjutkan',
stop: 'Berhenti',
},
states: {
running: 'Berjalan',
paused: 'Dijeda',
completed: 'Selesai',
failed: 'Gagal',
liquidated: 'Terlikuidasi',
},
form: {
aiModelLabel: 'Model AI',
selectAiModel: 'Pilih model AI',
providerLabel: 'Penyedia',
statusLabel: 'Status',
enabled: 'Aktif',
disabled: 'Nonaktif',
noModelWarning: 'Silakan tambahkan dan aktifkan model AI di halaman Konfigurasi Model terlebih dahulu.',
runIdLabel: 'Run ID',
runIdPlaceholder: 'Kosongkan untuk otomatis',
decisionTfLabel: 'TF Keputusan',
cadenceLabel: 'Irama keputusan (bar)',
timeRangeLabel: 'Rentang waktu',
symbolsLabel: 'Simbol (pisahkan dengan koma)',
customTfPlaceholder: 'TF kustom (pisahkan dengan koma, misal 2h,6h)',
initialBalanceLabel: 'Saldo awal (USDT)',
feeLabel: 'Biaya (bps)',
slippageLabel: 'Selisih harga (bps)',
btcEthLeverageLabel: 'Leverage BTC/ETH (x)',
altcoinLeverageLabel: 'Leverage Altcoin (x)',
fillPolicies: {
nextOpen: 'Harga buka berikutnya',
barVwap: 'VWAP Bar',
midPrice: 'Harga tengah',
},
promptPresets: {
baseline: 'Dasar',
aggressive: 'Agresif',
conservative: 'Konservatif',
scalping: 'Scalping',
},
cacheAiLabel: 'Gunakan cache AI',
replayOnlyLabel: 'Hanya putar ulang',
overridePromptLabel: 'Gunakan hanya prompt kustom',
customPromptLabel: 'Prompt kustom (opsional)',
customPromptPlaceholder: 'Tambahkan atau kustomisasi prompt strategi sepenuhnya',
},
runList: {
title: 'Daftar Run',
count: 'Total {count} catatan',
},
filters: {
allStates: 'Semua status',
searchPlaceholder: 'Run ID / label',
},
tableHeaders: {
runId: 'Run ID',
label: 'Label',
state: 'Status',
progress: 'Progres',
equity: 'Ekuitas',
lastError: 'Error Terakhir',
updated: 'Diperbarui',
},
emptyStates: {
noRuns: 'Belum ada run',
selectRun: 'Pilih run untuk melihat detail',
},
detail: {
tfAndSymbols: 'TF: {tf} · Simbol {count}',
labelPlaceholder: 'Catatan label',
saveLabel: 'Simpan',
deleteLabel: 'Hapus',
exportLabel: 'Ekspor',
errorLabel: 'Error',
},
toasts: {
selectModel: 'Silakan pilih model AI terlebih dahulu.',
modelDisabled: 'Model AI {name} tidak aktif.',
invalidRange: 'Waktu akhir harus lebih lambat dari waktu mulai.',
startSuccess: 'Backtest {id} dimulai.',
startFailed: 'Gagal memulai. Silakan coba lagi nanti.',
actionSuccess: '{action} {id} berhasil.',
actionFailed: 'Operasi gagal. Silakan coba lagi nanti.',
labelSaved: 'Label diperbarui.',
labelFailed: 'Gagal memperbarui label.',
confirmDelete: 'Hapus backtest {id}? Tindakan ini tidak dapat dibatalkan.',
deleteSuccess: 'Catatan backtest dihapus.',
deleteFailed: 'Gagal menghapus. Silakan coba lagi nanti.',
traceFailed: 'Gagal mengambil jejak AI.',
exportSuccess: 'Data untuk {id} diekspor.',
exportFailed: 'Gagal mengekspor.',
},
aiTrace: {
title: 'Jejak AI',
clear: 'Hapus',
cyclePlaceholder: 'Siklus',
fetch: 'Ambil',
prompt: 'Prompt',
cot: 'Rantai pemikiran',
output: 'Output',
cycleTag: 'Siklus #{cycle}',
},
decisionTrail: {
title: 'Jejak Keputusan AI',
subtitle: 'Menampilkan {count} siklus terakhir',
empty: 'Belum ada catatan',
emptyHint: 'Log pemikiran & eksekusi AI akan muncul setelah run dimulai.',
},
charts: {
equityTitle: 'Kurva Ekuitas',
equityEmpty: 'Belum ada data',
},
metrics: {
title: 'Metrik',
totalReturn: 'Total Return %',
maxDrawdown: 'Drawdown Maks %',
sharpe: 'Sharpe',
profitFactor: 'Profit Factor',
pending: 'Menghitung...',
realized: 'L/R Terealisasi',
unrealized: 'L/R Belum Terealisasi',
},
trades: {
title: 'Riwayat Trading',
headers: {
time: 'Waktu',
symbol: 'Simbol',
action: 'Aksi',
qty: 'Jml',
leverage: 'Leverage',
pnl: 'L/R',
},
empty: 'Belum ada trading',
},
metadata: {
title: 'Metadata',
created: 'Dibuat',
updated: 'Diperbarui',
processedBars: 'Bar Diproses',
maxDrawdown: 'DD Maks',
liquidated: 'Terlikuidasi',
yes: 'Ya',
no: 'Tidak',
},
},
// Competition Page
aiCompetition: 'Kompetisi AI',
traders: 'trader',
@@ -3799,11 +3165,11 @@ export const translations = {
faqCategoryAIModels: 'Model AI',
faqCategoryContributing: 'Kontribusi',
faqWhatIsNOFX: 'Apa itu NOFX?',
faqWhatIsNOFXAnswer: 'NOFX adalah sistem operasi trading bertenaga AI open-source untuk pasar kripto dan saham AS. Ia menggunakan model bahasa besar (LLM) seperti DeepSeek, GPT, Claude, Gemini untuk menganalisis data pasar dan membuat keputusan trading secara otonom. Fitur utama: dukungan multi-model AI, trading multi-bursa, pembangun strategi visual, dan backtesting.',
faqWhatIsNOFXAnswer: 'NOFX adalah sistem operasi trading bertenaga AI open-source untuk pasar kripto dan saham AS. Ia menggunakan model bahasa besar (LLM) seperti DeepSeek, GPT, Claude, Gemini untuk menganalisis data pasar dan membuat keputusan trading secara otonom. Fitur utama: dukungan multi-model AI, trading multi-bursa, dan pembangun strategi visual.',
faqHowDoesItWork: 'Bagaimana cara kerja NOFX?',
faqHowDoesItWorkAnswer: 'NOFX bekerja dalam 5 langkah: 1) Konfigurasi model AI dan kredensial API bursa; 2) Buat strategi trading (pemilihan koin, indikator, kontrol risiko); 3) Buat "Trader" menggabungkan Model AI + Bursa + Strategi; 4) Mulai trader - dia akan menganalisis data pasar secara berkala dan membuat keputusan beli/jual/tahan; 5) Pantau performa di dasbor.',
faqIsProfitable: 'Apakah NOFX menguntungkan?',
faqIsProfitableAnswer: 'Trading AI bersifat eksperimental dan TIDAK dijamin menguntungkan. Futures kripto sangat volatil dan berisiko. NOFX dirancang untuk tujuan edukasi dan riset. Kami sangat menyarankan: mulai dengan jumlah kecil (10-50 USDT), jangan investasi melebihi yang sanggup Anda rugi, uji dengan backtest sebelum trading nyata.',
faqIsProfitableAnswer: 'Trading AI bersifat eksperimental dan TIDAK dijamin menguntungkan. Futures kripto sangat volatil dan berisiko. NOFX dirancang untuk tujuan edukasi dan riset. Kami sangat menyarankan: mulai dengan jumlah kecil (10-50 USDT), jangan investasi melebihi yang sanggup Anda rugi, uji sebelum trading nyata.',
faqSupportedExchanges: 'Bursa mana yang didukung?',
faqSupportedExchangesAnswer: 'CEX (Tersentralisasi): Binance Futures, Bybit, OKX, Bitget. DEX (Terdesentralisasi): Hyperliquid, Aster DEX, Lighter. Setiap bursa memiliki fitur berbeda - Binance memiliki likuiditas terbesar, Hyperliquid sepenuhnya on-chain tanpa KYC.',
faqSupportedAIModels: 'Model AI mana yang didukung?',
@@ -3876,8 +3242,6 @@ export const translations = {
faqCanNOFXStealFundsAnswer: 'NOFX open-source (lisensi AGPL-3.0) - Anda bisa audit semua kode. API key disimpan lokal di mesin ANDA, tidak pernah dikirim ke server eksternal.',
faqStrategyStudio: 'Apa itu Strategy Studio?',
faqStrategyStudioAnswer: 'Strategy Studio adalah pembangun strategi visual untuk konfigurasi: Sumber Koin, Indikator Teknikal, Kontrol Risiko, dan Prompt Kustom. Tanpa coding.',
faqBacktestLab: 'Apa itu Lab Backtest?',
faqBacktestLabAnswer: 'Lab Backtest menguji strategi Anda terhadap data historis tanpa risiko dana nyata.',
faqCompetitionMode: 'Apa itu Mode Kompetisi?',
faqCompetitionModeAnswer: 'Halaman kompetisi menampilkan papan peringkat realtime semua trader Anda. Bandingkan ROI, L/R, rasio Sharpe, win rate.',
faqChainOfThought: 'Apa itu Chain of Thought (CoT)?',
@@ -4087,53 +3451,6 @@ export const translations = {
runAiTestHint: 'Klik untuk menjalankan uji AI',
},
// Backtest Page (additional keys)
backtestPageExtra: {
newBacktest: 'Backtest Baru',
confirmDelete: 'Konfirmasi Hapus',
delete: 'Hapus',
cancel: 'Batal',
equity: 'Ekuitas',
totalReturn: 'Return',
maxDD: 'Max DD',
sharpe: 'Sharpe',
tabOverview: 'Ringkasan',
tabChart: 'Grafik',
tabTrades: 'Trade',
tabDecisions: 'Keputusan AI',
addToCompare: 'Tambah ke perbandingan',
runs: 'berjalan',
runCount: 'berjalan',
},
// Backtest Overview Tab
backtestOverview: {
activePositions: 'Posisi Aktif',
margin: 'Margin',
unrealized: 'Belum Terealisasi',
qty: 'Kuantitas',
entry: 'Masuk',
mark: 'Harga Saat Ini',
winRate: 'Win Rate',
profitFactor: 'Profit Factor',
totalTrades: 'Total Trade',
bestSymbol: 'Simbol Terbaik',
},
// Backtest Chart Tab
backtestChart: {
noTrades: 'Tidak ada trade untuk ditampilkan',
symbol: 'Simbol',
interval: 'Interval',
trades: 'trade',
loadingKline: 'Memuat data kline...',
openProfit: 'Buka/Profit',
lossClose: 'Tutup Rugi',
close: 'Tutup',
equityCurve: 'Kurva Ekuitas',
candlestickTradeMarkers: 'Candlestick & Penanda Trade',
},
// Metric Tooltip
metricTooltip: {
formula: 'Formula',
@@ -4145,10 +3462,9 @@ export const translations = {
accessDenied: 'AKSES DITOLAK',
subtitleWithFeature: 'Modul "{featureName}" memerlukan hak akses lebih tinggi',
subtitleDefault: 'Otorisasi diperlukan untuk modul ini',
description: 'Inisialisasi protokol autentikasi untuk membuka kemampuan sistem penuh: konfigurasi Trader AI, aliran data Pasar Strategi, dan inti Simulasi Backtest.',
description: 'Inisialisasi protokol autentikasi untuk membuka kemampuan sistem penuh: konfigurasi Trader AI dan aliran data Pasar Strategi.',
benefit1: 'Kontrol Trader AI',
benefit2: 'Pasar Strategi HFT',
benefit3: 'Mesin Backtest Historis',
benefit4: 'Visualisasi Sistem Penuh',
loginButton: 'JALANKAN LOGIN',
registerButton: 'DAFTAR ID BARU',
@@ -4377,27 +3693,6 @@ export const translations = {
no: 'Tidak',
},
backtestConfigForm: {
selectModel: 'Pilih Model',
configure: 'Konfigurasi',
confirmStart: 'Konfirmasi',
strategyOptional: 'Strategi (Opsional)',
noSavedStrategy: 'Tanpa strategi tersimpan',
coinSource: 'Sumber Koin:',
clearDynamicCoins: 'Kosongkan kolom simbol di bawah untuk menggunakan koin dinamis strategi',
optionalCoinSource: 'Opsional - strategi sudah memiliki sumber koin',
leavEmptyForStrategy: 'Kosongkan untuk menggunakan sumber koin strategi',
clearToUseStrategy: 'Kosongkan untuk strategi',
next: 'Lanjut',
timeframes: 'Timeframe',
back: 'Kembali',
strategyStyle: 'Gaya Strategi',
quickRange24h: '24j',
quickRange3d: '3h',
quickRange7d: '7h',
quickRange30d: '30h',
},
},
}
-197
View File
@@ -1,197 +0,0 @@
import type {
DecisionRecord,
BacktestRunsResponse,
BacktestStartConfig,
BacktestStatusPayload,
BacktestEquityPoint,
BacktestTradeEvent,
BacktestMetrics,
BacktestRunMetadata,
BacktestKlinesResponse,
} from '../../types'
import { API_BASE, getAuthHeaders, handleJSONResponse } from './helpers'
export const backtestApi = {
async getBacktestRuns(params?: {
state?: string
search?: string
limit?: number
offset?: number
}): Promise<BacktestRunsResponse> {
const query = new URLSearchParams()
if (params?.state) query.set('state', params.state)
if (params?.search) query.set('search', params.search)
if (params?.limit) query.set('limit', String(params.limit))
if (params?.offset) query.set('offset', String(params.offset))
const res = await fetch(
`${API_BASE}/backtest/runs${query.toString() ? `?${query}` : ''}`,
{
headers: getAuthHeaders(),
}
)
return handleJSONResponse<BacktestRunsResponse>(res)
},
async startBacktest(config: BacktestStartConfig): Promise<BacktestRunMetadata> {
const res = await fetch(`${API_BASE}/backtest/start`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ config }),
})
return handleJSONResponse<BacktestRunMetadata>(res)
},
async pauseBacktest(runId: string): Promise<BacktestRunMetadata> {
const res = await fetch(`${API_BASE}/backtest/pause`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ run_id: runId }),
})
return handleJSONResponse<BacktestRunMetadata>(res)
},
async resumeBacktest(runId: string): Promise<BacktestRunMetadata> {
const res = await fetch(`${API_BASE}/backtest/resume`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ run_id: runId }),
})
return handleJSONResponse<BacktestRunMetadata>(res)
},
async stopBacktest(runId: string): Promise<BacktestRunMetadata> {
const res = await fetch(`${API_BASE}/backtest/stop`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ run_id: runId }),
})
return handleJSONResponse<BacktestRunMetadata>(res)
},
async updateBacktestLabel(
runId: string,
label: string
): Promise<BacktestRunMetadata> {
const res = await fetch(`${API_BASE}/backtest/label`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ run_id: runId, label }),
})
return handleJSONResponse<BacktestRunMetadata>(res)
},
async deleteBacktestRun(runId: string): Promise<void> {
const res = await fetch(`${API_BASE}/backtest/delete`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ run_id: runId }),
})
if (!res.ok) {
throw new Error(await res.text())
}
},
async getBacktestStatus(runId: string): Promise<BacktestStatusPayload> {
const res = await fetch(`${API_BASE}/backtest/status?run_id=${runId}`, {
headers: getAuthHeaders(),
})
return handleJSONResponse<BacktestStatusPayload>(res)
},
async getBacktestEquity(
runId: string,
timeframe?: string,
limit?: number
): Promise<BacktestEquityPoint[]> {
const query = new URLSearchParams({ run_id: runId })
if (timeframe) query.set('tf', timeframe)
if (limit) query.set('limit', String(limit))
const res = await fetch(`${API_BASE}/backtest/equity?${query}`, {
headers: getAuthHeaders(),
})
return handleJSONResponse<BacktestEquityPoint[]>(res)
},
async getBacktestTrades(
runId: string,
limit = 200
): Promise<BacktestTradeEvent[]> {
const query = new URLSearchParams({
run_id: runId,
limit: String(limit),
})
const res = await fetch(`${API_BASE}/backtest/trades?${query}`, {
headers: getAuthHeaders(),
})
return handleJSONResponse<BacktestTradeEvent[]>(res)
},
async getBacktestMetrics(runId: string): Promise<BacktestMetrics> {
const res = await fetch(`${API_BASE}/backtest/metrics?run_id=${runId}`, {
headers: getAuthHeaders(),
})
return handleJSONResponse<BacktestMetrics>(res)
},
async getBacktestKlines(
runId: string,
symbol: string,
timeframe?: string
): Promise<BacktestKlinesResponse> {
const query = new URLSearchParams({ run_id: runId, symbol })
if (timeframe) query.set('timeframe', timeframe)
const res = await fetch(`${API_BASE}/backtest/klines?${query}`, {
headers: getAuthHeaders(),
})
return handleJSONResponse<BacktestKlinesResponse>(res)
},
async getBacktestTrace(
runId: string,
cycle?: number
): Promise<DecisionRecord> {
const query = new URLSearchParams({ run_id: runId })
if (cycle) query.set('cycle', String(cycle))
const res = await fetch(`${API_BASE}/backtest/trace?${query}`, {
headers: getAuthHeaders(),
})
return handleJSONResponse<DecisionRecord>(res)
},
async getBacktestDecisions(
runId: string,
limit = 20,
offset = 0
): Promise<DecisionRecord[]> {
const query = new URLSearchParams({
run_id: runId,
limit: String(limit),
offset: String(offset),
})
const res = await fetch(`${API_BASE}/backtest/decisions?${query}`, {
headers: getAuthHeaders(),
})
return handleJSONResponse<DecisionRecord[]>(res)
},
async exportBacktest(runId: string): Promise<Blob> {
const res = await fetch(`${API_BASE}/backtest/export?run_id=${runId}`, {
headers: getAuthHeaders(),
})
if (!res.ok) {
const text = await res.text()
try {
const data = text ? JSON.parse(text) : null
throw new Error(
data?.error || data?.message || text || 'Export failed, please try again later'
)
} catch (err) {
if (err instanceof Error && err.message) {
throw err
}
throw new Error(text || 'Export failed, please try again later')
}
}
return res.blob()
},
}
-2
View File
@@ -1,5 +1,4 @@
import { traderApi } from './traders'
import { backtestApi } from './backtest'
import { strategyApi } from './strategies'
import { configApi } from './config'
import { dataApi } from './data'
@@ -7,7 +6,6 @@ import { telegramApi } from './telegram'
export const api = {
...traderApi,
...backtestApi,
...strategyApi,
...configApi,
...dataApi,
-1
View File
@@ -41,7 +41,6 @@ export function LandingPage() {
'strategy-market': '/strategy-market',
'traders': '/traders',
'trader': '/dashboard',
'backtest': '/backtest',
'strategy': '/strategy',
'faq': '/faq',
}
-167
View File
@@ -1,167 +0,0 @@
// Backtest types
export interface BacktestRunSummary {
symbol_count: number;
decision_tf: string;
processed_bars: number;
progress_pct: number;
equity_last: number;
max_drawdown_pct: number;
liquidated: boolean;
liquidation_note?: string;
}
export interface BacktestRunMetadata {
run_id: string;
label?: string;
user_id?: string;
last_error?: string;
version: number;
state: string;
created_at: string;
updated_at: string;
summary: BacktestRunSummary;
}
export interface BacktestRunsResponse {
total: number;
items: BacktestRunMetadata[];
}
// Position status for real-time display during backtest
export interface BacktestPositionStatus {
symbol: string;
side: string;
quantity: number;
entry_price: number;
mark_price: number;
leverage: number;
unrealized_pnl: number;
unrealized_pnl_pct: number;
margin_used: number;
}
export interface BacktestStatusPayload {
run_id: string;
state: string;
progress_pct: number;
processed_bars: number;
current_time: number;
decision_cycle: number;
equity: number;
unrealized_pnl: number;
realized_pnl: number;
positions?: BacktestPositionStatus[];
note?: string;
last_error?: string;
last_updated_iso: string;
}
export interface BacktestEquityPoint {
ts: number;
equity: number;
available: number;
pnl: number;
pnl_pct: number;
dd_pct: number;
cycle: number;
}
export interface BacktestTradeEvent {
ts: number;
symbol: string;
action: string;
side?: string;
qty: number;
price: number;
fee: number;
slippage: number;
order_value: number;
realized_pnl: number;
leverage?: number;
cycle: number;
position_after: number;
liquidation: boolean;
note?: string;
}
export interface BacktestMetrics {
total_return_pct: number;
max_drawdown_pct: number;
sharpe_ratio: number;
profit_factor: number;
win_rate: number;
trades: number;
avg_win: number;
avg_loss: number;
best_symbol: string;
worst_symbol: string;
liquidated: boolean;
symbol_stats?: Record<
string,
{
total_trades: number;
winning_trades: number;
losing_trades: number;
total_pnl: number;
avg_pnl: number;
win_rate: number;
}
>;
}
export interface BacktestStartConfig {
run_id?: string;
ai_model_id?: string;
strategy_id?: string; // Optional: use saved strategy from Strategy Studio
symbols: string[];
timeframes: string[];
decision_timeframe: string;
decision_cadence_nbars: number;
start_ts: number;
end_ts: number;
initial_balance: number;
fee_bps: number;
slippage_bps: number;
fill_policy: string;
prompt_variant?: string;
prompt_template?: string;
custom_prompt?: string;
override_prompt?: boolean;
cache_ai?: boolean;
replay_only?: boolean;
checkpoint_interval_bars?: number;
checkpoint_interval_seconds?: number;
replay_decision_dir?: string;
shared_ai_cache_path?: string;
ai?: {
provider?: string;
model?: string;
key?: string;
secret_key?: string;
base_url?: string;
};
leverage?: {
btc_eth_leverage?: number;
altcoin_leverage?: number;
};
}
// Kline data for backtest chart
export interface BacktestKline {
time: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface BacktestKlinesResponse {
symbol: string;
timeframe: string;
start_ts: number;
end_ts: number;
count: number;
klines: BacktestKline[];
run_id: string;
}
-1
View File
@@ -1,4 +1,3 @@
export * from './trading'
export * from './backtest'
export * from './strategy'
export * from './config'