mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
853 lines
26 KiB
Go
853 lines
26 KiB
Go
package kernel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"nofx/logger"
|
|
"nofx/market"
|
|
"nofx/provider/hyperliquid"
|
|
"nofx/provider/nofxos"
|
|
"nofx/security"
|
|
"nofx/store"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Type Definitions
|
|
// ============================================================================
|
|
|
|
// PositionInfo position information
|
|
type PositionInfo struct {
|
|
Symbol string `json:"symbol"`
|
|
Side string `json:"side"` // "long" or "short"
|
|
EntryPrice float64 `json:"entry_price"`
|
|
MarkPrice float64 `json:"mark_price"`
|
|
Quantity float64 `json:"quantity"`
|
|
Leverage int `json:"leverage"`
|
|
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
|
UnrealizedPnLPct float64 `json:"unrealized_pnl_pct"`
|
|
PeakPnLPct float64 `json:"peak_pnl_pct"` // Historical peak profit percentage
|
|
LiquidationPrice float64 `json:"liquidation_price"`
|
|
MarginUsed float64 `json:"margin_used"`
|
|
UpdateTime int64 `json:"update_time"` // Position update timestamp (milliseconds)
|
|
}
|
|
|
|
// AccountInfo account information
|
|
type AccountInfo struct {
|
|
TotalEquity float64 `json:"total_equity"` // Account equity
|
|
AvailableBalance float64 `json:"available_balance"` // Available balance
|
|
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized profit/loss
|
|
TotalPnL float64 `json:"total_pnl"` // Total profit/loss
|
|
TotalPnLPct float64 `json:"total_pnl_pct"` // Total profit/loss percentage
|
|
MarginUsed float64 `json:"margin_used"` // Used margin
|
|
MarginUsedPct float64 `json:"margin_used_pct"` // Margin usage rate
|
|
PositionCount int `json:"position_count"` // Number of positions
|
|
}
|
|
|
|
// CandidateCoin candidate coin (from coin pool)
|
|
type CandidateCoin struct {
|
|
Symbol string `json:"symbol"`
|
|
Sources []string `json:"sources"` // Sources: "ai500" and/or "oi_top"
|
|
}
|
|
|
|
// OITopData open interest growth top data (for AI decision reference)
|
|
type OITopData struct {
|
|
Rank int // OI Top ranking
|
|
OIDeltaPercent float64 // Open interest change percentage (1 hour)
|
|
OIDeltaValue float64 // Open interest change value
|
|
PriceDeltaPercent float64 // Price change percentage
|
|
}
|
|
|
|
// TradingStats trading statistics (for AI input)
|
|
type TradingStats struct {
|
|
TotalTrades int `json:"total_trades"` // Total number of trades (closed)
|
|
WinRate float64 `json:"win_rate"` // Win rate (%)
|
|
ProfitFactor float64 `json:"profit_factor"` // Profit factor
|
|
SharpeRatio float64 `json:"sharpe_ratio"` // Sharpe ratio
|
|
TotalPnL float64 `json:"total_pnl"` // Total profit/loss
|
|
AvgWin float64 `json:"avg_win"` // Average win
|
|
AvgLoss float64 `json:"avg_loss"` // Average loss
|
|
MaxDrawdownPct float64 `json:"max_drawdown_pct"` // Maximum drawdown (%)
|
|
}
|
|
|
|
// RecentOrder recently completed order (for AI input)
|
|
type RecentOrder struct {
|
|
Symbol string `json:"symbol"` // Trading pair
|
|
Side string `json:"side"` // long/short
|
|
EntryPrice float64 `json:"entry_price"` // Entry price
|
|
ExitPrice float64 `json:"exit_price"` // Exit price
|
|
RealizedPnL float64 `json:"realized_pnl"` // Realized profit/loss
|
|
PnLPct float64 `json:"pnl_pct"` // Profit/loss percentage
|
|
EntryTime string `json:"entry_time"` // Entry time
|
|
ExitTime string `json:"exit_time"` // Exit time
|
|
HoldDuration string `json:"hold_duration"` // Hold duration, e.g. "2h30m"
|
|
}
|
|
|
|
// Context trading context (complete information passed to AI)
|
|
type Context struct {
|
|
CurrentTime string `json:"current_time"`
|
|
RuntimeMinutes int `json:"runtime_minutes"`
|
|
CallCount int `json:"call_count"`
|
|
Account AccountInfo `json:"account"`
|
|
Positions []PositionInfo `json:"positions"`
|
|
CandidateCoins []CandidateCoin `json:"candidate_coins"`
|
|
PromptVariant string `json:"prompt_variant,omitempty"`
|
|
TradingStats *TradingStats `json:"trading_stats,omitempty"`
|
|
RecentOrders []RecentOrder `json:"recent_orders,omitempty"`
|
|
MarketDataMap map[string]*market.Data `json:"-"`
|
|
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
|
OITopDataMap map[string]*OITopData `json:"-"`
|
|
QuantDataMap map[string]*QuantData `json:"-"`
|
|
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
|
|
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
|
|
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
|
|
BTCETHLeverage int `json:"-"`
|
|
AltcoinLeverage int `json:"-"`
|
|
Timeframes []string `json:"-"`
|
|
}
|
|
|
|
// Decision AI trading decision
|
|
type Decision struct {
|
|
Symbol string `json:"symbol"`
|
|
Action string `json:"action"` // Standard: "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
|
// Grid actions: "place_buy_limit", "place_sell_limit", "cancel_order", "cancel_all_orders", "pause_grid", "resume_grid", "adjust_grid"
|
|
|
|
// Opening position parameters
|
|
Leverage int `json:"leverage,omitempty"`
|
|
PositionSizeUSD float64 `json:"position_size_usd,omitempty"`
|
|
StopLoss float64 `json:"stop_loss,omitempty"`
|
|
TakeProfit float64 `json:"take_profit,omitempty"`
|
|
|
|
// Grid trading parameters
|
|
Price float64 `json:"price,omitempty"` // Limit order price (for grid)
|
|
Quantity float64 `json:"quantity,omitempty"` // Order quantity (for grid)
|
|
LevelIndex int `json:"level_index,omitempty"` // Grid level index
|
|
OrderID string `json:"order_id,omitempty"` // Order ID (for cancel)
|
|
|
|
// Common parameters
|
|
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
|
|
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
|
|
Reasoning string `json:"reasoning"`
|
|
}
|
|
|
|
// FullDecision AI's complete decision (including chain of thought)
|
|
type FullDecision struct {
|
|
SystemPrompt string `json:"system_prompt"`
|
|
UserPrompt string `json:"user_prompt"`
|
|
CoTTrace string `json:"cot_trace"`
|
|
Decisions []Decision `json:"decisions"`
|
|
RawResponse string `json:"raw_response"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"`
|
|
}
|
|
|
|
// QuantData quantitative data structure (fund flow, position changes, price changes)
|
|
type QuantData struct {
|
|
Symbol string `json:"symbol"`
|
|
Price float64 `json:"price"`
|
|
Netflow *NetflowData `json:"netflow,omitempty"`
|
|
OI map[string]*OIData `json:"oi,omitempty"`
|
|
PriceChange map[string]float64 `json:"price_change,omitempty"`
|
|
}
|
|
|
|
type NetflowData struct {
|
|
Institution *FlowTypeData `json:"institution,omitempty"`
|
|
Personal *FlowTypeData `json:"personal,omitempty"`
|
|
}
|
|
|
|
type FlowTypeData struct {
|
|
Future map[string]float64 `json:"future,omitempty"`
|
|
Spot map[string]float64 `json:"spot,omitempty"`
|
|
}
|
|
|
|
type OIData struct {
|
|
CurrentOI float64 `json:"current_oi"`
|
|
Delta map[string]*OIDeltaData `json:"delta,omitempty"`
|
|
}
|
|
|
|
type OIDeltaData struct {
|
|
OIDelta float64 `json:"oi_delta"`
|
|
OIDeltaValue float64 `json:"oi_delta_value"`
|
|
OIDeltaPercent float64 `json:"oi_delta_percent"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// StrategyEngine - Core Strategy Execution Engine
|
|
// ============================================================================
|
|
|
|
// StrategyEngine strategy execution engine
|
|
type StrategyEngine struct {
|
|
config *store.StrategyConfig
|
|
nofxosClient *nofxos.Client
|
|
}
|
|
|
|
// NewStrategyEngine creates strategy execution engine
|
|
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
|
// 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
|
|
func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
|
return e.config.RiskControl
|
|
}
|
|
|
|
// GetLanguage returns the language from config or falls back to auto-detection
|
|
func (e *StrategyEngine) GetLanguage() Language {
|
|
switch e.config.Language {
|
|
case "zh":
|
|
return LangChinese
|
|
case "en":
|
|
return LangEnglish
|
|
default:
|
|
// Fall back to auto-detection from prompt content for backward compatibility
|
|
return detectLanguage(e.config.PromptSections.RoleDefinition)
|
|
}
|
|
}
|
|
|
|
// GetConfig gets complete strategy configuration
|
|
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
|
return e.config
|
|
}
|
|
|
|
// ============================================================================
|
|
// Candidate Coins
|
|
// ============================================================================
|
|
|
|
// GetCandidateCoins gets candidate coins based on strategy configuration
|
|
func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|
var candidates []CandidateCoin
|
|
symbolSources := make(map[string][]string)
|
|
|
|
coinSource := e.config.CoinSource
|
|
|
|
switch coinSource.SourceType {
|
|
case "static":
|
|
for _, symbol := range coinSource.StaticCoins {
|
|
symbol = market.Normalize(symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"static"},
|
|
})
|
|
}
|
|
|
|
return e.filterExcludedCoins(candidates), nil
|
|
|
|
case "ai500":
|
|
// Check use_ai500 flag; if false, fall back to static coins
|
|
if !coinSource.UseAI500 {
|
|
logger.Infof("⚠️ source_type is 'ai500' but use_ai500 is false, falling back to static coins")
|
|
for _, symbol := range coinSource.StaticCoins {
|
|
symbol = market.Normalize(symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"static"},
|
|
})
|
|
}
|
|
return e.filterExcludedCoins(candidates), nil
|
|
}
|
|
coins, err := e.getAI500Coins(coinSource.AI500Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Empty list is a normal condition, return directly
|
|
return e.filterExcludedCoins(coins), nil
|
|
|
|
case "oi_top":
|
|
// Check use_oi_top flag; if false, fall back to static coins
|
|
if !coinSource.UseOITop {
|
|
logger.Infof("⚠️ source_type is 'oi_top' but use_oi_top is false, falling back to static coins")
|
|
for _, symbol := range coinSource.StaticCoins {
|
|
symbol = market.Normalize(symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"static"},
|
|
})
|
|
}
|
|
return e.filterExcludedCoins(candidates), nil
|
|
}
|
|
coins, err := e.getOITopCoins(coinSource.OITopLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Empty list is a normal condition, return directly
|
|
return e.filterExcludedCoins(coins), nil
|
|
|
|
case "oi_low":
|
|
// OI decrease ranking, suitable for short positions
|
|
if !coinSource.UseOILow {
|
|
logger.Infof("⚠️ source_type is 'oi_low' but use_oi_low is false, falling back to static coins")
|
|
for _, symbol := range coinSource.StaticCoins {
|
|
symbol = market.Normalize(symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"static"},
|
|
})
|
|
}
|
|
return e.filterExcludedCoins(candidates), nil
|
|
}
|
|
coins, err := e.getOILowCoins(coinSource.OILowLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Empty list is a normal condition, return directly
|
|
return e.filterExcludedCoins(coins), nil
|
|
|
|
case "hyper_all":
|
|
// All Hyperliquid perp coins
|
|
if !coinSource.UseHyperAll {
|
|
logger.Infof("⚠️ source_type is 'hyper_all' but use_hyper_all is false, falling back to static coins")
|
|
for _, symbol := range coinSource.StaticCoins {
|
|
symbol = market.Normalize(symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"static"},
|
|
})
|
|
}
|
|
return e.filterExcludedCoins(candidates), nil
|
|
}
|
|
coins, err := e.getHyperAllCoins()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return e.filterExcludedCoins(coins), nil
|
|
|
|
case "hyper_main":
|
|
// Top N Hyperliquid coins by 24h volume
|
|
if !coinSource.UseHyperMain {
|
|
logger.Infof("⚠️ source_type is 'hyper_main' but use_hyper_main is false, falling back to static coins")
|
|
for _, symbol := range coinSource.StaticCoins {
|
|
symbol = market.Normalize(symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"static"},
|
|
})
|
|
}
|
|
return e.filterExcludedCoins(candidates), nil
|
|
}
|
|
coins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return e.filterExcludedCoins(coins), nil
|
|
|
|
case "mixed":
|
|
if coinSource.UseAI500 {
|
|
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to get AI500 coins: %v", err)
|
|
} else {
|
|
for _, coin := range poolCoins {
|
|
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
|
|
}
|
|
}
|
|
}
|
|
|
|
if coinSource.UseOITop {
|
|
oiCoins, err := e.getOITopCoins(coinSource.OITopLimit)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to get OI Top: %v", err)
|
|
} else {
|
|
for _, coin := range oiCoins {
|
|
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_top")
|
|
}
|
|
}
|
|
}
|
|
|
|
if coinSource.UseOILow {
|
|
oiLowCoins, err := e.getOILowCoins(coinSource.OILowLimit)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to get OI Low: %v", err)
|
|
} else {
|
|
for _, coin := range oiLowCoins {
|
|
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_low")
|
|
}
|
|
}
|
|
}
|
|
|
|
if coinSource.UseHyperAll {
|
|
hyperCoins, err := e.getHyperAllCoins()
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to get Hyperliquid All coins: %v", err)
|
|
} else {
|
|
for _, coin := range hyperCoins {
|
|
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_all")
|
|
}
|
|
}
|
|
}
|
|
|
|
if coinSource.UseHyperMain {
|
|
hyperMainCoins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to get Hyperliquid Main coins: %v", err)
|
|
} else {
|
|
for _, coin := range hyperMainCoins {
|
|
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_main")
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, symbol := range coinSource.StaticCoins {
|
|
symbol = market.Normalize(symbol)
|
|
if _, exists := symbolSources[symbol]; !exists {
|
|
symbolSources[symbol] = []string{"static"}
|
|
} else {
|
|
symbolSources[symbol] = append(symbolSources[symbol], "static")
|
|
}
|
|
}
|
|
|
|
for symbol, sources := range symbolSources {
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: sources,
|
|
})
|
|
}
|
|
return e.filterExcludedCoins(candidates), nil
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType)
|
|
}
|
|
}
|
|
|
|
// filterExcludedCoins removes excluded coins from the candidates list
|
|
func (e *StrategyEngine) filterExcludedCoins(candidates []CandidateCoin) []CandidateCoin {
|
|
if len(e.config.CoinSource.ExcludedCoins) == 0 {
|
|
return candidates
|
|
}
|
|
|
|
// Build excluded set for O(1) lookup
|
|
excluded := make(map[string]bool)
|
|
for _, coin := range e.config.CoinSource.ExcludedCoins {
|
|
normalized := market.Normalize(coin)
|
|
excluded[normalized] = true
|
|
}
|
|
|
|
// Filter out excluded coins
|
|
filtered := make([]CandidateCoin, 0, len(candidates))
|
|
for _, c := range candidates {
|
|
if !excluded[c.Symbol] {
|
|
filtered = append(filtered, c)
|
|
} else {
|
|
logger.Infof("🚫 Excluded coin: %s", c.Symbol)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
|
|
symbols, err := e.nofxosClient.GetTopRatedCoins(limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var candidates []CandidateCoin
|
|
for _, symbol := range symbols {
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"ai500"},
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
|
|
positions, err := e.nofxosClient.GetOITopPositions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var candidates []CandidateCoin
|
|
for i, pos := range positions {
|
|
if i >= limit {
|
|
break
|
|
}
|
|
symbol := market.Normalize(pos.Symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"oi_top"},
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
|
|
positions, err := e.nofxosClient.GetOILowPositions()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var candidates []CandidateCoin
|
|
for i, pos := range positions {
|
|
if i >= limit {
|
|
break
|
|
}
|
|
symbol := market.Normalize(pos.Symbol)
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: symbol,
|
|
Sources: []string{"oi_low"},
|
|
})
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
// getHyperAllCoins returns all available Hyperliquid perpetual coins
|
|
func (e *StrategyEngine) getHyperAllCoins() ([]CandidateCoin, error) {
|
|
ctx := context.Background()
|
|
symbols, err := hyperliquid.GetAllCoinSymbols(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get Hyperliquid coins: %w", err)
|
|
}
|
|
|
|
var candidates []CandidateCoin
|
|
for _, symbol := range symbols {
|
|
// Add USDT suffix for compatibility
|
|
normalizedSymbol := market.Normalize(symbol + "USDT")
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: normalizedSymbol,
|
|
Sources: []string{"hyper_all"},
|
|
})
|
|
}
|
|
logger.Infof("✅ Loaded %d Hyperliquid coins (hyper_all)", len(candidates))
|
|
return candidates, nil
|
|
}
|
|
|
|
// getHyperMainCoins returns top N Hyperliquid coins by 24h volume
|
|
func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
ctx := context.Background()
|
|
symbols, err := hyperliquid.GetMainCoinSymbols(ctx, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get Hyperliquid main coins: %w", err)
|
|
}
|
|
|
|
var candidates []CandidateCoin
|
|
for _, symbol := range symbols {
|
|
// Add USDT suffix for compatibility
|
|
normalizedSymbol := market.Normalize(symbol + "USDT")
|
|
candidates = append(candidates, CandidateCoin{
|
|
Symbol: normalizedSymbol,
|
|
Sources: []string{"hyper_main"},
|
|
})
|
|
}
|
|
logger.Infof("✅ Loaded %d Hyperliquid main coins (hyper_main) by 24h volume", len(candidates))
|
|
return candidates, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// External & Quant Data
|
|
// ============================================================================
|
|
|
|
// FetchMarketData fetches market data based on strategy configuration
|
|
func (e *StrategyEngine) FetchMarketData(symbol string) (*market.Data, error) {
|
|
return market.Get(symbol)
|
|
}
|
|
|
|
// FetchExternalData fetches external data sources
|
|
func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) {
|
|
externalData := make(map[string]interface{})
|
|
|
|
for _, source := range e.config.Indicators.ExternalDataSources {
|
|
data, err := e.fetchSingleExternalSource(source)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to fetch external data source [%s]: %v", source.Name, err)
|
|
continue
|
|
}
|
|
externalData[source.Name] = data
|
|
}
|
|
|
|
return externalData, nil
|
|
}
|
|
|
|
func (e *StrategyEngine) fetchSingleExternalSource(source store.ExternalDataSource) (interface{}, error) {
|
|
// SSRF Protection: Validate URL before making request
|
|
if err := security.ValidateURL(source.URL); err != nil {
|
|
return nil, fmt.Errorf("external source URL validation failed: %w", err)
|
|
}
|
|
|
|
timeout := time.Duration(source.RefreshSecs) * time.Second
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
// Use SSRF-safe HTTP client
|
|
client := security.SafeHTTPClient(timeout)
|
|
|
|
req, err := http.NewRequest(source.Method, source.URL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for k, v := range source.Headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result interface{}
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if source.DataPath != "" {
|
|
result = extractJSONPath(result, source.DataPath)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func extractJSONPath(data interface{}, path string) interface{} {
|
|
parts := strings.Split(path, ".")
|
|
current := data
|
|
|
|
for _, part := range parts {
|
|
if m, ok := current.(map[string]interface{}); ok {
|
|
current = m[part]
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
// FetchQuantData fetches quantitative data for a single coin
|
|
func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
|
if !e.config.Indicators.EnableQuantData {
|
|
return nil, nil
|
|
}
|
|
|
|
// Use nofxos client with unified API key
|
|
include := "oi,price"
|
|
if e.config.Indicators.EnableQuantNetflow {
|
|
include = "netflow,oi,price"
|
|
}
|
|
|
|
nofxosData, err := e.nofxosClient.GetCoinData(symbol, include)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch quant data: %w", err)
|
|
}
|
|
|
|
if nofxosData == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Convert nofxos.QuantData to kernel.QuantData
|
|
quantData := &QuantData{
|
|
Symbol: nofxosData.Symbol,
|
|
Price: nofxosData.Price,
|
|
PriceChange: nofxosData.PriceChange,
|
|
}
|
|
|
|
// Convert OI data
|
|
if nofxosData.OI != nil {
|
|
quantData.OI = make(map[string]*OIData)
|
|
for exchange, oiData := range nofxosData.OI {
|
|
if oiData != nil {
|
|
kData := &OIData{
|
|
CurrentOI: oiData.CurrentOI,
|
|
}
|
|
if oiData.Delta != nil {
|
|
kData.Delta = make(map[string]*OIDeltaData)
|
|
for dur, delta := range oiData.Delta {
|
|
if delta != nil {
|
|
kData.Delta[dur] = &OIDeltaData{
|
|
OIDelta: delta.OIDelta,
|
|
OIDeltaValue: delta.OIDeltaValue,
|
|
OIDeltaPercent: delta.OIDeltaPercent,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
quantData.OI[exchange] = kData
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert Netflow data
|
|
if nofxosData.Netflow != nil {
|
|
quantData.Netflow = &NetflowData{}
|
|
if nofxosData.Netflow.Institution != nil {
|
|
quantData.Netflow.Institution = &FlowTypeData{
|
|
Future: nofxosData.Netflow.Institution.Future,
|
|
Spot: nofxosData.Netflow.Institution.Spot,
|
|
}
|
|
}
|
|
if nofxosData.Netflow.Personal != nil {
|
|
quantData.Netflow.Personal = &FlowTypeData{
|
|
Future: nofxosData.Netflow.Personal.Future,
|
|
Spot: nofxosData.Netflow.Personal.Spot,
|
|
}
|
|
}
|
|
}
|
|
|
|
return quantData, nil
|
|
}
|
|
|
|
// FetchQuantDataBatch batch fetches quantitative data
|
|
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
|
|
result := make(map[string]*QuantData)
|
|
|
|
if !e.config.Indicators.EnableQuantData {
|
|
return result
|
|
}
|
|
|
|
for _, symbol := range symbols {
|
|
data, err := e.FetchQuantData(symbol)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to fetch quantitative data for %s: %v", symbol, err)
|
|
continue
|
|
}
|
|
if data != nil {
|
|
result[symbol] = data
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// FetchOIRankingData fetches market-wide OI ranking data
|
|
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
|
indicators := e.config.Indicators
|
|
if !indicators.EnableOIRanking {
|
|
return nil
|
|
}
|
|
|
|
duration := indicators.OIRankingDuration
|
|
if duration == "" {
|
|
duration = "1h"
|
|
}
|
|
|
|
limit := indicators.OIRankingLimit
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
|
|
logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit)
|
|
|
|
data, err := e.nofxosClient.GetOIRanking(duration, limit)
|
|
if err != nil {
|
|
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
|
return nil
|
|
}
|
|
|
|
logger.Infof("✓ OI ranking data ready: %d top, %d low positions",
|
|
len(data.TopPositions), len(data.LowPositions))
|
|
|
|
return data
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
// detectLanguage detects language from text content
|
|
// Returns LangChinese if text contains Chinese characters, otherwise LangEnglish
|
|
func detectLanguage(text string) Language {
|
|
for _, r := range text {
|
|
if r >= 0x4E00 && r <= 0x9FFF {
|
|
return LangChinese
|
|
}
|
|
}
|
|
return LangEnglish
|
|
}
|