From 0275e23b7e0d1536df0649f8e50fcb75b4f93af5 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 4 Jan 2026 00:59:07 +0800 Subject: [PATCH] feat: unify NofxOS data provider and fix language consistency - Add unified NofxOS API key configuration in IndicatorEditor - Add language field to StrategyConfig for consistent prompt generation - Auto-update prompt sections when interface language changes - Remove scattered URL inputs from CoinSourceEditor and IndicatorEditor - Create nofxos provider package with formatted data output - Update kernel engine to use config-based language setting --- api/backtest.go | 38 +- api/debate.go | 32 +- api/server.go | 6 +- api/strategy.go | 31 +- backtest/config.go | 14 +- backtest/runner.go | 20 +- debate/engine.go | 20 +- docs/api/API_REFERENCE.md | 852 ++++++++++++++++++ docs/api/coin_api.md | 350 ------- docs/api/oi_api.md | 254 ------ docs/architecture/STRATEGY_MODULE.zh-CN.md | 2 +- kernel/engine.go | 253 ++++-- kernel/formatter.go | 16 +- manager/trader_manager.go | 4 +- provider/data_provider.go | 593 ------------ provider/nofxos/ai500.go | 163 ++++ provider/nofxos/client.go | 146 +++ provider/nofxos/coin.go | 216 +++++ provider/nofxos/netflow.go | 263 ++++++ provider/nofxos/oi.go | 212 +++++ provider/nofxos/price.go | 182 ++++ provider/nofxos/util.go | 31 + store/strategy.go | 72 +- store/trader.go | 2 +- trader/auto_trader.go | 22 +- web/src/components/BacktestPage.tsx | 18 +- web/src/components/TraderConfigModal.tsx | 2 +- .../components/strategy/CoinSourceEditor.tsx | 316 +++---- .../components/strategy/IndicatorEditor.tsx | 570 ++++++++---- web/src/i18n/translations.ts | 12 +- web/src/pages/StrategyStudioPage.tsx | 48 +- web/src/types.ts | 36 +- 32 files changed, 3029 insertions(+), 1767 deletions(-) create mode 100644 docs/api/API_REFERENCE.md delete mode 100644 docs/api/coin_api.md delete mode 100644 docs/api/oi_api.md delete mode 100644 provider/data_provider.go create mode 100644 provider/nofxos/ai500.go create mode 100644 provider/nofxos/client.go create mode 100644 provider/nofxos/coin.go create mode 100644 provider/nofxos/netflow.go create mode 100644 provider/nofxos/oi.go create mode 100644 provider/nofxos/price.go create mode 100644 provider/nofxos/util.go diff --git a/api/backtest.go b/api/backtest.go index 9d0e2be3..acdfe378 100644 --- a/api/backtest.go +++ b/api/backtest.go @@ -15,7 +15,7 @@ import ( "nofx/backtest" "nofx/logger" "nofx/market" - "nofx/provider" + "nofx/provider/nofxos" "nofx/store" "github.com/gin-gonic/gin" @@ -92,9 +92,9 @@ func (s *Server) handleBacktestStart(c *gin.Context) { } cfg.SetLoadedStrategy(&strategyConfig) logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID) - logger.Infof("📊 Strategy coin source: type=%s, use_coin_pool=%v, use_oi_top=%v, static_coins=%v", + logger.Infof("📊 Strategy coin source: type=%s, use_ai500=%v, use_oi_top=%v, static_coins=%v", strategyConfig.CoinSource.SourceType, - strategyConfig.CoinSource.UseCoinPool, + strategyConfig.CoinSource.UseAI500, strategyConfig.CoinSource.UseOITop, strategyConfig.CoinSource.StaticCoins) @@ -638,21 +638,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s var symbols []string symbolSet := make(map[string]bool) - // Set custom API URLs if provided - if coinSource.CoinPoolAPIURL != "" { - provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) - } - if coinSource.OITopAPIURL != "" { - provider.SetOITopAPI(coinSource.OITopAPIURL) - } - // Handle empty source_type - check flags for backward compatibility sourceType := coinSource.SourceType if sourceType == "" { - if coinSource.UseCoinPool && coinSource.UseOITop { + if coinSource.UseAI500 && coinSource.UseOITop { sourceType = "mixed" - } else if coinSource.UseCoinPool { - sourceType = "coinpool" + } else if coinSource.UseAI500 { + sourceType = "ai500" } else if coinSource.UseOITop { sourceType = "oi_top" } else if len(coinSource.StaticCoins) > 0 { @@ -673,13 +665,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s } } - case "coinpool": - limit := coinSource.CoinPoolLimit + case "ai500": + limit := coinSource.AI500Limit if limit <= 0 { limit = 30 } logger.Infof("📊 Fetching AI500 coins with limit=%d", limit) - coins, err := provider.GetTopRatedCoins(limit) + coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit) if err != nil { return nil, fmt.Errorf("failed to get AI500 coins: %w", err) } @@ -693,7 +685,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s } case "oi_top": - coins, err := provider.GetOITopSymbols() + coins, err := nofxos.DefaultClient().GetOITopSymbols() if err != nil { return nil, fmt.Errorf("failed to get OI Top coins: %w", err) } @@ -713,13 +705,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s } case "mixed": - // Get from coin pool - if coinSource.UseCoinPool { - limit := coinSource.CoinPoolLimit + // Get from AI500 + if coinSource.UseAI500 { + limit := coinSource.AI500Limit if limit <= 0 { limit = 30 } - coins, err := provider.GetTopRatedCoins(limit) + coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit) if err != nil { logger.Warnf("Failed to get AI500 coins: %v", err) } else { @@ -735,7 +727,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s // Get from OI Top if coinSource.UseOITop { - coins, err := provider.GetOITopSymbols() + coins, err := nofxos.DefaultClient().GetOITopSymbols() if err != nil { logger.Warnf("Failed to get OI Top coins: %v", err) } else { diff --git a/api/debate.go b/api/debate.go index 5f5f52bd..89b31972 100644 --- a/api/debate.go +++ b/api/debate.go @@ -8,7 +8,7 @@ import ( "nofx/debate" "nofx/logger" - "nofx/provider" + "nofx/provider/nofxos" "nofx/store" "github.com/gin-gonic/gin" @@ -158,35 +158,27 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) { if len(coinSource.StaticCoins) > 0 { req.Symbol = coinSource.StaticCoins[0] } - case "coinpool": - // Fetch from coin pool API - if coinSource.CoinPoolAPIURL != "" { - provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) - } - if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 { + case "ai500": + // Fetch from AI500 API + if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 { req.Symbol = coins[0] - logger.Infof("Fetched coin from pool API: %s", req.Symbol) + logger.Infof("Fetched coin from AI500 API: %s", req.Symbol) } case "oi_top": // Fetch from OI top API - if coinSource.OITopAPIURL != "" { - provider.SetOITopAPI(coinSource.OITopAPIURL) - } - if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 { + if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 { req.Symbol = coins[0] logger.Infof("Fetched coin from OI Top API: %s", req.Symbol) } case "mixed": - // Try coin pool first, then OI top - if coinSource.UseCoinPool && coinSource.CoinPoolAPIURL != "" { - provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) - if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 { + // Try AI500 first, then OI top + if coinSource.UseAI500 { + if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 { req.Symbol = coins[0] - logger.Infof("Fetched coin from pool API (mixed): %s", req.Symbol) + logger.Infof("Fetched coin from AI500 API (mixed): %s", req.Symbol) } - } else if coinSource.UseOITop && coinSource.OITopAPIURL != "" { - provider.SetOITopAPI(coinSource.OITopAPIURL) - if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 { + } else if coinSource.UseOITop { + if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 { req.Symbol = coins[0] logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol) } diff --git a/api/server.go b/api/server.go index 7f16ec26..0cbb251f 100644 --- a/api/server.go +++ b/api/server.go @@ -406,7 +406,7 @@ type CreateTraderRequest struct { CustomPrompt string `json:"custom_prompt"` OverrideBasePrompt bool `json:"override_base_prompt"` SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name - UseCoinPool bool `json:"use_coin_pool"` + UseAI500 bool `json:"use_ai500"` UseOITop bool `json:"use_oi_top"` } @@ -666,7 +666,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) { BTCETHLeverage: btcEthLeverage, AltcoinLeverage: altcoinLeverage, TradingSymbols: req.TradingSymbols, - UseCoinPool: req.UseCoinPool, + UseAI500: req.UseAI500, UseOITop: req.UseOITop, CustomPrompt: req.CustomPrompt, OverrideBasePrompt: req.OverrideBasePrompt, @@ -2049,7 +2049,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) { "custom_prompt": traderConfig.CustomPrompt, "override_base_prompt": traderConfig.OverrideBasePrompt, "is_cross_margin": traderConfig.IsCrossMargin, - "use_coin_pool": traderConfig.UseCoinPool, + "use_ai500": traderConfig.UseAI500, "use_oi_top": traderConfig.UseOITop, "is_running": isRunning, } diff --git a/api/strategy.go b/api/strategy.go index d96b3826..5f724ab6 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -9,7 +9,6 @@ import ( "nofx/market" "nofx/mcp" "nofx/store" - "strings" "time" "github.com/gin-gonic/gin" @@ -20,11 +19,11 @@ import ( func validateStrategyConfig(config *store.StrategyConfig) []string { var warnings []string - // Validate quant data URL if enabled - if config.Indicators.EnableQuantData && config.Indicators.QuantDataAPIURL != "" { - if !strings.Contains(config.Indicators.QuantDataAPIURL, "{symbol}") { - warnings = append(warnings, "Quant data URL does not contain {symbol} placeholder. The same data will be used for all coins, which may not be correct.") - } + // Validate NofxOS API key if any NofxOS feature is enabled + if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking || + config.Indicators.EnableNetFlowRanking || config.Indicators.EnablePriceRanking) && + config.Indicators.NofxOSAPIKey == "" { + warnings = append(warnings, "NofxOS API key is not configured. NofxOS data sources may not work properly.") } return warnings @@ -504,6 +503,12 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { // Fetch OI ranking data (market-wide position changes) oiRankingData := engine.FetchOIRankingData() + // Fetch NetFlow ranking data (market-wide fund flow) + netFlowRankingData := engine.FetchNetFlowRankingData() + + // Fetch Price ranking data (market-wide gainers/losers) + priceRankingData := engine.FetchPriceRankingData() + // Build real context (for generating User Prompt) testContext := &kernel.Context{ CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), @@ -519,12 +524,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { MarginUsedPct: 0, PositionCount: 0, }, - Positions: []kernel.PositionInfo{}, - CandidateCoins: candidates, - PromptVariant: req.PromptVariant, - MarketDataMap: marketDataMap, - QuantDataMap: quantDataMap, - OIRankingData: oiRankingData, + Positions: []kernel.PositionInfo{}, + CandidateCoins: candidates, + PromptVariant: req.PromptVariant, + MarketDataMap: marketDataMap, + QuantDataMap: quantDataMap, + OIRankingData: oiRankingData, + NetFlowRankingData: netFlowRankingData, + PriceRankingData: priceRankingData, } // Build System Prompt diff --git a/backtest/config.go b/backtest/config.go index 0dcf48ed..5cb5d09b 100644 --- a/backtest/config.go +++ b/backtest/config.go @@ -199,7 +199,7 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig { if len(cfg.Symbols) > 0 { result.CoinSource.SourceType = "static" result.CoinSource.StaticCoins = cfg.Symbols - result.CoinSource.UseCoinPool = false + result.CoinSource.UseAI500 = false result.CoinSource.UseOITop = false } @@ -241,12 +241,12 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig { return &store.StrategyConfig{ CoinSource: store.CoinSourceConfig{ - SourceType: "static", - StaticCoins: cfg.Symbols, - UseCoinPool: false, - CoinPoolLimit: len(cfg.Symbols), - UseOITop: false, - OITopLimit: 0, + SourceType: "static", + StaticCoins: cfg.Symbols, + UseAI500: false, + AI500Limit: len(cfg.Symbols), + UseOITop: false, + OITopLimit: 0, }, Indicators: store.IndicatorConfig{ Klines: store.KlineConfig{ diff --git a/backtest/runner.go b/backtest/runner.go index d40b236a..5f039bd4 100644 --- a/backtest/runner.go +++ b/backtest/runner.go @@ -519,7 +519,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da // Fetch quantitative data if enabled in strategy (uses current data as approximation) strategyConfig := r.strategyEngine.GetConfig() - if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" { + if strategyConfig.Indicators.EnableQuantData { // Collect symbols to query (candidate coins + position coins) symbolSet := make(map[string]bool) for _, sym := range r.cfg.Symbols { @@ -547,6 +547,24 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da } } + // Fetch NetFlow ranking data if enabled in strategy + if strategyConfig.Indicators.EnableNetFlowRanking { + ctx.NetFlowRankingData = r.strategyEngine.FetchNetFlowRankingData() + if ctx.NetFlowRankingData != nil { + logger.Infof("💰 Backtest: NetFlow ranking data ready: inst_in=%d, inst_out=%d", + len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow)) + } + } + + // Fetch Price ranking data if enabled in strategy + if strategyConfig.Indicators.EnablePriceRanking { + ctx.PriceRankingData = r.strategyEngine.FetchPriceRankingData() + if ctx.PriceRankingData != nil { + logger.Infof("📈 Backtest: Price ranking data ready for %d durations", + len(ctx.PriceRankingData.Durations)) + } + } + record := &store.DecisionRecord{ AccountState: store.AccountSnapshot{ TotalBalance: accountInfo.TotalEquity, diff --git a/debate/engine.go b/debate/engine.go index 06247e46..4700d2ff 100644 --- a/debate/engine.go +++ b/debate/engine.go @@ -335,6 +335,12 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail // Fetch OI ranking data (market-wide position changes) oiRankingData := strategyEngine.FetchOIRankingData() + // Fetch NetFlow ranking data (market-wide fund flow) + netFlowRankingData := strategyEngine.FetchNetFlowRankingData() + + // Fetch Price ranking data (market-wide gainers/losers) + priceRankingData := strategyEngine.FetchPriceRankingData() + // Build context ctx := &kernel.Context{ CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), @@ -350,12 +356,14 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail MarginUsedPct: 0, PositionCount: 0, }, - Positions: []kernel.PositionInfo{}, - CandidateCoins: candidates, - PromptVariant: session.PromptVariant, - MarketDataMap: marketDataMap, - QuantDataMap: quantDataMap, - OIRankingData: oiRankingData, + Positions: []kernel.PositionInfo{}, + CandidateCoins: candidates, + PromptVariant: session.PromptVariant, + MarketDataMap: marketDataMap, + QuantDataMap: quantDataMap, + OIRankingData: oiRankingData, + NetFlowRankingData: netFlowRankingData, + PriceRankingData: priceRankingData, } return ctx, nil diff --git a/docs/api/API_REFERENCE.md b/docs/api/API_REFERENCE.md new file mode 100644 index 00000000..383ef3f3 --- /dev/null +++ b/docs/api/API_REFERENCE.md @@ -0,0 +1,852 @@ +# CryptoMaster API 接口文档 + +## 概述 + +### 基础信息 +- **Base URL**: `https://nofxos.ai` +- **响应格式**: JSON +- **缓存时间**: 15秒(所有数据接口) +- **限流**: 每个IP每秒最多30次请求 + +### 认证方式 +所有数据接口需要认证,支持两种方式: + +#### 方式1: Query参数(推荐) +``` +GET /api/ai500/list?auth=your_api_key +``` + +#### 方式2: Authorization Header +``` +GET /api/ai500/list +Authorization: Bearer your_api_key +``` + +### 响应格式 + +**成功响应:** +```json +{ + "success": true, + "data": { ... } +} +``` + +**错误响应:** +```json +{ + "success": false, + "error": "错误信息" +} +``` + +--- + +## 重要:数值格式说明 + +### 百分比字段格式 + +不同接口的百分比字段使用不同的格式,请注意区分: + +| 字段名 | 格式 | 示例 | 说明 | +|--------|------|------|------| +| `price_delta` (涨跌幅榜/币种详情) | **小数** | `0.05` = 5% | 需要 ×100 转换为百分比 | +| `oi_delta_percent` | **已×100** | `5.0` = 5% | 直接使用,无需转换 | +| `price_delta_percent` (OI接口) | **已×100** | `5.0` = 5% | 直接使用,无需转换 | +| `increase_percent` (AI500) | **已×100** | `7.14` = 7.14% | 直接使用,无需转换 | + +### 金额字段 + +| 字段名 | 单位 | 说明 | +|--------|------|------| +| `oi_delta_value` | USDT | 持仓价值变化 | +| `amount` / `future_flow` / `spot_flow` | USDT | 资金流量 | +| `price` | USDT | 当前价格 | + +### 持仓量字段 + +| 字段名 | 单位 | 说明 | +|--------|------|------| +| `oi_delta` | 张/个 | 持仓量变化 | +| `current_oi` / `oi` | 张/个 | 当前持仓量 | +| `net_long` / `net_short` | 张/个 | 净多头/空头持仓 | + +--- + +## 时间范围参数说明 + +所有接口支持的 `duration` 参数值: + +| 参数值 | 说明 | 备注 | +|--------|------|------| +| `1m` | 1分钟 | | +| `5m` | 5分钟 | | +| `15m` | 15分钟 | | +| `30m` | 30分钟 | | +| `1h` | 1小时 | 默认值 | +| `4h` | 4小时 | | +| `8h` | 8小时 | | +| `12h` | 12小时 | | +| `24h` / `1d` | 24小时 | 两种写法均可 | +| `2d` | 2天 | | +| `3d` | 3天 | | +| `5d` | 5天 | | +| `7d` | 7天 | | + +--- + +## 1. AI500 智能评分接口 + +AI500 是基于多维度量化指标的智能评分系统,用于筛选具有上涨潜力的币种。 + +### 1.1 获取AI500推荐币种列表 + +获取经过严格筛选的优质币种列表。 + +**请求** +``` +GET /api/ai500/list +``` + +**过滤条件** +- AI评分 > 70 +- 币安OI持仓价值 > 15M USDT +- 现价 > 上榜起始价格(只返回上涨中的币种) +- 资金没有持续流出(1h/4h/12h/24h不能全为负) + +**响应示例** +```json +{ + "success": true, + "data": { + "count": 5, + "coins": [ + { + "pair": "BTCUSDT", + "score": 85.234, + "start_time": 1704067200, + "start_price": 42000.5, + "last_score": 83.5, + "max_score": 87.2, + "max_price": 45000.0, + "increase_percent": 7.14 + } + ] + } +} +``` + +**字段说明** +| 字段 | 类型 | 说明 | +|------|------|------| +| `pair` | string | 交易对名称,如 BTCUSDT | +| `score` | float | 当前AI评分(0-100) | +| `start_time` | int64 | 上榜时间戳(Unix秒) | +| `start_price` | float | 上榜时价格(USDT) | +| `last_score` | float | 上次记录的评分 | +| `max_score` | float | 在榜期间最高评分 | +| `max_price` | float | 在榜期间最高价格(USDT) | +| `increase_percent` | float | 最大涨幅百分比(**已×100**,7.14 = 7.14%) | + +--- + +### 1.2 获取单个币种AI500信息 + +**请求** +``` +GET /api/ai500/:symbol +``` + +**路径参数** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `symbol` | string | 是 | 币种符号,支持 `BTCUSDT` 或 `BTC` 格式 | + +**示例** +``` +GET /api/ai500/BTC +GET /api/ai500/ETHUSDT +``` + +**响应示例** +```json +{ + "success": true, + "data": { + "info": { + "pair": "BTCUSDT", + "score": 85.234, + "start_time": 1704067200, + "start_price": 42000.5, + "last_score": 83.5, + "max_score": 87.2, + "max_price": 45000.0, + "increase_percent": 7.14 + }, + "current_price": 44500.0, + "score": 85.234 + } +} +``` + +--- + +### 1.3 获取AI500统计信息 + +获取AI500整体统计数据。 + +**请求** +``` +GET /api/ai500/stats +``` + +**响应示例** +```json +{ + "success": true, + "data": { + "statistics": { + "total_count": 50, + "average_score": 72.5, + "max_score": 95.2, + "min_score": 55.3, + "average_increase": 12.5 + }, + "top_coins": [...], + "bottom_coins": [...] + } +} +``` + +--- + +## 2. 持仓量(OI)排行接口 + +监控各币种的合约持仓量变化,用于判断市场资金动向。 + +### 2.1 获取OI增加排行榜 + +返回持仓价值增加最多的币种排行。 + +**请求** +``` +GET /api/oi/top-ranking +``` + +**查询参数** +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `limit` | int | 20 | 返回数量,最大100 | +| `duration` | string | `1h` | 时间范围,见[时间范围参数](#时间范围参数说明) | + +**示例** +``` +GET /api/oi/top-ranking?limit=50&duration=4h +``` + +**响应示例** +```json +{ + "success": true, + "data": { + "count": 20, + "exchange": "binance", + "time_range": "4小时", + "time_range_param": "4h", + "rank_type": "top", + "limit": 50, + "positions": [ + { + "rank": 1, + "symbol": "BTCUSDT", + "price": 44500.0, + "oi_delta": 1500.5, + "oi_delta_value": 65000000, + "oi_delta_percent": 2.5, + "current_oi": 62000, + "price_delta_percent": 1.2, + "net_long": 35000, + "net_short": 27000 + } + ] + } +} +``` + +**字段说明** +| 字段 | 类型 | 格式 | 说明 | +|------|------|------|------| +| `rank` | int | - | 排名 | +| `symbol` | string | - | 交易对名称 | +| `price` | float | USDT | 当前价格 | +| `oi_delta` | float | 张/个 | 持仓量变化 | +| `oi_delta_value` | float | USDT | 持仓价值变化(**排序依据**) | +| `oi_delta_percent` | float | **已×100** | 持仓量变化百分比,2.5 = 2.5% | +| `current_oi` | float | 张/个 | 当前持仓量 | +| `price_delta_percent` | float | **已×100** | 价格变化百分比,1.2 = 1.2% | +| `net_long` | float | 张/个 | 净多头持仓 | +| `net_short` | float | 张/个 | 净空头持仓 | + +--- + +### 2.2 获取OI减少排行榜 + +返回持仓价值减少最多的币种排行。 + +**请求** +``` +GET /api/oi/low-ranking +``` + +**查询参数** +同 [OI增加排行榜](#21-获取oi增加排行榜) + +**示例** +``` +GET /api/oi/low-ranking?limit=30&duration=24h +``` + +--- + +### 2.3 获取OI Top20(向后兼容) + +**请求** +``` +GET /api/oi/top +``` + +固定返回1小时内OI增加最多的Top20,用于向后兼容。 + +--- + +## 3. 资金流量(NetFlow)排行接口 + +监控机构和散户的资金流向。 + +### 3.1 获取资金流入排行榜 + +**请求** +``` +GET /api/netflow/top-ranking +``` + +**查询参数** +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `limit` | int | 20 | 返回数量,最大100 | +| `duration` | string | `1h` | 时间范围,见[时间范围参数](#时间范围参数说明) | +| `type` | string | `institution` | 资金类型:`institution`(机构), `personal`(散户) | +| `trade` | string | `future` | 交易类型:`future`(合约), `spot`(现货) | + +**示例** +``` +GET /api/netflow/top-ranking?limit=30&duration=4h&type=institution&trade=future +``` + +**响应示例** +```json +{ + "success": true, + "data": { + "count": 30, + "type": "institution", + "trade": "合约", + "time_range": "4h", + "rank_type": "top", + "limit": 30, + "netflows": [ + { + "rank": 1, + "symbol": "BTCUSDT", + "amount": 15000000.5, + "price": 44500.0 + } + ] + } +} +``` + +**字段说明** +| 字段 | 类型 | 格式 | 说明 | +|------|------|------|------| +| `rank` | int | - | 排名 | +| `symbol` | string | - | 交易对名称 | +| `amount` | float | USDT | 资金流量,**正数=流入,负数=流出** | +| `price` | float | USDT | 当前价格 | + +--- + +### 3.2 获取资金流出排行榜 + +**请求** +``` +GET /api/netflow/low-ranking +``` + +**查询参数** +同 [资金流入排行榜](#31-获取资金流入排行榜) + +**示例** +``` +GET /api/netflow/low-ranking?limit=20&duration=1h&type=personal&trade=spot +``` + +--- + +### 3.3 获取资金流入Top20(向后兼容) + +**请求** +``` +GET /api/netflow/top +``` + +固定返回1小时内机构合约资金流入最多的Top20。 + +--- + +## 4. 涨跌幅榜接口 + +### 4.1 获取涨跌幅榜 + +同时返回涨幅榜(top)和跌幅榜(low),支持多个时间周期同时查询。 + +**请求** +``` +GET /api/price/ranking +``` + +**查询参数** +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `duration` | string | `1h` | 时间范围,可多选逗号分隔:`1h,4h,24h` | +| `limit` | int | 20 | 每个榜单返回数量,最大100 | +| `exchange` | string | `binance` | 交易所 | + +**示例** +``` +GET /api/price/ranking?duration=1h,4h,24h&limit=20 +``` + +**响应示例** +```json +{ + "success": true, + "data": { + "durations": ["1h", "4h", "24h"], + "limit": 20, + "data": { + "1h": { + "top": [ + { + "pair": "MOGUSDT", + "symbol": "MOG", + "price_delta": 0.0723, + "price": 0.00123, + "future_flow": 201500, + "spot_flow": 0, + "oi": 15000000, + "oi_delta": 500000, + "oi_delta_value": 615 + } + ], + "low": [ + { + "pair": "XYZUSDT", + "symbol": "XYZ", + "price_delta": -0.0512, + "price": 1.234, + "future_flow": -50000, + "spot_flow": -10000, + "oi": 8000000, + "oi_delta": -200000, + "oi_delta_value": -246800 + } + ] + }, + "4h": { ... }, + "24h": { ... } + } + } +} +``` + +**字段说明** +| 字段 | 类型 | 格式 | 说明 | +|------|------|------|------| +| `pair` | string | - | 完整交易对名称,如 BTCUSDT | +| `symbol` | string | - | 币种符号(去除USDT),如 BTC | +| `price_delta` | float | **小数** | 价格变动比例,**0.0723 = 7.23%**(需×100显示) | +| `price` | float | USDT | 当前价格 | +| `future_flow` | float | USDT | 合约资金流量,正数=流入 | +| `spot_flow` | float | USDT | 现货资金流量,正数=流入 | +| `oi` | float | 张/个 | 当前持仓量 | +| `oi_delta` | float | 张/个 | 持仓变化量 | +| `oi_delta_value` | float | USDT | 持仓变化价值 | + +> **注意**:`price_delta` 使用小数格式,与 OI 接口的 `price_delta_percent` 不同! + +--- + +## 5. 币种详情接口 + +### 5.1 获取单币种完整数据 + +获取指定币种的所有统计信息,一次调用获取全部数据。 + +**请求** +``` +GET /api/coin/:symbol +``` + +**路径参数** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `symbol` | string | 是 | 币种符号,支持 `BTC` 或 `BTCUSDT` 格式 | + +**查询参数** +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `include` | string | `netflow,oi,price,ai500` | 包含的数据类型,逗号分隔 | + +**include 参数选项** +| 值 | 说明 | +|------|------| +| `netflow` | 资金流量数据(机构/散户,合约/现货) | +| `oi` | 持仓量数据(币安/Bybit) | +| `price` | 价格变化数据 | +| `ai500` | AI500评分 | + +**示例** +``` +GET /api/coin/BTC?include=netflow,oi,price,ai500 +GET /api/coin/ETHUSDT?include=netflow,oi +``` + +**响应示例** +```json +{ + "success": true, + "data": { + "symbol": "BTCUSDT", + "price": 44500.0, + "ai500": { + "score": 85.234, + "is_active": true, + "start_time": 1704067200, + "start_price": 42000.5, + "increase_percent": 5.95 + }, + "netflow": { + "institution": { + "future": { + "1m": 50000, + "5m": 200000, + "15m": 500000, + "30m": 800000, + "1h": 1500000, + "4h": 5000000, + "8h": 8000000, + "12h": 10000000, + "24h": 15000000, + "2d": 25000000, + "3d": 35000000, + "5d": 50000000, + "7d": 75000000 + }, + "spot": { ... } + }, + "personal": { + "future": { ... }, + "spot": { ... } + } + }, + "oi": { + "binance": { + "current_oi": 62000, + "net_long": 35000, + "net_short": 27000, + "delta": { + "1m": { + "oi_delta": 50, + "oi_delta_value": 2225000, + "oi_delta_percent": 0.08 + }, + "5m": { ... }, + "1h": { ... }, + "4h": { ... }, + "24h": { ... } + } + }, + "bybit": { ... } + }, + "price_change": { + "1m": 0.001, + "5m": 0.005, + "15m": 0.008, + "30m": 0.012, + "1h": 0.015, + "4h": 0.025, + "8h": 0.035, + "12h": 0.042, + "24h": 0.055, + "2d": 0.08, + "3d": 0.12, + "5d": 0.18, + "7d": 0.25 + } + } +} +``` + +**字段说明** + +**price_change 对象** +| 字段 | 类型 | 格式 | 说明 | +|------|------|------|------| +| `{duration}` | float | **小数** | 价格变化比例,**0.015 = 1.5%**(需×100显示) | + +**netflow 对象** +| 路径 | 类型 | 格式 | 说明 | +|------|------|------|------| +| `institution.future.{duration}` | float | USDT | 机构合约资金流量 | +| `institution.spot.{duration}` | float | USDT | 机构现货资金流量 | +| `personal.future.{duration}` | float | USDT | 散户合约资金流量 | +| `personal.spot.{duration}` | float | USDT | 散户现货资金流量 | + +**oi 对象** +| 路径 | 类型 | 格式 | 说明 | +|------|------|------|------| +| `binance.current_oi` | float | 张/个 | 币安当前持仓量 | +| `binance.net_long` | float | 张/个 | 币安净多头 | +| `binance.net_short` | float | 张/个 | 币安净空头 | +| `binance.delta.{duration}.oi_delta` | float | 张/个 | 持仓量变化 | +| `binance.delta.{duration}.oi_delta_value` | float | USDT | 持仓价值变化 | +| `binance.delta.{duration}.oi_delta_percent` | float | **已×100** | 持仓变化百分比,0.08 = 0.08% | +| `bybit.*` | - | - | Bybit数据,结构同上 | + +**ai500 对象** +| 字段 | 类型 | 格式 | 说明 | +|------|------|------|------| +| `score` | float | 0-100 | AI综合评分 | +| `is_active` | bool | - | 是否为活跃高分币种 | +| `start_time` | int64 | Unix秒 | 上榜时间 | +| `start_price` | float | USDT | 上榜时价格 | +| `increase_percent` | float | **已×100** | 最大涨幅,5.95 = 5.95% | + +--- + +## 错误码说明 + +| HTTP状态码 | 说明 | 常见原因 | +|------------|------|----------| +| 200 | 成功 | - | +| 400 | 请求参数错误 | 参数格式不正确、缺少必填参数 | +| 401 | 未授权 | 缺少认证信息或API Key无效 | +| 404 | 资源不存在 | 币种不存在或未被追踪 | +| 429 | 请求过于频繁 | 超过限流阈值(30次/秒) | +| 500 | 服务器内部错误 | 服务端异常 | + +**错误响应示例** +```json +{ + "success": false, + "error": "unauthorized" +} +``` + +--- + +## 使用示例 + +### cURL 示例 + +```bash +# 方式1: Query参数认证 +curl "https://nofxos.ai/api/ai500/list?auth=your_api_key" + +# 方式2: Header认证 +curl "https://nofxos.ai/api/ai500/list" \ + -H "Authorization: Bearer your_api_key" + +# 获取1小时涨跌幅榜 +curl "https://nofxos.ai/api/price/ranking?duration=1h&limit=20&auth=your_api_key" + +# 获取多个时间周期涨跌幅榜 +curl "https://nofxos.ai/api/price/ranking?duration=1h,4h,24h&limit=10&auth=your_api_key" + +# 获取BTC详细数据 +curl "https://nofxos.ai/api/coin/BTC?auth=your_api_key" + +# 只获取BTC的资金流和OI数据 +curl "https://nofxos.ai/api/coin/BTC?include=netflow,oi&auth=your_api_key" + +# 获取4小时OI增加排行Top50 +curl "https://nofxos.ai/api/oi/top-ranking?duration=4h&limit=50&auth=your_api_key" + +# 获取24小时OI减少排行Top30 +curl "https://nofxos.ai/api/oi/low-ranking?duration=24h&limit=30&auth=your_api_key" + +# 获取机构合约资金流入排行 +curl "https://nofxos.ai/api/netflow/top-ranking?type=institution&trade=future&duration=1h&auth=your_api_key" + +# 获取散户现货资金流出排行 +curl "https://nofxos.ai/api/netflow/low-ranking?type=personal&trade=spot&duration=4h&auth=your_api_key" +``` + +### Python 示例 + +```python +import requests + +BASE_URL = "https://nofxos.ai" +API_KEY = "your_api_key" + +# 方式1: Query参数认证 +def get_with_query_auth(endpoint, params=None): + if params is None: + params = {} + params["auth"] = API_KEY + response = requests.get(f"{BASE_URL}{endpoint}", params=params) + return response.json() + +# 方式2: Header认证 +def get_with_header_auth(endpoint, params=None): + headers = {"Authorization": f"Bearer {API_KEY}"} + response = requests.get(f"{BASE_URL}{endpoint}", params=params, headers=headers) + return response.json() + +# 获取AI500列表 +def get_ai500_list(): + return get_with_query_auth("/api/ai500/list") + +# 获取涨跌幅榜 +def get_price_ranking(durations="1h,4h,24h", limit=20): + return get_with_query_auth("/api/price/ranking", { + "duration": durations, + "limit": limit + }) + +# 获取币种详情 +def get_coin_stats(symbol, include="netflow,oi,price,ai500"): + return get_with_query_auth(f"/api/coin/{symbol}", { + "include": include + }) + +# 获取OI排行 +def get_oi_ranking(rank_type="top", duration="1h", limit=20): + endpoint = f"/api/oi/{rank_type}-ranking" + return get_with_query_auth(endpoint, { + "duration": duration, + "limit": limit + }) + +# 获取资金流排行 +def get_netflow_ranking(rank_type="top", duration="1h", limit=20, + flow_type="institution", trade="future"): + endpoint = f"/api/netflow/{rank_type}-ranking" + return get_with_query_auth(endpoint, { + "duration": duration, + "limit": limit, + "type": flow_type, + "trade": trade + }) + +# 使用示例 +if __name__ == "__main__": + # 获取AI500推荐币种 + ai500 = get_ai500_list() + print(f"AI500推荐币种数量: {ai500['data']['count']}") + + # 获取1小时涨幅榜前10 + ranking = get_price_ranking("1h", 10) + for coin in ranking['data']['data']['1h']['top'][:3]: + # 注意: price_delta 是小数,需要×100 + pct = coin['price_delta'] * 100 + print(f"{coin['symbol']}: {pct:.2f}%") + + # 获取BTC详情 + btc = get_coin_stats("BTC") + # 注意: price_change 是小数 + print(f"BTC 1小时涨跌: {btc['data']['price_change']['1h'] * 100:.2f}%") + + # 获取4小时OI增加Top20 + oi = get_oi_ranking("top", "4h", 20) + for pos in oi['data']['positions'][:3]: + # 注意: oi_delta_percent 已×100 + print(f"{pos['symbol']}: OI变化 {pos['oi_delta_percent']:.2f}%") +``` + +### JavaScript/TypeScript 示例 + +```typescript +const BASE_URL = "https://nofxos.ai"; +const API_KEY = "your_api_key"; + +// 通用请求函数 +async function apiRequest(endpoint: string, params: Record = {}): Promise { + const url = new URL(`${BASE_URL}${endpoint}`); + params.auth = API_KEY; + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + const response = await fetch(url.toString()); + return response.json(); +} + +// 获取涨跌幅榜 +interface PriceRankingItem { + pair: string; + symbol: string; + price_delta: number; // 小数格式,0.05 = 5% + price: number; + future_flow: number; + spot_flow: number; +} + +async function getPriceRanking(durations = "1h", limit = 20) { + const data = await apiRequest("/api/price/ranking", { duration: durations, limit }); + return data; +} + +// 使用示例 +async function main() { + const ranking = await getPriceRanking("1h,4h", 10); + + for (const coin of ranking.data.data["1h"].top) { + // 转换为百分比显示 + const pctChange = (coin.price_delta * 100).toFixed(2); + console.log(`${coin.symbol}: ${pctChange}%`); + } +} +``` + +--- + +## 常见问题 + +### Q: 为什么有些百分比字段格式不同? + +A: 这是历史原因造成的: +- **OI接口**的 `oi_delta_percent` 和 `price_delta_percent` 是**已乘100**的格式(5.0 = 5%) +- **涨跌幅榜和币种详情**的 `price_delta` / `price_change` 是**小数**格式(0.05 = 5%) + +建议在前端显示时统一处理。 + +### Q: duration 参数支持哪些值? + +A: 支持以下值:`1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `8h`, `12h`, `24h`(或`1d`), `2d`, `3d`, `5d`, `7d` + +### Q: 如何判断资金是流入还是流出? + +A: `amount`、`future_flow`、`spot_flow` 等字段: +- **正数** = 资金流入 +- **负数** = 资金流出 + +### Q: API缓存时间是多久? + +A: 所有数据接口缓存15秒,相同请求在15秒内返回缓存数据。 + +### Q: 限流规则是什么? + +A: 每个IP每秒最多30次请求,超过会返回 429 错误。 diff --git a/docs/api/coin_api.md b/docs/api/coin_api.md deleted file mode 100644 index 12d69b48..00000000 --- a/docs/api/coin_api.md +++ /dev/null @@ -1,350 +0,0 @@ -# 币种综合数据接口文档 - -## 接口概述 - -该接口提供单个币种的综合数据查询,一次请求即可获取资金净流入、持仓变化、价格变化等多维度数据。 - -## 请求信息 - -### 接口地址 - -``` -GET /api/coin/{symbol} -``` - -### 完整示例 - -``` -http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c -``` - -### 请求参数 - -| 参数 | 位置 | 类型 | 必填 | 说明 | -|-----|------|------|-----|------| -| symbol | path | string | 是 | 币种符号,如 `PIPPINUSDT`、`ETH`(会自动补全USDT后缀) | -| include | query | string | 否 | 返回数据类型,逗号分隔。可选值:`netflow,oi,price`。默认返回全部 | -| auth | query | string | 是 | 认证密钥 | - -### include 参数说明 - -| 值 | 说明 | -|---|------| -| netflow | 资金净流入数据(机构/散户、合约/现货) | -| oi | 持仓数据(币安、Bybit) | -| price | 价格变化百分比 | - ---- - -## 返回数据 - -### 完整响应示例 - -```json -{ - "code": 0, - "data": { - "symbol": "PIPPINUSDT", - "price": 0.085, - "netflow": { - "institution": { - "future": { - "1m": 120000, - "5m": 580000, - "15m": 1200000, - "30m": 2500000, - "1h": 5800000, - "4h": 12000000, - "8h": 25000000, - "12h": 38000000, - "24h": 65000000, - "2d": 120000000, - "3d": 180000000 - }, - "spot": { - "1m": 50000, - "5m": 280000, - "15m": 600000, - "30m": 1200000, - "1h": 2800000, - "4h": 6000000, - "8h": 12000000, - "12h": 18000000, - "24h": 32000000, - "2d": 60000000, - "3d": 90000000 - } - }, - "personal": { - "future": { - "1m": -80000, - "5m": -350000, - "15m": -800000, - "30m": -1500000, - "1h": -3200000, - "4h": -8000000, - "8h": -15000000, - "12h": -22000000, - "24h": -40000000, - "2d": -75000000, - "3d": -110000000 - }, - "spot": { - "1m": -30000, - "5m": -150000, - "15m": -400000, - "30m": -800000, - "1h": -1800000, - "4h": -4000000, - "8h": -8000000, - "12h": -12000000, - "24h": -22000000, - "2d": -40000000, - "3d": -60000000 - } - } - }, - "oi": { - "binance": { - "current_oi": 85000, - "net_long": 48000, - "net_short": 37000, - "delta": { - "1m": { - "oi_delta": 150, - "oi_delta_value": 14550000, - "oi_delta_percent": 0.18 - }, - "5m": { - "oi_delta": 680, - "oi_delta_value": 65960000, - "oi_delta_percent": 0.8 - }, - "1h": { - "oi_delta": 2500, - "oi_delta_value": 242500000, - "oi_delta_percent": 2.94 - }, - "4h": { - "oi_delta": 5200, - "oi_delta_value": 504400000, - "oi_delta_percent": 6.12 - }, - "24h": { - "oi_delta": 8500, - "oi_delta_value": 824500000, - "oi_delta_percent": 10.0 - } - } - }, - "bybit": { - "current_oi": 42000, - "net_long": 24000, - "net_short": 18000, - "delta": { - "1h": { - "oi_delta": 1200, - "oi_delta_value": 116400000, - "oi_delta_percent": 2.86 - } - } - } - }, - "price_change": { - "1m": 0.05, - "5m": 0.18, - "15m": 0.35, - "30m": 0.62, - "1h": 1.25, - "4h": 2.80, - "8h": 3.50, - "12h": 2.95, - "24h": 4.80, - "2d": 6.50, - "3d": 8.20 - } - } -} -``` - ---- - -## 字段详细说明 - -### 基础字段 - -| 字段 | 类型 | 说明 | -|-----|------|------| -| symbol | string | 币种交易对,如 `PIPPINUSDT` | -| price | float | 当前期货价格(单位:USDT) | - ---- - -### netflow - 资金净流入 - -资金净流入数据,**正数表示资金流入,负数表示资金流出**,单位为 USDT。 - -#### 数据结构 - -``` -netflow -├── institution # 机构资金 -│ ├── future # 合约市场 -│ └── spot # 现货市场 -└── personal # 散户资金 - ├── future # 合约市场 - └── spot # 现货市场 -``` - -#### 分类说明 - -| 字段 | 说明 | -|-----|------| -| institution.future | 机构在合约市场的资金净流入 | -| institution.spot | 机构在现货市场的资金净流入 | -| personal.future | 散户在合约市场的资金净流入 | -| personal.spot | 散户在现货市场的资金净流入 | - -#### 时间周期 - -| 字段 | 说明 | -|-----|------| -| 1m | 最近 1 分钟 | -| 5m | 最近 5 分钟 | -| 15m | 最近 15 分钟 | -| 30m | 最近 30 分钟 | -| 1h | 最近 1 小时 | -| 4h | 最近 4 小时 | -| 8h | 最近 8 小时 | -| 12h | 最近 12 小时 | -| 24h | 最近 24 小时 | -| 2d | 最近 2 天 | -| 3d | 最近 3 天 | - -#### 使用建议 - -- **机构资金流入 + 散户资金流出** = 典型的主力吸筹信号 -- **机构资金流出 + 散户资金流入** = 典型的主力出货信号 -- 关注 **合约与现货的资金流向是否一致**,判断市场情绪 - ---- - -### oi - 持仓数据 - -持仓量(Open Interest)数据,来源于币安和 Bybit 交易所。 - -#### 字段说明 - -| 字段 | 类型 | 说明 | -|-----|------|------| -| current_oi | float | 当前总持仓量(单位:币) | -| net_long | float | 净多头持仓量 | -| net_short | float | 净空头持仓量 | -| delta | object | 各时间周期的持仓变化 | - -#### delta 子字段 - -| 字段 | 类型 | 说明 | -|-----|------|------| -| oi_delta | float | 持仓量变化(单位:币) | -| oi_delta_value | float | 持仓价值变化(单位:USDT) | -| oi_delta_percent | float | 持仓量变化百分比(%) | - -#### 使用建议 - -- **持仓量增加 + 价格上涨** = 多头主导,趋势可能延续 -- **持仓量增加 + 价格下跌** = 空头主导,下跌趋势可能延续 -- **持仓量减少 + 价格变化** = 平仓为主,趋势可能反转 -- **net_long > net_short** = 市场整体偏多 - ---- - -### price_change - 价格变化 - -各时间周期的价格涨跌幅,**单位为百分比(%)**,正数表示上涨,负数表示下跌。 - -| 字段 | 说明 | -|-----|------| -| 1m | 最近 1 分钟涨跌幅 | -| 5m | 最近 5 分钟涨跌幅 | -| 15m | 最近 15 分钟涨跌幅 | -| 30m | 最近 30 分钟涨跌幅 | -| 1h | 最近 1 小时涨跌幅 | -| 4h | 最近 4 小时涨跌幅 | -| 8h | 最近 8 小时涨跌幅 | -| 12h | 最近 12 小时涨跌幅 | -| 24h | 最近 24 小时涨跌幅 | -| 2d | 最近 2 天涨跌幅 | -| 3d | 最近 3 天涨跌幅 | - ---- - -## 错误响应 - -| code | 说明 | -|------|------| -| 0 | 成功 | -| 400 | 参数错误(如缺少 symbol) | -| 401 | 认证失败(auth 无效) | -| 500 | 服务器内部错误 | - -错误响应示例: - -```json -{ - "code": 400, - "message": "symbol parameter is required" -} -``` - ---- - -## 调用示例 - -### cURL - -```bash -curl -X GET "http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c" -``` - -### Python - -```python -import requests - -url = "http://nofxaios.com:30006/api/coin/PIPPINUSDT" -params = { - "include": "netflow,oi,price", - "auth": "cm_568c67eae410d912c54c" -} - -response = requests.get(url, params=params) -data = response.json() - -print(f"当前价格: {data['data']['price']}") -print(f"1小时机构合约净流入: {data['data']['netflow']['institution']['future']['1h']}") -print(f"24小时价格涨跌幅: {data['data']['price_change']['24h']}%") -``` - -### JavaScript - -```javascript -const url = 'http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c'; - -fetch(url) - .then(response => response.json()) - .then(data => { - console.log('当前价格:', data.data.price); - console.log('1小时机构合约净流入:', data.data.netflow.institution.future['1h']); - console.log('24小时价格涨跌幅:', data.data.price_change['24h'], '%'); - }); -``` - ---- - -## 注意事项 - -1. **symbol 参数**:支持带或不带 `USDT` 后缀,如 `PIPPIN` 和 `PIPPINUSDT` 等效 -2. **include 参数**:可按需选择返回数据,减少不必要的数据传输 -3. **数据更新频率**:数据实时更新,建议轮询间隔不低于 1 秒 -4. **资金流向解读**:机构与散户的资金流向通常呈相反趋势,可作为市场情绪判断依据 diff --git a/docs/api/oi_api.md b/docs/api/oi_api.md deleted file mode 100644 index 07d8d861..00000000 --- a/docs/api/oi_api.md +++ /dev/null @@ -1,254 +0,0 @@ -# OI 持仓数据接口文档 - -## 接口概述 - -该接口提供币安交易所的合约持仓量(Open Interest)排行数据,支持查询持仓增加和减少排行榜。 - -## 接口列表 - -| 接口 | 说明 | -|-----|------| -| `/api/oi/top` | 持仓增加排行 Top20(固定参数,向后兼容) | -| `/api/oi/top-ranking` | 持仓增加排行(支持自定义参数) | -| `/api/oi/low-ranking` | 持仓减少排行(支持自定义参数) | - ---- - -## 1. 持仓增加排行 Top20 - -### 请求 - -``` -GET /api/oi/top -``` - -### 完整示例 - -``` -http://nofxaios.com:30006/api/oi/top?auth=cm_568c67eae410d912c54c -``` - -### 参数 - -| 参数 | 类型 | 必填 | 说明 | -|-----|------|-----|------| -| auth | string | 是 | 认证密钥 | - -### 说明 - -固定返回 1 小时内持仓价值增加最多的前 20 个币种,向后兼容接口。 - ---- - -## 2. 持仓增加排行(自定义参数) - -### 请求 - -``` -GET /api/oi/top-ranking -``` - -### 完整示例 - -``` -http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c -``` - -### 参数 - -| 参数 | 类型 | 必填 | 默认值 | 说明 | -|-----|------|-----|-------|------| -| limit | int | 否 | 20 | 获取数量,范围 1-100 | -| duration | string | 否 | 1h | 时间范围 | -| auth | string | 是 | - | 认证密钥 | - ---- - -## 3. 持仓减少排行 - -### 请求 - -``` -GET /api/oi/low-ranking -``` - -### 完整示例 - -``` -http://nofxaios.com:30006/api/oi/low-ranking?limit=30&duration=24h&auth=cm_568c67eae410d912c54c -``` - -### 参数 - -同持仓增加排行接口。 - ---- - -## duration 时间范围参数 - -| 值 | 说明 | -|---|------| -| 1m | 1 分钟 | -| 5m | 5 分钟 | -| 15m | 15 分钟 | -| 30m | 30 分钟 | -| 1h | 1 小时(默认) | -| 4h | 4 小时 | -| 8h | 8 小时 | -| 12h | 12 小时 | -| 24h | 24 小时 | -| 1d | 1 天(同 24h) | -| 2d | 2 天 | -| 3d | 3 天 | - ---- - -## 返回数据 - -### 响应示例 - -```json -{ - "code": 0, - "data": { - "count": 20, - "exchange": "binance", - "time_range": "4小时", - "time_range_param": "4h", - "rank_type": "top", - "limit": 20, - "positions": [ - { - "rank": 1, - "symbol": "BTCUSDT", - "oi_delta": 1500.5, - "oi_delta_value": 145500000, - "oi_delta_percent": 3.52, - "current_oi": 44000, - "price_delta_percent": 2.15, - "net_long": 26000, - "net_short": 18000 - }, - { - "rank": 2, - "symbol": "ETHUSDT", - "oi_delta": 25000, - "oi_delta_value": 87500000, - "oi_delta_percent": 2.85, - "current_oi": 900000, - "price_delta_percent": 1.80, - "net_long": 520000, - "net_short": 380000 - } - ] - } -} -``` - -### 字段说明 - -#### 外层字段 - -| 字段 | 类型 | 说明 | -|-----|------|------| -| count | int | 返回的币种数量 | -| exchange | string | 交易所,固定为 `binance` | -| time_range | string | 时间范围显示名称 | -| time_range_param | string | 时间范围参数值 | -| rank_type | string | 排行类型:`top` 增加 / `low` 减少 | -| limit | int | 请求的数量限制 | -| positions | array | 持仓数据列表 | - -#### positions 数组字段 - -| 字段 | 类型 | 说明 | -|-----|------|------| -| rank | int | 排名 | -| symbol | string | 币种交易对,如 `BTCUSDT` | -| oi_delta | float | 持仓量变化(单位:币) | -| oi_delta_value | float | 持仓价值变化(单位:USDT),**排序依据** | -| oi_delta_percent | float | 持仓量变化百分比(%) | -| current_oi | float | 当前持仓量(单位:币) | -| price_delta_percent | float | 价格变化百分比(%) | -| net_long | float | 净多头持仓量 | -| net_short | float | 净空头持仓量 | - ---- - -## 数据解读 - -### 持仓量与价格的关系 - -| 持仓变化 | 价格变化 | 市场含义 | -|---------|---------|---------| -| 增加 | 上涨 | 多头主导,上涨趋势可能延续 | -| 增加 | 下跌 | 空头主导,下跌趋势可能延续 | -| 减少 | 上涨 | 空头平仓,可能是反弹 | -| 减少 | 下跌 | 多头平仓,可能是回调 | - -### 多空比例 - -- `net_long > net_short`:市场整体偏多 -- `net_long < net_short`:市场整体偏空 - ---- - -## 调用示例 - -### cURL - -```bash -curl -X GET "http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c" -``` - -### Python - -```python -import requests - -url = "http://nofxaios.com:30006/api/oi/top-ranking" -params = { - "limit": 50, - "duration": "4h", - "auth": "cm_568c67eae410d912c54c" -} - -response = requests.get(url, params=params) -data = response.json() - -for pos in data['data']['positions']: - print(f"#{pos['rank']} {pos['symbol']}: 持仓价值变化 ${pos['oi_delta_value']:,.0f}") -``` - -### JavaScript - -```javascript -const url = 'http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c'; - -fetch(url) - .then(response => response.json()) - .then(data => { - data.data.positions.forEach(pos => { - console.log(`#${pos.rank} ${pos.symbol}: 持仓价值变化 $${pos.oi_delta_value.toLocaleString()}`); - }); - }); -``` - ---- - -## 错误响应 - -| code | 说明 | -|------|------| -| 0 | 成功 | -| 401 | 认证失败(auth 无效) | -| 500 | 服务器内部错误 | - ---- - -## 注意事项 - -1. 数据来源为币安交易所 -2. 排行依据为 `oi_delta_value`(持仓价值变化),非持仓量变化 -3. 数据缓存 2 秒,高频请求会命中缓存 -4. `limit` 最大值为 100 diff --git a/docs/architecture/STRATEGY_MODULE.zh-CN.md b/docs/architecture/STRATEGY_MODULE.zh-CN.md index 1ef8c8e3..1a3ce6c3 100644 --- a/docs/architecture/STRATEGY_MODULE.zh-CN.md +++ b/docs/architecture/STRATEGY_MODULE.zh-CN.md @@ -112,7 +112,7 @@ func (e *StrategyEngine) getCoinPoolCoins(limit int) []CandidateCoin { } ``` -- **API:** `config.CoinSource.CoinPoolAPIURL` (默认: `http://nofxaios.com:30006/api/ai500/list`) +- **API:** `config.CoinSource.CoinPoolAPIURL` (默认: `https://nofxos.ai/api/ai500/list`) - **用途:** 获取 AI 评分最高的 N 个币种 - **标签:** `["ai500"]` diff --git a/kernel/engine.go b/kernel/engine.go index f2dd927c..e452c730 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -8,7 +8,7 @@ import ( "nofx/logger" "nofx/market" "nofx/mcp" - "nofx/provider" + "nofx/provider/nofxos" "nofx/security" "nofx/store" "regexp" @@ -119,8 +119,10 @@ type Context struct { MultiTFMarket map[string]map[string]*market.Data `json:"-"` OITopDataMap map[string]*OITopData `json:"-"` QuantDataMap map[string]*QuantData `json:"-"` - OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data - BTCETHLeverage int `json:"-"` + OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data + NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data + PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers + BTCETHLeverage int `json:"-"` AltcoinLeverage int `json:"-"` Timeframes []string `json:"-"` } @@ -189,12 +191,23 @@ type OIDeltaData struct { // StrategyEngine strategy execution engine type StrategyEngine struct { - config *store.StrategyConfig + config *store.StrategyConfig + nofxosClient *nofxos.Client } // NewStrategyEngine creates strategy execution engine func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { - return &StrategyEngine{config: config} + // Create NofxOS client with API key from config + apiKey := config.Indicators.NofxOSAPIKey + if apiKey == "" { + apiKey = nofxos.DefaultAuthKey + } + client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey) + + return &StrategyEngine{ + config: config, + nofxosClient: client, + } } // GetRiskControlConfig gets risk control configuration @@ -202,6 +215,19 @@ func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig { return e.config.RiskControl } +// GetLanguage returns the language from config or falls back to auto-detection +func (e *StrategyEngine) GetLanguage() Language { + switch e.config.Language { + case "zh": + return LangChinese + case "en": + return LangEnglish + default: + // Fall back to auto-detection from prompt content for backward compatibility + return detectLanguage(e.config.PromptSections.RoleDefinition) + } +} + // GetConfig gets complete strategy configuration func (e *StrategyEngine) GetConfig() *store.StrategyConfig { return e.config @@ -239,7 +265,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S // Ensure OITopDataMap is initialized if ctx.OITopDataMap == nil { ctx.OITopDataMap = make(map[string]*OITopData) - oiPositions, err := provider.GetOITopPositions() + oiPositions, err := engine.nofxosClient.GetOITopPositions() if err == nil { for _, pos := range oiPositions { ctx.OITopDataMap[pos.Symbol] = &OITopData{ @@ -385,13 +411,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { coinSource := e.config.CoinSource - if coinSource.CoinPoolAPIURL != "" { - provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) - } - if coinSource.OITopAPIURL != "" { - provider.SetOITopAPI(coinSource.OITopAPIURL) - } - switch coinSource.SourceType { case "static": for _, symbol := range coinSource.StaticCoins { @@ -404,10 +423,10 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { return e.filterExcludedCoins(candidates), nil - case "coinpool": - // 检查 use_coin_pool 标志,如果为 false 则回退到静态币种 - if !coinSource.UseCoinPool { - logger.Infof("⚠️ source_type is 'coinpool' but use_coin_pool is false, falling back to static coins") + case "ai500": + // 检查 use_ai500 标志,如果为 false 则回退到静态币种 + if !coinSource.UseAI500 { + logger.Infof("⚠️ source_type is 'ai500' but use_ai500 is false, falling back to static coins") for _, symbol := range coinSource.StaticCoins { symbol = market.Normalize(symbol) candidates = append(candidates, CandidateCoin{ @@ -417,7 +436,7 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { } return e.filterExcludedCoins(candidates), nil } - coins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit) + coins, err := e.getAI500Coins(coinSource.AI500Limit) if err != nil { return nil, err } @@ -443,10 +462,10 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { return e.filterExcludedCoins(coins), nil case "mixed": - if coinSource.UseCoinPool { - poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit) + if coinSource.UseAI500 { + poolCoins, err := e.getAI500Coins(coinSource.AI500Limit) if err != nil { - logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err) + logger.Infof("⚠️ Failed to get AI500 coins: %v", err) } else { for _, coin := range poolCoins { symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500") @@ -513,12 +532,12 @@ func (e *StrategyEngine) filterExcludedCoins(candidates []CandidateCoin) []Candi return filtered } -func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) { +func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) { if limit <= 0 { limit = 30 } - symbols, err := provider.GetTopRatedCoins(limit) + symbols, err := e.nofxosClient.GetTopRatedCoins(limit) if err != nil { return nil, err } @@ -538,7 +557,7 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) { limit = 20 } - positions, err := provider.GetOITopPositions() + positions, err := e.nofxosClient.GetOITopPositions() if err != nil { return nil, err } @@ -645,50 +664,82 @@ func extractJSONPath(data interface{}, path string) interface{} { // FetchQuantData fetches quantitative data for a single coin func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) { - if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { + if !e.config.Indicators.EnableQuantData { return nil, nil } - apiURL := e.config.Indicators.QuantDataAPIURL - url := strings.Replace(apiURL, "{symbol}", symbol, -1) + // Use nofxos client with unified API key + include := "oi,price" + if e.config.Indicators.EnableQuantNetflow { + include = "netflow,oi,price" + } - // SSRF Protection: Validate URL before making request - resp, err := security.SafeGet(url, 10*time.Second) + nofxosData, err := e.nofxosClient.GetCoinData(symbol, include) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode) + return nil, fmt.Errorf("failed to fetch quant data: %w", err) } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + if nofxosData == nil { + return nil, nil } - var apiResp struct { - Code int `json:"code"` - Data *QuantData `json:"data"` + // Convert nofxos.QuantData to kernel.QuantData + quantData := &QuantData{ + Symbol: nofxosData.Symbol, + Price: nofxosData.Price, + PriceChange: nofxosData.PriceChange, } - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse JSON: %w", err) + // Convert OI data + if nofxosData.OI != nil { + quantData.OI = make(map[string]*OIData) + for exchange, oiData := range nofxosData.OI { + if oiData != nil { + kData := &OIData{ + CurrentOI: oiData.CurrentOI, + } + if oiData.Delta != nil { + kData.Delta = make(map[string]*OIDeltaData) + for dur, delta := range oiData.Delta { + if delta != nil { + kData.Delta[dur] = &OIDeltaData{ + OIDelta: delta.OIDelta, + OIDeltaValue: delta.OIDeltaValue, + OIDeltaPercent: delta.OIDeltaPercent, + } + } + } + } + quantData.OI[exchange] = kData + } + } } - if apiResp.Code != 0 { - return nil, fmt.Errorf("API returned error code: %d", apiResp.Code) + // Convert Netflow data + if nofxosData.Netflow != nil { + quantData.Netflow = &NetflowData{} + if nofxosData.Netflow.Institution != nil { + quantData.Netflow.Institution = &FlowTypeData{ + Future: nofxosData.Netflow.Institution.Future, + Spot: nofxosData.Netflow.Institution.Spot, + } + } + if nofxosData.Netflow.Personal != nil { + quantData.Netflow.Personal = &FlowTypeData{ + Future: nofxosData.Netflow.Personal.Future, + Spot: nofxosData.Netflow.Personal.Spot, + } + } } - return apiResp.Data, nil + return quantData, nil } // FetchQuantDataBatch batch fetches quantitative data func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData { result := make(map[string]*QuantData) - if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { + if !e.config.Indicators.EnableQuantData { return result } @@ -707,28 +758,12 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant } // FetchOIRankingData fetches market-wide OI ranking data -func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData { +func (e *StrategyEngine) FetchOIRankingData() *nofxos.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" @@ -741,7 +776,7 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData { logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit) - data, err := provider.GetOIRankingData(baseURL, authKey, duration, limit) + data, err := e.nofxosClient.GetOIRanking(duration, limit) if err != nil { logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err) return nil @@ -753,6 +788,68 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData { return data } +// FetchNetFlowRankingData fetches market-wide NetFlow ranking data +func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData { + indicators := e.config.Indicators + if !indicators.EnableNetFlowRanking { + return nil + } + + duration := indicators.NetFlowRankingDuration + if duration == "" { + duration = "1h" + } + + limit := indicators.NetFlowRankingLimit + if limit <= 0 { + limit = 10 + } + + logger.Infof("💰 Fetching NetFlow ranking data (duration: %s, limit: %d)", duration, limit) + + data, err := e.nofxosClient.GetNetFlowRanking(duration, limit) + if err != nil { + logger.Warnf("⚠️ Failed to fetch NetFlow ranking data: %v", err) + return nil + } + + logger.Infof("✓ NetFlow ranking data ready: inst_in=%d, inst_out=%d, retail_in=%d, retail_out=%d", + len(data.InstitutionFutureTop), len(data.InstitutionFutureLow), + len(data.PersonalFutureTop), len(data.PersonalFutureLow)) + + return data +} + +// FetchPriceRankingData fetches market-wide price ranking data (gainers/losers) +func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData { + indicators := e.config.Indicators + if !indicators.EnablePriceRanking { + return nil + } + + durations := indicators.PriceRankingDuration + if durations == "" { + durations = "1h" + } + + limit := indicators.PriceRankingLimit + if limit <= 0 { + limit = 10 + } + + logger.Infof("📈 Fetching Price ranking data (durations: %s, limit: %d)", durations, limit) + + data, err := e.nofxosClient.GetPriceRanking(durations, limit) + if err != nil { + logger.Warnf("⚠️ Failed to fetch Price ranking data: %v", err) + return nil + } + + logger.Infof("✓ Price ranking data ready for %d durations", len(data.Durations)) + + return data +} + // ============================================================================ // Prompt Building - System Prompt // ============================================================================ @@ -764,7 +861,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string promptSections := e.config.PromptSections // 0. Data Dictionary & Schema (ensure AI understands all fields) - lang := detectLanguage(promptSections.RoleDefinition) + lang := e.GetLanguage() schemaPrompt := GetSchemaPrompt(lang) sb.WriteString(schemaPrompt) sb.WriteString("\n\n") @@ -955,7 +1052,7 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) { sb.WriteString("- Funding rate\n") } - if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop { + if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop { sb.WriteString("- AI500 / OI_Top filter tags (if available)\n") } @@ -1011,8 +1108,8 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { // Historical trading statistics (helps AI understand past performance) if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { - // Detect language from strategy config - lang := detectLanguage(e.config.PromptSections.RoleDefinition) + // Get language from strategy config + lang := e.GetLanguage() // Win/Loss ratio var winLossRatio float64 @@ -1116,9 +1213,25 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { } sb.WriteString("\n") + // Get language for market data formatting + nofxosLang := nofxos.LangEnglish + if e.GetLanguage() == LangChinese { + nofxosLang = nofxos.LangChinese + } + // OI Ranking data (market-wide open interest changes) if ctx.OIRankingData != nil { - sb.WriteString(provider.FormatOIRankingForAI(ctx.OIRankingData)) + sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang)) + } + + // NetFlow Ranking data (market-wide fund flow) + if ctx.NetFlowRankingData != nil { + sb.WriteString(nofxos.FormatNetFlowRankingForAI(ctx.NetFlowRankingData, nofxosLang)) + } + + // Price Ranking data (market-wide gainers/losers) + if ctx.PriceRankingData != nil { + sb.WriteString(nofxos.FormatPriceRankingForAI(ctx.PriceRankingData, nofxosLang)) } sb.WriteString("---\n\n") diff --git a/kernel/formatter.go b/kernel/formatter.go index 7a5593ca..c4a2e454 100644 --- a/kernel/formatter.go +++ b/kernel/formatter.go @@ -3,6 +3,7 @@ package kernel import ( "fmt" "nofx/market" + "nofx/provider/nofxos" "sort" "strings" "time" @@ -89,11 +90,11 @@ func formatContextData(ctx *Context, lang Language) string { // 7. OI排名数据(如果有) if ctx.OIRankingData != nil { + nofxosLang := nofxos.LangEnglish if lang == LangChinese { - sb.WriteString(formatOIRankingZH(ctx.OIRankingData)) - } else { - sb.WriteString(formatOIRankingEN(ctx.OIRankingData)) + nofxosLang = nofxos.LangChinese } + sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang)) } return sb.String() @@ -354,11 +355,6 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD return sb.String() } -// formatOIRankingZH 格式化OI排名数据(中文) -func formatOIRankingZH(oiData interface{}) string { - // TODO: 根据实际OIRankingData结构实现 - return "## 市场持仓量排名\n\n(数据加载中...)\n\n" -} // getOIInterpretationZH 获取OI变化解读(中文) func getOIInterpretationZH(oiChange, priceChange string) string { @@ -624,10 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD return sb.String() } -// formatOIRankingEN 格式化OI排名数据(英文) -func formatOIRankingEN(oiData interface{}) string { - return "## Market-wide OI Ranking\n\n(Loading data...)\n\n" -} // getOIInterpretationEN 获取OI变化解读(英文) func getOIInterpretationEN(oiChange, priceChange string) string { diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 650d9a4d..5f4e9c1d 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -606,7 +606,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { continue } - // Add to TraderManager (coinPoolURL/oiTopURL already obtained from strategy config) + // Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config) err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st) if err != nil { logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err) @@ -641,7 +641,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name) } - // Build AutoTraderConfig (coinPoolURL/oiTopURL obtained from strategy config, used in StrategyEngine) + // Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine) traderConfig := trader.AutoTraderConfig{ ID: traderCfg.ID, Name: traderCfg.Name, diff --git a/provider/data_provider.go b/provider/data_provider.go deleted file mode 100644 index 22943f78..00000000 --- a/provider/data_provider.go +++ /dev/null @@ -1,593 +0,0 @@ -package provider - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "log" - "net/http" - "nofx/security" - "strings" - "time" -) - -// AI500Config AI500 data provider configuration -type AI500Config struct { - APIURL string - Timeout time.Duration -} - -var ai500Config = AI500Config{ - APIURL: "", - Timeout: 30 * time.Second, -} - -// CoinData coin information -type CoinData struct { - Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT) - Score float64 `json:"score"` // Current score - StartTime int64 `json:"start_time"` // Start time (Unix timestamp) - StartPrice float64 `json:"start_price"` // Start price - LastScore float64 `json:"last_score"` // Latest score - MaxScore float64 `json:"max_score"` // Highest score - MaxPrice float64 `json:"max_price"` // Highest price - IncreasePercent float64 `json:"increase_percent"` // Increase percentage - IsAvailable bool `json:"-"` // Whether tradable (internal use) -} - -// AI500APIResponse raw data structure returned by AI500 API -type AI500APIResponse struct { - Success bool `json:"success"` - Data struct { - Coins []CoinData `json:"coins"` - Count int `json:"count"` - } `json:"data"` -} - -// SetAI500API sets AI500 data provider API -func SetAI500API(apiURL string) { - ai500Config.APIURL = apiURL -} - -// SetOITopAPI sets OI Top API -func SetOITopAPI(apiURL string) { - oiTopConfig.APIURL = apiURL -} - - -// GetAI500Data retrieves AI500 coin list (with retry mechanism) -func GetAI500Data() ([]CoinData, error) { - // Check if API URL is configured - if strings.TrimSpace(ai500Config.APIURL) == "" { - return nil, fmt.Errorf("AI500 API URL not configured") - } - - maxRetries := 3 - var lastErr error - - // Try to fetch from API - for attempt := 1; attempt <= maxRetries; attempt++ { - if attempt > 1 { - log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries) - time.Sleep(2 * time.Second) - } - - coins, err := fetchAI500() - if err == nil { - if attempt > 1 { - log.Printf("✓ Retry attempt %d succeeded", attempt) - } - return coins, nil - } - - lastErr = err - log.Printf("❌ Request attempt %d failed: %v", attempt, err) - } - - return nil, fmt.Errorf("all API requests failed: %w", lastErr) -} - -// fetchAI500 actually executes AI500 request -func fetchAI500() ([]CoinData, error) { - log.Printf("🔄 Requesting AI500 data...") - - // SSRF Protection: Validate URL before making request - resp, err := security.SafeGet(ai500Config.APIURL, ai500Config.Timeout) - if err != nil { - return nil, fmt.Errorf("failed to request AI500 API: %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)) - } - - // Parse API response - var response AI500APIResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("JSON parsing failed: %w", err) - } - - if !response.Success { - return nil, fmt.Errorf("API returned failure status") - } - - if len(response.Data.Coins) == 0 { - return nil, fmt.Errorf("coin list is empty") - } - - // Set IsAvailable flag - coins := response.Data.Coins - for i := range coins { - coins[i].IsAvailable = true - } - - log.Printf("✓ Successfully fetched %d coins", len(coins)) - return coins, nil -} - -// GetAvailableCoins retrieves available coin list (filters out unavailable ones) -func GetAvailableCoins() ([]string, error) { - coins, err := GetAI500Data() - if err != nil { - return nil, err - } - - var symbols []string - for _, coin := range coins { - if coin.IsAvailable { - symbol := normalizeSymbol(coin.Pair) - symbols = append(symbols, symbol) - } - } - - if len(symbols) == 0 { - return nil, fmt.Errorf("no available coins") - } - - return symbols, nil -} - -// GetTopRatedCoins retrieves top N coins by score (sorted by score descending) -func GetTopRatedCoins(limit int) ([]string, error) { - coins, err := GetAI500Data() - if err != nil { - return nil, err - } - - // Filter available coins - var availableCoins []CoinData - for _, coin := range coins { - if coin.IsAvailable { - availableCoins = append(availableCoins, coin) - } - } - - if len(availableCoins) == 0 { - return nil, fmt.Errorf("no available coins") - } - - // Sort by Score descending (bubble sort) - for i := 0; i < len(availableCoins); i++ { - for j := i + 1; j < len(availableCoins); j++ { - if availableCoins[i].Score < availableCoins[j].Score { - availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i] - } - } - } - - // Take top N - maxCount := limit - if len(availableCoins) < maxCount { - maxCount = len(availableCoins) - } - - var symbols []string - for i := 0; i < maxCount; i++ { - symbol := normalizeSymbol(availableCoins[i].Pair) - symbols = append(symbols, symbol) - } - - return symbols, nil -} - -// normalizeSymbol normalizes coin symbol -func normalizeSymbol(symbol string) string { - symbol = trimSpaces(symbol) - symbol = toUpper(symbol) - if !endsWith(symbol, "USDT") { - symbol = symbol + "USDT" - } - return symbol -} - -// Helper functions -func trimSpaces(s string) string { - result := "" - for i := 0; i < len(s); i++ { - if s[i] != ' ' { - result += string(s[i]) - } - } - return result -} - -func toUpper(s string) string { - result := "" - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'a' && c <= 'z' { - c = c - 'a' + 'A' - } - result += string(c) - } - return result -} - -func endsWith(s, suffix string) bool { - if len(s) < len(suffix) { - return false - } - return s[len(s)-len(suffix):] == suffix -} - - -// ========== OI Top (Open Interest Growth Top 20) Data ========== - -// OIPosition open interest data -type OIPosition struct { - Symbol string `json:"symbol"` - Rank int `json:"rank"` - CurrentOI float64 `json:"current_oi"` - OIDelta float64 `json:"oi_delta"` - OIDeltaPercent float64 `json:"oi_delta_percent"` - OIDeltaValue float64 `json:"oi_delta_value"` - PriceDeltaPercent float64 `json:"price_delta_percent"` - NetLong float64 `json:"net_long"` - NetShort float64 `json:"net_short"` -} - -// OITopAPIResponse data structure returned by OI Top API -type OITopAPIResponse struct { - Code int `json:"code"` - Data struct { - Positions []OIPosition `json:"positions"` - Count int `json:"count"` - Exchange string `json:"exchange"` - TimeRange string `json:"time_range"` - TimeRangeParam string `json:"time_range_param"` - RankType string `json:"rank_type"` - Limit int `json:"limit"` - } `json:"data"` -} - -var oiTopConfig = struct { - APIURL string - Timeout time.Duration -}{ - APIURL: "", - Timeout: 30 * time.Second, -} - -// GetOITopPositions retrieves OI Top 20 data (with retry) -func GetOITopPositions() ([]OIPosition, error) { - if strings.TrimSpace(oiTopConfig.APIURL) == "" { - log.Printf("⚠️ OI Top API URL not configured, skipping OI Top data fetch") - return []OIPosition{}, nil - } - - maxRetries := 3 - var lastErr error - - for attempt := 1; attempt <= maxRetries; attempt++ { - if attempt > 1 { - log.Printf("⚠️ Retry attempt %d of %d to fetch OI Top data...", attempt, maxRetries) - time.Sleep(2 * time.Second) - } - - positions, err := fetchOITop() - if err == nil { - if attempt > 1 { - log.Printf("✓ Retry attempt %d succeeded", attempt) - } - return positions, nil - } - - lastErr = err - log.Printf("❌ OI Top request attempt %d failed: %v", attempt, err) - } - - log.Printf("⚠️ All OI Top API requests failed (last error: %v), skipping OI Top data", lastErr) - return []OIPosition{}, nil -} - -// fetchOITop actually executes OI Top request -func fetchOITop() ([]OIPosition, error) { - log.Printf("🔄 Requesting OI Top data...") - - // SSRF Protection: Validate URL before making request - resp, err := security.SafeGet(oiTopConfig.APIURL, oiTopConfig.Timeout) - if err != nil { - return nil, fmt.Errorf("failed to request OI Top API: %w", err) - } - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read OI Top response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("OI Top 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("OI Top JSON parsing failed: %w", err) - } - - if response.Code != 0 { - return nil, fmt.Errorf("OI Top API returned error code: %d", response.Code) - } - - if len(response.Data.Positions) == 0 { - return nil, fmt.Errorf("OI Top position list is empty") - } - - log.Printf("✓ Successfully fetched %d OI Top coins (time range: %s, type: %s)", - len(response.Data.Positions), response.Data.TimeRange, response.Data.RankType) - return response.Data.Positions, nil -} - -// GetOITopSymbols retrieves OI Top coin symbol list -func GetOITopSymbols() ([]string, error) { - positions, err := GetOITopPositions() - if err != nil { - return nil, err - } - - var symbols []string - for _, pos := range positions { - symbol := normalizeSymbol(pos.Symbol) - symbols = append(symbols, symbol) - } - - return symbols, nil -} - -// MergedData merged data (AI500 + OI Top) -type MergedData struct { - AI500Coins []CoinData - OITopCoins []OIPosition - AllSymbols []string - SymbolSources map[string][]string -} - -// OIRankingData OI ranking data for debate (includes both top and low) -type OIRankingData struct { - TimeRange string `json:"time_range"` - Duration string `json:"duration"` - 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) -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) { - // SSRF Protection: Validate URL before making request - resp, err := security.SafeGet(url, 30*time.Second) - 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)) - - 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%% |\n", - pos.Rank, - pos.Symbol, - formatOIValue(pos.OIDeltaValue), - pos.OIDeltaPercent, - pos.PriceDeltaPercent, - )) - } - sb.WriteString("\n") - sb.WriteString("**解读**: 持仓增加 + 价格上涨 = 多头主导; 持仓增加 + 价格下跌 = 空头主导\n\n") - } - - 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%% |\n", - pos.Rank, - pos.Symbol, - formatOIValue(pos.OIDeltaValue), - pos.OIDeltaPercent, - pos.PriceDeltaPercent, - )) - } - sb.WriteString("\n") - 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) -} - -// GetMergedData retrieves merged data (AI500 + OI Top, deduplicated) -func GetMergedData(ai500Limit int) (*MergedData, error) { - ai500TopSymbols, err := GetTopRatedCoins(ai500Limit) - if err != nil { - log.Printf("⚠️ Failed to get AI500 data: %v", err) - ai500TopSymbols = []string{} - } - - oiTopSymbols, err := GetOITopSymbols() - if err != nil { - log.Printf("⚠️ Failed to get OI Top data: %v", err) - oiTopSymbols = []string{} - } - - symbolSet := make(map[string]bool) - symbolSources := make(map[string][]string) - - for _, symbol := range ai500TopSymbols { - symbolSet[symbol] = true - symbolSources[symbol] = append(symbolSources[symbol], "ai500") - } - - for _, symbol := range oiTopSymbols { - if !symbolSet[symbol] { - symbolSet[symbol] = true - } - symbolSources[symbol] = append(symbolSources[symbol], "oi_top") - } - - var allSymbols []string - for symbol := range symbolSet { - allSymbols = append(allSymbols, symbol) - } - - ai500Coins, _ := GetAI500Data() - oiTopPositions, _ := GetOITopPositions() - - merged := &MergedData{ - AI500Coins: ai500Coins, - OITopCoins: oiTopPositions, - AllSymbols: allSymbols, - SymbolSources: symbolSources, - } - - log.Printf("📊 Data merge complete: AI500=%d, OI_Top=%d, Total(deduplicated)=%d", - len(ai500TopSymbols), len(oiTopSymbols), len(allSymbols)) - - return merged, nil -} - -// ========== Backward Compatibility Aliases ========== - -// Deprecated: Use SetAI500API instead -func SetCoinPoolAPI(apiURL string) { - SetAI500API(apiURL) -} - -// Deprecated: Use GetAI500Data instead -func GetCoinPool() ([]CoinData, error) { - return GetAI500Data() -} - -// Deprecated: Use MergedData instead -type MergedCoinPool = MergedData - -// Deprecated: Use GetMergedData instead -func GetMergedCoinPool(ai500Limit int) (*MergedData, error) { - return GetMergedData(ai500Limit) -} - -// Deprecated: Use CoinData instead -type CoinInfo = CoinData diff --git a/provider/nofxos/ai500.go b/provider/nofxos/ai500.go new file mode 100644 index 00000000..19f46cb4 --- /dev/null +++ b/provider/nofxos/ai500.go @@ -0,0 +1,163 @@ +package nofxos + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" +) + +// CoinData represents AI500 coin information +type CoinData struct { + Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT) + Score float64 `json:"score"` // Current AI score (0-100) + StartTime int64 `json:"start_time"` // Start time (Unix timestamp) + StartPrice float64 `json:"start_price"` // Start price + LastScore float64 `json:"last_score"` // Latest score + MaxScore float64 `json:"max_score"` // Highest score + MaxPrice float64 `json:"max_price"` // Highest price + IncreasePercent float64 `json:"increase_percent"` // Increase percentage (already x100) + IsAvailable bool `json:"-"` // Whether tradable (internal use) +} + +// AI500Response is the API response structure +type AI500Response struct { + Success bool `json:"success"` + Data struct { + Coins []CoinData `json:"coins"` + Count int `json:"count"` + } `json:"data"` +} + +// GetAI500List retrieves AI500 coin list with retry mechanism +func (c *Client) GetAI500List() ([]CoinData, error) { + maxRetries := 3 + var lastErr error + + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries) + time.Sleep(2 * time.Second) + } + + coins, err := c.fetchAI500() + if err == nil { + if attempt > 1 { + log.Printf("✓ Retry attempt %d succeeded", attempt) + } + return coins, nil + } + + lastErr = err + log.Printf("❌ AI500 request attempt %d failed: %v", attempt, err) + } + + return nil, fmt.Errorf("all AI500 API requests failed: %w", lastErr) +} + +func (c *Client) fetchAI500() ([]CoinData, error) { + log.Printf("🔄 Requesting AI500 data from %s...", c.GetBaseURL()) + + body, err := c.doRequest("/api/ai500/list") + if err != nil { + return nil, fmt.Errorf("failed to request AI500 API: %w", err) + } + + var response AI500Response + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("JSON parsing failed: %w", err) + } + + if !response.Success { + return nil, fmt.Errorf("API returned failure status") + } + + if len(response.Data.Coins) == 0 { + return nil, fmt.Errorf("coin list is empty") + } + + // Set IsAvailable flag + coins := response.Data.Coins + for i := range coins { + coins[i].IsAvailable = true + } + + log.Printf("✓ Successfully fetched %d AI500 coins", len(coins)) + return coins, nil +} + +// GetTopRatedCoins retrieves top N coins by score (sorted descending) +func (c *Client) GetTopRatedCoins(limit int) ([]string, error) { + coins, err := c.GetAI500List() + if err != nil { + return nil, err + } + + // Filter available coins + var availableCoins []CoinData + for _, coin := range coins { + if coin.IsAvailable { + availableCoins = append(availableCoins, coin) + } + } + + if len(availableCoins) == 0 { + return nil, fmt.Errorf("no available coins") + } + + // Sort by Score descending (bubble sort) + for i := 0; i < len(availableCoins); i++ { + for j := i + 1; j < len(availableCoins); j++ { + if availableCoins[i].Score < availableCoins[j].Score { + availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i] + } + } + } + + // Take top N + maxCount := limit + if len(availableCoins) < maxCount { + maxCount = len(availableCoins) + } + + var symbols []string + for i := 0; i < maxCount; i++ { + symbol := NormalizeSymbol(availableCoins[i].Pair) + symbols = append(symbols, symbol) + } + + return symbols, nil +} + +// GetAvailableCoins retrieves all available coin symbols +func (c *Client) GetAvailableCoins() ([]string, error) { + coins, err := c.GetAI500List() + if err != nil { + return nil, err + } + + var symbols []string + for _, coin := range coins { + if coin.IsAvailable { + symbol := NormalizeSymbol(coin.Pair) + symbols = append(symbols, symbol) + } + } + + if len(symbols) == 0 { + return nil, fmt.Errorf("no available coins") + } + + return symbols, nil +} + +// NormalizeSymbol normalizes coin symbol to XXXUSDT format +func NormalizeSymbol(symbol string) string { + symbol = strings.TrimSpace(symbol) + symbol = strings.ToUpper(symbol) + if !strings.HasSuffix(symbol, "USDT") { + symbol = symbol + "USDT" + } + return symbol +} diff --git a/provider/nofxos/client.go b/provider/nofxos/client.go new file mode 100644 index 00000000..a3a99a00 --- /dev/null +++ b/provider/nofxos/client.go @@ -0,0 +1,146 @@ +// Package nofxos provides data access to the NofxOS API (https://nofxos.ai) +// for quantitative trading data including AI500 scores, OI rankings, +// fund flow (NetFlow), price rankings, and coin details. +package nofxos + +import ( + "io/ioutil" + "net/http" + "nofx/security" + "strings" + "sync" + "time" +) + +// Default configuration +const ( + DefaultBaseURL = "https://nofxos.ai" + DefaultTimeout = 30 * time.Second + DefaultAuthKey = "cm_568c67eae410d912c54c" +) + +// Client is the NofxOS API client +type Client struct { + BaseURL string + AuthKey string + Timeout time.Duration + mu sync.RWMutex +} + +var ( + defaultClient *Client + clientOnce sync.Once +) + +// DefaultClient returns the singleton default client +func DefaultClient() *Client { + clientOnce.Do(func() { + defaultClient = &Client{ + BaseURL: DefaultBaseURL, + AuthKey: DefaultAuthKey, + Timeout: DefaultTimeout, + } + }) + return defaultClient +} + +// NewClient creates a new NofxOS API client +func NewClient(baseURL, authKey string) *Client { + if baseURL == "" { + baseURL = DefaultBaseURL + } + if authKey == "" { + authKey = DefaultAuthKey + } + return &Client{ + BaseURL: baseURL, + AuthKey: authKey, + Timeout: DefaultTimeout, + } +} + +// SetConfig updates client configuration +func (c *Client) SetConfig(baseURL, authKey string) { + c.mu.Lock() + defer c.mu.Unlock() + if baseURL != "" { + c.BaseURL = baseURL + } + if authKey != "" { + c.AuthKey = authKey + } +} + +// GetBaseURL returns the current base URL +func (c *Client) GetBaseURL() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.BaseURL +} + +// GetAuthKey returns the current auth key +func (c *Client) GetAuthKey() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.AuthKey +} + +// doRequest performs an HTTP GET request with authentication +func (c *Client) doRequest(endpoint string) ([]byte, error) { + c.mu.RLock() + baseURL := c.BaseURL + authKey := c.AuthKey + timeout := c.Timeout + c.mu.RUnlock() + + url := baseURL + endpoint + if !strings.Contains(url, "auth=") { + if strings.Contains(url, "?") { + url += "&auth=" + authKey + } else { + url += "?auth=" + authKey + } + } + + resp, err := security.SafeGet(url, timeout) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return body, &APIError{ + StatusCode: resp.StatusCode, + Message: string(body), + } + } + + return body, nil +} + +// APIError represents an API error response +type APIError struct { + StatusCode int + Message string +} + +func (e *APIError) Error() string { + return e.Message +} + +// ExtractAuthKey extracts auth key from a URL string +func ExtractAuthKey(url string) string { + if idx := strings.Index(url, "auth="); idx != -1 { + authKey := url[idx+5:] + if ampIdx := strings.Index(authKey, "&"); ampIdx != -1 { + authKey = authKey[:ampIdx] + } + return authKey + } + return "" +} diff --git a/provider/nofxos/coin.go b/provider/nofxos/coin.go new file mode 100644 index 00000000..5eafffa3 --- /dev/null +++ b/provider/nofxos/coin.go @@ -0,0 +1,216 @@ +package nofxos + +import ( + "encoding/json" + "fmt" + "log" + "strings" +) + +// QuantData represents quantitative data for a single coin +type QuantData struct { + Symbol string `json:"symbol"` + Price float64 `json:"price"` + Netflow *NetflowData `json:"netflow,omitempty"` + OI map[string]*OIData `json:"oi,omitempty"` // keyed by exchange: "binance", "bybit" + PriceChange map[string]float64 `json:"price_change,omitempty"` // keyed by duration: "1h", "4h", etc. +} + +// NetflowData contains fund flow data +type NetflowData struct { + Institution *FlowTypeData `json:"institution,omitempty"` + Personal *FlowTypeData `json:"personal,omitempty"` +} + +// FlowTypeData contains flow data by trade type +type FlowTypeData struct { + Future map[string]float64 `json:"future,omitempty"` // keyed by duration + Spot map[string]float64 `json:"spot,omitempty"` // keyed by duration +} + +// OIData contains open interest data for an exchange +type OIData struct { + CurrentOI float64 `json:"current_oi"` + NetLong float64 `json:"net_long"` + NetShort float64 `json:"net_short"` + Delta map[string]*OIDeltaData `json:"delta,omitempty"` // keyed by duration +} + +// OIDeltaData contains OI change data +type OIDeltaData struct { + OIDelta float64 `json:"oi_delta"` + OIDeltaValue float64 `json:"oi_delta_value"` + OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100 +} + +// CoinResponse is the API response structure for coin details +type CoinResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + Data *QuantData `json:"data"` +} + +// GetCoinData retrieves quantitative data for a single coin +func (c *Client) GetCoinData(symbol string, include string) (*QuantData, error) { + if symbol == "" { + return nil, fmt.Errorf("symbol is required") + } + + if include == "" { + include = "netflow,oi,price" + } + + // Normalize symbol (remove USDT suffix for API call if needed) + symbol = strings.TrimSuffix(strings.ToUpper(symbol), "USDT") + + endpoint := fmt.Sprintf("/api/coin/%s?include=%s", symbol, include) + + body, err := c.doRequest(endpoint) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var response CoinResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("JSON parsing failed: %w", err) + } + + // Check for success (support both success field and code field) + if !response.Success && response.Code != 0 { + return nil, fmt.Errorf("API returned error code: %d", response.Code) + } + + return response.Data, nil +} + +// GetCoinDataBatch retrieves quantitative data for multiple coins +func (c *Client) GetCoinDataBatch(symbols []string, include string) map[string]*QuantData { + result := make(map[string]*QuantData) + + for _, symbol := range symbols { + data, err := c.GetCoinData(symbol, include) + if err != nil { + log.Printf("⚠️ Failed to fetch coin data for %s: %v", symbol, err) + continue + } + if data != nil { + // Use normalized symbol as key + normalizedSymbol := NormalizeSymbol(symbol) + result[normalizedSymbol] = data + } + } + + return result +} + +// FormatQuantDataForAI formats single coin quant data for AI consumption +func FormatQuantDataForAI(symbol string, data *QuantData, lang Language) string { + if data == nil { + return "" + } + + if lang == LangChinese { + return formatQuantDataZH(symbol, data) + } + return formatQuantDataEN(symbol, data) +} + +func formatQuantDataZH(symbol string, data *QuantData) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("### %s 量化数据\n", symbol)) + sb.WriteString(fmt.Sprintf("价格: $%.4f\n\n", data.Price)) + + if len(data.PriceChange) > 0 { + sb.WriteString("**价格变化**:\n") + durations := []string{"1h", "4h", "8h", "12h", "24h"} + for _, d := range durations { + if change, ok := data.PriceChange[d]; ok { + sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100)) + } + } + sb.WriteString("\n") + } + + if len(data.OI) > 0 { + for exchange, oiData := range data.OI { + if oiData != nil { + sb.WriteString(fmt.Sprintf("**%s持仓**:\n", strings.ToUpper(exchange))) + sb.WriteString(fmt.Sprintf("- OI: %.2f\n", oiData.CurrentOI)) + if oiData.NetLong > 0 || oiData.NetShort > 0 { + sb.WriteString(fmt.Sprintf("- 多头: %.2f, 空头: %.2f\n", oiData.NetLong, oiData.NetShort)) + } + if oiData.Delta != nil { + if delta, ok := oiData.Delta["1h"]; ok && delta != nil { + sb.WriteString(fmt.Sprintf("- 1h变化: %s (%.2f%%)\n", + formatValue(delta.OIDeltaValue), delta.OIDeltaPercent)) + } + } + sb.WriteString("\n") + } + } + } + + if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil { + sb.WriteString("**机构资金流**:\n") + durations := []string{"1h", "4h", "24h"} + for _, d := range durations { + if flow, ok := data.Netflow.Institution.Future[d]; ok { + sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow))) + } + } + sb.WriteString("\n") + } + + return sb.String() +} + +func formatQuantDataEN(symbol string, data *QuantData) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("### %s Quant Data\n", symbol)) + sb.WriteString(fmt.Sprintf("Price: $%.4f\n\n", data.Price)) + + if len(data.PriceChange) > 0 { + sb.WriteString("**Price Change**:\n") + durations := []string{"1h", "4h", "8h", "12h", "24h"} + for _, d := range durations { + if change, ok := data.PriceChange[d]; ok { + sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100)) + } + } + sb.WriteString("\n") + } + + if len(data.OI) > 0 { + for exchange, oiData := range data.OI { + if oiData != nil { + sb.WriteString(fmt.Sprintf("**%s OI**:\n", strings.ToUpper(exchange))) + sb.WriteString(fmt.Sprintf("- Current OI: %.2f\n", oiData.CurrentOI)) + if oiData.NetLong > 0 || oiData.NetShort > 0 { + sb.WriteString(fmt.Sprintf("- Net Long: %.2f, Net Short: %.2f\n", oiData.NetLong, oiData.NetShort)) + } + if oiData.Delta != nil { + if delta, ok := oiData.Delta["1h"]; ok && delta != nil { + sb.WriteString(fmt.Sprintf("- 1h Change: %s (%.2f%%)\n", + formatValue(delta.OIDeltaValue), delta.OIDeltaPercent)) + } + } + sb.WriteString("\n") + } + } + } + + if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil { + sb.WriteString("**Institution Fund Flow**:\n") + durations := []string{"1h", "4h", "24h"} + for _, d := range durations { + if flow, ok := data.Netflow.Institution.Future[d]; ok { + sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow))) + } + } + sb.WriteString("\n") + } + + return sb.String() +} diff --git a/provider/nofxos/netflow.go b/provider/nofxos/netflow.go new file mode 100644 index 00000000..1731421d --- /dev/null +++ b/provider/nofxos/netflow.go @@ -0,0 +1,263 @@ +package nofxos + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" +) + +// NetFlowPosition represents fund flow data for a single coin +type NetFlowPosition struct { + Rank int `json:"rank"` + Symbol string `json:"symbol"` + Amount float64 `json:"amount"` // Fund flow amount in USDT (positive=inflow, negative=outflow) + Price float64 `json:"price"` +} + +// NetFlowResponse is the API response structure +type NetFlowResponse struct { + Success bool `json:"success"` + Data struct { + Netflows []NetFlowPosition `json:"netflows"` + Count int `json:"count"` + Type string `json:"type"` // institution or personal + Trade string `json:"trade"` // 合约 or 现货 + TimeRange string `json:"time_range"` + RankType string `json:"rank_type"` // top or low + Limit int `json:"limit"` + } `json:"data"` +} + +// NetFlowRankingData contains institution and personal fund flow rankings +type NetFlowRankingData struct { + Duration string `json:"duration"` + TimeRange string `json:"time_range"` + InstitutionFutureTop []NetFlowPosition `json:"institution_future_top"` + InstitutionFutureLow []NetFlowPosition `json:"institution_future_low"` + PersonalFutureTop []NetFlowPosition `json:"personal_future_top"` + PersonalFutureLow []NetFlowPosition `json:"personal_future_low"` + FetchedAt time.Time `json:"fetched_at"` +} + +// GetNetFlowRanking retrieves NetFlow ranking data (institution/personal, top/low) +func (c *Client) GetNetFlowRanking(duration string, limit int) (*NetFlowRankingData, error) { + if duration == "" { + duration = "1h" + } + if limit <= 0 { + limit = 10 + } + + result := &NetFlowRankingData{ + Duration: duration, + FetchedAt: time.Now(), + } + + // Fetch institution futures top (inflow) + positions, timeRange, err := c.fetchNetFlowRanking("top", duration, limit, "institution", "future") + if err != nil { + log.Printf("⚠️ Failed to fetch institution future inflow ranking: %v", err) + } else { + result.InstitutionFutureTop = positions + result.TimeRange = timeRange + } + + // Fetch institution futures low (outflow) + positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "institution", "future") + if err != nil { + log.Printf("⚠️ Failed to fetch institution future outflow ranking: %v", err) + } else { + result.InstitutionFutureLow = positions + } + + // Fetch personal futures top (retail inflow) + positions, _, err = c.fetchNetFlowRanking("top", duration, limit, "personal", "future") + if err != nil { + log.Printf("⚠️ Failed to fetch personal future inflow ranking: %v", err) + } else { + result.PersonalFutureTop = positions + } + + // Fetch personal futures low (retail outflow) + positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "personal", "future") + if err != nil { + log.Printf("⚠️ Failed to fetch personal future outflow ranking: %v", err) + } else { + result.PersonalFutureLow = positions + } + + log.Printf("✓ Fetched NetFlow ranking data: inst_in=%d, inst_out=%d, retail_in=%d, retail_out=%d (duration: %s)", + len(result.InstitutionFutureTop), len(result.InstitutionFutureLow), + len(result.PersonalFutureTop), len(result.PersonalFutureLow), duration) + + return result, nil +} + +func (c *Client) fetchNetFlowRanking(rankType, duration string, limit int, flowType, trade string) ([]NetFlowPosition, string, error) { + endpoint := fmt.Sprintf("/api/netflow/%s-ranking?limit=%d&duration=%s&type=%s&trade=%s", + rankType, limit, duration, flowType, trade) + + body, err := c.doRequest(endpoint) + if err != nil { + return nil, "", fmt.Errorf("request failed: %w", err) + } + + var response NetFlowResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, "", fmt.Errorf("JSON parsing failed: %w", err) + } + + if !response.Success { + return nil, "", fmt.Errorf("API returned failure status") + } + + return response.Data.Netflows, response.Data.TimeRange, nil +} + +// FormatNetFlowRankingForAI formats NetFlow ranking data for AI consumption +func FormatNetFlowRankingForAI(data *NetFlowRankingData, lang Language) string { + if data == nil { + return "" + } + + if lang == LangChinese { + return formatNetFlowRankingZH(data) + } + return formatNetFlowRankingEN(data) +} + +func formatNetFlowRankingZH(data *NetFlowRankingData) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("## 资金流向排行 (%s)\n\n", data.Duration)) + + // Institution inflow + if len(data.InstitutionFutureTop) > 0 { + sb.WriteString("### 机构资金流入榜\n") + sb.WriteString("Smart Money买入信号:\n\n") + sb.WriteString("| 排名 | 币种 | 流入金额(USDT) | 价格 |\n") + sb.WriteString("|------|------|----------------|------|\n") + for _, pos := range data.InstitutionFutureTop { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n", + pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price)) + } + sb.WriteString("\n") + } + + // Institution outflow + if len(data.InstitutionFutureLow) > 0 { + sb.WriteString("### 机构资金流出榜\n") + sb.WriteString("Smart Money卖出信号:\n\n") + sb.WriteString("| 排名 | 币种 | 流出金额(USDT) | 价格 |\n") + sb.WriteString("|------|------|----------------|------|\n") + for _, pos := range data.InstitutionFutureLow { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n", + pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price)) + } + sb.WriteString("\n") + } + + // Retail flow summary + if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 { + sb.WriteString("### 散户资金动向\n") + if len(data.PersonalFutureTop) > 0 { + sb.WriteString("散户买入: ") + for i, pos := range data.PersonalFutureTop { + if i >= 3 { + break + } + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount))) + } + sb.WriteString("\n") + } + if len(data.PersonalFutureLow) > 0 { + sb.WriteString("散户卖出: ") + for i, pos := range data.PersonalFutureLow { + if i >= 3 { + break + } + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount))) + } + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + sb.WriteString("**解读**: 机构买入+散户卖出=强烈看多 | 机构卖出+散户买入=强烈看空\n\n") + return sb.String() +} + +func formatNetFlowRankingEN(data *NetFlowRankingData) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("## Fund Flow Ranking (%s)\n\n", data.Duration)) + + // Institution inflow + if len(data.InstitutionFutureTop) > 0 { + sb.WriteString("### Institution Inflow\n") + sb.WriteString("Smart Money buying signals:\n\n") + sb.WriteString("| Rank | Symbol | Inflow (USDT) | Price |\n") + sb.WriteString("|------|--------|---------------|-------|\n") + for _, pos := range data.InstitutionFutureTop { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n", + pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price)) + } + sb.WriteString("\n") + } + + // Institution outflow + if len(data.InstitutionFutureLow) > 0 { + sb.WriteString("### Institution Outflow\n") + sb.WriteString("Smart Money selling signals:\n\n") + sb.WriteString("| Rank | Symbol | Outflow (USDT) | Price |\n") + sb.WriteString("|------|--------|----------------|-------|\n") + for _, pos := range data.InstitutionFutureLow { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n", + pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price)) + } + sb.WriteString("\n") + } + + // Retail flow summary + if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 { + sb.WriteString("### Retail Flow\n") + if len(data.PersonalFutureTop) > 0 { + sb.WriteString("Retail buying: ") + for i, pos := range data.PersonalFutureTop { + if i >= 3 { + break + } + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount))) + } + sb.WriteString("\n") + } + if len(data.PersonalFutureLow) > 0 { + sb.WriteString("Retail selling: ") + for i, pos := range data.PersonalFutureLow { + if i >= 3 { + break + } + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount))) + } + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + sb.WriteString("**Key**: Institution buy + Retail sell = Strong bullish | Institution sell + Retail buy = Strong bearish\n\n") + return sb.String() +} diff --git a/provider/nofxos/oi.go b/provider/nofxos/oi.go new file mode 100644 index 00000000..c6b5b778 --- /dev/null +++ b/provider/nofxos/oi.go @@ -0,0 +1,212 @@ +package nofxos + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" +) + +// OIPosition represents open interest data for a single coin +type OIPosition struct { + Symbol string `json:"symbol"` + Rank int `json:"rank"` + Price float64 `json:"price"` + CurrentOI float64 `json:"current_oi"` + OIDelta float64 `json:"oi_delta"` + OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100 (5.0 = 5%) + OIDeltaValue float64 `json:"oi_delta_value"` // USDT value + PriceDeltaPercent float64 `json:"price_delta_percent"` // Already x100 (5.0 = 5%) + NetLong float64 `json:"net_long"` + NetShort float64 `json:"net_short"` +} + +// OIRankingResponse is the API response structure for OI ranking +type OIRankingResponse struct { + Success bool `json:"success"` + Code int `json:"code"` + Data struct { + Positions []OIPosition `json:"positions"` + Count int `json:"count"` + Exchange string `json:"exchange"` + TimeRange string `json:"time_range"` + TimeRangeParam string `json:"time_range_param"` + RankType string `json:"rank_type"` + Limit int `json:"limit"` + } `json:"data"` +} + +// OIRankingData contains both top and low OI rankings +type OIRankingData struct { + TimeRange string `json:"time_range"` + Duration string `json:"duration"` + TopPositions []OIPosition `json:"top_positions"` + LowPositions []OIPosition `json:"low_positions"` + FetchedAt time.Time `json:"fetched_at"` +} + +// GetOIRanking retrieves OI ranking data (both top increase and low decrease) +func (c *Client) GetOIRanking(duration string, limit int) (*OIRankingData, error) { + if duration == "" { + duration = "1h" + } + if limit <= 0 { + limit = 20 + } + + result := &OIRankingData{ + Duration: duration, + FetchedAt: time.Now(), + } + + // Fetch top ranking (OI increase) + topPositions, timeRange, err := c.fetchOIRanking("top", duration, limit) + if err != nil { + log.Printf("⚠️ Failed to fetch OI top ranking: %v", err) + } else { + result.TopPositions = topPositions + result.TimeRange = timeRange + } + + // Fetch low ranking (OI decrease) + lowPositions, _, err := c.fetchOIRanking("low", duration, limit) + 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 +} + +func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosition, string, error) { + endpoint := fmt.Sprintf("/api/oi/%s-ranking?limit=%d&duration=%s", rankType, limit, duration) + + body, err := c.doRequest(endpoint) + if err != nil { + return nil, "", fmt.Errorf("request failed: %w", err) + } + + var response OIRankingResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, "", fmt.Errorf("JSON parsing failed: %w", err) + } + + // Check for success (support both success field and code field) + if !response.Success && response.Code != 0 { + return nil, "", fmt.Errorf("API returned error code: %d", response.Code) + } + + return response.Data.Positions, response.Data.TimeRange, nil +} + +// GetOITopPositions retrieves top OI increase positions (legacy compatibility) +func (c *Client) GetOITopPositions() ([]OIPosition, error) { + data, err := c.GetOIRanking("1h", 20) + if err != nil { + return nil, err + } + return data.TopPositions, nil +} + +// GetOITopSymbols retrieves OI top coin symbol list +func (c *Client) GetOITopSymbols() ([]string, error) { + positions, err := c.GetOITopPositions() + if err != nil { + return nil, err + } + + var symbols []string + for _, pos := range positions { + symbol := NormalizeSymbol(pos.Symbol) + symbols = append(symbols, symbol) + } + + return symbols, nil +} + +// FormatOIRankingForAI formats OI ranking data for AI consumption +func FormatOIRankingForAI(data *OIRankingData, lang Language) string { + if data == nil { + return "" + } + + if lang == LangChinese { + return formatOIRankingZH(data) + } + return formatOIRankingEN(data) +} + +func formatOIRankingZH(data *OIRankingData) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("## 持仓量变化排行 (%s)\n\n", data.Duration)) + + if len(data.TopPositions) > 0 { + sb.WriteString("### 持仓增加榜\n") + sb.WriteString("资金流入,趋势延续或新仓建立信号:\n\n") + sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n") + sb.WriteString("|------|------|----------------|---------|----------|\n") + for _, pos := range data.TopPositions { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n", + pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue), + pos.OIDeltaPercent, pos.PriceDeltaPercent)) + } + sb.WriteString("\n") + } + + if len(data.LowPositions) > 0 { + sb.WriteString("### 持仓减少榜\n") + sb.WriteString("资金流出,趋势反转或仓位平仓信号:\n\n") + sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n") + sb.WriteString("|------|------|----------------|---------|----------|\n") + for _, pos := range data.LowPositions { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n", + pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue), + pos.OIDeltaPercent, pos.PriceDeltaPercent)) + } + sb.WriteString("\n") + } + + sb.WriteString("**解读**: OI增+价涨=多头主导 | OI增+价跌=空头主导 | OI减+价涨=空头平仓 | OI减+价跌=多头平仓\n\n") + return sb.String() +} + +func formatOIRankingEN(data *OIRankingData) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("## Open Interest Changes (%s)\n\n", data.Duration)) + + if len(data.TopPositions) > 0 { + sb.WriteString("### OI Increase Ranking\n") + sb.WriteString("Capital inflow signals - trend continuation or new positions:\n\n") + sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n") + sb.WriteString("|------|--------|------------------|-------------|----------------|\n") + for _, pos := range data.TopPositions { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n", + pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue), + pos.OIDeltaPercent, pos.PriceDeltaPercent)) + } + sb.WriteString("\n") + } + + if len(data.LowPositions) > 0 { + sb.WriteString("### OI Decrease Ranking\n") + sb.WriteString("Capital outflow signals - trend reversal or position closing:\n\n") + sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n") + sb.WriteString("|------|--------|------------------|-------------|----------------|\n") + for _, pos := range data.LowPositions { + sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n", + pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue), + pos.OIDeltaPercent, pos.PriceDeltaPercent)) + } + sb.WriteString("\n") + } + + sb.WriteString("**Key**: OI up + Price up = Bulls dominant | OI up + Price down = Bears dominant | OI down + Price up = Short covering | OI down + Price down = Long liquidation\n\n") + return sb.String() +} diff --git a/provider/nofxos/price.go b/provider/nofxos/price.go new file mode 100644 index 00000000..4c39ab51 --- /dev/null +++ b/provider/nofxos/price.go @@ -0,0 +1,182 @@ +package nofxos + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" +) + +// PriceRankingItem represents single coin price ranking data +type PriceRankingItem struct { + Pair string `json:"pair"` + Symbol string `json:"symbol"` + PriceDelta float64 `json:"price_delta"` // Decimal format: 0.0723 = 7.23% + Price float64 `json:"price"` + FutureFlow float64 `json:"future_flow"` + SpotFlow float64 `json:"spot_flow"` + OI float64 `json:"oi"` + OIDelta float64 `json:"oi_delta"` + OIDeltaValue float64 `json:"oi_delta_value"` +} + +// PriceRankingDuration contains top gainers and losers for a single duration +type PriceRankingDuration struct { + Top []PriceRankingItem `json:"top"` + Low []PriceRankingItem `json:"low"` +} + +// PriceRankingResponse is the API response structure +type PriceRankingResponse struct { + Success bool `json:"success"` + Data struct { + Durations []string `json:"durations"` + Limit int `json:"limit"` + Data map[string]PriceRankingDuration `json:"data"` + } `json:"data"` +} + +// PriceRankingData contains price ranking data for multiple durations +type PriceRankingData struct { + Durations map[string]*PriceRankingDuration `json:"durations"` + FetchedAt time.Time `json:"fetched_at"` +} + +// GetPriceRanking retrieves price ranking data (gainers/losers) +func (c *Client) GetPriceRanking(durations string, limit int) (*PriceRankingData, error) { + if durations == "" { + durations = "1h" + } + if limit <= 0 { + limit = 10 + } + + endpoint := fmt.Sprintf("/api/price/ranking?duration=%s&limit=%d", durations, limit) + + body, err := c.doRequest(endpoint) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + var response PriceRankingResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("JSON parsing failed: %w", err) + } + + if !response.Success { + return nil, fmt.Errorf("API returned failure status") + } + + result := &PriceRankingData{ + Durations: make(map[string]*PriceRankingDuration), + FetchedAt: time.Now(), + } + + for duration, data := range response.Data.Data { + d := data // Create a copy to avoid pointer issues + result.Durations[duration] = &d + } + + log.Printf("✓ Fetched Price ranking data for %d durations", len(result.Durations)) + + return result, nil +} + +// FormatPriceRankingForAI formats Price ranking data for AI consumption +func FormatPriceRankingForAI(data *PriceRankingData, lang Language) string { + if data == nil || len(data.Durations) == 0 { + return "" + } + + if lang == LangChinese { + return formatPriceRankingZH(data) + } + return formatPriceRankingEN(data) +} + +func formatPriceRankingZH(data *PriceRankingData) string { + var sb strings.Builder + + sb.WriteString("## 涨跌幅排行\n\n") + + durationOrder := []string{"1h", "4h", "24h"} + for _, duration := range durationOrder { + durationData, exists := data.Durations[duration] + if !exists || durationData == nil { + continue + } + + sb.WriteString(fmt.Sprintf("### %s 涨跌幅\n\n", duration)) + + if len(durationData.Top) > 0 { + sb.WriteString("**涨幅榜**\n") + sb.WriteString("| 币种 | 涨幅 | 价格 | 资金流 | OI变化 |\n") + sb.WriteString("|------|------|------|--------|--------|\n") + for _, item := range durationData.Top { + sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n", + item.Symbol, item.PriceDelta*100, item.Price, + formatValue(item.FutureFlow), formatValue(item.OIDeltaValue))) + } + sb.WriteString("\n") + } + + if len(durationData.Low) > 0 { + sb.WriteString("**跌幅榜**\n") + sb.WriteString("| 币种 | 跌幅 | 价格 | 资金流 | OI变化 |\n") + sb.WriteString("|------|------|------|--------|--------|\n") + for _, item := range durationData.Low { + sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n", + item.Symbol, item.PriceDelta*100, item.Price, + formatValue(item.FutureFlow), formatValue(item.OIDeltaValue))) + } + sb.WriteString("\n") + } + } + + sb.WriteString("**解读**: 涨幅大+资金流入+OI增加=强势上涨 | 跌幅大+资金流出+OI减少=弱势下跌\n\n") + return sb.String() +} + +func formatPriceRankingEN(data *PriceRankingData) string { + var sb strings.Builder + + sb.WriteString("## Price Gainers/Losers\n\n") + + durationOrder := []string{"1h", "4h", "24h"} + for _, duration := range durationOrder { + durationData, exists := data.Durations[duration] + if !exists || durationData == nil { + continue + } + + sb.WriteString(fmt.Sprintf("### %s Price Change\n\n", duration)) + + if len(durationData.Top) > 0 { + sb.WriteString("**Top Gainers**\n") + sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n") + sb.WriteString("|--------|--------|-------|-----------|----------|\n") + for _, item := range durationData.Top { + sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n", + item.Symbol, item.PriceDelta*100, item.Price, + formatValue(item.FutureFlow), formatValue(item.OIDeltaValue))) + } + sb.WriteString("\n") + } + + if len(durationData.Low) > 0 { + sb.WriteString("**Top Losers**\n") + sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n") + sb.WriteString("|--------|--------|-------|-----------|----------|\n") + for _, item := range durationData.Low { + sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n", + item.Symbol, item.PriceDelta*100, item.Price, + formatValue(item.FutureFlow), formatValue(item.OIDeltaValue))) + } + sb.WriteString("\n") + } + } + + sb.WriteString("**Key**: Big gain + Fund inflow + OI increase = Strong bullish | Big loss + Fund outflow + OI decrease = Strong bearish\n\n") + return sb.String() +} diff --git a/provider/nofxos/util.go b/provider/nofxos/util.go new file mode 100644 index 00000000..c6ed48d1 --- /dev/null +++ b/provider/nofxos/util.go @@ -0,0 +1,31 @@ +package nofxos + +import "fmt" + +// Language represents the language for formatting output +type Language string + +const ( + LangChinese Language = "zh-CN" + LangEnglish Language = "en-US" +) + +// formatValue formats a numeric value with sign and appropriate suffix +func formatValue(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) +} diff --git a/store/strategy.go b/store/strategy.go index 3551ae1a..719d53d9 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -32,6 +32,9 @@ func (Strategy) TableName() string { return "strategies" } // StrategyConfig strategy configuration details (JSON structure) type StrategyConfig struct { + // language setting: "zh" for Chinese, "en" for English + // This determines the language used for data formatting and prompt generation + Language string `json:"language,omitempty"` // coin source configuration CoinSource CoinSourceConfig `json:"coin_source"` // quantitative data configuration @@ -58,24 +61,21 @@ type PromptSectionsConfig struct { // CoinSourceConfig coin source configuration type CoinSourceConfig struct { - // source type: "static" | "coinpool" | "oi_top" | "mixed" + // source type: "static" | "ai500" | "oi_top" | "mixed" SourceType string `json:"source_type"` // static coin list (used when source_type = "static") StaticCoins []string `json:"static_coins,omitempty"` // excluded coins list (filtered out from all sources) ExcludedCoins []string `json:"excluded_coins,omitempty"` // whether to use AI500 coin pool - UseCoinPool bool `json:"use_coin_pool"` + UseAI500 bool `json:"use_ai500"` // AI500 coin pool maximum count - CoinPoolLimit int `json:"coin_pool_limit,omitempty"` - // AI500 coin pool API URL (strategy-level configuration) - CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"` + AI500Limit int `json:"ai500_limit,omitempty"` // whether to use OI Top UseOITop bool `json:"use_oi_top"` // OI Top maximum count OITopLimit int `json:"oi_top_limit,omitempty"` - // OI Top API URL (strategy-level configuration) - OITopAPIURL string `json:"oi_top_api_url,omitempty"` + // Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig } // IndicatorConfig indicator configuration @@ -103,16 +103,30 @@ type IndicatorConfig struct { BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes // external data sources ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"` + + // ========== NofxOS Unified API Configuration ========== + // Unified API Key for all NofxOS data sources + NofxOSAPIKey string `json:"nofxos_api_key,omitempty"` + // quantitative data sources (capital flow, position changes, price changes) - EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data - 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 + EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data + 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) + + // NetFlow ranking data (market-wide fund flow rankings - institution/personal) + EnableNetFlowRanking bool `json:"enable_netflow_ranking"` // whether to enable NetFlow ranking data + NetFlowRankingDuration string `json:"netflow_ranking_duration,omitempty"` // duration: 1h, 4h, 24h + NetFlowRankingLimit int `json:"netflow_ranking_limit,omitempty"` // number of entries (default 10) + + // Price ranking data (market-wide gainers/losers) + EnablePriceRanking bool `json:"enable_price_ranking"` // whether to enable price ranking data + PriceRankingDuration string `json:"price_ranking_duration,omitempty"` // durations: "1h" or "1h,4h,24h" + PriceRankingLimit int `json:"price_ranking_limit,omitempty"` // number of entries per ranking (default 10) } // KlineConfig K-line configuration @@ -185,15 +199,20 @@ func (s *StrategyStore) initDefaultData() error { // GetDefaultStrategyConfig returns the default strategy configuration for the given language func GetDefaultStrategyConfig(lang string) StrategyConfig { + // Normalize language to "zh" or "en" + normalizedLang := "en" + if lang == "zh" { + normalizedLang = "zh" + } + config := StrategyConfig{ + Language: normalizedLang, CoinSource: CoinSourceConfig{ - SourceType: "coinpool", - UseCoinPool: true, - CoinPoolLimit: 10, - CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c", - UseOITop: false, - OITopLimit: 20, - OITopAPIURL: "http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c", + SourceType: "ai500", + UseAI500: true, + AI500Limit: 10, + UseOITop: false, + OITopLimit: 20, }, Indicators: IndicatorConfig{ Klines: KlineConfig{ @@ -217,15 +236,24 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { RSIPeriods: []int{7, 14}, ATRPeriods: []int{14}, BOLLPeriods: []int{20}, + // NofxOS unified API key + NofxOSAPIKey: "cm_568c67eae410d912c54c", + // Quant data EnableQuantData: true, - 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 + // OI ranking data EnableOIRanking: true, - OIRankingAPIURL: "http://nofxaios.com:30006", OIRankingDuration: "1h", OIRankingLimit: 10, + // NetFlow ranking data + EnableNetFlowRanking: true, + NetFlowRankingDuration: "1h", + NetFlowRankingLimit: 10, + // Price ranking data + EnablePriceRanking: true, + PriceRankingDuration: "1h,4h,24h", + PriceRankingLimit: 10, }, RiskControl: RiskControlConfig{ MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) diff --git a/store/trader.go b/store/trader.go index b7f364eb..426e7488 100644 --- a/store/trader.go +++ b/store/trader.go @@ -37,7 +37,7 @@ type Trader struct { BTCETHLeverage int `gorm:"column:btc_eth_leverage;default:5" json:"btc_eth_leverage,omitempty"` AltcoinLeverage int `gorm:"column:altcoin_leverage;default:5" json:"altcoin_leverage,omitempty"` TradingSymbols string `gorm:"column:trading_symbols;default:''" json:"trading_symbols,omitempty"` - UseCoinPool bool `gorm:"column:use_coin_pool;default:false" json:"use_coin_pool,omitempty"` + UseAI500 bool `gorm:"column:use_coin_pool;default:false" json:"use_ai500,omitempty"` UseOITop bool `gorm:"column:use_oi_top;default:false" json:"use_oi_top,omitempty"` CustomPrompt string `gorm:"column:custom_prompt;default:''" json:"custom_prompt,omitempty"` OverrideBasePrompt bool `gorm:"column:override_base_prompt;default:false" json:"override_base_prompt,omitempty"` diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 51d2672e..328152d8 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -899,7 +899,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { } // 8. Get quantitative data (if enabled in strategy config) - if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" { + if strategyConfig.Indicators.EnableQuantData { // Collect symbols to query (candidate coins + position coins) symbolsToQuery := make(map[string]bool) for _, coin := range candidateCoins { @@ -929,6 +929,26 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { } } + // 10. Get NetFlow ranking data (market-wide fund flow) + if strategyConfig.Indicators.EnableNetFlowRanking { + logger.Infof("💰 [%s] Fetching NetFlow ranking data...", at.name) + ctx.NetFlowRankingData = at.strategyEngine.FetchNetFlowRankingData() + if ctx.NetFlowRankingData != nil { + logger.Infof("💰 [%s] NetFlow ranking data ready: inst_in=%d, inst_out=%d", + at.name, len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow)) + } + } + + // 11. Get Price ranking data (market-wide gainers/losers) + if strategyConfig.Indicators.EnablePriceRanking { + logger.Infof("📈 [%s] Fetching Price ranking data...", at.name) + ctx.PriceRankingData = at.strategyEngine.FetchPriceRankingData() + if ctx.PriceRankingData != nil { + logger.Infof("📈 [%s] Price ranking data ready for %d durations", + at.name, len(ctx.PriceRankingData.Durations)) + } + } + return ctx, nil } diff --git a/web/src/components/BacktestPage.tsx b/web/src/components/BacktestPage.tsx index c359e4b5..041ae200 100644 --- a/web/src/components/BacktestPage.tsx +++ b/web/src/components/BacktestPage.tsx @@ -832,17 +832,17 @@ export function BacktestPage() { if (!coinSource) return false // Check explicit source_type - if (coinSource.source_type === 'coinpool' || coinSource.source_type === 'oi_top') { + if (coinSource.source_type === 'ai500' || coinSource.source_type === 'oi_top') { return true } - if (coinSource.source_type === 'mixed' && (coinSource.use_coin_pool || coinSource.use_oi_top)) { + if (coinSource.source_type === 'mixed' && (coinSource.use_ai500 || coinSource.use_oi_top)) { return true } // Also check flags for backward compatibility (when source_type is empty or not set) const srcType = coinSource.source_type as string if (!srcType) { - if (coinSource.use_coin_pool || coinSource.use_oi_top) { + if (coinSource.use_ai500 || coinSource.use_oi_top) { return true } } @@ -858,10 +858,10 @@ export function BacktestPage() { // Infer source_type from flags if empty (backward compatibility) let sourceType = cs.source_type as string if (!sourceType) { - if (cs.use_coin_pool && cs.use_oi_top) { + if (cs.use_ai500 && cs.use_oi_top) { sourceType = 'mixed' - } else if (cs.use_coin_pool) { - sourceType = 'coinpool' + } else if (cs.use_ai500) { + sourceType = 'ai500' } else if (cs.use_oi_top) { sourceType = 'oi_top' } else if (cs.static_coins?.length) { @@ -870,13 +870,13 @@ export function BacktestPage() { } switch (sourceType) { - case 'coinpool': - return { type: 'AI500', limit: cs.coin_pool_limit || 30 } + case 'ai500': + return { type: 'AI500', limit: cs.ai500_limit || 30 } case 'oi_top': return { type: 'OI Top', limit: cs.oi_top_limit || 30 } case 'mixed': const sources = [] - if (cs.use_coin_pool) sources.push(`AI500(${cs.coin_pool_limit || 30})`) + if (cs.use_ai500) sources.push(`AI500(${cs.ai500_limit || 30})`) if (cs.use_oi_top) sources.push(`OI Top(${cs.oi_top_limit || 30})`) if (cs.static_coins?.length) sources.push(`Static(${cs.static_coins.length})`) return { type: 'Mixed', desc: sources.join(' + ') } diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index e0079c08..328bf3ca 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -368,7 +368,7 @@ export function TraderConfigModal({
币种来源: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' : - selectedStrategy.config.coin_source.source_type === 'coinpool' ? 'Coin Pool' : + selectedStrategy.config.coin_source.source_type === 'ai500' ? 'AI500' : selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 12cd8ffd..8fe66254 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -1,11 +1,7 @@ import { useState } from 'react' -import { Plus, X, Database, TrendingUp, List, Link, AlertCircle, Ban } from 'lucide-react' +import { Plus, X, Database, TrendingUp, List, Ban, Zap } from 'lucide-react' import type { CoinSourceConfig } from '../../types' -// Default API URLs for data sources -const DEFAULT_COIN_POOL_API_URL = 'http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c' -const DEFAULT_OI_TOP_API_URL = 'http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c' - interface CoinSourceEditorProps { config: CoinSourceConfig onChange: (config: CoinSourceConfig) => void @@ -26,21 +22,17 @@ export function CoinSourceEditor({ const translations: Record> = { sourceType: { zh: '数据来源类型', en: 'Source Type' }, static: { zh: '静态列表', en: 'Static List' }, - coinpool: { zh: 'AI500 数据源', en: 'AI500 Data Provider' }, + ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' }, 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 Data Provider' }, - coinPoolLimit: { zh: '数据源数量上限', en: 'Data Provider Limit' }, - coinPoolApiUrl: { zh: 'AI500 API URL', en: 'AI500 API URL' }, - coinPoolApiUrlPlaceholder: { zh: '输入 AI500 数据源 API 地址...', en: 'Enter AI500 data provider API URL...' }, + useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' }, + ai500Limit: { zh: '数量上限', en: 'Limit' }, 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...' }, + oiTopLimit: { zh: '数量上限', en: 'Limit' }, staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' }, - coinpoolDesc: { + ai500Desc: { zh: '使用 AI500 智能筛选的热门币种', en: 'Use AI500 smart-filtered popular coins', }, @@ -52,19 +44,18 @@ export function CoinSourceEditor({ 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' }, - fillDefault: { zh: '填入默认', en: 'Fill Default' }, excludedCoins: { zh: '排除币种', en: 'Excluded Coins' }, excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' }, addExcludedCoin: { zh: '添加排除', en: 'Add Excluded' }, + nofxosNote: { zh: '使用 NofxOS API Key(在指标配置中设置)', en: 'Uses NofxOS API Key (set in Indicators config)' }, } return translations[key]?.[language] || key } const sourceTypes = [ { value: 'static', icon: List, color: '#848E9C' }, - { value: 'coinpool', icon: Database, color: '#F0B90B' }, + { value: 'ai500', icon: Database, color: '#F0B90B' }, { value: 'oi_top', icon: TrendingUp, color: '#0ECB81' }, { value: 'mixed', icon: Database, color: '#60a5fa' }, ] as const @@ -149,6 +140,20 @@ export function CoinSourceEditor({ }) } + // NofxOS badge component + const NofxOSBadge = () => ( + + NofxOS + + ) + return (
{/* Source Type Selector */} @@ -305,198 +310,137 @@ export function CoinSourceEditor({ )}
- {/* Coin Pool Options */} - {(config.source_type === 'coinpool' || config.source_type === 'mixed') && ( -
-
- - - {t('dataSourceConfig')} - AI500 - -
- -
-
- - {config.use_coin_pool && ( -
- - {t('coinPoolLimit')}: - - - !disabled && - onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 10 }) - } - disabled={disabled} - min={1} - max={100} - className="w-20 px-3 py-1.5 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- )} + {/* AI500 Options */} + {(config.source_type === 'ai500' || config.source_type === 'mixed') && ( +
+
+
+ + + AI500 {t('dataSourceConfig')} + +
- {config.use_coin_pool && ( -
-
- - {!disabled && !config.coin_pool_api_url && ( - - )} -
+
+
- )} + {t('useAI500')} + + + {config.use_ai500 && ( +
+ + {t('ai500Limit')}: + + +
+ )} + +

+ {t('nofxosNote')} +

+
)} {/* OI Top Options */} {(config.source_type === 'oi_top' || config.source_type === 'mixed') && ( -
-
- - - {t('dataSourceConfig')} - OI Top - -
- -
-
- - {config.use_oi_top && ( -
- - {t('oiTopLimit')}: - - - !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', - }} - /> -
- )} +
+
+
+ + + OI Top {t('dataSourceConfig')} + +
- {config.use_oi_top && ( -
-
- - {!disabled && !config.oi_top_api_url && ( - - )} -
+
+
- )} + {t('useOITop')} + + + {config.use_oi_top && ( +
+ + {t('oiTopLimit')}: + + +
+ )} + +

+ {t('nofxosNote')} +

+
)} -
) } diff --git a/web/src/components/strategy/IndicatorEditor.tsx b/web/src/components/strategy/IndicatorEditor.tsx index 69d4c04d..4b2b05ca 100644 --- a/web/src/components/strategy/IndicatorEditor.tsx +++ b/web/src/components/strategy/IndicatorEditor.tsx @@ -1,10 +1,8 @@ -import { Clock, Activity, Database, TrendingUp, BarChart2, Info, Lock, LineChart } from 'lucide-react' +import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } 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' +// Default NofxOS API Key +const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c' interface IndicatorEditorProps { config: IndicatorConfig @@ -47,7 +45,7 @@ export function IndicatorEditor({ marketSentiment: { zh: '市场情绪', en: 'Market Sentiment' }, marketSentimentDesc: { zh: '持仓量、资金费率等市场情绪数据', en: 'OI, funding rate and market sentiment data' }, quantData: { zh: '量化数据', en: 'Quant Data' }, - quantDataDesc: { zh: '第三方数据源:资金流向、大户动向', en: 'Third-party: netflow, whale movements' }, + quantDataDesc: { zh: '资金流向、大户动向', en: 'Netflow, whale movements' }, // Timeframes timeframes: { zh: '时间周期', en: 'Timeframes' }, @@ -81,20 +79,40 @@ export function IndicatorEditor({ fundingRate: { zh: '资金费率', en: 'Funding Rate' }, fundingRateDesc: { zh: '永续合约资金费率', en: 'Perpetual funding rate' }, - // Quant data - quantDataUrl: { zh: '数据接口 URL', en: 'Data API URL' }, - 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' }, + oiRanking: { zh: 'OI 排行', en: 'OI Ranking' }, + oiRankingDesc: { zh: '持仓量增减排行', en: 'OI change ranking' }, oiRankingNote: { zh: '显示持仓量增加/减少的币种排行,帮助发现资金流向', en: 'Shows coins with OI increase/decrease, helps identify capital flow' }, + // NetFlow Ranking + netflowRanking: { zh: '资金流向', en: 'NetFlow' }, + netflowRankingDesc: { zh: '机构/散户资金流向', en: 'Institution/retail fund flow' }, + netflowRankingNote: { zh: '显示机构资金流入/流出排行,散户动向对比,发现聪明钱信号', en: 'Shows institution inflow/outflow ranking, retail flow comparison, Smart Money signals' }, + + // Price Ranking + priceRanking: { zh: '涨跌幅排行', en: 'Price Ranking' }, + priceRankingDesc: { zh: '涨跌幅排行榜', en: 'Gainers/losers ranking' }, + priceRankingNote: { zh: '显示涨幅/跌幅排行,结合资金流和持仓变化分析趋势强度', en: 'Shows top gainers/losers, combined with fund flow and OI for trend analysis' }, + priceRankingMulti: { zh: '多周期', en: 'Multi-period' }, + + // Common settings + duration: { zh: '周期', en: 'Duration' }, + limit: { zh: '数量', en: 'Limit' }, + // Tips aiCanCalculate: { zh: '💡 提示:AI 可自行计算这些指标,开启可减少 AI 计算量', en: '💡 Tip: AI can calculate these, enabling reduces AI workload' }, + + // NofxOS Data Provider + nofxosTitle: { zh: 'NofxOS 量化数据源', en: 'NofxOS Data Provider' }, + nofxosDesc: { zh: '专业加密货币量化数据服务', en: 'Professional crypto quant data service' }, + nofxosFeatures: { zh: 'AI500 · OI排行 · 资金流向 · 涨跌榜', en: 'AI500 · OI Ranking · Fund Flow · Price Ranking' }, + viewApiDocs: { zh: 'API 文档', en: 'API Docs' }, + apiKey: { zh: 'API Key', en: 'API Key' }, + apiKeyPlaceholder: { zh: '输入 NofxOS API Key', en: 'Enter NofxOS API Key' }, + fillDefault: { zh: '填入默认', en: 'Fill Default' }, + connected: { zh: '已配置', en: 'Configured' }, + notConfigured: { zh: '未配置', en: 'Not Configured' }, + nofxosDataSources: { zh: 'NofxOS 数据源', en: 'NofxOS Data Sources' }, } return translations[key]?.[language] || key } @@ -166,9 +184,364 @@ export function IndicatorEditor({ ensureRawKlines() } + // Check if any NofxOS feature is enabled + const hasNofxosEnabled = config.enable_quant_data || config.enable_oi_ranking || config.enable_netflow_ranking || config.enable_price_ranking + const hasApiKey = !!config.nofxos_api_key + return (
- {/* Section 1: Market Data (Required) */} + {/* ============================================ */} + {/* NofxOS Data Provider - Top Configuration */} + {/* ============================================ */} +
+ {/* Decorative gradient line at top */} +
+ +
+ {/* Header Row */} +
+
+
+ +
+
+

+ {t('nofxosTitle')} +

+ + {t('nofxosFeatures')} + +
+
+ + {/* Status & API Docs */} +
+ {hasApiKey ? ( + + + {t('connected')} + + ) : ( + + + {t('notConfigured')} + + )} + + + {t('viewApiDocs')} + +
+
+ + {/* API Key Input */} +
+
+ + !disabled && onChange({ ...config, nofxos_api_key: e.target.value })} + disabled={disabled} + placeholder={t('apiKeyPlaceholder')} + className="w-full pl-9 pr-3 py-2 rounded-lg text-sm font-mono" + style={{ + background: 'rgba(30, 35, 41, 0.8)', + border: hasApiKey ? '1px solid rgba(14, 203, 129, 0.3)' : '1px solid rgba(139, 92, 246, 0.3)', + color: '#EAECEF', + }} + /> +
+ {!disabled && !config.nofxos_api_key && ( + + )} +
+ + {/* NofxOS Data Sources Grid */} +
+
+ {t('nofxosDataSources')} +
+
+ {/* Quant Data */} +
!disabled && onChange({ ...config, enable_quant_data: !config.enable_quant_data })} + > +
+
+
+ {t('quantData')} +
+ { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_data: e.target.checked }) }} + disabled={disabled} + className="w-3.5 h-3.5 rounded accent-blue-500" + /> +
+

