mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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 代码示例索引
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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',
|
||||
},
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -41,7 +41,6 @@ export function LandingPage() {
|
||||
'strategy-market': '/strategy-market',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
'strategy': '/strategy',
|
||||
'faq': '/faq',
|
||||
}
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
export * from './trading'
|
||||
export * from './backtest'
|
||||
export * from './strategy'
|
||||
export * from './config'
|
||||
|
||||
Reference in New Issue
Block a user