mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
When a trader uses claw402 as AI provider, the same wallet private key is now automatically used to route nofxos data API calls (AI500, OI, NetFlow, etc.) through claw402 payment as well. Users don't need to configure anything extra — if they already set up claw402 for AI, data APIs automatically go through claw402 too.
877 lines
26 KiB
Go
877 lines
26 KiB
Go
package kernel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"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.
|
|
// claw402WalletKey is optional — if provided, nofxos data requests are routed through claw402.
|
|
func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) *StrategyEngine {
|
|
// Create NofxOS client with API key from config
|
|
apiKey := config.Indicators.NofxOSAPIKey
|
|
if apiKey == "" {
|
|
apiKey = nofxos.DefaultAuthKey
|
|
}
|
|
client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey)
|
|
|
|
// If claw402 wallet key is provided (from trader's AI config), route through claw402
|
|
walletKey := ""
|
|
if len(claw402WalletKey) > 0 {
|
|
walletKey = claw402WalletKey[0]
|
|
}
|
|
if walletKey == "" {
|
|
walletKey = os.Getenv("CLAW402_WALLET_KEY")
|
|
}
|
|
if walletKey != "" {
|
|
claw402URL := os.Getenv("CLAW402_URL")
|
|
if claw402URL == "" {
|
|
claw402URL = "https://claw402.ai"
|
|
}
|
|
claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, nil)
|
|
if err == nil {
|
|
client.SetClaw402(claw402Client)
|
|
logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL)
|
|
} else {
|
|
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|