feat: add xyz dex balance calculation, market data providers, and UI improvements

- Fix xyz dex balance calculation (use marginSummary for isolated margin)
- Add Alpaca provider for US stocks market data
- Add TwelveData provider for forex & metals market data
- Add Hyperliquid kline provider
- Centralize API keys in config system
- Add builder fee for order routing
- Improve chart UI with compact design
- Fix position history fee display precision
- Add comprehensive balance calculation tests
This commit is contained in:
tinkle-community
2025-12-29 22:16:48 +08:00
parent 4776fc37ce
commit 47bff87966
21 changed files with 3863 additions and 393 deletions

171
provider/alpaca/kline.go Normal file
View File

@@ -0,0 +1,171 @@
package alpaca
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"nofx/config"
"time"
)
const (
DataAPIURL = "https://data.alpaca.markets/v2"
)
// Bar represents a single OHLCV bar from Alpaca
type Bar struct {
Timestamp time.Time `json:"t"`
Open float64 `json:"o"`
High float64 `json:"h"`
Low float64 `json:"l"`
Close float64 `json:"c"`
Volume uint64 `json:"v"`
TradeCount uint64 `json:"n"`
VWAP float64 `json:"vw"`
}
// BarsResponse represents the response from Alpaca bars API
type BarsResponse struct {
Bars []Bar `json:"bars"`
Symbol string `json:"symbol"`
NextPageToken string `json:"next_page_token"`
}
// Client is the Alpaca API client
type Client struct {
apiKey string
secretKey string
client *http.Client
}
// NewClient creates a new Alpaca client from config
func NewClient() *Client {
cfg := config.Get()
return &Client{
apiKey: cfg.AlpacaAPIKey,
secretKey: cfg.AlpacaSecretKey,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewClientWithKeys creates a new Alpaca client with provided keys
func NewClientWithKeys(apiKey, secretKey string) *Client {
return &Client{
apiKey: apiKey,
secretKey: secretKey,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// GetBars fetches historical bars for a symbol
// timeframe: 1Min, 5Min, 15Min, 30Min, 1Hour, 4Hour, 1Day, 1Week, 1Month
func (c *Client) GetBars(ctx context.Context, symbol string, timeframe string, limit int) ([]Bar, error) {
if c.apiKey == "" || c.secretKey == "" {
return nil, fmt.Errorf("alpaca API keys not configured")
}
// Build URL
endpoint := fmt.Sprintf("%s/stocks/%s/bars", DataAPIURL, symbol)
params := url.Values{}
params.Set("timeframe", timeframe)
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("adjustment", "raw")
params.Set("feed", "iex") // Use IEX feed (free tier)
// Set time range: last 30 days for intraday, last 2 years for daily
now := time.Now()
var start time.Time
switch timeframe {
case "1Day", "1Week", "1Month":
start = now.AddDate(-2, 0, 0) // 2 years back
default:
start = now.AddDate(0, 0, -30) // 30 days back for intraday
}
params.Set("start", start.Format(time.RFC3339))
params.Set("end", now.Format(time.RFC3339))
fullURL := endpoint + "?" + params.Encode()
// Create request
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set auth headers
req.Header.Set("APCA-API-KEY-ID", c.apiKey)
req.Header.Set("APCA-API-SECRET-KEY", c.secretKey)
// Execute request
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("alpaca API error (status %d): %s", resp.StatusCode, string(body))
}
// Parse response
var result BarsResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return result.Bars, nil
}
// MapTimeframe maps common timeframe strings to Alpaca format
func MapTimeframe(interval string) string {
switch interval {
case "1m":
return "1Min"
case "3m":
return "1Min" // Alpaca doesn't have 3m, use 1m
case "5m":
return "5Min"
case "10m":
return "15Min" // Alpaca doesn't have 10m, use 15m
case "15m":
return "15Min"
case "30m":
return "30Min"
case "1h":
return "1Hour"
case "2h":
return "1Hour" // Alpaca doesn't have 2h, use 1h
case "4h":
return "4Hour"
case "6h":
return "4Hour" // Alpaca doesn't have 6h, use 4h
case "8h":
return "4Hour" // Alpaca doesn't have 8h, use 4h
case "12h":
return "4Hour" // Alpaca doesn't have 12h, use 4h
case "1d":
return "1Day"
case "3d":
return "1Day" // Alpaca doesn't have 3d, use 1d
case "1w":
return "1Week"
case "1M":
return "1Month"
default:
return "5Min" // Default to 5 minutes
}
}

View File

@@ -0,0 +1,35 @@
package alpaca
import (
"context"
"fmt"
"testing"
)
func TestGetBars(t *testing.T) {
client := NewClient()
resp, err := client.GetBars(context.TODO(), "AAPL", "1Day", 5)
if err != nil {
t.Fatal(err)
}
t.Log("=== AAPL 日线数据 (Alpaca IEX feed) ===")
for i, bar := range resp {
t.Logf("\n[%d] 时间: %s", i, bar.Timestamp.Format("2006-01-02 15:04:05"))
t.Logf(" Open: %.2f", bar.Open)
t.Logf(" High: %.2f", bar.High)
t.Logf(" Low: %.2f", bar.Low)
t.Logf(" Close: %.2f", bar.Close)
t.Logf(" Volume: %d (股数)", bar.Volume)
t.Logf(" TradeCount: %d (成交笔数)", bar.TradeCount)
t.Logf(" VWAP: %.2f (成交量加权平均价)", bar.VWAP)
// 计算成交额
quoteVolume := float64(bar.Volume) * bar.Close
t.Logf(" 成交额: %.2f USD (Volume × Close)", quoteVolume)
}
fmt.Printf("\n⚠ 注意IEX feed 只包含 IEX 交易所的数据,不是完整市场数据\n")
fmt.Printf("完整市场数据需要使用 SIP feed付费\n")
}

View File

@@ -3,6 +3,7 @@ package coinank_api
import (
"context"
"encoding/json"
"fmt"
"nofx/provider/coinank/coinank_enum"
"testing"
"time"
@@ -19,3 +20,34 @@ func TestKline(t *testing.T) {
}
t.Logf("%s", res)
}
func TestKlineDaily(t *testing.T) {
resp, err := Kline(context.TODO(), "BTCUSDT", coinank_enum.Binance, time.Now().UnixMilli(), coinank_enum.To, 5, coinank_enum.Day1)
if err != nil {
t.Fatal(err)
}
t.Log("=== BTCUSDT 日线 K线数据 (coinank_api 免费接口) ===")
for i, k := range resp {
startTime := time.UnixMilli(k.StartTime).Format("2006-01-02 15:04:05")
t.Logf("\n[%d] 时间: %s", i, startTime)
t.Logf(" Open: %.2f", k.Open)
t.Logf(" High: %.2f", k.High)
t.Logf(" Low: %.2f", k.Low)
t.Logf(" Close: %.2f", k.Close)
t.Logf(" Volume: %.4f (k[6])", k.Volume)
t.Logf(" Quantity: %.4f (k[7])", k.Quantity)
t.Logf(" Count: %.0f (k[8])", k.Count)
// 计算验证
if k.Close > 0 && k.Volume > 0 {
t.Logf(" --- 验证 ---")
t.Logf(" Volume × Close = %.2f", k.Volume*k.Close)
t.Logf(" Quantity / Close = %.4f", k.Quantity/k.Close)
}
}
// 打印原始 JSON
res, _ := json.MarshalIndent(resp, "", " ")
fmt.Printf("\n原始 JSON:\n%s\n", res)
}

View File

@@ -3,6 +3,7 @@ package coinank
import (
"context"
"encoding/json"
"fmt"
"nofx/provider/coinank/coinank_enum"
"testing"
"time"
@@ -20,3 +21,36 @@ func TestKline(t *testing.T) {
}
t.Logf("%s", res)
}
func TestKlineDaily(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.Kline(context.TODO(), "BTCUSDT", coinank_enum.Binance, 0, time.Now().UnixMilli(), 5, coinank_enum.Day1)
if err != nil {
t.Fatal(err)
}
t.Log("=== BTCUSDT 日线 K线数据 ===")
for i, k := range resp {
startTime := time.UnixMilli(k.StartTime).Format("2006-01-02 15:04:05")
t.Logf("\n[%d] 时间: %s", i, startTime)
t.Logf(" Open: %.2f", k.Open)
t.Logf(" High: %.2f", k.High)
t.Logf(" Low: %.2f", k.Low)
t.Logf(" Close: %.2f", k.Close)
t.Logf(" Volume: %.2f (k[6])", k.Volume)
t.Logf(" Quantity: %.2f (k[7])", k.Quantity)
t.Logf(" Count: %.0f (k[8])", k.Count)
// 计算验证
if k.Close > 0 {
calcQuote := k.Volume * k.Close
t.Logf(" --- 验证 ---")
t.Logf(" Volume × Close = %.2f", calcQuote)
t.Logf(" Quantity / Close = %.2f", k.Quantity/k.Close)
}
}
// 打印原始 JSON
res, _ := json.MarshalIndent(resp, "", " ")
fmt.Printf("\n原始 JSON:\n%s\n", res)
}

View File

@@ -0,0 +1,414 @@
package hyperliquid
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
MainnetAPIURL = "https://api.hyperliquid.xyz/info"
TestnetAPIURL = "https://api.hyperliquid-testnet.xyz/info"
)
// Candle represents a single OHLCV candle from Hyperliquid
type Candle struct {
OpenTime int64 `json:"t"` // Open time in milliseconds
CloseTime int64 `json:"T"` // Close time in milliseconds
Symbol string `json:"s"` // Coin symbol
Interval string `json:"i"` // Interval
Open string `json:"o"` // Open price
High string `json:"h"` // High price
Low string `json:"l"` // Low price
Close string `json:"c"` // Close price
Volume string `json:"v"` // Volume in base unit
TradeCount int `json:"n"` // Number of trades
}
// CandleRequest represents the request for candleSnapshot
type CandleRequest struct {
Type string `json:"type"`
Req CandleRequestBody `json:"req"`
}
// CandleRequestBody represents the body of candleSnapshot request
type CandleRequestBody struct {
Coin string `json:"coin"`
Interval string `json:"interval"`
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
}
// Client is the Hyperliquid API client
type Client struct {
apiURL string
client *http.Client
}
// NewClient creates a new Hyperliquid client for mainnet
func NewClient() *Client {
return &Client{
apiURL: MainnetAPIURL,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewTestnetClient creates a new Hyperliquid client for testnet
func NewTestnetClient() *Client {
return &Client{
apiURL: TestnetAPIURL,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// GetCandles fetches historical candlestick data for a symbol
// coin: symbol name (e.g., "BTC", "TSLA", "AAPL", "xyz:TSLA")
// interval: "1m", "5m", "15m", "1h", "4h", "1d"
// limit: number of candles to fetch (max 5000)
func (c *Client) GetCandles(ctx context.Context, coin string, interval string, limit int) ([]Candle, error) {
// Format coin name for API (stock perps need xyz: prefix)
coin = FormatCoinForAPI(coin)
// Calculate time range based on interval and limit
now := time.Now()
endTime := now.UnixMilli()
// Calculate start time based on interval
intervalDuration := getIntervalDuration(interval)
startTime := now.Add(-intervalDuration * time.Duration(limit)).UnixMilli()
// Build request
reqBody := CandleRequest{
Type: "candleSnapshot",
Req: CandleRequestBody{
Coin: coin,
Interval: interval,
StartTime: startTime,
EndTime: endTime,
},
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create request
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Execute request
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body))
}
// Parse response
var candles []Candle
if err := json.Unmarshal(body, &candles); err != nil {
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body))
}
return candles, nil
}
// GetAllMids fetches current mid prices for all assets (default perp dex)
func (c *Client) GetAllMids(ctx context.Context) (map[string]string, error) {
return c.GetAllMidsWithDex(ctx, "")
}
// GetAllMidsXYZ fetches current mid prices for xyz dex (stocks, forex, commodities)
func (c *Client) GetAllMidsXYZ(ctx context.Context) (map[string]string, error) {
return c.GetAllMidsWithDex(ctx, XYZDex)
}
// GetAllMidsWithDex fetches current mid prices for a specific dex
func (c *Client) GetAllMidsWithDex(ctx context.Context, dex string) (map[string]string, error) {
reqBody := map[string]string{"type": "allMids"}
if dex != "" {
reqBody["dex"] = dex
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.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("hyperliquid API error (status %d): %s", resp.StatusCode, string(body))
}
var mids map[string]string
if err := json.Unmarshal(body, &mids); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return mids, nil
}
// GetMeta fetches metadata for all perpetual assets
func (c *Client) GetMeta(ctx context.Context) (*Meta, error) {
reqBody := map[string]string{"type": "meta"}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.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("hyperliquid API error (status %d): %s", resp.StatusCode, string(body))
}
var meta Meta
if err := json.Unmarshal(body, &meta); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &meta, nil
}
// Meta represents the metadata response
type Meta struct {
Universe []AssetInfo `json:"universe"`
}
// AssetInfo represents information about a single asset
type AssetInfo struct {
Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
}
// NormalizeCoin normalizes coin name for Hyperliquid API
// Examples:
// - "BTCUSDT" -> "BTC"
// - "TSLA-USDC" -> "TSLA"
// - "xyz:TSLA" -> "TSLA"
// - "BTC" -> "BTC"
func NormalizeCoin(symbol string) string {
return NormalizeCoinBase(symbol)
}
// MapTimeframe maps common timeframe strings to Hyperliquid format
func MapTimeframe(interval string) string {
switch interval {
case "1m":
return "1m"
case "3m":
return "5m" // Hyperliquid doesn't have 3m, use 5m
case "5m":
return "5m"
case "15m":
return "15m"
case "30m":
return "30m"
case "1h":
return "1h"
case "2h":
return "1h" // Hyperliquid doesn't have 2h, use 1h
case "4h":
return "4h"
case "6h":
return "4h" // Hyperliquid doesn't have 6h, use 4h
case "8h":
return "8h"
case "12h":
return "12h"
case "1d":
return "1d"
case "3d":
return "1d" // Hyperliquid doesn't have 3d, use 1d
case "1w":
return "1w"
case "1M":
return "1M"
default:
return "5m" // Default to 5 minutes
}
}
// getIntervalDuration returns the duration for a given interval
func getIntervalDuration(interval string) time.Duration {
switch interval {
case "1m":
return time.Minute
case "5m":
return 5 * time.Minute
case "15m":
return 15 * time.Minute
case "30m":
return 30 * time.Minute
case "1h":
return time.Hour
case "4h":
return 4 * time.Hour
case "8h":
return 8 * time.Hour
case "12h":
return 12 * time.Hour
case "1d":
return 24 * time.Hour
case "1w":
return 7 * 24 * time.Hour
case "1M":
return 30 * 24 * time.Hour
default:
return 5 * time.Minute
}
}
// XYZ Dex name for stock perps, forex, and commodities
const XYZDex = "xyz"
// Stock perps symbols available on Hyperliquid xyz dex
// Use xyz:SYMBOL format when calling the API
var StockPerpsSymbols = []string{
"TSLA", // Tesla
"AAPL", // Apple
"NVDA", // Nvidia
"MSFT", // Microsoft
"META", // Meta
"AMZN", // Amazon
"GOOGL", // Alphabet
"AMD", // AMD
"COIN", // Coinbase
"NFLX", // Netflix
"PLTR", // Palantir
"HOOD", // Robinhood
"INTC", // Intel
"MSTR", // MicroStrategy
"TSM", // TSMC
"ORCL", // Oracle
"MU", // Micron
"RIVN", // Rivian
"COST", // Costco
"LLY", // Eli Lilly
"CRCL", // Circle (new)
"SKHX", // Skyward (new)
"SNDK", // Sandisk (new)
}
// Forex and commodities on xyz dex
var XYZOtherSymbols = []string{
"GOLD", // Gold
"SILVER", // Silver
"EUR", // EUR/USD
"JPY", // USD/JPY
"XYZ100", // Index
}
// IsStockPerp checks if a symbol is a stock perpetual
func IsStockPerp(symbol string) bool {
coin := NormalizeCoinBase(symbol)
for _, s := range StockPerpsSymbols {
if s == coin {
return true
}
}
return false
}
// IsXYZAsset checks if a symbol is on the xyz dex (stocks, forex, commodities)
func IsXYZAsset(symbol string) bool {
coin := NormalizeCoinBase(symbol)
// Check stock perps
for _, s := range StockPerpsSymbols {
if s == coin {
return true
}
}
// Check other xyz assets
for _, s := range XYZOtherSymbols {
if s == coin {
return true
}
}
return false
}
// NormalizeCoinBase removes common suffixes to get base symbol
func NormalizeCoinBase(symbol string) string {
// Remove xyz: prefix if present
if strings.HasPrefix(symbol, "xyz:") {
return strings.TrimPrefix(symbol, "xyz:")
}
// Remove -USDC suffix
if strings.HasSuffix(symbol, "-USDC") {
return strings.TrimSuffix(symbol, "-USDC")
}
// Remove USDT suffix
if strings.HasSuffix(symbol, "USDT") {
return strings.TrimSuffix(symbol, "USDT")
}
// Remove USD suffix
if strings.HasSuffix(symbol, "USD") {
return strings.TrimSuffix(symbol, "USD")
}
return symbol
}
// FormatCoinForAPI formats the coin name for Hyperliquid API
// Stock perps need xyz:SYMBOL format, crypto uses plain symbol
func FormatCoinForAPI(symbol string) string {
base := NormalizeCoinBase(symbol)
if IsXYZAsset(base) {
return "xyz:" + base
}
return base
}

View File

@@ -0,0 +1,219 @@
package hyperliquid
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
)
func TestGetCandles_BTC(t *testing.T) {
client := NewClient()
candles, err := client.GetCandles(context.TODO(), "BTC", "1d", 5)
if err != nil {
t.Fatal(err)
}
t.Log("=== BTC 日线数据 (Hyperliquid) ===")
for i, c := range candles {
openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05")
t.Logf("\n[%d] 时间: %s", i, openTime)
t.Logf(" Symbol: %s", c.Symbol)
t.Logf(" Interval: %s", c.Interval)
t.Logf(" Open: %s", c.Open)
t.Logf(" High: %s", c.High)
t.Logf(" Low: %s", c.Low)
t.Logf(" Close: %s", c.Close)
t.Logf(" Volume: %s", c.Volume)
t.Logf(" TradeCount: %d", c.TradeCount)
}
// 打印原始 JSON
res, _ := json.MarshalIndent(candles, "", " ")
fmt.Printf("\n原始 JSON:\n%s\n", res)
}
func TestGetCandles_TSLA(t *testing.T) {
client := NewClient()
// 测试股票永续合约 - 使用 xyz dex
candles, err := client.GetCandles(context.TODO(), "TSLA", "1d", 5)
if err != nil {
t.Fatal(err)
}
t.Log("=== TSLA 日线数据 (Hyperliquid xyz dex) ===")
for i, c := range candles {
openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05")
t.Logf("\n[%d] 时间: %s", i, openTime)
t.Logf(" Symbol: %s", c.Symbol)
t.Logf(" Interval: %s", c.Interval)
t.Logf(" Open: %s", c.Open)
t.Logf(" High: %s", c.High)
t.Logf(" Low: %s", c.Low)
t.Logf(" Close: %s", c.Close)
t.Logf(" Volume: %s", c.Volume)
t.Logf(" TradeCount: %d", c.TradeCount)
}
// 打印原始 JSON
res, _ := json.MarshalIndent(candles, "", " ")
fmt.Printf("\n原始 JSON:\n%s\n", res)
}
func TestGetCandles_StockPerps(t *testing.T) {
client := NewClient()
// 测试多个股票永续合约 (xyz dex)
symbols := []string{"TSLA", "NVDA", "AAPL", "MSFT"}
for _, symbol := range symbols {
t.Logf("\n=== %s 日线数据 ===", symbol)
candles, err := client.GetCandles(context.TODO(), symbol, "1d", 3)
if err != nil {
t.Errorf("%s 获取失败: %v", symbol, err)
continue
}
if len(candles) == 0 {
t.Logf("%s: 无数据", symbol)
continue
}
latest := candles[len(candles)-1]
openTime := time.UnixMilli(latest.OpenTime).Format("2006-01-02")
t.Logf("%s 最新: %s Open=%s High=%s Low=%s Close=%s Vol=%s",
symbol, openTime, latest.Open, latest.High, latest.Low, latest.Close, latest.Volume)
}
}
func TestGetAllMids(t *testing.T) {
client := NewClient()
mids, err := client.GetAllMids(context.TODO())
if err != nil {
t.Fatal(err)
}
t.Log("=== 加密货币资产中间价 (默认 dex) ===")
// 显示一些主要加密货币资产
cryptoAssets := []string{"BTC", "ETH", "SOL", "DOGE", "XRP"}
for _, asset := range cryptoAssets {
if mid, ok := mids[asset]; ok {
t.Logf("%s: %s", asset, mid)
} else {
t.Logf("%s: 不存在", asset)
}
}
t.Logf("\n总共 %d 个加密货币交易对", len(mids))
}
func TestGetAllMidsXYZ(t *testing.T) {
client := NewClient()
mids, err := client.GetAllMidsXYZ(context.TODO())
if err != nil {
t.Fatal(err)
}
t.Log("=== xyz dex 资产中间价 (股票、外汇、大宗商品) ===")
// 显示所有 xyz dex 资产
for symbol, mid := range mids {
t.Logf("%s: %s", symbol, mid)
}
t.Logf("\n总共 %d 个 xyz dex 交易对", len(mids))
}
func TestGetMeta(t *testing.T) {
client := NewClient()
meta, err := client.GetMeta(context.TODO())
if err != nil {
t.Fatal(err)
}
t.Log("=== 资产元数据 ===")
t.Logf("总共 %d 个资产", len(meta.Universe))
// 显示股票永续合约
t.Log("\n股票永续合约:")
for _, asset := range meta.Universe {
if IsStockPerp(asset.Name) {
t.Logf(" %s: szDecimals=%d, maxLeverage=%d", asset.Name, asset.SzDecimals, asset.MaxLeverage)
}
}
}
func TestNormalizeCoin(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"BTC", "BTC"},
{"BTCUSDT", "BTC"},
{"BTCUSD", "BTC"},
{"TSLA-USDC", "TSLA"},
{"AAPL-USDC", "AAPL"},
{"ETH", "ETH"},
{"ETHUSDT", "ETH"},
}
for _, tt := range tests {
result := NormalizeCoin(tt.input)
if result != tt.expected {
t.Errorf("NormalizeCoin(%s) = %s, expected %s", tt.input, result, tt.expected)
}
}
}
func TestIsStockPerp(t *testing.T) {
tests := []struct {
symbol string
expected bool
}{
{"TSLA", true},
{"TSLA-USDC", true},
{"xyz:TSLA", true},
{"AAPL", true},
{"BTC", false},
{"BTCUSDT", false},
{"ETH", false},
}
for _, tt := range tests {
result := IsStockPerp(tt.symbol)
if result != tt.expected {
t.Errorf("IsStockPerp(%s) = %v, expected %v", tt.symbol, result, tt.expected)
}
}
}
func TestFormatCoinForAPI(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"BTC", "BTC"},
{"BTCUSDT", "BTC"},
{"ETH", "ETH"},
{"TSLA", "xyz:TSLA"},
{"TSLA-USDC", "xyz:TSLA"},
{"xyz:TSLA", "xyz:TSLA"},
{"NVDA", "xyz:NVDA"},
{"GOLD", "xyz:GOLD"},
{"EUR", "xyz:EUR"},
}
for _, tt := range tests {
result := FormatCoinForAPI(tt.input)
if result != tt.expected {
t.Errorf("FormatCoinForAPI(%s) = %s, expected %s", tt.input, result, tt.expected)
}
}
}

