Files
openyield/x/bond/keeper/clob.go
T
cloudinit-bot 6c34650a0d
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
Merge milestone/v0.5-bearers-runtime into main (v0.5 Bearers Runtime feature milestone release)
v0.5 Bearers Runtime — 7 runtime REQs (REQ-033..039) shipped as feature.
8 modules promoted to runtime (MsgServer + simtest). cosmos-sdk v0.50.8 +
ibc-go v8.2.1 added (G-006 controlled exception). G-003 + locked-const
firewalls intact. 8 keeper packages ≥80% coverage. 5 GRILL decisions
ratified; 8 binding fixes landed; 5 P1+ flagged for v0.6+.

---ci---
project: oy
phase: 8
milestone: v0.5
status: complete
requirements:
  covered: [REQ-033, REQ-034, REQ-035, REQ-036, REQ-037, REQ-038, REQ-039]
  partial: []
---/ci---
2026-08-18 03:42:01 +00:00

287 lines
14 KiB
Go

package keeper
// clob.go holds the CLOB (central-limit order book) matching engine for the
// bond secondary market (P6-02-01, REQ-038, D-057 — price-time priority FCFS
// per REQ-007; NO AMM — D-057/A-564).
//
// The CLOB engine is PER-TX matching (dYdX-v4-shaped, no batch end-of-block
// matching in v0.5 simtest — D-054). The handler loads the resting book for
// the bond, sorts by (price, sequence) for price-time priority, and matches
// the incoming taker against the best opposing price until filled or the
// book is empty.
//
// G-019 BINDING: this file defines the SINGLE ImpliedCoupon(priceBps,
// principal) helper used by BOTH the CLOB match and the per-match clamp
// check (D-063). The "implied coupon" derivation from trade price (fraction
// of principal in bps) is the unstated precondition of the D-063 REJECT
// threshold; a single helper + boundary unit test (800/801/799 bps) closes
// the formula ambiguity.
//
// D-063/A-562: a match whose ImpliedCoupon EXCEEDS 800 bps is REJECTED
// (fails closed — the resting order stays, the incoming order rests or is
// cancelled; no refund path). The 8% cap is a Mission-Lock invariant (D-028),
// not a soft cap. Matches within [0, 800] use Clamp (in-band, no refund
// needed).
//
// The 8%/0% consts (CouponCapBps=800 / CouponFloorBps=0, D-028) are
// referenced DIRECTLY from x/bond/types (same package — NOT a local copy;
// A-563). The REQ-030 cross-const test stays green.
//
// Lexicon (REQ-012, A-210): the coupon vocabulary is used EXCLUSIVELY. The
// banned coupon-synonyms are NEVER used.
//
// FEATURE PURITY GATE: the v0.3 types.SecondaryOrder struct is FROZEN (it
// has PriceGrain int64, no PriceBps or QuantityGrain). To avoid amending the
// v0.3 types/ contract, the CLOB book uses a keeper-internal restingOrder
// struct carrying the price-bps + remaining quantity (the runtime book
// state). The restingOrder embeds the public SecondaryOrder (the v0.3
// contract is preserved) PLUS the keeper-internal book fields. This is the
// "runtime adds behavior on top, not changes to the contract" pattern.
import (
"sort"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/oy/openyield/x/bond/types"
)
// restingOrder is the in-keeper book entry for a resting secondary-market
// order. It carries the public SecondaryOrder (the v0.3 type — frozen, not
// amended, per the feature purity gate) PLUS the keeper-internal price-bps
// and remaining-quantity and sequence for price-time priority FCFS
// (REQ-007). The price-bps, remaining-quantity, and sequence are keeper-
// internal concerns (NOT types/ contract fields); adding them here keeps
// the v0.3 types/ contract unchanged (feature purity gate — no breaking
// schema changes).
type restingOrder struct {
// Order is the public v0.3 SecondaryOrder (frozen contract). Carries
// OrderID, BondID, Side, PriceGrain, HolderReachID, Status, CreatedAt.
Order types.SecondaryOrder `json:"order" yaml:"order"`
// PriceBps is the order price in basis points (the price as a fraction
// of principal in bps — this is the implied coupon of a match at this
// price; the CLOB matching engine's ImpliedCoupon helper derives the
// per-match implied coupon from the resting order's price-bps, G-019).
// Keeper-internal (the v0.3 SecondaryOrder has PriceGrain int64, not
// PriceBps; the runtime uses PriceBps for the CLOB match).
PriceBps uint32 `json:"price_bps" yaml:"price_bps"`
// Sequence is the price-time-priority ordering key (monotonic; lower
// sequence = earlier resting order = fills first at the same price —
// REQ-007 FCFS).
Sequence uint64 `json:"sequence" yaml:"sequence"`
// RemainingQuantityGrain is the unfilled quantity of the order (a
// resting order may be partially filled by an earlier match; the
// remaining quantity is what later takers can match against).
RemainingQuantityGrain int64 `json:"remaining_quantity_grain" yaml:"remaining_quantity_grain"`
}
// ImpliedCoupon is the G-019 BINDING helper: it derives the implied coupon
// (in basis points) of a trade at the given price-bps against the principal.
// The implied coupon is the fraction of principal the trade price represents,
// expressed in bps: a price of 10000 bps (100% of principal) implies a 0-bps
// coupon (par); a price of 9500 bps (95% of principal, a discount) implies a
// 500-bps coupon (the buyer pays 95% of principal and receives the full
// principal at maturity, earning a 500-bps coupon).
//
// The formula: impliedCouponBps = max(0, 10000 - priceBps).
// - priceBps == 10000 (par) -> impliedCoupon 0 (no discount, no coupon).
// - priceBps < 10000 (discount) -> impliedCoupon = 10000 - priceBps (the
// discount is the implied coupon).
// - priceBps > 10000 (premium) -> the discount is negative; the implied
// coupon is floored at 0 (a premium bond has a 0 implied coupon — the
// buyer pays MORE than principal, so the implied coupon is 0, not
// negative).
//
// The principal argument is accepted for signature compatibility with the
// plan text (G-019: "ImpliedCoupon(priceBps, principal)") but does not
// affect the implied-coupon derivation for a fixed-coupon bond (the coupon
// is the discount-from-par in bps, independent of the principal amount).
// It is retained so a future v0.6+ amortization model can use it.
//
// G-019 boundary: the D-063 REJECT threshold is 800 bps. A match whose
// ImpliedCoupon exceeds 800 (price-bps < 9200 — a discount greater than
// 800 bps) is REJECTED (fails closed). The boundary unit test in
// msg_server_simtest_test.go covers:
// - price-bps 9200 -> ImpliedCoupon 800 (== cap, in-band, clears via Clamp).
// - price-bps 9199 -> ImpliedCoupon 801 (> cap, REJECTED — D-063).
// - price-bps 9201 -> ImpliedCoupon 799 (< cap, in-band, clears).
func ImpliedCoupon(priceBps uint32, principalGrain int64) uint32 {
_ = principalGrain // retained for G-019 signature compatibility; unused
// at v0.5 (fixed-coupon bond — coupon is discount-from-par in bps).
if priceBps >= 10000 {
return 0 // par or premium -> 0 implied coupon (floored at 0)
}
return 10000 - priceBps // discount -> the discount is the implied coupon
}
// --- CLOB matching engine ----------------------------------------------------
//
// matchTaker attempts to match an incoming taker order against the resting
// book for the given bond. Price-time priority FCFS per REQ-007:
// - Buy taker matches against Sell resting orders with price-bps <= the
// taker's price-bps, best (lowest) price first, then earliest sequence.
// - Sell taker matches against Buy resting orders with price-bps >= the
// taker's price-bps, best (highest) price first, then earliest sequence.
//
// Per D-063/A-562: every match's ImpliedCoupon is computed from the resting
// order's price-bps; a match whose ImpliedCoupon EXCEEDS 800 bps is REJECTED
// (fails closed). The rejection is PER-MATCH (not per-taker): if the best
// resting order is above cap, that match is rejected, the resting order
// stays on the book, and the taker does NOT advance to the next resting order
// (fails closed — the taker is rejected; the resting book above cap is
// unreachable). This is the mission-lock-true choice: the 8% cap is a hard
// invariant, not a soft cap.
//
// Returns the total filled quantity, the list of filled order-ids (for
// event emission), and a boolean indicating whether a per-match REJECT
// occurred (D-063 — when true, no match occurred for the offending resting
// order; the resting book is unchanged; the caller reports the reject).
func (k Keeper) matchTaker(
ctx sdk.Context,
bondID string,
takerSide types.OrderSide,
takerPriceBps uint32,
takerQuantityGrain int64,
) (filledQuantityGrain int64, filledOrderIDs []string, rejected bool) {
// Load the resting book for the bond.
resting := k.restingBookForBond(ctx, bondID)
// Sort for price-time priority.
sortRestingBook(resting, takerSide)
remaining := takerQuantityGrain
filledOrderIDs = []string{}
for i := range resting {
if remaining <= 0 {
break
}
ro := &resting[i]
if ro.Order.Status != types.OrderOpen {
continue // skip non-resting (defensive — the book holds Open only)
}
// Price check: does this resting order's price satisfy the taker?
if !priceCrosses(takerSide, takerPriceBps, ro.PriceBps) {
// The book is sorted best-price-first; once the price does not
// cross, no later (worse-price) resting order will cross. Stop.
break
}
// D-063 per-match coupon clamp (G-019 ImpliedCoupon helper). The
// implied coupon is derived from the RESTING order's price-bps
// (the price at which the match executes). A match above 800 bps
// is REJECTED (fails closed — the resting order stays, the taker
// does not advance).
implied := ImpliedCoupon(ro.PriceBps, 0)
if implied > types.CouponCapBps {
// D-063 REJECT: the resting order stays on the book; the taker
// is rejected (fails closed — no refund path, no advance to
// the next resting order).
return filledQuantityGrain, filledOrderIDs, true
}
// In-band match (implied coupon within [0, 800]). Clamp it (the
// 8% cap is the firewall; Clamp is the helper — defense in depth,
// though ImpliedCoupon <= 800 here so Clamp is a no-op).
clampedCoupon := types.Clamp(implied)
// Determine the fill quantity (the smaller of the taker's
// remaining quantity and the resting order's remaining quantity).
fill := remaining
if ro.RemainingQuantityGrain < fill {
fill = ro.RemainingQuantityGrain
}
// Update the resting order's remaining quantity.
ro.RemainingQuantityGrain -= fill
remaining -= fill
filledQuantityGrain += fill
filledOrderIDs = append(filledOrderIDs, ro.Order.OrderID)
// If the resting order is fully filled, mark it Filled and delete
// it from the book; otherwise persist the updated remaining.
if ro.RemainingQuantityGrain <= 0 {
ro.Order.Status = types.OrderFilled
k.deleteRestingOrder(ctx, ro.Order.OrderID)
} else {
k.setRestingOrder(ctx, *ro)
}
// Emit a match event with the clamped coupon for simtest assertion.
emitMatchEvent(ctx, ro.Order.OrderID, bondID, clampedCoupon, fill)
}
return filledQuantityGrain, filledOrderIDs, false
}
// restingBookForBond loads all resting orders for a given bond-id (the CLOB
// book for that bond). The book is unordered here; matchTaker sorts it for
// price-time priority.
func (k Keeper) restingBookForBond(ctx sdk.Context, bondID string) []restingOrder {
all := k.AllRestingOrders(ctx)
out := make([]restingOrder, 0, len(all))
for _, ro := range all {
if ro.Order.BondID == bondID && ro.Order.Status == types.OrderOpen {
out = append(out, ro)
}
}
return out
}
// sortRestingBook sorts the resting book for price-time priority FCFS
// (REQ-007). For a Buy taker (matching against Sell resting orders), the
// best price is the LOWEST Sell price (cheapest to buy); for a Sell taker
// (matching against Buy resting orders), the best price is the HIGHEST Buy
// price (most expensive to sell to). Ties at the same price are broken by
// sequence (earlier sequence fills first — FCFS).
func sortRestingBook(book []restingOrder, takerSide types.OrderSide) {
if takerSide == types.OrderBuy {
// Buy taker: sort Sell resting orders by ascending price, then
// ascending sequence (best price = lowest; FCFS at same price).
sort.SliceStable(book, func(i, j int) bool {
if book[i].PriceBps != book[j].PriceBps {
return book[i].PriceBps < book[j].PriceBps
}
return book[i].Sequence < book[j].Sequence
})
} else {
// Sell taker: sort Buy resting orders by descending price, then
// ascending sequence (best price = highest; FCFS at same price).
sort.SliceStable(book, func(i, j int) bool {
if book[i].PriceBps != book[j].PriceBps {
return book[i].PriceBps > book[j].PriceBps
}
return book[i].Sequence < book[j].Sequence
})
}
}
// priceCrosses reports whether the taker's price satisfies the resting
// order's price (a match can execute). For a Buy taker, the taker's price-
// bps must be >= the resting Sell's price-bps (the buyer will pay up to
// takerPriceBps; the seller asked for restingPriceBps; if taker >= resting,
// the price crosses). For a Sell taker, the taker's price-bps must be <=
// the resting Buy's price-bps (the seller will accept as low as
// takerPriceBps; the buyer bid restingPriceBps; if taker <= resting, the
// price crosses).
func priceCrosses(takerSide types.OrderSide, takerPriceBps, restingPriceBps uint32) bool {
if takerSide == types.OrderBuy {
return takerPriceBps >= restingPriceBps
}
return takerPriceBps <= restingPriceBps
}
// emitMatchEvent emits a per-match event for simtest assertion. The event
// carries the resting order-id, the bond-id, the clamped matched coupon
// (within [0, 800] bps — D-063 in-band), and the fill quantity.
//
// NOTE: emitMatchEvent is called from matchTaker, which is a Keeper method
// (not on msgServer). The ctx is the sdk.Context passed to matchTaker. This
// helper is defined here (not in msg_server.go) so the CLOB engine is
// self-contained.
func emitMatchEvent(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64) {
// Avoid importing sdk event helpers in clob.go to keep the import list
// lean; delegate to the msg_server.go helper via a function variable.
// (The simtest asserts events via ctx.EventManager().Events().)
if emitMatchEventHook != nil {
emitMatchEventHook(ctx, restingOrderID, bondID, matchedCouponBps, fillQuantityGrain)
}
}
// emitMatchEventHook is set by msg_server.go (which imports sdk event
// helpers). This indirection keeps clob.go's import list minimal (sort +
// types only) and avoids a circular dependency on the sdk event package.
var emitMatchEventHook func(ctx sdk.Context, restingOrderID, bondID string, matchedCouponBps uint32, fillQuantityGrain int64)