diff --git a/pkg/priceresolver/simple.go b/pkg/priceresolver/simple.go new file mode 100644 index 000000000..c1a0f46b6 --- /dev/null +++ b/pkg/priceresolver/simple.go @@ -0,0 +1,119 @@ +package priceresolver + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +// SimplePriceResolver implements a map-structure-based price index +type SimplePriceResolver struct { + // symbolPrices stores the latest trade price by mapping symbol to price + symbolPrices map[string]fixedpoint.Value + markets types.MarketMap + + // pricesByBase stores the prices by currency names as a 2-level map + // BTC -> USDT -> 48000.0 + // BTC -> TWD -> 1536000 + pricesByBase map[string]map[string]fixedpoint.Value + + // pricesByQuote is for reversed pairs, like USDT/TWD or BNB/BTC + // the reason that we don't store the reverse pricing in the same map is: + // expression like (1/price) could produce precision issue since the data type is fixed-point, only 8 fraction numbers are supported. + pricesByQuote map[string]map[string]fixedpoint.Value + + mu sync.Mutex +} + +func NewPriceMap(markets types.MarketMap) *SimplePriceResolver { + return &SimplePriceResolver{ + markets: markets, + symbolPrices: make(map[string]fixedpoint.Value), + pricesByBase: make(map[string]map[string]fixedpoint.Value), + pricesByQuote: make(map[string]map[string]fixedpoint.Value), + } +} + +func (m *SimplePriceResolver) Update(symbol string, price fixedpoint.Value) { + m.mu.Lock() + defer m.mu.Unlock() + + m.symbolPrices[symbol] = price + market, ok := m.markets[symbol] + if !ok { + log.Warnf("market info %s not found, unable to update price", symbol) + return + } + + quoteMap, ok2 := m.pricesByBase[market.BaseCurrency] + if !ok2 { + quoteMap = make(map[string]fixedpoint.Value) + m.pricesByBase[market.BaseCurrency] = quoteMap + } + + quoteMap[market.QuoteCurrency] = price + + baseMap, ok3 := m.pricesByQuote[market.QuoteCurrency] + if !ok3 { + baseMap = make(map[string]fixedpoint.Value) + m.pricesByQuote[market.QuoteCurrency] = baseMap + } + + baseMap[market.BaseCurrency] = price +} + +func (m *SimplePriceResolver) UpdateFromTrade(trade types.Trade) { + m.Update(trade.Symbol, trade.Price) +} + +func (m *SimplePriceResolver) inferencePrice(asset string, assetPrice fixedpoint.Value, preferredFiats ...string) (fixedpoint.Value, bool) { + // log.Infof("inferencePrice %s = %f", asset, assetPrice.Float64()) + quotePrices, ok := m.pricesByBase[asset] + if ok { + for quote, price := range quotePrices { + for _, fiat := range preferredFiats { + if quote == fiat { + return price.Mul(assetPrice), true + } + } + } + + for quote, price := range quotePrices { + if infPrice, ok := m.inferencePrice(quote, price.Mul(assetPrice), preferredFiats...); ok { + return infPrice, true + } + } + } + + // for example, quote = TWD here, we can get a price map with: + // USDT: 32.0 (for USDT/TWD at 32.0) + basePrices, ok := m.pricesByQuote[asset] + if ok { + for base, basePrice := range basePrices { + // log.Infof("base %s @ %s", base, basePrice.String()) + for _, fiat := range preferredFiats { + if base == fiat { + // log.Infof("ret %f / %f = %f", assetPrice.Float64(), basePrice.Float64(), assetPrice.Div(basePrice).Float64()) + return assetPrice.Div(basePrice), true + } + } + } + + for base, basePrice := range basePrices { + if infPrice, ok2 := m.inferencePrice(base, assetPrice.Div(basePrice), preferredFiats...); ok2 { + return infPrice, true + } + } + } + + return fixedpoint.Zero, false +} + +func (m *SimplePriceResolver) ResolvePrice(asset string, preferredFiats ...string) (fixedpoint.Value, bool) { + m.mu.Lock() + defer m.mu.Unlock() + return m.inferencePrice(asset, fixedpoint.One, preferredFiats...) +} diff --git a/pkg/priceresolver/simple_test.go b/pkg/priceresolver/simple_test.go new file mode 100644 index 000000000..439ae83a6 --- /dev/null +++ b/pkg/priceresolver/simple_test.go @@ -0,0 +1,146 @@ +package priceresolver + +import ( + "testing" + + . "github.com/c9s/bbgo/pkg/testing/testhelper" + "github.com/c9s/bbgo/pkg/types" + + "github.com/stretchr/testify/assert" +) + +func TestSimplePriceResolver(t *testing.T) { + markets := types.MarketMap{ + "BTCUSDT": types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + }, + "ETHUSDT": types.Market{ + BaseCurrency: "ETH", + QuoteCurrency: "USDT", + }, + "BTCTWD": types.Market{ + BaseCurrency: "BTC", + QuoteCurrency: "TWD", + }, + "ETHTWD": types.Market{ + BaseCurrency: "ETH", + QuoteCurrency: "TWD", + }, + "USDTTWD": types.Market{ + BaseCurrency: "USDT", + QuoteCurrency: "TWD", + }, + "ETHBTC": types.Market{ + BaseCurrency: "ETH", + QuoteCurrency: "BTC", + }, + } + + t.Run("direct reference", func(t *testing.T) { + pm := NewPriceMap(markets) + pm.UpdateFromTrade(types.Trade{ + Symbol: "BTCUSDT", + Price: Number(48000.0), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "ETHUSDT", + Price: Number(2800.0), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "USDTTWD", + Price: Number(32.0), + }) + + finalPrice, ok := pm.ResolvePrice("BTC", "USDT") + if assert.True(t, ok) { + assert.Equal(t, "48000", finalPrice.String()) + } + + finalPrice, ok = pm.ResolvePrice("ETH", "USDT") + if assert.True(t, ok) { + assert.Equal(t, "2800", finalPrice.String()) + } + + finalPrice, ok = pm.ResolvePrice("USDT", "TWD") + if assert.True(t, ok) { + assert.Equal(t, "32", finalPrice.String()) + } + }) + + t.Run("simple reference", func(t *testing.T) { + pm := NewPriceMap(markets) + pm.UpdateFromTrade(types.Trade{ + Symbol: "BTCUSDT", + Price: Number(48000.0), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "ETHUSDT", + Price: Number(2800.0), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "USDTTWD", + Price: Number(32.0), + }) + + finalPrice, ok := pm.ResolvePrice("BTC", "TWD") + if assert.True(t, ok) { + assert.Equal(t, "1536000", finalPrice.String()) + } + }) + + t.Run("crypto reference", func(t *testing.T) { + pm := NewPriceMap(markets) + pm.UpdateFromTrade(types.Trade{ + Symbol: "BTCUSDT", + Price: Number(52000.0), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "ETHBTC", + Price: Number(0.055), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "USDTTWD", + Price: Number(32.0), + }) + + finalPrice, ok := pm.ResolvePrice("ETH", "USDT") + if assert.True(t, ok) { + assert.Equal(t, "2860", finalPrice.String()) + } + }) + + t.Run("inverse reference", func(t *testing.T) { + pm := NewPriceMap(markets) + pm.UpdateFromTrade(types.Trade{ + Symbol: "BTCTWD", + Price: Number(1536000.0), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "USDTTWD", + Price: Number(32.0), + }) + + finalPrice, ok := pm.ResolvePrice("BTC", "USDT") + if assert.True(t, ok) { + assert.Equal(t, "48000", finalPrice.String()) + } + }) + + t.Run("inverse reference", func(t *testing.T) { + pm := NewPriceMap(markets) + pm.UpdateFromTrade(types.Trade{ + Symbol: "BTCTWD", + Price: Number(1536000.0), + }) + pm.UpdateFromTrade(types.Trade{ + Symbol: "USDTTWD", + Price: Number(32.0), + }) + + finalPrice, ok := pm.ResolvePrice("TWD", "USDT") + if assert.True(t, ok) { + assert.InDelta(t, 0.03125, finalPrice.Float64(), 0.0001) + } + }) +}