Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: fin provider #42

Merged
merged 9 commits into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/supported_assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var (
provider.ProviderCrypto: false,
provider.ProviderPolygon: true,
provider.ProviderMock: false,
provider.ProviderFin: false,
}

// SupportedQuotes defines a lookup table for which assets we support
Expand Down
3 changes: 3 additions & 0 deletions oracle/oracle.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,9 @@ func NewProvider(
case provider.ProviderPolygon:
return provider.NewPolygonProvider(ctx, logger, endpoint, providerPairs...)

case provider.ProviderFin:
return provider.NewFinProvider(endpoint), nil

case provider.ProviderMock:
return provider.NewMockProvider(), nil
}
Expand Down
309 changes: 309 additions & 0 deletions oracle/provider/fin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
package provider

import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/ojo-network/price-feeder/oracle/types"
)

const (
finRestURL = "https://api.kujira.app"
finPairsEndpoint = "/api/coingecko/pairs"
finTickersEndpoint = "/api/coingecko/tickers"
finCandlesEndpoint = "/api/trades/candles"
finCandleBinSizeMinutes = 5
finCandleWindowSizeHours = 240
)

var _ Provider = (*FinProvider)(nil)
adamewozniak marked this conversation as resolved.
Show resolved Hide resolved

type (
// FinProvider implements an Oracle provider for use with the
// json-based FIN API.
//
// It polls and caches all data once every 30 seconds.
FinProvider struct {
baseURL string
client *http.Client
}

FinTickers struct {
Tickers []FinTicker `json:"tickers"`
}
FinTicker struct {
Base string `json:"base_currency"`
Target string `json:"target_currency"`
Symbol string `json:"ticker_id"`
Price string `json:"last_price"`
Volume string `json:"base_volume"`
}

FinCandles struct {
Candles []FinCandle `json:"candles"`
}
FinCandle struct {
Bin string `json:"bin"`
Close string `json:"close"`
Volume string `json:"volume"`
}

FinPairs struct {
Pairs []FinPair `json:"pairs"`
}
FinPair struct {
Base string `json:"base"`
Target string `json:"target"`
Symbol string `json:"ticker_id"`
Address string `json:"pool_id"`
}
)

// NewFinProvider returns a new instance of the Fin Provider.
func NewFinProvider(endpoint Endpoint) *FinProvider {
if endpoint.Name == ProviderFin {
return &FinProvider{
baseURL: endpoint.Rest,
client: newDefaultHTTPClient(),
}
}
return &FinProvider{
baseURL: finRestURL,
client: newDefaultHTTPClient(),
}
}

// GetTickerPrices queries the FIN json API and returns with a
// map of string => types.TickerPrice.
func (p FinProvider) GetTickerPrices(pairs ...types.CurrencyPair) (
map[string]types.TickerPrice, error,
) {
path := fmt.Sprintf("%s%s", p.baseURL, finTickersEndpoint)
tickerResponse, err := p.client.Get(path)
if err != nil {
return nil, fmt.Errorf("FIN tickers request failed: %w", err)
}
defer tickerResponse.Body.Close()

tickerContent, err := io.ReadAll(tickerResponse.Body)
if err != nil {
return nil,
fmt.Errorf("FIN tickers response read failed: %w", err)
}
var tickers FinTickers
err = json.Unmarshal(tickerContent, &tickers)
if err != nil {
return nil,
fmt.Errorf("FIN tickers response unmarshal failed: %w", err)
}
return tickers.toTickerPrices(pairs)
}

// toTickerPrices takes a FinTickers object and returns a
// map of string => types.TickerPrice.
func (ft FinTickers) toTickerPrices(pairs []types.CurrencyPair) (
map[string]types.TickerPrice, error,
) {
tickerSymbolPairs := make(map[string]types.CurrencyPair, len(pairs))
for _, pair := range pairs {
tickerSymbolPairs[pair.Base+"_"+pair.Quote] = pair
}
tickerPrices := make(map[string]types.TickerPrice, len(pairs))
for _, ticker := range ft.Tickers {
pair, ok := tickerSymbolPairs[strings.ToUpper(ticker.Symbol)]
if !ok {
// skip tokens that are not requested
continue
}
_, ok = tickerPrices[pair.String()]
if ok {
return nil,
fmt.Errorf("FIN tickers response contained duplicate: %s", ticker.Symbol)
}

price, err := strToDec(ticker.Price)
if err != nil {
return nil, err
}
volume, err := strToDec(ticker.Volume)
if err != nil {
return nil, err
}
tickerPrices[pair.String()] = types.TickerPrice{
Price: price,
Volume: volume,
}
}

for _, pair := range pairs {
_, ok := tickerPrices[pair.String()]
if !ok {
return nil,
fmt.Errorf("FIN ticker price missing for pair: %s", pair.String())
}
}
return tickerPrices, nil
}