View File

@@ -0,0 +1,271 @@
package twelvedata
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"nofx/config"
"strconv"
"time"
)
const (
BaseURL = "https://api.twelvedata.com"
)
// Bar represents a single OHLCV bar from Twelve Data
type Bar struct {
Datetime string `json:"datetime"`
Open string `json:"open"`
High string `json:"high"`
Low string `json:"low"`
Close string `json:"close"`
Volume string `json:"volume,omitempty"`
}
// TimeSeriesResponse represents the response from Twelve Data time_series API
type TimeSeriesResponse struct {
Meta Meta `json:"meta"`
Values []Bar `json:"values"`
Status string `json:"status"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// Meta contains metadata about the time series
type Meta struct {
Symbol string `json:"symbol"`
Interval string `json:"interval"`
CurrencyBase string `json:"currency_base,omitempty"`
CurrencyQuote string `json:"currency_quote,omitempty"`
Type string `json:"type,omitempty"`
Exchange string `json:"exchange,omitempty"`
ExchangeTimezone string `json:"exchange_timezone,omitempty"`
}
// QuoteResponse represents the response from Twelve Data quote API
type QuoteResponse struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Exchange string `json:"exchange"`
Open string `json:"open"`
High string `json:"high"`
Low string `json:"low"`
Close string `json:"close"`
PreviousClose string `json:"previous_close"`
Volume string `json:"volume,omitempty"`
Change string `json:"change"`
PercentChange string `json:"percent_change"`
AverageVolume string `json:"average_volume,omitempty"`
FiftyTwoWeekHigh string `json:"fifty_two_week_high,omitempty"`
FiftyTwoWeekLow string `json:"fifty_two_week_low,omitempty"`
Datetime string `json:"datetime"`
Status string `json:"status,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// Client is the Twelve Data API client
type Client struct {
apiKey string
client *http.Client
}
// NewClient creates a new Twelve Data client from config
func NewClient() *Client {
return &Client{
apiKey: config.Get().TwelveDataKey,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewClientWithKey creates a new Twelve Data client with provided key
func NewClientWithKey(apiKey string) *Client {
return &Client{
apiKey: apiKey,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// GetTimeSeries fetches historical bars for a symbol
// interval: 1min, 5min, 15min, 30min, 45min, 1h, 2h, 4h, 1day, 1week, 1month
func (c *Client) GetTimeSeries(ctx context.Context, symbol string, interval string, limit int) (*TimeSeriesResponse, error) {
if c.apiKey == "" {
return nil, fmt.Errorf("twelve data API key not configured")
}
// Build URL
endpoint := fmt.Sprintf("%s/time_series", BaseURL)
params := url.Values{}
params.Set("symbol", symbol)
params.Set("interval", interval)
params.Set("outputsize", fmt.Sprintf("%d", limit))
params.Set("apikey", c.apiKey)
fullURL := endpoint + "?" + params.Encode()
// Create request
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Execute request
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Parse response
var result TimeSeriesResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Check for API errors
if result.Status == "error" {
return nil, fmt.Errorf("twelve data API error: %s", result.Message)
}
return &result, nil
}
// GetQuote fetches real-time quote for a symbol
func (c *Client) GetQuote(ctx context.Context, symbol string) (*QuoteResponse, error) {
if c.apiKey == "" {
return nil, fmt.Errorf("twelve data API key not configured")
}
// Build URL
endpoint := fmt.Sprintf("%s/quote", BaseURL)
params := url.Values{}
params.Set("symbol", symbol)
params.Set("apikey", c.apiKey)
fullURL := endpoint + "?" + params.Encode()
// Create request
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Execute request
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Parse response
var result QuoteResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Check for API errors
if result.Status == "error" {
return nil, fmt.Errorf("twelve data API error: %s", result.Message)
}
return &result, nil
}
// MapTimeframe maps common timeframe strings to Twelve Data format
func MapTimeframe(interval string) string {
switch interval {
case "1m":
return "1min"
case "3m":
return "5min" // Twelve Data doesn't have 3m, use 5m
case "5m":
return "5min"
case "10m":
return "15min" // Twelve Data doesn't have 10m, use 15m
case "15m":
return "15min"
case "30m":
return "30min"
case "1h":
return "1h"
case "2h":
return "2h"
case "4h":
return "4h"
case "6h":
return "4h" // Twelve Data doesn't have 6h, use 4h
case "8h":
return "4h" // Twelve Data doesn't have 8h, use 4h
case "12h":
return "4h" // Twelve Data doesn't have 12h, use 4h
case "1d":
return "1day"
case "3d":
return "1day" // Twelve Data doesn't have 3d, use 1d
case "1w":
return "1week"
case "1M":
return "1month"
default:
return "5min" // Default to 5 minutes
}
}
// ParseBar converts a Twelve Data bar to numeric values
func ParseBar(bar Bar) (open, high, low, close, volume float64, timestamp int64, err error) {
open, err = strconv.ParseFloat(bar.Open, 64)
if err != nil {
return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse open: %w", err)
}
high, err = strconv.ParseFloat(bar.High, 64)
if err != nil {
return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse high: %w", err)
}
low, err = strconv.ParseFloat(bar.Low, 64)
if err != nil {
return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse low: %w", err)
}
close, err = strconv.ParseFloat(bar.Close, 64)
if err != nil {
return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse close: %w", err)
}
// Volume might be empty for forex
if bar.Volume != "" {
volume, _ = strconv.ParseFloat(bar.Volume, 64)
}
// Parse datetime - format is "2024-01-15 09:30:00" or "2024-01-15"
var t time.Time
if len(bar.Datetime) > 10 {
t, err = time.Parse("2006-01-02 15:04:05", bar.Datetime)
} else {
t, err = time.Parse("2006-01-02", bar.Datetime)
}
if err != nil {
return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse datetime: %w", err)
}
timestamp = t.UnixMilli()
return open, high, low, close, volume, timestamp, nil
}