From 83e57fdd26b9d9974a65513eb82db7112f3c81d0 Mon Sep 17 00:00:00 2001 From: Brian Stafford Date: Tue, 28 Jan 2025 15:55:58 -0600 Subject: [PATCH] tatanka/trade: add order compatibility and matching functions This should give us some direction for how trading will work. --- tatanka/client/trade/README.md | 13 +++ tatanka/client/trade/compat.go | 68 +++++++++++++++ tatanka/client/trade/compat_test.go | 82 ++++++++++++++++++ tatanka/client/trade/match.go | 57 +++++++++++++ tatanka/client/trade/match_test.go | 124 ++++++++++++++++++++++++++++ tatanka/tanka/swaps.go | 39 ++++++--- 6 files changed, 373 insertions(+), 10 deletions(-) create mode 100644 tatanka/client/trade/README.md create mode 100644 tatanka/client/trade/compat.go create mode 100644 tatanka/client/trade/compat_test.go create mode 100644 tatanka/client/trade/match.go create mode 100644 tatanka/client/trade/match_test.go diff --git a/tatanka/client/trade/README.md b/tatanka/client/trade/README.md new file mode 100644 index 0000000000..6aef0f2ecc --- /dev/null +++ b/tatanka/client/trade/README.md @@ -0,0 +1,13 @@ +# Trading Sequence + +1. User initiates an order by specifying a desired quantity that they want to +buy or sell and an acceptable rate limit. This is the `DesiredTrade`. +2. The backend receives the `DesiredTrade` and checks whether there are any +orders on the order book to satisfy the trade using `MatchBook`. This generates +a set of potential matches (`[]*MatchProposal`), but there might be some +remaining quantity that we couldn't fulfill with the existing standing orders, +the `remain`. +3. Any `[]*MatchProposal` from `MatchBook` will generate match requests to +the owners of the matched standing orders. +4. If there is `remain`, we will generate our own standing order and broadcast +it to all market subscribers. \ No newline at end of file diff --git a/tatanka/client/trade/compat.go b/tatanka/client/trade/compat.go new file mode 100644 index 0000000000..6ea7c3a389 --- /dev/null +++ b/tatanka/client/trade/compat.go @@ -0,0 +1,68 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package trade + +import ( + "math" + + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/tatanka/tanka" +) + +// FeeParameters combines the user's fee exposure settings with the fees per +// lot, which are based on current on-chain fee rates and other asset-specific +// parameters e.g. swap tx size for utxo-based assets. +type FeeParameters struct { + // MaxFeeExposure is the maximum fee losses we are willing to incur from a + // trade, as a ratio of the trade size. + MaxFeeExposure float64 + BaseFeesPerMatch uint64 + QuoteFeesPerMatch uint64 +} + +const ( + ReasonOurQtyTooSmall = "our order size is less than their lot size" + ReasonTheirQtyTooSmall = "their order size is less than our lot size" +) + +// OrderIsMatchable determines whether a given standling limit order is +// matchable for our desired quantity and fee parameters. +func OrderIsMatchable(desiredQty uint64, theirOrd *tanka.Order, p *FeeParameters) (_ bool, reason string) { + // Can we satisfy their lot size? + if desiredQty < theirOrd.LotSize { + return false, ReasonOurQtyTooSmall + } + // Can they satisfy our lot size? + minLotSize := MinimumLotSize(theirOrd.Rate, p) + if theirOrd.Qty < minLotSize { + return false, ReasonTheirQtyTooSmall + } + return true, "" +} + +// MinimumLotSize calculates the smallest lot size that satisfies the our +// desired maximum fee exposure. +func MinimumLotSize(msgRate uint64, p *FeeParameters) uint64 { + // fee_exposure = (lots * base_fees_per_lot / qty) + (lots * quote_fees_per_lot / quote_qty) + // quote_qty = qty * rate + // ## We want fee_exposure < max_fee_exposure + // (lots * base_fees_per_lot / qty) + (lots * quote_fees_per_lot / (qty * rate)) < max_fee_exposure + // ## multiplying both sides by qty + // (lots * base_fees_per_lot) + (lots * quote_fees_per_lot / rate) < max_fee_exposure * qty + // ## Factoring out lots + // lots * (base_fees_per_lot + (quote_fees_per_lot / rate)) < max_fee_exposure * qty + // ## isolating lots + // lots < (max_fee_exposure * qty) / (base_fees_per_lot + (quote_fees_per_lot / rate)) + // ## Noting that lots = qty / lot_size + // qty / lot_size < (max_fee_exposure * qty) / (base_fees_per_lot + (quote_fees_per_lot / rate)) + // ## Dividing both size by qty + // 1 / lot_size < max_fee_exposure / (base_fees_per_lot + (quote_fees_per_lot / rate)) + // ## Fliparoo + // lot_size > (base_fees_per_lot + (quote_fees_per_lot / rate)) / max_fee_exposure + atomicRate := float64(msgRate) / calc.RateEncodingFactor + perfectLotSize := math.Ceil((float64(p.BaseFeesPerMatch) + float64(p.QuoteFeesPerMatch)/atomicRate) / float64(p.MaxFeeExposure)) + // How many powers of 2? + n := math.Ceil(math.Log2(perfectLotSize)) + return uint64(math.Round(math.Pow(2, n))) +} diff --git a/tatanka/client/trade/compat_test.go b/tatanka/client/trade/compat_test.go new file mode 100644 index 0000000000..e075195eb9 --- /dev/null +++ b/tatanka/client/trade/compat_test.go @@ -0,0 +1,82 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package trade + +import ( + "testing" + + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/tatanka/tanka" +) + +func TestMinimumLotSize(t *testing.T) { + // Exchange rate of 1. 10 atoms lost to fees in the match. To stay under 1%, + // the lot size needs to be >= 1000, so 1024 is the closest power of 2. + p := &FeeParameters{ + MaxFeeExposure: 0.01, + BaseFeesPerMatch: 5, + QuoteFeesPerMatch: 5, + } + var atomicRate uint64 = 1 + msgRate := atomicRate * calc.RateEncodingFactor + if l := MinimumLotSize(msgRate, p); l != 1024 { + t.Fatal(p, l) + } + // Doubling the fees should double the lot size. + p.QuoteFeesPerMatch *= 2 + p.BaseFeesPerMatch *= 2 + if l := MinimumLotSize(msgRate, p); l != 2048 { + t.Fatal(p, l) + } + // Double the quote fees, but also double the rate (to halve the quote qty). + // The effects should offset. + p.QuoteFeesPerMatch *= 2 + msgRate *= 2 + if l := MinimumLotSize(msgRate, p); l != 2048 { + t.Fatal(p, l) + } +} + +func TestOrderIsMatchable(t *testing.T) { + // Set up fee parameters with a min lot size of 1024. + p := &FeeParameters{ + MaxFeeExposure: 0.01, + BaseFeesPerMatch: 5, + QuoteFeesPerMatch: 5, + } + var desiredQty uint64 = 1024 + // Create a standing order that matches our lot size and has 2 lots + // available. + var atomicRate uint64 = 1 + msgRate := atomicRate * calc.RateEncodingFactor + var lotSize uint64 = 1024 + theirOrd := &tanka.Order{ + LotSize: lotSize, + Rate: msgRate, + Qty: lotSize * 2, + } + // Should be matchable. + if matchable, reason := OrderIsMatchable(desiredQty, theirOrd, p); !matchable { + t.Fatal(reason) + } + // Quadrupling our min lot size should cause unmatchability. + p.MaxFeeExposure /= 4 + matchable, reason := OrderIsMatchable(desiredQty, theirOrd, p) + if matchable { + t.Fatal("Their remaining qty should be too small") + } + if reason != ReasonTheirQtyTooSmall { + t.Fatal(reason) + } + p.MaxFeeExposure *= 4 // undoing + // Make our desired qty too small. + desiredQty /= 2 + matchable, reason = OrderIsMatchable(desiredQty, theirOrd, p) + if matchable { + t.Fatal("Our desired qty should be too small") + } + if reason != ReasonOurQtyTooSmall { + t.Fatal(reason) + } +} diff --git a/tatanka/client/trade/match.go b/tatanka/client/trade/match.go new file mode 100644 index 0000000000..b894fa4d3b --- /dev/null +++ b/tatanka/client/trade/match.go @@ -0,0 +1,57 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package trade + +import ( + "decred.org/dcrdex/tatanka/tanka" +) + +// DesiredTrade is the parameters of a trade that the user wants to make. +type DesiredTrade struct { + Qty uint64 + Rate uint64 + Sell bool +} + +// MatchProposal is a potential match based on our desired trade and the +// current standing orders. +type MatchProposal struct { + Order *tanka.Order + Qty uint64 +} + +// MatchBook matches our desired trade with the order book side. It is assumed +// that the order book side is correct for our choice of buy/sell, and that +// the orders are ordered by rate, with low-to-high for sell orders, and +// high-to-low for buy orders. +func MatchBook(desire *DesiredTrade, p *FeeParameters, ords []*tanka.Order) (matches []*MatchProposal, remain uint64) { + remain = desire.Qty + for _, ord := range ords { + // Check rate compatibility. + if desire.Sell { + if ord.Rate < desire.Rate { + break + } + } else if ord.Rate > desire.Rate { + break + } + // Check lot size compatibility. + if compat, _ := OrderIsMatchable(desire.Qty, ord, p); !compat { + continue + } + // How much can we match? + maxQty := ord.Qty + if ord.Qty > remain { + maxQty = remain + } + lots := maxQty / ord.LotSize + qty := lots * ord.LotSize + matches = append(matches, &MatchProposal{Order: ord, Qty: qty}) + remain -= qty + if remain == 0 { + break + } + } + return matches, remain +} diff --git a/tatanka/client/trade/match_test.go b/tatanka/client/trade/match_test.go new file mode 100644 index 0000000000..b2437745d8 --- /dev/null +++ b/tatanka/client/trade/match_test.go @@ -0,0 +1,124 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package trade + +import ( + "testing" + + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/tatanka/tanka" +) + +func TestMatchBook(t *testing.T) { + // These can be varied before resetting. + weSell := false + var weWantLots uint64 = 1 + + // These will be initialized by reset. + var lotSize, atomicRate, msgRate, baseQty uint64 + var desire *DesiredTrade + var ords []*tanka.Order + var p *FeeParameters + + // A function to reset everything + reset := func() { + atomicRate = 1 + lotSize = 1024 + msgRate = atomicRate * calc.RateEncodingFactor + baseQty = weWantLots * lotSize + p = &FeeParameters{ + MaxFeeExposure: 0.01, + BaseFeesPerMatch: 5, + QuoteFeesPerMatch: 5, + } + desire = &DesiredTrade{ + Qty: baseQty, + Rate: msgRate, + Sell: weSell, + } + ords = []*tanka.Order{ + { + Rate: msgRate, + Qty: baseQty, + LotSize: lotSize, + }, + } + } + + // Our testing function + testMatches := func(expRemain uint64, expQtys []uint64) { + t.Helper() + matches, remain := MatchBook(desire, p, ords) + if remain != expRemain { + t.Fatal("wrong remain", remain, expRemain) + } + if len(matches) != len(expQtys) { + t.Fatal("wrong number of matches", matches, len(expQtys)) + } + for i, m := range matches { + if m.Qty != expQtys[i] { + t.Fatal("wrong qty", i, m.Qty, expQtys[i]) + } + } + } + + // Basic 1-lot buy order that perfectly matches the 1 order on the book, + // leaving no remainder. + reset() + testMatches(0, []uint64{baseQty}) + // Double the first order's qty. Shouldn't change anything. + ords[0].Qty *= 2 + testMatches(0, []uint64{baseQty}) + // Triple our ask. We'll get the whole (now-doubled) order, with some + // remainder. + desire.Qty *= 3 + testMatches(baseQty, []uint64{2 * baseQty}) + // Add another order to satisfy our needs. + ords = append(ords, &tanka.Order{ + Rate: msgRate, + Qty: baseQty, + LotSize: lotSize, + }) + testMatches(0, []uint64{2 * baseQty, baseQty}) + // Double the rate of the new order though, and we're back to only getting + // the first order. + ords[1].Rate *= 2 + testMatches(baseQty, []uint64{2 * baseQty}) + + // Make sure it works with multiple lots too. + weWantLots = 4 + reset() + testMatches(0, []uint64{baseQty}) + + // Basic 1-lot sell order now. + weSell = true + reset() + testMatches(0, []uint64{baseQty}) + // Doubling our ask should leave a remainder. + desire.Qty *= 2 + testMatches(baseQty, []uint64{baseQty}) + // Add another order to satisfy our needs. + ords = append(ords, &tanka.Order{ + Rate: msgRate, + Qty: baseQty, + LotSize: lotSize, + }) + testMatches(0, []uint64{baseQty, baseQty}) + // But if the second order isn't offering enough, we can't match it. + ords[1].Rate -= 1 + testMatches(baseQty, []uint64{baseQty}) + + // Back to buying 1 lot + weSell, weWantLots = false, 1 + reset() + testMatches(0, []uint64{baseQty}) + + // Make the order incompatible because our maximum fee exposure is too low. + p.MaxFeeExposure /= 2 + testMatches(baseQty, nil) + + // Sanity check + reset() + testMatches(0, []uint64{baseQty}) +} diff --git a/tatanka/tanka/swaps.go b/tatanka/tanka/swaps.go index fe1244a113..de71735c7c 100644 --- a/tatanka/tanka/swaps.go +++ b/tatanka/tanka/swaps.go @@ -6,6 +6,8 @@ package tanka import ( "encoding/binary" "encoding/hex" + "errors" + "fmt" "time" "github.com/decred/dcrd/crypto/blake256" @@ -23,18 +25,13 @@ type Order struct { // orderbook orders that don't have the requisite lot size. The UI should // show lot size selection in terms of a sliding scale of fee exposure. // Lot sizes can only be powers of 2. - LotSize uint64 `json:"lotSize"` - // MinFeeRate: Tatankanet does not prescribe a fee rate on an order, but it - // does supply a suggested fee rate that is updated periodically. The user's - // UI should ignore an order from the order book if its MinFeeRate falls - // below the Tatnkanet suggested rate. - MinFeeRate uint64 `json:"minFeeRate"` + LotSize uint64 `json:"lotSize"` Stamp time.Time `json:"stamp"` Expiration time.Time `json:"expiration"` } func (ord *Order) ID() [32]byte { - const msgLen = 32 + 4 + 4 + 1 + 8 + 8 + 8 + 8 + 8 + 8 + const msgLen = 32 + 4 + 4 + 1 + 8 + 8 + 8 + 8 + 8 b := make([]byte, msgLen) copy(b[:32], ord.From[:]) binary.BigEndian.PutUint32(b[32:36], ord.BaseID) @@ -45,13 +42,35 @@ func (ord *Order) ID() [32]byte { binary.BigEndian.PutUint64(b[41:49], ord.Qty) binary.BigEndian.PutUint64(b[49:57], ord.Rate) binary.BigEndian.PutUint64(b[57:65], ord.LotSize) - binary.BigEndian.PutUint64(b[65:73], ord.MinFeeRate) - binary.BigEndian.PutUint64(b[73:81], uint64(ord.Stamp.UnixMilli())) - binary.BigEndian.PutUint64(b[81:89], uint64(ord.Expiration.UnixMilli())) + binary.BigEndian.PutUint64(b[65:73], uint64(ord.Stamp.UnixMilli())) + binary.BigEndian.PutUint64(b[73:81], uint64(ord.Expiration.UnixMilli())) return blake256.Sum256(b) } +func (ord *Order) Valid() error { + // Check whether the lot size is a power of 2, using binary ju-jitsu. + if ord.LotSize&(ord.LotSize-1) != 0 { + return fmt.Errorf("lot size %d is not a power of 2", ord.LotSize) + } + if ord.Qty%ord.LotSize != 0 { + return fmt.Errorf("order quantity %d is not an integer-multiple of the order lot size %d", ord.Qty, ord.LotSize) + } + if ord.BaseID == ord.QuoteID { + return fmt.Errorf("base and quote assets are identical. %d = %d", ord.BaseID, ord.QuoteID) + } + if ord.Qty == 0 { + return errors.New("order quantity is zero") + } + if ord.Rate == 0 { + return errors.New("order rate is zero") + } + if ord.Expiration.Equal(ord.Stamp) || ord.Expiration.Before(ord.Stamp) { + return errors.New("order is pre-expired") + } + return nil +} + type ID32 [32]byte func (i ID32) String() string {