mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
Feature/custom strategy (#1172)
* feat: add Strategy Studio with multi-timeframe support - Add Strategy Studio page with three-column layout for strategy management - Support multi-timeframe K-line data selection (5m, 15m, 1h, 4h, etc.) - Add GetWithTimeframes() function in market package for fetching multiple timeframes - Add TimeframeSeriesData struct for storing per-timeframe technical indicators - Update formatMarketData() to display all selected timeframes in AI prompt - Add strategy API endpoints for CRUD operations and test run - Integrate real AI test runs with configured AI models - Support custom AI500 and OI Top API URLs from strategy config * docs: add Strategy Studio screenshot to README files * fix: correct strategy-studio.png filename case in README * refactor: remove legacy signal source config and simplify trader creation - Remove signal source configuration from traders page (now handled by strategy) - Remove advanced options (legacy config) from TraderConfigModal - Rename default strategy to "默认山寨策略" with AI500 coin pool URL - Delete SignalSourceModal and SignalSourceWarning components - Clean up related stores, hooks, and page components
This commit is contained in:
@@ -87,6 +87,10 @@ Join our Telegram developer community to discuss, share ideas, and get support:
|
||||

|
||||
*Professional trading interface with equity curves, live positions, and AI decision logs with expandable input prompts & chain-of-thought reasoning*
|
||||
|
||||
### 🎛️ Strategy Studio - Custom Strategy Builder
|
||||

|
||||
*Three-column strategy editor with multi-timeframe selection (5m/15m/1h/4h), technical indicators configuration, risk control settings, and real-time AI test run with live market data*
|
||||
|
||||
---
|
||||
|
||||
## 🏦 Supported Exchanges (DEX/CEX Tutorials)
|
||||
|
||||
+30
-4
@@ -144,6 +144,20 @@ func (s *Server) setupRoutes() {
|
||||
protected.GET("/exchanges", s.handleGetExchangeConfigs)
|
||||
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
|
||||
|
||||
// 策略管理
|
||||
protected.GET("/strategies", s.handleGetStrategies)
|
||||
protected.GET("/strategies/active", s.handleGetActiveStrategy)
|
||||
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
|
||||
protected.GET("/strategies/templates", s.handleGetPromptTemplates)
|
||||
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
|
||||
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
|
||||
protected.GET("/strategies/:id", s.handleGetStrategy)
|
||||
protected.POST("/strategies", s.handleCreateStrategy)
|
||||
protected.PUT("/strategies/:id", s.handleUpdateStrategy)
|
||||
protected.DELETE("/strategies/:id", s.handleDeleteStrategy)
|
||||
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
||||
protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy)
|
||||
|
||||
// 用户信号源配置
|
||||
protected.GET("/user/signal-sources", s.handleGetUserSignalSource)
|
||||
protected.POST("/user/signal-sources", s.handleSaveUserSignalSource)
|
||||
@@ -373,15 +387,17 @@ type CreateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
||||
StrategyID string `json:"strategy_id"` // 策略ID(新版)
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
IsCrossMargin *bool `json:"is_cross_margin"` // 指针类型,nil表示使用默认值true
|
||||
// 以下字段为向后兼容保留,新版使用策略配置
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"` // 系统提示词模板名称
|
||||
IsCrossMargin *bool `json:"is_cross_margin"` // 指针类型,nil表示使用默认值true
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
}
|
||||
@@ -609,14 +625,15 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 创建交易员配置(数据库实体)
|
||||
logger.Infof("🔧 DEBUG: 开始创建交易员配置, ID=%s, Name=%s, AIModel=%s, Exchange=%s", traderID, req.Name, req.AIModelID, req.ExchangeID)
|
||||
logger.Infof("🔧 DEBUG: 开始创建交易员配置, ID=%s, Name=%s, AIModel=%s, Exchange=%s, StrategyID=%s", traderID, req.Name, req.AIModelID, req.ExchangeID, req.StrategyID)
|
||||
traderRecord := &store.Trader{
|
||||
ID: traderID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
AIModelID: req.AIModelID,
|
||||
ExchangeID: req.ExchangeID,
|
||||
InitialBalance: actualBalance, // 使用实际查询的余额
|
||||
StrategyID: req.StrategyID, // 关联策略ID(新版)
|
||||
InitialBalance: actualBalance, // 使用实际查询的余额
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
@@ -664,15 +681,17 @@ type UpdateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
||||
StrategyID string `json:"strategy_id"` // 策略ID(新版)
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
IsCrossMargin *bool `json:"is_cross_margin"`
|
||||
// 以下字段为向后兼容保留,新版使用策略配置
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
||||
IsCrossMargin *bool `json:"is_cross_margin"`
|
||||
}
|
||||
|
||||
// handleUpdateTrader 更新交易员配置
|
||||
@@ -736,6 +755,12 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
systemPromptTemplate = existingTrader.SystemPromptTemplate // 保持原值
|
||||
}
|
||||
|
||||
// 处理策略ID(如果没有提供,保持原值)
|
||||
strategyID := req.StrategyID
|
||||
if strategyID == "" {
|
||||
strategyID = existingTrader.StrategyID
|
||||
}
|
||||
|
||||
// 更新交易员配置
|
||||
traderRecord := &store.Trader{
|
||||
ID: traderID,
|
||||
@@ -743,6 +768,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
Name: req.Name,
|
||||
AIModelID: req.AIModelID,
|
||||
ExchangeID: req.ExchangeID,
|
||||
StrategyID: strategyID, // 关联策略ID
|
||||
InitialBalance: req.InitialBalance,
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/decision"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// handleGetStrategies 获取策略列表
|
||||
func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
strategies, err := s.store.Strategy().List(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取策略列表失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为前端格式
|
||||
result := make([]gin.H, 0, len(strategies))
|
||||
for _, st := range strategies {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": st.ID,
|
||||
"name": st.Name,
|
||||
"description": st.Description,
|
||||
"is_active": st.IsActive,
|
||||
"is_default": st.IsDefault,
|
||||
"config": config,
|
||||
"created_at": st.CreatedAt,
|
||||
"updated_at": st.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"strategies": result,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetStrategy 获取单个策略
|
||||
func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
strategy, err := s.store.Strategy().Get(userID, strategyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "策略不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
"name": strategy.Name,
|
||||
"description": strategy.Description,
|
||||
"is_active": strategy.IsActive,
|
||||
"is_default": strategy.IsDefault,
|
||||
"config": config,
|
||||
"created_at": strategy.CreatedAt,
|
||||
"updated_at": strategy.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateStrategy 创建策略
|
||||
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 序列化配置
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "序列化配置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
strategy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Create(strategy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建策略失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
"message": "策略创建成功",
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateStrategy 更新策略
|
||||
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是系统默认策略
|
||||
existing, err := s.store.Strategy().Get(userID, strategyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "策略不存在"})
|
||||
return
|
||||
}
|
||||
if existing.IsDefault {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "不能修改系统默认策略"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 序列化配置
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "序列化配置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
strategy := &store.Strategy{
|
||||
ID: strategyID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Update(strategy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新策略失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "策略更新成功"})
|
||||
}
|
||||
|
||||
// handleDeleteStrategy 删除策略
|
||||
func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除策略失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "策略删除成功"})
|
||||
}
|
||||
|
||||
// handleActivateStrategy 激活策略
|
||||
func (s *Server) handleActivateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().SetActive(userID, strategyID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "激活策略失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "策略激活成功"})
|
||||
}
|
||||
|
||||
// handleDuplicateStrategy 复制策略
|
||||
func (s *Server) handleDuplicateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
sourceID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
newID := uuid.New().String()
|
||||
if err := s.store.Strategy().Duplicate(userID, sourceID, newID, req.Name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "复制策略失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": newID,
|
||||
"message": "策略复制成功",
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetActiveStrategy 获取当前激活的策略
|
||||
func (s *Server) handleGetActiveStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
strategy, err := s.store.Strategy().GetActive(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "没有激活的策略"})
|
||||
return
|
||||
}
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
"name": strategy.Name,
|
||||
"description": strategy.Description,
|
||||
"is_active": strategy.IsActive,
|
||||
"is_default": strategy.IsDefault,
|
||||
"config": config,
|
||||
"created_at": strategy.CreatedAt,
|
||||
"updated_at": strategy.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetDefaultStrategyConfig 获取默认策略配置模板
|
||||
func (s *Server) handleGetDefaultStrategyConfig(c *gin.Context) {
|
||||
// 返回默认配置结构,供前端创建新策略时使用
|
||||
defaultConfig := store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "coinpool",
|
||||
UseCoinPool: true,
|
||||
CoinPoolLimit: 30,
|
||||
UseOITop: true,
|
||||
OITopLimit: 20,
|
||||
StaticCoins: []string{},
|
||||
},
|
||||
Indicators: store.IndicatorConfig{
|
||||
Klines: store.KlineConfig{
|
||||
PrimaryTimeframe: "5m",
|
||||
PrimaryCount: 30,
|
||||
LongerTimeframe: "4h",
|
||||
LongerCount: 10,
|
||||
EnableMultiTimeframe: true,
|
||||
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
|
||||
},
|
||||
EnableEMA: true,
|
||||
EnableMACD: true,
|
||||
EnableRSI: true,
|
||||
EnableATR: true,
|
||||
EnableVolume: true,
|
||||
EnableOI: true,
|
||||
EnableFundingRate: true,
|
||||
EMAPeriods: []int{20, 50},
|
||||
RSIPeriods: []int{7, 14},
|
||||
ATRPeriods: []int{14},
|
||||
},
|
||||
RiskControl: store.RiskControlConfig{
|
||||
MaxPositions: 3,
|
||||
BTCETHMaxLeverage: 5,
|
||||
AltcoinMaxLeverage: 5,
|
||||
MinRiskRewardRatio: 3.0,
|
||||
MaxMarginUsage: 0.9,
|
||||
MaxPositionRatio: 1.5,
|
||||
MinPositionSize: 12,
|
||||
MinConfidence: 75,
|
||||
},
|
||||
PromptSections: store.PromptSectionsConfig{
|
||||
RoleDefinition: `# 你是专业的加密货币交易AI
|
||||
|
||||
你专注于技术分析和风险管理,基于市场数据做出理性的交易决策。
|
||||
你的目标是在控制风险的前提下,捕捉高概率的交易机会。`,
|
||||
TradingFrequency: `# ⏱️ 交易频率认知
|
||||
|
||||
- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔
|
||||
- 每小时>2笔 = 过度交易
|
||||
- 单笔持仓时间≥30-60分钟
|
||||
如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。`,
|
||||
EntryStandards: `# 🎯 开仓标准(严格)
|
||||
|
||||
只在多重信号共振时开仓:
|
||||
- 趋势方向明确(EMA排列、价格位置)
|
||||
- 动量确认(MACD、RSI协同)
|
||||
- 波动率适中(ATR合理范围)
|
||||
- 量价配合(成交量支持方向)
|
||||
|
||||
避免:单一指标、信号矛盾、横盘震荡、刚平仓即重启。`,
|
||||
DecisionProcess: `# 📋 决策流程
|
||||
|
||||
1. 检查持仓 → 是否该止盈/止损
|
||||
2. 扫描候选币 + 多时间框 → 是否存在强信号
|
||||
3. 评估风险回报比 → 是否满足最小要求
|
||||
4. 先写思维链,再输出结构化JSON`,
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, defaultConfig)
|
||||
}
|
||||
|
||||
// handlePreviewPrompt 预览策略生成的 Prompt
|
||||
func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
AccountEquity float64 `json:"account_equity"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用默认值
|
||||
if req.AccountEquity <= 0 {
|
||||
req.AccountEquity = 1000.0 // 默认模拟账户净值
|
||||
}
|
||||
if req.PromptVariant == "" {
|
||||
req.PromptVariant = "balanced"
|
||||
}
|
||||
|
||||
// 创建策略引擎来构建 prompt
|
||||
engine := decision.NewStrategyEngine(&req.Config)
|
||||
|
||||
// 构建系统 prompt(使用策略引擎内置的方法)
|
||||
systemPrompt := engine.BuildSystemPrompt(
|
||||
req.AccountEquity,
|
||||
req.PromptVariant,
|
||||
)
|
||||
|
||||
// 获取可用的 prompt 模板列表
|
||||
templateNames := decision.GetAllPromptTemplateNames()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"system_prompt": systemPrompt,
|
||||
"prompt_variant": req.PromptVariant,
|
||||
"available_templates": templateNames,
|
||||
"config_summary": gin.H{
|
||||
"coin_source": req.Config.CoinSource.SourceType,
|
||||
"primary_tf": req.Config.Indicators.Klines.PrimaryTimeframe,
|
||||
"btc_eth_leverage": req.Config.RiskControl.BTCETHMaxLeverage,
|
||||
"altcoin_leverage": req.Config.RiskControl.AltcoinMaxLeverage,
|
||||
"max_positions": req.Config.RiskControl.MaxPositions,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// handleStrategyTestRun AI 测试运行(不执行交易,只返回 AI 分析结果)
|
||||
func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
AIModelID string `json:"ai_model_id"`
|
||||
RunRealAI bool `json:"run_real_ai"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.PromptVariant == "" {
|
||||
req.PromptVariant = "balanced"
|
||||
}
|
||||
|
||||
// 创建策略引擎来构建 prompt
|
||||
engine := decision.NewStrategyEngine(&req.Config)
|
||||
|
||||
// 获取候选币种
|
||||
candidates, err := engine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "获取候选币种失败: " + err.Error(),
|
||||
"ai_response": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取时间周期配置
|
||||
timeframes := req.Config.Indicators.Klines.SelectedTimeframes
|
||||
primaryTimeframe := req.Config.Indicators.Klines.PrimaryTimeframe
|
||||
klineCount := req.Config.Indicators.Klines.PrimaryCount
|
||||
|
||||
// 如果没有选择时间周期,使用默认值
|
||||
if len(timeframes) == 0 {
|
||||
// 兼容旧配置:使用主周期和长周期
|
||||
if primaryTimeframe != "" {
|
||||
timeframes = append(timeframes, primaryTimeframe)
|
||||
} else {
|
||||
timeframes = append(timeframes, "3m")
|
||||
}
|
||||
if req.Config.Indicators.Klines.LongerTimeframe != "" {
|
||||
timeframes = append(timeframes, req.Config.Indicators.Klines.LongerTimeframe)
|
||||
}
|
||||
}
|
||||
if primaryTimeframe == "" {
|
||||
primaryTimeframe = timeframes[0]
|
||||
}
|
||||
if klineCount <= 0 {
|
||||
klineCount = 30
|
||||
}
|
||||
|
||||
fmt.Printf("📊 使用时间周期: %v, 主周期: %s, K线数量: %d\n", timeframes, primaryTimeframe, klineCount)
|
||||
|
||||
// 获取真实市场数据(使用多时间周期)
|
||||
marketDataMap := make(map[string]*market.Data)
|
||||
for _, coin := range candidates {
|
||||
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
|
||||
if err != nil {
|
||||
// 如果获取某个币种数据失败,记录日志但继续
|
||||
fmt.Printf("⚠️ 获取 %s 市场数据失败: %v\n", coin.Symbol, err)
|
||||
continue
|
||||
}
|
||||
marketDataMap[coin.Symbol] = data
|
||||
}
|
||||
|
||||
// 构建真实的上下文(用于生成 User Prompt)
|
||||
testContext := &decision.Context{
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RuntimeMinutes: 0,
|
||||
CallCount: 1,
|
||||
Account: decision.AccountInfo{
|
||||
TotalEquity: 1000.0,
|
||||
AvailableBalance: 1000.0,
|
||||
UnrealizedPnL: 0,
|
||||
TotalPnL: 0,
|
||||
TotalPnLPct: 0,
|
||||
MarginUsed: 0,
|
||||
MarginUsedPct: 0,
|
||||
PositionCount: 0,
|
||||
},
|
||||
Positions: []decision.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
}
|
||||
|
||||
// 构建 System Prompt
|
||||
systemPrompt := engine.BuildSystemPrompt(1000.0, req.PromptVariant)
|
||||
|
||||
// 构建 User Prompt(使用真实市场数据)
|
||||
userPrompt := engine.BuildUserPrompt(testContext)
|
||||
|
||||
// 如果请求真实 AI 调用
|
||||
if req.RunRealAI && req.AIModelID != "" {
|
||||
aiResponse, aiErr := s.runRealAITest(userID, req.AIModelID, systemPrompt, userPrompt)
|
||||
if aiErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"system_prompt": systemPrompt,
|
||||
"user_prompt": userPrompt,
|
||||
"candidate_count": len(candidates),
|
||||
"candidates": candidates,
|
||||
"prompt_variant": req.PromptVariant,
|
||||
"ai_response": fmt.Sprintf("❌ AI 调用失败: %s", aiErr.Error()),
|
||||
"ai_error": aiErr.Error(),
|
||||
"note": "AI 调用出错",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"system_prompt": systemPrompt,
|
||||
"user_prompt": userPrompt,
|
||||
"candidate_count": len(candidates),
|
||||
"candidates": candidates,
|
||||
"prompt_variant": req.PromptVariant,
|
||||
"ai_response": aiResponse,
|
||||
"note": "✅ 真实 AI 测试运行成功",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回结果(不实际调用 AI,只返回构建的 prompt)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"system_prompt": systemPrompt,
|
||||
"user_prompt": userPrompt,
|
||||
"candidate_count": len(candidates),
|
||||
"candidates": candidates,
|
||||
"prompt_variant": req.PromptVariant,
|
||||
"ai_response": "请选择 AI 模型并点击「运行测试」来执行真实的 AI 分析。",
|
||||
"note": "未选择 AI 模型或未启用真实 AI 调用",
|
||||
})
|
||||
}
|
||||
|
||||
// runRealAITest 执行真实的 AI 测试调用
|
||||
func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) (string, error) {
|
||||
// 获取 AI 模型配置
|
||||
model, err := s.store.AIModel().Get(userID, modelID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 AI 模型失败: %w", err)
|
||||
}
|
||||
|
||||
if !model.Enabled {
|
||||
return "", fmt.Errorf("AI 模型 %s 尚未启用", model.Name)
|
||||
}
|
||||
|
||||
if model.APIKey == "" {
|
||||
return "", fmt.Errorf("AI 模型 %s 缺少 API Key", model.Name)
|
||||
}
|
||||
|
||||
// 创建 AI 客户端
|
||||
var aiClient mcp.AIClient
|
||||
provider := model.Provider
|
||||
|
||||
switch provider {
|
||||
case "qwen":
|
||||
aiClient = mcp.NewQwenClient()
|
||||
aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "deepseek":
|
||||
aiClient = mcp.NewDeepSeekClient()
|
||||
aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName)
|
||||
default:
|
||||
// 使用通用客户端
|
||||
aiClient = mcp.NewClient()
|
||||
aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName)
|
||||
}
|
||||
|
||||
// 调用 AI API
|
||||
response, err := aiClient.CallWithMessages(systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("AI API 调用失败: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -146,6 +146,164 @@ func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error
|
||||
return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "")
|
||||
}
|
||||
|
||||
// GetFullDecisionWithStrategy 使用 StrategyEngine 获取AI决策(新版:策略驱动)
|
||||
// 关键:使用策略配置的时间周期来获取市场数据,与 api/strategy.go 的测试运行逻辑保持一致
|
||||
func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *StrategyEngine, variant string) (*FullDecision, error) {
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("context is nil")
|
||||
}
|
||||
if engine == nil {
|
||||
// 如果没有策略引擎,回退到默认行为
|
||||
return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "")
|
||||
}
|
||||
|
||||
// 1. 使用策略配置获取市场数据(关键:使用多时间周期)
|
||||
if len(ctx.MarketDataMap) == 0 {
|
||||
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
||||
return nil, fmt.Errorf("获取市场数据失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 OITopDataMap 已初始化
|
||||
if ctx.OITopDataMap == nil {
|
||||
ctx.OITopDataMap = make(map[string]*OITopData)
|
||||
// 加载 OI Top 数据
|
||||
oiPositions, err := pool.GetOITopPositions()
|
||||
if err == nil {
|
||||
for _, pos := range oiPositions {
|
||||
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
||||
Rank: pos.Rank,
|
||||
OIDeltaPercent: pos.OIDeltaPercent,
|
||||
OIDeltaValue: pos.OIDeltaValue,
|
||||
PriceDeltaPercent: pos.PriceDeltaPercent,
|
||||
NetLong: pos.NetLong,
|
||||
NetShort: pos.NetShort,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 使用策略引擎构建 System Prompt
|
||||
riskConfig := engine.GetRiskControlConfig()
|
||||
systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant)
|
||||
|
||||
// 3. 使用策略引擎构建 User Prompt(包含多周期数据)
|
||||
userPrompt := engine.BuildUserPrompt(ctx)
|
||||
|
||||
// 4. 调用AI API
|
||||
aiCallStart := time.Now()
|
||||
aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
|
||||
aiCallDuration := time.Since(aiCallStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("调用AI API失败: %w", err)
|
||||
}
|
||||
|
||||
// 5. 解析AI响应
|
||||
decision, err := parseFullDecisionResponse(
|
||||
aiResponse,
|
||||
ctx.Account.TotalEquity,
|
||||
riskConfig.BTCETHMaxLeverage,
|
||||
riskConfig.AltcoinMaxLeverage,
|
||||
)
|
||||
|
||||
if decision != nil {
|
||||
decision.Timestamp = time.Now()
|
||||
decision.SystemPrompt = systemPrompt
|
||||
decision.UserPrompt = userPrompt
|
||||
decision.AIRequestDurationMs = aiCallDuration.Milliseconds()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return decision, fmt.Errorf("解析AI响应失败: %w", err)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// fetchMarketDataWithStrategy 使用策略配置获取市场数据(多时间周期)
|
||||
// 完全按照 api/strategy.go handleStrategyTestRun 的逻辑实现
|
||||
func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
|
||||
config := engine.GetConfig()
|
||||
ctx.MarketDataMap = make(map[string]*market.Data)
|
||||
|
||||
// 获取时间周期配置(与 api/strategy.go 逻辑完全一致)
|
||||
timeframes := config.Indicators.Klines.SelectedTimeframes
|
||||
primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe
|
||||
klineCount := config.Indicators.Klines.PrimaryCount
|
||||
|
||||
// 兼容旧配置
|
||||
if len(timeframes) == 0 {
|
||||
if primaryTimeframe != "" {
|
||||
timeframes = append(timeframes, primaryTimeframe)
|
||||
} else {
|
||||
timeframes = append(timeframes, "3m")
|
||||
}
|
||||
if config.Indicators.Klines.LongerTimeframe != "" {
|
||||
timeframes = append(timeframes, config.Indicators.Klines.LongerTimeframe)
|
||||
}
|
||||
}
|
||||
if primaryTimeframe == "" {
|
||||
primaryTimeframe = timeframes[0]
|
||||
}
|
||||
if klineCount <= 0 {
|
||||
klineCount = 30
|
||||
}
|
||||
|
||||
logger.Infof("📊 策略时间周期: %v, 主周期: %s, K线数量: %d", timeframes, primaryTimeframe, klineCount)
|
||||
|
||||
// 1. 先获取持仓币种的数据(必须获取)
|
||||
for _, pos := range ctx.Positions {
|
||||
data, err := market.GetWithTimeframes(pos.Symbol, timeframes, primaryTimeframe, klineCount)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ 获取持仓 %s 市场数据失败: %v", pos.Symbol, err)
|
||||
continue
|
||||
}
|
||||
ctx.MarketDataMap[pos.Symbol] = data
|
||||
}
|
||||
|
||||
// 2. 获取所有候选币种的数据(与 api/strategy.go 完全一致,不做数量限制)
|
||||
// 持仓币种集合(用于判断是否跳过OI检查)
|
||||
positionSymbols := make(map[string]bool)
|
||||
for _, pos := range ctx.Positions {
|
||||
positionSymbols[pos.Symbol] = true
|
||||
}
|
||||
|
||||
// OI 流动性过滤阈值(百万美元)
|
||||
const minOIThresholdMillions = 15.0 // 15M USD 最小持仓价值
|
||||
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
// 跳过已获取的持仓币种
|
||||
if _, exists := ctx.MarketDataMap[coin.Symbol]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ 获取 %s 市场数据失败: %v", coin.Symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// ⚠️ 流动性过滤:持仓价值低于阈值的币种不做(多空都不做)
|
||||
// 但现有持仓必须保留(需要决策是否平仓)
|
||||
isExistingPosition := positionSymbols[coin.Symbol]
|
||||
if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 {
|
||||
// 计算持仓价值(USD)= 持仓量 × 当前价格
|
||||
oiValue := data.OpenInterest.Latest * data.CurrentPrice
|
||||
oiValueInMillions := oiValue / 1_000_000 // 转换为百万美元单位
|
||||
if oiValueInMillions < minOIThresholdMillions {
|
||||
logger.Infof("⚠️ %s 持仓价值过低(%.2fM USD < %.1fM),跳过此币种 [持仓量:%.0f × 价格:%.4f]",
|
||||
coin.Symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ctx.MarketDataMap[coin.Symbol] = data
|
||||
}
|
||||
|
||||
logger.Infof("📊 成功获取 %d 个币种的多时间周期市场数据(已过滤低流动性币种)", len(ctx.MarketDataMap))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFullDecisionWithCustomPrompt 获取AI的完整交易决策(支持自定义prompt和模板选择)
|
||||
func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient mcp.AIClient, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) {
|
||||
if ctx == nil {
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
package decision
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/pool"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StrategyEngine 策略执行引擎
|
||||
// 负责基于策略配置动态获取数据和组装 Prompt
|
||||
type StrategyEngine struct {
|
||||
config *store.StrategyConfig
|
||||
}
|
||||
|
||||
// NewStrategyEngine 创建策略执行引擎
|
||||
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
||||
return &StrategyEngine{config: config}
|
||||
}
|
||||
|
||||
// GetCandidateCoins 根据策略配置获取候选币种
|
||||
func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
var candidates []CandidateCoin
|
||||
symbolSources := make(map[string][]string)
|
||||
|
||||
coinSource := e.config.CoinSource
|
||||
|
||||
// 设置自定义的 API URL(如果配置了)
|
||||
if coinSource.CoinPoolAPIURL != "" {
|
||||
pool.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
logger.Infof("✓ 使用策略配置的 AI500 API URL: %s", coinSource.CoinPoolAPIURL)
|
||||
}
|
||||
if coinSource.OITopAPIURL != "" {
|
||||
pool.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
logger.Infof("✓ 使用策略配置的 OI Top API URL: %s", coinSource.OITopAPIURL)
|
||||
}
|
||||
|
||||
switch coinSource.SourceType {
|
||||
case "static":
|
||||
// 静态币种列表
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
|
||||
case "coinpool":
|
||||
// 仅使用 AI500 币种池
|
||||
return e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
||||
|
||||
case "oi_top":
|
||||
// 仅使用 OI Top
|
||||
return e.getOITopCoins(coinSource.OITopLimit)
|
||||
|
||||
case "mixed":
|
||||
// 混合模式:AI500 + OI Top
|
||||
if coinSource.UseCoinPool {
|
||||
poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ 获取 AI500 币种池失败: %v", err)
|
||||
} else {
|
||||
for _, coin := range poolCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseOITop {
|
||||
oiCoins, err := e.getOITopCoins(coinSource.OITopLimit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ 获取 OI Top 失败: %v", err)
|
||||
} else {
|
||||
for _, coin := range oiCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_top")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加静态币种(如果有)
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
if _, exists := symbolSources[symbol]; !exists {
|
||||
symbolSources[symbol] = []string{"static"}
|
||||
} else {
|
||||
symbolSources[symbol] = append(symbolSources[symbol], "static")
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为候选币种列表
|
||||
for symbol, sources := range symbolSources {
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: sources,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("未知的币种来源类型: %s", coinSource.SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
// getCoinPoolCoins 获取 AI500 币种池
|
||||
func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
symbols, err := pool.GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for _, symbol := range symbols {
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"ai500"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// getOITopCoins 获取 OI Top 币种
|
||||
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
positions, err := pool.GetOITopPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for i, pos := range positions {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
symbol := market.Normalize(pos.Symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"oi_top"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// FetchMarketData 根据策略配置获取市场数据
|
||||
func (e *StrategyEngine) FetchMarketData(symbol string) (*market.Data, error) {
|
||||
// 目前使用现有的 market.Get,后续可以根据策略配置自定义
|
||||
return market.Get(symbol)
|
||||
}
|
||||
|
||||
// FetchExternalData 获取外部数据源
|
||||
func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) {
|
||||
externalData := make(map[string]interface{})
|
||||
|
||||
for _, source := range e.config.Indicators.ExternalDataSources {
|
||||
data, err := e.fetchSingleExternalSource(source)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ 获取外部数据源 [%s] 失败: %v", source.Name, err)
|
||||
continue
|
||||
}
|
||||
externalData[source.Name] = data
|
||||
}
|
||||
|
||||
return externalData, nil
|
||||
}
|
||||
|
||||
// fetchSingleExternalSource 获取单个外部数据源
|
||||
func (e *StrategyEngine) fetchSingleExternalSource(source store.ExternalDataSource) (interface{}, error) {
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(source.RefreshSecs) * time.Second,
|
||||
}
|
||||
|
||||
if client.Timeout == 0 {
|
||||
client.Timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(source.Method, source.URL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
for k, v := range source.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果指定了数据路径,提取指定路径的数据
|
||||
if source.DataPath != "" {
|
||||
result = extractJSONPath(result, source.DataPath)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractJSONPath 提取 JSON 路径数据(简单实现)
|
||||
func extractJSONPath(data interface{}, path string) interface{} {
|
||||
parts := strings.Split(path, ".")
|
||||
current := data
|
||||
|
||||
for _, part := range parts {
|
||||
if m, ok := current.(map[string]interface{}); ok {
|
||||
current = m[part]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// BuildUserPrompt 根据策略配置构建 User Prompt
|
||||
func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 系统状态
|
||||
sb.WriteString(fmt.Sprintf("时间: %s | 周期: #%d | 运行: %d分钟\n\n",
|
||||
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes))
|
||||
|
||||
// BTC 市场(如果配置了)
|
||||
if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC {
|
||||
sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n",
|
||||
btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h,
|
||||
btcData.CurrentMACD, btcData.CurrentRSI7))
|
||||
}
|
||||
|
||||
// 账户信息
|
||||
sb.WriteString(fmt.Sprintf("账户: 净值%.2f | 余额%.2f (%.1f%%) | 盈亏%+.2f%% | 保证金%.1f%% | 持仓%d个\n\n",
|
||||
ctx.Account.TotalEquity,
|
||||
ctx.Account.AvailableBalance,
|
||||
(ctx.Account.AvailableBalance/ctx.Account.TotalEquity)*100,
|
||||
ctx.Account.TotalPnLPct,
|
||||
ctx.Account.MarginUsedPct,
|
||||
ctx.Account.PositionCount))
|
||||
|
||||
// 持仓信息
|
||||
if len(ctx.Positions) > 0 {
|
||||
sb.WriteString("## 当前持仓\n")
|
||||
for i, pos := range ctx.Positions {
|
||||
sb.WriteString(e.formatPositionInfo(i+1, pos, ctx))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("当前持仓: 无\n\n")
|
||||
}
|
||||
|
||||
// 交易统计
|
||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||
sb.WriteString("## 历史交易统计\n")
|
||||
sb.WriteString(fmt.Sprintf("总交易数: %d | 胜率: %.1f%% | 盈亏比: %.2f | 夏普比: %.2f\n",
|
||||
ctx.TradingStats.TotalTrades,
|
||||
ctx.TradingStats.WinRate,
|
||||
ctx.TradingStats.ProfitFactor,
|
||||
ctx.TradingStats.SharpeRatio))
|
||||
sb.WriteString(fmt.Sprintf("总盈亏: %.2f USDT | 平均盈利: %.2f | 平均亏损: %.2f | 最大回撤: %.1f%%\n\n",
|
||||
ctx.TradingStats.TotalPnL,
|
||||
ctx.TradingStats.AvgWin,
|
||||
ctx.TradingStats.AvgLoss,
|
||||
ctx.TradingStats.MaxDrawdownPct))
|
||||
}
|
||||
|
||||
// 最近完成的订单
|
||||
if len(ctx.RecentOrders) > 0 {
|
||||
sb.WriteString("## 最近完成的交易\n")
|
||||
for i, order := range ctx.RecentOrders {
|
||||
resultStr := "盈利"
|
||||
if order.RealizedPnL < 0 {
|
||||
resultStr = "亏损"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | 入场%.4f 出场%.4f | %s: %+.2f USDT (%+.2f%%) | %s\n",
|
||||
i+1, order.Symbol, order.Side,
|
||||
order.EntryPrice, order.ExitPrice,
|
||||
resultStr, order.RealizedPnL, order.PnLPct,
|
||||
order.FilledAt))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// 候选币种
|
||||
sb.WriteString(fmt.Sprintf("## 候选币种 (%d个)\n\n", len(ctx.MarketDataMap)))
|
||||
displayedCount := 0
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
marketData, hasData := ctx.MarketDataMap[coin.Symbol]
|
||||
if !hasData {
|
||||
continue
|
||||
}
|
||||
displayedCount++
|
||||
|
||||
sourceTags := e.formatCoinSourceTag(coin.Sources)
|
||||
sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags))
|
||||
sb.WriteString(e.formatMarketData(marketData))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("现在请分析并输出决策(思维链 + JSON)\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatPositionInfo 格式化持仓信息
|
||||
func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// 计算持仓时长
|
||||
holdingDuration := ""
|
||||
if pos.UpdateTime > 0 {
|
||||
durationMs := time.Now().UnixMilli() - pos.UpdateTime
|
||||
durationMin := durationMs / (1000 * 60)
|
||||
if durationMin < 60 {
|
||||
holdingDuration = fmt.Sprintf(" | 持仓时长%d分钟", durationMin)
|
||||
} else {
|
||||
durationHour := durationMin / 60
|
||||
durationMinRemainder := durationMin % 60
|
||||
holdingDuration = fmt.Sprintf(" | 持仓时长%d小时%d分钟", durationHour, durationMinRemainder)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算仓位价值
|
||||
positionValue := pos.Quantity * pos.MarkPrice
|
||||
if positionValue < 0 {
|
||||
positionValue = -positionValue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | 入场价%.4f 当前价%.4f | 数量%.4f | 仓位价值%.2f USDT | 盈亏%+.2f%% | 盈亏金额%+.2f USDT | 最高收益率%.2f%% | 杠杆%dx | 保证金%.0f | 强平价%.4f%s\n\n",
|
||||
index, pos.Symbol, strings.ToUpper(pos.Side),
|
||||
pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct,
|
||||
pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration))
|
||||
|
||||
// 使用策略配置的指标输出市场数据
|
||||
if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||
sb.WriteString(e.formatMarketData(marketData))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatCoinSourceTag 格式化币种来源标签
|
||||
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
if len(sources) > 1 {
|
||||
return " (AI500+OI_Top双重信号)"
|
||||
} else if len(sources) == 1 {
|
||||
switch sources[0] {
|
||||
case "ai500":
|
||||
return " (AI500)"
|
||||
case "oi_top":
|
||||
return " (OI_Top持仓增长)"
|
||||
case "static":
|
||||
return " (手动选择)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatMarketData 根据策略配置格式化市场数据
|
||||
func (e *StrategyEngine) formatMarketData(data *market.Data) string {
|
||||
var sb strings.Builder
|
||||
indicators := e.config.Indicators
|
||||
|
||||
// 当前价格(总是显示)
|
||||
sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice))
|
||||
|
||||
// EMA
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20))
|
||||
}
|
||||
|
||||
// MACD
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD))
|
||||
}
|
||||
|
||||
// RSI
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// OI 和 Funding Rate
|
||||
if indicators.EnableOI || indicators.EnableFundingRate {
|
||||
sb.WriteString(fmt.Sprintf("Additional data for %s:\n\n", data.Symbol))
|
||||
|
||||
if indicators.EnableOI && data.OpenInterest != nil {
|
||||
sb.WriteString(fmt.Sprintf("Open Interest: Latest: %.2f Average: %.2f\n\n",
|
||||
data.OpenInterest.Latest, data.OpenInterest.Average))
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate))
|
||||
}
|
||||
}
|
||||
|
||||
// 优先使用多时间周期数据(新增)
|
||||
if len(data.TimeframeData) > 0 {
|
||||
// 按时间周期排序输出
|
||||
timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}
|
||||
for _, tf := range timeframeOrder {
|
||||
if tfData, ok := data.TimeframeData[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf("=== %s Timeframe (oldest → latest) ===\n\n", strings.ToUpper(tf)))
|
||||
e.formatTimeframeSeriesData(&sb, tfData, indicators)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 兼容旧的数据格式
|
||||
// 日内数据
|
||||
if data.IntradaySeries != nil {
|
||||
klineConfig := indicators.Klines
|
||||
sb.WriteString(fmt.Sprintf("Intraday series (%s intervals, oldest → latest):\n\n", klineConfig.PrimaryTimeframe))
|
||||
|
||||
if len(data.IntradaySeries.MidPrices) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices)))
|
||||
}
|
||||
|
||||
if indicators.EnableEMA && len(data.IntradaySeries.EMA20Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values)))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.IntradaySeries.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
if len(data.IntradaySeries.RSI7Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values)))
|
||||
}
|
||||
if len(data.IntradaySeries.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableVolume && len(data.IntradaySeries.Volume) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume)))
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString(fmt.Sprintf("3m ATR (14-period): %.3f\n\n", data.IntradaySeries.ATR14))
|
||||
}
|
||||
}
|
||||
|
||||
// 长周期数据
|
||||
if data.LongerTermContext != nil && indicators.Klines.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf("Longer-term context (%s timeframe):\n\n", indicators.Klines.LongerTimeframe))
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString(fmt.Sprintf("20-Period EMA: %.3f vs. 50-Period EMA: %.3f\n\n",
|
||||
data.LongerTermContext.EMA20, data.LongerTermContext.EMA50))
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString(fmt.Sprintf("3-Period ATR: %.3f vs. 14-Period ATR: %.3f\n\n",
|
||||
data.LongerTermContext.ATR3, data.LongerTermContext.ATR14))
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n",
|
||||
data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.LongerTermContext.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI && len(data.LongerTermContext.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatTimeframeSeriesData 格式化单个时间周期的序列数据
|
||||
func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) {
|
||||
if len(data.MidPrices) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices)))
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
if len(data.EMA20Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.EMA20Values)))
|
||||
}
|
||||
if len(data.EMA50Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA indicators (50-period): %s\n\n", formatFloatSlice(data.EMA50Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
if len(data.RSI7Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.RSI7Values)))
|
||||
}
|
||||
if len(data.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.RSI14Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableVolume && len(data.Volume) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume)))
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString(fmt.Sprintf("ATR (14-period): %.3f\n\n", data.ATR14))
|
||||
}
|
||||
}
|
||||
|
||||
// formatFloatSlice 格式化浮点数切片
|
||||
func formatFloatSlice(values []float64) string {
|
||||
strValues := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
strValues[i] = fmt.Sprintf("%.4f", v)
|
||||
}
|
||||
return "[" + strings.Join(strValues, ", ") + "]"
|
||||
}
|
||||
|
||||
// BuildSystemPrompt 根据策略配置构建 System Prompt
|
||||
func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string) string {
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
promptSections := e.config.PromptSections
|
||||
|
||||
// 1. 角色定义(可编辑)
|
||||
if promptSections.RoleDefinition != "" {
|
||||
sb.WriteString(promptSections.RoleDefinition)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 你是专业的加密货币交易AI\n\n")
|
||||
sb.WriteString("你的任务是根据提供的市场数据做出交易决策。\n\n")
|
||||
}
|
||||
|
||||
// 2. 交易模式变体
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
sb.WriteString("## 模式:Aggressive(进攻型)\n- 优先捕捉趋势突破,可在信心度≥70时分批建仓\n- 允许更高仓位,但须严格设置止损并说明盈亏比\n\n")
|
||||
case "conservative":
|
||||
sb.WriteString("## 模式:Conservative(稳健型)\n- 仅在多重信号共振时开仓\n- 优先保留现金,连续亏损必须暂停多个周期\n\n")
|
||||
case "scalping":
|
||||
sb.WriteString("## 模式:Scalping(剥头皮)\n- 聚焦短周期动量,目标收益较小但要求迅速\n- 若价格两根bar内未按预期运行,立即减仓或止损\n\n")
|
||||
}
|
||||
|
||||
// 3. 硬约束(风险控制)- 来自策略配置(不可编辑,自动生成)
|
||||
sb.WriteString("# 硬约束(风险控制)\n\n")
|
||||
sb.WriteString(fmt.Sprintf("1. 风险回报比: 必须 ≥ 1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("2. 最多持仓: %d个币种(质量>数量)\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("3. 单币仓位: 山寨%.0f-%.0f U | BTC/ETH %.0f-%.0f U\n",
|
||||
accountEquity*0.8, accountEquity*riskControl.MaxPositionRatio,
|
||||
accountEquity*5, accountEquity*10))
|
||||
sb.WriteString(fmt.Sprintf("4. 杠杆限制: **山寨币最大%dx杠杆** | **BTC/ETH最大%dx杠杆**\n",
|
||||
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("5. 保证金使用率 ≤ %.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("6. 开仓金额: 建议 ≥%.0f USDT\n", riskControl.MinPositionSize))
|
||||
sb.WriteString(fmt.Sprintf("7. 最小信心度: ≥%d\n\n", riskControl.MinConfidence))
|
||||
|
||||
// 4. 交易频率与信号质量(可编辑)
|
||||
if promptSections.TradingFrequency != "" {
|
||||
sb.WriteString(promptSections.TradingFrequency)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# ⏱️ 交易频率认知\n\n")
|
||||
sb.WriteString("- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔\n")
|
||||
sb.WriteString("- 每小时>2笔 = 过度交易\n")
|
||||
sb.WriteString("- 单笔持仓时间≥30-60分钟\n")
|
||||
sb.WriteString("如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。\n\n")
|
||||
}
|
||||
|
||||
// 5. 开仓标准(可编辑)
|
||||
if promptSections.EntryStandards != "" {
|
||||
sb.WriteString(promptSections.EntryStandards)
|
||||
sb.WriteString("\n\n你拥有以下指标数据:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\n**信心度 ≥%d** 才能开仓。\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString("# 🎯 开仓标准(严格)\n\n")
|
||||
sb.WriteString("只在多重信号共振时开仓。你拥有:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\n自由运用任何有效的分析方法,但**信心度 ≥%d** 才能开仓;避免单一指标、信号矛盾、横盘震荡、刚平仓即重启等低质量行为。\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// 6. 决策流程提示(可编辑)
|
||||
if promptSections.DecisionProcess != "" {
|
||||
sb.WriteString(promptSections.DecisionProcess)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📋 决策流程\n\n")
|
||||
sb.WriteString("1. 检查持仓 → 是否该止盈/止损\n")
|
||||
sb.WriteString("2. 扫描候选币 + 多时间框 → 是否存在强信号\n")
|
||||
sb.WriteString("3. 先写思维链,再输出结构化JSON\n\n")
|
||||
}
|
||||
|
||||
// 7. 输出格式
|
||||
sb.WriteString("# 输出格式 (严格遵守)\n\n")
|
||||
sb.WriteString("**必须使用XML标签 <reasoning> 和 <decision> 标签分隔思维链和决策JSON,避免解析错误**\n\n")
|
||||
sb.WriteString("## 格式要求\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
sb.WriteString("你的思维链分析...\n")
|
||||
sb.WriteString("- 简洁分析你的思考过程 \n")
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("第二步: JSON决策数组\n\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, accountEquity*5))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
sb.WriteString("## 字段说明\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100(开仓建议≥%d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n\n")
|
||||
|
||||
// 8. 自定义 Prompt
|
||||
if e.config.CustomPrompt != "" {
|
||||
sb.WriteString("# 📌 个性化交易策略\n\n")
|
||||
sb.WriteString(e.config.CustomPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("注意: 以上个性化策略是对基础规则的补充,不能违背基础风险控制原则。\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// writeAvailableIndicators 写入可用指标列表
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
indicators := e.config.Indicators
|
||||
kline := indicators.Klines
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- %s价格序列", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K线序列\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString("- EMA 指标")
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("(周期: %v)", indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- MACD 指标\n")
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- RSI 指标")
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("(周期: %v)", indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- ATR 指标")
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("(周期: %v)", indicators.ATRPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- 成交量数据\n")
|
||||
}
|
||||
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- 持仓量(OI)数据\n")
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- 资金费率\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top 筛选标签(若有)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// GetRiskControlConfig 获取风险控制配置
|
||||
func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
||||
return e.config.RiskControl
|
||||
}
|
||||
|
||||
// GetConfig 获取完整策略配置
|
||||
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
||||
return e.config
|
||||
}
|
||||
@@ -89,6 +89,10 @@
|
||||

|
||||
*专业交易界面,包含权益曲线、实时持仓、AI决策日志,支持展开查看输入提示词和AI思维链推理过程*
|
||||
|
||||
### 🎛️ 策略工作室 - 自定义策略构建器
|
||||

|
||||
*三栏式策略编辑器,支持多时间周期选择(5m/15m/1h/4h)、技术指标配置、风险控制设置,以及基于实时市场数据的AI测试运行*
|
||||
|
||||
---
|
||||
|
||||
## 🏦 支持的交易所(DEX/CEX教程)
|
||||
|
||||
+20
-73
@@ -2,14 +2,12 @@ package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -360,17 +358,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
|
||||
maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss")
|
||||
maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown")
|
||||
stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes")
|
||||
defaultCoinsStr, _ := st.SystemConfig().Get("default_coins")
|
||||
|
||||
// 获取用户信号源配置
|
||||
var coinPoolURL, oiTopURL string
|
||||
if signalSource, err := st.SignalSource().Get(userID); err == nil {
|
||||
coinPoolURL = signalSource.CoinPoolURL
|
||||
oiTopURL = signalSource.OITopURL
|
||||
logger.Infof("📡 加载用户 %s 的信号源配置: COIN POOL=%s, OI TOP=%s", userID, coinPoolURL, oiTopURL)
|
||||
} else {
|
||||
logger.Infof("🔍 用户 %s 暂未配置信号源", userID)
|
||||
}
|
||||
|
||||
// 解析配置
|
||||
maxDailyLoss := 10.0 // 默认值
|
||||
@@ -388,15 +375,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
|
||||
stopTradingMinutes = val
|
||||
}
|
||||
|
||||
// 解析默认币种列表
|
||||
var defaultCoins []string
|
||||
if defaultCoinsStr != "" {
|
||||
if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil {
|
||||
logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err)
|
||||
defaultCoins = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取AI模型和交易所列表(在循环外只查询一次)
|
||||
aiModels, err := st.AIModel().List(userID)
|
||||
if err != nil {
|
||||
@@ -465,7 +443,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
|
||||
}
|
||||
|
||||
// 使用现有的方法加载交易员
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, st)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err)
|
||||
}
|
||||
@@ -505,7 +483,6 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss")
|
||||
maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown")
|
||||
stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes")
|
||||
defaultCoinsStr, _ := st.SystemConfig().Get("default_coins")
|
||||
|
||||
// 解析配置
|
||||
maxDailyLoss := 10.0 // 默认值
|
||||
@@ -523,15 +500,6 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
stopTradingMinutes = val
|
||||
}
|
||||
|
||||
// 解析默认币种列表
|
||||
var defaultCoins []string
|
||||
if defaultCoinsStr != "" {
|
||||
if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil {
|
||||
logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err)
|
||||
defaultCoins = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个交易员获取AI模型和交易所配置
|
||||
for _, traderCfg := range allTraders {
|
||||
// 获取AI模型配置
|
||||
@@ -595,17 +563,8 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取用户信号源配置
|
||||
var coinPoolURL, oiTopURL string
|
||||
if signalSource, err := st.SignalSource().Get(traderCfg.UserID); err == nil {
|
||||
coinPoolURL = signalSource.CoinPoolURL
|
||||
oiTopURL = signalSource.OITopURL
|
||||
} else {
|
||||
logger.Infof("🔍 用户 %s 暂未配置信号源", traderCfg.UserID)
|
||||
}
|
||||
|
||||
// 添加到TraderManager
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, st)
|
||||
// 添加到TraderManager(coinPoolURL/oiTopURL 已从策略配置中获取)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st)
|
||||
if err != nil {
|
||||
logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err)
|
||||
continue
|
||||
@@ -617,36 +576,29 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
}
|
||||
|
||||
// addTraderFromStore 内部方法:从store配置添加交易员
|
||||
func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, st *store.Store) error {
|
||||
func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, st *store.Store) error {
|
||||
if _, exists := tm.traders[traderCfg.ID]; exists {
|
||||
return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID)
|
||||
}
|
||||
|
||||
// 处理交易币种列表
|
||||
var tradingCoins []string
|
||||
if traderCfg.TradingSymbols != "" {
|
||||
symbols := strings.Split(traderCfg.TradingSymbols, ",")
|
||||
for _, symbol := range symbols {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol != "" {
|
||||
tradingCoins = append(tradingCoins, symbol)
|
||||
}
|
||||
// 加载策略配置(必须有策略)
|
||||
var strategyConfig *store.StrategyConfig
|
||||
if traderCfg.StrategyID != "" {
|
||||
strategy, err := st.Strategy().Get(traderCfg.UserID, traderCfg.StrategyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("交易员 %s 的策略 %s 加载失败: %w", traderCfg.Name, traderCfg.StrategyID, err)
|
||||
}
|
||||
// 解析 JSON 配置
|
||||
strategyConfig, err = strategy.ParseConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("交易员 %s 的策略配置解析失败: %w", traderCfg.Name, err)
|
||||
}
|
||||
logger.Infof("✓ 交易员 %s 加载策略配置: %s", traderCfg.Name, strategy.Name)
|
||||
} else {
|
||||
return fmt.Errorf("交易员 %s 未配置策略", traderCfg.Name)
|
||||
}
|
||||
|
||||
// 如果没有指定交易币种,使用默认币种
|
||||
if len(tradingCoins) == 0 {
|
||||
tradingCoins = defaultCoins
|
||||
}
|
||||
|
||||
// 根据交易员配置决定是否使用信号源
|
||||
var effectiveCoinPoolURL string
|
||||
if traderCfg.UseCoinPool && coinPoolURL != "" {
|
||||
effectiveCoinPoolURL = coinPoolURL
|
||||
logger.Infof("✓ 交易员 %s 启用 COIN POOL 信号源: %s", traderCfg.Name, coinPoolURL)
|
||||
}
|
||||
|
||||
// 构建AutoTraderConfig
|
||||
// 构建AutoTraderConfig(coinPoolURL/oiTopURL 从策略配置获取,在 StrategyEngine 中使用)
|
||||
traderConfig := trader.AutoTraderConfig{
|
||||
ID: traderCfg.ID,
|
||||
Name: traderCfg.Name,
|
||||
@@ -656,7 +608,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
BinanceSecretKey: "",
|
||||
HyperliquidPrivateKey: "",
|
||||
HyperliquidTestnet: exchangeCfg.Testnet,
|
||||
CoinPoolAPIURL: effectiveCoinPoolURL,
|
||||
UseQwen: aiModelCfg.Provider == "qwen",
|
||||
DeepSeekKey: "",
|
||||
QwenKey: "",
|
||||
@@ -664,15 +615,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
CustomModelName: aiModelCfg.CustomModelName,
|
||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||
InitialBalance: traderCfg.InitialBalance,
|
||||
BTCETHLeverage: traderCfg.BTCETHLeverage,
|
||||
AltcoinLeverage: traderCfg.AltcoinLeverage,
|
||||
MaxDailyLoss: maxDailyLoss,
|
||||
MaxDrawdown: maxDrawdown,
|
||||
StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute,
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
DefaultCoins: defaultCoins,
|
||||
TradingCoins: tradingCoins,
|
||||
SystemPromptTemplate: traderCfg.SystemPromptTemplate,
|
||||
StrategyConfig: strategyConfig,
|
||||
}
|
||||
|
||||
// 根据交易所类型设置API密钥
|
||||
|
||||
+269
@@ -112,6 +112,230 @@ func Get(symbol string) (*Data, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetWithTimeframes 获取指定多个时间周期的市场数据
|
||||
// timeframes: 时间周期列表,如 ["5m", "15m", "1h", "4h"]
|
||||
// primaryTimeframe: 主时间周期(用于计算当前指标),默认使用 timeframes[0]
|
||||
// count: 每个时间周期的 K 线数量
|
||||
func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe string, count int) (*Data, error) {
|
||||
symbol = Normalize(symbol)
|
||||
|
||||
if len(timeframes) == 0 {
|
||||
return nil, fmt.Errorf("至少需要一个时间周期")
|
||||
}
|
||||
|
||||
// 如果未指定主周期,使用第一个
|
||||
if primaryTimeframe == "" {
|
||||
primaryTimeframe = timeframes[0]
|
||||
}
|
||||
|
||||
// 确保主周期在列表中
|
||||
hasPrimary := false
|
||||
for _, tf := range timeframes {
|
||||
if tf == primaryTimeframe {
|
||||
hasPrimary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPrimary {
|
||||
timeframes = append([]string{primaryTimeframe}, timeframes...)
|
||||
}
|
||||
|
||||
// 存储所有时间周期的数据
|
||||
timeframeData := make(map[string]*TimeframeSeriesData)
|
||||
var primaryKlines []Kline
|
||||
|
||||
// 获取每个时间周期的 K 线数据
|
||||
for _, tf := range timeframes {
|
||||
klines, err := WSMonitorCli.GetCurrentKlines(symbol, tf)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ 获取 %s %s K线失败: %v", symbol, tf, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(klines) == 0 {
|
||||
logger.Infof("⚠️ %s %s K线数据为空", symbol, tf)
|
||||
continue
|
||||
}
|
||||
|
||||
// 保存主周期的 K 线用于计算基础指标
|
||||
if tf == primaryTimeframe {
|
||||
primaryKlines = klines
|
||||
}
|
||||
|
||||
// 计算该时间周期的系列数据
|
||||
seriesData := calculateTimeframeSeries(klines, tf)
|
||||
timeframeData[tf] = seriesData
|
||||
}
|
||||
|
||||
// 如果主周期数据为空,返回错误
|
||||
if len(primaryKlines) == 0 {
|
||||
return nil, fmt.Errorf("主时间周期 %s K线数据为空", primaryTimeframe)
|
||||
}
|
||||
|
||||
// Data staleness detection
|
||||
if isStaleData(primaryKlines, symbol) {
|
||||
logger.Infof("⚠️ WARNING: %s detected stale data (consecutive price freeze), skipping symbol", symbol)
|
||||
return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol)
|
||||
}
|
||||
|
||||
// 计算当前指标 (基于主周期最新数据)
|
||||
currentPrice := primaryKlines[len(primaryKlines)-1].Close
|
||||
currentEMA20 := calculateEMA(primaryKlines, 20)
|
||||
currentMACD := calculateMACD(primaryKlines)
|
||||
currentRSI7 := calculateRSI(primaryKlines, 7)
|
||||
|
||||
// 计算价格变化
|
||||
priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1小时
|
||||
priceChange4h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 240) // 4小时
|
||||
|
||||
// 获取OI数据
|
||||
oiData, err := getOpenInterestData(symbol)
|
||||
if err != nil {
|
||||
oiData = &OIData{Latest: 0, Average: 0}
|
||||
}
|
||||
|
||||
// 获取Funding Rate
|
||||
fundingRate, _ := getFundingRate(symbol)
|
||||
|
||||
return &Data{
|
||||
Symbol: symbol,
|
||||
CurrentPrice: currentPrice,
|
||||
PriceChange1h: priceChange1h,
|
||||
PriceChange4h: priceChange4h,
|
||||
CurrentEMA20: currentEMA20,
|
||||
CurrentMACD: currentMACD,
|
||||
CurrentRSI7: currentRSI7,
|
||||
OpenInterest: oiData,
|
||||
FundingRate: fundingRate,
|
||||
TimeframeData: timeframeData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// calculateTimeframeSeries 计算单个时间周期的系列数据
|
||||
func calculateTimeframeSeries(klines []Kline, timeframe string) *TimeframeSeriesData {
|
||||
data := &TimeframeSeriesData{
|
||||
Timeframe: timeframe,
|
||||
MidPrices: make([]float64, 0, 10),
|
||||
EMA20Values: make([]float64, 0, 10),
|
||||
EMA50Values: make([]float64, 0, 10),
|
||||
MACDValues: make([]float64, 0, 10),
|
||||
RSI7Values: make([]float64, 0, 10),
|
||||
RSI14Values: make([]float64, 0, 10),
|
||||
Volume: make([]float64, 0, 10),
|
||||
}
|
||||
|
||||
// 获取最近10个数据点
|
||||
start := len(klines) - 10
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
for i := start; i < len(klines); i++ {
|
||||
data.MidPrices = append(data.MidPrices, klines[i].Close)
|
||||
data.Volume = append(data.Volume, klines[i].Volume)
|
||||
|
||||
// 计算每个点的 EMA20
|
||||
if i >= 19 {
|
||||
ema20 := calculateEMA(klines[:i+1], 20)
|
||||
data.EMA20Values = append(data.EMA20Values, ema20)
|
||||
}
|
||||
|
||||
// 计算每个点的 EMA50
|
||||
if i >= 49 {
|
||||
ema50 := calculateEMA(klines[:i+1], 50)
|
||||
data.EMA50Values = append(data.EMA50Values, ema50)
|
||||
}
|
||||
|
||||
// 计算每个点的 MACD
|
||||
if i >= 25 {
|
||||
macd := calculateMACD(klines[:i+1])
|
||||
data.MACDValues = append(data.MACDValues, macd)
|
||||
}
|
||||
|
||||
// 计算每个点的 RSI
|
||||
if i >= 7 {
|
||||
rsi7 := calculateRSI(klines[:i+1], 7)
|
||||
data.RSI7Values = append(data.RSI7Values, rsi7)
|
||||
}
|
||||
if i >= 14 {
|
||||
rsi14 := calculateRSI(klines[:i+1], 14)
|
||||
data.RSI14Values = append(data.RSI14Values, rsi14)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 ATR14
|
||||
data.ATR14 = calculateATR(klines, 14)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// calculatePriceChangeByBars 根据时间周期计算需要回溯多少根 K 线来计算价格变化
|
||||
func calculatePriceChangeByBars(klines []Kline, timeframe string, targetMinutes int) float64 {
|
||||
if len(klines) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 解析时间周期为分钟数
|
||||
tfMinutes := parseTimeframeToMinutes(timeframe)
|
||||
if tfMinutes <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 计算需要回溯多少根 K 线
|
||||
barsBack := targetMinutes / tfMinutes
|
||||
if barsBack < 1 {
|
||||
barsBack = 1
|
||||
}
|
||||
|
||||
currentPrice := klines[len(klines)-1].Close
|
||||
idx := len(klines) - 1 - barsBack
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
|
||||
oldPrice := klines[idx].Close
|
||||
if oldPrice > 0 {
|
||||
return ((currentPrice - oldPrice) / oldPrice) * 100
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseTimeframeToMinutes 将时间周期字符串解析为分钟数
|
||||
func parseTimeframeToMinutes(tf string) int {
|
||||
switch tf {
|
||||
case "1m":
|
||||
return 1
|
||||
case "3m":
|
||||
return 3
|
||||
case "5m":
|
||||
return 5
|
||||
case "15m":
|
||||
return 15
|
||||
case "30m":
|
||||
return 30
|
||||
case "1h":
|
||||
return 60
|
||||
case "2h":
|
||||
return 120
|
||||
case "4h":
|
||||
return 240
|
||||
case "6h":
|
||||
return 360
|
||||
case "8h":
|
||||
return 480
|
||||
case "12h":
|
||||
return 720
|
||||
case "1d":
|
||||
return 1440
|
||||
case "3d":
|
||||
return 4320
|
||||
case "1w":
|
||||
return 10080
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// calculateEMA 计算EMA
|
||||
func calculateEMA(klines []Kline, period int) float64 {
|
||||
if len(klines) < period {
|
||||
@@ -481,9 +705,54 @@ func Format(data *Data) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 多时间周期数据(新增)
|
||||
if len(data.TimeframeData) > 0 {
|
||||
// 按时间周期排序输出
|
||||
timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}
|
||||
for _, tf := range timeframeOrder {
|
||||
if tfData, ok := data.TimeframeData[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf("=== %s Timeframe ===\n\n", strings.ToUpper(tf)))
|
||||
formatTimeframeData(&sb, tfData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatTimeframeData 格式化单个时间周期的数据
|
||||
func formatTimeframeData(sb *strings.Builder, data *TimeframeSeriesData) {
|
||||
if len(data.MidPrices) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices)))
|
||||
}
|
||||
|
||||
if len(data.EMA20Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA indicators (20‑period): %s\n\n", formatFloatSlice(data.EMA20Values)))
|
||||
}
|
||||
|
||||
if len(data.EMA50Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA indicators (50‑period): %s\n\n", formatFloatSlice(data.EMA50Values)))
|
||||
}
|
||||
|
||||
if len(data.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.MACDValues)))
|
||||
}
|
||||
|
||||
if len(data.RSI7Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (7‑Period): %s\n\n", formatFloatSlice(data.RSI7Values)))
|
||||
}
|
||||
|
||||
if len(data.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14‑Period): %s\n\n", formatFloatSlice(data.RSI14Values)))
|
||||
}
|
||||
|
||||
if len(data.Volume) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume)))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("ATR (14‑period): %.3f\n\n", data.ATR14))
|
||||
}
|
||||
|
||||
// formatPriceWithDynamicPrecision 根据价格区间动态选择精度
|
||||
// 这样可以完美支持从超低价 meme coin (< 0.0001) 到 BTC/ETH 的所有币种
|
||||
func formatPriceWithDynamicPrecision(price float64) string {
|
||||
|
||||
@@ -15,6 +15,21 @@ type Data struct {
|
||||
FundingRate float64
|
||||
IntradaySeries *IntradayData
|
||||
LongerTermContext *LongerTermData
|
||||
// 多时间周期数据(新增)
|
||||
TimeframeData map[string]*TimeframeSeriesData `json:"timeframe_data,omitempty"`
|
||||
}
|
||||
|
||||
// TimeframeSeriesData 单个时间周期的序列数据
|
||||
type TimeframeSeriesData struct {
|
||||
Timeframe string `json:"timeframe"` // 时间周期标识,如 "5m", "15m", "1h"
|
||||
MidPrices []float64 `json:"mid_prices"` // 价格序列
|
||||
EMA20Values []float64 `json:"ema20_values"` // EMA20 序列
|
||||
EMA50Values []float64 `json:"ema50_values"` // EMA50 序列
|
||||
MACDValues []float64 `json:"macd_values"` // MACD 序列
|
||||
RSI7Values []float64 `json:"rsi7_values"` // RSI7 序列
|
||||
RSI14Values []float64 `json:"rsi14_values"` // RSI14 序列
|
||||
Volume []float64 `json:"volume"` // 成交量序列
|
||||
ATR14 float64 `json:"atr14"` // ATR14
|
||||
}
|
||||
|
||||
// OIData Open Interest数据
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
+1
-2
@@ -42,8 +42,7 @@ func (s *AIModelStore) initTables() error {
|
||||
custom_api_url TEXT DEFAULT '',
|
||||
custom_model_name TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
+1
-2
@@ -56,8 +56,7 @@ func (s *ExchangeStore) initTables() error {
|
||||
lighter_api_key_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
|
||||
PRIMARY KEY (id, user_id)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
+5
-4
@@ -69,7 +69,11 @@ func (s *PositionStore) InitTables() error {
|
||||
return fmt.Errorf("创建trader_positions表失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建索引
|
||||
// 迁移:为现有表添加 exchange_id 列(如果不存在)
|
||||
// 必须在创建索引之前执行!
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`)
|
||||
|
||||
// 创建索引(在迁移之后)
|
||||
indices := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_trader ON trader_positions(trader_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_exchange ON trader_positions(exchange_id)`,
|
||||
@@ -84,9 +88,6 @@ func (s *PositionStore) InitTables() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:为现有表添加 exchange_id 列(如果不存在)
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type Store struct {
|
||||
backtest *BacktestStore
|
||||
order *OrderStore
|
||||
position *PositionStore
|
||||
strategy *StrategyStore
|
||||
|
||||
// 加密函数
|
||||
encryptFunc func(string) string
|
||||
@@ -151,6 +152,9 @@ func (s *Store) initTables() error {
|
||||
if err := s.Position().InitTables(); err != nil {
|
||||
return fmt.Errorf("初始化仓位表失败: %w", err)
|
||||
}
|
||||
if err := s.Strategy().initTables(); err != nil {
|
||||
return fmt.Errorf("初始化策略表失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -165,6 +169,9 @@ func (s *Store) initDefaultData() error {
|
||||
if err := s.SystemConfig().initDefaultData(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Strategy().initDefaultData(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -289,6 +296,16 @@ func (s *Store) Position() *PositionStore {
|
||||
return s.position
|
||||
}
|
||||
|
||||
// Strategy 获取策略存储
|
||||
func (s *Store) Strategy() *StrategyStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.strategy == nil {
|
||||
s.strategy = &StrategyStore{db: s.db}
|
||||
}
|
||||
return s.strategy
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StrategyStore 策略存储
|
||||
type StrategyStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Strategy 策略配置
|
||||
type Strategy struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsActive bool `json:"is_active"` // 是否激活(一个用户只能有一个激活的策略)
|
||||
IsDefault bool `json:"is_default"` // 是否为系统默认策略
|
||||
Config string `json:"config"` // JSON 格式的策略配置
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StrategyConfig 策略配置详情(JSON 结构)
|
||||
type StrategyConfig struct {
|
||||
// 币种来源配置
|
||||
CoinSource CoinSourceConfig `json:"coin_source"`
|
||||
// 量化数据配置
|
||||
Indicators IndicatorConfig `json:"indicators"`
|
||||
// 自定义 Prompt(附加在最后)
|
||||
CustomPrompt string `json:"custom_prompt,omitempty"`
|
||||
// 风险控制配置
|
||||
RiskControl RiskControlConfig `json:"risk_control"`
|
||||
// System Prompt 可编辑部分
|
||||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
||||
}
|
||||
|
||||
// PromptSectionsConfig System Prompt 可编辑部分
|
||||
type PromptSectionsConfig struct {
|
||||
// 角色定义(标题+描述)
|
||||
RoleDefinition string `json:"role_definition,omitempty"`
|
||||
// 交易频率认知
|
||||
TradingFrequency string `json:"trading_frequency,omitempty"`
|
||||
// 开仓标准
|
||||
EntryStandards string `json:"entry_standards,omitempty"`
|
||||
// 决策流程
|
||||
DecisionProcess string `json:"decision_process,omitempty"`
|
||||
}
|
||||
|
||||
// CoinSourceConfig 币种来源配置
|
||||
type CoinSourceConfig struct {
|
||||
// 来源类型: "static" | "coinpool" | "oi_top" | "mixed"
|
||||
SourceType string `json:"source_type"`
|
||||
// 静态币种列表(当 source_type = "static" 时使用)
|
||||
StaticCoins []string `json:"static_coins,omitempty"`
|
||||
// 是否使用 AI500 币种池
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
// AI500 币种池最大数量
|
||||
CoinPoolLimit int `json:"coin_pool_limit,omitempty"`
|
||||
// AI500 币种池 API URL(策略级别配置)
|
||||
CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"`
|
||||
// 是否使用 OI Top
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
// OI Top 最大数量
|
||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||||
// OI Top API URL(策略级别配置)
|
||||
OITopAPIURL string `json:"oi_top_api_url,omitempty"`
|
||||
}
|
||||
|
||||
// IndicatorConfig 指标配置
|
||||
type IndicatorConfig struct {
|
||||
// K线配置
|
||||
Klines KlineConfig `json:"klines"`
|
||||
// 技术指标开关
|
||||
EnableEMA bool `json:"enable_ema"`
|
||||
EnableMACD bool `json:"enable_macd"`
|
||||
EnableRSI bool `json:"enable_rsi"`
|
||||
EnableATR bool `json:"enable_atr"`
|
||||
EnableVolume bool `json:"enable_volume"`
|
||||
EnableOI bool `json:"enable_oi"` // 持仓量
|
||||
EnableFundingRate bool `json:"enable_funding_rate"` // 资金费率
|
||||
// EMA 周期配置
|
||||
EMAPeriods []int `json:"ema_periods,omitempty"` // 默认 [20, 50]
|
||||
// RSI 周期配置
|
||||
RSIPeriods []int `json:"rsi_periods,omitempty"` // 默认 [7, 14]
|
||||
// ATR 周期配置
|
||||
ATRPeriods []int `json:"atr_periods,omitempty"` // 默认 [14]
|
||||
// 外部数据源
|
||||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
||||
}
|
||||
|
||||
// KlineConfig K线配置
|
||||
type KlineConfig struct {
|
||||
// 主时间周期: "1m", "3m", "5m", "15m", "1h", "4h"
|
||||
PrimaryTimeframe string `json:"primary_timeframe"`
|
||||
// 主时间周期 K 线数量
|
||||
PrimaryCount int `json:"primary_count"`
|
||||
// 长周期时间框架
|
||||
LongerTimeframe string `json:"longer_timeframe,omitempty"`
|
||||
// 长周期 K 线数量
|
||||
LongerCount int `json:"longer_count,omitempty"`
|
||||
// 是否启用多时间框架分析
|
||||
EnableMultiTimeframe bool `json:"enable_multi_timeframe"`
|
||||
// 选中的时间周期列表(新增:支持多周期选择)
|
||||
SelectedTimeframes []string `json:"selected_timeframes,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalDataSource 外部数据源配置
|
||||
type ExternalDataSource struct {
|
||||
Name string `json:"name"` // 数据源名称
|
||||
Type string `json:"type"` // 类型: "api" | "webhook"
|
||||
URL string `json:"url"` // API URL
|
||||
Method string `json:"method"` // HTTP 方法
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
DataPath string `json:"data_path,omitempty"` // JSON 数据路径
|
||||
RefreshSecs int `json:"refresh_secs,omitempty"` // 刷新间隔(秒)
|
||||
}
|
||||
|
||||
// RiskControlConfig 风险控制配置
|
||||
type RiskControlConfig struct {
|
||||
// 最大持仓数量
|
||||
MaxPositions int `json:"max_positions"`
|
||||
// BTC/ETH 最大杠杆
|
||||
BTCETHMaxLeverage int `json:"btc_eth_max_leverage"`
|
||||
// 山寨币最大杠杆
|
||||
AltcoinMaxLeverage int `json:"altcoin_max_leverage"`
|
||||
// 最小风险回报比
|
||||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
||||
// 最大保证金使用率
|
||||
MaxMarginUsage float64 `json:"max_margin_usage"`
|
||||
// 单币种最大仓位比例(相对账户净值)
|
||||
MaxPositionRatio float64 `json:"max_position_ratio"`
|
||||
// 最小开仓金额(USDT)
|
||||
MinPositionSize float64 `json:"min_position_size"`
|
||||
// 最小信心度
|
||||
MinConfidence int `json:"min_confidence"`
|
||||
}
|
||||
|
||||
func (s *StrategyStore) initTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS strategies (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
is_active BOOLEAN DEFAULT 0,
|
||||
is_default BOOLEAN DEFAULT 0,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建索引
|
||||
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON strategies(user_id)`)
|
||||
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_strategies_is_active ON strategies(is_active)`)
|
||||
|
||||
// 触发器:更新时自动更新 updated_at
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS update_strategies_updated_at
|
||||
AFTER UPDATE ON strategies
|
||||
BEGIN
|
||||
UPDATE strategies SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END
|
||||
`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *StrategyStore) initDefaultData() error {
|
||||
// 检查是否已有默认策略
|
||||
var count int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM strategies WHERE is_default = 1`).Scan(&count)
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建系统默认策略
|
||||
defaultConfig := StrategyConfig{
|
||||
CoinSource: CoinSourceConfig{
|
||||
SourceType: "coinpool",
|
||||
UseCoinPool: true,
|
||||
CoinPoolLimit: 30,
|
||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
||||
UseOITop: false,
|
||||
OITopLimit: 0,
|
||||
},
|
||||
Indicators: IndicatorConfig{
|
||||
Klines: KlineConfig{
|
||||
PrimaryTimeframe: "5m",
|
||||
PrimaryCount: 30,
|
||||
LongerTimeframe: "4h",
|
||||
LongerCount: 10,
|
||||
EnableMultiTimeframe: true,
|
||||
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
|
||||
},
|
||||
EnableEMA: true,
|
||||
EnableMACD: true,
|
||||
EnableRSI: true,
|
||||
EnableATR: true,
|
||||
EnableVolume: true,
|
||||
EnableOI: true,
|
||||
EnableFundingRate: true,
|
||||
EMAPeriods: []int{20, 50},
|
||||
RSIPeriods: []int{7, 14},
|
||||
ATRPeriods: []int{14},
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3,
|
||||
BTCETHMaxLeverage: 5,
|
||||
AltcoinMaxLeverage: 5,
|
||||
MinRiskRewardRatio: 3.0,
|
||||
MaxMarginUsage: 0.9,
|
||||
MaxPositionRatio: 1.5,
|
||||
MinPositionSize: 12,
|
||||
MinConfidence: 75,
|
||||
},
|
||||
PromptSections: PromptSectionsConfig{
|
||||
RoleDefinition: `# 你是专业的加密货币交易AI
|
||||
|
||||
你的任务是根据提供的市场数据做出交易决策。你是一位经验丰富的量化交易员,擅长技术分析和风险管理。`,
|
||||
TradingFrequency: `# ⏱️ 交易频率认知
|
||||
|
||||
- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔
|
||||
- 每小时>2笔 = 过度交易
|
||||
- 单笔持仓时间≥30-60分钟
|
||||
如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。`,
|
||||
EntryStandards: `# 🎯 开仓标准(严格)
|
||||
|
||||
只在多重信号共振时开仓。自由运用任何有效的分析方法,避免单一指标、信号矛盾、横盘震荡、刚平仓即重启等低质量行为。`,
|
||||
DecisionProcess: `# 📋 决策流程
|
||||
|
||||
1. 检查持仓 → 是否该止盈/止损
|
||||
2. 扫描候选币 + 多时间框 → 是否存在强信号
|
||||
3. 先写思维链,再输出结构化JSON`,
|
||||
},
|
||||
}
|
||||
|
||||
configJSON, _ := json.Marshal(defaultConfig)
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO strategies (id, user_id, name, description, is_active, is_default, config)
|
||||
VALUES ('default', 'system', '默认山寨策略', '系统默认的山寨币交易策略,使用 AI500 币种池,包含完整的技术指标', 0, 1, ?)
|
||||
`, string(configJSON))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Create 创建策略
|
||||
func (s *StrategyStore) Create(strategy *Strategy) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO strategies (id, user_id, name, description, is_active, is_default, config)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, strategy.ID, strategy.UserID, strategy.Name, strategy.Description, strategy.IsActive, strategy.IsDefault, strategy.Config)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update 更新策略
|
||||
func (s *StrategyStore) Update(strategy *Strategy) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE strategies SET
|
||||
name = ?, description = ?, config = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
`, strategy.Name, strategy.Description, strategy.Config, strategy.ID, strategy.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete 删除策略
|
||||
func (s *StrategyStore) Delete(userID, id string) error {
|
||||
// 不允许删除系统默认策略
|
||||
var isDefault bool
|
||||
s.db.QueryRow(`SELECT is_default FROM strategies WHERE id = ?`, id).Scan(&isDefault)
|
||||
if isDefault {
|
||||
return fmt.Errorf("不能删除系统默认策略")
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`DELETE FROM strategies WHERE id = ? AND user_id = ?`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// List 获取用户的策略列表
|
||||
func (s *StrategyStore) List(userID string) ([]*Strategy, error) {
|
||||
// 获取用户自己的策略 + 系统默认策略
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
|
||||
FROM strategies
|
||||
WHERE user_id = ? OR is_default = 1
|
||||
ORDER BY is_default DESC, created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var strategies []*Strategy
|
||||
for rows.Next() {
|
||||
var st Strategy
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&st.ID, &st.UserID, &st.Name, &st.Description,
|
||||
&st.IsActive, &st.IsDefault, &st.Config,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
strategies = append(strategies, &st)
|
||||
}
|
||||
return strategies, nil
|
||||
}
|
||||
|
||||
// Get 获取单个策略
|
||||
func (s *StrategyStore) Get(userID, id string) (*Strategy, error) {
|
||||
var st Strategy
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
|
||||
FROM strategies
|
||||
WHERE id = ? AND (user_id = ? OR is_default = 1)
|
||||
`, id, userID).Scan(
|
||||
&st.ID, &st.UserID, &st.Name, &st.Description,
|
||||
&st.IsActive, &st.IsDefault, &st.Config,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// GetActive 获取用户当前激活的策略
|
||||
func (s *StrategyStore) GetActive(userID string) (*Strategy, error) {
|
||||
var st Strategy
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
|
||||
FROM strategies
|
||||
WHERE user_id = ? AND is_active = 1
|
||||
`, userID).Scan(
|
||||
&st.ID, &st.UserID, &st.Name, &st.Description,
|
||||
&st.IsActive, &st.IsDefault, &st.Config,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
// 没有激活的策略,返回系统默认策略
|
||||
return s.GetDefault()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// GetDefault 获取系统默认策略
|
||||
func (s *StrategyStore) GetDefault() (*Strategy, error) {
|
||||
var st Strategy
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
|
||||
FROM strategies
|
||||
WHERE is_default = 1
|
||||
LIMIT 1
|
||||
`).Scan(
|
||||
&st.ID, &st.UserID, &st.Name, &st.Description,
|
||||
&st.IsActive, &st.IsDefault, &st.Config,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// SetActive 设置激活策略(会先取消其他策略的激活状态)
|
||||
func (s *StrategyStore) SetActive(userID, strategyID string) error {
|
||||
// 开启事务
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 先取消该用户所有策略的激活状态
|
||||
_, err = tx.Exec(`UPDATE strategies SET is_active = 0 WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 激活指定策略
|
||||
_, err = tx.Exec(`UPDATE strategies SET is_active = 1 WHERE id = ? AND (user_id = ? OR is_default = 1)`, strategyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Duplicate 复制策略(用于基于默认策略创建自定义策略)
|
||||
func (s *StrategyStore) Duplicate(userID, sourceID, newID, newName string) error {
|
||||
// 获取源策略
|
||||
source, err := s.Get(userID, sourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取源策略失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建新策略
|
||||
newStrategy := &Strategy{
|
||||
ID: newID,
|
||||
UserID: userID,
|
||||
Name: newName,
|
||||
Description: "基于 [" + source.Name + "] 创建",
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
Config: source.Config,
|
||||
}
|
||||
|
||||
return s.Create(newStrategy)
|
||||
}
|
||||
|
||||
// ParseConfig 解析策略配置 JSON
|
||||
func (s *Strategy) ParseConfig() (*StrategyConfig, error) {
|
||||
var config StrategyConfig
|
||||
if err := json.Unmarshal([]byte(s.Config), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析策略配置失败: %w", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SetConfig 设置策略配置
|
||||
func (s *Strategy) SetConfig(config *StrategyConfig) error {
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化策略配置失败: %w", err)
|
||||
}
|
||||
s.Config = string(data)
|
||||
return nil
|
||||
}
|
||||
@@ -36,13 +36,9 @@ func (s *SystemConfigStore) initDefaultData() error {
|
||||
configs := map[string]string{
|
||||
"beta_mode": "false",
|
||||
"api_server_port": "8080",
|
||||
"use_default_coins": "true",
|
||||
"default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`,
|
||||
"max_daily_loss": "10.0",
|
||||
"max_drawdown": "20.0",
|
||||
"stop_trading_minutes": "60",
|
||||
"btc_eth_leverage": "5",
|
||||
"altcoin_leverage": "5",
|
||||
"jwt_secret": "",
|
||||
"registration_enabled": "true",
|
||||
}
|
||||
|
||||
+122
-51
@@ -18,32 +18,36 @@ type TraderStore struct {
|
||||
|
||||
// Trader 交易员配置
|
||||
type Trader 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"`
|
||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||
TradingSymbols string `json:"trading_symbols"`
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
||||
IsCrossMargin bool `json:"is_cross_margin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
AIModelID string `json:"ai_model_id"`
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
StrategyID string `json:"strategy_id"` // 关联策略ID
|
||||
InitialBalance float64 `json:"initial_balance"`
|
||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
IsCrossMargin bool `json:"is_cross_margin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 以下字段已废弃,保留用于向后兼容,新交易员应使用 StrategyID
|
||||
BTCETHLeverage int `json:"btc_eth_leverage,omitempty"`
|
||||
AltcoinLeverage int `json:"altcoin_leverage,omitempty"`
|
||||
TradingSymbols string `json:"trading_symbols,omitempty"`
|
||||
UseCoinPool bool `json:"use_coin_pool,omitempty"`
|
||||
UseOITop bool `json:"use_oi_top,omitempty"`
|
||||
CustomPrompt string `json:"custom_prompt,omitempty"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt,omitempty"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template,omitempty"`
|
||||
}
|
||||
|
||||
// TraderFullConfig 交易员完整配置(包含AI模型和交易所)
|
||||
// TraderFullConfig 交易员完整配置(包含AI模型、交易所和策略)
|
||||
type TraderFullConfig struct {
|
||||
Trader *Trader
|
||||
AIModel *AIModel
|
||||
Exchange *Exchange
|
||||
Strategy *Strategy // 关联的策略配置
|
||||
}
|
||||
|
||||
func (s *TraderStore) initTables() error {
|
||||
@@ -98,6 +102,7 @@ func (s *TraderStore) initTables() error {
|
||||
`ALTER TABLE traders ADD COLUMN use_coin_pool BOOLEAN DEFAULT 0`,
|
||||
`ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`,
|
||||
`ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`,
|
||||
`ALTER TABLE traders ADD COLUMN strategy_id TEXT DEFAULT ''`,
|
||||
}
|
||||
for _, q := range alterQueries {
|
||||
s.db.Exec(q)
|
||||
@@ -116,25 +121,27 @@ func (s *TraderStore) decrypt(encrypted string) string {
|
||||
// Create 创建交易员
|
||||
func (s *TraderStore) Create(trader *Trader) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes,
|
||||
is_running, btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool,
|
||||
use_oi_top, custom_prompt, override_base_prompt, system_prompt_template, is_cross_margin)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance,
|
||||
trader.ScanIntervalMinutes, trader.IsRunning, trader.BTCETHLeverage, trader.AltcoinLeverage,
|
||||
trader.TradingSymbols, trader.UseCoinPool, trader.UseOITop, trader.CustomPrompt,
|
||||
trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin)
|
||||
INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, strategy_id, initial_balance,
|
||||
scan_interval_minutes, is_running, is_cross_margin,
|
||||
btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool,
|
||||
use_oi_top, custom_prompt, override_base_prompt, system_prompt_template)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID,
|
||||
trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.IsCrossMargin,
|
||||
trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.UseCoinPool,
|
||||
trader.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate)
|
||||
return err
|
||||
}
|
||||
|
||||
// List 获取用户的交易员列表
|
||||
func (s *TraderStore) List(userID string) ([]*Trader, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running,
|
||||
SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''),
|
||||
initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1),
|
||||
COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''),
|
||||
COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
|
||||
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'),
|
||||
COALESCE(is_cross_margin, 1), created_at, updated_at
|
||||
created_at, updated_at
|
||||
FROM traders WHERE user_id = ? ORDER BY created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
@@ -147,11 +154,11 @@ func (s *TraderStore) List(userID string) ([]*Trader, error) {
|
||||
var t Trader
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID,
|
||||
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning,
|
||||
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID,
|
||||
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin,
|
||||
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
|
||||
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
|
||||
&t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt,
|
||||
&t.SystemPromptTemplate, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -173,15 +180,12 @@ func (s *TraderStore) UpdateStatus(userID, id string, isRunning bool) error {
|
||||
func (s *TraderStore) Update(trader *Trader) error {
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE traders SET
|
||||
name = ?, ai_model_id = ?, exchange_id = ?, scan_interval_minutes = ?,
|
||||
btc_eth_leverage = ?, altcoin_leverage = ?, trading_symbols = ?,
|
||||
custom_prompt = ?, override_base_prompt = ?, system_prompt_template = ?,
|
||||
is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP
|
||||
name = ?, ai_model_id = ?, exchange_id = ?, strategy_id = ?,
|
||||
scan_interval_minutes = ?, is_cross_margin = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
`, trader.Name, trader.AIModelID, trader.ExchangeID, trader.ScanIntervalMinutes,
|
||||
trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols,
|
||||
trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate,
|
||||
trader.IsCrossMargin, trader.ID, trader.UserID)
|
||||
`, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID,
|
||||
trader.ScanIntervalMinutes, trader.IsCrossMargin, trader.ID, trader.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -215,11 +219,12 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT
|
||||
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.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, COALESCE(t.strategy_id, ''),
|
||||
t.initial_balance, t.scan_interval_minutes, t.is_running, COALESCE(t.is_cross_margin, 1),
|
||||
COALESCE(t.btc_eth_leverage, 5), COALESCE(t.altcoin_leverage, 5), COALESCE(t.trading_symbols, ''),
|
||||
COALESCE(t.use_coin_pool, 0), COALESCE(t.use_oi_top, 0), COALESCE(t.custom_prompt, ''),
|
||||
COALESCE(t.override_base_prompt, 0), COALESCE(t.system_prompt_template, 'default'),
|
||||
COALESCE(t.is_cross_margin, 1), t.created_at, t.updated_at,
|
||||
t.created_at, t.updated_at,
|
||||
a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key,
|
||||
COALESCE(a.custom_api_url, ''), COALESCE(a.custom_model_name, ''), 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,
|
||||
@@ -231,11 +236,11 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
|
||||
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.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, &trader.StrategyID,
|
||||
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, &trader.IsCrossMargin,
|
||||
&trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols,
|
||||
&trader.UseCoinPool, &trader.UseOITop, &trader.CustomPrompt, &trader.OverrideBasePrompt,
|
||||
&trader.SystemPromptTemplate, &trader.IsCrossMargin, &traderCreatedAt, &traderUpdatedAt,
|
||||
&trader.SystemPromptTemplate, &traderCreatedAt, &traderUpdatedAt,
|
||||
&aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
|
||||
&aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt,
|
||||
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
|
||||
@@ -263,13 +268,78 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
|
||||
exchange.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey)
|
||||
exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey)
|
||||
|
||||
// 加载关联的策略
|
||||
var strategy *Strategy
|
||||
if trader.StrategyID != "" {
|
||||
strategy, _ = s.getStrategyByID(userID, trader.StrategyID)
|
||||
}
|
||||
// 如果没有关联策略,获取用户的激活策略或默认策略
|
||||
if strategy == nil {
|
||||
strategy, _ = s.getActiveOrDefaultStrategy(userID)
|
||||
}
|
||||
|
||||
return &TraderFullConfig{
|
||||
Trader: &trader,
|
||||
AIModel: &aiModel,
|
||||
Exchange: &exchange,
|
||||
Strategy: strategy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getStrategyByID 内部方法:根据ID获取策略
|
||||
func (s *TraderStore) getStrategyByID(userID, strategyID string) (*Strategy, error) {
|
||||
var strategy Strategy
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
|
||||
FROM strategies WHERE id = ? AND (user_id = ? OR is_default = 1)
|
||||
`, strategyID, userID).Scan(
|
||||
&strategy.ID, &strategy.UserID, &strategy.Name, &strategy.Description,
|
||||
&strategy.IsActive, &strategy.IsDefault, &strategy.Config, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
strategy.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
strategy.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &strategy, nil
|
||||
}
|
||||
|
||||
// getActiveOrDefaultStrategy 内部方法:获取用户激活的策略或系统默认策略
|
||||
func (s *TraderStore) getActiveOrDefaultStrategy(userID string) (*Strategy, error) {
|
||||
var strategy Strategy
|
||||
var createdAt, updatedAt string
|
||||
|
||||
// 先尝试获取用户激活的策略
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
|
||||
FROM strategies WHERE user_id = ? AND is_active = 1
|
||||
`, userID).Scan(
|
||||
&strategy.ID, &strategy.UserID, &strategy.Name, &strategy.Description,
|
||||
&strategy.IsActive, &strategy.IsDefault, &strategy.Config, &createdAt, &updatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
strategy.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
strategy.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &strategy, nil
|
||||
}
|
||||
|
||||
// 回退到系统默认策略
|
||||
err = s.db.QueryRow(`
|
||||
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
|
||||
FROM strategies WHERE is_default = 1 LIMIT 1
|
||||
`).Scan(
|
||||
&strategy.ID, &strategy.UserID, &strategy.Name, &strategy.Description,
|
||||
&strategy.IsActive, &strategy.IsDefault, &strategy.Config, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
strategy.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
strategy.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
return &strategy, nil
|
||||
}
|
||||
|
||||
// GetCustomCoins 获取所有交易员自定义币种
|
||||
func (s *TraderStore) GetCustomCoins() []string {
|
||||
var symbol string
|
||||
@@ -310,11 +380,12 @@ func (s *TraderStore) GetCustomCoins() []string {
|
||||
// ListAll 获取所有用户的交易员列表
|
||||
func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running,
|
||||
SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''),
|
||||
initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1),
|
||||
COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''),
|
||||
COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
|
||||
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'),
|
||||
COALESCE(is_cross_margin, 1), created_at, updated_at
|
||||
created_at, updated_at
|
||||
FROM traders ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -327,11 +398,11 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||
var t Trader
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID,
|
||||
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning,
|
||||
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID,
|
||||
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin,
|
||||
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
|
||||
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
|
||||
&t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt,
|
||||
&t.SystemPromptTemplate, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+55
-135
@@ -3,12 +3,11 @@ package trader
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"math"
|
||||
"nofx/decision"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/pool"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -49,8 +48,6 @@ type AutoTraderConfig struct {
|
||||
LighterAPIKeyPrivateKey string // LIGHTER API Key私钥(40字节,用于签名交易)
|
||||
LighterTestnet bool // 是否使用testnet
|
||||
|
||||
CoinPoolAPIURL string
|
||||
|
||||
// AI配置
|
||||
UseQwen bool
|
||||
DeepSeekKey string
|
||||
@@ -67,10 +64,6 @@ type AutoTraderConfig struct {
|
||||
// 账户配置
|
||||
InitialBalance float64 // 初始金额(用于计算盈亏,需手动设置)
|
||||
|
||||
// 杠杆配置
|
||||
BTCETHLeverage int // BTC和ETH的杠杆倍数
|
||||
AltcoinLeverage int // 山寨币的杠杆倍数
|
||||
|
||||
// 风险控制(仅作为提示,AI可自主决定)
|
||||
MaxDailyLoss float64 // 最大日亏损百分比(提示)
|
||||
MaxDrawdown float64 // 最大回撤百分比(提示)
|
||||
@@ -79,12 +72,8 @@ type AutoTraderConfig struct {
|
||||
// 仓位模式
|
||||
IsCrossMargin bool // true=全仓模式, false=逐仓模式
|
||||
|
||||
// 币种配置
|
||||
DefaultCoins []string // 默认币种列表(从数据库获取)
|
||||
TradingCoins []string // 实际交易币种列表
|
||||
|
||||
// 系统提示词模板
|
||||
SystemPromptTemplate string // 系统提示词模板名称(如 "default", "aggressive")
|
||||
// 策略配置(使用完整策略配置)
|
||||
StrategyConfig *store.StrategyConfig // 策略配置(包含币种来源、指标、风控、Prompt等)
|
||||
}
|
||||
|
||||
// AutoTrader 自动交易器
|
||||
@@ -96,15 +85,13 @@ type AutoTrader struct {
|
||||
config AutoTraderConfig
|
||||
trader Trader // 使用Trader接口(支持多平台)
|
||||
mcpClient mcp.AIClient
|
||||
store *store.Store // 数据存储(决策记录等)
|
||||
cycleNumber int // 当前周期编号
|
||||
store *store.Store // 数据存储(决策记录等)
|
||||
strategyEngine *decision.StrategyEngine // 策略引擎(使用策略配置)
|
||||
cycleNumber int // 当前周期编号
|
||||
initialBalance float64
|
||||
dailyPnL float64
|
||||
customPrompt string // 自定义交易策略prompt
|
||||
overrideBasePrompt bool // 是否覆盖基础prompt
|
||||
systemPromptTemplate string // 系统提示词模板名称
|
||||
defaultCoins []string // 默认币种列表(从数据库获取)
|
||||
tradingCoins []string // 实际交易币种列表
|
||||
customPrompt string // 自定义交易策略prompt
|
||||
overrideBasePrompt bool // 是否覆盖基础prompt
|
||||
lastResetTime time.Time
|
||||
stopUntil time.Time
|
||||
isRunning bool
|
||||
@@ -164,11 +151,6 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化币种池API
|
||||
if config.CoinPoolAPIURL != "" {
|
||||
pool.SetCoinPoolAPI(config.CoinPoolAPIURL)
|
||||
}
|
||||
|
||||
// 设置默认交易平台
|
||||
if config.Exchange == "" {
|
||||
config.Exchange = "binance"
|
||||
@@ -243,12 +225,12 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
logger.Infof("📊 [%s] 决策记录将存储到数据库", config.Name)
|
||||
}
|
||||
|
||||
// 设置默认系统提示词模板
|
||||
systemPromptTemplate := config.SystemPromptTemplate
|
||||
if systemPromptTemplate == "" {
|
||||
// feature/partial-close-dynamic-tpsl 分支默认使用 adaptive(支持动态止盈止损)
|
||||
systemPromptTemplate = "adaptive"
|
||||
// 创建策略引擎(必须有策略配置)
|
||||
if config.StrategyConfig == nil {
|
||||
return nil, fmt.Errorf("[%s] 未配置策略", config.Name)
|
||||
}
|
||||
strategyEngine := decision.NewStrategyEngine(config.StrategyConfig)
|
||||
logger.Infof("✓ [%s] 使用策略引擎(策略配置已加载)", config.Name)
|
||||
|
||||
return &AutoTrader{
|
||||
id: config.ID,
|
||||
@@ -259,11 +241,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
trader: trader,
|
||||
mcpClient: mcpClient,
|
||||
store: st,
|
||||
strategyEngine: strategyEngine,
|
||||
cycleNumber: cycleNumber,
|
||||
initialBalance: config.InitialBalance,
|
||||
systemPromptTemplate: systemPromptTemplate,
|
||||
defaultCoins: config.DefaultCoins,
|
||||
tradingCoins: config.TradingCoins,
|
||||
lastResetTime: time.Now(),
|
||||
startTime: time.Now(),
|
||||
callCount: 0,
|
||||
@@ -400,24 +380,24 @@ func (at *AutoTrader) runCycle() error {
|
||||
logger.Infof("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d",
|
||||
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
|
||||
|
||||
// 5. 调用AI获取完整决策
|
||||
logger.Infof("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate)
|
||||
decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt, at.systemPromptTemplate)
|
||||
// 5. 使用策略引擎调用AI获取决策
|
||||
logger.Infof("🤖 正在请求AI分析并决策... [策略引擎]")
|
||||
aiDecision, err := decision.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
|
||||
|
||||
if decision != nil && decision.AIRequestDurationMs > 0 {
|
||||
record.AIRequestDurationMs = decision.AIRequestDurationMs
|
||||
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
|
||||
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
|
||||
logger.Infof("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000)
|
||||
record.ExecutionLog = append(record.ExecutionLog,
|
||||
fmt.Sprintf("AI调用耗时: %d ms", record.AIRequestDurationMs))
|
||||
}
|
||||
|
||||
// 即使有错误,也保存思维链、决策和输入prompt(用于debug)
|
||||
if decision != nil {
|
||||
record.SystemPrompt = decision.SystemPrompt // 保存系统提示词
|
||||
record.InputPrompt = decision.UserPrompt
|
||||
record.CoTTrace = decision.CoTTrace
|
||||
if len(decision.Decisions) > 0 {
|
||||
decisionJSON, _ := json.MarshalIndent(decision.Decisions, "", " ")
|
||||
if aiDecision != nil {
|
||||
record.SystemPrompt = aiDecision.SystemPrompt // 保存系统提示词
|
||||
record.InputPrompt = aiDecision.UserPrompt
|
||||
record.CoTTrace = aiDecision.CoTTrace
|
||||
if len(aiDecision.Decisions) > 0 {
|
||||
decisionJSON, _ := json.MarshalIndent(aiDecision.Decisions, "", " ")
|
||||
record.DecisionJSON = string(decisionJSON)
|
||||
}
|
||||
}
|
||||
@@ -427,18 +407,18 @@ func (at *AutoTrader) runCycle() error {
|
||||
record.ErrorMessage = fmt.Sprintf("获取AI决策失败: %v", err)
|
||||
|
||||
// 打印系统提示词和AI思维链(即使有错误,也要输出以便调试)
|
||||
if decision != nil {
|
||||
if aiDecision != nil {
|
||||
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
|
||||
logger.Infof("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate)
|
||||
logger.Infof("📋 系统提示词 (错误情况)")
|
||||
logger.Info(strings.Repeat("=", 70))
|
||||
logger.Info(decision.SystemPrompt)
|
||||
logger.Info(aiDecision.SystemPrompt)
|
||||
logger.Info(strings.Repeat("=", 70))
|
||||
|
||||
if decision.CoTTrace != "" {
|
||||
if aiDecision.CoTTrace != "" {
|
||||
logger.Info("\n" + strings.Repeat("-", 70) + "\n")
|
||||
logger.Info("💭 AI思维链分析(错误情况):")
|
||||
logger.Info(strings.Repeat("-", 70))
|
||||
logger.Info(decision.CoTTrace)
|
||||
logger.Info(aiDecision.CoTTrace)
|
||||
logger.Info(strings.Repeat("-", 70))
|
||||
}
|
||||
}
|
||||
@@ -476,7 +456,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
logger.Info(strings.Repeat("-", 70))
|
||||
|
||||
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
|
||||
sortedDecisions := sortDecisionsByPriority(decision.Decisions)
|
||||
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
|
||||
|
||||
logger.Info("🔄 执行顺序(已优化): 先平仓→后开仓")
|
||||
for i, d := range sortedDecisions {
|
||||
@@ -622,11 +602,15 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取交易员的候选币种池
|
||||
candidateCoins, err := at.getCandidateCoins()
|
||||
// 3. 使用策略引擎获取候选币种(必须有策略引擎)
|
||||
if at.strategyEngine == nil {
|
||||
return nil, fmt.Errorf("交易员未配置策略引擎")
|
||||
}
|
||||
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取候选币种失败: %w", err)
|
||||
}
|
||||
logger.Infof("📋 [%s] 策略引擎获取候选币种: %d个", at.name, len(candidateCoins))
|
||||
|
||||
// 4. 计算总盈亏
|
||||
totalPnL := totalEquity - at.initialBalance
|
||||
@@ -640,13 +624,19 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
marginUsedPct = (totalMarginUsed / totalEquity) * 100
|
||||
}
|
||||
|
||||
// 5. 构建上下文
|
||||
// 5. 从策略配置获取杠杆
|
||||
strategyConfig := at.strategyEngine.GetConfig()
|
||||
btcEthLeverage := strategyConfig.RiskControl.BTCETHMaxLeverage
|
||||
altcoinLeverage := strategyConfig.RiskControl.AltcoinMaxLeverage
|
||||
logger.Infof("📋 [%s] 策略杠杆配置: BTC/ETH=%dx, 山寨币=%dx", at.name, btcEthLeverage, altcoinLeverage)
|
||||
|
||||
// 6. 构建上下文
|
||||
ctx := &decision.Context{
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||||
CallCount: at.callCount,
|
||||
BTCETHLeverage: at.config.BTCETHLeverage, // 使用配置的杠杆倍数
|
||||
AltcoinLeverage: at.config.AltcoinLeverage, // 使用配置的杠杆倍数
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
Account: decision.AccountInfo{
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: availableBalance,
|
||||
@@ -661,7 +651,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
CandidateCoins: candidateCoins,
|
||||
}
|
||||
|
||||
// 6. 添加交易统计和历史订单(如果store可用)
|
||||
// 7. 添加交易统计和历史订单(如果store可用)
|
||||
if at.store != nil {
|
||||
// 获取交易统计(使用新的 positions 表)
|
||||
if stats, err := at.store.Position().GetFullStats(at.id); err == nil {
|
||||
@@ -989,14 +979,15 @@ func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
|
||||
at.overrideBasePrompt = override
|
||||
}
|
||||
|
||||
// SetSystemPromptTemplate 设置系统提示词模板
|
||||
func (at *AutoTrader) SetSystemPromptTemplate(templateName string) {
|
||||
at.systemPromptTemplate = templateName
|
||||
}
|
||||
|
||||
// GetSystemPromptTemplate 获取当前系统提示词模板名称
|
||||
// GetSystemPromptTemplate 获取当前系统提示词模板名称(从策略配置获取)
|
||||
func (at *AutoTrader) GetSystemPromptTemplate() string {
|
||||
return at.systemPromptTemplate
|
||||
if at.strategyEngine != nil {
|
||||
config := at.strategyEngine.GetConfig()
|
||||
if config.CustomPrompt != "" {
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
return "strategy"
|
||||
}
|
||||
|
||||
// saveDecision 保存决策记录到数据库
|
||||
@@ -1235,77 +1226,6 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
|
||||
return sorted
|
||||
}
|
||||
|
||||
// getCandidateCoins 获取交易员的候选币种列表
|
||||
func (at *AutoTrader) getCandidateCoins() ([]decision.CandidateCoin, error) {
|
||||
if len(at.tradingCoins) == 0 {
|
||||
// 使用数据库配置的默认币种列表
|
||||
var candidateCoins []decision.CandidateCoin
|
||||
|
||||
if len(at.defaultCoins) > 0 {
|
||||
// 使用数据库中配置的默认币种
|
||||
for _, coin := range at.defaultCoins {
|
||||
symbol := normalizeSymbol(coin)
|
||||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"default"}, // 标记为数据库默认币种
|
||||
})
|
||||
}
|
||||
logger.Infof("📋 [%s] 使用数据库默认币种: %d个币种 %v",
|
||||
at.name, len(candidateCoins), at.defaultCoins)
|
||||
return candidateCoins, nil
|
||||
} else {
|
||||
// 如果数据库中没有配置默认币种,则使用AI500+OI Top作为fallback
|
||||
const ai500Limit = 20 // AI500取前20个评分最高的币种
|
||||
|
||||
mergedPool, err := pool.GetMergedCoinPool(ai500Limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取合并币种池失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建候选币种列表(包含来源信息)
|
||||
for _, symbol := range mergedPool.AllSymbols {
|
||||
sources := mergedPool.SymbolSources[symbol]
|
||||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: sources, // "ai500" 和/或 "oi_top"
|
||||
})
|
||||
}
|
||||
|
||||
logger.Infof("📋 [%s] 数据库无默认币种配置,使用AI500+OI Top: AI500前%d + OI_Top20 = 总计%d个候选币种",
|
||||
at.name, ai500Limit, len(candidateCoins))
|
||||
return candidateCoins, nil
|
||||
}
|
||||
} else {
|
||||
// 使用自定义币种列表
|
||||
var candidateCoins []decision.CandidateCoin
|
||||
for _, coin := range at.tradingCoins {
|
||||
// 确保币种格式正确(转为大写USDT交易对)
|
||||
symbol := normalizeSymbol(coin)
|
||||
candidateCoins = append(candidateCoins, decision.CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"custom"}, // 标记为自定义来源
|
||||
})
|
||||
}
|
||||
|
||||
logger.Infof("📋 [%s] 使用自定义币种: %d个币种 %v",
|
||||
at.name, len(candidateCoins), at.tradingCoins)
|
||||
return candidateCoins, nil
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeSymbol 标准化币种符号(确保以USDT结尾)
|
||||
func normalizeSymbol(symbol string) string {
|
||||
// 转为大写
|
||||
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
||||
|
||||
// 确保以USDT结尾
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol = symbol + "USDT"
|
||||
}
|
||||
|
||||
return symbol
|
||||
}
|
||||
|
||||
// 启动回撤监控
|
||||
func (at *AutoTrader) startDrawdownMonitor() {
|
||||
at.monitorWg.Add(1)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ResetPasswordPage } from './components/ResetPasswordPage'
|
||||
import { CompetitionPage } from './components/CompetitionPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import HeaderBar from './components/HeaderBar'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
@@ -31,6 +32,7 @@ type Page =
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
@@ -62,6 +64,7 @@ function App() {
|
||||
|
||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||
return 'trader'
|
||||
return 'competition' // 默认为竞赛页面
|
||||
@@ -81,6 +84,8 @@ function App() {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/backtest' || hash === 'backtest') {
|
||||
setCurrentPage('backtest')
|
||||
} else if (path === '/strategy' || hash === 'strategy') {
|
||||
setCurrentPage('strategy')
|
||||
} else if (
|
||||
path === '/dashboard' ||
|
||||
hash === 'trader' ||
|
||||
@@ -291,6 +296,11 @@ function App() {
|
||||
window.history.pushState({}, '', '/backtest')
|
||||
setRoute('/backtest')
|
||||
setCurrentPage('backtest')
|
||||
} else if (page === 'strategy') {
|
||||
console.log('Navigating to strategy')
|
||||
window.history.pushState({}, '', '/strategy')
|
||||
setRoute('/strategy')
|
||||
setCurrentPage('strategy')
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -384,6 +394,10 @@ function App() {
|
||||
window.history.pushState({}, '', '/backtest')
|
||||
setRoute('/backtest')
|
||||
setCurrentPage('backtest')
|
||||
} else if (page === 'strategy') {
|
||||
window.history.pushState({}, '', '/strategy')
|
||||
setRoute('/strategy')
|
||||
setCurrentPage('strategy')
|
||||
} else if (page === 'faq') {
|
||||
window.history.pushState({}, '', '/faq')
|
||||
setRoute('/faq')
|
||||
@@ -406,6 +420,8 @@ function App() {
|
||||
/>
|
||||
) : currentPage === 'backtest' ? (
|
||||
<BacktestPage />
|
||||
) : currentPage === 'strategy' ? (
|
||||
<StrategyStudioPage />
|
||||
) : (
|
||||
<TraderDetailsPage
|
||||
selectedTrader={selectedTrader}
|
||||
|
||||
@@ -11,6 +11,7 @@ type Page =
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
@@ -225,6 +226,47 @@ export default function HeaderBar({
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('strategy')
|
||||
}
|
||||
navigate('/strategy')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'strategy'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'strategy') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'strategy') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentPage === 'strategy' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('strategyNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
@@ -780,6 +822,74 @@ export default function HeaderBar({
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('strategy')
|
||||
}
|
||||
navigate('/strategy')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'strategy'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'strategy' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('strategyNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('backtest')
|
||||
}
|
||||
navigate('/backtest')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'backtest'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'backtest' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Backtest
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AIModel, Exchange, CreateTraderRequest } from '../types'
|
||||
import type { AIModel, Exchange, CreateTraderRequest, Strategy } from '../types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { toast } from 'sonner'
|
||||
import { Pencil, Plus, X as IconX } from 'lucide-react'
|
||||
import { Pencil, Plus, X as IconX, Sparkles } from 'lucide-react'
|
||||
import { httpClient } from '../lib/httpClient'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
@@ -12,22 +12,18 @@ function getShortName(fullName: string): string {
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
||||
}
|
||||
|
||||
interface TraderConfigData {
|
||||
import type { TraderConfigData } from '../types'
|
||||
|
||||
// 表单内部状态类型
|
||||
interface FormState {
|
||||
trader_id?: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
btc_eth_leverage: number
|
||||
altcoin_leverage: number
|
||||
trading_symbols: string
|
||||
custom_prompt: string
|
||||
override_base_prompt: boolean
|
||||
system_prompt_template: string
|
||||
strategy_id: string
|
||||
is_cross_margin: boolean
|
||||
use_coin_pool: boolean
|
||||
use_oi_top: boolean
|
||||
initial_balance?: number // 可选:创建时不需要,编辑时使用
|
||||
scan_interval_minutes: number
|
||||
initial_balance?: number
|
||||
}
|
||||
|
||||
interface TraderConfigModalProps {
|
||||
@@ -50,154 +46,68 @@ export function TraderConfigModal({
|
||||
onSave,
|
||||
}: TraderConfigModalProps) {
|
||||
const { language } = useLanguage()
|
||||
const [formData, setFormData] = useState<TraderConfigData>({
|
||||
const [formData, setFormData] = useState<FormState>({
|
||||
trader_name: '',
|
||||
ai_model: '',
|
||||
exchange_id: '',
|
||||
btc_eth_leverage: 5,
|
||||
altcoin_leverage: 3,
|
||||
trading_symbols: '',
|
||||
custom_prompt: '',
|
||||
override_base_prompt: false,
|
||||
system_prompt_template: 'default',
|
||||
strategy_id: '',
|
||||
is_cross_margin: true,
|
||||
use_coin_pool: false,
|
||||
use_oi_top: false,
|
||||
scan_interval_minutes: 3,
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [availableCoins, setAvailableCoins] = useState<string[]>([])
|
||||
const [selectedCoins, setSelectedCoins] = useState<string[]>([])
|
||||
const [showCoinSelector, setShowCoinSelector] = useState(false)
|
||||
const [promptTemplates, setPromptTemplates] = useState<{ name: string }[]>([])
|
||||
const [strategies, setStrategies] = useState<Strategy[]>([])
|
||||
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
|
||||
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
|
||||
|
||||
// 获取用户的策略列表
|
||||
useEffect(() => {
|
||||
const fetchStrategies = async () => {
|
||||
try {
|
||||
const result = await httpClient.get<{ strategies: Strategy[] }>('/api/strategies')
|
||||
if (result.success && result.data?.strategies) {
|
||||
const strategyList = result.data.strategies
|
||||
setStrategies(strategyList)
|
||||
// 如果没有选择策略,默认选中激活的策略
|
||||
if (!formData.strategy_id && !isEditMode) {
|
||||
const activeStrategy = strategyList.find(s => s.is_active)
|
||||
if (activeStrategy) {
|
||||
setFormData(prev => ({ ...prev, strategy_id: activeStrategy.id }))
|
||||
} else if (strategyList.length > 0) {
|
||||
setFormData(prev => ({ ...prev, strategy_id: strategyList[0].id }))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch strategies:', error)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
fetchStrategies()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (traderData) {
|
||||
setFormData(traderData)
|
||||
// 设置已选择的币种
|
||||
if (traderData.trading_symbols) {
|
||||
const coins = traderData.trading_symbols
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s)
|
||||
setSelectedCoins(coins)
|
||||
}
|
||||
setFormData({
|
||||
...traderData,
|
||||
strategy_id: traderData.strategy_id || '',
|
||||
})
|
||||
} else if (!isEditMode) {
|
||||
setFormData({
|
||||
trader_name: '',
|
||||
ai_model: availableModels[0]?.id || '',
|
||||
exchange_id: availableExchanges[0]?.id || '',
|
||||
btc_eth_leverage: 5,
|
||||
altcoin_leverage: 3,
|
||||
trading_symbols: '',
|
||||
custom_prompt: '',
|
||||
override_base_prompt: false,
|
||||
system_prompt_template: 'default',
|
||||
strategy_id: '',
|
||||
is_cross_margin: true,
|
||||
use_coin_pool: false,
|
||||
use_oi_top: false,
|
||||
initial_balance: 1000,
|
||||
scan_interval_minutes: 3,
|
||||
})
|
||||
}
|
||||
// 确保旧数据也有默认的 system_prompt_template
|
||||
if (traderData && traderData.system_prompt_template === undefined) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
system_prompt_template: 'default',
|
||||
}))
|
||||
}
|
||||
}, [traderData, isEditMode, availableModels, availableExchanges])
|
||||
|
||||
// 获取系统配置中的币种列表
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const result = await httpClient.get<{ default_coins?: string[] }>(
|
||||
'/api/config'
|
||||
)
|
||||
if (result.success && result.data?.default_coins) {
|
||||
setAvailableCoins(result.data.default_coins)
|
||||
} else {
|
||||
// 使用默认币种列表
|
||||
setAvailableCoins([
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
'SOLUSDT',
|
||||
'BNBUSDT',
|
||||
'XRPUSDT',
|
||||
'DOGEUSDT',
|
||||
'ADAUSDT',
|
||||
])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
// 使用默认币种列表
|
||||
setAvailableCoins([
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
'SOLUSDT',
|
||||
'BNBUSDT',
|
||||
'XRPUSDT',
|
||||
'DOGEUSDT',
|
||||
'ADAUSDT',
|
||||
])
|
||||
}
|
||||
}
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
// 获取系统提示词模板列表
|
||||
useEffect(() => {
|
||||
const fetchPromptTemplates = async () => {
|
||||
try {
|
||||
const result = await httpClient.get<{ templates?: { name: string }[] }>(
|
||||
'/api/prompt-templates'
|
||||
)
|
||||
if (result.success && result.data?.templates) {
|
||||
setPromptTemplates(result.data.templates)
|
||||
} else {
|
||||
// 使用默认模板列表
|
||||
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch prompt templates:', error)
|
||||
// 使用默认模板列表
|
||||
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
|
||||
}
|
||||
}
|
||||
fetchPromptTemplates()
|
||||
}, [])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
|
||||
const handleInputChange = (field: keyof FormState, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
|
||||
// 如果是直接编辑trading_symbols,同步更新selectedCoins
|
||||
if (field === 'trading_symbols') {
|
||||
const coins = value
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s)
|
||||
setSelectedCoins(coins)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoinToggle = (coin: string) => {
|
||||
setSelectedCoins((prev) => {
|
||||
const newCoins = prev.includes(coin)
|
||||
? prev.filter((c) => c !== coin)
|
||||
: [...prev, coin]
|
||||
|
||||
// 同时更新 formData.trading_symbols
|
||||
const symbolsString = newCoins.join(',')
|
||||
setFormData((current) => ({ ...current, trading_symbols: symbolsString }))
|
||||
|
||||
return newCoins
|
||||
})
|
||||
}
|
||||
|
||||
const handleFetchCurrentBalance = async () => {
|
||||
@@ -216,11 +126,8 @@ export function TraderConfigModal({
|
||||
}>(`/api/account?trader_id=${traderData.trader_id}`)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// total_equity = 当前账户净值(包含未实现盈亏)
|
||||
// 这应该作为新的初始余额
|
||||
const currentBalance =
|
||||
result.data.total_equity || result.data.balance || 0
|
||||
|
||||
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
||||
toast.success('已获取当前余额')
|
||||
} else {
|
||||
@@ -229,7 +136,6 @@ export function TraderConfigModal({
|
||||
} catch (error) {
|
||||
console.error('获取余额失败:', error)
|
||||
setBalanceFetchError('获取余额失败,请检查网络连接')
|
||||
// Note: Network/system errors already shown via toast by httpClient
|
||||
} finally {
|
||||
setIsFetchingBalance(false)
|
||||
}
|
||||
@@ -244,19 +150,12 @@ export function TraderConfigModal({
|
||||
name: formData.trader_name,
|
||||
ai_model_id: formData.ai_model,
|
||||
exchange_id: formData.exchange_id,
|
||||
btc_eth_leverage: formData.btc_eth_leverage,
|
||||
altcoin_leverage: formData.altcoin_leverage,
|
||||
trading_symbols: formData.trading_symbols,
|
||||
custom_prompt: formData.custom_prompt,
|
||||
override_base_prompt: formData.override_base_prompt,
|
||||
system_prompt_template: formData.system_prompt_template,
|
||||
strategy_id: formData.strategy_id || undefined,
|
||||
is_cross_margin: formData.is_cross_margin,
|
||||
use_coin_pool: formData.use_coin_pool,
|
||||
use_oi_top: formData.use_oi_top,
|
||||
scan_interval_minutes: formData.scan_interval_minutes,
|
||||
}
|
||||
|
||||
// 只在编辑模式时包含initial_balance(用于手动更新)
|
||||
// 只在编辑模式时包含initial_balance
|
||||
if (isEditMode && formData.initial_balance !== undefined) {
|
||||
saveData.initial_balance = formData.initial_balance
|
||||
}
|
||||
@@ -274,10 +173,12 @@ export function TraderConfigModal({
|
||||
}
|
||||
}
|
||||
|
||||
const selectedStrategy = strategies.find(s => s.id === formData.strategy_id)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-y-auto">
|
||||
<div
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full my-8"
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full my-8"
|
||||
style={{ maxHeight: 'calc(100vh - 4rem)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -296,7 +197,7 @@ export function TraderConfigModal({
|
||||
{isEditMode ? '修改交易员' : '创建交易员'}
|
||||
</h2>
|
||||
<p className="text-sm text-[#848E9C] mt-1">
|
||||
{isEditMode ? '修改交易员配置参数' : '配置新的AI交易员'}
|
||||
{isEditMode ? '修改交易员配置' : '选择策略并配置基础参数'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,18 +211,18 @@ export function TraderConfigModal({
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="p-6 space-y-8 overflow-y-auto"
|
||||
className="p-6 space-y-6 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
{/* Basic Info */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
🤖 基础配置
|
||||
<span className="text-[#F0B90B]">1</span> 基础配置
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
交易员名称
|
||||
交易员名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -336,7 +237,7 @@ export function TraderConfigModal({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
AI模型
|
||||
AI模型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.ai_model}
|
||||
@@ -354,7 +255,7 @@ export function TraderConfigModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
交易所
|
||||
交易所 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.exchange_id}
|
||||
@@ -376,13 +277,77 @@ export function TraderConfigModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Configuration */}
|
||||
{/* Strategy Selection */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
⚖️ 交易配置
|
||||
<span className="text-[#F0B90B]">2</span> 选择交易策略
|
||||
<Sparkles className="w-4 h-4 text-[#F0B90B]" />
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
使用策略
|
||||
</label>
|
||||
<select
|
||||
value={formData.strategy_id}
|
||||
onChange={(e) =>
|
||||
handleInputChange('strategy_id', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
<option value="">-- 不使用策略(手动配置)--</option>
|
||||
{strategies.map((strategy) => (
|
||||
<option key={strategy.id} value={strategy.id}>
|
||||
{strategy.name}
|
||||
{strategy.is_active ? ' (当前激活)' : ''}
|
||||
{strategy.is_default ? ' [默认]' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{strategies.length === 0 && (
|
||||
<p className="text-xs text-[#848E9C] mt-2">
|
||||
暂无策略,请先在策略工作室创建策略
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Strategy Preview */}
|
||||
{selectedStrategy && (
|
||||
<div className="mt-3 p-4 bg-[#1E2329] border border-[#2B3139] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[#F0B90B] text-sm font-medium">
|
||||
策略详情
|
||||
</span>
|
||||
{selectedStrategy.is_active && (
|
||||
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
|
||||
激活中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[#848E9C] mb-2">
|
||||
{selectedStrategy.description || '无描述'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
|
||||
<div>
|
||||
币种来源: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
|
||||
selectedStrategy.config.coin_source.source_type === 'coinpool' ? 'Coin Pool' :
|
||||
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
||||
</div>
|
||||
<div>
|
||||
风控等级: {((selectedStrategy.config.risk_control?.max_position_ratio || 0.3) * 100).toFixed(0)}% 仓位
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Parameters */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
<span className="text-[#F0B90B]">3</span> 交易参数
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 第一行:保证金模式和初始余额 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
@@ -415,81 +380,6 @@ export function TraderConfigModal({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
初始余额 ($)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchCurrentBalance}
|
||||
disabled={isFetchingBalance}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
|
||||
>
|
||||
{isFetchingBalance ? '获取中...' : '获取当前余额'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.initial_balance || 0}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'initial_balance',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
onBlur={(e) => {
|
||||
// Force minimum value on blur
|
||||
const value = Number(e.target.value)
|
||||
if (value < 100) {
|
||||
handleInputChange('initial_balance', 100)
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="100"
|
||||
step="0.01"
|
||||
/>
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
用于手动更新初始余额基准(例如充值/提现后)
|
||||
</p>
|
||||
{balanceFetchError && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{balanceFetchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEditMode && (
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] mb-2 block">
|
||||
初始余额
|
||||
</label>
|
||||
<div className="w-full px-3 py-2 bg-[#1E2329] border border-[#2B3139] rounded text-[#848E9C] flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4 text-[#F0B90B]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span className="text-sm">
|
||||
系统将自动获取您的账户净值作为初始余额
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 第二行:AI 扫描决策间隔 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('aiScanInterval', language)}
|
||||
@@ -513,242 +403,54 @@ export function TraderConfigModal({
|
||||
{t('scanIntervalRecommend', language)}
|
||||
</p>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{/* 第三行:杠杆设置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Initial Balance (Edit mode only) */}
|
||||
{isEditMode && (
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
BTC/ETH 杠杆
|
||||
</label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
初始余额 ($)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchCurrentBalance}
|
||||
disabled={isFetchingBalance}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
|
||||
>
|
||||
{isFetchingBalance ? '获取中...' : '获取当前余额'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.btc_eth_leverage}
|
||||
value={formData.initial_balance || 0}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'btc_eth_leverage',
|
||||
'initial_balance',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
max="125"
|
||||
min="100"
|
||||
step="0.01"
|
||||
/>
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
用于手动更新初始余额基准(例如充值/提现后)
|
||||
</p>
|
||||
{balanceFetchError && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{balanceFetchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
山寨币杠杆
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.altcoin_leverage}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'altcoin_leverage',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
max="75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第三行:交易币种 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
交易币种 (用逗号分隔,留空使用默认)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCoinSelector(!showCoinSelector)}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors"
|
||||
>
|
||||
{showCoinSelector ? '收起选择' : '快速选择'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.trading_symbols}
|
||||
onChange={(e) =>
|
||||
handleInputChange('trading_symbols', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT"
|
||||
/>
|
||||
|
||||
{/* 币种选择器 */}
|
||||
{showCoinSelector && (
|
||||
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded">
|
||||
<div className="text-xs text-[#848E9C] mb-2">
|
||||
点击选择币种:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableCoins.map((coin) => (
|
||||
<button
|
||||
key={coin}
|
||||
type="button"
|
||||
onClick={() => handleCoinToggle(coin)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedCoins.includes(coin)
|
||||
? 'bg-[#F0B90B] text-black'
|
||||
: 'bg-[#1E2329] text-[#848E9C] border border-[#2B3139] hover:border-[#F0B90B]'
|
||||
}`}
|
||||
>
|
||||
{coin.replace('USDT', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal Sources */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
📡 信号源配置
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_coin_pool}
|
||||
onChange={(e) =>
|
||||
handleInputChange('use_coin_pool', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
使用 Coin Pool 信号
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_oi_top}
|
||||
onChange={(e) =>
|
||||
handleInputChange('use_oi_top', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
使用 OI Top 信号
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Prompt */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
💬 交易策略提示词
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 系统提示词模板选择 */}
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('systemPromptTemplate', language)}
|
||||
</label>
|
||||
<select
|
||||
value={formData.system_prompt_template}
|
||||
onChange={(e) =>
|
||||
handleInputChange('system_prompt_template', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{promptTemplates.map((template) => {
|
||||
// Template name mapping with i18n
|
||||
const getTemplateName = (name: string) => {
|
||||
const keyMap: Record<string, string> = {
|
||||
default: 'promptTemplateDefault',
|
||||
adaptive: 'promptTemplateAdaptive',
|
||||
adaptive_relaxed: 'promptTemplateAdaptiveRelaxed',
|
||||
Hansen: 'promptTemplateHansen',
|
||||
nof1: 'promptTemplateNof1',
|
||||
taro_long_prompts: 'promptTemplateTaroLong',
|
||||
}
|
||||
const key = keyMap[name]
|
||||
return key
|
||||
? t(key, language)
|
||||
: name.charAt(0).toUpperCase() + name.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<option key={template.name} value={template.name}>
|
||||
{getTemplateName(template.name)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* 動態描述區域 */}
|
||||
<div
|
||||
className="mt-2 p-3 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.05)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.15)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-xs font-semibold mb-1"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{(() => {
|
||||
const titleKeyMap: Record<string, string> = {
|
||||
default: 'promptDescDefault',
|
||||
adaptive: 'promptDescAdaptive',
|
||||
adaptive_relaxed: 'promptDescAdaptiveRelaxed',
|
||||
Hansen: 'promptDescHansen',
|
||||
nof1: 'promptDescNof1',
|
||||
taro_long_prompts: 'promptDescTaroLong',
|
||||
}
|
||||
const key = titleKeyMap[formData.system_prompt_template]
|
||||
return key
|
||||
? t(key, language)
|
||||
: t('promptDescDefault', language)
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{(() => {
|
||||
const contentKeyMap: Record<string, string> = {
|
||||
default: 'promptDescDefaultContent',
|
||||
adaptive: 'promptDescAdaptiveContent',
|
||||
adaptive_relaxed: 'promptDescAdaptiveRelaxedContent',
|
||||
Hansen: 'promptDescHansenContent',
|
||||
nof1: 'promptDescNof1Content',
|
||||
taro_long_prompts: 'promptDescTaroLongContent',
|
||||
}
|
||||
const key = contentKeyMap[formData.system_prompt_template]
|
||||
return key
|
||||
? t(key, language)
|
||||
: t('promptDescDefaultContent', language)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
选择预设的交易策略模板(包含交易哲学、风控原则等)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.override_base_prompt}
|
||||
onChange={(e) =>
|
||||
handleInputChange('override_base_prompt', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">覆盖默认提示词</label>
|
||||
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1">
|
||||
{/* Create mode info */}
|
||||
{!isEditMode && (
|
||||
<div className="p-3 bg-[#1E2329] border border-[#2B3139] rounded flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-3.5 h-3.5"
|
||||
className="w-4 h-4 text-[#F0B90B]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -756,34 +458,18 @@ export function TraderConfigModal({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
|
||||
<line x1="12" x2="12" y1="9" y2="13" />
|
||||
<line x1="12" x2="12.01" y1="17" y2="17" />
|
||||
</svg>{' '}
|
||||
启用后将完全替换默认策略
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{formData.override_base_prompt
|
||||
? '自定义提示词'
|
||||
: '附加提示词'}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.custom_prompt}
|
||||
onChange={(e) =>
|
||||
handleInputChange('custom_prompt', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none h-24 resize-none"
|
||||
placeholder={
|
||||
formData.override_base_prompt
|
||||
? '输入完整的交易策略提示词...'
|
||||
: '输入额外的交易策略提示...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span className="text-sm text-[#848E9C]">
|
||||
系统将自动获取您的账户净值作为初始余额
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X, Database, TrendingUp, List, Link, AlertCircle } from 'lucide-react'
|
||||
import type { CoinSourceConfig } from '../../types'
|
||||
|
||||
interface CoinSourceEditorProps {
|
||||
config: CoinSourceConfig
|
||||
onChange: (config: CoinSourceConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
export function CoinSourceEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: CoinSourceEditorProps) {
|
||||
const [newCoin, setNewCoin] = useState('')
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
||||
static: { zh: '静态列表', en: 'Static List' },
|
||||
coinpool: { zh: 'AI500 币种池', en: 'AI500 Coin Pool' },
|
||||
oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
|
||||
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
||||
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
||||
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
||||
useCoinPool: { zh: '启用 AI500 币种池', en: 'Enable AI500 Coin Pool' },
|
||||
coinPoolLimit: { zh: '币种池数量上限', en: 'Coin Pool Limit' },
|
||||
coinPoolApiUrl: { zh: 'AI500 API URL', en: 'AI500 API URL' },
|
||||
coinPoolApiUrlPlaceholder: { zh: '输入 AI500 币种池 API 地址...', en: 'Enter AI500 coin pool API URL...' },
|
||||
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
||||
oiTopLimit: { zh: 'OI Top 数量上限', en: 'OI Top Limit' },
|
||||
oiTopApiUrl: { zh: 'OI Top API URL', en: 'OI Top API URL' },
|
||||
oiTopApiUrlPlaceholder: { zh: '输入 OI Top 持仓数据 API 地址...', en: 'Enter OI Top API URL...' },
|
||||
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
||||
coinpoolDesc: {
|
||||
zh: '使用 AI500 智能筛选的热门币种',
|
||||
en: 'Use AI500 smart-filtered popular coins',
|
||||
},
|
||||
oiTopDesc: {
|
||||
zh: '使用持仓量增长最快的币种',
|
||||
en: 'Use coins with fastest OI growth',
|
||||
},
|
||||
mixedDesc: {
|
||||
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
||||
en: 'Combine multiple sources: AI500 + OI Top + Custom',
|
||||
},
|
||||
apiUrlRequired: { zh: '需要填写 API URL 才能获取数据', en: 'API URL required to fetch data' },
|
||||
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const sourceTypes = [
|
||||
{ value: 'static', icon: List, color: '#848E9C' },
|
||||
{ value: 'coinpool', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||
] as const
|
||||
|
||||
const handleAddCoin = () => {
|
||||
if (!newCoin.trim()) return
|
||||
const symbol = newCoin.toUpperCase().trim()
|
||||
const formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||
const currentCoins = config.static_coins || []
|
||||
if (!currentCoins.includes(formattedSymbol)) {
|
||||
onChange({
|
||||
...config,
|
||||
static_coins: [...currentCoins, formattedSymbol],
|
||||
})
|
||||
}
|
||||
setNewCoin('')
|
||||
}
|
||||
|
||||
const handleRemoveCoin = (coin: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
static_coins: (config.static_coins || []).filter((c) => c !== coin),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Source Type Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('sourceType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
!disabled &&
|
||||
onChange({ ...config, source_type: value as CoinSourceConfig['source_type'] })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`p-4 rounded-lg border transition-all ${
|
||||
config.source_type === value
|
||||
? 'ring-2 ring-yellow-500'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
config.source_type === value
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: '#0B0E11',
|
||||
borderColor: '#2B3139',
|
||||
}}
|
||||
>
|
||||
<Icon className="w-6 h-6 mx-auto mb-2" style={{ color }} />
|
||||
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t(value)}
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t(`${value}Desc`)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Static Coins */}
|
||||
{(config.source_type === 'static' || config.source_type === 'mixed') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('staticCoins')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(config.static_coins || []).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm"
|
||||
style={{ background: '#2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => handleRemoveCoin(coin)}
|
||||
className="ml-1 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCoin}
|
||||
onChange={(e) => setNewCoin(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddCoin()}
|
||||
placeholder="BTC, ETH, SOL..."
|
||||
className="flex-1 px-4 py-2 rounded-lg"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddCoin}
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('addCoin')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coin Pool Options */}
|
||||
{(config.source_type === 'coinpool' || config.source_type === 'mixed') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('dataSourceConfig')} - AI500
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_coin_pool}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_coin_pool: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-yellow-500"
|
||||
/>
|
||||
<span style={{ color: '#EAECEF' }}>{t('useCoinPool')}</span>
|
||||
</label>
|
||||
{config.use_coin_pool && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolLimit')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.coin_pool_limit || 30}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 30 })
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-20 px-3 py-1.5 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_coin_pool && (
|
||||
<div>
|
||||
<label className="block text-sm mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolApiUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={config.coin_pool_api_url || ''}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, coin_pool_api_url: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={t('coinPoolApiUrlPlaceholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
{!config.coin_pool_api_url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{t('apiUrlRequired')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Top Options */}
|
||||
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('dataSourceConfig')} - OI Top
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_top}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-yellow-500"
|
||||
/>
|
||||
<span style={{ color: '#EAECEF' }}>{t('useOITop')}</span>
|
||||
</label>
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopLimit')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.oi_top_limit || 20}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={50}
|
||||
className="w-20 px-3 py-1.5 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_oi_top && (
|
||||
<div>
|
||||
<label className="block text-sm mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopApiUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={config.oi_top_api_url || ''}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, oi_top_api_url: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={t('oiTopApiUrlPlaceholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
{!config.oi_top_api_url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{t('apiUrlRequired')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Clock, Activity } from 'lucide-react'
|
||||
import type { IndicatorConfig } from '../../types'
|
||||
|
||||
interface IndicatorEditorProps {
|
||||
config: IndicatorConfig
|
||||
onChange: (config: IndicatorConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
// 所有可用时间周期
|
||||
const allTimeframes = [
|
||||
{ value: '1m', label: '1m', category: 'scalp' },
|
||||
{ value: '3m', label: '3m', category: 'scalp' },
|
||||
{ value: '5m', label: '5m', category: 'scalp' },
|
||||
{ value: '15m', label: '15m', category: 'intraday' },
|
||||
{ value: '30m', label: '30m', category: 'intraday' },
|
||||
{ value: '1h', label: '1h', category: 'intraday' },
|
||||
{ value: '2h', label: '2h', category: 'swing' },
|
||||
{ value: '4h', label: '4h', category: 'swing' },
|
||||
{ value: '6h', label: '6h', category: 'swing' },
|
||||
{ value: '8h', label: '8h', category: 'swing' },
|
||||
{ value: '12h', label: '12h', category: 'swing' },
|
||||
{ value: '1d', label: '1D', category: 'position' },
|
||||
{ value: '3d', label: '3D', category: 'position' },
|
||||
{ value: '1w', label: '1W', category: 'position' },
|
||||
]
|
||||
|
||||
export function IndicatorEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: IndicatorEditorProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
timeframes: { zh: '时间周期', en: 'Timeframes' },
|
||||
timeframesDesc: { zh: '选择要分析的K线周期(可多选)', en: 'Select K-line timeframes to analyze (multi-select)' },
|
||||
primaryTimeframe: { zh: '主周期', en: 'Primary' },
|
||||
klineCount: { zh: 'K线数量', en: 'K-line Count' },
|
||||
technicalIndicators: { zh: '技术指标', en: 'Technical Indicators' },
|
||||
ema: { zh: 'EMA 均线', en: 'EMA' },
|
||||
macd: { zh: 'MACD', en: 'MACD' },
|
||||
rsi: { zh: 'RSI', en: 'RSI' },
|
||||
atr: { zh: 'ATR', en: 'ATR' },
|
||||
volume: { zh: '成交量', en: 'Volume' },
|
||||
oi: { zh: '持仓量', en: 'OI' },
|
||||
fundingRate: { zh: '资金费率', en: 'Funding' },
|
||||
periods: { zh: '周期', en: 'Periods' },
|
||||
scalp: { zh: '剥头皮', en: 'Scalp' },
|
||||
intraday: { zh: '日内', en: 'Intraday' },
|
||||
swing: { zh: '波段', en: 'Swing' },
|
||||
position: { zh: '趋势', en: 'Position' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
// 获取当前选中的时间周期
|
||||
const selectedTimeframes = config.klines.selected_timeframes || [config.klines.primary_timeframe]
|
||||
|
||||
// 切换时间周期选择
|
||||
const toggleTimeframe = (tf: string) => {
|
||||
if (disabled) return
|
||||
const current = [...selectedTimeframes]
|
||||
const index = current.indexOf(tf)
|
||||
|
||||
if (index >= 0) {
|
||||
// 如果已选中,取消选择(但保留至少一个)
|
||||
if (current.length > 1) {
|
||||
current.splice(index, 1)
|
||||
// 如果取消的是主周期,则选第一个为主周期
|
||||
const newPrimary = tf === config.klines.primary_timeframe ? current[0] : config.klines.primary_timeframe
|
||||
onChange({
|
||||
...config,
|
||||
klines: {
|
||||
...config.klines,
|
||||
selected_timeframes: current,
|
||||
primary_timeframe: newPrimary,
|
||||
enable_multi_timeframe: current.length > 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 添加新的时间周期
|
||||
current.push(tf)
|
||||
onChange({
|
||||
...config,
|
||||
klines: {
|
||||
...config.klines,
|
||||
selected_timeframes: current,
|
||||
enable_multi_timeframe: current.length > 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 设置主时间周期
|
||||
const setPrimaryTimeframe = (tf: string) => {
|
||||
if (disabled) return
|
||||
onChange({
|
||||
...config,
|
||||
klines: {
|
||||
...config.klines,
|
||||
primary_timeframe: tf,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const indicators = [
|
||||
{ key: 'enable_ema', label: 'ema', color: '#F0B90B', periodKey: 'ema_periods' },
|
||||
{ key: 'enable_macd', label: 'macd', color: '#0ECB81' },
|
||||
{ key: 'enable_rsi', label: 'rsi', color: '#F6465D', periodKey: 'rsi_periods' },
|
||||
{ key: 'enable_atr', label: 'atr', color: '#60a5fa', periodKey: 'atr_periods' },
|
||||
{ key: 'enable_volume', label: 'volume', color: '#c084fc' },
|
||||
{ key: 'enable_oi', label: 'oi', color: '#34d399' },
|
||||
{ key: 'enable_funding_rate', label: 'fundingRate', color: '#fbbf24' },
|
||||
]
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
scalp: '#F6465D',
|
||||
intraday: '#F0B90B',
|
||||
swing: '#0ECB81',
|
||||
position: '#60a5fa',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Timeframe Selection */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('timeframes')}</span>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: '#848E9C' }}>{t('timeframesDesc')}</p>
|
||||
|
||||
{/* Timeframe Grid by Category */}
|
||||
<div className="space-y-2">
|
||||
{(['scalp', 'intraday', 'swing', 'position'] as const).map((category) => {
|
||||
const categoryTfs = allTimeframes.filter((tf) => tf.category === category)
|
||||
return (
|
||||
<div key={category} className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] w-14 flex-shrink-0"
|
||||
style={{ color: categoryColors[category] }}
|
||||
>
|
||||
{t(category)}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoryTfs.map((tf) => {
|
||||
const isSelected = selectedTimeframes.includes(tf.value)
|
||||
const isPrimary = config.klines.primary_timeframe === tf.value
|
||||
return (
|
||||
<div key={tf.value} className="relative">
|
||||
<button
|
||||
onClick={() => toggleTimeframe(tf.value)}
|
||||
onDoubleClick={() => setPrimaryTimeframe(tf.value)}
|
||||
disabled={disabled}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${
|
||||
isSelected ? 'ring-1' : 'opacity-50 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
background: isSelected ? `${categoryColors[category]}20` : '#0B0E11',
|
||||
border: `1px solid ${isSelected ? categoryColors[category] : '#2B3139'}`,
|
||||
color: isSelected ? categoryColors[category] : '#848E9C',
|
||||
boxShadow: isPrimary ? `0 0 0 2px ${categoryColors[category]}` : undefined,
|
||||
}}
|
||||
title={isPrimary ? `${tf.label} (${t('primaryTimeframe')})` : tf.label}
|
||||
>
|
||||
{tf.label}
|
||||
{isPrimary && (
|
||||
<span className="ml-1 text-[8px]">★</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] mt-2" style={{ color: '#5E6673' }}>
|
||||
{language === 'zh' ? '★ = 主周期 (双击设置)' : '★ = Primary (double-click to set)'}
|
||||
</p>
|
||||
|
||||
{/* K-line Count */}
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>{t('klineCount')}:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.klines.primary_count}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({
|
||||
...config,
|
||||
klines: { ...config.klines, primary_count: parseInt(e.target.value) || 30 },
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={200}
|
||||
className="w-20 px-2 py-1 rounded text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Indicators */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('technicalIndicators')}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{indicators.map(({ key, label, color, periodKey }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-2 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: color }} />
|
||||
<span className="text-xs" style={{ color: '#EAECEF' }}>{t(label)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{periodKey && config[key as keyof IndicatorConfig] && (
|
||||
<input
|
||||
type="text"
|
||||
value={(config[periodKey as keyof IndicatorConfig] as number[])?.join(',') || ''}
|
||||
onChange={(e) => {
|
||||
if (disabled) return
|
||||
const periods = e.target.value
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim()))
|
||||
.filter((n) => !isNaN(n) && n > 0)
|
||||
onChange({ ...config, [periodKey]: periods })
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder="7,14"
|
||||
className="w-16 px-1.5 py-0.5 rounded text-[10px] text-center"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config[key as keyof IndicatorConfig] as boolean}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, [key]: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-yellow-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, RotateCcw, FileText } from 'lucide-react'
|
||||
import type { PromptSectionsConfig } from '../../types'
|
||||
|
||||
interface PromptSectionsEditorProps {
|
||||
config: PromptSectionsConfig | undefined
|
||||
onChange: (config: PromptSectionsConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
// Default prompt sections (same as backend defaults)
|
||||
const defaultSections: PromptSectionsConfig = {
|
||||
role_definition: `# 你是专业的加密货币交易AI
|
||||
|
||||
你专注于技术分析和风险管理,基于市场数据做出理性的交易决策。
|
||||
你的目标是在控制风险的前提下,捕捉高概率的交易机会。`,
|
||||
|
||||
trading_frequency: `# ⏱️ 交易频率认知
|
||||
|
||||
- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔
|
||||
- 每小时>2笔 = 过度交易
|
||||
- 单笔持仓时间≥30-60分钟
|
||||
如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。`,
|
||||
|
||||
entry_standards: `# 🎯 开仓标准(严格)
|
||||
|
||||
只在多重信号共振时开仓:
|
||||
- 趋势方向明确(EMA排列、价格位置)
|
||||
- 动量确认(MACD、RSI协同)
|
||||
- 波动率适中(ATR合理范围)
|
||||
- 量价配合(成交量支持方向)
|
||||
|
||||
避免:单一指标、信号矛盾、横盘震荡、刚平仓即重启。`,
|
||||
|
||||
decision_process: `# 📋 决策流程
|
||||
|
||||
1. 检查持仓 → 是否该止盈/止损
|
||||
2. 扫描候选币 + 多时间框 → 是否存在强信号
|
||||
3. 评估风险回报比 → 是否满足最小要求
|
||||
4. 先写思维链,再输出结构化JSON`,
|
||||
}
|
||||
|
||||
export function PromptSectionsEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: PromptSectionsEditorProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
role_definition: false,
|
||||
trading_frequency: false,
|
||||
entry_standards: false,
|
||||
decision_process: false,
|
||||
})
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
promptSections: { zh: 'System Prompt 自定义', en: 'System Prompt Customization' },
|
||||
promptSectionsDesc: { zh: '自定义 AI 行为和决策逻辑(输出格式和风控规则不可修改)', en: 'Customize AI behavior and decision logic (output format and risk rules are fixed)' },
|
||||
roleDefinition: { zh: '角色定义', en: 'Role Definition' },
|
||||
roleDefinitionDesc: { zh: '定义 AI 的身份和核心目标', en: 'Define AI identity and core objectives' },
|
||||
tradingFrequency: { zh: '交易频率', en: 'Trading Frequency' },
|
||||
tradingFrequencyDesc: { zh: '设定交易频率预期和过度交易警告', en: 'Set trading frequency expectations and overtrading warnings' },
|
||||
entryStandards: { zh: '开仓标准', en: 'Entry Standards' },
|
||||
entryStandardsDesc: { zh: '定义开仓信号条件和避免事项', en: 'Define entry signal conditions and avoidances' },
|
||||
decisionProcess: { zh: '决策流程', en: 'Decision Process' },
|
||||
decisionProcessDesc: { zh: '设定决策步骤和思考流程', en: 'Set decision steps and thinking process' },
|
||||
resetToDefault: { zh: '重置为默认', en: 'Reset to Default' },
|
||||
chars: { zh: '字符', en: 'chars' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ key: 'role_definition', label: t('roleDefinition'), desc: t('roleDefinitionDesc') },
|
||||
{ key: 'trading_frequency', label: t('tradingFrequency'), desc: t('tradingFrequencyDesc') },
|
||||
{ key: 'entry_standards', label: t('entryStandards'), desc: t('entryStandardsDesc') },
|
||||
{ key: 'decision_process', label: t('decisionProcess'), desc: t('decisionProcessDesc') },
|
||||
]
|
||||
|
||||
const currentConfig = config || {}
|
||||
|
||||
const updateSection = (key: keyof PromptSectionsConfig, value: string) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...currentConfig, [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
const resetSection = (key: keyof PromptSectionsConfig) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...currentConfig, [key]: defaultSections[key] })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const getValue = (key: keyof PromptSectionsConfig): string => {
|
||||
return currentConfig[key] || defaultSections[key] || ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 mt-0.5" style={{ color: '#a855f7' }} />
|
||||
<div>
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('promptSections')}
|
||||
</h3>
|
||||
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('promptSectionsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sections.map(({ key, label, desc }) => {
|
||||
const sectionKey = key as keyof PromptSectionsConfig
|
||||
const isExpanded = expandedSections[key]
|
||||
const value = getValue(sectionKey)
|
||||
const isModified = currentConfig[sectionKey] !== undefined && currentConfig[sectionKey] !== defaultSections[sectionKey]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{label}
|
||||
</span>
|
||||
{isModified && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded"
|
||||
style={{ background: 'rgba(168, 85, 247, 0.15)', color: '#a855f7' }}
|
||||
>
|
||||
{language === 'zh' ? '已修改' : 'Modified'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{value.length} {t('chars')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{desc}
|
||||
</p>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => updateSection(sectionKey, e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 rounded-lg resize-y font-mono text-xs"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
minHeight: '120px',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => resetSection(sectionKey)}
|
||||
disabled={disabled || !isModified}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors hover:bg-white/5 disabled:opacity-30"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
{t('resetToDefault')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Shield, AlertTriangle } from 'lucide-react'
|
||||
import type { RiskControlConfig } from '../../types'
|
||||
|
||||
interface RiskControlEditorProps {
|
||||
config: RiskControlConfig
|
||||
onChange: (config: RiskControlConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
export function RiskControlEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: RiskControlEditorProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
positionLimits: { zh: '仓位限制', en: 'Position Limits' },
|
||||
maxPositions: { zh: '最大持仓数量', en: 'Max Positions' },
|
||||
maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously' },
|
||||
btcEthLeverage: { zh: 'BTC/ETH 最大杠杆', en: 'BTC/ETH Max Leverage' },
|
||||
altcoinLeverage: { zh: '山寨币最大杠杆', en: 'Altcoin Max Leverage' },
|
||||
riskParameters: { zh: '风险参数', en: 'Risk Parameters' },
|
||||
minRiskReward: { zh: '最小风险回报比', en: 'Min Risk/Reward Ratio' },
|
||||
minRiskRewardDesc: { zh: '开仓要求的最低盈亏比', en: 'Minimum profit ratio for opening' },
|
||||
maxMarginUsage: { zh: '最大保证金使用率', en: 'Max Margin Usage' },
|
||||
maxMarginUsageDesc: { zh: '保证金使用率上限', en: 'Maximum margin utilization' },
|
||||
maxPositionRatio: { zh: '单币最大仓位比', en: 'Max Position Ratio' },
|
||||
maxPositionRatioDesc: { zh: '相对账户净值的倍数', en: 'Multiple of account equity' },
|
||||
entryRequirements: { zh: '开仓要求', en: 'Entry Requirements' },
|
||||
minPositionSize: { zh: '最小开仓金额', en: 'Min Position Size' },
|
||||
minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT' },
|
||||
minConfidence: { zh: '最小信心度', en: 'Min Confidence' },
|
||||
minConfidenceDesc: { zh: 'AI 开仓信心度阈值', en: 'AI confidence threshold for entry' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof RiskControlConfig>(
|
||||
key: K,
|
||||
value: RiskControlConfig[K]
|
||||
) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Position Limits */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('positionLimits')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxPositions')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxPositionsDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_positions}
|
||||
onChange={(e) =>
|
||||
updateField('max_positions', parseInt(e.target.value) || 3)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('btcEthLeverage')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.btc_eth_max_leverage}
|
||||
onChange={(e) =>
|
||||
updateField('btc_eth_max_leverage', parseInt(e.target.value))
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={20}
|
||||
className="flex-1 accent-yellow-500"
|
||||
/>
|
||||
<span
|
||||
className="w-12 text-center font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{config.btc_eth_max_leverage}x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('altcoinLeverage')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.altcoin_max_leverage}
|
||||
onChange={(e) =>
|
||||
updateField('altcoin_max_leverage', parseInt(e.target.value))
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={20}
|
||||
className="flex-1 accent-yellow-500"
|
||||
/>
|
||||
<span
|
||||
className="w-12 text-center font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{config.altcoin_max_leverage}x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Parameters */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5" style={{ color: '#F6465D' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('riskParameters')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('minRiskReward')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('minRiskRewardDesc')}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<span style={{ color: '#848E9C' }}>1:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.min_risk_reward_ratio}
|
||||
onChange={(e) =>
|
||||
updateField('min_risk_reward_ratio', parseFloat(e.target.value) || 3)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
className="w-20 px-3 py-2 rounded ml-2"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxMarginUsage')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxMarginUsageDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.max_margin_usage * 100}
|
||||
onChange={(e) =>
|
||||
updateField('max_margin_usage', parseInt(e.target.value) / 100)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={100}
|
||||
className="flex-1 accent-red-500"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono" style={{ color: '#F6465D' }}>
|
||||
{Math.round(config.max_margin_usage * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxPositionRatio')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxPositionRatioDesc')}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_position_ratio}
|
||||
onChange={(e) =>
|
||||
updateField('max_position_ratio', parseFloat(e.target.value) || 1.5)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={0.5}
|
||||
max={5}
|
||||
step={0.1}
|
||||
className="w-20 px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2" style={{ color: '#848E9C' }}>
|
||||
x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5" style={{ color: '#0ECB81' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('entryRequirements')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('minPositionSize')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('minPositionSizeDesc')}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={config.min_position_size}
|
||||
onChange={(e) =>
|
||||
updateField('min_position_size', parseFloat(e.target.value) || 12)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={1000}
|
||||
className="w-24 px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2" style={{ color: '#848E9C' }}>
|
||||
USDT
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('minConfidence')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('minConfidenceDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.min_confidence}
|
||||
onChange={(e) =>
|
||||
updateField('min_confidence', parseInt(e.target.value))
|
||||
}
|
||||
disabled={disabled}
|
||||
min={50}
|
||||
max={100}
|
||||
className="flex-1 accent-green-500"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
|
||||
{config.min_confidence}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
|
||||
interface SignalSourceModalProps {
|
||||
coinPoolUrl: string
|
||||
oiTopUrl: string
|
||||
onSave: (coinPoolUrl: string, oiTopUrl: string) => void
|
||||
onClose: () => void
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function SignalSourceModal({
|
||||
coinPoolUrl,
|
||||
oiTopUrl,
|
||||
onSave,
|
||||
onClose,
|
||||
language,
|
||||
}: SignalSourceModalProps) {
|
||||
const [coinPool, setCoinPool] = useState(coinPoolUrl || '')
|
||||
const [oiTop, setOiTop] = useState(oiTopUrl || '')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave(coinPool.trim(), oiTop.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
|
||||
{t('signalSourceConfig', language)}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
COIN POOL URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={coinPool}
|
||||
onChange={(e) => setCoinPool(e.target.value)}
|
||||
placeholder="https://api.example.com/coinpool"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolDescription', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
OI TOP URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={oiTop}
|
||||
onChange={(e) => setOiTop(e.target.value)}
|
||||
placeholder="https://api.example.com/oitop"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopDescription', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
ℹ️ {t('information', language)}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>{t('signalSourceInfo1', language)}</div>
|
||||
<div>{t('signalSourceInfo2', language)}</div>
|
||||
<div>{t('signalSourceInfo3', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('save', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export { Tooltip } from './Tooltip'
|
||||
export { SignalSourceModal } from './SignalSourceModal'
|
||||
export { ModelConfigModal } from './ModelConfigModal'
|
||||
export { ExchangeConfigModal } from './ExchangeConfigModal'
|
||||
export { getModelDisplayName, getShortName } from './utils'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, Plus, Radio } from 'lucide-react'
|
||||
import { Bot, Plus } from 'lucide-react'
|
||||
import { t, type Language } from '../../../i18n/translations'
|
||||
|
||||
interface PageHeaderProps {
|
||||
@@ -8,7 +8,6 @@ interface PageHeaderProps {
|
||||
configuredExchangesCount: number
|
||||
onAddModel: () => void
|
||||
onAddExchange: () => void
|
||||
onConfigureSignalSource: () => void
|
||||
onCreateTrader: () => void
|
||||
}
|
||||
|
||||
@@ -19,7 +18,6 @@ export function PageHeader({
|
||||
configuredExchangesCount,
|
||||
onAddModel,
|
||||
onAddExchange,
|
||||
onConfigureSignalSource,
|
||||
onCreateTrader,
|
||||
}: PageHeaderProps) {
|
||||
const canCreateTrader =
|
||||
@@ -86,19 +84,6 @@ export function PageHeader({
|
||||
{t('exchanges', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onConfigureSignalSource}
|
||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
||||
style={{
|
||||
background: '#2B3139',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #474D57',
|
||||
}}
|
||||
>
|
||||
<Radio className="w-3 h-3 md:w-4 md:h-4" />
|
||||
{t('signalSource', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCreateTrader}
|
||||
disabled={!canCreateTrader}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { t, type Language } from '../../../i18n/translations'
|
||||
|
||||
interface SignalSourceWarningProps {
|
||||
language: Language
|
||||
onConfigure: () => void
|
||||
}
|
||||
|
||||
export function SignalSourceWarning({
|
||||
language,
|
||||
onConfigure,
|
||||
}: SignalSourceWarningProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg px-4 py-3 flex items-start gap-3 animate-slide-in"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.3)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={20}
|
||||
className="flex-shrink-0 mt-0.5"
|
||||
style={{ color: '#F6465D' }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold mb-1" style={{ color: '#F6465D' }}>
|
||||
⚠️ {t('signalSourceNotConfigured', language)}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
<p className="mb-2">{t('signalSourceWarningMessage', language)}</p>
|
||||
<p>
|
||||
<strong>{t('solutions', language)}</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2 mt-1">
|
||||
<li>点击"{t('signalSource', language)}"按钮配置API地址</li>
|
||||
<li>或在交易员配置中禁用"使用币种池"和"使用OI Top"</li>
|
||||
<li>或在交易员配置中设置自定义币种列表</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={onConfigure}
|
||||
className="mt-3 px-3 py-1.5 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
{t('configureSignalSourceNow', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,15 +21,10 @@ interface UseTraderActionsParams {
|
||||
mutateTraders: () => Promise<any>
|
||||
setAllModels: (models: AIModel[]) => void
|
||||
setAllExchanges: (exchanges: Exchange[]) => void
|
||||
setUserSignalSource: (config: {
|
||||
coinPoolUrl: string
|
||||
oiTopUrl: string
|
||||
}) => void
|
||||
setShowCreateModal: (show: boolean) => void
|
||||
setShowEditModal: (show: boolean) => void
|
||||
setShowModelModal: (show: boolean) => void
|
||||
setShowExchangeModal: (show: boolean) => void
|
||||
setShowSignalSourceModal: (show: boolean) => void
|
||||
setEditingModel: (modelId: string | null) => void
|
||||
setEditingExchange: (exchangeId: string | null) => void
|
||||
editingTrader: TraderConfigData | null
|
||||
@@ -46,12 +41,10 @@ export function useTraderActions({
|
||||
mutateTraders,
|
||||
setAllModels,
|
||||
setAllExchanges,
|
||||
setUserSignalSource,
|
||||
setShowCreateModal,
|
||||
setShowEditModal,
|
||||
setShowModelModal,
|
||||
setShowExchangeModal,
|
||||
setShowSignalSourceModal,
|
||||
setEditingModel,
|
||||
setEditingExchange,
|
||||
editingTrader,
|
||||
@@ -605,24 +598,6 @@ export function useTraderActions({
|
||||
setShowExchangeModal(true)
|
||||
}
|
||||
|
||||
const handleSaveSignalSource = async (
|
||||
coinPoolUrl: string,
|
||||
oiTopUrl: string
|
||||
) => {
|
||||
try {
|
||||
await toast.promise(api.saveUserSignalSource(coinPoolUrl, oiTopUrl), {
|
||||
loading: '正在保存…',
|
||||
success: '保存成功',
|
||||
error: '保存失败',
|
||||
})
|
||||
setUserSignalSource({ coinPoolUrl, oiTopUrl })
|
||||
setShowSignalSourceModal(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to save signal source:', error)
|
||||
toast.error(t('saveSignalSourceFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 辅助函数
|
||||
isModelInUse,
|
||||
@@ -646,6 +621,5 @@ export function useTraderActions({
|
||||
handleDeleteModel,
|
||||
handleSaveExchange,
|
||||
handleDeleteExchange,
|
||||
handleSaveSignalSource,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const translations = {
|
||||
realtimeNav: 'Live',
|
||||
configNav: 'Config',
|
||||
dashboardNav: 'Dashboard',
|
||||
strategyNav: 'Strategy',
|
||||
faqNav: 'FAQ',
|
||||
|
||||
// Footer
|
||||
@@ -1017,6 +1018,7 @@ export const translations = {
|
||||
realtimeNav: '实时',
|
||||
configNav: '配置',
|
||||
dashboardNav: '看板',
|
||||
strategyNav: '策略',
|
||||
faqNav: '常见问题',
|
||||
|
||||
// Footer
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
BacktestTradeEvent,
|
||||
BacktestMetrics,
|
||||
BacktestRunMetadata,
|
||||
Strategy,
|
||||
StrategyConfig,
|
||||
} from '../types'
|
||||
import { CryptoService } from './crypto'
|
||||
import { httpClient } from './httpClient'
|
||||
@@ -553,4 +555,69 @@ export const api = {
|
||||
}
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
// Strategy APIs
|
||||
async getStrategies(): Promise<Strategy[]> {
|
||||
const result = await httpClient.get<Strategy[]>(`${API_BASE}/strategies`)
|
||||
if (!result.success) throw new Error('获取策略列表失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.get<Strategy>(`${API_BASE}/strategies/${strategyId}`)
|
||||
if (!result.success) throw new Error('获取策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getActiveStrategy(): Promise<Strategy> {
|
||||
const result = await httpClient.get<Strategy>(`${API_BASE}/strategies/active`)
|
||||
if (!result.success) throw new Error('获取激活策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getDefaultStrategyConfig(): Promise<StrategyConfig> {
|
||||
const result = await httpClient.get<StrategyConfig>(`${API_BASE}/strategies/default-config`)
|
||||
if (!result.success) throw new Error('获取默认策略配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async createStrategy(data: {
|
||||
name: string
|
||||
description: string
|
||||
config: StrategyConfig
|
||||
}): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies`, data)
|
||||
if (!result.success) throw new Error('创建策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateStrategy(
|
||||
strategyId: string,
|
||||
data: {
|
||||
name?: string
|
||||
description?: string
|
||||
config?: StrategyConfig
|
||||
}
|
||||
): Promise<Strategy> {
|
||||
const result = await httpClient.put<Strategy>(`${API_BASE}/strategies/${strategyId}`, data)
|
||||
if (!result.success) throw new Error('更新策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async deleteStrategy(strategyId: string): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/strategies/${strategyId}`)
|
||||
if (!result.success) throw new Error('删除策略失败')
|
||||
},
|
||||
|
||||
async activateStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies/${strategyId}/activate`)
|
||||
if (!result.success) throw new Error('激活策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async duplicateStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies/${strategyId}/duplicate`)
|
||||
if (!result.success) throw new Error('复制策略失败')
|
||||
return result.data!
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ import { useTradersConfigStore, useTradersModalStore } from '../stores'
|
||||
import { useTraderActions } from '../hooks/useTraderActions'
|
||||
import { TraderConfigModal } from '../components/TraderConfigModal'
|
||||
import {
|
||||
SignalSourceModal,
|
||||
ModelConfigModal,
|
||||
ExchangeConfigModal,
|
||||
} from '../components/traders'
|
||||
import { PageHeader } from '../components/traders/sections/PageHeader'
|
||||
import { SignalSourceWarning } from '../components/traders/sections/SignalSourceWarning'
|
||||
import { AIModelsSection } from '../components/traders/sections/AIModelsSection'
|
||||
import { ExchangesSection } from '../components/traders/sections/ExchangesSection'
|
||||
import { TradersGrid } from '../components/traders/sections/TradersGrid'
|
||||
@@ -35,11 +33,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
supportedExchanges,
|
||||
configuredModels,
|
||||
configuredExchanges,
|
||||
userSignalSource,
|
||||
loadConfigs,
|
||||
setAllModels,
|
||||
setAllExchanges,
|
||||
setUserSignalSource,
|
||||
} = useTradersConfigStore()
|
||||
|
||||
const {
|
||||
@@ -47,7 +43,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
showEditModal,
|
||||
showModelModal,
|
||||
showExchangeModal,
|
||||
showSignalSourceModal,
|
||||
editingModel,
|
||||
editingExchange,
|
||||
editingTrader,
|
||||
@@ -55,7 +50,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
setShowEditModal,
|
||||
setShowModelModal,
|
||||
setShowExchangeModal,
|
||||
setShowSignalSourceModal,
|
||||
setEditingModel,
|
||||
setEditingExchange,
|
||||
setEditingTrader,
|
||||
@@ -90,7 +84,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
handleDeleteModel,
|
||||
handleSaveExchange,
|
||||
handleDeleteExchange,
|
||||
handleSaveSignalSource,
|
||||
} = useTraderActions({
|
||||
traders,
|
||||
allModels,
|
||||
@@ -101,12 +94,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
mutateTraders,
|
||||
setAllModels,
|
||||
setAllExchanges,
|
||||
setUserSignalSource,
|
||||
setShowCreateModal,
|
||||
setShowEditModal,
|
||||
setShowModelModal,
|
||||
setShowExchangeModal,
|
||||
setShowSignalSourceModal,
|
||||
setEditingModel,
|
||||
setEditingExchange,
|
||||
editingTrader,
|
||||
@@ -127,12 +118,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
return true
|
||||
}) || []
|
||||
|
||||
// 检查是否需要显示信号源警告
|
||||
const showSignalWarning =
|
||||
traders?.some((t) => t.use_coin_pool || t.use_oi_top) &&
|
||||
!userSignalSource.coinPoolUrl &&
|
||||
!userSignalSource.oiTopUrl
|
||||
|
||||
// 处理交易员查看
|
||||
const handleTraderSelect = (traderId: string) => {
|
||||
if (onTraderSelect) {
|
||||
@@ -152,18 +137,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
configuredExchangesCount={configuredExchanges.length}
|
||||
onAddModel={handleAddModel}
|
||||
onAddExchange={handleAddExchange}
|
||||
onConfigureSignalSource={() => setShowSignalSourceModal(true)}
|
||||
onCreateTrader={() => setShowCreateModal(true)}
|
||||
/>
|
||||
|
||||
{/* Signal Source Warning */}
|
||||
{showSignalWarning && (
|
||||
<SignalSourceWarning
|
||||
language={language}
|
||||
onConfigure={() => setShowSignalSourceModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Configuration Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
<AIModelsSection
|
||||
@@ -233,16 +209,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSignalSourceModal && (
|
||||
<SignalSourceModal
|
||||
coinPoolUrl={userSignalSource.coinPoolUrl}
|
||||
oiTopUrl={userSignalSource.oiTopUrl}
|
||||
onSave={handleSaveSignalSource}
|
||||
onClose={() => setShowSignalSourceModal(false)}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import {
|
||||
Plus,
|
||||
Copy,
|
||||
Trash2,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
BarChart3,
|
||||
Target,
|
||||
Shield,
|
||||
Zap,
|
||||
Activity,
|
||||
Save,
|
||||
Sparkles,
|
||||
Eye,
|
||||
Play,
|
||||
FileText,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Bot,
|
||||
Terminal,
|
||||
Code,
|
||||
Send,
|
||||
} from 'lucide-react'
|
||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
|
||||
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
|
||||
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ''
|
||||
|
||||
export function StrategyStudioPage() {
|
||||
const { token } = useAuth()
|
||||
const { language } = useLanguage()
|
||||
|
||||
const [strategies, setStrategies] = useState<Strategy[]>([])
|
||||
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(null)
|
||||
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
// AI Models for test run
|
||||
const [aiModels, setAiModels] = useState<AIModel[]>([])
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>('')
|
||||
|
||||
// Accordion states for left panel
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
coinSource: true,
|
||||
indicators: false,
|
||||
riskControl: false,
|
||||
promptSections: false,
|
||||
customPrompt: false,
|
||||
})
|
||||
|
||||
// Right panel states
|
||||
const [activeRightTab, setActiveRightTab] = useState<'prompt' | 'test'>('prompt')
|
||||
const [promptPreview, setPromptPreview] = useState<{
|
||||
system_prompt: string
|
||||
user_prompt?: string
|
||||
prompt_variant: string
|
||||
config_summary: Record<string, unknown>
|
||||
} | null>(null)
|
||||
const [isLoadingPrompt, setIsLoadingPrompt] = useState(false)
|
||||
const [selectedVariant, setSelectedVariant] = useState('balanced')
|
||||
|
||||
// AI Test Run states
|
||||
const [aiTestResult, setAiTestResult] = useState<{
|
||||
system_prompt?: string
|
||||
user_prompt?: string
|
||||
ai_response?: string
|
||||
reasoning?: string
|
||||
decisions?: unknown[]
|
||||
error?: string
|
||||
duration_ms?: number
|
||||
} | null>(null)
|
||||
const [isRunningAiTest, setIsRunningAiTest] = useState(false)
|
||||
|
||||
const toggleSection = (section: keyof typeof expandedSections) => {
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
|
||||
// Fetch AI Models
|
||||
const fetchAiModels = useCallback(async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/models`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// 后端返回的是数组,不是 { models: [] }
|
||||
const allModels = Array.isArray(data) ? data : (data.models || [])
|
||||
const enabledModels = allModels.filter((m: AIModel) => m.enabled)
|
||||
setAiModels(enabledModels)
|
||||
if (enabledModels.length > 0 && !selectedModelId) {
|
||||
setSelectedModelId(enabledModels[0].id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch AI models:', err)
|
||||
}
|
||||
}, [token, selectedModelId])
|
||||
|
||||
// Fetch strategies
|
||||
const fetchStrategies = useCallback(async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/strategies`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to fetch strategies')
|
||||
const data = await response.json()
|
||||
setStrategies(data.strategies || [])
|
||||
|
||||
// Select active or first strategy
|
||||
const active = data.strategies?.find((s: Strategy) => s.is_active)
|
||||
if (active) {
|
||||
setSelectedStrategy(active)
|
||||
setEditingConfig(active.config)
|
||||
} else if (data.strategies?.length > 0) {
|
||||
setSelectedStrategy(data.strategies[0])
|
||||
setEditingConfig(data.strategies[0].config)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStrategies()
|
||||
fetchAiModels()
|
||||
}, [fetchStrategies, fetchAiModels])
|
||||
|
||||
// Create new strategy
|
||||
const handleCreateStrategy = async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
const configResponse = await fetch(
|
||||
`${API_BASE}/api/strategies/default-config`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
const defaultConfig = await configResponse.json()
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/strategies`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: language === 'zh' ? '新策略' : 'New Strategy',
|
||||
description: '',
|
||||
config: defaultConfig,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create strategy')
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// Delete strategy
|
||||
const handleDeleteStrategy = async (id: string) => {
|
||||
if (!token || !confirm(language === 'zh' ? '确定删除此策略?' : 'Delete this strategy?')) return
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/strategies/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to delete strategy')
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate strategy
|
||||
const handleDuplicateStrategy = async (id: string) => {
|
||||
if (!token) return
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/strategies/${id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: language === 'zh' ? '策略副本' : 'Strategy Copy',
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to duplicate strategy')
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// Activate strategy
|
||||
const handleActivateStrategy = async (id: string) => {
|
||||
if (!token) return
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/strategies/${id}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to activate strategy')
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// Save strategy
|
||||
const handleSaveStrategy = async () => {
|
||||
if (!token || !selectedStrategy || !editingConfig) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/strategies/${selectedStrategy.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: selectedStrategy.name,
|
||||
description: selectedStrategy.description,
|
||||
config: editingConfig,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to save strategy')
|
||||
setHasChanges(false)
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Update config section
|
||||
const updateConfig = <K extends keyof StrategyConfig>(
|
||||
section: K,
|
||||
value: StrategyConfig[K]
|
||||
) => {
|
||||
if (!editingConfig) return
|
||||
setEditingConfig({
|
||||
...editingConfig,
|
||||
[section]: value,
|
||||
})
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
// Fetch prompt preview
|
||||
const fetchPromptPreview = async () => {
|
||||
if (!token || !editingConfig) return
|
||||
setIsLoadingPrompt(true)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/strategies/preview-prompt`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
config: editingConfig,
|
||||
account_equity: 1000,
|
||||
prompt_variant: selectedVariant,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to fetch prompt preview')
|
||||
const data = await response.json()
|
||||
setPromptPreview(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setIsLoadingPrompt(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Run AI test with real AI model
|
||||
const runAiTest = async () => {
|
||||
if (!token || !editingConfig || !selectedModelId) return
|
||||
setIsRunningAiTest(true)
|
||||
setAiTestResult(null)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/strategies/test-run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
config: editingConfig,
|
||||
prompt_variant: selectedVariant,
|
||||
ai_model_id: selectedModelId,
|
||||
run_real_ai: true,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to run AI test')
|
||||
const data = await response.json()
|
||||
setAiTestResult(data)
|
||||
} catch (err) {
|
||||
setAiTestResult({
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
setIsRunningAiTest(false)
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
strategyStudio: { zh: '策略工作室', en: 'Strategy Studio' },
|
||||
subtitle: { zh: '可视化配置和测试交易策略', en: 'Configure and test trading strategies' },
|
||||
strategies: { zh: '策略', en: 'Strategies' },
|
||||
newStrategy: { zh: '新建', en: 'New' },
|
||||
coinSource: { zh: '币种来源', en: 'Coin Source' },
|
||||
indicators: { zh: '技术指标', en: 'Indicators' },
|
||||
riskControl: { zh: '风控参数', en: 'Risk Control' },
|
||||
promptSections: { zh: 'Prompt 编辑', en: 'Prompt Editor' },
|
||||
customPrompt: { zh: '附加提示', en: 'Extra Prompt' },
|
||||
save: { zh: '保存', en: 'Save' },
|
||||
saving: { zh: '保存中...', en: 'Saving...' },
|
||||
activate: { zh: '激活', en: 'Activate' },
|
||||
active: { zh: '激活中', en: 'Active' },
|
||||
default: { zh: '默认', en: 'Default' },
|
||||
promptPreview: { zh: 'Prompt 预览', en: 'Prompt Preview' },
|
||||
aiTestRun: { zh: 'AI 测试', en: 'AI Test' },
|
||||
systemPrompt: { zh: 'System Prompt', en: 'System Prompt' },
|
||||
userPrompt: { zh: 'User Prompt', en: 'User Prompt' },
|
||||
loadPrompt: { zh: '生成 Prompt', en: 'Generate Prompt' },
|
||||
refreshPrompt: { zh: '刷新', en: 'Refresh' },
|
||||
promptVariant: { zh: '风格', en: 'Style' },
|
||||
balanced: { zh: '平衡', en: 'Balanced' },
|
||||
aggressive: { zh: '激进', en: 'Aggressive' },
|
||||
conservative: { zh: '保守', en: 'Conservative' },
|
||||
selectModel: { zh: '选择 AI 模型', en: 'Select AI Model' },
|
||||
runTest: { zh: '运行 AI 测试', en: 'Run AI Test' },
|
||||
running: { zh: '运行中...', en: 'Running...' },
|
||||
aiOutput: { zh: 'AI 输出', en: 'AI Output' },
|
||||
reasoning: { zh: '思维链', en: 'Reasoning' },
|
||||
decisions: { zh: '决策', en: 'Decisions' },
|
||||
duration: { zh: '耗时', en: 'Duration' },
|
||||
noModel: { zh: '请先配置 AI 模型', en: 'Please configure AI model first' },
|
||||
testNote: { zh: '使用真实 AI 模型测试,不执行交易', en: 'Test with real AI, no trading' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[70vh]">
|
||||
<div className="text-center">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-full border-4 border-yellow-500/20 border-t-yellow-500 animate-spin" />
|
||||
<Zap className="w-6 h-6 text-yellow-500 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const configSections = [
|
||||
{
|
||||
key: 'coinSource' as const,
|
||||
icon: Target,
|
||||
color: '#F0B90B',
|
||||
title: t('coinSource'),
|
||||
content: editingConfig && (
|
||||
<CoinSourceEditor
|
||||
config={editingConfig.coin_source}
|
||||
onChange={(coinSource) => updateConfig('coin_source', coinSource)}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
language={language}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'indicators' as const,
|
||||
icon: BarChart3,
|
||||
color: '#0ECB81',
|
||||
title: t('indicators'),
|
||||
content: editingConfig && (
|
||||
<IndicatorEditor
|
||||
config={editingConfig.indicators}
|
||||
onChange={(indicators) => updateConfig('indicators', indicators)}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
language={language}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'riskControl' as const,
|
||||
icon: Shield,
|
||||
color: '#F6465D',
|
||||
title: t('riskControl'),
|
||||
content: editingConfig && (
|
||||
<RiskControlEditor
|
||||
config={editingConfig.risk_control}
|
||||
onChange={(riskControl) => updateConfig('risk_control', riskControl)}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
language={language}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'promptSections' as const,
|
||||
icon: FileText,
|
||||
color: '#a855f7',
|
||||
title: t('promptSections'),
|
||||
content: editingConfig && (
|
||||
<PromptSectionsEditor
|
||||
config={editingConfig.prompt_sections}
|
||||
onChange={(promptSections) => updateConfig('prompt_sections', promptSections)}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
language={language}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'customPrompt' as const,
|
||||
icon: Settings,
|
||||
color: '#60a5fa',
|
||||
title: t('customPrompt'),
|
||||
content: editingConfig && (
|
||||
<div>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格' : 'Extra prompt appended to System Prompt for personalized trading style'}
|
||||
</p>
|
||||
<textarea
|
||||
value={editingConfig.custom_prompt || ''}
|
||||
onChange={(e) => updateConfig('custom_prompt', e.target.value)}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
placeholder={language === 'zh' ? '输入自定义提示词...' : 'Enter custom prompt...'}
|
||||
className="w-full h-32 px-3 py-2 rounded-lg resize-none font-mono text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col" style={{ background: '#0B0E11' }}>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b" style={{ borderColor: '#2B3139' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
<Sparkles className="w-5 h-5 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold" style={{ color: '#EAECEF' }}>{t('strategyStudio')}</h1>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="hover:underline">×</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Three Columns */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Column - Strategy List */}
|
||||
<div className="w-48 flex-shrink-0 border-r overflow-y-auto" style={{ borderColor: '#2B3139' }}>
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2 px-2">
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('strategies')}</span>
|
||||
<button
|
||||
onClick={handleCreateStrategy}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{strategies.map((strategy) => (
|
||||
<div
|
||||
key={strategy.id}
|
||||
onClick={() => {
|
||||
setSelectedStrategy(strategy)
|
||||
setEditingConfig(strategy.config)
|
||||
setHasChanges(false)
|
||||
setPromptPreview(null)
|
||||
setAiTestResult(null)
|
||||
}}
|
||||
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${
|
||||
selectedStrategy?.id === strategy.id ? 'ring-1 ring-yellow-500/50' : 'hover:bg-white/5'
|
||||
}`}
|
||||
style={{
|
||||
background: selectedStrategy?.id === strategy.id ? 'rgba(240, 185, 11, 0.1)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm truncate" style={{ color: '#EAECEF' }}>{strategy.name}</span>
|
||||
{!strategy.is_default && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
>
|
||||
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
|
||||
className="p-1 rounded hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{strategy.is_active && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded" style={{ background: 'rgba(14, 203, 129, 0.15)', color: '#0ECB81' }}>
|
||||
{t('active')}
|
||||
</span>
|
||||
)}
|
||||
{strategy.is_default && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
|
||||
{t('default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Column - Config Editor */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto border-r" style={{ borderColor: '#2B3139' }}>
|
||||
{selectedStrategy && editingConfig ? (
|
||||
<div className="p-4">
|
||||
{/* Strategy Name & Actions */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.name}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none w-full"
|
||||
style={{ color: '#EAECEF' }}
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>● 未保存</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!selectedStrategy.is_active && (
|
||||
<button
|
||||
onClick={() => handleActivateStrategy(selectedStrategy.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)', color: '#0ECB81' }}
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
{t('activate')}
|
||||
</button>
|
||||
)}
|
||||
{!selectedStrategy.is_default && (
|
||||
<button
|
||||
onClick={handleSaveStrategy}
|
||||
disabled={isSaving || !hasChanges}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
background: hasChanges ? '#F0B90B' : '#2B3139',
|
||||
color: hasChanges ? '#0B0E11' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{isSaving ? t('saving') : t('save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config Sections */}
|
||||
<div className="space-y-2">
|
||||
{configSections.map(({ key, icon: Icon, color, title, content }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" style={{ color }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{title}</span>
|
||||
</div>
|
||||
{expandedSections[key] ? (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
</button>
|
||||
{expandedSections[key] && (
|
||||
<div className="px-3 pb-3">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Activity className="w-12 h-12 mx-auto mb-2 opacity-30" style={{ color: '#848E9C' }} />
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '选择或创建策略' : 'Select or create a strategy'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Prompt Preview & AI Test */}
|
||||
<div className="w-[420px] flex-shrink-0 flex flex-col overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex-shrink-0 flex border-b" style={{ borderColor: '#2B3139' }}>
|
||||
<button
|
||||
onClick={() => setActiveRightTab('prompt')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeRightTab === 'prompt' ? 'border-b-2' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: activeRightTab === 'prompt' ? '#a855f7' : 'transparent',
|
||||
color: activeRightTab === 'prompt' ? '#a855f7' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{t('promptPreview')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveRightTab('test')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeRightTab === 'test' ? 'border-b-2' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: activeRightTab === 'test' ? '#22c55e' : 'transparent',
|
||||
color: activeRightTab === 'test' ? '#22c55e' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{t('aiTestRun')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeRightTab === 'prompt' ? (
|
||||
/* Prompt Preview Tab */
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="balanced">{t('balanced')}</option>
|
||||
<option value="aggressive">{t('aggressive')}</option>
|
||||
<option value="conservative">{t('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchPromptPreview}
|
||||
disabled={isLoadingPrompt || !editingConfig}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{ background: '#a855f7', color: '#fff' }}
|
||||
>
|
||||
{isLoadingPrompt ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
|
||||
{promptPreview ? t('refreshPrompt') : t('loadPrompt')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{promptPreview ? (
|
||||
<>
|
||||
{/* Config Summary */}
|
||||
<div className="p-2 rounded-lg" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Code className="w-3 h-3" style={{ color: '#a855f7' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#a855f7' }}>Config</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{Object.entries(promptPreview.config_summary || {}).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<div style={{ color: '#848E9C' }}>{key.replace(/_/g, ' ')}</div>
|
||||
<div style={{ color: '#EAECEF' }}>{String(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="w-3 h-3" style={{ color: '#a855f7' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('systemPrompt')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
{promptPreview.system_prompt.length.toLocaleString()} chars
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[11px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '400px' }}
|
||||
>
|
||||
{promptPreview.system_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12" style={{ color: '#848E9C' }}>
|
||||
<Eye className="w-10 h-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">{language === 'zh' ? '点击生成 Prompt 预览' : 'Click to generate prompt preview'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* AI Test Tab */
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4" style={{ color: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('selectModel')}</span>
|
||||
</div>
|
||||
{aiModels.length > 0 ? (
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(e) => setSelectedModelId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{aiModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name} ({model.provider})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="px-3 py-2 rounded-lg text-sm" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{t('noModel')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="balanced">{t('balanced')}</option>
|
||||
<option value="aggressive">{t('aggressive')}</option>
|
||||
<option value="conservative">{t('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={runAiTest}
|
||||
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #22c55e 0%, #4ade80 100%)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.3)',
|
||||
}}
|
||||
>
|
||||
{isRunningAiTest ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('running')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
{t('runTest')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px]" style={{ color: '#848E9C' }}>{t('testNote')}</p>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{aiTestResult ? (
|
||||
<div className="space-y-3">
|
||||
{aiTestResult.error ? (
|
||||
<div className="p-3 rounded-lg" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}>
|
||||
<p className="text-sm" style={{ color: '#F6465D' }}>{aiTestResult.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{aiTestResult.duration_ms && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('duration')}: {(aiTestResult.duration_ms / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Prompt Input */}
|
||||
{aiTestResult.user_prompt && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Terminal className="w-3 h-3" style={{ color: '#60a5fa' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('userPrompt')} (Input)</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '200px' }}
|
||||
>
|
||||
{aiTestResult.user_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Reasoning */}
|
||||
{aiTestResult.reasoning && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Sparkles className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('reasoning')}</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap"
|
||||
style={{ background: '#0B0E11', border: '1px solid rgba(240, 185, 11, 0.3)', color: '#EAECEF', maxHeight: '200px' }}
|
||||
>
|
||||
{aiTestResult.reasoning}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Decisions */}
|
||||
{aiTestResult.decisions && aiTestResult.decisions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Activity className="w-3 h-3" style={{ color: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('decisions')}</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid rgba(34, 197, 94, 0.3)', color: '#EAECEF', maxHeight: '200px' }}
|
||||
>
|
||||
{JSON.stringify(aiTestResult.decisions, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw AI Response */}
|
||||
{aiTestResult.ai_response && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<FileText className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('aiOutput')} (Raw)</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '300px' }}
|
||||
>
|
||||
{aiTestResult.ai_response}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12" style={{ color: '#848E9C' }}>
|
||||
<Play className="w-10 h-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">{language === 'zh' ? '点击运行 AI 测试' : 'Click to run AI test'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StrategyStudioPage
|
||||
@@ -2,18 +2,12 @@ import { create } from 'zustand'
|
||||
import type { AIModel, Exchange } from '../types'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface SignalSource {
|
||||
coinPoolUrl: string
|
||||
oiTopUrl: string
|
||||
}
|
||||
|
||||
interface TradersConfigState {
|
||||
// 数据
|
||||
allModels: AIModel[]
|
||||
allExchanges: Exchange[]
|
||||
supportedModels: AIModel[]
|
||||
supportedExchanges: Exchange[]
|
||||
userSignalSource: SignalSource
|
||||
|
||||
// 计算属性
|
||||
configuredModels: AIModel[]
|
||||
@@ -24,7 +18,6 @@ interface TradersConfigState {
|
||||
setAllExchanges: (exchanges: Exchange[]) => void
|
||||
setSupportedModels: (models: AIModel[]) => void
|
||||
setSupportedExchanges: (exchanges: Exchange[]) => void
|
||||
setUserSignalSource: (source: SignalSource) => void
|
||||
|
||||
// 异步加载
|
||||
loadConfigs: (user: any, token: string | null) => Promise<void>
|
||||
@@ -38,7 +31,6 @@ const initialState = {
|
||||
allExchanges: [],
|
||||
supportedModels: [],
|
||||
supportedExchanges: [],
|
||||
userSignalSource: { coinPoolUrl: '', oiTopUrl: '' },
|
||||
configuredModels: [],
|
||||
configuredExchanges: [],
|
||||
}
|
||||
@@ -73,7 +65,6 @@ export const useTradersConfigStore = create<TradersConfigState>((set, get) => ({
|
||||
|
||||
setSupportedModels: (models) => set({ supportedModels: models }),
|
||||
setSupportedExchanges: (exchanges) => set({ supportedExchanges: exchanges }),
|
||||
setUserSignalSource: (source) => set({ userSignalSource: source }),
|
||||
|
||||
loadConfigs: async (user, token) => {
|
||||
if (!user || !token) {
|
||||
@@ -108,17 +99,6 @@ export const useTradersConfigStore = create<TradersConfigState>((set, get) => ({
|
||||
get().setAllExchanges(exchangeConfigs)
|
||||
get().setSupportedModels(supportedModels)
|
||||
get().setSupportedExchanges(supportedExchanges)
|
||||
|
||||
// 加载用户信号源配置
|
||||
try {
|
||||
const signalSource = await api.getUserSignalSource()
|
||||
get().setUserSignalSource({
|
||||
coinPoolUrl: signalSource.coin_pool_url || '',
|
||||
oiTopUrl: signalSource.oi_top_url || '',
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('📡 用户信号源配置暂未设置')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load configs:', error)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ interface TradersModalState {
|
||||
showEditModal: boolean
|
||||
showModelModal: boolean
|
||||
showExchangeModal: boolean
|
||||
showSignalSourceModal: boolean
|
||||
|
||||
// 编辑状态
|
||||
editingModel: string | null
|
||||
@@ -19,7 +18,6 @@ interface TradersModalState {
|
||||
setShowEditModal: (show: boolean) => void
|
||||
setShowModelModal: (show: boolean) => void
|
||||
setShowExchangeModal: (show: boolean) => void
|
||||
setShowSignalSourceModal: (show: boolean) => void
|
||||
|
||||
setEditingModel: (modelId: string | null) => void
|
||||
setEditingExchange: (exchangeId: string | null) => void
|
||||
@@ -40,7 +38,6 @@ const initialState = {
|
||||
showEditModal: false,
|
||||
showModelModal: false,
|
||||
showExchangeModal: false,
|
||||
showSignalSourceModal: false,
|
||||
editingModel: null,
|
||||
editingExchange: null,
|
||||
editingTrader: null,
|
||||
@@ -53,7 +50,6 @@ export const useTradersModalStore = create<TradersModalState>((set) => ({
|
||||
setShowEditModal: (show) => set({ showEditModal: show }),
|
||||
setShowModelModal: (show) => set({ showModelModal: show }),
|
||||
setShowExchangeModal: (show) => set({ showExchangeModal: show }),
|
||||
setShowSignalSourceModal: (show) => set({ showSignalSourceModal: show }),
|
||||
|
||||
setEditingModel: (modelId) => set({ editingModel: modelId }),
|
||||
setEditingExchange: (exchangeId) => set({ editingExchange: exchangeId }),
|
||||
|
||||
+93
-5
@@ -131,15 +131,17 @@ export interface CreateTraderRequest {
|
||||
name: string
|
||||
ai_model_id: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID(新版,使用保存的策略配置)
|
||||
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
|
||||
scan_interval_minutes?: number
|
||||
is_cross_margin?: boolean
|
||||
// 以下字段为向后兼容保留,新版使用策略配置
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
trading_symbols?: string
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
is_cross_margin?: boolean
|
||||
use_coin_pool?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
@@ -201,18 +203,20 @@ export interface TraderConfigData {
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID(新版)
|
||||
is_cross_margin: boolean
|
||||
scan_interval_minutes: number
|
||||
initial_balance: number
|
||||
is_running: boolean
|
||||
// 以下为旧版字段(向后兼容)
|
||||
btc_eth_leverage: number
|
||||
altcoin_leverage: number
|
||||
trading_symbols: string
|
||||
custom_prompt: string
|
||||
override_base_prompt: boolean
|
||||
system_prompt_template: string
|
||||
is_cross_margin: boolean
|
||||
use_coin_pool: boolean
|
||||
use_oi_top: boolean
|
||||
initial_balance: number
|
||||
scan_interval_minutes: number
|
||||
is_running: boolean
|
||||
}
|
||||
|
||||
// Backtest types
|
||||
@@ -347,3 +351,87 @@ export interface BacktestStartConfig {
|
||||
altcoin_leverage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Strategy Studio Types
|
||||
export interface Strategy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
config: StrategyConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PromptSectionsConfig {
|
||||
role_definition?: string;
|
||||
trading_frequency?: string;
|
||||
entry_standards?: string;
|
||||
decision_process?: string;
|
||||
}
|
||||
|
||||
export interface StrategyConfig {
|
||||
coin_source: CoinSourceConfig;
|
||||
indicators: IndicatorConfig;
|
||||
custom_prompt?: string;
|
||||
risk_control: RiskControlConfig;
|
||||
prompt_sections?: PromptSectionsConfig;
|
||||
}
|
||||
|
||||
export interface CoinSourceConfig {
|
||||
source_type: 'static' | 'coinpool' | 'oi_top' | 'mixed';
|
||||
static_coins?: string[];
|
||||
use_coin_pool: boolean;
|
||||
coin_pool_limit?: number;
|
||||
coin_pool_api_url?: string; // AI500 币种池 API URL
|
||||
use_oi_top: boolean;
|
||||
oi_top_limit?: number;
|
||||
oi_top_api_url?: string; // OI Top API URL
|
||||
}
|
||||
|
||||
export interface IndicatorConfig {
|
||||
klines: KlineConfig;
|
||||
enable_ema: boolean;
|
||||
enable_macd: boolean;
|
||||
enable_rsi: boolean;
|
||||
enable_atr: boolean;
|
||||
enable_volume: boolean;
|
||||
enable_oi: boolean;
|
||||
enable_funding_rate: boolean;
|
||||
ema_periods?: number[];
|
||||
rsi_periods?: number[];
|
||||
atr_periods?: number[];
|
||||
external_data_sources?: ExternalDataSource[];
|
||||
}
|
||||
|
||||
export interface KlineConfig {
|
||||
primary_timeframe: string;
|
||||
primary_count: number;
|
||||
longer_timeframe?: string;
|
||||
longer_count?: number;
|
||||
enable_multi_timeframe: boolean;
|
||||
// 新增:支持选择多个时间周期
|
||||
selected_timeframes?: string[];
|
||||
}
|
||||
|
||||
export interface ExternalDataSource {
|
||||
name: string;
|
||||
type: 'api' | 'webhook';
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
data_path?: string;
|
||||
refresh_secs?: number;
|
||||
}
|
||||
|
||||
export interface RiskControlConfig {
|
||||
max_positions: number;
|
||||
btc_eth_max_leverage: number;
|
||||
altcoin_max_leverage: number;
|
||||
min_risk_reward_ratio: number;
|
||||
max_margin_usage: number;
|
||||
max_position_ratio: number;
|
||||
min_position_size: number;
|
||||
min_confidence: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user