mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
feat: add OI ranking data support and fix trader config update issues
- Add OI ranking data fetching and formatting for AI prompts - Fix trader update not saving strategy_id, ai_model_id, initial_balance - Fix AI API key not set for non-qwen/deepseek providers - Add strategy_id to trader config API response - Remove old trader from memory before reloading on update - Clean up unused useTraderActions.ts
This commit is contained in:
@@ -66,6 +66,10 @@ type CreateDebateRequest struct {
|
||||
AutoExecute bool `json:"auto_execute"`
|
||||
TraderID string `json:"trader_id"`
|
||||
Participants []ParticipantConfig `json:"participants" binding:"required,min=2"`
|
||||
// OI Ranking data options
|
||||
EnableOIRanking bool `json:"enable_oi_ranking"` // Whether to include OI ranking data
|
||||
OIRankingLimit int `json:"oi_ranking_limit"` // Number of OI ranking entries (default 10)
|
||||
OIDuration string `json:"oi_duration"` // Duration for OI data (1h, 4h, 24h, etc.)
|
||||
}
|
||||
|
||||
// ParticipantConfig represents a participant configuration
|
||||
@@ -215,6 +219,9 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
||||
PromptVariant: req.PromptVariant,
|
||||
AutoExecute: req.AutoExecute,
|
||||
TraderID: req.TraderID,
|
||||
EnableOIRanking: req.EnableOIRanking,
|
||||
OIRankingLimit: req.OIRankingLimit,
|
||||
OIDuration: req.OIDuration,
|
||||
}
|
||||
|
||||
if err := h.debateStore.CreateSession(session); err != nil {
|
||||
|
||||
+55
-46
@@ -792,19 +792,24 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Update database
|
||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, req.StrategyID=%s",
|
||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, req.StrategyID)
|
||||
err = s.store.Trader().Update(traderRecord)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update trader: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload traders into memory
|
||||
// Remove old trader from memory first to ensure fresh config is loaded
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
|
||||
// Reload traders into memory with fresh config
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"trader_id": traderID,
|
||||
@@ -854,54 +859,57 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
// Trader not in memory, try loading from database
|
||||
logger.Infof("🔄 Trader %s not in memory, trying to load...", traderID)
|
||||
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
||||
logger.Infof("❌ Failed to load user traders: %v", loadErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
|
||||
return
|
||||
}
|
||||
// Try to get trader again
|
||||
trader, err = s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
// Check detailed reason
|
||||
fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if fullCfg != nil && fullCfg.Trader != nil {
|
||||
// Check strategy
|
||||
if fullCfg.Strategy == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader has no strategy configured, please create a strategy in Strategy Studio and associate it with the trader"})
|
||||
return
|
||||
}
|
||||
// Check AI model
|
||||
if fullCfg.AIModel == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model does not exist, please check AI model configuration"})
|
||||
return
|
||||
}
|
||||
if !fullCfg.AIModel.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model is not enabled, please enable the AI model first"})
|
||||
return
|
||||
}
|
||||
// Check exchange
|
||||
if fullCfg.Exchange == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange does not exist, please check exchange configuration"})
|
||||
return
|
||||
}
|
||||
if !fullCfg.Exchange.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange is not enabled, please enable the exchange first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
|
||||
// Check if trader exists in memory and if it's running
|
||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||
if existingTrader != nil {
|
||||
status := existingTrader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
||||
return
|
||||
}
|
||||
// Trader exists but is stopped - remove from memory to reload fresh config
|
||||
logger.Infof("🔄 Removing stopped trader %s from memory to reload config...", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Check if trader is already running
|
||||
status := trader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
||||
// Load trader from database (always reload to get latest config)
|
||||
logger.Infof("🔄 Loading trader %s from database...", traderID)
|
||||
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
||||
logger.Infof("❌ Failed to load user traders: %v", loadErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
// Check detailed reason
|
||||
fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if fullCfg != nil && fullCfg.Trader != nil {
|
||||
// Check strategy
|
||||
if fullCfg.Strategy == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader has no strategy configured, please create a strategy in Strategy Studio and associate it with the trader"})
|
||||
return
|
||||
}
|
||||
// Check AI model
|
||||
if fullCfg.AIModel == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model does not exist, please check AI model configuration"})
|
||||
return
|
||||
}
|
||||
if !fullCfg.AIModel.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model is not enabled, please enable the AI model first"})
|
||||
return
|
||||
}
|
||||
// Check exchange
|
||||
if fullCfg.Exchange == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange does not exist, please check exchange configuration"})
|
||||
return
|
||||
}
|
||||
if !fullCfg.Exchange.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange is not enabled, please enable the exchange first"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1730,6 +1738,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
"trader_name": traderConfig.Name,
|
||||
"ai_model": aiModelID,
|
||||
"exchange_id": traderConfig.ExchangeID,
|
||||
"strategy_id": traderConfig.StrategyID,
|
||||
"initial_balance": traderConfig.InitialBalance,
|
||||
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
|
||||
"btc_eth_leverage": traderConfig.BTCETHLeverage,
|
||||
|
||||
@@ -449,6 +449,16 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
marketDataMap[coin.Symbol] = data
|
||||
}
|
||||
|
||||
// Fetch quantitative data for each candidate coin
|
||||
symbols := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
symbols = append(symbols, c.Symbol)
|
||||
}
|
||||
quantDataMap := engine.FetchQuantDataBatch(symbols)
|
||||
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := engine.FetchOIRankingData()
|
||||
|
||||
// Build real context (for generating User Prompt)
|
||||
testContext := &decision.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
@@ -468,6 +478,8 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
}
|
||||
|
||||
// Build System Prompt
|
||||
|
||||
+5
-1
@@ -182,7 +182,7 @@ func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strate
|
||||
// Build system prompt based on strategy (same as AI Test)
|
||||
baseSystemPrompt := strategyEngine.BuildSystemPrompt(1000.0, session.PromptVariant)
|
||||
|
||||
// Build user prompt with market data
|
||||
// Build user prompt with market data (OI ranking data is included via ctx.OIRankingData)
|
||||
userPrompt := strategyEngine.BuildUserPrompt(ctx)
|
||||
|
||||
// Run debate rounds
|
||||
@@ -332,6 +332,9 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
||||
}
|
||||
quantDataMap := strategyEngine.FetchQuantDataBatch(symbols)
|
||||
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := strategyEngine.FetchOIRankingData()
|
||||
|
||||
// Build context
|
||||
ctx := &decision.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
@@ -352,6 +355,7 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
||||
PromptVariant: session.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
|
||||
@@ -120,6 +120,7 @@ type Context struct {
|
||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||
OITopDataMap map[string]*OITopData `json:"-"`
|
||||
QuantDataMap map[string]*QuantData `json:"-"`
|
||||
OIRankingData *pool.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||
BTCETHLeverage int `json:"-"`
|
||||
AltcoinLeverage int `json:"-"`
|
||||
Timeframes []string `json:"-"`
|
||||
@@ -642,6 +643,53 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
||||
return result
|
||||
}
|
||||
|
||||
// FetchOIRankingData fetches market-wide OI ranking data
|
||||
func (e *StrategyEngine) FetchOIRankingData() *pool.OIRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableOIRanking {
|
||||
return nil
|
||||
}
|
||||
|
||||
baseURL := indicators.OIRankingAPIURL
|
||||
if baseURL == "" {
|
||||
baseURL = "http://nofxaios.com:30006"
|
||||
}
|
||||
|
||||
// Get auth key from existing API URL or use default
|
||||
authKey := "cm_568c67eae410d912c54c"
|
||||
if indicators.QuantDataAPIURL != "" {
|
||||
if idx := strings.Index(indicators.QuantDataAPIURL, "auth="); idx != -1 {
|
||||
authKey = indicators.QuantDataAPIURL[idx+5:]
|
||||
if ampIdx := strings.Index(authKey, "&"); ampIdx != -1 {
|
||||
authKey = authKey[:ampIdx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duration := indicators.OIRankingDuration
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
|
||||
limit := indicators.OIRankingLimit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit)
|
||||
|
||||
data, err := pool.GetOIRankingData(baseURL, authKey, duration, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("✓ OI ranking data ready: %d top, %d low positions",
|
||||
len(data.TopPositions), len(data.LowPositions))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Building - System Prompt
|
||||
// ============================================================================
|
||||
@@ -904,6 +952,11 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// OI Ranking data (market-wide open interest changes)
|
||||
if ctx.OIRankingData != nil {
|
||||
sb.WriteString(pool.FormatOIRankingForAI(ctx.OIRankingData))
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
|
||||
|
||||
|
||||
@@ -676,10 +676,14 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
}
|
||||
|
||||
// Set API keys based on AI model
|
||||
if aiModelCfg.Provider == "qwen" {
|
||||
switch aiModelCfg.Provider {
|
||||
case "qwen":
|
||||
traderConfig.QwenKey = aiModelCfg.APIKey
|
||||
} else if aiModelCfg.Provider == "deepseek" {
|
||||
case "deepseek":
|
||||
traderConfig.DeepSeekKey = aiModelCfg.APIKey
|
||||
default:
|
||||
// For other providers (grok, openai, claude, gemini, kimi, etc.), use CustomAPIKey
|
||||
traderConfig.CustomAPIKey = aiModelCfg.APIKey
|
||||
}
|
||||
|
||||
// Create trader instance
|
||||
|
||||
@@ -590,6 +590,169 @@ type MergedCoinPool struct {
|
||||
SymbolSources map[string][]string // Source of each coin ("ai500"/"oi_top")
|
||||
}
|
||||
|
||||
// OIRankingData OI ranking data for debate (includes both top and low)
|
||||
type OIRankingData struct {
|
||||
TimeRange string `json:"time_range"` // e.g., "1小时"
|
||||
Duration string `json:"duration"` // e.g., "1h"
|
||||
TopPositions []OIPosition `json:"top_positions"` // 持仓增加排行
|
||||
LowPositions []OIPosition `json:"low_positions"` // 持仓减少排行
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetOIRankingData retrieves OI ranking data (both top increase and low decrease)
|
||||
// duration: "1h", "4h", "24h" etc. limit: number of results
|
||||
func GetOIRankingData(baseURL, authKey string, duration string, limit int) (*OIRankingData, error) {
|
||||
if baseURL == "" || authKey == "" {
|
||||
return nil, fmt.Errorf("OI API URL or auth key not configured")
|
||||
}
|
||||
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
result := &OIRankingData{
|
||||
Duration: duration,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch top ranking (持仓增加)
|
||||
topURL := fmt.Sprintf("%s/api/oi/top-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
|
||||
topPositions, timeRange, err := fetchOIRanking(topURL)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI top ranking: %v", err)
|
||||
} else {
|
||||
result.TopPositions = topPositions
|
||||
result.TimeRange = timeRange
|
||||
}
|
||||
|
||||
// Fetch low ranking (持仓减少)
|
||||
lowURL := fmt.Sprintf("%s/api/oi/low-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
|
||||
lowPositions, _, err := fetchOIRanking(lowURL)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI low ranking: %v", err)
|
||||
} else {
|
||||
result.LowPositions = lowPositions
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched OI ranking data: %d top, %d low (duration: %s)",
|
||||
len(result.TopPositions), len(result.LowPositions), duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchOIRanking fetches OI ranking from a single endpoint
|
||||
func fetchOIRanking(url string) ([]OIPosition, string, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var response OITopAPIResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return nil, "", fmt.Errorf("API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
return response.Data.Positions, response.Data.TimeRange, nil
|
||||
}
|
||||
|
||||
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||
func FormatOIRankingForAI(data *OIRankingData) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 📊 市场持仓量变化数据 (Open Interest Changes in %s / %s)\n\n", data.TimeRange, data.Duration))
|
||||
|
||||
// Top rankings (持仓增加)
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### 🔺 持仓量增加排行 (OI Increase Ranking)\n")
|
||||
sb.WriteString("市场资金正在流入以下币种,可能表示趋势延续或新仓位建立:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 | 多头 | 空头 |\n")
|
||||
sb.WriteString("|------|------|------------------|----------|----------|------|------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% | %.0f | %.0f |\n",
|
||||
pos.Rank,
|
||||
pos.Symbol,
|
||||
formatOIValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent,
|
||||
pos.PriceDeltaPercent,
|
||||
pos.NetLong,
|
||||
pos.NetShort,
|
||||
))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Market interpretation
|
||||
sb.WriteString("**解读**: 持仓增加 + 价格上涨 = 多头主导; 持仓增加 + 价格下跌 = 空头主导\n\n")
|
||||
}
|
||||
|
||||
// Low rankings (持仓减少)
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### 🔻 持仓量减少排行 (OI Decrease Ranking)\n")
|
||||
sb.WriteString("市场资金正在流出以下币种,可能表示趋势反转或仓位平仓:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 | 多头 | 空头 |\n")
|
||||
sb.WriteString("|------|------|------------------|----------|----------|------|------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% | %.0f | %.0f |\n",
|
||||
pos.Rank,
|
||||
pos.Symbol,
|
||||
formatOIValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent,
|
||||
pos.PriceDeltaPercent,
|
||||
pos.NetLong,
|
||||
pos.NetShort,
|
||||
))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Market interpretation
|
||||
sb.WriteString("**解读**: 持仓减少 + 价格上涨 = 空头平仓(反弹); 持仓减少 + 价格下跌 = 多头平仓(回调)\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatOIValue formats OI value for display
|
||||
func formatOIValue(v float64) string {
|
||||
sign := ""
|
||||
if v >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
absV := v
|
||||
if absV < 0 {
|
||||
absV = -absV
|
||||
}
|
||||
if absV >= 1e9 {
|
||||
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
|
||||
} else if absV >= 1e6 {
|
||||
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
|
||||
} else if absV >= 1e3 {
|
||||
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", sign, v)
|
||||
}
|
||||
|
||||
// GetMergedCoinPool retrieves merged coin pool (AI500 + OI Top, deduplicated)
|
||||
func GetMergedCoinPool(ai500Limit int) (*MergedCoinPool, error) {
|
||||
// 1. Get AI500 data
|
||||
|
||||
+37
-5
@@ -65,6 +65,10 @@ type DebateSession struct {
|
||||
FinalDecisions []*DebateDecision `json:"final_decisions,omitempty"` // Multi-coin decisions
|
||||
AutoExecute bool `json:"auto_execute"`
|
||||
TraderID string `json:"trader_id,omitempty"` // Trader to use for auto-execute
|
||||
// OI Ranking data options
|
||||
EnableOIRanking bool `json:"enable_oi_ranking"` // Whether to include OI ranking data
|
||||
OIRankingLimit int `json:"oi_ranking_limit"` // Number of OI ranking entries (default 10)
|
||||
OIDuration string `json:"oi_duration"` // Duration for OI data (1h, 4h, 24h, etc.)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -239,6 +243,9 @@ func (s *DebateStore) InitSchema() error {
|
||||
`ALTER TABLE debate_sessions ADD COLUMN interval_minutes INTEGER DEFAULT 5`,
|
||||
`ALTER TABLE debate_sessions ADD COLUMN prompt_variant TEXT DEFAULT 'balanced'`,
|
||||
`ALTER TABLE debate_sessions ADD COLUMN trader_id TEXT`,
|
||||
`ALTER TABLE debate_sessions ADD COLUMN enable_oi_ranking BOOLEAN DEFAULT 0`,
|
||||
`ALTER TABLE debate_sessions ADD COLUMN oi_ranking_limit INTEGER DEFAULT 10`,
|
||||
`ALTER TABLE debate_sessions ADD COLUMN oi_duration TEXT DEFAULT '1h'`,
|
||||
`ALTER TABLE debate_votes ADD COLUMN leverage INTEGER DEFAULT 5`,
|
||||
`ALTER TABLE debate_votes ADD COLUMN position_pct REAL DEFAULT 0.2`,
|
||||
`ALTER TABLE debate_votes ADD COLUMN stop_loss_pct REAL DEFAULT 0.03`,
|
||||
@@ -266,15 +273,22 @@ func (s *DebateStore) CreateSession(session *DebateSession) error {
|
||||
if session.PromptVariant == "" {
|
||||
session.PromptVariant = "balanced"
|
||||
}
|
||||
if session.OIRankingLimit == 0 {
|
||||
session.OIRankingLimit = 10
|
||||
}
|
||||
if session.OIDuration == "" {
|
||||
session.OIDuration = "1h"
|
||||
}
|
||||
session.CreatedAt = time.Now()
|
||||
session.UpdatedAt = time.Now()
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO debate_sessions (id, user_id, name, strategy_id, status, symbol, max_rounds, current_round, interval_minutes, prompt_variant, auto_execute, trader_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
INSERT INTO debate_sessions (id, user_id, name, strategy_id, status, symbol, max_rounds, current_round, interval_minutes, prompt_variant, auto_execute, trader_id, enable_oi_ranking, oi_ranking_limit, oi_duration, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
session.ID, session.UserID, session.Name, session.StrategyID, session.Status,
|
||||
session.Symbol, session.MaxRounds, session.CurrentRound, session.IntervalMinutes, session.PromptVariant,
|
||||
session.AutoExecute, session.TraderID, session.CreatedAt, session.UpdatedAt,
|
||||
session.AutoExecute, session.TraderID, session.EnableOIRanking, session.OIRankingLimit, session.OIDuration,
|
||||
session.CreatedAt, session.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -286,17 +300,22 @@ func (s *DebateStore) GetSession(id string) (*DebateSession, error) {
|
||||
var traderID sql.NullString
|
||||
var intervalMinutes sql.NullInt64
|
||||
var promptVariant sql.NullString
|
||||
var enableOIRanking sql.NullBool
|
||||
var oiRankingLimit sql.NullInt64
|
||||
var oiDuration sql.NullString
|
||||
|
||||
// Try new schema first
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, user_id, name, strategy_id, status, symbol, max_rounds, current_round,
|
||||
interval_minutes, prompt_variant, final_decision, auto_execute, trader_id, created_at, updated_at
|
||||
interval_minutes, prompt_variant, final_decision, auto_execute, trader_id,
|
||||
enable_oi_ranking, oi_ranking_limit, oi_duration, created_at, updated_at
|
||||
FROM debate_sessions WHERE id = ?`, id,
|
||||
).Scan(
|
||||
&session.ID, &session.UserID, &session.Name, &session.StrategyID,
|
||||
&session.Status, &session.Symbol, &session.MaxRounds, &session.CurrentRound,
|
||||
&intervalMinutes, &promptVariant,
|
||||
&finalDecisionJSON, &session.AutoExecute, &traderID, &session.CreatedAt, &session.UpdatedAt,
|
||||
&finalDecisionJSON, &session.AutoExecute, &traderID,
|
||||
&enableOIRanking, &oiRankingLimit, &oiDuration, &session.CreatedAt, &session.UpdatedAt,
|
||||
)
|
||||
|
||||
// Fallback to basic schema if new columns don't exist
|
||||
@@ -316,6 +335,8 @@ func (s *DebateStore) GetSession(id string) (*DebateSession, error) {
|
||||
// Set defaults for new fields
|
||||
session.IntervalMinutes = 5
|
||||
session.PromptVariant = "balanced"
|
||||
session.OIRankingLimit = 10
|
||||
session.OIDuration = "1h"
|
||||
} else {
|
||||
// Set defaults for nullable fields
|
||||
session.IntervalMinutes = 5
|
||||
@@ -329,6 +350,17 @@ func (s *DebateStore) GetSession(id string) (*DebateSession, error) {
|
||||
if traderID.Valid {
|
||||
session.TraderID = traderID.String
|
||||
}
|
||||
if enableOIRanking.Valid {
|
||||
session.EnableOIRanking = enableOIRanking.Bool
|
||||
}
|
||||
session.OIRankingLimit = 10
|
||||
if oiRankingLimit.Valid {
|
||||
session.OIRankingLimit = int(oiRankingLimit.Int64)
|
||||
}
|
||||
session.OIDuration = "1h"
|
||||
if oiDuration.Valid {
|
||||
session.OIDuration = oiDuration.String
|
||||
}
|
||||
}
|
||||
|
||||
if finalDecisionJSON.Valid && finalDecisionJSON.String != "" {
|
||||
|
||||
@@ -98,6 +98,11 @@ type IndicatorConfig struct {
|
||||
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
|
||||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||
// OI ranking data (market-wide open interest increase/decrease rankings)
|
||||
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data
|
||||
OIRankingAPIURL string `json:"oi_ranking_api_url,omitempty"` // OI ranking API base URL
|
||||
OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||||
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10)
|
||||
}
|
||||
|
||||
// KlineConfig K-line configuration
|
||||
@@ -246,6 +251,11 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
||||
EnableQuantOI: true,
|
||||
EnableQuantNetflow: true,
|
||||
// OI ranking data - market-wide OI increase/decrease rankings
|
||||
EnableOIRanking: true,
|
||||
OIRankingAPIURL: "http://nofxaios.com:30006",
|
||||
OIRankingDuration: "1h",
|
||||
OIRankingLimit: 10,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
|
||||
+15
-3
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -262,14 +263,25 @@ func (s *TraderStore) UpdateShowInCompetition(userID, id string, showInCompetiti
|
||||
|
||||
// Update updates trader configuration
|
||||
func (s *TraderStore) Update(trader *Trader) error {
|
||||
fmt.Printf("📝 TraderStore.Update: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s\n",
|
||||
trader.ID, trader.Name, trader.AIModelID, trader.StrategyID)
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE traders SET
|
||||
name = ?, ai_model_id = ?, exchange_id = ?, strategy_id = ?,
|
||||
scan_interval_minutes = ?, is_cross_margin = ?, show_in_competition = ?,
|
||||
name = ?,
|
||||
ai_model_id = ?,
|
||||
exchange_id = ?,
|
||||
strategy_id = ?,
|
||||
initial_balance = CASE WHEN ? > 0 THEN ? ELSE initial_balance END,
|
||||
scan_interval_minutes = CASE WHEN ? > 0 THEN ? ELSE scan_interval_minutes END,
|
||||
is_cross_margin = ?,
|
||||
show_in_competition = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND user_id = ?
|
||||
`, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID,
|
||||
trader.ScanIntervalMinutes, trader.IsCrossMargin, trader.ShowInCompetition, trader.ID, trader.UserID)
|
||||
trader.InitialBalance, trader.InitialBalance,
|
||||
trader.ScanIntervalMinutes, trader.ScanIntervalMinutes,
|
||||
trader.IsCrossMargin, trader.ShowInCompetition,
|
||||
trader.ID, trader.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -773,6 +773,16 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
logger.Infof("📊 [%s] Successfully fetched quantitative data for %d symbols", at.name, len(ctx.QuantDataMap))
|
||||
}
|
||||
|
||||
// 9. Get OI ranking data (market-wide position changes)
|
||||
if strategyConfig.Indicators.EnableOIRanking {
|
||||
logger.Infof("📊 [%s] Fetching OI ranking data...", at.name)
|
||||
ctx.OIRankingData = at.strategyEngine.FetchOIRankingData()
|
||||
if ctx.OIRankingData != nil {
|
||||
logger.Infof("📊 [%s] OI ranking data ready: %d top, %d low positions",
|
||||
at.name, len(ctx.OIRankingData.TopPositions), len(ctx.OIRankingData.LowPositions))
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
}
|
||||
|
||||
const handleSaveEditTrader = async (data: CreateTraderRequest) => {
|
||||
console.log('🔥🔥🔥 handleSaveEditTrader CALLED with data:', data)
|
||||
if (!editingTrader) return
|
||||
|
||||
try {
|
||||
@@ -310,19 +311,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
name: data.name,
|
||||
ai_model_id: data.ai_model_id,
|
||||
exchange_id: data.exchange_id,
|
||||
strategy_id: data.strategy_id,
|
||||
initial_balance: data.initial_balance,
|
||||
scan_interval_minutes: data.scan_interval_minutes,
|
||||
btc_eth_leverage: data.btc_eth_leverage,
|
||||
altcoin_leverage: data.altcoin_leverage,
|
||||
trading_symbols: data.trading_symbols,
|
||||
custom_prompt: data.custom_prompt,
|
||||
override_base_prompt: data.override_base_prompt,
|
||||
system_prompt_template: data.system_prompt_template,
|
||||
is_cross_margin: data.is_cross_margin,
|
||||
use_coin_pool: data.use_coin_pool,
|
||||
use_oi_top: data.use_oi_top,
|
||||
show_in_competition: data.show_in_competition,
|
||||
}
|
||||
|
||||
console.log('🔥 handleSaveEditTrader - data:', data)
|
||||
console.log('🔥 handleSaveEditTrader - data.strategy_id:', data.strategy_id)
|
||||
console.log('🔥 handleSaveEditTrader - request:', request)
|
||||
|
||||
await toast.promise(api.updateTrader(editingTrader.trader_id, request), {
|
||||
loading: '正在保存…',
|
||||
success: '保存成功',
|
||||
|
||||
@@ -163,7 +163,7 @@ export function TraderConfigModal({
|
||||
name: formData.trader_name,
|
||||
ai_model_id: formData.ai_model,
|
||||
exchange_id: formData.exchange_id,
|
||||
strategy_id: formData.strategy_id || undefined,
|
||||
strategy_id: formData.strategy_id,
|
||||
is_cross_margin: formData.is_cross_margin,
|
||||
show_in_competition: formData.show_in_competition,
|
||||
scan_interval_minutes: formData.scan_interval_minutes,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Clock, Activity, Database, TrendingUp, BarChart2, Info, Lock } from 'lucide-react'
|
||||
import { Clock, Activity, Database, TrendingUp, BarChart2, Info, Lock, LineChart } from 'lucide-react'
|
||||
import type { IndicatorConfig } from '../../types'
|
||||
|
||||
// Default API URL for quant data (must contain {symbol} placeholder)
|
||||
const DEFAULT_QUANT_DATA_API_URL = 'http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c'
|
||||
// Default API base URL for OI ranking data
|
||||
const DEFAULT_OI_RANKING_API_URL = 'http://nofxaios.com:30006'
|
||||
|
||||
interface IndicatorEditorProps {
|
||||
config: IndicatorConfig
|
||||
@@ -82,6 +84,13 @@ export function IndicatorEditor({
|
||||
fillDefault: { zh: '填入默认', en: 'Fill Default' },
|
||||
symbolPlaceholder: { zh: '{symbol} 会被替换为币种', en: '{symbol} will be replaced with coin' },
|
||||
|
||||
// OI Ranking
|
||||
oiRanking: { zh: 'OI 排行数据', en: 'OI Ranking Data' },
|
||||
oiRankingDesc: { zh: '市场持仓量增减排行,反映资金流向', en: 'Market-wide OI changes, reflects capital flow' },
|
||||
oiRankingDuration: { zh: '时间周期', en: 'Duration' },
|
||||
oiRankingLimit: { zh: '排行数量', en: 'Top N' },
|
||||
oiRankingNote: { zh: '显示持仓量增加/减少的币种排行,帮助发现资金流向', en: 'Shows coins with OI increase/decrease, helps identify capital flow' },
|
||||
|
||||
// Tips
|
||||
aiCanCalculate: { zh: '💡 提示:AI 可自行计算这些指标,开启可减少 AI 计算量', en: '💡 Tip: AI can calculate these, enabling reduces AI workload' },
|
||||
}
|
||||
@@ -456,6 +465,82 @@ export function IndicatorEditor({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 5: OI Ranking Data (Market-wide) */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<LineChart className="w-4 h-4" style={{ color: '#22c55e' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>- {t('oiRankingDesc')}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_oi_ranking || false}
|
||||
onChange={(e) => !disabled && onChange({
|
||||
...config,
|
||||
enable_oi_ranking: e.target.checked,
|
||||
// Set defaults when enabling
|
||||
...(e.target.checked && !config.oi_ranking_api_url ? { oi_ranking_api_url: DEFAULT_OI_RANKING_API_URL } : {}),
|
||||
...(e.target.checked && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}),
|
||||
...(e.target.checked && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}),
|
||||
})}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
{config.enable_oi_ranking && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
{/* Duration */}
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] mb-1 block" style={{ color: '#848E9C' }}>
|
||||
{t('oiRankingDuration')}
|
||||
</label>
|
||||
<select
|
||||
value={config.oi_ranking_duration || '1h'}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
|
||||
disabled={disabled}
|
||||
className="w-full px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<option value="1h">{language === 'zh' ? '1小时' : '1 Hour'}</option>
|
||||
<option value="4h">{language === 'zh' ? '4小时' : '4 Hours'}</option>
|
||||
<option value="24h">{language === 'zh' ? '24小时' : '24 Hours'}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Limit */}
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] mb-1 block" style={{ color: '#848E9C' }}>
|
||||
{t('oiRankingLimit')}
|
||||
</label>
|
||||
<select
|
||||
value={config.oi_ranking_limit || 10}
|
||||
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
|
||||
disabled={disabled}
|
||||
className="w-full px-2 py-1.5 rounded text-xs"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{[5, 10, 15, 20].map(n => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px]" style={{ color: '#5E6673' }}>{t('oiRankingNote')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
import { api } from '../lib/api'
|
||||
import type {
|
||||
TraderInfo,
|
||||
CreateTraderRequest,
|
||||
TraderConfigData,
|
||||
AIModel,
|
||||
Exchange,
|
||||
} from '../types'
|
||||
import { t } from '../i18n/translations'
|
||||
import { confirmToast } from '../lib/notify'
|
||||
import { toast } from 'sonner'
|
||||
import type { Language } from '../i18n/translations'
|
||||
|
||||
interface UseTraderActionsParams {
|
||||
traders: TraderInfo[] | undefined
|
||||
allModels: AIModel[]
|
||||
allExchanges: Exchange[]
|
||||
supportedModels: AIModel[]
|
||||
supportedExchanges: Exchange[]
|
||||
language: Language
|
||||
mutateTraders: () => Promise<any>
|
||||
setAllModels: (models: AIModel[]) => void
|
||||
setAllExchanges: (exchanges: Exchange[]) => void
|
||||
setShowCreateModal: (show: boolean) => void
|
||||
setShowEditModal: (show: boolean) => void
|
||||
setShowModelModal: (show: boolean) => void
|
||||
setShowExchangeModal: (show: boolean) => void
|
||||
setEditingModel: (modelId: string | null) => void
|
||||
setEditingExchange: (exchangeId: string | null) => void
|
||||
editingTrader: TraderConfigData | null
|
||||
setEditingTrader: (trader: TraderConfigData | null) => void
|
||||
}
|
||||
|
||||
export function useTraderActions({
|
||||
traders,
|
||||
allModels,
|
||||
allExchanges,
|
||||
supportedModels,
|
||||
supportedExchanges,
|
||||
language,
|
||||
mutateTraders,
|
||||
setAllModels,
|
||||
setAllExchanges,
|
||||
setShowCreateModal,
|
||||
setShowEditModal,
|
||||
setShowModelModal,
|
||||
setShowExchangeModal,
|
||||
setEditingModel,
|
||||
setEditingExchange,
|
||||
editingTrader,
|
||||
setEditingTrader,
|
||||
}: UseTraderActionsParams) {
|
||||
// 检查模型是否正在被运行中的交易员使用(用于UI禁用)
|
||||
const isModelInUse = (modelId: string) => {
|
||||
return traders?.some((t) => t.ai_model === modelId && t.is_running) || false
|
||||
}
|
||||
|
||||
// 检查交易所是否正在被运行中的交易员使用(用于UI禁用)
|
||||
const isExchangeInUse = (exchangeId: string) => {
|
||||
return (
|
||||
traders?.some((t) => t.exchange_id === exchangeId && t.is_running) ||
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
// 检查模型是否被任何交易员使用(包括停止状态的)
|
||||
const isModelUsedByAnyTrader = (modelId: string) => {
|
||||
return traders?.some((t) => t.ai_model === modelId) || false
|
||||
}
|
||||
|
||||
// 检查交易所是否被任何交易员使用(包括停止状态的)
|
||||
const isExchangeUsedByAnyTrader = (exchangeId: string) => {
|
||||
return traders?.some((t) => t.exchange_id === exchangeId) || false
|
||||
}
|
||||
|
||||
// 获取使用特定模型的交易员列表
|
||||
const getTradersUsingModel = (modelId: string) => {
|
||||
return traders?.filter((t) => t.ai_model === modelId) || []
|
||||
}
|
||||
|
||||
// 获取使用特定交易所的交易员列表
|
||||
const getTradersUsingExchange = (exchangeId: string) => {
|
||||
return traders?.filter((t) => t.exchange_id === exchangeId) || []
|
||||
}
|
||||
|
||||
const handleCreateTrader = async (data: CreateTraderRequest) => {
|
||||
try {
|
||||
const model = allModels?.find((m) => m.id === data.ai_model_id)
|
||||
const exchange = allExchanges?.find((e) => e.id === data.exchange_id)
|
||||
|
||||
if (!model?.enabled) {
|
||||
toast.error(t('modelNotConfigured', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (!exchange?.enabled) {
|
||||
toast.error(t('exchangeNotConfigured', language))
|
||||
return
|
||||
}
|
||||
|
||||
await toast.promise(api.createTrader(data), {
|
||||
loading: '正在创建…',
|
||||
success: '创建成功',
|
||||
error: '创建失败',
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
// Immediately refresh traders list for better UX
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to create trader:', error)
|
||||
toast.error(t('createTraderFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTrader = async (traderId: string) => {
|
||||
try {
|
||||
const traderConfig = await api.getTraderConfig(traderId)
|
||||
setEditingTrader(traderConfig)
|
||||
setShowEditModal(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch trader config:', error)
|
||||
toast.error(t('getTraderConfigFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveEditTrader = async (data: CreateTraderRequest) => {
|
||||
if (!editingTrader || !editingTrader.trader_id) return
|
||||
|
||||
try {
|
||||
const enabledModels = allModels?.filter((m) => m.enabled) || []
|
||||
const enabledExchanges =
|
||||
allExchanges?.filter((e) => {
|
||||
if (!e.enabled) return false
|
||||
|
||||
// Aster 交易所需要特殊字段
|
||||
if (e.id === 'aster') {
|
||||
return (
|
||||
e.asterUser &&
|
||||
e.asterUser.trim() !== '' &&
|
||||
e.asterSigner &&
|
||||
e.asterSigner.trim() !== ''
|
||||
)
|
||||
}
|
||||
|
||||
// Hyperliquid 需要钱包地址
|
||||
if (e.id === 'hyperliquid') {
|
||||
return (
|
||||
e.hyperliquidWalletAddr && e.hyperliquidWalletAddr.trim() !== ''
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}) || []
|
||||
|
||||
const model = enabledModels?.find((m) => m.id === data.ai_model_id)
|
||||
const exchange = enabledExchanges?.find((e) => e.id === data.exchange_id)
|
||||
|
||||
if (!model) {
|
||||
toast.error(t('modelConfigNotExist', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (!exchange) {
|
||||
toast.error(t('exchangeConfigNotExist', language))
|
||||
return
|
||||
}
|
||||
|
||||
const request = {
|
||||
name: data.name,
|
||||
ai_model_id: data.ai_model_id,
|
||||
exchange_id: data.exchange_id,
|
||||
initial_balance: data.initial_balance,
|
||||
scan_interval_minutes: data.scan_interval_minutes,
|
||||
btc_eth_leverage: data.btc_eth_leverage,
|
||||
altcoin_leverage: data.altcoin_leverage,
|
||||
trading_symbols: data.trading_symbols,
|
||||
custom_prompt: data.custom_prompt,
|
||||
override_base_prompt: data.override_base_prompt,
|
||||
system_prompt_template: data.system_prompt_template,
|
||||
is_cross_margin: data.is_cross_margin,
|
||||
use_coin_pool: data.use_coin_pool,
|
||||
use_oi_top: data.use_oi_top,
|
||||
}
|
||||
|
||||
await toast.promise(api.updateTrader(editingTrader.trader_id, request), {
|
||||
loading: '正在保存…',
|
||||
success: '保存成功',
|
||||
error: '保存失败',
|
||||
})
|
||||
setShowEditModal(false)
|
||||
setEditingTrader(null)
|
||||
// Immediately refresh traders list for better UX
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to update trader:', error)
|
||||
toast.error(t('updateTraderFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTrader = async (traderId: string) => {
|
||||
{
|
||||
const ok = await confirmToast(t('confirmDeleteTrader', language))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
try {
|
||||
await toast.promise(api.deleteTrader(traderId), {
|
||||
loading: '正在删除…',
|
||||
success: '删除成功',
|
||||
error: '删除失败',
|
||||
})
|
||||
|
||||
// Immediately refresh traders list for better UX
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete trader:', error)
|
||||
toast.error(t('deleteTraderFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleTrader = async (traderId: string, running: boolean) => {
|
||||
try {
|
||||
if (running) {
|
||||
await toast.promise(api.stopTrader(traderId), {
|
||||
loading: '正在停止…',
|
||||
success: '已停止',
|
||||
error: '停止失败',
|
||||
})
|
||||
} else {
|
||||
await toast.promise(api.startTrader(traderId), {
|
||||
loading: '正在启动…',
|
||||
success: '已启动',
|
||||
error: '启动失败',
|
||||
})
|
||||
}
|
||||
|
||||
// Immediately refresh traders list to update running status
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle trader:', error)
|
||||
toast.error(t('operationFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
|
||||
try {
|
||||
const newValue = !currentShowInCompetition
|
||||
await toast.promise(api.toggleCompetition(traderId, newValue), {
|
||||
loading: '正在更新…',
|
||||
success: newValue ? '已在竞技场显示' : '已在竞技场隐藏',
|
||||
error: '更新失败',
|
||||
})
|
||||
|
||||
// Immediately refresh traders list to update status
|
||||
await mutateTraders()
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle competition visibility:', error)
|
||||
toast.error(t('operationFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelClick = (modelId: string) => {
|
||||
if (!isModelInUse(modelId)) {
|
||||
setEditingModel(modelId)
|
||||
setShowModelModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExchangeClick = (exchangeId: string) => {
|
||||
if (!isExchangeInUse(exchangeId)) {
|
||||
setEditingExchange(exchangeId)
|
||||
setShowExchangeModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 通用删除配置处理函数
|
||||
const handleDeleteConfig = async <T extends { id: string }>(config: {
|
||||
id: string
|
||||
type: 'model' | 'exchange'
|
||||
checkInUse: (id: string) => boolean
|
||||
getUsingTraders: (id: string) => any[]
|
||||
cannotDeleteKey: string
|
||||
confirmDeleteKey: string
|
||||
allItems: T[] | undefined
|
||||
clearFields: (item: T) => T
|
||||
buildRequest: (items: T[]) => any
|
||||
updateApi: (request: any) => Promise<void>
|
||||
refreshApi: () => Promise<T[]>
|
||||
setItems: (items: T[]) => void
|
||||
closeModal: () => void
|
||||
errorKey: string
|
||||
}) => {
|
||||
// 检查是否有交易员正在使用
|
||||
if (config.checkInUse(config.id)) {
|
||||
const usingTraders = config.getUsingTraders(config.id)
|
||||
const traderNames = usingTraders.map((t) => t.trader_name).join(', ')
|
||||
toast.error(
|
||||
`${t(config.cannotDeleteKey, language)} · ${t('tradersUsing', language)}: ${traderNames} · ${t('pleaseDeleteTradersFirst', language)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
{
|
||||
const ok = await confirmToast(t(config.confirmDeleteKey, language))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedItems =
|
||||
config.allItems?.map((item) =>
|
||||
item.id === config.id ? config.clearFields(item) : item
|
||||
) || []
|
||||
|
||||
const request = config.buildRequest(updatedItems)
|
||||
await toast.promise(config.updateApi(request), {
|
||||
loading: '正在更新配置…',
|
||||
success: '配置已更新',
|
||||
error: '更新配置失败',
|
||||
})
|
||||
|
||||
// 重新获取用户配置以确保数据同步
|
||||
const refreshedItems = await config.refreshApi()
|
||||
config.setItems(refreshedItems)
|
||||
|
||||
config.closeModal()
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete ${config.type} config:`, error)
|
||||
toast.error(t(config.errorKey, language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
await handleDeleteConfig({
|
||||
id: modelId,
|
||||
type: 'model',
|
||||
checkInUse: isModelUsedByAnyTrader,
|
||||
getUsingTraders: getTradersUsingModel,
|
||||
cannotDeleteKey: 'cannotDeleteModelInUse',
|
||||
confirmDeleteKey: 'confirmDeleteModel',
|
||||
allItems: allModels,
|
||||
clearFields: (m) => ({
|
||||
...m,
|
||||
apiKey: '',
|
||||
customApiUrl: '',
|
||||
customModelName: '',
|
||||
enabled: false,
|
||||
}),
|
||||
buildRequest: (models) => ({
|
||||
models: Object.fromEntries(
|
||||
models.map((model) => [
|
||||
model.provider,
|
||||
{
|
||||
enabled: model.enabled,
|
||||
api_key: model.apiKey || '',
|
||||
custom_api_url: model.customApiUrl || '',
|
||||
custom_model_name: model.customModelName || '',
|
||||
},
|
||||
])
|
||||
),
|
||||
}),
|
||||
updateApi: api.updateModelConfigs,
|
||||
refreshApi: api.getModelConfigs,
|
||||
setItems: (items) => {
|
||||
// 使用函数式更新确保状态正确更新
|
||||
setAllModels([...items])
|
||||
},
|
||||
closeModal: () => {
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
},
|
||||
errorKey: 'deleteConfigFailed',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveModel = async (
|
||||
modelId: string,
|
||||
apiKey: string,
|
||||
customApiUrl?: string,
|
||||
customModelName?: string
|
||||
) => {
|
||||
try {
|
||||
// 创建或更新用户的模型配置
|
||||
const existingModel = allModels?.find((m) => m.id === modelId)
|
||||
let updatedModels
|
||||
|
||||
// 找到要配置的模型(优先从已配置列表,其次从支持列表)
|
||||
const modelToUpdate =
|
||||
existingModel || supportedModels?.find((m) => m.id === modelId)
|
||||
if (!modelToUpdate) {
|
||||
toast.error(t('modelNotExist', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (existingModel) {
|
||||
// 更新现有配置
|
||||
updatedModels =
|
||||
allModels?.map((m) =>
|
||||
m.id === modelId
|
||||
? {
|
||||
...m,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
: m
|
||||
) || []
|
||||
} else {
|
||||
// 添加新配置
|
||||
const newModel = {
|
||||
...modelToUpdate,
|
||||
apiKey,
|
||||
customApiUrl: customApiUrl || '',
|
||||
customModelName: customModelName || '',
|
||||
enabled: true,
|
||||
}
|
||||
updatedModels = [...(allModels || []), newModel]
|
||||
}
|
||||
|
||||
const request = {
|
||||
models: Object.fromEntries(
|
||||
updatedModels.map((model) => [
|
||||
model.provider, // 使用 provider 而不是 id
|
||||
{
|
||||
enabled: model.enabled,
|
||||
api_key: model.apiKey || '',
|
||||
custom_api_url: model.customApiUrl || '',
|
||||
custom_model_name: model.customModelName || '',
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
await toast.promise(api.updateModelConfigs(request), {
|
||||
loading: '正在更新模型配置…',
|
||||
success: '模型配置已更新',
|
||||
error: '更新模型配置失败',
|
||||
})
|
||||
|
||||
// 重新获取用户配置以确保数据同步
|
||||
const refreshedModels = await api.getModelConfigs()
|
||||
setAllModels(refreshedModels)
|
||||
|
||||
setShowModelModal(false)
|
||||
setEditingModel(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to save model config:', error)
|
||||
toast.error(t('saveConfigFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteExchange = async (exchangeId: string) => {
|
||||
await handleDeleteConfig({
|
||||
id: exchangeId,
|
||||
type: 'exchange',
|
||||
checkInUse: isExchangeUsedByAnyTrader,
|
||||
getUsingTraders: getTradersUsingExchange,
|
||||
cannotDeleteKey: 'cannotDeleteExchangeInUse',
|
||||
confirmDeleteKey: 'confirmDeleteExchange',
|
||||
allItems: allExchanges,
|
||||
clearFields: (e) => ({
|
||||
...e,
|
||||
apiKey: '',
|
||||
secretKey: '',
|
||||
passphrase: '', // OKX专用
|
||||
hyperliquidWalletAddr: '',
|
||||
asterUser: '',
|
||||
asterSigner: '',
|
||||
asterPrivateKey: '',
|
||||
enabled: false,
|
||||
}),
|
||||
buildRequest: (exchanges) => ({
|
||||
exchanges: Object.fromEntries(
|
||||
exchanges.map((exchange) => [
|
||||
exchange.id,
|
||||
{
|
||||
enabled: exchange.enabled,
|
||||
api_key: exchange.apiKey || '',
|
||||
secret_key: exchange.secretKey || '',
|
||||
passphrase: exchange.passphrase || '', // OKX专用
|
||||
testnet: exchange.testnet || false,
|
||||
hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '',
|
||||
aster_user: exchange.asterUser || '',
|
||||
aster_signer: exchange.asterSigner || '',
|
||||
aster_private_key: exchange.asterPrivateKey || '',
|
||||
},
|
||||
])
|
||||
),
|
||||
}),
|
||||
updateApi: api.updateExchangeConfigsEncrypted,
|
||||
refreshApi: api.getExchangeConfigs,
|
||||
setItems: (items) => {
|
||||
// 使用函数式更新确保状态正确更新
|
||||
setAllExchanges([...items])
|
||||
},
|
||||
closeModal: () => {
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
},
|
||||
errorKey: 'deleteExchangeConfigFailed',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveExchange = async (
|
||||
exchangeId: string,
|
||||
apiKey: string,
|
||||
secretKey?: string,
|
||||
passphrase?: string, // OKX专用
|
||||
testnet?: boolean,
|
||||
hyperliquidWalletAddr?: string,
|
||||
asterUser?: string,
|
||||
asterSigner?: string,
|
||||
asterPrivateKey?: string,
|
||||
lighterWalletAddr?: string,
|
||||
lighterPrivateKey?: string,
|
||||
lighterApiKeyPrivateKey?: string
|
||||
) => {
|
||||
try {
|
||||
// 找到要配置的交易所(从supportedExchanges中)
|
||||
const exchangeToUpdate = supportedExchanges?.find(
|
||||
(e) => e.id === exchangeId
|
||||
)
|
||||
if (!exchangeToUpdate) {
|
||||
toast.error(t('exchangeNotExist', language))
|
||||
return
|
||||
}
|
||||
|
||||
// 创建或更新用户的交易所配置
|
||||
const existingExchange = allExchanges?.find((e) => e.id === exchangeId)
|
||||
let updatedExchanges
|
||||
|
||||
if (existingExchange) {
|
||||
// 更新现有配置
|
||||
updatedExchanges =
|
||||
allExchanges?.map((e) =>
|
||||
e.id === exchangeId
|
||||
? {
|
||||
...e,
|
||||
apiKey,
|
||||
secretKey,
|
||||
passphrase, // OKX专用
|
||||
testnet,
|
||||
hyperliquidWalletAddr,
|
||||
asterUser,
|
||||
asterSigner,
|
||||
asterPrivateKey,
|
||||
lighterWalletAddr,
|
||||
lighterPrivateKey,
|
||||
lighterApiKeyPrivateKey,
|
||||
enabled: true,
|
||||
}
|
||||
: e
|
||||
) || []
|
||||
} else {
|
||||
// 添加新配置
|
||||
const newExchange = {
|
||||
...exchangeToUpdate,
|
||||
apiKey,
|
||||
secretKey,
|
||||
passphrase, // OKX专用
|
||||
testnet,
|
||||
hyperliquidWalletAddr,
|
||||
asterUser,
|
||||
asterSigner,
|
||||
asterPrivateKey,
|
||||
lighterWalletAddr,
|
||||
lighterPrivateKey,
|
||||
lighterApiKeyPrivateKey,
|
||||
enabled: true,
|
||||
}
|
||||
updatedExchanges = [...(allExchanges || []), newExchange]
|
||||
}
|
||||
|
||||
const request = {
|
||||
exchanges: Object.fromEntries(
|
||||
updatedExchanges.map((exchange) => [
|
||||
exchange.id,
|
||||
{
|
||||
enabled: exchange.enabled,
|
||||
api_key: exchange.apiKey || '',
|
||||
secret_key: exchange.secretKey || '',
|
||||
passphrase: exchange.passphrase || '', // OKX专用
|
||||
testnet: exchange.testnet || false,
|
||||
hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '',
|
||||
aster_user: exchange.asterUser || '',
|
||||
aster_signer: exchange.asterSigner || '',
|
||||
aster_private_key: exchange.asterPrivateKey || '',
|
||||
lighter_wallet_addr: exchange.lighterWalletAddr || '',
|
||||
lighter_private_key: exchange.lighterPrivateKey || '',
|
||||
lighter_api_key_private_key: exchange.lighterApiKeyPrivateKey || '',
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
await toast.promise(api.updateExchangeConfigsEncrypted(request), {
|
||||
loading: '正在更新交易所配置…',
|
||||
success: '交易所配置已更新',
|
||||
error: '更新交易所配置失败',
|
||||
})
|
||||
|
||||
// 重新获取用户配置以确保数据同步
|
||||
const refreshedExchanges = await api.getExchangeConfigs()
|
||||
setAllExchanges(refreshedExchanges)
|
||||
|
||||
setShowExchangeModal(false)
|
||||
setEditingExchange(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to save exchange config:', error)
|
||||
toast.error(t('saveConfigFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddModel = () => {
|
||||
setEditingModel(null)
|
||||
setShowModelModal(true)
|
||||
}
|
||||
|
||||
const handleAddExchange = () => {
|
||||
setEditingExchange(null)
|
||||
setShowExchangeModal(true)
|
||||
}
|
||||
|
||||
return {
|
||||
// 辅助函数
|
||||
isModelInUse,
|
||||
isExchangeInUse,
|
||||
isModelUsedByAnyTrader,
|
||||
isExchangeUsedByAnyTrader,
|
||||
getTradersUsingModel,
|
||||
getTradersUsingExchange,
|
||||
|
||||
// 事件处理函数
|
||||
handleCreateTrader,
|
||||
handleEditTrader,
|
||||
handleSaveEditTrader,
|
||||
handleDeleteTrader,
|
||||
handleToggleTrader,
|
||||
handleToggleCompetition,
|
||||
handleAddModel,
|
||||
handleAddExchange,
|
||||
handleModelClick,
|
||||
handleExchangeClick,
|
||||
handleSaveModel,
|
||||
handleDeleteModel,
|
||||
handleSaveExchange,
|
||||
handleDeleteExchange,
|
||||
}
|
||||
}
|
||||
@@ -438,6 +438,11 @@ export interface IndicatorConfig {
|
||||
quant_data_api_url?: string;
|
||||
enable_quant_oi?: boolean;
|
||||
enable_quant_netflow?: boolean;
|
||||
// OI 排行数据(市场持仓量增减排行)
|
||||
enable_oi_ranking?: boolean;
|
||||
oi_ranking_api_url?: string;
|
||||
oi_ranking_duration?: string; // "1h", "4h", "24h"
|
||||
oi_ranking_limit?: number;
|
||||
}
|
||||
|
||||
export interface KlineConfig {
|
||||
@@ -579,6 +584,10 @@ export interface CreateDebateRequest {
|
||||
prompt_variant?: string; // balanced, aggressive, conservative, scalping
|
||||
auto_execute?: boolean;
|
||||
trader_id?: string; // Trader to use for auto-execute
|
||||
// OI Ranking data options
|
||||
enable_oi_ranking?: boolean; // Whether to include OI ranking data
|
||||
oi_ranking_limit?: number; // Number of OI ranking entries (default 10)
|
||||
oi_duration?: string; // Duration for OI data (1h, 4h, 24h, etc.)
|
||||
participants: {
|
||||
ai_model_id: string;
|
||||
personality: DebatePersonality;
|
||||
|
||||
Reference in New Issue
Block a user