feat(trader): add automatic balance sync every 30 minutes

## Summary
Automatically sync trader's initial_balance when exchange balance changes
significantly (>5%), eliminating the need for manual intervention after
deposits/withdrawals.

## Changes

### trader/auto_trader.go
- Add fields: lastBalanceSyncTime, database, userID
- Add autoSyncBalanceIfNeeded() method:
  * Check every 30 minutes (配合3分钟扫描周期,约10次扫描触发一次)
  * Query exchange balance via trader.GetBalance()
  * Update if change >5%
  * Log changes with emoji indicators
- Integrate into runCycle() before trading decisions

### manager/trader_manager.go
- Update NewAutoTrader() calls to pass database and userID
- Update method signatures:
  * addTraderFromDB()
  * AddTraderFromDB()
  * loadSingleTrader()

## Configuration

- Check interval: 30 minutes
- Change threshold: 5%
- Automatic detection and update

## Benefits

 完全自动化,无需手动调用API
 30分钟检查间隔,及时又不频繁
 智能判断(变化>5%才更新)
 充值/提现后自动更新initial_balance
 前端P&L显示自动修正

## Example Logs

🔄 [DS-BF] 开始自动检查余额变化...
🔔 [DS-BF] 检测到余额大幅变化: 693.00 → 3000.00 USDT (333.33%)
 [DS-BF] 已自动同步余额到数据库

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZhouYongyou
2025-11-04 20:31:40 +08:00
parent e36ffc5a18
commit e8f05d1761
2 changed files with 80 additions and 13 deletions

View File