{t('quantDataDesc')}

+ {config.enable_quant_data && ( +
+ + +
+ )} +
+ + {/* OI Ranking */} +
!disabled && onChange({ + ...config, + enable_oi_ranking: !config.enable_oi_ranking, + ...(!config.enable_oi_ranking && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}), + ...(!config.enable_oi_ranking && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}), + })} + > +
+
+
+ {t('oiRanking')} +
+ { e.stopPropagation(); !disabled && onChange({ + ...config, + enable_oi_ranking: e.target.checked, + ...(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-3.5 h-3.5 rounded accent-green-500" + /> +
+

{t('oiRankingDesc')}

+ {config.enable_oi_ranking && ( +
e.stopPropagation()}> + + +
+ )} +
+ + {/* NetFlow Ranking */} +
!disabled && onChange({ + ...config, + enable_netflow_ranking: !config.enable_netflow_ranking, + ...(!config.enable_netflow_ranking && !config.netflow_ranking_duration ? { netflow_ranking_duration: '1h' } : {}), + ...(!config.enable_netflow_ranking && !config.netflow_ranking_limit ? { netflow_ranking_limit: 10 } : {}), + })} + > +
+
+
+ {t('netflowRanking')} +
+ { e.stopPropagation(); !disabled && onChange({ + ...config, + enable_netflow_ranking: e.target.checked, + ...(e.target.checked && !config.netflow_ranking_duration ? { netflow_ranking_duration: '1h' } : {}), + ...(e.target.checked && !config.netflow_ranking_limit ? { netflow_ranking_limit: 10 } : {}), + }) }} + disabled={disabled} + className="w-3.5 h-3.5 rounded accent-amber-500" + /> +
+

{t('netflowRankingDesc')}

+ {config.enable_netflow_ranking && ( +
e.stopPropagation()}> + + +
+ )} +
+ + {/* Price Ranking */} +
!disabled && onChange({ + ...config, + enable_price_ranking: !config.enable_price_ranking, + ...(!config.enable_price_ranking && !config.price_ranking_duration ? { price_ranking_duration: '1h,4h,24h' } : {}), + ...(!config.enable_price_ranking && !config.price_ranking_limit ? { price_ranking_limit: 10 } : {}), + })} + > +
+
+
+ {t('priceRanking')} +
+ { e.stopPropagation(); !disabled && onChange({ + ...config, + enable_price_ranking: e.target.checked, + ...(e.target.checked && !config.price_ranking_duration ? { price_ranking_duration: '1h,4h,24h' } : {}), + ...(e.target.checked && !config.price_ranking_limit ? { price_ranking_limit: 10 } : {}), + }) }} + disabled={disabled} + className="w-3.5 h-3.5 rounded accent-pink-500" + /> +
+

