diff --git a/api/server.go b/api/server.go index eca75a90..d9b426df 100644 --- a/api/server.go +++ b/api/server.go @@ -4,17 +4,17 @@ import ( "context" "encoding/json" "fmt" - "nofx/logger" "net" "net/http" "nofx/auth" "nofx/backtest" + "nofx/config" "nofx/crypto" "nofx/decision" + "nofx/logger" "nofx/manager" "nofx/store" "nofx/trader" - "strconv" "strings" "time" @@ -159,10 +159,6 @@ func (s *Server) setupRoutes() { protected.POST("/strategies/:id/activate", s.handleActivateStrategy) protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy) - // 用户信号源配置 - protected.GET("/user/signal-sources", s.handleGetUserSignalSource) - protected.POST("/user/signal-sources", s.handleSaveUserSignalSource) - // 指定trader的数据(使用query参数 ?trader_id=xxx) protected.GET("/status", s.handleStatus) protected.GET("/account", s.handleAccount) @@ -184,45 +180,12 @@ func (s *Server) handleHealth(c *gin.Context) { // handleGetSystemConfig 获取系统配置(客户端需要知道的配置) func (s *Server) handleGetSystemConfig(c *gin.Context) { - // 获取默认币种 - defaultCoinsStr, _ := s.store.SystemConfig().Get("default_coins") - var defaultCoins []string - if defaultCoinsStr != "" { - json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins) - } - if len(defaultCoins) == 0 { - // 使用硬编码的默认币种 - defaultCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT", "ADAUSDT", "HYPEUSDT"} - } - - // 获取杠杆配置 - btcEthLeverageStr, _ := s.store.SystemConfig().Get("btc_eth_leverage") - altcoinLeverageStr, _ := s.store.SystemConfig().Get("altcoin_leverage") - - btcEthLeverage := 5 - if val, err := strconv.Atoi(btcEthLeverageStr); err == nil && val > 0 { - btcEthLeverage = val - } - - altcoinLeverage := 5 - if val, err := strconv.Atoi(altcoinLeverageStr); err == nil && val > 0 { - altcoinLeverage = val - } - - // 获取内测模式配置 - betaModeStr, _ := s.store.SystemConfig().Get("beta_mode") - betaMode := betaModeStr == "true" - - // 获取注册开关配置(默认开启) - registrationEnabledStr, _ := s.store.SystemConfig().Get("registration_enabled") - registrationEnabled := registrationEnabledStr != "false" + cfg := config.Get() c.JSON(http.StatusOK, gin.H{ - "beta_mode": betaMode, - "registration_enabled": registrationEnabled, - "default_coins": defaultCoins, - "btc_eth_leverage": btcEthLeverage, - "altcoin_leverage": altcoinLeverage, + "registration_enabled": cfg.RegistrationEnabled, + "btc_eth_leverage": 10, // 默认值 + "altcoin_leverage": 5, // 默认值 }) } @@ -510,28 +473,14 @@ func (s *Server) handleCreateTrader(c *gin.Context) { isCrossMargin = *req.IsCrossMargin } - // 设置杠杆默认值(从系统配置获取) - btcEthLeverage := 5 - altcoinLeverage := 5 + // 设置杠杆默认值 + btcEthLeverage := 10 // 默认值 + altcoinLeverage := 5 // 默认值 if req.BTCETHLeverage > 0 { btcEthLeverage = req.BTCETHLeverage - } else { - // 从系统配置获取默认值 - if btcEthLeverageStr, _ := s.store.SystemConfig().Get("btc_eth_leverage"); btcEthLeverageStr != "" { - if val, err := strconv.Atoi(btcEthLeverageStr); err == nil && val > 0 { - btcEthLeverage = val - } - } } if req.AltcoinLeverage > 0 { altcoinLeverage = req.AltcoinLeverage - } else { - // 从系统配置获取默认值 - if altcoinLeverageStr, _ := s.store.SystemConfig().Get("altcoin_leverage"); altcoinLeverageStr != "" { - if val, err := strconv.Atoi(altcoinLeverageStr); err == nil && val > 0 { - altcoinLeverage = val - } - } } // 设置系统提示词模板默认值 @@ -1424,48 +1373,6 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "交易所配置已更新"}) } -// handleGetUserSignalSource 获取用户信号源配置 -func (s *Server) handleGetUserSignalSource(c *gin.Context) { - userID := c.GetString("user_id") - source, err := s.store.SignalSource().Get(userID) - if err != nil { - // 如果配置不存在,返回空配置而不是404错误 - c.JSON(http.StatusOK, gin.H{ - "coin_pool_url": "", - "oi_top_url": "", - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "coin_pool_url": source.CoinPoolURL, - "oi_top_url": source.OITopURL, - }) -} - -// handleSaveUserSignalSource 保存用户信号源配置 -func (s *Server) handleSaveUserSignalSource(c *gin.Context) { - userID := c.GetString("user_id") - var req struct { - CoinPoolURL string `json:"coin_pool_url"` - OITopURL string `json:"oi_top_url"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - err := s.store.SignalSource().Create(userID, req.CoinPoolURL, req.OITopURL) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("保存用户信号源配置失败: %v", err)}) - return - } - - logger.Infof("✓ 用户信号源配置已保存: user=%s, coin_pool=%s, oi_top=%s", userID, req.CoinPoolURL, req.OITopURL) - c.JSON(http.StatusOK, gin.H{"message": "用户信号源配置已保存"}) -} - // handleTraderList trader列表 func (s *Server) handleTraderList(c *gin.Context) { userID := c.GetString("user_id") @@ -1731,6 +1638,7 @@ func (s *Server) handleCompetition(c *gin.Context) { } // handleEquityHistory 收益率历史数据 +// 直接从数据库查询,不依赖内存中的 trader(这样重启后也能获取历史数据) func (s *Server) handleEquityHistory(c *gin.Context) { _, traderID, err := s.getTraderFromQuery(c) if err != nil { @@ -1738,15 +1646,9 @@ func (s *Server) handleEquityHistory(c *gin.Context) { return } - trader, err := s.traderManager.GetTrader(traderID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - - // 获取尽可能多的历史数据(几天的数据) + // 从新的 equity 表获取净值历史数据 // 每3分钟一个周期:10000条 = 约20天的数据 - records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000) + snapshots, err := s.store.Equity().GetLatest(traderID, 10000) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": fmt.Sprintf("获取历史数据失败: %v", err), @@ -1754,62 +1656,44 @@ func (s *Server) handleEquityHistory(c *gin.Context) { return } + if len(snapshots) == 0 { + c.JSON(http.StatusOK, []interface{}{}) + return + } + // 构建收益率历史数据点 type EquityPoint struct { Timestamp string `json:"timestamp"` TotalEquity float64 `json:"total_equity"` // 账户净值(wallet + unrealized) AvailableBalance float64 `json:"available_balance"` // 可用余额 - TotalPnL float64 `json:"total_pnl"` // 总盈亏(相对初始余额) + TotalPnL float64 `json:"total_pnl"` // 总盈亏(未实现盈亏) TotalPnLPct float64 `json:"total_pnl_pct"` // 总盈亏百分比 PositionCount int `json:"position_count"` // 持仓数量 MarginUsedPct float64 `json:"margin_used_pct"` // 保证金使用率 - CycleNumber int `json:"cycle_number"` } - // 从AutoTrader获取初始余额(用于计算盈亏百分比) - initialBalance := 0.0 - if status := trader.GetStatus(); status != nil { - if ib, ok := status["initial_balance"].(float64); ok && ib > 0 { - initialBalance = ib - } - } - - // 如果无法从status获取,且有历史记录,则从第一条记录获取 - if initialBalance == 0 && len(records) > 0 { - // 第一条记录的equity作为初始余额 - initialBalance = records[0].AccountState.TotalBalance - } - - // 如果还是无法获取,返回错误 + // 使用第一条记录的余额作为初始余额来计算收益率 + initialBalance := snapshots[0].Balance if initialBalance == 0 { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "无法获取初始余额", - }) - return + initialBalance = 1 // 避免除零 } var history []EquityPoint - for _, record := range records { - // TotalBalance字段实际存储的是TotalEquity - totalEquity := record.AccountState.TotalBalance - // TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额) - totalPnL := record.AccountState.TotalUnrealizedProfit - + for _, snap := range snapshots { // 计算盈亏百分比 totalPnLPct := 0.0 if initialBalance > 0 { - totalPnLPct = (totalPnL / initialBalance) * 100 + totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100 } history = append(history, EquityPoint{ - Timestamp: record.Timestamp.Format("2006-01-02 15:04:05"), - TotalEquity: totalEquity, - AvailableBalance: record.AccountState.AvailableBalance, - TotalPnL: totalPnL, + Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"), + TotalEquity: snap.TotalEquity, + AvailableBalance: snap.Balance, + TotalPnL: snap.UnrealizedPnL, TotalPnLPct: totalPnLPct, - PositionCount: record.AccountState.PositionCount, - MarginUsedPct: record.AccountState.MarginUsedPct, - CycleNumber: record.CycleNumber, + PositionCount: snap.PositionCount, + MarginUsedPct: snap.MarginUsedPct, }) } @@ -1889,11 +1773,15 @@ func (s *Server) handleLogout(c *gin.Context) { // handleRegister 处理用户注册请求 func (s *Server) handleRegister(c *gin.Context) { + // 检查是否允许注册 + if !config.Get().RegistrationEnabled { + c.JSON(http.StatusForbidden, gin.H{"error": "注册功能已关闭"}) + return + } var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` - BetaCode string `json:"beta_code"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -1901,27 +1789,6 @@ func (s *Server) handleRegister(c *gin.Context) { return } - // 检查是否开启了内测模式 - betaModeStr, _ := s.store.SystemConfig().Get("beta_mode") - if betaModeStr == "true" { - // 内测模式下必须提供有效的内测码 - if req.BetaCode == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "内测期间,注册需要提供内测码"}) - return - } - - // 验证内测码 - isValid, err := s.store.BetaCode().Validate(req.BetaCode) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "验证内测码失败"}) - return - } - if !isValid { - c.JSON(http.StatusBadRequest, gin.H{"error": "内测码无效或已被使用"}) - return - } - } - // 检查邮箱是否已存在 _, err := s.store.User().GetByEmail(req.Email) if err == nil { @@ -1959,18 +1826,6 @@ func (s *Server) handleRegister(c *gin.Context) { return } - // 如果是内测模式,标记内测码为已使用 - betaModeStr2, _ := s.store.SystemConfig().Get("beta_mode") - if betaModeStr2 == "true" && req.BetaCode != "" { - err := s.store.BetaCode().Use(req.BetaCode, req.Email) - if err != nil { - logger.Infof("⚠️ 标记内测码为已使用失败: %v", err) - // 这里不返回错误,因为用户已经创建成功 - } else { - logger.Infof("✓ 内测码 %s 已被用户 %s 使用", req.BetaCode, req.Email) - } - } - // 返回OTP设置信息 qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email) c.JSON(http.StatusOK, gin.H{ @@ -2420,6 +2275,7 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) { } // getEquityHistoryForTraders 获取多个交易员的历史数据 +// 直接从数据库查询,不依赖内存中的 trader(这样重启后也能获取历史数据) func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]interface{} { result := make(map[string]interface{}) histories := make(map[string]interface{}) @@ -2430,30 +2286,27 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter continue } - trader, err := s.traderManager.GetTrader(traderID) - if err != nil { - errors[traderID] = "交易员不存在" - continue - } - - // 获取历史数据(用于对比展示,限制数据量) - records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 500) + // 从新的 equity 表获取净值历史数据 + snapshots, err := s.store.Equity().GetLatest(traderID, 500) if err != nil { errors[traderID] = fmt.Sprintf("获取历史数据失败: %v", err) continue } - // 构建收益率历史数据 - history := make([]map[string]interface{}, 0, len(records)) - for _, record := range records { - // 计算总权益(余额+未实现盈亏) - totalEquity := record.AccountState.TotalBalance + record.AccountState.TotalUnrealizedProfit + if len(snapshots) == 0 { + // 没有历史记录,返回空数组 + histories[traderID] = []map[string]interface{}{} + continue + } + // 构建收益率历史数据 + history := make([]map[string]interface{}, 0, len(snapshots)) + for _, snap := range snapshots { history = append(history, map[string]interface{}{ - "timestamp": record.Timestamp, - "total_equity": totalEquity, - "total_pnl": record.AccountState.TotalUnrealizedProfit, - "balance": record.AccountState.TotalBalance, + "timestamp": snap.Timestamp, + "total_equity": snap.TotalEquity, + "total_pnl": snap.UnrealizedPnL, + "balance": snap.Balance, }) } diff --git a/config.json.example b/config.json.example index 331fbc54..50dba709 100644 --- a/config.json.example +++ b/config.json.example @@ -1,27 +1,17 @@ { - "beta_mode": false, - "registration_enabled": true, - "leverage": { - "btc_eth_leverage": 5, - "altcoin_leverage": 5 + "_说明": "此文件仅供参考,系统不会读取此文件。所有配置从 .env 文件加载。", + + "_env配置说明": { + "JWT_SECRET": "JWT密钥,必须设置", + "REGISTRATION_ENABLED": "是否允许注册,true/false", + "API_SERVER_PORT": "API服务器端口,默认8080", + "DEEPSEEK_API_KEY": "DeepSeek API Key(回测用)" }, - "use_default_coins": true, - "default_coins": [ - "BTCUSDT", - "ETHUSDT", - "SOLUSDT", - "BNBUSDT", - "XRPUSDT", - "DOGEUSDT", - "ADAUSDT", - "HYPEUSDT" - ], - "api_server_port": 8080, - "max_daily_loss": 10.0, - "max_drawdown": 20.0, - "stop_trading_minutes": 60, - "jwt_secret": "Qk0kAa+d0iIEzXVHXbNbm+UaN3RNabmWtH8rDWZ5OPf+4GX8pBflAHodfpbipVMyrw1fsDanHsNBjhgbDeK9Jg==", - "log": { - "level": "info" + + "_数据库配置说明": { + "traders表": "交易员配置,包含杠杆、扫描间隔等", + "strategies表": "策略配置,包含AI500 API URL、OI Top API URL等", + "ai_models表": "AI模型配置", + "exchanges表": "交易所配置" } -} \ No newline at end of file +} diff --git a/config/config.go b/config/config.go index 26d89cd1..e8dfc75e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,59 +1,55 @@ package config import ( - "encoding/json" - "fmt" - "nofx/logger" "os" + "strconv" + "strings" ) -// LeverageConfig 杠杆配置 -type LeverageConfig struct { - BTCETHLeverage int `json:"btc_eth_leverage"` // BTC和ETH的杠杆倍数(主账户建议5-50,子账户≤5) - AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币的杠杆倍数(主账户建议5-20,子账户≤5) -} +// 全局配置实例 +var global *Config -// LogConfig 日志配置 -type LogConfig struct { - Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info) -} - -// Config 总配置 +// Config 全局配置(从 .env 加载) +// 只包含真正的全局配置,交易相关配置在 trader/策略 级别 type Config struct { - BetaMode bool `json:"beta_mode"` - APIServerPort int `json:"api_server_port"` - UseDefaultCoins bool `json:"use_default_coins"` - DefaultCoins []string `json:"default_coins"` - CoinPoolAPIURL string `json:"coin_pool_api_url"` - OITopAPIURL string `json:"oi_top_api_url"` - MaxDailyLoss float64 `json:"max_daily_loss"` - MaxDrawdown float64 `json:"max_drawdown"` - StopTradingMinutes int `json:"stop_trading_minutes"` - Leverage LeverageConfig `json:"leverage"` - JWTSecret string `json:"jwt_secret"` - DataKLineTime string `json:"data_k_line_time"` - Log *LogConfig `json:"nofx/logger"` // 日志配置 + // 服务配置 + APIServerPort int + JWTSecret string + RegistrationEnabled bool } -// LoadConfig 从文件加载配置 -func LoadConfig(filename string) (*Config, error) { - // 检查filename是否存在 - if _, err := os.Stat(filename); os.IsNotExist(err) { - logger.Infof("📄 %s不存在,使用默认配置", filename) - return &Config{}, nil +// Init 初始化全局配置(从 .env 加载) +func Init() { + cfg := &Config{ + APIServerPort: 8080, + RegistrationEnabled: true, } - // 读取 filename - data, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("读取%s失败: %w", filename, err) + // 从环境变量加载 + if v := os.Getenv("JWT_SECRET"); v != "" { + cfg.JWTSecret = strings.TrimSpace(v) + } + if cfg.JWTSecret == "" { + cfg.JWTSecret = "default-jwt-secret-change-in-production" } - // 解析JSON - var configFile Config - if err := json.Unmarshal(data, &configFile); err != nil { - return nil, fmt.Errorf("解析%s失败: %w", filename, err) + if v := os.Getenv("REGISTRATION_ENABLED"); v != "" { + cfg.RegistrationEnabled = strings.ToLower(v) == "true" } - return &configFile, nil + if v := os.Getenv("API_SERVER_PORT"); v != "" { + if port, err := strconv.Atoi(v); err == nil && port > 0 { + cfg.APIServerPort = port + } + } + + global = cfg +} + +// Get 获取全局配置 +func Get() *Config { + if global == nil { + Init() + } + return global } diff --git a/main.go b/main.go index c3da2172..4809b4b0 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,6 @@ package main import ( - "encoding/json" - "fmt" "nofx/api" "nofx/auth" "nofx/backtest" @@ -12,152 +10,16 @@ import ( "nofx/manager" "nofx/market" "nofx/mcp" - "nofx/pool" "nofx/store" - "nofx/trader" "os" "os/signal" - "strconv" - "strings" "syscall" - "time" "github.com/joho/godotenv" ) -// ConfigFile 配置文件结构,只包含需要同步到数据库的字段 -// TODO 现在与config.Config相同,未来会被替换, 现在为了兼容性不得不保留当前文件 -type ConfigFile struct { - BetaMode bool `json:"beta_mode"` - APIServerPort int `json:"api_server_port"` - UseDefaultCoins bool `json:"use_default_coins"` - DefaultCoins []string `json:"default_coins"` - CoinPoolAPIURL string `json:"coin_pool_api_url"` - OITopAPIURL string `json:"oi_top_api_url"` - MaxDailyLoss float64 `json:"max_daily_loss"` - MaxDrawdown float64 `json:"max_drawdown"` - StopTradingMinutes int `json:"stop_trading_minutes"` - Leverage config.LeverageConfig `json:"leverage"` - JWTSecret string `json:"jwt_secret"` - DataKLineTime string `json:"data_k_line_time"` - Log *config.LogConfig `json:"log"` // 日志配置 -} - -// loadConfigFile 读取并解析config.json文件 -func loadConfigFile() (*ConfigFile, error) { - // 检查config.json是否存在 - if _, err := os.Stat("config.json"); os.IsNotExist(err) { - logger.Info("📄 config.json不存在,使用默认配置") - return &ConfigFile{}, nil - } - - // 读取config.json - data, err := os.ReadFile("config.json") - if err != nil { - return nil, fmt.Errorf("读取config.json失败: %w", err) - } - - // 解析JSON - var configFile ConfigFile - if err := json.Unmarshal(data, &configFile); err != nil { - return nil, fmt.Errorf("解析config.json失败: %w", err) - } - - return &configFile, nil -} - -// syncConfigToDatabase 将配置同步到数据库 -func syncConfigToDatabase(st *store.Store, configFile *ConfigFile) error { - if configFile == nil { - return nil - } - - logger.Info("🔄 开始同步config.json到数据库...") - - // 同步各配置项到数据库 - configs := map[string]string{ - "beta_mode": fmt.Sprintf("%t", configFile.BetaMode), - "api_server_port": strconv.Itoa(configFile.APIServerPort), - "use_default_coins": fmt.Sprintf("%t", configFile.UseDefaultCoins), - "coin_pool_api_url": configFile.CoinPoolAPIURL, - "oi_top_api_url": configFile.OITopAPIURL, - "max_daily_loss": fmt.Sprintf("%.1f", configFile.MaxDailyLoss), - "max_drawdown": fmt.Sprintf("%.1f", configFile.MaxDrawdown), - "stop_trading_minutes": strconv.Itoa(configFile.StopTradingMinutes), - } - - // 同步default_coins(转换为JSON字符串存储) - if len(configFile.DefaultCoins) > 0 { - defaultCoinsJSON, err := json.Marshal(configFile.DefaultCoins) - if err == nil { - configs["default_coins"] = string(defaultCoinsJSON) - } - } - - // 同步杠杆配置 - if configFile.Leverage.BTCETHLeverage > 0 { - configs["btc_eth_leverage"] = strconv.Itoa(configFile.Leverage.BTCETHLeverage) - } - if configFile.Leverage.AltcoinLeverage > 0 { - configs["altcoin_leverage"] = strconv.Itoa(configFile.Leverage.AltcoinLeverage) - } - - // 如果JWT密钥不为空,也同步 - if configFile.JWTSecret != "" { - configs["jwt_secret"] = configFile.JWTSecret - } - - // 更新数据库配置 - for key, value := range configs { - if err := st.SystemConfig().Set(key, value); err != nil { - logger.Warnf("⚠️ 更新配置 %s 失败: %v", key, err) - } else { - logger.Infof("✓ 同步配置: %s = %s", key, value) - } - } - - logger.Info("✅ config.json同步完成") - return nil -} - -// loadBetaCodesToDatabase 加载内测码文件到数据库 -func loadBetaCodesToDatabase(st *store.Store) error { - betaCodeFile := "beta_codes.txt" - - // 检查内测码文件是否存在 - if _, err := os.Stat(betaCodeFile); os.IsNotExist(err) { - logger.Infof("📄 内测码文件 %s 不存在,跳过加载", betaCodeFile) - return nil - } - - // 获取文件信息 - fileInfo, err := os.Stat(betaCodeFile) - if err != nil { - return fmt.Errorf("获取内测码文件信息失败: %w", err) - } - - logger.Infof("🔄 发现内测码文件 %s (%.1f KB),开始加载...", betaCodeFile, float64(fileInfo.Size())/1024) - - // 加载内测码到数据库 - err = st.BetaCode().LoadFromFile(betaCodeFile) - if err != nil { - return fmt.Errorf("加载内测码失败: %w", err) - } - - // 显示统计信息 - total, used, err := st.BetaCode().GetStats() - if err != nil { - logger.Warnf("⚠️ 获取内测码统计失败: %v", err) - } else { - logger.Infof("✅ 内测码加载完成: 总计 %d 个,已使用 %d 个,剩余 %d 个", total, used, total-used) - } - - return nil -} - func main() { - // Load environment variables from .env file if present (for local/dev runs) - // In Docker Compose, variables are injected by the runtime and this is harmless. + // 加载 .env 环境变量 _ = godotenv.Load() // 初始化日志 @@ -167,19 +29,18 @@ func main() { logger.Info("║ 🤖 AI多模型交易系统 - 支持 DeepSeek & Qwen ║") logger.Info("╚════════════════════════════════════════════════════════════╝") - // 初始化数据库配置 + // 初始化全局配置(从 .env 加载) + config.Init() + cfg := config.Get() + logger.Info("✅ 配置加载完成") + + // 初始化数据库 dbPath := "data.db" if len(os.Args) > 1 { dbPath = os.Args[1] } - // 读取配置文件 - configFile, err := loadConfigFile() - if err != nil { - logger.Fatalf("❌ 读取config.json失败: %v", err) - } - - logger.Infof("📋 初始化配置数据库: %s", dbPath) + logger.Infof("📋 初始化数据库: %s", dbPath) st, err := store.New(dbPath) if err != nil { logger.Fatalf("❌ 初始化数据库失败: %v", err) @@ -193,7 +54,6 @@ func main() { if err != nil { logger.Fatalf("❌ 初始化加密服务失败: %v", err) } - // 创建加密/解密包装函数 encryptFunc := func(plaintext string) string { if plaintext == "" { return plaintext @@ -222,213 +82,76 @@ func main() { st.SetCryptoFuncs(encryptFunc, decryptFunc) logger.Info("✅ 加密服务初始化成功") - // 同步config.json到数据库 - if err := syncConfigToDatabase(st, configFile); err != nil { - logger.Warnf("⚠️ 同步config.json到数据库失败: %v", err) - } - - // 加载内测码到数据库 - if err := loadBetaCodesToDatabase(st); err != nil { - logger.Warnf("⚠️ 加载内测码到数据库失败: %v", err) - } - - // 获取系统配置 - useDefaultCoinsStr, _ := st.SystemConfig().Get("use_default_coins") - useDefaultCoins := useDefaultCoinsStr == "true" - apiPortStr, _ := st.SystemConfig().Get("api_server_port") - - // 设置JWT密钥(优先使用环境变量) - jwtSecret := strings.TrimSpace(os.Getenv("JWT_SECRET")) - if jwtSecret == "" { - // 回退到数据库配置 - jwtSecret, _ = st.SystemConfig().Get("jwt_secret") - if jwtSecret == "" { - jwtSecret = "your-jwt-secret-key-change-in-production-make-it-long-and-random" - logger.Warn("⚠️ 使用默认JWT密钥,建议使用加密设置脚本生成安全密钥") - } else { - logger.Info("🔑 使用数据库中JWT密钥") - } - } else { - logger.Info("🔑 使用环境变量JWT密钥") - } - auth.SetJWTSecret(jwtSecret) - - // 管理员模式下需要管理员密码,缺失则退出 - - logger.Info("✓ 配置数据库初始化成功") - - // 从数据库读取默认主流币种列表 - defaultCoinsJSON, _ := st.SystemConfig().Get("default_coins") - var defaultCoins []string - - if defaultCoinsJSON != "" { - // 尝试从JSON解析 - if err := json.Unmarshal([]byte(defaultCoinsJSON), &defaultCoins); err != nil { - logger.Warnf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err) - defaultCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT", "ADAUSDT", "HYPEUSDT"} - } else { - logger.Infof("✓ 从数据库加载默认币种列表(共%d个): %v", len(defaultCoins), defaultCoins) - } - } else { - // 如果数据库中没有配置,使用硬编码默认值 - defaultCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT", "ADAUSDT", "HYPEUSDT"} - logger.Warn("⚠️ 数据库中未配置default_coins,使用硬编码默认值") - } - - pool.SetDefaultCoins(defaultCoins) - // 设置是否使用默认主流币种 - pool.SetUseDefaultCoins(useDefaultCoins) - if useDefaultCoins { - logger.Info("✓ 已启用默认主流币种列表") - } - - // 设置币种池API URL - coinPoolAPIURL, _ := st.SystemConfig().Get("coin_pool_api_url") - if coinPoolAPIURL != "" { - pool.SetCoinPoolAPI(coinPoolAPIURL) - logger.Info("✓ 已配置AI500币种池API") - } - - oiTopAPIURL, _ := st.SystemConfig().Get("oi_top_api_url") - if oiTopAPIURL != "" { - pool.SetOITopAPI(oiTopAPIURL) - logger.Info("✓ 已配置OI Top API") - } - - // 创建TraderManager 与 BacktestManager - cfgForAI, cfgErr := config.LoadConfig("config.json") - if cfgErr != nil { - logger.Warnf("⚠️ 加载config.json用于AI客户端失败: %v", cfgErr) - } + // 设置 JWT 密钥 + auth.SetJWTSecret(cfg.JWTSecret) + logger.Info("🔑 JWT 密钥已设置") + // 创建 TraderManager 与 BacktestManager traderManager := manager.NewTraderManager() - mcpClient := newSharedMCPClient(cfgForAI) + mcpClient := newSharedMCPClient() backtestManager := backtest.NewManager(mcpClient) if err := backtestManager.RestoreRuns(); err != nil { - logger.Warnf("⚠️ 恢复历史回测失败: %v", err) + logger.Warnf("⚠️ 恢复历史回测失败: %v", err) } // 从数据库加载所有交易员到内存 - err = traderManager.LoadTradersFromStore(st) - if err != nil { + if err := traderManager.LoadTradersFromStore(st); err != nil { logger.Fatalf("❌ 加载交易员失败: %v", err) } - // 获取数据库中的所有交易员配置(用于显示,使用default用户) + // 显示加载的交易员信息 traders, err := st.Trader().List("default") if err != nil { logger.Fatalf("❌ 获取交易员列表失败: %v", err) } - // 显示加载的交易员信息 logger.Info("🤖 数据库中的AI交易员配置:") if len(traders) == 0 { - logger.Info(" • 暂无配置的交易员,请通过Web界面创建") + logger.Info(" (无交易员配置,请通过Web管理界面创建)") } else { - for _, trader := range traders { - status := "停止" - if trader.IsRunning { - status = "运行中" + for _, t := range traders { + status := "❌ 已停止" + if t.IsRunning { + status = "✅ 运行中" } - logger.Infof(" • %s (%s + %s) - 初始资金: %.0f USDT [%s]", - trader.Name, strings.ToUpper(trader.AIModelID), strings.ToUpper(trader.ExchangeID), - trader.InitialBalance, status) + logger.Infof(" • %s [%s] %s - AI模型: %s, 交易所: %s", + t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID) } } - logger.Info("🤖 AI全权决策模式:") - logger.Info(" • AI将自主决定每笔交易的杠杆倍数(山寨币最高5倍,BTC/ETH最高5倍)") - logger.Info(" • AI将自主决定每笔交易的仓位大小") - logger.Info(" • AI将自主设置止损和止盈价格") - logger.Info(" • AI将基于市场数据、技术指标、账户状态做出全面分析") - logger.Warn("⚠️ 风险提示: AI自动交易有风险,建议小额资金测试!") - logger.Info("按 Ctrl+C 停止运行") - logger.Info(strings.Repeat("=", 60)) + // 启动 WebSocket 行情监控(获取所有 USDT 永续合约的行情数据) + go market.NewWSMonitor(150).Start(nil) + logger.Info("📊 WebSocket 行情监控已启动") - // 自动恢复之前运行中的交易员 - traderManager.AutoStartRunningTraders(st) - - // 获取API服务器端口(优先级:环境变量 > 数据库配置 > 默认值) - apiPort := 8080 // 默认端口 - - // 1. 优先从环境变量 NOFX_BACKEND_PORT 读取 - if envPort := strings.TrimSpace(os.Getenv("NOFX_BACKEND_PORT")); envPort != "" { - if port, err := strconv.Atoi(envPort); err == nil && port > 0 { - apiPort = port - logger.Infof("🔌 使用环境变量端口: %d (NOFX_BACKEND_PORT)", apiPort) - } else { - logger.Warnf("⚠️ 环境变量 NOFX_BACKEND_PORT 无效: %s", envPort) - } - } else if apiPortStr != "" { - // 2. 从数据库配置读取(config.json 同步过来的) - if port, err := strconv.Atoi(apiPortStr); err == nil && port > 0 { - apiPort = port - logger.Infof("🔌 使用数据库配置端口: %d (api_server_port)", apiPort) - } - } else { - logger.Infof("🔌 使用默认端口: %d", apiPort) - } - - // 启动订单同步管理器 - orderSyncManager := trader.NewOrderSyncManager(st, 10*time.Second) - orderSyncManager.Start() - - // 启动仓位同步管理器(检测手动平仓等变化) - positionSyncManager := trader.NewPositionSyncManager(st, 10*time.Second) - positionSyncManager.Start() - - // 创建并启动API服务器 - apiServer := api.NewServer(traderManager, st, cryptoService, backtestManager, apiPort) + // 启动API服务器 + server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort) go func() { - if err := apiServer.Start(); err != nil { - logger.Errorf("❌ API服务器错误: %v", err) + if err := server.Start(); err != nil { + logger.Fatalf("❌ API服务器启动失败: %v", err) } }() - // 启动流行情数据 - 默认使用所有交易员设置的币种 如果没有设置币种 则优先使用系统默认 - go market.NewWSMonitor(150).Start(st.Trader().GetCustomCoins()) - //go market.NewWSMonitor(150).Start([]string{}) //这里是一个使用方式 传入空的话 则使用market市场的所有币种 - // 设置优雅退出 - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + // 等待中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - // TODO: 启动数据库中配置为运行状态的交易员 - // traderManager.StartAll() + logger.Info("✅ 系统启动完成,等待交易指令...") + logger.Info("📌 提示: 使用 Ctrl+C 停止系统") - // 等待退出信号 - <-sigChan - logger.Info("📛 收到退出信号,正在优雅关闭...") + <-quit + logger.Info("📴 收到停止信号,正在关闭系统...") - // 步骤 1: 停止所有交易员 - logger.Info("⏸️ 停止所有交易员...") + // 停止所有交易员 traderManager.StopAll() - logger.Info("✅ 所有交易员已停止") - - // 步骤 2: 停止订单同步管理器和仓位同步管理器 - logger.Info("📦 停止订单同步管理器...") - orderSyncManager.Stop() - logger.Info("📊 停止仓位同步管理器...") - positionSyncManager.Stop() - - // 步骤 3: 关闭 API 服务器 - logger.Info("🛑 停止 API 服务器...") - if err := apiServer.Shutdown(); err != nil { - logger.Warnf("⚠️ 关闭 API 服务器时出错: %v", err) - } else { - logger.Info("✅ API 服务器已安全关闭") - } - - // 步骤 4: 关闭数据库连接 (确保所有写入完成) - logger.Info("💾 关闭数据库连接...") - if err := st.Close(); err != nil { - logger.Errorf("❌ 关闭数据库失败: %v", err) - } else { - logger.Info("✅ 数据库已安全关闭,所有数据已持久化") - } - - logger.Info("👋 感谢使用AI交易系统!") + logger.Info("✅ 系统已安全关闭") } -func newSharedMCPClient(cfg *config.Config) mcp.AIClient { - return mcp.NewClient() +// newSharedMCPClient 创建共享的 MCP AI 客户端(用于回测) +func newSharedMCPClient() mcp.AIClient { + apiKey := os.Getenv("DEEPSEEK_API_KEY") + if apiKey == "" { + logger.Warn("⚠️ DEEPSEEK_API_KEY 未设置,AI 功能将不可用") + return nil + } + return mcp.NewDeepSeekClient() } diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 7cdbfe69..994fa389 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -7,7 +7,6 @@ import ( "nofx/store" "nofx/trader" "sort" - "strconv" "sync" "time" ) @@ -398,27 +397,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string logger.Infof("📋 为用户 %s 加载交易员配置: %d 个", userID, len(traders)) - // 获取系统配置 - maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss") - maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") - stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") - - // 解析配置 - maxDailyLoss := 10.0 // 默认值 - if val, err := strconv.ParseFloat(maxDailyLossStr, 64); err == nil { - maxDailyLoss = val - } - - maxDrawdown := 20.0 // 默认值 - if val, err := strconv.ParseFloat(maxDrawdownStr, 64); err == nil { - maxDrawdown = val - } - - stopTradingMinutes := 60 // 默认值 - if val, err := strconv.Atoi(stopTradingMinutesStr); err == nil { - stopTradingMinutes = val - } - // 获取AI模型和交易所列表(在循环外只查询一次) aiModels, err := st.AIModel().List(userID) if err != nil { @@ -488,7 +466,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string // 使用现有的方法加载交易员 logger.Infof("📦 正在加载交易员 %s (AI模型: %s, 交易所: %s, 策略ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID, traderCfg.StrategyID) - err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st) + err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st) if err != nil { logger.Infof("❌ 加载交易员 %s 失败: %v", traderCfg.Name, err) } @@ -524,27 +502,6 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { logger.Infof("📋 总共加载 %d 个交易员配置", len(allTraders)) - // 获取系统配置 - maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss") - maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") - stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") - - // 解析配置 - maxDailyLoss := 10.0 // 默认值 - if val, err := strconv.ParseFloat(maxDailyLossStr, 64); err == nil { - maxDailyLoss = val - } - - maxDrawdown := 20.0 // 默认值 - if val, err := strconv.ParseFloat(maxDrawdownStr, 64); err == nil { - maxDrawdown = val - } - - stopTradingMinutes := 60 // 默认值 - if val, err := strconv.Atoi(stopTradingMinutesStr); err == nil { - stopTradingMinutes = val - } - // 为每个交易员获取AI模型和交易所配置 for _, traderCfg := range allTraders { // 获取AI模型配置 @@ -609,7 +566,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { } // 添加到TraderManager(coinPoolURL/oiTopURL 已从策略配置中获取) - err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st) + err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st) if err != nil { logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err) continue @@ -621,7 +578,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { } // addTraderFromStore 内部方法:从store配置添加交易员 -func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, st *store.Store) error { +func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, st *store.Store) error { if _, exists := tm.traders[traderCfg.ID]; exists { return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) } @@ -658,12 +615,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg QwenKey: "", CustomAPIURL: aiModelCfg.CustomAPIURL, CustomModelName: aiModelCfg.CustomModelName, - ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, - InitialBalance: traderCfg.InitialBalance, - MaxDailyLoss: maxDailyLoss, - MaxDrawdown: maxDrawdown, - StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, - IsCrossMargin: traderCfg.IsCrossMargin, + ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, + InitialBalance: traderCfg.InitialBalance, + IsCrossMargin: traderCfg.IsCrossMargin, StrategyConfig: strategyConfig, } diff --git a/store/beta_code.go b/store/beta_code.go deleted file mode 100644 index dc4f3658..00000000 --- a/store/beta_code.go +++ /dev/null @@ -1,121 +0,0 @@ -package store - -import ( - "database/sql" - "fmt" - "nofx/logger" - "os" - "strings" -) - -// BetaCodeStore 内测码存储 -type BetaCodeStore struct { - db *sql.DB -} - -func (s *BetaCodeStore) initTables() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS beta_codes ( - code TEXT PRIMARY KEY, - used BOOLEAN DEFAULT 0, - used_by TEXT DEFAULT '', - used_at DATETIME DEFAULT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - return err -} - -// LoadFromFile 从文件加载内测码 -func (s *BetaCodeStore) LoadFromFile(filePath string) error { - content, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("读取内测码文件失败: %w", err) - } - - lines := strings.Split(string(content), "\n") - var codes []string - for _, line := range lines { - code := strings.TrimSpace(line) - if code != "" && !strings.HasPrefix(code, "#") { - codes = append(codes, code) - } - } - - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("开始事务失败: %w", err) - } - defer tx.Rollback() - - stmt, err := tx.Prepare(`INSERT OR IGNORE INTO beta_codes (code) VALUES (?)`) - if err != nil { - return fmt.Errorf("准备语句失败: %w", err) - } - defer stmt.Close() - - insertedCount := 0 - for _, code := range codes { - result, err := stmt.Exec(code) - if err != nil { - logger.Warnf("插入内测码 %s 失败: %v", code, err) - continue - } - if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 { - insertedCount++ - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("提交事务失败: %w", err) - } - - logger.Infof("✅ 成功加载 %d 个内测码到数据库 (总计 %d 个)", insertedCount, len(codes)) - return nil -} - -// Validate 验证内测码是否有效 -func (s *BetaCodeStore) Validate(code string) (bool, error) { - var used bool - err := s.db.QueryRow(`SELECT used FROM beta_codes WHERE code = ?`, code).Scan(&used) - if err != nil { - if err == sql.ErrNoRows { - return false, nil - } - return false, err - } - return !used, nil -} - -// Use 使用内测码 -func (s *BetaCodeStore) Use(code, userEmail string) error { - result, err := s.db.Exec(` - UPDATE beta_codes SET used = 1, used_by = ?, used_at = CURRENT_TIMESTAMP - WHERE code = ? AND used = 0 - `, userEmail, code) - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return err - } - if rowsAffected == 0 { - return fmt.Errorf("内测码无效或已被使用") - } - return nil -} - -// GetStats 获取内测码统计 -func (s *BetaCodeStore) GetStats() (total, used int, err error) { - err = s.db.QueryRow(`SELECT COUNT(*) FROM beta_codes`).Scan(&total) - if err != nil { - return 0, 0, err - } - err = s.db.QueryRow(`SELECT COUNT(*) FROM beta_codes WHERE used = 1`).Scan(&used) - if err != nil { - return 0, 0, err - } - return total, used, nil -} diff --git a/store/decision.go b/store/decision.go index 7758deb0..b122b78b 100644 --- a/store/decision.go +++ b/store/decision.go @@ -76,10 +76,11 @@ type Statistics struct { TotalClosePositions int `json:"total_close_positions"` } -// initTables 初始化决策相关表 +// initTables 初始化 AI 决策日志表 +// 注意:账户净值曲线数据已迁移到 trader_equity_snapshots 表(由 EquityStore 管理) func (s *DecisionStore) initTables() error { queries := []string{ - // 决策记录主表 + // AI 决策日志表(记录 AI 的输入输出、思维链等) `CREATE TABLE IF NOT EXISTS decision_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, trader_id TEXT NOT NULL, @@ -96,58 +97,9 @@ func (s *DecisionStore) initTables() error { ai_request_duration_ms INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, - - // 账户状态快照表 - `CREATE TABLE IF NOT EXISTS decision_account_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - decision_id INTEGER NOT NULL, - total_balance REAL DEFAULT 0, - available_balance REAL DEFAULT 0, - total_unrealized_profit REAL DEFAULT 0, - position_count INTEGER DEFAULT 0, - margin_used_pct REAL DEFAULT 0, - initial_balance REAL DEFAULT 0, - FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE - )`, - - // 持仓快照表 - `CREATE TABLE IF NOT EXISTS decision_position_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - decision_id INTEGER NOT NULL, - symbol TEXT NOT NULL, - side TEXT DEFAULT '', - position_amt REAL DEFAULT 0, - entry_price REAL DEFAULT 0, - mark_price REAL DEFAULT 0, - unrealized_profit REAL DEFAULT 0, - leverage REAL DEFAULT 0, - liquidation_price REAL DEFAULT 0, - FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE - )`, - - // 决策动作表(订单详情) - `CREATE TABLE IF NOT EXISTS decision_actions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - decision_id INTEGER NOT NULL, - trader_id TEXT NOT NULL, - action TEXT NOT NULL, - symbol TEXT NOT NULL, - quantity REAL DEFAULT 0, - leverage INTEGER DEFAULT 0, - price REAL DEFAULT 0, - order_id INTEGER DEFAULT 0, - timestamp DATETIME NOT NULL, - success BOOLEAN DEFAULT 0, - error TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE - )`, - // 索引 `CREATE INDEX IF NOT EXISTS idx_decision_records_trader_time ON decision_records(trader_id, timestamp DESC)`, `CREATE INDEX IF NOT EXISTS idx_decision_records_timestamp ON decision_records(timestamp DESC)`, - `CREATE INDEX IF NOT EXISTS idx_decision_actions_trader ON decision_actions(trader_id, timestamp DESC)`, - `CREATE INDEX IF NOT EXISTS idx_decision_actions_symbol ON decision_actions(symbol, timestamp DESC)`, } for _, query := range queries { @@ -159,7 +111,7 @@ func (s *DecisionStore) initTables() error { return nil } -// LogDecision 记录决策 +// LogDecision 记录决策(仅保存 AI 决策日志,净值曲线已迁移到 equity 表) func (s *DecisionStore) LogDecision(record *DecisionRecord) error { if record.Timestamp.IsZero() { record.Timestamp = time.Now().UTC() @@ -167,19 +119,12 @@ func (s *DecisionStore) LogDecision(record *DecisionRecord) error { record.Timestamp = record.Timestamp.UTC() } - // 开始事务 - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("开始事务失败: %w", err) - } - defer tx.Rollback() - // 序列化候选币种和执行日志为 JSON candidateCoinsJSON, _ := json.Marshal(record.CandidateCoins) executionLogJSON, _ := json.Marshal(record.ExecutionLog) - // 插入决策记录主表 - result, err := tx.Exec(` + // 插入决策记录主表(仅保存 AI 决策相关内容) + result, err := s.db.Exec(` INSERT INTO decision_records ( trader_id, cycle_number, timestamp, system_prompt, input_prompt, cot_trace, decision_json, candidate_coins, execution_log, @@ -201,63 +146,6 @@ func (s *DecisionStore) LogDecision(record *DecisionRecord) error { } record.ID = decisionID - // 插入账户状态快照 - _, err = tx.Exec(` - INSERT INTO decision_account_snapshots ( - decision_id, total_balance, available_balance, total_unrealized_profit, - position_count, margin_used_pct, initial_balance - ) VALUES (?, ?, ?, ?, ?, ?, ?) - `, - decisionID, record.AccountState.TotalBalance, record.AccountState.AvailableBalance, - record.AccountState.TotalUnrealizedProfit, record.AccountState.PositionCount, - record.AccountState.MarginUsedPct, record.AccountState.InitialBalance, - ) - if err != nil { - return fmt.Errorf("插入账户快照失败: %w", err) - } - - // 插入持仓快照 - for _, pos := range record.Positions { - _, err = tx.Exec(` - INSERT INTO decision_position_snapshots ( - decision_id, symbol, side, position_amt, entry_price, - mark_price, unrealized_profit, leverage, liquidation_price - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - decisionID, pos.Symbol, pos.Side, pos.PositionAmt, pos.EntryPrice, - pos.MarkPrice, pos.UnrealizedProfit, pos.Leverage, pos.LiquidationPrice, - ) - if err != nil { - return fmt.Errorf("插入持仓快照失败: %w", err) - } - } - - // 插入决策动作(订单详情) - for _, action := range record.Decisions { - actionTimestamp := action.Timestamp - if actionTimestamp.IsZero() { - actionTimestamp = record.Timestamp - } - _, err = tx.Exec(` - INSERT INTO decision_actions ( - decision_id, trader_id, action, symbol, quantity, leverage, - price, order_id, timestamp, success, error - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - decisionID, record.TraderID, action.Action, action.Symbol, action.Quantity, - action.Leverage, action.Price, action.OrderID, - actionTimestamp.Format(time.RFC3339), action.Success, action.Error, - ) - if err != nil { - return fmt.Errorf("插入决策动作失败: %w", err) - } - } - - // 提交事务 - if err := tx.Commit(); err != nil { - return fmt.Errorf("提交事务失败: %w", err) - } - return nil } @@ -394,21 +282,17 @@ func (s *DecisionStore) GetStatistics(traderID string) (*Statistics, error) { } stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles - err = s.db.QueryRow(` - SELECT COUNT(*) FROM decision_actions - WHERE trader_id = ? AND success = 1 AND action IN ('open_long', 'open_short') + // 从 trader_orders 表统计开仓次数 + s.db.QueryRow(` + SELECT COUNT(*) FROM trader_orders + WHERE trader_id = ? AND status = 'FILLED' AND action IN ('open_long', 'open_short') `, traderID).Scan(&stats.TotalOpenPositions) - if err != nil { - return nil, fmt.Errorf("查询开仓次数失败: %w", err) - } - err = s.db.QueryRow(` - SELECT COUNT(*) FROM decision_actions - WHERE trader_id = ? AND success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') + // 从 trader_orders 表统计平仓次数 + s.db.QueryRow(` + SELECT COUNT(*) FROM trader_orders + WHERE trader_id = ? AND status = 'FILLED' AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') `, traderID).Scan(&stats.TotalClosePositions) - if err != nil { - return nil, fmt.Errorf("查询平仓次数失败: %w", err) - } return stats, nil } @@ -421,14 +305,15 @@ func (s *DecisionStore) GetAllStatistics() (*Statistics, error) { s.db.QueryRow(`SELECT COUNT(*) FROM decision_records WHERE success = 1`).Scan(&stats.SuccessfulCycles) stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles + // 从 trader_orders 表统计 s.db.QueryRow(` - SELECT COUNT(*) FROM decision_actions - WHERE success = 1 AND action IN ('open_long', 'open_short') + SELECT COUNT(*) FROM trader_orders + WHERE status = 'FILLED' AND action IN ('open_long', 'open_short') `).Scan(&stats.TotalOpenPositions) s.db.QueryRow(` - SELECT COUNT(*) FROM decision_actions - WHERE success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') + SELECT COUNT(*) FROM trader_orders + WHERE status = 'FILLED' AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') `).Scan(&stats.TotalClosePositions) return stats, nil @@ -469,62 +354,11 @@ func (s *DecisionStore) scanDecisionRecord(rows *sql.Rows) (*DecisionRecord, err return &record, nil } -// fillRecordDetails 填充决策记录的关联数据 +// fillRecordDetails 填充决策记录的关联数据(旧的关联表已删除,此函数保留用于兼容性) +// 注意:账户快照、持仓快照、决策动作等数据已不再存储在 decision 相关表中 +// - 净值数据请使用 EquityStore.GetLatest() +// - 订单数据请使用 OrderStore func (s *DecisionStore) fillRecordDetails(record *DecisionRecord) { - // 查询账户状态 - s.db.QueryRow(` - SELECT total_balance, available_balance, total_unrealized_profit, - position_count, margin_used_pct, initial_balance - FROM decision_account_snapshots - WHERE decision_id = ? - `, record.ID).Scan( - &record.AccountState.TotalBalance, - &record.AccountState.AvailableBalance, - &record.AccountState.TotalUnrealizedProfit, - &record.AccountState.PositionCount, - &record.AccountState.MarginUsedPct, - &record.AccountState.InitialBalance, - ) - - // 查询持仓快照 - posRows, err := s.db.Query(` - SELECT symbol, side, position_amt, entry_price, mark_price, - unrealized_profit, leverage, liquidation_price - FROM decision_position_snapshots - WHERE decision_id = ? - `, record.ID) - if err == nil { - defer posRows.Close() - for posRows.Next() { - var pos PositionSnapshot - posRows.Scan( - &pos.Symbol, &pos.Side, &pos.PositionAmt, &pos.EntryPrice, - &pos.MarkPrice, &pos.UnrealizedProfit, &pos.Leverage, - &pos.LiquidationPrice, - ) - record.Positions = append(record.Positions, pos) - } - } - - // 查询决策动作 - actionRows, err := s.db.Query(` - SELECT action, symbol, quantity, leverage, price, order_id, - timestamp, success, error - FROM decision_actions - WHERE decision_id = ? - `, record.ID) - if err == nil { - defer actionRows.Close() - for actionRows.Next() { - var action DecisionAction - var timestampStr string - actionRows.Scan( - &action.Action, &action.Symbol, &action.Quantity, - &action.Leverage, &action.Price, &action.OrderID, - ×tampStr, &action.Success, &action.Error, - ) - action.Timestamp, _ = time.Parse(time.RFC3339, timestampStr) - record.Decisions = append(record.Decisions, action) - } - } + // 旧的关联表已删除,不再需要填充 + // AccountState, Positions, Decisions 字段将保持为零值 } diff --git a/store/equity.go b/store/equity.go new file mode 100644 index 00000000..b8351433 --- /dev/null +++ b/store/equity.go @@ -0,0 +1,257 @@ +package store + +import ( + "database/sql" + "fmt" + "time" +) + +// EquityStore 账户净值存储(用于绘制收益率曲线) +type EquityStore struct { + db *sql.DB +} + +// EquitySnapshot 净值快照 +type EquitySnapshot struct { + ID int64 `json:"id"` + TraderID string `json:"trader_id"` + Timestamp time.Time `json:"timestamp"` + TotalEquity float64 `json:"total_equity"` // 账户净值 (余额 + 未实现盈亏) + Balance float64 `json:"balance"` // 账户余额 + UnrealizedPnL float64 `json:"unrealized_pnl"` // 未实现盈亏 + PositionCount int `json:"position_count"` // 持仓数量 + MarginUsedPct float64 `json:"margin_used_pct"` // 保证金使用率 +} + +// initTables 初始化净值表 +func (s *EquityStore) initTables() error { + queries := []string{ + // 净值快照表 - 专门用于收益率曲线 + `CREATE TABLE IF NOT EXISTS trader_equity_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trader_id TEXT NOT NULL, + timestamp DATETIME NOT NULL, + total_equity REAL NOT NULL DEFAULT 0, + balance REAL NOT NULL DEFAULT 0, + unrealized_pnl REAL NOT NULL DEFAULT 0, + position_count INTEGER DEFAULT 0, + margin_used_pct REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + // 索引 + `CREATE INDEX IF NOT EXISTS idx_equity_trader_time ON trader_equity_snapshots(trader_id, timestamp DESC)`, + `CREATE INDEX IF NOT EXISTS idx_equity_timestamp ON trader_equity_snapshots(timestamp DESC)`, + } + + for _, query := range queries { + if _, err := s.db.Exec(query); err != nil { + return fmt.Errorf("执行SQL失败: %w", err) + } + } + + return nil +} + +// Save 保存净值快照 +func (s *EquityStore) Save(snapshot *EquitySnapshot) error { + if snapshot.Timestamp.IsZero() { + snapshot.Timestamp = time.Now().UTC() + } else { + snapshot.Timestamp = snapshot.Timestamp.UTC() + } + + result, err := s.db.Exec(` + INSERT INTO trader_equity_snapshots ( + trader_id, timestamp, total_equity, balance, + unrealized_pnl, position_count, margin_used_pct + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + snapshot.TraderID, + snapshot.Timestamp.Format(time.RFC3339), + snapshot.TotalEquity, + snapshot.Balance, + snapshot.UnrealizedPnL, + snapshot.PositionCount, + snapshot.MarginUsedPct, + ) + if err != nil { + return fmt.Errorf("保存净值快照失败: %w", err) + } + + id, _ := result.LastInsertId() + snapshot.ID = id + return nil +} + +// GetLatest 获取指定交易员最近N条净值记录(按时间正序:从旧到新) +func (s *EquityStore) GetLatest(traderID string, limit int) ([]*EquitySnapshot, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, timestamp, total_equity, balance, + unrealized_pnl, position_count, margin_used_pct + FROM trader_equity_snapshots + WHERE trader_id = ? + ORDER BY timestamp DESC + LIMIT ? + `, traderID, limit) + if err != nil { + return nil, fmt.Errorf("查询净值记录失败: %w", err) + } + defer rows.Close() + + var snapshots []*EquitySnapshot + for rows.Next() { + snap := &EquitySnapshot{} + var timestampStr string + err := rows.Scan( + &snap.ID, &snap.TraderID, ×tampStr, &snap.TotalEquity, + &snap.Balance, &snap.UnrealizedPnL, &snap.PositionCount, &snap.MarginUsedPct, + ) + if err != nil { + continue + } + snap.Timestamp, _ = time.Parse(time.RFC3339, timestampStr) + snapshots = append(snapshots, snap) + } + + // 反转数组,让时间从旧到新排列(适合绘制曲线) + for i, j := 0, len(snapshots)-1; i < j; i, j = i+1, j-1 { + snapshots[i], snapshots[j] = snapshots[j], snapshots[i] + } + + return snapshots, nil +} + +// GetByTimeRange 获取指定时间范围内的净值记录 +func (s *EquityStore) GetByTimeRange(traderID string, start, end time.Time) ([]*EquitySnapshot, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, timestamp, total_equity, balance, + unrealized_pnl, position_count, margin_used_pct + FROM trader_equity_snapshots + WHERE trader_id = ? AND timestamp >= ? AND timestamp <= ? + ORDER BY timestamp ASC + `, traderID, start.Format(time.RFC3339), end.Format(time.RFC3339)) + if err != nil { + return nil, fmt.Errorf("查询净值记录失败: %w", err) + } + defer rows.Close() + + var snapshots []*EquitySnapshot + for rows.Next() { + snap := &EquitySnapshot{} + var timestampStr string + err := rows.Scan( + &snap.ID, &snap.TraderID, ×tampStr, &snap.TotalEquity, + &snap.Balance, &snap.UnrealizedPnL, &snap.PositionCount, &snap.MarginUsedPct, + ) + if err != nil { + continue + } + snap.Timestamp, _ = time.Parse(time.RFC3339, timestampStr) + snapshots = append(snapshots, snap) + } + + return snapshots, nil +} + +// GetAllTradersLatest 获取所有交易员的最新净值(用于排行榜) +func (s *EquityStore) GetAllTradersLatest() (map[string]*EquitySnapshot, error) { + rows, err := s.db.Query(` + SELECT e.id, e.trader_id, e.timestamp, e.total_equity, e.balance, + e.unrealized_pnl, e.position_count, e.margin_used_pct + FROM trader_equity_snapshots e + INNER JOIN ( + SELECT trader_id, MAX(timestamp) as max_ts + FROM trader_equity_snapshots + GROUP BY trader_id + ) latest ON e.trader_id = latest.trader_id AND e.timestamp = latest.max_ts + `) + if err != nil { + return nil, fmt.Errorf("查询最新净值失败: %w", err) + } + defer rows.Close() + + result := make(map[string]*EquitySnapshot) + for rows.Next() { + snap := &EquitySnapshot{} + var timestampStr string + err := rows.Scan( + &snap.ID, &snap.TraderID, ×tampStr, &snap.TotalEquity, + &snap.Balance, &snap.UnrealizedPnL, &snap.PositionCount, &snap.MarginUsedPct, + ) + if err != nil { + continue + } + snap.Timestamp, _ = time.Parse(time.RFC3339, timestampStr) + result[snap.TraderID] = snap + } + + return result, nil +} + +// CleanOldRecords 清理N天前的旧记录 +func (s *EquityStore) CleanOldRecords(traderID string, days int) (int64, error) { + cutoffTime := time.Now().AddDate(0, 0, -days).Format(time.RFC3339) + + result, err := s.db.Exec(` + DELETE FROM trader_equity_snapshots + WHERE trader_id = ? AND timestamp < ? + `, traderID, cutoffTime) + if err != nil { + return 0, fmt.Errorf("清理旧记录失败: %w", err) + } + + return result.RowsAffected() +} + +// GetCount 获取指定交易员的记录数 +func (s *EquityStore) GetCount(traderID string) (int, error) { + var count int + err := s.db.QueryRow(` + SELECT COUNT(*) FROM trader_equity_snapshots WHERE trader_id = ? + `, traderID).Scan(&count) + return count, err +} + +// MigrateFromDecision 从旧的 decision_account_snapshots 迁移数据 +func (s *EquityStore) MigrateFromDecision() (int64, error) { + // 检查是否需要迁移(新表是否为空) + var count int + s.db.QueryRow(`SELECT COUNT(*) FROM trader_equity_snapshots`).Scan(&count) + if count > 0 { + return 0, nil // 已有数据,跳过迁移 + } + + // 检查旧表是否存在 + var tableName string + err := s.db.QueryRow(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='decision_account_snapshots' + `).Scan(&tableName) + if err != nil { + return 0, nil // 旧表不存在,跳过 + } + + // 迁移数据:从 decision_records + decision_account_snapshots 联合查询 + result, err := s.db.Exec(` + INSERT INTO trader_equity_snapshots ( + trader_id, timestamp, total_equity, balance, + unrealized_pnl, position_count, margin_used_pct + ) + SELECT + dr.trader_id, + dr.timestamp, + das.total_balance, + das.available_balance, + das.total_unrealized_profit, + das.position_count, + das.margin_used_pct + FROM decision_records dr + JOIN decision_account_snapshots das ON dr.id = das.decision_id + ORDER BY dr.timestamp ASC + `) + if err != nil { + return 0, fmt.Errorf("迁移数据失败: %w", err) + } + + return result.RowsAffected() +} diff --git a/store/signal_source.go b/store/signal_source.go deleted file mode 100644 index 6f0cc0e5..00000000 --- a/store/signal_source.go +++ /dev/null @@ -1,86 +0,0 @@ -package store - -import ( - "database/sql" - "time" -) - -// SignalSourceStore 信号源存储 -type SignalSourceStore struct { - db *sql.DB -} - -// SignalSource 用户信号源配置 -type SignalSource struct { - ID int `json:"id"` - UserID string `json:"user_id"` - CoinPoolURL string `json:"coin_pool_url"` - OITopURL string `json:"oi_top_url"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (s *SignalSourceStore) initTables() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS user_signal_sources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - coin_pool_url TEXT DEFAULT '', - oi_top_url TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id) - ) - `) - if err != nil { - return err - } - - // 触发器 - _, err = s.db.Exec(` - CREATE TRIGGER IF NOT EXISTS update_user_signal_sources_updated_at - AFTER UPDATE ON user_signal_sources - BEGIN - UPDATE user_signal_sources SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END - `) - return err -} - -// Create 创建信号源配置 -func (s *SignalSourceStore) Create(userID, coinPoolURL, oiTopURL string) error { - _, err := s.db.Exec(` - INSERT OR REPLACE INTO user_signal_sources (user_id, coin_pool_url, oi_top_url, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - `, userID, coinPoolURL, oiTopURL) - return err -} - -// Get 获取信号源配置 -func (s *SignalSourceStore) Get(userID string) (*SignalSource, error) { - var source SignalSource - var createdAt, updatedAt string - err := s.db.QueryRow(` - SELECT id, user_id, coin_pool_url, oi_top_url, created_at, updated_at - FROM user_signal_sources WHERE user_id = ? - `, userID).Scan( - &source.ID, &source.UserID, &source.CoinPoolURL, &source.OITopURL, - &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - source.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - source.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - return &source, nil -} - -// Update 更新信号源配置 -func (s *SignalSourceStore) Update(userID, coinPoolURL, oiTopURL string) error { - _, err := s.db.Exec(` - UPDATE user_signal_sources SET coin_pool_url = ?, oi_top_url = ?, updated_at = CURRENT_TIMESTAMP - WHERE user_id = ? - `, coinPoolURL, oiTopURL, userID) - return err -} diff --git a/store/store.go b/store/store.go index 20daf185..19a50359 100644 --- a/store/store.go +++ b/store/store.go @@ -16,18 +16,16 @@ type Store struct { db *sql.DB // 子存储(延迟初始化) - user *UserStore - aiModel *AIModelStore - exchange *ExchangeStore - trader *TraderStore - systemConfig *SystemConfigStore - betaCode *BetaCodeStore - signalSource *SignalSourceStore - decision *DecisionStore - backtest *BacktestStore - order *OrderStore - position *PositionStore - strategy *StrategyStore + user *UserStore + aiModel *AIModelStore + exchange *ExchangeStore + trader *TraderStore + decision *DecisionStore + backtest *BacktestStore + order *OrderStore + position *PositionStore + strategy *StrategyStore + equity *EquityStore // 加密函数 encryptFunc func(string) string @@ -131,15 +129,6 @@ func (s *Store) initTables() error { if err := s.Trader().initTables(); err != nil { return fmt.Errorf("初始化交易员表失败: %w", err) } - if err := s.SystemConfig().initTables(); err != nil { - return fmt.Errorf("初始化系统配置表失败: %w", err) - } - if err := s.BetaCode().initTables(); err != nil { - return fmt.Errorf("初始化内测码表失败: %w", err) - } - if err := s.SignalSource().initTables(); err != nil { - return fmt.Errorf("初始化信号源表失败: %w", err) - } if err := s.Decision().initTables(); err != nil { return fmt.Errorf("初始化决策日志表失败: %w", err) } @@ -155,6 +144,9 @@ func (s *Store) initTables() error { if err := s.Strategy().initTables(); err != nil { return fmt.Errorf("初始化策略表失败: %w", err) } + if err := s.Equity().initTables(); err != nil { + return fmt.Errorf("初始化净值表失败: %w", err) + } return nil } @@ -166,12 +158,15 @@ func (s *Store) initDefaultData() error { if err := s.Exchange().initDefaultData(); err != nil { return err } - if err := s.SystemConfig().initDefaultData(); err != nil { - return err - } if err := s.Strategy().initDefaultData(); err != nil { return err } + // 迁移旧的 decision_account_snapshots 数据到新的 trader_equity_snapshots 表 + if migrated, err := s.Equity().MigrateFromDecision(); err != nil { + logger.Warnf("迁移净值数据失败: %v", err) + } else if migrated > 0 { + logger.Infof("✅ 已迁移 %d 条净值数据到新表", migrated) + } return nil } @@ -226,36 +221,6 @@ func (s *Store) Trader() *TraderStore { return s.trader } -// SystemConfig 获取系统配置存储 -func (s *Store) SystemConfig() *SystemConfigStore { - s.mu.Lock() - defer s.mu.Unlock() - if s.systemConfig == nil { - s.systemConfig = &SystemConfigStore{db: s.db} - } - return s.systemConfig -} - -// BetaCode 获取内测码存储 -func (s *Store) BetaCode() *BetaCodeStore { - s.mu.Lock() - defer s.mu.Unlock() - if s.betaCode == nil { - s.betaCode = &BetaCodeStore{db: s.db} - } - return s.betaCode -} - -// SignalSource 获取信号源存储 -func (s *Store) SignalSource() *SignalSourceStore { - s.mu.Lock() - defer s.mu.Unlock() - if s.signalSource == nil { - s.signalSource = &SignalSourceStore{db: s.db} - } - return s.signalSource -} - // Decision 获取决策日志存储 func (s *Store) Decision() *DecisionStore { s.mu.Lock() @@ -306,6 +271,16 @@ func (s *Store) Strategy() *StrategyStore { return s.strategy } +// Equity 获取净值存储 +func (s *Store) Equity() *EquityStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.equity == nil { + s.equity = &EquityStore{db: s.db} + } + return s.equity +} + // Close 关闭数据库连接 func (s *Store) Close() error { return s.db.Close() diff --git a/store/system_config.go b/store/system_config.go deleted file mode 100644 index 2bd13ec5..00000000 --- a/store/system_config.go +++ /dev/null @@ -1,66 +0,0 @@ -package store - -import ( - "database/sql" -) - -// SystemConfigStore 系统配置存储 -type SystemConfigStore struct { - db *sql.DB -} - -func (s *SystemConfigStore) initTables() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS system_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - return err - } - - // 触发器 - _, err = s.db.Exec(` - CREATE TRIGGER IF NOT EXISTS update_system_config_updated_at - AFTER UPDATE ON system_config - BEGIN - UPDATE system_config SET updated_at = CURRENT_TIMESTAMP WHERE key = NEW.key; - END - `) - return err -} - -func (s *SystemConfigStore) initDefaultData() error { - configs := map[string]string{ - "beta_mode": "false", - "api_server_port": "8080", - "max_daily_loss": "10.0", - "max_drawdown": "20.0", - "stop_trading_minutes": "60", - "jwt_secret": "", - "registration_enabled": "true", - } - - for key, value := range configs { - _, err := s.db.Exec(`INSERT OR IGNORE INTO system_config (key, value) VALUES (?, ?)`, key, value) - if err != nil { - return err - } - } - return nil -} - -// Get 获取配置值 -func (s *SystemConfigStore) Get(key string) (string, error) { - var value string - err := s.db.QueryRow(`SELECT value FROM system_config WHERE key = ?`, key).Scan(&value) - return value, err -} - -// Set 设置配置值 -func (s *SystemConfigStore) Set(key, value string) error { - _, err := s.db.Exec(`INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?)`, key, value) - return err -} diff --git a/store/trader.go b/store/trader.go index 329ba7d2..f300fe3b 100644 --- a/store/trader.go +++ b/store/trader.go @@ -2,11 +2,6 @@ package store import ( "database/sql" - "encoding/json" - "nofx/logger" - "nofx/market" - "slices" - "strings" "time" ) @@ -341,43 +336,6 @@ func (s *TraderStore) getActiveOrDefaultStrategy(userID string) (*Strategy, erro return &strategy, nil } -// GetCustomCoins 获取所有交易员自定义币种 -func (s *TraderStore) GetCustomCoins() []string { - var symbol string - var symbols []string - _ = s.db.QueryRow(` - SELECT GROUP_CONCAT(trading_symbols, ',') as symbol - FROM traders WHERE trading_symbols != '' - `).Scan(&symbol) - - // 如果没有自定义币种,返回默认币种 - if symbol == "" { - var symbolJSON string - _ = s.db.QueryRow(`SELECT value FROM system_config WHERE key = 'default_coins'`).Scan(&symbolJSON) - if symbolJSON != "" { - if err := json.Unmarshal([]byte(symbolJSON), &symbols); err != nil { - logger.Warnf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err) - symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"} - } - } else { - symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"} - } - return symbols - } - - // 处理并去重币种列表 - for _, s := range strings.Split(symbol, ",") { - if s == "" { - continue - } - coin := market.Normalize(s) - if !slices.Contains(symbols, coin) { - symbols = append(symbols, coin) - } - } - return symbols -} - // ListAll 获取所有用户的交易员列表 func (s *TraderStore) ListAll() ([]*Trader, error) { rows, err := s.db.Query(` diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 4906c986..e02df5f1 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -375,29 +375,8 @@ func (at *AutoTrader) runCycle() error { return fmt.Errorf("构建交易上下文失败: %w", err) } - // 保存账户状态快照 - record.AccountState = store.AccountSnapshot{ - TotalBalance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL, - AvailableBalance: ctx.Account.AvailableBalance, - TotalUnrealizedProfit: ctx.Account.UnrealizedPnL, - PositionCount: ctx.Account.PositionCount, - MarginUsedPct: ctx.Account.MarginUsedPct, - InitialBalance: at.initialBalance, // 记录当时的初始余额基准 - } - - // 保存持仓快照 - for _, pos := range ctx.Positions { - record.Positions = append(record.Positions, store.PositionSnapshot{ - Symbol: pos.Symbol, - Side: pos.Side, - PositionAmt: pos.Quantity, - EntryPrice: pos.EntryPrice, - MarkPrice: pos.MarkPrice, - UnrealizedProfit: pos.UnrealizedPnL, - Leverage: float64(pos.Leverage), - LiquidationPrice: pos.LiquidationPrice, - }) - } + // 独立保存净值快照(与 AI 决策解耦,用于绘制收益曲线) + at.saveEquitySnapshot(ctx) logger.Info(strings.Repeat("=", 70)) for _, coin := range ctx.CandidateCoins { @@ -1038,10 +1017,31 @@ func (at *AutoTrader) GetSystemPromptTemplate() string { return "strategy" } -// saveDecision 保存决策记录到数据库 +// saveEquitySnapshot 独立保存净值快照(用于绘制收益曲线,与 AI 决策解耦) +func (at *AutoTrader) saveEquitySnapshot(ctx *decision.Context) { + if at.store == nil || ctx == nil { + return + } + + snapshot := &store.EquitySnapshot{ + TraderID: at.id, + Timestamp: time.Now().UTC(), + TotalEquity: ctx.Account.TotalEquity, + Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL, + UnrealizedPnL: ctx.Account.UnrealizedPnL, + PositionCount: ctx.Account.PositionCount, + MarginUsedPct: ctx.Account.MarginUsedPct, + } + + if err := at.store.Equity().Save(snapshot); err != nil { + logger.Infof("⚠️ 保存净值快照失败: %v", err) + } +} + +// saveDecision 保存 AI 决策日志到数据库(仅记录 AI 输入输出,用于调试) func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error { if at.store == nil { - return nil // 没有 store 时静默忽略 + return nil } at.cycleNumber++ diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 536be28e..dff63437 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -30,10 +30,8 @@ import { Trash2, Plus, Users, - AlertTriangle, BookOpen, HelpCircle, - Radio, Pencil, } from 'lucide-react' import { confirmToast } from '../lib/notify' @@ -71,7 +69,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const [showEditModal, setShowEditModal] = useState(false) const [showModelModal, setShowModelModal] = useState(false) const [showExchangeModal, setShowExchangeModal] = useState(false) - const [showSignalSourceModal, setShowSignalSourceModal] = useState(false) const [editingModel, setEditingModel] = useState(null) const [editingExchange, setEditingExchange] = useState(null) const [editingTrader, setEditingTrader] = useState(null) @@ -79,13 +76,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const [allExchanges, setAllExchanges] = useState([]) const [supportedModels, setSupportedModels] = useState([]) const [supportedExchanges, setSupportedExchanges] = useState([]) - const [userSignalSource, setUserSignalSource] = useState<{ - coinPoolUrl: string - oiTopUrl: string - }>({ - coinPoolUrl: '', - oiTopUrl: '', - }) const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR( user && token ? 'traders' : null, @@ -127,17 +117,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { setAllExchanges(exchangeConfigs) setSupportedModels(supportedModels) setSupportedExchanges(supportedExchanges) - - // 加载用户信号源配置 - try { - const signalSource = await api.getUserSignalSource() - setUserSignalSource({ - coinPoolUrl: signalSource.coin_pool_url || '', - oiTopUrl: signalSource.oi_top_url || '', - }) - } catch (error) { - console.log('📡 用户信号源配置暂未设置') - } } catch (error) { console.error('Failed to load configs:', error) } @@ -717,24 +696,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { setShowExchangeModal(true) } - const handleSaveSignalSource = async ( - coinPoolUrl: string, - oiTopUrl: string - ) => { - try { - await toast.promise(api.saveUserSignalSource(coinPoolUrl, oiTopUrl), { - loading: '正在保存…', - success: '保存成功', - error: '保存失败', - }) - setUserSignalSource({ coinPoolUrl, oiTopUrl }) - setShowSignalSourceModal(false) - } catch (error) { - console.error('Failed to save signal source:', error) - toast.error(t('saveSignalSourceFailed', language)) - } - } - return (
{/* Header */} @@ -798,19 +759,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { {t('exchanges', language)} - -
- {/* 信号源配置警告 */} - {traders && - traders.some((t) => t.use_coin_pool || t.use_oi_top) && - !userSignalSource.coinPoolUrl && - !userSignalSource.oiTopUrl && ( -
- -
-
- ⚠️ {t('signalSourceNotConfigured', language)} -
-
-

- {t('signalSourceWarningMessage', language)} -

-

- {t('solutions', language)} -

-
    -
  • 点击"{t('signalSource', language)}"按钮配置API地址
  • -
  • 或在交易员配置中禁用"使用币种池"和"使用OI Top"
  • -
  • 或在交易员配置中设置自定义币种列表
  • -
-
- -
-
- )} - {/* Configuration Status */}
{/* AI Models */} @@ -1304,17 +1204,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { language={language} /> )} - - {/* Signal Source Configuration Modal */} - {showSignalSourceModal && ( - setShowSignalSourceModal(false)} - language={language} - /> - )}
) } @@ -1364,141 +1253,6 @@ function Tooltip({ ) } -// Signal Source Configuration Modal Component -function SignalSourceModal({ - coinPoolUrl, - oiTopUrl, - onSave, - onClose, - language, -}: { - coinPoolUrl: string - oiTopUrl: string - onSave: (coinPoolUrl: string, oiTopUrl: string) => void - onClose: () => void - language: Language -}) { - const [coinPool, setCoinPool] = useState(coinPoolUrl || '') - const [oiTop, setOiTop] = useState(oiTopUrl || '') - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - onSave(coinPool.trim(), oiTop.trim()) - } - - return ( -
-
-

