mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
2d272bb7b8
- Migrate all store packages from raw database/sql to GORM ORM - Add PostgreSQL support alongside SQLite - Move EncryptedString type to crypto package for cleaner architecture - Add automatic encryption/decryption for sensitive fields (API keys, secrets) - Fix PostgreSQL AutoMigrate conflicts by skipping existing tables - Fix duplicate /klines route registration - Update tests to use GORM database connections - Add database configuration support in config package
871 lines
23 KiB
Go
871 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"nofx/backtest"
|
|
"nofx/logger"
|
|
"nofx/market"
|
|
"nofx/provider"
|
|
"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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load strategy: %v", err)})
|
|
return
|
|
}
|
|
if strategy == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("strategy not found: %s", cfg.StrategyID)})
|
|
return
|
|
}
|
|
var strategyConfig store.StrategyConfig
|
|
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to parse strategy config: %v", err)})
|
|
return
|
|
}
|
|
cfg.SetLoadedStrategy(&strategyConfig)
|
|
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
|
|
logger.Infof("📊 Strategy coin source: type=%s, use_coin_pool=%v, use_oi_top=%v, static_coins=%v",
|
|
strategyConfig.CoinSource.SourceType,
|
|
strategyConfig.CoinSource.UseCoinPool,
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to resolve coins from strategy: %v", err)})
|
|
return
|
|
}
|
|
cfg.Symbols = symbols
|
|
logger.Infof("📊 Resolved %d coins from strategy: %v", len(symbols), symbols)
|
|
}
|
|
}
|
|
|
|
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if req.RunID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
|
return
|
|
}
|
|
|
|
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
|
|
return
|
|
}
|
|
|
|
if err := fn(req.RunID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.RunID) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.RunID) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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
|
|
}
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
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 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
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 {
|
|
logger.Errorf("Failed to fetch klines for %s: %v", symbol, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to fetch klines: %v", 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):
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "No permission to access this backtest task"})
|
|
case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows):
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Backtest task does not exist"})
|
|
default:
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
}
|
|
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)
|
|
|
|
// Set custom API URLs if provided
|
|
if coinSource.CoinPoolAPIURL != "" {
|
|
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
|
}
|
|
if coinSource.OITopAPIURL != "" {
|
|
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
|
}
|
|
|
|
// Handle empty source_type - check flags for backward compatibility
|
|
sourceType := coinSource.SourceType
|
|
if sourceType == "" {
|
|
if coinSource.UseCoinPool && coinSource.UseOITop {
|
|
sourceType = "mixed"
|
|
} else if coinSource.UseCoinPool {
|
|
sourceType = "coinpool"
|
|
} 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 "coinpool":
|
|
limit := coinSource.CoinPoolLimit
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
|
coins, err := provider.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 := provider.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 coin pool
|
|
if coinSource.UseCoinPool {
|
|
limit := coinSource.CoinPoolLimit
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
coins, err := provider.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 := provider.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 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
|
|
}
|