6c34650a0d
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---
287 lines
14 KiB
Go
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)
|