From c64727ffd0d82674a685671c72a9d55336d83822 Mon Sep 17 00:00:00 2001 From: icy Date: Thu, 30 Oct 2025 20:51:22 +0800 Subject: [PATCH 1/3] sync fork --- Dockerfile | 2 +- README.md | 262 ++++--- api/server.go | 302 +++++++- config/config.go | 138 ---- config/database.go | 390 ++++++++++ docker-compose.yml | 2 - go.mod | 1 + go.sum | 2 + main.go | 97 ++- manager/trader_manager.go | 208 +++++- web/.dockerignore | 1 - web/Dockerfile | 2 +- web/src/App.tsx | 166 ++-- web/src/components/AITradersPage.tsx | 998 +++++++++++++++++++++++++ web/src/components/ComparisonChart.tsx | 336 --------- web/src/components/CompetitionPage.tsx | 251 ------- web/src/i18n/translations.ts | 84 ++- web/src/lib/api.ts | 77 +- web/src/types.ts | 58 +- web/vite.config.ts | 2 +- 20 files changed, 2337 insertions(+), 1042 deletions(-) delete mode 100644 config/config.go create mode 100644 config/database.go create mode 100644 web/src/components/AITradersPage.tsx delete mode 100644 web/src/components/ComparisonChart.tsx delete mode 100644 web/src/components/CompetitionPage.tsx diff --git a/Dockerfile b/Dockerfile index 49f54a09..25c389c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # 构建阶段 -FROM golang:1.21-alpine AS builder +FROM golang:1.25-alpine AS builder # 安装必要的构建工具 RUN apk add --no-cache git gcc musl-dev diff --git a/README.md b/README.md index c957998a..2cf5671b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ -# 🤖 NOFX - AI-Driven Crypto Futures Auto Trading Competition System +# 🤖 NOFX - Multi-AI Model Automated Trading Platform [![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/) [![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) +[![SQLite](https://img.shields.io/badge/SQLite-3+-003B57?style=flat&logo=sqlite)](https://sqlite.org/) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) **Languages:** [English](README.md) | [中文](README.zh-CN.md) | [Українська](README.uk.md) | [Русский](README.ru.md) --- -An automated crypto futures trading system powered by **DeepSeek/Qwen AI**, supporting **Binance and Hyperliquid exchanges**, **multi-AI model live trading competition**, featuring comprehensive market analysis, AI decision-making, **self-learning mechanism**, and professional Web monitoring interface. +A modern automated crypto futures trading platform powered by **DeepSeek/Qwen AI**, supporting **Binance and Hyperliquid exchanges**. Create and manage multiple AI traders with dynamic configuration through a web interface. Features comprehensive market analysis, AI decision-making, and professional monitoring dashboard. > ⚠️ **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only! @@ -23,40 +24,41 @@ Join our Telegram developer community to discuss, share ideas, and get support: ## 🆕 What's New (Latest Update) -### 🚀 Hyperliquid Exchange Support Added! +### 🚀 Complete System Transformation - Web-Based Configuration! -NOFX now supports **Hyperliquid** - a high-performance decentralized perpetual futures exchange! +NOFX has been **completely transformed** from a static config-based system to a **dynamic web-based trading platform**! -**Key Features:** -- ✅ Full trading support (long/short, leverage, stop-loss/take-profit) -- ✅ Automatic precision handling (order size & price) -- ✅ Unified trader interface (seamless exchange switching) -- ✅ Support for both mainnet and testnet -- ✅ No API keys needed - just your Ethereum private key +**Major Changes:** +- ✅ **Web-Based Configuration**: Create and manage AI traders through a modern web interface +- ✅ **Database-Driven Architecture**: SQLite database replaces static JSON configuration +- ✅ **Separate AI Models & Exchanges**: Configure AI models and exchanges independently +- ✅ **Dynamic Trader Creation**: Create traders by combining configured AI models and exchanges +- ✅ **Real-Time Management**: Start/stop traders, update configurations without restart +- ✅ **No Default Traders**: Clean slate - create only the traders you need -**Why Hyperliquid?** -- 🔥 Lower fees than centralized exchanges -- 🔒 Non-custodial - you control your funds -- ⚡ Fast execution with on-chain settlement -- 🌍 No KYC required +**New Workflow:** +1. **Configure AI Models**: Add your DeepSeek/Qwen API keys through the web interface +2. **Configure Exchanges**: Set up Binance/Hyperliquid API credentials +3. **Create Traders**: Combine any AI model with any exchange to create custom traders +4. **Monitor & Control**: Start/stop traders and monitor performance in real-time -**Quick Start:** -1. Get your MetaMask private key (remove `0x` prefix) -2. Set `"exchange": "hyperliquid"` in config.json -3. Add `"hyperliquid_private_key": "your_key"` -4. Start trading! +**Why This Update?** +- 🎯 **User-Friendly**: No more editing JSON files or server restarts +- 🔧 **Flexible**: Mix and match different AI models with different exchanges +- 📊 **Scalable**: Create unlimited trader combinations +- 🔒 **Secure**: Database storage with proper data management -See [Configuration Guide](#-alternative-using-hyperliquid-exchange) for details. +See [Quick Start](#-quick-start) for the new setup process! --- ## ✨ Core Features -### 🏆 Multi-AI Competition Mode -- **Qwen vs DeepSeek** live trading battle -- Independent account management and decision logs -- Real-time performance comparison charts -- ROI PK and win rate statistics +### 🎛️ Web-Based Configuration Management +- **Dynamic AI Model Setup**: Configure DeepSeek and Qwen API keys through web interface +- **Exchange Management**: Set up Binance and Hyperliquid credentials independently +- **Flexible Trader Creation**: Mix any AI model with any exchange +- **Real-Time Control**: Start/stop traders without system restart ### 🧠 AI Self-Learning Mechanism (NEW!) - **Historical Feedback**: Analyzes last 20 cycles of trading performance before each decision @@ -195,22 +197,13 @@ Before using this system, you need a Binance Futures account. **Use our referral ## 🚀 Quick Start -### 🐳 Option A: Docker One-Click Deployment (EASIEST - Recommended for Beginners!) +### 🐳 Option A: Docker One-Click Deployment (EASIEST - Recommended!) -**⚡ Start trading in 3 simple steps with Docker - No installation needed!** +**⚡ Start the platform in 2 simple steps with Docker - No installation needed!** -Docker automatically handles all dependencies (Go, Node.js, TA-Lib) and environment setup. Perfect for beginners! +Docker automatically handles all dependencies (Go, Node.js, TA-Lib, SQLite) and environment setup. -#### Step 1: Prepare Configuration -```bash -# Copy configuration template -cp config.json.example config.json - -# Edit and fill in your API keys -nano config.json # or use any editor -``` - -#### Step 2: One-Click Start +#### Step 1: One-Click Start ```bash # Option 1: Use convenience script (Recommended) chmod +x start.sh @@ -220,10 +213,16 @@ chmod +x start.sh docker-compose up -d --build ``` -#### Step 3: Access Dashboard +#### Step 2: Access Web Interface Open your browser and visit: **http://localhost:3000** -**That's it! 🎉** Your AI trading system is now running! +**That's it! 🎉** Your AI trading platform is now running! + +#### Initial Setup (Through Web Interface) +1. **Configure AI Models**: Add your DeepSeek/Qwen API keys +2. **Configure Exchanges**: Set up Binance/Hyperliquid credentials +3. **Create Traders**: Combine AI models with exchanges +4. **Start Trading**: Launch your configured traders #### Manage Your System ```bash @@ -328,67 +327,73 @@ Before configuring the system, you need to obtain AI API keys. Choose one of the --- -### 5. System Configuration +### 5. Start the System -**Two configuration modes available:** -- **🌟 Beginner Mode**: Single trader + default coins (recommended!) -- **⚔️ Expert Mode**: Multiple traders competition - -#### 🌟 Beginner Mode Configuration (Recommended) - -**Step 1**: Copy and rename the example config file +#### **Step 1: Start the Backend** ```bash -cp config.json.example config.json +# Build the program (first time only, or after code changes) +go build -o nofx + +# Start the backend +./nofx ``` -**Step 2**: Edit `config.json` with your API keys +**What you should see:** -```json -{ - "traders": [ - { - "id": "my_trader", - "name": "My AI Trader", - "ai_model": "deepseek", - "binance_api_key": "YOUR_BINANCE_API_KEY", - "binance_secret_key": "YOUR_BINANCE_SECRET_KEY", - "use_qwen": false, - "deepseek_key": "sk-xxxxxxxxxxxxx", - "qwen_key": "", - "initial_balance": 1000.0, - "scan_interval_minutes": 3 - } - ], - "use_default_coins": true, - "coin_pool_api_url": "", - "oi_top_api_url": "", - "api_server_port": 8080 -} +``` +╔════════════════════════════════════════════════════════════╗ +║ 🤖 AI多模型交易系统 - 支持 DeepSeek & Qwen ║ +╚════════════════════════════════════════════════════════════╝ + +🤖 数据库中的AI交易员配置: + • 暂无配置的交易员,请通过Web界面创建 + +🌐 API服务器启动在 http://localhost:8081 ``` -**Step 3**: Replace placeholders with your actual keys +#### **Step 2: Start the Frontend** -| Placeholder | Replace With | Where to Get | -|------------|--------------|--------------| -| `YOUR_BINANCE_API_KEY` | Your Binance API Key | Binance → Account → API Management | -| `YOUR_BINANCE_SECRET_KEY` | Your Binance Secret Key | Same as above | -| `sk-xxxxxxxxxxxxx` | Your DeepSeek API Key | [platform.deepseek.com](https://platform.deepseek.com) | +Open a **NEW terminal window**, then: -**Step 4**: Adjust initial balance (optional) +```bash +cd web +npm run dev +``` -- `initial_balance`: Set to your actual Binance futures account balance -- Used to calculate profit/loss percentage -- Example: If you have 500 USDT, set `"initial_balance": 500.0` +#### **Step 3: Access the Web Interface** -**✅ Configuration Checklist:** +Open your browser and visit: **🌐 http://localhost:3000** -- [ ] Binance API key filled in (no quotes issues) -- [ ] Binance Secret key filled in (no quotes issues) -- [ ] DeepSeek API key filled in (starts with `sk-`) -- [ ] `use_default_coins` set to `true` (for beginners) -- [ ] `initial_balance` matches your account balance -- [ ] File saved as `config.json` (not `.example`) +### 6. Configure Through Web Interface + +**Now configure everything through the web interface - no more JSON editing!** + +#### **Step 1: Configure AI Models** +1. Click "AI模型配置" button +2. Enable DeepSeek or Qwen (or both) +3. Enter your API keys +4. Save configuration + +#### **Step 2: Configure Exchanges** +1. Click "交易所配置" button +2. Enable Binance or Hyperliquid (or both) +3. Enter your API credentials +4. Save configuration + +#### **Step 3: Create Traders** +1. Click "创建交易员" button +2. Select an AI model (must be configured first) +3. Select an exchange (must be configured first) +4. Set initial balance and trader name +5. Create trader + +#### **Step 4: Start Trading** +- Your traders will appear in the main interface +- Use Start/Stop buttons to control them +- Monitor performance in real-time + +**✅ No more JSON file editing - everything is done through the web interface!** --- @@ -866,14 +871,26 @@ Each decision cycle (default 3 minutes), the system executes the following intel ## 🎛️ API Endpoints -### Competition Related +### Configuration Management ```bash -GET /api/competition # Competition leaderboard (all traders) -GET /api/traders # Trader list +GET /api/models # Get AI model configurations +PUT /api/models # Update AI model configurations +GET /api/exchanges # Get exchange configurations +PUT /api/exchanges # Update exchange configurations ``` -### Single Trader Related +### Trader Management + +```bash +GET /api/traders # List all traders +POST /api/traders # Create new trader +DELETE /api/traders/:id # Delete trader +POST /api/traders/:id/start # Start trader +POST /api/traders/:id/stop # Stop trader +``` + +### Trading Data & Monitoring ```bash GET /api/status?trader_id=xxx # System status @@ -882,13 +899,13 @@ GET /api/positions?trader_id=xxx # Position list GET /api/equity-history?trader_id=xxx # Equity history (chart data) GET /api/decisions/latest?trader_id=xxx # Latest 5 decisions GET /api/statistics?trader_id=xxx # Statistics +GET /api/performance?trader_id=xxx # AI performance analysis ``` ### System Endpoints ```bash GET /health # Health check -GET /api/config # System configuration ``` --- @@ -980,6 +997,61 @@ sudo apt-get install libta-lib0-dev ## 🔄 Changelog +### v3.0.0 (2025-10-30) - Major Architecture Transformation + +**🚀 Complete System Redesign - Web-Based Configuration Platform** + +This is a **major breaking update** that completely transforms NOFX from a static config-based system to a modern web-based trading platform. + +**Revolutionary Changes:** + +**1. Database-Driven Architecture** +- ✅ **SQLite Integration**: Replaced static JSON config with SQLite database +- ✅ **Persistent Storage**: All configurations stored in database with automatic timestamps +- ✅ **Data Integrity**: Foreign key relationships and triggers for data consistency +- ✅ **Schema Design**: Separate tables for AI models, exchanges, traders, and system config + +**2. Web-Based Configuration Interface** +- ✅ **No More JSON Editing**: Complete web-based configuration management +- ✅ **AI Model Setup**: Configure DeepSeek/Qwen API keys through web interface +- ✅ **Exchange Management**: Set up Binance/Hyperliquid credentials independently +- ✅ **Dynamic Trader Creation**: Create traders by combining any AI model with any exchange +- ✅ **Real-Time Control**: Start/stop traders without system restart + +**3. Flexible Architecture** +- ✅ **Separation of Concerns**: AI models and exchanges configured independently +- ✅ **Mix & Match**: Create unlimited combinations (e.g., Qwen + Binance, DeepSeek + Hyperliquid) +- ✅ **Scalable Design**: Support for unlimited traders and configurations +- ✅ **Clean Slate**: No default traders - create only what you need + +**4. Enhanced API Layer** +- ✅ **RESTful Design**: Complete CRUD operations for all configuration entities +- ✅ **New Endpoints**: + - `GET/PUT /api/models` - AI model configuration + - `GET/PUT /api/exchanges` - Exchange configuration + - `POST/DELETE /api/traders` - Trader management + - `POST /api/traders/:id/start|stop` - Trader control +- ✅ **Updated Documentation**: All API endpoints documented + +**5. Modernized Codebase** +- ✅ **Type Safety**: Proper separation of legacy and new configuration types +- ✅ **Database Abstraction**: Clean database layer with prepared statements +- ✅ **Error Handling**: Comprehensive error handling and validation +- ✅ **Code Organization**: Better separation between database, API, and business logic + +**Migration Notes:** +- ⚠️ **Breaking Change**: Old `config.json` files are no longer used +- ⚠️ **Fresh Start**: All configurations must be redone through web interface +- ✅ **Easier Setup**: Web-based configuration is much more user-friendly +- ✅ **Better UX**: No more server restarts for configuration changes + +**Why This Update Matters:** +- 🎯 **User Experience**: Much easier to configure and manage +- 🔧 **Flexibility**: Create any combination of AI models and exchanges +- 📊 **Scalability**: Support for complex multi-trader setups +- 🔒 **Reliability**: Database ensures data persistence and consistency +- 🚀 **Future-Proof**: Foundation for advanced features like trader templates, backtesting, etc. + ### v2.0.2 (2025-10-29) **Critical Bug Fixes - Trade History & Performance Analysis:** @@ -1091,7 +1163,7 @@ Issues and Pull Requests are welcome! --- -**Last Updated**: 2025-10-29 (v2.0.2) +**Last Updated**: 2025-10-30 (v3.0.0) **⚡ Explore the possibilities of quantitative trading with the power of AI!** diff --git a/api/server.go b/api/server.go index 32d24c51..4d761585 100644 --- a/api/server.go +++ b/api/server.go @@ -4,7 +4,9 @@ import ( "fmt" "log" "net/http" + "nofx/config" "nofx/manager" + "time" "github.com/gin-gonic/gin" ) @@ -13,11 +15,12 @@ import ( type Server struct { router *gin.Engine traderManager *manager.TraderManager + database *config.Database port int } // NewServer 创建API服务器 -func NewServer(traderManager *manager.TraderManager, port int) *Server { +func NewServer(traderManager *manager.TraderManager, database *config.Database, port int) *Server { // 设置为Release模式(减少日志输出) gin.SetMode(gin.ReleaseMode) @@ -29,6 +32,7 @@ func NewServer(traderManager *manager.TraderManager, port int) *Server { s := &Server{ router: router, traderManager: traderManager, + database: database, port: port, } @@ -62,11 +66,20 @@ func (s *Server) setupRoutes() { // API路由组 api := s.router.Group("/api") { - // 竞赛总览 - api.GET("/competition", s.handleCompetition) - - // Trader列表 + // AI交易员管理 api.GET("/traders", s.handleTraderList) + api.POST("/traders", s.handleCreateTrader) + api.DELETE("/traders/:id", s.handleDeleteTrader) + api.POST("/traders/:id/start", s.handleStartTrader) + api.POST("/traders/:id/stop", s.handleStopTrader) + + // AI模型配置 + api.GET("/models", s.handleGetModelConfigs) + api.PUT("/models", s.handleUpdateModelConfigs) + + // 交易所配置 + api.GET("/exchanges", s.handleGetExchangeConfigs) + api.PUT("/exchanges", s.handleUpdateExchangeConfigs) // 指定trader的数据(使用query参数 ?trader_id=xxx) api.GET("/status", s.handleStatus) @@ -102,28 +115,266 @@ func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, str return s.traderManager, traderID, nil } -// handleCompetition 竞赛总览(对比所有trader) -func (s *Server) handleCompetition(c *gin.Context) { - comparison, err := s.traderManager.GetComparisonData() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("获取对比数据失败: %v", err), - }) +// AI交易员管理相关结构体 +type CreateTraderRequest struct { + Name string `json:"name" binding:"required"` + AIModelID string `json:"ai_model_id" binding:"required"` + ExchangeID string `json:"exchange_id" binding:"required"` + InitialBalance float64 `json:"initial_balance"` +} + +type ModelConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey,omitempty"` +} + +type ExchangeConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "cex" or "dex" + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey,omitempty"` + SecretKey string `json:"secretKey,omitempty"` + Testnet bool `json:"testnet,omitempty"` +} + +type UpdateModelConfigRequest struct { + Models map[string]struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + } `json:"models"` +} + +type UpdateExchangeConfigRequest struct { + Exchanges map[string]struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + SecretKey string `json:"secret_key"` + Testnet bool `json:"testnet"` + } `json:"exchanges"` +} + +// handleCreateTrader 创建新的AI交易员 +func (s *Server) handleCreateTrader(c *gin.Context) { + var req CreateTraderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, comparison) + + // 生成交易员ID + traderID := fmt.Sprintf("%s_%s_%d", req.ExchangeID, req.AIModelID, time.Now().Unix()) + + // 创建交易员配置 + trader := &config.TraderConfig{ + ID: traderID, + Name: req.Name, + AIModelID: req.AIModelID, + ExchangeID: req.ExchangeID, + InitialBalance: req.InitialBalance, + ScanIntervalMinutes: 3, // 默认3分钟 + IsRunning: false, + } + + // 保存到数据库 + err := s.database.CreateTrader(trader) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("创建交易员失败: %v", err)}) + return + } + + log.Printf("✓ 创建交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID) + + c.JSON(http.StatusCreated, gin.H{ + "trader_id": traderID, + "trader_name": req.Name, + "ai_model": req.AIModelID, + "is_running": false, + }) +} + +// handleDeleteTrader 删除交易员 +func (s *Server) handleDeleteTrader(c *gin.Context) { + traderID := c.Param("id") + + // 从数据库删除 + err := s.database.DeleteTrader(traderID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("删除交易员失败: %v", err)}) + return + } + + // 如果交易员正在运行,先停止它 + if trader, err := s.traderManager.GetTrader(traderID); err == nil { + status := trader.GetStatus() + if isRunning, ok := status["is_running"].(bool); ok && isRunning { + trader.Stop() + log.Printf("⏹ 已停止运行中的交易员: %s", traderID) + } + } + + log.Printf("✓ 交易员已删除: %s", traderID) + c.JSON(http.StatusOK, gin.H{"message": "交易员已删除"}) +} + +// handleStartTrader 启动交易员 +func (s *Server) handleStartTrader(c *gin.Context) { + traderID := c.Param("id") + + trader, err := s.traderManager.GetTrader(traderID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"}) + return + } + + // 检查交易员是否已经在运行 + status := trader.GetStatus() + if isRunning, ok := status["is_running"].(bool); ok && isRunning { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易员已在运行中"}) + return + } + + // 启动交易员 + go func() { + log.Printf("▶️ 启动交易员 %s (%s)", traderID, trader.GetName()) + if err := trader.Run(); err != nil { + log.Printf("❌ 交易员 %s 运行错误: %v", trader.GetName(), err) + } + }() + + // 更新数据库中的运行状态 + err = s.database.UpdateTraderStatus(traderID, true) + if err != nil { + log.Printf("⚠️ 更新交易员状态失败: %v", err) + } + + log.Printf("✓ 交易员 %s 已启动", trader.GetName()) + c.JSON(http.StatusOK, gin.H{"message": "交易员已启动"}) +} + +// handleStopTrader 停止交易员 +func (s *Server) handleStopTrader(c *gin.Context) { + traderID := c.Param("id") + + trader, err := s.traderManager.GetTrader(traderID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"}) + return + } + + // 检查交易员是否正在运行 + status := trader.GetStatus() + if isRunning, ok := status["is_running"].(bool); ok && !isRunning { + c.JSON(http.StatusBadRequest, gin.H{"error": "交易员已停止"}) + return + } + + // 停止交易员 + trader.Stop() + + // 更新数据库中的运行状态 + err = s.database.UpdateTraderStatus(traderID, false) + if err != nil { + log.Printf("⚠️ 更新交易员状态失败: %v", err) + } + + log.Printf("⏹ 交易员 %s 已停止", trader.GetName()) + c.JSON(http.StatusOK, gin.H{"message": "交易员已停止"}) +} + +// handleGetModelConfigs 获取AI模型配置 +func (s *Server) handleGetModelConfigs(c *gin.Context) { + models, err := s.database.GetAIModels() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取AI模型配置失败: %v", err)}) + return + } + + c.JSON(http.StatusOK, models) +} + +// handleUpdateModelConfigs 更新AI模型配置 +func (s *Server) handleUpdateModelConfigs(c *gin.Context) { + var req UpdateModelConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 更新每个模型的配置 + for modelID, modelData := range req.Models { + err := s.database.UpdateAIModel(modelID, modelData.Enabled, modelData.APIKey) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新模型 %s 失败: %v", modelID, err)}) + return + } + } + + log.Printf("✓ AI模型配置已更新: %+v", req.Models) + c.JSON(http.StatusOK, gin.H{"message": "模型配置已更新"}) +} + +// handleGetExchangeConfigs 获取交易所配置 +func (s *Server) handleGetExchangeConfigs(c *gin.Context) { + exchanges, err := s.database.GetExchanges() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易所配置失败: %v", err)}) + return + } + + c.JSON(http.StatusOK, exchanges) +} + +// handleUpdateExchangeConfigs 更新交易所配置 +func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { + var req UpdateExchangeConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 更新每个交易所的配置 + for exchangeID, exchangeData := range req.Exchanges { + err := s.database.UpdateExchange(exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)}) + return + } + } + + log.Printf("✓ 交易所配置已更新: %+v", req.Exchanges) + c.JSON(http.StatusOK, gin.H{"message": "交易所配置已更新"}) } // handleTraderList trader列表 func (s *Server) handleTraderList(c *gin.Context) { - traders := s.traderManager.GetAllTraders() - result := make([]map[string]interface{}, 0, len(traders)) + traders, err := s.database.GetTraders() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易员列表失败: %v", err)}) + return + } + + result := make([]map[string]interface{}, 0, len(traders)) + for _, trader := range traders { + // 获取实时运行状态 + isRunning := trader.IsRunning + if at, err := s.traderManager.GetTrader(trader.ID); err == nil { + status := at.GetStatus() + if running, ok := status["is_running"].(bool); ok { + isRunning = running + } + } - for _, t := range traders { result = append(result, map[string]interface{}{ - "trader_id": t.GetID(), - "trader_name": t.GetName(), - "ai_model": t.GetAIModel(), + "trader_id": trader.ID, + "trader_name": trader.Name, + "ai_model": trader.AIModelID, + "exchange_id": trader.ExchangeID, + "is_running": isRunning, + "initial_balance": trader.InitialBalance, }) } @@ -405,8 +656,16 @@ func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) log.Printf("🌐 API服务器启动在 http://localhost%s", addr) log.Printf("📊 API文档:") - log.Printf(" • GET /api/competition - 竞赛总览(对比所有trader)") - log.Printf(" • GET /api/traders - Trader列表") + log.Printf(" • GET /health - 健康检查") + log.Printf(" • GET /api/traders - AI交易员列表") + log.Printf(" • POST /api/traders - 创建新的AI交易员") + log.Printf(" • DELETE /api/traders/:id - 删除AI交易员") + log.Printf(" • POST /api/traders/:id/start - 启动AI交易员") + log.Printf(" • POST /api/traders/:id/stop - 停止AI交易员") + log.Printf(" • GET /api/models - 获取AI模型配置") + log.Printf(" • PUT /api/models - 更新AI模型配置") + log.Printf(" • GET /api/exchanges - 获取交易所配置") + log.Printf(" • PUT /api/exchanges - 更新交易所配置") log.Printf(" • GET /api/status?trader_id=xxx - 指定trader的系统状态") log.Printf(" • GET /api/account?trader_id=xxx - 指定trader的账户信息") log.Printf(" • GET /api/positions?trader_id=xxx - 指定trader的持仓列表") @@ -415,7 +674,6 @@ func (s *Server) Start() error { log.Printf(" • GET /api/statistics?trader_id=xxx - 指定trader的统计信息") log.Printf(" • GET /api/equity-history?trader_id=xxx - 指定trader的收益率历史数据") log.Printf(" • GET /api/performance?trader_id=xxx - 指定trader的AI学习表现分析") - log.Printf(" • GET /health - 健康检查") log.Println() return s.router.Run(addr) diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 67b5e559..00000000 --- a/config/config.go +++ /dev/null @@ -1,138 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "time" -) - -// TraderConfig 单个trader的配置 -type TraderConfig struct { - ID string `json:"id"` - Name string `json:"name"` - AIModel string `json:"ai_model"` // "qwen" or "deepseek" - - // 交易平台选择(二选一) - Exchange string `json:"exchange"` // "binance" or "hyperliquid" - - // 币安配置 - BinanceAPIKey string `json:"binance_api_key,omitempty"` - BinanceSecretKey string `json:"binance_secret_key,omitempty"` - - // Hyperliquid配置 - HyperliquidPrivateKey string `json:"hyperliquid_private_key,omitempty"` - HyperliquidTestnet bool `json:"hyperliquid_testnet,omitempty"` - - // AI配置 - QwenKey string `json:"qwen_key,omitempty"` - DeepSeekKey string `json:"deepseek_key,omitempty"` - - InitialBalance float64 `json:"initial_balance"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` -} - -// Config 总配置 -type Config struct { - Traders []TraderConfig `json:"traders"` - UseDefaultCoins bool `json:"use_default_coins"` // 是否使用默认主流币种列表 - CoinPoolAPIURL string `json:"coin_pool_api_url"` - OITopAPIURL string `json:"oi_top_api_url"` - APIServerPort int `json:"api_server_port"` - MaxDailyLoss float64 `json:"max_daily_loss"` - MaxDrawdown float64 `json:"max_drawdown"` - StopTradingMinutes int `json:"stop_trading_minutes"` -} - -// LoadConfig 从文件加载配置 -func LoadConfig(filename string) (*Config, error) { - data, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("读取配置文件失败: %w", err) - } - - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("解析配置文件失败: %w", err) - } - - // 设置默认值:如果use_default_coins未设置(为false)且没有配置coin_pool_api_url,则默认使用默认币种列表 - if !config.UseDefaultCoins && config.CoinPoolAPIURL == "" { - config.UseDefaultCoins = true - } - - // 验证配置 - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("配置验证失败: %w", err) - } - - return &config, nil -} - -// Validate 验证配置有效性 -func (c *Config) Validate() error { - if len(c.Traders) == 0 { - return fmt.Errorf("至少需要配置一个trader") - } - - traderIDs := make(map[string]bool) - for i, trader := range c.Traders { - if trader.ID == "" { - return fmt.Errorf("trader[%d]: ID不能为空", i) - } - if traderIDs[trader.ID] { - return fmt.Errorf("trader[%d]: ID '%s' 重复", i, trader.ID) - } - traderIDs[trader.ID] = true - - if trader.Name == "" { - return fmt.Errorf("trader[%d]: Name不能为空", i) - } - if trader.AIModel != "qwen" && trader.AIModel != "deepseek" { - return fmt.Errorf("trader[%d]: ai_model必须是 'qwen' 或 'deepseek'", i) - } - - // 验证交易平台配置 - if trader.Exchange == "" { - trader.Exchange = "binance" // 默认使用币安 - } - if trader.Exchange != "binance" && trader.Exchange != "hyperliquid" { - return fmt.Errorf("trader[%d]: exchange必须是 'binance' 或 'hyperliquid'", i) - } - - // 根据平台验证对应的密钥 - if trader.Exchange == "binance" { - if trader.BinanceAPIKey == "" || trader.BinanceSecretKey == "" { - return fmt.Errorf("trader[%d]: 使用币安时必须配置binance_api_key和binance_secret_key", i) - } - } else if trader.Exchange == "hyperliquid" { - if trader.HyperliquidPrivateKey == "" { - return fmt.Errorf("trader[%d]: 使用Hyperliquid时必须配置hyperliquid_private_key", i) - } - } - - if trader.AIModel == "qwen" && trader.QwenKey == "" { - return fmt.Errorf("trader[%d]: 使用Qwen时必须配置qwen_key", i) - } - if trader.AIModel == "deepseek" && trader.DeepSeekKey == "" { - return fmt.Errorf("trader[%d]: 使用DeepSeek时必须配置deepseek_key", i) - } - if trader.InitialBalance <= 0 { - return fmt.Errorf("trader[%d]: initial_balance必须大于0", i) - } - if trader.ScanIntervalMinutes <= 0 { - trader.ScanIntervalMinutes = 3 // 默认3分钟 - } - } - - if c.APIServerPort <= 0 { - c.APIServerPort = 8080 // 默认8080端口 - } - - return nil -} - -// GetScanInterval 获取扫描间隔 -func (tc *TraderConfig) GetScanInterval() time.Duration { - return time.Duration(tc.ScanIntervalMinutes) * time.Minute -} diff --git a/config/database.go b/config/database.go new file mode 100644 index 00000000..8e1c193a --- /dev/null +++ b/config/database.go @@ -0,0 +1,390 @@ +package config + +import ( + "database/sql" + "fmt" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Database 配置数据库 +type Database struct { + db *sql.DB +} + +// NewDatabase 创建配置数据库 +func NewDatabase(dbPath string) (*Database, error) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("打开数据库失败: %w", err) + } + + database := &Database{db: db} + if err := database.createTables(); err != nil { + return nil, fmt.Errorf("创建表失败: %w", err) + } + + if err := database.initDefaultData(); err != nil { + return nil, fmt.Errorf("初始化默认数据失败: %w", err) + } + + return database, nil +} + +// createTables 创建数据库表 +func (d *Database) createTables() error { + queries := []string{ + // AI模型配置表 + `CREATE TABLE IF NOT EXISTS ai_models ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + provider TEXT NOT NULL, + enabled BOOLEAN DEFAULT 0, + api_key TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 交易所配置表 + `CREATE TABLE IF NOT EXISTS exchanges ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, -- 'cex' or 'dex' + enabled BOOLEAN DEFAULT 0, + api_key TEXT DEFAULT '', + secret_key TEXT DEFAULT '', + testnet BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 交易员配置表 + `CREATE TABLE IF NOT EXISTS traders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + ai_model_id TEXT NOT NULL, + exchange_id TEXT NOT NULL, + initial_balance REAL NOT NULL, + scan_interval_minutes INTEGER DEFAULT 3, + is_running BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ai_model_id) REFERENCES ai_models(id), + FOREIGN KEY (exchange_id) REFERENCES exchanges(id) + )`, + + // 系统配置表 + `CREATE TABLE IF NOT EXISTS system_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 触发器:自动更新 updated_at + `CREATE TRIGGER IF NOT EXISTS update_ai_models_updated_at + AFTER UPDATE ON ai_models + BEGIN + UPDATE ai_models SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END`, + + `CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at + AFTER UPDATE ON exchanges + BEGIN + UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END`, + + `CREATE TRIGGER IF NOT EXISTS update_traders_updated_at + AFTER UPDATE ON traders + BEGIN + UPDATE traders SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END`, + + `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`, + } + + for _, query := range queries { + if _, err := d.db.Exec(query); err != nil { + return fmt.Errorf("执行SQL失败 [%s]: %w", query, err) + } + } + + return nil +} + +// initDefaultData 初始化默认数据 +func (d *Database) initDefaultData() error { + // 初始化AI模型 + aiModels := []struct { + id, name, provider string + }{ + {"deepseek", "DeepSeek", "deepseek"}, + {"qwen", "Qwen", "qwen"}, + } + + for _, model := range aiModels { + _, err := d.db.Exec(` + INSERT OR IGNORE INTO ai_models (id, name, provider, enabled) + VALUES (?, ?, ?, 0) + `, model.id, model.name, model.provider) + if err != nil { + return fmt.Errorf("初始化AI模型失败: %w", err) + } + } + + // 初始化交易所 + exchanges := []struct { + id, name, typ string + }{ + {"binance", "Binance", "cex"}, + {"hyperliquid", "Hyperliquid", "dex"}, + } + + for _, exchange := range exchanges { + _, err := d.db.Exec(` + INSERT OR IGNORE INTO exchanges (id, name, type, enabled) + VALUES (?, ?, ?, 0) + `, exchange.id, exchange.name, exchange.typ) + if err != nil { + return fmt.Errorf("初始化交易所失败: %w", err) + } + } + + // 初始化系统配置 + systemConfigs := map[string]string{ + "api_server_port": "8081", + "use_default_coins": "true", + "coin_pool_api_url": "", + "oi_top_api_url": "", + "max_daily_loss": "10.0", + "max_drawdown": "20.0", + "stop_trading_minutes": "60", + } + + for key, value := range systemConfigs { + _, err := d.db.Exec(` + INSERT OR IGNORE INTO system_config (key, value) + VALUES (?, ?) + `, key, value) + if err != nil { + return fmt.Errorf("初始化系统配置失败: %w", err) + } + } + + return nil +} + +// AIModelConfig AI模型配置 +type AIModelConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ExchangeConfig 交易所配置 +type ExchangeConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey"` + SecretKey string `json:"secretKey"` + Testnet bool `json:"testnet"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TraderConfig 交易员配置 +type TraderConfig struct { + ID string `json:"id"` + Name string `json:"name"` + AIModelID string `json:"ai_model_id"` + ExchangeID string `json:"exchange_id"` + InitialBalance float64 `json:"initial_balance"` + ScanIntervalMinutes int `json:"scan_interval_minutes"` + IsRunning bool `json:"is_running"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetAIModels 获取所有AI模型配置 +func (d *Database) GetAIModels() ([]*AIModelConfig, error) { + rows, err := d.db.Query(` + SELECT id, name, provider, enabled, api_key, created_at, updated_at + FROM ai_models ORDER BY id + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var models []*AIModelConfig + for rows.Next() { + var model AIModelConfig + err := rows.Scan( + &model.ID, &model.Name, &model.Provider, + &model.Enabled, &model.APIKey, + &model.CreatedAt, &model.UpdatedAt, + ) + if err != nil { + return nil, err + } + models = append(models, &model) + } + + return models, nil +} + +// UpdateAIModel 更新AI模型配置 +func (d *Database) UpdateAIModel(id string, enabled bool, apiKey string) error { + _, err := d.db.Exec(` + UPDATE ai_models SET enabled = ?, api_key = ? WHERE id = ? + `, enabled, apiKey, id) + return err +} + +// GetExchanges 获取所有交易所配置 +func (d *Database) GetExchanges() ([]*ExchangeConfig, error) { + rows, err := d.db.Query(` + SELECT id, name, type, enabled, api_key, secret_key, testnet, created_at, updated_at + FROM exchanges ORDER BY id + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var exchanges []*ExchangeConfig + for rows.Next() { + var exchange ExchangeConfig + err := rows.Scan( + &exchange.ID, &exchange.Name, &exchange.Type, + &exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, + &exchange.CreatedAt, &exchange.UpdatedAt, + ) + if err != nil { + return nil, err + } + exchanges = append(exchanges, &exchange) + } + + return exchanges, nil +} + +// UpdateExchange 更新交易所配置 +func (d *Database) UpdateExchange(id string, enabled bool, apiKey, secretKey string, testnet bool) error { + _, err := d.db.Exec(` + UPDATE exchanges SET enabled = ?, api_key = ?, secret_key = ?, testnet = ? WHERE id = ? + `, enabled, apiKey, secretKey, testnet, id) + return err +} + +// CreateTrader 创建交易员 +func (d *Database) CreateTrader(trader *TraderConfig) error { + _, err := d.db.Exec(` + INSERT INTO traders (id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, trader.ID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning) + return err +} + +// GetTraders 获取所有交易员 +func (d *Database) GetTraders() ([]*TraderConfig, error) { + rows, err := d.db.Query(` + SELECT id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, created_at, updated_at + FROM traders ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var traders []*TraderConfig + for rows.Next() { + var trader TraderConfig + err := rows.Scan( + &trader.ID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, + &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, + &trader.CreatedAt, &trader.UpdatedAt, + ) + if err != nil { + return nil, err + } + traders = append(traders, &trader) + } + + return traders, nil +} + +// UpdateTraderStatus 更新交易员状态 +func (d *Database) UpdateTraderStatus(id string, isRunning bool) error { + _, err := d.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ?`, isRunning, id) + return err +} + +// DeleteTrader 删除交易员 +func (d *Database) DeleteTrader(id string) error { + _, err := d.db.Exec(`DELETE FROM traders WHERE id = ?`, id) + return err +} + +// GetTraderConfig 获取交易员完整配置(包含AI模型和交易所信息) +func (d *Database) GetTraderConfig(traderID string) (*TraderConfig, *AIModelConfig, *ExchangeConfig, error) { + var trader TraderConfig + var aiModel AIModelConfig + var exchange ExchangeConfig + + err := d.db.QueryRow(` + SELECT + t.id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, t.created_at, t.updated_at, + a.id, a.name, a.provider, a.enabled, a.api_key, a.created_at, a.updated_at, + e.id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, e.created_at, e.updated_at + FROM traders t + JOIN ai_models a ON t.ai_model_id = a.id + JOIN exchanges e ON t.exchange_id = e.id + WHERE t.id = ? + `, traderID).Scan( + &trader.ID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, + &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, + &trader.CreatedAt, &trader.UpdatedAt, + &aiModel.ID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, + &aiModel.CreatedAt, &aiModel.UpdatedAt, + &exchange.ID, &exchange.Name, &exchange.Type, &exchange.Enabled, + &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, + &exchange.CreatedAt, &exchange.UpdatedAt, + ) + + if err != nil { + return nil, nil, nil, err + } + + return &trader, &aiModel, &exchange, nil +} + +// GetSystemConfig 获取系统配置 +func (d *Database) GetSystemConfig(key string) (string, error) { + var value string + err := d.db.QueryRow(`SELECT value FROM system_config WHERE key = ?`, key).Scan(&value) + return value, err +} + +// SetSystemConfig 设置系统配置 +func (d *Database) SetSystemConfig(key, value string) error { + _, err := d.db.Exec(` + INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?) + `, key, value) + return err +} + +// Close 关闭数据库连接 +func (d *Database) Close() error { + return d.db.Close() +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0a64d72c..c063d1e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # 后端服务 backend: diff --git a/go.mod b/go.mod index cddc9e3a..0dbb9655 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/adshao/go-binance/v2 v2.8.7 github.com/ethereum/go-ethereum v1.16.5 github.com/gin-gonic/gin v1.11.0 + github.com/mattn/go-sqlite3 v1.14.32 github.com/sonirico/go-hyperliquid v0.17.0 ) diff --git a/go.sum b/go.sum index c30f7b02..eb440888 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= diff --git a/main.go b/main.go index b956209b..72fc9402 100644 --- a/main.go +++ b/main.go @@ -9,72 +9,87 @@ import ( "nofx/pool" "os" "os/signal" + "strconv" "strings" "syscall" ) func main() { fmt.Println("╔════════════════════════════════════════════════════════════╗") - fmt.Println("║ 🏆 AI模型交易竞赛系统 - Qwen vs DeepSeek ║") + fmt.Println("║ 🤖 AI多模型交易系统 - 支持 DeepSeek & Qwen ║") fmt.Println("╚════════════════════════════════════════════════════════════╝") fmt.Println() - // 加载配置文件 - configFile := "config.json" + // 初始化数据库配置 + dbPath := "config.db" if len(os.Args) > 1 { - configFile = os.Args[1] + dbPath = os.Args[1] } - log.Printf("📋 加载配置文件: %s", configFile) - cfg, err := config.LoadConfig(configFile) + log.Printf("📋 初始化配置数据库: %s", dbPath) + database, err := config.NewDatabase(dbPath) if err != nil { - log.Fatalf("❌ 加载配置失败: %v", err) + log.Fatalf("❌ 初始化数据库失败: %v", err) } + defer database.Close() - log.Printf("✓ 配置加载成功,共%d个trader参赛", len(cfg.Traders)) + // 获取系统配置 + useDefaultCoinsStr, _ := database.GetSystemConfig("use_default_coins") + useDefaultCoins := useDefaultCoinsStr == "true" + apiPortStr, _ := database.GetSystemConfig("api_server_port") + + log.Printf("✓ 配置数据库初始化成功") fmt.Println() // 设置是否使用默认主流币种 - pool.SetUseDefaultCoins(cfg.UseDefaultCoins) - if cfg.UseDefaultCoins { + pool.SetUseDefaultCoins(useDefaultCoins) + if useDefaultCoins { log.Printf("✓ 已启用默认主流币种列表(BTC、ETH、SOL、BNB、XRP、DOGE、ADA、HYPE)") } // 设置币种池API URL - if cfg.CoinPoolAPIURL != "" { - pool.SetCoinPoolAPI(cfg.CoinPoolAPIURL) + coinPoolAPIURL, _ := database.GetSystemConfig("coin_pool_api_url") + if coinPoolAPIURL != "" { + pool.SetCoinPoolAPI(coinPoolAPIURL) log.Printf("✓ 已配置AI500币种池API") } - if cfg.OITopAPIURL != "" { - pool.SetOITopAPI(cfg.OITopAPIURL) + + oiTopAPIURL, _ := database.GetSystemConfig("oi_top_api_url") + if oiTopAPIURL != "" { + pool.SetOITopAPI(oiTopAPIURL) log.Printf("✓ 已配置OI Top API") } // 创建TraderManager traderManager := manager.NewTraderManager() - // 添加所有trader - for i, traderCfg := range cfg.Traders { - log.Printf("📦 [%d/%d] 初始化 %s (%s模型)...", - i+1, len(cfg.Traders), traderCfg.Name, strings.ToUpper(traderCfg.AIModel)) - - err := traderManager.AddTrader( - traderCfg, - cfg.CoinPoolAPIURL, - cfg.MaxDailyLoss, - cfg.MaxDrawdown, - cfg.StopTradingMinutes, - ) - if err != nil { - log.Fatalf("❌ 初始化trader失败: %v", err) - } + // 从数据库加载所有交易员到内存 + err = traderManager.LoadTradersFromDatabase(database) + if err != nil { + log.Fatalf("❌ 加载交易员失败: %v", err) } + // 获取数据库中的所有交易员配置(用于显示) + traders, err := database.GetTraders() + if err != nil { + log.Fatalf("❌ 获取交易员列表失败: %v", err) + } + + // 显示加载的交易员信息 fmt.Println() - fmt.Println("🏁 竞赛参赛者:") - for _, traderCfg := range cfg.Traders { - fmt.Printf(" • %s (%s) - 初始资金: %.0f USDT\n", - traderCfg.Name, strings.ToUpper(traderCfg.AIModel), traderCfg.InitialBalance) + fmt.Println("🤖 数据库中的AI交易员配置:") + if len(traders) == 0 { + fmt.Println(" • 暂无配置的交易员,请通过Web界面创建") + } else { + for _, trader := range traders { + status := "停止" + if trader.IsRunning { + status = "运行中" + } + fmt.Printf(" • %s (%s + %s) - 初始资金: %.0f USDT [%s]\n", + trader.Name, strings.ToUpper(trader.AIModelID), strings.ToUpper(trader.ExchangeID), + trader.InitialBalance, status) + } } fmt.Println() @@ -90,8 +105,16 @@ func main() { fmt.Println(strings.Repeat("=", 60)) fmt.Println() + // 获取API服务器端口 + apiPort := 8081 // 默认端口 + if apiPortStr != "" { + if port, err := strconv.Atoi(apiPortStr); err == nil { + apiPort = port + } + } + // 创建并启动API服务器 - apiServer := api.NewServer(traderManager, cfg.APIServerPort) + apiServer := api.NewServer(traderManager, database, apiPort) go func() { if err := apiServer.Start(); err != nil { log.Printf("❌ API服务器错误: %v", err) @@ -102,8 +125,8 @@ func main() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - // 启动所有trader - traderManager.StartAll() + // TODO: 启动数据库中配置为运行状态的交易员 + // traderManager.StartAll() // 等待退出信号 <-sigChan @@ -113,5 +136,5 @@ func main() { traderManager.StopAll() fmt.Println() - fmt.Println("👋 感谢使用AI交易竞赛系统!") + fmt.Println("👋 感谢使用AI交易系统!") } diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 5747572a..65cb92f0 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -5,6 +5,7 @@ import ( "log" "nofx/config" "nofx/trader" + "strconv" "sync" "time" ) @@ -22,44 +23,213 @@ func NewTraderManager() *TraderManager { } } -// AddTrader 添加一个trader -func (tm *TraderManager) AddTrader(cfg config.TraderConfig, coinPoolURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int) error { +// LoadTradersFromDatabase 从数据库加载所有交易员到内存 +func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) error { tm.mu.Lock() defer tm.mu.Unlock() - if _, exists := tm.traders[cfg.ID]; exists { - return fmt.Errorf("trader ID '%s' 已存在", cfg.ID) + // 获取数据库中的所有交易员 + traders, err := database.GetTraders() + if err != nil { + return fmt.Errorf("获取交易员列表失败: %w", err) + } + + log.Printf("📋 加载数据库中的交易员配置: %d 个", len(traders)) + + // 获取系统配置 + coinPoolURL, _ := database.GetSystemConfig("coin_pool_api_url") + maxDailyLossStr, _ := database.GetSystemConfig("max_daily_loss") + maxDrawdownStr, _ := database.GetSystemConfig("max_drawdown") + stopTradingMinutesStr, _ := database.GetSystemConfig("stop_trading_minutes") + + // 解析配置 + maxDailyLoss := 10.0 // 默认值 + if val, err := strconv.ParseFloat(maxDailyLossStr, 64); err == nil { + maxDailyLoss = val + } + + maxDrawdown := 20.0 // 默认值 + if val, err := strconv.ParseFloat(maxDrawdownStr, 64); err == nil { + maxDrawdown = val + } + + stopTradingMinutes := 60 // 默认值 + if val, err := strconv.Atoi(stopTradingMinutesStr); err == nil { + stopTradingMinutes = val + } + + // 为每个交易员获取AI模型和交易所配置 + for _, traderCfg := range traders { + // 获取AI模型配置 + aiModels, err := database.GetAIModels() + if err != nil { + log.Printf("⚠️ 获取AI模型配置失败: %v", err) + continue + } + + var aiModelCfg *config.AIModelConfig + for _, model := range aiModels { + if model.ID == traderCfg.AIModelID { + aiModelCfg = model + break + } + } + + if aiModelCfg == nil { + log.Printf("⚠️ 交易员 %s 的AI模型 %s 不存在,跳过", traderCfg.Name, traderCfg.AIModelID) + continue + } + + if !aiModelCfg.Enabled { + log.Printf("⚠️ 交易员 %s 的AI模型 %s 未启用,跳过", traderCfg.Name, traderCfg.AIModelID) + continue + } + + // 获取交易所配置 + exchanges, err := database.GetExchanges() + if err != nil { + log.Printf("⚠️ 获取交易所配置失败: %v", err) + continue + } + + var exchangeCfg *config.ExchangeConfig + for _, exchange := range exchanges { + if exchange.ID == traderCfg.ExchangeID { + exchangeCfg = exchange + break + } + } + + if exchangeCfg == nil { + log.Printf("⚠️ 交易员 %s 的交易所 %s 不存在,跳过", traderCfg.Name, traderCfg.ExchangeID) + continue + } + + if !exchangeCfg.Enabled { + log.Printf("⚠️ 交易员 %s 的交易所 %s 未启用,跳过", traderCfg.Name, traderCfg.ExchangeID) + continue + } + + // 添加到TraderManager + err = tm.addTraderFromConfig(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, maxDailyLoss, maxDrawdown, stopTradingMinutes) + if err != nil { + log.Printf("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err) + continue + } + } + + log.Printf("✓ 成功加载 %d 个交易员到内存", len(tm.traders)) + return nil +} + +// addTraderFromConfig 内部方法:从配置添加交易员(不加锁,因为调用方已加锁) +func (tm *TraderManager) addTraderFromConfig(traderCfg *config.TraderConfig, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int) error { + if _, exists := tm.traders[traderCfg.ID]; exists { + return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) } // 构建AutoTraderConfig traderConfig := trader.AutoTraderConfig{ - ID: cfg.ID, - Name: cfg.Name, - AIModel: cfg.AIModel, - Exchange: cfg.Exchange, - BinanceAPIKey: cfg.BinanceAPIKey, - BinanceSecretKey: cfg.BinanceSecretKey, - HyperliquidPrivateKey: cfg.HyperliquidPrivateKey, - HyperliquidTestnet: cfg.HyperliquidTestnet, + ID: traderCfg.ID, + Name: traderCfg.Name, + AIModel: aiModelCfg.Provider, // 使用provider作为模型标识 + Exchange: exchangeCfg.ID, // 使用exchange ID + BinanceAPIKey: "", + BinanceSecretKey: "", + HyperliquidPrivateKey: "", + HyperliquidTestnet: exchangeCfg.Testnet, CoinPoolAPIURL: coinPoolURL, - UseQwen: cfg.AIModel == "qwen", - DeepSeekKey: cfg.DeepSeekKey, - QwenKey: cfg.QwenKey, - ScanInterval: cfg.GetScanInterval(), - InitialBalance: cfg.InitialBalance, + UseQwen: aiModelCfg.Provider == "qwen", + DeepSeekKey: "", + QwenKey: "", + ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, + InitialBalance: traderCfg.InitialBalance, MaxDailyLoss: maxDailyLoss, MaxDrawdown: maxDrawdown, StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, } + // 根据交易所类型设置API密钥 + if exchangeCfg.ID == "binance" { + traderConfig.BinanceAPIKey = exchangeCfg.APIKey + traderConfig.BinanceSecretKey = exchangeCfg.SecretKey + } else if exchangeCfg.ID == "hyperliquid" { + traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key + } + + // 根据AI模型设置API密钥 + if aiModelCfg.Provider == "qwen" { + traderConfig.QwenKey = aiModelCfg.APIKey + } else if aiModelCfg.Provider == "deepseek" { + traderConfig.DeepSeekKey = aiModelCfg.APIKey + } + // 创建trader实例 at, err := trader.NewAutoTrader(traderConfig) if err != nil { return fmt.Errorf("创建trader失败: %w", err) } - tm.traders[cfg.ID] = at - log.Printf("✓ Trader '%s' (%s) 已添加", cfg.Name, cfg.AIModel) + tm.traders[traderCfg.ID] = at + log.Printf("✓ Trader '%s' (%s + %s) 已加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) + return nil +} + +// AddTrader 从数据库配置添加trader (移除旧版兼容性) + +// AddTraderFromDB 从数据库配置添加trader +func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderConfig, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int) error { + tm.mu.Lock() + defer tm.mu.Unlock() + + if _, exists := tm.traders[traderCfg.ID]; exists { + return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) + } + + // 构建AutoTraderConfig + traderConfig := trader.AutoTraderConfig{ + ID: traderCfg.ID, + Name: traderCfg.Name, + AIModel: aiModelCfg.Provider, // 使用provider作为模型标识 + Exchange: exchangeCfg.ID, // 使用exchange ID + BinanceAPIKey: "", + BinanceSecretKey: "", + HyperliquidPrivateKey: "", + HyperliquidTestnet: exchangeCfg.Testnet, + CoinPoolAPIURL: coinPoolURL, + UseQwen: aiModelCfg.Provider == "qwen", + DeepSeekKey: "", + QwenKey: "", + ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, + InitialBalance: traderCfg.InitialBalance, + MaxDailyLoss: maxDailyLoss, + MaxDrawdown: maxDrawdown, + StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, + } + + // 根据交易所类型设置API密钥 + if exchangeCfg.ID == "binance" { + traderConfig.BinanceAPIKey = exchangeCfg.APIKey + traderConfig.BinanceSecretKey = exchangeCfg.SecretKey + } else if exchangeCfg.ID == "hyperliquid" { + traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key + } + + // 根据AI模型设置API密钥 + if aiModelCfg.Provider == "qwen" { + traderConfig.QwenKey = aiModelCfg.APIKey + } else if aiModelCfg.Provider == "deepseek" { + traderConfig.DeepSeekKey = aiModelCfg.APIKey + } + + // 创建trader实例 + at, err := trader.NewAutoTrader(traderConfig) + if err != nil { + return fmt.Errorf("创建trader失败: %w", err) + } + + tm.traders[traderCfg.ID] = at + log.Printf("✓ Trader '%s' (%s + %s) 已添加", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) return nil } diff --git a/web/.dockerignore b/web/.dockerignore index 2d8c3534..a96f3fde 100644 --- a/web/.dockerignore +++ b/web/.dockerignore @@ -1,6 +1,5 @@ # Dependencies node_modules/ -package-lock.json yarn.lock pnpm-lock.yaml diff --git a/web/Dockerfile b/web/Dockerfile index f7614cad..5219148c 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /app COPY package*.json ./ # 安装依赖 -RUN npm ci --only=production=false +RUN npm ci # 复制源代码 COPY . . diff --git a/web/src/App.tsx b/web/src/App.tsx index 026c3e2a..dfe418d3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import useSWR from 'swr'; import { api } from './lib/api'; import { EquityChart } from './components/EquityChart'; -import { CompetitionPage } from './components/CompetitionPage'; +import { AITradersPage } from './components/AITradersPage'; import AILearning from './components/AILearning'; import { LanguageProvider, useLanguage } from './contexts/LanguageContext'; import { t, type Language } from './i18n/translations'; @@ -15,11 +15,11 @@ import type { TraderInfo, } from './types'; -type Page = 'competition' | 'trader'; +type Page = 'traders' | 'trader'; function App() { const { language, setLanguage } = useLanguage(); - const [currentPage, setCurrentPage] = useState('competition'); + const [currentPage, setCurrentPage] = useState('traders'); const [selectedTraderId, setSelectedTraderId] = useState(); const [lastUpdate, setLastUpdate] = useState('--:--:--'); @@ -102,7 +102,8 @@ function App() { {/* Header - Binance Style */}
-
+
+ {/* Left - Logo and Title */}
⚡ @@ -116,30 +117,48 @@ function App() {

-
- {/* GitHub Link */} - { - e.currentTarget.style.background = '#2B3139'; - e.currentTarget.style.color = '#EAECEF'; - e.currentTarget.style.borderColor = '#F0B90B'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = '#1E2329'; - e.currentTarget.style.color = '#848E9C'; - e.currentTarget.style.borderColor = '#2B3139'; - }} + + {/* Center - Page Toggle (absolutely positioned) */} +
+ + +
+ + {/* Right - Actions */} +
+ {/* Trader Selector (only show on trader page) */} + {currentPage === 'trader' && traders && traders.length > 0 && ( + + )} {/* Language Toggle */}
@@ -165,48 +184,6 @@ function App() {
- {/* Page Toggle */} -
- - -
- - {/* Trader Selector (only show on trader page) */} - {currentPage === 'trader' && traders && traders.length > 0 && ( - - )} - {/* Status Indicator (only show on trader page) */} {currentPage === 'trader' && status && (
@@ -290,7 +243,6 @@ function TraderDetailsPage({ account, positions, decisions, - stats, lastUpdate, language, }: { @@ -358,9 +310,9 @@ function TraderDetailsPage({ {account && (
- 🔄 Last Update: {lastUpdate} | Total Equity: {account.total_equity.toFixed(2)} | - Available: {account.available_balance.toFixed(2)} | P&L: {account.total_pnl.toFixed(2)}{' '} - ({account.total_pnl_pct.toFixed(2)}%) + 🔄 Last Update: {lastUpdate} | Total Equity: {account?.total_equity?.toFixed(2) || '0.00'} | + Available: {account?.available_balance?.toFixed(2) || '0.00'} | P&L: {account?.total_pnl?.toFixed(2) || '0.00'}{' '} + ({account?.total_pnl_pct?.toFixed(2) || '0.00'}%)
)} @@ -369,20 +321,20 @@ function TraderDetailsPage({
0 : false} + positive={account ? (account.total_pnl || 0) > 0 : false} /> = 0 ? '+' : ''}${account?.total_pnl.toFixed(2) || '0.00'} USDT`} + value={`${(account?.total_pnl || 0) >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'} USDT`} change={account?.total_pnl_pct || 0} - positive={account ? account.total_pnl >= 0 : false} + positive={account ? (account.total_pnl || 0) >= 0 : false} /> {/* AI Input Prompt - Collapsible */} - {decision.input_prompt && ( + {(decision as any).input_prompt && (
{showInput && (
- {decision.input_prompt} + {(decision as any).input_prompt}
)}
diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx new file mode 100644 index 00000000..27e0986a --- /dev/null +++ b/web/src/components/AITradersPage.tsx @@ -0,0 +1,998 @@ +import React, { useState, useEffect } from 'react'; +import useSWR from 'swr'; +import { api } from '../lib/api'; +import type { TraderInfo, CreateTraderRequest, AIModel, Exchange } from '../types'; +import { useLanguage } from '../contexts/LanguageContext'; +import { t } from '../i18n/translations'; + +export function AITradersPage() { + const { language } = useLanguage(); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showModelModal, setShowModelModal] = useState(false); + const [showExchangeModal, setShowExchangeModal] = useState(false); + const [editingModel, setEditingModel] = useState(null); + const [editingExchange, setEditingExchange] = useState(null); + const [allModels, setAllModels] = useState([]); + const [allExchanges, setAllExchanges] = useState([]); + + const { data: traders, mutate: mutateTraders } = useSWR( + 'traders', + api.getTraders, + { refreshInterval: 5000 } + ); + + // 加载AI模型和交易所配置 + useEffect(() => { + const loadConfigs = async () => { + try { + const [modelConfigs, exchangeConfigs] = await Promise.all([ + api.getModelConfigs(), + api.getExchangeConfigs() + ]); + setAllModels(modelConfigs); + setAllExchanges(exchangeConfigs); + } catch (error) { + console.error('Failed to load configs:', error); + } + }; + loadConfigs(); + }, []); + + // 只显示已配置的模型和交易所 + const configuredModels = allModels.filter(m => m.enabled && m.apiKey); + const configuredExchanges = allExchanges.filter(e => e.enabled && e.apiKey && (e.id === 'hyperliquid' || e.secretKey)); + + // 检查模型是否正在被运行中的交易员使用 + const isModelInUse = (modelId: string) => { + return traders?.some(t => t.ai_model === modelId && t.is_running) || false; + }; + + // 检查交易所是否正在被运行中的交易员使用 + const isExchangeInUse = (exchangeId: string) => { + return traders?.some(t => t.exchange_id === exchangeId && t.is_running) || false; + }; + + const handleCreateTrader = async (modelId: string, exchangeId: string, name: string, initialBalance: number) => { + try { + const model = allModels.find(m => m.id === modelId); + const exchange = allExchanges.find(e => e.id === exchangeId); + + if (!model?.enabled) { + alert(t('modelNotConfigured', language)); + return; + } + + if (!exchange?.enabled) { + alert(t('exchangeNotConfigured', language)); + return; + } + + const request: CreateTraderRequest = { + name, + ai_model_id: modelId, + exchange_id: exchangeId, + initial_balance: initialBalance + }; + + await api.createTrader(request); + setShowCreateModal(false); + mutateTraders(); + } catch (error) { + console.error('Failed to create trader:', error); + alert('创建交易员失败'); + } + }; + + const handleDeleteTrader = async (traderId: string) => { + if (!confirm(t('confirmDeleteTrader', language))) return; + + try { + await api.deleteTrader(traderId); + mutateTraders(); + } catch (error) { + console.error('Failed to delete trader:', error); + alert('删除交易员失败'); + } + }; + + const handleToggleTrader = async (traderId: string, running: boolean) => { + try { + if (running) { + await api.stopTrader(traderId); + } else { + await api.startTrader(traderId); + } + mutateTraders(); + } catch (error) { + console.error('Failed to toggle trader:', error); + alert('操作失败'); + } + }; + + const handleModelClick = (modelId: string) => { + if (!isModelInUse(modelId)) { + setEditingModel(modelId); + setShowModelModal(true); + } + }; + + const handleExchangeClick = (exchangeId: string) => { + if (!isExchangeInUse(exchangeId)) { + setEditingExchange(exchangeId); + setShowExchangeModal(true); + } + }; + + const handleDeleteModelConfig = async (modelId: string) => { + if (!confirm('确定要删除此AI模型配置吗?')) return; + + try { + const updatedModels = allModels.map(m => + m.id === modelId ? { ...m, apiKey: '', enabled: false } : m + ); + + const request = { + models: Object.fromEntries( + updatedModels.map(model => [ + model.id, + { + enabled: model.enabled, + api_key: model.apiKey || '' + } + ]) + ) + }; + + await api.updateModelConfigs(request); + setAllModels(updatedModels); + setShowModelModal(false); + setEditingModel(null); + } catch (error) { + console.error('Failed to delete model config:', error); + alert('删除配置失败'); + } + }; + + const handleSaveModelConfig = async (modelId: string, apiKey: string) => { + try { + const updatedModels = allModels.map(m => + m.id === modelId ? { ...m, apiKey, enabled: true } : m + ); + + const request = { + models: Object.fromEntries( + updatedModels.map(model => [ + model.id, + { + enabled: model.enabled, + api_key: model.apiKey || '' + } + ]) + ) + }; + + await api.updateModelConfigs(request); + setAllModels(updatedModels); + setShowModelModal(false); + setEditingModel(null); + } catch (error) { + console.error('Failed to save model config:', error); + alert('保存配置失败'); + } + }; + + const handleDeleteExchangeConfig = async (exchangeId: string) => { + if (!confirm('确定要删除此交易所配置吗?')) return; + + try { + const updatedExchanges = allExchanges.map(e => + e.id === exchangeId ? { ...e, apiKey: '', secretKey: '', enabled: false } : e + ); + + const request = { + exchanges: Object.fromEntries( + updatedExchanges.map(exchange => [ + exchange.id, + { + enabled: exchange.enabled, + api_key: exchange.apiKey || '', + secret_key: exchange.secretKey || '', + testnet: exchange.testnet || false + } + ]) + ) + }; + + await api.updateExchangeConfigs(request); + setAllExchanges(updatedExchanges); + setShowExchangeModal(false); + setEditingExchange(null); + } catch (error) { + console.error('Failed to delete exchange config:', error); + alert('删除交易所配置失败'); + } + }; + + const handleSaveExchangeConfig = async (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean) => { + try { + const updatedExchanges = allExchanges.map(e => + e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, enabled: true } : e + ); + + const request = { + exchanges: Object.fromEntries( + updatedExchanges.map(exchange => [ + exchange.id, + { + enabled: exchange.enabled, + api_key: exchange.apiKey || '', + secret_key: exchange.secretKey || '', + testnet: exchange.testnet || false + } + ]) + ) + }; + + await api.updateExchangeConfigs(request); + setAllExchanges(updatedExchanges); + setShowExchangeModal(false); + setEditingExchange(null); + } catch (error) { + console.error('Failed to save exchange config:', error); + alert('保存交易所配置失败'); + } + }; + + const handleAddModel = () => { + setEditingModel(null); + setShowModelModal(true); + }; + + const handleAddExchange = () => { + setEditingExchange(null); + setShowExchangeModal(true); + }; + + return ( +
+ {/* Header */} +
+
+
+ 🤖 +
+
+

+ {t('aiTraders', language)} + + {traders?.length || 0} {t('active', language)} + +

+

+ {t('manageAITraders', language)} +

+
+
+ +
+ + + + + +
+
+ + {/* Configuration Status */} +
+ {/* AI Models */} +
+

+ 🧠 {t('aiModels', language)} +

+
+ {configuredModels.map(model => { + const inUse = isModelInUse(model.id); + return ( +
handleModelClick(model.id)} + > +
+
+ {model.name[0]} +
+
+
{model.name}
+
+ {t('configured', language)} +
+
+
+
+
+ ); + })} + {configuredModels.length === 0 && ( +
+
🧠
+
暂无已配置的AI模型
+
+ )} +
+
+ + {/* Exchanges */} +
+

+ 🏦 {t('exchanges', language)} +

+
+ {configuredExchanges.map(exchange => { + const inUse = isExchangeInUse(exchange.id); + return ( +
handleExchangeClick(exchange.id)} + > +
+
+ {exchange.name[0]} +
+
+
{exchange.name}
+
+ {exchange.type.toUpperCase()} • {t('configured', language)} +
+
+
+
+
+ ); + })} + {configuredExchanges.length === 0 && ( +
+
🏦
+
暂无已配置的交易所
+
+ )} +
+
+
+ + {/* Traders List */} +
+
+

+ 👥 {t('currentTraders', language)} +

+
+ + {traders && traders.length > 0 ? ( +
+ {traders.map(trader => ( +
+
+
+ 🤖 +
+
+
+ {trader.trader_name} +
+
+ {trader.ai_model.toUpperCase()} Model • {trader.exchange_id?.toUpperCase()} +
+
+
+ +
+ {/* Status */} +
+
{t('status', language)}
+
+ {trader.is_running ? t('running', language) : t('stopped', language)} +
+
+ + {/* Actions */} +
+ + + +
+
+
+ ))} +
+ ) : ( +
+
🤖
+
{t('noTraders', language)}
+
{t('createFirstTrader', language)}
+ {(configuredModels.length === 0 || configuredExchanges.length === 0) && ( +
+ {configuredModels.length === 0 && configuredExchanges.length === 0 + ? t('configureModelsAndExchangesFirst', language) + : configuredModels.length === 0 + ? t('configureModelsFirst', language) + : t('configureExchangesFirst', language) + } +
+ )} +
+ )} +
+ + {/* Create Trader Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + language={language} + /> + )} + + {/* Model Configuration Modal */} + {showModelModal && ( + { + setShowModelModal(false); + setEditingModel(null); + }} + language={language} + /> + )} + + {/* Exchange Configuration Modal */} + {showExchangeModal && ( + { + setShowExchangeModal(false); + setEditingExchange(null); + }} + language={language} + /> + )} +
+ ); +} + +// Create Trader Modal Component +function CreateTraderModal({ + enabledModels, + enabledExchanges, + onCreate, + onClose, + language +}: { + enabledModels: AIModel[]; + enabledExchanges: Exchange[]; + onCreate: (modelId: string, exchangeId: string, name: string, initialBalance: number) => void; + onClose: () => void; + language: any; +}) { + // 默认选择DeepSeek模型,如果没有启用则选择第一个 + const defaultModel = enabledModels.find(m => m.id === 'deepseek') || enabledModels[0]; + // 默认选择Binance交易所,如果没有启用则选择第一个 + const defaultExchange = enabledExchanges.find(e => e.id === 'binance') || enabledExchanges[0]; + + const [selectedModel, setSelectedModel] = useState(defaultModel?.id || ''); + const [selectedExchange, setSelectedExchange] = useState(defaultExchange?.id || ''); + const [traderName, setTraderName] = useState(''); + const [initialBalance, setInitialBalance] = useState(1000); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedModel || !selectedExchange || !traderName.trim()) return; + + onCreate(selectedModel, selectedExchange, traderName.trim(), initialBalance); + }; + + return ( +
+
+

+ {t('createNewTrader', language)} +

+ +
+
+ + +
+ +
+ + setTraderName(e.target.value)} + placeholder={t('enterTraderName', language)} + className="w-full px-3 py-2 rounded" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
+ +
+ + +
+ +
+ + setInitialBalance(Number(e.target.value))} + min="100" + max="100000" + className="w-full px-3 py-2 rounded" + style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
+ +
+ + +
+
+
+
+ ); +} + +// Model Configuration Modal Component +function ModelConfigModal({ + allModels, + editingModelId, + onSave, + onDelete, + onClose, + language +}: { + allModels: AIModel[]; + editingModelId: string | null; + onSave: (modelId: string, apiKey: string) => void; + onDelete: (modelId: string) => void; + onClose: () => void; + language: any; +}) { + const [selectedModelId, setSelectedModelId] = useState(editingModelId || ''); + const [apiKey, setApiKey] = useState(''); + + // 获取当前编辑的模型信息 + const selectedModel = allModels.find(m => m.id === selectedModelId); + + // 如果是编辑现有模型,初始化API Key + useEffect(() => { + if (editingModelId && selectedModel) { + setApiKey(selectedModel.apiKey || ''); + } + }, [editingModelId, selectedModel]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedModelId || !apiKey.trim()) return; + + onSave(selectedModelId, apiKey.trim()); + }; + + // 可选择的模型列表(排除已配置的,除非是当前编辑的) + const availableModels = allModels.filter(m => + !m.enabled || !m.apiKey || m.id === editingModelId + ); + + return ( +
+
+

