refactor: simplify config and remove unused database tables

- Remove system_config, beta_codes, signal_source tables and related code
- Simplify config.go to only read from .env (APIServerPort, JWTSecret, RegistrationEnabled)
- Remove GetCustomCoins, use all USDT perpetual contracts for WSMonitor
- Add trader_equity_snapshots table for equity tracking
- Remove signal source modal from frontend AITradersPage
- Fix WSMonitor nil panic by restoring initialization in main.go
This commit is contained in:
tinkle-community
2025-12-07 20:17:03 +08:00
parent 07ac8e4ecd
commit 2334d78e4a
15 changed files with 490 additions and 1493 deletions
+49 -196
View File
@@ -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,
})
}
+13 -23
View File
@@ -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表": "交易所配置"
}
}
+38 -42
View File
@@ -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
}
+44 -321
View File
@@ -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")
}
// 设置 JWT 密钥
auth.SetJWTSecret(cfg.JWTSecret)
logger.Info("🔑 JWT 密钥已设置")
// 创建 TraderManager 与 BacktestManager
cfgForAI, cfgErr := config.LoadConfig("config.json")
if cfgErr != nil {
logger.Warnf("⚠️ 加载config.json用于AI客户端失败: %v", cfgErr)
}
traderManager := manager.NewTraderManager()
mcpClient := newSharedMCPClient(cfgForAI)
mcpClient := newSharedMCPClient()
backtestManager := backtest.NewManager(mcpClient)
if err := backtestManager.RestoreRuns(); err != nil {
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 服务器已安全关闭")
logger.Info("✅ 系统已安全关闭")
}
// 步骤 4: 关闭数据库连接 (确保所有写入完成)
logger.Info("💾 关闭数据库连接...")
if err := st.Close(); err != nil {
logger.Errorf("❌ 关闭数据库失败: %v", err)
} else {
logger.Info("✅ 数据库已安全关闭,所有数据已持久化")
// newSharedMCPClient 创建共享的 MCP AI 客户端(用于回测)
func newSharedMCPClient() mcp.AIClient {
apiKey := os.Getenv("DEEPSEEK_API_KEY")
if apiKey == "" {
logger.Warn("⚠️ DEEPSEEK_API_KEY 未设置,AI 功能将不可用")
return nil
}
logger.Info("👋 感谢使用AI交易系统!")
}
func newSharedMCPClient(cfg *config.Config) mcp.AIClient {
return mcp.NewClient()
return mcp.NewDeepSeekClient()
}
+3 -49
View File
@@ -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 {
}
// 添加到TraderManagercoinPoolURL/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)
}
@@ -660,9 +617,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
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,
StrategyConfig: strategyConfig,
}
-121
View File
@@ -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
}
+25 -191
View File
@@ -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,
&timestampStr, &action.Success, &action.Error,
)
action.Timestamp, _ = time.Parse(time.RFC3339, timestampStr)
record.Decisions = append(record.Decisions, action)
}
}
// 旧的关联表已删除,不再需要填充
// AccountState, Positions, Decisions 字段将保持为零值
}
+257
View File
@@ -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, &timestampStr, &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, &timestampStr, &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, &timestampStr, &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()
}
-86
View File
@@ -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
}
+20 -45
View File
@@ -20,14 +20,12 @@ type Store struct {
aiModel *AIModelStore
exchange *ExchangeStore
trader *TraderStore
systemConfig *SystemConfigStore
betaCode *BetaCodeStore
signalSource *SignalSourceStore
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()
-66
View File
@@ -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
}
-42
View File
@@ -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(`
+25 -25
View File
@@ -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++
-246
View File
@@ -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<string | null>(null)
const [editingExchange, setEditingExchange] = useState<string | null>(null)
const [editingTrader, setEditingTrader] = useState<any>(null)
@@ -79,13 +76,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([])
const [userSignalSource, setUserSignalSource] = useState<{
coinPoolUrl: string
oiTopUrl: string
}>({
coinPoolUrl: '',
oiTopUrl: '',
})
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
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 (
<div className="space-y-4 md:space-y-6 animate-fade-in">
{/* Header */}
@@ -798,19 +759,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
{t('exchanges', language)}
</button>
<button
onClick={() => setShowSignalSourceModal(true)}
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
style={{
background: '#2B3139',
color: '#EAECEF',
border: '1px solid #474D57',
}}
>
<Radio className="w-3 h-3 md:w-4 md:h-4" />
{t('signalSource', language)}
</button>
<button
onClick={() => setShowCreateModal(true)}
disabled={
@@ -834,54 +782,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
</div>
</div>
{/* 信号源配置警告 */}
{traders &&
traders.some((t) => t.use_coin_pool || t.use_oi_top) &&
!userSignalSource.coinPoolUrl &&
!userSignalSource.oiTopUrl && (
<div
className="rounded-lg px-4 py-3 flex items-start gap-3 animate-slide-in"
style={{
background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.3)',
}}
>
<AlertTriangle
size={20}
className="flex-shrink-0 mt-0.5"
style={{ color: '#F6465D' }}
/>
<div className="flex-1">
<div className="font-semibold mb-1" style={{ color: '#F6465D' }}>
{t('signalSourceNotConfigured', language)}
</div>
<div className="text-sm" style={{ color: '#848E9C' }}>
<p className="mb-2">
{t('signalSourceWarningMessage', language)}
</p>
<p>
<strong>{t('solutions', language)}</strong>
</p>
<ul className="list-disc list-inside space-y-1 ml-2 mt-1">
<li>"{t('signalSource', language)}"API地址</li>
<li>"使用币种池""使用OI Top"</li>
<li></li>
</ul>
</div>
<button
onClick={() => setShowSignalSourceModal(true)}
className="mt-3 px-3 py-1.5 rounded text-sm font-semibold transition-all hover:scale-105"
style={{
background: '#F0B90B',
color: '#000',
}}
>
{t('configureSignalSourceNow', language)}
</button>
</div>
</div>
)}
{/* Configuration Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
{/* AI Models */}
@@ -1304,17 +1204,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
language={language}
/>
)}
{/* Signal Source Configuration Modal */}
{showSignalSourceModal && (
<SignalSourceModal
coinPoolUrl={userSignalSource.coinPoolUrl}
oiTopUrl={userSignalSource.oiTopUrl}
onSave={handleSaveSignalSource}
onClose={() => setShowSignalSourceModal(false)}
language={language}
/>
)}
</div>
)
}
@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
style={{
background: '#1E2329',
maxHeight: 'calc(100vh - 4rem)',
}}
>
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
{t('signalSourceConfig', language)}
</h3>
<form onSubmit={handleSubmit} className="px-6 pb-6">
<div
className="space-y-4 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 16rem)' }}
>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
COIN POOL URL
</label>
<input
type="url"
value={coinPool}
onChange={(e) => 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',
}}
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('coinPoolDescription', language)}
</div>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
OI TOP URL
</label>
<input
type="url"
value={oiTop}
onChange={(e) => 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',
}}
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('oiTopDescription', language)}
</div>
</div>
<div
className="p-4 rounded"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
>
<div
className="text-sm font-semibold mb-2"
style={{ color: '#F0B90B' }}
>
{t('information', language)}
</div>
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
<div>{t('signalSourceInfo1', language)}</div>
<div>{t('signalSourceInfo2', language)}</div>
<div>{t('signalSourceInfo3', language)}</div>
</div>
</div>
</div>
<div
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
style={{ background: '#1E2329' }}
>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{t('cancel', language)}
</button>
<button
type="submit"
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('save', language)}
</button>
</div>
</form>
</div>
</div>
)
}
// Model Configuration Modal Component
function ModelConfigModal({
allModels,
-24
View File
@@ -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<void> {
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