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
This commit is contained in:
tinkle-community
2026-01-04 00:59:07 +08:00
parent 13fda47151
commit 0275e23b7e
32 changed files with 3029 additions and 1767 deletions
+15 -23
View File
@@ -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 {
+12 -20
View File
@@ -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)
}
+3 -3
View File
@@ -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,
}
+19 -12
View File
@@ -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
+7 -7
View File
@@ -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{
+19 -1
View File
@@ -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,
+14 -6
View File
@@ -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
+852
View File
@@ -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<T>(endpoint: string, params: Record<string, any> = {}): Promise<T> {
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<any>("/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 错误。
-350
View File
@@ -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. **资金流向解读**:机构与散户的资金流向通常呈相反趋势,可作为市场情绪判断依据
-254
View File
@@ -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
+1 -1
View File
@@ -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"]`
+183 -70
View File
@@ -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")
+4 -12
View File
@@ -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 {
+2 -2
View File
@@ -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,
-593
View File
@@ -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
+163
View File
@@ -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
}
+146
View File
@@ -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 ""
}
+216
View File
@@ -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()
}
+263
View File
@@ -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()
}
+212
View File
@@ -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()
}
+182
View File
@@ -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()
}
+31
View File
@@ -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)
}
+50 -22
View File
@@ -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)
+1 -1
View File
@@ -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"`
+21 -1
View File
@@ -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
}
+9 -9
View File
@@ -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(' + ') }
+1 -1
View File
@@ -368,7 +368,7 @@ export function TraderConfigModal({
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
selectedStrategy.config.coin_source.source_type === 'coinpool' ? 'Coin Pool' :
selectedStrategy.config.coin_source.source_type === 'ai500' ? 'AI500' :
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
</div>
<div>
+130 -186
View File
@@ -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<string, Record<string, string>> = {
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 = () => (
<span
className="text-[9px] px-1.5 py-0.5 rounded font-medium"
style={{
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2))',
color: '#a855f7',
border: '1px solid rgba(139, 92, 246, 0.3)'
}}
>
NofxOS
</span>
)
return (
<div className="space-y-6">
{/* Source Type Selector */}
@@ -305,198 +310,137 @@ export function CoinSourceEditor({
)}
</div>
{/* Coin Pool Options */}
{(config.source_type === 'coinpool' || config.source_type === 'mixed') && (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{t('dataSourceConfig')} - AI500
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-3 mb-3 cursor-pointer">
<input
type="checkbox"
checked={config.use_coin_pool}
onChange={(e) =>
!disabled && onChange({ ...config, use_coin_pool: e.target.checked })
}
disabled={disabled}
className="w-5 h-5 rounded accent-yellow-500"
/>
<span style={{ color: '#EAECEF' }}>{t('useCoinPool')}</span>
</label>
{config.use_coin_pool && (
<div className="flex items-center gap-3">
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('coinPoolLimit')}:
</span>
<input
type="number"
value={config.coin_pool_limit || 10}
onChange={(e) =>
!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',
}}
/>
</div>
)}
{/* AI500 Options */}
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
<div
className="p-4 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.05)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
AI500 {t('dataSourceConfig')}
</span>
<NofxOSBadge />
</div>
</div>
{config.use_coin_pool && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm" style={{ color: '#848E9C' }}>
{t('coinPoolApiUrl')}
</label>
{!disabled && !config.coin_pool_api_url && (
<button
type="button"
onClick={() => onChange({ ...config, coin_pool_api_url: DEFAULT_COIN_POOL_API_URL })}
className="text-xs px-2 py-1 rounded"
style={{ background: '#F0B90B20', color: '#F0B90B' }}
>
{t('fillDefault')}
</button>
)}
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="url"
value={config.coin_pool_api_url || ''}
type="checkbox"
checked={config.use_ai500}
onChange={(e) =>
!disabled && onChange({ ...config, coin_pool_api_url: e.target.value })
!disabled && onChange({ ...config, use_ai500: e.target.checked })
}
disabled={disabled}
placeholder={t('coinPoolApiUrlPlaceholder')}
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
className="w-5 h-5 rounded accent-yellow-500"
/>
{!config.coin_pool_api_url && (
<div className="flex items-center gap-2 mt-2">
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-xs" style={{ color: '#F0B90B' }}>
{t('apiUrlRequired')}
</span>
</div>
)}
</div>
)}
<span style={{ color: '#EAECEF' }}>{t('useAI500')}</span>
</label>
{config.use_ai500 && (
<div className="flex items-center gap-3 pl-8">
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('ai500Limit')}:
</span>
<select
value={config.ai500_limit || 10}
onChange={(e) =>
!disabled &&
onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
}
disabled={disabled}
className="px-3 py-1.5 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
)}
<p className="text-xs pl-8" style={{ color: '#5E6673' }}>
{t('nofxosNote')}
</p>
</div>
</div>
)}
{/* OI Top Options */}
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4" style={{ color: '#0ECB81' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{t('dataSourceConfig')} - OI Top
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-3 mb-3 cursor-pointer">
<input
type="checkbox"
checked={config.use_oi_top}
onChange={(e) =>
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
}
disabled={disabled}
className="w-5 h-5 rounded accent-yellow-500"
/>
<span style={{ color: '#EAECEF' }}>{t('useOITop')}</span>
</label>
{config.use_oi_top && (
<div className="flex items-center gap-3">
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('oiTopLimit')}:
</span>
<input
type="number"
value={config.oi_top_limit || 20}
onChange={(e) =>
!disabled &&
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
}
disabled={disabled}
min={1}
max={50}
className="w-20 px-3 py-1.5 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
</div>
)}
<div
className="p-4 rounded-lg"
style={{
background: 'rgba(14, 203, 129, 0.05)',
border: '1px solid rgba(14, 203, 129, 0.2)',
}}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" style={{ color: '#0ECB81' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
OI Top {t('dataSourceConfig')}
</span>
<NofxOSBadge />
</div>
</div>
{config.use_oi_top && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm" style={{ color: '#848E9C' }}>
{t('oiTopApiUrl')}
</label>
{!disabled && !config.oi_top_api_url && (
<button
type="button"
onClick={() => onChange({ ...config, oi_top_api_url: DEFAULT_OI_TOP_API_URL })}
className="text-xs px-2 py-1 rounded"
style={{ background: '#0ECB8120', color: '#0ECB81' }}
>
{t('fillDefault')}
</button>
)}
</div>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="url"
value={config.oi_top_api_url || ''}
type="checkbox"
checked={config.use_oi_top}
onChange={(e) =>
!disabled && onChange({ ...config, oi_top_api_url: e.target.value })
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
}
disabled={disabled}
placeholder={t('oiTopApiUrlPlaceholder')}
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
className="w-5 h-5 rounded accent-green-500"
/>
{!config.oi_top_api_url && (
<div className="flex items-center gap-2 mt-2">
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-xs" style={{ color: '#F0B90B' }}>
{t('apiUrlRequired')}
</span>
</div>
)}
</div>
)}
<span style={{ color: '#EAECEF' }}>{t('useOITop')}</span>
</label>
{config.use_oi_top && (
<div className="flex items-center gap-3 pl-8">
<span className="text-sm" style={{ color: '#848E9C' }}>
{t('oiTopLimit')}:
</span>
<select
value={config.oi_top_limit || 20}
onChange={(e) =>
!disabled &&
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
}
disabled={disabled}
className="px-3 py-1.5 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
>
{[5, 10, 15, 20, 30, 50].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
)}
<p className="text-xs pl-8" style={{ color: '#5E6673' }}>
{t('nofxosNote')}
</p>
</div>
</div>
)}
</div>
)
}
+395 -175
View File
@@ -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 (
<div className="space-y-5">
{/* Section 1: Market Data (Required) */}
{/* ============================================ */}
{/* NofxOS Data Provider - Top Configuration */}
{/* ============================================ */}
<div
className="rounded-lg overflow-hidden relative"
style={{
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(168, 85, 247, 0.08) 50%, rgba(236, 72, 153, 0.08) 100%)',
border: '1px solid rgba(139, 92, 246, 0.3)',
}}
>
{/* Decorative gradient line at top */}
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ background: 'linear-gradient(90deg, #6366f1, #a855f7, #ec4899)' }}
/>
<div className="p-4">
{/* Header Row */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: 'linear-gradient(135deg, #6366f1, #a855f7)' }}
>
<Zap className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{t('nofxosTitle')}
</h3>
<span className="text-[10px]" style={{ color: '#848E9C' }}>
{t('nofxosFeatures')}
</span>
</div>
</div>
{/* Status & API Docs */}
<div className="flex items-center gap-2">
{hasApiKey ? (
<span className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full" style={{ background: 'rgba(14, 203, 129, 0.15)', color: '#0ECB81' }}>
<Check className="w-3 h-3" />
{t('connected')}
</span>
) : (
<span className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full" style={{ background: 'rgba(246, 70, 93, 0.15)', color: '#F6465D' }}>
<AlertCircle className="w-3 h-3" />
{t('notConfigured')}
</span>
)}
<a
href="https://nofxos.ai/api-docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-all hover:scale-[1.02]"
style={{
background: 'rgba(139, 92, 246, 0.2)',
color: '#a855f7',
}}
>
<ExternalLink className="w-3 h-3" />
{t('viewApiDocs')}
</a>
</div>
</div>
{/* API Key Input */}
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: '#848E9C' }} />
<input
type="text"
value={config.nofxos_api_key || ''}
onChange={(e) => !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',
}}
/>
</div>
{!disabled && !config.nofxos_api_key && (
<button
type="button"
onClick={() => onChange({ ...config, nofxos_api_key: DEFAULT_NOFXOS_API_KEY })}
className="px-3 py-2 rounded-lg text-xs font-medium transition-all hover:scale-[1.02]"
style={{
background: 'linear-gradient(135deg, #6366f1, #a855f7)',
color: '#fff',
}}
>
{t('fillDefault')}
</button>
)}
</div>
{/* NofxOS Data Sources Grid */}
<div className="mt-4">
<div className="text-[10px] font-medium mb-2" style={{ color: '#848E9C' }}>
{t('nofxosDataSources')}
</div>
<div className="grid grid-cols-2 gap-2">
{/* Quant Data */}
<div
className="p-2.5 rounded-lg transition-all cursor-pointer"
style={{
background: config.enable_quant_data ? 'rgba(96, 165, 250, 0.1)' : 'rgba(30, 35, 41, 0.5)',
border: config.enable_quant_data ? '1px solid rgba(96, 165, 250, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
opacity: disabled ? 0.5 : 1,
}}
onClick={() => !disabled && onChange({ ...config, enable_quant_data: !config.enable_quant_data })}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: '#60a5fa' }} />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
</div>
<input
type="checkbox"
checked={config.enable_quant_data || false}
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_data: e.target.checked }) }}
disabled={disabled}
className="w-3.5 h-3.5 rounded accent-blue-500"
/>
</div>
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('quantDataDesc')}</p>
{config.enable_quant_data && (
<div className="flex gap-3 mt-2">
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={config.enable_quant_oi !== false}
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_oi: e.target.checked }) }}
disabled={disabled}
className="w-3 h-3 rounded accent-blue-500"
/>
<span className="text-[10px]" style={{ color: '#EAECEF' }}>OI</span>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={config.enable_quant_netflow !== false}
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_netflow: e.target.checked }) }}
disabled={disabled}
className="w-3 h-3 rounded accent-blue-500"
/>
<span className="text-[10px]" style={{ color: '#EAECEF' }}>Netflow</span>
</label>
</div>
)}
</div>
{/* OI Ranking */}
<div
className="p-2.5 rounded-lg transition-all cursor-pointer"
style={{
background: config.enable_oi_ranking ? 'rgba(34, 197, 94, 0.1)' : 'rgba(30, 35, 41, 0.5)',
border: config.enable_oi_ranking ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
opacity: disabled ? 0.5 : 1,
}}
onClick={() => !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 } : {}),
})}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
</div>
<input
type="checkbox"
checked={config.enable_oi_ranking || false}
onChange={(e) => { 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"
/>
</div>
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('oiRankingDesc')}</p>
{config.enable_oi_ranking && (
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
<select
value={config.oi_ranking_duration || '1h'}
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
disabled={disabled}
className="flex-1 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="24h">24h</option>
</select>
<select
value={config.oi_ranking_limit || 10}
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
)}
</div>
{/* NetFlow Ranking */}
<div
className="p-2.5 rounded-lg transition-all cursor-pointer"
style={{
background: config.enable_netflow_ranking ? 'rgba(245, 158, 11, 0.1)' : 'rgba(30, 35, 41, 0.5)',
border: config.enable_netflow_ranking ? '1px solid rgba(245, 158, 11, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
opacity: disabled ? 0.5 : 1,
}}
onClick={() => !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 } : {}),
})}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: '#f59e0b' }} />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('netflowRanking')}</span>
</div>
<input
type="checkbox"
checked={config.enable_netflow_ranking || false}
onChange={(e) => { 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"
/>
</div>
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('netflowRankingDesc')}</p>
{config.enable_netflow_ranking && (
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
<select
value={config.netflow_ranking_duration || '1h'}
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_duration: e.target.value })}
disabled={disabled}
className="flex-1 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="24h">24h</option>
</select>
<select
value={config.netflow_ranking_limit || 10}
onChange={(e) => !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(e.target.value) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
)}
</div>
{/* Price Ranking */}
<div
className="p-2.5 rounded-lg transition-all cursor-pointer"
style={{
background: config.enable_price_ranking ? 'rgba(236, 72, 153, 0.1)' : 'rgba(30, 35, 41, 0.5)',
border: config.enable_price_ranking ? '1px solid rgba(236, 72, 153, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
opacity: disabled ? 0.5 : 1,
}}
onClick={() => !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 } : {}),
})}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: '#ec4899' }} />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('priceRanking')}</span>
</div>
<input
type="checkbox"
checked={config.enable_price_ranking || false}
onChange={(e) => { 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"
/>
</div>
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('priceRankingDesc')}</p>
{config.enable_price_ranking && (
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
<select
value={config.price_ranking_duration || '1h,4h,24h'}
onChange={(e) => !disabled && onChange({ ...config, price_ranking_duration: e.target.value })}
disabled={disabled}
className="flex-1 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="24h">24h</option>
<option value="1h,4h,24h">{t('priceRankingMulti')}</option>
</select>
<select
value={config.price_ranking_limit || 10}
onChange={(e) => !disabled && onChange({ ...config, price_ranking_limit: parseInt(e.target.value) })}
disabled={disabled}
className="w-14 px-2 py-1 rounded text-[10px]"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
)}
</div>
</div>
{/* Warning if features enabled but no API key */}
{hasNofxosEnabled && !hasApiKey && (
<div className="flex items-center gap-2 mt-3 p-2 rounded-lg" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.2)' }}>
<AlertCircle className="w-4 h-4 flex-shrink-0" style={{ color: '#F6465D' }} />
<span className="text-[10px]" style={{ color: '#F6465D' }}>
{language === 'zh' ? '请配置 API Key 以启用 NofxOS 数据源' : 'Please configure API Key to enable NofxOS data sources'}
</span>
</div>
)}
</div>
</div>
</div>
{/* ============================================ */}
{/* Section 1: Market Data (Required) */}
{/* ============================================ */}
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
<BarChart2 className="w-4 h-4" style={{ color: '#F0B90B' }} />
@@ -275,7 +648,9 @@ export function IndicatorEditor({
</div>
</div>
{/* Section 2: Technical Indicators (Optional) */}
{/* ============================================ */}
{/* Section 2: Technical Indicators (Optional) */}
{/* ============================================ */}
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
@@ -345,7 +720,9 @@ export function IndicatorEditor({
</div>
</div>
{/* Section 3: Market Sentiment */}
{/* ============================================ */}
{/* Section 3: Market Sentiment */}
{/* ============================================ */}
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
<TrendingUp className="w-4 h-4" style={{ color: '#22c55e' }} />
@@ -387,163 +764,6 @@ export function IndicatorEditor({
</div>
</div>
</div>
{/* Section 4: Quant Data (External API) */}
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
<Database className="w-4 h-4" style={{ color: '#60a5fa' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
<span className="text-xs" style={{ color: '#848E9C' }}>- {t('quantDataDesc')}</span>
</div>
<div className="p-3 space-y-3">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: '#60a5fa' }} />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
</div>
<input
type="checkbox"
checked={config.enable_quant_data || false}
onChange={(e) => !disabled && onChange({ ...config, enable_quant_data: e.target.checked })}
disabled={disabled}
className="w-4 h-4 rounded accent-blue-500"
/>
</div>
{/* API URL */}
{config.enable_quant_data && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px]" style={{ color: '#848E9C' }}>
{t('quantDataUrl')}
</label>
{!disabled && !config.quant_data_api_url && (
<button
type="button"
onClick={() => onChange({ ...config, quant_data_api_url: DEFAULT_QUANT_DATA_API_URL })}
className="text-[10px] px-2 py-0.5 rounded"
style={{ background: '#60a5fa20', color: '#60a5fa' }}
>
{t('fillDefault')}
</button>
)}
</div>
<input
type="text"
value={config.quant_data_api_url || ''}
onChange={(e) => !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' }}
/>
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{t('symbolPlaceholder')}</p>
{/* OI and Netflow toggles */}
<div className="flex gap-4 mt-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={config.enable_quant_oi !== false}
onChange={(e) => !disabled && onChange({ ...config, enable_quant_oi: e.target.checked })}
disabled={disabled}
className="w-3.5 h-3.5 rounded accent-blue-500"
/>
<span className="text-xs" style={{ color: '#EAECEF' }}>OI</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={config.enable_quant_netflow !== false}
onChange={(e) => !disabled && onChange({ ...config, enable_quant_netflow: e.target.checked })}
disabled={disabled}
className="w-3.5 h-3.5 rounded accent-blue-500"
/>
<span className="text-xs" style={{ color: '#EAECEF' }}>Netflow</span>
</label>
</div>
</div>
)}
</div>
</div>
{/* Section 5: OI Ranking Data (Market-wide) */}
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
<LineChart className="w-4 h-4" style={{ color: '#22c55e' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
<span className="text-xs" style={{ color: '#848E9C' }}>- {t('oiRankingDesc')}</span>
</div>
<div className="p-3 space-y-3">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} />
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('oiRanking')}</span>
</div>
<input
type="checkbox"
checked={config.enable_oi_ranking || false}
onChange={(e) => !disabled && onChange({
...config,
enable_oi_ranking: e.target.checked,
// Set defaults when enabling
...(e.target.checked && !config.oi_ranking_api_url ? { oi_ranking_api_url: DEFAULT_OI_RANKING_API_URL } : {}),
...(e.target.checked && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}),
...(e.target.checked && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}),
})}
disabled={disabled}
className="w-4 h-4 rounded accent-green-500"
/>
</div>
{/* Settings */}
{config.enable_oi_ranking && (
<div className="space-y-3">
<div className="flex gap-3">
{/* Duration */}
<div className="flex-1">
<label className="text-[10px] mb-1 block" style={{ color: '#848E9C' }}>
{t('oiRankingDuration')}
</label>
<select
value={config.oi_ranking_duration || '1h'}
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_duration: e.target.value })}
disabled={disabled}
className="w-full px-2 py-1.5 rounded text-xs"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<option value="1h">{language === 'zh' ? '1小时' : '1 Hour'}</option>
<option value="4h">{language === 'zh' ? '4小时' : '4 Hours'}</option>
<option value="24h">{language === 'zh' ? '24小时' : '24 Hours'}</option>
</select>
</div>
{/* Limit */}
<div className="flex-1">
<label className="text-[10px] mb-1 block" style={{ color: '#848E9C' }}>
{t('oiRankingLimit')}
</label>
<select
value={config.oi_ranking_limit || 10}
onChange={(e) => !disabled && onChange({ ...config, oi_ranking_limit: parseInt(e.target.value) })}
disabled={disabled}
className="w-full px-2 py-1.5 rounded text-xs"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{[5, 10, 15, 20].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
</div>
<p className="text-[10px]" style={{ color: '#5E6673' }}>{t('oiRankingNote')}</p>
</div>
)}
</div>
</div>
</div>
)
}
+6 -6
View File
@@ -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,交易员无法正常工作。',
+46 -2
View File
@@ -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,
}),
+26 -10
View File
@@ -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 {