+ {editingModelId ? '编辑AI模型' : '添加AI模型'} +

+ +
+ {!editingModelId && ( +
+ + +
+ )} + + {selectedModel && ( +
+
+
+ {selectedModel.name[0]} +
+
+
{selectedModel.name}
+
{selectedModel.provider}
+
+
+ +
+ + setApiKey(e.target.value)} + placeholder={`请输入 ${selectedModel.name} API Key`} + className="w-full px-3 py-2 rounded" + style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
+
+ )} + +
+ + {editingModelId && ( + + )} + +
+
+
+
+ ); +} + +// Exchange Configuration Modal Component +function ExchangeConfigModal({ + allExchanges, + editingExchangeId, + onSave, + onDelete, + onClose, + language +}: { + allExchanges: Exchange[]; + editingExchangeId: string | null; + onSave: (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean) => void; + onDelete: (exchangeId: string) => void; + onClose: () => void; + language: any; +}) { + const [selectedExchangeId, setSelectedExchangeId] = useState(editingExchangeId || ''); + const [apiKey, setApiKey] = useState(''); + const [secretKey, setSecretKey] = useState(''); + const [testnet, setTestnet] = useState(false); + + // 获取当前编辑的交易所信息 + const selectedExchange = allExchanges.find(e => e.id === selectedExchangeId); + + // 如果是编辑现有交易所,初始化表单数据 + useEffect(() => { + if (editingExchangeId && selectedExchange) { + setApiKey(selectedExchange.apiKey || ''); + setSecretKey(selectedExchange.secretKey || ''); + setTestnet(selectedExchange.testnet || false); + } + }, [editingExchangeId, selectedExchange]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedExchangeId || !apiKey.trim()) return; + if (selectedExchange?.id !== 'hyperliquid' && !secretKey.trim()) return; + + onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet); + }; + + // 可选择的交易所列表(排除已配置的,除非是当前编辑的) + const availableExchanges = allExchanges.filter(e => + !e.enabled || !e.apiKey || e.id === editingExchangeId + ); + + return ( +
+
+

