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
+33 -18
View File
@@ -64,23 +64,23 @@ nano config.json # 或使用其他编辑器
**必须配置的字段:**
```json
{
"traders": [
{
"id": "my_trader",
"name": "My AI Trader",
"ai_model": "deepseek",
"binance_api_key": "YOUR_BINANCE_API_KEY", // ← 填入你的币安 API Key
"binance_secret_key": "YOUR_BINANCE_SECRET_KEY", // ← 填入你的币安 Secret Key
"deepseek_key": "YOUR_DEEPSEEK_API_KEY", // ← 填入你的 DeepSeek API Key
"initial_balance": 1000.0,
"scan_interval_minutes": 3
}
],
"use_default_coins": true,
"api_server_port": 8080
"api_server_port": 8081,
"jwt_secret": "YOUR_JWT_SECRET_CHANGE_IN_PRODUCTION" // ← 填入一个长随机字符串作为JWT密钥
}
```
> **⚠️ 重要安全提醒**
> - `jwt_secret` 字段是用户认证系统的关键安全配置
> - **必须设置一个长度至少32位的随机字符串**
> - 在生产环境中,建议使用64位以上的随机字符串
> - 可以使用命令生成:`openssl rand -base64 64`
**配置说明:**
- 🔐 **用户认证**:系统现在支持用户注册登录,每个用户都有独立的AI模型和交易所配置
- 🚫 **移除traders配置**:不再需要在config.json中预配置交易员,用户可以通过Web界面创建
- 🔑 **JWT密钥**:用于保护用户会话安全,强烈建议在生产环境中设置复杂密钥
### 第 2 步:一键启动
```bash
@@ -310,23 +310,38 @@ docker system prune -a --volumes
## 🔐 安全建议
1. **不要将 config.json 提交到 Git**
1. **JWT密钥安全配置**
```bash
# 生成强随机JWT密钥
openssl rand -base64 64
# 或者使用其他工具生成
head -c 64 /dev/urandom | base64
```
**JWT密钥要求:**
- 长度至少32位,推荐64位以上
- 使用随机生成的字符串
- 在生产环境中绝不使用默认值
- 定期更换(会使现有用户需要重新登录)
2. **不要将 config.json 提交到 Git**
```bash
# 确保 config.json 在 .gitignore 中
echo "config.json" >> .gitignore
```
2. **使用环境变量存储敏感信息**
3. **使用环境变量存储敏感信息**
```yaml
# docker-compose.yml
services:
backend:
environment:
- BINANCE_API_KEY=${BINANCE_API_KEY}
- BINANCE_SECRET_KEY=${BINANCE_SECRET_KEY}
- JWT_SECRET=${JWT_SECRET}
# 用户的API密钥现在通过Web界面配置,不再需要环境变量
```
3. **限制 API 访问**
4. **限制 API 访问**
```yaml
# 只允许本地访问
services:
+439 -35
View File
@@ -4,11 +4,14 @@ import (
"fmt"
"log"
"net/http"
"nofx/auth"
"nofx/config"
"nofx/manager"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Server HTTP API服务器
@@ -66,30 +69,51 @@ func (s *Server) setupRoutes() {
// API路由组
api := s.router.Group("/api")
{
// AI交易员管理
api.GET("/traders", s.handleTraderList)
api.POST("/traders", s.handleCreateTrader)
api.DELETE("/traders/:id", s.handleDeleteTrader)
api.POST("/traders/:id/start", s.handleStartTrader)
api.POST("/traders/:id/stop", s.handleStopTrader)
// 认证相关路由(无需认证)
api.POST("/register", s.handleRegister)
api.POST("/login", s.handleLogin)
api.POST("/verify-otp", s.handleVerifyOTP)
api.POST("/complete-registration", s.handleCompleteRegistration)
// 系统支持的模型和交易所(无需认证)
api.GET("/supported-models", s.handleGetSupportedModels)
api.GET("/supported-exchanges", s.handleGetSupportedExchanges)
// 系统配置(无需认证)
api.GET("/config", s.handleGetSystemConfig)
// AI模型配置
api.GET("/models", s.handleGetModelConfigs)
api.PUT("/models", s.handleUpdateModelConfigs)
// 需要认证的路由
protected := api.Group("/", s.authMiddleware())
{
// AI交易员管理
protected.GET("/traders", s.handleTraderList)
protected.POST("/traders", s.handleCreateTrader)
protected.DELETE("/traders/:id", s.handleDeleteTrader)
protected.POST("/traders/:id/start", s.handleStartTrader)
protected.POST("/traders/:id/stop", s.handleStopTrader)
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
// 交易所配置
api.GET("/exchanges", s.handleGetExchangeConfigs)
api.PUT("/exchanges", s.handleUpdateExchangeConfigs)
// AI模型配置
protected.GET("/models", s.handleGetModelConfigs)
protected.PUT("/models", s.handleUpdateModelConfigs)
// 指定trader的数据(使用query参数 ?trader_id=xxx
api.GET("/status", s.handleStatus)
api.GET("/account", s.handleAccount)
api.GET("/positions", s.handlePositions)
api.GET("/decisions", s.handleDecisions)
api.GET("/decisions/latest", s.handleLatestDecisions)
api.GET("/statistics", s.handleStatistics)
api.GET("/equity-history", s.handleEquityHistory)
api.GET("/performance", s.handlePerformance)
// 交易所配置
protected.GET("/exchanges", s.handleGetExchangeConfigs)
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
// 竞赛总览
protected.GET("/competition", s.handleCompetition)
// 指定trader的数据(使用query参数 ?trader_id=xxx
protected.GET("/status", s.handleStatus)
protected.GET("/account", s.handleAccount)
protected.GET("/positions", s.handlePositions)
protected.GET("/decisions", s.handleDecisions)
protected.GET("/decisions/latest", s.handleLatestDecisions)
protected.GET("/statistics", s.handleStatistics)
protected.GET("/equity-history", s.handleEquityHistory)
protected.GET("/performance", s.handlePerformance)
}
}
}
@@ -101,17 +125,40 @@ func (s *Server) handleHealth(c *gin.Context) {
})
}
// handleGetSystemConfig 获取系统配置(客户端需要知道的配置)
func (s *Server) handleGetSystemConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"admin_mode": auth.IsAdminMode(),
})
}
// getTraderFromQuery 从query参数获取trader
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
userID := c.GetString("user_id")
traderID := c.Query("trader_id")
// 确保用户的交易员已加载到内存中
err := s.traderManager.LoadUserTraders(s.database, userID)
if err != nil {
log.Printf("⚠️ 加载用户 %s 的交易员失败: %v", userID, err)
}
if traderID == "" {
// 如果没有指定trader_id,返回第一个trader
// 如果没有指定trader_id,返回该用户的第一个trader
ids := s.traderManager.GetTraderIDs()
if len(ids) == 0 {
return nil, "", fmt.Errorf("没有可用的trader")
}
traderID = ids[0]
// 获取用户的交易员列表,优先返回用户自己的交易员
userTraders, err := s.database.GetTraders(userID)
if err == nil && len(userTraders) > 0 {
traderID = userTraders[0].ID
} else {
traderID = ids[0]
}
}
return s.traderManager, traderID, nil
}
@@ -121,6 +168,8 @@ type CreateTraderRequest struct {
AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"`
InitialBalance float64 `json:"initial_balance"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
}
type ModelConfig struct {
@@ -150,15 +199,20 @@ type UpdateModelConfigRequest struct {
type UpdateExchangeConfigRequest struct {
Exchanges map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
} `json:"exchanges"`
}
// handleCreateTrader 创建新的AI交易员
func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -171,10 +225,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// 创建交易员配置
trader := &config.TraderConfig{
ID: traderID,
UserID: userID,
Name: req.Name,
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
InitialBalance: req.InitialBalance,
CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt,
ScanIntervalMinutes: 3, // 默认3分钟
IsRunning: false,
}
@@ -186,6 +243,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
return
}
// 立即将新交易员加载到TraderManager中
err = s.traderManager.LoadUserTraders(s.database, userID)
if err != nil {
log.Printf("⚠️ 加载用户交易员到内存失败: %v", err)
// 这里不返回错误,因为交易员已经成功创建到数据库
}
log.Printf("✓ 创建交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID)
c.JSON(http.StatusCreated, gin.H{
@@ -198,10 +262,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// handleDeleteTrader 删除交易员
func (s *Server) handleDeleteTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// 从数据库删除
err := s.database.DeleteTrader(traderID)
err := s.database.DeleteTrader(userID, traderID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("删除交易员失败: %v", err)})
return
@@ -246,7 +311,8 @@ func (s *Server) handleStartTrader(c *gin.Context) {
}()
// 更新数据库中的运行状态
err = s.database.UpdateTraderStatus(traderID, true)
userID := c.GetString("user_id")
err = s.database.UpdateTraderStatus(userID, traderID, true)
if err != nil {
log.Printf("⚠️ 更新交易员状态失败: %v", err)
}
@@ -276,7 +342,8 @@ func (s *Server) handleStopTrader(c *gin.Context) {
trader.Stop()
// 更新数据库中的运行状态
err = s.database.UpdateTraderStatus(traderID, false)
userID := c.GetString("user_id")
err = s.database.UpdateTraderStatus(userID, traderID, false)
if err != nil {
log.Printf("⚠️ 更新交易员状态失败: %v", err)
}
@@ -285,19 +352,57 @@ func (s *Server) handleStopTrader(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "交易员已停止"})
}
// handleUpdateTraderPrompt 更新交易员自定义Prompt
func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
traderID := c.Param("id")
userID := c.GetString("user_id")
var req struct {
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 更新数据库
err := s.database.UpdateTraderCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新自定义prompt失败: %v", err)})
return
}
// 如果trader在内存中,更新其custom prompt和override设置
trader, err := s.traderManager.GetTrader(traderID)
if err == nil {
trader.SetCustomPrompt(req.CustomPrompt)
trader.SetOverrideBasePrompt(req.OverrideBasePrompt)
log.Printf("✓ 已更新交易员 %s 的自定义prompt (覆盖基础=%v)", trader.GetName(), req.OverrideBasePrompt)
}
c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"})
}
// handleGetModelConfigs 获取AI模型配置
func (s *Server) handleGetModelConfigs(c *gin.Context) {
models, err := s.database.GetAIModels()
userID := c.GetString("user_id")
log.Printf("🔍 查询用户 %s 的AI模型配置", userID)
models, err := s.database.GetAIModels(userID)
if err != nil {
log.Printf("❌ 获取AI模型配置失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取AI模型配置失败: %v", err)})
return
}
log.Printf("✅ 找到 %d 个AI模型配置", len(models))
c.JSON(http.StatusOK, models)
}
// handleUpdateModelConfigs 更新AI模型配置
func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
userID := c.GetString("user_id")
var req UpdateModelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -306,7 +411,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
// 更新每个模型的配置
for modelID, modelData := range req.Models {
err := s.database.UpdateAIModel(modelID, modelData.Enabled, modelData.APIKey)
err := s.database.UpdateAIModel(userID, modelID, modelData.Enabled, modelData.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新模型 %s 失败: %v", modelID, err)})
return
@@ -319,17 +424,22 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
// handleGetExchangeConfigs 获取交易所配置
func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
exchanges, err := s.database.GetExchanges()
userID := c.GetString("user_id")
log.Printf("🔍 查询用户 %s 的交易所配置", userID)
exchanges, err := s.database.GetExchanges(userID)
if err != nil {
log.Printf("❌ 获取交易所配置失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易所配置失败: %v", err)})
return
}
log.Printf("✅ 找到 %d 个交易所配置", len(exchanges))
c.JSON(http.StatusOK, exchanges)
}
// handleUpdateExchangeConfigs 更新交易所配置
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
userID := c.GetString("user_id")
var req UpdateExchangeConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -338,7 +448,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
// 更新每个交易所的配置
for exchangeID, exchangeData := range req.Exchanges {
err := s.database.UpdateExchange(exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet)
err := s.database.UpdateExchange(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)})
return
@@ -351,7 +461,8 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
// handleTraderList trader列表
func (s *Server) handleTraderList(c *gin.Context) {
traders, err := s.database.GetTraders()
userID := c.GetString("user_id")
traders, err := s.database.GetTraders(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易员列表失败: %v", err)})
return
@@ -539,6 +650,27 @@ func (s *Server) handleStatistics(c *gin.Context) {
c.JSON(http.StatusOK, stats)
}
// handleCompetition 竞赛总览(对比所有trader)
func (s *Server) handleCompetition(c *gin.Context) {
userID := c.GetString("user_id")
// 确保用户的交易员已加载到内存中
err := s.traderManager.LoadUserTraders(s.database, userID)
if err != nil {
log.Printf("⚠️ 加载用户 %s 的交易员失败: %v", userID, err)
}
competition, err := s.traderManager.GetCompetitionData(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("获取竞赛数据失败: %v", err),
})
return
}
c.JSON(http.StatusOK, competition)
}
// handleEquityHistory 收益率历史数据
func (s *Server) handleEquityHistory(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
@@ -652,6 +784,278 @@ func (s *Server) handlePerformance(c *gin.Context) {
c.JSON(http.StatusOK, performance)
}
// authMiddleware JWT认证中间件
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 如果是管理员模式,直接使用admin用户
if auth.IsAdminMode() {
c.Set("user_id", "admin")
c.Set("email", "admin@localhost")
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少Authorization头"})
c.Abort()
return
}
// 检查Bearer token格式
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的Authorization格式"})
c.Abort()
return
}
// 验证JWT token
claims, err := auth.ValidateJWT(tokenParts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token: " + err.Error()})
c.Abort()
return
}
// 将用户信息存储到上下文中
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}
// handleRegister 处理用户注册请求
func (s *Server) handleRegister(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查邮箱是否已存在
_, err := s.database.GetUserByEmail(req.Email)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"})
return
}
// 生成密码哈希
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码处理失败"})
return
}
// 生成OTP密钥
otpSecret, err := auth.GenerateOTPSecret()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "OTP密钥生成失败"})
return
}
// 创建用户(未验证OTP状态)
userID := uuid.New().String()
user := &config.User{
ID: userID,
Email: req.Email,
PasswordHash: passwordHash,
OTPSecret: otpSecret,
OTPVerified: false,
}
err = s.database.CreateUser(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败: " + err.Error()})
return
}
// 返回OTP设置信息
qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email)
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"email": req.Email,
"otp_secret": otpSecret,
"qr_code_url": qrCodeURL,
"message": "请使用Google Authenticator扫描二维码并验证OTP",
})
}
// handleCompleteRegistration 完成注册(验证OTP
func (s *Server) handleCompleteRegistration(c *gin.Context) {
var req struct {
UserID string `json:"user_id" binding:"required"`
OTPCode string `json:"otp_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 获取用户信息
user, err := s.database.GetUserByID(req.UserID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
// 验证OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "OTP验证码错误"})
return
}
// 更新用户OTP验证状态
err = s.database.UpdateUserOTPVerified(req.UserID, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户状态失败"})
return
}
// 生成JWT token
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"})
return
}
// 初始化用户的默认模型和交易所配置
err = s.initUserDefaultConfigs(user.ID)
if err != nil {
log.Printf("初始化用户默认配置失败: %v", err)
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": user.ID,
"email": user.Email,
"message": "注册完成",
})
}
// handleLogin 处理用户登录请求
func (s *Server) handleLogin(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 获取用户信息
user, err := s.database.GetUserByEmail(req.Email)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
return
}
// 验证密码
if !auth.CheckPassword(req.Password, user.PasswordHash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
return
}
// 检查OTP是否已验证
if !user.OTPVerified {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "账户未完成OTP设置",
"user_id": user.ID,
"requires_otp_setup": true,
})
return
}
// 返回需要OTP验证的状态
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"email": user.Email,
"message": "请输入Google Authenticator验证码",
"requires_otp": true,
})
}
// handleVerifyOTP 验证OTP并完成登录
func (s *Server) handleVerifyOTP(c *gin.Context) {
var req struct {
UserID string `json:"user_id" binding:"required"`
OTPCode string `json:"otp_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 获取用户信息
user, err := s.database.GetUserByID(req.UserID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
// 验证OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "验证码错误"})
return
}
// 生成JWT token
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": user.ID,
"email": user.Email,
"message": "登录成功",
})
}
// initUserDefaultConfigs 为新用户初始化默认的模型和交易所配置
func (s *Server) initUserDefaultConfigs(userID string) error {
// 注释掉自动创建默认配置,让用户手动添加
// 这样新用户注册后不会自动有配置项
log.Printf("用户 %s 注册完成,等待手动配置AI模型和交易所", userID)
return nil
}
// handleGetSupportedModels 获取系统支持的AI模型列表
func (s *Server) handleGetSupportedModels(c *gin.Context) {
// 返回系统支持的AI模型(从default用户获取)
models, err := s.database.GetAIModels("default")
if err != nil {
log.Printf("❌ 获取支持的AI模型失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取支持的AI模型失败"})
return
}
c.JSON(http.StatusOK, models)
}
// handleGetSupportedExchanges 获取系统支持的交易所列表
func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
// 返回系统支持的交易所(从default用户获取)
exchanges, err := s.database.GetExchanges("default")
if err != nil {
log.Printf("❌ 获取支持的交易所失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取支持的交易所失败"})
return
}
c.JSON(http.StatusOK, exchanges)
}
// Start 启动服务器
func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.port)
+121
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)
}
-80
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
}
+441 -62
View File
@@ -1,8 +1,11 @@
package config
import (
"crypto/rand"
"database/sql"
"encoding/base32"
"fmt"
"log"
"time"
_ "github.com/mattn/go-sqlite3"
@@ -38,30 +41,41 @@ func (d *Database) createTables() error {
// AI模型配置表
`CREATE TABLE IF NOT EXISTS ai_models (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
provider TEXT NOT NULL,
enabled BOOLEAN DEFAULT 0,
api_key TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
// 交易所配置表
`CREATE TABLE IF NOT EXISTS exchanges (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'cex' or 'dex'
enabled BOOLEAN DEFAULT 0,
api_key TEXT DEFAULT '',
secret_key TEXT DEFAULT '',
testnet BOOLEAN DEFAULT 0,
-- Hyperliquid 特定字段
hyperliquid_wallet_addr TEXT DEFAULT '',
-- Aster 特定字段
aster_user TEXT DEFAULT '',
aster_signer TEXT DEFAULT '',
aster_private_key TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
// 交易员配置表
`CREATE TABLE IF NOT EXISTS traders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
ai_model_id TEXT NOT NULL,
exchange_id TEXT NOT NULL,
@@ -70,10 +84,22 @@ func (d *Database) createTables() error {
is_running BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (ai_model_id) REFERENCES ai_models(id),
FOREIGN KEY (exchange_id) REFERENCES exchanges(id)
)`,
// 用户表
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
otp_secret TEXT,
otp_verified BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// 系统配置表
`CREATE TABLE IF NOT EXISTS system_config (
key TEXT PRIMARY KEY,
@@ -82,6 +108,12 @@ func (d *Database) createTables() error {
)`,
// 触发器:自动更新 updated_at
`CREATE TRIGGER IF NOT EXISTS update_users_updated_at
AFTER UPDATE ON users
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END`,
`CREATE TRIGGER IF NOT EXISTS update_ai_models_updated_at
AFTER UPDATE ON ai_models
BEGIN
@@ -113,12 +145,33 @@ func (d *Database) createTables() error {
}
}
// 为现有数据库添加新字段(向后兼容)
alterQueries := []string{
`ALTER TABLE exchanges ADD COLUMN hyperliquid_wallet_addr TEXT DEFAULT ''`,
`ALTER TABLE exchanges ADD COLUMN aster_user TEXT DEFAULT ''`,
`ALTER TABLE exchanges ADD COLUMN aster_signer TEXT DEFAULT ''`,
`ALTER TABLE exchanges ADD COLUMN aster_private_key TEXT DEFAULT ''`,
`ALTER TABLE traders ADD COLUMN custom_prompt TEXT DEFAULT ''`,
`ALTER TABLE traders ADD COLUMN override_base_prompt BOOLEAN DEFAULT 0`,
}
for _, query := range alterQueries {
// 忽略已存在字段的错误
d.db.Exec(query)
}
// 检查是否需要迁移exchanges表的主键结构
err := d.migrateExchangesTable()
if err != nil {
log.Printf("⚠️ 迁移exchanges表失败: %v", err)
}
return nil
}
// initDefaultData 初始化默认数据
func (d *Database) initDefaultData() error {
// 初始化AI模型
// 初始化AI模型(使用default用户)
aiModels := []struct {
id, name, provider string
}{
@@ -128,26 +181,27 @@ func (d *Database) initDefaultData() error {
for _, model := range aiModels {
_, err := d.db.Exec(`
INSERT OR IGNORE INTO ai_models (id, name, provider, enabled)
VALUES (?, ?, ?, 0)
INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled)
VALUES (?, 'default', ?, ?, 0)
`, model.id, model.name, model.provider)
if err != nil {
return fmt.Errorf("初始化AI模型失败: %w", err)
}
}
// 初始化交易所
// 初始化交易所(使用default用户)
exchanges := []struct {
id, name, typ string
}{
{"binance", "Binance", "cex"},
{"hyperliquid", "Hyperliquid", "dex"},
{"binance", "Binance Futures", "binance"},
{"hyperliquid", "Hyperliquid", "hyperliquid"},
{"aster", "Aster DEX", "aster"},
}
for _, exchange := range exchanges {
_, err := d.db.Exec(`
INSERT OR IGNORE INTO exchanges (id, name, type, enabled)
VALUES (?, ?, ?, 0)
INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled)
VALUES (?, 'default', ?, ?, 0)
`, exchange.id, exchange.name, exchange.typ)
if err != nil {
return fmt.Errorf("初始化交易所失败: %w", err)
@@ -156,7 +210,7 @@ func (d *Database) initDefaultData() error {
// 初始化系统配置
systemConfigs := map[string]string{
"api_server_port": "8081",
"api_server_port": "8080",
"use_default_coins": "true",
"coin_pool_api_url": "",
"oi_top_api_url": "",
@@ -178,9 +232,103 @@ func (d *Database) initDefaultData() error {
return nil
}
// migrateExchangesTable 迁移exchanges表支持多用户
func (d *Database) migrateExchangesTable() error {
// 检查是否已经迁移过
var count int
err := d.db.QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name='exchanges_new'
`).Scan(&count)
if err != nil {
return err
}
// 如果已经迁移过,直接返回
if count > 0 {
return nil
}
log.Printf("🔄 开始迁移exchanges表...")
// 创建新的exchanges表,使用复合主键
_, err = d.db.Exec(`
CREATE TABLE exchanges_new (
id TEXT NOT NULL,
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled BOOLEAN DEFAULT 0,
api_key TEXT DEFAULT '',
secret_key TEXT DEFAULT '',
testnet BOOLEAN DEFAULT 0,
hyperliquid_wallet_addr TEXT DEFAULT '',
aster_user TEXT DEFAULT '',
aster_signer TEXT DEFAULT '',
aster_private_key TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`)
if err != nil {
return fmt.Errorf("创建新exchanges表失败: %w", err)
}
// 复制数据到新表
_, err = d.db.Exec(`
INSERT INTO exchanges_new
SELECT * FROM exchanges
`)
if err != nil {
return fmt.Errorf("复制数据失败: %w", err)
}
// 删除旧表
_, err = d.db.Exec(`DROP TABLE exchanges`)
if err != nil {
return fmt.Errorf("删除旧表失败: %w", err)
}
// 重命名新表
_, err = d.db.Exec(`ALTER TABLE exchanges_new RENAME TO exchanges`)
if err != nil {
return fmt.Errorf("重命名表失败: %w", err)
}
// 重新创建触发器
_, err = d.db.Exec(`
CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at
AFTER UPDATE ON exchanges
BEGIN
UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP
WHERE id = NEW.id AND user_id = NEW.user_id;
END
`)
if err != nil {
return fmt.Errorf("创建触发器失败: %w", err)
}
log.Printf("✅ exchanges表迁移完成")
return nil
}
// User 用户配置
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"` // 不返回到前端
OTPSecret string `json:"-"` // 不返回到前端
OTPVerified bool `json:"otp_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AIModelConfig AI模型配置
type AIModelConfig struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
@@ -192,45 +340,139 @@ type AIModelConfig struct {
// ExchangeConfig 交易所配置
type ExchangeConfig struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
Testnet bool `json:"testnet"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Hyperliquid 特定字段
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"`
// Aster 特定字段
AsterUser string `json:"asterUser"`
AsterSigner string `json:"asterSigner"`
AsterPrivateKey string `json:"asterPrivateKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TraderConfig 交易员配置
type TraderConfig struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"`
CustomPrompt string `json:"custom_prompt"` // 自定义交易策略prompt
OverrideBasePrompt bool `json:"override_base_prompt"` // 是否覆盖基础prompt
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GetAIModels 获取所有AI模型配置
func (d *Database) GetAIModels() ([]*AIModelConfig, error) {
// GenerateOTPSecret 生成OTP密钥
func GenerateOTPSecret() (string, error) {
secret := make([]byte, 20)
_, err := rand.Read(secret)
if err != nil {
return "", err
}
return base32.StdEncoding.EncodeToString(secret), nil
}
// CreateUser 创建用户
func (d *Database) CreateUser(user *User) error {
_, err := d.db.Exec(`
INSERT INTO users (id, email, password_hash, otp_secret, otp_verified)
VALUES (?, ?, ?, ?, ?)
`, user.ID, user.Email, user.PasswordHash, user.OTPSecret, user.OTPVerified)
return err
}
// EnsureAdminUser 确保admin用户存在(用于管理员模式)
func (d *Database) EnsureAdminUser() error {
// 检查admin用户是否已存在
var count int
err := d.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = 'admin'`).Scan(&count)
if err != nil {
return err
}
// 如果已存在,直接返回
if count > 0 {
return nil
}
// 创建admin用户(密码为空,因为管理员模式下不需要密码)
adminUser := &User{
ID: "admin",
Email: "admin@localhost",
PasswordHash: "", // 管理员模式下不使用密码
OTPSecret: "",
OTPVerified: true,
}
return d.CreateUser(adminUser)
}
// GetUserByEmail 通过邮箱获取用户
func (d *Database) GetUserByEmail(email string) (*User, error) {
var user User
err := d.db.QueryRow(`
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
FROM users WHERE email = ?
`, email).Scan(
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
&user.OTPVerified, &user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID 通过ID获取用户
func (d *Database) GetUserByID(userID string) (*User, error) {
var user User
err := d.db.QueryRow(`
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
FROM users WHERE id = ?
`, userID).Scan(
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
&user.OTPVerified, &user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, err
}
return &user, nil
}
// UpdateUserOTPVerified 更新用户OTP验证状态
func (d *Database) UpdateUserOTPVerified(userID string, verified bool) error {
_, err := d.db.Exec(`UPDATE users SET otp_verified = ? WHERE id = ?`, verified, userID)
return err
}
// GetAIModels 获取用户的AI模型配置
func (d *Database) GetAIModels(userID string) ([]*AIModelConfig, error) {
rows, err := d.db.Query(`
SELECT id, name, provider, enabled, api_key, created_at, updated_at
FROM ai_models ORDER BY id
`)
SELECT id, user_id, name, provider, enabled, api_key, created_at, updated_at
FROM ai_models WHERE user_id = ? ORDER BY id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var models []*AIModelConfig
// 初始化为空切片而不是nil,确保JSON序列化为[]而不是null
models := make([]*AIModelConfig, 0)
for rows.Next() {
var model AIModelConfig
err := rows.Scan(
&model.ID, &model.Name, &model.Provider,
&model.ID, &model.UserID, &model.Name, &model.Provider,
&model.Enabled, &model.APIKey,
&model.CreatedAt, &model.UpdatedAt,
)
@@ -243,31 +485,80 @@ func (d *Database) GetAIModels() ([]*AIModelConfig, error) {
return models, nil
}
// UpdateAIModel 更新AI模型配置
func (d *Database) UpdateAIModel(id string, enabled bool, apiKey string) error {
_, err := d.db.Exec(`
UPDATE ai_models SET enabled = ?, api_key = ? WHERE id = ?
`, enabled, apiKey, id)
return err
// UpdateAIModel 更新AI模型配置,如果不存在则创建用户特定配置
func (d *Database) UpdateAIModel(userID, id string, enabled bool, apiKey string) error {
// 首先尝试更新现有的用户配置
result, err := d.db.Exec(`
UPDATE ai_models SET enabled = ?, api_key = ? WHERE id = ? AND user_id = ?
`, enabled, apiKey, id, userID)
if err != nil {
return err
}
// 检查是否有行被更新
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// 如果没有行被更新,说明用户没有这个模型的配置,需要创建
if rowsAffected == 0 {
// 获取模型的基本信息
var name, provider string
err = d.db.QueryRow(`
SELECT name, provider FROM ai_models WHERE provider = ? LIMIT 1
`, id).Scan(&name, &provider)
if err != nil {
// 如果找不到基本信息,使用默认值
if id == "deepseek" {
name = "DeepSeek AI"
provider = "deepseek"
} else if id == "qwen" {
name = "Qwen AI"
provider = "qwen"
} else {
name = id + " AI"
provider = id
}
}
// 创建用户特定的配置
userModelID := fmt.Sprintf("%s_%s", userID, id)
_, err = d.db.Exec(`
INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, userModelID, userID, name, provider, enabled, apiKey)
return err
}
return nil
}
// GetExchanges 获取所有交易所配置
func (d *Database) GetExchanges() ([]*ExchangeConfig, error) {
// GetExchanges 获取用户的交易所配置
func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) {
rows, err := d.db.Query(`
SELECT id, name, type, enabled, api_key, secret_key, testnet, created_at, updated_at
FROM exchanges ORDER BY id
`)
SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet,
COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr,
COALESCE(aster_user, '') as aster_user,
COALESCE(aster_signer, '') as aster_signer,
COALESCE(aster_private_key, '') as aster_private_key,
created_at, updated_at
FROM exchanges WHERE user_id = ? ORDER BY id
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var exchanges []*ExchangeConfig
// 初始化为空切片而不是nil,确保JSON序列化为[]而不是null
exchanges := make([]*ExchangeConfig, 0)
for rows.Next() {
var exchange ExchangeConfig
err := rows.Scan(
&exchange.ID, &exchange.Name, &exchange.Type,
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type,
&exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
&exchange.HyperliquidWalletAddr, &exchange.AsterUser,
&exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.CreatedAt, &exchange.UpdatedAt,
)
if err != nil {
@@ -279,29 +570,105 @@ func (d *Database) GetExchanges() ([]*ExchangeConfig, error) {
return exchanges, nil
}
// UpdateExchange 更新交易所配置
func (d *Database) UpdateExchange(id string, enabled bool, apiKey, secretKey string, testnet bool) error {
// UpdateExchange 更新交易所配置,如果不存在则创建用户特定配置
func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
log.Printf("🔧 UpdateExchange: userID=%s, id=%s, enabled=%v", userID, id, enabled)
// 首先尝试更新现有的用户配置
result, err := d.db.Exec(`
UPDATE exchanges SET enabled = ?, api_key = ?, secret_key = ?, testnet = ?,
hyperliquid_wallet_addr = ?, aster_user = ?, aster_signer = ?, aster_private_key = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, id, userID)
if err != nil {
log.Printf("❌ UpdateExchange: 更新失败: %v", err)
return err
}
// 检查是否有行被更新
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("❌ UpdateExchange: 获取影响行数失败: %v", err)
return err
}
log.Printf("📊 UpdateExchange: 影响行数 = %d", rowsAffected)
// 如果没有行被更新,说明用户没有这个交易所的配置,需要创建
if rowsAffected == 0 {
log.Printf("💡 UpdateExchange: 没有现有记录,创建新记录")
// 根据交易所ID确定基本信息
var name, typ string
if id == "binance" {
name = "Binance Futures"
typ = "cex"
} else if id == "hyperliquid" {
name = "Hyperliquid"
typ = "dex"
} else if id == "aster" {
name = "Aster DEX"
typ = "dex"
} else {
name = id + " Exchange"
typ = "cex"
}
log.Printf("🆕 UpdateExchange: 创建新记录 ID=%s, name=%s, type=%s", id, name, typ)
// 创建用户特定的配置,使用原始的交易所ID
_, err = d.db.Exec(`
INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet,
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, id, userID, name, typ, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey)
if err != nil {
log.Printf("❌ UpdateExchange: 创建记录失败: %v", err)
} else {
log.Printf("✅ UpdateExchange: 创建记录成功")
}
return err
}
log.Printf("✅ UpdateExchange: 更新现有记录成功")
return nil
}
// CreateAIModel 创建AI模型配置
func (d *Database) CreateAIModel(userID, id, name, provider string, enabled bool, apiKey string) error {
_, err := d.db.Exec(`
UPDATE exchanges SET enabled = ?, api_key = ?, secret_key = ?, testnet = ? WHERE id = ?
`, enabled, apiKey, secretKey, testnet, id)
INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled, api_key)
VALUES (?, ?, ?, ?, ?, ?)
`, id, userID, name, provider, enabled, apiKey)
return err
}
// CreateExchange 创建交易所配置
func (d *Database) CreateExchange(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
_, err := d.db.Exec(`
INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, id, userID, name, typ, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey)
return err
}
// CreateTrader 创建交易员
func (d *Database) CreateTrader(trader *TraderConfig) error {
_, err := d.db.Exec(`
INSERT INTO traders (id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, trader.ID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning)
INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, custom_prompt, override_base_prompt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.CustomPrompt, trader.OverrideBasePrompt)
return err
}
// GetTraders 获取所有交易员
func (d *Database) GetTraders() ([]*TraderConfig, error) {
// GetTraders 获取用户的交易员
func (d *Database) GetTraders(userID string) ([]*TraderConfig, error) {
rows, err := d.db.Query(`
SELECT id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, created_at, updated_at
FROM traders ORDER BY created_at DESC
`)
SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running,
COALESCE(custom_prompt, '') as custom_prompt, COALESCE(override_base_prompt, 0) as override_base_prompt, created_at, updated_at
FROM traders WHERE user_id = ? ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
@@ -311,9 +678,9 @@ func (d *Database) GetTraders() ([]*TraderConfig, error) {
for rows.Next() {
var trader TraderConfig
err := rows.Scan(
&trader.ID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
&trader.CreatedAt, &trader.UpdatedAt,
&trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.CreatedAt, &trader.UpdatedAt,
)
if err != nil {
return nil, err
@@ -325,40 +692,52 @@ func (d *Database) GetTraders() ([]*TraderConfig, error) {
}
// UpdateTraderStatus 更新交易员状态
func (d *Database) UpdateTraderStatus(id string, isRunning bool) error {
_, err := d.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ?`, isRunning, id)
func (d *Database) UpdateTraderStatus(userID, id string, isRunning bool) error {
_, err := d.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ? AND user_id = ?`, isRunning, id, userID)
return err
}
// UpdateTraderCustomPrompt 更新交易员自定义Prompt
func (d *Database) UpdateTraderCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error {
_, err := d.db.Exec(`UPDATE traders SET custom_prompt = ?, override_base_prompt = ? WHERE id = ? AND user_id = ?`, customPrompt, overrideBase, id, userID)
return err
}
// DeleteTrader 删除交易员
func (d *Database) DeleteTrader(id string) error {
_, err := d.db.Exec(`DELETE FROM traders WHERE id = ?`, id)
func (d *Database) DeleteTrader(userID, id string) error {
_, err := d.db.Exec(`DELETE FROM traders WHERE id = ? AND user_id = ?`, id, userID)
return err
}
// GetTraderConfig 获取交易员完整配置(包含AI模型和交易所信息)
func (d *Database) GetTraderConfig(traderID string) (*TraderConfig, *AIModelConfig, *ExchangeConfig, error) {
func (d *Database) GetTraderConfig(userID, traderID string) (*TraderConfig, *AIModelConfig, *ExchangeConfig, error) {
var trader TraderConfig
var aiModel AIModelConfig
var exchange ExchangeConfig
err := d.db.QueryRow(`
SELECT
t.id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, t.created_at, t.updated_at,
a.id, a.name, a.provider, a.enabled, a.api_key, a.created_at, a.updated_at,
e.id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, e.created_at, e.updated_at
t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, t.created_at, t.updated_at,
a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, a.created_at, a.updated_at,
e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet,
COALESCE(e.hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr,
COALESCE(e.aster_user, '') as aster_user,
COALESCE(e.aster_signer, '') as aster_signer,
COALESCE(e.aster_private_key, '') as aster_private_key,
e.created_at, e.updated_at
FROM traders t
JOIN ai_models a ON t.ai_model_id = a.id
JOIN exchanges e ON t.exchange_id = e.id
WHERE t.id = ?
`, traderID).Scan(
&trader.ID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id
JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id
WHERE t.id = ? AND t.user_id = ?
`, traderID, userID).Scan(
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
&trader.CreatedAt, &trader.UpdatedAt,
&aiModel.ID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
&aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
&aiModel.CreatedAt, &aiModel.UpdatedAt,
&exchange.ID, &exchange.Name, &exchange.Type, &exchange.Enabled,
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
&exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
&exchange.HyperliquidWalletAddr, &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.CreatedAt, &exchange.UpdatedAt,
)
+33 -1
View File
@@ -91,13 +91,18 @@ type FullDecision struct {
// GetFullDecision 获取AI的完整交易决策(批量分析所有币种和持仓)
func GetFullDecision(ctx *Context, mcpClient *mcp.Client) (*FullDecision, error) {
return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false)
}
// GetFullDecisionWithCustomPrompt 获取AI的完整交易决策(支持自定义prompt)
func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient *mcp.Client, customPrompt string, overrideBase bool) (*FullDecision, error) {
// 1. 为所有币种获取市场数据
if err := fetchMarketDataForContext(ctx); err != nil {
return nil, fmt.Errorf("获取市场数据失败: %w", err)
}
// 2. 构建 System Prompt(固定规则)和 User Prompt(动态数据)
systemPrompt := buildSystemPrompt(ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage)
systemPrompt := buildSystemPromptWithCustom(ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage, customPrompt, overrideBase)
userPrompt := buildUserPrompt(ctx)
// 3. 调用AI API(使用 system + user prompt
@@ -199,6 +204,33 @@ func calculateMaxCandidates(ctx *Context) int {
return len(ctx.CandidateCoins)
}
// buildSystemPromptWithCustom 构建包含自定义内容的 System Prompt
func buildSystemPromptWithCustom(accountEquity float64, btcEthLeverage, altcoinLeverage int, customPrompt string, overrideBase bool) string {
// 如果覆盖基础prompt且有自定义prompt,只使用自定义prompt
if overrideBase && customPrompt != "" {
return customPrompt
}
// 获取基础prompt
basePrompt := buildSystemPrompt(accountEquity, btcEthLeverage, altcoinLeverage)
// 如果没有自定义prompt,直接返回基础prompt
if customPrompt == "" {
return basePrompt
}
// 添加自定义prompt部分到基础prompt
var sb strings.Builder
sb.WriteString(basePrompt)
sb.WriteString("\n\n")
sb.WriteString("# 📌 个性化交易策略\n\n")
sb.WriteString(customPrompt)
sb.WriteString("\n\n")
sb.WriteString("**注意**: 以上个性化策略是对基础规则的补充,不能违背基础风险控制原则。\n")
return sb.String()
}
// buildSystemPrompt 构建 System Prompt(固定规则,可缓存)
func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage int) string {
var sb strings.Builder
+5 -2
View File
@@ -6,14 +6,19 @@ require (
github.com/adshao/go-binance/v2 v2.8.7
github.com/ethereum/go-ethereum v1.16.5
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/pquerna/otp v1.4.0
github.com/sonirico/go-hyperliquid v0.17.0
golang.org/x/crypto v0.42.0
)
require (
github.com/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.0 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -32,7 +37,6 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
@@ -66,7 +70,6 @@ require (
go.elastic.co/fastjson v1.5.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
+6
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/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
@@ -67,6 +69,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
@@ -132,6 +136,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
+27 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"nofx/api"
"nofx/auth"
"nofx/config"
"nofx/manager"
"nofx/pool"
@@ -38,6 +39,29 @@ func main() {
useDefaultCoins := useDefaultCoinsStr == "true"
apiPortStr, _ := database.GetSystemConfig("api_server_port")
// 获取管理员模式配置
adminModeStr, _ := database.GetSystemConfig("admin_mode")
adminMode := adminModeStr != "false" // 默认为true
// 设置JWT密钥
jwtSecret, _ := database.GetSystemConfig("jwt_secret")
if jwtSecret == "" {
jwtSecret = "your-jwt-secret-key-change-in-production-make-it-long-and-random"
log.Printf("⚠️ 使用默认JWT密钥,建议在生产环境中配置")
}
auth.SetJWTSecret(jwtSecret)
// 在管理员模式下,确保admin用户存在
if adminMode {
err := database.EnsureAdminUser()
if err != nil {
log.Printf("⚠️ 创建admin用户失败: %v", err)
} else {
log.Printf("✓ 管理员模式已启用,无需登录")
}
auth.SetAdminMode(true)
}
log.Printf("✓ 配置数据库初始化成功")
fmt.Println()
@@ -73,8 +97,8 @@ func main() {
log.Fatalf("❌ 加载交易员失败: %v", err)
}
// 获取数据库中的所有交易员配置(用于显示)
traders, err := database.GetTraders()
// 获取数据库中的所有交易员配置(用于显示,使用default用户
traders, err := database.GetTraders("default")
if err != nil {
log.Fatalf("❌ 获取交易员列表失败: %v", err)
}
@@ -110,7 +134,7 @@ func main() {
fmt.Println()
// 获取API服务器端口
apiPort := 8081 // 默认端口
apiPort := 8080 // 默认端口
if apiPortStr != "" {
if port, err := strconv.Atoi(apiPortStr); err == nil {
apiPort = port
+276 -4
View File
@@ -28,13 +28,20 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro
tm.mu.Lock()
defer tm.mu.Unlock()
// 根据admin_mode确定用户ID
adminModeStr, _ := database.GetSystemConfig("admin_mode")
userID := "default"
if adminModeStr != "false" { // 默认为true
userID = "admin"
}
// 获取数据库中的所有交易员
traders, err := database.GetTraders()
traders, err := database.GetTraders(userID)
if err != nil {
return fmt.Errorf("获取交易员列表失败: %w", err)
}
log.Printf("📋 加载数据库中的交易员配置: %d 个", len(traders))
log.Printf("📋 加载数据库中的交易员配置: %d 个 (用户: %s)", len(traders), userID)
// 获取系统配置
coinPoolURL, _ := database.GetSystemConfig("coin_pool_api_url")
@@ -61,7 +68,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro
// 为每个交易员获取AI模型和交易所配置
for _, traderCfg := range traders {
// 获取AI模型配置
aiModels, err := database.GetAIModels()
aiModels, err := database.GetAIModels(userID)
if err != nil {
log.Printf("⚠️ 获取AI模型配置失败: %v", err)
continue
@@ -86,7 +93,7 @@ func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) erro
}
// 获取交易所配置
exchanges, err := database.GetExchanges()
exchanges, err := database.GetExchanges(userID)
if err != nil {
log.Printf("⚠️ 获取交易所配置失败: %v", err)
continue
@@ -155,6 +162,11 @@ func (tm *TraderManager) addTraderFromConfig(traderCfg *config.TraderConfig, aiM
traderConfig.BinanceSecretKey = exchangeCfg.SecretKey
} else if exchangeCfg.ID == "hyperliquid" {
traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
} else if exchangeCfg.ID == "aster" {
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey
}
// 根据AI模型设置API密钥
@@ -169,6 +181,17 @@ func (tm *TraderManager) addTraderFromConfig(traderCfg *config.TraderConfig, aiM
if err != nil {
return fmt.Errorf("创建trader失败: %w", err)
}
// 设置自定义prompt(如果有)
if traderCfg.CustomPrompt != "" {
at.SetCustomPrompt(traderCfg.CustomPrompt)
at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt)
if traderCfg.OverrideBasePrompt {
log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)")
} else {
log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)")
}
}
tm.traders[traderCfg.ID] = at
log.Printf("✓ Trader '%s' (%s + %s) 已加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID)
@@ -213,6 +236,11 @@ func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderConfig, aiModel
traderConfig.BinanceSecretKey = exchangeCfg.SecretKey
} else if exchangeCfg.ID == "hyperliquid" {
traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
} else if exchangeCfg.ID == "aster" {
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey
}
// 根据AI模型设置API密钥
@@ -227,6 +255,17 @@ func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderConfig, aiModel
if err != nil {
return fmt.Errorf("创建trader失败: %w", err)
}
// 设置自定义prompt(如果有)
if traderCfg.CustomPrompt != "" {
at.SetCustomPrompt(traderCfg.CustomPrompt)
at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt)
if traderCfg.OverrideBasePrompt {
log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)")
} else {
log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)")
}
}
tm.traders[traderCfg.ID] = at
log.Printf("✓ Trader '%s' (%s + %s) 已添加", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID)
@@ -331,3 +370,236 @@ func (tm *TraderManager) GetComparisonData() (map[string]interface{}, error) {
return comparison, nil
}
// GetCompetitionData 获取竞赛数据(特定用户的所有交易员)
func (tm *TraderManager) GetCompetitionData(userID string) (map[string]interface{}, error) {
tm.mu.RLock()
defer tm.mu.RUnlock()
comparison := make(map[string]interface{})
traders := make([]map[string]interface{}, 0)
// 只获取该用户的交易员
for traderID, t := range tm.traders {
// 检查trader是否属于该用户(通过ID前缀判断)
// 格式:userID_traderName
if !isUserTrader(traderID, userID) {
continue
}
account, err := t.GetAccountInfo()
if err != nil {
log.Printf("⚠️ 获取交易员 %s 账户信息失败: %v", traderID, err)
continue
}
status := t.GetStatus()
traders = append(traders, map[string]interface{}{
"trader_id": t.GetID(),
"trader_name": t.GetName(),
"ai_model": t.GetAIModel(),
"total_equity": account["total_equity"],
"total_pnl": account["total_pnl"],
"total_pnl_pct": account["total_pnl_pct"],
"position_count": account["position_count"],
"margin_used_pct": account["margin_used_pct"],
"is_running": status["is_running"],
})
}
comparison["traders"] = traders
comparison["count"] = len(traders)
return comparison, nil
}
// isUserTrader 检查trader是否属于指定用户
func isUserTrader(traderID, userID string) bool {
// trader ID格式: userID_traderName 或 randomUUID_modelName
// 为了兼容性,我们检查前缀
if len(traderID) >= len(userID) && traderID[:len(userID)] == userID {
return true
}
// 对于老的default用户,所有没有明确用户前缀的都属于default
if userID == "default" && !containsUserPrefix(traderID) {
return true
}
return false
}
// containsUserPrefix 检查trader ID是否包含用户前缀
func containsUserPrefix(traderID string) bool {
// 检查是否包含邮箱格式的前缀(user@example.com_traderName
for i, ch := range traderID {
if ch == '@' {
// 找到@符号,说明可能是email前缀
return true
}
if ch == '_' && i > 0 {
// 找到下划线但前面没有@,可能是UUID或其他格式
break
}
}
return false
}
// LoadUserTraders 为特定用户加载交易员到内存
func (tm *TraderManager) LoadUserTraders(database *config.Database, userID string) error {
tm.mu.Lock()
defer tm.mu.Unlock()
// 获取指定用户的所有交易员
traders, err := database.GetTraders(userID)
if err != nil {
return fmt.Errorf("获取用户 %s 的交易员列表失败: %w", userID, err)
}
log.Printf("📋 为用户 %s 加载交易员配置: %d 个", userID, len(traders))
// 获取系统配置
coinPoolURL, _ := database.GetSystemConfig("coin_pool_api_url")
maxDailyLossStr, _ := database.GetSystemConfig("max_daily_loss")
maxDrawdownStr, _ := database.GetSystemConfig("max_drawdown")
stopTradingMinutesStr, _ := database.GetSystemConfig("stop_trading_minutes")
// 解析配置
maxDailyLoss := 10.0 // 默认值
if val, err := strconv.ParseFloat(maxDailyLossStr, 64); err == nil {
maxDailyLoss = val
}
maxDrawdown := 20.0 // 默认值
if val, err := strconv.ParseFloat(maxDrawdownStr, 64); err == nil {
maxDrawdown = val
}
stopTradingMinutes := 60 // 默认值
if val, err := strconv.Atoi(stopTradingMinutesStr); err == nil {
stopTradingMinutes = val
}
// 为每个交易员获取AI模型和交易所配置
for _, traderCfg := range traders {
// 检查是否已经加载过这个交易员
if _, exists := tm.traders[traderCfg.ID]; exists {
log.Printf("⚠️ 交易员 %s 已经加载,跳过", traderCfg.Name)
continue
}
// 获取AI模型配置(使用该用户的配置)
aiModels, err := database.GetAIModels(userID)
if err != nil {
log.Printf("⚠️ 获取用户 %s 的AI模型配置失败: %v", userID, err)
continue
}
var aiModelCfg *config.AIModelConfig
for _, model := range aiModels {
if model.ID == traderCfg.AIModelID {
aiModelCfg = model
break
}
}
if aiModelCfg == nil {
log.Printf("⚠️ 交易员 %s 的AI模型 %s 不存在,跳过", traderCfg.Name, traderCfg.AIModelID)
continue
}
if !aiModelCfg.Enabled {
log.Printf("⚠️ 交易员 %s 的AI模型 %s 未启用,跳过", traderCfg.Name, traderCfg.AIModelID)
continue
}
// 获取交易所配置(使用该用户的配置)
exchanges, err := database.GetExchanges(userID)
if err != nil {
log.Printf("⚠️ 获取用户 %s 的交易所配置失败: %v", userID, err)
continue
}
var exchangeCfg *config.ExchangeConfig
for _, exchange := range exchanges {
if exchange.ID == traderCfg.ExchangeID {
exchangeCfg = exchange
break
}
}
if exchangeCfg == nil {
log.Printf("⚠️ 交易员 %s 的交易所 %s 不存在,跳过", traderCfg.Name, traderCfg.ExchangeID)
continue
}
if !exchangeCfg.Enabled {
log.Printf("⚠️ 交易员 %s 的交易所 %s 未启用,跳过", traderCfg.Name, traderCfg.ExchangeID)
continue
}
// 使用现有的方法加载交易员
err = tm.loadSingleTrader(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, maxDailyLoss, maxDrawdown, stopTradingMinutes)
if err != nil {
log.Printf("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err)
}
}
return nil
}
// loadSingleTrader 加载单个交易员(从现有代码提取的公共逻辑)
func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderConfig, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int) error {
// 构建AutoTraderConfig
traderConfig := trader.AutoTraderConfig{
ID: traderCfg.ID,
Name: traderCfg.Name,
AIModel: aiModelCfg.Provider, // 使用provider作为模型标识
Exchange: exchangeCfg.ID, // 使用exchange ID
InitialBalance: traderCfg.InitialBalance,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
CoinPoolAPIURL: coinPoolURL,
MaxDailyLoss: maxDailyLoss,
MaxDrawdown: maxDrawdown,
StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute,
}
// 根据交易所类型设置API密钥
if exchangeCfg.ID == "binance" {
traderConfig.BinanceAPIKey = exchangeCfg.APIKey
traderConfig.BinanceSecretKey = exchangeCfg.SecretKey
} else if exchangeCfg.ID == "hyperliquid" {
traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
} else if exchangeCfg.ID == "aster" {
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey
}
// 根据AI模型设置API密钥
if aiModelCfg.Provider == "qwen" {
traderConfig.QwenKey = aiModelCfg.APIKey
} else if aiModelCfg.Provider == "deepseek" {
traderConfig.DeepSeekKey = aiModelCfg.APIKey
}
// 创建trader实例
at, err := trader.NewAutoTrader(traderConfig)
if err != nil {
return fmt.Errorf("创建trader失败: %w", err)
}
// 设置自定义prompt(如果有)
if traderCfg.CustomPrompt != "" {
at.SetCustomPrompt(traderCfg.CustomPrompt)
at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt)
if traderCfg.OverrideBasePrompt {
log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)")
} else {
log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)")
}
}
tm.traders[traderCfg.ID] = at
log.Printf("✓ Trader '%s' (%s + %s) 已为用户加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID)
return nil
}
+21 -8
View File
@@ -77,6 +77,8 @@ type AutoTrader struct {
decisionLogger *logger.DecisionLogger // 决策日志记录器
initialBalance float64
dailyPnL float64
customPrompt string // 自定义交易策略prompt
overrideBasePrompt bool // 是否覆盖基础prompt
lastResetTime time.Time
stopUntil time.Time
isRunning bool
@@ -287,7 +289,7 @@ func (at *AutoTrader) runCycle() error {
// 4. 调用AI获取完整决策
log.Println("🤖 正在请求AI分析并决策...")
decision, err := decision.GetFullDecision(ctx, at.mcpClient)
decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt)
// 即使有错误,也保存思维链、决策和输入prompt(用于debug
if decision != nil {
@@ -735,6 +737,16 @@ func (at *AutoTrader) GetAIModel() string {
return at.aiModel
}
// SetCustomPrompt 设置自定义交易策略prompt
func (at *AutoTrader) SetCustomPrompt(prompt string) {
at.customPrompt = prompt
}
// SetOverrideBasePrompt 设置是否覆盖基础prompt
func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
at.overrideBasePrompt = override
}
// GetDecisionLogger 获取决策日志记录器
func (at *AutoTrader) GetDecisionLogger() *logger.DecisionLogger {
return at.decisionLogger
@@ -871,14 +883,15 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
leverage = int(lev)
}
pnlPct := 0.0
if side == "long" {
pnlPct = ((markPrice - entryPrice) / entryPrice) * 100
} else {
pnlPct = ((entryPrice - markPrice) / entryPrice) * 100
}
// 计算占用保证金
marginUsed := (quantity * markPrice) / float64(leverage)
// 计算盈亏百分比(基于保证金)
// 收益率 = 未实现盈亏 / 保证金 × 100%
pnlPct := 0.0
if marginUsed > 0 {
pnlPct = (unrealizedPnl / marginUsed) * 100
}
result = append(result, map[string]interface{}{
"symbol": symbol,
+23
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

+1
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

+1
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

+3
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

+1
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

+207 -86
View File
@@ -3,9 +3,14 @@ import useSWR from 'swr';
import { api } from './lib/api';
import { EquityChart } from './components/EquityChart';
import { AITradersPage } from './components/AITradersPage';
import { LoginPage } from './components/LoginPage';
import { RegisterPage } from './components/RegisterPage';
import { CompetitionPage } from './components/CompetitionPage';
import AILearning from './components/AILearning';
import { LanguageProvider, useLanguage } from './contexts/LanguageContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { t, type Language } from './i18n/translations';
import { useSystemConfig } from './hooks/useSystemConfig';
import type {
SystemStatus,
AccountInfo,
@@ -15,11 +20,34 @@ import type {
TraderInfo,
} from './types';
type Page = 'traders' | 'trader';
type Page = 'competition' | 'traders' | 'trader';
// 获取友好的AI模型名称
function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) {
case 'deepseek':
return 'DeepSeek';
case 'qwen':
return 'Qwen';
case 'claude':
return 'Claude';
case 'gpt4':
case 'gpt-4':
return 'GPT-4';
case 'gpt3.5':
case 'gpt-3.5':
return 'GPT-3.5';
default:
return modelId.toUpperCase();
}
}
function App() {
const { language, setLanguage } = useLanguage();
const [currentPage, setCurrentPage] = useState<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 [lastUpdate, setLastUpdate] = useState<string>('--:--:--');
@@ -105,59 +133,118 @@ function App() {
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId);
// Handle routing
useEffect(() => {
const handlePopState = () => {
setRoute(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
// Show loading spinner while checking auth or config
if (isLoading || configLoading) {
return (
<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 (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
{/* Header - Binance Style */}
<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">
{/* Mobile: Two rows, Desktop: Single row */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{/* Left: Logo and Title */}
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
<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 className="max-w-[1920px] mx-auto px-6 py-4">
<div className="relative flex items-center">
{/* 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-base sm:text-xl font-bold leading-tight" style={{ color: '#EAECEF' }}>
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-xs mono hidden sm:block" style={{ color: '#848E9C' }}>
<p className="text-xs mono" style={{ color: '#848E9C' }}>
{t('subtitle', language)}
</p>
</div>
</div>
{/* Right: Controls - Wrap on mobile */}
<div className="flex items-center gap-2 flex-wrap md:flex-nowrap">
{/* GitHub Link - Hidden on mobile, icon only on tablet */}
<a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
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"
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';
}}
{/* Center - Page Toggle (absolutely positioned) */}
<div className="absolute left-1/2 transform -translate-x-1/2 flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<button
onClick={() => setCurrentPage('competition')}
className={`px-3 py-2 rounded text-sm font-semibold transition-all`}
style={currentPage === 'competition'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
<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>
<span className="hidden md:inline">GitHub</span>
</a>
</button>
<button
onClick={() => setCurrentPage('traders')}
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 */}
<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
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'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
@@ -167,7 +254,7 @@ function App() {
</button>
<button
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'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
@@ -177,50 +264,21 @@ function App() {
</button>
</div>
{/* Page Toggle */}
<div className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1" style={{ background: '#1E2329' }}>
{/* Logout Button - Only show if not in admin mode */}
{!systemConfig?.admin_mode && (
<button
onClick={() => setCurrentPage('traders')}
className="px-2 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-semibold transition-all"
style={currentPage === 'traders'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
onClick={logout}
className="px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }}
>
{t('aiTraders', language)}
退
</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) */}
{currentPage === 'trader' && status && (
<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
? { 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)' }
@@ -242,8 +300,15 @@ function App() {
{/* Main Content */}
<main className="max-w-[1920px] mx-auto px-6 py-6">
{currentPage === 'traders' ? (
<AITradersPage />
{currentPage === 'competition' ? (
<CompetitionPage />
) : currentPage === 'traders' ? (
<AITradersPage
onTraderSelect={(traderId) => {
setSelectedTraderId(traderId);
setCurrentPage('trader');
}}
/>
) : (
<TraderDetailsPage
selectedTrader={selectedTrader}
@@ -254,6 +319,9 @@ function App() {
stats={stats}
lastUpdate={lastUpdate}
language={language}
traders={traders}
selectedTraderId={selectedTraderId}
onTraderSelect={setSelectedTraderId}
/>
)}
</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' }}>
<p>{t('footerTitle', 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>
</footer>
</div>
@@ -278,8 +370,14 @@ function TraderDetailsPage({
decisions,
lastUpdate,
language,
traders,
selectedTraderId,
onTraderSelect,
}: {
selectedTrader?: TraderInfo;
traders?: TraderInfo[];
selectedTraderId?: string;
onTraderSelect: (traderId: string) => void;
status?: SystemStatus;
account?: AccountInfo;
positions?: Position[];
@@ -320,14 +418,35 @@ function TraderDetailsPage({
<div>
{/* 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)' }}>
<h2 className="text-2xl font-bold mb-3 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}
</h2>
<div className="flex items-start justify-between mb-3">
<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}
</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' }}>
<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 && (
<>
<span></span>
@@ -669,11 +788,13 @@ function DecisionCard({ decision, language }: { decision: DecisionRecord; langua
);
}
// Wrap App with LanguageProvider
export default function AppWithLanguage() {
// Wrap App with providers
export default function AppWithProviders() {
return (
<LanguageProvider>
<App />
<AuthProvider>
<App />
</AuthProvider>
</LanguageProvider>
);
}
+434 -152
View File
@@ -4,8 +4,34 @@ import { api } from '../lib/api';
import type { TraderInfo, CreateTraderRequest, AIModel, Exchange } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { getExchangeIcon } from './ExchangeIcons';
import { getModelIcon } from './ModelIcons';
export function AITradersPage() {
// 获取友好的AI模型名称
function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) {
case 'deepseek':
return 'DeepSeek';
case 'qwen':
return 'Qwen';
case 'claude':
return 'Claude';
case 'gpt4':
case 'gpt-4':
return 'GPT-4';
case 'gpt3.5':
case 'gpt-3.5':
return 'GPT-3.5';
default:
return modelId.toUpperCase();
}
}
interface AITradersPageProps {
onTraderSelect?: (traderId: string) => void;
}
export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const { language } = useLanguage();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showModelModal, setShowModelModal] = useState(false);
@@ -14,6 +40,8 @@ export function AITradersPage() {
const [editingExchange, setEditingExchange] = useState<string | null>(null);
const [allModels, setAllModels] = useState<AIModel[]>([]);
const [allExchanges, setAllExchanges] = useState<Exchange[]>([]);
const [supportedModels, setSupportedModels] = useState<AIModel[]>([]);
const [supportedExchanges, setSupportedExchanges] = useState<Exchange[]>([]);
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
'traders',
@@ -25,22 +53,41 @@ export function AITradersPage() {
useEffect(() => {
const loadConfigs = async () => {
try {
const [modelConfigs, exchangeConfigs] = await Promise.all([
console.log('🔄 开始加载模型和交易所配置...');
const [modelConfigs, exchangeConfigs, supportedModels, supportedExchanges] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs()
api.getExchangeConfigs(),
api.getSupportedModels(),
api.getSupportedExchanges()
]);
console.log('✅ 用户模型配置加载成功:', modelConfigs);
console.log('✅ 用户交易所配置加载成功:', exchangeConfigs);
console.log('✅ 支持的模型加载成功:', supportedModels);
console.log('✅ 支持的交易所加载成功:', supportedExchanges);
setAllModels(modelConfigs);
setAllExchanges(exchangeConfigs);
setSupportedModels(supportedModels);
setSupportedExchanges(supportedExchanges);
} catch (error) {
console.error('Failed to load configs:', error);
console.error('❌ 加载配置失败:', error);
}
};
loadConfigs();
}, []);
// 显示已配置的模型和交易所
const configuredModels = allModels.filter(m => m.enabled && m.apiKey);
const configuredExchanges = allExchanges.filter(e => e.enabled && e.apiKey && (e.id === 'hyperliquid' || e.secretKey));
// 显示所有用户的模型和交易所配置(用于调试)
const configuredModels = allModels || [];
const configuredExchanges = allExchanges || [];
// 只在创建交易员时使用已启用且配置完整的
const enabledModels = allModels?.filter(m => m.enabled && m.apiKey) || [];
const enabledExchanges = allExchanges?.filter(e => {
if (!e.enabled || !e.apiKey) return false;
// Hyperliquid 只需要私钥(作为apiKey),不需要secretKey
if (e.id === 'hyperliquid') return true;
// 其他交易所需要secretKey
return e.secretKey && e.secretKey.trim() !== '';
}) || [];
// 检查模型是否正在被运行中的交易员使用
const isModelInUse = (modelId: string) => {
@@ -52,10 +99,10 @@ export function AITradersPage() {
return traders?.some(t => t.exchange_id === exchangeId && t.is_running) || false;
};
const handleCreateTrader = async (modelId: string, exchangeId: string, name: string, initialBalance: number) => {
const handleCreateTrader = async (modelId: string, exchangeId: string, name: string, initialBalance: number, customPrompt?: string, overrideBase?: boolean) => {
try {
const model = allModels.find(m => m.id === modelId);
const exchange = allExchanges.find(e => e.id === exchangeId);
const model = allModels?.find(m => m.id === modelId);
const exchange = allExchanges?.find(e => e.id === exchangeId);
if (!model?.enabled) {
alert(t('modelNotConfigured', language));
@@ -71,7 +118,9 @@ export function AITradersPage() {
name,
ai_model_id: modelId,
exchange_id: exchangeId,
initial_balance: initialBalance
initial_balance: initialBalance,
custom_prompt: customPrompt,
override_base_prompt: overrideBase
};
await api.createTrader(request);
@@ -127,9 +176,9 @@ export function AITradersPage() {
if (!confirm('确定要删除此AI模型配置吗?')) return;
try {
const updatedModels = allModels.map(m =>
const updatedModels = allModels?.map(m =>
m.id === modelId ? { ...m, apiKey: '', enabled: false } : m
);
) || [];
const request = {
models: Object.fromEntries(
@@ -155,9 +204,27 @@ export function AITradersPage() {
const handleSaveModelConfig = async (modelId: string, apiKey: string) => {
try {
const updatedModels = allModels.map(m =>
m.id === modelId ? { ...m, apiKey, enabled: true } : m
);
// 找到要配置的模型(从supportedModels中)
const modelToUpdate = supportedModels?.find(m => m.id === modelId);
if (!modelToUpdate) {
alert('模型不存在');
return;
}
// 创建或更新用户的模型配置
const existingModel = allModels?.find(m => m.id === modelId);
let updatedModels;
if (existingModel) {
// 更新现有配置
updatedModels = allModels?.map(m =>
m.id === modelId ? { ...m, apiKey, enabled: true } : m
) || [];
} else {
// 添加新配置
const newModel = { ...modelToUpdate, apiKey, enabled: true };
updatedModels = [...(allModels || []), newModel];
}
const request = {
models: Object.fromEntries(
@@ -172,7 +239,11 @@ export function AITradersPage() {
};
await api.updateModelConfigs(request);
setAllModels(updatedModels);
// 重新获取用户配置以确保数据同步
const refreshedModels = await api.getModelConfigs();
setAllModels(refreshedModels);
setShowModelModal(false);
setEditingModel(null);
} catch (error) {
@@ -185,9 +256,9 @@ export function AITradersPage() {
if (!confirm('确定要删除此交易所配置吗?')) return;
try {
const updatedExchanges = allExchanges.map(e =>
const updatedExchanges = allExchanges?.map(e =>
e.id === exchangeId ? { ...e, apiKey: '', secretKey: '', enabled: false } : e
);
) || [];
const request = {
exchanges: Object.fromEntries(
@@ -213,11 +284,29 @@ export function AITradersPage() {
}
};
const handleSaveExchangeConfig = async (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean) => {
const handleSaveExchangeConfig = async (exchangeId: string, apiKey: string, secretKey?: string, testnet?: boolean, hyperliquidWalletAddr?: string, asterUser?: string, asterSigner?: string, asterPrivateKey?: string) => {
try {
const updatedExchanges = allExchanges.map(e =>
e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, enabled: true } : e
);
// 找到要配置的交易所(从supportedExchanges中)
const exchangeToUpdate = supportedExchanges?.find(e => e.id === exchangeId);
if (!exchangeToUpdate) {
alert('交易所不存在');
return;
}
// 创建或更新用户的交易所配置
const existingExchange = allExchanges?.find(e => e.id === exchangeId);
let updatedExchanges;
if (existingExchange) {
// 更新现有配置
updatedExchanges = allExchanges?.map(e =>
e.id === exchangeId ? { ...e, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, enabled: true } : e
) || [];
} else {
// 添加新配置
const newExchange = { ...exchangeToUpdate, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, enabled: true };
updatedExchanges = [...(allExchanges || []), newExchange];
}
const request = {
exchanges: Object.fromEntries(
@@ -227,14 +316,22 @@ export function AITradersPage() {
enabled: exchange.enabled,
api_key: exchange.apiKey || '',
secret_key: exchange.secretKey || '',
testnet: exchange.testnet || false
testnet: exchange.testnet || false,
hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '',
aster_user: exchange.asterUser || '',
aster_signer: exchange.asterSigner || '',
aster_private_key: exchange.asterPrivateKey || ''
}
])
)
};
await api.updateExchangeConfigs(request);
setAllExchanges(updatedExchanges);
// 重新获取用户配置以确保数据同步
const refreshedExchanges = await api.getExchangeConfigs();
setAllExchanges(refreshedExchanges);
setShowExchangeModal(false);
setEditingExchange(null);
} catch (error) {
@@ -339,21 +436,25 @@ export function AITradersPage() {
onClick={() => handleModelClick(model.id)}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
style={{
background: model.id === 'deepseek' ? '#60a5fa' : '#c084fc',
color: '#fff'
}}>
{model.name[0]}
<div className="w-8 h-8 flex items-center justify-center">
{getModelIcon(model.provider || model.id, { width: 32, height: 32 }) || (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
style={{
background: model.id === 'deepseek' ? '#60a5fa' : '#c084fc',
color: '#fff'
}}>
{model.name[0]}
</div>
)}
</div>
<div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{model.name}</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('configured', language)}
{inUse ? '正在使用' : model.enabled ? '已启用' : '已配置'}
</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>
);
})}
@@ -384,21 +485,17 @@ export function AITradersPage() {
onClick={() => handleExchangeClick(exchange.id)}
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
style={{
background: exchange.type === 'cex' ? '#F0B90B' : '#0ECB81',
color: '#000'
}}>
{exchange.name[0]}
<div className="w-8 h-8 flex items-center justify-center">
{getExchangeIcon(exchange.id, { width: 32, height: 32 })}
</div>
<div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{exchange.name}</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{exchange.type.toUpperCase()} {t('configured', language)}
{exchange.type.toUpperCase()} {inUse ? '正在使用' : exchange.enabled ? '已启用' : '已配置'}
</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>
);
})}
@@ -429,7 +526,7 @@ export function AITradersPage() {
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center text-xl"
style={{
background: trader.ai_model === 'deepseek' ? '#60a5fa' : '#c084fc',
background: trader.ai_model.includes('deepseek') ? '#60a5fa' : '#c084fc',
color: '#fff'
}}>
🤖
@@ -439,9 +536,9 @@ export function AITradersPage() {
{trader.trader_name}
</div>
<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>
@@ -463,7 +560,15 @@ export function AITradersPage() {
{/* Actions */}
<div className="flex gap-2">
<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"
style={trader.is_running
? { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
@@ -507,8 +612,8 @@ export function AITradersPage() {
{/* Create Trader Modal */}
{showCreateModal && (
<CreateTraderModal
enabledModels={configuredModels}
enabledExchanges={configuredExchanges}
enabledModels={enabledModels}
enabledExchanges={enabledExchanges}
onCreate={handleCreateTrader}
onClose={() => setShowCreateModal(false)}
language={language}
@@ -518,7 +623,7 @@ export function AITradersPage() {
{/* Model Configuration Modal */}
{showModelModal && (
<ModelConfigModal
allModels={allModels}
allModels={supportedModels}
editingModelId={editingModel}
onSave={handleSaveModelConfig}
onDelete={handleDeleteModelConfig}
@@ -533,7 +638,7 @@ export function AITradersPage() {
{/* Exchange Configuration Modal */}
{showExchangeModal && (
<ExchangeConfigModal
allExchanges={allExchanges}
allExchanges={supportedExchanges}
editingExchangeId={editingExchange}
onSave={handleSaveExchangeConfig}
onDelete={handleDeleteExchangeConfig}
@@ -558,7 +663,7 @@ function CreateTraderModal({
}: {
enabledModels: AIModel[];
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;
language: any;
}) {
@@ -571,12 +676,15 @@ function CreateTraderModal({
const [selectedExchange, setSelectedExchange] = useState(defaultExchange?.id || '');
const [traderName, setTraderName] = useState('');
const [initialBalance, setInitialBalance] = useState(1000);
const [customPrompt, setCustomPrompt] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [overrideBase, setOverrideBase] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedModel || !selectedExchange || !traderName.trim()) return;
onCreate(selectedModel, selectedExchange, traderName.trim(), initialBalance);
onCreate(selectedModel, selectedExchange, traderName.trim(), initialBalance, customPrompt.trim() || undefined, overrideBase);
};
return (
@@ -655,6 +763,68 @@ function CreateTraderModal({
required
/>
</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">
<button
@@ -699,7 +869,7 @@ function ModelConfigModal({
const [apiKey, setApiKey] = useState('');
// 获取当前编辑的模型信息
const selectedModel = allModels.find(m => m.id === selectedModelId);
const selectedModel = allModels?.find(m => m.id === selectedModelId);
// 如果是编辑现有模型,初始化API Key
useEffect(() => {
@@ -715,17 +885,32 @@ function ModelConfigModal({
onSave(selectedModelId, apiKey.trim());
};
// 可选择的模型列表(排除已配置的,除非是当前编辑的
const availableModels = allModels.filter(m =>
!m.enabled || !m.apiKey || m.id === editingModelId
);
// 可选择的模型列表(所有支持的模型
const availableModels = allModels || [];
return (
<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' }}>
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
{editingModelId ? '编辑AI模型' : '添加AI模型'}
</h3>
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative" style={{ background: '#1E2329' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{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">
{!editingModelId && (
@@ -753,12 +938,16 @@ function ModelConfigModal({
{selectedModel && (
<div className="p-4 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<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"
style={{
background: selectedModel.id === 'deepseek' ? '#60a5fa' : '#c084fc',
color: '#fff'
}}>
{selectedModel.name[0]}
<div className="w-8 h-8 flex items-center justify-center">
{getModelIcon(selectedModel.provider || selectedModel.id, { width: 32, height: 32 }) || (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
style={{
background: selectedModel.id === 'deepseek' ? '#60a5fa' : '#c084fc',
color: '#fff'
}}>
{selectedModel.name[0]}
</div>
)}
</div>
<div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{selectedModel.name}</div>
@@ -792,18 +981,6 @@ function ModelConfigModal({
>
{t('cancel', language)}
</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
type="submit"
disabled={!selectedModelId || !apiKey.trim()}
@@ -830,7 +1007,7 @@ function ExchangeConfigModal({
}: {
allExchanges: Exchange[];
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;
onClose: () => void;
language: any;
@@ -839,9 +1016,15 @@ function ExchangeConfigModal({
const [apiKey, setApiKey] = useState('');
const [secretKey, setSecretKey] = useState('');
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(() => {
@@ -849,28 +1032,57 @@ function ExchangeConfigModal({
setApiKey(selectedExchange.apiKey || '');
setSecretKey(selectedExchange.secretKey || '');
setTestnet(selectedExchange.testnet || false);
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '');
setAsterUser(selectedExchange.asterUser || '');
setAsterSigner(selectedExchange.asterSigner || '');
setAsterPrivateKey(selectedExchange.asterPrivateKey || '');
}
}, [editingExchangeId, selectedExchange]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedExchangeId || !apiKey.trim()) return;
if (selectedExchange?.id !== 'hyperliquid' && !secretKey.trim()) return;
if (!selectedExchangeId) 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 =>
!e.enabled || !e.apiKey || e.id === editingExchangeId
);
// 可选择的交易所列表(所有支持的交易所
const availableExchanges = allExchanges || [];
return (
<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' }}>
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
{editingExchangeId ? '编辑交易所' : '添加交易所'}
</h3>
<div className="bg-gray-800 rounded-lg p-6 w-full max-w-lg relative" style={{ background: '#1E2329' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{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">
{!editingExchangeId && (
@@ -898,12 +1110,8 @@ function ExchangeConfigModal({
{selectedExchange && (
<div className="p-4 rounded" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<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"
style={{
background: selectedExchange.type === 'cex' ? '#F0B90B' : '#0ECB81',
color: '#000'
}}>
{selectedExchange.name[0]}
<div className="w-8 h-8 flex items-center justify-center">
{getExchangeIcon(selectedExchange.id, { width: 32, height: 32 })}
</div>
<div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>{selectedExchange.name}</div>
@@ -912,50 +1120,131 @@ function ExchangeConfigModal({
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
{selectedExchange.id === 'hyperliquid' ? 'Private Key (无需0x前缀)' : 'API Key'}
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={selectedExchange.id === 'hyperliquid' ? '请输入以太坊私钥' : `请输入 ${selectedExchange.name} API Key`}
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
{selectedExchange.id !== 'hyperliquid' && (
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
Secret Key
</label>
<input
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder={`请输入 ${selectedExchange.name} Secret Key`}
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
{/* Binance 配置 */}
{selectedExchange.id === 'binance' && (
<>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
API Key
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="请输入 Binance API Key"
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' }}>
Secret Key
</label>
<input
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder="请输入 Binance Secret Key"
className="w-full px-3 py-2 rounded"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
</>
)}
{selectedExchange.type === 'dex' && (
<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' }}>
{t('useTestnet', language)}
</label>
</div>
{/* Hyperliquid 配置 */}
{selectedExchange.id === 'hyperliquid' && (
<>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
Private Key (0x前缀)
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
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>
@@ -970,21 +1259,14 @@ function ExchangeConfigModal({
>
{t('cancel', language)}
</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
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"
style={{ background: '#F0B90B', color: '#000' }}
>
+344
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>
);
}
+249
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>
);
}
+120
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>
);
}
};
+58
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>
);
}
+195
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>
);
}
+36
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%' }}
/>
);
};
+311
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>
);
}
+209
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;
}
+29
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 };
}
+104
View File
@@ -152,6 +152,58 @@ export const translations = {
loading: 'Loading...',
loadingError: '⚠️ Failed to load AI learning data',
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: {
// Header
@@ -304,6 +356,58 @@ export const translations = {
loading: '加载中...',
loadingError: '⚠️ 加载AI学习数据失败',
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个字符',
}
};
+84 -13
View File
@@ -10,14 +10,31 @@ import type {
CreateTraderRequest,
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CompetitionData,
} from '../types';
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 = {
// AI交易员管理接口
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列表失败');
return res.json();
},
@@ -25,7 +42,7 @@ export const api = {
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('创建交易员失败');
@@ -35,6 +52,7 @@ export const api = {
async deleteTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('删除交易员失败');
},
@@ -42,6 +60,7 @@ export const api = {
async startTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('启动交易员失败');
},
@@ -49,21 +68,40 @@ export const api = {
async stopTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
method: 'POST',
headers: getAuthHeaders(),
});
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模型配置接口
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('获取模型配置失败');
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> {
const res = await fetch(`${API_BASE}/models`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新模型配置失败');
@@ -71,15 +109,24 @@ export const api = {
// 交易所配置接口
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('获取交易所配置失败');
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> {
const res = await fetch(`${API_BASE}/exchanges`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新交易所配置失败');
@@ -90,7 +137,9 @@ export const api = {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取系统状态失败');
return res.json();
},
@@ -103,6 +152,7 @@ export const api = {
const res = await fetch(url, {
cache: 'no-store',
headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache',
},
});
@@ -117,7 +167,9 @@ export const api = {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取持仓列表失败');
return res.json();
},
@@ -127,7 +179,9 @@ export const api = {
const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取决策日志失败');
return res.json();
},
@@ -137,7 +191,9 @@ export const api = {
const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取最新决策失败');
return res.json();
},
@@ -147,7 +203,9 @@ export const api = {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取统计信息失败');
return res.json();
},
@@ -157,7 +215,9 @@ export const api = {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取历史数据失败');
return res.json();
},
@@ -167,8 +227,19 @@ export const api = {
const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance`;
const res = await fetch(url);
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取AI学习数据失败');
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
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;
}
+34
View File
@@ -89,7 +89,9 @@ export interface TraderInfo {
trader_id: string;
trader_name: string;
ai_model: string;
exchange_id?: string;
is_running?: boolean;
custom_prompt?: string;
}
export interface AIModel {
@@ -108,6 +110,12 @@ export interface Exchange {
apiKey?: string;
secretKey?: string;
testnet?: boolean;
// Hyperliquid 特定字段
hyperliquidWalletAddr?: string;
// Aster 特定字段
asterUser?: string;
asterSigner?: string;
asterPrivateKey?: string;
}
export interface CreateTraderRequest {
@@ -115,6 +123,8 @@ export interface CreateTraderRequest {
ai_model_id: string;
exchange_id: string;
initial_balance: number;
custom_prompt?: string;
override_base_prompt?: boolean;
}
export interface UpdateModelConfigRequest {
@@ -133,6 +143,30 @@ export interface UpdateExchangeConfigRequest {
api_key: string;
secret_key: string;
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;
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8081',
target: 'http://localhost:8080',
changeOrigin: true,
},
},