- {t('signalSourceConfig', language)} -

- -
-
-
- - setCoinPool(e.target.value)} - placeholder="https://api.example.com/coinpool" - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- {t('coinPoolDescription', language)} -
-
- -
- - setOiTop(e.target.value)} - placeholder="https://api.example.com/oitop" - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- {t('oiTopDescription', language)} -
-
- -
-
- ℹ️ {t('information', language)} -
-
-
{t('signalSourceInfo1', language)}
-
{t('signalSourceInfo2', language)}
-
{t('signalSourceInfo3', language)}
-
-
-
- -
- - -
-
-
-
- ) -} - // Model Configuration Modal Component function ModelConfigModal({ allModels, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 166ea4e0..c5db616a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -357,30 +357,6 @@ export const api = { return result.data! }, - // 用户信号源配置接口 - async getUserSignalSource(): Promise<{ - coin_pool_url: string - oi_top_url: string - }> { - const result = await httpClient.get<{ - coin_pool_url: string - oi_top_url: string - }>(`${API_BASE}/user/signal-sources`) - if (!result.success) throw new Error('获取用户信号源配置失败') - return result.data! - }, - - async saveUserSignalSource( - coinPoolUrl: string, - oiTopUrl: string - ): Promise { - const result = await httpClient.post(`${API_BASE}/user/signal-sources`, { - coin_pool_url: coinPoolUrl, - oi_top_url: oiTopUrl, - }) - if (!result.success) throw new Error('保存用户信号源配置失败') - }, - // 获取服务器IP(需要认证,用于白名单配置) async getServerIP(): Promise<{ public_ip: string