mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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:
+15
-23
@@ -15,7 +15,7 @@ import (
|
|||||||
"nofx/backtest"
|
"nofx/backtest"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/provider"
|
"nofx/provider/nofxos"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -92,9 +92,9 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
cfg.SetLoadedStrategy(&strategyConfig)
|
cfg.SetLoadedStrategy(&strategyConfig)
|
||||||
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
|
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.SourceType,
|
||||||
strategyConfig.CoinSource.UseCoinPool,
|
strategyConfig.CoinSource.UseAI500,
|
||||||
strategyConfig.CoinSource.UseOITop,
|
strategyConfig.CoinSource.UseOITop,
|
||||||
strategyConfig.CoinSource.StaticCoins)
|
strategyConfig.CoinSource.StaticCoins)
|
||||||
|
|
||||||
@@ -638,21 +638,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
|||||||
var symbols []string
|
var symbols []string
|
||||||
symbolSet := make(map[string]bool)
|
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
|
// Handle empty source_type - check flags for backward compatibility
|
||||||
sourceType := coinSource.SourceType
|
sourceType := coinSource.SourceType
|
||||||
if sourceType == "" {
|
if sourceType == "" {
|
||||||
if coinSource.UseCoinPool && coinSource.UseOITop {
|
if coinSource.UseAI500 && coinSource.UseOITop {
|
||||||
sourceType = "mixed"
|
sourceType = "mixed"
|
||||||
} else if coinSource.UseCoinPool {
|
} else if coinSource.UseAI500 {
|
||||||
sourceType = "coinpool"
|
sourceType = "ai500"
|
||||||
} else if coinSource.UseOITop {
|
} else if coinSource.UseOITop {
|
||||||
sourceType = "oi_top"
|
sourceType = "oi_top"
|
||||||
} else if len(coinSource.StaticCoins) > 0 {
|
} else if len(coinSource.StaticCoins) > 0 {
|
||||||
@@ -673,13 +665,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "coinpool":
|
case "ai500":
|
||||||
limit := coinSource.CoinPoolLimit
|
limit := coinSource.AI500Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
||||||
coins, err := provider.GetTopRatedCoins(limit)
|
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
|
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":
|
case "oi_top":
|
||||||
coins, err := provider.GetOITopSymbols()
|
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
|
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":
|
case "mixed":
|
||||||
// Get from coin pool
|
// Get from AI500
|
||||||
if coinSource.UseCoinPool {
|
if coinSource.UseAI500 {
|
||||||
limit := coinSource.CoinPoolLimit
|
limit := coinSource.AI500Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
coins, err := provider.GetTopRatedCoins(limit)
|
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Failed to get AI500 coins: %v", err)
|
logger.Warnf("Failed to get AI500 coins: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -735,7 +727,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
|||||||
|
|
||||||
// Get from OI Top
|
// Get from OI Top
|
||||||
if coinSource.UseOITop {
|
if coinSource.UseOITop {
|
||||||
coins, err := provider.GetOITopSymbols()
|
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Failed to get OI Top coins: %v", err)
|
logger.Warnf("Failed to get OI Top coins: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+12
-20
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"nofx/debate"
|
"nofx/debate"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/provider"
|
"nofx/provider/nofxos"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -158,35 +158,27 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
|||||||
if len(coinSource.StaticCoins) > 0 {
|
if len(coinSource.StaticCoins) > 0 {
|
||||||
req.Symbol = coinSource.StaticCoins[0]
|
req.Symbol = coinSource.StaticCoins[0]
|
||||||
}
|
}
|
||||||
case "coinpool":
|
case "ai500":
|
||||||
// Fetch from coin pool API
|
// Fetch from AI500 API
|
||||||
if coinSource.CoinPoolAPIURL != "" {
|
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
|
||||||
}
|
|
||||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = 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":
|
case "oi_top":
|
||||||
// Fetch from OI top API
|
// Fetch from OI top API
|
||||||
if coinSource.OITopAPIURL != "" {
|
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
|
||||||
}
|
|
||||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = coins[0]
|
req.Symbol = coins[0]
|
||||||
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
||||||
}
|
}
|
||||||
case "mixed":
|
case "mixed":
|
||||||
// Try coin pool first, then OI top
|
// Try AI500 first, then OI top
|
||||||
if coinSource.UseCoinPool && coinSource.CoinPoolAPIURL != "" {
|
if coinSource.UseAI500 {
|
||||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = 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 != "" {
|
} else if coinSource.UseOITop {
|
||||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = coins[0]
|
req.Symbol = coins[0]
|
||||||
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
|
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -406,7 +406,7 @@ type CreateTraderRequest struct {
|
|||||||
CustomPrompt string `json:"custom_prompt"`
|
CustomPrompt string `json:"custom_prompt"`
|
||||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||||
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
|
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"`
|
UseOITop bool `json:"use_oi_top"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,7 +666,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
BTCETHLeverage: btcEthLeverage,
|
BTCETHLeverage: btcEthLeverage,
|
||||||
AltcoinLeverage: altcoinLeverage,
|
AltcoinLeverage: altcoinLeverage,
|
||||||
TradingSymbols: req.TradingSymbols,
|
TradingSymbols: req.TradingSymbols,
|
||||||
UseCoinPool: req.UseCoinPool,
|
UseAI500: req.UseAI500,
|
||||||
UseOITop: req.UseOITop,
|
UseOITop: req.UseOITop,
|
||||||
CustomPrompt: req.CustomPrompt,
|
CustomPrompt: req.CustomPrompt,
|
||||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
OverrideBasePrompt: req.OverrideBasePrompt,
|
||||||
@@ -2049,7 +2049,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
|||||||
"custom_prompt": traderConfig.CustomPrompt,
|
"custom_prompt": traderConfig.CustomPrompt,
|
||||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
||||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
"is_cross_margin": traderConfig.IsCrossMargin,
|
||||||
"use_coin_pool": traderConfig.UseCoinPool,
|
"use_ai500": traderConfig.UseAI500,
|
||||||
"use_oi_top": traderConfig.UseOITop,
|
"use_oi_top": traderConfig.UseOITop,
|
||||||
"is_running": isRunning,
|
"is_running": isRunning,
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-12
@@ -9,7 +9,6 @@ import (
|
|||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -20,11 +19,11 @@ import (
|
|||||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||||
var warnings []string
|
var warnings []string
|
||||||
|
|
||||||
// Validate quant data URL if enabled
|
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||||
if config.Indicators.EnableQuantData && config.Indicators.QuantDataAPIURL != "" {
|
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||||
if !strings.Contains(config.Indicators.QuantDataAPIURL, "{symbol}") {
|
config.Indicators.EnableNetFlowRanking || config.Indicators.EnablePriceRanking) &&
|
||||||
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.")
|
config.Indicators.NofxOSAPIKey == "" {
|
||||||
}
|
warnings = append(warnings, "NofxOS API key is not configured. NofxOS data sources may not work properly.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
@@ -504,6 +503,12 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
// Fetch OI ranking data (market-wide position changes)
|
// Fetch OI ranking data (market-wide position changes)
|
||||||
oiRankingData := engine.FetchOIRankingData()
|
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)
|
// Build real context (for generating User Prompt)
|
||||||
testContext := &kernel.Context{
|
testContext := &kernel.Context{
|
||||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
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,
|
MarginUsedPct: 0,
|
||||||
PositionCount: 0,
|
PositionCount: 0,
|
||||||
},
|
},
|
||||||
Positions: []kernel.PositionInfo{},
|
Positions: []kernel.PositionInfo{},
|
||||||
CandidateCoins: candidates,
|
CandidateCoins: candidates,
|
||||||
PromptVariant: req.PromptVariant,
|
PromptVariant: req.PromptVariant,
|
||||||
MarketDataMap: marketDataMap,
|
MarketDataMap: marketDataMap,
|
||||||
QuantDataMap: quantDataMap,
|
QuantDataMap: quantDataMap,
|
||||||
OIRankingData: oiRankingData,
|
OIRankingData: oiRankingData,
|
||||||
|
NetFlowRankingData: netFlowRankingData,
|
||||||
|
PriceRankingData: priceRankingData,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build System Prompt
|
// Build System Prompt
|
||||||
|
|||||||
+7
-7
@@ -199,7 +199,7 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
|||||||
if len(cfg.Symbols) > 0 {
|
if len(cfg.Symbols) > 0 {
|
||||||
result.CoinSource.SourceType = "static"
|
result.CoinSource.SourceType = "static"
|
||||||
result.CoinSource.StaticCoins = cfg.Symbols
|
result.CoinSource.StaticCoins = cfg.Symbols
|
||||||
result.CoinSource.UseCoinPool = false
|
result.CoinSource.UseAI500 = false
|
||||||
result.CoinSource.UseOITop = false
|
result.CoinSource.UseOITop = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,12 +241,12 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
|||||||
|
|
||||||
return &store.StrategyConfig{
|
return &store.StrategyConfig{
|
||||||
CoinSource: store.CoinSourceConfig{
|
CoinSource: store.CoinSourceConfig{
|
||||||
SourceType: "static",
|
SourceType: "static",
|
||||||
StaticCoins: cfg.Symbols,
|
StaticCoins: cfg.Symbols,
|
||||||
UseCoinPool: false,
|
UseAI500: false,
|
||||||
CoinPoolLimit: len(cfg.Symbols),
|
AI500Limit: len(cfg.Symbols),
|
||||||
UseOITop: false,
|
UseOITop: false,
|
||||||
OITopLimit: 0,
|
OITopLimit: 0,
|
||||||
},
|
},
|
||||||
Indicators: store.IndicatorConfig{
|
Indicators: store.IndicatorConfig{
|
||||||
Klines: store.KlineConfig{
|
Klines: store.KlineConfig{
|
||||||
|
|||||||
+19
-1
@@ -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)
|
// Fetch quantitative data if enabled in strategy (uses current data as approximation)
|
||||||
strategyConfig := r.strategyEngine.GetConfig()
|
strategyConfig := r.strategyEngine.GetConfig()
|
||||||
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
|
if strategyConfig.Indicators.EnableQuantData {
|
||||||
// Collect symbols to query (candidate coins + position coins)
|
// Collect symbols to query (candidate coins + position coins)
|
||||||
symbolSet := make(map[string]bool)
|
symbolSet := make(map[string]bool)
|
||||||
for _, sym := range r.cfg.Symbols {
|
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{
|
record := &store.DecisionRecord{
|
||||||
AccountState: store.AccountSnapshot{
|
AccountState: store.AccountSnapshot{
|
||||||
TotalBalance: accountInfo.TotalEquity,
|
TotalBalance: accountInfo.TotalEquity,
|
||||||
|
|||||||
+14
-6
@@ -335,6 +335,12 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
|||||||
// Fetch OI ranking data (market-wide position changes)
|
// Fetch OI ranking data (market-wide position changes)
|
||||||
oiRankingData := strategyEngine.FetchOIRankingData()
|
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
|
// Build context
|
||||||
ctx := &kernel.Context{
|
ctx := &kernel.Context{
|
||||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
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,
|
MarginUsedPct: 0,
|
||||||
PositionCount: 0,
|
PositionCount: 0,
|
||||||
},
|
},
|
||||||
Positions: []kernel.PositionInfo{},
|
Positions: []kernel.PositionInfo{},
|
||||||
CandidateCoins: candidates,
|
CandidateCoins: candidates,
|
||||||
PromptVariant: session.PromptVariant,
|
PromptVariant: session.PromptVariant,
|
||||||
MarketDataMap: marketDataMap,
|
MarketDataMap: marketDataMap,
|
||||||
QuantDataMap: quantDataMap,
|
QuantDataMap: quantDataMap,
|
||||||
OIRankingData: oiRankingData,
|
OIRankingData: oiRankingData,
|
||||||
|
NetFlowRankingData: netFlowRankingData,
|
||||||
|
PriceRankingData: priceRankingData,
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx, nil
|
return ctx, nil
|
||||||
|
|||||||
@@ -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 错误。
|
||||||
@@ -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. **资金流向解读**:机构与散户的资金流向通常呈相反趋势,可作为市场情绪判断依据
|
|
||||||
@@ -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
|
|
||||||
@@ -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 个币种
|
- **用途:** 获取 AI 评分最高的 N 个币种
|
||||||
- **标签:** `["ai500"]`
|
- **标签:** `["ai500"]`
|
||||||
|
|
||||||
|
|||||||
+183
-70
@@ -8,7 +8,7 @@ import (
|
|||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/provider"
|
"nofx/provider/nofxos"
|
||||||
"nofx/security"
|
"nofx/security"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -119,8 +119,10 @@ type Context struct {
|
|||||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||||
OITopDataMap map[string]*OITopData `json:"-"`
|
OITopDataMap map[string]*OITopData `json:"-"`
|
||||||
QuantDataMap map[string]*QuantData `json:"-"`
|
QuantDataMap map[string]*QuantData `json:"-"`
|
||||||
OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data
|
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||||
BTCETHLeverage int `json:"-"`
|
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:"-"`
|
AltcoinLeverage int `json:"-"`
|
||||||
Timeframes []string `json:"-"`
|
Timeframes []string `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -189,12 +191,23 @@ type OIDeltaData struct {
|
|||||||
|
|
||||||
// StrategyEngine strategy execution engine
|
// StrategyEngine strategy execution engine
|
||||||
type StrategyEngine struct {
|
type StrategyEngine struct {
|
||||||
config *store.StrategyConfig
|
config *store.StrategyConfig
|
||||||
|
nofxosClient *nofxos.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStrategyEngine creates strategy execution engine
|
// NewStrategyEngine creates strategy execution engine
|
||||||
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
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
|
// GetRiskControlConfig gets risk control configuration
|
||||||
@@ -202,6 +215,19 @@ func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
|||||||
return e.config.RiskControl
|
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
|
// GetConfig gets complete strategy configuration
|
||||||
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
||||||
return e.config
|
return e.config
|
||||||
@@ -239,7 +265,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
|||||||
// Ensure OITopDataMap is initialized
|
// Ensure OITopDataMap is initialized
|
||||||
if ctx.OITopDataMap == nil {
|
if ctx.OITopDataMap == nil {
|
||||||
ctx.OITopDataMap = make(map[string]*OITopData)
|
ctx.OITopDataMap = make(map[string]*OITopData)
|
||||||
oiPositions, err := provider.GetOITopPositions()
|
oiPositions, err := engine.nofxosClient.GetOITopPositions()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, pos := range oiPositions {
|
for _, pos := range oiPositions {
|
||||||
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
||||||
@@ -385,13 +411,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
|
|
||||||
coinSource := e.config.CoinSource
|
coinSource := e.config.CoinSource
|
||||||
|
|
||||||
if coinSource.CoinPoolAPIURL != "" {
|
|
||||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
|
||||||
}
|
|
||||||
if coinSource.OITopAPIURL != "" {
|
|
||||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch coinSource.SourceType {
|
switch coinSource.SourceType {
|
||||||
case "static":
|
case "static":
|
||||||
for _, symbol := range coinSource.StaticCoins {
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
@@ -404,10 +423,10 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
|
|
||||||
return e.filterExcludedCoins(candidates), nil
|
return e.filterExcludedCoins(candidates), nil
|
||||||
|
|
||||||
case "coinpool":
|
case "ai500":
|
||||||
// 检查 use_coin_pool 标志,如果为 false 则回退到静态币种
|
// 检查 use_ai500 标志,如果为 false 则回退到静态币种
|
||||||
if !coinSource.UseCoinPool {
|
if !coinSource.UseAI500 {
|
||||||
logger.Infof("⚠️ source_type is 'coinpool' but use_coin_pool is false, falling back to static coins")
|
logger.Infof("⚠️ source_type is 'ai500' but use_ai500 is false, falling back to static coins")
|
||||||
for _, symbol := range coinSource.StaticCoins {
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
symbol = market.Normalize(symbol)
|
symbol = market.Normalize(symbol)
|
||||||
candidates = append(candidates, CandidateCoin{
|
candidates = append(candidates, CandidateCoin{
|
||||||
@@ -417,7 +436,7 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
}
|
}
|
||||||
return e.filterExcludedCoins(candidates), nil
|
return e.filterExcludedCoins(candidates), nil
|
||||||
}
|
}
|
||||||
coins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
coins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -443,10 +462,10 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
return e.filterExcludedCoins(coins), nil
|
return e.filterExcludedCoins(coins), nil
|
||||||
|
|
||||||
case "mixed":
|
case "mixed":
|
||||||
if coinSource.UseCoinPool {
|
if coinSource.UseAI500 {
|
||||||
poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err)
|
logger.Infof("⚠️ Failed to get AI500 coins: %v", err)
|
||||||
} else {
|
} else {
|
||||||
for _, coin := range poolCoins {
|
for _, coin := range poolCoins {
|
||||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
|
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
|
||||||
@@ -513,12 +532,12 @@ func (e *StrategyEngine) filterExcludedCoins(candidates []CandidateCoin) []Candi
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) {
|
func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
symbols, err := provider.GetTopRatedCoins(limit)
|
symbols, err := e.nofxosClient.GetTopRatedCoins(limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -538,7 +557,7 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
|||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
positions, err := provider.GetOITopPositions()
|
positions, err := e.nofxosClient.GetOITopPositions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -645,50 +664,82 @@ func extractJSONPath(data interface{}, path string) interface{} {
|
|||||||
|
|
||||||
// FetchQuantData fetches quantitative data for a single coin
|
// FetchQuantData fetches quantitative data for a single coin
|
||||||
func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := e.config.Indicators.QuantDataAPIURL
|
// Use nofxos client with unified API key
|
||||||
url := strings.Replace(apiURL, "{symbol}", symbol, -1)
|
include := "oi,price"
|
||||||
|
if e.config.Indicators.EnableQuantNetflow {
|
||||||
|
include = "netflow,oi,price"
|
||||||
|
}
|
||||||
|
|
||||||
// SSRF Protection: Validate URL before making request
|
nofxosData, err := e.nofxosClient.GetCoinData(symbol, include)
|
||||||
resp, err := security.SafeGet(url, 10*time.Second)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("failed to fetch quant data: %w", err)
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
if nofxosData == nil {
|
||||||
if err != nil {
|
return nil, nil
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp struct {
|
// Convert nofxos.QuantData to kernel.QuantData
|
||||||
Code int `json:"code"`
|
quantData := &QuantData{
|
||||||
Data *QuantData `json:"data"`
|
Symbol: nofxosData.Symbol,
|
||||||
|
Price: nofxosData.Price,
|
||||||
|
PriceChange: nofxosData.PriceChange,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
// Convert OI data
|
||||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
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 {
|
// Convert Netflow data
|
||||||
return nil, fmt.Errorf("API returned error code: %d", apiResp.Code)
|
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
|
// FetchQuantDataBatch batch fetches quantitative data
|
||||||
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
|
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
|
||||||
result := make(map[string]*QuantData)
|
result := make(map[string]*QuantData)
|
||||||
|
|
||||||
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
|
if !e.config.Indicators.EnableQuantData {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,28 +758,12 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FetchOIRankingData fetches market-wide OI ranking data
|
// FetchOIRankingData fetches market-wide OI ranking data
|
||||||
func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
||||||
indicators := e.config.Indicators
|
indicators := e.config.Indicators
|
||||||
if !indicators.EnableOIRanking {
|
if !indicators.EnableOIRanking {
|
||||||
return nil
|
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
|
duration := indicators.OIRankingDuration
|
||||||
if duration == "" {
|
if duration == "" {
|
||||||
duration = "1h"
|
duration = "1h"
|
||||||
@@ -741,7 +776,7 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
|||||||
|
|
||||||
logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit)
|
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 {
|
if err != nil {
|
||||||
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
||||||
return nil
|
return nil
|
||||||
@@ -753,6 +788,68 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
|||||||
return data
|
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
|
// Prompt Building - System Prompt
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -764,7 +861,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
|||||||
promptSections := e.config.PromptSections
|
promptSections := e.config.PromptSections
|
||||||
|
|
||||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||||
lang := detectLanguage(promptSections.RoleDefinition)
|
lang := e.GetLanguage()
|
||||||
schemaPrompt := GetSchemaPrompt(lang)
|
schemaPrompt := GetSchemaPrompt(lang)
|
||||||
sb.WriteString(schemaPrompt)
|
sb.WriteString(schemaPrompt)
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
@@ -955,7 +1052,7 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
|||||||
sb.WriteString("- Funding rate\n")
|
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")
|
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)
|
// Historical trading statistics (helps AI understand past performance)
|
||||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||||
// Detect language from strategy config
|
// Get language from strategy config
|
||||||
lang := detectLanguage(e.config.PromptSections.RoleDefinition)
|
lang := e.GetLanguage()
|
||||||
|
|
||||||
// Win/Loss ratio
|
// Win/Loss ratio
|
||||||
var winLossRatio float64
|
var winLossRatio float64
|
||||||
@@ -1116,9 +1213,25 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
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)
|
// OI Ranking data (market-wide open interest changes)
|
||||||
if ctx.OIRankingData != nil {
|
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")
|
sb.WriteString("---\n\n")
|
||||||
|
|||||||
+4
-12
@@ -3,6 +3,7 @@ package kernel
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
|
"nofx/provider/nofxos"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -89,11 +90,11 @@ func formatContextData(ctx *Context, lang Language) string {
|
|||||||
|
|
||||||
// 7. OI排名数据(如果有)
|
// 7. OI排名数据(如果有)
|
||||||
if ctx.OIRankingData != nil {
|
if ctx.OIRankingData != nil {
|
||||||
|
nofxosLang := nofxos.LangEnglish
|
||||||
if lang == LangChinese {
|
if lang == LangChinese {
|
||||||
sb.WriteString(formatOIRankingZH(ctx.OIRankingData))
|
nofxosLang = nofxos.LangChinese
|
||||||
} else {
|
|
||||||
sb.WriteString(formatOIRankingEN(ctx.OIRankingData))
|
|
||||||
}
|
}
|
||||||
|
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -354,11 +355,6 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatOIRankingZH 格式化OI排名数据(中文)
|
|
||||||
func formatOIRankingZH(oiData interface{}) string {
|
|
||||||
// TODO: 根据实际OIRankingData结构实现
|
|
||||||
return "## 市场持仓量排名\n\n(数据加载中...)\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOIInterpretationZH 获取OI变化解读(中文)
|
// getOIInterpretationZH 获取OI变化解读(中文)
|
||||||
func getOIInterpretationZH(oiChange, priceChange string) string {
|
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||||
@@ -624,10 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatOIRankingEN 格式化OI排名数据(英文)
|
|
||||||
func formatOIRankingEN(oiData interface{}) string {
|
|
||||||
return "## Market-wide OI Ranking\n\n(Loading data...)\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOIInterpretationEN 获取OI变化解读(英文)
|
// getOIInterpretationEN 获取OI变化解读(英文)
|
||||||
func getOIInterpretationEN(oiChange, priceChange string) string {
|
func getOIInterpretationEN(oiChange, priceChange string) string {
|
||||||
|
|||||||
@@ -606,7 +606,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
|||||||
continue
|
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)
|
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err)
|
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)
|
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{
|
traderConfig := trader.AutoTraderConfig{
|
||||||
ID: traderCfg.ID,
|
ID: traderCfg.ID,
|
||||||
Name: traderCfg.Name,
|
Name: traderCfg.Name,
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -32,6 +32,9 @@ func (Strategy) TableName() string { return "strategies" }
|
|||||||
|
|
||||||
// StrategyConfig strategy configuration details (JSON structure)
|
// StrategyConfig strategy configuration details (JSON structure)
|
||||||
type StrategyConfig struct {
|
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
|
// coin source configuration
|
||||||
CoinSource CoinSourceConfig `json:"coin_source"`
|
CoinSource CoinSourceConfig `json:"coin_source"`
|
||||||
// quantitative data configuration
|
// quantitative data configuration
|
||||||
@@ -58,24 +61,21 @@ type PromptSectionsConfig struct {
|
|||||||
|
|
||||||
// CoinSourceConfig coin source configuration
|
// CoinSourceConfig coin source configuration
|
||||||
type CoinSourceConfig struct {
|
type CoinSourceConfig struct {
|
||||||
// source type: "static" | "coinpool" | "oi_top" | "mixed"
|
// source type: "static" | "ai500" | "oi_top" | "mixed"
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
// static coin list (used when source_type = "static")
|
// static coin list (used when source_type = "static")
|
||||||
StaticCoins []string `json:"static_coins,omitempty"`
|
StaticCoins []string `json:"static_coins,omitempty"`
|
||||||
// excluded coins list (filtered out from all sources)
|
// excluded coins list (filtered out from all sources)
|
||||||
ExcludedCoins []string `json:"excluded_coins,omitempty"`
|
ExcludedCoins []string `json:"excluded_coins,omitempty"`
|
||||||
// whether to use AI500 coin pool
|
// whether to use AI500 coin pool
|
||||||
UseCoinPool bool `json:"use_coin_pool"`
|
UseAI500 bool `json:"use_ai500"`
|
||||||
// AI500 coin pool maximum count
|
// AI500 coin pool maximum count
|
||||||
CoinPoolLimit int `json:"coin_pool_limit,omitempty"`
|
AI500Limit int `json:"ai500_limit,omitempty"`
|
||||||
// AI500 coin pool API URL (strategy-level configuration)
|
|
||||||
CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"`
|
|
||||||
// whether to use OI Top
|
// whether to use OI Top
|
||||||
UseOITop bool `json:"use_oi_top"`
|
UseOITop bool `json:"use_oi_top"`
|
||||||
// OI Top maximum count
|
// OI Top maximum count
|
||||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||||||
// OI Top API URL (strategy-level configuration)
|
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||||
OITopAPIURL string `json:"oi_top_api_url,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IndicatorConfig indicator configuration
|
// IndicatorConfig indicator configuration
|
||||||
@@ -103,16 +103,30 @@ type IndicatorConfig struct {
|
|||||||
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
||||||
// external data sources
|
// external data sources
|
||||||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
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)
|
// quantitative data sources (capital flow, position changes, price changes)
|
||||||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
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
|
||||||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
|
||||||
// OI ranking data (market-wide open interest increase/decrease rankings)
|
// OI ranking data (market-wide open interest increase/decrease rankings)
|
||||||
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data
|
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
|
OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||||||
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10)
|
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
|
// KlineConfig K-line configuration
|
||||||
@@ -185,15 +199,20 @@ func (s *StrategyStore) initDefaultData() error {
|
|||||||
|
|
||||||
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
||||||
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||||
|
// Normalize language to "zh" or "en"
|
||||||
|
normalizedLang := "en"
|
||||||
|
if lang == "zh" {
|
||||||
|
normalizedLang = "zh"
|
||||||
|
}
|
||||||
|
|
||||||
config := StrategyConfig{
|
config := StrategyConfig{
|
||||||
|
Language: normalizedLang,
|
||||||
CoinSource: CoinSourceConfig{
|
CoinSource: CoinSourceConfig{
|
||||||
SourceType: "coinpool",
|
SourceType: "ai500",
|
||||||
UseCoinPool: true,
|
UseAI500: true,
|
||||||
CoinPoolLimit: 10,
|
AI500Limit: 10,
|
||||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
UseOITop: false,
|
||||||
UseOITop: false,
|
OITopLimit: 20,
|
||||||
OITopLimit: 20,
|
|
||||||
OITopAPIURL: "http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c",
|
|
||||||
},
|
},
|
||||||
Indicators: IndicatorConfig{
|
Indicators: IndicatorConfig{
|
||||||
Klines: KlineConfig{
|
Klines: KlineConfig{
|
||||||
@@ -217,15 +236,24 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
RSIPeriods: []int{7, 14},
|
RSIPeriods: []int{7, 14},
|
||||||
ATRPeriods: []int{14},
|
ATRPeriods: []int{14},
|
||||||
BOLLPeriods: []int{20},
|
BOLLPeriods: []int{20},
|
||||||
|
// NofxOS unified API key
|
||||||
|
NofxOSAPIKey: "cm_568c67eae410d912c54c",
|
||||||
|
// Quant data
|
||||||
EnableQuantData: true,
|
EnableQuantData: true,
|
||||||
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
|
||||||
EnableQuantOI: true,
|
EnableQuantOI: true,
|
||||||
EnableQuantNetflow: true,
|
EnableQuantNetflow: true,
|
||||||
// OI ranking data - market-wide OI increase/decrease rankings
|
// OI ranking data
|
||||||
EnableOIRanking: true,
|
EnableOIRanking: true,
|
||||||
OIRankingAPIURL: "http://nofxaios.com:30006",
|
|
||||||
OIRankingDuration: "1h",
|
OIRankingDuration: "1h",
|
||||||
OIRankingLimit: 10,
|
OIRankingLimit: 10,
|
||||||
|
// NetFlow ranking data
|
||||||
|
EnableNetFlowRanking: true,
|
||||||
|
NetFlowRankingDuration: "1h",
|
||||||
|
NetFlowRankingLimit: 10,
|
||||||
|
// Price ranking data
|
||||||
|
EnablePriceRanking: true,
|
||||||
|
PriceRankingDuration: "1h,4h,24h",
|
||||||
|
PriceRankingLimit: 10,
|
||||||
},
|
},
|
||||||
RiskControl: RiskControlConfig{
|
RiskControl: RiskControlConfig{
|
||||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ type Trader struct {
|
|||||||
BTCETHLeverage int `gorm:"column:btc_eth_leverage;default:5" json:"btc_eth_leverage,omitempty"`
|
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"`
|
AltcoinLeverage int `gorm:"column:altcoin_leverage;default:5" json:"altcoin_leverage,omitempty"`
|
||||||
TradingSymbols string `gorm:"column:trading_symbols;default:''" json:"trading_symbols,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"`
|
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"`
|
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"`
|
OverrideBasePrompt bool `gorm:"column:override_base_prompt;default:false" json:"override_base_prompt,omitempty"`
|
||||||
|
|||||||
+21
-1
@@ -899,7 +899,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Get quantitative data (if enabled in strategy config)
|
// 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)
|
// Collect symbols to query (candidate coins + position coins)
|
||||||
symbolsToQuery := make(map[string]bool)
|
symbolsToQuery := make(map[string]bool)
|
||||||
for _, coin := range candidateCoins {
|
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
|
return ctx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -832,17 +832,17 @@ export function BacktestPage() {
|
|||||||
if (!coinSource) return false
|
if (!coinSource) return false
|
||||||
|
|
||||||
// Check explicit source_type
|
// 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
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check flags for backward compatibility (when source_type is empty or not set)
|
// Also check flags for backward compatibility (when source_type is empty or not set)
|
||||||
const srcType = coinSource.source_type as string
|
const srcType = coinSource.source_type as string
|
||||||
if (!srcType) {
|
if (!srcType) {
|
||||||
if (coinSource.use_coin_pool || coinSource.use_oi_top) {
|
if (coinSource.use_ai500 || coinSource.use_oi_top) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -858,10 +858,10 @@ export function BacktestPage() {
|
|||||||
// Infer source_type from flags if empty (backward compatibility)
|
// Infer source_type from flags if empty (backward compatibility)
|
||||||
let sourceType = cs.source_type as string
|
let sourceType = cs.source_type as string
|
||||||
if (!sourceType) {
|
if (!sourceType) {
|
||||||
if (cs.use_coin_pool && cs.use_oi_top) {
|
if (cs.use_ai500 && cs.use_oi_top) {
|
||||||
sourceType = 'mixed'
|
sourceType = 'mixed'
|
||||||
} else if (cs.use_coin_pool) {
|
} else if (cs.use_ai500) {
|
||||||
sourceType = 'coinpool'
|
sourceType = 'ai500'
|
||||||
} else if (cs.use_oi_top) {
|
} else if (cs.use_oi_top) {
|
||||||
sourceType = 'oi_top'
|
sourceType = 'oi_top'
|
||||||
} else if (cs.static_coins?.length) {
|
} else if (cs.static_coins?.length) {
|
||||||
@@ -870,13 +870,13 @@ export function BacktestPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (sourceType) {
|
switch (sourceType) {
|
||||||
case 'coinpool':
|
case 'ai500':
|
||||||
return { type: 'AI500', limit: cs.coin_pool_limit || 30 }
|
return { type: 'AI500', limit: cs.ai500_limit || 30 }
|
||||||
case 'oi_top':
|
case 'oi_top':
|
||||||
return { type: 'OI Top', limit: cs.oi_top_limit || 30 }
|
return { type: 'OI Top', limit: cs.oi_top_limit || 30 }
|
||||||
case 'mixed':
|
case 'mixed':
|
||||||
const sources = []
|
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.use_oi_top) sources.push(`OI Top(${cs.oi_top_limit || 30})`)
|
||||||
if (cs.static_coins?.length) sources.push(`Static(${cs.static_coins.length})`)
|
if (cs.static_coins?.length) sources.push(`Static(${cs.static_coins.length})`)
|
||||||
return { type: 'Mixed', desc: sources.join(' + ') }
|
return { type: 'Mixed', desc: sources.join(' + ') }
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export function TraderConfigModal({
|
|||||||
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
|
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
|
||||||
<div>
|
<div>
|
||||||
币种来源: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
|
币种来源: {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' : '混合'}
|
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useState } from 'react'
|
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'
|
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 {
|
interface CoinSourceEditorProps {
|
||||||
config: CoinSourceConfig
|
config: CoinSourceConfig
|
||||||
onChange: (config: CoinSourceConfig) => void
|
onChange: (config: CoinSourceConfig) => void
|
||||||
@@ -26,21 +22,17 @@ export function CoinSourceEditor({
|
|||||||
const translations: Record<string, Record<string, string>> = {
|
const translations: Record<string, Record<string, string>> = {
|
||||||
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
||||||
static: { zh: '静态列表', en: 'Static List' },
|
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' },
|
oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
|
||||||
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
||||||
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
||||||
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
||||||
useCoinPool: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
||||||
coinPoolLimit: { zh: '数据源数量上限', en: 'Data Provider Limit' },
|
ai500Limit: { zh: '数量上限', en: 'Limit' },
|
||||||
coinPoolApiUrl: { zh: 'AI500 API URL', en: 'AI500 API URL' },
|
|
||||||
coinPoolApiUrlPlaceholder: { zh: '输入 AI500 数据源 API 地址...', en: 'Enter AI500 data provider API URL...' },
|
|
||||||
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
||||||
oiTopLimit: { zh: 'OI Top 数量上限', en: 'OI Top Limit' },
|
oiTopLimit: { zh: '数量上限', en: 'Limit' },
|
||||||
oiTopApiUrl: { zh: 'OI Top API URL', en: 'OI Top API URL' },
|
|
||||||
oiTopApiUrlPlaceholder: { zh: '输入 OI Top 持仓数据 API 地址...', en: 'Enter OI Top API URL...' },
|
|
||||||
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
||||||
coinpoolDesc: {
|
ai500Desc: {
|
||||||
zh: '使用 AI500 智能筛选的热门币种',
|
zh: '使用 AI500 智能筛选的热门币种',
|
||||||
en: 'Use AI500 smart-filtered popular coins',
|
en: 'Use AI500 smart-filtered popular coins',
|
||||||
},
|
},
|
||||||
@@ -52,19 +44,18 @@ export function CoinSourceEditor({
|
|||||||
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
||||||
en: 'Combine multiple sources: AI500 + OI Top + Custom',
|
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' },
|
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
||||||
fillDefault: { zh: '填入默认', en: 'Fill Default' },
|
|
||||||
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
|
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
|
||||||
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' },
|
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' },
|
||||||
addExcludedCoin: { zh: '添加排除', en: 'Add Excluded' },
|
addExcludedCoin: { zh: '添加排除', en: 'Add Excluded' },
|
||||||
|
nofxosNote: { zh: '使用 NofxOS API Key(在指标配置中设置)', en: 'Uses NofxOS API Key (set in Indicators config)' },
|
||||||
}
|
}
|
||||||
return translations[key]?.[language] || key
|
return translations[key]?.[language] || key
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTypes = [
|
const sourceTypes = [
|
||||||
{ value: 'static', icon: List, color: '#848E9C' },
|
{ 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: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||||
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||||
] as const
|
] 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Source Type Selector */}
|
{/* Source Type Selector */}
|
||||||
@@ -305,198 +310,137 @@ export function CoinSourceEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coin Pool Options */}
|
{/* AI500 Options */}
|
||||||
{(config.source_type === 'coinpool' || config.source_type === 'mixed') && (
|
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
|
||||||
<div className="space-y-4">
|
<div
|
||||||
<div className="flex items-center gap-2 mb-2">
|
className="p-4 rounded-lg"
|
||||||
<Link className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
style={{
|
||||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
background: 'rgba(240, 185, 11, 0.05)',
|
||||||
{t('dataSourceConfig')} - AI500
|
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||||
</span>
|
}}
|
||||||
</div>
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Zap className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||||
<input
|
AI500 {t('dataSourceConfig')}
|
||||||
type="checkbox"
|
</span>
|
||||||
checked={config.use_coin_pool}
|
<NofxOSBadge />
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.use_coin_pool && (
|
<div className="space-y-3">
|
||||||
<div>
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<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>
|
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="checkbox"
|
||||||
value={config.coin_pool_api_url || ''}
|
checked={config.use_ai500}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
!disabled && onChange({ ...config, coin_pool_api_url: e.target.value })
|
!disabled && onChange({ ...config, use_ai500: e.target.checked })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={t('coinPoolApiUrlPlaceholder')}
|
className="w-5 h-5 rounded accent-yellow-500"
|
||||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
|
||||||
style={{
|
|
||||||
background: '#0B0E11',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{!config.coin_pool_api_url && (
|
<span style={{ color: '#EAECEF' }}>{t('useAI500')}</span>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
</label>
|
||||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
|
||||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
{config.use_ai500 && (
|
||||||
{t('apiUrlRequired')}
|
<div className="flex items-center gap-3 pl-8">
|
||||||
</span>
|
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
</div>
|
{t('ai500Limit')}:
|
||||||
)}
|
</span>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OI Top Options */}
|
{/* OI Top Options */}
|
||||||
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
||||||
<div className="space-y-4">
|
<div
|
||||||
<div className="flex items-center gap-2 mb-2">
|
className="p-4 rounded-lg"
|
||||||
<Link className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
style={{
|
||||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
background: 'rgba(14, 203, 129, 0.05)',
|
||||||
{t('dataSourceConfig')} - OI Top
|
border: '1px solid rgba(14, 203, 129, 0.2)',
|
||||||
</span>
|
}}
|
||||||
</div>
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<TrendingUp className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||||
<input
|
OI Top {t('dataSourceConfig')}
|
||||||
type="checkbox"
|
</span>
|
||||||
checked={config.use_oi_top}
|
<NofxOSBadge />
|
||||||
onChange={(e) =>
|
|
||||||
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-5 h-5 rounded accent-yellow-500"
|
|
||||||
/>
|
|
||||||
<span style={{ color: '#EAECEF' }}>{t('useOITop')}</span>
|
|
||||||
</label>
|
|
||||||
{config.use_oi_top && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
|
||||||
{t('oiTopLimit')}:
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.oi_top_limit || 20}
|
|
||||||
onChange={(e) =>
|
|
||||||
!disabled &&
|
|
||||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
min={1}
|
|
||||||
max={50}
|
|
||||||
className="w-20 px-3 py-1.5 rounded"
|
|
||||||
style={{
|
|
||||||
background: '#0B0E11',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.use_oi_top && (
|
<div className="space-y-3">
|
||||||
<div>
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<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>
|
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="checkbox"
|
||||||
value={config.oi_top_api_url || ''}
|
checked={config.use_oi_top}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
!disabled && onChange({ ...config, oi_top_api_url: e.target.value })
|
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={t('oiTopApiUrlPlaceholder')}
|
className="w-5 h-5 rounded accent-green-500"
|
||||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
|
||||||
style={{
|
|
||||||
background: '#0B0E11',
|
|
||||||
border: '1px solid #2B3139',
|
|
||||||
color: '#EAECEF',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{!config.oi_top_api_url && (
|
<span style={{ color: '#EAECEF' }}>{t('useOITop')}</span>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
</label>
|
||||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
|
||||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
{config.use_oi_top && (
|
||||||
{t('apiUrlRequired')}
|
<div className="flex items-center gap-3 pl-8">
|
||||||
</span>
|
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
</div>
|
{t('oiTopLimit')}:
|
||||||
)}
|
</span>
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
import type { IndicatorConfig } from '../../types'
|
||||||
|
|
||||||
// Default API URL for quant data (must contain {symbol} placeholder)
|
// Default NofxOS API Key
|
||||||
const DEFAULT_QUANT_DATA_API_URL = 'http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c'
|
const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c'
|
||||||
// Default API base URL for OI ranking data
|
|
||||||
const DEFAULT_OI_RANKING_API_URL = 'http://nofxaios.com:30006'
|
|
||||||
|
|
||||||
interface IndicatorEditorProps {
|
interface IndicatorEditorProps {
|
||||||
config: IndicatorConfig
|
config: IndicatorConfig
|
||||||
@@ -47,7 +45,7 @@ export function IndicatorEditor({
|
|||||||
marketSentiment: { zh: '市场情绪', en: 'Market Sentiment' },
|
marketSentiment: { zh: '市场情绪', en: 'Market Sentiment' },
|
||||||
marketSentimentDesc: { zh: '持仓量、资金费率等市场情绪数据', en: 'OI, funding rate and market sentiment data' },
|
marketSentimentDesc: { zh: '持仓量、资金费率等市场情绪数据', en: 'OI, funding rate and market sentiment data' },
|
||||||
quantData: { zh: '量化数据', en: 'Quant Data' },
|
quantData: { zh: '量化数据', en: 'Quant Data' },
|
||||||
quantDataDesc: { zh: '第三方数据源:资金流向、大户动向', en: 'Third-party: netflow, whale movements' },
|
quantDataDesc: { zh: '资金流向、大户动向', en: 'Netflow, whale movements' },
|
||||||
|
|
||||||
// Timeframes
|
// Timeframes
|
||||||
timeframes: { zh: '时间周期', en: 'Timeframes' },
|
timeframes: { zh: '时间周期', en: 'Timeframes' },
|
||||||
@@ -81,20 +79,40 @@ export function IndicatorEditor({
|
|||||||
fundingRate: { zh: '资金费率', en: 'Funding Rate' },
|
fundingRate: { zh: '资金费率', en: 'Funding Rate' },
|
||||||
fundingRateDesc: { zh: '永续合约资金费率', en: 'Perpetual 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
|
// OI Ranking
|
||||||
oiRanking: { zh: 'OI 排行数据', en: 'OI Ranking Data' },
|
oiRanking: { zh: 'OI 排行', en: 'OI Ranking' },
|
||||||
oiRankingDesc: { zh: '市场持仓量增减排行,反映资金流向', en: 'Market-wide OI changes, reflects capital flow' },
|
oiRankingDesc: { zh: '持仓量增减排行', en: 'OI change ranking' },
|
||||||
oiRankingDuration: { zh: '时间周期', en: 'Duration' },
|
|
||||||
oiRankingLimit: { zh: '排行数量', en: 'Top N' },
|
|
||||||
oiRankingNote: { zh: '显示持仓量增加/减少的币种排行,帮助发现资金流向', en: 'Shows coins with OI increase/decrease, helps identify capital flow' },
|
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
|
// Tips
|
||||||
aiCanCalculate: { zh: '💡 提示:AI 可自行计算这些指标,开启可减少 AI 计算量', en: '💡 Tip: AI can calculate these, enabling reduces AI workload' },
|
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
|
return translations[key]?.[language] || key
|
||||||
}
|
}
|
||||||
@@ -166,9 +184,364 @@ export function IndicatorEditor({
|
|||||||
ensureRawKlines()
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<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="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' }}>
|
<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' }} />
|
<BarChart2 className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||||
@@ -275,7 +648,9 @@ export function IndicatorEditor({
|
|||||||
</div>
|
</div>
|
||||||
</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="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' }}>
|
<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' }} />
|
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||||
@@ -345,7 +720,9 @@ export function IndicatorEditor({
|
|||||||
</div>
|
</div>
|
||||||
</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="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' }}>
|
<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' }} />
|
<TrendingUp className="w-4 h-4" style={{ color: '#22c55e' }} />
|
||||||
@@ -387,163 +764,6 @@ export function IndicatorEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ export const translations = {
|
|||||||
noExchangesConfigured: 'No configured exchanges',
|
noExchangesConfigured: 'No configured exchanges',
|
||||||
signalSource: 'Signal Source',
|
signalSource: 'Signal Source',
|
||||||
signalSourceConfig: 'Signal Source Configuration',
|
signalSourceConfig: 'Signal Source Configuration',
|
||||||
coinPoolDescription:
|
ai500Description:
|
||||||
'API endpoint for AI500 data provider, leave blank to disable this signal source',
|
'API endpoint for AI500 data provider, leave blank to disable this signal source',
|
||||||
oiTopDescription:
|
oiTopDescription:
|
||||||
'API endpoint for open interest rankings, leave blank to disable this signal source',
|
'API endpoint for open interest rankings, leave blank to disable this signal source',
|
||||||
@@ -784,7 +784,7 @@ export const translations = {
|
|||||||
candidateCoins: 'Candidate Coins',
|
candidateCoins: 'Candidate Coins',
|
||||||
candidateCoinsZeroWarning: 'Candidate Coins Count is 0',
|
candidateCoinsZeroWarning: 'Candidate Coins Count is 0',
|
||||||
possibleReasons: 'Possible Reasons:',
|
possibleReasons: 'Possible Reasons:',
|
||||||
coinPoolApiNotConfigured:
|
ai500ApiNotConfigured:
|
||||||
'AI500 data provider API not configured or inaccessible (check signal source settings)',
|
'AI500 data provider API not configured or inaccessible (check signal source settings)',
|
||||||
apiConnectionTimeout: 'API connection timeout or returned empty data',
|
apiConnectionTimeout: 'API connection timeout or returned empty data',
|
||||||
noCustomCoinsAndApiFailed:
|
noCustomCoinsAndApiFailed:
|
||||||
@@ -792,7 +792,7 @@ export const translations = {
|
|||||||
solutions: 'Solutions:',
|
solutions: 'Solutions:',
|
||||||
setCustomCoinsInConfig: 'Set custom coin list in trader configuration',
|
setCustomCoinsInConfig: 'Set custom coin list in trader configuration',
|
||||||
orConfigureCorrectApiUrl: 'Or configure correct data provider API address',
|
orConfigureCorrectApiUrl: 'Or configure correct data provider API address',
|
||||||
orDisableCoinPoolOptions:
|
orDisableAI500Options:
|
||||||
'Or disable "Use AI500 Data Provider" and "Use OI Top" options',
|
'Or disable "Use AI500 Data Provider" and "Use OI Top" options',
|
||||||
signalSourceNotConfigured: 'Signal Source Not Configured',
|
signalSourceNotConfigured: 'Signal Source Not Configured',
|
||||||
signalSourceWarningMessage:
|
signalSourceWarningMessage:
|
||||||
@@ -1691,7 +1691,7 @@ export const translations = {
|
|||||||
noExchangesConfigured: '暂无已配置的交易所',
|
noExchangesConfigured: '暂无已配置的交易所',
|
||||||
signalSource: '信号源',
|
signalSource: '信号源',
|
||||||
signalSourceConfig: '信号源配置',
|
signalSourceConfig: '信号源配置',
|
||||||
coinPoolDescription:
|
ai500Description:
|
||||||
'用于获取 AI500 数据源的 API 地址,留空则不使用此数据源',
|
'用于获取 AI500 数据源的 API 地址,留空则不使用此数据源',
|
||||||
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
|
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
|
||||||
information: '说明',
|
information: '说明',
|
||||||
@@ -1939,14 +1939,14 @@ export const translations = {
|
|||||||
candidateCoins: '候选币种',
|
candidateCoins: '候选币种',
|
||||||
candidateCoinsZeroWarning: '候选币种数量为 0',
|
candidateCoinsZeroWarning: '候选币种数量为 0',
|
||||||
possibleReasons: '可能原因:',
|
possibleReasons: '可能原因:',
|
||||||
coinPoolApiNotConfigured:
|
ai500ApiNotConfigured:
|
||||||
'AI500 数据源 API 未配置或无法访问(请检查信号源设置)',
|
'AI500 数据源 API 未配置或无法访问(请检查信号源设置)',
|
||||||
apiConnectionTimeout: 'API连接超时或返回数据为空',
|
apiConnectionTimeout: 'API连接超时或返回数据为空',
|
||||||
noCustomCoinsAndApiFailed: '未配置自定义币种且API获取失败',
|
noCustomCoinsAndApiFailed: '未配置自定义币种且API获取失败',
|
||||||
solutions: '解决方案:',
|
solutions: '解决方案:',
|
||||||
setCustomCoinsInConfig: '在交易员配置中设置自定义币种列表',
|
setCustomCoinsInConfig: '在交易员配置中设置自定义币种列表',
|
||||||
orConfigureCorrectApiUrl: '或者配置正确的数据源 API 地址',
|
orConfigureCorrectApiUrl: '或者配置正确的数据源 API 地址',
|
||||||
orDisableCoinPoolOptions: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项',
|
orDisableAI500Options: '或者禁用"使用 AI500 数据源"和"使用 OI Top"选项',
|
||||||
signalSourceNotConfigured: '信号源未配置',
|
signalSourceNotConfigured: '信号源未配置',
|
||||||
signalSourceWarningMessage:
|
signalSourceWarningMessage:
|
||||||
'您有交易员启用了"使用 AI500 数据源"或"使用 OI Top",但尚未配置信号源 API 地址。这将导致候选币种数量为 0,交易员无法正常工作。',
|
'您有交易员启用了"使用 AI500 数据源"或"使用 OI Top",但尚未配置信号源 API 地址。这将导致候选币种数量为 0,交易员无法正常工作。',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import {
|
import {
|
||||||
@@ -150,6 +150,45 @@ export function StrategyStudioPage() {
|
|||||||
fetchAiModels()
|
fetchAiModels()
|
||||||
}, [fetchStrategies, 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
|
// Create new strategy
|
||||||
const handleCreateStrategy = async () => {
|
const handleCreateStrategy = async () => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -336,6 +375,11 @@ export function StrategyStudioPage() {
|
|||||||
if (!token || !selectedStrategy || !editingConfig) return
|
if (!token || !selectedStrategy || !editingConfig) return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
|
// Always sync the config language with the current interface language
|
||||||
|
const configWithLanguage = {
|
||||||
|
...editingConfig,
|
||||||
|
language: language as 'zh' | 'en',
|
||||||
|
}
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${API_BASE}/api/strategies/${selectedStrategy.id}`,
|
`${API_BASE}/api/strategies/${selectedStrategy.id}`,
|
||||||
{
|
{
|
||||||
@@ -347,7 +391,7 @@ export function StrategyStudioPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: selectedStrategy.name,
|
name: selectedStrategy.name,
|
||||||
description: selectedStrategy.description,
|
description: selectedStrategy.description,
|
||||||
config: editingConfig,
|
config: configWithLanguage,
|
||||||
is_public: selectedStrategy.is_public,
|
is_public: selectedStrategy.is_public,
|
||||||
config_visible: selectedStrategy.config_visible,
|
config_visible: selectedStrategy.config_visible,
|
||||||
}),
|
}),
|
||||||
|
|||||||
+26
-10
@@ -99,7 +99,7 @@ export interface TraderInfo {
|
|||||||
strategy_id?: string
|
strategy_id?: string
|
||||||
strategy_name?: string
|
strategy_name?: string
|
||||||
custom_prompt?: string
|
custom_prompt?: string
|
||||||
use_coin_pool?: boolean
|
use_ai500?: boolean
|
||||||
use_oi_top?: boolean
|
use_oi_top?: boolean
|
||||||
system_prompt_template?: string
|
system_prompt_template?: string
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ export interface CreateTraderRequest {
|
|||||||
custom_prompt?: string
|
custom_prompt?: string
|
||||||
override_base_prompt?: boolean
|
override_base_prompt?: boolean
|
||||||
system_prompt_template?: string
|
system_prompt_template?: string
|
||||||
use_coin_pool?: boolean
|
use_ai500?: boolean
|
||||||
use_oi_top?: boolean
|
use_oi_top?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ export interface TraderConfigData {
|
|||||||
custom_prompt?: string
|
custom_prompt?: string
|
||||||
override_base_prompt?: boolean
|
override_base_prompt?: boolean
|
||||||
system_prompt_template?: string
|
system_prompt_template?: string
|
||||||
use_coin_pool?: boolean
|
use_ai500?: boolean
|
||||||
use_oi_top?: boolean
|
use_oi_top?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +462,9 @@ export interface PromptSectionsConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StrategyConfig {
|
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;
|
coin_source: CoinSourceConfig;
|
||||||
indicators: IndicatorConfig;
|
indicators: IndicatorConfig;
|
||||||
custom_prompt?: string;
|
custom_prompt?: string;
|
||||||
@@ -470,15 +473,14 @@ export interface StrategyConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CoinSourceConfig {
|
export interface CoinSourceConfig {
|
||||||
source_type: 'static' | 'coinpool' | 'oi_top' | 'mixed';
|
source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
|
||||||
static_coins?: string[];
|
static_coins?: string[];
|
||||||
excluded_coins?: string[]; // 排除的币种列表
|
excluded_coins?: string[]; // 排除的币种列表
|
||||||
use_coin_pool: boolean;
|
use_ai500: boolean;
|
||||||
coin_pool_limit?: number;
|
ai500_limit?: number;
|
||||||
coin_pool_api_url?: string; // AI500 币种池 API URL
|
|
||||||
use_oi_top: boolean;
|
use_oi_top: boolean;
|
||||||
oi_top_limit?: number;
|
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 {
|
export interface IndicatorConfig {
|
||||||
@@ -499,16 +501,30 @@ export interface IndicatorConfig {
|
|||||||
atr_periods?: number[];
|
atr_periods?: number[];
|
||||||
boll_periods?: number[];
|
boll_periods?: number[];
|
||||||
external_data_sources?: ExternalDataSource[];
|
external_data_sources?: ExternalDataSource[];
|
||||||
|
|
||||||
|
// ========== NofxOS 数据源统一配置 ==========
|
||||||
|
// Unified NofxOS API Key - used for all NofxOS data sources
|
||||||
|
nofxos_api_key?: string;
|
||||||
|
|
||||||
// 量化数据源(资金流向、持仓变化、价格变化)
|
// 量化数据源(资金流向、持仓变化、价格变化)
|
||||||
enable_quant_data?: boolean;
|
enable_quant_data?: boolean;
|
||||||
quant_data_api_url?: string;
|
|
||||||
enable_quant_oi?: boolean;
|
enable_quant_oi?: boolean;
|
||||||
enable_quant_netflow?: boolean;
|
enable_quant_netflow?: boolean;
|
||||||
|
|
||||||
// OI 排行数据(市场持仓量增减排行)
|
// OI 排行数据(市场持仓量增减排行)
|
||||||
enable_oi_ranking?: boolean;
|
enable_oi_ranking?: boolean;
|
||||||
oi_ranking_api_url?: string;
|
|
||||||
oi_ranking_duration?: string; // "1h", "4h", "24h"
|
oi_ranking_duration?: string; // "1h", "4h", "24h"
|
||||||
oi_ranking_limit?: number;
|
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 {
|
export interface KlineConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user