mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2025-12-06 13:54:41 +08:00
* refactor: 简化交易动作,移除 update_stop_loss/update_take_profit/partial_close - 移除 Decision 结构体中的 NewStopLoss, NewTakeProfit, ClosePercentage 字段 - 删除 executeUpdateStopLossWithRecord, executeUpdateTakeProfitWithRecord, executePartialCloseWithRecord 函数 - 简化 logger 中的 partial_close 聚合逻辑 - 更新 AI prompt 和验证逻辑,只保留 6 个核心动作 - 清理相关测试代码 保留的交易动作: open_long, open_short, close_long, close_short, hold, wait * refactor: 移除 AI学习与反思 模块 - 删除前端 AILearning.tsx 组件和相关引用 - 删除后端 /performance API 接口 - 删除 logger 中 AnalyzePerformance、calculateSharpeRatio 等函数 - 删除 PerformanceAnalysis、TradeOutcome、SymbolPerformance 等结构体 - 删除 Context 中的 Performance 字段 - 移除 AI prompt 中夏普比率自我进化相关内容 - 清理 i18n 翻译文件中的相关条目 该模块基于磁盘存储计算,经常出错,做减法移除 * refactor: 将数据库操作统一迁移到 store 包 - 新增 store/ 包,统一管理所有数据库操作 - store.go: 主 Store 结构,懒加载各子模块 - user.go, ai_model.go, exchange.go, trader.go 等子模块 - 支持加密/解密函数注入 (SetCryptoFuncs) - 更新 main.go 使用 store.New() 替代 config.NewDatabase() - 更新 api/server.go 使用 *store.Store 替代 *config.Database - 更新 manager/trader_manager.go: - 新增 LoadTradersFromStore, LoadUserTradersFromStore 方法 - 删除旧版 LoadUserTraders, LoadTraderByID, loadSingleTrader 等方法 - 移除 nofx/config 依赖 - 删除 config/database.go 和 config/database_test.go - 更新 api/server_test.go 使用 store.Trader 类型 - 清理 logger/ 包中未使用的 telegram 相关代码 * refactor: unify encryption key management via .env - Remove redundant EncryptionManager and SecureStorage - Simplify CryptoService to load keys from environment variables only - RSA_PRIVATE_KEY: RSA private key for client-server encryption - DATA_ENCRYPTION_KEY: AES-256 key for database encryption - JWT_SECRET: JWT signing key for authentication - Update start.sh to auto-generate missing keys on first run - Remove secrets/ directory and file-based key storage - Delete obsolete encryption setup scripts - Update .env.example with all required keys * refactor: unify logger usage across mcp package - Add MCPLogger adapter in logger package to implement mcp.Logger interface - Update mcp/config.go to use global logger by default - Remove redundant defaultLogger from mcp/logger.go - Keep noopLogger for testing purposes * chore: remove leftover test RSA key file * chore: remove unused bootstrap package * refactor: unify logging to use logger package instead of fmt/log - Replace all fmt.Print/log.Print calls with logger package - Add auto-initialization in logger package init() for test compatibility - Update main.go to initialize logger at startup - Migrate all packages: api, backtest, config, decision, manager, market, store, trader * refactor: rename database file from config.db to data.db - Update main.go, start.sh, docker-compose.yml - Update migration script and documentation - Update .gitignore and translations * fix: add RSA_PRIVATE_KEY to docker-compose environment * fix: add registration_enabled to /api/config response * fix: Fix navigation between login and register pages Use window.location.href instead of react-router's navigate() to fix the issue where URL changes but the page doesn't reload due to App.tsx using custom route state management. * fix: Switch SQLite from WAL to DELETE mode for Docker compatibility WAL mode causes data sync issues with Docker bind mounts on macOS due to incompatible file locking mechanisms between the container and host. DELETE mode (traditional journaling) ensures data is written directly to the main database file. * refactor: Remove default user from database initialization The default user was a legacy placeholder that is no longer needed now that proper user registration is in place. * feat: Add order tracking system with centralized status sync - Add trader_orders table for tracking all order lifecycle - Implement GetOrderStatus interface for all exchanges (Binance, Bybit, Hyperliquid, Aster, Lighter) - Create OrderSyncManager for centralized order status polling - Add trading statistics (Sharpe ratio, win rate, profit factor) to AI context - Include recent completed orders in AI decision input - Remove per-order goroutine polling in favor of global sync manager * feat: Add TradingView K-line chart to dashboard - Create TradingViewChart component with exchange/symbol selectors - Support Binance, Bybit, OKX, Coinbase, Kraken, KuCoin exchanges - Add popular symbols quick selection - Support multiple timeframes (1m to 1W) - Add fullscreen mode - Integrate with Dashboard page below equity chart - Add i18n translations for zh/en * refactor: Replace separate charts with tabbed ChartTabs component - Create ChartTabs component with tab switching between equity curve and K-line - Add embedded mode support for EquityChart and TradingViewChart - User can now switch between account equity and market chart in same area * fix: Use ChartTabs in App.tsx and fix embedded mode in EquityChart - Replace EquityChart with ChartTabs in App.tsx (the actual dashboard renderer) - Fix EquityChart embedded mode for error and empty data states - Rename interval state to timeInterval to avoid shadowing window.setInterval - Add debug logging to ChartTabs component * feat: Add position tracking system for accurate trade history - Add trader_positions table to track complete open/close trades - Add PositionSyncManager to detect manual closes via polling - Record position on open, update on close with PnL calculation - Use positions table for trading stats and recent trades (replacing orders table) - Fix TradingView chart symbol format (add .P suffix for futures) - Fix DecisionCard wait/hold action color (gray instead of red) - Auto-append USDT suffix for custom symbol input * update ---------
1600 lines
50 KiB
Go
1600 lines
50 KiB
Go
package trader
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"nofx/logger"
|
||
"math"
|
||
"nofx/decision"
|
||
"nofx/market"
|
||
"nofx/mcp"
|
||
"nofx/pool"
|
||
"nofx/store"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// AutoTraderConfig 自动交易配置(简化版 - AI全权决策)
|
||
type AutoTraderConfig struct {
|
||
// Trader标识
|
||
ID string // Trader唯一标识(用于日志目录等)
|
||
Name string // Trader显示名称
|
||
AIModel string // AI模型: "qwen" 或 "deepseek"
|
||
|
||
// 交易平台选择
|
||
Exchange string // "binance", "bybit", "hyperliquid", "aster" 或 "lighter"
|
||
|
||
// 币安API配置
|
||
BinanceAPIKey string
|
||
BinanceSecretKey string
|
||
|
||
// Bybit API配置
|
||
BybitAPIKey string
|
||
BybitSecretKey string
|
||
|
||
// Hyperliquid配置
|
||
HyperliquidPrivateKey string
|
||
HyperliquidWalletAddr string
|
||
HyperliquidTestnet bool
|
||
|
||
// Aster配置
|
||
AsterUser string // Aster主钱包地址
|
||
AsterSigner string // Aster API钱包地址
|
||
AsterPrivateKey string // Aster API钱包私钥
|
||
|
||
// LIGHTER配置
|
||
LighterWalletAddr string // LIGHTER钱包地址(L1 wallet)
|
||
LighterPrivateKey string // LIGHTER L1私钥(用于识别账户)
|
||
LighterAPIKeyPrivateKey string // LIGHTER API Key私钥(40字节,用于签名交易)
|
||
LighterTestnet bool // 是否使用testnet
|
||
|
||
CoinPoolAPIURL string
|
||
|
||
// AI配置
|
||
UseQwen bool
|
||
DeepSeekKey string
|
||
QwenKey string
|
||
|
||
// 自定义AI API配置
|
||
CustomAPIURL string
|
||
CustomAPIKey string
|
||
CustomModelName string
|
||
|
||
// 扫描配置
|
||
ScanInterval time.Duration // 扫描间隔(建议3分钟)
|
||
|
||
// 账户配置
|
||
InitialBalance float64 // 初始金额(用于计算盈亏,需手动设置)
|
||
|
||
// 杠杆配置
|
||
BTCETHLeverage int // BTC和ETH的杠杆倍数
|
||
AltcoinLeverage int // 山寨币的杠杆倍数
|
||
|
||
// 风险控制(仅作为提示,AI可自主决定)
|
||
MaxDailyLoss float64 // 最大日亏损百分比(提示)
|
||
MaxDrawdown float64 // 最大回撤百分比(提示)
|
||
StopTradingTime time.Duration // 触发风控后暂停时长
|
||
|
||
// 仓位模式
|
||
IsCrossMargin bool // true=全仓模式, false=逐仓模式
|
||
|
||
// 币种配置
|
||
DefaultCoins []string // 默认币种列表(从数据库获取)
|
||
TradingCoins []string // 实际交易币种列表
|
||
|
||
// 系统提示词模板
|
||
SystemPromptTemplate string // 系统提示词模板名称(如 "default", "aggressive")
|
||
}
|
||
|
||
// AutoTrader 自动交易器
|
||
type AutoTrader struct {
|
||
id string // Trader唯一标识
|
||
name string // Trader显示名称
|
||
aiModel string // AI模型名称
|
||
exchange string // 交易平台名称
|
||
config AutoTraderConfig
|
||
trader Trader // 使用Trader接口(支持多平台)
|
||
mcpClient mcp.AIClient
|
||
store *store.Store // 数据存储(决策记录等)
|
||
cycleNumber int // 当前周期编号
|
||
initialBalance float64
|
||
dailyPnL float64
|
||
customPrompt string // 自定义交易策略prompt
|
||
overrideBasePrompt bool // 是否覆盖基础prompt
|
||
systemPromptTemplate string // 系统提示词模板名称
|
||
defaultCoins []string // 默认币种列表(从数据库获取)
|
||
tradingCoins []string // 实际交易币种列表
|
||
lastResetTime time.Time
|
||
stopUntil time.Time
|
||
isRunning bool
|
||
startTime time.Time // 系统启动时间
|
||
callCount int // AI调用次数
|
||
positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒)
|
||
stopMonitorCh chan struct{} // 用于停止监控goroutine
|
||
monitorWg sync.WaitGroup // 用于等待监控goroutine结束
|
||
peakPnLCache map[string]float64 // 最高收益缓存 (symbol -> 峰值盈亏百分比)
|
||
peakPnLCacheMutex sync.RWMutex // 缓存读写锁
|
||
lastBalanceSyncTime time.Time // 上次余额同步时间
|
||
userID string // 用户ID
|
||
}
|
||
|
||
// NewAutoTrader 创建自动交易器
|
||
// st 参数用于存储决策记录到数据库
|
||
func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*AutoTrader, error) {
|
||
// 设置默认值
|
||
if config.ID == "" {
|
||
config.ID = "default_trader"
|
||
}
|
||
if config.Name == "" {
|
||
config.Name = "Default Trader"
|
||
}
|
||
if config.AIModel == "" {
|
||
if config.UseQwen {
|
||
config.AIModel = "qwen"
|
||
} else {
|
||
config.AIModel = "deepseek"
|
||
}
|
||
}
|
||
|
||
mcpClient := mcp.New()
|
||
|
||
// 初始化AI
|
||
if config.AIModel == "custom" {
|
||
// 使用自定义API
|
||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||
logger.Infof("🤖 [%s] 使用自定义AI API: %s (模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
} else if config.UseQwen || config.AIModel == "qwen" {
|
||
// 使用Qwen (支持自定义URL和Model)
|
||
mcpClient = mcp.NewQwenClient()
|
||
mcpClient.SetAPIKey(config.QwenKey, config.CustomAPIURL, config.CustomModelName)
|
||
if config.CustomAPIURL != "" || config.CustomModelName != "" {
|
||
logger.Infof("🤖 [%s] 使用阿里云Qwen AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
} else {
|
||
logger.Infof("🤖 [%s] 使用阿里云Qwen AI", config.Name)
|
||
}
|
||
} else {
|
||
// 默认使用DeepSeek (支持自定义URL和Model)
|
||
mcpClient = mcp.NewDeepSeekClient()
|
||
mcpClient.SetAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName)
|
||
if config.CustomAPIURL != "" || config.CustomModelName != "" {
|
||
logger.Infof("🤖 [%s] 使用DeepSeek AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
|
||
} else {
|
||
logger.Infof("🤖 [%s] 使用DeepSeek AI", config.Name)
|
||
}
|
||
}
|
||
|
||
// 初始化币种池API
|
||
if config.CoinPoolAPIURL != "" {
|
||
pool.SetCoinPoolAPI(config.CoinPoolAPIURL)
|
||
}
|
||
|
||
// 设置默认交易平台
|
||
if config.Exchange == "" {
|
||
config.Exchange = "binance"
|
||
}
|
||
|
||
// 根据配置创建对应的交易器
|
||
var trader Trader
|
||
var err error
|
||
|
||
// 记录仓位模式(通用)
|
||
marginModeStr := "全仓"
|
||
if !config.IsCrossMargin {
|
||
marginModeStr = "逐仓"
|
||
}
|
||
logger.Infof("📊 [%s] 仓位模式: %s", config.Name, marginModeStr)
|
||
|
||
switch config.Exchange {
|
||
case "binance":
|
||
logger.Infof("🏦 [%s] 使用币安合约交易", config.Name)
|
||
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||
case "bybit":
|
||
logger.Infof("🏦 [%s] 使用Bybit合约交易", config.Name)
|
||
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
||
case "hyperliquid":
|
||
logger.Infof("🏦 [%s] 使用Hyperliquid交易", config.Name)
|
||
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化Hyperliquid交易器失败: %w", err)
|
||
}
|
||
case "aster":
|
||
logger.Infof("🏦 [%s] 使用Aster交易", config.Name)
|
||
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化Aster交易器失败: %w", err)
|
||
}
|
||
case "lighter":
|
||
logger.Infof("🏦 [%s] 使用LIGHTER交易", config.Name)
|
||
|
||
// 優先使用 V2(需要 API Key)
|
||
if config.LighterAPIKeyPrivateKey != "" {
|
||
logger.Infof("✓ 使用 LIGHTER SDK (V2) - 完整簽名支持")
|
||
trader, err = NewLighterTraderV2(
|
||
config.LighterPrivateKey,
|
||
config.LighterWalletAddr,
|
||
config.LighterAPIKeyPrivateKey,
|
||
config.LighterTestnet,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化LIGHTER交易器(V2)失败: %w", err)
|
||
}
|
||
} else {
|
||
// 降級使用 V1(基本HTTP實現)
|
||
logger.Infof("⚠️ 使用 LIGHTER 基本實現 (V1) - 功能受限,請配置 API Key")
|
||
trader, err = NewLighterTrader(config.LighterPrivateKey, config.LighterWalletAddr, config.LighterTestnet)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化LIGHTER交易器(V1)失败: %w", err)
|
||
}
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("不支持的交易平台: %s", config.Exchange)
|
||
}
|
||
|
||
// 验证初始金额配置
|
||
if config.InitialBalance <= 0 {
|
||
return nil, fmt.Errorf("初始金额必须大于0,请在配置中设置InitialBalance")
|
||
}
|
||
|
||
// 获取最后的周期编号(用于恢复)
|
||
var cycleNumber int
|
||
if st != nil {
|
||
cycleNumber, _ = st.Decision().GetLastCycleNumber(config.ID)
|
||
logger.Infof("📊 [%s] 决策记录将存储到数据库", config.Name)
|
||
}
|
||
|
||
// 设置默认系统提示词模板
|
||
systemPromptTemplate := config.SystemPromptTemplate
|
||
if systemPromptTemplate == "" {
|
||
// feature/partial-close-dynamic-tpsl 分支默认使用 adaptive(支持动态止盈止损)
|
||
systemPromptTemplate = "adaptive"
|
||
}
|
||
|
||
return &AutoTrader{
|
||
id: config.ID,
|
||
name: config.Name,
|
||
aiModel: config.AIModel,
|
||
exchange: config.Exchange,
|
||
config: config,
|
||
trader: trader,
|
||
mcpClient: mcpClient,
|
||
store: st,
|
||
cycleNumber: cycleNumber,
|
||
initialBalance: config.InitialBalance,
|
||
systemPromptTemplate: systemPromptTemplate,
|
||
defaultCoins: config.DefaultCoins,
|
||
tradingCoins: config.TradingCoins,
|
||
lastResetTime: time.Now(),
|
||
startTime: time.Now(),
|
||
callCount: 0,
|
||
isRunning: false,
|
||
positionFirstSeenTime: make(map[string]int64),
|
||
stopMonitorCh: make(chan struct{}),
|
||
monitorWg: sync.WaitGroup{},
|
||
peakPnLCache: make(map[string]float64),
|
||
peakPnLCacheMutex: sync.RWMutex{},
|
||
lastBalanceSyncTime: time.Now(),
|
||
userID: userID,
|
||
}, nil
|
||
}
|
||
|
||
// Run 运行自动交易主循环
|
||
func (at *AutoTrader) Run() error {
|
||
at.isRunning = true
|
||
at.stopMonitorCh = make(chan struct{})
|
||
at.startTime = time.Now()
|
||
|
||
logger.Info("🚀 AI驱动自动交易系统启动")
|
||
logger.Infof("💰 初始余额: %.2f USDT", at.initialBalance)
|
||
logger.Infof("⚙️ 扫描间隔: %v", at.config.ScanInterval)
|
||
logger.Info("🤖 AI将全权决定杠杆、仓位大小、止损止盈等参数")
|
||
at.monitorWg.Add(1)
|
||
defer at.monitorWg.Done()
|
||
|
||
// 启动回撤监控
|
||
at.startDrawdownMonitor()
|
||
|
||
ticker := time.NewTicker(at.config.ScanInterval)
|
||
defer ticker.Stop()
|
||
|
||
// 首次立即执行
|
||
if err := at.runCycle(); err != nil {
|
||
logger.Infof("❌ 执行失败: %v", err)
|
||
}
|
||
|
||
for at.isRunning {
|
||
select {
|
||
case <-ticker.C:
|
||
if err := at.runCycle(); err != nil {
|
||
logger.Infof("❌ 执行失败: %v", err)
|
||
}
|
||
case <-at.stopMonitorCh:
|
||
logger.Infof("[%s] ⏹ 收到停止信号,退出自动交易主循环", at.name)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Stop 停止自动交易
|
||
func (at *AutoTrader) Stop() {
|
||
if !at.isRunning {
|
||
return
|
||
}
|
||
at.isRunning = false
|
||
close(at.stopMonitorCh) // 通知监控goroutine停止
|
||
at.monitorWg.Wait() // 等待监控goroutine结束
|
||
logger.Info("⏹ 自动交易系统停止")
|
||
}
|
||
|
||
// runCycle 运行一个交易周期(使用AI全权决策)
|
||
func (at *AutoTrader) runCycle() error {
|
||
at.callCount++
|
||
|
||
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
|
||
logger.Infof("⏰ %s - AI决策周期 #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
|
||
logger.Info(strings.Repeat("=", 70))
|
||
|
||
// 创建决策记录
|
||
record := &store.DecisionRecord{
|
||
ExecutionLog: []string{},
|
||
Success: true,
|
||
}
|
||
|
||
// 1. 检查是否需要停止交易
|
||
if time.Now().Before(at.stopUntil) {
|
||
remaining := at.stopUntil.Sub(time.Now())
|
||
logger.Infof("⏸ 风险控制:暂停交易中,剩余 %.0f 分钟", remaining.Minutes())
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("风险控制暂停中,剩余 %.0f 分钟", remaining.Minutes())
|
||
at.saveDecision(record)
|
||
return nil
|
||
}
|
||
|
||
// 2. 重置日盈亏(每天重置)
|
||
if time.Since(at.lastResetTime) > 24*time.Hour {
|
||
at.dailyPnL = 0
|
||
at.lastResetTime = time.Now()
|
||
logger.Info("📅 日盈亏已重置")
|
||
}
|
||
|
||
// 4. 收集交易上下文
|
||
ctx, err := at.buildTradingContext()
|
||
if err != nil {
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("构建交易上下文失败: %v", err)
|
||
at.saveDecision(record)
|
||
return fmt.Errorf("构建交易上下文失败: %w", err)
|
||
}
|
||
|
||
// 保存账户状态快照
|
||
record.AccountState = store.AccountSnapshot{
|
||
TotalBalance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
|
||
AvailableBalance: ctx.Account.AvailableBalance,
|
||
TotalUnrealizedProfit: ctx.Account.UnrealizedPnL,
|
||
PositionCount: ctx.Account.PositionCount,
|
||
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||
InitialBalance: at.initialBalance, // 记录当时的初始余额基准
|
||
}
|
||
|
||
// 保存持仓快照
|
||
for _, pos := range ctx.Positions {
|
||
record.Positions = append(record.Positions, store.PositionSnapshot{
|
||
Symbol: pos.Symbol,
|
||
Side: pos.Side,
|
||
PositionAmt: pos.Quantity,
|
||
EntryPrice: pos.EntryPrice,
|
||
MarkPrice: pos.MarkPrice,
|
||
UnrealizedProfit: pos.UnrealizedPnL,
|
||
Leverage: float64(pos.Leverage),
|
||
LiquidationPrice: pos.LiquidationPrice,
|
||
})
|
||
}
|
||
|
||
logger.Info(strings.Repeat("=", 70))
|
||
for _, coin := range ctx.CandidateCoins {
|
||
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
||
}
|
||
|
||
logger.Infof("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d",
|
||
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
|
||
|
||
// 5. 调用AI获取完整决策
|
||
logger.Infof("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate)
|
||
decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt, at.systemPromptTemplate)
|
||
|
||
if decision != nil && decision.AIRequestDurationMs > 0 {
|
||
record.AIRequestDurationMs = decision.AIRequestDurationMs
|
||
logger.Infof("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000)
|
||
record.ExecutionLog = append(record.ExecutionLog,
|
||
fmt.Sprintf("AI调用耗时: %d ms", record.AIRequestDurationMs))
|
||
}
|
||
|
||
// 即使有错误,也保存思维链、决策和输入prompt(用于debug)
|
||
if decision != nil {
|
||
record.SystemPrompt = decision.SystemPrompt // 保存系统提示词
|
||
record.InputPrompt = decision.UserPrompt
|
||
record.CoTTrace = decision.CoTTrace
|
||
if len(decision.Decisions) > 0 {
|
||
decisionJSON, _ := json.MarshalIndent(decision.Decisions, "", " ")
|
||
record.DecisionJSON = string(decisionJSON)
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
record.Success = false
|
||
record.ErrorMessage = fmt.Sprintf("获取AI决策失败: %v", err)
|
||
|
||
// 打印系统提示词和AI思维链(即使有错误,也要输出以便调试)
|
||
if decision != nil {
|
||
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
|
||
logger.Infof("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate)
|
||
logger.Info(strings.Repeat("=", 70))
|
||
logger.Info(decision.SystemPrompt)
|
||
logger.Info(strings.Repeat("=", 70))
|
||
|
||
if decision.CoTTrace != "" {
|
||
logger.Info("\n" + strings.Repeat("-", 70) + "\n")
|
||
logger.Info("💭 AI思维链分析(错误情况):")
|
||
logger.Info(strings.Repeat("-", 70))
|
||
logger.Info(decision.CoTTrace)
|
||
logger.Info(strings.Repeat("-", 70))
|
||
}
|
||
}
|
||
|
||
at.saveDecision(record)
|
||
return fmt.Errorf("获取AI决策失败: %w", err)
|
||
}
|
||
|
||
// // 5. 打印系统提示词
|
||
// logger.Infof("\n" + strings.Repeat("=", 70))
|
||
// logger.Infof("📋 系统提示词 [模板: %s]", at.systemPromptTemplate)
|
||
// logger.Info(strings.Repeat("=", 70))
|
||
// logger.Info(decision.SystemPrompt)
|
||
// logger.Infof(strings.Repeat("=", 70) + "\n")
|
||
|
||
// 6. 打印AI思维链
|
||
// logger.Infof("\n" + strings.Repeat("-", 70))
|
||
// logger.Info("💭 AI思维链分析:")
|
||
// logger.Info(strings.Repeat("-", 70))
|
||
// logger.Info(decision.CoTTrace)
|
||
// logger.Infof(strings.Repeat("-", 70) + "\n")
|
||
|
||
// 7. 打印AI决策
|
||
// logger.Infof("📋 AI决策列表 (%d 个):\n", len(decision.Decisions))
|
||
// for i, d := range decision.Decisions {
|
||
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
|
||
// if d.Action == "open_long" || d.Action == "open_short" {
|
||
// logger.Infof(" 杠杆: %dx | 仓位: %.2f USDT | 止损: %.4f | 止盈: %.4f",
|
||
// d.Leverage, d.PositionSizeUSD, d.StopLoss, d.TakeProfit)
|
||
// }
|
||
// }
|
||
logger.Info()
|
||
logger.Info(strings.Repeat("-", 70))
|
||
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
|
||
logger.Info(strings.Repeat("-", 70))
|
||
|
||
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
|
||
sortedDecisions := sortDecisionsByPriority(decision.Decisions)
|
||
|
||
logger.Info("🔄 执行顺序(已优化): 先平仓→后开仓")
|
||
for i, d := range sortedDecisions {
|
||
logger.Infof(" [%d] %s %s", i+1, d.Symbol, d.Action)
|
||
}
|
||
logger.Info()
|
||
|
||
// 执行决策并记录结果
|
||
for _, d := range sortedDecisions {
|
||
actionRecord := store.DecisionAction{
|
||
Action: d.Action,
|
||
Symbol: d.Symbol,
|
||
Quantity: 0,
|
||
Leverage: d.Leverage,
|
||
Price: 0,
|
||
Timestamp: time.Now(),
|
||
Success: false,
|
||
}
|
||
|
||
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
|
||
logger.Infof("❌ 执行决策失败 (%s %s): %v", d.Symbol, d.Action, err)
|
||
actionRecord.Error = err.Error()
|
||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s 失败: %v", d.Symbol, d.Action, err))
|
||
} else {
|
||
actionRecord.Success = true
|
||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("✓ %s %s 成功", d.Symbol, d.Action))
|
||
// 成功执行后短暂延迟
|
||
time.Sleep(1 * time.Second)
|
||
}
|
||
|
||
record.Decisions = append(record.Decisions, actionRecord)
|
||
}
|
||
|
||
// 9. 保存决策记录
|
||
if err := at.saveDecision(record); err != nil {
|
||
logger.Infof("⚠ 保存决策记录失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// buildTradingContext 构建交易上下文
|
||
func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||
// 1. 获取账户信息
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取账户余额失败: %w", err)
|
||
}
|
||
|
||
// 获取账户字段
|
||
totalWalletBalance := 0.0
|
||
totalUnrealizedProfit := 0.0
|
||
availableBalance := 0.0
|
||
|
||
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
|
||
totalWalletBalance = wallet
|
||
}
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
totalUnrealizedProfit = unrealized
|
||
}
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Total Equity = 钱包余额 + 未实现盈亏
|
||
totalEquity := totalWalletBalance + totalUnrealizedProfit
|
||
|
||
// 2. 获取持仓信息
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
var positionInfos []decision.PositionInfo
|
||
totalMarginUsed := 0.0
|
||
|
||
// 当前持仓的key集合(用于清理已平仓的记录)
|
||
currentPositionKeys := make(map[string]bool)
|
||
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity // 空仓数量为负,转为正数
|
||
}
|
||
|
||
// 跳过已平仓的持仓(quantity = 0),防止"幽灵持仓"传递给AI
|
||
if quantity == 0 {
|
||
continue
|
||
}
|
||
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
liquidationPrice := pos["liquidationPrice"].(float64)
|
||
|
||
// 计算占用保证金(估算)
|
||
leverage := 10 // 默认值,实际应该从持仓信息获取
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
totalMarginUsed += marginUsed
|
||
|
||
// 计算盈亏百分比(基于保证金,考虑杠杆)
|
||
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
||
|
||
// 跟踪持仓首次出现时间
|
||
posKey := symbol + "_" + side
|
||
currentPositionKeys[posKey] = true
|
||
if _, exists := at.positionFirstSeenTime[posKey]; !exists {
|
||
// 新持仓,记录当前时间
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
}
|
||
updateTime := at.positionFirstSeenTime[posKey]
|
||
|
||
// 获取该持仓的历史最高收益率
|
||
at.peakPnLCacheMutex.RLock()
|
||
peakPnlPct := at.peakPnLCache[posKey]
|
||
at.peakPnLCacheMutex.RUnlock()
|
||
|
||
positionInfos = append(positionInfos, decision.PositionInfo{
|
||
Symbol: symbol,
|
||
Side: side,
|
||
EntryPrice: entryPrice,
|
||
MarkPrice: markPrice,
|
||
Quantity: quantity,
|
||
Leverage: leverage,
|
||
UnrealizedPnL: unrealizedPnl,
|
||
UnrealizedPnLPct: pnlPct,
|
||
PeakPnLPct: peakPnlPct,
|
||
LiquidationPrice: liquidationPrice,
|
||
MarginUsed: marginUsed,
|
||
UpdateTime: updateTime,
|
||
})
|
||
}
|
||
|
||
// 清理已平仓的持仓记录
|
||
for key := range at.positionFirstSeenTime {
|
||
if !currentPositionKeys[key] {
|
||
delete(at.positionFirstSeenTime, key)
|
||
}
|
||
}
|
||
|
||
// 3. 获取交易员的候选币种池
|
||
candidateCoins, err := at.getCandidateCoins()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取候选币种失败: %w", err)
|
||
}
|
||
|
||
// 4. 计算总盈亏
|
||
totalPnL := totalEquity - at.initialBalance
|
||
totalPnLPct := 0.0
|
||
if at.initialBalance > 0 {
|
||
totalPnLPct = (totalPnL / at.initialBalance) * 100
|
||
}
|
||
|
||
marginUsedPct := 0.0
|
||
if totalEquity > 0 {
|
||
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
||
}
|
||
|
||
// 5. 构建上下文
|
||
ctx := &decision.Context{
|
||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||
CallCount: at.callCount,
|
||
BTCETHLeverage: at.config.BTCETHLeverage, // 使用配置的杠杆倍数
|
||
AltcoinLeverage: at.config.AltcoinLeverage, // 使用配置的杠杆倍数
|
||
Account: decision.AccountInfo{
|
||
TotalEquity: totalEquity,
|
||
AvailableBalance: availableBalance,
|
||
UnrealizedPnL: totalUnrealizedProfit,
|
||
TotalPnL: totalPnL,
|
||
TotalPnLPct: totalPnLPct,
|
||
MarginUsed: totalMarginUsed,
|
||
MarginUsedPct: marginUsedPct,
|
||
PositionCount: len(positionInfos),
|
||
},
|
||
Positions: positionInfos,
|
||
CandidateCoins: candidateCoins,
|
||
}
|
||
|
||
// 6. 添加交易统计和历史订单(如果store可用)
|
||
if at.store != nil {
|
||
// 获取交易统计(使用新的 positions 表)
|
||
if stats, err := at.store.Position().GetFullStats(at.id); err == nil {
|
||
ctx.TradingStats = &decision.TradingStats{
|
||
TotalTrades: stats.TotalTrades,
|
||
WinRate: stats.WinRate,
|
||
ProfitFactor: stats.ProfitFactor,
|
||
SharpeRatio: stats.SharpeRatio,
|
||
TotalPnL: stats.TotalPnL,
|
||
AvgWin: stats.AvgWin,
|
||
AvgLoss: stats.AvgLoss,
|
||
MaxDrawdownPct: stats.MaxDrawdownPct,
|
||
}
|
||
}
|
||
|
||
// 获取最近10条已平仓交易(使用新的 positions 表)
|
||
if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil {
|
||
for _, trade := range recentTrades {
|
||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||
Symbol: trade.Symbol,
|
||
Side: trade.Side,
|
||
EntryPrice: trade.EntryPrice,
|
||
ExitPrice: trade.ExitPrice,
|
||
RealizedPnL: trade.RealizedPnL,
|
||
PnLPct: trade.PnLPct,
|
||
FilledAt: trade.ExitTime,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
return ctx, nil
|
||
}
|
||
|
||
// executeDecisionWithRecord 执行AI决策并记录详细信息
|
||
func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||
switch decision.Action {
|
||
case "open_long":
|
||
return at.executeOpenLongWithRecord(decision, actionRecord)
|
||
case "open_short":
|
||
return at.executeOpenShortWithRecord(decision, actionRecord)
|
||
case "close_long":
|
||
return at.executeCloseLongWithRecord(decision, actionRecord)
|
||
case "close_short":
|
||
return at.executeCloseShortWithRecord(decision, actionRecord)
|
||
case "hold", "wait":
|
||
// 无需执行,仅记录
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("未知的action: %s", decision.Action)
|
||
}
|
||
}
|
||
|
||
// executeOpenLongWithRecord 执行开多仓并记录详细信息
|
||
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 📈 开多仓: %s", decision.Symbol)
|
||
|
||
// ⚠️ 关键:检查是否已有同币种同方向持仓,如果有则拒绝开仓(防止仓位叠加超限)
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||
return fmt.Errorf("❌ %s 已有多仓,拒绝开仓以防止仓位叠加超限。如需换仓,请先给出 close_long 决策", decision.Symbol)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 计算数量
|
||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||
actionRecord.Quantity = quantity
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// ⚠️ 保证金验证:防止保证金不足错误(code=-2019)
|
||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return fmt.Errorf("获取账户余额失败: %w", err)
|
||
}
|
||
availableBalance := 0.0
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// 手续费估算(Taker费率 0.04%)
|
||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||
totalRequired := requiredMargin + estimatedFee
|
||
|
||
if totalRequired > availableBalance {
|
||
return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT",
|
||
totalRequired, requiredMargin, estimatedFee, availableBalance)
|
||
}
|
||
|
||
// 设置仓位模式
|
||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||
logger.Infof(" ⚠️ 设置仓位模式失败: %v", err)
|
||
// 继续执行,不影响交易
|
||
}
|
||
|
||
// 开仓
|
||
order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
logger.Infof(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity)
|
||
|
||
// 记录订单到数据库并轮询确认
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "open_long", quantity, marketData.CurrentPrice, decision.Leverage, 0)
|
||
|
||
// 记录开仓时间
|
||
posKey := decision.Symbol + "_long"
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
|
||
// 设置止损止盈
|
||
if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil {
|
||
logger.Infof(" ⚠ 设置止损失败: %v", err)
|
||
}
|
||
if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil {
|
||
logger.Infof(" ⚠ 设置止盈失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// executeOpenShortWithRecord 执行开空仓并记录详细信息
|
||
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 📉 开空仓: %s", decision.Symbol)
|
||
|
||
// ⚠️ 关键:检查是否已有同币种同方向持仓,如果有则拒绝开仓(防止仓位叠加超限)
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||
return fmt.Errorf("❌ %s 已有空仓,拒绝开仓以防止仓位叠加超限。如需换仓,请先给出 close_short 决策", decision.Symbol)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 计算数量
|
||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||
actionRecord.Quantity = quantity
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// ⚠️ 保证金验证:防止保证金不足错误(code=-2019)
|
||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return fmt.Errorf("获取账户余额失败: %w", err)
|
||
}
|
||
availableBalance := 0.0
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// 手续费估算(Taker费率 0.04%)
|
||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||
totalRequired := requiredMargin + estimatedFee
|
||
|
||
if totalRequired > availableBalance {
|
||
return fmt.Errorf("❌ 保证金不足: 需要 %.2f USDT(保证金 %.2f + 手续费 %.2f),可用 %.2f USDT",
|
||
totalRequired, requiredMargin, estimatedFee, availableBalance)
|
||
}
|
||
|
||
// 设置仓位模式
|
||
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
|
||
logger.Infof(" ⚠️ 设置仓位模式失败: %v", err)
|
||
// 继续执行,不影响交易
|
||
}
|
||
|
||
// 开仓
|
||
order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
logger.Infof(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity)
|
||
|
||
// 记录订单到数据库并轮询确认
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "open_short", quantity, marketData.CurrentPrice, decision.Leverage, 0)
|
||
|
||
// 记录开仓时间
|
||
posKey := decision.Symbol + "_short"
|
||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||
|
||
// 设置止损止盈
|
||
if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil {
|
||
logger.Infof(" ⚠ 设置止损失败: %v", err)
|
||
}
|
||
if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil {
|
||
logger.Infof(" ⚠ 设置止盈失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// executeCloseLongWithRecord 执行平多仓并记录详细信息
|
||
func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 🔄 平多仓: %s", decision.Symbol)
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// 获取开仓价格(用于计算盈亏)
|
||
var entryPrice float64
|
||
var quantity float64
|
||
if at.store != nil {
|
||
if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "long"); err == nil {
|
||
entryPrice = openOrder.AvgPrice
|
||
quantity = openOrder.ExecutedQty
|
||
}
|
||
}
|
||
|
||
// 平仓
|
||
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
// 记录订单到数据库并轮询确认
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice)
|
||
|
||
logger.Infof(" ✓ 平仓成功")
|
||
return nil
|
||
}
|
||
|
||
// executeCloseShortWithRecord 执行平空仓并记录详细信息
|
||
func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||
logger.Infof(" 🔄 平空仓: %s", decision.Symbol)
|
||
|
||
// 获取当前价格
|
||
marketData, err := market.Get(decision.Symbol)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
actionRecord.Price = marketData.CurrentPrice
|
||
|
||
// 获取开仓价格(用于计算盈亏)
|
||
var entryPrice float64
|
||
var quantity float64
|
||
if at.store != nil {
|
||
if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "short"); err == nil {
|
||
entryPrice = openOrder.AvgPrice
|
||
quantity = openOrder.ExecutedQty
|
||
}
|
||
}
|
||
|
||
// 平仓
|
||
order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录订单ID
|
||
if orderID, ok := order["orderId"].(int64); ok {
|
||
actionRecord.OrderID = orderID
|
||
}
|
||
|
||
// 记录订单到数据库并轮询确认
|
||
at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice)
|
||
|
||
logger.Infof(" ✓ 平仓成功")
|
||
return nil
|
||
}
|
||
|
||
// GetID 获取trader ID
|
||
func (at *AutoTrader) GetID() string {
|
||
return at.id
|
||
}
|
||
|
||
// GetName 获取trader名称
|
||
func (at *AutoTrader) GetName() string {
|
||
return at.name
|
||
}
|
||
|
||
// GetAIModel 获取AI模型
|
||
func (at *AutoTrader) GetAIModel() string {
|
||
return at.aiModel
|
||
}
|
||
|
||
// GetExchange 获取交易所
|
||
func (at *AutoTrader) GetExchange() string {
|
||
return at.exchange
|
||
}
|
||
|
||
// SetCustomPrompt 设置自定义交易策略prompt
|
||
func (at *AutoTrader) SetCustomPrompt(prompt string) {
|
||
at.customPrompt = prompt
|
||
}
|
||
|
||
// SetOverrideBasePrompt 设置是否覆盖基础prompt
|
||
func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
|
||
at.overrideBasePrompt = override
|
||
}
|
||
|
||
// SetSystemPromptTemplate 设置系统提示词模板
|
||
func (at *AutoTrader) SetSystemPromptTemplate(templateName string) {
|
||
at.systemPromptTemplate = templateName
|
||
}
|
||
|
||
// GetSystemPromptTemplate 获取当前系统提示词模板名称
|
||
func (at *AutoTrader) GetSystemPromptTemplate() string {
|
||
return at.systemPromptTemplate
|
||
}
|
||
|
||
// saveDecision 保存决策记录到数据库
|
||
func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error {
|
||
if at.store == nil {
|
||
return nil // 没有 store 时静默忽略
|
||
}
|
||
|
||
at.cycleNumber++
|
||
record.CycleNumber = at.cycleNumber
|
||
record.TraderID = at.id
|
||
|
||
if record.Timestamp.IsZero() {
|
||
record.Timestamp = time.Now().UTC()
|
||
}
|
||
|
||
if err := at.store.Decision().LogDecision(record); err != nil {
|
||
logger.Infof("⚠️ 保存决策记录失败: %v", err)
|
||
return err
|
||
}
|
||
|
||
logger.Infof("📝 决策记录已保存: trader=%s, cycle=%d", at.id, at.cycleNumber)
|
||
return nil
|
||
}
|
||
|
||
// GetStore 获取数据存储(用于外部访问决策记录等)
|
||
func (at *AutoTrader) GetStore() *store.Store {
|
||
return at.store
|
||
}
|
||
|
||
// GetStatus 获取系统状态(用于API)
|
||
func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||
aiProvider := "DeepSeek"
|
||
if at.config.UseQwen {
|
||
aiProvider = "Qwen"
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"trader_id": at.id,
|
||
"trader_name": at.name,
|
||
"ai_model": at.aiModel,
|
||
"exchange": at.exchange,
|
||
"is_running": at.isRunning,
|
||
"start_time": at.startTime.Format(time.RFC3339),
|
||
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
|
||
"call_count": at.callCount,
|
||
"initial_balance": at.initialBalance,
|
||
"scan_interval": at.config.ScanInterval.String(),
|
||
"stop_until": at.stopUntil.Format(time.RFC3339),
|
||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||
"ai_provider": aiProvider,
|
||
}
|
||
}
|
||
|
||
// GetAccountInfo 获取账户信息(用于API)
|
||
func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取余额失败: %w", err)
|
||
}
|
||
|
||
// 获取账户字段
|
||
totalWalletBalance := 0.0
|
||
totalUnrealizedProfit := 0.0
|
||
availableBalance := 0.0
|
||
|
||
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
|
||
totalWalletBalance = wallet
|
||
}
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
totalUnrealizedProfit = unrealized
|
||
}
|
||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||
availableBalance = avail
|
||
}
|
||
|
||
// Total Equity = 钱包余额 + 未实现盈亏
|
||
totalEquity := totalWalletBalance + totalUnrealizedProfit
|
||
|
||
// 获取持仓计算总保证金
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
totalMarginUsed := 0.0
|
||
totalUnrealizedPnLCalculated := 0.0
|
||
for _, pos := range positions {
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity
|
||
}
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
totalUnrealizedPnLCalculated += unrealizedPnl
|
||
|
||
leverage := 10
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
totalMarginUsed += marginUsed
|
||
}
|
||
|
||
// 验证未实现盈亏的一致性(API值 vs 从持仓计算)
|
||
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
|
||
if diff > 0.1 { // 允许0.01 USDT的误差
|
||
logger.Infof("⚠️ 未实现盈亏不一致: API=%.4f, 计算=%.4f, 差异=%.4f",
|
||
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
|
||
}
|
||
|
||
totalPnL := totalEquity - at.initialBalance
|
||
totalPnLPct := 0.0
|
||
if at.initialBalance > 0 {
|
||
totalPnLPct = (totalPnL / at.initialBalance) * 100
|
||
} else {
|
||
logger.Infof("⚠️ Initial Balance异常: %.2f,无法计算PNL百分比", at.initialBalance)
|
||
}
|
||
|
||
marginUsedPct := 0.0
|
||
if totalEquity > 0 {
|
||
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
// 核心字段
|
||
"total_equity": totalEquity, // 账户净值 = wallet + unrealized
|
||
"wallet_balance": totalWalletBalance, // 钱包余额(不含未实现盈亏)
|
||
"unrealized_profit": totalUnrealizedProfit, // 未实现盈亏(交易所API官方值)
|
||
"available_balance": availableBalance, // 可用余额
|
||
|
||
// 盈亏统计
|
||
"total_pnl": totalPnL, // 总盈亏 = equity - initial
|
||
"total_pnl_pct": totalPnLPct, // 总盈亏百分比
|
||
"initial_balance": at.initialBalance, // 初始余额
|
||
"daily_pnl": at.dailyPnL, // 日盈亏
|
||
|
||
// 持仓信息
|
||
"position_count": len(positions), // 持仓数量
|
||
"margin_used": totalMarginUsed, // 保证金占用
|
||
"margin_used_pct": marginUsedPct, // 保证金使用率
|
||
}, nil
|
||
}
|
||
|
||
// GetPositions 获取持仓列表(用于API)
|
||
func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取持仓失败: %w", err)
|
||
}
|
||
|
||
var result []map[string]interface{}
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity
|
||
}
|
||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||
liquidationPrice := pos["liquidationPrice"].(float64)
|
||
|
||
leverage := 10
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
|
||
// 计算占用保证金
|
||
marginUsed := (quantity * markPrice) / float64(leverage)
|
||
|
||
// 计算盈亏百分比(基于保证金)
|
||
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
||
|
||
result = append(result, map[string]interface{}{
|
||
"symbol": symbol,
|
||
"side": side,
|
||
"entry_price": entryPrice,
|
||
"mark_price": markPrice,
|
||
"quantity": quantity,
|
||
"leverage": leverage,
|
||
"unrealized_pnl": unrealizedPnl,
|
||
"unrealized_pnl_pct": pnlPct,
|
||
"liquidation_price": liquidationPrice,
|
||
"margin_used": marginUsed,
|
||
})
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// calculatePnLPercentage 计算盈亏百分比(基于保证金,自动考虑杠杆)
|
||
// 收益率 = 未实现盈亏 / 保证金 × 100%
|
||
func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
|
||
if marginUsed > 0 {
|
||
return (unrealizedPnl / marginUsed) * 100
|
||
}
|
||
return 0.0
|
||
}
|
||
|
||
// sortDecisionsByPriority 对决策排序:先平仓,再开仓,最后hold/wait
|
||
// 这样可以避免换仓时仓位叠加超限
|
||
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
|
||
if len(decisions) <= 1 {
|
||
return decisions
|
||
}
|
||
|
||
// 定义优先级
|
||
getActionPriority := func(action string) int {
|
||
switch action {
|
||
case "close_long", "close_short":
|
||
return 1 // 最高优先级:先平仓
|
||
case "open_long", "open_short":
|
||
return 2 // 次优先级:后开仓
|
||
case "hold", "wait":
|
||
return 3 // 最低优先级:观望
|
||
default:
|
||
return 999 // 未知动作放最后
|
||
}
|
||
}
|
||
|
||
// 复制决策列表
|
||
sorted := make([]decision.Decision, len(decisions))
|
||
copy(sorted, decisions)
|
||
|
||
// 按优先级排序
|
||
for i := 0; i < len(sorted)-1; i++ {
|
||
for j := i + 1; j < len(sorted); j++ {
|
||
if getActionPriority(sorted[i].Action) > getActionPriority(sorted[j].Action) {
|
||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
return sorted
|
||
}
|
||
|
||
// getCandidateCoins 获取交易员的候选币种列表
|
||
func (at *AutoTrader) getCandidateCoins() ([]decision.CandidateCoin, error) {
|
||
if len(at.tradingCoins) == 0 {
|
||
// 使用数据库配置的默认币种列表
|
||
var candidateCoins []decision.CandidateCoin
|
||
|
||
if len(at.defaultCoins) > 0 {
|
||
// 使用数据库中配置的默认币种
|
||
for _, coin := range at.defaultCoins {
|
||
symbol := normalizeSymbol(coin)
|
||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||
Symbol: symbol,
|
||
Sources: []string{"default"}, // 标记为数据库默认币种
|
||
})
|
||
}
|
||
logger.Infof("📋 [%s] 使用数据库默认币种: %d个币种 %v",
|
||
at.name, len(candidateCoins), at.defaultCoins)
|
||
return candidateCoins, nil
|
||
} else {
|
||
// 如果数据库中没有配置默认币种,则使用AI500+OI Top作为fallback
|
||
const ai500Limit = 20 // AI500取前20个评分最高的币种
|
||
|
||
mergedPool, err := pool.GetMergedCoinPool(ai500Limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取合并币种池失败: %w", err)
|
||
}
|
||
|
||
// 构建候选币种列表(包含来源信息)
|
||
for _, symbol := range mergedPool.AllSymbols {
|
||
sources := mergedPool.SymbolSources[symbol]
|
||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||
Symbol: symbol,
|
||
Sources: sources, // "ai500" 和/或 "oi_top"
|
||
})
|
||
}
|
||
|
||
logger.Infof("📋 [%s] 数据库无默认币种配置,使用AI500+OI Top: AI500前%d + OI_Top20 = 总计%d个候选币种",
|
||
at.name, ai500Limit, len(candidateCoins))
|
||
return candidateCoins, nil
|
||
}
|
||
} else {
|
||
// 使用自定义币种列表
|
||
var candidateCoins []decision.CandidateCoin
|
||
for _, coin := range at.tradingCoins {
|
||
// 确保币种格式正确(转为大写USDT交易对)
|
||
symbol := normalizeSymbol(coin)
|
||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||
Symbol: symbol,
|
||
Sources: []string{"custom"}, // 标记为自定义来源
|
||
})
|
||
}
|
||
|
||
logger.Infof("📋 [%s] 使用自定义币种: %d个币种 %v",
|
||
at.name, len(candidateCoins), at.tradingCoins)
|
||
return candidateCoins, nil
|
||
}
|
||
}
|
||
|
||
// normalizeSymbol 标准化币种符号(确保以USDT结尾)
|
||
func normalizeSymbol(symbol string) string {
|
||
// 转为大写
|
||
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
||
|
||
// 确保以USDT结尾
|
||
if !strings.HasSuffix(symbol, "USDT") {
|
||
symbol = symbol + "USDT"
|
||
}
|
||
|
||
return symbol
|
||
}
|
||
|
||
// 启动回撤监控
|
||
func (at *AutoTrader) startDrawdownMonitor() {
|
||
at.monitorWg.Add(1)
|
||
go func() {
|
||
defer at.monitorWg.Done()
|
||
|
||
ticker := time.NewTicker(1 * time.Minute) // 每分钟检查一次
|
||
defer ticker.Stop()
|
||
|
||
logger.Info("📊 启动持仓回撤监控(每分钟检查一次)")
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
at.checkPositionDrawdown()
|
||
case <-at.stopMonitorCh:
|
||
logger.Info("⏹ 停止持仓回撤监控")
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// 检查持仓回撤情况
|
||
func (at *AutoTrader) checkPositionDrawdown() {
|
||
// 获取当前持仓
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
logger.Infof("❌ 回撤监控:获取持仓失败: %v", err)
|
||
return
|
||
}
|
||
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity // 空仓数量为负,转为正数
|
||
}
|
||
|
||
// 计算当前盈亏百分比
|
||
leverage := 10 // 默认值
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
|
||
var currentPnLPct float64
|
||
if side == "long" {
|
||
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
|
||
} else {
|
||
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
|
||
}
|
||
|
||
// 构造持仓唯一标识(区分多空)
|
||
posKey := symbol + "_" + side
|
||
|
||
// 获取该持仓的历史最高收益
|
||
at.peakPnLCacheMutex.RLock()
|
||
peakPnLPct, exists := at.peakPnLCache[posKey]
|
||
at.peakPnLCacheMutex.RUnlock()
|
||
|
||
if !exists {
|
||
// 如果没有历史最高记录,使用当前盈亏作为初始值
|
||
peakPnLPct = currentPnLPct
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
} else {
|
||
// 更新峰值缓存
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
}
|
||
|
||
// 计算回撤(从最高点下跌的幅度)
|
||
var drawdownPct float64
|
||
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
|
||
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
|
||
}
|
||
|
||
// 检查平仓条件:收益大于5%且回撤超过40%
|
||
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
|
||
logger.Infof("🚨 触发回撤平仓条件: %s %s | 当前收益: %.2f%% | 最高收益: %.2f%% | 回撤: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
|
||
// 执行平仓
|
||
if err := at.emergencyClosePosition(symbol, side); err != nil {
|
||
logger.Infof("❌ 回撤平仓失败 (%s %s): %v", symbol, side, err)
|
||
} else {
|
||
logger.Infof("✅ 回撤平仓成功: %s %s", symbol, side)
|
||
// 平仓后清理该持仓的缓存
|
||
at.ClearPeakPnLCache(symbol, side)
|
||
}
|
||
} else if currentPnLPct > 5.0 {
|
||
// 记录接近平仓条件的情况(用于调试)
|
||
logger.Infof("📊 回撤监控: %s %s | 收益: %.2f%% | 最高: %.2f%% | 回撤: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 紧急平仓函数
|
||
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
|
||
switch side {
|
||
case "long":
|
||
order, err := at.trader.CloseLong(symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
logger.Infof("✅ 紧急平多仓成功,订单ID: %v", order["orderId"])
|
||
case "short":
|
||
order, err := at.trader.CloseShort(symbol, 0) // 0 = 全部平仓
|
||
if err != nil {
|
||
return err
|
||
}
|
||
logger.Infof("✅ 紧急平空仓成功,订单ID: %v", order["orderId"])
|
||
default:
|
||
return fmt.Errorf("未知的持仓方向: %s", side)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetPeakPnLCache 获取最高收益缓存
|
||
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
|
||
at.peakPnLCacheMutex.RLock()
|
||
defer at.peakPnLCacheMutex.RUnlock()
|
||
|
||
// 返回缓存的副本
|
||
cache := make(map[string]float64)
|
||
for k, v := range at.peakPnLCache {
|
||
cache[k] = v
|
||
}
|
||
return cache
|
||
}
|
||
|
||
// UpdatePeakPnL 更新最高收益缓存
|
||
func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
if peak, exists := at.peakPnLCache[posKey]; exists {
|
||
// 更新峰值(如果是多头,取较大值;如果是空头,currentPnLPct为负,也要比较)
|
||
if currentPnLPct > peak {
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
} else {
|
||
// 首次记录
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
}
|
||
|
||
// ClearPeakPnLCache 清除指定持仓的峰值缓存
|
||
func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
delete(at.peakPnLCache, posKey)
|
||
}
|
||
|
||
// recordAndConfirmOrder 记录订单并轮询确认状态
|
||
// action: open_long, open_short, close_long, close_short
|
||
// entryPrice: 平仓时的开仓价(开仓时为0)
|
||
func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) {
|
||
if at.store == nil {
|
||
return
|
||
}
|
||
|
||
// 获取订单ID(支持多种类型)
|
||
var orderID string
|
||
switch v := orderResult["orderId"].(type) {
|
||
case int64:
|
||
orderID = fmt.Sprintf("%d", v)
|
||
case float64:
|
||
orderID = fmt.Sprintf("%.0f", v)
|
||
case string:
|
||
orderID = v
|
||
default:
|
||
orderID = fmt.Sprintf("%v", v)
|
||
}
|
||
|
||
if orderID == "" || orderID == "0" {
|
||
logger.Infof(" ⚠️ 订单ID为空,跳过记录")
|
||
return
|
||
}
|
||
|
||
// 确定 side 和 positionSide
|
||
var side, positionSide string
|
||
switch action {
|
||
case "open_long":
|
||
side = "BUY"
|
||
positionSide = "LONG"
|
||
case "close_long":
|
||
side = "SELL"
|
||
positionSide = "LONG"
|
||
case "open_short":
|
||
side = "SELL"
|
||
positionSide = "SHORT"
|
||
case "close_short":
|
||
side = "BUY"
|
||
positionSide = "SHORT"
|
||
}
|
||
|
||
// 创建订单记录
|
||
order := &store.TraderOrder{
|
||
TraderID: at.id,
|
||
OrderID: orderID,
|
||
Symbol: symbol,
|
||
Side: side,
|
||
PositionSide: positionSide,
|
||
Action: action,
|
||
OrderType: "MARKET",
|
||
Quantity: quantity,
|
||
Price: price,
|
||
Leverage: leverage,
|
||
Status: "NEW",
|
||
EntryPrice: entryPrice,
|
||
}
|
||
|
||
// 保存到数据库
|
||
if err := at.store.Order().Create(order); err != nil {
|
||
logger.Infof(" ⚠️ 记录订单失败: %v", err)
|
||
return
|
||
}
|
||
|
||
logger.Infof(" 📝 订单已记录 (ID: %s, action: %s)", orderID, action)
|
||
|
||
// 记录仓位变化
|
||
at.recordPositionChange(orderID, symbol, positionSide, action, quantity, price, leverage, entryPrice)
|
||
}
|
||
|
||
// recordPositionChange 记录仓位变化(开仓创建记录,平仓更新记录)
|
||
func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64) {
|
||
if at.store == nil {
|
||
return
|
||
}
|
||
|
||
switch action {
|
||
case "open_long", "open_short":
|
||
// 开仓:创建新的仓位记录
|
||
pos := &store.TraderPosition{
|
||
TraderID: at.id,
|
||
Symbol: symbol,
|
||
Side: side, // LONG or SHORT
|
||
Quantity: quantity,
|
||
EntryPrice: price,
|
||
EntryOrderID: orderID,
|
||
EntryTime: time.Now(),
|
||
Leverage: leverage,
|
||
Status: "OPEN",
|
||
}
|
||
if err := at.store.Position().Create(pos); err != nil {
|
||
logger.Infof(" ⚠️ 记录仓位失败: %v", err)
|
||
} else {
|
||
logger.Infof(" 📊 仓位已记录 [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
|
||
}
|
||
|
||
case "close_long", "close_short":
|
||
// 平仓:找到对应的开仓记录并更新
|
||
openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side)
|
||
if err != nil || openPos == nil {
|
||
logger.Infof(" ⚠️ 找不到对应的开仓记录 (%s %s)", symbol, side)
|
||
return
|
||
}
|
||
|
||
// 计算盈亏
|
||
var realizedPnL float64
|
||
if side == "LONG" {
|
||
realizedPnL = (price - openPos.EntryPrice) * openPos.Quantity
|
||
} else {
|
||
realizedPnL = (openPos.EntryPrice - price) * openPos.Quantity
|
||
}
|
||
|
||
// 更新仓位记录
|
||
err = at.store.Position().ClosePosition(
|
||
openPos.ID,
|
||
price, // exitPrice
|
||
orderID, // exitOrderID
|
||
realizedPnL,
|
||
0, // fee (暂不计算)
|
||
"ai_decision",
|
||
)
|
||
if err != nil {
|
||
logger.Infof(" ⚠️ 更新仓位失败: %v", err)
|
||
} else {
|
||
logger.Infof(" 📊 仓位已平仓 [%s] %s %s @ %.4f → %.4f, PnL: %.2f",
|
||
at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL)
|
||
}
|
||
}
|
||
}
|
||
|