feat: unify NofxOS data provider and fix language consistency

- Add unified NofxOS API key configuration in IndicatorEditor
- Add language field to StrategyConfig for consistent prompt generation
- Auto-update prompt sections when interface language changes
- Remove scattered URL inputs from CoinSourceEditor and IndicatorEditor
- Create nofxos provider package with formatted data output
- Update kernel engine to use config-based language setting
This commit is contained in:
tinkle-community
2026-01-04 00:59:07 +08:00
parent 13fda47151
commit 0275e23b7e
32 changed files with 3029 additions and 1767 deletions
-593
View File
@@ -1,593 +0,0 @@
package provider
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"nofx/security"
"strings"
"time"
)
// AI500Config AI500 data provider configuration
type AI500Config struct {
APIURL string
Timeout time.Duration
}
var ai500Config = AI500Config{
APIURL: "",
Timeout: 30 * time.Second,
}
// CoinData coin information
type CoinData struct {
Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT)
Score float64 `json:"score"` // Current score
StartTime int64 `json:"start_time"` // Start time (Unix timestamp)
StartPrice float64 `json:"start_price"` // Start price
LastScore float64 `json:"last_score"` // Latest score
MaxScore float64 `json:"max_score"` // Highest score
MaxPrice float64 `json:"max_price"` // Highest price
IncreasePercent float64 `json:"increase_percent"` // Increase percentage
IsAvailable bool `json:"-"` // Whether tradable (internal use)
}
// AI500APIResponse raw data structure returned by AI500 API
type AI500APIResponse struct {
Success bool `json:"success"`
Data struct {
Coins []CoinData `json:"coins"`
Count int `json:"count"`
} `json:"data"`
}
// SetAI500API sets AI500 data provider API
func SetAI500API(apiURL string) {
ai500Config.APIURL = apiURL
}
// SetOITopAPI sets OI Top API
func SetOITopAPI(apiURL string) {
oiTopConfig.APIURL = apiURL
}
// GetAI500Data retrieves AI500 coin list (with retry mechanism)
func GetAI500Data() ([]CoinData, error) {
// Check if API URL is configured
if strings.TrimSpace(ai500Config.APIURL) == "" {
return nil, fmt.Errorf("AI500 API URL not configured")
}
maxRetries := 3
var lastErr error
// Try to fetch from API
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries)
time.Sleep(2 * time.Second)
}
coins, err := fetchAI500()
if err == nil {
if attempt > 1 {
log.Printf("✓ Retry attempt %d succeeded", attempt)
}
return coins, nil
}
lastErr = err
log.Printf("❌ Request attempt %d failed: %v", attempt, err)
}
return nil, fmt.Errorf("all API requests failed: %w", lastErr)
}
// fetchAI500 actually executes AI500 request
func fetchAI500() ([]CoinData, error) {
log.Printf("🔄 Requesting AI500 data...")
// SSRF Protection: Validate URL before making request
resp, err := security.SafeGet(ai500Config.APIURL, ai500Config.Timeout)
if err != nil {
return nil, fmt.Errorf("failed to request AI500 API: %w", err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
}
// Parse API response
var response AI500APIResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w", err)
}
if !response.Success {
return nil, fmt.Errorf("API returned failure status")
}
if len(response.Data.Coins) == 0 {
return nil, fmt.Errorf("coin list is empty")
}
// Set IsAvailable flag
coins := response.Data.Coins
for i := range coins {
coins[i].IsAvailable = true
}
log.Printf("✓ Successfully fetched %d coins", len(coins))
return coins, nil
}
// GetAvailableCoins retrieves available coin list (filters out unavailable ones)
func GetAvailableCoins() ([]string, error) {
coins, err := GetAI500Data()
if err != nil {
return nil, err
}
var symbols []string
for _, coin := range coins {
if coin.IsAvailable {
symbol := normalizeSymbol(coin.Pair)
symbols = append(symbols, symbol)
}
}
if len(symbols) == 0 {
return nil, fmt.Errorf("no available coins")
}
return symbols, nil
}
// GetTopRatedCoins retrieves top N coins by score (sorted by score descending)
func GetTopRatedCoins(limit int) ([]string, error) {
coins, err := GetAI500Data()
if err != nil {
return nil, err
}
// Filter available coins
var availableCoins []CoinData
for _, coin := range coins {
if coin.IsAvailable {
availableCoins = append(availableCoins, coin)
}
}
if len(availableCoins) == 0 {
return nil, fmt.Errorf("no available coins")
}
// Sort by Score descending (bubble sort)
for i := 0; i < len(availableCoins); i++ {
for j := i + 1; j < len(availableCoins); j++ {
if availableCoins[i].Score < availableCoins[j].Score {
availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i]
}
}
}
// Take top N
maxCount := limit
if len(availableCoins) < maxCount {
maxCount = len(availableCoins)
}
var symbols []string
for i := 0; i < maxCount; i++ {
symbol := normalizeSymbol(availableCoins[i].Pair)
symbols = append(symbols, symbol)
}
return symbols, nil
}
// normalizeSymbol normalizes coin symbol
func normalizeSymbol(symbol string) string {
symbol = trimSpaces(symbol)
symbol = toUpper(symbol)
if !endsWith(symbol, "USDT") {
symbol = symbol + "USDT"
}
return symbol
}
// Helper functions
func trimSpaces(s string) string {
result := ""
for i := 0; i < len(s); i++ {
if s[i] != ' ' {
result += string(s[i])
}
}
return result
}
func toUpper(s string) string {
result := ""
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' {
c = c - 'a' + 'A'
}
result += string(c)
}
return result
}
func endsWith(s, suffix string) bool {
if len(s) < len(suffix) {
return false
}
return s[len(s)-len(suffix):] == suffix
}
// ========== OI Top (Open Interest Growth Top 20) Data ==========
// OIPosition open interest data
type OIPosition struct {
Symbol string `json:"symbol"`
Rank int `json:"rank"`
CurrentOI float64 `json:"current_oi"`
OIDelta float64 `json:"oi_delta"`
OIDeltaPercent float64 `json:"oi_delta_percent"`
OIDeltaValue float64 `json:"oi_delta_value"`
PriceDeltaPercent float64 `json:"price_delta_percent"`
NetLong float64 `json:"net_long"`
NetShort float64 `json:"net_short"`
}
// OITopAPIResponse data structure returned by OI Top API
type OITopAPIResponse struct {
Code int `json:"code"`
Data struct {
Positions []OIPosition `json:"positions"`
Count int `json:"count"`
Exchange string `json:"exchange"`
TimeRange string `json:"time_range"`
TimeRangeParam string `json:"time_range_param"`
RankType string `json:"rank_type"`
Limit int `json:"limit"`
} `json:"data"`
}
var oiTopConfig = struct {
APIURL string
Timeout time.Duration
}{
APIURL: "",
Timeout: 30 * time.Second,
}
// GetOITopPositions retrieves OI Top 20 data (with retry)
func GetOITopPositions() ([]OIPosition, error) {
if strings.TrimSpace(oiTopConfig.APIURL) == "" {
log.Printf("⚠️ OI Top API URL not configured, skipping OI Top data fetch")
return []OIPosition{}, nil
}
maxRetries := 3
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
log.Printf("⚠️ Retry attempt %d of %d to fetch OI Top data...", attempt, maxRetries)
time.Sleep(2 * time.Second)
}
positions, err := fetchOITop()
if err == nil {
if attempt > 1 {
log.Printf("✓ Retry attempt %d succeeded", attempt)
}
return positions, nil
}
lastErr = err
log.Printf("❌ OI Top request attempt %d failed: %v", attempt, err)
}
log.Printf("⚠️ All OI Top API requests failed (last error: %v), skipping OI Top data", lastErr)
return []OIPosition{}, nil
}
// fetchOITop actually executes OI Top request
func fetchOITop() ([]OIPosition, error) {
log.Printf("🔄 Requesting OI Top data...")
// SSRF Protection: Validate URL before making request
resp, err := security.SafeGet(oiTopConfig.APIURL, oiTopConfig.Timeout)
if err != nil {
return nil, fmt.Errorf("failed to request OI Top API: %w", err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read OI Top response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OI Top API returned error (status %d): %s", resp.StatusCode, string(body))
}
var response OITopAPIResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("OI Top JSON parsing failed: %w", err)
}
if response.Code != 0 {
return nil, fmt.Errorf("OI Top API returned error code: %d", response.Code)
}
if len(response.Data.Positions) == 0 {
return nil, fmt.Errorf("OI Top position list is empty")
}
log.Printf("✓ Successfully fetched %d OI Top coins (time range: %s, type: %s)",
len(response.Data.Positions), response.Data.TimeRange, response.Data.RankType)
return response.Data.Positions, nil
}
// GetOITopSymbols retrieves OI Top coin symbol list
func GetOITopSymbols() ([]string, error) {
positions, err := GetOITopPositions()
if err != nil {
return nil, err
}
var symbols []string
for _, pos := range positions {
symbol := normalizeSymbol(pos.Symbol)
symbols = append(symbols, symbol)
}
return symbols, nil
}
// MergedData merged data (AI500 + OI Top)
type MergedData struct {
AI500Coins []CoinData
OITopCoins []OIPosition
AllSymbols []string
SymbolSources map[string][]string
}
// OIRankingData OI ranking data for debate (includes both top and low)
type OIRankingData struct {
TimeRange string `json:"time_range"`
Duration string `json:"duration"`
TopPositions []OIPosition `json:"top_positions"`
LowPositions []OIPosition `json:"low_positions"`
FetchedAt time.Time `json:"fetched_at"`
}
// GetOIRankingData retrieves OI ranking data (both top increase and low decrease)
func GetOIRankingData(baseURL, authKey string, duration string, limit int) (*OIRankingData, error) {
if baseURL == "" || authKey == "" {
return nil, fmt.Errorf("OI API URL or auth key not configured")
}
if duration == "" {
duration = "1h"
}
if limit <= 0 {
limit = 20
}
result := &OIRankingData{
Duration: duration,
FetchedAt: time.Now(),
}
// Fetch top ranking
topURL := fmt.Sprintf("%s/api/oi/top-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
topPositions, timeRange, err := fetchOIRanking(topURL)
if err != nil {
log.Printf("⚠️ Failed to fetch OI top ranking: %v", err)
} else {
result.TopPositions = topPositions
result.TimeRange = timeRange
}
// Fetch low ranking
lowURL := fmt.Sprintf("%s/api/oi/low-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
lowPositions, _, err := fetchOIRanking(lowURL)
if err != nil {
log.Printf("⚠️ Failed to fetch OI low ranking: %v", err)
} else {
result.LowPositions = lowPositions
}
log.Printf("✓ Fetched OI ranking data: %d top, %d low (duration: %s)",
len(result.TopPositions), len(result.LowPositions), duration)
return result, nil
}
// fetchOIRanking fetches OI ranking from a single endpoint
func fetchOIRanking(url string) ([]OIPosition, string, error) {
// SSRF Protection: Validate URL before making request
resp, err := security.SafeGet(url, 30*time.Second)
if err != nil {
return nil, "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
}
var response OITopAPIResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
}
if response.Code != 0 {
return nil, "", fmt.Errorf("API returned error code: %d", response.Code)
}
return response.Data.Positions, response.Data.TimeRange, nil
}
// FormatOIRankingForAI formats OI ranking data for AI consumption
func FormatOIRankingForAI(data *OIRankingData) string {
if data == nil {
return ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## 📊 市场持仓量变化数据 (Open Interest Changes in %s / %s)\n\n", data.TimeRange, data.Duration))
if len(data.TopPositions) > 0 {
sb.WriteString("### 🔺 持仓量增加排行 (OI Increase Ranking)\n")
sb.WriteString("市场资金正在流入以下币种,可能表示趋势延续或新仓位建立:\n\n")
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 |\n")
sb.WriteString("|------|------|------------------|----------|----------|\n")
for _, pos := range data.TopPositions {
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% |\n",
pos.Rank,
pos.Symbol,
formatOIValue(pos.OIDeltaValue),
pos.OIDeltaPercent,
pos.PriceDeltaPercent,
))
}
sb.WriteString("\n")
sb.WriteString("**解读**: 持仓增加 + 价格上涨 = 多头主导; 持仓增加 + 价格下跌 = 空头主导\n\n")
}
if len(data.LowPositions) > 0 {
sb.WriteString("### 🔻 持仓量减少排行 (OI Decrease Ranking)\n")
sb.WriteString("市场资金正在流出以下币种,可能表示趋势反转或仓位平仓:\n\n")
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 |\n")
sb.WriteString("|------|------|------------------|----------|----------|\n")
for _, pos := range data.LowPositions {
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% |\n",
pos.Rank,
pos.Symbol,
formatOIValue(pos.OIDeltaValue),
pos.OIDeltaPercent,
pos.PriceDeltaPercent,
))
}
sb.WriteString("\n")
sb.WriteString("**解读**: 持仓减少 + 价格上涨 = 空头平仓(反弹); 持仓减少 + 价格下跌 = 多头平仓(回调)\n\n")
}
return sb.String()
}
// formatOIValue formats OI value for display
func formatOIValue(v float64) string {
sign := ""
if v >= 0 {
sign = "+"
}
absV := v
if absV < 0 {
absV = -absV
}
if absV >= 1e9 {
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
} else if absV >= 1e6 {
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
} else if absV >= 1e3 {
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
}
return fmt.Sprintf("%s%.2f", sign, v)
}
// GetMergedData retrieves merged data (AI500 + OI Top, deduplicated)
func GetMergedData(ai500Limit int) (*MergedData, error) {
ai500TopSymbols, err := GetTopRatedCoins(ai500Limit)
if err != nil {
log.Printf("⚠️ Failed to get AI500 data: %v", err)
ai500TopSymbols = []string{}
}
oiTopSymbols, err := GetOITopSymbols()
if err != nil {
log.Printf("⚠️ Failed to get OI Top data: %v", err)
oiTopSymbols = []string{}
}
symbolSet := make(map[string]bool)
symbolSources := make(map[string][]string)
for _, symbol := range ai500TopSymbols {
symbolSet[symbol] = true
symbolSources[symbol] = append(symbolSources[symbol], "ai500")
}
for _, symbol := range oiTopSymbols {
if !symbolSet[symbol] {
symbolSet[symbol] = true
}
symbolSources[symbol] = append(symbolSources[symbol], "oi_top")
}
var allSymbols []string
for symbol := range symbolSet {
allSymbols = append(allSymbols, symbol)
}
ai500Coins, _ := GetAI500Data()
oiTopPositions, _ := GetOITopPositions()
merged := &MergedData{
AI500Coins: ai500Coins,
OITopCoins: oiTopPositions,
AllSymbols: allSymbols,
SymbolSources: symbolSources,
}
log.Printf("📊 Data merge complete: AI500=%d, OI_Top=%d, Total(deduplicated)=%d",
len(ai500TopSymbols), len(oiTopSymbols), len(allSymbols))
return merged, nil
}
// ========== Backward Compatibility Aliases ==========
// Deprecated: Use SetAI500API instead
func SetCoinPoolAPI(apiURL string) {
SetAI500API(apiURL)
}
// Deprecated: Use GetAI500Data instead
func GetCoinPool() ([]CoinData, error) {
return GetAI500Data()
}
// Deprecated: Use MergedData instead
type MergedCoinPool = MergedData
// Deprecated: Use GetMergedData instead
func GetMergedCoinPool(ai500Limit int) (*MergedData, error) {
return GetMergedData(ai500Limit)
}
// Deprecated: Use CoinData instead
type CoinInfo = CoinData
+163
View File
@@ -0,0 +1,163 @@
package nofxos
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
)
// CoinData represents AI500 coin information
type CoinData struct {
Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT)
Score float64 `json:"score"` // Current AI score (0-100)
StartTime int64 `json:"start_time"` // Start time (Unix timestamp)
StartPrice float64 `json:"start_price"` // Start price
LastScore float64 `json:"last_score"` // Latest score
MaxScore float64 `json:"max_score"` // Highest score
MaxPrice float64 `json:"max_price"` // Highest price
IncreasePercent float64 `json:"increase_percent"` // Increase percentage (already x100)
IsAvailable bool `json:"-"` // Whether tradable (internal use)
}
// AI500Response is the API response structure
type AI500Response struct {
Success bool `json:"success"`
Data struct {
Coins []CoinData `json:"coins"`
Count int `json:"count"`
} `json:"data"`
}
// GetAI500List retrieves AI500 coin list with retry mechanism
func (c *Client) GetAI500List() ([]CoinData, error) {
maxRetries := 3
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries)
time.Sleep(2 * time.Second)
}
coins, err := c.fetchAI500()
if err == nil {
if attempt > 1 {
log.Printf("✓ Retry attempt %d succeeded", attempt)
}
return coins, nil
}
lastErr = err
log.Printf("❌ AI500 request attempt %d failed: %v", attempt, err)
}
return nil, fmt.Errorf("all AI500 API requests failed: %w", lastErr)
}
func (c *Client) fetchAI500() ([]CoinData, error) {
log.Printf("🔄 Requesting AI500 data from %s...", c.GetBaseURL())
body, err := c.doRequest("/api/ai500/list")
if err != nil {
return nil, fmt.Errorf("failed to request AI500 API: %w", err)
}
var response AI500Response
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w", err)
}
if !response.Success {
return nil, fmt.Errorf("API returned failure status")
}
if len(response.Data.Coins) == 0 {
return nil, fmt.Errorf("coin list is empty")
}
// Set IsAvailable flag
coins := response.Data.Coins
for i := range coins {
coins[i].IsAvailable = true
}
log.Printf("✓ Successfully fetched %d AI500 coins", len(coins))
return coins, nil
}
// GetTopRatedCoins retrieves top N coins by score (sorted descending)
func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
coins, err := c.GetAI500List()
if err != nil {
return nil, err
}
// Filter available coins
var availableCoins []CoinData
for _, coin := range coins {
if coin.IsAvailable {
availableCoins = append(availableCoins, coin)
}
}
if len(availableCoins) == 0 {
return nil, fmt.Errorf("no available coins")
}
// Sort by Score descending (bubble sort)
for i := 0; i < len(availableCoins); i++ {
for j := i + 1; j < len(availableCoins); j++ {
if availableCoins[i].Score < availableCoins[j].Score {
availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i]
}
}
}
// Take top N
maxCount := limit
if len(availableCoins) < maxCount {
maxCount = len(availableCoins)
}
var symbols []string
for i := 0; i < maxCount; i++ {
symbol := NormalizeSymbol(availableCoins[i].Pair)
symbols = append(symbols, symbol)
}
return symbols, nil
}
// GetAvailableCoins retrieves all available coin symbols
func (c *Client) GetAvailableCoins() ([]string, error) {
coins, err := c.GetAI500List()
if err != nil {
return nil, err
}
var symbols []string
for _, coin := range coins {
if coin.IsAvailable {
symbol := NormalizeSymbol(coin.Pair)
symbols = append(symbols, symbol)
}
}
if len(symbols) == 0 {
return nil, fmt.Errorf("no available coins")
}
return symbols, nil
}
// NormalizeSymbol normalizes coin symbol to XXXUSDT format
func NormalizeSymbol(symbol string) string {
symbol = strings.TrimSpace(symbol)
symbol = strings.ToUpper(symbol)
if !strings.HasSuffix(symbol, "USDT") {
symbol = symbol + "USDT"
}
return symbol
}
+146
View File
@@ -0,0 +1,146 @@
// Package nofxos provides data access to the NofxOS API (https://nofxos.ai)
// for quantitative trading data including AI500 scores, OI rankings,
// fund flow (NetFlow), price rankings, and coin details.
package nofxos
import (
"io/ioutil"
"net/http"
"nofx/security"
"strings"
"sync"
"time"
)
// Default configuration
const (
DefaultBaseURL = "https://nofxos.ai"
DefaultTimeout = 30 * time.Second
DefaultAuthKey = "cm_568c67eae410d912c54c"
)
// Client is the NofxOS API client
type Client struct {
BaseURL string
AuthKey string
Timeout time.Duration
mu sync.RWMutex
}
var (
defaultClient *Client
clientOnce sync.Once
)
// DefaultClient returns the singleton default client
func DefaultClient() *Client {
clientOnce.Do(func() {
defaultClient = &Client{
BaseURL: DefaultBaseURL,
AuthKey: DefaultAuthKey,
Timeout: DefaultTimeout,
}
})
return defaultClient
}
// NewClient creates a new NofxOS API client
func NewClient(baseURL, authKey string) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
if authKey == "" {
authKey = DefaultAuthKey
}
return &Client{
BaseURL: baseURL,
AuthKey: authKey,
Timeout: DefaultTimeout,
}
}
// SetConfig updates client configuration
func (c *Client) SetConfig(baseURL, authKey string) {
c.mu.Lock()
defer c.mu.Unlock()
if baseURL != "" {
c.BaseURL = baseURL
}
if authKey != "" {
c.AuthKey = authKey
}
}
// GetBaseURL returns the current base URL
func (c *Client) GetBaseURL() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.BaseURL
}
// GetAuthKey returns the current auth key
func (c *Client) GetAuthKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.AuthKey
}
// doRequest performs an HTTP GET request with authentication
func (c *Client) doRequest(endpoint string) ([]byte, error) {
c.mu.RLock()
baseURL := c.BaseURL
authKey := c.AuthKey
timeout := c.Timeout
c.mu.RUnlock()
url := baseURL + endpoint
if !strings.Contains(url, "auth=") {
if strings.Contains(url, "?") {
url += "&auth=" + authKey
} else {
url += "?auth=" + authKey
}
}
resp, err := security.SafeGet(url, timeout)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return body, &APIError{
StatusCode: resp.StatusCode,
Message: string(body),
}
}
return body, nil
}
// APIError represents an API error response
type APIError struct {
StatusCode int
Message string
}
func (e *APIError) Error() string {
return e.Message
}
// ExtractAuthKey extracts auth key from a URL string
func ExtractAuthKey(url string) string {
if idx := strings.Index(url, "auth="); idx != -1 {
authKey := url[idx+5:]
if ampIdx := strings.Index(authKey, "&"); ampIdx != -1 {
authKey = authKey[:ampIdx]
}
return authKey
}
return ""
}
+216
View File
@@ -0,0 +1,216 @@
package nofxos
import (
"encoding/json"
"fmt"
"log"
"strings"
)
// QuantData represents quantitative data for a single coin
type QuantData struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Netflow *NetflowData `json:"netflow,omitempty"`
OI map[string]*OIData `json:"oi,omitempty"` // keyed by exchange: "binance", "bybit"
PriceChange map[string]float64 `json:"price_change,omitempty"` // keyed by duration: "1h", "4h", etc.
}
// NetflowData contains fund flow data
type NetflowData struct {
Institution *FlowTypeData `json:"institution,omitempty"`
Personal *FlowTypeData `json:"personal,omitempty"`
}
// FlowTypeData contains flow data by trade type
type FlowTypeData struct {
Future map[string]float64 `json:"future,omitempty"` // keyed by duration
Spot map[string]float64 `json:"spot,omitempty"` // keyed by duration
}
// OIData contains open interest data for an exchange
type OIData struct {
CurrentOI float64 `json:"current_oi"`
NetLong float64 `json:"net_long"`
NetShort float64 `json:"net_short"`
Delta map[string]*OIDeltaData `json:"delta,omitempty"` // keyed by duration
}
// OIDeltaData contains OI change data
type OIDeltaData struct {
OIDelta float64 `json:"oi_delta"`
OIDeltaValue float64 `json:"oi_delta_value"`
OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100
}
// CoinResponse is the API response structure for coin details
type CoinResponse struct {
Success bool `json:"success"`
Code int `json:"code"`
Data *QuantData `json:"data"`
}
// GetCoinData retrieves quantitative data for a single coin
func (c *Client) GetCoinData(symbol string, include string) (*QuantData, error) {
if symbol == "" {
return nil, fmt.Errorf("symbol is required")
}
if include == "" {
include = "netflow,oi,price"
}
// Normalize symbol (remove USDT suffix for API call if needed)
symbol = strings.TrimSuffix(strings.ToUpper(symbol), "USDT")
endpoint := fmt.Sprintf("/api/coin/%s?include=%s", symbol, include)
body, err := c.doRequest(endpoint)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var response CoinResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w", err)
}
// Check for success (support both success field and code field)
if !response.Success && response.Code != 0 {
return nil, fmt.Errorf("API returned error code: %d", response.Code)
}
return response.Data, nil
}
// GetCoinDataBatch retrieves quantitative data for multiple coins
func (c *Client) GetCoinDataBatch(symbols []string, include string) map[string]*QuantData {
result := make(map[string]*QuantData)
for _, symbol := range symbols {
data, err := c.GetCoinData(symbol, include)
if err != nil {
log.Printf("⚠️ Failed to fetch coin data for %s: %v", symbol, err)
continue
}
if data != nil {
// Use normalized symbol as key
normalizedSymbol := NormalizeSymbol(symbol)
result[normalizedSymbol] = data
}
}
return result
}
// FormatQuantDataForAI formats single coin quant data for AI consumption
func FormatQuantDataForAI(symbol string, data *QuantData, lang Language) string {
if data == nil {
return ""
}
if lang == LangChinese {
return formatQuantDataZH(symbol, data)
}
return formatQuantDataEN(symbol, data)
}
func formatQuantDataZH(symbol string, data *QuantData) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("### %s 量化数据\n", symbol))
sb.WriteString(fmt.Sprintf("价格: $%.4f\n\n", data.Price))
if len(data.PriceChange) > 0 {
sb.WriteString("**价格变化**:\n")
durations := []string{"1h", "4h", "8h", "12h", "24h"}
for _, d := range durations {
if change, ok := data.PriceChange[d]; ok {
sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100))
}
}
sb.WriteString("\n")
}
if len(data.OI) > 0 {
for exchange, oiData := range data.OI {
if oiData != nil {
sb.WriteString(fmt.Sprintf("**%s持仓**:\n", strings.ToUpper(exchange)))
sb.WriteString(fmt.Sprintf("- OI: %.2f\n", oiData.CurrentOI))
if oiData.NetLong > 0 || oiData.NetShort > 0 {
sb.WriteString(fmt.Sprintf("- 多头: %.2f, 空头: %.2f\n", oiData.NetLong, oiData.NetShort))
}
if oiData.Delta != nil {
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
sb.WriteString(fmt.Sprintf("- 1h变化: %s (%.2f%%)\n",
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
}
}
sb.WriteString("\n")
}
}
}
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
sb.WriteString("**机构资金流**:\n")
durations := []string{"1h", "4h", "24h"}
for _, d := range durations {
if flow, ok := data.Netflow.Institution.Future[d]; ok {
sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow)))
}
}
sb.WriteString("\n")
}
return sb.String()
}
func formatQuantDataEN(symbol string, data *QuantData) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("### %s Quant Data\n", symbol))
sb.WriteString(fmt.Sprintf("Price: $%.4f\n\n", data.Price))
if len(data.PriceChange) > 0 {
sb.WriteString("**Price Change**:\n")
durations := []string{"1h", "4h", "8h", "12h", "24h"}
for _, d := range durations {
if change, ok := data.PriceChange[d]; ok {
sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100))
}
}
sb.WriteString("\n")
}
if len(data.OI) > 0 {
for exchange, oiData := range data.OI {
if oiData != nil {
sb.WriteString(fmt.Sprintf("**%s OI**:\n", strings.ToUpper(exchange)))
sb.WriteString(fmt.Sprintf("- Current OI: %.2f\n", oiData.CurrentOI))
if oiData.NetLong > 0 || oiData.NetShort > 0 {
sb.WriteString(fmt.Sprintf("- Net Long: %.2f, Net Short: %.2f\n", oiData.NetLong, oiData.NetShort))
}
if oiData.Delta != nil {
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
sb.WriteString(fmt.Sprintf("- 1h Change: %s (%.2f%%)\n",
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
}
}
sb.WriteString("\n")
}
}
}
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
sb.WriteString("**Institution Fund Flow**:\n")
durations := []string{"1h", "4h", "24h"}
for _, d := range durations {
if flow, ok := data.Netflow.Institution.Future[d]; ok {
sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow)))
}
}
sb.WriteString("\n")
}
return sb.String()
}
+263
View File
@@ -0,0 +1,263 @@
package nofxos
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
)
// NetFlowPosition represents fund flow data for a single coin
type NetFlowPosition struct {
Rank int `json:"rank"`
Symbol string `json:"symbol"`
Amount float64 `json:"amount"` // Fund flow amount in USDT (positive=inflow, negative=outflow)
Price float64 `json:"price"`
}
// NetFlowResponse is the API response structure
type NetFlowResponse struct {
Success bool `json:"success"`
Data struct {
Netflows []NetFlowPosition `json:"netflows"`
Count int `json:"count"`
Type string `json:"type"` // institution or personal
Trade string `json:"trade"` // 合约 or 现货
TimeRange string `json:"time_range"`
RankType string `json:"rank_type"` // top or low
Limit int `json:"limit"`
} `json:"data"`
}
// NetFlowRankingData contains institution and personal fund flow rankings
type NetFlowRankingData struct {
Duration string `json:"duration"`
TimeRange string `json:"time_range"`
InstitutionFutureTop []NetFlowPosition `json:"institution_future_top"`
InstitutionFutureLow []NetFlowPosition `json:"institution_future_low"`
PersonalFutureTop []NetFlowPosition `json:"personal_future_top"`
PersonalFutureLow []NetFlowPosition `json:"personal_future_low"`
FetchedAt time.Time `json:"fetched_at"`
}
// GetNetFlowRanking retrieves NetFlow ranking data (institution/personal, top/low)
func (c *Client) GetNetFlowRanking(duration string, limit int) (*NetFlowRankingData, error) {
if duration == "" {
duration = "1h"
}
if limit <= 0 {
limit = 10
}
result := &NetFlowRankingData{
Duration: duration,
FetchedAt: time.Now(),
}
// Fetch institution futures top (inflow)
positions, timeRange, err := c.fetchNetFlowRanking("top", duration, limit, "institution", "future")
if err != nil {
log.Printf("⚠️ Failed to fetch institution future inflow ranking: %v", err)
} else {
result.InstitutionFutureTop = positions
result.TimeRange = timeRange
}
// Fetch institution futures low (outflow)
positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "institution", "future")
if err != nil {
log.Printf("⚠️ Failed to fetch institution future outflow ranking: %v", err)
} else {
result.InstitutionFutureLow = positions
}
// Fetch personal futures top (retail inflow)
positions, _, err = c.fetchNetFlowRanking("top", duration, limit, "personal", "future")
if err != nil {
log.Printf("⚠️ Failed to fetch personal future inflow ranking: %v", err)
} else {
result.PersonalFutureTop = positions
}
// Fetch personal futures low (retail outflow)
positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "personal", "future")
if err != nil {
log.Printf("⚠️ Failed to fetch personal future outflow ranking: %v", err)
} else {
result.PersonalFutureLow = positions
}
log.Printf("✓ Fetched NetFlow ranking data: inst_in=%d, inst_out=%d, retail_in=%d, retail_out=%d (duration: %s)",
len(result.InstitutionFutureTop), len(result.InstitutionFutureLow),
len(result.PersonalFutureTop), len(result.PersonalFutureLow), duration)
return result, nil
}
func (c *Client) fetchNetFlowRanking(rankType, duration string, limit int, flowType, trade string) ([]NetFlowPosition, string, error) {
endpoint := fmt.Sprintf("/api/netflow/%s-ranking?limit=%d&duration=%s&type=%s&trade=%s",
rankType, limit, duration, flowType, trade)
body, err := c.doRequest(endpoint)
if err != nil {
return nil, "", fmt.Errorf("request failed: %w", err)
}
var response NetFlowResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
}
if !response.Success {
return nil, "", fmt.Errorf("API returned failure status")
}
return response.Data.Netflows, response.Data.TimeRange, nil
}
// FormatNetFlowRankingForAI formats NetFlow ranking data for AI consumption
func FormatNetFlowRankingForAI(data *NetFlowRankingData, lang Language) string {
if data == nil {
return ""
}
if lang == LangChinese {
return formatNetFlowRankingZH(data)
}
return formatNetFlowRankingEN(data)
}
func formatNetFlowRankingZH(data *NetFlowRankingData) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## 资金流向排行 (%s)\n\n", data.Duration))
// Institution inflow
if len(data.InstitutionFutureTop) > 0 {
sb.WriteString("### 机构资金流入榜\n")
sb.WriteString("Smart Money买入信号:\n\n")
sb.WriteString("| 排名 | 币种 | 流入金额(USDT) | 价格 |\n")
sb.WriteString("|------|------|----------------|------|\n")
for _, pos := range data.InstitutionFutureTop {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
}
sb.WriteString("\n")
}
// Institution outflow
if len(data.InstitutionFutureLow) > 0 {
sb.WriteString("### 机构资金流出榜\n")
sb.WriteString("Smart Money卖出信号:\n\n")
sb.WriteString("| 排名 | 币种 | 流出金额(USDT) | 价格 |\n")
sb.WriteString("|------|------|----------------|------|\n")
for _, pos := range data.InstitutionFutureLow {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
}
sb.WriteString("\n")
}
// Retail flow summary
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
sb.WriteString("### 散户资金动向\n")
if len(data.PersonalFutureTop) > 0 {
sb.WriteString("散户买入: ")
for i, pos := range data.PersonalFutureTop {
if i >= 3 {
break
}
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
}
sb.WriteString("\n")
}
if len(data.PersonalFutureLow) > 0 {
sb.WriteString("散户卖出: ")
for i, pos := range data.PersonalFutureLow {
if i >= 3 {
break
}
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
}
sb.WriteString("\n")
}
sb.WriteString("\n")
}
sb.WriteString("**解读**: 机构买入+散户卖出=强烈看多 | 机构卖出+散户买入=强烈看空\n\n")
return sb.String()
}
func formatNetFlowRankingEN(data *NetFlowRankingData) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Fund Flow Ranking (%s)\n\n", data.Duration))
// Institution inflow
if len(data.InstitutionFutureTop) > 0 {
sb.WriteString("### Institution Inflow\n")
sb.WriteString("Smart Money buying signals:\n\n")
sb.WriteString("| Rank | Symbol | Inflow (USDT) | Price |\n")
sb.WriteString("|------|--------|---------------|-------|\n")
for _, pos := range data.InstitutionFutureTop {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
}
sb.WriteString("\n")
}
// Institution outflow
if len(data.InstitutionFutureLow) > 0 {
sb.WriteString("### Institution Outflow\n")
sb.WriteString("Smart Money selling signals:\n\n")
sb.WriteString("| Rank | Symbol | Outflow (USDT) | Price |\n")
sb.WriteString("|------|--------|----------------|-------|\n")
for _, pos := range data.InstitutionFutureLow {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
}
sb.WriteString("\n")
}
// Retail flow summary
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
sb.WriteString("### Retail Flow\n")
if len(data.PersonalFutureTop) > 0 {
sb.WriteString("Retail buying: ")
for i, pos := range data.PersonalFutureTop {
if i >= 3 {
break
}
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
}
sb.WriteString("\n")
}
if len(data.PersonalFutureLow) > 0 {
sb.WriteString("Retail selling: ")
for i, pos := range data.PersonalFutureLow {
if i >= 3 {
break
}
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
}
sb.WriteString("\n")
}
sb.WriteString("\n")
}
sb.WriteString("**Key**: Institution buy + Retail sell = Strong bullish | Institution sell + Retail buy = Strong bearish\n\n")
return sb.String()
}
+212
View File
@@ -0,0 +1,212 @@
package nofxos
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
)
// OIPosition represents open interest data for a single coin
type OIPosition struct {
Symbol string `json:"symbol"`
Rank int `json:"rank"`
Price float64 `json:"price"`
CurrentOI float64 `json:"current_oi"`
OIDelta float64 `json:"oi_delta"`
OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100 (5.0 = 5%)
OIDeltaValue float64 `json:"oi_delta_value"` // USDT value
PriceDeltaPercent float64 `json:"price_delta_percent"` // Already x100 (5.0 = 5%)
NetLong float64 `json:"net_long"`
NetShort float64 `json:"net_short"`
}
// OIRankingResponse is the API response structure for OI ranking
type OIRankingResponse struct {
Success bool `json:"success"`
Code int `json:"code"`
Data struct {
Positions []OIPosition `json:"positions"`
Count int `json:"count"`
Exchange string `json:"exchange"`
TimeRange string `json:"time_range"`
TimeRangeParam string `json:"time_range_param"`
RankType string `json:"rank_type"`
Limit int `json:"limit"`
} `json:"data"`
}
// OIRankingData contains both top and low OI rankings
type OIRankingData struct {
TimeRange string `json:"time_range"`
Duration string `json:"duration"`
TopPositions []OIPosition `json:"top_positions"`
LowPositions []OIPosition `json:"low_positions"`
FetchedAt time.Time `json:"fetched_at"`
}
// GetOIRanking retrieves OI ranking data (both top increase and low decrease)
func (c *Client) GetOIRanking(duration string, limit int) (*OIRankingData, error) {
if duration == "" {
duration = "1h"
}
if limit <= 0 {
limit = 20
}
result := &OIRankingData{
Duration: duration,
FetchedAt: time.Now(),
}
// Fetch top ranking (OI increase)
topPositions, timeRange, err := c.fetchOIRanking("top", duration, limit)
if err != nil {
log.Printf("⚠️ Failed to fetch OI top ranking: %v", err)
} else {
result.TopPositions = topPositions
result.TimeRange = timeRange
}
// Fetch low ranking (OI decrease)
lowPositions, _, err := c.fetchOIRanking("low", duration, limit)
if err != nil {
log.Printf("⚠️ Failed to fetch OI low ranking: %v", err)
} else {
result.LowPositions = lowPositions
}
log.Printf("✓ Fetched OI ranking data: %d top, %d low (duration: %s)",
len(result.TopPositions), len(result.LowPositions), duration)
return result, nil
}
func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosition, string, error) {
endpoint := fmt.Sprintf("/api/oi/%s-ranking?limit=%d&duration=%s", rankType, limit, duration)
body, err := c.doRequest(endpoint)
if err != nil {
return nil, "", fmt.Errorf("request failed: %w", err)
}
var response OIRankingResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
}
// Check for success (support both success field and code field)
if !response.Success && response.Code != 0 {
return nil, "", fmt.Errorf("API returned error code: %d", response.Code)
}
return response.Data.Positions, response.Data.TimeRange, nil
}
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
data, err := c.GetOIRanking("1h", 20)
if err != nil {
return nil, err
}
return data.TopPositions, nil
}
// GetOITopSymbols retrieves OI top coin symbol list
func (c *Client) GetOITopSymbols() ([]string, error) {
positions, err := c.GetOITopPositions()
if err != nil {
return nil, err
}
var symbols []string
for _, pos := range positions {
symbol := NormalizeSymbol(pos.Symbol)
symbols = append(symbols, symbol)
}
return symbols, nil
}
// FormatOIRankingForAI formats OI ranking data for AI consumption
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
if data == nil {
return ""
}
if lang == LangChinese {
return formatOIRankingZH(data)
}
return formatOIRankingEN(data)
}
func formatOIRankingZH(data *OIRankingData) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## 持仓量变化排行 (%s)\n\n", data.Duration))
if len(data.TopPositions) > 0 {
sb.WriteString("### 持仓增加榜\n")
sb.WriteString("资金流入,趋势延续或新仓建立信号:\n\n")
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
sb.WriteString("|------|------|----------------|---------|----------|\n")
for _, pos := range data.TopPositions {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
pos.OIDeltaPercent, pos.PriceDeltaPercent))
}
sb.WriteString("\n")
}
if len(data.LowPositions) > 0 {
sb.WriteString("### 持仓减少榜\n")
sb.WriteString("资金流出,趋势反转或仓位平仓信号:\n\n")
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
sb.WriteString("|------|------|----------------|---------|----------|\n")
for _, pos := range data.LowPositions {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
pos.OIDeltaPercent, pos.PriceDeltaPercent))
}
sb.WriteString("\n")
}
sb.WriteString("**解读**: OI增+价涨=多头主导 | OI增+价跌=空头主导 | OI减+价涨=空头平仓 | OI减+价跌=多头平仓\n\n")
return sb.String()
}
func formatOIRankingEN(data *OIRankingData) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Open Interest Changes (%s)\n\n", data.Duration))
if len(data.TopPositions) > 0 {
sb.WriteString("### OI Increase Ranking\n")
sb.WriteString("Capital inflow signals - trend continuation or new positions:\n\n")
sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n")
sb.WriteString("|------|--------|------------------|-------------|----------------|\n")
for _, pos := range data.TopPositions {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
pos.OIDeltaPercent, pos.PriceDeltaPercent))
}
sb.WriteString("\n")
}
if len(data.LowPositions) > 0 {
sb.WriteString("### OI Decrease Ranking\n")
sb.WriteString("Capital outflow signals - trend reversal or position closing:\n\n")
sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n")
sb.WriteString("|------|--------|------------------|-------------|----------------|\n")
for _, pos := range data.LowPositions {
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
pos.OIDeltaPercent, pos.PriceDeltaPercent))
}
sb.WriteString("\n")
}
sb.WriteString("**Key**: OI up + Price up = Bulls dominant | OI up + Price down = Bears dominant | OI down + Price up = Short covering | OI down + Price down = Long liquidation\n\n")
return sb.String()
}
+182
View File
@@ -0,0 +1,182 @@
package nofxos
import (
"encoding/json"
"fmt"
"log"
"strings"
"time"
)
// PriceRankingItem represents single coin price ranking data
type PriceRankingItem struct {
Pair string `json:"pair"`
Symbol string `json:"symbol"`
PriceDelta float64 `json:"price_delta"` // Decimal format: 0.0723 = 7.23%
Price float64 `json:"price"`
FutureFlow float64 `json:"future_flow"`
SpotFlow float64 `json:"spot_flow"`
OI float64 `json:"oi"`
OIDelta float64 `json:"oi_delta"`
OIDeltaValue float64 `json:"oi_delta_value"`
}
// PriceRankingDuration contains top gainers and losers for a single duration
type PriceRankingDuration struct {
Top []PriceRankingItem `json:"top"`
Low []PriceRankingItem `json:"low"`
}
// PriceRankingResponse is the API response structure
type PriceRankingResponse struct {
Success bool `json:"success"`
Data struct {
Durations []string `json:"durations"`
Limit int `json:"limit"`
Data map[string]PriceRankingDuration `json:"data"`
} `json:"data"`
}
// PriceRankingData contains price ranking data for multiple durations
type PriceRankingData struct {
Durations map[string]*PriceRankingDuration `json:"durations"`
FetchedAt time.Time `json:"fetched_at"`
}
// GetPriceRanking retrieves price ranking data (gainers/losers)
func (c *Client) GetPriceRanking(durations string, limit int) (*PriceRankingData, error) {
if durations == "" {
durations = "1h"
}
if limit <= 0 {
limit = 10
}
endpoint := fmt.Sprintf("/api/price/ranking?duration=%s&limit=%d", durations, limit)
body, err := c.doRequest(endpoint)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var response PriceRankingResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w", err)
}
if !response.Success {
return nil, fmt.Errorf("API returned failure status")
}
result := &PriceRankingData{
Durations: make(map[string]*PriceRankingDuration),
FetchedAt: time.Now(),
}
for duration, data := range response.Data.Data {
d := data // Create a copy to avoid pointer issues
result.Durations[duration] = &d
}
log.Printf("✓ Fetched Price ranking data for %d durations", len(result.Durations))
return result, nil
}
// FormatPriceRankingForAI formats Price ranking data for AI consumption
func FormatPriceRankingForAI(data *PriceRankingData, lang Language) string {
if data == nil || len(data.Durations) == 0 {
return ""
}
if lang == LangChinese {
return formatPriceRankingZH(data)
}
return formatPriceRankingEN(data)
}
func formatPriceRankingZH(data *PriceRankingData) string {
var sb strings.Builder
sb.WriteString("## 涨跌幅排行\n\n")
durationOrder := []string{"1h", "4h", "24h"}
for _, duration := range durationOrder {
durationData, exists := data.Durations[duration]
if !exists || durationData == nil {
continue
}
sb.WriteString(fmt.Sprintf("### %s 涨跌幅\n\n", duration))
if len(durationData.Top) > 0 {
sb.WriteString("**涨幅榜**\n")
sb.WriteString("| 币种 | 涨幅 | 价格 | 资金流 | OI变化 |\n")
sb.WriteString("|------|------|------|--------|--------|\n")
for _, item := range durationData.Top {
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
item.Symbol, item.PriceDelta*100, item.Price,
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
}
sb.WriteString("\n")
}
if len(durationData.Low) > 0 {
sb.WriteString("**跌幅榜**\n")
sb.WriteString("| 币种 | 跌幅 | 价格 | 资金流 | OI变化 |\n")
sb.WriteString("|------|------|------|--------|--------|\n")
for _, item := range durationData.Low {
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
item.Symbol, item.PriceDelta*100, item.Price,
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
}
sb.WriteString("\n")
}
}
sb.WriteString("**解读**: 涨幅大+资金流入+OI增加=强势上涨 | 跌幅大+资金流出+OI减少=弱势下跌\n\n")
return sb.String()
}
func formatPriceRankingEN(data *PriceRankingData) string {
var sb strings.Builder
sb.WriteString("## Price Gainers/Losers\n\n")
durationOrder := []string{"1h", "4h", "24h"}
for _, duration := range durationOrder {
durationData, exists := data.Durations[duration]
if !exists || durationData == nil {
continue
}
sb.WriteString(fmt.Sprintf("### %s Price Change\n\n", duration))
if len(durationData.Top) > 0 {
sb.WriteString("**Top Gainers**\n")
sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n")
sb.WriteString("|--------|--------|-------|-----------|----------|\n")
for _, item := range durationData.Top {
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
item.Symbol, item.PriceDelta*100, item.Price,
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
}
sb.WriteString("\n")
}
if len(durationData.Low) > 0 {
sb.WriteString("**Top Losers**\n")
sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n")
sb.WriteString("|--------|--------|-------|-----------|----------|\n")
for _, item := range durationData.Low {
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
item.Symbol, item.PriceDelta*100, item.Price,
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
}
sb.WriteString("\n")
}
}
sb.WriteString("**Key**: Big gain + Fund inflow + OI increase = Strong bullish | Big loss + Fund outflow + OI decrease = Strong bearish\n\n")
return sb.String()
}
+31
View File
@@ -0,0 +1,31 @@
package nofxos
import "fmt"
// Language represents the language for formatting output
type Language string
const (
LangChinese Language = "zh-CN"
LangEnglish Language = "en-US"
)
// formatValue formats a numeric value with sign and appropriate suffix
func formatValue(v float64) string {
sign := "+"
if v < 0 {
sign = ""
}
absV := v
if absV < 0 {
absV = -absV
}
if absV >= 1e9 {
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
} else if absV >= 1e6 {
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
} else if absV >= 1e3 {
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
}
return fmt.Sprintf("%s%.2f", sign, v)
}