service backend
This commit is contained in:
139
api/fx/ingestor/internal/market/binance/connector.go
Normal file
139
api/fx/ingestor/internal/market/binance/connector.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package binance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type binanceConnector struct {
|
||||
id mmodel.Driver
|
||||
provider string
|
||||
client *http.Client
|
||||
base string
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
const defaultBinanceBaseURL = "https://api.binance.com"
|
||||
const (
|
||||
defaultDialTimeoutSeconds = 5 * time.Second
|
||||
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||
)
|
||||
|
||||
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||
baseURL := defaultBinanceBaseURL
|
||||
provider := strings.ToLower(mmodel.DriverBinance.String())
|
||||
dialTimeout := defaultDialTimeoutSeconds
|
||||
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||
requestTimeout := defaultRequestTimeoutSeconds
|
||||
|
||||
if settings != nil {
|
||||
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
baseURL = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
provider = strings.TrimSpace(value)
|
||||
}
|
||||
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("binance: invalid base url", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
}
|
||||
|
||||
connector := &binanceConnector{
|
||||
id: mmodel.DriverBinance,
|
||||
provider: provider,
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
},
|
||||
base: parsed.String(),
|
||||
logger: logger.Named("binance"),
|
||||
}
|
||||
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
func (c *binanceConnector) ID() mmodel.Driver {
|
||||
return c.id
|
||||
}
|
||||
|
||||
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||
if strings.TrimSpace(symbol) == "" {
|
||||
return nil, fmerrors.New("binance: symbol is empty")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("binance: parse base url", err)
|
||||
}
|
||||
endpoint.Path = "/api/v3/ticker/bookTicker"
|
||||
query := endpoint.Query()
|
||||
query.Set("symbol", strings.ToUpper(strings.TrimSpace(symbol)))
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("binance: build request", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("binance: request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Symbol string `json:"symbol"`
|
||||
BidPrice string `json:"bidPrice"`
|
||||
AskPrice string `json:"askPrice"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("binance: decode response", err)
|
||||
}
|
||||
|
||||
return &mmodel.Ticker{
|
||||
Symbol: payload.Symbol,
|
||||
BidPrice: payload.BidPrice,
|
||||
AskPrice: payload.AskPrice,
|
||||
Provider: c.provider,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
222
api/fx/ingestor/internal/market/coingecko/connector.go
Normal file
222
api/fx/ingestor/internal/market/coingecko/connector.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package coingecko
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type coingeckoConnector struct {
|
||||
id mmodel.Driver
|
||||
provider string
|
||||
client *http.Client
|
||||
base string
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
const defaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3"
|
||||
|
||||
const (
|
||||
defaultDialTimeoutSeconds = 5 * time.Second
|
||||
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||
)
|
||||
|
||||
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||
baseURL := defaultCoinGeckoBaseURL
|
||||
provider := strings.ToLower(mmodel.DriverCoinGecko.String())
|
||||
dialTimeout := defaultDialTimeoutSeconds
|
||||
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||
requestTimeout := defaultRequestTimeoutSeconds
|
||||
|
||||
if settings != nil {
|
||||
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
baseURL = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
provider = strings.TrimSpace(value)
|
||||
}
|
||||
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
}
|
||||
|
||||
connector := &coingeckoConnector{
|
||||
id: mmodel.DriverCoinGecko,
|
||||
provider: provider,
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
},
|
||||
base: strings.TrimRight(parsed.String(), "/"),
|
||||
logger: logger.Named("coingecko"),
|
||||
}
|
||||
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
func (c *coingeckoConnector) ID() mmodel.Driver {
|
||||
return c.id
|
||||
}
|
||||
|
||||
func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||
coinID, vsCurrency, err := parseSymbol(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("coingecko: parse base url", err)
|
||||
}
|
||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
|
||||
query := endpoint.Query()
|
||||
query.Set("ids", coinID)
|
||||
query.Set("vs_currencies", vsCurrency)
|
||||
query.Set("include_last_updated_at", "true")
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("coingecko: build request", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("coingecko: request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
decoder.UseNumber()
|
||||
|
||||
var payload map[string]map[string]interface{}
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("coingecko: decode response", err)
|
||||
}
|
||||
|
||||
coinData, ok := payload[coinID]
|
||||
if !ok {
|
||||
return nil, fmerrors.New("coingecko: coin id not found in response")
|
||||
}
|
||||
priceValue, ok := coinData[vsCurrency]
|
||||
if !ok {
|
||||
return nil, fmerrors.New("coingecko: vs currency not found in response")
|
||||
}
|
||||
|
||||
price, ok := toFloat(priceValue)
|
||||
if !ok || price <= 0 {
|
||||
return nil, fmerrors.New("coingecko: invalid price value in response")
|
||||
}
|
||||
|
||||
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
|
||||
|
||||
timestamp := time.Now().UnixMilli()
|
||||
if tsValue, ok := coinData["last_updated_at"]; ok {
|
||||
if tsFloat, ok := toFloat(tsValue); ok && tsFloat > 0 {
|
||||
tsMillis := int64(tsFloat * 1000)
|
||||
if tsMillis > 0 {
|
||||
timestamp = tsMillis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refSymbol := coinID + "_" + vsCurrency
|
||||
|
||||
return &mmodel.Ticker{
|
||||
Symbol: refSymbol,
|
||||
BidPrice: priceStr,
|
||||
AskPrice: priceStr,
|
||||
Provider: c.provider,
|
||||
Timestamp: timestamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSymbol(symbol string) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(symbol)
|
||||
if trimmed == "" {
|
||||
return "", "", fmerrors.New("coingecko: symbol is empty")
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
|
||||
switch r {
|
||||
case ':', '/', '-', '_':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
|
||||
}
|
||||
|
||||
coinID := strings.TrimSpace(parts[0])
|
||||
vsCurrency := strings.TrimSpace(parts[1])
|
||||
if coinID == "" || vsCurrency == "" {
|
||||
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
|
||||
}
|
||||
|
||||
return coinID, vsCurrency, nil
|
||||
}
|
||||
|
||||
func toFloat(value interface{}) (float64, bool) {
|
||||
switch v := value.(type) {
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case uint64:
|
||||
return float64(v), true
|
||||
case string:
|
||||
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
46
api/fx/ingestor/internal/market/common/settings.go
Normal file
46
api/fx/ingestor/internal/market/common/settings.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// DurationSetting reads a positive duration override from settings or returns def when the value is missing or invalid.
|
||||
func DurationSetting(settings model.SettingsT, key string, def time.Duration) time.Duration {
|
||||
if settings == nil {
|
||||
return def
|
||||
}
|
||||
value, ok := settings[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case time.Duration:
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
case int:
|
||||
if v > 0 {
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
case int64:
|
||||
if v > 0 {
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
case float64:
|
||||
if v > 0 {
|
||||
return time.Duration(v * float64(time.Second))
|
||||
}
|
||||
case string:
|
||||
if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
if seconds, err := strconv.ParseFloat(v, 64); err == nil && seconds > 0 {
|
||||
return time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
55
api/fx/ingestor/internal/market/factory.go
Normal file
55
api/fx/ingestor/internal/market/factory.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type ConnectorFactory func(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error)
|
||||
|
||||
func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.Driver]) (map[mmodel.Driver]mmodel.Connector, error) {
|
||||
connectors := make(map[mmodel.Driver]mmodel.Connector, len(configs))
|
||||
|
||||
for _, cfg := range configs {
|
||||
driver := mmodel.NormalizeDriver(cfg.Driver)
|
||||
if driver.IsEmpty() {
|
||||
return nil, fmerrors.New("market: connector driver is empty")
|
||||
}
|
||||
|
||||
var (
|
||||
conn mmodel.Connector
|
||||
err error
|
||||
)
|
||||
|
||||
switch driver {
|
||||
case mmodel.DriverBinance:
|
||||
conn, err = binance.NewConnector(logger, cfg.Settings)
|
||||
case mmodel.DriverCoinGecko:
|
||||
conn, err = coingecko.NewConnector(logger, cfg.Settings)
|
||||
default:
|
||||
err = fmerrors.New("market: unsupported driver " + driver.String())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
|
||||
}
|
||||
connectors[driver] = conn
|
||||
}
|
||||
|
||||
return connectors, nil
|
||||
}
|
||||
|
||||
func BuildRateReference(provider, symbol string, now time.Time) string {
|
||||
if strings.TrimSpace(provider) == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
return provider + ":" + symbol + ":" + strconv.FormatInt(now.UnixMilli(), 10)
|
||||
}
|
||||
Reference in New Issue
Block a user