From f0185fa3dd9432c8610c43c7c74d5144a50e9232 Mon Sep 17 00:00:00 2001 From: sundayonah Date: Thu, 13 Feb 2025 08:18:21 +0100 Subject: [PATCH 1/5] feat: implement multi-provider rate fetching system with test --- utils/external_markets/external_market.go | 251 ++++++++++++++++++ .../external_markets/external_market_test.go | 168 ++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 utils/external_markets/external_market.go create mode 100644 utils/external_markets/external_market_test.go diff --git a/utils/external_markets/external_market.go b/utils/external_markets/external_market.go new file mode 100644 index 00000000..623c8b20 --- /dev/null +++ b/utils/external_markets/external_market.go @@ -0,0 +1,251 @@ +package externalmarkets + +import ( + "fmt" + + "context" + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// Provider represents a rate provider +type Provider string + +// const ( +// ProviderBitget Provider = "BITGET" +// ProviderBinance Provider = "BINANCE" +// ProviderQuidax Provider = "QUIDAX" +// ) + +// // Rate holds currency pair information ***** +// type Rate struct { +// Currency string +// Price float64 +// Provider Provider +// Timestamp time.Time +// } + +// RateResponse represents a standardized rate response +type RateResponse struct { + Rate float64 + Error error +} + +// BitgetP2PAd represents a P2P advertisement from Bitget +type BitgetP2PAd struct { + Price string `json:"price"` + Available string `json:"available"` +} + +// BitgetResponse represents the API response from Bitget +type BitgetResponse struct { + Code string `json:"code"` + Message string `json:"msg"` + Data []BitgetP2PAd `json:"data"` +} + +// ExternalMarketRates handles fetching and calculating rates from external providers +type ExternalMarketRates struct { + httpClient *http.Client + bitgetURL string + binanceURL string + quidaxURL string +} + +// NewExternalMarketRates creates a new instance of ExternalMarketRates +func NewExternalMarketRates() *ExternalMarketRates { + return &ExternalMarketRates{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + bitgetURL: "https://api.bitget.com/api/v1/p2p/ads", + binanceURL: "https://api.binance.com/api/v3/ticker/price", + quidaxURL: "https://www.quidax.com/api/v1/markets", + } +} + +// FetchRate gets the median rate for a given currency +func (e *ExternalMarketRates) FetchRate(ctx context.Context, currency string) (float64, error) { + switch strings.ToUpper(currency) { + case "NGN": + return e.fetchNGNRate(ctx) + default: + return e.fetchOtherCurrencyRate(ctx, currency) + } +} + +// fetchNGNRate fetches rate for NGN from Quidax and BItget +func (e *ExternalMarketRates) fetchNGNRate(ctx context.Context) (float64, error) { + rates := make(chan RateResponse, 2) + + // Fetch rates concurrently + go func() { + rate, err := e.fetchQuidaxRate(ctx, "NGN") + rates <- RateResponse{Rate: rate, Error: err} + }() + + go func() { + rate, err := e.fetchBitgetRate(ctx, "NGN") + rates <- RateResponse{Rate: rate, Error: err} + }() + + // Collect results + var validRates []float64 + for i := 0; i < 2; i++ { + resp := <-rates + if resp.Error == nil { + validRates = append(validRates, resp.Rate) + } + } + if len(validRates) == 0 { + return 0, fmt.Errorf("no valid rates found for NGN") + } + return calculateMedian(validRates), nil +} + +// fetchOtherCurrencyRate fetches rate for other currencies from Binance and Bidget +func (e *ExternalMarketRates) fetchOtherCurrencyRate(ctx context.Context, currency string) (float64, error) { + rates := make(chan RateResponse, 2) + + go func() { + rate, err := e.fetchBinanceRate(ctx, currency) + rates <- RateResponse{Rate: rate, Error: err} + }() + + go func() { + rate, err := e.fetchBitgetRate(ctx, currency) + rates <- RateResponse{Rate: rate, Error: err} + }() + + var validRates []float64 + for i := 0; i < 2; i++ { + resp := <-rates + if resp.Error == nil { + validRates = append(validRates, resp.Rate) + } + } + if len(validRates) == 0 { + return 0, fmt.Errorf("no valid rates found for %s", currency) + } + return calculateMedian(validRates), nil +} + +// fetchBitgetRate fetches rates from Bitget +func (e *ExternalMarketRates) fetchBitgetRate(ctx context.Context, currency string) (float64, error) { + url := fmt.Sprintf("%s?fiat=%s&crypto=USDT&type=BUY&limit=20", e.bitgetURL, currency) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, fmt.Errorf("creating request: %w", err) + } + + resp, err := e.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("fetching from Bitget:: %w", err) + } + defer resp.Body.Close() + + var result BitgetResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decoding Bitget response: %w", err) + } + + if len(result.Data) == 0 { + return 0, fmt.Errorf("no Bitget P2P ads found for %s", currency) + } + + // Extract and convert prices to float64 + var prices []float64 + for _, ad := range result.Data { + price, err := strconv.ParseFloat(ad.Price, 64) + if err != nil { + continue + } + prices = append(prices, price) + } + + if len(prices) == 0 { + return 0, fmt.Errorf("no valid Bitget P2P prices found for %s", currency) + } + + return calculateMedian(prices), nil + +} + +// fetchBinanceRate fetches rates from Binance +func (e *ExternalMarketRates) fetchBinanceRate(ctx context.Context, currency string) (float64, error) { + symbol := fmt.Sprintf("USDT%s", currency) + url := fmt.Sprintf("%s?symbol=%s", e.binanceURL, symbol) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, fmt.Errorf("creating request: %w", err) + } + + resp, err := e.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("fetching from Binance:: %w", err) + } + defer resp.Body.Close() + + var result struct { + Price string `json:"price"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decoding Binance response: %w", err) + } + price, err := strconv.ParseFloat(result.Price, 64) + if err != nil { + return 0, fmt.Errorf("parsing Binance price: %w", err) + } + return price, nil +} + +// fetchQuidaxRate fetches ratesfrom Quidax +func (e *ExternalMarketRates) fetchQuidaxRate(ctx context.Context, currency string) (float64, error) { + url := fmt.Sprintf("%s/usd%s/ticker", e.quidaxURL, strings.ToLower(currency)) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, fmt.Errorf("creating request: %w", err) + } + resp, err := e.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("fetching from Quidax:: %w", err) + } + defer resp.Body.Close() + + var result struct { + Data struct { + LastPrice string `json:"last_price"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("decoding Quidax response: %w", err) + } + + price, err := strconv.ParseFloat(result.Data.LastPrice, 64) + if err != nil { + return 0, fmt.Errorf("parsing Quidax price: %w", err) + } + return price, nil +} + +// calculateMedian calculates the median value from slice of float64 +func calculateMedian(values []float64) float64 { + if len(values) == 0 { + return 0 + } + + sort.Float64s(values) + middle := len(values) / 2 + + if len(values)%2 == 0 { + return (values[middle-1] + values[middle]) / 2 + } + return values[middle] +} diff --git a/utils/external_markets/external_market_test.go b/utils/external_markets/external_market_test.go new file mode 100644 index 00000000..14519b48 --- /dev/null +++ b/utils/external_markets/external_market_test.go @@ -0,0 +1,168 @@ +package externalmarkets + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestExternalMarketRates(t *testing.T) { + // Mock Bitget server with currency-specific responses + bitgetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currency := r.URL.Query().Get("fiat") + var response string + + switch currency { + case "NGN": + response = `{ + "code": "00000", + "msg": "success", + "data": [ + {"price": "740.0", "available": "1000"}, + {"price": "745.0", "available": "2000"}, + {"price": "750.0", "available": "1500"} + ] + }` + case "KES": + response = `{ + "code": "00000", + "msg": "success", + "data": [ + {"price": "145.0", "available": "1000"}, + {"price": "145.5", "available": "2000"}, + {"price": "146.0", "available": "1500"} + ] + }` + case "GHS": + response = `{ + "code": "00000", + "msg": "success", + "data": [ + {"price": "545.0", "available": "1000"}, + {"price": "545.5", "available": "2000"}, + {"price": "546.0", "available": "1500"} + ] + }` + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer bitgetServer.Close() + + // Mock Binance server with currency-specific responses + binanceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + symbol := r.URL.Query().Get("symbol") + var response string + + switch symbol { + case "USDTKES": + response = `{"symbol":"USDTKES","price":"145.50"}` + case "USDTGHS": + response = `{"symbol":"USDTGHS","price":"545.50"}` + default: + response = `{"symbol":"USDTNGN","price":"750.00"}` + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer binanceServer.Close() + + // Mock Quidax server with currency-specific responses + quidaxServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "data": { + "last_price": "755.00" + } + }` + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer quidaxServer.Close() + + // Create ExternalMarketRates instance with mock servers + emr := NewExternalMarketRates() + emr.bitgetURL = bitgetServer.URL + emr.binanceURL = binanceServer.URL + emr.quidaxURL = quidaxServer.URL + + ctx := context.Background() + + t.Run("FetchRate", func(t *testing.T) { + tests := []struct { + name string + currency string + want float64 + wantErr bool + }{ + { + name: "Test NGN rate fetch", + currency: "NGN", + want: 750.0, + wantErr: false, + }, + { + name: "Test other currency rate fetch (KES)", + currency: "KES", + want: 145.5, + wantErr: false, + }, + { + name: "Test other currency rate fetch (GHS)", + currency: "GHS", + want: 545.5, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := emr.FetchRate(ctx, tt.currency) + if (err != nil) != tt.wantErr { + t.Errorf("FetchRate() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("FetchRate() = %v, want %v", got, tt.want) + } + }) + } + }) + + t.Run("CalculateMedian", func(t *testing.T) { + tests := []struct { + name string + values []float64 + want float64 + }{ + { + name: "Odd number of values", + values: []float64{1, 2, 3}, + want: 2, + }, + { + name: "Even number of values", + values: []float64{1, 2, 3, 4}, + want: 2.5, + }, + { + name: "Empty slice", + values: []float64{}, + want: 0, + }, + { + name: "Single value", + values: []float64{1}, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := calculateMedian(tt.values); got != tt.want { + t.Errorf("calculateMedian() = %v, want %v", got, tt.want) + } + }) + } + }) +} From 469f49fe35b1305ff7f327c65dfcdfde5692d235 Mon Sep 17 00:00:00 2001 From: sundayonah Date: Thu, 13 Feb 2025 11:57:07 +0100 Subject: [PATCH 2/5] feat(rates): enhance rate fetching with detailed metadata --- utils/external_markets/external_market.go | 148 ++++++++++++------ .../external_markets/external_market_test.go | 2 +- utils/utils.go | 8 + 3 files changed, 109 insertions(+), 49 deletions(-) diff --git a/utils/external_markets/external_market.go b/utils/external_markets/external_market.go index 623c8b20..643c820d 100644 --- a/utils/external_markets/external_market.go +++ b/utils/external_markets/external_market.go @@ -10,28 +10,30 @@ import ( "strconv" "strings" "time" + + "github.com/paycrest/aggregator/utils" ) // Provider represents a rate provider type Provider string -// const ( -// ProviderBitget Provider = "BITGET" -// ProviderBinance Provider = "BINANCE" -// ProviderQuidax Provider = "QUIDAX" -// ) +const ( + ProviderBitget Provider = "BITGET" + ProviderBinance Provider = "BINANCE" + ProviderQuidax Provider = "QUIDAX" +) -// // Rate holds currency pair information ***** -// type Rate struct { -// Currency string -// Price float64 -// Provider Provider -// Timestamp time.Time -// } +// Rate holds currency pair information ***** +type Rate struct { + Currency string + Price float64 + Provider Provider + Timestamp time.Time +} // RateResponse represents a standardized rate response type RateResponse struct { - Rate float64 + Rate Rate Error error } @@ -51,9 +53,9 @@ type BitgetResponse struct { // ExternalMarketRates handles fetching and calculating rates from external providers type ExternalMarketRates struct { httpClient *http.Client + quidaxURL string bitgetURL string binanceURL string - quidaxURL string } // NewExternalMarketRates creates a new instance of ExternalMarketRates @@ -62,14 +64,14 @@ func NewExternalMarketRates() *ExternalMarketRates { httpClient: &http.Client{ Timeout: 10 * time.Second, }, - bitgetURL: "https://api.bitget.com/api/v1/p2p/ads", - binanceURL: "https://api.binance.com/api/v3/ticker/price", quidaxURL: "https://www.quidax.com/api/v1/markets", + bitgetURL: "https://api.bitget.com/api/mix/v1/market/p2p/advertisements", + binanceURL: "https://api.binance.com/api/v3/ticker/price", } } // FetchRate gets the median rate for a given currency -func (e *ExternalMarketRates) FetchRate(ctx context.Context, currency string) (float64, error) { +func (e *ExternalMarketRates) FetchRate(ctx context.Context, currency string) (Rate, error) { switch strings.ToUpper(currency) { case "NGN": return e.fetchNGNRate(ctx) @@ -79,7 +81,7 @@ func (e *ExternalMarketRates) FetchRate(ctx context.Context, currency string) (f } // fetchNGNRate fetches rate for NGN from Quidax and BItget -func (e *ExternalMarketRates) fetchNGNRate(ctx context.Context) (float64, error) { +func (e *ExternalMarketRates) fetchNGNRate(ctx context.Context) (Rate, error) { rates := make(chan RateResponse, 2) // Fetch rates concurrently @@ -94,7 +96,7 @@ func (e *ExternalMarketRates) fetchNGNRate(ctx context.Context) (float64, error) }() // Collect results - var validRates []float64 + var validRates []Rate for i := 0; i < 2; i++ { resp := <-rates if resp.Error == nil { @@ -102,13 +104,13 @@ func (e *ExternalMarketRates) fetchNGNRate(ctx context.Context) (float64, error) } } if len(validRates) == 0 { - return 0, fmt.Errorf("no valid rates found for NGN") + return Rate{}, fmt.Errorf("no valid rates found for NGN") } - return calculateMedian(validRates), nil + return calculateMedianRate(validRates), nil } // fetchOtherCurrencyRate fetches rate for other currencies from Binance and Bidget -func (e *ExternalMarketRates) fetchOtherCurrencyRate(ctx context.Context, currency string) (float64, error) { +func (e *ExternalMarketRates) fetchOtherCurrencyRate(ctx context.Context, currency string) (Rate, error) { rates := make(chan RateResponse, 2) go func() { @@ -121,7 +123,7 @@ func (e *ExternalMarketRates) fetchOtherCurrencyRate(ctx context.Context, curren rates <- RateResponse{Rate: rate, Error: err} }() - var validRates []float64 + var validRates []Rate for i := 0; i < 2; i++ { resp := <-rates if resp.Error == nil { @@ -129,33 +131,33 @@ func (e *ExternalMarketRates) fetchOtherCurrencyRate(ctx context.Context, curren } } if len(validRates) == 0 { - return 0, fmt.Errorf("no valid rates found for %s", currency) + return Rate{}, fmt.Errorf("no valid rates found for %s", currency) } - return calculateMedian(validRates), nil + return calculateMedianRate(validRates), nil } // fetchBitgetRate fetches rates from Bitget -func (e *ExternalMarketRates) fetchBitgetRate(ctx context.Context, currency string) (float64, error) { - url := fmt.Sprintf("%s?fiat=%s&crypto=USDT&type=BUY&limit=20", e.bitgetURL, currency) +func (e *ExternalMarketRates) fetchBitgetRate(ctx context.Context, currency string) (Rate, error) { + url := fmt.Sprintf("%s?symbol=USDT%s&limit=20", e.bitgetURL, strings.ToUpper(currency)) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return 0, fmt.Errorf("creating request: %w", err) + return Rate{}, fmt.Errorf("creating request: %w", err) } resp, err := e.httpClient.Do(req) if err != nil { - return 0, fmt.Errorf("fetching from Bitget:: %w", err) + return Rate{}, fmt.Errorf("fetching from Bitget:: %w", err) } defer resp.Body.Close() var result BitgetResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return 0, fmt.Errorf("decoding Bitget response: %w", err) + return Rate{}, fmt.Errorf("decoding Bitget response: %w", err) } if len(result.Data) == 0 { - return 0, fmt.Errorf("no Bitget P2P ads found for %s", currency) + return Rate{}, fmt.Errorf("no Bitget P2P ads found for %s", currency) } // Extract and convert prices to float64 @@ -169,26 +171,31 @@ func (e *ExternalMarketRates) fetchBitgetRate(ctx context.Context, currency stri } if len(prices) == 0 { - return 0, fmt.Errorf("no valid Bitget P2P prices found for %s", currency) + return Rate{}, fmt.Errorf("no valid Bitget P2P prices found for %s", currency) } - return calculateMedian(prices), nil + medianPrice := calculateMedian(prices) + return Rate{ + Currency: currency, + Price: medianPrice, + Provider: ProviderBitget, + Timestamp: time.Now(), + }, nil } // fetchBinanceRate fetches rates from Binance -func (e *ExternalMarketRates) fetchBinanceRate(ctx context.Context, currency string) (float64, error) { - symbol := fmt.Sprintf("USDT%s", currency) - url := fmt.Sprintf("%s?symbol=%s", e.binanceURL, symbol) +func (e *ExternalMarketRates) fetchBinanceRate(ctx context.Context, currency string) (Rate, error) { + url := fmt.Sprintf("%s?symbol=USDT%s", e.binanceURL, strings.ToUpper(currency)) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return 0, fmt.Errorf("creating request: %w", err) + return Rate{}, fmt.Errorf("creating request: %w", err) } resp, err := e.httpClient.Do(req) if err != nil { - return 0, fmt.Errorf("fetching from Binance:: %w", err) + return Rate{}, fmt.Errorf("fetching from Binance:: %w", err) } defer resp.Body.Close() @@ -196,26 +203,31 @@ func (e *ExternalMarketRates) fetchBinanceRate(ctx context.Context, currency str Price string `json:"price"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return 0, fmt.Errorf("decoding Binance response: %w", err) + return Rate{}, fmt.Errorf("decoding Binance response: %w", err) } price, err := strconv.ParseFloat(result.Price, 64) if err != nil { - return 0, fmt.Errorf("parsing Binance price: %w", err) + return Rate{}, fmt.Errorf("parsing Binance price: %w", err) } - return price, nil + return Rate{ + Currency: currency, + Price: price, + Provider: ProviderBinance, + Timestamp: time.Now(), + }, nil } // fetchQuidaxRate fetches ratesfrom Quidax -func (e *ExternalMarketRates) fetchQuidaxRate(ctx context.Context, currency string) (float64, error) { - url := fmt.Sprintf("%s/usd%s/ticker", e.quidaxURL, strings.ToLower(currency)) +func (e *ExternalMarketRates) fetchQuidaxRate(ctx context.Context, currency string) (Rate, error) { + url := fmt.Sprintf("%s/usdt%s/ticker", e.quidaxURL, strings.ToLower(currency)) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return 0, fmt.Errorf("creating request: %w", err) + return Rate{}, fmt.Errorf("creating request: %w", err) } resp, err := e.httpClient.Do(req) if err != nil { - return 0, fmt.Errorf("fetching from Quidax:: %w", err) + return Rate{}, fmt.Errorf("fetching from Quidax:: %w", err) } defer resp.Body.Close() @@ -225,14 +237,20 @@ func (e *ExternalMarketRates) fetchQuidaxRate(ctx context.Context, currency stri } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return 0, fmt.Errorf("decoding Quidax response: %w", err) + return Rate{}, fmt.Errorf("decoding Quidax response: %w", err) } price, err := strconv.ParseFloat(result.Data.LastPrice, 64) if err != nil { - return 0, fmt.Errorf("parsing Quidax price: %w", err) + return Rate{}, fmt.Errorf("parsing Quidax price: %w", err) } - return price, nil + + return Rate{ + Currency: currency, + Price: price, + Provider: ProviderQuidax, + Timestamp: time.Now(), + }, nil } // calculateMedian calculates the median value from slice of float64 @@ -249,3 +267,37 @@ func calculateMedian(values []float64) float64 { } return values[middle] } + +// New helper function to calculate median from Rate objects +func calculateMedianRate(rates []Rate) Rate { + if len(rates) == 0 { + return Rate{} + } + + // Extract prices for median calculation + prices := make([]float64, len(rates)) + for i, rate := range rates { + prices[i] = rate.Price + } + + medianPrice := calculateMedian(prices) + + // Find the rate object closest to the median price + var closestRate Rate + smallestDiff := float64(^uint(0) >> 1) // Max float64 + for _, rate := range rates { + diff := utils.Abs(rate.Price - medianPrice) + if diff < smallestDiff { + smallestDiff = diff + closestRate = rate + } + } + + // Return a new Rate with the median price but keeping other metadata from the closest rate + return Rate{ + Currency: closestRate.Currency, + Price: medianPrice, + Provider: closestRate.Provider, + Timestamp: closestRate.Timestamp, + } +} diff --git a/utils/external_markets/external_market_test.go b/utils/external_markets/external_market_test.go index 14519b48..87572d1c 100644 --- a/utils/external_markets/external_market_test.go +++ b/utils/external_markets/external_market_test.go @@ -122,7 +122,7 @@ func TestExternalMarketRates(t *testing.T) { t.Errorf("FetchRate() error = %v, wantErr %v", err, tt.wantErr) return } - if got != tt.want { + if got.Price != tt.want { t.Errorf("FetchRate() = %v, want %v", got, tt.want) } }) diff --git a/utils/utils.go b/utils/utils.go index 54aef04b..b31e70db 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -146,6 +146,14 @@ func ContainsString(slice []string, item string) bool { return false } +// abs returns the absolute value of x. +func Abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} + // Median returns the median value of a decimal slice func Median(data []decimal.Decimal) decimal.Decimal { l := len(data) From 9d003400a1cdebe98ba34275c2341d76ab59bee5 Mon Sep 17 00:00:00 2001 From: sundayonah Date: Fri, 14 Feb 2025 05:03:39 +0100 Subject: [PATCH 3/5] test: modify test and move external_market file to utils --- .../{external_markets => }/external_market.go | 9 ++-- .../external_market_test.go | 46 ++++++++++++++----- 2 files changed, 39 insertions(+), 16 deletions(-) rename utils/{external_markets => }/external_market.go (98%) rename utils/{external_markets => }/external_market_test.go (77%) diff --git a/utils/external_markets/external_market.go b/utils/external_market.go similarity index 98% rename from utils/external_markets/external_market.go rename to utils/external_market.go index 643c820d..4de3d394 100644 --- a/utils/external_markets/external_market.go +++ b/utils/external_market.go @@ -1,4 +1,4 @@ -package externalmarkets +package utils import ( "fmt" @@ -10,8 +10,6 @@ import ( "strconv" "strings" "time" - - "github.com/paycrest/aggregator/utils" ) // Provider represents a rate provider @@ -285,8 +283,11 @@ func calculateMedianRate(rates []Rate) Rate { // Find the rate object closest to the median price var closestRate Rate smallestDiff := float64(^uint(0) >> 1) // Max float64 + for _, rate := range rates { - diff := utils.Abs(rate.Price - medianPrice) + + // Find closest rate to median + diff := Abs(rate.Price - medianPrice) if diff < smallestDiff { smallestDiff = diff closestRate = rate diff --git a/utils/external_markets/external_market_test.go b/utils/external_market_test.go similarity index 77% rename from utils/external_markets/external_market_test.go rename to utils/external_market_test.go index 87572d1c..6318904f 100644 --- a/utils/external_markets/external_market_test.go +++ b/utils/external_market_test.go @@ -1,4 +1,4 @@ -package externalmarkets +package utils import ( "context" @@ -10,21 +10,21 @@ import ( func TestExternalMarketRates(t *testing.T) { // Mock Bitget server with currency-specific responses bitgetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - currency := r.URL.Query().Get("fiat") + symbol := r.URL.Query().Get("symbol") var response string - switch currency { - case "NGN": + switch symbol { + case "USDTNGN": response = `{ "code": "00000", "msg": "success", "data": [ - {"price": "740.0", "available": "1000"}, - {"price": "745.0", "available": "2000"}, - {"price": "750.0", "available": "1500"} + {"price": "745.0", "available": "1000"}, + {"price": "750.0", "available": "2000"}, + {"price": "755.0", "available": "1500"} ] }` - case "KES": + case "USDTKES": response = `{ "code": "00000", "msg": "success", @@ -34,7 +34,7 @@ func TestExternalMarketRates(t *testing.T) { {"price": "146.0", "available": "1500"} ] }` - case "GHS": + case "USDTGHS": response = `{ "code": "00000", "msg": "success", @@ -94,11 +94,12 @@ func TestExternalMarketRates(t *testing.T) { currency string want float64 wantErr bool + setup func(emr *ExternalMarketRates) }{ { name: "Test NGN rate fetch", currency: "NGN", - want: 750.0, + want: 752.5, wantErr: false, }, { @@ -117,13 +118,16 @@ func TestExternalMarketRates(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup(emr) + } got, err := emr.FetchRate(ctx, tt.currency) if (err != nil) != tt.wantErr { t.Errorf("FetchRate() error = %v, wantErr %v", err, tt.wantErr) return } - if got.Price != tt.want { - t.Errorf("FetchRate() = %v, want %v", got, tt.want) + if !tt.wantErr && got.Price != tt.want { + t.Errorf("FetchRate() = %v, want %v", got.Price, tt.want) } }) } @@ -165,4 +169,22 @@ func TestExternalMarketRates(t *testing.T) { }) } }) + + t.Run("Concurrent Provider Failures", func(t *testing.T) { + // Set up mock servers that fail + failingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer failingServer.Close() + + emr := NewExternalMarketRates() + emr.bitgetURL = failingServer.URL + emr.binanceURL = "invalid-url" + + // Should still work with one valid provider + _, err := emr.FetchRate(context.Background(), "KES") + if err == nil { + t.Error("Expected error when all providers fail") + } + }) } From 7f72384b74a84c2986e0d12a0acec85e5c1fd64d Mon Sep 17 00:00:00 2001 From: sundayonah Date: Fri, 14 Feb 2025 05:23:44 +0100 Subject: [PATCH 4/5] refactor(config): move API URLs to environment variables --- .env.example | 3 +++ config/server.go | 27 ++++++++++++++++++--------- utils/external_market.go | 9 ++++++--- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index b196e24f..d208c0cb 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ HMAC_TIMESTAMP_AGE=5 ENVIRONMENT=local # local, staging, production SENTRY_DSN= HOST_DOMAIN=http://localhost:8000 +QUIDAX_URL="https://your-custom-quidax-url" +BITGET_URL="https://your-custom-bitget-url" +BINANCE_URL="https://your-custom-binance-url" # Database Config DB_NAME=paycrest diff --git a/config/server.go b/config/server.go index 2579f304..7547d04e 100644 --- a/config/server.go +++ b/config/server.go @@ -18,6 +18,9 @@ type ServerConfiguration struct { HostDomain string RateLimitUnauthenticated int RateLimitAuthenticated int + QuidaxURL string + BitgetURL string + BinanceURL string } // ServerConfig sets the server configuration @@ -31,18 +34,24 @@ func ServerConfig() *ServerConfiguration { viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("RATE_LIMIT_UNAUTHENTICATED", 5) viper.SetDefault("RATE_LIMIT_AUTHENTICATED", 50) + viper.SetDefault("QUIDAX_URL", "https://www.quidax.com/api/v1/markets") + viper.SetDefault("BITGET_URL", "https://api.bitget.com/api/mix/v1/market/p2p/advertisements") + viper.SetDefault("BINANCE_URL", "https://api.binance.com/api/v3/ticker/price") return &ServerConfiguration{ - Debug: viper.GetBool("DEBUG"), - Host: viper.GetString("SERVER_HOST"), - Port: viper.GetString("SERVER_PORT"), - Timezone: viper.GetString("SERVER_TIMEZONE"), - AllowedHosts: viper.GetString("ALLOWED_HOSTS"), - Environment: viper.GetString("ENVIRONMENT"), - SentryDSN: viper.GetString("SENTRY_DSN"), - HostDomain: viper.GetString("HOST_DOMAIN"), + Debug: viper.GetBool("DEBUG"), + Host: viper.GetString("SERVER_HOST"), + Port: viper.GetString("SERVER_PORT"), + Timezone: viper.GetString("SERVER_TIMEZONE"), + AllowedHosts: viper.GetString("ALLOWED_HOSTS"), + Environment: viper.GetString("ENVIRONMENT"), + SentryDSN: viper.GetString("SENTRY_DSN"), + HostDomain: viper.GetString("HOST_DOMAIN"), RateLimitUnauthenticated: viper.GetInt("RATE_LIMIT_UNAUTHENTICATED"), - RateLimitAuthenticated: viper.GetInt("RATE_LIMIT_AUTHENTICATED"), + RateLimitAuthenticated: viper.GetInt("RATE_LIMIT_AUTHENTICATED"), + QuidaxURL: viper.GetString("QUIDAX_URL"), + BitgetURL: viper.GetString("BITGET_URL"), + BinanceURL: viper.GetString("BINANCE_URL"), } } diff --git a/utils/external_market.go b/utils/external_market.go index 4de3d394..2a0d4b66 100644 --- a/utils/external_market.go +++ b/utils/external_market.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" "time" + + "github.com/paycrest/aggregator/config" ) // Provider represents a rate provider @@ -58,13 +60,14 @@ type ExternalMarketRates struct { // NewExternalMarketRates creates a new instance of ExternalMarketRates func NewExternalMarketRates() *ExternalMarketRates { + cfg := config.ServerConfig() return &ExternalMarketRates{ httpClient: &http.Client{ Timeout: 10 * time.Second, }, - quidaxURL: "https://www.quidax.com/api/v1/markets", - bitgetURL: "https://api.bitget.com/api/mix/v1/market/p2p/advertisements", - binanceURL: "https://api.binance.com/api/v3/ticker/price", + quidaxURL: cfg.QuidaxURL, + bitgetURL: cfg.BitgetURL, + binanceURL: cfg.BinanceURL, } } From bd3c348fd268c9cbdbdd6af0a985efb6f88efece Mon Sep 17 00:00:00 2001 From: sundayonah Date: Fri, 14 Feb 2025 09:03:40 +0100 Subject: [PATCH 5/5] refactor(utils): overhaul external market rate fetching implementation --- .env.example | 3 - config/server.go | 9 - go.sum | 2 - tasks/tasks.go | 90 +------- tasks/tasks_test.go | 7 - utils/external_market.go | 374 ++++++++++++---------------------- utils/external_market_test.go | 229 +++++++-------------- utils/http.go | 9 +- utils/utils.go | 8 - 9 files changed, 205 insertions(+), 526 deletions(-) diff --git a/.env.example b/.env.example index d208c0cb..b196e24f 100644 --- a/.env.example +++ b/.env.example @@ -10,9 +10,6 @@ HMAC_TIMESTAMP_AGE=5 ENVIRONMENT=local # local, staging, production SENTRY_DSN= HOST_DOMAIN=http://localhost:8000 -QUIDAX_URL="https://your-custom-quidax-url" -BITGET_URL="https://your-custom-bitget-url" -BINANCE_URL="https://your-custom-binance-url" # Database Config DB_NAME=paycrest diff --git a/config/server.go b/config/server.go index 7547d04e..e79823a7 100644 --- a/config/server.go +++ b/config/server.go @@ -18,9 +18,6 @@ type ServerConfiguration struct { HostDomain string RateLimitUnauthenticated int RateLimitAuthenticated int - QuidaxURL string - BitgetURL string - BinanceURL string } // ServerConfig sets the server configuration @@ -34,9 +31,6 @@ func ServerConfig() *ServerConfiguration { viper.SetDefault("SENTRY_DSN", "") viper.SetDefault("RATE_LIMIT_UNAUTHENTICATED", 5) viper.SetDefault("RATE_LIMIT_AUTHENTICATED", 50) - viper.SetDefault("QUIDAX_URL", "https://www.quidax.com/api/v1/markets") - viper.SetDefault("BITGET_URL", "https://api.bitget.com/api/mix/v1/market/p2p/advertisements") - viper.SetDefault("BINANCE_URL", "https://api.binance.com/api/v3/ticker/price") return &ServerConfiguration{ Debug: viper.GetBool("DEBUG"), @@ -49,9 +43,6 @@ func ServerConfig() *ServerConfiguration { HostDomain: viper.GetString("HOST_DOMAIN"), RateLimitUnauthenticated: viper.GetInt("RATE_LIMIT_UNAUTHENTICATED"), RateLimitAuthenticated: viper.GetInt("RATE_LIMIT_AUTHENTICATED"), - QuidaxURL: viper.GetString("QUIDAX_URL"), - BitgetURL: viper.GetString("BITGET_URL"), - BinanceURL: viper.GetString("BINANCE_URL"), } } diff --git a/go.sum b/go.sum index 952f1a38..9539e61f 100644 --- a/go.sum +++ b/go.sum @@ -562,8 +562,6 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= diff --git a/tasks/tasks.go b/tasks/tasks.go index a98225f4..b263be27 100644 --- a/tasks/tasks.go +++ b/tasks/tasks.go @@ -1077,94 +1077,6 @@ func SubscribeToRedisKeyspaceEvents() { go ReassignStaleOrderRequest(ctx, orderRequestChan) } -// fetchExternalRate fetches the external rate for a fiat currency -func fetchExternalRate(currency string) (decimal.Decimal, error) { - currency = strings.ToUpper(currency) - supportedCurrencies := []string{"KES", "NGN", "GHS", "TZS", "UGX", "XOF"} - isSupported := false - for _, supported := range supportedCurrencies { - if currency == supported { - isSupported = true - break - } - } - if !isSupported { - return decimal.Zero, fmt.Errorf("ComputeMarketRate: currency not supported") - } - - // Fetch rates from third-party APIs - var price decimal.Decimal - if currency == "NGN" { - res, err := fastshot.NewClient("https://www.quidax.com"). - Config().SetTimeout(30*time.Second). - Build().GET(fmt.Sprintf("/api/v1/markets/tickers/usdt%s", strings.ToLower(currency))). - Retry().Set(3, 5*time.Second). - Send() - if err != nil { - return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err) - } - - data, err := utils.ParseJSONResponse(res.RawResponse) - if err != nil { - return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w %v", err, data) - } - - price, err = decimal.NewFromString(data["data"].(map[string]interface{})["ticker"].(map[string]interface{})["buy"].(string)) - if err != nil { - return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err) - } - } else { - res, err := fastshot.NewClient("https://p2p.binance.com"). - Config().SetTimeout(30*time.Second). - Header().Add("Content-Type", "application/json"). - Build().POST("/bapi/c2c/v2/friendly/c2c/adv/search"). - Retry().Set(3, 5*time.Second). - Body().AsJSON(map[string]interface{}{ - "asset": "USDT", - "fiat": currency, - "tradeType": "SELL", - "page": 1, - "rows": 20, - }). - Send() - if err != nil { - return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err) - } - - resData, err := utils.ParseJSONResponse(res.RawResponse) - if err != nil { - return decimal.Zero, fmt.Errorf("ComputeMarketRate: %w", err) - } - - // Access the data array - data, ok := resData["data"].([]interface{}) - if !ok || len(data) == 0 { - return decimal.Zero, fmt.Errorf("ComputeMarketRate: No data in the response") - } - - // Loop through the data array and extract prices - var prices []decimal.Decimal - for _, item := range data { - adv, ok := item.(map[string]interface{})["adv"].(map[string]interface{}) - if !ok { - continue - } - - price, err := decimal.NewFromString(adv["price"].(string)) - if err != nil { - continue - } - - prices = append(prices, price) - } - - // Calculate and return the median - price = utils.Median(prices) - } - - return price, nil -} - // ComputeMarketRate computes the market price for fiat currencies func ComputeMarketRate() error { ctx := context.Background() @@ -1180,7 +1092,7 @@ func ComputeMarketRate() error { for _, currency := range currencies { // Fetch external rate - externalRate, err := fetchExternalRate(currency.Code) + externalRate, err := utils.FetchQuidaxRate(currency.Code) if err != nil { continue } diff --git a/tasks/tasks_test.go b/tasks/tasks_test.go index bd4c3305..066ebb44 100644 --- a/tasks/tasks_test.go +++ b/tasks/tasks_test.go @@ -18,7 +18,6 @@ import ( "github.com/paycrest/aggregator/types" "github.com/paycrest/aggregator/utils" "github.com/paycrest/aggregator/utils/test" - "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" ) @@ -169,10 +168,4 @@ func TestTasks(t *testing.T) { assert.Equal(t, hook.Status, webhookretryattempt.StatusExpired) }) - - t.Run("fetchExternalRate", func(t *testing.T) { - value, err := fetchExternalRate("KSH") - assert.Error(t, err) - assert.Equal(t, value, decimal.Zero) - }) } diff --git a/utils/external_market.go b/utils/external_market.go index 2a0d4b66..9768db58 100644 --- a/utils/external_market.go +++ b/utils/external_market.go @@ -3,305 +3,189 @@ package utils import ( "fmt" - "context" - "encoding/json" - "net/http" - "sort" - "strconv" "strings" "time" - "github.com/paycrest/aggregator/config" + fastshot "github.com/opus-domini/fast-shot" + "github.com/shopspring/decimal" ) -// Provider represents a rate provider -type Provider string - -const ( - ProviderBitget Provider = "BITGET" - ProviderBinance Provider = "BINANCE" - ProviderQuidax Provider = "QUIDAX" +var ( + BitgetAPIURL = "https://api.bitget.com" + BinanceAPIURL = "https://api.binance.com" + QuidaxAPIURL = "https://www.quidax.com/api/v1" ) -// Rate holds currency pair information ***** -type Rate struct { - Currency string - Price float64 - Provider Provider - Timestamp time.Time -} - -// RateResponse represents a standardized rate response -type RateResponse struct { - Rate Rate - Error error -} - -// BitgetP2PAd represents a P2P advertisement from Bitget -type BitgetP2PAd struct { - Price string `json:"price"` - Available string `json:"available"` -} - -// BitgetResponse represents the API response from Bitget -type BitgetResponse struct { - Code string `json:"code"` - Message string `json:"msg"` - Data []BitgetP2PAd `json:"data"` -} - -// ExternalMarketRates handles fetching and calculating rates from external providers -type ExternalMarketRates struct { - httpClient *http.Client - quidaxURL string - bitgetURL string - binanceURL string -} - -// NewExternalMarketRates creates a new instance of ExternalMarketRates -func NewExternalMarketRates() *ExternalMarketRates { - cfg := config.ServerConfig() - return &ExternalMarketRates{ - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - quidaxURL: cfg.QuidaxURL, - bitgetURL: cfg.BitgetURL, - binanceURL: cfg.BinanceURL, - } -} - -// FetchRate gets the median rate for a given currency -func (e *ExternalMarketRates) FetchRate(ctx context.Context, currency string) (Rate, error) { - switch strings.ToUpper(currency) { - case "NGN": - return e.fetchNGNRate(ctx) - default: - return e.fetchOtherCurrencyRate(ctx, currency) - } -} - -// fetchNGNRate fetches rate for NGN from Quidax and BItget -func (e *ExternalMarketRates) fetchNGNRate(ctx context.Context) (Rate, error) { - rates := make(chan RateResponse, 2) - - // Fetch rates concurrently - go func() { - rate, err := e.fetchQuidaxRate(ctx, "NGN") - rates <- RateResponse{Rate: rate, Error: err} - }() - - go func() { - rate, err := e.fetchBitgetRate(ctx, "NGN") - rates <- RateResponse{Rate: rate, Error: err} - }() - - // Collect results - var validRates []Rate - for i := 0; i < 2; i++ { - resp := <-rates - if resp.Error == nil { - validRates = append(validRates, resp.Rate) +// fetchExternalRate fetches the external rate for a fiat currency +func FetchExternalRate(currency string) (decimal.Decimal, error) { + currency = strings.ToUpper(currency) + supportedCurrencies := []string{"KES", "NGN", "GHS", "TZS", "UGX", "XOF"} + isSupported := false + for _, supported := range supportedCurrencies { + if currency == supported { + isSupported = true + break } } - if len(validRates) == 0 { - return Rate{}, fmt.Errorf("no valid rates found for NGN") + if !isSupported { + return decimal.Zero, fmt.Errorf("ComputeMarketRate: currency not supported") } - return calculateMedianRate(validRates), nil -} -// fetchOtherCurrencyRate fetches rate for other currencies from Binance and Bidget -func (e *ExternalMarketRates) fetchOtherCurrencyRate(ctx context.Context, currency string) (Rate, error) { - rates := make(chan RateResponse, 2) - - go func() { - rate, err := e.fetchBinanceRate(ctx, currency) - rates <- RateResponse{Rate: rate, Error: err} - }() - - go func() { - rate, err := e.fetchBitgetRate(ctx, currency) - rates <- RateResponse{Rate: rate, Error: err} - }() - - var validRates []Rate - for i := 0; i < 2; i++ { - resp := <-rates - if resp.Error == nil { - validRates = append(validRates, resp.Rate) - } - } - if len(validRates) == 0 { - return Rate{}, fmt.Errorf("no valid rates found for %s", currency) - } - return calculateMedianRate(validRates), nil -} - -// fetchBitgetRate fetches rates from Bitget -func (e *ExternalMarketRates) fetchBitgetRate(ctx context.Context, currency string) (Rate, error) { - url := fmt.Sprintf("%s?symbol=USDT%s&limit=20", e.bitgetURL, strings.ToUpper(currency)) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return Rate{}, fmt.Errorf("creating request: %w", err) - } + var prices []decimal.Decimal - resp, err := e.httpClient.Do(req) - if err != nil { - return Rate{}, fmt.Errorf("fetching from Bitget:: %w", err) - } - defer resp.Body.Close() - - var result BitgetResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return Rate{}, fmt.Errorf("decoding Bitget response: %w", err) - } - - if len(result.Data) == 0 { - return Rate{}, fmt.Errorf("no Bitget P2P ads found for %s", currency) + // Fetch rates based on currency + if currency == "NGN" { + quidaxRate, err := FetchQuidaxRate(currency) + if err == nil { + prices = append(prices, quidaxRate) + } + } else { + binanceRate, err := FetchBinanceRate(currency) + if err == nil { + prices = append(prices, binanceRate) + } } - // Extract and convert prices to float64 - var prices []float64 - for _, ad := range result.Data { - price, err := strconv.ParseFloat(ad.Price, 64) - if err != nil { - continue - } - prices = append(prices, price) + // Fetch Bitget rate for all supported currencies + bitgetRate, err := FetchBitgetRate(currency) + if err == nil { + prices = append(prices, bitgetRate) } if len(prices) == 0 { - return Rate{}, fmt.Errorf("no valid Bitget P2P prices found for %s", currency) + return decimal.Zero, fmt.Errorf("ComputeMarketRate: no valid rates found") } - medianPrice := calculateMedian(prices) - return Rate{ - Currency: currency, - Price: medianPrice, - Provider: ProviderBitget, - Timestamp: time.Now(), - }, nil - + // Return the median price + return Median(prices), nil } -// fetchBinanceRate fetches rates from Binance -func (e *ExternalMarketRates) fetchBinanceRate(ctx context.Context, currency string) (Rate, error) { - url := fmt.Sprintf("%s?symbol=USDT%s", e.binanceURL, strings.ToUpper(currency)) +// FetchQuidaxRate fetches the USDT exchange rate from Quidax (NGN only) +func FetchQuidaxRate(currency string) (decimal.Decimal, error) { + url := fmt.Sprintf("/api/v1/markets/tickers/usdt%s", strings.ToLower(currency)) - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + res, err := fastshot.NewClient(QuidaxAPIURL). + Config().SetTimeout(30*time.Second). + Build().GET(url). + Retry().Set(3, 5*time.Second). + Send() if err != nil { - return Rate{}, fmt.Errorf("creating request: %w", err) + return decimal.Zero, fmt.Errorf("FetchQuidaxRate: %w", err) } - resp, err := e.httpClient.Do(req) + data, err := ParseJSONResponse(res.RawResponse) if err != nil { - return Rate{}, fmt.Errorf("fetching from Binance:: %w", err) + return decimal.Zero, fmt.Errorf("FetchQuidaxRate: %w", err) } - defer resp.Body.Close() - var result struct { - Price string `json:"price"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return Rate{}, fmt.Errorf("decoding Binance response: %w", err) - } - price, err := strconv.ParseFloat(result.Price, 64) + price, err := decimal.NewFromString(data["data"].(map[string]interface{})["ticker"].(map[string]interface{})["buy"].(string)) if err != nil { - return Rate{}, fmt.Errorf("parsing Binance price: %w", err) + return decimal.Zero, fmt.Errorf("FetchQuidaxRate: %w", err) } - return Rate{ - Currency: currency, - Price: price, - Provider: ProviderBinance, - Timestamp: time.Now(), - }, nil -} -// fetchQuidaxRate fetches ratesfrom Quidax -func (e *ExternalMarketRates) fetchQuidaxRate(ctx context.Context, currency string) (Rate, error) { - url := fmt.Sprintf("%s/usdt%s/ticker", e.quidaxURL, strings.ToLower(currency)) + return price, nil +} - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) +// FetchBinanceRate fetches the median USDT exchange rate from Binance P2P +func FetchBinanceRate(currency string) (decimal.Decimal, error) { + + res, err := fastshot.NewClient(BinanceAPIURL). + Config().SetTimeout(30*time.Second). + Header().Add("Content-Type", "application/json"). + Build().POST("/bapi/c2c/v2/friendly/c2c/adv/search"). + Retry().Set(3, 5*time.Second). + Body().AsJSON(map[string]interface{}{ + "asset": "USDT", + "fiat": currency, + "tradeType": "SELL", + "page": 1, + "rows": 20, + }). + Send() if err != nil { - return Rate{}, fmt.Errorf("creating request: %w", err) + return decimal.Zero, fmt.Errorf("FetchBinanceRate: %w", err) } - resp, err := e.httpClient.Do(req) + + resData, err := ParseJSONResponse(res.RawResponse) if err != nil { - return Rate{}, fmt.Errorf("fetching from Quidax:: %w", err) + return decimal.Zero, fmt.Errorf("FetchBinanceRate: %w", err) } - defer resp.Body.Close() - var result struct { - Data struct { - LastPrice string `json:"last_price"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return Rate{}, fmt.Errorf("decoding Quidax response: %w", err) + data, ok := resData["data"].([]interface{}) + if !ok || len(data) == 0 { + return decimal.Zero, fmt.Errorf("FetchBinanceRate: no data in response") } - price, err := strconv.ParseFloat(result.Data.LastPrice, 64) - if err != nil { - return Rate{}, fmt.Errorf("parsing Quidax price: %w", err) - } + var prices []decimal.Decimal + for _, item := range data { + adv, ok := item.(map[string]interface{})["adv"].(map[string]interface{}) + if !ok { + continue + } - return Rate{ - Currency: currency, - Price: price, - Provider: ProviderQuidax, - Timestamp: time.Now(), - }, nil -} + price, err := decimal.NewFromString(adv["price"].(string)) + if err != nil { + continue + } -// calculateMedian calculates the median value from slice of float64 -func calculateMedian(values []float64) float64 { - if len(values) == 0 { - return 0 + prices = append(prices, price) } - sort.Float64s(values) - middle := len(values) / 2 - - if len(values)%2 == 0 { - return (values[middle-1] + values[middle]) / 2 + if len(prices) == 0 { + return decimal.Zero, fmt.Errorf("FetchBinanceRate: no valid prices found") } - return values[middle] + + return Median(prices), nil } -// New helper function to calculate median from Rate objects -func calculateMedianRate(rates []Rate) Rate { - if len(rates) == 0 { - return Rate{} +// FetchBitgetRate fetches the median USDT exchange rate from Bitget P2P +func FetchBitgetRate(currency string) (decimal.Decimal, error) { + + res, err := fastshot.NewClient(BitgetAPIURL). + Config().SetTimeout(30*time.Second). + Header().Add("Content-Type", "application/json"). + Build().POST("/api/v2/p2p/adv/search"). + Retry().Set(3, 5*time.Second). + Body().AsJSON(map[string]interface{}{ + "tokenId": "USDT", + "fiat": currency, + "side": "sell", + "page": 1, + "size": 20, + }). + Send() + if err != nil { + return decimal.Zero, fmt.Errorf("FetchBitgetRate: %w", err) } - // Extract prices for median calculation - prices := make([]float64, len(rates)) - for i, rate := range rates { - prices[i] = rate.Price + resData, err := ParseJSONResponse(res.RawResponse) + if err != nil { + return decimal.Zero, fmt.Errorf("FetchBitgetRate: %w", err) } - medianPrice := calculateMedian(prices) - - // Find the rate object closest to the median price - var closestRate Rate - smallestDiff := float64(^uint(0) >> 1) // Max float64 + data, ok := resData["data"].([]interface{}) + if !ok || len(data) == 0 { + return decimal.Zero, fmt.Errorf("FetchBitgetRate: no data in response") + } - for _, rate := range rates { + var prices []decimal.Decimal + for _, item := range data { + adv, ok := item.(map[string]interface{})["price"].(string) + if !ok { + continue + } - // Find closest rate to median - diff := Abs(rate.Price - medianPrice) - if diff < smallestDiff { - smallestDiff = diff - closestRate = rate + price, err := decimal.NewFromString(adv) + if err != nil { + continue } + + prices = append(prices, price) } - // Return a new Rate with the median price but keeping other metadata from the closest rate - return Rate{ - Currency: closestRate.Currency, - Price: medianPrice, - Provider: closestRate.Provider, - Timestamp: closestRate.Timestamp, + if len(prices) == 0 { + return decimal.Zero, fmt.Errorf("FetchBitgetRate: no valid prices found") } + + return Median(prices), nil } diff --git a/utils/external_market_test.go b/utils/external_market_test.go index 6318904f..31884e0f 100644 --- a/utils/external_market_test.go +++ b/utils/external_market_test.go @@ -1,190 +1,101 @@ package utils import ( - "context" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" ) -func TestExternalMarketRates(t *testing.T) { - // Mock Bitget server with currency-specific responses +// TestFetchExternalRate with mock API servers +func TestFetchExternalRate(t *testing.T) { + // Mock Bitget server bitgetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - symbol := r.URL.Query().Get("symbol") - var response string - - switch symbol { - case "USDTNGN": - response = `{ - "code": "00000", - "msg": "success", - "data": [ - {"price": "745.0", "available": "1000"}, - {"price": "750.0", "available": "2000"}, - {"price": "755.0", "available": "1500"} - ] - }` - case "USDTKES": - response = `{ - "code": "00000", - "msg": "success", - "data": [ - {"price": "145.0", "available": "1000"}, - {"price": "145.5", "available": "2000"}, - {"price": "146.0", "available": "1500"} - ] - }` - case "USDTGHS": - response = `{ - "code": "00000", - "msg": "success", - "data": [ - {"price": "545.0", "available": "1000"}, - {"price": "545.5", "available": "2000"}, - {"price": "546.0", "available": "1500"} - ] - }` - } w.Header().Set("Content-Type", "application/json") - w.Write([]byte(response)) - })) - defer bitgetServer.Close() + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) - // Mock Binance server with currency-specific responses - binanceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - symbol := r.URL.Query().Get("symbol") var response string - - switch symbol { - case "USDTKES": - response = `{"symbol":"USDTKES","price":"145.50"}` - case "USDTGHS": - response = `{"symbol":"USDTGHS","price":"545.50"}` + switch reqBody["fiat"] { + case "NGN": + response = `{"code":"00000","msg":"success","data":[{"price":"750.0"},{"price":"755.0"}]}` + case "KES": + response = `{"code":"00000","msg":"success","data":[{"price":"145.0"},{"price":"146.0"}]}` default: - response = `{"symbol":"USDTNGN","price":"750.00"}` + w.WriteHeader(http.StatusBadRequest) + return } - w.Header().Set("Content-Type", "application/json") w.Write([]byte(response)) })) - defer binanceServer.Close() + defer bitgetServer.Close() - // Mock Quidax server with currency-specific responses quidaxServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - response := `{ - "data": { - "last_price": "755.00" - } - }` w.Header().Set("Content-Type", "application/json") + response := `{"data": {"ticker": {"buy": "755.00"}}}` w.Write([]byte(response)) })) defer quidaxServer.Close() - // Create ExternalMarketRates instance with mock servers - emr := NewExternalMarketRates() - emr.bitgetURL = bitgetServer.URL - emr.binanceURL = binanceServer.URL - emr.quidaxURL = quidaxServer.URL - - ctx := context.Background() - - t.Run("FetchRate", func(t *testing.T) { - tests := []struct { - name string - currency string - want float64 - wantErr bool - setup func(emr *ExternalMarketRates) - }{ - { - name: "Test NGN rate fetch", - currency: "NGN", - want: 752.5, - wantErr: false, - }, - { - name: "Test other currency rate fetch (KES)", - currency: "KES", - want: 145.5, - wantErr: false, - }, - { - name: "Test other currency rate fetch (GHS)", - currency: "GHS", - want: 545.5, - wantErr: false, - }, - } + binanceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + response := `{"data":[{"adv":{"price":"145.50"}}]}` + w.Write([]byte(response)) + })) + defer binanceServer.Close() + + // Override API URLs + BitgetAPIURL = bitgetServer.URL + BinanceAPIURL = binanceServer.URL + QuidaxAPIURL = quidaxServer.URL + + fmt.Println("BitgetAPIURL:", BitgetAPIURL) + fmt.Println("QuidaxAPIURL:", QuidaxAPIURL) + fmt.Println("BinanceAPIURL:", BinanceAPIURL) + + // Run test cases + t.Run("Fetch rate for NGN (Quidax & Bitget)", func(t *testing.T) { + rate, err := FetchExternalRate("NGN") + fmt.Println(rate, err) + assert.NoError(t, err) + expectedMedian := decimal.NewFromFloat(753.75) + assert.Equal(t, expectedMedian.StringFixed(2), rate.StringFixed(2)) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.setup != nil { - tt.setup(emr) - } - got, err := emr.FetchRate(ctx, tt.currency) - if (err != nil) != tt.wantErr { - t.Errorf("FetchRate() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !tt.wantErr && got.Price != tt.want { - t.Errorf("FetchRate() = %v, want %v", got.Price, tt.want) - } - }) - } }) - t.Run("CalculateMedian", func(t *testing.T) { - tests := []struct { - name string - values []float64 - want float64 - }{ - { - name: "Odd number of values", - values: []float64{1, 2, 3}, - want: 2, - }, - { - name: "Even number of values", - values: []float64{1, 2, 3, 4}, - want: 2.5, - }, - { - name: "Empty slice", - values: []float64{}, - want: 0, - }, - { - name: "Single value", - values: []float64{1}, - want: 1, - }, - } + t.Run("Fetch rate for KES (Binance & Bitget)", func(t *testing.T) { + rate, err := FetchExternalRate("KES") + assert.NoError(t, err) + expectedMedian := decimal.NewFromFloat(145.50) + assert.Equal(t, expectedMedian.StringFixed(2), rate.StringFixed(2)) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := calculateMedian(tt.values); got != tt.want { - t.Errorf("calculateMedian() = %v, want %v", got, tt.want) - } - }) - } }) - t.Run("Concurrent Provider Failures", func(t *testing.T) { - // Set up mock servers that fail - failingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer failingServer.Close() - - emr := NewExternalMarketRates() - emr.bitgetURL = failingServer.URL - emr.binanceURL = "invalid-url" - - // Should still work with one valid provider - _, err := emr.FetchRate(context.Background(), "KES") - if err == nil { - t.Error("Expected error when all providers fail") - } + t.Run("Fetch rate for GHS (Binance & Bitget)", func(t *testing.T) { + rate, err := FetchExternalRate("GHS") + assert.NoError(t, err) + expectedMedian := decimal.NewFromFloat(145.50) + assert.Equal(t, expectedMedian.StringFixed(2), rate.StringFixed(2)) + + }) + + t.Run("Unsupported currency", func(t *testing.T) { + _, err := FetchExternalRate("USD") + assert.Error(t, err) + assert.Contains(t, err.Error(), "currency not supported") + }) + + t.Run("API failures - No valid rates", func(t *testing.T) { + // Simulate API failure by setting invalid URLs + BitgetAPIURL = "http://invalid-url" + BinanceAPIURL = "http://invalid-url" + QuidaxAPIURL = "http://invalid-url" + + _, err := FetchExternalRate("NGN") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no valid rates found") }) } diff --git a/utils/http.go b/utils/http.go index d63b286d..7f2e4ad1 100644 --- a/utils/http.go +++ b/utils/http.go @@ -80,11 +80,12 @@ func ParseJSONResponse(res *http.Response) (map[string]interface{}, error) { } } - if res.StatusCode >= 500 { // Return on server errors - return body, fmt.Errorf(fmt.Sprint(res.StatusCode)) + if res.StatusCode >= 500 { + return body, fmt.Errorf("server error: %d", res.StatusCode) } - if res.StatusCode >= 400 { // Return on client errors - return body, fmt.Errorf(fmt.Sprint(res.StatusCode)) + + if res.StatusCode >= 400 { + return body, fmt.Errorf("client error: %d", res.StatusCode) } return body, nil diff --git a/utils/utils.go b/utils/utils.go index b31e70db..54aef04b 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -146,14 +146,6 @@ func ContainsString(slice []string, item string) bool { return false } -// abs returns the absolute value of x. -func Abs(x float64) float64 { - if x < 0 { - return -x - } - return x -} - // Median returns the median value of a decimal slice func Median(data []decimal.Decimal) decimal.Decimal { l := len(data)