mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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:
+49
-196
@@ -4,17 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"nofx/logger"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"nofx/auth"
|
"nofx/auth"
|
||||||
"nofx/backtest"
|
"nofx/backtest"
|
||||||
|
"nofx/config"
|
||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
"nofx/decision"
|
"nofx/decision"
|
||||||
|
"nofx/logger"
|
||||||
"nofx/manager"
|
"nofx/manager"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -159,10 +159,6 @@ func (s *Server) setupRoutes() {
|
|||||||
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
||||||
protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy)
|
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)
|
// 指定trader的数据(使用query参数 ?trader_id=xxx)
|
||||||
protected.GET("/status", s.handleStatus)
|
protected.GET("/status", s.handleStatus)
|
||||||
protected.GET("/account", s.handleAccount)
|
protected.GET("/account", s.handleAccount)
|
||||||
@@ -184,45 +180,12 @@ func (s *Server) handleHealth(c *gin.Context) {
|
|||||||
|
|
||||||
// handleGetSystemConfig 获取系统配置(客户端需要知道的配置)
|
// handleGetSystemConfig 获取系统配置(客户端需要知道的配置)
|
||||||
func (s *Server) handleGetSystemConfig(c *gin.Context) {
|
func (s *Server) handleGetSystemConfig(c *gin.Context) {
|
||||||
// 获取默认币种
|
cfg := config.Get()
|
||||||
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"
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"beta_mode": betaMode,
|
"registration_enabled": cfg.RegistrationEnabled,
|
||||||
"registration_enabled": registrationEnabled,
|
"btc_eth_leverage": 10, // 默认值
|
||||||
"default_coins": defaultCoins,
|
"altcoin_leverage": 5, // 默认值
|
||||||
"btc_eth_leverage": btcEthLeverage,
|
|
||||||
"altcoin_leverage": altcoinLeverage,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,28 +473,14 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
isCrossMargin = *req.IsCrossMargin
|
isCrossMargin = *req.IsCrossMargin
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置杠杆默认值(从系统配置获取)
|
// 设置杠杆默认值
|
||||||
btcEthLeverage := 5
|
btcEthLeverage := 10 // 默认值
|
||||||
altcoinLeverage := 5
|
altcoinLeverage := 5 // 默认值
|
||||||
if req.BTCETHLeverage > 0 {
|
if req.BTCETHLeverage > 0 {
|
||||||
btcEthLeverage = req.BTCETHLeverage
|
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 {
|
if req.AltcoinLeverage > 0 {
|
||||||
altcoinLeverage = req.AltcoinLeverage
|
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": "交易所配置已更新"})
|
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列表
|
// handleTraderList trader列表
|
||||||
func (s *Server) handleTraderList(c *gin.Context) {
|
func (s *Server) handleTraderList(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
@@ -1731,6 +1638,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleEquityHistory 收益率历史数据
|
// handleEquityHistory 收益率历史数据
|
||||||
|
// 直接从数据库查询,不依赖内存中的 trader(这样重启后也能获取历史数据)
|
||||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1738,15 +1646,9 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
// 从新的 equity 表获取净值历史数据
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取尽可能多的历史数据(几天的数据)
|
|
||||||
// 每3分钟一个周期:10000条 = 约20天的数据
|
// 每3分钟一个周期:10000条 = 约20天的数据
|
||||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": fmt.Sprintf("获取历史数据失败: %v", err),
|
"error": fmt.Sprintf("获取历史数据失败: %v", err),
|
||||||
@@ -1754,62 +1656,44 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
c.JSON(http.StatusOK, []interface{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 构建收益率历史数据点
|
// 构建收益率历史数据点
|
||||||
type EquityPoint struct {
|
type EquityPoint struct {
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
TotalEquity float64 `json:"total_equity"` // 账户净值(wallet + unrealized)
|
TotalEquity float64 `json:"total_equity"` // 账户净值(wallet + unrealized)
|
||||||
AvailableBalance float64 `json:"available_balance"` // 可用余额
|
AvailableBalance float64 `json:"available_balance"` // 可用余额
|
||||||
TotalPnL float64 `json:"total_pnl"` // 总盈亏(相对初始余额)
|
TotalPnL float64 `json:"total_pnl"` // 总盈亏(未实现盈亏)
|
||||||
TotalPnLPct float64 `json:"total_pnl_pct"` // 总盈亏百分比
|
TotalPnLPct float64 `json:"total_pnl_pct"` // 总盈亏百分比
|
||||||
PositionCount int `json:"position_count"` // 持仓数量
|
PositionCount int `json:"position_count"` // 持仓数量
|
||||||
MarginUsedPct float64 `json:"margin_used_pct"` // 保证金使用率
|
MarginUsedPct float64 `json:"margin_used_pct"` // 保证金使用率
|
||||||
CycleNumber int `json:"cycle_number"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从AutoTrader获取初始余额(用于计算盈亏百分比)
|
// 使用第一条记录的余额作为初始余额来计算收益率
|
||||||
initialBalance := 0.0
|
initialBalance := snapshots[0].Balance
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果还是无法获取,返回错误
|
|
||||||
if initialBalance == 0 {
|
if initialBalance == 0 {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
initialBalance = 1 // 避免除零
|
||||||
"error": "无法获取初始余额",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var history []EquityPoint
|
var history []EquityPoint
|
||||||
for _, record := range records {
|
for _, snap := range snapshots {
|
||||||
// TotalBalance字段实际存储的是TotalEquity
|
|
||||||
totalEquity := record.AccountState.TotalBalance
|
|
||||||
// TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额)
|
|
||||||
totalPnL := record.AccountState.TotalUnrealizedProfit
|
|
||||||
|
|
||||||
// 计算盈亏百分比
|
// 计算盈亏百分比
|
||||||
totalPnLPct := 0.0
|
totalPnLPct := 0.0
|
||||||
if initialBalance > 0 {
|
if initialBalance > 0 {
|
||||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
history = append(history, EquityPoint{
|
history = append(history, EquityPoint{
|
||||||
Timestamp: record.Timestamp.Format("2006-01-02 15:04:05"),
|
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||||
TotalEquity: totalEquity,
|
TotalEquity: snap.TotalEquity,
|
||||||
AvailableBalance: record.AccountState.AvailableBalance,
|
AvailableBalance: snap.Balance,
|
||||||
TotalPnL: totalPnL,
|
TotalPnL: snap.UnrealizedPnL,
|
||||||
TotalPnLPct: totalPnLPct,
|
TotalPnLPct: totalPnLPct,
|
||||||
PositionCount: record.AccountState.PositionCount,
|
PositionCount: snap.PositionCount,
|
||||||
MarginUsedPct: record.AccountState.MarginUsedPct,
|
MarginUsedPct: snap.MarginUsedPct,
|
||||||
CycleNumber: record.CycleNumber,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1889,11 +1773,15 @@ func (s *Server) handleLogout(c *gin.Context) {
|
|||||||
|
|
||||||
// handleRegister 处理用户注册请求
|
// handleRegister 处理用户注册请求
|
||||||
func (s *Server) handleRegister(c *gin.Context) {
|
func (s *Server) handleRegister(c *gin.Context) {
|
||||||
|
// 检查是否允许注册
|
||||||
|
if !config.Get().RegistrationEnabled {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "注册功能已关闭"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
BetaCode string `json:"beta_code"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -1901,27 +1789,6 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
return
|
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)
|
_, err := s.store.User().GetByEmail(req.Email)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1959,18 +1826,6 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
return
|
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设置信息
|
// 返回OTP设置信息
|
||||||
qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email)
|
qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -2420,6 +2275,7 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getEquityHistoryForTraders 获取多个交易员的历史数据
|
// getEquityHistoryForTraders 获取多个交易员的历史数据
|
||||||
|
// 直接从数据库查询,不依赖内存中的 trader(这样重启后也能获取历史数据)
|
||||||
func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]interface{} {
|
func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]interface{} {
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]interface{})
|
||||||
histories := make(map[string]interface{})
|
histories := make(map[string]interface{})
|
||||||
@@ -2430,30 +2286,27 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
// 从新的 equity 表获取净值历史数据
|
||||||
if err != nil {
|
snapshots, err := s.store.Equity().GetLatest(traderID, 500)
|
||||||
errors[traderID] = "交易员不存在"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取历史数据(用于对比展示,限制数据量)
|
|
||||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 500)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors[traderID] = fmt.Sprintf("获取历史数据失败: %v", err)
|
errors[traderID] = fmt.Sprintf("获取历史数据失败: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建收益率历史数据
|
if len(snapshots) == 0 {
|
||||||
history := make([]map[string]interface{}, 0, len(records))
|
// 没有历史记录,返回空数组
|
||||||
for _, record := range records {
|
histories[traderID] = []map[string]interface{}{}
|
||||||
// 计算总权益(余额+未实现盈亏)
|
continue
|
||||||
totalEquity := record.AccountState.TotalBalance + record.AccountState.TotalUnrealizedProfit
|
}
|
||||||
|
|
||||||
|
// 构建收益率历史数据
|
||||||
|
history := make([]map[string]interface{}, 0, len(snapshots))
|
||||||
|
for _, snap := range snapshots {
|
||||||
history = append(history, map[string]interface{}{
|
history = append(history, map[string]interface{}{
|
||||||
"timestamp": record.Timestamp,
|
"timestamp": snap.Timestamp,
|
||||||
"total_equity": totalEquity,
|
"total_equity": snap.TotalEquity,
|
||||||
"total_pnl": record.AccountState.TotalUnrealizedProfit,
|
"total_pnl": snap.UnrealizedPnL,
|
||||||
"balance": record.AccountState.TotalBalance,
|
"balance": snap.Balance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-24
@@ -1,27 +1,17 @@
|
|||||||
{
|
{
|
||||||
"beta_mode": false,
|
"_说明": "此文件仅供参考,系统不会读取此文件。所有配置从 .env 文件加载。",
|
||||||
"registration_enabled": true,
|
|
||||||
"leverage": {
|
"_env配置说明": {
|
||||||
"btc_eth_leverage": 5,
|
"JWT_SECRET": "JWT密钥,必须设置",
|
||||||
"altcoin_leverage": 5
|
"REGISTRATION_ENABLED": "是否允许注册,true/false",
|
||||||
|
"API_SERVER_PORT": "API服务器端口,默认8080",
|
||||||
|
"DEEPSEEK_API_KEY": "DeepSeek API Key(回测用)"
|
||||||
},
|
},
|
||||||
"use_default_coins": true,
|
|
||||||
"default_coins": [
|
"_数据库配置说明": {
|
||||||
"BTCUSDT",
|
"traders表": "交易员配置,包含杠杆、扫描间隔等",
|
||||||
"ETHUSDT",
|
"strategies表": "策略配置,包含AI500 API URL、OI Top API URL等",
|
||||||
"SOLUSDT",
|
"ai_models表": "AI模型配置",
|
||||||
"BNBUSDT",
|
"exchanges表": "交易所配置"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-42
@@ -1,59 +1,55 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"nofx/logger"
|
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LeverageConfig 杠杆配置
|
// 全局配置实例
|
||||||
type LeverageConfig struct {
|
var global *Config
|
||||||
BTCETHLeverage int `json:"btc_eth_leverage"` // BTC和ETH的杠杆倍数(主账户建议5-50,子账户≤5)
|
|
||||||
AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币的杠杆倍数(主账户建议5-20,子账户≤5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogConfig 日志配置
|
// Config 全局配置(从 .env 加载)
|
||||||
type LogConfig struct {
|
// 只包含真正的全局配置,交易相关配置在 trader/策略 级别
|
||||||
Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config 总配置
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BetaMode bool `json:"beta_mode"`
|
// 服务配置
|
||||||
APIServerPort int `json:"api_server_port"`
|
APIServerPort int
|
||||||
UseDefaultCoins bool `json:"use_default_coins"`
|
JWTSecret string
|
||||||
DefaultCoins []string `json:"default_coins"`
|
RegistrationEnabled bool
|
||||||
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"` // 日志配置
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadConfig 从文件加载配置
|
// Init 初始化全局配置(从 .env 加载)
|
||||||
func LoadConfig(filename string) (*Config, error) {
|
func Init() {
|
||||||
// 检查filename是否存在
|
cfg := &Config{
|
||||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
APIServerPort: 8080,
|
||||||
logger.Infof("📄 %s不存在,使用默认配置", filename)
|
RegistrationEnabled: true,
|
||||||
return &Config{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取 filename
|
// 从环境变量加载
|
||||||
data, err := os.ReadFile(filename)
|
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||||
if err != nil {
|
cfg.JWTSecret = strings.TrimSpace(v)
|
||||||
return nil, fmt.Errorf("读取%s失败: %w", filename, err)
|
}
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析JSON
|
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
|
||||||
var configFile Config
|
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
|
||||||
if err := json.Unmarshal(data, &configFile); err != nil {
|
|
||||||
return nil, fmt.Errorf("解析%s失败: %w", filename, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"nofx/api"
|
"nofx/api"
|
||||||
"nofx/auth"
|
"nofx/auth"
|
||||||
"nofx/backtest"
|
"nofx/backtest"
|
||||||
@@ -12,152 +10,16 @@ import (
|
|||||||
"nofx/manager"
|
"nofx/manager"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/pool"
|
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"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() {
|
func main() {
|
||||||
// Load environment variables from .env file if present (for local/dev runs)
|
// 加载 .env 环境变量
|
||||||
// In Docker Compose, variables are injected by the runtime and this is harmless.
|
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
// 初始化日志
|
// 初始化日志
|
||||||
@@ -167,19 +29,18 @@ func main() {
|
|||||||
logger.Info("║ 🤖 AI多模型交易系统 - 支持 DeepSeek & Qwen ║")
|
logger.Info("║ 🤖 AI多模型交易系统 - 支持 DeepSeek & Qwen ║")
|
||||||
logger.Info("╚════════════════════════════════════════════════════════════╝")
|
logger.Info("╚════════════════════════════════════════════════════════════╝")
|
||||||
|
|
||||||
// 初始化数据库配置
|
// 初始化全局配置(从 .env 加载)
|
||||||
|
config.Init()
|
||||||
|
cfg := config.Get()
|
||||||
|
logger.Info("✅ 配置加载完成")
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
dbPath := "data.db"
|
dbPath := "data.db"
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
dbPath = os.Args[1]
|
dbPath = os.Args[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取配置文件
|
logger.Infof("📋 初始化数据库: %s", dbPath)
|
||||||
configFile, err := loadConfigFile()
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("❌ 读取config.json失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("📋 初始化配置数据库: %s", dbPath)
|
|
||||||
st, err := store.New(dbPath)
|
st, err := store.New(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("❌ 初始化数据库失败: %v", err)
|
logger.Fatalf("❌ 初始化数据库失败: %v", err)
|
||||||
@@ -193,7 +54,6 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("❌ 初始化加密服务失败: %v", err)
|
logger.Fatalf("❌ 初始化加密服务失败: %v", err)
|
||||||
}
|
}
|
||||||
// 创建加密/解密包装函数
|
|
||||||
encryptFunc := func(plaintext string) string {
|
encryptFunc := func(plaintext string) string {
|
||||||
if plaintext == "" {
|
if plaintext == "" {
|
||||||
return plaintext
|
return plaintext
|
||||||
@@ -222,213 +82,76 @@ func main() {
|
|||||||
st.SetCryptoFuncs(encryptFunc, decryptFunc)
|
st.SetCryptoFuncs(encryptFunc, decryptFunc)
|
||||||
logger.Info("✅ 加密服务初始化成功")
|
logger.Info("✅ 加密服务初始化成功")
|
||||||
|
|
||||||
// 同步config.json到数据库
|
// 设置 JWT 密钥
|
||||||
if err := syncConfigToDatabase(st, configFile); err != nil {
|
auth.SetJWTSecret(cfg.JWTSecret)
|
||||||
logger.Warnf("⚠️ 同步config.json到数据库失败: %v", err)
|
logger.Info("🔑 JWT 密钥已设置")
|
||||||
}
|
|
||||||
|
|
||||||
// 加载内测码到数据库
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// 创建 TraderManager 与 BacktestManager
|
||||||
traderManager := manager.NewTraderManager()
|
traderManager := manager.NewTraderManager()
|
||||||
mcpClient := newSharedMCPClient(cfgForAI)
|
mcpClient := newSharedMCPClient()
|
||||||
backtestManager := backtest.NewManager(mcpClient)
|
backtestManager := backtest.NewManager(mcpClient)
|
||||||
if err := backtestManager.RestoreRuns(); err != nil {
|
if err := backtestManager.RestoreRuns(); err != nil {
|
||||||
logger.Warnf("⚠️ 恢复历史回测失败: %v", err)
|
logger.Warnf("⚠️ 恢复历史回测失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从数据库加载所有交易员到内存
|
// 从数据库加载所有交易员到内存
|
||||||
err = traderManager.LoadTradersFromStore(st)
|
if err := traderManager.LoadTradersFromStore(st); err != nil {
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("❌ 加载交易员失败: %v", err)
|
logger.Fatalf("❌ 加载交易员失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取数据库中的所有交易员配置(用于显示,使用default用户)
|
// 显示加载的交易员信息
|
||||||
traders, err := st.Trader().List("default")
|
traders, err := st.Trader().List("default")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("❌ 获取交易员列表失败: %v", err)
|
logger.Fatalf("❌ 获取交易员列表失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示加载的交易员信息
|
|
||||||
logger.Info("🤖 数据库中的AI交易员配置:")
|
logger.Info("🤖 数据库中的AI交易员配置:")
|
||||||
if len(traders) == 0 {
|
if len(traders) == 0 {
|
||||||
logger.Info(" • 暂无配置的交易员,请通过Web界面创建")
|
logger.Info(" (无交易员配置,请通过Web管理界面创建)")
|
||||||
} else {
|
} else {
|
||||||
for _, trader := range traders {
|
for _, t := range traders {
|
||||||
status := "停止"
|
status := "❌ 已停止"
|
||||||
if trader.IsRunning {
|
if t.IsRunning {
|
||||||
status = "运行中"
|
status = "✅ 运行中"
|
||||||
}
|
}
|
||||||
logger.Infof(" • %s (%s + %s) - 初始资金: %.0f USDT [%s]",
|
logger.Infof(" • %s [%s] %s - AI模型: %s, 交易所: %s",
|
||||||
trader.Name, strings.ToUpper(trader.AIModelID), strings.ToUpper(trader.ExchangeID),
|
t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID)
|
||||||
trader.InitialBalance, status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("🤖 AI全权决策模式:")
|
// 启动 WebSocket 行情监控(获取所有 USDT 永续合约的行情数据)
|
||||||
logger.Info(" • AI将自主决定每笔交易的杠杆倍数(山寨币最高5倍,BTC/ETH最高5倍)")
|
go market.NewWSMonitor(150).Start(nil)
|
||||||
logger.Info(" • AI将自主决定每笔交易的仓位大小")
|
logger.Info("📊 WebSocket 行情监控已启动")
|
||||||
logger.Info(" • AI将自主设置止损和止盈价格")
|
|
||||||
logger.Info(" • AI将基于市场数据、技术指标、账户状态做出全面分析")
|
|
||||||
logger.Warn("⚠️ 风险提示: AI自动交易有风险,建议小额资金测试!")
|
|
||||||
logger.Info("按 Ctrl+C 停止运行")
|
|
||||||
logger.Info(strings.Repeat("=", 60))
|
|
||||||
|
|
||||||
// 自动恢复之前运行中的交易员
|
// 启动API服务器
|
||||||
traderManager.AutoStartRunningTraders(st)
|
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
||||||
|
|
||||||
// 获取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)
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := apiServer.Start(); err != nil {
|
if err := server.Start(); err != nil {
|
||||||
logger.Errorf("❌ API服务器错误: %v", err)
|
logger.Fatalf("❌ API服务器启动失败: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 启动流行情数据 - 默认使用所有交易员设置的币种 如果没有设置币种 则优先使用系统默认
|
// 等待中断信号
|
||||||
go market.NewWSMonitor(150).Start(st.Trader().GetCustomCoins())
|
quit := make(chan os.Signal, 1)
|
||||||
//go market.NewWSMonitor(150).Start([]string{}) //这里是一个使用方式 传入空的话 则使用market市场的所有币种
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
// 设置优雅退出
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
// TODO: 启动数据库中配置为运行状态的交易员
|
logger.Info("✅ 系统启动完成,等待交易指令...")
|
||||||
// traderManager.StartAll()
|
logger.Info("📌 提示: 使用 Ctrl+C 停止系统")
|
||||||
|
|
||||||
// 等待退出信号
|
<-quit
|
||||||
<-sigChan
|
logger.Info("📴 收到停止信号,正在关闭系统...")
|
||||||
logger.Info("📛 收到退出信号,正在优雅关闭...")
|
|
||||||
|
|
||||||
// 步骤 1: 停止所有交易员
|
// 停止所有交易员
|
||||||
logger.Info("⏸️ 停止所有交易员...")
|
|
||||||
traderManager.StopAll()
|
traderManager.StopAll()
|
||||||
logger.Info("✅ 所有交易员已停止")
|
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交易系统!")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSharedMCPClient(cfg *config.Config) mcp.AIClient {
|
// newSharedMCPClient 创建共享的 MCP AI 客户端(用于回测)
|
||||||
return mcp.NewClient()
|
func newSharedMCPClient() mcp.AIClient {
|
||||||
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
logger.Warn("⚠️ DEEPSEEK_API_KEY 未设置,AI 功能将不可用")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mcp.NewDeepSeekClient()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -398,27 +397,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
|
|||||||
|
|
||||||
logger.Infof("📋 为用户 %s 加载交易员配置: %d 个", userID, len(traders))
|
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模型和交易所列表(在循环外只查询一次)
|
// 获取AI模型和交易所列表(在循环外只查询一次)
|
||||||
aiModels, err := st.AIModel().List(userID)
|
aiModels, err := st.AIModel().List(userID)
|
||||||
if err != nil {
|
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)
|
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 {
|
if err != nil {
|
||||||
logger.Infof("❌ 加载交易员 %s 失败: %v", traderCfg.Name, err)
|
logger.Infof("❌ 加载交易员 %s 失败: %v", traderCfg.Name, err)
|
||||||
}
|
}
|
||||||
@@ -524,27 +502,6 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
|||||||
|
|
||||||
logger.Infof("📋 总共加载 %d 个交易员配置", len(allTraders))
|
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模型和交易所配置
|
// 为每个交易员获取AI模型和交易所配置
|
||||||
for _, traderCfg := range allTraders {
|
for _, traderCfg := range allTraders {
|
||||||
// 获取AI模型配置
|
// 获取AI模型配置
|
||||||
@@ -609,7 +566,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加到TraderManager(coinPoolURL/oiTopURL 已从策略配置中获取)
|
// 添加到TraderManager(coinPoolURL/oiTopURL 已从策略配置中获取)
|
||||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st)
|
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err)
|
logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err)
|
||||||
continue
|
continue
|
||||||
@@ -621,7 +578,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addTraderFromStore 内部方法:从store配置添加交易员
|
// 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 {
|
if _, exists := tm.traders[traderCfg.ID]; exists {
|
||||||
return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID)
|
return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID)
|
||||||
}
|
}
|
||||||
@@ -658,12 +615,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
|||||||
QwenKey: "",
|
QwenKey: "",
|
||||||
CustomAPIURL: aiModelCfg.CustomAPIURL,
|
CustomAPIURL: aiModelCfg.CustomAPIURL,
|
||||||
CustomModelName: aiModelCfg.CustomModelName,
|
CustomModelName: aiModelCfg.CustomModelName,
|
||||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||||
InitialBalance: traderCfg.InitialBalance,
|
InitialBalance: traderCfg.InitialBalance,
|
||||||
MaxDailyLoss: maxDailyLoss,
|
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||||
MaxDrawdown: maxDrawdown,
|
|
||||||
StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute,
|
|
||||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
|
||||||
StrategyConfig: strategyConfig,
|
StrategyConfig: strategyConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -76,10 +76,11 @@ type Statistics struct {
|
|||||||
TotalClosePositions int `json:"total_close_positions"`
|
TotalClosePositions int `json:"total_close_positions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// initTables 初始化决策相关表
|
// initTables 初始化 AI 决策日志表
|
||||||
|
// 注意:账户净值曲线数据已迁移到 trader_equity_snapshots 表(由 EquityStore 管理)
|
||||||
func (s *DecisionStore) initTables() error {
|
func (s *DecisionStore) initTables() error {
|
||||||
queries := []string{
|
queries := []string{
|
||||||
// 决策记录主表
|
// AI 决策日志表(记录 AI 的输入输出、思维链等)
|
||||||
`CREATE TABLE IF NOT EXISTS decision_records (
|
`CREATE TABLE IF NOT EXISTS decision_records (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
trader_id TEXT NOT NULL,
|
trader_id TEXT NOT NULL,
|
||||||
@@ -96,58 +97,9 @@ func (s *DecisionStore) initTables() error {
|
|||||||
ai_request_duration_ms INTEGER DEFAULT 0,
|
ai_request_duration_ms INTEGER DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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_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_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 {
|
for _, query := range queries {
|
||||||
@@ -159,7 +111,7 @@ func (s *DecisionStore) initTables() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogDecision 记录决策
|
// LogDecision 记录决策(仅保存 AI 决策日志,净值曲线已迁移到 equity 表)
|
||||||
func (s *DecisionStore) LogDecision(record *DecisionRecord) error {
|
func (s *DecisionStore) LogDecision(record *DecisionRecord) error {
|
||||||
if record.Timestamp.IsZero() {
|
if record.Timestamp.IsZero() {
|
||||||
record.Timestamp = time.Now().UTC()
|
record.Timestamp = time.Now().UTC()
|
||||||
@@ -167,19 +119,12 @@ func (s *DecisionStore) LogDecision(record *DecisionRecord) error {
|
|||||||
record.Timestamp = record.Timestamp.UTC()
|
record.Timestamp = record.Timestamp.UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始事务
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("开始事务失败: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// 序列化候选币种和执行日志为 JSON
|
// 序列化候选币种和执行日志为 JSON
|
||||||
candidateCoinsJSON, _ := json.Marshal(record.CandidateCoins)
|
candidateCoinsJSON, _ := json.Marshal(record.CandidateCoins)
|
||||||
executionLogJSON, _ := json.Marshal(record.ExecutionLog)
|
executionLogJSON, _ := json.Marshal(record.ExecutionLog)
|
||||||
|
|
||||||
// 插入决策记录主表
|
// 插入决策记录主表(仅保存 AI 决策相关内容)
|
||||||
result, err := tx.Exec(`
|
result, err := s.db.Exec(`
|
||||||
INSERT INTO decision_records (
|
INSERT INTO decision_records (
|
||||||
trader_id, cycle_number, timestamp, system_prompt, input_prompt,
|
trader_id, cycle_number, timestamp, system_prompt, input_prompt,
|
||||||
cot_trace, decision_json, candidate_coins, execution_log,
|
cot_trace, decision_json, candidate_coins, execution_log,
|
||||||
@@ -201,63 +146,6 @@ func (s *DecisionStore) LogDecision(record *DecisionRecord) error {
|
|||||||
}
|
}
|
||||||
record.ID = decisionID
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,21 +282,17 @@ func (s *DecisionStore) GetStatistics(traderID string) (*Statistics, error) {
|
|||||||
}
|
}
|
||||||
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
||||||
|
|
||||||
err = s.db.QueryRow(`
|
// 从 trader_orders 表统计开仓次数
|
||||||
SELECT COUNT(*) FROM decision_actions
|
s.db.QueryRow(`
|
||||||
WHERE trader_id = ? AND success = 1 AND action IN ('open_long', 'open_short')
|
SELECT COUNT(*) FROM trader_orders
|
||||||
|
WHERE trader_id = ? AND status = 'FILLED' AND action IN ('open_long', 'open_short')
|
||||||
`, traderID).Scan(&stats.TotalOpenPositions)
|
`, traderID).Scan(&stats.TotalOpenPositions)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("查询开仓次数失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.db.QueryRow(`
|
// 从 trader_orders 表统计平仓次数
|
||||||
SELECT COUNT(*) FROM decision_actions
|
s.db.QueryRow(`
|
||||||
WHERE trader_id = ? AND success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short')
|
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)
|
`, traderID).Scan(&stats.TotalClosePositions)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("查询平仓次数失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
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)
|
s.db.QueryRow(`SELECT COUNT(*) FROM decision_records WHERE success = 1`).Scan(&stats.SuccessfulCycles)
|
||||||
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
||||||
|
|
||||||
|
// 从 trader_orders 表统计
|
||||||
s.db.QueryRow(`
|
s.db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM decision_actions
|
SELECT COUNT(*) FROM trader_orders
|
||||||
WHERE success = 1 AND action IN ('open_long', 'open_short')
|
WHERE status = 'FILLED' AND action IN ('open_long', 'open_short')
|
||||||
`).Scan(&stats.TotalOpenPositions)
|
`).Scan(&stats.TotalOpenPositions)
|
||||||
|
|
||||||
s.db.QueryRow(`
|
s.db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM decision_actions
|
SELECT COUNT(*) FROM trader_orders
|
||||||
WHERE success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short')
|
WHERE status = 'FILLED' AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short')
|
||||||
`).Scan(&stats.TotalClosePositions)
|
`).Scan(&stats.TotalClosePositions)
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
@@ -469,62 +354,11 @@ func (s *DecisionStore) scanDecisionRecord(rows *sql.Rows) (*DecisionRecord, err
|
|||||||
return &record, nil
|
return &record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fillRecordDetails 填充决策记录的关联数据
|
// fillRecordDetails 填充决策记录的关联数据(旧的关联表已删除,此函数保留用于兼容性)
|
||||||
|
// 注意:账户快照、持仓快照、决策动作等数据已不再存储在 decision 相关表中
|
||||||
|
// - 净值数据请使用 EquityStore.GetLatest()
|
||||||
|
// - 订单数据请使用 OrderStore
|
||||||
func (s *DecisionStore) fillRecordDetails(record *DecisionRecord) {
|
func (s *DecisionStore) fillRecordDetails(record *DecisionRecord) {
|
||||||
// 查询账户状态
|
// 旧的关联表已删除,不再需要填充
|
||||||
s.db.QueryRow(`
|
// AccountState, Positions, Decisions 字段将保持为零值
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+257
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+29
-54
@@ -16,18 +16,16 @@ type Store struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
|
||||||
// 子存储(延迟初始化)
|
// 子存储(延迟初始化)
|
||||||
user *UserStore
|
user *UserStore
|
||||||
aiModel *AIModelStore
|
aiModel *AIModelStore
|
||||||
exchange *ExchangeStore
|
exchange *ExchangeStore
|
||||||
trader *TraderStore
|
trader *TraderStore
|
||||||
systemConfig *SystemConfigStore
|
decision *DecisionStore
|
||||||
betaCode *BetaCodeStore
|
backtest *BacktestStore
|
||||||
signalSource *SignalSourceStore
|
order *OrderStore
|
||||||
decision *DecisionStore
|
position *PositionStore
|
||||||
backtest *BacktestStore
|
strategy *StrategyStore
|
||||||
order *OrderStore
|
equity *EquityStore
|
||||||
position *PositionStore
|
|
||||||
strategy *StrategyStore
|
|
||||||
|
|
||||||
// 加密函数
|
// 加密函数
|
||||||
encryptFunc func(string) string
|
encryptFunc func(string) string
|
||||||
@@ -131,15 +129,6 @@ func (s *Store) initTables() error {
|
|||||||
if err := s.Trader().initTables(); err != nil {
|
if err := s.Trader().initTables(); err != nil {
|
||||||
return fmt.Errorf("初始化交易员表失败: %w", err)
|
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 {
|
if err := s.Decision().initTables(); err != nil {
|
||||||
return fmt.Errorf("初始化决策日志表失败: %w", err)
|
return fmt.Errorf("初始化决策日志表失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -155,6 +144,9 @@ func (s *Store) initTables() error {
|
|||||||
if err := s.Strategy().initTables(); err != nil {
|
if err := s.Strategy().initTables(); err != nil {
|
||||||
return fmt.Errorf("初始化策略表失败: %w", err)
|
return fmt.Errorf("初始化策略表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := s.Equity().initTables(); err != nil {
|
||||||
|
return fmt.Errorf("初始化净值表失败: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,12 +158,15 @@ func (s *Store) initDefaultData() error {
|
|||||||
if err := s.Exchange().initDefaultData(); err != nil {
|
if err := s.Exchange().initDefaultData(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.SystemConfig().initDefaultData(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.Strategy().initDefaultData(); err != nil {
|
if err := s.Strategy().initDefaultData(); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,36 +221,6 @@ func (s *Store) Trader() *TraderStore {
|
|||||||
return s.trader
|
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 获取决策日志存储
|
// Decision 获取决策日志存储
|
||||||
func (s *Store) Decision() *DecisionStore {
|
func (s *Store) Decision() *DecisionStore {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -306,6 +271,16 @@ func (s *Store) Strategy() *StrategyStore {
|
|||||||
return s.strategy
|
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 关闭数据库连接
|
// Close 关闭数据库连接
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,6 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/market"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -341,43 +336,6 @@ func (s *TraderStore) getActiveOrDefaultStrategy(userID string) (*Strategy, erro
|
|||||||
return &strategy, nil
|
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 获取所有用户的交易员列表
|
// ListAll 获取所有用户的交易员列表
|
||||||
func (s *TraderStore) ListAll() ([]*Trader, error) {
|
func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
|
|||||||
+25
-25
@@ -375,29 +375,8 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
return fmt.Errorf("构建交易上下文失败: %w", err)
|
return fmt.Errorf("构建交易上下文失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存账户状态快照
|
// 独立保存净值快照(与 AI 决策解耦,用于绘制收益曲线)
|
||||||
record.AccountState = store.AccountSnapshot{
|
at.saveEquitySnapshot(ctx)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info(strings.Repeat("=", 70))
|
logger.Info(strings.Repeat("=", 70))
|
||||||
for _, coin := range ctx.CandidateCoins {
|
for _, coin := range ctx.CandidateCoins {
|
||||||
@@ -1038,10 +1017,31 @@ func (at *AutoTrader) GetSystemPromptTemplate() string {
|
|||||||
return "strategy"
|
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 {
|
func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error {
|
||||||
if at.store == nil {
|
if at.store == nil {
|
||||||
return nil // 没有 store 时静默忽略
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
at.cycleNumber++
|
at.cycleNumber++
|
||||||
|
|||||||
@@ -30,10 +30,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
Users,
|
Users,
|
||||||
AlertTriangle,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Radio,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { confirmToast } from '../lib/notify'
|
import { confirmToast } from '../lib/notify'
|
||||||
@@ -71,7 +69,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
const [showModelModal, setShowModelModal] = useState(false)
|
const [showModelModal, setShowModelModal] = useState(false)
|
||||||
const [showExchangeModal, setShowExchangeModal] = useState(false)
|
const [showExchangeModal, setShowExchangeModal] = useState(false)
|
||||||
const [showSignalSourceModal, setShowSignalSourceModal] = useState(false)
|
|
||||||
const [editingModel, setEditingModel] = useState<string | null>(null)
|
const [editingModel, setEditingModel] = useState<string | null>(null)
|
||||||
const [editingExchange, setEditingExchange] = useState<string | null>(null)
|
const [editingExchange, setEditingExchange] = useState<string | null>(null)
|
||||||
const [editingTrader, setEditingTrader] = useState<any>(null)
|
const [editingTrader, setEditingTrader] = useState<any>(null)
|
||||||
@@ -79,13 +76,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
|
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
|
||||||
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
|
||||||
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([])
|
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([])
|
||||||
const [userSignalSource, setUserSignalSource] = useState<{
|
|
||||||
coinPoolUrl: string
|
|
||||||
oiTopUrl: string
|
|
||||||
}>({
|
|
||||||
coinPoolUrl: '',
|
|
||||||
oiTopUrl: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
|
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
|
||||||
user && token ? 'traders' : null,
|
user && token ? 'traders' : null,
|
||||||
@@ -127,17 +117,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
setAllExchanges(exchangeConfigs)
|
setAllExchanges(exchangeConfigs)
|
||||||
setSupportedModels(supportedModels)
|
setSupportedModels(supportedModels)
|
||||||
setSupportedExchanges(supportedExchanges)
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load configs:', error)
|
console.error('Failed to load configs:', error)
|
||||||
}
|
}
|
||||||
@@ -717,24 +696,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
setShowExchangeModal(true)
|
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 (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6 animate-fade-in">
|
<div className="space-y-4 md:space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -798,19 +759,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
{t('exchanges', language)}
|
{t('exchanges', language)}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -834,54 +782,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Configuration Status */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||||
{/* AI Models */}
|
{/* AI Models */}
|
||||||
@@ -1304,17 +1204,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
language={language}
|
language={language}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Signal Source Configuration Modal */}
|
|
||||||
{showSignalSourceModal && (
|
|
||||||
<SignalSourceModal
|
|
||||||
coinPoolUrl={userSignalSource.coinPoolUrl}
|
|
||||||
oiTopUrl={userSignalSource.oiTopUrl}
|
|
||||||
onSave={handleSaveSignalSource}
|
|
||||||
onClose={() => setShowSignalSourceModal(false)}
|
|
||||||
language={language}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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
|
// Model Configuration Modal Component
|
||||||
function ModelConfigModal({
|
function ModelConfigModal({
|
||||||
allModels,
|
allModels,
|
||||||
|
|||||||
@@ -357,30 +357,6 @@ export const api = {
|
|||||||
return result.data!
|
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(需要认证,用于白名单配置)
|
// 获取服务器IP(需要认证,用于白名单配置)
|
||||||
async getServerIP(): Promise<{
|
async getServerIP(): Promise<{
|
||||||
public_ip: string
|
public_ip: string
|
||||||
|
|||||||
Reference in New Issue
Block a user