service backend
This commit is contained in:
27
api/fx/oracle/internal/appversion/version.go
Normal file
27
api/fx/oracle/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 Oracle Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&vi)
|
||||
}
|
||||
101
api/fx/oracle/internal/server/internal/serverimp.go
Normal file
101
api/fx/oracle/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/oracle/internal/service/oracle"
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Imp struct {
|
||||
logger mlogger.Logger
|
||||
file string
|
||||
debug bool
|
||||
|
||||
config *grpcapp.Config
|
||||
app *grpcapp.App[storage.Repository]
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
logger: logger.Named("server"),
|
||||
file: file,
|
||||
debug: debug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i *Imp) Shutdown() {
|
||||
if i.app == nil {
|
||||
return
|
||||
}
|
||||
timeout := 15 * time.Second
|
||||
if i.config != nil && i.config.Runtime != nil {
|
||||
timeout = i.config.Runtime.ShutdownTimeout()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
i.app.Shutdown(ctx)
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (i *Imp) Start() error {
|
||||
cfg, err := i.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.config = cfg
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
return mongostorage.New(logger, conn)
|
||||
}
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
return oracle.NewService(logger, repo, producer), nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
}
|
||||
|
||||
func (i *Imp) loadConfig() (*grpcapp.Config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &grpcapp.Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.Runtime == nil {
|
||||
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||
}
|
||||
|
||||
if cfg.GRPC == nil {
|
||||
cfg.GRPC = &routers.GRPCConfig{
|
||||
Network: "tcp",
|
||||
Address: ":50051",
|
||||
EnableReflection: true,
|
||||
EnableHealth: true,
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
11
api/fx/oracle/internal/server/server.go
Normal file
11
api/fx/oracle/internal/server/server.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
serverimp "github.com/tech/sendico/fx/oracle/internal/server/internal"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
)
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return serverimp.Create(logger, file, debug)
|
||||
}
|
||||
223
api/fx/oracle/internal/service/oracle/calculator.go
Normal file
223
api/fx/oracle/internal/service/oracle/calculator.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type quoteComputation struct {
|
||||
pair *model.Pair
|
||||
rate *model.RateSnapshot
|
||||
sideProto fxv1.Side
|
||||
sideModel model.QuoteSide
|
||||
price *big.Rat
|
||||
baseInput *big.Rat
|
||||
quoteInput *big.Rat
|
||||
amountType model.QuoteAmountType
|
||||
baseRounded *big.Rat
|
||||
quoteRounded *big.Rat
|
||||
priceRounded *big.Rat
|
||||
baseScale uint32
|
||||
quoteScale uint32
|
||||
priceScale uint32
|
||||
provider string
|
||||
}
|
||||
|
||||
func newQuoteComputation(pair *model.Pair, rate *model.RateSnapshot, side fxv1.Side, provider string) (*quoteComputation, error) {
|
||||
if pair == nil || rate == nil {
|
||||
return nil, merrors.InvalidArgument("oracle: missing pair or rate")
|
||||
}
|
||||
sideModel := protoSideToModel(side)
|
||||
if sideModel == "" {
|
||||
return nil, merrors.InvalidArgument("oracle: unsupported side")
|
||||
}
|
||||
price, err := priceFromRate(rate, side)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(provider) == "" {
|
||||
provider = rate.Provider
|
||||
}
|
||||
return "eComputation{
|
||||
pair: pair,
|
||||
rate: rate,
|
||||
sideProto: side,
|
||||
sideModel: sideModel,
|
||||
price: price,
|
||||
baseScale: pair.BaseMeta.Decimals,
|
||||
quoteScale: pair.QuoteMeta.Decimals,
|
||||
priceScale: pair.QuoteMeta.Decimals,
|
||||
provider: provider,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) withBaseInput(m *moneyv1.Money) error {
|
||||
if m == nil {
|
||||
return merrors.InvalidArgument("oracle: base amount missing")
|
||||
}
|
||||
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Base) {
|
||||
return merrors.InvalidArgument("oracle: base amount currency mismatch")
|
||||
}
|
||||
val, err := ratFromString(m.GetAmount())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.baseInput = val
|
||||
qc.amountType = model.QuoteAmountTypeBase
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) withQuoteInput(m *moneyv1.Money) error {
|
||||
if m == nil {
|
||||
return merrors.InvalidArgument("oracle: quote amount missing")
|
||||
}
|
||||
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Quote) {
|
||||
return merrors.InvalidArgument("oracle: quote amount currency mismatch")
|
||||
}
|
||||
val, err := ratFromString(m.GetAmount())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.quoteInput = val
|
||||
qc.amountType = model.QuoteAmountTypeQuote
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) compute() error {
|
||||
var baseRaw, quoteRaw *big.Rat
|
||||
switch qc.amountType {
|
||||
case model.QuoteAmountTypeBase:
|
||||
baseRaw = qc.baseInput
|
||||
quoteRaw = mulRat(qc.baseInput, qc.price)
|
||||
case model.QuoteAmountTypeQuote:
|
||||
quoteRaw = qc.quoteInput
|
||||
base, err := divRat(qc.quoteInput, qc.price)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseRaw = base
|
||||
default:
|
||||
return merrors.InvalidArgument("oracle: amount type not set")
|
||||
}
|
||||
|
||||
var err error
|
||||
qc.baseRounded, err = roundRatToScale(baseRaw, qc.baseScale, qc.pair.BaseMeta.Rounding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.quoteRounded, err = roundRatToScale(quoteRaw, qc.quoteScale, qc.pair.QuoteMeta.Rounding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.priceRounded, err = roundRatToScale(qc.price, qc.priceScale, qc.pair.QuoteMeta.Rounding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *oraclev1.GetQuoteRequest) (*model.Quote, error) {
|
||||
if qc.baseRounded == nil || qc.quoteRounded == nil || qc.priceRounded == nil {
|
||||
return nil, merrors.Internal("oracle: computation not executed")
|
||||
}
|
||||
|
||||
quote := &model.Quote{
|
||||
QuoteRef: uuid.NewString(),
|
||||
Firm: firm,
|
||||
Status: model.QuoteStatusIssued,
|
||||
Pair: qc.pair.Pair,
|
||||
Side: qc.sideModel,
|
||||
Price: formatRat(qc.priceRounded, qc.priceScale),
|
||||
BaseAmount: model.Money{
|
||||
Currency: qc.pair.Pair.Base,
|
||||
Amount: formatRat(qc.baseRounded, qc.baseScale),
|
||||
},
|
||||
QuoteAmount: model.Money{
|
||||
Currency: qc.pair.Pair.Quote,
|
||||
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
|
||||
},
|
||||
AmountType: qc.amountType,
|
||||
RateRef: qc.rate.RateRef,
|
||||
Provider: qc.provider,
|
||||
PreferredProvider: req.GetPreferredProvider(),
|
||||
RequestedTTLMs: req.GetTtlMs(),
|
||||
MaxAgeToleranceMs: int64(req.GetMaxAgeMs()),
|
||||
Meta: buildQuoteMeta(req.GetMeta()),
|
||||
}
|
||||
|
||||
if firm {
|
||||
quote.ExpiresAtUnixMs = expiryMillis
|
||||
expiry := time.UnixMilli(expiryMillis)
|
||||
quote.ExpiresAt = &expiry
|
||||
}
|
||||
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
trace := meta.GetTrace()
|
||||
qm := &model.QuoteMeta{
|
||||
RequestRef: deriveRequestRef(meta, trace),
|
||||
TenantRef: meta.GetTenantRef(),
|
||||
TraceRef: deriveTraceRef(meta, trace),
|
||||
IdempotencyKey: deriveIdempotencyKey(meta, trace),
|
||||
}
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
|
||||
qm.SetOrganizationRef(objID)
|
||||
}
|
||||
}
|
||||
return qm
|
||||
}
|
||||
|
||||
func protoSideToModel(side fxv1.Side) model.QuoteSide {
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
return model.QuoteSideBuyBaseSellQuote
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
return model.QuoteSideSellBaseBuyQuote
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
|
||||
if ttlMs <= 0 {
|
||||
return 0, merrors.InvalidArgument("oracle: ttl must be positive")
|
||||
}
|
||||
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
|
||||
}
|
||||
|
||||
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||
if trace != nil && trace.GetRequestRef() != "" {
|
||||
return trace.GetRequestRef()
|
||||
}
|
||||
return meta.GetRequestRef()
|
||||
}
|
||||
|
||||
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||
if trace != nil && trace.GetTraceRef() != "" {
|
||||
return trace.GetTraceRef()
|
||||
}
|
||||
return meta.GetTraceRef()
|
||||
}
|
||||
|
||||
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||
if trace != nil && trace.GetIdempotencyKey() != "" {
|
||||
return trace.GetIdempotencyKey()
|
||||
}
|
||||
return meta.GetIdempotencyKey()
|
||||
}
|
||||
221
api/fx/oracle/internal/service/oracle/cross.go
Normal file
221
api/fx/oracle/internal/service/oracle/cross.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
}
|
||||
67
api/fx/oracle/internal/service/oracle/math.go
Normal file
67
api/fx/oracle/internal/service/oracle/math.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
)
|
||||
|
||||
// Convenience aliases to pkg/decimal for backward compatibility
|
||||
var (
|
||||
ratFromString = decimal.RatFromString
|
||||
mulRat = decimal.MulRat
|
||||
divRat = decimal.DivRat
|
||||
formatRat = decimal.FormatRat
|
||||
)
|
||||
|
||||
// roundRatToScale wraps pkg/decimal.RoundRatToScale with model RoundingMode conversion
|
||||
func roundRatToScale(value *big.Rat, scale uint32, mode model.RoundingMode) (*big.Rat, error) {
|
||||
return decimal.RoundRatToScale(value, scale, convertRoundingMode(mode))
|
||||
}
|
||||
|
||||
// convertRoundingMode converts fx/storage model.RoundingMode to pkg/decimal.RoundingMode
|
||||
func convertRoundingMode(mode model.RoundingMode) decimal.RoundingMode {
|
||||
switch mode {
|
||||
case model.RoundingModeHalfEven:
|
||||
return decimal.RoundingModeHalfEven
|
||||
case model.RoundingModeHalfUp:
|
||||
return decimal.RoundingModeHalfUp
|
||||
case model.RoundingModeDown:
|
||||
return decimal.RoundingModeDown
|
||||
case model.RoundingModeUnspecified:
|
||||
return decimal.RoundingModeUnspecified
|
||||
default:
|
||||
return decimal.RoundingModeHalfEven
|
||||
}
|
||||
}
|
||||
|
||||
func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
|
||||
var priceStr string
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
priceStr = rate.Ask
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
priceStr = rate.Bid
|
||||
default:
|
||||
priceStr = ""
|
||||
}
|
||||
|
||||
if strings.TrimSpace(priceStr) == "" {
|
||||
priceStr = rate.Mid
|
||||
}
|
||||
|
||||
if strings.TrimSpace(priceStr) == "" {
|
||||
return nil, merrors.InvalidArgument("oracle: rate snapshot missing price")
|
||||
}
|
||||
|
||||
return ratFromString(priceStr)
|
||||
}
|
||||
|
||||
func timeFromUnixMilli(ms int64) time.Time {
|
||||
return time.Unix(0, ms*int64(time.Millisecond))
|
||||
}
|
||||
65
api/fx/oracle/internal/service/oracle/metrics.go
Normal file
65
api/fx/oracle/internal/service/oracle/metrics.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
|
||||
rpcRequestsTotal *prometheus.CounterVec
|
||||
rpcLatency *prometheus.HistogramVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricsOnce.Do(func() {
|
||||
rpcRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: "fx",
|
||||
Subsystem: "oracle",
|
||||
Name: "requests_total",
|
||||
Help: "Total number of FX oracle RPC calls handled.",
|
||||
},
|
||||
[]string{"method", "result"},
|
||||
)
|
||||
|
||||
rpcLatency = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "fx",
|
||||
Subsystem: "oracle",
|
||||
Name: "request_latency_seconds",
|
||||
Help: "Latency of FX oracle RPC calls.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"method", "result"},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func observeRPC(start time.Time, method string, err error) {
|
||||
result := labelFromError(err)
|
||||
rpcRequestsTotal.WithLabelValues(method, result).Inc()
|
||||
rpcLatency.WithLabelValues(method, result).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
|
||||
func labelFromError(err error) string {
|
||||
if err == nil {
|
||||
return strings.ToLower(codes.OK.String())
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return "error"
|
||||
}
|
||||
code := st.Code()
|
||||
if code == codes.OK {
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
402
api/fx/oracle/internal/service/oracle/service.go
Normal file
402
api/fx/oracle/internal/service/oracle/service.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type serviceError string
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
errSideRequired = serviceError("oracle: side is required")
|
||||
errAmountsMutuallyExclusive = serviceError("oracle: exactly one amount must be provided")
|
||||
errAmountRequired = serviceError("oracle: amount is required")
|
||||
errQuoteRefRequired = serviceError("oracle: quote_ref is required")
|
||||
errEmptyRequest = serviceError("oracle: request payload is empty")
|
||||
errLedgerTxnRefRequired = serviceError("oracle: ledger_txn_ref is required")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
producer pmessaging.Producer
|
||||
oraclev1.UnimplementedOracleServer
|
||||
}
|
||||
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
|
||||
initMetrics()
|
||||
return &Service{
|
||||
logger: logger.Named("oracle"),
|
||||
storage: repo,
|
||||
producer: prod,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Register(router routers.GRPC) error {
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
oraclev1.RegisterOracleServer(reg, s)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.getQuoteResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "GetQuote", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) ValidateQuote(ctx context.Context, req *oraclev1.ValidateQuoteRequest) (*oraclev1.ValidateQuoteResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.validateQuoteResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "ValidateQuote", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) ConsumeQuote(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) (*oraclev1.ConsumeQuoteResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.consumeQuoteResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "ConsumeQuote", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) LatestRate(ctx context.Context, req *oraclev1.LatestRateRequest) (*oraclev1.LatestRateResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.latestRateResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "LatestRate", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) ListPairs(ctx context.Context, req *oraclev1.ListPairsRequest) (*oraclev1.ListPairsResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.listPairsResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "ListPairs", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteRequest) gsresponse.Responder[oraclev1.GetQuoteResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.GetQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
|
||||
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
|
||||
}
|
||||
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
|
||||
}
|
||||
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairMsg := req.GetPair()
|
||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
|
||||
pair, err := s.storage.Pairs().Get(ctx, pairKey)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := req.GetPreferredProvider()
|
||||
if provider == "" {
|
||||
provider = pair.DefaultProvider
|
||||
}
|
||||
if provider == "" && len(pair.Providers) > 0 {
|
||||
provider = pair.Providers[0]
|
||||
}
|
||||
|
||||
rate, err := s.getLatestRate(ctx, pair, provider)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
|
||||
age := now.UnixMilli() - rate.AsOfUnixMs
|
||||
if age > int64(maxAge) {
|
||||
s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
|
||||
}
|
||||
}
|
||||
|
||||
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
if req.GetBaseAmount() != nil {
|
||||
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
} else if req.GetQuoteAmount() != nil {
|
||||
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := comp.compute(); err != nil {
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
expiresAt := int64(0)
|
||||
if req.GetFirm() {
|
||||
expiry, err := computeExpiry(now, req.GetTtlMs())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
expiresAt = expiry
|
||||
}
|
||||
|
||||
quoteModel, err := comp.buildModelQuote(req.GetFirm(), expiresAt, req)
|
||||
if err != nil {
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
if req.GetFirm() {
|
||||
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
|
||||
}
|
||||
|
||||
resp := &oraclev1.GetQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: quoteModelToProto(quoteModel),
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.ValidateQuoteRequest) gsresponse.Responder[oraclev1.ValidateQuoteResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.ValidateQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
|
||||
if req.GetQuoteRef() == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
resp := &oraclev1.ValidateQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: nil,
|
||||
Valid: false,
|
||||
Reason: "not_found",
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
valid := true
|
||||
reason := ""
|
||||
if quote.IsExpired(now) {
|
||||
valid = false
|
||||
reason = "expired"
|
||||
} else if quote.Status == model.QuoteStatusConsumed {
|
||||
valid = false
|
||||
reason = "consumed"
|
||||
}
|
||||
|
||||
resp := &oraclev1.ValidateQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: quoteModelToProto(quote),
|
||||
Valid: valid,
|
||||
Reason: reason,
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) gsresponse.Responder[oraclev1.ConsumeQuoteResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.ConsumeQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
if req.GetQuoteRef() == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||
}
|
||||
if req.GetLedgerTxnRef() == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrQuoteExpired):
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
|
||||
case errors.Is(err, storage.ErrQuoteConsumed):
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
|
||||
case errors.Is(err, storage.ErrQuoteNotFirm):
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &oraclev1.ConsumeQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Consumed: true,
|
||||
Reason: "consumed",
|
||||
}
|
||||
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestRateRequest) gsresponse.Responder[oraclev1.LatestRateResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.LatestRateRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairMsg := req.GetPair()
|
||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
|
||||
pairMeta, err := s.storage.Pairs().Get(ctx, pair)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := req.GetProvider()
|
||||
if provider == "" {
|
||||
provider = pairMeta.DefaultProvider
|
||||
}
|
||||
if provider == "" && len(pairMeta.Providers) > 0 {
|
||||
provider = pairMeta.Providers[0]
|
||||
}
|
||||
|
||||
rate, err := s.getLatestRate(ctx, pairMeta, provider)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &oraclev1.LatestRateResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Rate: rateModelToProto(rate),
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPairsRequest) gsresponse.Responder[oraclev1.ListPairsResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.ListPairsRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ListPairs")
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairs, err := s.storage.Pairs().ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
result := make([]*oraclev1.PairMeta, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
result = append(result, pairModelToProto(pair))
|
||||
}
|
||||
resp := &oraclev1.ListPairsResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Pairs: result,
|
||||
}
|
||||
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) pingStorage(ctx context.Context) error {
|
||||
if s.storage == nil {
|
||||
return nil
|
||||
}
|
||||
return s.storage.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
|
||||
rate, err := s.storage.Rates().LatestSnapshot(ctx, pair.Pair, provider)
|
||||
if err == nil {
|
||||
return rate, nil
|
||||
}
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
crossRate, crossErr := s.computeCrossRate(ctx, pair, provider)
|
||||
if crossErr != nil {
|
||||
if errors.Is(crossErr, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, crossErr
|
||||
}
|
||||
s.logger.Debug("Derived cross rate", zap.String("pair", pair.Pair.Base+"/"+pair.Pair.Quote), zap.String("provider", provider))
|
||||
return crossRate, nil
|
||||
}
|
||||
|
||||
var _ oraclev1.OracleServer = (*Service)(nil)
|
||||
467
api/fx/oracle/internal/service/oracle/service_test.go
Normal file
467
api/fx/oracle/internal/service/oracle/service_test.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type repositoryStub struct {
|
||||
rates storage.RatesStore
|
||||
quotes storage.QuotesStore
|
||||
pairs storage.PairStore
|
||||
currencies storage.CurrencyStore
|
||||
pingErr error
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Ping(ctx context.Context) error { return r.pingErr }
|
||||
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
|
||||
func (r *repositoryStub) Quotes() storage.QuotesStore { return r.quotes }
|
||||
func (r *repositoryStub) Pairs() storage.PairStore { return r.pairs }
|
||||
func (r *repositoryStub) Currencies() storage.CurrencyStore {
|
||||
return r.currencies
|
||||
}
|
||||
|
||||
type ratesStoreStub struct {
|
||||
latestFn func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error)
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
if r.latestFn != nil {
|
||||
return r.latestFn(ctx, pair, provider)
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
type quotesStoreStub struct {
|
||||
issueFn func(ctx context.Context, quote *model.Quote) error
|
||||
getFn func(ctx context.Context, ref string) (*model.Quote, error)
|
||||
consumeFn func(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error)
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) Issue(ctx context.Context, quote *model.Quote) error {
|
||||
if q.issueFn != nil {
|
||||
return q.issueFn(ctx, quote)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) GetByRef(ctx context.Context, ref string) (*model.Quote, error) {
|
||||
if q.getFn != nil {
|
||||
return q.getFn(ctx, ref)
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) Consume(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error) {
|
||||
if q.consumeFn != nil {
|
||||
return q.consumeFn(ctx, ref, ledger, when)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type pairStoreStub struct {
|
||||
getFn func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error)
|
||||
listFn func(ctx context.Context) ([]*model.Pair, error)
|
||||
}
|
||||
|
||||
func (p *pairStoreStub) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
|
||||
if p.listFn != nil {
|
||||
return p.listFn(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *pairStoreStub) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if p.getFn != nil {
|
||||
return p.getFn(ctx, pair)
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (p *pairStoreStub) Upsert(ctx context.Context, pair *model.Pair) error { return nil }
|
||||
|
||||
type currencyStoreStub struct{}
|
||||
|
||||
func (currencyStoreStub) Get(ctx context.Context, code string) (*model.Currency, error) {
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
func (currencyStoreStub) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (currencyStoreStub) Upsert(ctx context.Context, currency *model.Currency) error { return nil }
|
||||
|
||||
func TestServiceGetQuoteFirm(t *testing.T) {
|
||||
repo := &repositoryStub{}
|
||||
repo.pairs = &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo.rates = &ratesStoreStub{
|
||||
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "1.10",
|
||||
Bid: "1.08",
|
||||
RateRef: "rate#1",
|
||||
AsOfUnixMs: time.Now().UnixMilli(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
savedQuote := &model.Quote{}
|
||||
repo.quotes = "esStoreStub{
|
||||
issueFn: func(ctx context.Context, quote *model.Quote) error {
|
||||
*savedQuote = *quote
|
||||
return nil
|
||||
},
|
||||
}
|
||||
repo.currencies = currencyStoreStub{}
|
||||
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
req := &oraclev1.GetQuoteRequest{
|
||||
Meta: &oraclev1.RequestMeta{
|
||||
TenantRef: "tenant",
|
||||
Trace: &tracev1.TraceContext{RequestRef: "req"},
|
||||
},
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{
|
||||
Currency: "USD",
|
||||
Amount: "100",
|
||||
}},
|
||||
Firm: true,
|
||||
TtlMs: 60000,
|
||||
}
|
||||
|
||||
resp, err := svc.GetQuote(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetQuote().GetFirm() != true {
|
||||
t.Fatalf("expected firm quote")
|
||||
}
|
||||
if resp.GetQuote().GetQuoteAmount().GetAmount() != "110.00" {
|
||||
t.Fatalf("unexpected quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
|
||||
}
|
||||
if savedQuote.QuoteRef == "" {
|
||||
t.Fatalf("expected quote persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGetQuoteRateNotFound(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
pairs: &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
rates: &ratesStoreStub{latestFn: func(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
|
||||
return nil, merrors.ErrNoData
|
||||
}},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
_, err := svc.GetQuote(context.Background(), &oraclev1.GetQuoteRequest{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGetQuoteCrossRate(t *testing.T) {
|
||||
repo := &repositoryStub{}
|
||||
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
|
||||
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
|
||||
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
|
||||
|
||||
repo.pairs = &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if pair != targetPair {
|
||||
t.Fatalf("unexpected pair lookup: %v", pair)
|
||||
}
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
DefaultProvider: "CROSSPROV",
|
||||
Cross: &model.CrossRateConfig{
|
||||
Enabled: true,
|
||||
BaseLeg: model.CrossRateLeg{
|
||||
Pair: baseLegPair,
|
||||
Invert: true,
|
||||
},
|
||||
QuoteLeg: model.CrossRateLeg{
|
||||
Pair: quoteLegPair,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo.rates = &ratesStoreStub{
|
||||
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
switch pair {
|
||||
case targetPair:
|
||||
return nil, merrors.ErrNoData
|
||||
case baseLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "0.90",
|
||||
Bid: "0.90",
|
||||
Mid: "0.90",
|
||||
RateRef: "base-leg",
|
||||
AsOfUnixMs: 1_000,
|
||||
}, nil
|
||||
case quoteLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "90",
|
||||
Bid: "90",
|
||||
Mid: "90",
|
||||
RateRef: "quote-leg",
|
||||
AsOfUnixMs: 2_000,
|
||||
}, nil
|
||||
default:
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
},
|
||||
}
|
||||
repo.quotes = "esStoreStub{}
|
||||
repo.currencies = currencyStoreStub{}
|
||||
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
req := &oraclev1.GetQuoteRequest{
|
||||
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "EUR", Amount: "1"}},
|
||||
}
|
||||
|
||||
resp, err := svc.GetQuote(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetQuote().GetPrice().GetValue() != "100.00" {
|
||||
t.Fatalf("unexpected cross price: %s", resp.GetQuote().GetPrice().GetValue())
|
||||
}
|
||||
if resp.GetQuote().GetQuoteAmount().GetAmount() != "100.00" {
|
||||
t.Fatalf("unexpected cross quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
|
||||
}
|
||||
if !strings.HasPrefix(resp.GetQuote().GetRateRef(), "cross|") {
|
||||
t.Fatalf("expected cross rate ref, got %s", resp.GetQuote().GetRateRef())
|
||||
}
|
||||
if resp.GetQuote().GetProvider() != "CROSSPROV" {
|
||||
t.Fatalf("unexpected provider: %s", resp.GetQuote().GetProvider())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestServiceLatestRateCross(t *testing.T) {
|
||||
repo := &repositoryStub{}
|
||||
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
|
||||
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
|
||||
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
|
||||
|
||||
repo.pairs = &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if pair != targetPair {
|
||||
t.Fatalf("unexpected pair lookup: %v", pair)
|
||||
}
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
DefaultProvider: "CROSSPROV",
|
||||
Cross: &model.CrossRateConfig{
|
||||
Enabled: true,
|
||||
BaseLeg: model.CrossRateLeg{
|
||||
Pair: baseLegPair,
|
||||
Invert: true,
|
||||
},
|
||||
QuoteLeg: model.CrossRateLeg{
|
||||
Pair: quoteLegPair,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo.rates = &ratesStoreStub{
|
||||
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
switch pair {
|
||||
case targetPair:
|
||||
return nil, merrors.ErrNoData
|
||||
case baseLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "0.90",
|
||||
Bid: "0.90",
|
||||
Mid: "0.90",
|
||||
RateRef: "base-leg",
|
||||
AsOfUnixMs: 1_000,
|
||||
}, nil
|
||||
case quoteLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "90",
|
||||
Bid: "90",
|
||||
Mid: "90",
|
||||
RateRef: "quote-leg",
|
||||
AsOfUnixMs: 2_000,
|
||||
}, nil
|
||||
default:
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
},
|
||||
}
|
||||
repo.quotes = "esStoreStub{}
|
||||
repo.currencies = currencyStoreStub{}
|
||||
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{
|
||||
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.GetRate().GetMid().GetValue() != "100.00000000" {
|
||||
t.Fatalf("unexpected mid price: %s", resp.GetRate().GetMid().GetValue())
|
||||
}
|
||||
if resp.GetRate().GetProvider() != "CROSSPROV" {
|
||||
t.Fatalf("unexpected provider: %s", resp.GetRate().GetProvider())
|
||||
}
|
||||
if !strings.HasPrefix(resp.GetRate().GetRateRef(), "cross|") {
|
||||
t.Fatalf("expected cross rate ref, got %s", resp.GetRate().GetRateRef())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidateQuote(t *testing.T) {
|
||||
now := time.Now().Add(time.Minute)
|
||||
repo := &repositoryStub{
|
||||
quotes: "esStoreStub{
|
||||
getFn: func(context.Context, string) (*model.Quote, error) {
|
||||
return &model.Quote{
|
||||
QuoteRef: "q1",
|
||||
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: model.QuoteSideBuyBaseSellQuote,
|
||||
Price: "1.10",
|
||||
BaseAmount: model.Money{Currency: "USD", Amount: "100"},
|
||||
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
|
||||
ExpiresAtUnixMs: now.UnixMilli(),
|
||||
Status: model.QuoteStatusIssued,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.ValidateQuote(context.Background(), &oraclev1.ValidateQuoteRequest{QuoteRef: "q1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.GetValid() {
|
||||
t.Fatalf("expected quote valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceConsumeQuoteExpired(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
quotes: "esStoreStub{
|
||||
consumeFn: func(context.Context, string, string, time.Time) (*model.Quote, error) {
|
||||
return nil, storage.ErrQuoteExpired
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
_, err := svc.ConsumeQuote(context.Background(), &oraclev1.ConsumeQuoteRequest{QuoteRef: "q1", LedgerTxnRef: "ledger"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestRateSuccess(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
rates: &ratesStoreStub{latestFn: func(_ context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
if pair != (model.CurrencyPair{Base: "USD", Quote: "EUR"}) {
|
||||
t.Fatalf("unexpected pair: %v", pair)
|
||||
}
|
||||
if provider != "DEFAULT" {
|
||||
t.Fatalf("unexpected provider: %s", provider)
|
||||
}
|
||||
return &model.RateSnapshot{Pair: pair, RateRef: "rate", Provider: provider}, nil
|
||||
}},
|
||||
pairs: &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
DefaultProvider: "DEFAULT",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetRate().GetRateRef() != "rate" {
|
||||
t.Fatalf("unexpected rate ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListPairs(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
pairs: &pairStoreStub{listFn: func(context.Context) ([]*model.Pair, error) {
|
||||
return []*model.Pair{{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}}, nil
|
||||
}},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.ListPairs(context.Background(), &oraclev1.ListPairsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(resp.GetPairs()) != 1 {
|
||||
t.Fatalf("expected one pair")
|
||||
}
|
||||
}
|
||||
126
api/fx/oracle/internal/service/oracle/transform.go
Normal file
126
api/fx/oracle/internal/service/oracle/transform.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
)
|
||||
|
||||
func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
|
||||
resp := &oraclev1.ResponseMeta{}
|
||||
if meta == nil {
|
||||
return resp
|
||||
}
|
||||
resp.RequestRef = meta.GetRequestRef()
|
||||
resp.TraceRef = meta.GetTraceRef()
|
||||
|
||||
trace := meta.GetTrace()
|
||||
if trace == nil {
|
||||
trace = &tracev1.TraceContext{
|
||||
RequestRef: meta.GetRequestRef(),
|
||||
IdempotencyKey: meta.GetIdempotencyKey(),
|
||||
TraceRef: meta.GetTraceRef(),
|
||||
}
|
||||
}
|
||||
resp.Trace = trace
|
||||
return resp
|
||||
}
|
||||
|
||||
func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &oraclev1.Quote{
|
||||
QuoteRef: q.QuoteRef,
|
||||
Pair: &fxv1.CurrencyPair{Base: q.Pair.Base, Quote: q.Pair.Quote},
|
||||
Side: sideModelToProto(q.Side),
|
||||
Price: decimalStringToProto(q.Price),
|
||||
BaseAmount: moneyModelToProto(&q.BaseAmount),
|
||||
QuoteAmount: moneyModelToProto(&q.QuoteAmount),
|
||||
ExpiresAtUnixMs: q.ExpiresAtUnixMs,
|
||||
Provider: q.Provider,
|
||||
RateRef: q.RateRef,
|
||||
Firm: q.Firm,
|
||||
}
|
||||
}
|
||||
|
||||
func moneyModelToProto(m *model.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{Currency: m.Currency, Amount: m.Amount}
|
||||
}
|
||||
|
||||
func sideModelToProto(side model.QuoteSide) fxv1.Side {
|
||||
switch side {
|
||||
case model.QuoteSideBuyBaseSellQuote:
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case model.QuoteSideSellBaseBuyQuote:
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func rateModelToProto(rate *model.RateSnapshot) *oraclev1.RateSnapshot {
|
||||
if rate == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.RateSnapshot{
|
||||
Pair: &fxv1.CurrencyPair{Base: rate.Pair.Base, Quote: rate.Pair.Quote},
|
||||
Mid: decimalStringToProto(rate.Mid),
|
||||
Bid: decimalStringToProto(rate.Bid),
|
||||
Ask: decimalStringToProto(rate.Ask),
|
||||
AsofUnixMs: rate.AsOfUnixMs,
|
||||
Provider: rate.Provider,
|
||||
RateRef: rate.RateRef,
|
||||
SpreadBps: decimalStringToProto(rate.SpreadBps),
|
||||
}
|
||||
}
|
||||
|
||||
func pairModelToProto(pair *model.Pair) *oraclev1.PairMeta {
|
||||
if pair == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.PairMeta{
|
||||
Pair: &fxv1.CurrencyPair{Base: pair.Pair.Base, Quote: pair.Pair.Quote},
|
||||
BaseMeta: currencySettingsToProto(&pair.BaseMeta),
|
||||
QuoteMeta: currencySettingsToProto(&pair.QuoteMeta),
|
||||
}
|
||||
}
|
||||
|
||||
func currencySettingsToProto(c *model.CurrencySettings) *moneyv1.CurrencyMeta {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.CurrencyMeta{
|
||||
Code: c.Code,
|
||||
Decimals: c.Decimals,
|
||||
Rounding: roundingModeToProto(c.Rounding),
|
||||
}
|
||||
}
|
||||
|
||||
func roundingModeToProto(mode model.RoundingMode) moneyv1.RoundingMode {
|
||||
switch mode {
|
||||
case model.RoundingModeHalfUp:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||
case model.RoundingModeDown:
|
||||
return moneyv1.RoundingMode_ROUND_DOWN
|
||||
case model.RoundingModeHalfEven, model.RoundingModeUnspecified:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||
default:
|
||||
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func decimalStringToProto(value string) *moneyv1.Decimal {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Decimal{Value: value}
|
||||
}
|
||||
Reference in New Issue
Block a user