Skip to content

Commit

Permalink
tatanka/trade: add order compatibility and matching functions
Browse files Browse the repository at this point in the history
This should give us some direction for how trading will work.
  • Loading branch information
buck54321 committed Jan 29, 2025
1 parent 4b5de9f commit 1994ddf
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 10 deletions.
13 changes: 13 additions & 0 deletions tatanka/client/trade/README.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions tatanka/client/trade/compat.go
Original file line number Diff line number Diff line change
@@ -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)))
}
82 changes: 82 additions & 0 deletions tatanka/client/trade/compat_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
58 changes: 58 additions & 0 deletions tatanka/client/trade/match.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// 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
// We have a potential match.
qty := lots * ord.LotSize
matches = append(matches, &MatchProposal{Order: ord, Qty: qty})
remain -= qty
if remain == 0 {
break
}
}
return matches, remain
}
124 changes: 124 additions & 0 deletions tatanka/client/trade/match_test.go
Original file line number Diff line number Diff line change
@@ -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})
}
Loading

0 comments on commit 1994ddf

Please sign in to comment.