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)