diff --git a/api/debate.go b/api/debate.go index 3eaedd5e..88da5df8 100644 --- a/api/debate.go +++ b/api/debate.go @@ -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 { diff --git a/api/server.go b/api/server.go index 4c37b1c0..ee24e6f7 100644 --- a/api/server.go +++ b/api/server.go @@ -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, diff --git a/api/strategy.go b/api/strategy.go index 3bd97f9b..e9a2e2de 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -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 diff --git a/debate/engine.go b/debate/engine.go index a48b4bc6..48d50122 100644 --- a/debate/engine.go +++ b/debate/engine.go @@ -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 diff --git a/decision/engine.go b/decision/engine.go index fc50632e..a96db8aa 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -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") diff --git a/manager/trader_manager.go b/manager/trader_manager.go index f3377c00..d63d8b73 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -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 diff --git a/pool/coin_pool.go b/pool/coin_pool.go index b436f96c..b519e824 100644 --- a/pool/coin_pool.go +++ b/pool/coin_pool.go @@ -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 diff --git a/store/debate.go b/store/debate.go index 7914310e..3f57d2fb 100644 --- a/store/debate.go +++ b/store/debate.go @@ -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 != "" { diff --git a/store/strategy.go b/store/strategy.go index 551a0299..a016e2b2 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -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) diff --git a/store/trader.go b/store/trader.go index 436be826..de72dc13 100644 --- a/store/trader.go +++ b/store/trader.go @@ -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 } diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 46505f35..9128a7ab 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -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 } diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 732093f8..89f98302 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -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: '保存成功', diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index 9904302a..25c191e2 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -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, diff --git a/web/src/components/strategy/IndicatorEditor.tsx b/web/src/components/strategy/IndicatorEditor.tsx index 760ca47b..afa78ef4 100644 --- a/web/src/components/strategy/IndicatorEditor.tsx +++ b/web/src/components/strategy/IndicatorEditor.tsx @@ -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({ )} + + {/* Section 5: OI Ranking Data (Market-wide) */} +
{t('oiRankingNote')}
+