// Package book models an Injective exchange-module L2 order book and the // checks the chain applies to a new order. Every price and quantity is a // fixed-point dec.Dec; floats appear only in display helpers. package book import ( "errors" "fmt" "github.com/edwardtay/injective-booklab/dec" ) type Side int const ( Buy Side = iota Sell ) func (s Side) String() string { if s == Buy { return "buy" } return "sell" } // ParseSide accepts "buy"/"sell" (and "b"/"s"). func ParseSide(s string) (Side, error) { switch s { case "buy", "b", "BUY": return Buy, nil case "sell", "s", "SELL": return Sell, nil } return 0, fmt.Errorf("unknown side %q", s) } type Level struct { Price dec.Dec `json:"p"` Qty dec.Dec `json:"q"` } // Book holds bids sorted best (highest) first and asks best (lowest) first. // Seq is the exchange module's orderbook sequence number for the market. type Book struct { MarketID string `json:"market_id"` Seq uint64 `json:"seq"` Bids []Level `json:"bids"` Asks []Level `json:"asks"` } var ( ErrUnsorted = errors.New("book: levels out of order") ErrCrossed = errors.New("book: best bid >= best ask") ErrBadLevel = errors.New("book: non-positive price or quantity") ) // Validate checks ordering, positivity and that the book is not crossed. // A snapshot that fails here came from a broken or lagging endpoint. func (b *Book) Validate() error { check := func(lv []Level, better func(a, b dec.Dec) bool) error { for i, l := range lv { if !l.Price.IsPositive() || !l.Qty.IsPositive() { return fmt.Errorf("%w at %s", ErrBadLevel, l.Price) } if i > 0 && !better(lv[i-1].Price, l.Price) { return fmt.Errorf("%w at %s", ErrUnsorted, l.Price) } } return nil } if err := check(b.Bids, dec.Dec.GT); err != nil { return err } if err := check(b.Asks, dec.Dec.LT); err != nil { return err } if len(b.Bids) > 0 && len(b.Asks) > 0 && b.Bids[0].Price.GTE(b.Asks[0].Price) { return ErrCrossed } return nil } // Mid returns (best bid + best ask) / 2. func (b *Book) Mid() (dec.Dec, bool) { if len(b.Bids) == 0 || len(b.Asks) == 0 { return dec.Zero(), false } return b.Bids[0].Price.Add(b.Asks[0].Price).Quo(dec.FromInt(2)), true } // SpreadBps returns the quoted spread in basis points of mid. func (b *Book) SpreadBps() (dec.Dec, bool) { mid, ok := b.Mid() if !ok || mid.IsZero() { return dec.Zero(), false } return b.Asks[0].Price.Sub(b.Bids[0].Price).Quo(mid).Mul(dec.FromInt(10000)), true } // DepthWithin sums quantity and notional on each side within bps of mid. type Depth struct { Bps int64 `json:"bps"` BidQty dec.Dec `json:"bid_qty"` AskQty dec.Dec `json:"ask_qty"` BidNotional dec.Dec `json:"bid_notional"` AskNotional dec.Dec `json:"ask_notional"` } func (b *Book) DepthWithin(bps int64) Depth { d := Depth{Bps: bps, BidQty: dec.Zero(), AskQty: dec.Zero(), BidNotional: dec.Zero(), AskNotional: dec.Zero()} mid, ok := b.Mid() if !ok { return d } band := mid.Mul(dec.FromInt(bps)).Quo(dec.FromInt(10000)) lo, hi := mid.Sub(band), mid.Add(band) for _, l := range b.Bids { if l.Price.LT(lo) { break } d.BidQty = d.BidQty.Add(l.Qty) d.BidNotional = d.BidNotional.Add(l.Price.Mul(l.Qty)) } for _, l := range b.Asks { if l.Price.GT(hi) { break } d.AskQty = d.AskQty.Add(l.Qty) d.AskNotional = d.AskNotional.Add(l.Price.Mul(l.Qty)) } return d } // Market is the subset of exchange-module market params that order // validation needs. type Market struct { ID string `json:"market_id"` Ticker string `json:"ticker"` Kind string `json:"kind"` // "spot" or "derivative" PriceTick dec.Dec `json:"min_price_tick_size"` QtyTick dec.Dec `json:"min_quantity_tick_size"` MinNotional dec.Dec `json:"min_notional"` MakerFee dec.Dec `json:"maker_fee_rate"` TakerFee dec.Dec `json:"taker_fee_rate"` } // CheckOrder mirrors the exchange module's tick and notional rules for a // limit order. It returns every violation, so a client can show them all. func (m Market) CheckOrder(price, qty dec.Dec) []string { var out []string if !price.IsPositive() { out = append(out, "price must be positive") } else if !price.IsMultipleOf(m.PriceTick) { out = append(out, fmt.Sprintf("price %s is not a multiple of min_price_tick_size %s (nearest: %s or %s)", price, m.PriceTick, price.FloorTo(m.PriceTick), price.CeilTo(m.PriceTick))) } if !qty.IsPositive() { out = append(out, "quantity must be positive") } else if !qty.IsMultipleOf(m.QtyTick) { out = append(out, fmt.Sprintf("quantity %s is not a multiple of min_quantity_tick_size %s (round down to %s)", qty, m.QtyTick, qty.FloorTo(m.QtyTick))) } if price.IsPositive() && qty.IsPositive() && m.MinNotional.IsPositive() { if n := price.Mul(qty); n.LT(m.MinNotional) { out = append(out, fmt.Sprintf("notional %s is below min_notional %s", n, m.MinNotional)) } } return out }