+ {editingExchangeId ? '编辑交易所' : '添加交易所'} +

+ +
+ {!editingExchangeId && ( +
+ + +
+ )} + + {selectedExchange && ( +
+
+
+ {selectedExchange.name[0]} +
+
+
{selectedExchange.name}
+
{selectedExchange.type.toUpperCase()}
+
+
+ +
+
+ + setApiKey(e.target.value)} + placeholder={selectedExchange.id === 'hyperliquid' ? '请输入以太坊私钥' : `请输入 ${selectedExchange.name} API Key`} + className="w-full px-3 py-2 rounded" + style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
+ + {selectedExchange.id !== 'hyperliquid' && ( +
+ + setSecretKey(e.target.value)} + placeholder={`请输入 ${selectedExchange.name} Secret Key`} + className="w-full px-3 py-2 rounded" + style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} + required + /> +
+ )} + + {selectedExchange.type === 'dex' && ( +
+ setTestnet(e.target.checked)} + className="w-4 h-4" + /> + +
+ )} +
+
+ )} + +
+ + {editingExchangeId && ( + + )} + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/ComparisonChart.tsx b/web/src/components/ComparisonChart.tsx deleted file mode 100644 index 3fdc9dcc..00000000 --- a/web/src/components/ComparisonChart.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { useState, useEffect, useMemo } from 'react'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - ReferenceLine, - Legend, -} from 'recharts'; -import useSWR from 'swr'; -import { api } from '../lib/api'; -import type { CompetitionTraderData } from '../types'; - -interface ComparisonChartProps { - traders: CompetitionTraderData[]; -} - -export function ComparisonChart({ traders }: ComparisonChartProps) { - // 获取所有trader的历史数据 - const traderHistories = traders.map((trader) => { - // eslint-disable-next-line react-hooks/rules-of-hooks - return useSWR(`equity-history-${trader.trader_id}`, () => - api.getEquityHistory(trader.trader_id), - { refreshInterval: 10000 } - ); - }); - - // 使用useMemo自动处理数据合并,直接使用data对象作为依赖 - const combinedData = useMemo(() => { - // 等待所有数据加载完成 - const allLoaded = traderHistories.every((h) => h.data); - if (!allLoaded) return []; - - console.log(`[${new Date().toISOString()}] Recalculating chart data...`); - - // 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置) - // 收集所有时间戳 - const timestampMap = new Map; - }>(); - - traderHistories.forEach((history, index) => { - const trader = traders[index]; - if (!history.data) return; - - console.log(`Trader ${trader.trader_id}: ${history.data.length} data points`); - - history.data.forEach((point: any) => { - const ts = point.timestamp; - - if (!timestampMap.has(ts)) { - const time = new Date(ts).toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - }); - timestampMap.set(ts, { - timestamp: ts, - time, - traders: new Map() - }); - } - - timestampMap.get(ts)!.traders.set(trader.trader_id, { - pnl_pct: point.total_pnl_pct, - equity: point.total_equity - }); - }); - }); - - // 按时间戳排序,转换为数组 - const combined = Array.from(timestampMap.entries()) - .sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime()) - .map(([ts, data], index) => { - const entry: any = { - index: index + 1, // 使用序号代替cycle - time: data.time, - timestamp: ts - }; - - traders.forEach((trader) => { - const traderData = data.traders.get(trader.trader_id); - if (traderData) { - entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct; - entry[`${trader.trader_id}_equity`] = traderData.equity; - } - }); - - return entry; - }); - - if (combined.length > 0) { - const lastPoint = combined[combined.length - 1]; - console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`); - console.log('Last 3 points:', combined.slice(-3).map(p => ({ - time: p.time, - timestamp: p.timestamp, - deepseek: p.deepseek_trader_pnl_pct, - qwen: p.qwen_trader_pnl_pct - }))); - } - - return combined; - }, [ - traderHistories[0]?.data, - traderHistories[1]?.data, - ]); - - const isLoading = traderHistories.some((h) => !h.data); - - if (isLoading) { - return ( -
-
-
Loading comparison data...
-
- ); - } - - if (combinedData.length === 0) { - return ( -
-
📊
-
暂无历史数据
-
运行几个周期后将显示对比曲线
-
- ); - } - - // 限制显示数据点 - const MAX_DISPLAY_POINTS = 2000; - const displayData = - combinedData.length > MAX_DISPLAY_POINTS - ? combinedData.slice(-MAX_DISPLAY_POINTS) - : combinedData; - - // 计算Y轴范围 - const calculateYDomain = () => { - const allValues: number[] = []; - displayData.forEach((point) => { - traders.forEach((trader) => { - const value = point[`${trader.trader_id}_pnl_pct`]; - if (value !== undefined) { - allValues.push(value); - } - }); - }); - - if (allValues.length === 0) return [-5, 5]; - - const minVal = Math.min(...allValues); - const maxVal = Math.max(...allValues); - const range = Math.max(Math.abs(maxVal), Math.abs(minVal)); - const padding = Math.max(range * 0.2, 1); // 至少留1%余量 - - return [ - Math.floor(minVal - padding), - Math.ceil(maxVal + padding) - ]; - }; - - // Trader颜色配置 - 使用更鲜艳对比度更高的颜色 - const getTraderColor = (traderId: string) => { - const trader = traders.find((t) => t.trader_id === traderId); - if (trader?.ai_model === 'qwen') { - return '#c084fc'; // purple-400 (更亮) - } else { - return '#60a5fa'; // blue-400 (更亮) - } - }; - - // 自定义Tooltip - Binance Style - const CustomTooltip = ({ active, payload }: any) => { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-
- {data.time} - #{data.index} -
- {traders.map((trader) => { - const pnlPct = data[`${trader.trader_id}_pnl_pct`]; - const equity = data[`${trader.trader_id}_equity`]; - if (pnlPct === undefined) return null; - - return ( -
-
- {trader.trader_name} -
-
= 0 ? '#0ECB81' : '#F6465D' }}> - {pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}% - - ({equity?.toFixed(2)} USDT) - -
-
- ); - })} -
- ); - } - return null; - }; - - // 计算当前差距 - const currentGap = displayData.length > 0 ? (() => { - const lastPoint = displayData[displayData.length - 1]; - const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0); - return Math.abs(values[0] - values[1]); - })() : 0; - - return ( -
-
- - - - {traders.map((trader) => ( - - - - - ))} - - - - - - - `${value.toFixed(1)}%`} - width={60} - /> - - } /> - - - - {traders.map((trader, index) => ( - - ))} - - { - const traderId = traders.find((t) => value === t.trader_name)?.trader_id; - const trader = traders.find((t) => t.trader_id === traderId); - return ( - - {trader?.trader_name} ({trader?.ai_model.toUpperCase()}) - - ); - }} - /> - - -
- - {/* Stats */} -
-
-
对比模式
-
PnL %
-
-
-
数据点数
-
{combinedData.length} 个
-
-
-
当前差距
-
1 ? '#F0B90B' : '#EAECEF' }}> - {currentGap.toFixed(2)}% -
-
-
-
显示范围
-
- {combinedData.length > MAX_DISPLAY_POINTS - ? `最近 ${MAX_DISPLAY_POINTS}` - : '全部数据'} -
-
-
-
- ); -} diff --git a/web/src/components/CompetitionPage.tsx b/web/src/components/CompetitionPage.tsx deleted file mode 100644 index a72f6dfb..00000000 --- a/web/src/components/CompetitionPage.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import useSWR from 'swr'; -import { api } from '../lib/api'; -import type { CompetitionData } from '../types'; -import { ComparisonChart } from './ComparisonChart'; -import { useLanguage } from '../contexts/LanguageContext'; -import { t } from '../i18n/translations'; - -export function CompetitionPage() { - const { language } = useLanguage(); - const { data: competition } = useSWR( - 'competition', - api.getCompetition, - { - refreshInterval: 5000, - revalidateOnFocus: true, - } - ); - - if (!competition || !competition.traders) { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); - } - - // 按收益率排序 - const sortedTraders = [...competition.traders].sort( - (a, b) => b.total_pnl_pct - a.total_pnl_pct - ); - - // 找出领先者 - const leader = sortedTraders[0]; - - return ( -
- {/* Competition Header - 精简版 */} -
-
-
- 🏆 -
-
-

