account system、custom prompt

This commit is contained in:
icy
2025-10-31 03:42:01 +08:00
parent bbe1e1f929
commit ceb2f7b435
32 changed files with 3873 additions and 465 deletions

View File

@@ -64,23 +64,23 @@ nano config.json # 或使用其他编辑器
**必须配置的字段:** **必须配置的字段:**
```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, "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 步:一键启动 ### 第 2 步:一键启动
```bash ```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 ```bash
# 确保 config.json 在 .gitignore 中 # 确保 config.json 在 .gitignore 中
echo "config.json" >> .gitignore echo "config.json" >> .gitignore
``` ```
2. **使用环境变量存储敏感信息** 3. **使用环境变量存储敏感信息**
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
services: services:
backend: backend:
environment: environment:
- BINANCE_API_KEY=${BINANCE_API_KEY} - JWT_SECRET=${JWT_SECRET}
- BINANCE_SECRET_KEY=${BINANCE_SECRET_KEY} # 用户的API密钥现在通过Web界面配置不再需要环境变量
``` ```
3. **限制 API 访问** 4. **限制 API 访问**
```yaml ```yaml
# 只允许本地访问 # 只允许本地访问
services: services:

View File

@@ -4,11 +4,14 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"nofx/auth"
"nofx/config" "nofx/config"
"nofx/manager" "nofx/manager"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
) )
// Server HTTP API服务器 // Server HTTP API服务器
@@ -66,30 +69,51 @@ func (s *Server) setupRoutes() {
// API路由组 // API路由组
api := s.router.Group("/api") api := s.router.Group("/api")
{ {
// AI交易员管理 // 认证相关路由(无需认证)
api.GET("/traders", s.handleTraderList) api.POST("/register", s.handleRegister)
api.POST("/traders", s.handleCreateTrader) api.POST("/login", s.handleLogin)
api.DELETE("/traders/:id", s.handleDeleteTrader) api.POST("/verify-otp", s.handleVerifyOTP)
api.POST("/traders/:id/start", s.handleStartTrader) api.POST("/complete-registration", s.handleCompleteRegistration)
api.POST("/traders/:id/stop", s.handleStopTrader)
// 系统支持的模型和交易所(无需认证)
api.GET("/supported-models", s.handleGetSupportedModels)
api.GET("/supported-exchanges", s.handleGetSupportedExchanges)
// 系统配置(无需认证)
api.GET("/config", s.handleGetSystemConfig)
// AI模型配置 // 需要认证的路由
api.GET("/models", s.handleGetModelConfigs) protected := api.Group("/", s.authMiddleware())
api.PUT("/models", s.handleUpdateModelConfigs) {
// 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)
// 交易所配置 // AI模型配置
api.GET("/exchanges", s.handleGetExchangeConfigs) protected.GET("/models", s.handleGetModelConfigs)
api.PUT("/exchanges", s.handleUpdateExchangeConfigs) protected.PUT("/models", s.handleUpdateModelConfigs)
// 指定trader的数据使用query参数 ?trader_id=xxx // 交易所配置
api.GET("/status", s.handleStatus) protected.GET("/exchanges", s.handleGetExchangeConfigs)
api.GET("/account", s.handleAccount) protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
api.GET("/positions", s.handlePositions)
api.GET("/decisions", s.handleDecisions) // 竞赛总览
api.GET("/decisions/latest", s.handleLatestDecisions) protected.GET("/competition", s.handleCompetition)
api.GET("/statistics", s.handleStatistics)
api.GET("/equity-history", s.handleEquityHistory) // 指定trader的数据使用query参数 ?trader_id=xxx
api.GET("/performance", s.handlePerformance) 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 // getTraderFromQuery 从query参数获取trader
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) { func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
userID := c.GetString("user_id")
traderID := c.Query("trader_id") traderID := c.Query("trader_id")
// 确保用户的交易员已加载到内存中
err := s.traderManager.LoadUserTraders(s.database, userID)
if err != nil {
log.Printf("⚠️ 加载用户 %s 的交易员失败: %v", userID, err)
}
if traderID == "" { if traderID == "" {
// 如果没有指定trader_id返回第一个trader // 如果没有指定trader_id返回该用户的第一个trader
ids := s.traderManager.GetTraderIDs() ids := s.traderManager.GetTraderIDs()
if len(ids) == 0 { if len(ids) == 0 {
return nil, "", fmt.Errorf("没有可用的trader") 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 return s.traderManager, traderID, nil
} }
@@ -121,6 +168,8 @@ type CreateTraderRequest struct {
AIModelID string `json:"ai_model_id" binding:"required"` AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"` ExchangeID string `json:"exchange_id" binding:"required"`
InitialBalance float64 `json:"initial_balance"` InitialBalance float64 `json:"initial_balance"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
} }
type ModelConfig struct { type ModelConfig struct {
@@ -150,15 +199,20 @@ type UpdateModelConfigRequest struct {
type UpdateExchangeConfigRequest struct { type UpdateExchangeConfigRequest struct {
Exchanges map[string]struct { Exchanges map[string]struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"` SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"` 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"` } `json:"exchanges"`
} }
// handleCreateTrader 创建新的AI交易员 // handleCreateTrader 创建新的AI交易员
func (s *Server) handleCreateTrader(c *gin.Context) { func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -171,10 +225,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// 创建交易员配置 // 创建交易员配置
trader := &config.TraderConfig{ trader := &config.TraderConfig{
ID: traderID, ID: traderID,
UserID: userID,
Name: req.Name, Name: req.Name,
AIModelID: req.AIModelID, AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID, ExchangeID: req.ExchangeID,
InitialBalance: req.InitialBalance, InitialBalance: req.InitialBalance,
CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt,
ScanIntervalMinutes: 3, // 默认3分钟 ScanIntervalMinutes: 3, // 默认3分钟
IsRunning: false, IsRunning: false,
} }
@@ -186,6 +243,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
return 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) log.Printf("✓ 创建交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID)
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
@@ -198,10 +262,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// handleDeleteTrader 删除交易员 // handleDeleteTrader 删除交易员
func (s *Server) handleDeleteTrader(c *gin.Context) { func (s *Server) handleDeleteTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id") traderID := c.Param("id")
// 从数据库删除 // 从数据库删除
err := s.database.DeleteTrader(traderID) err := s.database.DeleteTrader(userID, traderID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("删除交易员失败: %v", err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("删除交易员失败: %v", err)})
return 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 { if err != nil {
log.Printf("⚠️ 更新交易员状态失败: %v", err) log.Printf("⚠️ 更新交易员状态失败: %v", err)
} }
@@ -276,7 +342,8 @@ func (s *Server) handleStopTrader(c *gin.Context) {
trader.Stop() trader.Stop()
// 更新数据库中的运行状态 // 更新数据库中的运行状态
err = s.database.UpdateTraderStatus(traderID, false) userID := c.GetString("user_id")
err = s.database.UpdateTraderStatus(userID, traderID, false)
if err != nil { if err != nil {
log.Printf("⚠️ 更新交易员状态失败: %v", err) log.Printf("⚠️ 更新交易员状态失败: %v", err)
} }
@@ -285,19 +352,57 @@ func (s *Server) handleStopTrader(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "交易员已停止"}) 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模型配置 // handleGetModelConfigs 获取AI模型配置
func (s *Server) handleGetModelConfigs(c *gin.Context) { 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 { if err != nil {
log.Printf("❌ 获取AI模型配置失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取AI模型配置失败: %v", err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取AI模型配置失败: %v", err)})
return return
} }
log.Printf("✅ 找到 %d 个AI模型配置", len(models))
c.JSON(http.StatusOK, models) c.JSON(http.StatusOK, models)
} }
// handleUpdateModelConfigs 更新AI模型配置 // handleUpdateModelConfigs 更新AI模型配置
func (s *Server) handleUpdateModelConfigs(c *gin.Context) { func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
userID := c.GetString("user_id")
var req UpdateModelConfigRequest var req UpdateModelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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 { 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新模型 %s 失败: %v", modelID, err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新模型 %s 失败: %v", modelID, err)})
return return
@@ -319,17 +424,22 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
// handleGetExchangeConfigs 获取交易所配置 // handleGetExchangeConfigs 获取交易所配置
func (s *Server) handleGetExchangeConfigs(c *gin.Context) { 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 { if err != nil {
log.Printf("❌ 获取交易所配置失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易所配置失败: %v", err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易所配置失败: %v", err)})
return return
} }
log.Printf("✅ 找到 %d 个交易所配置", len(exchanges))
c.JSON(http.StatusOK, exchanges) c.JSON(http.StatusOK, exchanges)
} }
// handleUpdateExchangeConfigs 更新交易所配置 // handleUpdateExchangeConfigs 更新交易所配置
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
userID := c.GetString("user_id")
var req UpdateExchangeConfigRequest var req UpdateExchangeConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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 { 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)})
return return
@@ -351,7 +461,8 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
// handleTraderList trader列表 // handleTraderList trader列表
func (s *Server) handleTraderList(c *gin.Context) { 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易员列表失败: %v", err)}) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易员列表失败: %v", err)})
return return
@@ -539,6 +650,27 @@ func (s *Server) handleStatistics(c *gin.Context) {
c.JSON(http.StatusOK, stats) 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 收益率历史数据 // handleEquityHistory 收益率历史数据
func (s *Server) handleEquityHistory(c *gin.Context) { func (s *Server) handleEquityHistory(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c) _, traderID, err := s.getTraderFromQuery(c)
@@ -652,6 +784,278 @@ func (s *Server) handlePerformance(c *gin.Context) {
c.JSON(http.StatusOK, performance) 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 启动服务器 // Start 启动服务器
func (s *Server) Start() error { func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.port) addr := fmt.Sprintf(":%d", s.port)

121
auth/auth.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -1,8 +1,11 @@
package config package config
import ( import (
"crypto/rand"
"database/sql" "database/sql"
"encoding/base32"
"fmt" "fmt"
"log"
"time" "time"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@@ -38,30 +41,41 @@ func (d *Database) createTables() error {
// AI模型配置表 // AI模型配置表
`CREATE TABLE IF NOT EXISTS ai_models ( `CREATE TABLE IF NOT EXISTS ai_models (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL, name TEXT NOT NULL,
provider TEXT NOT NULL, provider TEXT NOT NULL,
enabled BOOLEAN DEFAULT 0, enabled BOOLEAN DEFAULT 0,
api_key TEXT DEFAULT '', api_key TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 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 ( `CREATE TABLE IF NOT EXISTS exchanges (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL, name TEXT NOT NULL,
type TEXT NOT NULL, -- 'cex' or 'dex' type TEXT NOT NULL, -- 'cex' or 'dex'
enabled BOOLEAN DEFAULT 0, enabled BOOLEAN DEFAULT 0,
api_key TEXT DEFAULT '', api_key TEXT DEFAULT '',
secret_key TEXT DEFAULT '', secret_key TEXT DEFAULT '',
testnet BOOLEAN DEFAULT 0, 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, 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 ( `CREATE TABLE IF NOT EXISTS traders (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL, name TEXT NOT NULL,
ai_model_id TEXT NOT NULL, ai_model_id TEXT NOT NULL,
exchange_id TEXT NOT NULL, exchange_id TEXT NOT NULL,
@@ -70,10 +84,22 @@ func (d *Database) createTables() error {
is_running BOOLEAN DEFAULT 0, is_running BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 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,
FOREIGN KEY (ai_model_id) REFERENCES ai_models(id), FOREIGN KEY (ai_model_id) REFERENCES ai_models(id),
FOREIGN KEY (exchange_id) REFERENCES exchanges(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 ( `CREATE TABLE IF NOT EXISTS system_config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -82,6 +108,12 @@ func (d *Database) createTables() error {
)`, )`,
// 触发器:自动更新 updated_at // 触发器:自动更新 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 `CREATE TRIGGER IF NOT EXISTS update_ai_models_updated_at
AFTER UPDATE ON ai_models AFTER UPDATE ON ai_models
BEGIN 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 return nil
} }
// initDefaultData 初始化默认数据 // initDefaultData 初始化默认数据
func (d *Database) initDefaultData() error { func (d *Database) initDefaultData() error {
// 初始化AI模型 // 初始化AI模型使用default用户
aiModels := []struct { aiModels := []struct {
id, name, provider string id, name, provider string
}{ }{
@@ -128,26 +181,27 @@ func (d *Database) initDefaultData() error {
for _, model := range aiModels { for _, model := range aiModels {
_, err := d.db.Exec(` _, err := d.db.Exec(`
INSERT OR IGNORE INTO ai_models (id, name, provider, enabled) INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled)
VALUES (?, ?, ?, 0) VALUES (?, 'default', ?, ?, 0)
`, model.id, model.name, model.provider) `, model.id, model.name, model.provider)
if err != nil { if err != nil {
return fmt.Errorf("初始化AI模型失败: %w", err) return fmt.Errorf("初始化AI模型失败: %w", err)
} }
} }
// 初始化交易所 // 初始化交易所使用default用户
exchanges := []struct { exchanges := []struct {
id, name, typ string id, name, typ string
}{ }{
{"binance", "Binance", "cex"}, {"binance", "Binance Futures", "binance"},
{"hyperliquid", "Hyperliquid", "dex"}, {"hyperliquid", "Hyperliquid", "hyperliquid"},
{"aster", "Aster DEX", "aster"},
} }
for _, exchange := range exchanges { for _, exchange := range exchanges {
_, err := d.db.Exec(` _, err := d.db.Exec(`
INSERT OR IGNORE INTO exchanges (id, name, type, enabled) INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled)
VALUES (?, ?, ?, 0) VALUES (?, 'default', ?, ?, 0)
`, exchange.id, exchange.name, exchange.typ) `, exchange.id, exchange.name, exchange.typ)
if err != nil { if err != nil {
return fmt.Errorf("初始化交易所失败: %w", err) return fmt.Errorf("初始化交易所失败: %w", err)
@@ -156,7 +210,7 @@ func (d *Database) initDefaultData() error {
// 初始化系统配置 // 初始化系统配置
systemConfigs := map[string]string{ systemConfigs := map[string]string{
"api_server_port": "8081", "api_server_port": "8080",
"use_default_coins": "true", "use_default_coins": "true",
"coin_pool_api_url": "", "coin_pool_api_url": "",
"oi_top_api_url": "", "oi_top_api_url": "",
@@ -178,9 +232,103 @@ func (d *Database) initDefaultData() error {
return nil 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模型配置 // AIModelConfig AI模型配置
type AIModelConfig struct { type AIModelConfig struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Provider string `json:"provider"` Provider string `json:"provider"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -192,45 +340,139 @@ type AIModelConfig struct {
// ExchangeConfig 交易所配置 // ExchangeConfig 交易所配置
type ExchangeConfig struct { type ExchangeConfig struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"` SecretKey string `json:"secretKey"`
Testnet bool `json:"testnet"` Testnet bool `json:"testnet"`
CreatedAt time.Time `json:"created_at"` // Hyperliquid 特定字段
UpdatedAt time.Time `json:"updated_at"` 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 交易员配置 // TraderConfig 交易员配置
type TraderConfig struct { type TraderConfig struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
AIModelID string `json:"ai_model_id"` AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"` ExchangeID string `json:"exchange_id"`
InitialBalance float64 `json:"initial_balance"` InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"` IsRunning bool `json:"is_running"`
CustomPrompt string `json:"custom_prompt"` // 自定义交易策略prompt
OverrideBasePrompt bool `json:"override_base_prompt"` // 是否覆盖基础prompt
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// GetAIModels 获取所有AI模型配置 // GenerateOTPSecret 生成OTP密钥
func (d *Database) GetAIModels() ([]*AIModelConfig, error) { 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(` rows, err := d.db.Query(`
SELECT id, name, provider, enabled, api_key, created_at, updated_at SELECT id, user_id, name, provider, enabled, api_key, created_at, updated_at
FROM ai_models ORDER BY id FROM ai_models WHERE user_id = ? ORDER BY id
`) `, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var models []*AIModelConfig // 初始化为空切片而不是nil确保JSON序列化为[]而不是null
models := make([]*AIModelConfig, 0)
for rows.Next() { for rows.Next() {
var model AIModelConfig var model AIModelConfig
err := rows.Scan( err := rows.Scan(
&model.ID, &model.Name, &model.Provider, &model.ID, &model.UserID, &model.Name, &model.Provider,
&model.Enabled, &model.APIKey, &model.Enabled, &model.APIKey,
&model.CreatedAt, &model.UpdatedAt, &model.CreatedAt, &model.UpdatedAt,
) )
@@ -243,31 +485,80 @@ func (d *Database) GetAIModels() ([]*AIModelConfig, error) {
return models, nil return models, nil
} }
// UpdateAIModel 更新AI模型配置 // UpdateAIModel 更新AI模型配置,如果不存在则创建用户特定配置
func (d *Database) UpdateAIModel(id string, enabled bool, apiKey string) error { func (d *Database) UpdateAIModel(userID, id string, enabled bool, apiKey string) error {
_, err := d.db.Exec(` // 首先尝试更新现有的用户配置
UPDATE ai_models SET enabled = ?, api_key = ? WHERE id = ? result, err := d.db.Exec(`
`, enabled, apiKey, id) UPDATE ai_models SET enabled = ?, api_key = ? WHERE id = ? AND user_id = ?
return err `, 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 获取所有交易所配置 // GetExchanges 获取用户的交易所配置
func (d *Database) GetExchanges() ([]*ExchangeConfig, error) { func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) {
rows, err := d.db.Query(` rows, err := d.db.Query(`
SELECT id, name, type, enabled, api_key, secret_key, testnet, created_at, updated_at SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet,
FROM exchanges ORDER BY id 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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var exchanges []*ExchangeConfig // 初始化为空切片而不是nil确保JSON序列化为[]而不是null
exchanges := make([]*ExchangeConfig, 0)
for rows.Next() { for rows.Next() {
var exchange ExchangeConfig var exchange ExchangeConfig
err := rows.Scan( 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.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
&exchange.HyperliquidWalletAddr, &exchange.AsterUser,
&exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.CreatedAt, &exchange.UpdatedAt, &exchange.CreatedAt, &exchange.UpdatedAt,
) )
if err != nil { if err != nil {
@@ -279,29 +570,105 @@ func (d *Database) GetExchanges() ([]*ExchangeConfig, error) {
return exchanges, nil return exchanges, nil
} }
// UpdateExchange 更新交易所配置 // UpdateExchange 更新交易所配置,如果不存在则创建用户特定配置
func (d *Database) UpdateExchange(id string, enabled bool, apiKey, secretKey string, testnet bool) error { 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(` _, err := d.db.Exec(`
UPDATE exchanges SET enabled = ?, api_key = ?, secret_key = ?, testnet = ? WHERE id = ? INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled, api_key)
`, enabled, apiKey, secretKey, testnet, id) 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 return err
} }
// CreateTrader 创建交易员 // CreateTrader 创建交易员
func (d *Database) CreateTrader(trader *TraderConfig) error { func (d *Database) CreateTrader(trader *TraderConfig) error {
_, err := d.db.Exec(` _, err := d.db.Exec(`
INSERT INTO traders (id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running) 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 (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, trader.ID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning) `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.CustomPrompt, trader.OverrideBasePrompt)
return err return err
} }
// GetTraders 获取所有交易员 // GetTraders 获取用户的交易员
func (d *Database) GetTraders() ([]*TraderConfig, error) { func (d *Database) GetTraders(userID string) ([]*TraderConfig, error) {
rows, err := d.db.Query(` rows, err := d.db.Query(`
SELECT id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, created_at, updated_at SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running,
FROM traders ORDER BY created_at DESC 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 { if err != nil {
return nil, err return nil, err
} }
@@ -311,9 +678,9 @@ func (d *Database) GetTraders() ([]*TraderConfig, error) {
for rows.Next() { for rows.Next() {
var trader TraderConfig var trader TraderConfig
err := rows.Scan( 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.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
&trader.CreatedAt, &trader.UpdatedAt, &trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.CreatedAt, &trader.UpdatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -325,40 +692,52 @@ func (d *Database) GetTraders() ([]*TraderConfig, error) {
} }
// UpdateTraderStatus 更新交易员状态 // UpdateTraderStatus 更新交易员状态
func (d *Database) UpdateTraderStatus(id string, isRunning bool) error { func (d *Database) UpdateTraderStatus(userID, id string, isRunning bool) error {
_, err := d.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ?`, isRunning, id) _, 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 return err
} }
// DeleteTrader 删除交易员 // DeleteTrader 删除交易员
func (d *Database) DeleteTrader(id string) error { func (d *Database) DeleteTrader(userID, id string) error {
_, err := d.db.Exec(`DELETE FROM traders WHERE id = ?`, id) _, err := d.db.Exec(`DELETE FROM traders WHERE id = ? AND user_id = ?`, id, userID)
return err return err
} }
// GetTraderConfig 获取交易员完整配置包含AI模型和交易所信息 // 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 trader TraderConfig
var aiModel AIModelConfig var aiModel AIModelConfig
var exchange ExchangeConfig var exchange ExchangeConfig
err := d.db.QueryRow(` err := d.db.QueryRow(`
SELECT 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, 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.name, a.provider, a.enabled, a.api_key, a.created_at, a.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.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, e.created_at, e.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 FROM traders t
JOIN ai_models a ON t.ai_model_id = a.id 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 JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id
WHERE t.id = ? WHERE t.id = ? AND t.user_id = ?
`, traderID).Scan( `, traderID, userID).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.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
&trader.CreatedAt, &trader.UpdatedAt, &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, &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.APIKey, &exchange.SecretKey, &exchange.Testnet,
&exchange.HyperliquidWalletAddr, &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.CreatedAt, &exchange.UpdatedAt, &exchange.CreatedAt, &exchange.UpdatedAt,
) )

View File

@@ -91,13 +91,18 @@ type FullDecision struct {
// GetFullDecision 获取AI的完整交易决策批量分析所有币种和持仓 // GetFullDecision 获取AI的完整交易决策批量分析所有币种和持仓
func GetFullDecision(ctx *Context, mcpClient *mcp.Client) (*FullDecision, error) { 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. 为所有币种获取市场数据 // 1. 为所有币种获取市场数据
if err := fetchMarketDataForContext(ctx); err != nil { if err := fetchMarketDataForContext(ctx); err != nil {
return nil, fmt.Errorf("获取市场数据失败: %w", err) return nil, fmt.Errorf("获取市场数据失败: %w", err)
} }
// 2. 构建 System Prompt固定规则和 User Prompt动态数据 // 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) userPrompt := buildUserPrompt(ctx)
// 3. 调用AI API使用 system + user prompt // 3. 调用AI API使用 system + user prompt
@@ -199,6 +204,33 @@ func calculateMaxCandidates(ctx *Context) int {
return len(ctx.CandidateCoins) 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固定规则可缓存 // buildSystemPrompt 构建 System Prompt固定规则可缓存
func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage int) string { func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage int) string {
var sb strings.Builder var sb strings.Builder

7
go.mod
View File

@@ -6,14 +6,19 @@ require (
github.com/adshao/go-binance/v2 v2.8.7 github.com/adshao/go-binance/v2 v2.8.7
github.com/ethereum/go-ethereum v1.16.5 github.com/ethereum/go-ethereum v1.16.5
github.com/gin-gonic/gin v1.11.0 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/mattn/go-sqlite3 v1.14.32
github.com/pquerna/otp v1.4.0
github.com/sonirico/go-hyperliquid v0.17.0 github.com/sonirico/go-hyperliquid v0.17.0
golang.org/x/crypto v0.42.0
) )
require ( require (
github.com/armon/go-radix v1.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.0 // indirect github.com/bitly/go-simplejson v0.5.0 // indirect
github.com/bits-and-blooms/bitset v1.24.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 v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // 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/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/joho/godotenv v1.5.1 // indirect
@@ -66,7 +70,6 @@ require (
go.elastic.co/fastjson v1.5.1 // indirect go.elastic.co/fastjson v1.5.1 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.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/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect

6
go.sum
View File

@@ -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/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 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 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 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= 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/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 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= 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 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=

30
main.go
View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"nofx/api" "nofx/api"
"nofx/auth"
"nofx/config" "nofx/config"
"nofx/manager" "nofx/manager"
"nofx/pool" "nofx/pool"
@@ -38,6 +39,29 @@ func main() {
useDefaultCoins := useDefaultCoinsStr == "true" useDefaultCoins := useDefaultCoinsStr == "true"
apiPortStr, _ := database.GetSystemConfig("api_server_port") 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("✓ 配置数据库初始化成功") log.Printf("✓ 配置数据库初始化成功")
fmt.Println() fmt.Println()
@@ -73,8 +97,8 @@ func main() {
log.Fatalf("❌ 加载交易员失败: %v", err) log.Fatalf("❌ 加载交易员失败: %v", err)
} }
// 获取数据库中的所有交易员配置(用于显示) // 获取数据库中的所有交易员配置(用于显示使用default用户
traders, err := database.GetTraders() traders, err := database.GetTraders("default")
if err != nil { if err != nil {
log.Fatalf("❌ 获取交易员列表失败: %v", err) log.Fatalf("❌ 获取交易员列表失败: %v", err)
} }
@@ -110,7 +134,7 @@ func main() {
fmt.Println() fmt.Println()
// 获取API服务器端口 // 获取API服务器端口
apiPort := 8081 // 默认端口 apiPort := 8080 // 默认端口
if apiPortStr != "" { if apiPortStr != "" {
if port, err := strconv.Atoi(apiPortStr); err == nil { if port, err := strconv.Atoi(apiPortStr); err == nil {
apiPort = port apiPort = port

View File

@@ -28,13 +28,20 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro
tm.mu.Lock() tm.mu.Lock()
defer tm.mu.Unlock() 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 { if err != nil {
return fmt.Errorf("获取交易员列表失败: %w", err) return fmt.Errorf("获取交易员列表失败: %w", err)
} }
log.Printf("📋 加载数据库中的交易员配置: %d 个", len(traders)) log.Printf("📋 加载数据库中的交易员配置: %d 个 (用户: %s)", len(traders), userID)
// 获取系统配置 // 获取系统配置
coinPoolURL, _ := database.GetSystemConfig("coin_pool_api_url") coinPoolURL, _ := database.GetSystemConfig("coin_pool_api_url")
@@ -61,7 +68,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro
// 为每个交易员获取AI模型和交易所配置 // 为每个交易员获取AI模型和交易所配置
for _, traderCfg := range traders { for _, traderCfg := range traders {
// 获取AI模型配置 // 获取AI模型配置
aiModels, err := database.GetAIModels() aiModels, err := database.GetAIModels(userID)
if err != nil { if err != nil {
log.Printf("⚠️ 获取AI模型配置失败: %v", err) log.Printf("⚠️ 获取AI模型配置失败: %v", err)
continue 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 { if err != nil {
log.Printf("⚠️ 获取交易所配置失败: %v", err) log.Printf("⚠️ 获取交易所配置失败: %v", err)
continue continue
@@ -155,6 +162,11 @@ func (tm *TraderManager) addTraderFromConfig(traderCfg *config.TraderConfig, aiM
traderConfig.BinanceSecretKey = exchangeCfg.SecretKey traderConfig.BinanceSecretKey = exchangeCfg.SecretKey
} else if exchangeCfg.ID == "hyperliquid" { } else if exchangeCfg.ID == "hyperliquid" {
traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key 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密钥 // 根据AI模型设置API密钥
@@ -169,6 +181,17 @@ func (tm *TraderManager) addTraderFromConfig(traderCfg *config.TraderConfig, aiM
if err != nil { if err != nil {
return fmt.Errorf("创建trader失败: %w", err) 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 tm.traders[traderCfg.ID] = at
log.Printf("✓ Trader '%s' (%s + %s) 已加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) 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 traderConfig.BinanceSecretKey = exchangeCfg.SecretKey
} else if exchangeCfg.ID == "hyperliquid" { } else if exchangeCfg.ID == "hyperliquid" {
traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key 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密钥 // 根据AI模型设置API密钥
@@ -227,6 +255,17 @@ func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderConfig, aiModel
if err != nil { if err != nil {
return fmt.Errorf("创建trader失败: %w", err) 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 tm.traders[traderCfg.ID] = at
log.Printf("✓ Trader '%s' (%s + %s) 已添加", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) 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 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
}

View File

@@ -77,6 +77,8 @@ type AutoTrader struct {
decisionLogger *logger.DecisionLogger // 决策日志记录器 decisionLogger *logger.DecisionLogger // 决策日志记录器
initialBalance float64 initialBalance float64
dailyPnL float64 dailyPnL float64
customPrompt string // 自定义交易策略prompt
overrideBasePrompt bool // 是否覆盖基础prompt
lastResetTime time.Time lastResetTime time.Time
stopUntil time.Time stopUntil time.Time
isRunning bool isRunning bool
@@ -287,7 +289,7 @@ func (at *AutoTrader) runCycle() error {
// 4. 调用AI获取完整决策 // 4. 调用AI获取完整决策
log.Println("🤖 正在请求AI分析并决策...") log.Println("🤖 正在请求AI分析并决策...")
decision, err := decision.GetFullDecision(ctx, at.mcpClient) decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt)
// 即使有错误也保存思维链、决策和输入prompt用于debug // 即使有错误也保存思维链、决策和输入prompt用于debug
if decision != nil { if decision != nil {
@@ -735,6 +737,16 @@ func (at *AutoTrader) GetAIModel() string {
return at.aiModel 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 获取决策日志记录器 // GetDecisionLogger 获取决策日志记录器
func (at *AutoTrader) GetDecisionLogger() *logger.DecisionLogger { func (at *AutoTrader) GetDecisionLogger() *logger.DecisionLogger {
return at.decisionLogger return at.decisionLogger
@@ -871,14 +883,15 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
leverage = int(lev) 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) marginUsed := (quantity * markPrice) / float64(leverage)
// 计算盈亏百分比(基于保证金)
// 收益率 = 未实现盈亏 / 保证金 × 100%
pnlPct := 0.0
if marginUsed > 0 {
pnlPct = (unrealizedPnl / marginUsed) * 100
}
result = append(result, map[string]interface{}{ result = append(result, map[string]interface{}{
"symbol": symbol, "symbol": symbol,

View File

@@ -0,0 +1,23 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
<defs>
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
<stop offset="1" stop-color="#FFD29F"/>
</linearGradient>
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
<stop offset="1" stop-color="#FFD29F"/>
</linearGradient>
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
<stop offset="1" stop-color="#FFD29F"/>
</linearGradient>
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stop-color="#F4D5B1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="40" width="40" viewBox="-52.785 -88 457.47 528"><path d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z" fill="#f0b90b"/></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="144" height="144" viewBox="0 0 144 144" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z" fill="#97FCE4"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -3,9 +3,14 @@ import useSWR from 'swr';
import { api } from './lib/api'; import { api } from './lib/api';
import { EquityChart } from './components/EquityChart'; import { EquityChart } from './components/EquityChart';
import { AITradersPage } from './components/AITradersPage'; 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 AILearning from './components/AILearning';
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'; import { LanguageProvider, useLanguage } from './contexts/LanguageContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { t, type Language } from './i18n/translations'; import { t, type Language } from './i18n/translations';
import { useSystemConfig } from './hooks/useSystemConfig';
import type { import type {
SystemStatus, SystemStatus,
AccountInfo, AccountInfo,
@@ -15,11 +20,34 @@ import type {
TraderInfo, TraderInfo,
} from './types'; } 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() { function App() {
const { language, setLanguage } = useLanguage(); const { language, setLanguage } = useLanguage();
const [currentPage, setCurrentPage] = useState<Page>('traders'); const { user, token, logout, isLoading } = useAuth();
const { config: systemConfig, loading: configLoading } = useSystemConfig();
const [route, setRoute] = useState(window.location.pathname);
const [currentPage, setCurrentPage] = useState<Page>('competition');
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>(); const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>();
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--'); const [lastUpdate, setLastUpdate] = useState<string>('--:--:--');
@@ -105,59 +133,118 @@ function App() {
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId); 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 (
<div className="min-h-screen flex items-center justify-center" style={{ background: '#0B0E11' }}>
<div className="text-center">
<div className="w-16 h-16 rounded-full mx-auto mb-4 flex items-center justify-center text-3xl animate-spin"
style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
</div>
<p style={{ color: '#EAECEF' }}>...</p>
</div>
</div>
);
}
// If not in admin mode and not authenticated, show login/register pages
if (!systemConfig?.admin_mode && (!user || !token)) {
if (route === '/register') {
return <RegisterPage />;
}
return <LoginPage />;
}
return ( return (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}> <div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
{/* Header - Binance Style */} {/* Header - Binance Style */}
<header className="glass sticky top-0 z-50 backdrop-blur-xl"> <header className="glass sticky top-0 z-50 backdrop-blur-xl">
<div className="max-w-[1920px] mx-auto px-3 sm:px-6 py-3 sm:py-4"> <div className="max-w-[1920px] mx-auto px-6 py-4">
{/* Mobile: Two rows, Desktop: Single row */} <div className="relative flex items-center">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> {/* Left - Logo and Title */}
{/* Left: Logo and Title */} <div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0"> <div className="w-8 h-8 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-lg sm:text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
</div> </div>
<div> <div>
<h1 className="text-base sm:text-xl font-bold leading-tight" style={{ color: '#EAECEF' }}> <h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)} {t('appTitle', language)}
</h1> </h1>
<p className="text-xs mono hidden sm:block" style={{ color: '#848E9C' }}> <p className="text-xs mono" style={{ color: '#848E9C' }}>
{t('subtitle', language)} {t('subtitle', language)}
</p> </p>
</div> </div>
</div> </div>
{/* Right: Controls - Wrap on mobile */} {/* Center - Page Toggle (absolutely positioned) */}
<div className="flex items-center gap-2 flex-wrap md:flex-nowrap"> <div className="absolute left-1/2 transform -translate-x-1/2 flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
{/* GitHub Link - Hidden on mobile, icon only on tablet */} <button
<a onClick={() => setCurrentPage('competition')}
href="https://github.com/tinkle-community/nofx" className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
target="_blank" style={currentPage === 'competition'
rel="noopener noreferrer" ? { background: '#F0B90B', color: '#000' }
className="hidden sm:flex items-center gap-2 px-2 md:px-3 py-1.5 md:py-2 rounded text-sm font-semibold transition-all hover:scale-105" : { background: 'transparent', color: '#848E9C' }
style={{ background: '#1E2329', color: '#848E9C', border: '1px solid #2B3139' }} }
onMouseEnter={(e) => {
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';
}}
> >
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> </button>
</svg> <button
<span className="hidden md:inline">GitHub</span> onClick={() => setCurrentPage('traders')}
</a> className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
style={currentPage === 'traders'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
{t('aiTraders', language)}
</button>
<button
onClick={() => setCurrentPage('trader')}
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
style={currentPage === 'trader'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
{t('tradingPanel', language)}
</button>
</div>
{/* Right - Actions */}
<div className="ml-auto flex items-center gap-3">
{/* User Info - Only show if not in admin mode */}
{!systemConfig?.admin_mode && user && (
<div className="flex items-center gap-2 px-3 py-2 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold" style={{ background: '#F0B90B', color: '#000' }}>
{user.email[0].toUpperCase()}
</div>
<span className="text-sm" style={{ color: '#EAECEF' }}>{user.email}</span>
</div>
)}
{/* Admin Mode Indicator */}
{systemConfig?.admin_mode && (
<div className="flex items-center gap-2 px-3 py-2 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<span className="text-sm font-semibold" style={{ color: '#F0B90B' }}> </span>
</div>
)}
{/* Language Toggle */} {/* Language Toggle */}
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1" style={{ background: '#1E2329' }}> <div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<button <button
onClick={() => setLanguage('zh')} onClick={() => setLanguage('zh')}
className="px-2 sm:px-3 py-1 sm:py-1.5 rounded text-xs font-semibold transition-all" className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'zh' style={language === 'zh'
? { background: '#F0B90B', color: '#000' } ? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' } : { background: 'transparent', color: '#848E9C' }
@@ -167,7 +254,7 @@ function App() {
</button> </button>
<button <button
onClick={() => setLanguage('en')} onClick={() => setLanguage('en')}
className="px-2 sm:px-3 py-1 sm:py-1.5 rounded text-xs font-semibold transition-all" className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'en' style={language === 'en'
? { background: '#F0B90B', color: '#000' } ? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' } : { background: 'transparent', color: '#848E9C' }
@@ -177,50 +264,21 @@ function App() {
</button> </button>
</div> </div>
{/* Page Toggle */} {/* Logout Button - Only show if not in admin mode */}
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1" style={{ background: '#1E2329' }}> {!systemConfig?.admin_mode && (
<button <button
onClick={() => setCurrentPage('traders')} onClick={logout}
className="px-2 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-semibold transition-all" className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={currentPage === 'traders' style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }}
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
> >
{t('aiTraders', language)} 退
</button> </button>
<button
onClick={() => setCurrentPage('trader')}
className="px-2 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-semibold transition-all"
style={currentPage === 'trader'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
{t('tradingPanel', language)}
</button>
</div>
{/* Trader Selector (only show on trader page) */}
{currentPage === 'trader' && traders && traders.length > 0 && (
<select
value={selectedTraderId}
onChange={(e) => setSelectedTraderId(e.target.value)}
className="rounded px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium cursor-pointer transition-colors flex-1 sm:flex-initial"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id}>
{trader.trader_name} ({trader.ai_model.toUpperCase()})
</option>
))}
</select>
)} )}
{/* Status Indicator (only show on trader page) */} {/* Status Indicator (only show on trader page) */}
{currentPage === 'trader' && status && ( {currentPage === 'trader' && status && (
<div <div
className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded" className="flex items-center gap-2 px-3 py-2 rounded"
style={status.is_running style={status.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81', border: '1px solid rgba(14, 203, 129, 0.2)' } ? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81', border: '1px solid rgba(14, 203, 129, 0.2)' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' } : { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }
@@ -242,8 +300,15 @@ function App() {
{/* Main Content */} {/* Main Content */}
<main className="max-w-[1920px] mx-auto px-6 py-6"> <main className="max-w-[1920px] mx-auto px-6 py-6">
{currentPage === 'traders' ? ( {currentPage === 'competition' ? (
<AITradersPage /> <CompetitionPage />
) : currentPage === 'traders' ? (
<AITradersPage
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId);
setCurrentPage('trader');
}}
/>
) : ( ) : (
<TraderDetailsPage <TraderDetailsPage
selectedTrader={selectedTrader} selectedTrader={selectedTrader}
@@ -254,6 +319,9 @@ function App() {
stats={stats} stats={stats}
lastUpdate={lastUpdate} lastUpdate={lastUpdate}
language={language} language={language}
traders={traders}
selectedTraderId={selectedTraderId}
onTraderSelect={setSelectedTraderId}
/> />
)} )}
</main> </main>
@@ -263,6 +331,30 @@ function App() {
<div className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm" style={{ color: '#5E6673' }}> <div className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm" style={{ color: '#5E6673' }}>
<p>{t('footerTitle', language)}</p> <p>{t('footerTitle', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p> <p className="mt-1">{t('footerWarning', language)}</p>
<div className="mt-4">
<a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#1E2329', color: '#848E9C', border: '1px solid #2B3139' }}
onMouseEnter={(e) => {
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';
}}
>
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>
GitHub
</a>
</div>
</div> </div>
</footer> </footer>
</div> </div>
@@ -278,8 +370,14 @@ function TraderDetailsPage({
decisions, decisions,
lastUpdate, lastUpdate,
language, language,
traders,
selectedTraderId,
onTraderSelect,
}: { }: {
selectedTrader?: TraderInfo; selectedTrader?: TraderInfo;
traders?: TraderInfo[];
selectedTraderId?: string;
onTraderSelect: (traderId: string) => void;
status?: SystemStatus; status?: SystemStatus;
account?: AccountInfo; account?: AccountInfo;
positions?: Position[]; positions?: Position[];
@@ -320,14 +418,35 @@ function TraderDetailsPage({
<div> <div>
{/* Trader Header */} {/* Trader Header */}
<div className="mb-6 rounded p-6 animate-scale-in" style={{ background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%)', border: '1px solid rgba(240, 185, 11, 0.2)', boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)' }}> <div className="mb-6 rounded p-6 animate-scale-in" style={{ background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%)', border: '1px solid rgba(240, 185, 11, 0.2)', boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)' }}>
<h2 className="text-2xl font-bold mb-3 flex items-center gap-2" style={{ color: '#EAECEF' }}> <div className="flex items-start justify-between mb-3">
<span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}> <h2 className="text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
🤖 <span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
</span> 🤖
{selectedTrader.trader_name} </span>
</h2> {selectedTrader.trader_name}
</h2>
{/* Trader Selector */}
{traders && traders.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: '#848E9C' }}>:</span>
<select
value={selectedTraderId}
onChange={(e) => onTraderSelect(e.target.value)}
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id}>
{trader.trader_name}
</option>
))}
</select>
</div>
)}
</div>
<div className="flex items-center gap-4 text-sm" style={{ color: '#848E9C' }}> <div className="flex items-center gap-4 text-sm" style={{ color: '#848E9C' }}>
<span>AI Model: <span className="font-semibold" style={{ color: selectedTrader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa' }}>{selectedTrader.ai_model.toUpperCase()}</span></span> <span>AI Model: <span className="font-semibold" style={{ color: selectedTrader.ai_model.includes('qwen') ? '#c084fc' : '#60a5fa' }}>{getModelDisplayName(selectedTrader.ai_model.split('_').pop() || selectedTrader.ai_model)}</span></span>
{status && ( {status && (
<> <>
<span></span> <span></span>
@@ -669,11 +788,13 @@ function DecisionCard({ decision, language }: { decision: DecisionRecord; langua
); );
} }
// Wrap App with LanguageProvider // Wrap App with providers
export default function AppWithLanguage() { export default function AppWithProviders() {
return ( return (
<LanguageProvider> <LanguageProvider>
<App /> <AuthProvider>
<App />
</AuthProvider>
</LanguageProvider> </LanguageProvider>
); );
} }

View File

@@ -4,8 +4,34 @@ import { api } from '../lib/api';
import type { TraderInfo, CreateTraderRequest, AIModel, Exchange } from '../types'; import type { TraderInfo, CreateTraderRequest, AIModel, Exchange } from '../types';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations'; 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 { language } = useLanguage();
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showModelModal, setShowModelModal] = useState(false); const [showModelModal, setShowModelModal] = useState(false);
@@ -14,6 +40,8 @@ export function AITradersPage() {
const [editingExchange, setEditingExchange] = useState<string | null>(null); const [editingExchange, setEditingExchange] = useState<string | null>(null);
const [allModels, setAllModels] = useState<AIModel[]>([]); const [allModels, setAllModels] = useState<AIModel[]>([]);
const [allExchanges, setAllExchanges] = useState<Exchange[]>([]); const [allExchanges, setAllExchanges] = useState<Exchange[]>([]);
const [supportedModels, setSupportedModels] = useState<AIModel[]>([]);
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([]);
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>( const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
'traders', 'traders',
@@ -25,22 +53,41 @@ export function AITradersPage() {
useEffect(() => { useEffect(() => {
const loadConfigs = async () => { const loadConfigs = async () => {
try { try {
const [modelConfigs, exchangeConfigs] = await Promise.all([ console.log('🔄 开始加载模型和交易所配置...');
const [modelConfigs, exchangeConfigs, supportedModels, supportedExchanges] = await Promise.all([
api.getModelConfigs(), api.getModelConfigs(),
api.getExchangeConfigs() api.getExchangeConfigs(),
api.getSupportedModels(),
api.getSupportedExchanges()
]); ]);
console.log('✅ 用户模型配置加载成功:', modelConfigs);
console.log('✅ 用户交易所配置加载成功:', exchangeConfigs);
console.log('✅ 支持的模型加载成功:', supportedModels);
console.log('✅ 支持的交易所加载成功:', supportedExchanges);
setAllModels(modelConfigs); setAllModels(modelConfigs);
setAllExchanges(exchangeConfigs); setAllExchanges(exchangeConfigs);
setSupportedModels(supportedModels);
setSupportedExchanges(supportedExchanges);
} catch (error) { } catch (error) {
console.error('Failed to load configs:', error); console.error('❌ 加载配置失败:', error);
} }
}; };
loadConfigs(); loadConfigs();
}, []); }, []);
// 显示已配置的模型和交易所 // 显示所有用户的模型和交易所配置(用于调试)
const configuredModels = allModels.filter(m => m.enabled && m.apiKey); const configuredModels = allModels || [];
const configuredExchanges = allExchanges.filter(e => e.enabled && e.apiKey && (e.id === 'hyperliquid' || e.secretKey)); 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) => { const isModelInUse = (modelId: string) => {
@@ -52,10 +99,10 @@ export function AITradersPage() {
return traders?.some(t => t.exchange_id === exchangeId && t.is_running) || false; 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 { try {
const model = allModels.find(m => m.id === modelId); const model = allModels?.find(m => m.id === modelId);
const exchange = allExchanges.find(e => e.id === exchangeId); const exchange = allExchanges?.find(e => e.id === exchangeId);
if (!model?.enabled) { if (!model?.enabled) {
alert(t('modelNotConfigured', language)); alert(t('modelNotConfigured', language));
@@ -71,7 +118,9 @@ export function AITradersPage() {
name, name,
ai_model_id: modelId, ai_model_id: modelId,
exchange_id: exchangeId, exchange_id: exchangeId,
initial_balance: initialBalance initial_balance: initialBalance,
custom_prompt: customPrompt,
override_base_prompt: overrideBase
}; };
await api.createTrader(request); await api.createTrader(request);
@@ -127,9 +176,9 @@ export function AITradersPage() {
if (!confirm('确定要删除此AI模型配置吗')) return; if (!confirm('确定要删除此AI模型配置吗')) return;
try { try {
const updatedModels = allModels.map(m => const updatedModels = allModels?.map(m =>
m.id === modelId ? { ...m, apiKey: '', enabled: false } : m m.id === modelId ? { ...m, apiKey: '', enabled: false } : m
); ) || [];
const request = { const request = {
models: Object.fromEntries( models: Object.fromEntries(
@@ -155,9 +204,27 @@ export function AITradersPage() {
const handleSaveModelConfig = async (modelId: string, apiKey: string) => { const handleSaveModelConfig = async (modelId: string, apiKey: string) => {
try { try {
const updatedModels = allModels.map(m => // 找到要配置的模型从supportedModels中
m.id === modelId ? { ...m, apiKey, enabled: true } : m 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 = { const request = {
models: Object.fromEntries( models: Object.fromEntries(
@@ -172,7 +239,11 @@ export function AITradersPage() {
}; };
await api.updateModelConfigs(request); await api.updateModelConfigs(request);
setAllModels(updatedModels);
// 重新获取用户配置以确保数据同步
const refreshedModels = await api.getModelConfigs();
setAllModels(refreshedModels);
setShowModelModal(false); setShowModelModal(false);
setEditingModel(null); setEditingModel(null);
} catch (error) { } catch (error) {
@@ -185,9 +256,9 @@ export function AITradersPage() {
if (!confirm('确定要删除此交易所配置吗?')) return; if (!confirm('确定要删除此交易所配置吗?')) return;
try { try {
const updatedExchanges = allExchanges.map(e => const updatedExchanges = allExchanges?.map(e =>
e.id === exchangeId ? { ...e, apiKey: '', secretKey: '', enabled: false } : e e.id === exchangeId ? { ...e, apiKey: '', secretKey: '', enabled: false } : e
); ) || [];
const request = { const request = {
exchanges: Object.fromEntries( 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 { try {
const updatedExchanges = allExchanges.map(e => // 找到要配置的交易所从supportedExchanges中
e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, enabled: true } : e 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 = { const request = {
exchanges: Object.fromEntries( exchanges: Object.fromEntries(
@@ -227,14 +316,22 @@ export function AITradersPage() {
enabled: exchange.enabled, enabled: exchange.enabled,
api_key: exchange.apiKey || '', api_key: exchange.apiKey || '',
secret_key: exchange.secretKey || '', 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); await api.updateExchangeConfigs(request);
setAllExchanges(updatedExchanges);
// 重新获取用户配置以确保数据同步
const refreshedExchanges = await api.getExchangeConfigs();
setAllExchanges(refreshedExchanges);
setShowExchangeModal(false); setShowExchangeModal(false);
setEditingExchange(null); setEditingExchange(null);
} catch (error) { } catch (error) {
@@ -339,21 +436,25 @@ export function AITradersPage() {
onClick={() => handleModelClick(model.id)} onClick={() => handleModelClick(model.id)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold" <div className="w-8 h-8 flex items-center justify-center">
style={{ {getModelIcon(model.provider || model.id, { width: 32, height: 32 }) || (
background: model.id === 'deepseek' ? '#60a5fa' : '#c084fc', <div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
color: '#fff' style={{
}}> background: model.id === 'deepseek' ? '#60a5fa' : '#c084fc',
{model.name[0]} color: '#fff'
}}>
{model.name[0]}
</div>
)}
</div> </div>
<div> <div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{model.name}</div> <div className="font-semibold" style={{ color: '#EAECEF' }}>{model.name}</div>
<div className="text-xs" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{t('configured', language)} {inUse ? '正在使用' : model.enabled ? '已启用' : '已配置'}
</div> </div>
</div> </div>
</div> </div>
<div className={`w-3 h-3 rounded-full bg-green-400`} /> <div className={`w-3 h-3 rounded-full ${model.enabled && model.apiKey ? 'bg-green-400' : 'bg-gray-500'}`} />
</div> </div>
); );
})} })}
@@ -384,21 +485,17 @@ export function AITradersPage() {
onClick={() => handleExchangeClick(exchange.id)} onClick={() => handleExchangeClick(exchange.id)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold" <div className="w-8 h-8 flex items-center justify-center">
style={{ {getExchangeIcon(exchange.id, { width: 32, height: 32 })}
background: exchange.type === 'cex' ? '#F0B90B' : '#0ECB81',
color: '#000'
}}>
{exchange.name[0]}
</div> </div>
<div> <div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{exchange.name}</div> <div className="font-semibold" style={{ color: '#EAECEF' }}>{exchange.name}</div>
<div className="text-xs" style={{ color: '#848E9C' }}> <div className="text-xs" style={{ color: '#848E9C' }}>
{exchange.type.toUpperCase()} {t('configured', language)} {exchange.type.toUpperCase()} {inUse ? '正在使用' : exchange.enabled ? '已启用' : '已配置'}
</div> </div>
</div> </div>
</div> </div>
<div className={`w-3 h-3 rounded-full bg-green-400`} /> <div className={`w-3 h-3 rounded-full ${exchange.enabled && exchange.apiKey ? 'bg-green-400' : 'bg-gray-500'}`} />
</div> </div>
); );
})} })}
@@ -429,7 +526,7 @@ export function AITradersPage() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center text-xl" <div className="w-12 h-12 rounded-full flex items-center justify-center text-xl"
style={{ style={{
background: trader.ai_model === 'deepseek' ? '#60a5fa' : '#c084fc', background: trader.ai_model.includes('deepseek') ? '#60a5fa' : '#c084fc',
color: '#fff' color: '#fff'
}}> }}>
🤖 🤖
@@ -439,9 +536,9 @@ export function AITradersPage() {
{trader.trader_name} {trader.trader_name}
</div> </div>
<div className="text-sm" style={{ <div className="text-sm" style={{
color: trader.ai_model === 'deepseek' ? '#60a5fa' : '#c084fc' color: trader.ai_model.includes('deepseek') ? '#60a5fa' : '#c084fc'
}}> }}>
{trader.ai_model.toUpperCase()} Model {trader.exchange_id?.toUpperCase()} {getModelDisplayName(trader.ai_model.split('_').pop() || trader.ai_model)} Model {trader.exchange_id?.toUpperCase()}
</div> </div>
</div> </div>
</div> </div>
@@ -463,7 +560,15 @@ export function AITradersPage() {
{/* Actions */} {/* Actions */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleToggleTrader(trader.trader_id, trader.is_running)} onClick={() => onTraderSelect?.(trader.trader_id)}
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: 'rgba(99, 102, 241, 0.1)', color: '#6366F1' }}
>
📊
</button>
<button
onClick={() => handleToggleTrader(trader.trader_id, trader.is_running || false)}
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105" className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={trader.is_running style={trader.is_running
? { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' } ? { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
@@ -507,8 +612,8 @@ export function AITradersPage() {
{/* Create Trader Modal */} {/* Create Trader Modal */}
{showCreateModal && ( {showCreateModal && (
<CreateTraderModal <CreateTraderModal
enabledModels={configuredModels} enabledModels={enabledModels}
enabledExchanges={configuredExchanges} enabledExchanges={enabledExchanges}
onCreate={handleCreateTrader} onCreate={handleCreateTrader}
onClose={() => setShowCreateModal(false)} onClose={() => setShowCreateModal(false)}
language={language} language={language}
@@ -518,7 +623,7 @@ export function AITradersPage() {
{/* Model Configuration Modal */} {/* Model Configuration Modal */}
{showModelModal && ( {showModelModal && (
<ModelConfigModal <ModelConfigModal
allModels={allModels} allModels={supportedModels}
editingModelId={editingModel} editingModelId={editingModel}
onSave={handleSaveModelConfig} onSave={handleSaveModelConfig}
onDelete={handleDeleteModelConfig} onDelete={handleDeleteModelConfig}
@@ -533,7 +638,7 @@ export function AITradersPage() {
{/* Exchange Configuration Modal */} {/* Exchange Configuration Modal */}
{showExchangeModal && ( {showExchangeModal && (
<ExchangeConfigModal <ExchangeConfigModal
allExchanges={allExchanges} allExchanges={supportedExchanges}
editingExchangeId={editingExchange} editingExchangeId={editingExchange}
onSave={handleSaveExchangeConfig} onSave={handleSaveExchangeConfig}
onDelete={handleDeleteExchangeConfig} onDelete={handleDeleteExchangeConfig}
@@ -558,7 +663,7 @@ function CreateTraderModal({
}: { }: {
enabledModels: AIModel[]; enabledModels: AIModel[];
enabledExchanges: Exchange[]; enabledExchanges: Exchange[];
onCreate: (modelId: string, exchangeId: string, name: string, initialBalance: number) => void; onCreate: (modelId: string, exchangeId: string, name: string, initialBalance: number, customPrompt?: string, overrideBase?: boolean) => void;
onClose: () => void; onClose: () => void;
language: any; language: any;
}) { }) {
@@ -571,12 +676,15 @@ function CreateTraderModal({
const [selectedExchange, setSelectedExchange] = useState(defaultExchange?.id || ''); const [selectedExchange, setSelectedExchange] = useState(defaultExchange?.id || '');
const [traderName, setTraderName] = useState(''); const [traderName, setTraderName] = useState('');
const [initialBalance, setInitialBalance] = useState(1000); const [initialBalance, setInitialBalance] = useState(1000);
const [customPrompt, setCustomPrompt] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [overrideBase, setOverrideBase] = useState(false);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedModel || !selectedExchange || !traderName.trim()) return; if (!selectedModel || !selectedExchange || !traderName.trim()) return;
onCreate(selectedModel, selectedExchange, traderName.trim(), initialBalance); onCreate(selectedModel, selectedExchange, traderName.trim(), initialBalance, customPrompt.trim() || undefined, overrideBase);
}; };
return ( return (
@@ -655,6 +763,68 @@ function CreateTraderModal({
required required
/> />
</div> </div>
{/* Advanced Settings Toggle */}
<div className="mt-4">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-sm font-semibold"
style={{ color: '#F0B90B' }}
>
<span style={{ transform: showAdvanced ? 'rotate(90deg)' : 'rotate(0)', transition: 'transform 0.2s' }}></span>
</button>
</div>
{/* Custom Prompt Field - Show when advanced is toggled */}
{showAdvanced && (
<div className="mt-4">
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
()
</label>
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="例如专注于主流币种BTC/ETH/SOL避免MEME币。使用保守策略单笔仓位不超过账户的30%..."
rows={5}
className="w-full px-3 py-2 rounded resize-none"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
fontSize: '14px'
}}
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
AI交易员的额外指导使
</div>
{/* Override Base Strategy Checkbox */}
{customPrompt.trim() && (
<div className="mt-3 p-3 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.2)' }}>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={overrideBase}
onChange={(e) => setOverrideBase(e.target.checked)}
className="mt-1"
style={{ accentColor: '#F6465D' }}
/>
<div>
<div className="text-sm font-semibold" style={{ color: '#F6465D' }}>
</div>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
使使
使
</div>
</div>
</label>
</div>
)}
</div>
)}
<div className="flex gap-3 mt-6"> <div className="flex gap-3 mt-6">
<button <button
@@ -699,7 +869,7 @@ function ModelConfigModal({
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
// 获取当前编辑的模型信息 // 获取当前编辑的模型信息
const selectedModel = allModels.find(m => m.id === selectedModelId); const selectedModel = allModels?.find(m => m.id === selectedModelId);
// 如果是编辑现有模型初始化API Key // 如果是编辑现有模型初始化API Key
useEffect(() => { useEffect(() => {
@@ -715,17 +885,32 @@ function ModelConfigModal({
onSave(selectedModelId, apiKey.trim()); onSave(selectedModelId, apiKey.trim());
}; };
// 可选择的模型列表(排除已配置的,除非是当前编辑的 // 可选择的模型列表(所有支持的模型
const availableModels = allModels.filter(m => const availableModels = allModels || [];
!m.enabled || !m.apiKey || m.id === editingModelId
);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg" style={{ background: '#1E2329' }}> <div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative" style={{ background: '#1E2329' }}>
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}> <div className="flex items-center justify-between mb-4">
{editingModelId ? '编辑AI模型' : '添加AI模型'} <h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
</h3> {editingModelId ? '编辑AI模型' : '添加AI模型'}
</h3>
{editingModelId && (
<button
type="button"
onClick={() => {
if (confirm('确定要删除此AI模型配置吗')) {
onDelete(editingModelId);
}
}}
className="p-2 rounded hover:bg-red-100 transition-colors"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
title="删除配置"
>
🗑
</button>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{!editingModelId && ( {!editingModelId && (
@@ -753,12 +938,16 @@ function ModelConfigModal({
{selectedModel && ( {selectedModel && (
<div className="p-4 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}> <div className="p-4 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold" <div className="w-8 h-8 flex items-center justify-center">
style={{ {getModelIcon(selectedModel.provider || selectedModel.id, { width: 32, height: 32 }) || (
background: selectedModel.id === 'deepseek' ? '#60a5fa' : '#c084fc', <div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
color: '#fff' style={{
}}> background: selectedModel.id === 'deepseek' ? '#60a5fa' : '#c084fc',
{selectedModel.name[0]} color: '#fff'
}}>
{selectedModel.name[0]}
</div>
)}
</div> </div>
<div> <div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{selectedModel.name}</div> <div className="font-semibold" style={{ color: '#EAECEF' }}>{selectedModel.name}</div>
@@ -792,18 +981,6 @@ function ModelConfigModal({
> >
{t('cancel', language)} {t('cancel', language)}
</button> </button>
{editingModelId && (
<button
type="button"
onClick={() => {
onDelete(editingModelId);
}}
className="px-4 py-2 rounded text-sm font-semibold"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
>
🗑
</button>
)}
<button <button
type="submit" type="submit"
disabled={!selectedModelId || !apiKey.trim()} disabled={!selectedModelId || !apiKey.trim()}
@@ -830,7 +1007,7 @@ function ExchangeConfigModal({
}: { }: {
allExchanges: Exchange[]; allExchanges: Exchange[];
editingExchangeId: string | null; editingExchangeId: string | null;
onSave: (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean) => void; onSave: (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, asterSigner?: string, asterPrivateKey?: string) => void;
onDelete: (exchangeId: string) => void; onDelete: (exchangeId: string) => void;
onClose: () => void; onClose: () => void;
language: any; language: any;
@@ -839,9 +1016,15 @@ function ExchangeConfigModal({
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [secretKey, setSecretKey] = useState(''); const [secretKey, setSecretKey] = useState('');
const [testnet, setTestnet] = useState(false); const [testnet, setTestnet] = useState(false);
// Hyperliquid 特定字段
const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('');
// Aster 特定字段
const [asterUser, setAsterUser] = useState('');
const [asterSigner, setAsterSigner] = useState('');
const [asterPrivateKey, setAsterPrivateKey] = useState('');
// 获取当前编辑的交易所信息 // 获取当前编辑的交易所信息
const selectedExchange = allExchanges.find(e => e.id === selectedExchangeId); const selectedExchange = allExchanges?.find(e => e.id === selectedExchangeId);
// 如果是编辑现有交易所,初始化表单数据 // 如果是编辑现有交易所,初始化表单数据
useEffect(() => { useEffect(() => {
@@ -849,28 +1032,57 @@ function ExchangeConfigModal({
setApiKey(selectedExchange.apiKey || ''); setApiKey(selectedExchange.apiKey || '');
setSecretKey(selectedExchange.secretKey || ''); setSecretKey(selectedExchange.secretKey || '');
setTestnet(selectedExchange.testnet || false); setTestnet(selectedExchange.testnet || false);
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '');
setAsterUser(selectedExchange.asterUser || '');
setAsterSigner(selectedExchange.asterSigner || '');
setAsterPrivateKey(selectedExchange.asterPrivateKey || '');
} }
}, [editingExchangeId, selectedExchange]); }, [editingExchangeId, selectedExchange]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedExchangeId || !apiKey.trim()) return; if (!selectedExchangeId) return;
if (selectedExchange?.id !== 'hyperliquid' && !secretKey.trim()) return;
onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet); // 根据交易所类型验证不同字段
if (selectedExchange?.id === 'hyperliquid') {
if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return;
} else if (selectedExchange?.id === 'aster') {
if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return;
} else {
// Binance 等其他交易所
if (!apiKey.trim() || !secretKey.trim()) return;
}
onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), testnet,
hyperliquidWalletAddr.trim(), asterUser.trim(), asterSigner.trim(), asterPrivateKey.trim());
}; };
// 可选择的交易所列表(排除已配置的,除非是当前编辑的 // 可选择的交易所列表(所有支持的交易所
const availableExchanges = allExchanges.filter(e => const availableExchanges = allExchanges || [];
!e.enabled || !e.apiKey || e.id === editingExchangeId
);
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg" style={{ background: '#1E2329' }}> <div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative" style={{ background: '#1E2329' }}>
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}> <div className="flex items-center justify-between mb-4">
{editingExchangeId ? '编辑交易所' : '添加交易所'} <h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
</h3> {editingExchangeId ? '编辑交易所' : '添加交易所'}
</h3>
{editingExchangeId && (
<button
type="button"
onClick={() => {
if (confirm('确定要删除此交易所配置吗?')) {
onDelete(editingExchangeId);
}
}}
className="p-2 rounded hover:bg-red-100 transition-colors"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
title="删除配置"
>
🗑
</button>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{!editingExchangeId && ( {!editingExchangeId && (
@@ -898,12 +1110,8 @@ function ExchangeConfigModal({
{selectedExchange && ( {selectedExchange && (
<div className="p-4 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}> <div className="p-4 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold" <div className="w-8 h-8 flex items-center justify-center">
style={{ {getExchangeIcon(selectedExchange.id, { width: 32, height: 32 })}
background: selectedExchange.type === 'cex' ? '#F0B90B' : '#0ECB81',
color: '#000'
}}>
{selectedExchange.name[0]}
</div> </div>
<div> <div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{selectedExchange.name}</div> <div className="font-semibold" style={{ color: '#EAECEF' }}>{selectedExchange.name}</div>
@@ -912,50 +1120,131 @@ function ExchangeConfigModal({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div> {/* Binance 配置 */}
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}> {selectedExchange.id === 'binance' && (
{selectedExchange.id === 'hyperliquid' ? 'Private Key (无需0x前缀)' : 'API Key'} <>
</label> <div>
<input <label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
type="password" API Key
value={apiKey} </label>
onChange={(e) => setApiKey(e.target.value)} <input
placeholder={selectedExchange.id === 'hyperliquid' ? '请输入以太坊私钥' : `请输入 ${selectedExchange.name} API Key`} type="password"
className="w-full px-3 py-2 rounded" value={apiKey}
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} onChange={(e) => setApiKey(e.target.value)}
required placeholder="请输入 Binance API Key"
/> className="w-full px-3 py-2 rounded"
</div> style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
{selectedExchange.id !== 'hyperliquid' && ( />
<div> </div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}> <div>
Secret Key <label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
</label> Secret Key
<input </label>
type="password" <input
value={secretKey} type="password"
onChange={(e) => setSecretKey(e.target.value)} value={secretKey}
placeholder={`请输入 ${selectedExchange.name} Secret Key`} onChange={(e) => setSecretKey(e.target.value)}
className="w-full px-3 py-2 rounded" placeholder="请输入 Binance Secret Key"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} className="w-full px-3 py-2 rounded"
required style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
/> required
</div> />
</div>
</>
)} )}
{selectedExchange.type === 'dex' && ( {/* Hyperliquid 配置 */}
<div className="flex items-center gap-2"> {selectedExchange.id === 'hyperliquid' && (
<input <>
type="checkbox" <div>
checked={testnet} <label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
onChange={(e) => setTestnet(e.target.checked)} Private Key (0x前缀)
className="w-4 h-4" </label>
/> <input
<label className="text-sm" style={{ color: '#EAECEF' }}> type="password"
{t('useTestnet', language)} value={apiKey}
</label> onChange={(e) => setApiKey(e.target.value)}
</div> placeholder="请输入以太坊私钥"
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
</label>
<input
type="text"
value={hyperliquidWalletAddr}
onChange={(e) => setHyperliquidWalletAddr(e.target.value)}
placeholder="请输入以太坊钱包地址"
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={testnet}
onChange={(e) => setTestnet(e.target.checked)}
className="w-4 h-4"
/>
<label className="text-sm" style={{ color: '#EAECEF' }}>
使
</label>
</div>
</>
)}
{/* Aster 配置 */}
{selectedExchange.id === 'aster' && (
<>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
</label>
<input
type="text"
value={asterUser}
onChange={(e) => setAsterUser(e.target.value)}
placeholder="请输入 Aster 用户地址"
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
</label>
<input
type="text"
value={asterSigner}
onChange={(e) => setAsterSigner(e.target.value)}
placeholder="请输入 Aster 签名者地址"
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
</label>
<input
type="password"
value={asterPrivateKey}
onChange={(e) => setAsterPrivateKey(e.target.value)}
placeholder="请输入 Aster 私钥"
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
</>
)} )}
</div> </div>
</div> </div>
@@ -970,21 +1259,14 @@ function ExchangeConfigModal({
> >
{t('cancel', language)} {t('cancel', language)}
</button> </button>
{editingExchangeId && (
<button
type="button"
onClick={() => {
onDelete(editingExchangeId);
}}
className="px-4 py-2 rounded text-sm font-semibold"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
>
🗑
</button>
)}
<button <button
type="submit" type="submit"
disabled={!selectedExchangeId || !apiKey.trim() || (selectedExchange?.id !== 'hyperliquid' && !secretKey.trim())} disabled={
!selectedExchangeId ||
(selectedExchange?.id === 'binance' && (!apiKey.trim() || !secretKey.trim())) ||
(selectedExchange?.id === 'hyperliquid' && (!apiKey.trim() || !hyperliquidWalletAddr.trim())) ||
(selectedExchange?.id === 'aster' && (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()))
}
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50" className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }} style={{ background: '#F0B90B', color: '#000' }}
> >

View File

@@ -0,0 +1,344 @@
import { 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的历史数据 - 使用单个useSWR并发请求所有trader数据
// 生成唯一的key当traders变化时会触发重新请求
const tradersKey = traders.map(t => t.trader_id).sort().join(',');
const { data: allTraderHistories, isLoading } = useSWR(
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
async () => {
// 并发请求所有trader的历史数据
const promises = traders.map(trader =>
api.getEquityHistory(trader.trader_id)
);
return Promise.all(promises);
},
{
refreshInterval: 30000, // 30秒刷新对比图表数据更新频率较低
revalidateOnFocus: false,
dedupingInterval: 20000,
}
);
// 将数据转换为与原格式兼容的结构
const traderHistories = useMemo(() => {
if (!allTraderHistories) {
return traders.map(() => ({ data: undefined }));
}
return allTraderHistories.map(data => ({ data }));
}, [allTraderHistories, traders.length]);
// 使用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<string, {
timestamp: string;
time: string;
traders: Map<string, { pnl_pct: number; equity: number }>;
}>();
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}`);
}
return combined;
}, [allTraderHistories, traders]);
if (isLoading) {
return (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="spinner mx-auto mb-4"></div>
<div className="text-sm font-semibold">Loading comparison data...</div>
</div>
);
}
if (combinedData.length === 0) {
return (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="text-6xl mb-4 opacity-50">📊</div>
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">线</div>
</div>
);
}
// 限制显示数据点
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 (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
{data.time} - #{data.index}
</div>
{traders.map((trader) => {
const pnlPct = data[`${trader.trader_id}_pnl_pct`];
const equity = data[`${trader.trader_id}_equity`];
if (pnlPct === undefined) return null;
return (
<div key={trader.trader_id} className="mb-1.5 last:mb-0">
<div
className="text-xs font-semibold mb-0.5"
style={{ color: getTraderColor(trader.trader_id) }}
>
{trader.trader_name}
</div>
<div className="text-sm mono font-bold" style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}>
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
<span className="text-xs ml-2 font-normal" style={{ color: '#848E9C' }}>
({equity?.toFixed(2)} USDT)
</span>
</div>
</div>
);
})}
</div>
);
}
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 (
<div>
<div style={{ borderRadius: '8px', overflow: 'hidden' }}>
<ResponsiveContainer width="100%" height={520}>
<LineChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}>
<defs>
{traders.map((trader) => (
<linearGradient
key={`gradient-${trader.trader_id}`}
id={`gradient-${trader.trader_id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={getTraderColor(trader.trader_id)} stopOpacity={0.9} />
<stop offset="95%" stopColor={getTraderColor(trader.trader_id)} stopOpacity={0.2} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(displayData.length / 12)}
angle={-15}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) => `${value.toFixed(1)}%`}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={0}
stroke="#474D57"
strokeDasharray="5 5"
strokeWidth={1.5}
label={{
value: 'Break Even',
fill: '#848E9C',
fontSize: 11,
position: 'right',
}}
/>
{traders.map((trader) => (
<Line
key={trader.trader_id}
type="monotone"
dataKey={`${trader.trader_id}_pnl_pct`}
stroke={getTraderColor(trader.trader_id)}
strokeWidth={3}
dot={displayData.length < 50 ? { fill: getTraderColor(trader.trader_id), r: 3 } : false}
activeDot={{ r: 6, fill: getTraderColor(trader.trader_id), stroke: '#fff', strokeWidth: 2 }}
name={trader.trader_name}
connectNulls
/>
))}
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
formatter={(value, entry: any) => {
const traderId = traders.find((t) => value === t.trader_name)?.trader_id;
const trader = traders.find((t) => t.trader_id === traderId);
return (
<span style={{ color: entry.color, fontWeight: 600, fontSize: '14px' }}>
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
</span>
);
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Stats */}
<div className="mt-6 grid grid-cols-4 gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{combinedData.length} </div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}>
{currentGap.toFixed(2)}%
</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{combinedData.length > MAX_DISPLAY_POINTS
? `最近 ${MAX_DISPLAY_POINTS}`
: '全部数据'}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import useSWR from 'swr';
import { api } from '../lib/api';
import type { CompetitionData } from '../types';
import { ComparisonChart } from './ComparisonChart';
export function CompetitionPage() {
const { data: competition } = useSWR<CompetitionData>(
'competition',
api.getCompetition,
{
refreshInterval: 15000, // 15秒刷新竞赛数据不需要太频繁更新
revalidateOnFocus: false,
dedupingInterval: 10000,
}
);
if (!competition || !competition.traders) {
return (
<div className="space-y-6">
<div className="binance-card p-8 animate-pulse">
<div className="flex items-center justify-between mb-6">
<div className="space-y-3 flex-1">
<div className="skeleton h-8 w-64"></div>
<div className="skeleton h-4 w-48"></div>
</div>
<div className="skeleton h-12 w-32"></div>
</div>
</div>
<div className="binance-card p-6">
<div className="skeleton h-6 w-40 mb-4"></div>
<div className="space-y-3">
<div className="skeleton h-20 w-full rounded"></div>
<div className="skeleton h-20 w-full rounded"></div>
</div>
</div>
</div>
);
}
// 按收益率排序
const sortedTraders = [...competition.traders].sort(
(a, b) => b.total_pnl_pct - a.total_pnl_pct
);
// 找出领先者
const leader = sortedTraders[0];
return (
<div className="space-y-5 animate-fade-in">
{/* Competition Header - 精简版 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
}}>
🏆
</div>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
AI竞赛
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
{competition.count}
</span>
</h1>
<p className="text-xs" style={{ color: '#848E9C' }}>
</p>
</div>
</div>
<div className="text-right">
<div className="text-xs mb-1" style={{ color: '#848E9C' }}></div>
<div className="text-lg font-bold" style={{ color: '#F0B90B' }}>{leader?.trader_name}</div>
<div className="text-sm font-semibold" style={{ color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
</div>
</div>
{/* Left/Right Split: Performance Chart + Leaderboard */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Left: Performance Comparison Chart */}
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
</h2>
<div className="text-xs" style={{ color: '#848E9C' }}>
</div>
</div>
<ComparisonChart traders={sortedTraders} />
</div>
{/* Right: Leaderboard */}
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
</h2>
<div className="text-xs px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
</div>
</div>
<div className="space-y-2">
{sortedTraders.map((trader, index) => {
const isLeader = index === 0;
const aiModelColor = trader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa';
return (
<div
key={trader.trader_id}
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px]"
style={{
background: isLeader ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' : '#0B0E11',
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
boxShadow: isLeader ? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)' : '0 1px 4px rgba(0, 0, 0, 0.3)'
}}
>
<div className="flex items-center justify-between">
{/* Rank & Name */}
<div className="flex items-center gap-3">
<div className="text-2xl w-6">
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
</div>
<div>
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
<div className="text-xs mono font-semibold" style={{ color: aiModelColor }}>
{trader.ai_model.toUpperCase()}
</div>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-3">
{/* Total Equity */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}></div>
<div className="text-sm font-bold mono" style={{ color: '#EAECEF' }}>
{trader.total_equity?.toFixed(2) || '0.00'}
</div>
</div>
{/* P&L */}
<div className="text-right min-w-[90px]">
<div className="text-xs" style={{ color: '#848E9C' }}></div>
<div
className="text-lg font-bold mono"
style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
<div className="text-xs mono" style={{ color: '#848E9C' }}>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl?.toFixed(2) || '0.00'}
</div>
</div>
{/* Positions */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}></div>
<div className="text-sm font-bold mono" style={{ color: '#EAECEF' }}>
{trader.position_count}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{trader.margin_used_pct.toFixed(1)}%
</div>
</div>
{/* Status */}
<div>
<div
className="px-2 py-1 rounded text-xs font-bold"
style={trader.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
}
>
{trader.is_running ? '●' : '○'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Head-to-Head Stats */}
{competition.traders.length === 2 && (
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.3s' }}>
<h2 className="text-lg font-bold mb-4 flex items-center gap-2" style={{ color: '#EAECEF' }}>
</h2>
<div className="grid grid-cols-2 gap-4">
{sortedTraders.map((trader, index) => {
const isWinning = index === 0;
const opponent = sortedTraders[1 - index];
const gap = trader.total_pnl_pct - opponent.total_pnl_pct;
return (
<div
key={trader.trader_id}
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
style={isWinning
? {
background: 'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
border: '2px solid rgba(14, 203, 129, 0.3)',
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)'
}
: {
background: '#0B0E11',
border: '1px solid #2B3139',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)'
}
}
>
<div className="text-center">
<div
className="text-base font-bold mb-2"
style={{ color: trader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa' }}
>
{trader.trader_name}
</div>
<div className="text-2xl font-bold mono mb-1" style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
{isWinning && gap > 0 && (
<div className="text-xs font-semibold" style={{ color: '#0ECB81' }}>
{gap.toFixed(2)}%
</div>
)}
{!isWinning && gap < 0 && (
<div className="text-xs font-semibold" style={{ color: '#F6465D' }}>
{Math.abs(gap).toFixed(2)}%
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import React from 'react';
interface IconProps {
width?: number;
height?: number;
className?: string;
}
// Binance SVG 图标组件
const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="-52.785 -88 457.47 528"
className={className}
>
<path
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
fill="#f0b90b"
/>
</svg>
);
// Hyperliquid SVG 图标组件
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
width={width}
height={height}
viewBox="0 0 144 144"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
fill="#97FCE4"
/>
</svg>
);
// Aster SVG 图标组件
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
width={width}
height={height}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
</linearGradient>
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
</linearGradient>
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
</linearGradient>
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
</linearGradient>
</defs>
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
</svg>
);
// 获取交易所图标的函数
export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => {
// 支持完整ID或类型名
const type = exchangeType.toLowerCase().includes('binance') ? 'binance' :
exchangeType.toLowerCase().includes('hyperliquid') ? 'hyperliquid' :
exchangeType.toLowerCase().includes('aster') ? 'aster' :
exchangeType.toLowerCase();
const iconProps = {
width: props.width || 24,
height: props.height || 24,
className: props.className
};
switch (type) {
case 'binance':
case 'cex':
return <BinanceIcon {...iconProps} />;
case 'hyperliquid':
case 'dex':
return <HyperliquidIcon {...iconProps} />;
case 'aster':
return <AsterIcon {...iconProps} />;
default:
return (
<div
className={props.className}
style={{
width: props.width || 24,
height: props.height || 24,
borderRadius: '50%',
background: '#2B3139',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
color: '#EAECEF'
}}
>
{type[0]?.toUpperCase() || '?'}
</div>
);
}
};

View File

@@ -0,0 +1,58 @@
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
interface HeaderProps {
simple?: boolean; // For login/register pages
}
export function Header({ simple = false }: HeaderProps) {
const { language, setLanguage } = useLanguage();
return (
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
<div className="max-w-[1920px] mx-auto px-6 py-4">
<div className="flex items-center justify-between">
{/* Left - Logo and Title */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xl"
style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
</div>
<div>
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-xs mono" style={{ color: '#848E9C' }}>
{t('subtitle', language)}
</p>
</div>
</div>
{/* Right - Language Toggle */}
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<button
onClick={() => setLanguage('zh')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'zh'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
</button>
<button
onClick={() => setLanguage('en')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'en'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
EN
</button>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { Header } from './Header';
export function LoginPage() {
const { language } = useLanguage();
const { login, verifyOTP } = useAuth();
const [step, setStep] = useState<'login' | 'otp'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [userID, setUserID] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(email, password);
if (result.success) {
if (result.requiresOTP && result.userID) {
setUserID(result.userID);
setStep('otp');
}
} else {
setError(result.message || t('loginFailed', language));
}
setLoading(false);
};
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await verifyOTP(userID, otpCode);
if (!result.success) {
setError(result.message || t('verificationFailed', language));
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false);
};
return (
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
<Header simple />
<div className="flex items-center justify-center" style={{ minHeight: 'calc(100vh - 80px)' }}>
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-full mx-auto mb-4 flex items-center justify-center text-3xl"
style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('loginTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'login' ? t('loginTitle', language) : t('enterOTPCode', language)}
</p>
</div>
{/* Login Form */}
<div className="rounded-lg p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
{step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('loginButton', language)}
</button>
</form>
) : (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">📱</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('scanQRCodeInstructions', language)}<br />
{t('enterOTPCode', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('login')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('verifyOTP', language)}
</button>
</div>
</form>
)}
</div>
{/* Register Link */}
<div className="text-center mt-6">
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('noAccount', language)}{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/register');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline"
style={{ color: '#F0B90B' }}
>
{t('registerNow', language)}
</button>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
interface IconProps {
width?: number;
height?: number;
className?: string;
}
// 获取AI模型图标的函数
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
// 支持完整ID或类型名
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType;
let iconPath: string | null = null;
switch (type) {
case 'deepseek':
iconPath = '/icons/deepseek.svg';
break;
case 'qwen':
iconPath = '/icons/qwen.svg';
break;
default:
return null;
}
return (
<img
src={iconPath}
alt={`${type} icon`}
width={props.width || 24}
height={props.height || 24}
className={props.className}
style={{ borderRadius: '50%' }}
/>
);
};

View File

@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
export function RegisterPage() {
const { language } = useLanguage();
const { register, completeRegistration } = useAuth();
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>('register');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [userID, setUserID] = useState('');
const [otpSecret, setOtpSecret] = useState('');
const [qrCodeURL, setQrCodeURL] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError(t('passwordMismatch', language));
return;
}
if (password.length < 6) {
setError(t('passwordTooShort', language));
return;
}
setLoading(true);
const result = await register(email, password);
if (result.success && result.userID) {
setUserID(result.userID);
setOtpSecret(result.otpSecret || '');
setQrCodeURL(result.qrCodeURL || '');
setStep('setup-otp');
} else {
setError(result.message || t('registrationFailed', language));
}
setLoading(false);
};
const handleSetupComplete = () => {
setStep('verify-otp');
};
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await completeRegistration(userID, otpCode);
if (!result.success) {
setError(result.message || t('registrationFailed', language));
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: '#0B0E11' }}>
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-full mx-auto mb-4 flex items-center justify-center text-3xl"
style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'register' && t('registerTitle', language)}
{step === 'setup-otp' && t('setupTwoFactor', language)}
{step === 'verify-otp' && t('verifyOTP', language)}
</p>
</div>
{/* Registration Form */}
<div className="rounded-lg p-6" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
{step === 'register' && (
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('confirmPassword', language)}
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('registerButton', language)}
</button>
</form>
)}
{step === 'setup-otp' && (
<div className="space-y-4">
<div className="text-center">
<div className="text-4xl mb-2">📱</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
<div className="space-y-3">
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('step1Title', language)}
</p>
<p className="text-xs" style={{ color: '#848E9C' }}>
{t('step1Desc', language)}
</p>
</div>
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('step2Title', language)}
</p>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('step2Desc', language)}
</p>
{qrCodeURL && (
<div className="mt-2">
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p>
<div className="bg-white p-2 rounded text-center">
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
alt="QR Code" className="mx-auto" />
</div>
</div>
)}
<div className="mt-2">
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1 text-xs rounded font-mono"
style={{ background: '#2B3139', color: '#EAECEF' }}>
{otpSecret}
</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="px-2 py-1 text-xs rounded"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('copy', language)}
</button>
</div>
</div>
</div>
<div className="p-3 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<p className="text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('step3Title', language)}
</p>
<p className="text-xs" style={{ color: '#848E9C' }}>
{t('step3Desc', language)}
</p>
</div>
</div>
<button
onClick={handleSetupComplete}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('setupCompleteContinue', language)}
</button>
</div>
)}
{step === 'verify-otp' && (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">🔐</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('enterOTPCode', language)}<br />
{t('completeRegistrationSubtitle', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('setup-otp')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('completeRegistration', language)}
</button>
</div>
</form>
)}
</div>
{/* Login Link */}
{step === 'register' && (
<div className="text-center mt-6">
<p className="text-sm" style={{ color: '#848E9C' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/login');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline"
style={{ color: '#F0B90B' }}
>
</button>
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getSystemConfig } from '../lib/config';
interface User {
id: string;
email: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; requiresOTP?: boolean }>;
register: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; otpSecret?: string; qrCodeURL?: string }>;
verifyOTP: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
completeRegistration: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
getSystemConfig()
.then(data => {
if (data.admin_mode) {
// 管理员模式下模拟admin用户
setUser({ id: 'admin', email: 'admin@localhost' });
setToken('admin-mode');
} else {
// 非管理员模式检查本地存储中是否有token
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
}
setIsLoading(false);
})
.catch(err => {
console.error('Failed to fetch system config:', err);
// 发生错误时,继续检查本地存储
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
setIsLoading(false);
});
}, []);
const login = async (email: string, password: string) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
if (data.requires_otp) {
return {
success: true,
userID: data.user_id,
requiresOTP: true,
message: data.message,
};
}
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: '登录失败,请重试' };
}
return { success: false, message: '未知错误' };
};
const register = async (email: string, password: string) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
return {
success: true,
userID: data.user_id,
otpSecret: data.otp_secret,
qrCodeURL: data.qr_code_url,
message: data.message,
};
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: '注册失败,请重试' };
}
};
const verifyOTP = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/verify-otp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
});
const data = await response.json();
if (response.ok) {
// 登录成功保存token和用户信息
const userInfo = { id: data.user_id, email: data.email };
setToken(data.token);
setUser(userInfo);
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(userInfo));
return { success: true, message: data.message };
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: 'OTP验证失败请重试' };
}
};
const completeRegistration = async (userID: string, otpCode: string) => {
try {
const response = await fetch('/api/complete-registration', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
});
const data = await response.json();
if (response.ok) {
// 注册完成,自动登录
const userInfo = { id: data.user_id, email: data.email };
setToken(data.token);
setUser(userInfo);
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(userInfo));
return { success: true, message: data.message };
} else {
return { success: false, message: data.error };
}
} catch (error) {
return { success: false, message: '注册完成失败,请重试' };
}
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
};
return (
<AuthContext.Provider
value={{
user,
token,
login,
register,
verifyOTP,
completeRegistration,
logout,
isLoading,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { getSystemConfig, type SystemConfig } from '../lib/config';
export function useSystemConfig() {
const [config, setConfig] = useState<SystemConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
getSystemConfig()
.then((data) => {
if (!mounted) return;
setConfig(data);
setLoading(false);
})
.catch((err: Error) => {
if (!mounted) return;
console.error('Failed to fetch system config:', err);
setError(err.message);
setLoading(false);
});
return () => {
mounted = false;
};
}, []);
return { config, loading, error };
}

View File

@@ -152,6 +152,58 @@ export const translations = {
loading: 'Loading...', loading: 'Loading...',
loadingError: '⚠️ Failed to load AI learning data', loadingError: '⚠️ Failed to load AI learning data',
noCompleteData: 'No complete trading data (needs to complete open → close cycle)', noCompleteData: 'No complete trading data (needs to complete open → close cycle)',
// Login & Register
login: 'Sign In',
register: 'Sign Up',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
emailPlaceholder: 'your@email.com',
passwordPlaceholder: 'Enter your password',
confirmPasswordPlaceholder: 'Re-enter your password',
otpPlaceholder: '000000',
loginTitle: 'Sign in to your account',
registerTitle: 'Create a new account',
loginButton: 'Sign In',
registerButton: 'Sign Up',
back: 'Back',
noAccount: "Don't have an account?",
hasAccount: 'Already have an account?',
registerNow: 'Sign up now',
loginNow: 'Sign in now',
forgotPassword: 'Forgot password?',
rememberMe: 'Remember me',
otpCode: 'OTP Code',
scanQRCode: 'Scan QR Code',
enterOTPCode: 'Enter 6-digit OTP code',
verifyOTP: 'Verify OTP',
setupTwoFactor: 'Set up two-factor authentication',
setupTwoFactorDesc: 'Follow the steps below to secure your account with Google Authenticator',
scanQRCodeInstructions: 'Scan this QR code with Google Authenticator or Authy',
otpSecret: 'Or enter this secret manually:',
qrCodeHint: 'QR code (if scanning fails, use the secret below):',
step1Title: 'Step 1: Install Google Authenticator',
step1Desc: 'Download and install Google Authenticator from your app store',
step2Title: 'Step 2: Add account',
step2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"',
step3Title: 'Step 3: Verify setup',
step3Desc: 'After setup, continue to enter the 6-digit code',
setupCompleteContinue: 'I have completed setup, continue',
copy: 'Copy',
completeRegistration: 'Complete Registration',
completeRegistrationSubtitle: 'to complete registration',
loginSuccess: 'Login successful',
registrationSuccess: 'Registration successful',
loginFailed: 'Login failed',
registrationFailed: 'Registration failed',
verificationFailed: 'OTP verification failed',
invalidCredentials: 'Invalid email or password',
passwordMismatch: 'Passwords do not match',
emailRequired: 'Email is required',
passwordRequired: 'Password is required',
invalidEmail: 'Invalid email format',
passwordTooShort: 'Password must be at least 6 characters',
}, },
zh: { zh: {
// Header // Header
@@ -304,6 +356,58 @@ export const translations = {
loading: '加载中...', loading: '加载中...',
loadingError: '⚠️ 加载AI学习数据失败', loadingError: '⚠️ 加载AI学习数据失败',
noCompleteData: '暂无完整交易数据(需要完成开仓→平仓的完整周期)', noCompleteData: '暂无完整交易数据(需要完成开仓→平仓的完整周期)',
// Login & Register
login: '登录',
register: '注册',
email: '邮箱',
password: '密码',
confirmPassword: '确认密码',
emailPlaceholder: '请输入邮箱地址',
passwordPlaceholder: '请输入密码至少6位',
confirmPasswordPlaceholder: '请再次输入密码',
otpPlaceholder: '000000',
loginTitle: '登录到您的账户',
registerTitle: '创建新账户',
loginButton: '登录',
registerButton: '注册',
back: '返回',
noAccount: '还没有账户?',
hasAccount: '已有账户?',
registerNow: '立即注册',
loginNow: '立即登录',
forgotPassword: '忘记密码?',
rememberMe: '记住我',
otpCode: 'OTP验证码',
scanQRCode: '扫描二维码',
enterOTPCode: '输入6位OTP验证码',
verifyOTP: '验证OTP',
setupTwoFactor: '设置双因素认证',
setupTwoFactorDesc: '请按以下步骤设置Google验证器以保护您的账户安全',
scanQRCodeInstructions: '使用Google Authenticator或Authy扫描此二维码',
otpSecret: '或手动输入此密钥:',
qrCodeHint: '二维码(如果无法扫描,请使用下方密钥):',
step1Title: '步骤1下载Google Authenticator',
step1Desc: '在手机应用商店下载并安装Google Authenticator应用',
step2Title: '步骤2添加账户',
step2Desc: '在应用中点击“+”,选择“扫描二维码”或“手动输入密钥”',
step3Title: '步骤3验证设置',
step3Desc: '设置完成后点击下方按钮输入6位验证码',
setupCompleteContinue: '我已完成设置,继续',
copy: '复制',
completeRegistration: '完成注册',
completeRegistrationSubtitle: '以完成注册',
loginSuccess: '登录成功',
registrationSuccess: '注册成功',
loginFailed: '登录失败',
registrationFailed: '注册失败',
verificationFailed: 'OTP验证失败',
invalidCredentials: '邮箱或密码错误',
passwordMismatch: '两次输入的密码不一致',
emailRequired: '请输入邮箱',
passwordRequired: '请输入密码',
invalidEmail: '邮箱格式不正确',
passwordTooShort: '密码至少需要6个字符',
} }
}; };

View File

@@ -10,14 +10,31 @@ import type {
CreateTraderRequest, CreateTraderRequest,
UpdateModelConfigRequest, UpdateModelConfigRequest,
UpdateExchangeConfigRequest, UpdateExchangeConfigRequest,
CompetitionData,
} from '../types'; } from '../types';
const API_BASE = '/api'; const API_BASE = '/api';
// Helper function to get auth headers
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
export const api = { export const api = {
// AI交易员管理接口 // AI交易员管理接口
async getTraders(): Promise<TraderInfo[]> { async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/traders`); const res = await fetch(`${API_BASE}/traders`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取trader列表失败'); if (!res.ok) throw new Error('获取trader列表失败');
return res.json(); return res.json();
}, },
@@ -25,7 +42,7 @@ export const api = {
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> { async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders`, { const res = await fetch(`${API_BASE}/traders`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); });
if (!res.ok) throw new Error('创建交易员失败'); if (!res.ok) throw new Error('创建交易员失败');
@@ -35,6 +52,7 @@ export const api = {
async deleteTrader(traderId: string): Promise<void> { async deleteTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, { const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders(),
}); });
if (!res.ok) throw new Error('删除交易员失败'); if (!res.ok) throw new Error('删除交易员失败');
}, },
@@ -42,6 +60,7 @@ export const api = {
async startTrader(traderId: string): Promise<void> { async startTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, { const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(),
}); });
if (!res.ok) throw new Error('启动交易员失败'); if (!res.ok) throw new Error('启动交易员失败');
}, },
@@ -49,21 +68,40 @@ export const api = {
async stopTrader(traderId: string): Promise<void> { async stopTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, { const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(),
}); });
if (!res.ok) throw new Error('停止交易员失败'); if (!res.ok) throw new Error('停止交易员失败');
}, },
async updateTraderPrompt(traderId: string, customPrompt: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ custom_prompt: customPrompt }),
});
if (!res.ok) throw new Error('更新自定义策略失败');
},
// AI模型配置接口 // AI模型配置接口
async getModelConfigs(): Promise<AIModel[]> { async getModelConfigs(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/models`); const res = await fetch(`${API_BASE}/models`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取模型配置失败'); if (!res.ok) throw new Error('获取模型配置失败');
return res.json(); return res.json();
}, },
// 获取系统支持的AI模型列表无需认证
async getSupportedModels(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/supported-models`);
if (!res.ok) throw new Error('获取支持的模型失败');
return res.json();
},
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> { async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
const res = await fetch(`${API_BASE}/models`, { const res = await fetch(`${API_BASE}/models`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); });
if (!res.ok) throw new Error('更新模型配置失败'); if (!res.ok) throw new Error('更新模型配置失败');
@@ -71,15 +109,24 @@ export const api = {
// 交易所配置接口 // 交易所配置接口
async getExchangeConfigs(): Promise<Exchange[]> { async getExchangeConfigs(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/exchanges`); const res = await fetch(`${API_BASE}/exchanges`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取交易所配置失败'); if (!res.ok) throw new Error('获取交易所配置失败');
return res.json(); return res.json();
}, },
// 获取系统支持的交易所列表(无需认证)
async getSupportedExchanges(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/supported-exchanges`);
if (!res.ok) throw new Error('获取支持的交易所失败');
return res.json();
},
async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> { async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> {
const res = await fetch(`${API_BASE}/exchanges`, { const res = await fetch(`${API_BASE}/exchanges`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: getAuthHeaders(),
body: JSON.stringify(request), body: JSON.stringify(request),
}); });
if (!res.ok) throw new Error('更新交易所配置失败'); if (!res.ok) throw new Error('更新交易所配置失败');
@@ -90,7 +137,9 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/status?trader_id=${traderId}` ? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`; : `${API_BASE}/status`;
const res = await fetch(url); const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取系统状态失败'); if (!res.ok) throw new Error('获取系统状态失败');
return res.json(); return res.json();
}, },
@@ -103,6 +152,7 @@ export const api = {
const res = await fetch(url, { const res = await fetch(url, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
}, },
}); });
@@ -117,7 +167,9 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}` ? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`; : `${API_BASE}/positions`;
const res = await fetch(url); const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取持仓列表失败'); if (!res.ok) throw new Error('获取持仓列表失败');
return res.json(); return res.json();
}, },
@@ -127,7 +179,9 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}` ? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`; : `${API_BASE}/decisions`;
const res = await fetch(url); const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取决策日志失败'); if (!res.ok) throw new Error('获取决策日志失败');
return res.json(); return res.json();
}, },
@@ -137,7 +191,9 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}` ? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`; : `${API_BASE}/decisions/latest`;
const res = await fetch(url); const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取最新决策失败'); if (!res.ok) throw new Error('获取最新决策失败');
return res.json(); return res.json();
}, },
@@ -147,7 +203,9 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}` ? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`; : `${API_BASE}/statistics`;
const res = await fetch(url); const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取统计信息失败'); if (!res.ok) throw new Error('获取统计信息失败');
return res.json(); return res.json();
}, },
@@ -157,7 +215,9 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}` ? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`; : `${API_BASE}/equity-history`;
const res = await fetch(url); const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取历史数据失败'); if (!res.ok) throw new Error('获取历史数据失败');
return res.json(); return res.json();
}, },
@@ -167,8 +227,19 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}` ? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance`; : `${API_BASE}/performance`;
const res = await fetch(url); const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取AI学习数据失败'); if (!res.ok) throw new Error('获取AI学习数据失败');
return res.json(); return res.json();
}, },
// 获取竞赛数据
async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取竞赛数据失败');
return res.json();
},
}; };

27
web/src/lib/config.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface SystemConfig {
admin_mode: boolean;
}
let configPromise: Promise<SystemConfig> | null = null;
let cachedConfig: SystemConfig | null = null;
export function getSystemConfig(): Promise<SystemConfig> {
if (cachedConfig) {
return Promise.resolve(cachedConfig);
}
if (configPromise) {
return configPromise;
}
configPromise = fetch('/api/config')
.then((res) => res.json())
.then((data: SystemConfig) => {
cachedConfig = data;
return data;
})
.finally(() => {
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
});
return configPromise;
}

View File

@@ -89,7 +89,9 @@ export interface TraderInfo {
trader_id: string; trader_id: string;
trader_name: string; trader_name: string;
ai_model: string; ai_model: string;
exchange_id?: string;
is_running?: boolean; is_running?: boolean;
custom_prompt?: string;
} }
export interface AIModel { export interface AIModel {
@@ -108,6 +110,12 @@ export interface Exchange {
apiKey?: string; apiKey?: string;
secretKey?: string; secretKey?: string;
testnet?: boolean; testnet?: boolean;
// Hyperliquid 特定字段
hyperliquidWalletAddr?: string;
// Aster 特定字段
asterUser?: string;
asterSigner?: string;
asterPrivateKey?: string;
} }
export interface CreateTraderRequest { export interface CreateTraderRequest {
@@ -115,6 +123,8 @@ export interface CreateTraderRequest {
ai_model_id: string; ai_model_id: string;
exchange_id: string; exchange_id: string;
initial_balance: number; initial_balance: number;
custom_prompt?: string;
override_base_prompt?: boolean;
} }
export interface UpdateModelConfigRequest { export interface UpdateModelConfigRequest {
@@ -133,6 +143,30 @@ export interface UpdateExchangeConfigRequest {
api_key: string; api_key: string;
secret_key: string; secret_key: string;
testnet?: boolean; testnet?: boolean;
// Hyperliquid 特定字段
hyperliquid_wallet_addr?: string;
// Aster 特定字段
aster_user?: string;
aster_signer?: string;
aster_private_key?: string;
}; };
}; };
} }
// Competition related types
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;
is_running: boolean;
}
export interface CompetitionData {
traders: CompetitionTraderData[];
count: number;
}

View File

@@ -8,7 +8,7 @@ export default defineConfig({
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8081', target: 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
}, },
}, },