service backend
This commit is contained in:
27
api/fx/ingestor/internal/appversion/version.go
Normal file
27
api/fx/ingestor/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
func Create() version.Printer {
|
||||
vi := version.Info{
|
||||
Program: "MeetX Connectica FX Ingestor Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&vi)
|
||||
}
|
||||
147
api/fx/ingestor/internal/config/config.go
Normal file
147
api/fx/ingestor/internal/config/config.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPollInterval = 30 * time.Second
|
||||
|
||||
type Config struct {
|
||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||
Market MarketConfig `yaml:"market"`
|
||||
Database *db.Config `yaml:"database"`
|
||||
Metrics *MetricsConfig `yaml:"metrics"`
|
||||
|
||||
pairs []Pair
|
||||
pairsBySource map[mmodel.Driver][]PairConfig
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
return nil, fmerrors.New("config: path is empty")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("config: failed to read file", err)
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
|
||||
}
|
||||
|
||||
if len(cfg.Market.Sources) == 0 {
|
||||
return nil, fmerrors.New("config: no market sources configured")
|
||||
}
|
||||
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
||||
for idx := range cfg.Market.Sources {
|
||||
src := &cfg.Market.Sources[idx]
|
||||
if src.Driver.IsEmpty() {
|
||||
return nil, fmerrors.New("config: market source driver is empty")
|
||||
}
|
||||
sourceSet[src.Driver] = struct{}{}
|
||||
}
|
||||
|
||||
if len(cfg.Market.Pairs) == 0 {
|
||||
return nil, fmerrors.New("config: no pairs configured")
|
||||
}
|
||||
|
||||
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
||||
pairsBySource := make(map[mmodel.Driver][]PairConfig, len(cfg.Market.Pairs))
|
||||
var flattened []Pair
|
||||
|
||||
for rawSource, pairList := range cfg.Market.Pairs {
|
||||
driver := mmodel.Driver(rawSource)
|
||||
if driver.IsEmpty() {
|
||||
return nil, fmerrors.New("config: pair source is empty")
|
||||
}
|
||||
if _, ok := sourceSet[driver]; !ok {
|
||||
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
|
||||
}
|
||||
|
||||
processed := make([]PairConfig, len(pairList))
|
||||
for idx := range pairList {
|
||||
pair := pairList[idx]
|
||||
pair.Base = strings.ToUpper(strings.TrimSpace(pair.Base))
|
||||
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
||||
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
||||
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
||||
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
|
||||
}
|
||||
if strings.TrimSpace(pair.Provider) == "" {
|
||||
pair.Provider = strings.ToLower(driver.String())
|
||||
}
|
||||
processed[idx] = pair
|
||||
flattened = append(flattened, Pair{
|
||||
PairConfig: pair,
|
||||
Source: driver,
|
||||
})
|
||||
}
|
||||
pairsBySource[driver] = processed
|
||||
normalizedPairs[driver.String()] = processed
|
||||
}
|
||||
|
||||
cfg.Market.Pairs = normalizedPairs
|
||||
cfg.pairsBySource = pairsBySource
|
||||
cfg.pairs = flattened
|
||||
if cfg.Database == nil {
|
||||
return nil, fmerrors.New("config: database configuration is required")
|
||||
}
|
||||
|
||||
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
||||
cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address)
|
||||
if cfg.Metrics.Address == "" {
|
||||
cfg.Metrics.Address = ":9102"
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) PollInterval() time.Duration {
|
||||
if c == nil {
|
||||
return defaultPollInterval
|
||||
}
|
||||
if c.PollIntervalSeconds <= 0 {
|
||||
return defaultPollInterval
|
||||
}
|
||||
return time.Duration(c.PollIntervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c *Config) Pairs() []Pair {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Pair, len(c.pairs))
|
||||
copy(out, c.pairs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) PairsBySource() map[mmodel.Driver][]PairConfig {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[mmodel.Driver][]PairConfig, len(c.pairsBySource))
|
||||
for driver, pairs := range c.pairsBySource {
|
||||
cp := make([]PairConfig, len(pairs))
|
||||
copy(cp, pairs)
|
||||
out[driver] = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) MetricsConfig() *MetricsConfig {
|
||||
if c == nil || c.Metrics == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *c.Metrics
|
||||
return &cp
|
||||
}
|
||||
24
api/fx/ingestor/internal/config/market.go
Normal file
24
api/fx/ingestor/internal/config/market.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type PairConfig struct {
|
||||
Base string `yaml:"base"`
|
||||
Quote string `yaml:"quote"`
|
||||
Symbol string `yaml:"symbol"`
|
||||
Provider string `yaml:"provider"`
|
||||
Invert bool `yaml:"invert"`
|
||||
}
|
||||
|
||||
type Pair struct {
|
||||
PairConfig `yaml:",inline"`
|
||||
Source mmodel.Driver `yaml:"-"`
|
||||
}
|
||||
|
||||
type MarketConfig struct {
|
||||
Sources []pmodel.DriverConfig[mmodel.Driver] `yaml:"sources"`
|
||||
Pairs map[string][]PairConfig `yaml:"pairs"`
|
||||
}
|
||||
6
api/fx/ingestor/internal/config/metrics.go
Normal file
6
api/fx/ingestor/internal/config/metrics.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
type MetricsConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
35
api/fx/ingestor/internal/fmerrors/market.go
Normal file
35
api/fx/ingestor/internal/fmerrors/market.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package fmerrors
|
||||
|
||||
type Error struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.cause == nil {
|
||||
return e.message
|
||||
}
|
||||
return e.message + ": " + e.cause.Error()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func New(message string) error {
|
||||
return &Error{message: message}
|
||||
}
|
||||
|
||||
func Wrap(message string, cause error) error {
|
||||
return &Error{message: message, cause: cause}
|
||||
}
|
||||
|
||||
func NewDecimal(value string) error {
|
||||
return &Error{message: "invalid decimal \"" + value + "\""}
|
||||
}
|
||||
84
api/fx/ingestor/internal/ingestor/metrics.go
Normal file
84
api/fx/ingestor/internal/ingestor/metrics.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ingestor
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
type serviceMetrics struct {
|
||||
pollDuration *prometheus.HistogramVec
|
||||
pollTotal *prometheus.CounterVec
|
||||
pairDuration *prometheus.HistogramVec
|
||||
pairTotal *prometheus.CounterVec
|
||||
pairLastUpdate *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
globalMetricsRef *serviceMetrics
|
||||
)
|
||||
|
||||
func getServiceMetrics() *serviceMetrics {
|
||||
metricsOnce.Do(func() {
|
||||
reg := prometheus.DefaultRegisterer
|
||||
globalMetricsRef = &serviceMetrics{
|
||||
pollDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "fx_ingestor_poll_duration_seconds",
|
||||
Help: "Duration of a polling cycle.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"result"}),
|
||||
pollTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "fx_ingestor_poll_total",
|
||||
Help: "Total polling cycles executed.",
|
||||
}, []string{"result"}),
|
||||
pairDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "fx_ingestor_pair_duration_seconds",
|
||||
Help: "Duration of individual pair ingestion.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"source", "provider", "symbol", "result"}),
|
||||
pairTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "fx_ingestor_pair_total",
|
||||
Help: "Total ingestion attempts per pair.",
|
||||
}, []string{"source", "provider", "symbol", "result"}),
|
||||
pairLastUpdate: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "fx_ingestor_pair_last_success_unix",
|
||||
Help: "Unix timestamp of the last successful ingestion per pair.",
|
||||
}, []string{"source", "provider", "symbol"}),
|
||||
}
|
||||
})
|
||||
return globalMetricsRef
|
||||
}
|
||||
|
||||
func (m *serviceMetrics) observePoll(duration time.Duration, err error) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
result := labelForError(err)
|
||||
m.pollDuration.WithLabelValues(result).Observe(duration.Seconds())
|
||||
m.pollTotal.WithLabelValues(result).Inc()
|
||||
}
|
||||
|
||||
func (m *serviceMetrics) observePair(pair config.Pair, duration time.Duration, err error) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
result := labelForError(err)
|
||||
labels := []string{pair.Source.String(), pair.Provider, pair.Symbol, result}
|
||||
m.pairDuration.WithLabelValues(labels...).Observe(duration.Seconds())
|
||||
m.pairTotal.WithLabelValues(labels...).Inc()
|
||||
if err == nil {
|
||||
m.pairLastUpdate.WithLabelValues(pair.Source.String(), pair.Provider, pair.Symbol).
|
||||
Set(float64(time.Now().Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
func labelForError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return "success"
|
||||
}
|
||||
207
api/fx/ingestor/internal/ingestor/service.go
Normal file
207
api/fx/ingestor/internal/ingestor/service.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package ingestor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
cfg *config.Config
|
||||
rates storage.RatesStore
|
||||
pairs []config.Pair
|
||||
connectors map[mmodel.Driver]mmodel.Connector
|
||||
metrics *serviceMetrics
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
||||
if logger == nil {
|
||||
return nil, fmerrors.New("ingestor: nil logger")
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, fmerrors.New("ingestor: nil config")
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, fmerrors.New("ingestor: nil repository")
|
||||
}
|
||||
|
||||
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("build connectors", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
logger: logger.Named("ingestor"),
|
||||
cfg: cfg,
|
||||
rates: repo.Rates(),
|
||||
pairs: cfg.Pairs(),
|
||||
connectors: connectors,
|
||||
metrics: getServiceMetrics(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) error {
|
||||
interval := s.cfg.PollInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
s.logger.Info("FX ingestion service started", zap.Duration("poll_interval", interval), zap.Int("pairs", len(s.pairs)))
|
||||
|
||||
if err := s.executePoll(ctx); err != nil {
|
||||
s.logger.Warn("Initial poll completed with errors", zap.Error(err))
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Info("Context cancelled, stopping ingestor")
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := s.executePoll(ctx); err != nil {
|
||||
s.logger.Warn("Polling cycle completed with errors", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) executePoll(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
err := s.pollOnce(ctx)
|
||||
if s.metrics != nil {
|
||||
s.metrics.observePoll(time.Since(start), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) pollOnce(ctx context.Context) error {
|
||||
var firstErr error
|
||||
for _, pair := range s.pairs {
|
||||
start := time.Now()
|
||||
err := s.upsertPair(ctx, pair)
|
||||
elapsed := time.Since(start)
|
||||
if s.metrics != nil {
|
||||
s.metrics.observePair(pair, elapsed, err)
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
s.logger.Warn("Failed to ingest pair",
|
||||
zap.String("symbol", pair.Symbol),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.Duration("elapsed", elapsed),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
connector, ok := s.connectors[pair.Source]
|
||||
if !ok {
|
||||
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
|
||||
}
|
||||
|
||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("fetch ticker", err)
|
||||
}
|
||||
|
||||
bid, err := parseDecimal(ticker.BidPrice)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("parse bid price", err)
|
||||
}
|
||||
ask, err := parseDecimal(ticker.AskPrice)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("parse ask price", err)
|
||||
}
|
||||
|
||||
if pair.Invert {
|
||||
bid, ask = invertPrices(bid, ask)
|
||||
}
|
||||
|
||||
if ask.Cmp(bid) < 0 {
|
||||
// Ensure bid <= ask to keep downstream logic predictable.
|
||||
bid, ask = ask, bid
|
||||
}
|
||||
|
||||
mid := new(big.Rat).Add(bid, ask)
|
||||
mid.Quo(mid, big.NewRat(2, 1))
|
||||
|
||||
spread := big.NewRat(0, 1)
|
||||
if mid.Sign() != 0 {
|
||||
spread.Sub(ask, bid)
|
||||
if spread.Sign() < 0 {
|
||||
spread.Neg(spread)
|
||||
}
|
||||
spread.Quo(spread, mid)
|
||||
spread.Mul(spread, big.NewRat(10000, 1)) // basis points
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
asOf := now
|
||||
snapshot := &model.RateSnapshot{
|
||||
RateRef: market.BuildRateReference(pair.Provider, pair.Symbol, now),
|
||||
Pair: model.CurrencyPair{Base: pair.Base, Quote: pair.Quote},
|
||||
Provider: pair.Provider,
|
||||
Mid: formatDecimal(mid),
|
||||
Bid: formatDecimal(bid),
|
||||
Ask: formatDecimal(ask),
|
||||
SpreadBps: formatDecimal(spread),
|
||||
AsOfUnixMs: now.UnixMilli(),
|
||||
AsOf: &asOf,
|
||||
Source: ticker.Provider,
|
||||
ProviderRef: ticker.Symbol,
|
||||
}
|
||||
|
||||
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
||||
return fmerrors.Wrap("upsert snapshot", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Snapshot ingested",
|
||||
zap.String("pair", pair.Base+"/"+pair.Quote),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("bid", snapshot.Bid),
|
||||
zap.String("ask", snapshot.Ask),
|
||||
zap.String("mid", snapshot.Mid),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDecimal(value string) (*big.Rat, error) {
|
||||
r := new(big.Rat)
|
||||
if _, ok := r.SetString(value); !ok {
|
||||
return nil, fmerrors.NewDecimal(value)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func invertPrices(bid, ask *big.Rat) (*big.Rat, *big.Rat) {
|
||||
if bid.Sign() == 0 || ask.Sign() == 0 {
|
||||
return bid, ask
|
||||
}
|
||||
one := big.NewRat(1, 1)
|
||||
invBid := new(big.Rat).Quo(one, ask) // invert ask to get bid
|
||||
invAsk := new(big.Rat).Quo(one, bid) // invert bid to get ask
|
||||
return invBid, invAsk
|
||||
}
|
||||
|
||||
func formatDecimal(r *big.Rat) string {
|
||||
if r == nil {
|
||||
return "0"
|
||||
}
|
||||
// Format with 8 decimal places, trimming trailing zeros.
|
||||
return r.FloatString(8)
|
||||
}
|
||||
237
api/fx/ingestor/internal/ingestor/service_test.go
Normal file
237
api/fx/ingestor/internal/ingestor/service_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package ingestor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestParseDecimal(t *testing.T) {
|
||||
got, err := parseDecimal("123.456")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDecimal returned error: %v", err)
|
||||
}
|
||||
if got.String() != "15432/125" { // 123.456 expressed as a rational
|
||||
t.Fatalf("unexpected rational value: %s", got.String())
|
||||
}
|
||||
|
||||
if _, err := parseDecimal("not-a-number"); err == nil {
|
||||
t.Fatalf("parseDecimal should fail on invalid decimal string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvertPrices(t *testing.T) {
|
||||
bid, err := parseDecimal("2")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDecimal: %v", err)
|
||||
}
|
||||
ask, err := parseDecimal("4")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDecimal: %v", err)
|
||||
}
|
||||
|
||||
invBid, invAsk := invertPrices(bid, ask)
|
||||
if diff := cmp.Diff("0.5", invAsk.FloatString(1)); diff != "" {
|
||||
t.Fatalf("unexpected inverted ask (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff("0.25", invBid.FloatString(2)); diff != "" {
|
||||
t.Fatalf("unexpected inverted bid (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpsertPairStoresSnapshot(t *testing.T) {
|
||||
store := &ratesStoreStub{}
|
||||
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||
mmarket.DriverBinance: &connectorStub{
|
||||
id: mmarket.DriverBinance,
|
||||
ticker: &mmarket.Ticker{
|
||||
Symbol: "EURUSDT",
|
||||
BidPrice: "1.0000",
|
||||
AskPrice: "1.2000",
|
||||
Provider: "binance",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
pair := config.Pair{
|
||||
PairConfig: config.PairConfig{
|
||||
Base: "USDT",
|
||||
Quote: "EUR",
|
||||
Symbol: "EURUSDT",
|
||||
Provider: "binance",
|
||||
},
|
||||
Source: mmarket.DriverBinance,
|
||||
}
|
||||
|
||||
if err := svc.upsertPair(context.Background(), pair); err != nil {
|
||||
t.Fatalf("upsertPair returned error: %v", err)
|
||||
}
|
||||
if len(store.snapshots) != 1 {
|
||||
t.Fatalf("expected 1 snapshot stored, got %d", len(store.snapshots))
|
||||
}
|
||||
snap := store.snapshots[0]
|
||||
if snap.Pair.Base != "USDT" || snap.Pair.Quote != "EUR" {
|
||||
t.Fatalf("unexpected currency pair stored: %+v", snap.Pair)
|
||||
}
|
||||
if snap.Provider != "binance" {
|
||||
t.Fatalf("unexpected provider: %s", snap.Provider)
|
||||
}
|
||||
if snap.Bid != "1.00000000" || snap.Ask != "1.20000000" {
|
||||
t.Fatalf("unexpected bid/ask: %s / %s", snap.Bid, snap.Ask)
|
||||
}
|
||||
if snap.Mid != "1.10000000" {
|
||||
t.Fatalf("unexpected mid price: %s", snap.Mid)
|
||||
}
|
||||
if snap.SpreadBps != "1818.18181818" {
|
||||
t.Fatalf("unexpected spread bps: %s", snap.SpreadBps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpsertPairInvertsPrices(t *testing.T) {
|
||||
store := &ratesStoreStub{}
|
||||
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||
mmarket.DriverCoinGecko: &connectorStub{
|
||||
id: mmarket.DriverCoinGecko,
|
||||
ticker: &mmarket.Ticker{
|
||||
Symbol: "RUBUSDT",
|
||||
BidPrice: "2",
|
||||
AskPrice: "4",
|
||||
Provider: "coingecko",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
pair := config.Pair{
|
||||
PairConfig: config.PairConfig{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
Symbol: "RUBUSDT",
|
||||
Provider: "coingecko",
|
||||
Invert: true,
|
||||
},
|
||||
Source: mmarket.DriverCoinGecko,
|
||||
}
|
||||
|
||||
if err := svc.upsertPair(context.Background(), pair); err != nil {
|
||||
t.Fatalf("upsertPair returned error: %v", err)
|
||||
}
|
||||
|
||||
snap := store.snapshots[0]
|
||||
if snap.Bid != "0.25000000" || snap.Ask != "0.50000000" {
|
||||
t.Fatalf("unexpected inverted bid/ask: %s / %s", snap.Bid, snap.Ask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePollOnceReturnsFirstError(t *testing.T) {
|
||||
errFetch := fmerrors.New("fetch failed")
|
||||
connectorSuccess := &connectorStub{
|
||||
id: mmarket.DriverBinance,
|
||||
ticker: &mmarket.Ticker{
|
||||
Symbol: "EURUSDT",
|
||||
BidPrice: "1",
|
||||
AskPrice: "1",
|
||||
Provider: "binance",
|
||||
},
|
||||
}
|
||||
connectorFail := &connectorStub{
|
||||
id: mmarket.DriverCoinGecko,
|
||||
err: errFetch,
|
||||
}
|
||||
|
||||
store := &ratesStoreStub{}
|
||||
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||
mmarket.DriverBinance: connectorSuccess,
|
||||
mmarket.DriverCoinGecko: connectorFail,
|
||||
})
|
||||
svc.pairs = []config.Pair{
|
||||
{PairConfig: config.PairConfig{Base: "USDT", Quote: "EUR", Symbol: "EURUSDT"}, Source: mmarket.DriverBinance},
|
||||
{PairConfig: config.PairConfig{Base: "USDT", Quote: "RUB", Symbol: "RUBUSDT"}, Source: mmarket.DriverCoinGecko},
|
||||
}
|
||||
|
||||
err := svc.pollOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("pollOnce expected to return error")
|
||||
}
|
||||
if !errors.Is(err, errFetch) {
|
||||
t.Fatalf("pollOnce returned unexpected error: %v", err)
|
||||
}
|
||||
if connectorSuccess.calls != 1 {
|
||||
t.Fatalf("expected success connector called once, got %d", connectorSuccess.calls)
|
||||
}
|
||||
if connectorFail.calls != 1 {
|
||||
t.Fatalf("expected failing connector called once, got %d", connectorFail.calls)
|
||||
}
|
||||
if len(store.snapshots) != 1 {
|
||||
t.Fatalf("expected snapshot stored only for successful pair, got %d", len(store.snapshots))
|
||||
}
|
||||
}
|
||||
|
||||
// -- test helpers -----------------------------------------------------------------
|
||||
|
||||
type ratesStoreStub struct {
|
||||
snapshots []*model.RateSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) UpsertSnapshot(_ context.Context, snapshot *model.RateSnapshot) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
cp := *snapshot
|
||||
r.snapshots = append(r.snapshots, &cp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) LatestSnapshot(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
rates storage.RatesStore
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Ping(context.Context) error { return nil }
|
||||
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
|
||||
func (r *repositoryStub) Quotes() storage.QuotesStore { return nil }
|
||||
func (r *repositoryStub) Pairs() storage.PairStore { return nil }
|
||||
func (r *repositoryStub) Currencies() storage.CurrencyStore { return nil }
|
||||
|
||||
type connectorStub struct {
|
||||
id mmarket.Driver
|
||||
ticker *mmarket.Ticker
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *connectorStub) ID() mmarket.Driver {
|
||||
return c.id
|
||||
}
|
||||
|
||||
func (c *connectorStub) FetchTicker(_ context.Context, symbol string) (*mmarket.Ticker, error) {
|
||||
c.calls++
|
||||
if c.ticker != nil {
|
||||
cp := *c.ticker
|
||||
cp.Symbol = symbol
|
||||
return &cp, c.err
|
||||
}
|
||||
return nil, c.err
|
||||
}
|
||||
|
||||
func testService(store storage.RatesStore, connectors map[mmarket.Driver]mmarket.Connector) *Service {
|
||||
return &Service{
|
||||
logger: zap.NewNop(),
|
||||
cfg: &config.Config{},
|
||||
rates: store,
|
||||
connectors: connectors,
|
||||
pairs: nil,
|
||||
metrics: nil,
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
134
api/fx/ingestor/internal/metrics/server.go
Normal file
134
api/fx/ingestor/internal/metrics/server.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/health"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAddress = ":9102"
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
defaultShutdownWindow = 5 * time.Second
|
||||
)
|
||||
|
||||
type Server interface {
|
||||
SetStatus(health.ServiceStatus)
|
||||
Close(context.Context)
|
||||
}
|
||||
|
||||
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
|
||||
if logger == nil {
|
||||
return nil, fmerrors.New("metrics: logger is nil")
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
logger.Debug("Metrics disabled; using noop server")
|
||||
return noopServer{}, nil
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
address = defaultAddress
|
||||
}
|
||||
|
||||
metricsLogger := logger.Named("metrics")
|
||||
router := chi.NewRouter()
|
||||
router.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
var healthRouter routers.Health
|
||||
if hr, err := routers.NewHealthRouter(metricsLogger, router, ""); err != nil {
|
||||
metricsLogger.Warn("Failed to initialise health router", zap.Error(err))
|
||||
} else {
|
||||
hr.SetStatus(health.SSStarting)
|
||||
healthRouter = hr
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: address,
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
}
|
||||
|
||||
ms := &httpServerWrapper{
|
||||
logger: metricsLogger,
|
||||
server: httpServer,
|
||||
health: healthRouter,
|
||||
timeout: defaultShutdownWindow,
|
||||
}
|
||||
|
||||
go func() {
|
||||
metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
|
||||
if healthRouter != nil {
|
||||
healthRouter.SetStatus(health.SSTerminating)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
type httpServerWrapper struct {
|
||||
logger mlogger.Logger
|
||||
server *http.Server
|
||||
health routers.Health
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (s *httpServerWrapper) SetStatus(status health.ServiceStatus) {
|
||||
if s == nil || s.health == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Debug("Updating metrics health status", zap.String("status", string(status)))
|
||||
s.health.SetStatus(status)
|
||||
}
|
||||
|
||||
func (s *httpServerWrapper) Close(ctx context.Context) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.health != nil {
|
||||
s.health.SetStatus(health.SSTerminating)
|
||||
s.health.Finish()
|
||||
s.health = nil
|
||||
}
|
||||
|
||||
if s.server == nil {
|
||||
return
|
||||
}
|
||||
|
||||
shutdownCtx := ctx
|
||||
if shutdownCtx == nil {
|
||||
shutdownCtx = context.Background()
|
||||
}
|
||||
if s.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if err := s.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("Metrics server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
type noopServer struct{}
|
||||
|
||||
func (noopServer) SetStatus(health.ServiceStatus) {}
|
||||
|
||||
func (noopServer) Close(context.Context) {}
|
||||
30
api/fx/ingestor/internal/model/connector.go
Normal file
30
api/fx/ingestor/internal/model/connector.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Driver string
|
||||
|
||||
const (
|
||||
DriverBinance Driver = "BINANCE"
|
||||
DriverCoinGecko Driver = "COINGECKO"
|
||||
)
|
||||
|
||||
func (d Driver) String() string {
|
||||
return string(d)
|
||||
}
|
||||
|
||||
func (d Driver) IsEmpty() bool {
|
||||
return strings.TrimSpace(string(d)) == ""
|
||||
}
|
||||
|
||||
func NormalizeDriver(d Driver) Driver {
|
||||
return Driver(strings.ToUpper(strings.TrimSpace(string(d))))
|
||||
}
|
||||
|
||||
type Connector interface {
|
||||
ID() Driver
|
||||
FetchTicker(ctx context.Context, symbol string) (*Ticker, error)
|
||||
}
|
||||
9
api/fx/ingestor/internal/model/ticker.go
Normal file
9
api/fx/ingestor/internal/model/ticker.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type Ticker struct {
|
||||
Symbol string
|
||||
BidPrice string
|
||||
AskPrice string
|
||||
Provider string
|
||||
Timestamp int64
|
||||
}
|
||||
14
api/fx/ingestor/internal/signalctx/signalctx.go
Normal file
14
api/fx/ingestor/internal/signalctx/signalctx.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package signalctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
func WithSignals(parent context.Context, sig ...os.Signal) (context.Context, context.CancelFunc) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
return signal.NotifyContext(parent, sig...)
|
||||
}
|
||||
Reference in New Issue
Block a user