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:
tinkle-community
2025-12-06 07:20:11 +08:00
committed by GitHub
parent afb2d158ac
commit 5cff32e4f2
37 changed files with 4965 additions and 1051 deletions
+4
View File
@@ -87,6 +87,10 @@ Join our Telegram developer community to discuss, share ideas, and get support:
![Details Page](screenshots/details-page.png) ![Details Page](screenshots/details-page.png)
*Professional trading interface with equity curves, live positions, and AI decision logs with expandable input prompts & chain-of-thought reasoning* *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
![Strategy Studio](screenshots/strategy-studio.png)
*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) ## 🏦 Supported Exchanges (DEX/CEX Tutorials)
+30 -4
View File
@@ -144,6 +144,20 @@ func (s *Server) setupRoutes() {
protected.GET("/exchanges", s.handleGetExchangeConfigs) protected.GET("/exchanges", s.handleGetExchangeConfigs)
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs) 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.GET("/user/signal-sources", s.handleGetUserSignalSource)
protected.POST("/user/signal-sources", s.handleSaveUserSignalSource) protected.POST("/user/signal-sources", s.handleSaveUserSignalSource)
@@ -373,15 +387,17 @@ type CreateTraderRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
AIModelID string `json:"ai_model_id" binding:"required"` AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"` ExchangeID string `json:"exchange_id" binding:"required"`
StrategyID string `json:"strategy_id"` // 策略ID(新版)
InitialBalance float64 `json:"initial_balance"` InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"` // 指针类型,nil表示使用默认值true
// 以下字段为向后兼容保留,新版使用策略配置
BTCETHLeverage int `json:"btc_eth_leverage"` BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"` AltcoinLeverage int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"` TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"` CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"` OverrideBasePrompt bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"` // 系统提示词模板名称 SystemPromptTemplate string `json:"system_prompt_template"` // 系统提示词模板名称
IsCrossMargin *bool `json:"is_cross_margin"` // 指针类型,nil表示使用默认值true
UseCoinPool bool `json:"use_coin_pool"` UseCoinPool bool `json:"use_coin_pool"`
UseOITop bool `json:"use_oi_top"` 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{ traderRecord := &store.Trader{
ID: traderID, ID: traderID,
UserID: userID, UserID: userID,
Name: req.Name, Name: req.Name,
AIModelID: req.AIModelID, AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID, ExchangeID: req.ExchangeID,
InitialBalance: actualBalance, // 使用实际查询的余额 StrategyID: req.StrategyID, // 关联策略ID(新版)
InitialBalance: actualBalance, // 使用实际查询的余额
BTCETHLeverage: btcEthLeverage, BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage, AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols, TradingSymbols: req.TradingSymbols,
@@ -664,15 +681,17 @@ type UpdateTraderRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
AIModelID string `json:"ai_model_id" binding:"required"` AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"` ExchangeID string `json:"exchange_id" binding:"required"`
StrategyID string `json:"strategy_id"` // 策略ID(新版)
InitialBalance float64 `json:"initial_balance"` InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"`
// 以下字段为向后兼容保留,新版使用策略配置
BTCETHLeverage int `json:"btc_eth_leverage"` BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"` AltcoinLeverage int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"` TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"` CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"` OverrideBasePrompt bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"` SystemPromptTemplate string `json:"system_prompt_template"`
IsCrossMargin *bool `json:"is_cross_margin"`
} }
// handleUpdateTrader 更新交易员配置 // handleUpdateTrader 更新交易员配置
@@ -736,6 +755,12 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
systemPromptTemplate = existingTrader.SystemPromptTemplate // 保持原值 systemPromptTemplate = existingTrader.SystemPromptTemplate // 保持原值
} }
// 处理策略ID(如果没有提供,保持原值)
strategyID := req.StrategyID
if strategyID == "" {
strategyID = existingTrader.StrategyID
}
// 更新交易员配置 // 更新交易员配置
traderRecord := &store.Trader{ traderRecord := &store.Trader{
ID: traderID, ID: traderID,
@@ -743,6 +768,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
Name: req.Name, Name: req.Name,
AIModelID: req.AIModelID, AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID, ExchangeID: req.ExchangeID,
StrategyID: strategyID, // 关联策略ID
InitialBalance: req.InitialBalance, InitialBalance: req.InitialBalance,
BTCETHLeverage: btcEthLeverage, BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage, AltcoinLeverage: altcoinLeverage,
+595
View File
@@ -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
}
+158
View File
@@ -146,6 +146,164 @@ func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error
return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") 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和模板选择) // GetFullDecisionWithCustomPrompt 获取AI的完整交易决策(支持自定义prompt和模板选择)
func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient mcp.AIClient, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) { func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient mcp.AIClient, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) {
if ctx == nil { if ctx == nil {
+719
View File
@@ -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
}
+4
View File
@@ -89,6 +89,10 @@
![详情页面](../../../screenshots/details-page.png) ![详情页面](../../../screenshots/details-page.png)
*专业交易界面,包含权益曲线、实时持仓、AI决策日志,支持展开查看输入提示词和AI思维链推理过程* *专业交易界面,包含权益曲线、实时持仓、AI决策日志,支持展开查看输入提示词和AI思维链推理过程*
### 🎛️ 策略工作室 - 自定义策略构建器
![策略工作室](../../../screenshots/strategy-studio.png)
*三栏式策略编辑器,支持多时间周期选择(5m/15m/1h/4h)、技术指标配置、风险控制设置,以及基于实时市场数据的AI测试运行*
--- ---
## 🏦 支持的交易所(DEX/CEX教程) ## 🏦 支持的交易所(DEX/CEX教程)
+20 -73
View File
@@ -2,14 +2,12 @@ package manager
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/store" "nofx/store"
"nofx/trader" "nofx/trader"
"sort" "sort"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -360,17 +358,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss") maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss")
maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown")
stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") 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 // 默认值 maxDailyLoss := 10.0 // 默认值
@@ -388,15 +375,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
stopTradingMinutes = val stopTradingMinutes = val
} }
// 解析默认币种列表
var defaultCoins []string
if defaultCoinsStr != "" {
if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil {
logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err)
defaultCoins = []string{}
}
}
// 获取AI模型和交易所列表(在循环外只查询一次) // 获取AI模型和交易所列表(在循环外只查询一次)
aiModels, err := st.AIModel().List(userID) aiModels, err := st.AIModel().List(userID)
if err != nil { 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 { if err != nil {
logger.Infof("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err) 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") maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss")
maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown")
stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes")
defaultCoinsStr, _ := st.SystemConfig().Get("default_coins")
// 解析配置 // 解析配置
maxDailyLoss := 10.0 // 默认值 maxDailyLoss := 10.0 // 默认值
@@ -523,15 +500,6 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
stopTradingMinutes = val stopTradingMinutes = val
} }
// 解析默认币种列表
var defaultCoins []string
if defaultCoinsStr != "" {
if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil {
logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err)
defaultCoins = []string{}
}
}
// 为每个交易员获取AI模型和交易所配置 // 为每个交易员获取AI模型和交易所配置
for _, traderCfg := range allTraders { for _, traderCfg := range allTraders {
// 获取AI模型配置 // 获取AI模型配置
@@ -595,17 +563,8 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
continue continue
} }
// 获取用户信号源配置 // 添加到TraderManagercoinPoolURL/oiTopURL 已从策略配置中获取)
var coinPoolURL, oiTopURL string err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st)
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)
if err != nil { if err != nil {
logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err) logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err)
continue continue
@@ -617,36 +576,29 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
} }
// addTraderFromStore 内部方法:从store配置添加交易员 // 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 { if _, exists := tm.traders[traderCfg.ID]; exists {
return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID)
} }
// 处理交易币种列表 // 加载策略配置(必须有策略)
var tradingCoins []string var strategyConfig *store.StrategyConfig
if traderCfg.TradingSymbols != "" { if traderCfg.StrategyID != "" {
symbols := strings.Split(traderCfg.TradingSymbols, ",") strategy, err := st.Strategy().Get(traderCfg.UserID, traderCfg.StrategyID)
for _, symbol := range symbols { if err != nil {
symbol = strings.TrimSpace(symbol) return fmt.Errorf("交易员 %s 的策略 %s 加载失败: %w", traderCfg.Name, traderCfg.StrategyID, err)
if symbol != "" {
tradingCoins = append(tradingCoins, symbol)
}
} }
// 解析 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)
} }
// 如果没有指定交易币种,使用默认币种 // 构建AutoTraderConfigcoinPoolURL/oiTopURL 从策略配置获取,在 StrategyEngine 中使用)
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
traderConfig := trader.AutoTraderConfig{ traderConfig := trader.AutoTraderConfig{
ID: traderCfg.ID, ID: traderCfg.ID,
Name: traderCfg.Name, Name: traderCfg.Name,
@@ -656,7 +608,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
BinanceSecretKey: "", BinanceSecretKey: "",
HyperliquidPrivateKey: "", HyperliquidPrivateKey: "",
HyperliquidTestnet: exchangeCfg.Testnet, HyperliquidTestnet: exchangeCfg.Testnet,
CoinPoolAPIURL: effectiveCoinPoolURL,
UseQwen: aiModelCfg.Provider == "qwen", UseQwen: aiModelCfg.Provider == "qwen",
DeepSeekKey: "", DeepSeekKey: "",
QwenKey: "", QwenKey: "",
@@ -664,15 +615,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
CustomModelName: aiModelCfg.CustomModelName, CustomModelName: aiModelCfg.CustomModelName,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance, InitialBalance: traderCfg.InitialBalance,
BTCETHLeverage: traderCfg.BTCETHLeverage,
AltcoinLeverage: traderCfg.AltcoinLeverage,
MaxDailyLoss: maxDailyLoss, MaxDailyLoss: maxDailyLoss,
MaxDrawdown: maxDrawdown, MaxDrawdown: maxDrawdown,
StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute,
IsCrossMargin: traderCfg.IsCrossMargin, IsCrossMargin: traderCfg.IsCrossMargin,
DefaultCoins: defaultCoins, StrategyConfig: strategyConfig,
TradingCoins: tradingCoins,
SystemPromptTemplate: traderCfg.SystemPromptTemplate,
} }
// 根据交易所类型设置API密钥 // 根据交易所类型设置API密钥
+269
View File
@@ -112,6 +112,230 @@ func Get(symbol string) (*Data, error) {
}, nil }, 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 // calculateEMA 计算EMA
func calculateEMA(klines []Kline, period int) float64 { func calculateEMA(klines []Kline, period int) float64 {
if len(klines) < period { 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() 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 (20period): %s\n\n", formatFloatSlice(data.EMA20Values)))
}
if len(data.EMA50Values) > 0 {
sb.WriteString(fmt.Sprintf("EMA indicators (50period): %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 (7Period): %s\n\n", formatFloatSlice(data.RSI7Values)))
}
if len(data.RSI14Values) > 0 {
sb.WriteString(fmt.Sprintf("RSI indicators (14Period): %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 (14period): %.3f\n\n", data.ATR14))
}
// formatPriceWithDynamicPrecision 根据价格区间动态选择精度 // formatPriceWithDynamicPrecision 根据价格区间动态选择精度
// 这样可以完美支持从超低价 meme coin (< 0.0001) 到 BTC/ETH 的所有币种 // 这样可以完美支持从超低价 meme coin (< 0.0001) 到 BTC/ETH 的所有币种
func formatPriceWithDynamicPrecision(price float64) string { func formatPriceWithDynamicPrecision(price float64) string {
+15
View File
@@ -15,6 +15,21 @@ type Data struct {
FundingRate float64 FundingRate float64
IntradaySeries *IntradayData IntradaySeries *IntradayData
LongerTermContext *LongerTermData 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数据 // OIData Open Interest数据
Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

+1 -2
View File
@@ -42,8 +42,7 @@ func (s *AIModelStore) initTables() error {
custom_api_url TEXT DEFAULT '', custom_api_url TEXT DEFAULT '',
custom_model_name TEXT DEFAULT '', custom_model_name TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) )
`) `)
if err != nil { if err != nil {
+1 -2
View File
@@ -56,8 +56,7 @@ func (s *ExchangeStore) initTables() error {
lighter_api_key_private_key TEXT DEFAULT '', lighter_api_key_private_key TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, user_id), PRIMARY KEY (id, user_id)
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) )
`) `)
if err != nil { if err != nil {
+5 -4
View File
@@ -69,7 +69,11 @@ func (s *PositionStore) InitTables() error {
return fmt.Errorf("创建trader_positions表失败: %w", err) 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{ indices := []string{
`CREATE INDEX IF NOT EXISTS idx_positions_trader ON trader_positions(trader_id)`, `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)`, `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 return nil
} }
+17
View File
@@ -27,6 +27,7 @@ type Store struct {
backtest *BacktestStore backtest *BacktestStore
order *OrderStore order *OrderStore
position *PositionStore position *PositionStore
strategy *StrategyStore
// 加密函数 // 加密函数
encryptFunc func(string) string encryptFunc func(string) string
@@ -151,6 +152,9 @@ func (s *Store) initTables() error {
if err := s.Position().InitTables(); err != nil { if err := s.Position().InitTables(); err != nil {
return fmt.Errorf("初始化仓位表失败: %w", err) return fmt.Errorf("初始化仓位表失败: %w", err)
} }
if err := s.Strategy().initTables(); err != nil {
return fmt.Errorf("初始化策略表失败: %w", err)
}
return nil return nil
} }
@@ -165,6 +169,9 @@ func (s *Store) initDefaultData() error {
if err := s.SystemConfig().initDefaultData(); err != nil { if err := s.SystemConfig().initDefaultData(); err != nil {
return err return err
} }
if err := s.Strategy().initDefaultData(); err != nil {
return err
}
return nil return nil
} }
@@ -289,6 +296,16 @@ func (s *Store) Position() *PositionStore {
return s.position 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 关闭数据库连接 // Close 关闭数据库连接
func (s *Store) Close() error { func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
+452
View File
@@ -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
}
-4
View File
@@ -36,13 +36,9 @@ func (s *SystemConfigStore) initDefaultData() error {
configs := map[string]string{ configs := map[string]string{
"beta_mode": "false", "beta_mode": "false",
"api_server_port": "8080", "api_server_port": "8080",
"use_default_coins": "true",
"default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`,
"max_daily_loss": "10.0", "max_daily_loss": "10.0",
"max_drawdown": "20.0", "max_drawdown": "20.0",
"stop_trading_minutes": "60", "stop_trading_minutes": "60",
"btc_eth_leverage": "5",
"altcoin_leverage": "5",
"jwt_secret": "", "jwt_secret": "",
"registration_enabled": "true", "registration_enabled": "true",
} }
+122 -51
View File
@@ -18,32 +18,36 @@ type TraderStore struct {
// Trader 交易员配置 // Trader 交易员配置
type Trader struct { type Trader struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
AIModelID string `json:"ai_model_id"` AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"` ExchangeID string `json:"exchange_id"`
InitialBalance float64 `json:"initial_balance"` StrategyID string `json:"strategy_id"` // 关联策略ID
ScanIntervalMinutes int `json:"scan_interval_minutes"` InitialBalance float64 `json:"initial_balance"`
IsRunning bool `json:"is_running"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
BTCETHLeverage int `json:"btc_eth_leverage"` IsRunning bool `json:"is_running"`
AltcoinLeverage int `json:"altcoin_leverage"` IsCrossMargin bool `json:"is_cross_margin"`
TradingSymbols string `json:"trading_symbols"` CreatedAt time.Time `json:"created_at"`
UseCoinPool bool `json:"use_coin_pool"` UpdatedAt time.Time `json:"updated_at"`
UseOITop bool `json:"use_oi_top"`
CustomPrompt string `json:"custom_prompt"` // 以下字段已废弃,保留用于向后兼容,新交易员应使用 StrategyID
OverrideBasePrompt bool `json:"override_base_prompt"` BTCETHLeverage int `json:"btc_eth_leverage,omitempty"`
SystemPromptTemplate string `json:"system_prompt_template"` AltcoinLeverage int `json:"altcoin_leverage,omitempty"`
IsCrossMargin bool `json:"is_cross_margin"` TradingSymbols string `json:"trading_symbols,omitempty"`
CreatedAt time.Time `json:"created_at"` UseCoinPool bool `json:"use_coin_pool,omitempty"`
UpdatedAt time.Time `json:"updated_at"` 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 { type TraderFullConfig struct {
Trader *Trader Trader *Trader
AIModel *AIModel AIModel *AIModel
Exchange *Exchange Exchange *Exchange
Strategy *Strategy // 关联的策略配置
} }
func (s *TraderStore) initTables() error { 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_coin_pool BOOLEAN DEFAULT 0`,
`ALTER TABLE traders ADD COLUMN use_oi_top 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 system_prompt_template TEXT DEFAULT 'default'`,
`ALTER TABLE traders ADD COLUMN strategy_id TEXT DEFAULT ''`,
} }
for _, q := range alterQueries { for _, q := range alterQueries {
s.db.Exec(q) s.db.Exec(q)
@@ -116,25 +121,27 @@ func (s *TraderStore) decrypt(encrypted string) string {
// Create 创建交易员 // Create 创建交易员
func (s *TraderStore) Create(trader *Trader) error { func (s *TraderStore) Create(trader *Trader) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, strategy_id, initial_balance,
is_running, btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool, scan_interval_minutes, is_running, is_cross_margin,
use_oi_top, custom_prompt, override_base_prompt, system_prompt_template, is_cross_margin) btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) use_oi_top, custom_prompt, override_base_prompt, system_prompt_template)
`, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
trader.ScanIntervalMinutes, trader.IsRunning, trader.BTCETHLeverage, trader.AltcoinLeverage, `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID,
trader.TradingSymbols, trader.UseCoinPool, trader.UseOITop, trader.CustomPrompt, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.IsCrossMargin,
trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin) trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.UseCoinPool,
trader.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate)
return err return err
} }
// List 获取用户的交易员列表 // List 获取用户的交易员列表
func (s *TraderStore) List(userID string) ([]*Trader, error) { func (s *TraderStore) List(userID string) ([]*Trader, error) {
rows, err := s.db.Query(` 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(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(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), 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 FROM traders WHERE user_id = ? ORDER BY created_at DESC
`, userID) `, userID)
if err != nil { if err != nil {
@@ -147,11 +154,11 @@ func (s *TraderStore) List(userID string) ([]*Trader, error) {
var t Trader var t Trader
var createdAt, updatedAt string var createdAt, updatedAt string
err := rows.Scan( err := rows.Scan(
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID,
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin,
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
&t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt, &t.SystemPromptTemplate, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -173,15 +180,12 @@ func (s *TraderStore) UpdateStatus(userID, id string, isRunning bool) error {
func (s *TraderStore) Update(trader *Trader) error { func (s *TraderStore) Update(trader *Trader) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
UPDATE traders SET UPDATE traders SET
name = ?, ai_model_id = ?, exchange_id = ?, scan_interval_minutes = ?, name = ?, ai_model_id = ?, exchange_id = ?, strategy_id = ?,
btc_eth_leverage = ?, altcoin_leverage = ?, trading_symbols = ?, scan_interval_minutes = ?, is_cross_margin = ?,
custom_prompt = ?, override_base_prompt = ?, system_prompt_template = ?, updated_at = CURRENT_TIMESTAMP
is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
`, trader.Name, trader.AIModelID, trader.ExchangeID, trader.ScanIntervalMinutes, `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID,
trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.ScanIntervalMinutes, trader.IsCrossMargin, trader.ID, trader.UserID)
trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate,
trader.IsCrossMargin, trader.ID, trader.UserID)
return err return err
} }
@@ -215,11 +219,12 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT 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.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.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.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, 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, 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, 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 JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id
WHERE t.id = ? AND t.user_id = ? WHERE t.id = ? AND t.user_id = ?
`, traderID, userID).Scan( `, traderID, userID).Scan(
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, &trader.StrategyID,
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, &trader.IsCrossMargin,
&trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols, &trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols,
&trader.UseCoinPool, &trader.UseOITop, &trader.CustomPrompt, &trader.OverrideBasePrompt, &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.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
&aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt, &aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt,
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, &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.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey)
exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey) 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{ return &TraderFullConfig{
Trader: &trader, Trader: &trader,
AIModel: &aiModel, AIModel: &aiModel,
Exchange: &exchange, Exchange: &exchange,
Strategy: strategy,
}, nil }, 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 获取所有交易员自定义币种 // GetCustomCoins 获取所有交易员自定义币种
func (s *TraderStore) GetCustomCoins() []string { func (s *TraderStore) GetCustomCoins() []string {
var symbol string var symbol string
@@ -310,11 +380,12 @@ func (s *TraderStore) GetCustomCoins() []string {
// ListAll 获取所有用户的交易员列表 // ListAll 获取所有用户的交易员列表
func (s *TraderStore) ListAll() ([]*Trader, error) { func (s *TraderStore) ListAll() ([]*Trader, error) {
rows, err := s.db.Query(` 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(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(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), 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 FROM traders ORDER BY created_at DESC
`) `)
if err != nil { if err != nil {
@@ -327,11 +398,11 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
var t Trader var t Trader
var createdAt, updatedAt string var createdAt, updatedAt string
err := rows.Scan( err := rows.Scan(
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID,
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin,
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
&t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt, &t.SystemPromptTemplate, &createdAt, &updatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
+55 -135
View File
@@ -3,12 +3,11 @@ package trader
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"nofx/logger"
"math" "math"
"nofx/decision" "nofx/decision"
"nofx/logger"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
"nofx/pool"
"nofx/store" "nofx/store"
"strings" "strings"
"sync" "sync"
@@ -49,8 +48,6 @@ type AutoTraderConfig struct {
LighterAPIKeyPrivateKey string // LIGHTER API Key私钥(40字节,用于签名交易) LighterAPIKeyPrivateKey string // LIGHTER API Key私钥(40字节,用于签名交易)
LighterTestnet bool // 是否使用testnet LighterTestnet bool // 是否使用testnet
CoinPoolAPIURL string
// AI配置 // AI配置
UseQwen bool UseQwen bool
DeepSeekKey string DeepSeekKey string
@@ -67,10 +64,6 @@ type AutoTraderConfig struct {
// 账户配置 // 账户配置
InitialBalance float64 // 初始金额(用于计算盈亏,需手动设置) InitialBalance float64 // 初始金额(用于计算盈亏,需手动设置)
// 杠杆配置
BTCETHLeverage int // BTC和ETH的杠杆倍数
AltcoinLeverage int // 山寨币的杠杆倍数
// 风险控制(仅作为提示,AI可自主决定) // 风险控制(仅作为提示,AI可自主决定)
MaxDailyLoss float64 // 最大日亏损百分比(提示) MaxDailyLoss float64 // 最大日亏损百分比(提示)
MaxDrawdown float64 // 最大回撤百分比(提示) MaxDrawdown float64 // 最大回撤百分比(提示)
@@ -79,12 +72,8 @@ type AutoTraderConfig struct {
// 仓位模式 // 仓位模式
IsCrossMargin bool // true=全仓模式, false=逐仓模式 IsCrossMargin bool // true=全仓模式, false=逐仓模式
// 币种配置 // 策略配置(使用完整策略配置)
DefaultCoins []string // 默认币种列表(从数据库获取 StrategyConfig *store.StrategyConfig // 策略配置(包含币种来源、指标、风控、Prompt等
TradingCoins []string // 实际交易币种列表
// 系统提示词模板
SystemPromptTemplate string // 系统提示词模板名称(如 "default", "aggressive"
} }
// AutoTrader 自动交易器 // AutoTrader 自动交易器
@@ -96,15 +85,13 @@ type AutoTrader struct {
config AutoTraderConfig config AutoTraderConfig
trader Trader // 使用Trader接口(支持多平台) trader Trader // 使用Trader接口(支持多平台)
mcpClient mcp.AIClient mcpClient mcp.AIClient
store *store.Store // 数据存储(决策记录等) store *store.Store // 数据存储(决策记录等)
cycleNumber int // 当前周期编号 strategyEngine *decision.StrategyEngine // 策略引擎(使用策略配置)
cycleNumber int // 当前周期编号
initialBalance float64 initialBalance float64
dailyPnL float64 dailyPnL float64
customPrompt string // 自定义交易策略prompt customPrompt string // 自定义交易策略prompt
overrideBasePrompt bool // 是否覆盖基础prompt overrideBasePrompt bool // 是否覆盖基础prompt
systemPromptTemplate string // 系统提示词模板名称
defaultCoins []string // 默认币种列表(从数据库获取)
tradingCoins []string // 实际交易币种列表
lastResetTime time.Time lastResetTime time.Time
stopUntil time.Time stopUntil time.Time
isRunning bool 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 == "" { if config.Exchange == "" {
config.Exchange = "binance" config.Exchange = "binance"
@@ -243,12 +225,12 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
logger.Infof("📊 [%s] 决策记录将存储到数据库", config.Name) logger.Infof("📊 [%s] 决策记录将存储到数据库", config.Name)
} }
// 设置默认系统提示词模板 // 创建策略引擎(必须有策略配置)
systemPromptTemplate := config.SystemPromptTemplate if config.StrategyConfig == nil {
if systemPromptTemplate == "" { return nil, fmt.Errorf("[%s] 未配置策略", config.Name)
// feature/partial-close-dynamic-tpsl 分支默认使用 adaptive(支持动态止盈止损)
systemPromptTemplate = "adaptive"
} }
strategyEngine := decision.NewStrategyEngine(config.StrategyConfig)
logger.Infof("✓ [%s] 使用策略引擎(策略配置已加载)", config.Name)
return &AutoTrader{ return &AutoTrader{
id: config.ID, id: config.ID,
@@ -259,11 +241,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
trader: trader, trader: trader,
mcpClient: mcpClient, mcpClient: mcpClient,
store: st, store: st,
strategyEngine: strategyEngine,
cycleNumber: cycleNumber, cycleNumber: cycleNumber,
initialBalance: config.InitialBalance, initialBalance: config.InitialBalance,
systemPromptTemplate: systemPromptTemplate,
defaultCoins: config.DefaultCoins,
tradingCoins: config.TradingCoins,
lastResetTime: time.Now(), lastResetTime: time.Now(),
startTime: time.Now(), startTime: time.Now(),
callCount: 0, callCount: 0,
@@ -400,24 +380,24 @@ func (at *AutoTrader) runCycle() error {
logger.Infof("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d", logger.Infof("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d",
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount) ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
// 5. 调用AI获取完整决策 // 5. 使用策略引擎调用AI获取决策
logger.Infof("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate) logger.Infof("🤖 正在请求AI分析并决策... [策略引擎]")
decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt, at.systemPromptTemplate) aiDecision, err := decision.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
if decision != nil && decision.AIRequestDurationMs > 0 { if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
record.AIRequestDurationMs = decision.AIRequestDurationMs record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
logger.Infof("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000) logger.Infof("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000)
record.ExecutionLog = append(record.ExecutionLog, record.ExecutionLog = append(record.ExecutionLog,
fmt.Sprintf("AI调用耗时: %d ms", record.AIRequestDurationMs)) fmt.Sprintf("AI调用耗时: %d ms", record.AIRequestDurationMs))
} }
// 即使有错误,也保存思维链、决策和输入prompt(用于debug // 即使有错误,也保存思维链、决策和输入prompt(用于debug
if decision != nil { if aiDecision != nil {
record.SystemPrompt = decision.SystemPrompt // 保存系统提示词 record.SystemPrompt = aiDecision.SystemPrompt // 保存系统提示词
record.InputPrompt = decision.UserPrompt record.InputPrompt = aiDecision.UserPrompt
record.CoTTrace = decision.CoTTrace record.CoTTrace = aiDecision.CoTTrace
if len(decision.Decisions) > 0 { if len(aiDecision.Decisions) > 0 {
decisionJSON, _ := json.MarshalIndent(decision.Decisions, "", " ") decisionJSON, _ := json.MarshalIndent(aiDecision.Decisions, "", " ")
record.DecisionJSON = string(decisionJSON) record.DecisionJSON = string(decisionJSON)
} }
} }
@@ -427,18 +407,18 @@ func (at *AutoTrader) runCycle() error {
record.ErrorMessage = fmt.Sprintf("获取AI决策失败: %v", err) record.ErrorMessage = fmt.Sprintf("获取AI决策失败: %v", err)
// 打印系统提示词和AI思维链(即使有错误,也要输出以便调试) // 打印系统提示词和AI思维链(即使有错误,也要输出以便调试)
if decision != nil { if aiDecision != nil {
logger.Info("\n" + strings.Repeat("=", 70) + "\n") logger.Info("\n" + strings.Repeat("=", 70) + "\n")
logger.Infof("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate) logger.Infof("📋 系统提示词 (错误情况)")
logger.Info(strings.Repeat("=", 70)) logger.Info(strings.Repeat("=", 70))
logger.Info(decision.SystemPrompt) logger.Info(aiDecision.SystemPrompt)
logger.Info(strings.Repeat("=", 70)) logger.Info(strings.Repeat("=", 70))
if decision.CoTTrace != "" { if aiDecision.CoTTrace != "" {
logger.Info("\n" + strings.Repeat("-", 70) + "\n") logger.Info("\n" + strings.Repeat("-", 70) + "\n")
logger.Info("💭 AI思维链分析(错误情况):") logger.Info("💭 AI思维链分析(错误情况):")
logger.Info(strings.Repeat("-", 70)) logger.Info(strings.Repeat("-", 70))
logger.Info(decision.CoTTrace) logger.Info(aiDecision.CoTTrace)
logger.Info(strings.Repeat("-", 70)) logger.Info(strings.Repeat("-", 70))
} }
} }
@@ -476,7 +456,7 @@ func (at *AutoTrader) runCycle() error {
logger.Info(strings.Repeat("-", 70)) logger.Info(strings.Repeat("-", 70))
// 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限) // 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限)
sortedDecisions := sortDecisionsByPriority(decision.Decisions) sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
logger.Info("🔄 执行顺序(已优化): 先平仓→后开仓") logger.Info("🔄 执行顺序(已优化): 先平仓→后开仓")
for i, d := range sortedDecisions { for i, d := range sortedDecisions {
@@ -622,11 +602,15 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
} }
} }
// 3. 获取交易员的候选币种池 // 3. 使用策略引擎获取候选币种(必须有策略引擎)
candidateCoins, err := at.getCandidateCoins() if at.strategyEngine == nil {
return nil, fmt.Errorf("交易员未配置策略引擎")
}
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
if err != nil { if err != nil {
return nil, fmt.Errorf("获取候选币种失败: %w", err) return nil, fmt.Errorf("获取候选币种失败: %w", err)
} }
logger.Infof("📋 [%s] 策略引擎获取候选币种: %d个", at.name, len(candidateCoins))
// 4. 计算总盈亏 // 4. 计算总盈亏
totalPnL := totalEquity - at.initialBalance totalPnL := totalEquity - at.initialBalance
@@ -640,13 +624,19 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
marginUsedPct = (totalMarginUsed / totalEquity) * 100 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{ ctx := &decision.Context{
CurrentTime: time.Now().Format("2006-01-02 15:04:05"), CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
RuntimeMinutes: int(time.Since(at.startTime).Minutes()), RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
CallCount: at.callCount, CallCount: at.callCount,
BTCETHLeverage: at.config.BTCETHLeverage, // 使用配置的杠杆倍数 BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: at.config.AltcoinLeverage, // 使用配置的杠杆倍数 AltcoinLeverage: altcoinLeverage,
Account: decision.AccountInfo{ Account: decision.AccountInfo{
TotalEquity: totalEquity, TotalEquity: totalEquity,
AvailableBalance: availableBalance, AvailableBalance: availableBalance,
@@ -661,7 +651,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
CandidateCoins: candidateCoins, CandidateCoins: candidateCoins,
} }
// 6. 添加交易统计和历史订单(如果store可用) // 7. 添加交易统计和历史订单(如果store可用)
if at.store != nil { if at.store != nil {
// 获取交易统计(使用新的 positions 表) // 获取交易统计(使用新的 positions 表)
if stats, err := at.store.Position().GetFullStats(at.id); err == nil { if stats, err := at.store.Position().GetFullStats(at.id); err == nil {
@@ -989,14 +979,15 @@ func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
at.overrideBasePrompt = override at.overrideBasePrompt = override
} }
// SetSystemPromptTemplate 设置系统提示词模板 // GetSystemPromptTemplate 获取当前系统提示词模板名称(从策略配置获取)
func (at *AutoTrader) SetSystemPromptTemplate(templateName string) {
at.systemPromptTemplate = templateName
}
// GetSystemPromptTemplate 获取当前系统提示词模板名称
func (at *AutoTrader) GetSystemPromptTemplate() string { func (at *AutoTrader) GetSystemPromptTemplate() string {
return at.systemPromptTemplate if at.strategyEngine != nil {
config := at.strategyEngine.GetConfig()
if config.CustomPrompt != "" {
return "custom"
}
}
return "strategy"
} }
// saveDecision 保存决策记录到数据库 // saveDecision 保存决策记录到数据库
@@ -1235,77 +1226,6 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
return sorted 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() { func (at *AutoTrader) startDrawdownMonitor() {
at.monitorWg.Add(1) at.monitorWg.Add(1)
+16
View File
@@ -9,6 +9,7 @@ import { ResetPasswordPage } from './components/ResetPasswordPage'
import { CompetitionPage } from './components/CompetitionPage' import { CompetitionPage } from './components/CompetitionPage'
import { LandingPage } from './pages/LandingPage' import { LandingPage } from './pages/LandingPage'
import { FAQPage } from './pages/FAQPage' import { FAQPage } from './pages/FAQPage'
import { StrategyStudioPage } from './pages/StrategyStudioPage'
import HeaderBar from './components/HeaderBar' import HeaderBar from './components/HeaderBar'
import { LanguageProvider, useLanguage } from './contexts/LanguageContext' import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
@@ -31,6 +32,7 @@ type Page =
| 'traders' | 'traders'
| 'trader' | 'trader'
| 'backtest' | 'backtest'
| 'strategy'
| 'faq' | 'faq'
| 'login' | 'login'
| 'register' | 'register'
@@ -62,6 +64,7 @@ function App() {
if (path === '/traders' || hash === 'traders') return 'traders' if (path === '/traders' || hash === 'traders') return 'traders'
if (path === '/backtest' || hash === 'backtest') return 'backtest' if (path === '/backtest' || hash === 'backtest') return 'backtest'
if (path === '/strategy' || hash === 'strategy') return 'strategy'
if (path === '/dashboard' || hash === 'trader' || hash === 'details') if (path === '/dashboard' || hash === 'trader' || hash === 'details')
return 'trader' return 'trader'
return 'competition' // 默认为竞赛页面 return 'competition' // 默认为竞赛页面
@@ -81,6 +84,8 @@ function App() {
setCurrentPage('traders') setCurrentPage('traders')
} else if (path === '/backtest' || hash === 'backtest') { } else if (path === '/backtest' || hash === 'backtest') {
setCurrentPage('backtest') setCurrentPage('backtest')
} else if (path === '/strategy' || hash === 'strategy') {
setCurrentPage('strategy')
} else if ( } else if (
path === '/dashboard' || path === '/dashboard' ||
hash === 'trader' || hash === 'trader' ||
@@ -291,6 +296,11 @@ function App() {
window.history.pushState({}, '', '/backtest') window.history.pushState({}, '', '/backtest')
setRoute('/backtest') setRoute('/backtest')
setCurrentPage('backtest') setCurrentPage('backtest')
} else if (page === 'strategy') {
console.log('Navigating to strategy')
window.history.pushState({}, '', '/strategy')
setRoute('/strategy')
setCurrentPage('strategy')
} }
console.log( console.log(
@@ -384,6 +394,10 @@ function App() {
window.history.pushState({}, '', '/backtest') window.history.pushState({}, '', '/backtest')
setRoute('/backtest') setRoute('/backtest')
setCurrentPage('backtest') setCurrentPage('backtest')
} else if (page === 'strategy') {
window.history.pushState({}, '', '/strategy')
setRoute('/strategy')
setCurrentPage('strategy')
} else if (page === 'faq') { } else if (page === 'faq') {
window.history.pushState({}, '', '/faq') window.history.pushState({}, '', '/faq')
setRoute('/faq') setRoute('/faq')
@@ -406,6 +420,8 @@ function App() {
/> />
) : currentPage === 'backtest' ? ( ) : currentPage === 'backtest' ? (
<BacktestPage /> <BacktestPage />
) : currentPage === 'strategy' ? (
<StrategyStudioPage />
) : ( ) : (
<TraderDetailsPage <TraderDetailsPage
selectedTrader={selectedTrader} selectedTrader={selectedTrader}
+110
View File
@@ -11,6 +11,7 @@ type Page =
| 'traders' | 'traders'
| 'trader' | 'trader'
| 'backtest' | 'backtest'
| 'strategy'
| 'faq' | 'faq'
| 'login' | 'login'
| 'register' | 'register'
@@ -225,6 +226,47 @@ export default function HeaderBar({
{t('dashboardNav', language)} {t('dashboardNav', language)}
</button> </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 <button
onClick={() => { onClick={() => {
if (onPageChange) { if (onPageChange) {
@@ -780,6 +822,74 @@ export default function HeaderBar({
{t('dashboardNav', language)} {t('dashboardNav', language)}
</button> </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 <button
onClick={() => { onClick={() => {
if (onPageChange) { if (onPageChange) {
+164 -478
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react' 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 { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations' import { t } from '../i18n/translations'
import { toast } from 'sonner' 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' import { httpClient } from '../lib/httpClient'
// 提取下划线后面的名称部分 // 提取下划线后面的名称部分
@@ -12,22 +12,18 @@ function getShortName(fullName: string): string {
return parts.length > 1 ? parts[parts.length - 1] : fullName return parts.length > 1 ? parts[parts.length - 1] : fullName
} }
interface TraderConfigData { import type { TraderConfigData } from '../types'
// 表单内部状态类型
interface FormState {
trader_id?: string trader_id?: string
trader_name: string trader_name: string
ai_model: string ai_model: string
exchange_id: string exchange_id: string
btc_eth_leverage: number strategy_id: string
altcoin_leverage: number
trading_symbols: string
custom_prompt: string
override_base_prompt: boolean
system_prompt_template: string
is_cross_margin: boolean is_cross_margin: boolean
use_coin_pool: boolean
use_oi_top: boolean
initial_balance?: number // 可选:创建时不需要,编辑时使用
scan_interval_minutes: number scan_interval_minutes: number
initial_balance?: number
} }
interface TraderConfigModalProps { interface TraderConfigModalProps {
@@ -50,154 +46,68 @@ export function TraderConfigModal({
onSave, onSave,
}: TraderConfigModalProps) { }: TraderConfigModalProps) {
const { language } = useLanguage() const { language } = useLanguage()
const [formData, setFormData] = useState<TraderConfigData>({ const [formData, setFormData] = useState<FormState>({
trader_name: '', trader_name: '',
ai_model: '', ai_model: '',
exchange_id: '', exchange_id: '',
btc_eth_leverage: 5, strategy_id: '',
altcoin_leverage: 3,
trading_symbols: '',
custom_prompt: '',
override_base_prompt: false,
system_prompt_template: 'default',
is_cross_margin: true, is_cross_margin: true,
use_coin_pool: false,
use_oi_top: false,
scan_interval_minutes: 3, scan_interval_minutes: 3,
}) })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [availableCoins, setAvailableCoins] = useState<string[]>([]) const [strategies, setStrategies] = useState<Strategy[]>([])
const [selectedCoins, setSelectedCoins] = useState<string[]>([])
const [showCoinSelector, setShowCoinSelector] = useState(false)
const [promptTemplates, setPromptTemplates] = useState<{ name: string }[]>([])
const [isFetchingBalance, setIsFetchingBalance] = useState(false) const [isFetchingBalance, setIsFetchingBalance] = useState(false)
const [balanceFetchError, setBalanceFetchError] = useState<string>('') 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(() => { useEffect(() => {
if (traderData) { if (traderData) {
setFormData(traderData) setFormData({
// 设置已选择的币种 ...traderData,
if (traderData.trading_symbols) { strategy_id: traderData.strategy_id || '',
const coins = traderData.trading_symbols })
.split(',')
.map((s) => s.trim())
.filter((s) => s)
setSelectedCoins(coins)
}
} else if (!isEditMode) { } else if (!isEditMode) {
setFormData({ setFormData({
trader_name: '', trader_name: '',
ai_model: availableModels[0]?.id || '', ai_model: availableModels[0]?.id || '',
exchange_id: availableExchanges[0]?.id || '', exchange_id: availableExchanges[0]?.id || '',
btc_eth_leverage: 5, strategy_id: '',
altcoin_leverage: 3,
trading_symbols: '',
custom_prompt: '',
override_base_prompt: false,
system_prompt_template: 'default',
is_cross_margin: true, is_cross_margin: true,
use_coin_pool: false,
use_oi_top: false,
initial_balance: 1000,
scan_interval_minutes: 3, 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]) }, [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 if (!isOpen) return null
const handleInputChange = (field: keyof TraderConfigData, value: any) => { const handleInputChange = (field: keyof FormState, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value })) 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 () => { const handleFetchCurrentBalance = async () => {
@@ -216,11 +126,8 @@ export function TraderConfigModal({
}>(`/api/account?trader_id=${traderData.trader_id}`) }>(`/api/account?trader_id=${traderData.trader_id}`)
if (result.success && result.data) { if (result.success && result.data) {
// total_equity = 当前账户净值(包含未实现盈亏)
// 这应该作为新的初始余额
const currentBalance = const currentBalance =
result.data.total_equity || result.data.balance || 0 result.data.total_equity || result.data.balance || 0
setFormData((prev) => ({ ...prev, initial_balance: currentBalance })) setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
toast.success('已获取当前余额') toast.success('已获取当前余额')
} else { } else {
@@ -229,7 +136,6 @@ export function TraderConfigModal({
} catch (error) { } catch (error) {
console.error('获取余额失败:', error) console.error('获取余额失败:', error)
setBalanceFetchError('获取余额失败,请检查网络连接') setBalanceFetchError('获取余额失败,请检查网络连接')
// Note: Network/system errors already shown via toast by httpClient
} finally { } finally {
setIsFetchingBalance(false) setIsFetchingBalance(false)
} }
@@ -244,19 +150,12 @@ export function TraderConfigModal({
name: formData.trader_name, name: formData.trader_name,
ai_model_id: formData.ai_model, ai_model_id: formData.ai_model,
exchange_id: formData.exchange_id, exchange_id: formData.exchange_id,
btc_eth_leverage: formData.btc_eth_leverage, strategy_id: formData.strategy_id || undefined,
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,
is_cross_margin: formData.is_cross_margin, 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, scan_interval_minutes: formData.scan_interval_minutes,
} }
// 只在编辑模式时包含initial_balance(用于手动更新) // 只在编辑模式时包含initial_balance
if (isEditMode && formData.initial_balance !== undefined) { if (isEditMode && formData.initial_balance !== undefined) {
saveData.initial_balance = formData.initial_balance saveData.initial_balance = formData.initial_balance
} }
@@ -274,10 +173,12 @@ export function TraderConfigModal({
} }
} }
const selectedStrategy = strategies.find(s => s.id === formData.strategy_id)
return ( 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="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-y-auto">
<div <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)' }} style={{ maxHeight: 'calc(100vh - 4rem)' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -296,7 +197,7 @@ export function TraderConfigModal({
{isEditMode ? '修改交易员' : '创建交易员'} {isEditMode ? '修改交易员' : '创建交易员'}
</h2> </h2>
<p className="text-sm text-[#848E9C] mt-1"> <p className="text-sm text-[#848E9C] mt-1">
{isEditMode ? '修改交易员配置参数' : '配置新的AI交易员'} {isEditMode ? '修改交易员配置' : '选择策略并配置基础参数'}
</p> </p>
</div> </div>
</div> </div>
@@ -310,18 +211,18 @@ export function TraderConfigModal({
{/* Content */} {/* Content */}
<div <div
className="p-6 space-y-8 overflow-y-auto" className="p-6 space-y-6 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 16rem)' }} style={{ maxHeight: 'calc(100vh - 16rem)' }}
> >
{/* Basic Info */} {/* Basic Info */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5"> <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 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
🤖 <span className="text-[#F0B90B]">1</span>
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> <label className="text-sm text-[#EAECEF] block mb-2">
<span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -336,7 +237,7 @@ export function TraderConfigModal({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> <label className="text-sm text-[#EAECEF] block mb-2">
AI模型 AI模型 <span className="text-red-500">*</span>
</label> </label>
<select <select
value={formData.ai_model} value={formData.ai_model}
@@ -354,7 +255,7 @@ export function TraderConfigModal({
</div> </div>
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> <label className="text-sm text-[#EAECEF] block mb-2">
<span className="text-red-500">*</span>
</label> </label>
<select <select
value={formData.exchange_id} value={formData.exchange_id}
@@ -376,13 +277,77 @@ export function TraderConfigModal({
</div> </div>
</div> </div>
{/* Trading Configuration */} {/* Strategy Selection */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5"> <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 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> </h3>
<div className="space-y-4"> <div className="space-y-4">
{/* 第一行:保证金模式和初始余额 */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> <label className="text-sm text-[#EAECEF] block mb-2">
@@ -415,81 +380,6 @@ export function TraderConfigModal({
</button> </button>
</div> </div>
</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> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> <label className="text-sm text-[#EAECEF] block mb-2">
{t('aiScanInterval', language)} {t('aiScanInterval', language)}
@@ -513,242 +403,54 @@ export function TraderConfigModal({
{t('scanIntervalRecommend', language)} {t('scanIntervalRecommend', language)}
</p> </p>
</div> </div>
<div></div>
</div> </div>
{/* 第三行:杠杆设置 */} {/* Initial Balance (Edit mode only) */}
<div className="grid grid-cols-2 gap-4"> {isEditMode && (
<div> <div>
<label className="text-sm text-[#EAECEF] block mb-2"> <div className="flex items-center justify-between mb-2">
BTC/ETH <label className="text-sm text-[#EAECEF]">
</label> ($)
</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 <input
type="number" type="number"
value={formData.btc_eth_leverage} value={formData.initial_balance || 0}
onChange={(e) => onChange={(e) =>
handleInputChange( handleInputChange(
'btc_eth_leverage', 'initial_balance',
Number(e.target.value) 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" className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1" min="100"
max="125" 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>
<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>
{/* 第三行:交易币种 */} {/* Create mode info */}
<div> {!isEditMode && (
<div className="flex items-center justify-between mb-2"> <div className="p-3 bg-[#1E2329] border border-[#2B3139] rounded flex items-center gap-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">
<svg <svg
xmlns="http://www.w3.org/2000/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" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -756,34 +458,18 @@ export function TraderConfigModal({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="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" /> <circle cx="12" cy="12" r="10" />
<line x1="12" x2="12" y1="9" y2="13" /> <line x1="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="17" y2="17" /> <line x1="12" x2="12.01" y1="16" y2="16" />
</svg>{' '} </svg>
<span className="text-sm text-[#848E9C]">
</span>
</div> </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>
</div> </div>
</div> </div>
</div> </div>
{/* Footer */} {/* 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
View File
@@ -1,5 +1,4 @@
export { Tooltip } from './Tooltip' export { Tooltip } from './Tooltip'
export { SignalSourceModal } from './SignalSourceModal'
export { ModelConfigModal } from './ModelConfigModal' export { ModelConfigModal } from './ModelConfigModal'
export { ExchangeConfigModal } from './ExchangeConfigModal' export { ExchangeConfigModal } from './ExchangeConfigModal'
export { getModelDisplayName, getShortName } from './utils' 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' import { t, type Language } from '../../../i18n/translations'
interface PageHeaderProps { interface PageHeaderProps {
@@ -8,7 +8,6 @@ interface PageHeaderProps {
configuredExchangesCount: number configuredExchangesCount: number
onAddModel: () => void onAddModel: () => void
onAddExchange: () => void onAddExchange: () => void
onConfigureSignalSource: () => void
onCreateTrader: () => void onCreateTrader: () => void
} }
@@ -19,7 +18,6 @@ export function PageHeader({
configuredExchangesCount, configuredExchangesCount,
onAddModel, onAddModel,
onAddExchange, onAddExchange,
onConfigureSignalSource,
onCreateTrader, onCreateTrader,
}: PageHeaderProps) { }: PageHeaderProps) {
const canCreateTrader = const canCreateTrader =
@@ -86,19 +84,6 @@ export function PageHeader({
{t('exchanges', language)} {t('exchanges', language)}
</button> </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 <button
onClick={onCreateTrader} onClick={onCreateTrader}
disabled={!canCreateTrader} 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>
)
}
-26
View File
@@ -21,15 +21,10 @@ interface UseTraderActionsParams {
mutateTraders: () => Promise<any> mutateTraders: () => Promise<any>
setAllModels: (models: AIModel[]) => void setAllModels: (models: AIModel[]) => void
setAllExchanges: (exchanges: Exchange[]) => void setAllExchanges: (exchanges: Exchange[]) => void
setUserSignalSource: (config: {
coinPoolUrl: string
oiTopUrl: string
}) => void
setShowCreateModal: (show: boolean) => void setShowCreateModal: (show: boolean) => void
setShowEditModal: (show: boolean) => void setShowEditModal: (show: boolean) => void
setShowModelModal: (show: boolean) => void setShowModelModal: (show: boolean) => void
setShowExchangeModal: (show: boolean) => void setShowExchangeModal: (show: boolean) => void
setShowSignalSourceModal: (show: boolean) => void
setEditingModel: (modelId: string | null) => void setEditingModel: (modelId: string | null) => void
setEditingExchange: (exchangeId: string | null) => void setEditingExchange: (exchangeId: string | null) => void
editingTrader: TraderConfigData | null editingTrader: TraderConfigData | null
@@ -46,12 +41,10 @@ export function useTraderActions({
mutateTraders, mutateTraders,
setAllModels, setAllModels,
setAllExchanges, setAllExchanges,
setUserSignalSource,
setShowCreateModal, setShowCreateModal,
setShowEditModal, setShowEditModal,
setShowModelModal, setShowModelModal,
setShowExchangeModal, setShowExchangeModal,
setShowSignalSourceModal,
setEditingModel, setEditingModel,
setEditingExchange, setEditingExchange,
editingTrader, editingTrader,
@@ -605,24 +598,6 @@ export function useTraderActions({
setShowExchangeModal(true) 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 { return {
// 辅助函数 // 辅助函数
isModelInUse, isModelInUse,
@@ -646,6 +621,5 @@ export function useTraderActions({
handleDeleteModel, handleDeleteModel,
handleSaveExchange, handleSaveExchange,
handleDeleteExchange, handleDeleteExchange,
handleSaveSignalSource,
} }
} }
+2
View File
@@ -21,6 +21,7 @@ export const translations = {
realtimeNav: 'Live', realtimeNav: 'Live',
configNav: 'Config', configNav: 'Config',
dashboardNav: 'Dashboard', dashboardNav: 'Dashboard',
strategyNav: 'Strategy',
faqNav: 'FAQ', faqNav: 'FAQ',
// Footer // Footer
@@ -1017,6 +1018,7 @@ export const translations = {
realtimeNav: '实时', realtimeNav: '实时',
configNav: '配置', configNav: '配置',
dashboardNav: '看板', dashboardNav: '看板',
strategyNav: '策略',
faqNav: '常见问题', faqNav: '常见问题',
// Footer // Footer
+67
View File
@@ -19,6 +19,8 @@ import type {
BacktestTradeEvent, BacktestTradeEvent,
BacktestMetrics, BacktestMetrics,
BacktestRunMetadata, BacktestRunMetadata,
Strategy,
StrategyConfig,
} from '../types' } from '../types'
import { CryptoService } from './crypto' import { CryptoService } from './crypto'
import { httpClient } from './httpClient' import { httpClient } from './httpClient'
@@ -553,4 +555,69 @@ export const api = {
} }
return res.blob() 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!
},
} }
-34
View File
@@ -8,12 +8,10 @@ import { useTradersConfigStore, useTradersModalStore } from '../stores'
import { useTraderActions } from '../hooks/useTraderActions' import { useTraderActions } from '../hooks/useTraderActions'
import { TraderConfigModal } from '../components/TraderConfigModal' import { TraderConfigModal } from '../components/TraderConfigModal'
import { import {
SignalSourceModal,
ModelConfigModal, ModelConfigModal,
ExchangeConfigModal, ExchangeConfigModal,
} from '../components/traders' } from '../components/traders'
import { PageHeader } from '../components/traders/sections/PageHeader' import { PageHeader } from '../components/traders/sections/PageHeader'
import { SignalSourceWarning } from '../components/traders/sections/SignalSourceWarning'
import { AIModelsSection } from '../components/traders/sections/AIModelsSection' import { AIModelsSection } from '../components/traders/sections/AIModelsSection'
import { ExchangesSection } from '../components/traders/sections/ExchangesSection' import { ExchangesSection } from '../components/traders/sections/ExchangesSection'
import { TradersGrid } from '../components/traders/sections/TradersGrid' import { TradersGrid } from '../components/traders/sections/TradersGrid'
@@ -35,11 +33,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
supportedExchanges, supportedExchanges,
configuredModels, configuredModels,
configuredExchanges, configuredExchanges,
userSignalSource,
loadConfigs, loadConfigs,
setAllModels, setAllModels,
setAllExchanges, setAllExchanges,
setUserSignalSource,
} = useTradersConfigStore() } = useTradersConfigStore()
const { const {
@@ -47,7 +43,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
showEditModal, showEditModal,
showModelModal, showModelModal,
showExchangeModal, showExchangeModal,
showSignalSourceModal,
editingModel, editingModel,
editingExchange, editingExchange,
editingTrader, editingTrader,
@@ -55,7 +50,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setShowEditModal, setShowEditModal,
setShowModelModal, setShowModelModal,
setShowExchangeModal, setShowExchangeModal,
setShowSignalSourceModal,
setEditingModel, setEditingModel,
setEditingExchange, setEditingExchange,
setEditingTrader, setEditingTrader,
@@ -90,7 +84,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
handleDeleteModel, handleDeleteModel,
handleSaveExchange, handleSaveExchange,
handleDeleteExchange, handleDeleteExchange,
handleSaveSignalSource,
} = useTraderActions({ } = useTraderActions({
traders, traders,
allModels, allModels,
@@ -101,12 +94,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
mutateTraders, mutateTraders,
setAllModels, setAllModels,
setAllExchanges, setAllExchanges,
setUserSignalSource,
setShowCreateModal, setShowCreateModal,
setShowEditModal, setShowEditModal,
setShowModelModal, setShowModelModal,
setShowExchangeModal, setShowExchangeModal,
setShowSignalSourceModal,
setEditingModel, setEditingModel,
setEditingExchange, setEditingExchange,
editingTrader, editingTrader,
@@ -127,12 +118,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
return true return true
}) || [] }) || []
// 检查是否需要显示信号源警告
const showSignalWarning =
traders?.some((t) => t.use_coin_pool || t.use_oi_top) &&
!userSignalSource.coinPoolUrl &&
!userSignalSource.oiTopUrl
// 处理交易员查看 // 处理交易员查看
const handleTraderSelect = (traderId: string) => { const handleTraderSelect = (traderId: string) => {
if (onTraderSelect) { if (onTraderSelect) {
@@ -152,18 +137,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
configuredExchangesCount={configuredExchanges.length} configuredExchangesCount={configuredExchanges.length}
onAddModel={handleAddModel} onAddModel={handleAddModel}
onAddExchange={handleAddExchange} onAddExchange={handleAddExchange}
onConfigureSignalSource={() => setShowSignalSourceModal(true)}
onCreateTrader={() => setShowCreateModal(true)} onCreateTrader={() => setShowCreateModal(true)}
/> />
{/* Signal Source Warning */}
{showSignalWarning && (
<SignalSourceWarning
language={language}
onConfigure={() => setShowSignalSourceModal(true)}
/>
)}
{/* Configuration Status */} {/* Configuration Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
<AIModelsSection <AIModelsSection
@@ -233,16 +209,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
language={language} language={language}
/> />
)} )}
{showSignalSourceModal && (
<SignalSourceModal
coinPoolUrl={userSignalSource.coinPoolUrl}
oiTopUrl={userSignalSource.oiTopUrl}
onSave={handleSaveSignalSource}
onClose={() => setShowSignalSourceModal(false)}
language={language}
/>
)}
</div> </div>
) )
} }
+914
View File
@@ -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
-20
View File
@@ -2,18 +2,12 @@ import { create } from 'zustand'
import type { AIModel, Exchange } from '../types' import type { AIModel, Exchange } from '../types'
import { api } from '../lib/api' import { api } from '../lib/api'
interface SignalSource {
coinPoolUrl: string
oiTopUrl: string
}
interface TradersConfigState { interface TradersConfigState {
// 数据 // 数据
allModels: AIModel[] allModels: AIModel[]
allExchanges: Exchange[] allExchanges: Exchange[]
supportedModels: AIModel[] supportedModels: AIModel[]
supportedExchanges: Exchange[] supportedExchanges: Exchange[]
userSignalSource: SignalSource
// 计算属性 // 计算属性
configuredModels: AIModel[] configuredModels: AIModel[]
@@ -24,7 +18,6 @@ interface TradersConfigState {
setAllExchanges: (exchanges: Exchange[]) => void setAllExchanges: (exchanges: Exchange[]) => void
setSupportedModels: (models: AIModel[]) => void setSupportedModels: (models: AIModel[]) => void
setSupportedExchanges: (exchanges: Exchange[]) => void setSupportedExchanges: (exchanges: Exchange[]) => void
setUserSignalSource: (source: SignalSource) => void
// 异步加载 // 异步加载
loadConfigs: (user: any, token: string | null) => Promise<void> loadConfigs: (user: any, token: string | null) => Promise<void>
@@ -38,7 +31,6 @@ const initialState = {
allExchanges: [], allExchanges: [],
supportedModels: [], supportedModels: [],
supportedExchanges: [], supportedExchanges: [],
userSignalSource: { coinPoolUrl: '', oiTopUrl: '' },
configuredModels: [], configuredModels: [],
configuredExchanges: [], configuredExchanges: [],
} }
@@ -73,7 +65,6 @@ export const useTradersConfigStore = create<TradersConfigState>((set, get) => ({
setSupportedModels: (models) => set({ supportedModels: models }), setSupportedModels: (models) => set({ supportedModels: models }),
setSupportedExchanges: (exchanges) => set({ supportedExchanges: exchanges }), setSupportedExchanges: (exchanges) => set({ supportedExchanges: exchanges }),
setUserSignalSource: (source) => set({ userSignalSource: source }),
loadConfigs: async (user, token) => { loadConfigs: async (user, token) => {
if (!user || !token) { if (!user || !token) {
@@ -108,17 +99,6 @@ export const useTradersConfigStore = create<TradersConfigState>((set, get) => ({
get().setAllExchanges(exchangeConfigs) get().setAllExchanges(exchangeConfigs)
get().setSupportedModels(supportedModels) get().setSupportedModels(supportedModels)
get().setSupportedExchanges(supportedExchanges) 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) { } catch (error) {
console.error('Failed to load configs:', error) console.error('Failed to load configs:', error)
} }
-4
View File
@@ -7,7 +7,6 @@ interface TradersModalState {
showEditModal: boolean showEditModal: boolean
showModelModal: boolean showModelModal: boolean
showExchangeModal: boolean showExchangeModal: boolean
showSignalSourceModal: boolean
// 编辑状态 // 编辑状态
editingModel: string | null editingModel: string | null
@@ -19,7 +18,6 @@ interface TradersModalState {
setShowEditModal: (show: boolean) => void setShowEditModal: (show: boolean) => void
setShowModelModal: (show: boolean) => void setShowModelModal: (show: boolean) => void
setShowExchangeModal: (show: boolean) => void setShowExchangeModal: (show: boolean) => void
setShowSignalSourceModal: (show: boolean) => void
setEditingModel: (modelId: string | null) => void setEditingModel: (modelId: string | null) => void
setEditingExchange: (exchangeId: string | null) => void setEditingExchange: (exchangeId: string | null) => void
@@ -40,7 +38,6 @@ const initialState = {
showEditModal: false, showEditModal: false,
showModelModal: false, showModelModal: false,
showExchangeModal: false, showExchangeModal: false,
showSignalSourceModal: false,
editingModel: null, editingModel: null,
editingExchange: null, editingExchange: null,
editingTrader: null, editingTrader: null,
@@ -53,7 +50,6 @@ export const useTradersModalStore = create<TradersModalState>((set) => ({
setShowEditModal: (show) => set({ showEditModal: show }), setShowEditModal: (show) => set({ showEditModal: show }),
setShowModelModal: (show) => set({ showModelModal: show }), setShowModelModal: (show) => set({ showModelModal: show }),
setShowExchangeModal: (show) => set({ showExchangeModal: show }), setShowExchangeModal: (show) => set({ showExchangeModal: show }),
setShowSignalSourceModal: (show) => set({ showSignalSourceModal: show }),
setEditingModel: (modelId) => set({ editingModel: modelId }), setEditingModel: (modelId) => set({ editingModel: modelId }),
setEditingExchange: (exchangeId) => set({ editingExchange: exchangeId }), setEditingExchange: (exchangeId) => set({ editingExchange: exchangeId }),
+93 -5
View File
@@ -131,15 +131,17 @@ export interface CreateTraderRequest {
name: string name: string
ai_model_id: string ai_model_id: string
exchange_id: string exchange_id: string
strategy_id?: string // 策略ID(新版,使用保存的策略配置)
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新 initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
scan_interval_minutes?: number scan_interval_minutes?: number
is_cross_margin?: boolean
// 以下字段为向后兼容保留,新版使用策略配置
btc_eth_leverage?: number btc_eth_leverage?: number
altcoin_leverage?: number altcoin_leverage?: number
trading_symbols?: string trading_symbols?: string
custom_prompt?: string custom_prompt?: string
override_base_prompt?: boolean override_base_prompt?: boolean
system_prompt_template?: string system_prompt_template?: string
is_cross_margin?: boolean
use_coin_pool?: boolean use_coin_pool?: boolean
use_oi_top?: boolean use_oi_top?: boolean
} }
@@ -201,18 +203,20 @@ export interface TraderConfigData {
trader_name: string trader_name: string
ai_model: string ai_model: string
exchange_id: 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 btc_eth_leverage: number
altcoin_leverage: number altcoin_leverage: number
trading_symbols: string trading_symbols: string
custom_prompt: string custom_prompt: string
override_base_prompt: boolean override_base_prompt: boolean
system_prompt_template: string system_prompt_template: string
is_cross_margin: boolean
use_coin_pool: boolean use_coin_pool: boolean
use_oi_top: boolean use_oi_top: boolean
initial_balance: number
scan_interval_minutes: number
is_running: boolean
} }
// Backtest types // Backtest types
@@ -347,3 +351,87 @@ export interface BacktestStartConfig {
altcoin_leverage?: number; 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;
}