// GetCandlePrices queries the FIN json API for each pair,
// gets each set of candles, and returns with a map
// of string => []types.CandlePrice.
func (p FinProvider) GetCandlePrices(pairs ...types.CurrencyPair) (
map[string][]types.CandlePrice, error,
) {
pairAddresses, err := p.getFinPairAddresses()
if err != nil {
return nil,
fmt.Errorf("FIN pair addresses lookup failed: %w", err)
}

candlePricesPairs := make(map[string][]types.CandlePrice)
for _, pair := range pairs {
address, ok := pairAddresses[pair.String()]
if !ok {
return nil,
fmt.Errorf("FIN contract address lookup failed for pair: %s", pair.String())
}
candlePricesPairs[pair.String()] = []types.CandlePrice{}

windowEndTime := time.Now()
windowStartTime := windowEndTime.Add(-finCandleWindowSizeHours * time.Hour)
path := fmt.Sprintf("%s%s?contract=%s&precision=%d&from=%s&to=%s",
p.baseURL,
finCandlesEndpoint,
address,
finCandleBinSizeMinutes,
windowStartTime.Format(time.RFC3339),
windowEndTime.Format(time.RFC3339),
)
candlesResponse, err := p.client.Get(path)
if err != nil {
return nil, fmt.Errorf("FIN candles request failed: %w", err)
}
defer candlesResponse.Body.Close()

candlesContent, err := io.ReadAll(candlesResponse.Body)
if err != nil {
return nil, fmt.Errorf("FIN candles response read failed: %w", err)
}
var candles FinCandles
err = json.Unmarshal(candlesContent, &candles)
if err != nil {
return nil, fmt.Errorf("FIN candles response unmarshal failed: %w", err)
}

cp, err := candles.ToCandlePrice()
if err != nil {
return nil, err
}
candlePricesPairs[pair.String()] = cp
}
return candlePricesPairs, nil
}

// ToCandlePrice converts a FinCandles object to a []types.CandlePrice object.
func (fc FinCandles) ToCandlePrice() ([]types.CandlePrice, error) {
candlePrices := []types.CandlePrice{}
for _, candle := range fc.Candles {
timeStamp, err := binToTimeStamp(candle.Bin)
if err != nil {
return nil, fmt.Errorf("FIN candle timestamp failed to parse: %s", candle.Bin)
}

close, err := strToDec(candle.Close)
if err != nil {
return nil, err
}
volume, err := strToDec(candle.Volume)
if err != nil {
return nil, err
}
candlePrices = append(candlePrices, types.CandlePrice{
Price: close,
Volume: volume,
TimeStamp: timeStamp,
})
}

return candlePrices, nil
}

// GetAvailablePairs queries fin's pairs and returns a map of
// pair => empty struct.
func (p FinProvider) GetAvailablePairs() (map[string]struct{}, error) {
finPairs, err := p.getFinPairs()
if err != nil {
return nil, err
}
availablePairs := make(map[string]struct{}, len(finPairs.Pairs))
for _, pair := range finPairs.Pairs {
pair := types.CurrencyPair{
Base: strings.ToUpper(pair.Base),
Quote: strings.ToUpper(pair.Target),
}
availablePairs[pair.String()] = struct{}{}
}
return availablePairs, nil
}

// getFinPairs queries the fin json API for available pairs,
// parses it, and returns it.
func (p FinProvider) getFinPairs() (FinPairs, error) {
path := fmt.Sprintf("%s%s", p.baseURL, finPairsEndpoint)
pairsResponse, err := p.client.Get(path)
if err != nil {
return FinPairs{}, err
}
defer pairsResponse.Body.Close()
var pairs FinPairs
err = json.NewDecoder(pairsResponse.Body).Decode(&pairs)
if err != nil {
return FinPairs{}, err
}
return pairs, nil
}

// getFinPairAddresses queries the fin API for token pairs,
// and returns a map of [base+quote] => pool id address.
func (p FinProvider) getFinPairAddresses() (map[string]string, error) {
finPairs, err := p.getFinPairs()
if err != nil {
return nil, err
}
pairAddresses := make(map[string]string, len(finPairs.Pairs))
for _, pair := range finPairs.Pairs {
pairAddresses[strings.ToUpper(pair.Base+pair.Target)] = pair.Address
}
return pairAddresses, nil
}

// SubscribeCurrencyPairs performs a no-op since fin does not use websockets
func (p FinProvider) SubscribeCurrencyPairs(_ ...types.CurrencyPair) {}

// binToTimeStamp takes a bin time expressed in a string
// and converts it into a unix timestamp.
func binToTimeStamp(bin string) (int64, error) {
timeParsed, err := time.Parse(time.RFC3339, bin)
if err != nil {
return -1, err
}
return timeParsed.Unix(), nil
}

// strToDec converts fin provider's decimals as a string to sdk.Dec.
// It's necessary because the precision of the decimal returned
// by the API is greater than what's supported by sdk.Dec (18).
func strToDec(str string) (sdk.Dec, error) {
if strings.Contains(str, ".") {
split := strings.Split(str, ".")
if len(split[1]) > 18 {
str = split[0] + "." + split[1][0:18]
}
}
return sdk.NewDecFromStr(str)
}
Loading