- {t('aiCompetition', language)} - - {competition.count} {t('traders', language)} - -

-

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

-
-
-
-
{t('leader', language)}
-
{leader?.trader_name}
-
= 0 ? '#0ECB81' : '#F6465D' }}> - {leader.total_pnl >= 0 ? '+' : ''}{leader.total_pnl_pct.toFixed(2)}% -
-
-
- - {/* Left/Right Split: Performance Chart + Leaderboard */} -
- {/* Left: Performance Comparison Chart */} -
-
-

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

-
- {t('realTimePnL', language)} -
-
- -
- - {/* Right: Leaderboard */} -
-
-

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

-
- {t('live', language)} -
-
-
- {sortedTraders.map((trader, index) => { - const isLeader = index === 0; - const aiModelColor = trader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa'; - - return ( -
-
- {/* Rank & Name */} -
-
- {index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'} -
-
-
{trader.trader_name}
-
- {trader.ai_model.toUpperCase()} -
-
-
- - {/* Stats */} -
- {/* Total Equity */} -
-
{t('equity', language)}
-
- {trader.total_equity.toFixed(2)} -
-
- - {/* P&L */} -
-
{t('pnl', language)}
-
= 0 ? '#0ECB81' : '#F6465D' }} - > - {trader.total_pnl >= 0 ? '+' : ''} - {trader.total_pnl_pct.toFixed(2)}% -
-
- {trader.total_pnl >= 0 ? '+' : ''}{trader.total_pnl.toFixed(2)} -
-
- - {/* Positions */} -
-
{t('pos', language)}
-
- {trader.position_count} -
-
- {trader.margin_used_pct.toFixed(1)}% -
-
- - {/* Status */} -
-
- {trader.is_running ? '●' : '○'} -
-
-
-
-
- ); - })} -
-
-
- - {/* Head-to-Head Stats */} - {competition.traders.length === 2 && ( -
-

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

-
- {sortedTraders.map((trader, index) => { - const isWinning = index === 0; - const opponent = sortedTraders[1 - index]; - const gap = trader.total_pnl_pct - opponent.total_pnl_pct; - - return ( -
-
-
- {trader.trader_name} -
-
= 0 ? '#0ECB81' : '#F6465D' }}> - {trader.total_pnl >= 0 ? '+' : ''}{trader.total_pnl_pct.toFixed(2)}% -
- {isWinning && gap > 0 && ( -
- {t('leadingBy', language, { gap: gap.toFixed(2) })} -
- )} - {!isWinning && gap < 0 && ( -
- {t('behindBy', language, { gap: Math.abs(gap).toFixed(2) })} -
- )} -
-
- ); - })} -
-
- )} -
- ); -} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 9f43ef4e..c5682568 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -3,15 +3,16 @@ export type Language = 'en' | 'zh'; export const translations = { en: { // Header - appTitle: 'AI Trading Competition', - subtitle: 'Qwen vs DeepSeek · Real-time', - competition: 'Competition', + appTitle: 'AI Trading System', + subtitle: 'Multi-AI Model Trading Platform', + aiTraders: 'AI Traders', details: 'Details', + tradingPanel: 'Trading Panel', running: 'RUNNING', stopped: 'STOPPED', // Footer - footerTitle: 'NOFX - AI Trading Competition System', + footerTitle: 'NOFX - AI Trading System', footerWarning: '⚠️ Trading involves risk. Use at your own discretion.', // Stats Cards @@ -114,6 +115,39 @@ export const translations = { aiLearningPoint3: 'Optimizes position sizing based on win rate', aiLearningPoint4: 'Avoids repeating past mistakes', + // AI Traders Management + manageAITraders: 'Manage your AI trading bots', + aiModels: 'AI Models', + exchanges: 'Exchanges', + createTrader: 'Create Trader', + modelConfiguration: 'Model Configuration', + configured: 'Configured', + notConfigured: 'Not Configured', + currentTraders: 'Current Traders', + noTraders: 'No AI Traders', + createFirstTrader: 'Create your first AI trader to get started', + configureModelsFirst: 'Please configure AI models first', + configureExchangesFirst: 'Please configure exchanges first', + configureModelsAndExchangesFirst: 'Please configure AI models and exchanges first', + modelNotConfigured: 'Selected model is not configured', + exchangeNotConfigured: 'Selected exchange is not configured', + confirmDeleteTrader: 'Are you sure you want to delete this trader?', + status: 'Status', + start: 'Start', + stop: 'Stop', + createNewTrader: 'Create New AI Trader', + selectAIModel: 'Select AI Model', + selectExchange: 'Select Exchange', + traderName: 'Trader Name', + enterTraderName: 'Enter trader name', + cancel: 'Cancel', + create: 'Create', + configureAIModels: 'Configure AI Models', + configureExchanges: 'Configure Exchanges', + useTestnet: 'Use Testnet', + enabled: 'Enabled', + save: 'Save', + // Loading & Error loading: 'Loading...', loadingError: '⚠️ Failed to load AI learning data', @@ -121,15 +155,16 @@ export const translations = { }, zh: { // Header - appTitle: 'AI交易竞赛', - subtitle: 'Qwen vs DeepSeek · 实时', - competition: '竞赛', + appTitle: 'AI交易系统', + subtitle: '多AI模型交易平台', + aiTraders: 'AI交易员', details: '详情', + tradingPanel: '交易面板', running: '运行中', stopped: '已停止', // Footer - footerTitle: 'NOFX - AI交易竞赛系统', + footerTitle: 'NOFX - AI交易系统', footerWarning: '⚠️ 交易有风险,请谨慎使用。', // Stats Cards @@ -232,6 +267,39 @@ export const translations = { aiLearningPoint3: '根据胜率优化仓位大小', aiLearningPoint4: '避免重复过去的错误', + // AI Traders Management + manageAITraders: '管理您的AI交易机器人', + aiModels: 'AI模型', + exchanges: '交易所', + createTrader: '创建交易员', + modelConfiguration: '模型配置', + configured: '已配置', + notConfigured: '未配置', + currentTraders: '当前交易员', + noTraders: '暂无AI交易员', + createFirstTrader: '创建您的第一个AI交易员开始使用', + configureModelsFirst: '请先配置AI模型', + configureExchangesFirst: '请先配置交易所', + configureModelsAndExchangesFirst: '请先配置AI模型和交易所', + modelNotConfigured: '所选模型未配置', + exchangeNotConfigured: '所选交易所未配置', + confirmDeleteTrader: '确定要删除这个交易员吗?', + status: '状态', + start: '启动', + stop: '停止', + createNewTrader: '创建新的AI交易员', + selectAIModel: '选择AI模型', + selectExchange: '选择交易所', + traderName: '交易员名称', + enterTraderName: '输入交易员名称', + cancel: '取消', + create: '创建', + configureAIModels: '配置AI模型', + configureExchanges: '配置交易所', + useTestnet: '使用测试网', + enabled: '启用', + save: '保存', + // Loading & Error loading: '加载中...', loadingError: '⚠️ 加载AI学习数据失败', diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 22d1b82d..d457755d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,25 +5,86 @@ import type { DecisionRecord, Statistics, TraderInfo, - CompetitionData, + AIModel, + Exchange, + CreateTraderRequest, + UpdateModelConfigRequest, + UpdateExchangeConfigRequest, } from '../types'; const API_BASE = '/api'; export const api = { - // 竞赛相关接口 - async getCompetition(): Promise { - const res = await fetch(`${API_BASE}/competition`); - if (!res.ok) throw new Error('获取竞赛数据失败'); - return res.json(); - }, - + // AI交易员管理接口 async getTraders(): Promise { const res = await fetch(`${API_BASE}/traders`); if (!res.ok) throw new Error('获取trader列表失败'); return res.json(); }, + async createTrader(request: CreateTraderRequest): Promise { + const res = await fetch(`${API_BASE}/traders`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + if (!res.ok) throw new Error('创建交易员失败'); + return res.json(); + }, + + async deleteTrader(traderId: string): Promise { + const res = await fetch(`${API_BASE}/traders/${traderId}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('删除交易员失败'); + }, + + async startTrader(traderId: string): Promise { + const res = await fetch(`${API_BASE}/traders/${traderId}/start`, { + method: 'POST', + }); + if (!res.ok) throw new Error('启动交易员失败'); + }, + + async stopTrader(traderId: string): Promise { + const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, { + method: 'POST', + }); + if (!res.ok) throw new Error('停止交易员失败'); + }, + + // AI模型配置接口 + async getModelConfigs(): Promise { + const res = await fetch(`${API_BASE}/models`); + if (!res.ok) throw new Error('获取模型配置失败'); + return res.json(); + }, + + async updateModelConfigs(request: UpdateModelConfigRequest): Promise { + const res = await fetch(`${API_BASE}/models`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + if (!res.ok) throw new Error('更新模型配置失败'); + }, + + // 交易所配置接口 + async getExchangeConfigs(): Promise { + const res = await fetch(`${API_BASE}/exchanges`); + if (!res.ok) throw new Error('获取交易所配置失败'); + return res.json(); + }, + + async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise { + const res = await fetch(`${API_BASE}/exchanges`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + if (!res.ok) throw new Error('更新交易所配置失败'); + }, + // 获取系统状态(支持trader_id) async getStatus(traderId?: string): Promise { const url = traderId diff --git a/web/src/types.ts b/web/src/types.ts index c61eca80..90d1e94f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -83,27 +83,55 @@ export interface Statistics { total_close_positions: number; } -// 新增:竞赛相关类型 +// AI Trading相关类型 export interface TraderInfo { trader_id: string; trader_name: string; ai_model: string; + is_running?: boolean; } -export interface CompetitionTraderData { - trader_id: string; - trader_name: string; - ai_model: string; - total_equity: number; - total_pnl: number; - total_pnl_pct: number; - position_count: number; - margin_used_pct: number; - call_count: number; - is_running: boolean; +export interface AIModel { + id: string; + name: string; + provider: string; + enabled: boolean; + apiKey?: string; } -export interface CompetitionData { - traders: CompetitionTraderData[]; - count: number; +export interface Exchange { + id: string; + name: string; + type: 'cex' | 'dex'; + enabled: boolean; + apiKey?: string; + secretKey?: string; + testnet?: boolean; +} + +export interface CreateTraderRequest { + name: string; + ai_model_id: string; + exchange_id: string; + initial_balance: number; +} + +export interface UpdateModelConfigRequest { + models: { + [key: string]: { + enabled: boolean; + api_key: string; + }; + }; +} + +export interface UpdateExchangeConfigRequest { + exchanges: { + [key: string]: { + enabled: boolean; + api_key: string; + secret_key: string; + testnet?: boolean; + }; + }; } diff --git a/web/vite.config.ts b/web/vite.config.ts index 3e35b475..576ccf6c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ port: 3000, proxy: { '/api': { - target: 'http://localhost:8080', + target: 'http://localhost:8081', changeOrigin: true, }, }, From d3e7b7dbb14bf1e40e524eaa344e39727330226e Mon Sep 17 00:00:00 2001 From: icy Date: Fri, 31 Oct 2025 03:42:01 +0800 Subject: [PATCH 2/3] =?UTF-8?q?account=20system=E3=80=81custom=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DOCKER_DEPLOY.md | 51 ++- api/server.go | 474 ++++++++++++++++++-- auth/auth.go | 121 +++++ config.json.example | 80 ---- config/database.go | 503 ++++++++++++++++++--- decision/engine.go | 34 +- go.mod | 7 +- go.sum | 6 + main.go | 30 +- manager/trader_manager.go | 280 +++++++++++- trader/auto_trader.go | 29 +- web/public/icons/aster.svg | 23 + web/public/icons/binance.svg | 1 + web/public/icons/deepseek.svg | 1 + web/public/icons/hypeliquid.svg | 3 + web/public/icons/qwen.svg | 1 + web/src/App.tsx | 293 +++++++++---- web/src/components/AITradersPage.tsx | 586 ++++++++++++++++++------- web/src/components/ComparisonChart.tsx | 344 +++++++++++++++ web/src/components/CompetitionPage.tsx | 249 +++++++++++ web/src/components/ExchangeIcons.tsx | 120 +++++ web/src/components/Header.tsx | 58 +++ web/src/components/LoginPage.tsx | 195 ++++++++ web/src/components/ModelIcons.tsx | 36 ++ web/src/components/RegisterPage.tsx | 311 +++++++++++++ web/src/contexts/AuthContext.tsx | 209 +++++++++ web/src/hooks/useSystemConfig.ts | 29 ++ web/src/i18n/translations.ts | 104 +++++ web/src/lib/api.ts | 97 +++- web/src/lib/config.ts | 27 ++ web/src/types.ts | 34 ++ web/vite.config.ts | 2 +- 32 files changed, 3873 insertions(+), 465 deletions(-) create mode 100644 auth/auth.go delete mode 100644 config.json.example create mode 100644 web/public/icons/aster.svg create mode 100644 web/public/icons/binance.svg create mode 100644 web/public/icons/deepseek.svg create mode 100644 web/public/icons/hypeliquid.svg create mode 100644 web/public/icons/qwen.svg create mode 100644 web/src/components/ComparisonChart.tsx create mode 100644 web/src/components/CompetitionPage.tsx create mode 100644 web/src/components/ExchangeIcons.tsx create mode 100644 web/src/components/Header.tsx create mode 100644 web/src/components/LoginPage.tsx create mode 100644 web/src/components/ModelIcons.tsx create mode 100644 web/src/components/RegisterPage.tsx create mode 100644 web/src/contexts/AuthContext.tsx create mode 100644 web/src/hooks/useSystemConfig.ts create mode 100644 web/src/lib/config.ts diff --git a/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md index 536ee159..b1245b4d 100644 --- a/DOCKER_DEPLOY.md +++ b/DOCKER_DEPLOY.md @@ -64,23 +64,23 @@ nano config.json # 或使用其他编辑器 **必须配置的字段:** ```json { - "traders": [ - { - "id": "my_trader", - "name": "My AI Trader", - "ai_model": "deepseek", - "binance_api_key": "YOUR_BINANCE_API_KEY", // ← 填入你的币安 API Key - "binance_secret_key": "YOUR_BINANCE_SECRET_KEY", // ← 填入你的币安 Secret Key - "deepseek_key": "YOUR_DEEPSEEK_API_KEY", // ← 填入你的 DeepSeek API Key - "initial_balance": 1000.0, - "scan_interval_minutes": 3 - } - ], "use_default_coins": true, - "api_server_port": 8080 + "api_server_port": 8081, + "jwt_secret": "YOUR_JWT_SECRET_CHANGE_IN_PRODUCTION" // ← 填入一个长随机字符串作为JWT密钥 } ``` +> **⚠️ 重要安全提醒**: +> - `jwt_secret` 字段是用户认证系统的关键安全配置 +> - **必须设置一个长度至少32位的随机字符串** +> - 在生产环境中,建议使用64位以上的随机字符串 +> - 可以使用命令生成:`openssl rand -base64 64` + +**配置说明:** +- 🔐 **用户认证**:系统现在支持用户注册登录,每个用户都有独立的AI模型和交易所配置 +- 🚫 **移除traders配置**:不再需要在config.json中预配置交易员,用户可以通过Web界面创建 +- 🔑 **JWT密钥**:用于保护用户会话安全,强烈建议在生产环境中设置复杂密钥 + ### 第 2 步:一键启动 ```bash @@ -310,23 +310,38 @@ docker system prune -a --volumes ## 🔐 安全建议 -1. **不要将 config.json 提交到 Git** +1. **JWT密钥安全配置** + ```bash + # 生成强随机JWT密钥 + openssl rand -base64 64 + + # 或者使用其他工具生成 + head -c 64 /dev/urandom | base64 + ``` + + **JWT密钥要求:** + - 长度至少32位,推荐64位以上 + - 使用随机生成的字符串 + - 在生产环境中绝不使用默认值 + - 定期更换(会使现有用户需要重新登录) + +2. **不要将 config.json 提交到 Git** ```bash # 确保 config.json 在 .gitignore 中 echo "config.json" >> .gitignore ``` -2. **使用环境变量存储敏感信息** +3. **使用环境变量存储敏感信息** ```yaml # docker-compose.yml services: backend: environment: - - BINANCE_API_KEY=${BINANCE_API_KEY} - - BINANCE_SECRET_KEY=${BINANCE_SECRET_KEY} + - JWT_SECRET=${JWT_SECRET} + # 用户的API密钥现在通过Web界面配置,不再需要环境变量 ``` -3. **限制 API 访问** +4. **限制 API 访问** ```yaml # 只允许本地访问 services: diff --git a/api/server.go b/api/server.go index c82c7bc1..1cac868f 100644 --- a/api/server.go +++ b/api/server.go @@ -4,11 +4,14 @@ import ( "fmt" "log" "net/http" + "nofx/auth" "nofx/config" "nofx/manager" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" ) // Server HTTP API服务器 @@ -66,30 +69,51 @@ func (s *Server) setupRoutes() { // API路由组 api := s.router.Group("/api") { - // AI交易员管理 - api.GET("/traders", s.handleTraderList) - api.POST("/traders", s.handleCreateTrader) - api.DELETE("/traders/:id", s.handleDeleteTrader) - api.POST("/traders/:id/start", s.handleStartTrader) - api.POST("/traders/:id/stop", s.handleStopTrader) + // 认证相关路由(无需认证) + api.POST("/register", s.handleRegister) + api.POST("/login", s.handleLogin) + api.POST("/verify-otp", s.handleVerifyOTP) + api.POST("/complete-registration", s.handleCompleteRegistration) + + // 系统支持的模型和交易所(无需认证) + api.GET("/supported-models", s.handleGetSupportedModels) + api.GET("/supported-exchanges", s.handleGetSupportedExchanges) + + // 系统配置(无需认证) + api.GET("/config", s.handleGetSystemConfig) - // AI模型配置 - api.GET("/models", s.handleGetModelConfigs) - api.PUT("/models", s.handleUpdateModelConfigs) + // 需要认证的路由 + protected := api.Group("/", s.authMiddleware()) + { + // AI交易员管理 + protected.GET("/traders", s.handleTraderList) + protected.POST("/traders", s.handleCreateTrader) + protected.DELETE("/traders/:id", s.handleDeleteTrader) + protected.POST("/traders/:id/start", s.handleStartTrader) + protected.POST("/traders/:id/stop", s.handleStopTrader) + protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt) - // 交易所配置 - api.GET("/exchanges", s.handleGetExchangeConfigs) - api.PUT("/exchanges", s.handleUpdateExchangeConfigs) + // AI模型配置 + protected.GET("/models", s.handleGetModelConfigs) + protected.PUT("/models", s.handleUpdateModelConfigs) - // 指定trader的数据(使用query参数 ?trader_id=xxx) - api.GET("/status", s.handleStatus) - api.GET("/account", s.handleAccount) - api.GET("/positions", s.handlePositions) - api.GET("/decisions", s.handleDecisions) - api.GET("/decisions/latest", s.handleLatestDecisions) - api.GET("/statistics", s.handleStatistics) - api.GET("/equity-history", s.handleEquityHistory) - api.GET("/performance", s.handlePerformance) + // 交易所配置 + protected.GET("/exchanges", s.handleGetExchangeConfigs) + protected.PUT("/exchanges", s.handleUpdateExchangeConfigs) + + // 竞赛总览 + protected.GET("/competition", s.handleCompetition) + + // 指定trader的数据(使用query参数 ?trader_id=xxx) + protected.GET("/status", s.handleStatus) + protected.GET("/account", s.handleAccount) + protected.GET("/positions", s.handlePositions) + protected.GET("/decisions", s.handleDecisions) + protected.GET("/decisions/latest", s.handleLatestDecisions) + protected.GET("/statistics", s.handleStatistics) + protected.GET("/equity-history", s.handleEquityHistory) + protected.GET("/performance", s.handlePerformance) + } } } @@ -101,17 +125,40 @@ func (s *Server) handleHealth(c *gin.Context) { }) } +// handleGetSystemConfig 获取系统配置(客户端需要知道的配置) +func (s *Server) handleGetSystemConfig(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "admin_mode": auth.IsAdminMode(), + }) +} + // getTraderFromQuery 从query参数获取trader func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) { + userID := c.GetString("user_id") traderID := c.Query("trader_id") + + // 确保用户的交易员已加载到内存中 + err := s.traderManager.LoadUserTraders(s.database, userID) + if err != nil { + log.Printf("⚠️ 加载用户 %s 的交易员失败: %v", userID, err) + } + if traderID == "" { - // 如果没有指定trader_id,返回第一个trader + // 如果没有指定trader_id,返回该用户的第一个trader ids := s.traderManager.GetTraderIDs() if len(ids) == 0 { return nil, "", fmt.Errorf("没有可用的trader") } - traderID = ids[0] + + // 获取用户的交易员列表,优先返回用户自己的交易员 + userTraders, err := s.database.GetTraders(userID) + if err == nil && len(userTraders) > 0 { + traderID = userTraders[0].ID + } else { + traderID = ids[0] + } } + return s.traderManager, traderID, nil } @@ -121,6 +168,8 @@ type CreateTraderRequest struct { AIModelID string `json:"ai_model_id" binding:"required"` ExchangeID string `json:"exchange_id" binding:"required"` InitialBalance float64 `json:"initial_balance"` + CustomPrompt string `json:"custom_prompt"` + OverrideBasePrompt bool `json:"override_base_prompt"` } type ModelConfig struct { @@ -150,15 +199,20 @@ type UpdateModelConfigRequest struct { type UpdateExchangeConfigRequest struct { Exchanges map[string]struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - SecretKey string `json:"secret_key"` - Testnet bool `json:"testnet"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + SecretKey string `json:"secret_key"` + Testnet bool `json:"testnet"` + HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` + AsterUser string `json:"aster_user"` + AsterSigner string `json:"aster_signer"` + AsterPrivateKey string `json:"aster_private_key"` } `json:"exchanges"` } // handleCreateTrader 创建新的AI交易员 func (s *Server) handleCreateTrader(c *gin.Context) { + userID := c.GetString("user_id") var req CreateTraderRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -171,10 +225,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) { // 创建交易员配置 trader := &config.TraderConfig{ ID: traderID, + UserID: userID, Name: req.Name, AIModelID: req.AIModelID, ExchangeID: req.ExchangeID, InitialBalance: req.InitialBalance, + CustomPrompt: req.CustomPrompt, + OverrideBasePrompt: req.OverrideBasePrompt, ScanIntervalMinutes: 3, // 默认3分钟 IsRunning: false, } @@ -186,6 +243,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) { return } + // 立即将新交易员加载到TraderManager中 + err = s.traderManager.LoadUserTraders(s.database, userID) + if err != nil { + log.Printf("⚠️ 加载用户交易员到内存失败: %v", err) + // 这里不返回错误,因为交易员已经成功创建到数据库 + } + log.Printf("✓ 创建交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID) c.JSON(http.StatusCreated, gin.H{ @@ -198,10 +262,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) { // handleDeleteTrader 删除交易员 func (s *Server) handleDeleteTrader(c *gin.Context) { + userID := c.GetString("user_id") traderID := c.Param("id") // 从数据库删除 - err := s.database.DeleteTrader(traderID) + err := s.database.DeleteTrader(userID, traderID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("删除交易员失败: %v", err)}) return @@ -246,7 +311,8 @@ func (s *Server) handleStartTrader(c *gin.Context) { }() // 更新数据库中的运行状态 - err = s.database.UpdateTraderStatus(traderID, true) + userID := c.GetString("user_id") + err = s.database.UpdateTraderStatus(userID, traderID, true) if err != nil { log.Printf("⚠️ 更新交易员状态失败: %v", err) } @@ -276,7 +342,8 @@ func (s *Server) handleStopTrader(c *gin.Context) { trader.Stop() // 更新数据库中的运行状态 - err = s.database.UpdateTraderStatus(traderID, false) + userID := c.GetString("user_id") + err = s.database.UpdateTraderStatus(userID, traderID, false) if err != nil { log.Printf("⚠️ 更新交易员状态失败: %v", err) } @@ -285,19 +352,57 @@ func (s *Server) handleStopTrader(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "交易员已停止"}) } +// handleUpdateTraderPrompt 更新交易员自定义Prompt +func (s *Server) handleUpdateTraderPrompt(c *gin.Context) { + traderID := c.Param("id") + userID := c.GetString("user_id") + + var req struct { + CustomPrompt string `json:"custom_prompt"` + OverrideBasePrompt bool `json:"override_base_prompt"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 更新数据库 + err := s.database.UpdateTraderCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新自定义prompt失败: %v", err)}) + return + } + + // 如果trader在内存中,更新其custom prompt和override设置 + trader, err := s.traderManager.GetTrader(traderID) + if err == nil { + trader.SetCustomPrompt(req.CustomPrompt) + trader.SetOverrideBasePrompt(req.OverrideBasePrompt) + log.Printf("✓ 已更新交易员 %s 的自定义prompt (覆盖基础=%v)", trader.GetName(), req.OverrideBasePrompt) + } + + c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"}) +} + // handleGetModelConfigs 获取AI模型配置 func (s *Server) handleGetModelConfigs(c *gin.Context) { - models, err := s.database.GetAIModels() + userID := c.GetString("user_id") + log.Printf("🔍 查询用户 %s 的AI模型配置", userID) + models, err := s.database.GetAIModels(userID) if err != nil { + log.Printf("❌ 获取AI模型配置失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取AI模型配置失败: %v", err)}) return } + log.Printf("✅ 找到 %d 个AI模型配置", len(models)) c.JSON(http.StatusOK, models) } // handleUpdateModelConfigs 更新AI模型配置 func (s *Server) handleUpdateModelConfigs(c *gin.Context) { + userID := c.GetString("user_id") var req UpdateModelConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -306,7 +411,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { // 更新每个模型的配置 for modelID, modelData := range req.Models { - err := s.database.UpdateAIModel(modelID, modelData.Enabled, modelData.APIKey) + err := s.database.UpdateAIModel(userID, modelID, modelData.Enabled, modelData.APIKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新模型 %s 失败: %v", modelID, err)}) return @@ -319,17 +424,22 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { // handleGetExchangeConfigs 获取交易所配置 func (s *Server) handleGetExchangeConfigs(c *gin.Context) { - exchanges, err := s.database.GetExchanges() + userID := c.GetString("user_id") + log.Printf("🔍 查询用户 %s 的交易所配置", userID) + exchanges, err := s.database.GetExchanges(userID) if err != nil { + log.Printf("❌ 获取交易所配置失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易所配置失败: %v", err)}) return } + log.Printf("✅ 找到 %d 个交易所配置", len(exchanges)) c.JSON(http.StatusOK, exchanges) } // handleUpdateExchangeConfigs 更新交易所配置 func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { + userID := c.GetString("user_id") var req UpdateExchangeConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -338,7 +448,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // 更新每个交易所的配置 for exchangeID, exchangeData := range req.Exchanges { - err := s.database.UpdateExchange(exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet) + err := s.database.UpdateExchange(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)}) return @@ -351,7 +461,8 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // handleTraderList trader列表 func (s *Server) handleTraderList(c *gin.Context) { - traders, err := s.database.GetTraders() + userID := c.GetString("user_id") + traders, err := s.database.GetTraders(userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易员列表失败: %v", err)}) return @@ -539,6 +650,27 @@ func (s *Server) handleStatistics(c *gin.Context) { c.JSON(http.StatusOK, stats) } +// handleCompetition 竞赛总览(对比所有trader) +func (s *Server) handleCompetition(c *gin.Context) { + userID := c.GetString("user_id") + + // 确保用户的交易员已加载到内存中 + err := s.traderManager.LoadUserTraders(s.database, userID) + if err != nil { + log.Printf("⚠️ 加载用户 %s 的交易员失败: %v", userID, err) + } + + competition, err := s.traderManager.GetCompetitionData(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("获取竞赛数据失败: %v", err), + }) + return + } + + c.JSON(http.StatusOK, competition) +} + // handleEquityHistory 收益率历史数据 func (s *Server) handleEquityHistory(c *gin.Context) { _, traderID, err := s.getTraderFromQuery(c) @@ -652,6 +784,278 @@ func (s *Server) handlePerformance(c *gin.Context) { c.JSON(http.StatusOK, performance) } +// authMiddleware JWT认证中间件 +func (s *Server) authMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 如果是管理员模式,直接使用admin用户 + if auth.IsAdminMode() { + c.Set("user_id", "admin") + c.Set("email", "admin@localhost") + c.Next() + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少Authorization头"}) + c.Abort() + return + } + + // 检查Bearer token格式 + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的Authorization格式"}) + c.Abort() + return + } + + // 验证JWT token + claims, err := auth.ValidateJWT(tokenParts[1]) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token: " + err.Error()}) + c.Abort() + return + } + + // 将用户信息存储到上下文中 + c.Set("user_id", claims.UserID) + c.Set("email", claims.Email) + c.Next() + } +} + +// handleRegister 处理用户注册请求 +func (s *Server) handleRegister(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 检查邮箱是否已存在 + _, err := s.database.GetUserByEmail(req.Email) + if err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"}) + return + } + + // 生成密码哈希 + passwordHash, err := auth.HashPassword(req.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码处理失败"}) + return + } + + // 生成OTP密钥 + otpSecret, err := auth.GenerateOTPSecret() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OTP密钥生成失败"}) + return + } + + // 创建用户(未验证OTP状态) + userID := uuid.New().String() + user := &config.User{ + ID: userID, + Email: req.Email, + PasswordHash: passwordHash, + OTPSecret: otpSecret, + OTPVerified: false, + } + + err = s.database.CreateUser(user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败: " + err.Error()}) + return + } + + // 返回OTP设置信息 + qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email) + c.JSON(http.StatusOK, gin.H{ + "user_id": userID, + "email": req.Email, + "otp_secret": otpSecret, + "qr_code_url": qrCodeURL, + "message": "请使用Google Authenticator扫描二维码并验证OTP", + }) +} + +// handleCompleteRegistration 完成注册(验证OTP) +func (s *Server) handleCompleteRegistration(c *gin.Context) { + var req struct { + UserID string `json:"user_id" binding:"required"` + OTPCode string `json:"otp_code" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 获取用户信息 + user, err := s.database.GetUserByID(req.UserID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + // 验证OTP + if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "OTP验证码错误"}) + return + } + + // 更新用户OTP验证状态 + err = s.database.UpdateUserOTPVerified(req.UserID, true) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户状态失败"}) + return + } + + // 生成JWT token + token, err := auth.GenerateJWT(user.ID, user.Email) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"}) + return + } + + // 初始化用户的默认模型和交易所配置 + err = s.initUserDefaultConfigs(user.ID) + if err != nil { + log.Printf("初始化用户默认配置失败: %v", err) + } + + c.JSON(http.StatusOK, gin.H{ + "token": token, + "user_id": user.ID, + "email": user.Email, + "message": "注册完成", + }) +} + +// handleLogin 处理用户登录请求 +func (s *Server) handleLogin(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 获取用户信息 + user, err := s.database.GetUserByEmail(req.Email) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"}) + return + } + + // 验证密码 + if !auth.CheckPassword(req.Password, user.PasswordHash) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"}) + return + } + + // 检查OTP是否已验证 + if !user.OTPVerified { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "账户未完成OTP设置", + "user_id": user.ID, + "requires_otp_setup": true, + }) + return + } + + // 返回需要OTP验证的状态 + c.JSON(http.StatusOK, gin.H{ + "user_id": user.ID, + "email": user.Email, + "message": "请输入Google Authenticator验证码", + "requires_otp": true, + }) +} + +// handleVerifyOTP 验证OTP并完成登录 +func (s *Server) handleVerifyOTP(c *gin.Context) { + var req struct { + UserID string `json:"user_id" binding:"required"` + OTPCode string `json:"otp_code" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 获取用户信息 + user, err := s.database.GetUserByID(req.UserID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + // 验证OTP + if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) { + c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"}) + return + } + + // 生成JWT token + token, err := auth.GenerateJWT(user.ID, user.Email) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "token": token, + "user_id": user.ID, + "email": user.Email, + "message": "登录成功", + }) +} + +// initUserDefaultConfigs 为新用户初始化默认的模型和交易所配置 +func (s *Server) initUserDefaultConfigs(userID string) error { + // 注释掉自动创建默认配置,让用户手动添加 + // 这样新用户注册后不会自动有配置项 + log.Printf("用户 %s 注册完成,等待手动配置AI模型和交易所", userID) + return nil +} + +// handleGetSupportedModels 获取系统支持的AI模型列表 +func (s *Server) handleGetSupportedModels(c *gin.Context) { + // 返回系统支持的AI模型(从default用户获取) + models, err := s.database.GetAIModels("default") + if err != nil { + log.Printf("❌ 获取支持的AI模型失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取支持的AI模型失败"}) + return + } + + c.JSON(http.StatusOK, models) +} + +// handleGetSupportedExchanges 获取系统支持的交易所列表 +func (s *Server) handleGetSupportedExchanges(c *gin.Context) { + // 返回系统支持的交易所(从default用户获取) + exchanges, err := s.database.GetExchanges("default") + if err != nil { + log.Printf("❌ 获取支持的交易所失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取支持的交易所失败"}) + return + } + + c.JSON(http.StatusOK, exchanges) +} + // Start 启动服务器 func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 00000000..685d08e6 --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,121 @@ +package auth + +import ( + "crypto/rand" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/pquerna/otp/totp" + "golang.org/x/crypto/bcrypt" +) + +// JWTSecret JWT密钥,将从配置中动态设置 +var JWTSecret []byte + +// AdminMode 管理员模式标志 +var AdminMode bool = false + +// OTPIssuer OTP发行者名称 +const OTPIssuer = "nofxAI" + +// SetJWTSecret 设置JWT密钥 +func SetJWTSecret(secret string) { + JWTSecret = []byte(secret) +} + +// SetAdminMode 设置管理员模式 +func SetAdminMode(enabled bool) { + AdminMode = enabled +} + +// IsAdminMode 检查是否为管理员模式 +func IsAdminMode() bool { + return AdminMode +} + +// Claims JWT声明 +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +// HashPassword 哈希密码 +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPassword 验证密码 +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// GenerateOTPSecret 生成OTP密钥 +func GenerateOTPSecret() (string, error) { + secret := make([]byte, 20) + _, err := rand.Read(secret) + if err != nil { + return "", err + } + + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: OTPIssuer, + AccountName: uuid.New().String(), + }) + if err != nil { + return "", err + } + + return key.Secret(), nil +} + +// VerifyOTP 验证OTP码 +func VerifyOTP(secret, code string) bool { + return totp.Validate(code, secret) +} + +// GenerateJWT 生成JWT token +func GenerateJWT(userID, email string) (string, error) { + claims := Claims{ + UserID: userID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 24小时过期 + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "nofxAI", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(JWTSecret) +} + +// ValidateJWT 验证JWT token +func ValidateJWT(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("意外的签名方法: %v", token.Header["alg"]) + } + return JWTSecret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("无效的token") +} + +// GetOTPQRCodeURL 获取OTP二维码URL +func GetOTPQRCodeURL(secret, email string) string { + return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer) +} \ No newline at end of file diff --git a/config.json.example b/config.json.example deleted file mode 100644 index 8f41523e..00000000 --- a/config.json.example +++ /dev/null @@ -1,80 +0,0 @@ -{ - "traders": [ - { - "id": "hyperliquid_deepseek", - "name": "Hyperliquid DeepSeek Trader", - "ai_model": "deepseek", - "exchange": "hyperliquid", - "hyperliquid_private_key": "your_ethereum_private_key_without_0x_prefix", - "hyperliquid_wallet_addr": "your_ethereum_address", - "hyperliquid_testnet": false, - "deepseek_key": "your_deepseek_api_key", - "initial_balance": 1000, - "scan_interval_minutes": 3 - }, - { - "id": "binance_qwen", - "name": "Binance Qwen Trader", - "ai_model": "qwen", - "exchange": "binance", - "binance_api_key": "your_binance_api_key", - "binance_secret_key": "your_binance_secret_key", - "qwen_key": "your_qwen_api_key", - "initial_balance": 1000, - "scan_interval_minutes": 3 - }, - { - "id": "binance_custom", - "name": "Binance Custom API Trader", - "ai_model": "custom", - "exchange": "binance", - "binance_api_key": "your_binance_api_key", - "binance_secret_key": "your_binance_secret_key", - "custom_api_url": "https://api.openai.com/v1", - "custom_api_key": "sk-your-api-key", - "custom_model_name": "gpt-4o", - "initial_balance": 1000, - "scan_interval_minutes": 3 - }, - { - "id": "aster_deepseek", - "name": "Aster DeepSeek Trader", - "ai_model": "deepseek", - "exchange": "aster", - - // 注意请仔细阅读这三个提示 请进入https://www.asterdex.com/en/api-wallet网站 -> 选择专业api -> 创建新api获取以下信息 - // user: 主钱包地址 (登录地址/连接到aster的钱包地址) - // signer: API钱包地址 (点击生成地址后生成的地址) - // privateKey: API钱包私钥 (生成地址对应的私钥) - - "aster_user": "0x63DD5aCC6b1aa0f563956C0e534DD30B6dcF7C4e", - "aster_signer": "0x21cF8Ae13Bb72632562c6Fff438652Ba1a151bb0", - "aster_private_key": "your_aster_api_wallet_private_key_without_0x_prefix", - - "deepseek_key": "your_deepseek_api_key", - "initial_balance": 1000.0, - "scan_interval_minutes": 3 - } - ], - "leverage": { - "btc_eth_leverage": 5, - "altcoin_leverage": 5 - }, - "use_default_coins": true, - "default_coins": [ - "BTCUSDT", - "ETHUSDT", - "SOLUSDT", - "BNBUSDT", - "XRPUSDT", - "DOGEUSDT", - "ADAUSDT", - "HYPEUSDT", - ], - "coin_pool_api_url": "", - "oi_top_api_url": "", - "api_server_port": 8080, - "max_daily_loss": 10.0, - "max_drawdown": 20.0, - "stop_trading_minutes": 60 -} diff --git a/config/database.go b/config/database.go index 8e1c193a..dd40866c 100644 --- a/config/database.go +++ b/config/database.go @@ -1,8 +1,11 @@ package config import ( + "crypto/rand" "database/sql" + "encoding/base32" "fmt" + "log" "time" _ "github.com/mattn/go-sqlite3" @@ -38,30 +41,41 @@ func (d *Database) createTables() error { // AI模型配置表 `CREATE TABLE IF NOT EXISTS ai_models ( id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT 'default', name TEXT NOT NULL, provider TEXT NOT NULL, enabled BOOLEAN DEFAULT 0, api_key TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, // 交易所配置表 `CREATE TABLE IF NOT EXISTS exchanges ( id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT 'default', name TEXT NOT NULL, type TEXT NOT NULL, -- 'cex' or 'dex' enabled BOOLEAN DEFAULT 0, api_key TEXT DEFAULT '', secret_key TEXT DEFAULT '', testnet BOOLEAN DEFAULT 0, + -- Hyperliquid 特定字段 + hyperliquid_wallet_addr TEXT DEFAULT '', + -- Aster 特定字段 + aster_user TEXT DEFAULT '', + aster_signer TEXT DEFAULT '', + aster_private_key TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE )`, // 交易员配置表 `CREATE TABLE IF NOT EXISTS traders ( id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT 'default', name TEXT NOT NULL, ai_model_id TEXT NOT NULL, exchange_id TEXT NOT NULL, @@ -70,10 +84,22 @@ func (d *Database) createTables() error { is_running BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (ai_model_id) REFERENCES ai_models(id), FOREIGN KEY (exchange_id) REFERENCES exchanges(id) )`, + // 用户表 + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + otp_secret TEXT, + otp_verified BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + // 系统配置表 `CREATE TABLE IF NOT EXISTS system_config ( key TEXT PRIMARY KEY, @@ -82,6 +108,12 @@ func (d *Database) createTables() error { )`, // 触发器:自动更新 updated_at + `CREATE TRIGGER IF NOT EXISTS update_users_updated_at + AFTER UPDATE ON users + BEGIN + UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END`, + `CREATE TRIGGER IF NOT EXISTS update_ai_models_updated_at AFTER UPDATE ON ai_models BEGIN @@ -113,12 +145,33 @@ func (d *Database) createTables() error { } } + // 为现有数据库添加新字段(向后兼容) + alterQueries := []string{ + `ALTER TABLE exchanges ADD COLUMN hyperliquid_wallet_addr TEXT DEFAULT ''`, + `ALTER TABLE exchanges ADD COLUMN aster_user TEXT DEFAULT ''`, + `ALTER TABLE exchanges ADD COLUMN aster_signer TEXT DEFAULT ''`, + `ALTER TABLE exchanges ADD COLUMN aster_private_key TEXT DEFAULT ''`, + `ALTER TABLE traders ADD COLUMN custom_prompt TEXT DEFAULT ''`, + `ALTER TABLE traders ADD COLUMN override_base_prompt BOOLEAN DEFAULT 0`, + } + + for _, query := range alterQueries { + // 忽略已存在字段的错误 + d.db.Exec(query) + } + + // 检查是否需要迁移exchanges表的主键结构 + err := d.migrateExchangesTable() + if err != nil { + log.Printf("⚠️ 迁移exchanges表失败: %v", err) + } + return nil } // initDefaultData 初始化默认数据 func (d *Database) initDefaultData() error { - // 初始化AI模型 + // 初始化AI模型(使用default用户) aiModels := []struct { id, name, provider string }{ @@ -128,26 +181,27 @@ func (d *Database) initDefaultData() error { for _, model := range aiModels { _, err := d.db.Exec(` - INSERT OR IGNORE INTO ai_models (id, name, provider, enabled) - VALUES (?, ?, ?, 0) + INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled) + VALUES (?, 'default', ?, ?, 0) `, model.id, model.name, model.provider) if err != nil { return fmt.Errorf("初始化AI模型失败: %w", err) } } - // 初始化交易所 + // 初始化交易所(使用default用户) exchanges := []struct { id, name, typ string }{ - {"binance", "Binance", "cex"}, - {"hyperliquid", "Hyperliquid", "dex"}, + {"binance", "Binance Futures", "binance"}, + {"hyperliquid", "Hyperliquid", "hyperliquid"}, + {"aster", "Aster DEX", "aster"}, } for _, exchange := range exchanges { _, err := d.db.Exec(` - INSERT OR IGNORE INTO exchanges (id, name, type, enabled) - VALUES (?, ?, ?, 0) + INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled) + VALUES (?, 'default', ?, ?, 0) `, exchange.id, exchange.name, exchange.typ) if err != nil { return fmt.Errorf("初始化交易所失败: %w", err) @@ -156,7 +210,7 @@ func (d *Database) initDefaultData() error { // 初始化系统配置 systemConfigs := map[string]string{ - "api_server_port": "8081", + "api_server_port": "8080", "use_default_coins": "true", "coin_pool_api_url": "", "oi_top_api_url": "", @@ -178,9 +232,103 @@ func (d *Database) initDefaultData() error { return nil } +// migrateExchangesTable 迁移exchanges表支持多用户 +func (d *Database) migrateExchangesTable() error { + // 检查是否已经迁移过 + var count int + err := d.db.QueryRow(` + SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name='exchanges_new' + `).Scan(&count) + if err != nil { + return err + } + + // 如果已经迁移过,直接返回 + if count > 0 { + return nil + } + + log.Printf("🔄 开始迁移exchanges表...") + + // 创建新的exchanges表,使用复合主键 + _, err = d.db.Exec(` + CREATE TABLE exchanges_new ( + id TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + type TEXT NOT NULL, + enabled BOOLEAN DEFAULT 0, + api_key TEXT DEFAULT '', + secret_key TEXT DEFAULT '', + testnet BOOLEAN DEFAULT 0, + hyperliquid_wallet_addr TEXT DEFAULT '', + aster_user TEXT DEFAULT '', + aster_signer TEXT DEFAULT '', + aster_private_key TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `) + if err != nil { + return fmt.Errorf("创建新exchanges表失败: %w", err) + } + + // 复制数据到新表 + _, err = d.db.Exec(` + INSERT INTO exchanges_new + SELECT * FROM exchanges + `) + if err != nil { + return fmt.Errorf("复制数据失败: %w", err) + } + + // 删除旧表 + _, err = d.db.Exec(`DROP TABLE exchanges`) + if err != nil { + return fmt.Errorf("删除旧表失败: %w", err) + } + + // 重命名新表 + _, err = d.db.Exec(`ALTER TABLE exchanges_new RENAME TO exchanges`) + if err != nil { + return fmt.Errorf("重命名表失败: %w", err) + } + + // 重新创建触发器 + _, err = d.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at + AFTER UPDATE ON exchanges + BEGIN + UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id AND user_id = NEW.user_id; + END + `) + if err != nil { + return fmt.Errorf("创建触发器失败: %w", err) + } + + log.Printf("✅ exchanges表迁移完成") + return nil +} + +// User 用户配置 +type User struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` // 不返回到前端 + OTPSecret string `json:"-"` // 不返回到前端 + OTPVerified bool `json:"otp_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // AIModelConfig AI模型配置 type AIModelConfig struct { ID string `json:"id"` + UserID string `json:"user_id"` Name string `json:"name"` Provider string `json:"provider"` Enabled bool `json:"enabled"` @@ -192,45 +340,139 @@ type AIModelConfig struct { // ExchangeConfig 交易所配置 type ExchangeConfig struct { ID string `json:"id"` + UserID string `json:"user_id"` Name string `json:"name"` Type string `json:"type"` Enabled bool `json:"enabled"` APIKey string `json:"apiKey"` SecretKey string `json:"secretKey"` Testnet bool `json:"testnet"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // Hyperliquid 特定字段 + HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` + // Aster 特定字段 + AsterUser string `json:"asterUser"` + AsterSigner string `json:"asterSigner"` + AsterPrivateKey string `json:"asterPrivateKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TraderConfig 交易员配置 type TraderConfig struct { ID string `json:"id"` + UserID string `json:"user_id"` Name string `json:"name"` AIModelID string `json:"ai_model_id"` ExchangeID string `json:"exchange_id"` InitialBalance float64 `json:"initial_balance"` ScanIntervalMinutes int `json:"scan_interval_minutes"` IsRunning bool `json:"is_running"` + CustomPrompt string `json:"custom_prompt"` // 自定义交易策略prompt + OverrideBasePrompt bool `json:"override_base_prompt"` // 是否覆盖基础prompt CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -// GetAIModels 获取所有AI模型配置 -func (d *Database) GetAIModels() ([]*AIModelConfig, error) { +// GenerateOTPSecret 生成OTP密钥 +func GenerateOTPSecret() (string, error) { + secret := make([]byte, 20) + _, err := rand.Read(secret) + if err != nil { + return "", err + } + return base32.StdEncoding.EncodeToString(secret), nil +} + +// CreateUser 创建用户 +func (d *Database) CreateUser(user *User) error { + _, err := d.db.Exec(` + INSERT INTO users (id, email, password_hash, otp_secret, otp_verified) + VALUES (?, ?, ?, ?, ?) + `, user.ID, user.Email, user.PasswordHash, user.OTPSecret, user.OTPVerified) + return err +} + +// EnsureAdminUser 确保admin用户存在(用于管理员模式) +func (d *Database) EnsureAdminUser() error { + // 检查admin用户是否已存在 + var count int + err := d.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = 'admin'`).Scan(&count) + if err != nil { + return err + } + + // 如果已存在,直接返回 + if count > 0 { + return nil + } + + // 创建admin用户(密码为空,因为管理员模式下不需要密码) + adminUser := &User{ + ID: "admin", + Email: "admin@localhost", + PasswordHash: "", // 管理员模式下不使用密码 + OTPSecret: "", + OTPVerified: true, + } + + return d.CreateUser(adminUser) +} + +// GetUserByEmail 通过邮箱获取用户 +func (d *Database) GetUserByEmail(email string) (*User, error) { + var user User + err := d.db.QueryRow(` + SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at + FROM users WHERE email = ? + `, email).Scan( + &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, + &user.OTPVerified, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &user, nil +} + +// GetUserByID 通过ID获取用户 +func (d *Database) GetUserByID(userID string) (*User, error) { + var user User + err := d.db.QueryRow(` + SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at + FROM users WHERE id = ? + `, userID).Scan( + &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, + &user.OTPVerified, &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &user, nil +} + +// UpdateUserOTPVerified 更新用户OTP验证状态 +func (d *Database) UpdateUserOTPVerified(userID string, verified bool) error { + _, err := d.db.Exec(`UPDATE users SET otp_verified = ? WHERE id = ?`, verified, userID) + return err +} + +// GetAIModels 获取用户的AI模型配置 +func (d *Database) GetAIModels(userID string) ([]*AIModelConfig, error) { rows, err := d.db.Query(` - SELECT id, name, provider, enabled, api_key, created_at, updated_at - FROM ai_models ORDER BY id - `) + SELECT id, user_id, name, provider, enabled, api_key, created_at, updated_at + FROM ai_models WHERE user_id = ? ORDER BY id + `, userID) if err != nil { return nil, err } defer rows.Close() - var models []*AIModelConfig + // 初始化为空切片而不是nil,确保JSON序列化为[]而不是null + models := make([]*AIModelConfig, 0) for rows.Next() { var model AIModelConfig err := rows.Scan( - &model.ID, &model.Name, &model.Provider, + &model.ID, &model.UserID, &model.Name, &model.Provider, &model.Enabled, &model.APIKey, &model.CreatedAt, &model.UpdatedAt, ) @@ -243,31 +485,80 @@ func (d *Database) GetAIModels() ([]*AIModelConfig, error) { return models, nil } -// UpdateAIModel 更新AI模型配置 -func (d *Database) UpdateAIModel(id string, enabled bool, apiKey string) error { - _, err := d.db.Exec(` - UPDATE ai_models SET enabled = ?, api_key = ? WHERE id = ? - `, enabled, apiKey, id) - return err +// UpdateAIModel 更新AI模型配置,如果不存在则创建用户特定配置 +func (d *Database) UpdateAIModel(userID, id string, enabled bool, apiKey string) error { + // 首先尝试更新现有的用户配置 + result, err := d.db.Exec(` + UPDATE ai_models SET enabled = ?, api_key = ? WHERE id = ? AND user_id = ? + `, enabled, apiKey, id, userID) + if err != nil { + return err + } + + // 检查是否有行被更新 + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + // 如果没有行被更新,说明用户没有这个模型的配置,需要创建 + if rowsAffected == 0 { + // 获取模型的基本信息 + var name, provider string + err = d.db.QueryRow(` + SELECT name, provider FROM ai_models WHERE provider = ? LIMIT 1 + `, id).Scan(&name, &provider) + if err != nil { + // 如果找不到基本信息,使用默认值 + if id == "deepseek" { + name = "DeepSeek AI" + provider = "deepseek" + } else if id == "qwen" { + name = "Qwen AI" + provider = "qwen" + } else { + name = id + " AI" + provider = id + } + } + + // 创建用户特定的配置 + userModelID := fmt.Sprintf("%s_%s", userID, id) + _, err = d.db.Exec(` + INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `, userModelID, userID, name, provider, enabled, apiKey) + return err + } + + return nil } -// GetExchanges 获取所有交易所配置 -func (d *Database) GetExchanges() ([]*ExchangeConfig, error) { +// GetExchanges 获取用户的交易所配置 +func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) { rows, err := d.db.Query(` - SELECT id, name, type, enabled, api_key, secret_key, testnet, created_at, updated_at - FROM exchanges ORDER BY id - `) + SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet, + COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, + COALESCE(aster_user, '') as aster_user, + COALESCE(aster_signer, '') as aster_signer, + COALESCE(aster_private_key, '') as aster_private_key, + created_at, updated_at + FROM exchanges WHERE user_id = ? ORDER BY id + `, userID) if err != nil { return nil, err } defer rows.Close() - var exchanges []*ExchangeConfig + // 初始化为空切片而不是nil,确保JSON序列化为[]而不是null + exchanges := make([]*ExchangeConfig, 0) for rows.Next() { var exchange ExchangeConfig err := rows.Scan( - &exchange.ID, &exchange.Name, &exchange.Type, + &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, + &exchange.HyperliquidWalletAddr, &exchange.AsterUser, + &exchange.AsterSigner, &exchange.AsterPrivateKey, &exchange.CreatedAt, &exchange.UpdatedAt, ) if err != nil { @@ -279,29 +570,105 @@ func (d *Database) GetExchanges() ([]*ExchangeConfig, error) { return exchanges, nil } -// UpdateExchange 更新交易所配置 -func (d *Database) UpdateExchange(id string, enabled bool, apiKey, secretKey string, testnet bool) error { +// UpdateExchange 更新交易所配置,如果不存在则创建用户特定配置 +func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { + log.Printf("🔧 UpdateExchange: userID=%s, id=%s, enabled=%v", userID, id, enabled) + + // 首先尝试更新现有的用户配置 + result, err := d.db.Exec(` + UPDATE exchanges SET enabled = ?, api_key = ?, secret_key = ?, testnet = ?, + hyperliquid_wallet_addr = ?, aster_user = ?, aster_signer = ?, aster_private_key = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ? + `, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, id, userID) + if err != nil { + log.Printf("❌ UpdateExchange: 更新失败: %v", err) + return err + } + + // 检查是否有行被更新 + rowsAffected, err := result.RowsAffected() + if err != nil { + log.Printf("❌ UpdateExchange: 获取影响行数失败: %v", err) + return err + } + + log.Printf("📊 UpdateExchange: 影响行数 = %d", rowsAffected) + + // 如果没有行被更新,说明用户没有这个交易所的配置,需要创建 + if rowsAffected == 0 { + log.Printf("💡 UpdateExchange: 没有现有记录,创建新记录") + + // 根据交易所ID确定基本信息 + var name, typ string + if id == "binance" { + name = "Binance Futures" + typ = "cex" + } else if id == "hyperliquid" { + name = "Hyperliquid" + typ = "dex" + } else if id == "aster" { + name = "Aster DEX" + typ = "dex" + } else { + name = id + " Exchange" + typ = "cex" + } + + log.Printf("🆕 UpdateExchange: 创建新记录 ID=%s, name=%s, type=%s", id, name, typ) + + // 创建用户特定的配置,使用原始的交易所ID + _, err = d.db.Exec(` + INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `, id, userID, name, typ, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey) + + if err != nil { + log.Printf("❌ UpdateExchange: 创建记录失败: %v", err) + } else { + log.Printf("✅ UpdateExchange: 创建记录成功") + } + return err + } + + log.Printf("✅ UpdateExchange: 更新现有记录成功") + return nil +} + +// CreateAIModel 创建AI模型配置 +func (d *Database) CreateAIModel(userID, id, name, provider string, enabled bool, apiKey string) error { _, err := d.db.Exec(` - UPDATE exchanges SET enabled = ?, api_key = ?, secret_key = ?, testnet = ? WHERE id = ? - `, enabled, apiKey, secretKey, testnet, id) + INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled, api_key) + VALUES (?, ?, ?, ?, ?, ?) + `, id, userID, name, provider, enabled, apiKey) + return err +} + +// CreateExchange 创建交易所配置 +func (d *Database) CreateExchange(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { + _, err := d.db.Exec(` + INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, id, userID, name, typ, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey) return err } // CreateTrader 创建交易员 func (d *Database) CreateTrader(trader *TraderConfig) error { _, err := d.db.Exec(` - INSERT INTO traders (id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, trader.ID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning) + INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, custom_prompt, override_base_prompt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.CustomPrompt, trader.OverrideBasePrompt) return err } -// GetTraders 获取所有交易员 -func (d *Database) GetTraders() ([]*TraderConfig, error) { +// GetTraders 获取用户的交易员 +func (d *Database) GetTraders(userID string) ([]*TraderConfig, error) { rows, err := d.db.Query(` - SELECT id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, created_at, updated_at - FROM traders ORDER BY created_at DESC - `) + SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, + COALESCE(custom_prompt, '') as custom_prompt, COALESCE(override_base_prompt, 0) as override_base_prompt, created_at, updated_at + FROM traders WHERE user_id = ? ORDER BY created_at DESC + `, userID) if err != nil { return nil, err } @@ -311,9 +678,9 @@ func (d *Database) GetTraders() ([]*TraderConfig, error) { for rows.Next() { var trader TraderConfig err := rows.Scan( - &trader.ID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, + &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, - &trader.CreatedAt, &trader.UpdatedAt, + &trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.CreatedAt, &trader.UpdatedAt, ) if err != nil { return nil, err @@ -325,40 +692,52 @@ func (d *Database) GetTraders() ([]*TraderConfig, error) { } // UpdateTraderStatus 更新交易员状态 -func (d *Database) UpdateTraderStatus(id string, isRunning bool) error { - _, err := d.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ?`, isRunning, id) +func (d *Database) UpdateTraderStatus(userID, id string, isRunning bool) error { + _, err := d.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ? AND user_id = ?`, isRunning, id, userID) + return err +} + +// UpdateTraderCustomPrompt 更新交易员自定义Prompt +func (d *Database) UpdateTraderCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error { + _, err := d.db.Exec(`UPDATE traders SET custom_prompt = ?, override_base_prompt = ? WHERE id = ? AND user_id = ?`, customPrompt, overrideBase, id, userID) return err } // DeleteTrader 删除交易员 -func (d *Database) DeleteTrader(id string) error { - _, err := d.db.Exec(`DELETE FROM traders WHERE id = ?`, id) +func (d *Database) DeleteTrader(userID, id string) error { + _, err := d.db.Exec(`DELETE FROM traders WHERE id = ? AND user_id = ?`, id, userID) return err } // GetTraderConfig 获取交易员完整配置(包含AI模型和交易所信息) -func (d *Database) GetTraderConfig(traderID string) (*TraderConfig, *AIModelConfig, *ExchangeConfig, error) { +func (d *Database) GetTraderConfig(userID, traderID string) (*TraderConfig, *AIModelConfig, *ExchangeConfig, error) { var trader TraderConfig var aiModel AIModelConfig var exchange ExchangeConfig err := d.db.QueryRow(` SELECT - t.id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, t.created_at, t.updated_at, - a.id, a.name, a.provider, a.enabled, a.api_key, a.created_at, a.updated_at, - e.id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, e.created_at, e.updated_at + t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, t.created_at, t.updated_at, + a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, a.created_at, a.updated_at, + e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, + COALESCE(e.hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, + COALESCE(e.aster_user, '') as aster_user, + COALESCE(e.aster_signer, '') as aster_signer, + COALESCE(e.aster_private_key, '') as aster_private_key, + e.created_at, e.updated_at FROM traders t - JOIN ai_models a ON t.ai_model_id = a.id - JOIN exchanges e ON t.exchange_id = e.id - WHERE t.id = ? - `, traderID).Scan( - &trader.ID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, + JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id + JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id + WHERE t.id = ? AND t.user_id = ? + `, traderID, userID).Scan( + &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, &trader.CreatedAt, &trader.UpdatedAt, - &aiModel.ID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, + &aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, &aiModel.CreatedAt, &aiModel.UpdatedAt, - &exchange.ID, &exchange.Name, &exchange.Type, &exchange.Enabled, + &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, + &exchange.HyperliquidWalletAddr, &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey, &exchange.CreatedAt, &exchange.UpdatedAt, ) diff --git a/decision/engine.go b/decision/engine.go index 76bcffca..1e990670 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -91,13 +91,18 @@ type FullDecision struct { // GetFullDecision 获取AI的完整交易决策(批量分析所有币种和持仓) func GetFullDecision(ctx *Context, mcpClient *mcp.Client) (*FullDecision, error) { + return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false) +} + +// GetFullDecisionWithCustomPrompt 获取AI的完整交易决策(支持自定义prompt) +func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient *mcp.Client, customPrompt string, overrideBase bool) (*FullDecision, error) { // 1. 为所有币种获取市场数据 if err := fetchMarketDataForContext(ctx); err != nil { return nil, fmt.Errorf("获取市场数据失败: %w", err) } // 2. 构建 System Prompt(固定规则)和 User Prompt(动态数据) - systemPrompt := buildSystemPrompt(ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage) + systemPrompt := buildSystemPromptWithCustom(ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage, customPrompt, overrideBase) userPrompt := buildUserPrompt(ctx) // 3. 调用AI API(使用 system + user prompt) @@ -199,6 +204,33 @@ func calculateMaxCandidates(ctx *Context) int { return len(ctx.CandidateCoins) } +// buildSystemPromptWithCustom 构建包含自定义内容的 System Prompt +func buildSystemPromptWithCustom(accountEquity float64, btcEthLeverage, altcoinLeverage int, customPrompt string, overrideBase bool) string { + // 如果覆盖基础prompt且有自定义prompt,只使用自定义prompt + if overrideBase && customPrompt != "" { + return customPrompt + } + + // 获取基础prompt + basePrompt := buildSystemPrompt(accountEquity, btcEthLeverage, altcoinLeverage) + + // 如果没有自定义prompt,直接返回基础prompt + if customPrompt == "" { + return basePrompt + } + + // 添加自定义prompt部分到基础prompt + var sb strings.Builder + sb.WriteString(basePrompt) + sb.WriteString("\n\n") + sb.WriteString("# 📌 个性化交易策略\n\n") + sb.WriteString(customPrompt) + sb.WriteString("\n\n") + sb.WriteString("**注意**: 以上个性化策略是对基础规则的补充,不能违背基础风险控制原则。\n") + + return sb.String() +} + // buildSystemPrompt 构建 System Prompt(固定规则,可缓存) func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage int) string { var sb strings.Builder diff --git a/go.mod b/go.mod index 0dbb9655..0c6dcfde 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,19 @@ require ( github.com/adshao/go-binance/v2 v2.8.7 github.com/ethereum/go-ethereum v1.16.5 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.32 + github.com/pquerna/otp v1.4.0 github.com/sonirico/go-hyperliquid v0.17.0 + golang.org/x/crypto v0.42.0 ) require ( github.com/armon/go-radix v1.0.0 // indirect github.com/bitly/go-simplejson v0.5.0 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect @@ -32,7 +37,6 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/joho/godotenv v1.5.1 // indirect @@ -66,7 +70,6 @@ require ( go.elastic.co/fastjson v1.5.1 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.42.0 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect diff --git a/go.sum b/go.sum index 4bb6ff45..d0d7d69a 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJiz github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -67,6 +69,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= @@ -132,6 +136,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= diff --git a/main.go b/main.go index 238a6d5c..a9ea34c9 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "nofx/api" + "nofx/auth" "nofx/config" "nofx/manager" "nofx/pool" @@ -38,6 +39,29 @@ func main() { useDefaultCoins := useDefaultCoinsStr == "true" apiPortStr, _ := database.GetSystemConfig("api_server_port") + // 获取管理员模式配置 + adminModeStr, _ := database.GetSystemConfig("admin_mode") + adminMode := adminModeStr != "false" // 默认为true + + // 设置JWT密钥 + jwtSecret, _ := database.GetSystemConfig("jwt_secret") + if jwtSecret == "" { + jwtSecret = "your-jwt-secret-key-change-in-production-make-it-long-and-random" + log.Printf("⚠️ 使用默认JWT密钥,建议在生产环境中配置") + } + auth.SetJWTSecret(jwtSecret) + + // 在管理员模式下,确保admin用户存在 + if adminMode { + err := database.EnsureAdminUser() + if err != nil { + log.Printf("⚠️ 创建admin用户失败: %v", err) + } else { + log.Printf("✓ 管理员模式已启用,无需登录") + } + auth.SetAdminMode(true) + } + log.Printf("✓ 配置数据库初始化成功") fmt.Println() @@ -73,8 +97,8 @@ func main() { log.Fatalf("❌ 加载交易员失败: %v", err) } - // 获取数据库中的所有交易员配置(用于显示) - traders, err := database.GetTraders() + // 获取数据库中的所有交易员配置(用于显示,使用default用户) + traders, err := database.GetTraders("default") if err != nil { log.Fatalf("❌ 获取交易员列表失败: %v", err) } @@ -110,7 +134,7 @@ func main() { fmt.Println() // 获取API服务器端口 - apiPort := 8081 // 默认端口 + apiPort := 8080 // 默认端口 if apiPortStr != "" { if port, err := strconv.Atoi(apiPortStr); err == nil { apiPort = port diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 65cb92f0..31800fda 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -28,13 +28,20 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro tm.mu.Lock() defer tm.mu.Unlock() + // 根据admin_mode确定用户ID + adminModeStr, _ := database.GetSystemConfig("admin_mode") + userID := "default" + if adminModeStr != "false" { // 默认为true + userID = "admin" + } + // 获取数据库中的所有交易员 - traders, err := database.GetTraders() + traders, err := database.GetTraders(userID) if err != nil { return fmt.Errorf("获取交易员列表失败: %w", err) } - log.Printf("📋 加载数据库中的交易员配置: %d 个", len(traders)) + log.Printf("📋 加载数据库中的交易员配置: %d 个 (用户: %s)", len(traders), userID) // 获取系统配置 coinPoolURL, _ := database.GetSystemConfig("coin_pool_api_url") @@ -61,7 +68,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro // 为每个交易员获取AI模型和交易所配置 for _, traderCfg := range traders { // 获取AI模型配置 - aiModels, err := database.GetAIModels() + aiModels, err := database.GetAIModels(userID) if err != nil { log.Printf("⚠️ 获取AI模型配置失败: %v", err) continue @@ -86,7 +93,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro } // 获取交易所配置 - exchanges, err := database.GetExchanges() + exchanges, err := database.GetExchanges(userID) if err != nil { log.Printf("⚠️ 获取交易所配置失败: %v", err) continue @@ -155,6 +162,11 @@ func (tm *TraderManager) addTraderFromConfig(traderCfg *config.TraderConfig, aiM traderConfig.BinanceSecretKey = exchangeCfg.SecretKey } else if exchangeCfg.ID == "hyperliquid" { traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key + traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr + } else if exchangeCfg.ID == "aster" { + traderConfig.AsterUser = exchangeCfg.AsterUser + traderConfig.AsterSigner = exchangeCfg.AsterSigner + traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey } // 根据AI模型设置API密钥 @@ -169,6 +181,17 @@ func (tm *TraderManager) addTraderFromConfig(traderCfg *config.TraderConfig, aiM if err != nil { return fmt.Errorf("创建trader失败: %w", err) } + + // 设置自定义prompt(如果有) + if traderCfg.CustomPrompt != "" { + at.SetCustomPrompt(traderCfg.CustomPrompt) + at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt) + if traderCfg.OverrideBasePrompt { + log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)") + } else { + log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)") + } + } tm.traders[traderCfg.ID] = at log.Printf("✓ Trader '%s' (%s + %s) 已加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) @@ -213,6 +236,11 @@ func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderConfig, aiModel traderConfig.BinanceSecretKey = exchangeCfg.SecretKey } else if exchangeCfg.ID == "hyperliquid" { traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key + traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr + } else if exchangeCfg.ID == "aster" { + traderConfig.AsterUser = exchangeCfg.AsterUser + traderConfig.AsterSigner = exchangeCfg.AsterSigner + traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey } // 根据AI模型设置API密钥 @@ -227,6 +255,17 @@ func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderConfig, aiModel if err != nil { return fmt.Errorf("创建trader失败: %w", err) } + + // 设置自定义prompt(如果有) + if traderCfg.CustomPrompt != "" { + at.SetCustomPrompt(traderCfg.CustomPrompt) + at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt) + if traderCfg.OverrideBasePrompt { + log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)") + } else { + log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)") + } + } tm.traders[traderCfg.ID] = at log.Printf("✓ Trader '%s' (%s + %s) 已添加", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) @@ -331,3 +370,236 @@ func (tm *TraderManager) GetComparisonData() (map[string]interface{}, error) { return comparison, nil } + +// GetCompetitionData 获取竞赛数据(特定用户的所有交易员) +func (tm *TraderManager) GetCompetitionData(userID string) (map[string]interface{}, error) { + tm.mu.RLock() + defer tm.mu.RUnlock() + + comparison := make(map[string]interface{}) + traders := make([]map[string]interface{}, 0) + + // 只获取该用户的交易员 + for traderID, t := range tm.traders { + // 检查trader是否属于该用户(通过ID前缀判断) + // 格式:userID_traderName + if !isUserTrader(traderID, userID) { + continue + } + + account, err := t.GetAccountInfo() + if err != nil { + log.Printf("⚠️ 获取交易员 %s 账户信息失败: %v", traderID, err) + continue + } + + status := t.GetStatus() + traders = append(traders, map[string]interface{}{ + "trader_id": t.GetID(), + "trader_name": t.GetName(), + "ai_model": t.GetAIModel(), + "total_equity": account["total_equity"], + "total_pnl": account["total_pnl"], + "total_pnl_pct": account["total_pnl_pct"], + "position_count": account["position_count"], + "margin_used_pct": account["margin_used_pct"], + "is_running": status["is_running"], + }) + } + + comparison["traders"] = traders + comparison["count"] = len(traders) + + return comparison, nil +} + +// isUserTrader 检查trader是否属于指定用户 +func isUserTrader(traderID, userID string) bool { + // trader ID格式: userID_traderName 或 randomUUID_modelName + // 为了兼容性,我们检查前缀 + if len(traderID) >= len(userID) && traderID[:len(userID)] == userID { + return true + } + // 对于老的default用户,所有没有明确用户前缀的都属于default + if userID == "default" && !containsUserPrefix(traderID) { + return true + } + return false +} + +// containsUserPrefix 检查trader ID是否包含用户前缀 +func containsUserPrefix(traderID string) bool { + // 检查是否包含邮箱格式的前缀(user@example.com_traderName) + for i, ch := range traderID { + if ch == '@' { + // 找到@符号,说明可能是email前缀 + return true + } + if ch == '_' && i > 0 { + // 找到下划线但前面没有@,可能是UUID或其他格式 + break + } + } + return false +} + +// LoadUserTraders 为特定用户加载交易员到内存 +func (tm *TraderManager) LoadUserTraders(database *config.Database, userID string) error { + tm.mu.Lock() + defer tm.mu.Unlock() + + // 获取指定用户的所有交易员 + traders, err := database.GetTraders(userID) + if err != nil { + return fmt.Errorf("获取用户 %s 的交易员列表失败: %w", userID, err) + } + + log.Printf("📋 为用户 %s 加载交易员配置: %d 个", userID, len(traders)) + + // 获取系统配置 + coinPoolURL, _ := database.GetSystemConfig("coin_pool_api_url") + maxDailyLossStr, _ := database.GetSystemConfig("max_daily_loss") + maxDrawdownStr, _ := database.GetSystemConfig("max_drawdown") + stopTradingMinutesStr, _ := database.GetSystemConfig("stop_trading_minutes") + + // 解析配置 + maxDailyLoss := 10.0 // 默认值 + if val, err := strconv.ParseFloat(maxDailyLossStr, 64); err == nil { + maxDailyLoss = val + } + + maxDrawdown := 20.0 // 默认值 + if val, err := strconv.ParseFloat(maxDrawdownStr, 64); err == nil { + maxDrawdown = val + } + + stopTradingMinutes := 60 // 默认值 + if val, err := strconv.Atoi(stopTradingMinutesStr); err == nil { + stopTradingMinutes = val + } + + // 为每个交易员获取AI模型和交易所配置 + for _, traderCfg := range traders { + // 检查是否已经加载过这个交易员 + if _, exists := tm.traders[traderCfg.ID]; exists { + log.Printf("⚠️ 交易员 %s 已经加载,跳过", traderCfg.Name) + continue + } + + // 获取AI模型配置(使用该用户的配置) + aiModels, err := database.GetAIModels(userID) + if err != nil { + log.Printf("⚠️ 获取用户 %s 的AI模型配置失败: %v", userID, err) + continue + } + + var aiModelCfg *config.AIModelConfig + for _, model := range aiModels { + if model.ID == traderCfg.AIModelID { + aiModelCfg = model + break + } + } + + if aiModelCfg == nil { + log.Printf("⚠️ 交易员 %s 的AI模型 %s 不存在,跳过", traderCfg.Name, traderCfg.AIModelID) + continue + } + + if !aiModelCfg.Enabled { + log.Printf("⚠️ 交易员 %s 的AI模型 %s 未启用,跳过", traderCfg.Name, traderCfg.AIModelID) + continue + } + + // 获取交易所配置(使用该用户的配置) + exchanges, err := database.GetExchanges(userID) + if err != nil { + log.Printf("⚠️ 获取用户 %s 的交易所配置失败: %v", userID, err) + continue + } + + var exchangeCfg *config.ExchangeConfig + for _, exchange := range exchanges { + if exchange.ID == traderCfg.ExchangeID { + exchangeCfg = exchange + break + } + } + + if exchangeCfg == nil { + log.Printf("⚠️ 交易员 %s 的交易所 %s 不存在,跳过", traderCfg.Name, traderCfg.ExchangeID) + continue + } + + if !exchangeCfg.Enabled { + log.Printf("⚠️ 交易员 %s 的交易所 %s 未启用,跳过", traderCfg.Name, traderCfg.ExchangeID) + continue + } + + // 使用现有的方法加载交易员 + err = tm.loadSingleTrader(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, maxDailyLoss, maxDrawdown, stopTradingMinutes) + if err != nil { + log.Printf("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err) + } + } + + return nil +} + +// loadSingleTrader 加载单个交易员(从现有代码提取的公共逻辑) +func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderConfig, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int) error { + // 构建AutoTraderConfig + traderConfig := trader.AutoTraderConfig{ + ID: traderCfg.ID, + Name: traderCfg.Name, + AIModel: aiModelCfg.Provider, // 使用provider作为模型标识 + Exchange: exchangeCfg.ID, // 使用exchange ID + InitialBalance: traderCfg.InitialBalance, + ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, + CoinPoolAPIURL: coinPoolURL, + MaxDailyLoss: maxDailyLoss, + MaxDrawdown: maxDrawdown, + StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, + } + + // 根据交易所类型设置API密钥 + if exchangeCfg.ID == "binance" { + traderConfig.BinanceAPIKey = exchangeCfg.APIKey + traderConfig.BinanceSecretKey = exchangeCfg.SecretKey + } else if exchangeCfg.ID == "hyperliquid" { + traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key + traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr + } else if exchangeCfg.ID == "aster" { + traderConfig.AsterUser = exchangeCfg.AsterUser + traderConfig.AsterSigner = exchangeCfg.AsterSigner + traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey + } + + // 根据AI模型设置API密钥 + if aiModelCfg.Provider == "qwen" { + traderConfig.QwenKey = aiModelCfg.APIKey + } else if aiModelCfg.Provider == "deepseek" { + traderConfig.DeepSeekKey = aiModelCfg.APIKey + } + + // 创建trader实例 + at, err := trader.NewAutoTrader(traderConfig) + if err != nil { + return fmt.Errorf("创建trader失败: %w", err) + } + + // 设置自定义prompt(如果有) + if traderCfg.CustomPrompt != "" { + at.SetCustomPrompt(traderCfg.CustomPrompt) + at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt) + if traderCfg.OverrideBasePrompt { + log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)") + } else { + log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)") + } + } + + tm.traders[traderCfg.ID] = at + log.Printf("✓ Trader '%s' (%s + %s) 已为用户加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) + return nil +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 42bc2e69..aed0dc79 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -77,6 +77,8 @@ type AutoTrader struct { decisionLogger *logger.DecisionLogger // 决策日志记录器 initialBalance float64 dailyPnL float64 + customPrompt string // 自定义交易策略prompt + overrideBasePrompt bool // 是否覆盖基础prompt lastResetTime time.Time stopUntil time.Time isRunning bool @@ -287,7 +289,7 @@ func (at *AutoTrader) runCycle() error { // 4. 调用AI获取完整决策 log.Println("🤖 正在请求AI分析并决策...") - decision, err := decision.GetFullDecision(ctx, at.mcpClient) + decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt) // 即使有错误,也保存思维链、决策和输入prompt(用于debug) if decision != nil { @@ -735,6 +737,16 @@ func (at *AutoTrader) GetAIModel() string { return at.aiModel } +// SetCustomPrompt 设置自定义交易策略prompt +func (at *AutoTrader) SetCustomPrompt(prompt string) { + at.customPrompt = prompt +} + +// SetOverrideBasePrompt 设置是否覆盖基础prompt +func (at *AutoTrader) SetOverrideBasePrompt(override bool) { + at.overrideBasePrompt = override +} + // GetDecisionLogger 获取决策日志记录器 func (at *AutoTrader) GetDecisionLogger() *logger.DecisionLogger { return at.decisionLogger @@ -871,14 +883,15 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) { leverage = int(lev) } - pnlPct := 0.0 - if side == "long" { - pnlPct = ((markPrice - entryPrice) / entryPrice) * 100 - } else { - pnlPct = ((entryPrice - markPrice) / entryPrice) * 100 - } - + // 计算占用保证金 marginUsed := (quantity * markPrice) / float64(leverage) + + // 计算盈亏百分比(基于保证金) + // 收益率 = 未实现盈亏 / 保证金 × 100% + pnlPct := 0.0 + if marginUsed > 0 { + pnlPct = (unrealizedPnl / marginUsed) * 100 + } result = append(result, map[string]interface{}{ "symbol": symbol, diff --git a/web/public/icons/aster.svg b/web/public/icons/aster.svg new file mode 100644 index 00000000..0ac6b2f8 --- /dev/null +++ b/web/public/icons/aster.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/icons/binance.svg b/web/public/icons/binance.svg new file mode 100644 index 00000000..1eab330d --- /dev/null +++ b/web/public/icons/binance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/deepseek.svg b/web/public/icons/deepseek.svg new file mode 100644 index 00000000..3fc23024 --- /dev/null +++ b/web/public/icons/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/web/public/icons/hypeliquid.svg b/web/public/icons/hypeliquid.svg new file mode 100644 index 00000000..c9eb0bd0 --- /dev/null +++ b/web/public/icons/hypeliquid.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/icons/qwen.svg b/web/public/icons/qwen.svg new file mode 100644 index 00000000..33b3f645 --- /dev/null +++ b/web/public/icons/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index b1a20464..050baf26 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,9 +3,14 @@ import useSWR from 'swr'; import { api } from './lib/api'; import { EquityChart } from './components/EquityChart'; import { AITradersPage } from './components/AITradersPage'; +import { LoginPage } from './components/LoginPage'; +import { RegisterPage } from './components/RegisterPage'; +import { CompetitionPage } from './components/CompetitionPage'; import AILearning from './components/AILearning'; import { LanguageProvider, useLanguage } from './contexts/LanguageContext'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; import { t, type Language } from './i18n/translations'; +import { useSystemConfig } from './hooks/useSystemConfig'; import type { SystemStatus, AccountInfo, @@ -15,11 +20,34 @@ import type { TraderInfo, } from './types'; -type Page = 'traders' | 'trader'; +type Page = 'competition' | 'traders' | 'trader'; + +// 获取友好的AI模型名称 +function getModelDisplayName(modelId: string): string { + switch (modelId.toLowerCase()) { + case 'deepseek': + return 'DeepSeek'; + case 'qwen': + return 'Qwen'; + case 'claude': + return 'Claude'; + case 'gpt4': + case 'gpt-4': + return 'GPT-4'; + case 'gpt3.5': + case 'gpt-3.5': + return 'GPT-3.5'; + default: + return modelId.toUpperCase(); + } +} function App() { const { language, setLanguage } = useLanguage(); - const [currentPage, setCurrentPage] = useState('traders'); + const { user, token, logout, isLoading } = useAuth(); + const { config: systemConfig, loading: configLoading } = useSystemConfig(); + const [route, setRoute] = useState(window.location.pathname); + const [currentPage, setCurrentPage] = useState('competition'); const [selectedTraderId, setSelectedTraderId] = useState(); const [lastUpdate, setLastUpdate] = useState('--:--:--'); @@ -105,59 +133,118 @@ function App() { const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId); + // Handle routing + useEffect(() => { + const handlePopState = () => { + setRoute(window.location.pathname); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + // Show loading spinner while checking auth or config + if (isLoading || configLoading) { + return ( +
+
+
+ ⚡ +
+

加载中...

+
+
+ ); + } + + // If not in admin mode and not authenticated, show login/register pages + if (!systemConfig?.admin_mode && (!user || !token)) { + if (route === '/register') { + return ; + } + return ; + } + return (
{/* Header - Binance Style */}
-
- {/* Mobile: Two rows, Desktop: Single row */} -
- {/* Left: Logo and Title */} -
-
+
+
+ {/* Left - Logo and Title */} +
+
-