{t('priceRankingDesc')}

+ {config.enable_price_ranking && ( +
e.stopPropagation()}> + + +
+ )} +
+
+ + {/* Warning if features enabled but no API key */} + {hasNofxosEnabled && !hasApiKey && ( +
+ + + {language === 'zh' ? '请配置 API Key 以启用 NofxOS 数据源' : 'Please configure API Key to enable NofxOS data sources'} + +
+ )} +
+
+
+ + {/* ============================================ */} + {/* Section 1: Market Data (Required) */} + {/* ============================================ */}
@@ -275,7 +648,9 @@ export function IndicatorEditor({
- {/* Section 2: Technical Indicators (Optional) */} + {/* ============================================ */} + {/* Section 2: Technical Indicators (Optional) */} + {/* ============================================ */}
@@ -345,7 +720,9 @@ export function IndicatorEditor({
- {/* Section 3: Market Sentiment */} + {/* ============================================ */} + {/* Section 3: Market Sentiment */} + {/* ============================================ */}
@@ -387,163 +764,6 @@ export function IndicatorEditor({
- - {/* Section 4: Quant Data (External API) */} -
-
- - {t('quantData')} - - {t('quantDataDesc')} -
- -
- {/* Enable Toggle */} -
-
-
- {t('quantData')} -
- !disabled && onChange({ ...config, enable_quant_data: e.target.checked })} - disabled={disabled} - className="w-4 h-4 rounded accent-blue-500" - /> -
- - {/* API URL */} - {config.enable_quant_data && ( -
-
- - {!disabled && !config.quant_data_api_url && ( - - )} -
- !disabled && onChange({ ...config, quant_data_api_url: e.target.value })} - disabled={disabled} - placeholder="http://example.com/api/coin/{symbol}?include=netflow,oi" - className="w-full px-2 py-1.5 rounded text-xs font-mono" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - /> -

{t('symbolPlaceholder')}

- - {/* OI and Netflow toggles */} -
- - -
-
- )} -
-
- - {/* Section 5: OI Ranking Data (Market-wide) */} -
-
- - {t('oiRanking')} - - {t('oiRankingDesc')} -
- -
- {/* Enable Toggle */} -
-
-
- {t('oiRanking')} -
- !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" - /> -
- - {/* Settings */} - {config.enable_oi_ranking && ( -
-
- {/* Duration */} -
- - -
- {/* Limit */} -
- - -
-
-

{t('oiRankingNote')}

-
- )} -
-
) } diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index f30e2622..089f7c6b 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -504,7 +504,7 @@ export const translations = { noExchangesConfigured: 'No configured exchanges', signalSource: 'Signal Source', signalSourceConfig: 'Signal Source Configuration', - coinPoolDescription: + ai500Description: 'API endpoint for AI500 data provider, leave blank to disable this signal source', oiTopDescription: 'API endpoint for open interest rankings, leave blank to disable this signal source', @@ -784,7 +784,7 @@ export const translations = { candidateCoins: 'Candidate Coins', candidateCoinsZeroWarning: 'Candidate Coins Count is 0', possibleReasons: 'Possible Reasons:', - coinPoolApiNotConfigured: + ai500ApiNotConfigured: 'AI500 data provider API not configured or inaccessible (check signal source settings)', apiConnectionTimeout: 'API connection timeout or returned empty data', noCustomCoinsAndApiFailed: @@ -792,7 +792,7 @@ export const translations = { solutions: 'Solutions:', setCustomCoinsInConfig: 'Set custom coin list in trader configuration', orConfigureCorrectApiUrl: 'Or configure correct data provider API address', - orDisableCoinPoolOptions: + orDisableAI500Options: 'Or disable "Use AI500 Data Provider" and "Use OI Top" options', signalSourceNotConfigured: 'Signal Source Not Configured', signalSourceWarningMessage: @@ -1691,7 +1691,7 @@ export const translations = { noExchangesConfigured: '暂无已配置的交易所', signalSource: '信号源', signalSourceConfig: '信号源配置', - coinPoolDescription: + ai500Description: '用于获取 AI500 数据源的 API 地址,留空则不使用此数据源', oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源', information: '说明', @@ -1939,14 +1939,14 @@ export const translations = { candidateCoins: '候选币种', candidateCoinsZeroWarning: '候选币种数量为 0', possibleReasons: '可能原因:', - coinPoolApiNotConfigured: + ai500ApiNotConfigured: 'AI500 数据源 API 未配置或无法访问(请检查信号源设置)', apiConnectionTimeout: 'API连接超时或返回数据为空', noCustomCoinsAndApiFailed: '未配置自定义币种且API获取失败', solutions: '解决方案:', setCustomCoinsInConfig: '在交易员配置中设置自定义币种列表', orConfigureCorrectApiUrl: '或者配置正确的数据源 API 地址', - orDisableCoinPoolOptions: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项', + orDisableAI500Options: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项', signalSourceNotConfigured: '信号源未配置', signalSourceWarningMessage: '您有交易员启用了"使用 AI500 数据源"或"使用 OI Top",但尚未配置信号源 API 地址。这将导致候选币种数量为 0,交易员无法正常工作。', diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index 53d918d8..05e0f162 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' import { @@ -150,6 +150,45 @@ export function StrategyStudioPage() { fetchAiModels() }, [fetchStrategies, fetchAiModels]) + // Track previous language to detect actual changes + const prevLanguageRef = useRef(language) + + // When language changes, update prompt sections to match the new language + useEffect(() => { + const updatePromptSectionsForLanguage = async () => { + // Only update if language actually changed (not on initial mount) + if (prevLanguageRef.current === language) return + prevLanguageRef.current = language + + if (!token) return + + try { + // Fetch default config for the new language + const response = await fetch( + `${API_BASE}/api/strategies/default-config?lang=${language}`, + { headers: { Authorization: `Bearer ${token}` } } + ) + if (!response.ok) return + const defaultConfig = await response.json() + + // Update only the prompt sections and language field + setEditingConfig(prev => { + if (!prev) return prev + return { + ...prev, + language: language as 'zh' | 'en', + prompt_sections: defaultConfig.prompt_sections, + } + }) + setHasChanges(true) + } catch (err) { + console.error('Failed to update prompt sections for language:', err) + } + } + + updatePromptSectionsForLanguage() + }, [language, token]) // Only trigger when language changes + // Create new strategy const handleCreateStrategy = async () => { if (!token) return @@ -336,6 +375,11 @@ export function StrategyStudioPage() { if (!token || !selectedStrategy || !editingConfig) return setIsSaving(true) try { + // Always sync the config language with the current interface language + const configWithLanguage = { + ...editingConfig, + language: language as 'zh' | 'en', + } const response = await fetch( `${API_BASE}/api/strategies/${selectedStrategy.id}`, { @@ -347,7 +391,7 @@ export function StrategyStudioPage() { body: JSON.stringify({ name: selectedStrategy.name, description: selectedStrategy.description, - config: editingConfig, + config: configWithLanguage, is_public: selectedStrategy.is_public, config_visible: selectedStrategy.config_visible, }), diff --git a/web/src/types.ts b/web/src/types.ts index 598d3c53..b4f39278 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -99,7 +99,7 @@ export interface TraderInfo { strategy_id?: string strategy_name?: string custom_prompt?: string - use_coin_pool?: boolean + use_ai500?: boolean use_oi_top?: boolean system_prompt_template?: string } @@ -172,7 +172,7 @@ export interface CreateTraderRequest { custom_prompt?: string override_base_prompt?: boolean system_prompt_template?: string - use_coin_pool?: boolean + use_ai500?: boolean use_oi_top?: boolean } @@ -249,7 +249,7 @@ export interface TraderConfigData { custom_prompt?: string override_base_prompt?: boolean system_prompt_template?: string - use_coin_pool?: boolean + use_ai500?: boolean use_oi_top?: boolean } @@ -462,6 +462,9 @@ export interface PromptSectionsConfig { } export interface StrategyConfig { + // Language setting: "zh" for Chinese, "en" for English + // Determines the language used for data formatting and prompt generation + language?: 'zh' | 'en'; coin_source: CoinSourceConfig; indicators: IndicatorConfig; custom_prompt?: string; @@ -470,15 +473,14 @@ export interface StrategyConfig { } export interface CoinSourceConfig { - source_type: 'static' | 'coinpool' | 'oi_top' | 'mixed'; + source_type: 'static' | 'ai500' | 'oi_top' | 'mixed'; static_coins?: string[]; excluded_coins?: string[]; // 排除的币种列表 - use_coin_pool: boolean; - coin_pool_limit?: number; - coin_pool_api_url?: string; // AI500 币种池 API URL + use_ai500: boolean; + ai500_limit?: number; use_oi_top: boolean; oi_top_limit?: number; - oi_top_api_url?: string; // OI Top API URL + // Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig } export interface IndicatorConfig { @@ -499,16 +501,30 @@ export interface IndicatorConfig { atr_periods?: number[]; boll_periods?: number[]; external_data_sources?: ExternalDataSource[]; + + // ========== NofxOS 数据源统一配置 ========== + // Unified NofxOS API Key - used for all NofxOS data sources + nofxos_api_key?: string; + // 量化数据源(资金流向、持仓变化、价格变化) enable_quant_data?: boolean; - 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; + + // NetFlow 排行数据(机构/散户资金流向排行) + enable_netflow_ranking?: boolean; + netflow_ranking_duration?: string; // "1h", "4h", "24h" + netflow_ranking_limit?: number; + + // Price 排行数据(涨跌幅排行) + enable_price_ranking?: boolean; + price_ranking_duration?: string; // "1h", "4h", "24h" or "1h,4h,24h" + price_ranking_limit?: number; } export interface KlineConfig {