@@ -181,7 +181,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro
} }
// 添加到TraderManager // 添加到TraderManager
err = tm.addTraderFromDB(traderCfg, aiModelCfg, exchangeCfg, newsCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins) err = tm.addTraderFromDB(traderCfg, aiModelCfg, exchangeCfg, newsCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, database, traderCfg.UserID)
if err != nil { if err != nil {
log.Printf("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err) log.Printf("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err)
continue continue
@@ -193,7 +193,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro
} }
// addTraderFromConfig 内部方法:从配置添加交易员(不加锁,因为调用方已加锁) // addTraderFromConfig 内部方法:从配置添加交易员(不加锁,因为调用方已加锁)
func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, newsCfg []config.NewsConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string) error { func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, newsCfg []config.NewsConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) 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)
} }
@@ -273,8 +273,8 @@ func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModel
traderConfig.DeepSeekKey = aiModelCfg.APIKey traderConfig.DeepSeekKey = aiModelCfg.APIKey
} }
// 创建trader实例 // 创建trader实例传入database和userID用于自动余额同步
at, err := trader.NewAutoTrader(traderConfig) at, err := trader.NewAutoTrader(traderConfig, database, userID)
if err != nil { if err != nil {
return fmt.Errorf("创建trader失败: %w", err) return fmt.Errorf("创建trader失败: %w", err)
} }
@@ -298,7 +298,7 @@ func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModel
// AddTrader 从数据库配置添加trader (移除旧版兼容性) // AddTrader 从数据库配置添加trader (移除旧版兼容性)
// AddTraderFromDB 从数据库配置添加trader // AddTraderFromDB 从数据库配置添加trader
func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string) error { func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error {
tm.mu.Lock() tm.mu.Lock()
defer tm.mu.Unlock() defer tm.mu.Unlock()
@@ -379,8 +379,8 @@ func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModel
traderConfig.DeepSeekKey = aiModelCfg.APIKey traderConfig.DeepSeekKey = aiModelCfg.APIKey
} }
// 创建trader实例 // 创建trader实例传入database和userID用于自动余额同步
at, err := trader.NewAutoTrader(traderConfig) at, err := trader.NewAutoTrader(traderConfig, database, userID)
if err != nil { if err != nil {
return fmt.Errorf("创建trader失败: %w", err) return fmt.Errorf("创建trader失败: %w", err)
} }
@@ -710,7 +710,7 @@ func (tm *TraderManager) LoadUserTraders(database *config.Database, userID strin
} }
// 使用现有的方法加载交易员 // 使用现有的方法加载交易员
err = tm.loadSingleTrader(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins) err = tm.loadSingleTrader(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, database, userID)
if err != nil { if err != nil {
log.Printf("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err) log.Printf("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err)
} }
@@ -720,7 +720,7 @@ func (tm *TraderManager) LoadUserTraders(database *config.Database, userID strin
} }
// loadSingleTrader 加载单个交易员(从现有代码提取的公共逻辑) // loadSingleTrader 加载单个交易员(从现有代码提取的公共逻辑)
func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string) error { func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error {
// 处理交易币种列表 // 处理交易币种列表
var tradingCoins []string var tradingCoins []string
if traderCfg.TradingSymbols != "" { if traderCfg.TradingSymbols != "" {
@@ -789,8 +789,8 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode
traderConfig.DeepSeekKey = aiModelCfg.APIKey traderConfig.DeepSeekKey = aiModelCfg.APIKey
} }
// 创建trader实例 // 创建trader实例传入database和userID用于自动余额同步
at, err := trader.NewAutoTrader(traderConfig) at, err := trader.NewAutoTrader(traderConfig, database, userID)
if err != nil { if err != nil {
return fmt.Errorf("创建trader失败: %w", err) return fmt.Errorf("创建trader失败: %w", err)
} }

View File

@@ -118,10 +118,13 @@ type AutoTrader struct {
positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒) positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒)
lastPositions map[string]*PositionSnapshot // 上一个周期的持仓快照 (symbol_side -> snapshot) lastPositions map[string]*PositionSnapshot // 上一个周期的持仓快照 (symbol_side -> snapshot)
newsProcessor []news.Provider // 新闻 newsProcessor []news.Provider // 新闻
lastBalanceSyncTime time.Time // 上次余额同步时间
database *config.Database // 数据库引用(用于自动更新余额)
userID string // 用户ID
} }
// NewAutoTrader 创建自动交易器 // NewAutoTrader 创建自动交易器
func NewAutoTrader(traderConfig AutoTraderConfig) (*AutoTrader, error) { func NewAutoTrader(traderConfig AutoTraderConfig, db *config.Database, userID string) (*AutoTrader, error) {
// 设置默认值 // 设置默认值
if traderConfig.ID == "" { if traderConfig.ID == "" {
traderConfig.ID = "default_trader" traderConfig.ID = "default_trader"
@@ -261,6 +264,9 @@ func NewAutoTrader(traderConfig AutoTraderConfig) (*AutoTrader, error) {
positionFirstSeenTime: make(map[string]int64), positionFirstSeenTime: make(map[string]int64),
lastPositions: make(map[string]*PositionSnapshot), lastPositions: make(map[string]*PositionSnapshot),
newsProcessor: newsProcessor, newsProcessor: newsProcessor,
lastBalanceSyncTime: time.Now(), // 初始化为当前时间
database: db,
userID: userID,
}, nil }, nil
} }
@@ -298,6 +304,64 @@ func (at *AutoTrader) Stop() {
log.Println("⏹ 自动交易系统停止") log.Println("⏹ 自动交易系统停止")
} }
// autoSyncBalanceIfNeeded 自动同步余额每30分钟检查一次变化>5%才更新)
func (at *AutoTrader) autoSyncBalanceIfNeeded() {
// 距离上次同步不足30分钟跳过
if time.Since(at.lastBalanceSyncTime) < 30*time.Minute {
return
}
log.Printf("🔄 [%s] 开始自动检查余额变化...", at.name)
// 查询实际余额
balanceInfo, err := at.trader.GetBalance()
if err != nil {
log.Printf("⚠️ [%s] 查询余额失败: %v", at.name, err)
at.lastBalanceSyncTime = time.Now() // 即使失败也更新时间,避免频繁重试
return
}
// 提取可用余额
var actualBalance float64
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
actualBalance = availableBalance
} else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
actualBalance = availableBalance
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
actualBalance = totalBalance
} else {
log.Printf("⚠️ [%s] 无法提取可用余额", at.name)
at.lastBalanceSyncTime = time.Now()
return
}
oldBalance := at.initialBalance
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
// 变化超过5%才更新
if math.Abs(changePercent) > 5.0 {
log.Printf("🔔 [%s] 检测到余额大幅变化: %.2f → %.2f USDT (%.2f%%)",
at.name, oldBalance, actualBalance, changePercent)
// 更新内存中的 initialBalance
at.initialBalance = actualBalance
// 更新数据库
if at.database != nil {
err := at.database.UpdateTraderInitialBalance(at.userID, at.id, actualBalance)
if err != nil {
log.Printf("❌ [%s] 更新数据库失败: %v", at.name, err)
} else {
log.Printf("✅ [%s] 已自动同步余额到数据库", at.name)
}
}
} else {
log.Printf("✓ [%s] 余额变化不大 (%.2f%%),无需更新", at.name, changePercent)
}
at.lastBalanceSyncTime = time.Now()
}
// runCycle 运行一个交易周期使用AI全权决策 // runCycle 运行一个交易周期使用AI全权决策
func (at *AutoTrader) runCycle() error { func (at *AutoTrader) runCycle() error {
at.callCount++ at.callCount++
@@ -329,7 +393,10 @@ func (at *AutoTrader) runCycle() error {
log.Println("📅 日盈亏已重置") log.Println("📅 日盈亏已重置")
} }
// 3. 收集交易上下文 // 3. 自动同步余额每30分钟检查一次充值/提现后自动更新)
at.autoSyncBalanceIfNeeded()
// 4. 收集交易上下文
ctx, err := at.buildTradingContext() ctx, err := at.buildTradingContext()
if err != nil { if err != nil {
record.Success = false record.Success = false