+

{t('appTitle', language)}

-

+

{t('subtitle', language)}

- - {/* Right: Controls - Wrap on mobile */} -
- {/* GitHub Link - Hidden on mobile, icon only on tablet */} - { - e.currentTarget.style.background = '#2B3139'; - e.currentTarget.style.color = '#EAECEF'; - e.currentTarget.style.borderColor = '#F0B90B'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = '#1E2329'; - e.currentTarget.style.color = '#848E9C'; - e.currentTarget.style.borderColor = '#2B3139'; - }} + + {/* Center - Page Toggle (absolutely positioned) */} +
+ + + +
+ + {/* Right - Actions */} +
+ + {/* User Info - Only show if not in admin mode */} + {!systemConfig?.admin_mode && user && ( +
+
+ {user.email[0].toUpperCase()} +
+ {user.email} +
+ )} + + {/* Admin Mode Indicator */} + {systemConfig?.admin_mode && ( +
+ ⚡ 管理员模式 +
+ )} {/* Language Toggle */} -
+
- {/* Page Toggle */} -
+ {/* Logout Button - Only show if not in admin mode */} + {!systemConfig?.admin_mode && ( - -
- - {/* Trader Selector (only show on trader page) */} - {currentPage === 'trader' && traders && traders.length > 0 && ( - )} {/* Status Indicator (only show on trader page) */} {currentPage === 'trader' && status && (
- {currentPage === 'traders' ? ( - + {currentPage === 'competition' ? ( + + ) : currentPage === 'traders' ? ( + { + setSelectedTraderId(traderId); + setCurrentPage('trader'); + }} + /> ) : ( )} @@ -263,6 +331,30 @@ function App() {
@@ -278,8 +370,14 @@ function TraderDetailsPage({ decisions, lastUpdate, language, + traders, + selectedTraderId, + onTraderSelect, }: { selectedTrader?: TraderInfo; + traders?: TraderInfo[]; + selectedTraderId?: string; + onTraderSelect: (traderId: string) => void; status?: SystemStatus; account?: AccountInfo; positions?: Position[]; @@ -320,14 +418,35 @@ function TraderDetailsPage({
{/* Trader Header */}
-

- - 🤖 - - {selectedTrader.trader_name} -

+
+

+ + 🤖 + + {selectedTrader.trader_name} +

+ + {/* Trader Selector */} + {traders && traders.length > 0 && ( +
+ 切换交易员: + +
+ )} +
- AI Model: {selectedTrader.ai_model.toUpperCase()} + AI Model: {getModelDisplayName(selectedTrader.ai_model.split('_').pop() || selectedTrader.ai_model)} {status && ( <> @@ -669,11 +788,13 @@ function DecisionCard({ decision, language }: { decision: DecisionRecord; langua ); } -// Wrap App with LanguageProvider -export default function AppWithLanguage() { +// Wrap App with providers +export default function AppWithProviders() { return ( - + + + ); } diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 27e0986a..9f157652 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -4,8 +4,34 @@ import { api } from '../lib/api'; import type { TraderInfo, CreateTraderRequest, AIModel, Exchange } from '../types'; import { useLanguage } from '../contexts/LanguageContext'; import { t } from '../i18n/translations'; +import { getExchangeIcon } from './ExchangeIcons'; +import { getModelIcon } from './ModelIcons'; -export function AITradersPage() { +// 获取友好的AI模型名称 +function getModelDisplayName(modelId: string): string { + switch (modelId.toLowerCase()) { + case 'deepseek': + return 'DeepSeek'; + case 'qwen': + return 'Qwen'; + case 'claude': + return 'Claude'; + case 'gpt4': + case 'gpt-4': + return 'GPT-4'; + case 'gpt3.5': + case 'gpt-3.5': + return 'GPT-3.5'; + default: + return modelId.toUpperCase(); + } +} + +interface AITradersPageProps { + onTraderSelect?: (traderId: string) => void; +} + +export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const { language } = useLanguage(); const [showCreateModal, setShowCreateModal] = useState(false); const [showModelModal, setShowModelModal] = useState(false); @@ -14,6 +40,8 @@ export function AITradersPage() { const [editingExchange, setEditingExchange] = useState(null); const [allModels, setAllModels] = useState([]); const [allExchanges, setAllExchanges] = useState([]); + const [supportedModels, setSupportedModels] = useState([]); + const [supportedExchanges, setSupportedExchanges] = useState([]); const { data: traders, mutate: mutateTraders } = useSWR( 'traders', @@ -25,22 +53,41 @@ export function AITradersPage() { useEffect(() => { const loadConfigs = async () => { try { - const [modelConfigs, exchangeConfigs] = await Promise.all([ + console.log('🔄 开始加载模型和交易所配置...'); + const [modelConfigs, exchangeConfigs, supportedModels, supportedExchanges] = await Promise.all([ api.getModelConfigs(), - api.getExchangeConfigs() + api.getExchangeConfigs(), + api.getSupportedModels(), + api.getSupportedExchanges() ]); + console.log('✅ 用户模型配置加载成功:', modelConfigs); + console.log('✅ 用户交易所配置加载成功:', exchangeConfigs); + console.log('✅ 支持的模型加载成功:', supportedModels); + console.log('✅ 支持的交易所加载成功:', supportedExchanges); setAllModels(modelConfigs); setAllExchanges(exchangeConfigs); + setSupportedModels(supportedModels); + setSupportedExchanges(supportedExchanges); } catch (error) { - console.error('Failed to load configs:', error); + console.error('❌ 加载配置失败:', error); } }; loadConfigs(); }, []); - // 只显示已配置的模型和交易所 - const configuredModels = allModels.filter(m => m.enabled && m.apiKey); - const configuredExchanges = allExchanges.filter(e => e.enabled && e.apiKey && (e.id === 'hyperliquid' || e.secretKey)); + // 显示所有用户的模型和交易所配置(用于调试) + const configuredModels = allModels || []; + const configuredExchanges = allExchanges || []; + + // 只在创建交易员时使用已启用且配置完整的 + const enabledModels = allModels?.filter(m => m.enabled && m.apiKey) || []; + const enabledExchanges = allExchanges?.filter(e => { + if (!e.enabled || !e.apiKey) return false; + // Hyperliquid 只需要私钥(作为apiKey),不需要secretKey + if (e.id === 'hyperliquid') return true; + // 其他交易所需要secretKey + return e.secretKey && e.secretKey.trim() !== ''; + }) || []; // 检查模型是否正在被运行中的交易员使用 const isModelInUse = (modelId: string) => { @@ -52,10 +99,10 @@ export function AITradersPage() { return traders?.some(t => t.exchange_id === exchangeId && t.is_running) || false; }; - const handleCreateTrader = async (modelId: string, exchangeId: string, name: string, initialBalance: number) => { + const handleCreateTrader = async (modelId: string, exchangeId: string, name: string, initialBalance: number, customPrompt?: string, overrideBase?: boolean) => { try { - const model = allModels.find(m => m.id === modelId); - const exchange = allExchanges.find(e => e.id === exchangeId); + const model = allModels?.find(m => m.id === modelId); + const exchange = allExchanges?.find(e => e.id === exchangeId); if (!model?.enabled) { alert(t('modelNotConfigured', language)); @@ -71,7 +118,9 @@ export function AITradersPage() { name, ai_model_id: modelId, exchange_id: exchangeId, - initial_balance: initialBalance + initial_balance: initialBalance, + custom_prompt: customPrompt, + override_base_prompt: overrideBase }; await api.createTrader(request); @@ -127,9 +176,9 @@ export function AITradersPage() { if (!confirm('确定要删除此AI模型配置吗?')) return; try { - const updatedModels = allModels.map(m => + const updatedModels = allModels?.map(m => m.id === modelId ? { ...m, apiKey: '', enabled: false } : m - ); + ) || []; const request = { models: Object.fromEntries( @@ -155,9 +204,27 @@ export function AITradersPage() { const handleSaveModelConfig = async (modelId: string, apiKey: string) => { try { - const updatedModels = allModels.map(m => - m.id === modelId ? { ...m, apiKey, enabled: true } : m - ); + // 找到要配置的模型(从supportedModels中) + const modelToUpdate = supportedModels?.find(m => m.id === modelId); + if (!modelToUpdate) { + alert('模型不存在'); + return; + } + + // 创建或更新用户的模型配置 + const existingModel = allModels?.find(m => m.id === modelId); + let updatedModels; + + if (existingModel) { + // 更新现有配置 + updatedModels = allModels?.map(m => + m.id === modelId ? { ...m, apiKey, enabled: true } : m + ) || []; + } else { + // 添加新配置 + const newModel = { ...modelToUpdate, apiKey, enabled: true }; + updatedModels = [...(allModels || []), newModel]; + } const request = { models: Object.fromEntries( @@ -172,7 +239,11 @@ export function AITradersPage() { }; await api.updateModelConfigs(request); - setAllModels(updatedModels); + + // 重新获取用户配置以确保数据同步 + const refreshedModels = await api.getModelConfigs(); + setAllModels(refreshedModels); + setShowModelModal(false); setEditingModel(null); } catch (error) { @@ -185,9 +256,9 @@ export function AITradersPage() { if (!confirm('确定要删除此交易所配置吗?')) return; try { - const updatedExchanges = allExchanges.map(e => + const updatedExchanges = allExchanges?.map(e => e.id === exchangeId ? { ...e, apiKey: '', secretKey: '', enabled: false } : e - ); + ) || []; const request = { exchanges: Object.fromEntries( @@ -213,11 +284,29 @@ export function AITradersPage() { } }; - const handleSaveExchangeConfig = async (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean) => { + const handleSaveExchangeConfig = async (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, asterSigner?: string, asterPrivateKey?: string) => { try { - const updatedExchanges = allExchanges.map(e => - e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, enabled: true } : e - ); + // 找到要配置的交易所(从supportedExchanges中) + const exchangeToUpdate = supportedExchanges?.find(e => e.id === exchangeId); + if (!exchangeToUpdate) { + alert('交易所不存在'); + return; + } + + // 创建或更新用户的交易所配置 + const existingExchange = allExchanges?.find(e => e.id === exchangeId); + let updatedExchanges; + + if (existingExchange) { + // 更新现有配置 + updatedExchanges = allExchanges?.map(e => + e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, enabled: true } : e + ) || []; + } else { + // 添加新配置 + const newExchange = { ...exchangeToUpdate, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, enabled: true }; + updatedExchanges = [...(allExchanges || []), newExchange]; + } const request = { exchanges: Object.fromEntries( @@ -227,14 +316,22 @@ export function AITradersPage() { enabled: exchange.enabled, api_key: exchange.apiKey || '', secret_key: exchange.secretKey || '', - testnet: exchange.testnet || false + testnet: exchange.testnet || false, + hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', + aster_user: exchange.asterUser || '', + aster_signer: exchange.asterSigner || '', + aster_private_key: exchange.asterPrivateKey || '' } ]) ) }; await api.updateExchangeConfigs(request); - setAllExchanges(updatedExchanges); + + // 重新获取用户配置以确保数据同步 + const refreshedExchanges = await api.getExchangeConfigs(); + setAllExchanges(refreshedExchanges); + setShowExchangeModal(false); setEditingExchange(null); } catch (error) { @@ -339,21 +436,25 @@ export function AITradersPage() { onClick={() => handleModelClick(model.id)} >
-
- {model.name[0]} +
+ {getModelIcon(model.provider || model.id, { width: 32, height: 32 }) || ( +
+ {model.name[0]} +
+ )}
{model.name}
- {t('configured', language)} + {inUse ? '正在使用' : model.enabled ? '已启用' : '已配置'}
-
+
); })} @@ -384,21 +485,17 @@ export function AITradersPage() { onClick={() => handleExchangeClick(exchange.id)} >
-
- {exchange.name[0]} +
+ {getExchangeIcon(exchange.id, { width: 32, height: 32 })}
{exchange.name}
- {exchange.type.toUpperCase()} • {t('configured', language)} + {exchange.type.toUpperCase()} • {inUse ? '正在使用' : exchange.enabled ? '已启用' : '已配置'}
-
+
); })} @@ -429,7 +526,7 @@ export function AITradersPage() {
🤖 @@ -439,9 +536,9 @@ export function AITradersPage() { {trader.trader_name}
- {trader.ai_model.toUpperCase()} Model • {trader.exchange_id?.toUpperCase()} + {getModelDisplayName(trader.ai_model.split('_').pop() || trader.ai_model)} Model • {trader.exchange_id?.toUpperCase()}
@@ -463,7 +560,15 @@ export function AITradersPage() { {/* Actions */}
+ +
+ + {/* Advanced Settings Toggle */} +
+ +
+ + {/* Custom Prompt Field - Show when advanced is toggled */} + {showAdvanced && ( +
+ +