mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
47bff87966
- 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
272 lines
7.2 KiB
Go
272 lines
7.2 KiB
Go
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
|
|
}
|