222 lines
5.0 KiB
Go
222 lines
5.0 KiB
Go
package oracle
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tech/sendico/fx/storage/model"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
)
|
|
|
|
type priceSet struct {
|
|
bid *big.Rat
|
|
ask *big.Rat
|
|
mid *big.Rat
|
|
}
|
|
|
|
func (s *Service) computeCrossRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
|
|
if pair == nil || pair.Cross == nil || !pair.Cross.Enabled {
|
|
return nil, merrors.ErrNoData
|
|
}
|
|
|
|
baseSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.BaseLeg, provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
quoteSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.QuoteLeg, provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
basePrices, err := buildPriceSet(baseSnap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
quotePrices, err := buildPriceSet(quoteSnap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if pair.Cross.BaseLeg.Invert {
|
|
basePrices, err = invertPriceSet(basePrices)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if pair.Cross.QuoteLeg.Invert {
|
|
quotePrices, err = invertPriceSet(quotePrices)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
result := multiplyPriceSets(basePrices, quotePrices)
|
|
if result.ask.Cmp(result.bid) < 0 {
|
|
result.ask, result.bid = result.bid, result.ask
|
|
}
|
|
|
|
spread := calcSpreadBps(result)
|
|
|
|
asOfMs := minNonZero(baseSnap.AsOfUnixMs, quoteSnap.AsOfUnixMs)
|
|
if asOfMs == 0 {
|
|
asOfMs = time.Now().UnixMilli()
|
|
}
|
|
asOf := time.UnixMilli(asOfMs)
|
|
|
|
rateRef := fmt.Sprintf("cross|%s/%s|%s|%s+%s", pair.Pair.Base, pair.Pair.Quote, provider, baseSnap.RateRef, quoteSnap.RateRef)
|
|
|
|
return &model.RateSnapshot{
|
|
RateRef: rateRef,
|
|
Pair: pair.Pair,
|
|
Provider: provider,
|
|
Mid: formatPrice(result.mid),
|
|
Bid: formatPrice(result.bid),
|
|
Ask: formatPrice(result.ask),
|
|
SpreadBps: formatPrice(spread),
|
|
AsOfUnixMs: asOfMs,
|
|
AsOf: &asOf,
|
|
Source: "cross_rate",
|
|
ProviderRef: rateRef,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) fetchCrossLegSnapshot(ctx context.Context, leg model.CrossRateLeg, fallbackProvider string) (*model.RateSnapshot, error) {
|
|
provider := fallbackProvider
|
|
if strings.TrimSpace(leg.Provider) != "" {
|
|
provider = leg.Provider
|
|
}
|
|
if provider == "" {
|
|
return nil, merrors.InvalidArgument("oracle: cross leg provider missing")
|
|
}
|
|
return s.storage.Rates().LatestSnapshot(ctx, leg.Pair, provider)
|
|
}
|
|
|
|
func buildPriceSet(rate *model.RateSnapshot) (priceSet, error) {
|
|
if rate == nil {
|
|
return priceSet{}, merrors.InvalidArgument("oracle: cross rate requires underlying snapshot")
|
|
}
|
|
ask, err := parsePrice(rate.Ask)
|
|
if err != nil {
|
|
return priceSet{}, err
|
|
}
|
|
bid, err := parsePrice(rate.Bid)
|
|
if err != nil {
|
|
return priceSet{}, err
|
|
}
|
|
mid, err := parsePrice(rate.Mid)
|
|
if err != nil {
|
|
return priceSet{}, err
|
|
}
|
|
|
|
if ask == nil && bid == nil {
|
|
if mid == nil {
|
|
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing price data")
|
|
}
|
|
ask = new(big.Rat).Set(mid)
|
|
bid = new(big.Rat).Set(mid)
|
|
}
|
|
if ask == nil && mid != nil {
|
|
ask = new(big.Rat).Set(mid)
|
|
}
|
|
if bid == nil && mid != nil {
|
|
bid = new(big.Rat).Set(mid)
|
|
}
|
|
if ask == nil || bid == nil {
|
|
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing bid/ask data")
|
|
}
|
|
|
|
ps := priceSet{
|
|
bid: new(big.Rat).Set(bid),
|
|
ask: new(big.Rat).Set(ask),
|
|
mid: averageOrMid(bid, ask, mid),
|
|
}
|
|
if ps.ask.Cmp(ps.bid) < 0 {
|
|
ps.ask, ps.bid = ps.bid, ps.ask
|
|
}
|
|
return ps, nil
|
|
}
|
|
|
|
func parsePrice(value string) (*big.Rat, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil, nil
|
|
}
|
|
return ratFromString(value)
|
|
}
|
|
|
|
func averageOrMid(bid, ask, mid *big.Rat) *big.Rat {
|
|
if mid != nil {
|
|
return new(big.Rat).Set(mid)
|
|
}
|
|
sum := new(big.Rat).Add(bid, ask)
|
|
return sum.Quo(sum, big.NewRat(2, 1))
|
|
}
|
|
|
|
func invertPriceSet(ps priceSet) (priceSet, error) {
|
|
if ps.ask.Sign() == 0 || ps.bid.Sign() == 0 {
|
|
return priceSet{}, merrors.InvalidArgument("oracle: cannot invert zero price")
|
|
}
|
|
one := big.NewRat(1, 1)
|
|
invBid := new(big.Rat).Quo(one, ps.ask)
|
|
invAsk := new(big.Rat).Quo(one, ps.bid)
|
|
var invMid *big.Rat
|
|
if ps.mid != nil && ps.mid.Sign() != 0 {
|
|
invMid = new(big.Rat).Quo(one, ps.mid)
|
|
} else {
|
|
invMid = averageOrMid(invBid, invAsk, nil)
|
|
}
|
|
result := priceSet{
|
|
bid: invBid,
|
|
ask: invAsk,
|
|
mid: invMid,
|
|
}
|
|
if result.ask.Cmp(result.bid) < 0 {
|
|
result.ask, result.bid = result.bid, result.ask
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func multiplyPriceSets(a, b priceSet) priceSet {
|
|
result := priceSet{
|
|
bid: mulRat(a.bid, b.bid),
|
|
ask: mulRat(a.ask, b.ask),
|
|
}
|
|
result.mid = averageOrMid(result.bid, result.ask, nil)
|
|
return result
|
|
}
|
|
|
|
func calcSpreadBps(ps priceSet) *big.Rat {
|
|
if ps.mid == nil || ps.mid.Sign() == 0 {
|
|
return nil
|
|
}
|
|
spread := new(big.Rat).Sub(ps.ask, ps.bid)
|
|
if spread.Sign() < 0 {
|
|
spread.Neg(spread)
|
|
}
|
|
spread.Quo(spread, ps.mid)
|
|
spread.Mul(spread, big.NewRat(10000, 1))
|
|
return spread
|
|
}
|
|
|
|
func minNonZero(values ...int64) int64 {
|
|
var result int64
|
|
for _, v := range values {
|
|
if v <= 0 {
|
|
continue
|
|
}
|
|
if result == 0 || v < result {
|
|
result = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func formatPrice(r *big.Rat) string {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
return r.FloatString(8)
|
|
}
|