diff --git a/.claude/rules/predict-contracts.md b/.claude/rules/predict-contracts.md index bccca014a..85b600a8f 100644 --- a/.claude/rules/predict-contracts.md +++ b/.claude/rules/predict-contracts.md @@ -125,7 +125,7 @@ Predict-specific Move rules: package architecture, config and capability shapes, - Predict order IDs are scoped by `(expiry_market_id, order_id)`. Do not encode or infer market lifecycle facts such as expiry from the order ID; bind an order to a market through `predict_account` position keys (`PositionKey`) and the market/exposure state that created it. - Mint-admission policy must not be part of packed order decoding or structural `Order` validation. Future upgrades to mint-only policy, such as price thresholds, must not retroactively make existing packed order IDs invalid. - `PoolVault.active_expiry_markets` contains only expiries that still contribute pool valuation or risk. The pool-coordinated settled-market sweep must deactivate the expiry as it returns free cash and materializes terminal accounting; do not expose an expiry-only terminal path that can strand pool capital. The [design decisions](../../packages/predict/docs/design/decisions.md) own this mechanism. -- After a settled-market sweep, the expiry may retain only payout backing and its inventory-impact escrow. Return free cash and unused fee incentives to `PoolVault`; live rebalancing must not run for expired or settled markets. +- After a settled-market sweep, the expiry may retain only payout backing. Return all cash above it and unused fee incentives to `PoolVault`; live rebalancing must not run for expired or settled markets. - Model Predict positions as binary range contracts. Live value is range probability value times quantity; settled value is the full quantity for a winner and zero for a loser. There is no floor, no financing, and no liquidation: leverage was removed, so an order's only durable terms are its strike range, quantity, and sequence. - Terminology: docs use options/structured-product vocabulary (canonical glossary: `packages/predict/docs/glossary.md`). The mint-economics identifiers are `premium` and `min_premium` (and the `OrderMinted.premium` event field); the holder pays the contract's full entry value, so premium equals entry value and no separate `entry_value` identifier exists. The EWMA congestion surcharge keeps core's `penalty` vocabulary in code. - Mint slippage bounds must name what they control. Both mint entrypoints cap the all-in account withdrawal through `max_cost`; quantity mint may disable it and separately cap probability, while budget mint requires it and uses `min_quantity` against `max_premium` instead of a duplicate probability argument. Keep a one-use payment sum inline, and keep `OrderMinted` payment components separate. The [response-policy register](../../packages/predict/predeploy/response-policies.md) RP-19 owns the rationale and pinning tests. diff --git a/.claude/rules/predict-harness.md b/.claude/rules/predict-harness.md index 30687f3ae..5c524b231 100644 --- a/.claude/rules/predict-harness.md +++ b/.claude/rules/predict-harness.md @@ -15,6 +15,7 @@ Read this before editing the Predict local development system under `packages/pr - Python tests: `python3 -m unittest discover -s harness/tests -p 'test_*.py' -v` and `python3 -m unittest discover -s simulations/tests -p 'test_*.py' -v` from `packages/predict/`. - Validate behavior with a real localnet run (`python3 -m harness live --traders N --seconds S`) or a scoped campaign, then `python3 -m harness analyze`. Run these in the **main loop or background, never a blocking subagent** (long runs trip watchdogs). Retention/teardown model: README. - **On a PTB abort, read the real VM error, not the framework tag.** A `MovePrimitiveRuntimeError` in `0x2::dynamic_field::borrow_child_object` names only the framework fn; the true cause is the dry-run `executionErrorSource` in the saved `artifacts/failed_transactions/*.json` (now also printed live by `runtime.ts` and by the `analyze` bug oracle). The C-1 object-cache ceiling was chased for days off the truncated framework string while that field read `Object runtime cached objects limit (1000 entries) reached` all along — the trace's `errorTag` only kept its first 120 chars. +- **`executionErrorSource` can be absent, and then the framework tag is not evidence.** Under the gRPC executor the dry run may return only a structured `status.error` with `cleverError: "[Undefined]"`, so `runtime.ts` prints nothing and the analyzer cannot corroborate a declared wall — `p32-refresh-gas-2026-08-20.md` records a whole run with the field empty on all 100 artifacts. Do not read `dynamic_field:0` as either cause on its own: the object-cache limit and a genuine missing field share that location, and the gRPC transport labels both `MoveAbort` with `abortCode` 0. Discriminate by running the same call over a large table in `sui move test`, which enforces no object-cache limit: a structural read error aborts there at any size, the VM ceiling never does. - **`sui move test` does NOT enforce Sui execution-layer limits.** The object-runtime cached-objects cap (1,000 dynamic-field children/tx), per-tx gas, and max object size are full-node checks — a unit test loads 1,100+ children without aborting. Reproduce these on localnet (a harness strategy), never in `sui move test`. - **Never blind-`rm` `.localnets/instances/` while a run may be live.** A bare `rm -rf` deletes a *running* campaign's dir out from under it — its keeper/updater then `ENOENT` on every write and the trace is lost. Check `python3 -m harness status` first (non-empty slots = a live run), then use the **slot-aware** `cleanup --instances`, which skips any dir whose run-id is an active slot. diff --git a/.claude/skills/predict-audit/evals/seeds.md b/.claude/skills/predict-audit/evals/seeds.md index 5a6e19f5f..3acd7f898 100644 --- a/.claude/skills/predict-audit/evals/seeds.md +++ b/.claude/skills/predict-audit/evals/seeds.md @@ -29,12 +29,10 @@ verify panel refuted a real bug. Record misses; they are the highest-signal inpu - Bug: a supplier now mints ≥ fair shares (rounds UP), diluting incumbent LPs — a ROUNDING_POLICY R2 violation (user-facing outflows/shares must round in the protocol's favor). Expect the invariants lens (R2) to flag it. -### S2 — solvency guard dropped (impact escrow unbacked) · lens 01 / lens 06 · expect High/Critical +### S2 — solvency guard dropped · lens 01 / lens 06 · expect High/Critical - File: `packages/predict/sources/expiry_cash.move`, `assert_backing`. -- Replace `cash.required_cash(payout_liability)` with `payout_liability` (drops the `+ inventory_impact_reserve` term). -- Bug: backing now covers payout but NOT the isolated inventory-impact escrow, so cash can fall below - `payout_liability + inventory_impact_reserve` — the exact invariant this module documents, and a live close - can then be paid out of cash that backs a winner. Expect a solvency/assertion finding. +- Delete the `assert!(cash.balance() >= payout_liability, EInsufficientCash);` line. +- Bug: cash can fall below payout liability, so a later payout can be paid from unbacked cash. Expect a solvency/assertion finding. ### S3 — removed version gate on a state mutator · lens 04 access-control · expect High - File: `packages/predict/sources/expiry_market.move`, `assert_live_mint_allowed`. diff --git a/.claude/skills/predict-audit/evals/verify_corpus.json b/.claude/skills/predict-audit/evals/verify_corpus.json index 10cb8ab51..b4d0a07db 100644 --- a/.claude/skills/predict-audit/evals/verify_corpus.json +++ b/.claude/skills/predict-audit/evals/verify_corpus.json @@ -64,12 +64,12 @@ { "id": "refute-assert-backing-off-by-one", "expect": "refuted", - "why": "The code is correct: assert_backing checks balance >= required_cash(payout_liability) where required_cash = payout_liability + inventory_impact_reserve. The invariant is an inclusive lower bound; there is no off-by-one \u2014 dust never aborts by ROUNDING_POLICY R1 (reserve >= payout by construction).", + "why": "The code is correct: assert_backing checks balance >= payout_liability. The invariant is an inclusive lower bound; there is no off-by-one \u2014 dust never aborts by ROUNDING_POLICY R1 (cash >= payout by construction).", "severity": "Medium", "title": "assert_backing uses >= where it should use > , allowing a one-ulp shortfall", "location": "packages/predict/sources/expiry_cash.move:62", "claim": "The backing assert admits balance exactly equal to required cash, leaving zero margin and risking a downstream underflow.", - "scenario": "balance == payout_liability + inventory_impact_reserve exactly; a later dust subtraction underflows.", + "scenario": "balance == payout_liability exactly; a later dust subtraction underflows.", "impact": "correctness", "confidence": "low", "recommendation": "Tighten the compare to strictly greater.", diff --git a/.claude/skills/predict-audit/lenses/01-invariants.md b/.claude/skills/predict-audit/lenses/01-invariants.md index 5ffa27757..bb2cc7692 100644 --- a/.claude/skills/predict-audit/lenses/01-invariants.md +++ b/.claude/skills/predict-audit/lenses/01-invariants.md @@ -17,7 +17,7 @@ Produce: it (solvency / accounting-consistency / ordering / conservation); judge whether the code maintains it across ALL touching flows. Give special weight to **cross-module and cross-package** invariants — a property established in one module/package and silently relied on in another (e.g. `expiry_cash`'s - `cash_balance >= payout_liability + inventory_impact_reserve`; the exact `current_nav` mark used identically for PLP + `cash_balance >= payout_liability`; the exact `current_nav` mark used identically for PLP supply and withdraw; the packed-order-ID `quantity` round-trip — mint insert must add bit-equal what remove subtracts). 2. **ECONOMIC FLOW MAP** — trace every path where value (DUSDC, PLP) enters, moves, or leaves: diff --git a/.claude/skills/predict-audit/lenses/09-economic-simulation.md b/.claude/skills/predict-audit/lenses/09-economic-simulation.md index e091cc8af..f746be65f 100644 --- a/.claude/skills/predict-audit/lenses/09-economic-simulation.md +++ b/.claude/skills/predict-audit/lenses/09-economic-simulation.md @@ -19,7 +19,7 @@ new adversarial scenarios and property/fuzz tests.** **Required campaigns (write new scenarios; assert invariants after every step):** 1. **Solvency / conservation fuzz.** Randomized sequences of mint / redeem / liquidate / supply / withdraw over - the python mirrors. After each op assert: cash-backing (`balance >= payout_liability + inventory_impact_reserve`), + the python mirrors. After each op assert: cash-backing (`balance >= payout_liability`), no negative balances, DUSDC conserved across the trader/LP/protocol/builder split, rounding favors the protocol (never the user). Report any breaking seed + the minimal reproducing sequence. 2. **NAV mark symmetry / timing.** Drive supply and withdraw across a flush at a moving mark; confirm diff --git a/.claude/skills/predict-audit/primer.md b/.claude/skills/predict-audit/primer.md index cb96ba9c1..505c00b3b 100644 --- a/.claude/skills/predict-audit/primer.md +++ b/.claude/skills/predict-audit/primer.md @@ -52,7 +52,7 @@ DUSDC (settlement/custody for all trading + payouts, and the sponsored fee-incen - `builder_code.move` — fee-attribution object; accrues + claims builder fees. - `order.move` — packs immutable position terms (absolute boundary ticks, quantity, sequence) into a u256 order id (132 dense bits); validates shape. - `expiry_market.move` — per-expiry risk engine; mint / live redeem / settled redeem / settlement / compaction state machine; routes DUSDC; produces per-expiry `current_nav`. -- `expiry_cash.move` — raw DUSDC custody arithmetic; enforces `cash_balance >= payout_liability + inventory_impact_reserve`. +- `expiry_cash.move` — raw DUSDC custody arithmetic; enforces `cash_balance >= payout_liability`. - `ewma.move` — gas-congestion surcharge ("EWMA penalty") added to trade fees. - `constants.move` — upgrade-only constants/sentinels (version, scalings, `pos_inf_tick`, resolution period). - `pricing/pricing.move` — the live pricing boundary: binds the market's underlying to current propbook feeds, pre-expiry live-pricing check, feed freshness, the pricing-safe surface envelope (forward>0, basis, |rho|<=1, sigma band), SVI variance + normal-CDF binary pricing; settlement read. @@ -86,7 +86,7 @@ DUSDC (settlement/custody for all trading + payouts, and the sponsored fee-incen `market_manager` cadence config → `create_and_share_expiry_market` (reads no live spot; absolute ticks snapshotted from cadence) → seed propbook Pyth + BS data for the emitted expiry → `mint` → live trade/redeem (partial or full close) → **passive settlement** (terminal spot = the exact post-expiry Pyth print from propbook minute history; if absent, the market stays unsettled and live valuation aborts) → settled redeem → compaction (free storage). Full-pool valuation: a transaction-local `PoolValuation` snapshots active expiries, values each once under the valuation lock; the **privileged** flush prices PLP supply AND withdraw at one exact `current_nav` mark. ## Glossary (neutral) -absolute tick = strike unit; `raw = tick * tick_size`. `pos_inf_tick`/`neg_inf` = open-ended-range sentinels. winner payout = the full `Q` (leverage was removed 2026-08-14; there is no floor). The only pre-settlement conservatism is the aggregate disjoint-backing λ buffer (D030). payout_liability / settled_payout_liability = cash the market must back. inventory_impact_reserve = isolated escrow of collected inventory-impact charges. EWMA penalty = gas-congestion fee surcharge. basis = forward/spot from BS pushes. SVI = volatility-surface parameterization for the binary tail. NAV = pool value pricing PLP shares; the flush mark is the **exact** `current_nav` (tree `walk_linear`, floored), no conservative band. float_scaling = 1e9 fixed-point. +absolute tick = strike unit; `raw = tick * tick_size`. `pos_inf_tick`/`neg_inf` = open-ended-range sentinels. winner payout = the full `Q` (leverage was removed 2026-08-14; there is no floor). The only pre-settlement conservatism is the aggregate disjoint-backing λ buffer (D030). payout_liability / settled_payout_liability = cash the market must back. inventory-impact charges are ordinary expiry cash (D034); there is no isolated escrow. EWMA penalty = gas-congestion fee surcharge. basis = forward/spot from BS pushes. SVI = volatility-surface parameterization for the binary tail. NAV = pool value pricing PLP shares; the flush mark is the **exact** `current_nav` (tree `walk_linear`, floored), no conservative band. float_scaling = 1e9 fixed-point. ## Prior-awareness (mandatory) Before raising anything, read and apply the [Predict development-system authority order](../../../packages/predict/predeploy/README.md#authority-order). Do not duplicate an existing open item or re-litigate a rejected direction unless its recorded revisit condition is met. diff --git a/.claude/skills/predict-audit/references/defi-invariant-classes.md b/.claude/skills/predict-audit/references/defi-invariant-classes.md index 23a7f9e4a..0a4e9a306 100644 --- a/.claude/skills/predict-audit/references/defi-invariant-classes.md +++ b/.claude/skills/predict-audit/references/defi-invariant-classes.md @@ -5,7 +5,7 @@ Recon-style invariant testing), mapped to Predict surfaces. Lens 01 builds the l ## 1. Solvency / conservation The protocol must always be able to pay what it owes; value is conserved across parties. -- `expiry_cash`: `cash_balance >= payout_liability + inventory_impact_reserve` after EVERY cash mutation. +- `expiry_cash`: `cash_balance >= payout_liability` after EVERY cash mutation. - Live backing for a winning order is the exact `quantity - floor_shares` (= its settled payout under the static floor) plus the aggregate disjoint-backing λ buffer (D030) — not a time-varying max-live term. - DUSDC is conserved across trader / LP / protocol / builder — no path mints value from nothing or strands it. - LP NAV: the exact `current_nav` mark prices PLP supply AND withdraw identically (`supply_NAV == withdraw_NAV diff --git a/packages/predict/devtools/ts/runtime.ts b/packages/predict/devtools/ts/runtime.ts index ced740ce1..d40c41330 100644 --- a/packages/predict/devtools/ts/runtime.ts +++ b/packages/predict/devtools/ts/runtime.ts @@ -1340,10 +1340,11 @@ function addRedeem(tx: Transaction, params: RedeemParams): void { tx.pure.u256(BigInt(params.orderId)), tx.pure.u64(params.closeQuantity), // `min_probability` then `min_proceeds` close-side slippage floors; the - // benchmark never sets a floor, so pass 0 to disable both (mirrors mint's - // U64_MAX caps). + // The benchmark disables close floors and permits any inventory-impact + // debit if a hedge-removing close exceeds its gross proceeds. tx.pure.u64(0), tx.pure.u64(0), + tx.pure.u64(U64_MAX), // `redeem_live` loads the account and ambient-settles it (`settle`) // before crediting the payout, so it reads the singleton AccumulatorRoot at 0xacc. tx.object(ACCUMULATOR_ROOT_ID), @@ -1505,6 +1506,22 @@ export function bindFeedsToUnderlyingTx(params: { pythFeedId: string }): Transac return tx; } +export function setTemplateInventoryImpactMaxRateTx( + protocolConfigId: string, + maxRate: bigint, +): Transaction { + const tx = new Transaction(); + tx.moveCall({ + target: target("protocol_config", "set_template_inventory_impact_max_rate"), + arguments: [ + tx.object(protocolConfigId), + tx.object(ADMIN_CAP_ID), + tx.pure.u64(maxRate), + ], + }); + return tx; +} + export function setTemplateExpiryFeeConfigTx( protocolConfigId: string, expiryFeeWindowMs: bigint, @@ -1612,6 +1629,38 @@ export function createExpiryMarketTx(params: { return tx; } +// Same create, plus a mass-check of an off-chain 1% ratio ladder before the +// market is shared. The keeper uses this when the template rate is nonzero. +// Requires a live surface for the deployable expiry already on the bound +// feeds (RP-24: written in a prior tx). +export function createExpiryMarketWithInventoryTx(params: { + poolVaultId: string; + protocolConfigId: string; + lifecycleCapId: string; + cadenceId: number; + ratios: bigint[]; +} & OracleFeedIds): Transaction { + const tx = new Transaction(); + tx.moveCall({ + target: target("registry", "create_and_share_expiry_market_with_inventory_grid"), + arguments: [ + tx.object(REGISTRY_ID), + tx.object(params.poolVaultId), + tx.object(params.protocolConfigId), + tx.object(ORACLE_REGISTRY_ID), + tx.object(params.pythFeedId), + tx.object(params.bsValueStoreId), + tx.object(params.bsSviStoreId), + tx.object(params.lifecycleCapId), + tx.pure.u32(PREDICT_ORACLE_ID), + tx.pure.u8(params.cadenceId), + tx.pure(bcs.vector(bcs.u64()).serialize(params.ratios)), + tx.object(CLOCK_ID), + ], + }); + return tx; +} + // Fund / rebalance one expiry's cash from pool idle toward target. Standalone and // permissionless; this is what makes a freshly created market mintable. Replaces // the old setup-only PLP sync. diff --git a/packages/predict/docs/concepts/fees-and-rebates.md b/packages/predict/docs/concepts/fees-and-rebates.md index 8f119dd41..f07f9a01d 100644 --- a/packages/predict/docs/concepts/fees-and-rebates.md +++ b/packages/predict/docs/concepts/fees-and-rebates.md @@ -97,49 +97,23 @@ One accepted weakness: because the first observation seeds the variance directly The congestion surcharge is handled differently from the trading fee in the cash flow. It is withdrawn from the trader (at mint) or withheld from the payout (at redeem), but it then rides into the expiry's cash as **surplus**: it earns no builder cut. It compensates liquidity providers for transacting during congestion rather than being a fee on the contract itself. -## Inventory-impact charge and rebate +## Inventory-impact charge -Inventory impact is an optional, path-independent transfer layered **on top of** the normal fee system. It is not trading-fee revenue, does not earn a builder cut, and is not a sponsor subsidy. `inventory_impact_max_rate` ships at `0`, so the mechanism is inert until an admin enables it for future markets. Each market freezes the configured rate and uses its cadence `max_expiry_allocation` as the immutable impact scale `B`; changing either template later cannot reprice its live book. The maximum valid rate is `1_000_000_000` (1.0, or 100%). +Inventory impact is an optional charge layered **on top of** the normal fee system. It is not trading-fee revenue, does not earn a builder cut, and is not a sponsor subsidy. `inventory_impact_max_rate` ships at `0`, so the mechanism is inert until an admin enables it for future markets. Each market snapshots that rate and `inventory_impact_scale` (`B`); changing either template later cannot reprice its live book. The maximum valid rate is `1_000_000_000` (1.0, or 100%). -### Step 1: measure the book's payout liability - -Let: - -- `M` be the largest summed net payout at any one settlement price; -- `T` be the sum of every live order's payout (`quantity`); -- `lambda` be `backing_buffer_lambda`. - -The existing live reserve liability is: - -```text -L = M + lambda * (T - M) -``` - -A candidate range does not necessarily move `M` by its full net payout. The payout tree therefore reads the current maximum inside the range and in its complement in `O(log n)`, computes the exact prospective `M` and `T`, and evaluates the complete liability formula before and after the trade. Evaluating both complete states matters for integer arithmetic: rounding only the incremental buffer could miss a one-atom carry already accumulated in `lambda * (T-M)`. This charges overlapping exposure more than a cold disjoint range when it raises the book's worst settlement point. - -### Step 2: map liability to one book-level potential +The risk coordinate is frozen-grid capital `K`: the average payout across the five worst of 100 equally likely settlement buckets, minus the book's expected payout. The keeper inverts the 1% ladder off-chain and the create path mass-checks those `strike / forward` ratios in the same transaction; later quotes rematerialize them against the live forward and the frozen SVI shape. A charged mint without a grid aborts. `L` stays the settlement reserve and is not the fee coordinate. For maximum marginal rate `r_max` and scale `B`: ```text -phi(L) = r_max * L^2 / (2 * B) when L <= B -phi(L) = r_max * B / 2 + r_max * (L - B) when L > B +phi(K) = r_max * K^2 / (2 * B) when K <= B +phi(K) = r_max * B / 2 + r_max * (K - B) when K > B +charge = max(0, phi(K_after) - phi(K_before)) ``` -Below `B`, the marginal rate rises linearly from zero to `r_max`: at 25% utilization the marginal rate is 25% of `r_max`; at 100% utilization it reaches `r_max`. Above `B`, it stays capped instead of growing without bound. On chain, `phi` is defined by one exact sequence of round-down fixed-point operations. Both directions evaluate that same integer function. - -### Step 3: charge or rebate only the potential change - -```text -mint charge = phi(L_after) - phi(L_before) -live-close rebate = phi(L_before) - phi(L_after) -``` - -This state-function construction is the key safety property. Splitting a trade, closing it in pieces, or cycling through ranges only creates intermediate terms that cancel. For any sequence that returns the book to the same state, total inventory charges equal total inventory rebates exactly, including integer rounding. A probability-local multiplier would not have this property: changing another range could change the price/rate used on exit and make a cross-range cycle profitable. - -Mint charges remain inside `ExpiryCash` but are earmarked in `inventory_impact_reserve`. Required cash includes the earmark and free cash/NAV excludes it. A live close can spend only this reserve. Settlement releases whatever remains into ordinary expiry surplus, because no live close can occur afterward. +Both mints and live closes pay when they raise `K`. A trade that lowers `K` pays nothing; there is no rebate and no isolated escrow. The charge is ordinary expiry cash and counts in NAV like any other fee. Splitting a risk-increasing trade collects the same total; a dip-and-recover path collects more. -This design adapts established ideas rather than claiming a new optimal market-making model: convex cost functions price trades by differences of a global state function ([Abernethy, Chen, and Vaughan](https://arxiv.org/abs/1011.1941); [Othman et al.](https://www.cs.cmu.edu/~sandholm/www/liquidity-sensitive%20AMMs%20via%20homogeneous%20risk%20measures.wine11.pdf)), Synthetix integrates a linear skew curve so execution is path invariant ([SIP-279](https://sips.synthetix.io/sips/sip-279/)), and GMX computes price impact from the change between pre- and post-trade imbalance powers ([GMX fees](https://docs.gmx.io/docs/trading/fees/)). Predict's exact choice of `L`, the cap at `B`, and its integer rounding are protocol-specific adaptations, not results those sources prove optimal for range digitals. +`OrderMinted` and `LiveOrderRedeemed` carry `inventory_impact_charge`, `k_before`, and `k_after`. `MarketCreated` carries the snapshotted `inventory_impact_max_rate` and `inventory_impact_scale`. ## How the components combine @@ -155,9 +129,9 @@ flowchart TD FEE --> COLLECT[fee -> expiry cash] BUILD --> BUILDER[builder fee -> builder code address] CONG --> SURPLUS[surcharge -> expiry cash surplus] - L[Book payout liability L] --> PHI["inventory potential phi(L)"] - PHI --> IMPACT["mint: charge delta / live close: rebate delta"] - IMPACT --> IRESERVE[isolated inventory-impact reserve] + K[Frozen-grid capital K] --> PHI["inventory potential phi(K)"] + PHI --> IMPACT["charge max(0, phi after − phi before)"] + IMPACT --> CASH[ordinary expiry cash] ``` Cash routing at trade time: @@ -167,9 +141,9 @@ Cash routing at trade time: | Trading fee | mint price / redeem payout | expiry cash (LP + protocol) | — | | Builder fee | add-on to trading fee | builder code address | — | | Congestion surcharge | add-on / withheld | expiry cash surplus | No | -| Inventory impact | mint add-on / live-close credit | isolated expiry escrow; residual becomes surplus at settlement | No | +| Inventory impact | mint or live close when `K` rises | ordinary expiry cash | No | -At **mint**, the trader's withdrawal is `premium + trading_fee + builder_fee + congestion_surcharge + inventory_impact_charge`. The `mint_exact_quantity` entrypoint's `max_cost` argument caps this full withdrawal; callers that accept any final cost can pass `std::u64::max_value!()`. Its `max_probability` argument separately caps the quoted per-contract probability before fees. The `mint_exact_amount` entrypoint instead fixes the `premium` budget, capped to the account's available DUSDC before sizing, and pays the ordinary fees and inventory-impact charge on top; its own `max_cost` argument caps that full withdrawal and is required — zero aborts, and no value disables it. At **live redeem**, the account receives `gross_redeem_amount + inventory_impact_rebate - trading_fee - builder_fee - congestion_surcharge`; `min_proceeds` protects that final net amount. At **settled redeem**, the winning payout is paid in full with no per-trade or inventory-impact rebate. +At **mint**, the trader's withdrawal is `premium + trading_fee + builder_fee + congestion_surcharge + inventory_impact_charge`. The `mint_exact_quantity` entrypoint's `max_cost` argument caps this full withdrawal; callers that accept any final cost can pass `std::u64::max_value!()`. Its `max_probability` argument separately caps the quoted per-contract probability before fees. The `mint_exact_amount` entrypoint instead fixes the `premium` budget, capped to the account's available DUSDC before sizing, and pays the ordinary fees and inventory-impact charge on top; its own `max_cost` argument caps that full withdrawal and is required — zero aborts, and no value disables it. At **live redeem**, the account receives `gross_redeem_amount - trading_fee - builder_fee - congestion_surcharge - inventory_impact_charge`; `min_proceeds` protects that final net amount. A close that raises `K` can make deductions exceed the redeem amount, and `max_cost` then caps the extra withdrawal. At **settled redeem**, the winning payout is paid in full with no inventory-impact charge. ## The LP supply/withdraw fee diff --git a/packages/predict/docs/concepts/liquidity-and-nav.md b/packages/predict/docs/concepts/liquidity-and-nav.md index 8f51c6c25..572def21c 100644 --- a/packages/predict/docs/concepts/liquidity-and-nav.md +++ b/packages/predict/docs/concepts/liquidity-and-nav.md @@ -101,18 +101,15 @@ The pool NAV above is just `idle + Σ current_nav`. The substance is `current_na ### An active expiry's exact NAV -`current_nav` is a pure read: free cash minus the exact per-order live liability, floored at zero. +`current_nav` is a pure read: expiry cash minus the exact per-order live liability, floored at zero. ``` -current_nav = max(0, free_cash − live_marked_liability) +current_nav = max(0, cash_balance − live_marked_liability) ``` -where: +where **`live_marked_liability = walk_linear`**, floored at zero, is the mark-to-model liability of every open order: `Σ_orders quantity × P(range)`, evaluated as the full payout-tree walk that prices each distinct boundary tick once through the resolved pricer. Every position is worth exactly its quantity times its range probability, so there is no per-order correction term. Inventory-impact charges are already in `cash_balance` and are not subtracted again. -- **`free_cash = cash_balance − inventory_impact_reserve`** — the expiry's DUSDC net of the isolated impact escrow it still owes. Inventory-impact escrow is not LP value while live. -- **`live_marked_liability = walk_linear`**, floored at zero, is the mark-to-model liability of every open order: `Σ_orders quantity × P(range)`, evaluated as the full payout-tree walk that prices each distinct boundary tick once through the resolved pricer. Every position is worth exactly its quantity times its range probability, so there is no per-order correction term. - -The aggregate is netted per boundary rather than summed per order, so it can differ from the per-order sum by boundary rounding; it is clamped at zero once, inside the walk. `free_cash − liability` is exactly the cash the pool keeps once every open contract is marked. +The aggregate is netted per boundary rather than summed per order, so it can differ from the per-order sum by boundary rounding; it is clamped at zero once, inside the walk. `cash − liability` is exactly the cash the pool keeps once every open contract is marked. `current_nav` carries **no backing assert** — it is purely a valuation read. Backing is a separate, always-on invariant owned by the cash leaf (below) and proven on every trade; the `max(0, ·)` cash floor only marks a degenerate (underwater) market at zero, which is its correct limited-recourse value, never negative. @@ -128,7 +125,7 @@ If that exact spot is not present, the market remains unsettled and the live bra Idle pool cash is funded into expiries to back trading, and surplus is swept back. The policy lives entirely in the pool; the expiry only enforces its own backing on every cash move. `rebalance_expiry_cash` is permissionless and standalone (callable at any cadence), and the same lock-free inner logic runs inside the flush's `value_expiry` before each market is valued. -Each expiry has a **required cash** floor of `payout_liability + inventory_impact_reserve`. The pool rebalances each active expiry toward a target derived from a **rebalance band** around that requirement: +Each expiry has a **required cash** floor of `payout_liability`. The pool rebalances each active expiry toward a target derived from a **rebalance band** around that requirement: - `target_cash = max(required_cash × (1 + band), expiry_cash_floor)` - `sweep_threshold = max(required_cash × (1 + 2 × band), expiry_cash_floor)` @@ -137,7 +134,7 @@ where `band` is `expiry_rebalance_pct` (a 1e9-scaled fraction) and `expiry_cash_ - **Top up:** if `cash_balance < target_cash`, the pool sends `target_cash − cash_balance`, capped by available idle DUSDC and by the expiry's remaining **funding room**. - **Sweep:** if `cash_balance > sweep_threshold`, the pool pulls `cash_balance − target_cash` back to idle. The expiry only releases surplus above its own required backing — a sweep can never break solvency. -- **Settled sweep:** settlement first releases the now-unclaimable inventory-impact earmark into ordinary expiry surplus. The expiry is then deactivated, all cash above settled payout liability is returned, and terminal profit from that returned cash is materialized (see [Profit materialization](#profit-materialization-at-settlement)). +- **Settled sweep:** the expiry is deactivated, all cash above settled payout liability is returned, and terminal profit from that returned cash is materialized (see [Profit materialization](#profit-materialization-at-settlement)). Funding room is bounded by the **per-expiry allocation cap** snapshotted from cadence config when the market is created. The cap limits **net** funding (`sent − received`); every send checks that net funding stays within the cap, bounding how much LP capital a single expiry can put at risk. @@ -150,7 +147,7 @@ Every cash movement is recorded in the ledger: cash sent accumulates into the pr The custody leaf (`ExpiryCash`) enforces, on every operation, that: ``` -cash_balance ≥ payout_liability + inventory_impact_reserve +cash_balance ≥ payout_liability ``` For a live market, `payout_liability` is a **settlement floor plus a liquidity buffer**: @@ -165,7 +162,7 @@ The floor is `max_net_payout` — the maximum summed net payout at any *single* - **Releasing surplus** to the pool requires cash to cover required backing *plus* the released amount — surplus is, by definition, only what is above the requirement. - **Settled cash release** computes the terminal liability, asserts backing, and returns only the strict excess. -The independent `inventory_impact_reserve` is the cumulative inventory potential collected from mints minus rebates paid to voluntary live closes. It is excluded from NAV and pool sweeps, and the market additionally asserts `inventory_impact_reserve ≥ phi(current payout_liability)`. Exact state-function differences make equality hold for ordinary mint/close paths; liquidations can only leave a surplus. Settlement releases that residual earmark because the live-rebate path is no longer reachable. See [fees and rebates](./fees-and-rebates.md#inventory-impact-charge-and-rebate). +Inventory-impact charges join expiry cash like any other fee; they are not a second backing term. See [fees and rebates](./fees-and-rebates.md#inventory-impact-charge). ## Profit materialization at settlement diff --git a/packages/predict/docs/concepts/markets-and-positions.md b/packages/predict/docs/concepts/markets-and-positions.md index fdacbc92d..650992e09 100644 --- a/packages/predict/docs/concepts/markets-and-positions.md +++ b/packages/predict/docs/concepts/markets-and-positions.md @@ -18,10 +18,10 @@ The `Registry` enforces uniqueness, admin approval, and cadence policy: 1. **Validate inputs before mutating.** The caller must present a `MarketLifecycleCap` on the registry's allowlist, the running package version must be allowed, global trading must be enabled, the underlying must be registered in Predict, and the requested cadence must be enabled. The market manager then scans forward from the cadence watermark/current-clock candidate, skips slots reserved for enabled higher-rank cadences, and requires the selected expiry to remain inside the cadence window and not already exist. 2. **Require current Propbook coverage.** The caller also passes Propbook's `OracleRegistry`; the registry asserts that Propbook has current canonical bindings for Pyth spot, BS spot, and the selected expiry's BS forward/SVI feeds for the supplied `propbook_underlying_id`. The market does **not** store those oracle object IDs. -3. **Compute expiry and snapshot config.** The market manager picks the next missing expiry from the cadence watermark and current clock, then the `ExpiryMarket` snapshots its strike-exposure and cash config from `ProtocolConfig`, stores `propbook_underlying_id`, and snapshots the cadence `tick_size`. Pool accounting snapshots the cadence `max_expiry_allocation` and `initial_expiry_cash`; the market also freezes `max_expiry_allocation` as the inventory-impact scale. Creation needs **no live spot** — strikes are absolute ticks, so there is no grid to center on a price. +3. **Compute expiry and snapshot config.** The market manager picks the next missing expiry from the cadence watermark and current clock, then the `ExpiryMarket` snapshots its strike-exposure and cash config from `ProtocolConfig`, stores `propbook_underlying_id`, and snapshots the cadence `tick_size`. Pool accounting snapshots the cadence `max_expiry_allocation` and `initial_expiry_cash`. Inventory-impact `max_rate` and `scale` come from that same strike-exposure snapshot, not from the allocation cap. The oracle-free create still needs **no live spot** — strikes are absolute ticks. `create_and_share_expiry_market_with_inventory_grid` mass-checks a supplied 1% ratio ladder against the live surface before the market is shared, so a nonzero rate does not invert on-chain. 4. **Create, share, and register.** The `ExpiryMarket` is shared, registered with the pool vault as an active-expiry accounting row, and indexed by expiry in the registry. -The new `ExpiryMarket` starts with **zero DUSDC cash** and is **not mintable** until pool capital funds it through PLP rebalancing (see [liquidity and NAV](./liquidity-and-nav.md)). On success the protocol emits `MarketCreated`, carrying the expiry market id, pool vault id, `propbook_underlying_id`, expiry, `tick_size`, `max_expiry_allocation`, `initial_expiry_cash`, and the immutable policy snapshot applied to that expiry (`backing_buffer_lambda`, fee bounds, entry-probability bounds, expiry-fee ramp terms, and `inventory_impact_max_rate`). The event carries `tick_size` — **not** a min/max strike — because the strike domain is the absolute tick ladder; indexers and SDKs derive raw strikes as `tick × tick_size`. The event also carries the immutable per-expiry pool allocation cap/impact scale, initial cash target, and policy because the cadence and protocol template configs that produced them can change later. +The new `ExpiryMarket` starts with **zero DUSDC cash** and is **not mintable** until pool capital funds it through PLP rebalancing (see [liquidity and NAV](./liquidity-and-nav.md)). On success the protocol emits `MarketCreated`, carrying the expiry market id, pool vault id, `propbook_underlying_id`, expiry, `tick_size`, `max_expiry_allocation`, `initial_expiry_cash`, and the immutable policy snapshot applied to that expiry (`backing_buffer_lambda`, fee bounds, entry-probability bounds, expiry-fee ramp terms, `inventory_impact_max_rate`, and `inventory_impact_scale`). The event carries `tick_size` — **not** a min/max strike — because the strike domain is the absolute tick ladder; indexers and SDKs derive raw strikes as `tick × tick_size`. The event also carries the immutable per-expiry pool allocation cap, initial cash target, and policy because the cadence and protocol template configs that produced them can change later. ```mermaid flowchart TD @@ -91,7 +91,7 @@ stateDiagram-v2 ### Mint -`mint_exact_quantity` creates a live position for a fixed quantity. It requires: the package version allowed for the market, per-market minting not paused, global trading enabled, no pool valuation in progress, valid account owner auth, current canonical Propbook feeds with fresh BS price/SVI inputs, and enough expiry cash to back the post-mint payout liability plus both reserves. The flow takes the `(lower_tick, higher_tick)` pair, quotes the entry range probability, derives the net premium (the contract's full entry value), allocates an `Order` (assigning the next expiry-local sequence), inserts it into the strike-exposure index, and settles payment (net premium + ordinary fees + isolated inventory-impact charge). Its `max_cost` argument is an all-in slippage cap on that payment, and `max_probability` caps the quoted per-contract probability before fees; pass `std::u64::max_value!()` for either uncapped guard. It emits **`OrderMinted`** with the separate `inventory_impact_charge` and returns the order ID. The event carries each pricing input's own economic clock — the timestamp its freshness was validated against (`pyth_spot_source_timestamp_ms` is Pyth's source time; the three `block_scholes_*_source_timestamp_ms` fields are each observation's batch publish time, and the SVI one is also the roll-down anchor); the provider's calibration times are recoverable from the store's `BlockScholesObservationRecorded` ingestion events. `LiveOrderRedeemed` exposes the same pricing provenance. Mint gating (feed freshness, mint pause, range validity) connects to [pricing and oracles](./pricing-and-oracles.md). +`mint_exact_quantity` creates a live position for a fixed quantity. It requires: the package version allowed for the market, per-market minting not paused, global trading enabled, no pool valuation in progress, valid account owner auth, current canonical Propbook feeds with fresh BS price/SVI inputs, and enough expiry cash to back the post-mint payout liability. The flow takes the `(lower_tick, higher_tick)` pair, quotes the entry range probability, derives the net premium (the contract's full entry value), allocates an `Order` (assigning the next expiry-local sequence), inserts it into the strike-exposure index, and settles payment (net premium + ordinary fees + inventory-impact charge). Its `max_cost` argument is an all-in slippage cap on that payment, and `max_probability` caps the quoted per-contract probability before fees; pass `std::u64::max_value!()` for either uncapped guard. It emits **`OrderMinted`** with `inventory_impact_charge`, `k_before`, and `k_after`, and returns the order ID. The event carries each pricing input's own economic clock — the timestamp its freshness was validated against (`pyth_spot_source_timestamp_ms` is Pyth's source time; the three `block_scholes_*_source_timestamp_ms` fields are each observation's batch publish time, and the SVI one is also the roll-down anchor); the provider's calibration times are recoverable from the store's `BlockScholesObservationRecorded` ingestion events. `LiveOrderRedeemed` exposes the same pricing provenance. Mint gating (feed freshness, mint pause, range validity) connects to [pricing and oracles](./pricing-and-oracles.md). `mint_exact_amount` is the fixed-amount variant. Instead of fixing `quantity`, the caller fixes the premium budget; the market caps that budget to the account's available DUSDC, computes the largest lot-rounded quantity whose `premium` fits it, then aborts if it is below `min_quantity`. Trading fee, optional builder fee, and EWMA congestion penalty are still charged on top of the premium budget, so this variant also takes `max_cost` as the all-in cap on the total withdrawal. Unlike `mint_exact_quantity`'s guards, `max_cost` here is required: zero aborts, and there is no value that disables it. It carries no `max_probability` argument, because `min_quantity` against the premium budget already bounds the price paid per contract. @@ -102,7 +102,7 @@ While the market is active, `redeem_live` closes a position the caller has trade - **Full close** (`close_quantity == quantity`): the order's full live-index terms are removed, the redeem amount is quoted at the current range probability, fees and penalty are deducted, and the payout is deposited to the account. No replacement is produced. - **Partial close** (`close_quantity < quantity`): the protocol removes the closed slice from the live indexes and creates a **replacement** order for the remaining quantity with a new sequence. -Both paths emit **`LiveOrderRedeemed`** (carrying `quantity_closed`, `remaining_quantity`, `replacement_order_id` when present, and the separate `inventory_impact_rebate`). `min_proceeds` applies to the final credited amount: gross redeem plus inventory rebate minus ordinary fees. Live redeem requires `account::Auth` (owner auth, or app-auth via the account registry). +Both paths emit **`LiveOrderRedeemed`** (carrying `quantity_closed`, `remaining_quantity`, `replacement_order_id` when present, `inventory_impact_charge`, `k_before`, and `k_after`). `min_proceeds` applies to the final credited amount: gross redeem minus ordinary fees and any inventory charge. Live redeem requires `account::Auth` (owner auth, or app-auth via the account registry). ### Settlement recorded diff --git a/packages/predict/docs/design/configuration.md b/packages/predict/docs/design/configuration.md index 2a7bac6bd..97839a9dc 100644 --- a/packages/predict/docs/design/configuration.md +++ b/packages/predict/docs/design/configuration.md @@ -18,6 +18,8 @@ Some structural constants are real and stable enough to state directly: - **Position lot size** and **minimum mint-time net premium** are fixed constants, not admin-tunable. - **The minimum per-expiry allocation cap** is an upgrade-required floor. The actual per-expiry cap is admin-tuned per cadence and snapshotted into pool accounting when a market is created. - **Market tick sizes** are admin-tuned per cadence and must be positive and within the protocol's overflow-safe bounds. There is no centered strike grid and no per-oracle tick-count constant — a strike is an absolute tick from zero (`raw_strike = tick * tick_size`) over the fixed 30-bit tick domain. +- **K-grid inventory impact** (D034): 100 buckets of 1% mass with a 1 bp mass check, `K` as the average of the worst 5 minus the stored incremental expected payout, an off-chain ratio invert with that on-chain mass check, and a 2,048-cell log-price payout mirror. These live in `inventory_grid` / `inventory_cells`, not in `config_constants`. + ## Three classes of configuration Beyond the tunable/constant split, the admin-tunable layer is organized by *when and where* a value is read. There are three classes. @@ -28,11 +30,11 @@ Beyond the tunable/constant split, the admin-tunable layer is organized by *when | Template (on `ProtocolConfig`) | Snapshotted into | Governs | | --- | --- | --- | -| `StrikeExposureConfig` | `StrikeExposure` (embedded on the per-expiry `ExpiryMarket`) | Entry-probability admission band, backing-buffer lambda (fraction of the disjoint-book gap reserved for early exits; 1.0 = fully summed reserve), fee policy (base/min fee, Bernoulli scaling, expiry-fee ramp window and max multiplier), all-in mint price bounds, and `inventory_impact_max_rate` (maximum marginal rate of the isolated inventory potential) | +| `StrikeExposureConfig` | `StrikeExposure` (embedded on the per-expiry `ExpiryMarket`) | Entry-probability admission band, backing-buffer lambda (fraction of the disjoint-book gap reserved for early exits; 1.0 = fully summed reserve), fee policy (base/min fee, Bernoulli scaling, expiry-fee ramp window and max multiplier), all-in mint price bounds, and the inventory-impact pair `inventory_impact_max_rate` / `inventory_impact_scale` (capped convex potential on frozen-grid `K`; D034) | When `create_and_share_expiry_market` runs, the per-expiry object constructors snapshot each template into an independent copy stored inside the new object. From that moment the snapshot is decoupled from the template: a later admin change to a template updates the value future markets will snapshot, but it **does not** reach back through the template into any already-created market. -It is a **contract-term** template: its snapshot has no per-object admin setter, so once a market is created its fee schedule, entry-probability admission band, backing-buffer lambda, and inventory-impact max rate are fixed for the life of the contract. The inventory-impact curve also freezes the cadence's `max_expiry_allocation` as its scale `B`; the rate ships at `0` (disabled) and is bounded to `0..1_000_000_000` (0–100%). Traders who minted under one set of terms keep those terms, and an admin cannot retroactively alter the economics of a live market. The setters are named with `template` (for example `set_template_base_fee`, `set_template_min_entry_probability`, `set_template_inventory_impact_max_rate`) to make this "future-only" effect explicit at the call site. There is no template-class value an admin can move on a live market — the former settlement-freshness exception went away with the oracle extraction (settlement freshness now lives in the external feeds, not in a Predict template). +It is a **contract-term** template: its snapshot has no per-object admin setter, so once a market is created its fee schedule, entry-probability admission band, backing-buffer lambda, and inventory-impact pair are fixed for the life of the contract. Inventory impact's only admin knobs are `inventory_impact_max_rate` (`r_max`, ships at `0`, envelope `0..1`) and `inventory_impact_scale` (`B`, ships at `$1,000` DUSDC); they are not the cadence allocation cap. The 100-bucket / top-5 coordinate, on-chain 1 bp mass check, and 2,048-cell lattice are upgrade-required macros in `inventory_grid` and `inventory_cells`. Traders who minted under one set of terms keep those terms, and an admin cannot retroactively alter the economics of a live market. The setters are named with `template` (for example `set_template_base_fee`, `set_template_min_entry_probability`, `set_template_inventory_impact_max_rate`, `set_template_inventory_impact_scale`) to make this "future-only" effect explicit at the call site. There is no template-class value an admin can move on a live market — the former settlement-freshness exception went away with the oracle extraction (settlement freshness now lives in the external feeds, not in a Predict template). ```mermaid flowchart LR @@ -92,7 +94,7 @@ Several bounds are tightened on purpose so a single bad admin call cannot quietl ## Registry tuning: underlyings and cadences -The `Registry` records admin-approved Propbook underlyings and owns per-underlying cadence deployment config through its `MarketManager`. `register_underlying` is `AdminCap`-gated and records which Propbook underlyings Predict may create markets for; newly registered underlyings start with every cadence disabled. `set_template_cadence_config` is also `AdminCap`-gated and sets one underlying's cadence `tick_size`, `admission_tick_size`, `max_expiry_allocation`, `initial_expiry_cash`, and `window_size` together. A zeroed cadence is disabled; an enabled cadence's tick size, allocation cap, and initial cash target are snapshotted into each created market. The same `max_expiry_allocation` is the immutable scale `B` for that market's inventory-impact potential, so utilization is measured against its approved capital envelope rather than a manipulable live balance. There is no on-chain check that a cadence tick size matches the asset's price scale — sizing it is an operational responsibility, and a mismatch fails loud at the first mint (a strike outside the 30-bit tick domain cannot be encoded). The global live-market count bound is enforced by PLP when a created market is registered with the pool. +The `Registry` records admin-approved Propbook underlyings and owns per-underlying cadence deployment config through its `MarketManager`. `register_underlying` is `AdminCap`-gated and records which Propbook underlyings Predict may create markets for; newly registered underlyings start with every cadence disabled. `set_template_cadence_config` is also `AdminCap`-gated and sets one underlying's cadence `tick_size`, `admission_tick_size`, `max_expiry_allocation`, `initial_expiry_cash`, and `window_size` together. A zeroed cadence is disabled; an enabled cadence's tick size, allocation cap, and initial cash target are snapshotted into each created market. Inventory-impact utilization is measured against the market's snapshotted `inventory_impact_scale`, not against `max_expiry_allocation`. There is no on-chain check that a cadence tick size matches the asset's price scale — sizing it is an operational responsibility, and a mismatch fails loud at the first mint (a strike outside the 30-bit tick domain cannot be encoded). The global live-market count bound is enforced by PLP when a created market is registered with the pool. The `Registry` also owns the `PauseCap` / `MarketLifecycleCap` allowlists; the protocol version watermark lives on `ProtocolConfig` (below). The oracle/feed objects themselves are external (`propbook`); the registry only records which Propbook underlyings Predict may create markets for and the cadence policies used to create them. ## Versioning and pause governance @@ -112,7 +114,7 @@ A `PauseCap` is a revocable emergency capability the admin mints into `Registry. | `PauseCap` (via `Registry`) | Force global trading pause, force per-expiry mint pause, force protocol-wide freeze — all one-way (engage only) | | `MarketLifecycleCap` (Registry allowlist) | Create expiry markets; also the sole authority to start the privileged pool flush (`start_pool_valuation`). No oracle-write or config authority | | Permissionless | Cash rebalance and settled-market sweep flows (subject to the valuation lock, not the trading pause); LP supply/withdraw requests and their cancellation | -| Upgrade only | Everything in the `constants` module: scaling, lot size, minimum net premium, the dynamic admission-curve shape constant, the minimum per-expiry allocation cap, the 30-bit tick domain, and every `min_*`/`max_*` bound in `config_constants` | +| Upgrade only | Everything in the `constants` module: scaling, lot size, minimum net premium, the dynamic admission-curve shape constant, the minimum per-expiry allocation cap, the 30-bit tick domain, every `min_*`/`max_*` bound in `config_constants`, and the K-grid macros in `inventory_grid` / `inventory_cells` | All admin setters route through their owning module: global protocol policy through `protocol_config`, per-object policy through the object's own module, and only registry-owned concerns (pause caps, lifecycle caps, uniqueness, underlying admission, and cadence deployment policy) through `registry`. The privileged pool flush is started on `plp`. The embedded config struct setters themselves are package-internal; the public, capability-gated entrypoints are the only external surface for changing policy. diff --git a/packages/predict/docs/design/decisions.md b/packages/predict/docs/design/decisions.md index 0ce01bb1b..a89298d2b 100644 --- a/packages/predict/docs/design/decisions.md +++ b/packages/predict/docs/design/decisions.md @@ -167,7 +167,54 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m also serves the exact NAV linear walk (`Σ qty·P` over its live boundaries), so it is the single full-lifecycle live index. *Rejected:* folding settlement into the deleted NAV matrix and dropping the tree. +- **D034 — Inventory impact prices 95% economic capital, and never refunds.** + The risk coordinate is `K`, the average payout across the five worst of 100 + equally likely settlement buckets minus the book's stored incremental expected + payout. Each trade adds or removes its cell-span mass under that quote's live + forward; quotes do not re-integrate the lattice, so `E` is the path of those + increments when spot moves. A trade + pays `max(0, phi(K_after) − phi(K_before))` for the same capped convex `phi` + D032 defined, so both mints and live closes pay when they raise the pool's + capital and neither is credited when they lower it. *Why `K` and not `L`:* + `L` is the settlement and early-exit reserve. It scores a fully funded book + as maximum risk, ignores premium already paid, and charges a genuinely + offsetting range for its buffer. `K` is the extra hole in the worst 5% of + outcomes above funded premium. *Why no rebate:* a refund needs a counterparty + whose risk the closer actually took off the pool, which range digitals do + not supply. Charges are therefore path-dependent in the conservative + direction only — splitting a risk-increasing trade collects the same total, + while a dip-and-recover path collects more. The charge is ordinary expiry + cash on arrival: no escrow, no position credit, no settlement release, and + it counts in NAV like any other fee. *How the pointer is built:* the + keeper inverts the 1% CDF off-chain and the lifecycle create mass-checks + the 99 `strike / forward` rungs (1% ± 1 bp) in the same transaction, then + stores them with the SVI shape. Later quotes rematerialize + `ratio × F_live` against that frozen shape. A later + `provision_inventory_grid` is a no-op when the grid is already filled. A + charged mint or quote without a grid aborts. Init and create are one + transaction, so there is no empty-book race and no grid keeper. A ladder + that fails the mass check aborts that create. Later mints do not + re-invert: smile decay is accepted, and these markets are not traded in + the last tenth of life. A close re-derives its expected-payout delta from the + live rematerialization rather than a value stored at mint. The inline + 2,048-cell payout mirror supplies the range maxima so the quote never walks + the payout tree. The only admin-tunable economics are the template pair + `inventory_impact_max_rate` (`r_max`, default 0, envelope 0..1) and + `inventory_impact_scale` (`B`, default $1,000 DUSDC), snapshotted at market + creation; they are not the cadence allocation cap. The coordinate itself is + upgrade-required: 100 buckets of 1% mass (±1 bp), `K` averages the worst 5, + invert is off-chain with an on-chain 1 bp mass check, and the payout + mirror is 2,048 log-price cells spanning 1.72× the 1–99% ladder. *Rejected:* `L` as the fee coordinate; holding charges in + escrow until settlement, which defers LP compensation and lets an LP supply + just before the release to capture a NAV jump; a keeper that re-cuts dollar + boundaries after every spot move, which the ratio axis already absorbs; an + off-chain lifecycle-cap cut in a later transaction than create, which + reintroduced the empty-book race; on-chain invert at create or first mint, + which spent most of the computation cap; and re-inverting on later mints. - **D032 — Inventory impact is the difference of one capped book-level potential.** + *SUPERSEDED by D034 — the risk coordinate moved from `L` to `K`, and the rebate + and its escrow were removed. The capped convex potential and the + state-difference construction survive.* Define the risk coordinate as the existing payout liability `L = M + λ(T-M)`, and charge mints / rebate voluntary live closes by the signed change of a convex potential whose marginal rate rises linearly to @@ -390,7 +437,7 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m the cost is a ~24h LP settlement delay. *Rejected:* an operator-posted NAV (this is a trustless on-chain crank), a multi-tx crank, and a flush that pauses trading. - **`current_nav` is the exact per-expiry mark — one mark, no band.** Per expiry, - `current_nav = free_cash − live_marked_liability`, floored at zero, where the + `current_nav = cash − live_marked_liability`, floored at zero, where the liability is the payout tree's boundary-linear walk alone, with no per-order correction (leverage was removed — see "Leverage removal" below). The flush prices supply *and* withdraw at the single `pool_nav = idle + Σ current_nav` (net of @@ -607,7 +654,7 @@ the invariants these decisions must preserve, see [invariants.md](./invariants.m - **Leverage, the static floor, and knock-out liquidation are removed entirely.** Every position is 1x: live value is `quantity × range_probability`, and a winning position settles for its full `quantity`. There is no floor, no financed amount, no liquidation book, no knock-out threshold, and no near-expiry leverage-admission window. *Rationale:* leverage's risk surface — the liquidation book, the NAV floor correction, the bounded liquidation sweep folded into mint and live redeem, the probability-sensitive admission cap, and the near-expiry block — was disproportionate to its value pre-launch; removing it collapses NAV to a single boundary-linear walk and deletes an entire class of keeper-timeliness risk. *Superseded:* every leverage/floor/knock-out decision above in "Economic model", "Data structures", and "Near-expiry leverage block", retired in place rather than deleted, per the response-policy register's RETIRED convention (RP-17). - **Mint admission is an entry-probability band plus a minimum premium.** `strike_exposure_config::assert_mint_admission` requires `entry_probability` inside `[min_entry_probability, max_entry_probability]` and `premium = entry_probability × quantity >= min_premium`; the holder pays the contract's full entry value, so premium equals entry value. *Rejected:* keeping the admission machinery as a dead 1x-only code path — deleting it removes the liquidation book's guard surface entirely rather than leaving it unreachable. -- **NAV is the payout tree's boundary-linear walk alone.** `current_nav = free_cash − walk_linear(pricer)`, floored at zero. `walk_linear` still prices every boundary; what is gone is the `correction_value` term, the liquidation-book scan, and the price memo. The non-monotone-surface guard moved with the memo's deletion, from `pricing::ENonMonotonePriceMemo` to `strike_payout_tree::ENonMonotonePrice`, and is still enforced at every boundary (RP-15). +- **NAV is the payout tree's boundary-linear walk alone.** `current_nav = cash − walk_linear(pricer)`, floored at zero. `walk_linear` still prices every boundary; what is gone is the `correction_value` term, the liquidation-book scan, and the price memo. The non-monotone-surface guard moved with the memo's deletion, from `pricing::ENonMonotonePriceMemo` to `strike_payout_tree::ENonMonotonePrice`, and is still enforced at every boundary (RP-15). See `predeploy/response-policies.md` RP-27 for the guard-duty inventory this removal required. @@ -622,7 +669,7 @@ RP-11's late-stake reasoning changed with this removal — the rebate is now the ## Staking and the trading-loss rebate removal (2026-08-18) - **DEEP staking and the trading-loss rebate are removed entirely.** `stake_deep` / `unstake_deep`, the pool's `staked_deep` custody, the account's active/inactive stake split and its lazy epoch roll, `StakeConfig` and its two template setters, the rebate reserve and its `trading_loss_rebate_rate` (with the whole `ExpiryCashConfig` it was the only field of), both rebate-claim entrypoints, and the `DeepStaked` / `DeepUnstaked` / `TradingLossRebateClaimed` events are all deleted. *Rationale:* the rebate was the last surviving staking benefit after the fee discount went (see "Stake fee-discount removal"), and it is a mechanism the protocol ships disabled — `max_benefit_ratio` is `0`, so no market pays it. What it did cost, unconditionally, was solvency-critical surface: a second term in the expiry cash-backing invariant, a per-account per-expiry summary table with a claim as its only reaper, a permissionless claim flow whose economics needed their own gas measurements, and a one-shot claim whose ordering against settlement, unstaking, and the settled sweep had to be reasoned about. Removing it is the largest single reduction in tail-state surface available before the deploy freezes the ABI. *Rejected:* keeping the rebate without stake scaling — the stake was the sybil gate that made an aggregate-net-loss rebate targetable at all (a rebate paid at the flat rate to every address is farmable one address per order), so an unstaked rebate is a different and worse mechanism, not a smaller one. -- **The expiry cash-backing invariant is now payout liability plus the inventory-impact escrow.** `required_cash = payout_liability + inventory_impact_reserve`; `free_cash` nets out the escrow alone. The settled sweep therefore returns all free cash at settlement instead of holding a per-account reserve back until a keeper resolves it, and an expiry no longer strands cash waiting on a cleanout. +- **The expiry cash-backing invariant is now payout liability plus the inventory-impact escrow.** *SUPERSEDED by D034 — the escrow was removed with the inventory rebate, so the invariant is payout liability alone and `free_cash` no longer exists as a distinct quantity.* `required_cash = payout_liability + inventory_impact_reserve`; `free_cash` nets out the escrow alone. The settled sweep therefore returns all free cash at settlement instead of holding a per-account reserve back until a keeper resolves it, and an expiry no longer strands cash waiting on a cleanout. - **`ExpiryTradingSummary` is deleted with the rebate, not kept for its position count.** The summary's other three fields (fees paid, gross paid, gross received) existed only to price a rebate, and its open-position count only gated the claim. Its row was created lazily per account per expiry and removed by the claim, so keeping the table without the claim would leak one row per account per expiry forever. Position state is the `positions` table, which is complete on its own. *Consequence:* `expiry_position_count` and `trading_fees_paid` are gone from the public read surface. - **`MarketCreated` no longer carries `trading_loss_rebate_rate`, `max_benefit_ratio`, or the two `*_benefit_power` thresholds.** The market policy snapshot keeps the strike-exposure terms only. Off-chain consumers of those four fields must be updated with this change. diff --git a/packages/predict/docs/design/invariants.md b/packages/predict/docs/design/invariants.md index 2340f4aa1..487e33fe7 100644 --- a/packages/predict/docs/design/invariants.md +++ b/packages/predict/docs/design/invariants.md @@ -12,20 +12,13 @@ and contributors. For *how* each mechanism works, follow the links into ## Solvency and custody - **Cash backing.** Every expiry's DUSDC cash always covers its payout liability - and isolated inventory-impact reserve - (`cash ≥ payout_liability + inventory_impact_reserve`), - re-asserted after every cash mutation - (`expiry_cash::assert_backing`). -- **Inventory-impact escrow covers the current potential.** While live, - `inventory_impact_reserve ≥ phi(payout_liability)`. Mints credit exactly the - potential increase and voluntary live closes may withdraw only the potential - decrease; - settlement releases the residual earmark when live closes become impossible. -- **Inventory cycles telescope.** Inventory charge/rebate is always the signed - difference between two evaluations of the same deterministic integer state - function. Therefore any sequence returning the payout book to its starting - state has exactly zero net inventory transfer, including rounding and - cross-range reorderings. + (`cash ≥ payout_liability`), re-asserted after every cash mutation + (`expiry_cash::assert_backing`). Inventory-impact charges are ordinary expiry + cash on arrival; there is no isolated escrow. +- **Inventory impact is charge-only.** A trade pays + `max(0, phi(K_after) − phi(K_before))` on frozen-grid capital (D034). Splitting + a risk-increasing trade collects the same total; a dip-and-recover path + collects more. There is no rebate and no telescoping closed cycle. - **Live payout liability is a settlement floor plus a liquidity buffer.** The floor is the maximum summed payout at any *single* settlement price, read from `StrikePayoutTree::payout_reserve_terms`; the buffer is @@ -62,8 +55,7 @@ and contributors. For *how* each mechanism works, follow the links into ## NAV and valuation - **`current_nav` is the exact per-expiry mark.** `expiry_market::current_nav = - free_cash − live_marked_liability`, floored at zero, where `free_cash = - cash − inventory_impact_reserve` and the liability is the + cash − live_marked_liability`, floored at zero, where the liability is the payout tree's boundary-linear walk (`strike_payout_tree::walk_linear`, `Σ quantity × P(range)`) with no per-order correction. It is a **pure read with no backing assert** (backing is owned by the payout-tree diff --git a/packages/predict/docs/risks.md b/packages/predict/docs/risks.md index f28189d63..a7752f694 100644 --- a/packages/predict/docs/risks.md +++ b/packages/predict/docs/risks.md @@ -151,7 +151,7 @@ Normal one-operation flows are unaffected. Routers, keepers, and integrators bui Predict is pre-deployment software. Beyond the per-topic caveats above: - **Settlement depends on exact Propbook timestamp data.** As above, a past-expiry market cannot be valued or swept until Propbook has the exact normalized Pyth spot at that expiry timestamp. Operators must ensure exact settlement inserts are available around expiry. -- **The interface is still changing.** Module boundaries, function signatures, events, and config shapes are not frozen. Integrations built against the current code should expect breaking changes. In particular, the off-chain indexer/server is not yet rewired for the current event set (async LP/flush events including request limits and limit-miss/cancel reasons, the `(lower_tick, higher_tick)`, inventory-impact charge/rebate, and SVI parameter-anchor provenance fields on pricing-derived order events, `MarketCreated` carrying `propbook_underlying_id`, `tick_size`, cadence terms, and the immutable policy snapshot including `inventory_impact_max_rate`, slimmer expiry-cash/profit events, removed config-value history events, and the removed oracle events). +- **The interface is still changing.** Module boundaries, function signatures, events, and config shapes are not frozen. Integrations built against the current code should expect breaking changes. In particular, the off-chain indexer/server is not yet rewired for the current event set (async LP/flush events including request limits and limit-miss/cancel reasons, the `(lower_tick, higher_tick)`, inventory-impact charge and `k_before`/`k_after`, and SVI parameter-anchor provenance fields on pricing-derived order events, `MarketCreated` carrying `propbook_underlying_id`, `tick_size`, cadence terms, and the immutable policy snapshot including `inventory_impact_max_rate` and `inventory_impact_scale`, slimmer expiry-cash/profit events, removed config-value history events, and the removed oracle events). - **The package address is unset pre-deploy.** The indexer is wired to fail fast on an empty package address rather than silently index nothing; this is a development guard, not a runtime risk, but it reflects that the system has not yet been deployed end-to-end. - **Valuation quantity math has a documented u64 envelope.** The per-strike valuation accumulator multiplies quantity by strike price in u64: a maximum-size single mint overflows (and cleanly aborts) only at strikes above roughly $430k, and the aggregate valuation fold would need on the order of $123–184M of concentrated high-strike open interest in one expiry — far above normal cadence allocation caps — before it could abort valuation. Accepted and bounded by allocation caps rather than widened. diff --git a/packages/predict/harness/README.md b/packages/predict/harness/README.md index 656fefe8e..d646e8b84 100644 --- a/packages/predict/harness/README.md +++ b/packages/predict/harness/README.md @@ -40,6 +40,8 @@ Capacity profiles generated by one strategy family: - `capacity-single` — one far market, batched book fill - `capacity-pool` — round-robin batched fill across live markets - `capacity-tree` — one market with distinct payout-tree strikes +- `capacity-user-off` — single-leg mints, inventory-impact rate off (user-cost control) +- `capacity-user-on` — single-leg mints at a 2% inventory-impact rate; the keeper create pushes an off-chain 1% ladder Cleanup-economics profiles generated by one state machine: diff --git a/packages/predict/harness/analyze.py b/packages/predict/harness/analyze.py index 6032348ec..545f9affd 100644 --- a/packages/predict/harness/analyze.py +++ b/packages/predict/harness/analyze.py @@ -46,7 +46,7 @@ _BASE_TRACE_FIELDS = {"schema", "type", "ts"} _KEEPER_TRACE_SCHEMAS: dict[str, tuple[set[str], set[str]]] = { "settle": ({"market", "expiryMs"}, set()), - "fail": ({"tag"}, {"lane", "fatal"}), + "fail": ({"tag"}, {"lane", "market", "fatal"}), "keeper-stall": ({"consecutiveDefers", "lastError"}, set()), "flush": ( { @@ -60,6 +60,11 @@ }, set(), ), + # One authenticated inventory-grid cut. `initialize` runs on an empty book so it + # is a fixed cost; `refresh` re-queries a range-max per bucket and walks the whole + # payout tree, so its computation is what the book-size join below measures. + "gridInit": ({"market", "expiryMs", "buckets", "gas", "compGas"}, set()), + "gridRefresh": ({"market", "expiryMs", "buckets", "gas", "compGas"}, set()), "liquidate": ({"markets", "gas"}, {"budget"}), # One rung of the trade-liquidation-budget ladder. `requestedAtMs` is stamped before the # set-tx so the analysis can discard probes that ran while the in-force budget was @@ -161,6 +166,7 @@ "elapsedMs", "requestedAtMs", "book", + "buckets", "compGas", "computationCost", "consecutiveDefers", @@ -424,6 +430,44 @@ def _is_gas_oog(f: dict) -> bool: else: print(f" no breakpoint (computation peaked at {max_c:,} = {pct} of the {COMP_CAP:,} cap); grow the book further for the empirical limit") + # Inventory grid: refresh computation vs payout-tree size. A refresh re-cuts the + # 100 boundaries, re-queries one range-max per bucket, and walks the whole tree, so + # its cost grows with the node count the tree profile drives up. The keeper traces + # the cut; the trader traces the node count; they are joined per market by time. + grid_refreshes = [r for r in recs if r.get("type") == "gridRefresh" and r.get("_actor") == "keeper"] + if grid_refreshes: + node_sizes = sorted( + ( + (r["ts"], int(r["perMarket"]), str(r["market"])) + for r in recs + if r.get("type") == "nodes" and r.get("ts") and r.get("market") and "perMarket" in r + ), + ) + inits = [int(r["compGas"]) for r in recs if r.get("type") == "gridInit" and r.get("compGas")] + print(f"\ninventory grid — refresh computation vs payout-tree size ({len(grid_refreshes)} refreshes):") + if inits: + print(f" initialize (empty book): {max(inits):,} comp ({max(inits) / COMP_CAP * 100:.0f}% of the {COMP_CAP:,} cap)") + pts = measurements.cost_curve(recs, "gridRefresh", node_sizes) + if pts: + lo, hi = min(pts, key=lambda p: p[0]), max(pts, key=lambda p: p[0]) + print(f" {lo[0]} orders -> {lo[1]:,} comp ... {hi[0]} orders -> {hi[1]:,} comp") + fit = measurements.cap_crossing(pts, COMP_CAP) + if fit: + slope, base, cross = fit + print(f" ~{int(slope):,} comp/order (+{int(base):,} base) -> hits the {COMP_CAP:,} computation cap at ~{cross:,} orders") + peak_comp = max(c for _, c in pts) + print(f" peak refresh {peak_comp:,} comp at {max(s for s, _ in pts)} orders ({peak_comp / COMP_CAP * 100:.0f}% of the cap)") + else: + # Every refresh ran against a market the trader never reported a size for, + # so the cost is real but unattributable to a book size. + measured = [int(r["compGas"]) for r in grid_refreshes if r.get("compGas")] + if measured: + print(f" {max(measured):,} peak comp, but no node-count trace to size it against") + grid_fails = [f for f in keeper_fails if str(f.get("lane", "")).startswith("grid-")] + if grid_fails: + tags = sorted({str(f.get("tag", "")) for f in grid_fails}) + print(f" {len(grid_fails)} cut(s) deferred: {', '.join(tags)}") + # Batched transaction measurements shared by the capacity and cleanup families. batch_samples, batch_oogs = measurements.batch_computation(recs) if batch_samples or batch_oogs: @@ -679,6 +723,33 @@ def has_strategy_progress(instance: Path, strategy: str) -> bool: ) +def _print_user_mint_increment(insts: list[Path]) -> None: + """Compare single-leg mint computation with the grid+rate off versus on.""" + by_profile: dict[str, list[int]] = {} + for inst in insts: + samples, _ = measurements.batch_computation(_load(inst / "trace")) + for profile, size, mean, _count in samples: + if size != 1: + continue + if profile.endswith("user-off") or profile.endswith("user-on"): + by_profile.setdefault(profile, []).append(mean) + off = by_profile.get("capacity/user-off") or by_profile.get("user-off") + on = by_profile.get("capacity/user-on") or by_profile.get("user-on") + if not off or not on: + return + off_mean = sum(off) // len(off) + on_mean = sum(on) // len(on) + delta = on_mean - off_mean + print("user mint computation (single-leg PTB, rate 2% vs no grid):") + print(f" off (no grid, rate 0): {off_mean:>14,} comp") + print(f" on (grid, rate 2%): {on_mean:>14,} comp") + print( + f" increment: {delta:>14,} comp " + f"({delta / COMP_CAP * 100:.2f}% of the {COMP_CAP:,} cap)" + ) + print() + + def analyze( instances: list[str] | None = None, expect: list[str] | None = None, @@ -721,6 +792,7 @@ def analyze( for inst in sorted(insts, key=lambda d: d.name): signals += _analyze_one(inst) print() + _print_user_mint_increment(insts) if len(insts) > 1 or expect: print(f"=== aggregate verdict over {len(insts)} instance(s): {'FAIL' if signals else 'clean'} ===") # Non-zero exit so background/autonomous runs have a programmatic failure signal. diff --git a/packages/predict/harness/live.py b/packages/predict/harness/live.py index 5c5854e27..c6615d0b2 100644 --- a/packages/predict/harness/live.py +++ b/packages/predict/harness/live.py @@ -577,6 +577,9 @@ def record_ready_localnet( "TRADER_DUSDC": strat_meta[strategy]["fund"], "TRADER_ADDRESSES": addr, "SIM_GAS_BUDGET": str(KEEPER_GAS_BUDGET), + "INVENTORY_IMPACT_MAX_RATE": str( + strat_meta[strategy].get("inventoryImpactMaxRate", "0") + ), }, ) stack.callback(cancellation.stop_process_group, keeper, keeper.pid) diff --git a/packages/predict/harness/measurements.py b/packages/predict/harness/measurements.py index c0062f495..2dd811203 100644 --- a/packages/predict/harness/measurements.py +++ b/packages/predict/harness/measurements.py @@ -32,6 +32,72 @@ def gas_by_moneyness(records: Iterable[dict[str, Any]]) -> dict[str, list[int]]: return dict(buckets) +def cost_curve( + records: Iterable[dict[str, Any]], + record_type: str, + sizes: list[tuple[int, int, str]], +) -> list[tuple[int, int]]: + """Join per-transaction computation cost to the book size in force when it ran. + + `sizes` is `(ts, size, market)` in ascending `ts`, as a size-emitting strategy + traces it. There is no on-chain read that reports a payout tree's node count, + so size is carried by the actor that built the book and matched to the measured + transaction by timestamp, per market: the newest size at or before the + transaction is the book it saw. Records whose market never reported a size drop + out rather than being credited with someone else's book. + """ + latest: dict[str, int] = {} + by_market: dict[str, list[tuple[int, int]]] = defaultdict(list) + for ts, size, market in sizes: + by_market[market].append((ts, size)) + points: list[tuple[int, int]] = [] + for record in sorted( + ( + record + for record in records + if record.get("type") == record_type + and record.get("ts") + and record.get("compGas") + and record.get("market") + ), + key=lambda record: record["ts"], + ): + market = str(record["market"]) + for ts, size in by_market.get(market, ()): + if ts <= record["ts"]: + latest[market] = size + else: + break + size = latest.get(market, 0) + if size > 0: + points.append((size, int(record["compGas"]))) + return points + + +def cap_crossing( + points: list[tuple[int, int]], cap: int +) -> tuple[float, float, int] | None: + """Least-squares `(slope, intercept, size at which cost reaches cap)`. + + None when the samples cannot place a line (fewer than two points, one distinct + size, or a non-increasing fit), so a caller reports the raw peak instead of + extrapolating a crossing from noise. + """ + count = len(points) + if count < 2: + return None + sum_size = sum(size for size, _ in points) + sum_cost = sum(cost for _, cost in points) + denominator = count * sum(size * size for size, _ in points) - sum_size * sum_size + if not denominator: + return None + slope = (count * sum(size * cost for size, cost in points) - sum_size * sum_cost) / denominator + if slope <= 0: + return None + intercept = (sum_cost - slope * sum_size) / count + return slope, intercept, int((cap - intercept) / slope) + + def nav_summary(records: Iterable[dict[str, Any]]) -> dict[str, float | int] | None: flushes = sorted( ( diff --git a/packages/predict/harness/tests/test_staging_publish.py b/packages/predict/harness/tests/test_staging_publish.py index 1f290c834..e9529e9c4 100644 --- a/packages/predict/harness/tests/test_staging_publish.py +++ b/packages/predict/harness/tests/test_staging_publish.py @@ -481,7 +481,9 @@ def write(path, manifest): run_manifest.write_manifest(path, manifest) metadata = { - "strategies": {strategy: strategy_metadata}, + # Fields the campaign reads but these orchestration cases do not + # exercise are defaulted here so each case declares only what it asserts on. + "strategies": {strategy: {**strategy_metadata}}, "cadences": [], } with contextlib.ExitStack() as patches: diff --git a/packages/predict/harness/ts/inventoryGrid.ts b/packages/predict/harness/ts/inventoryGrid.ts new file mode 100644 index 000000000..437577d6e --- /dev/null +++ b/packages/predict/harness/ts/inventoryGrid.ts @@ -0,0 +1,75 @@ +// Off-chain replica of the on-chain 1% invert. +// +// The keeper inverts the live surface here and the create path mass-checks the 99 +// interior `strike / forward` ratios, 1e9-scaled. Later quotes rematerialize +// those ratios against the live forward. This module is the float twin of the +// test-only Move invert and the ladder the keeper submits. +// +// Submitting ratios rather than absolute prices is what makes the cut operable at +// all. Pricing reads a strike only as `ln(strike) - ln(forward)`, so a bucket's mass +// is a function of these ratios alone and is unchanged by anything spot does between +// this generator pricing the surface and the transaction executing. An absolute +// ladder has no such property: an equal-mass bucket is roughly two basis points of +// the forward wide, so a quarter of a basis point of drift already pushes a bucket +// out of tolerance, and spot covers that in well under a second. +// +// The remaining error is float-vs-fixed-point disagreement plus the SVI roll-down +// over the submission delay. Scoring float-derived ratios with the contract-faithful +// fixed-point mirror in `simulations/python_replay.py` puts the worst bucket-mass +// error at a couple of hundred raw at zero delay and around 3.3e3 per second of +// delay thereafter, so a cut has tens of seconds of budget against the 1e5 +// tolerance. That is why this reuses the float `pricer.ts` port instead of carrying +// a second fixed-point implementation. +import { type Svi, upPrice } from "./pricer.js"; + +// Bucket count mirroring `inventory_grid`. The contract owns the open-end sentinels +// and requires exactly `bucket_count - 1` interior ratios. +export const GRID_BUCKETS = 100; +const RATIO_SCALE = 1_000_000_000; + +// Log-space bisection halves the bracket each pass, so 80 passes over a 1e-4..1e4 +// multiple of the forward drive the interval below double precision. The whole +// ladder is ~8k float evaluations, which is free next to the RPC round trip. +const BISECTION_PASSES = 80; +const BRACKET_MULTIPLE = 1e4; + +// Strike whose UP price is `target`. `upPrice` is monotonically decreasing in +// strike, so a midpoint priced above the target means the strike is still too +// low. Bisection is geometric because the surface is parameterized in +// log-moneyness. +function strikeAtUpPrice(svi: Svi, forward: number, target: number): number { + let low = forward / BRACKET_MULTIPLE; + let high = forward * BRACKET_MULTIPLE; + for (let pass = 0; pass < BISECTION_PASSES; pass += 1) { + const mid = Math.sqrt(low * high); + if (upPrice(svi, forward, mid) > target) low = mid; + else high = mid; + } + return Math.sqrt(low * high); +} + +/** + * The 99 interior boundaries cutting `svi`/`forward` into 100 equal-mass buckets, + * as 1e9-scaled multiples of the forward, or null if the surface is degenerate. + * + * Returns null rather than throwing so a caller can skip a degenerate surface: + * as remaining time goes to zero the distribution collapses onto the forward + * and adjacent quantiles round to the same ratio, which the contract rejects + * as a non-increasing boundary. + */ +export function gridBoundaries(svi: Svi, forward: number): bigint[] | null { + if (!Number.isFinite(forward) || forward <= 0) return null; + const ratios: bigint[] = []; + for (let index = 1; index < GRID_BUCKETS; index += 1) { + // Bucket i is `(boundaries[i], boundaries[i + 1]]` and UP price is a + // survival function, so the boundary closing the i-th percentile from below + // is the strike with `1 - i/100` of the mass above it. + const strike = strikeAtUpPrice(svi, forward, 1 - index / GRID_BUCKETS); + if (!Number.isFinite(strike) || strike <= 0) return null; + const ratio = BigInt(Math.round((strike / forward) * RATIO_SCALE)); + if (ratio <= 0n) return null; + if (ratios.length > 0 && ratio <= ratios[ratios.length - 1]) return null; + ratios.push(ratio); + } + return ratios; +} diff --git a/packages/predict/harness/ts/keeperService.ts b/packages/predict/harness/ts/keeperService.ts index 13274c59e..76c5632b4 100644 --- a/packages/predict/harness/ts/keeperService.ts +++ b/packages/predict/harness/ts/keeperService.ts @@ -38,7 +38,8 @@ const CADENCE_IDS = Object.keys(CADENCES) .sort((a, b) => a - b); const TICK_MS = Number(process.env.KEEPER_TICK_MS ?? 15_000); const DURATION_MS = requiredNonnegativeInt("DURATION_MS"); // 0 = until killed -const MARKETS_PATH = `${requiredEnv("INSTANCE_DIR")}/markets.json`; +const INSTANCE_DIR = requiredEnv("INSTANCE_DIR"); +const MARKETS_PATH = `${INSTANCE_DIR}/markets.json`; const TRADER_ADDRESSES = definedEnv("TRADER_ADDRESSES").split(",").filter(Boolean); const TRADER_DUSDC = BigInt(requiredEnv("TRADER_DUSDC")); @@ -181,7 +182,7 @@ async function tick(feeds: Feeds, lifecycleCapId: string) { const expectedExpiry = nextDeployableExpiry(live, c, liveClock, CADENCE_IDS); if (expectedExpiry === null) continue; try { - const { marketId, expiryMs } = await createMarket(lifecycleCapId, c); + const { marketId, expiryMs } = await createMarket(lifecycleCapId, c, feeds, expectedExpiry); if (Number(expiryMs) !== expectedExpiry) { throw new Error(`keeper cadence schedule drift c${c}: expected ${expectedExpiry}, created ${expiryMs}`); } @@ -201,11 +202,12 @@ async function tick(feeds: Feeds, lifecycleCapId: string) { } // Publish only the FUNDED live markets for the trade generator (never advertise unfunded). - atomicWriteFile(MARKETS_PATH, JSON.stringify(live.filter((m) => funded.has(m.id)).map((m) => ({ id: m.id, expiryMs: m.expiryMs })))); + const advertised = live.filter((m) => funded.has(m.id)); + atomicWriteFile(MARKETS_PATH, JSON.stringify(advertised.map((m) => ({ id: m.id, expiryMs: m.expiryMs })))); } async function main() { - console.log(`[keeper] cadences=${CADENCE_IDS.join(",")} windows=${CADENCE_IDS.map((c) => CADENCES[c].windowSize).join(",")} tick=${TICK_MS}ms duration=${DURATION_MS || "∞"}ms`); + console.log(`[keeper] cadences=${CADENCE_IDS.join(",")} windows=${CADENCE_IDS.map((c) => CADENCES[c].windowSize).join(",")} tick=${TICK_MS}ms duration=${DURATION_MS || "∞"}ms inventoryImpactMaxRate=${process.env.INVENTORY_IMPACT_MAX_RATE ?? "0"}`); const { feeds, lifecycleCapId } = await setupFeedsAndConfig(CADENCE_IDS); await bootstrapPool(lifecycleCapId); for (const addr of TRADER_ADDRESSES) { diff --git a/packages/predict/harness/ts/oracleEnv.ts b/packages/predict/harness/ts/oracleEnv.ts new file mode 100644 index 000000000..2852dd82b --- /dev/null +++ b/packages/predict/harness/ts/oracleEnv.ts @@ -0,0 +1,74 @@ +// The updater-maintained oracle snapshot and its mapping onto the pricing inputs +// the contract will use. +// +// Owned here rather than in the trader so strategy resolution prices against +// the same surface the chain does; a second copy of this derivation would drift. +import { readFileSync } from "node:fs"; + +import { forwardPrice, rollDownSvi } from "./pricer.js"; +import { type Snapshot } from "./resolver.js"; + +/** `snapshot.json` as the updater writes it. */ +export interface Snap { + spot1e9: string; + bsSpot1e9: string; + publishedAtMs: string; + expiries: Record; +} + +export function readSnapshot(instanceDir: string): Snap | null { + try { + return JSON.parse(readFileSync(`${instanceDir}/snapshot.json`, "utf8")); + } catch { + return null; + } +} + +/** + * The pricing inputs for one expiry, or null if the snapshot has no usable entry. + * + * Match load_live_pricer: use Block Scholes' own signed spot for the basis + * re-anchor, then roll a/b from the ON-CHAIN batch envelope to this quote's + * wall-clock time. The updater re-signs every push under its own clamped + * envelope and writes it back as the snapshot's `publishedAtMs`, so that — not + * the upstream provider's batch timestamp, which never reaches the chain — is + * the anchor the contract will use. Using Pyth as both spots and leaving SVI at + * its anchor made near-expiry max-probability guards reject otherwise valid + * strategy quotes. + */ +export function pricerEnvFor( + snap: Snap | null, + expiryMs: number, + pricingTimestampMs: number, +): Snapshot | null { + const expiry = snap?.expiries?.[String(expiryMs)]; + if (!snap || !expiry) return null; + const svi = rollDownSvi( + { + a: expiry.svi.alpha, + b: expiry.svi.beta, + rho: expiry.svi.rho, + m: expiry.svi.m, + sigma: expiry.svi.sigma, + }, + Number(snap.publishedAtMs), + expiryMs, + pricingTimestampMs, + ); + if (!svi) return null; + return { + pythSpot: Number(snap.spot1e9) / 1e9, + bsSpot: Number(snap.bsSpot1e9) / 1e9, + bsForward: Number(expiry.forward), + svi, + }; +} + +/** The forward the contract prices this expiry against. */ +export function forwardFor(env: Snapshot): number { + return forwardPrice(env.pythSpot, env.bsSpot, env.bsForward); +} diff --git a/packages/predict/harness/ts/predictConfig.ts b/packages/predict/harness/ts/predictConfig.ts index 11a0c3dbe..af43aecb9 100644 --- a/packages/predict/harness/ts/predictConfig.ts +++ b/packages/predict/harness/ts/predictConfig.ts @@ -41,6 +41,13 @@ export const CADENCES: Record = { // Genesis bootstrap supply: 10M DUSDC (lock_capital mints min_bootstrap_liquidity itself). export const BOOTSTRAP_SUPPLY = 10_000_000_000_000n; +// Remaining life a market needs before a capacity measurement will touch it. A +// book only reaches an interesting size if it has time to be filled, and the +// settlement distribution has to be wide enough to partition: the width scales +// with the square root of remaining time, so a market minutes from expiry has +// quantiles that round onto the same raw strike. +export const FAR_MARKET_MIN_HORIZON_MS = 2 * 3_600_000; + // Resolver market params — all snapshotted from the contract defaults the market gets. // tickSize / admissionTickSize are in USD (raw / 1e9). export const RESOLVER_MARKET: MarketParams = { diff --git a/packages/predict/harness/ts/predictSetup.ts b/packages/predict/harness/ts/predictSetup.ts index 8353a63a1..eae090740 100644 --- a/packages/predict/harness/ts/predictSetup.ts +++ b/packages/predict/harness/ts/predictSetup.ts @@ -4,8 +4,10 @@ import { existsSync, readFileSync } from "node:fs"; import { atomicWriteFile } from "./io.js"; +import { gridBoundaries } from "./inventoryGrid.js"; +import { forwardFor, pricerEnvFor, readSnapshot } from "./oracleEnv.js"; import { BOOTSTRAP_SUPPLY, CADENCES } from "./predictConfig.js"; -import { requiredEnv } from "./runnerConfig.js"; +import { bigintEnv, requiredEnv } from "./runnerConfig.js"; import { POOL_VAULT_ID, PROTOCOL_CONFIG_ID, @@ -13,7 +15,9 @@ import { bareFlushTx, bindFeedsToUnderlyingTx, createAccountTx, + clockTimestampMs, createExpiryMarketTx, + createExpiryMarketWithInventoryTx, deriveAccountWrapperId, executeAndWait, lockCapitalTx, @@ -26,6 +30,7 @@ import { requestSupplyTx, setBlockScholesSignerTx, setCadenceConfigTx, + setTemplateInventoryImpactMaxRateTx, updatePythTrustedSignerTx, } from "../../devtools/ts/runtime.js"; @@ -73,19 +78,55 @@ export async function setupFeedsAndConfig(cadenceIds: number[]): Promise<{ feeds for (const cadenceId of cadenceIds) { await executeAndWait(setCadenceConfigTx({ cadenceId, ...CADENCES[cadenceId] }), `cadence-${cadenceId}`); } + // Markets snapshot the template rate at creation. The default is zero; a + // measurement arm that wants the quote walk on must set this before the first roll. + const inventoryImpactMaxRate = bigintEnv("INVENTORY_IMPACT_MAX_RATE", 0n); + await executeAndWait( + setTemplateInventoryImpactMaxRateTx(PROTOCOL_CONFIG_ID, inventoryImpactMaxRate), + "inventory-rate", + ); return { feeds, lifecycleCapId }; } -// Create one cadence market. Reads NO oracle (absolute ticks need no grid centering), -// so a keeper with a live updater needs no per-market seed — the updater warms the feed. +// Create one cadence market. Rate zero reads no oracle. A nonzero inventory +// rate pushes an off-chain 1% ladder and mass-checks it in the same create; +// the updater must already have warmed that expiry's surface (RP-24). export async function createMarket( lifecycleCapId: string, cadenceId: number, + feeds: Feeds, + expectedExpiryMs?: number, ): Promise<{ marketId: string; expiryMs: bigint }> { - const mkR = await executeAndWait( - createExpiryMarketTx({ poolVaultId: POOL_VAULT_ID, protocolConfigId: PROTOCOL_CONFIG_ID, lifecycleCapId, cadenceId }), - "create-market", - ); + const inventoryImpactMaxRate = bigintEnv("INVENTORY_IMPACT_MAX_RATE", 0n); + let createTx = createExpiryMarketTx({ + poolVaultId: POOL_VAULT_ID, + protocolConfigId: PROTOCOL_CONFIG_ID, + lifecycleCapId, + cadenceId, + }); + if (inventoryImpactMaxRate > 0n) { + if (expectedExpiryMs == null) { + throw new Error("createMarket with a nonzero inventory rate needs the deployable expiry"); + } + const clockMs = Number(await clockTimestampMs()); + const env = pricerEnvFor(readSnapshot(requiredEnv("INSTANCE_DIR")), expectedExpiryMs, clockMs); + if (!env) { + throw new Error(`no rolled-down surface for inventory grid at ${expectedExpiryMs}`); + } + const ratios = gridBoundaries(env.svi, forwardFor(env)); + if (!ratios) { + throw new Error(`degenerate inventory grid at ${expectedExpiryMs}`); + } + createTx = createExpiryMarketWithInventoryTx({ + poolVaultId: POOL_VAULT_ID, + protocolConfigId: PROTOCOL_CONFIG_ID, + lifecycleCapId, + cadenceId, + ratios, + ...feeds, + }); + } + const mkR = await executeAndWait(createTx, "create-market"); return { marketId: found(mkR, "ExpiryMarket"), expiryMs: BigInt(eventField(mkR, "MarketCreated", "expiry")) }; } diff --git a/packages/predict/harness/ts/runnerConfig.ts b/packages/predict/harness/ts/runnerConfig.ts index 3c3b1d9a5..b98153973 100644 --- a/packages/predict/harness/ts/runnerConfig.ts +++ b/packages/predict/harness/ts/runnerConfig.ts @@ -38,6 +38,21 @@ export function bigintEnv(name: string, fallback: bigint): bigint { return BigInt(value); } +/** Parse a `0`/`1` env flag, defaulting to off. + * + * Validated rather than truthiness-tested for the reason `bigintEnv` gives: a + * launcher that passes `"false"` or `"off"` would otherwise silently enable the + * feature it meant to disable. */ +export function flagEnv(name: string): boolean { + const raw = process.env[name]; + if (raw === undefined || raw === "") return false; + const value = raw.trim(); + if (value !== "0" && value !== "1") { + throw new Error(`${name} must be "0" or "1", got "${raw}"`); + } + return value === "1"; +} + export interface BudgetRung { atMs: number; budget: bigint; diff --git a/packages/predict/harness/ts/strategies/capacity.ts b/packages/predict/harness/ts/strategies/capacity.ts index fa628e925..2ebf5c40a 100644 --- a/packages/predict/harness/ts/strategies/capacity.ts +++ b/packages/predict/harness/ts/strategies/capacity.ts @@ -1,14 +1,14 @@ +import { FAR_MARKET_MIN_HORIZON_MS } from "../predictConfig.js"; import { type Instruction } from "../resolver.js"; import { type MintLeg, type Mkt, type Strategy, type StrategyCtx } from "../strategy.js"; import { errorTag, isOog } from "../trace.js"; const SCALE = 1_000_000_000n; -const TWO_HOURS_MS = 2 * 3_600_000; const MAX_BOOK = 5_000; const FUND = 20_000_000_000_000n; const GAS_BUDGET = 50_000_000_000; -export type CapacityProfile = "single" | "pool" | "tree"; +export type CapacityProfile = "single" | "pool" | "tree" | "user-off" | "user-on"; interface CapacityConfig { profile: CapacityProfile; @@ -32,15 +32,33 @@ const CONFIG: Record = { batchSize: 12, probability: [0.02, 0.98], }, + // Single-leg mints on one far market. `user-off` is the no-grid control; + // `user-on` cuts a grid and snapshots a 2% max marginal rate so the quote + // walks capital. maxCost is uncapped on both so a growing charge cannot + // abort the measurement. + "user-off": { + profile: "user-off", + batchSize: 1, + probability: [0.45, 0.6], + }, + "user-on": { + profile: "user-on", + batchSize: 1, + probability: [0.45, 0.6], + }, }; +const USER_PROFILES = new Set(["user-off", "user-on"]); +const USER_MAX_COST = 1_000_000_000_000_000n; +const USER_IMPACT_RATE = 20_000_000n; // 2%, 1e9-scaled + function farMarket(ctx: StrategyCtx): Mkt | null { const live = ctx.markets(); if (!live.length) return null; const farthest = live.reduce((left, right) => right.expiryMs > left.expiryMs ? right : left, ); - return farthest.expiryMs > Date.now() + TWO_HOURS_MS ? farthest : null; + return farthest.expiryMs > Date.now() + FAR_MARKET_MIN_HORIZON_MS ? farthest : null; } function mintLeg( @@ -118,9 +136,13 @@ export function createCapacityStrategy(profile: CapacityProfile): Strategy { return { name: `capacity-${profile}`, tickMs: profile === "tree" ? 1_200 : 1_500, - maxOps: 0, fund: FUND, gasBudget: GAS_BUDGET, + // The tree profile still drives the node count the flush hits the + // object-cache ceiling at. user-on turns the inventory-impact rate on; + // the keeper create pushes an off-chain 1% ladder. + inventoryImpactMaxRate: profile === "user-on" ? USER_IMPACT_RATE : 0n, + maxOps: USER_PROFILES.has(profile) ? 40 : 0, expect: profile === "tree" ? { @@ -149,7 +171,10 @@ export function createCapacityStrategy(profile: CapacityProfile): Strategy { market, ctx.rand(minimum, maximum), ); - if (leg) legs.push(leg); + if (leg) { + if (USER_PROFILES.has(profile)) leg.maxCost = USER_MAX_COST; + legs.push(leg); + } } if (!legs.length) return null; diff --git a/packages/predict/harness/ts/strategies/index.ts b/packages/predict/harness/ts/strategies/index.ts index da25582b2..9bad181a9 100644 --- a/packages/predict/harness/ts/strategies/index.ts +++ b/packages/predict/harness/ts/strategies/index.ts @@ -11,6 +11,8 @@ const capacity = [ createCapacityStrategy("single"), createCapacityStrategy("pool"), createCapacityStrategy("tree"), + createCapacityStrategy("user-off"), + createCapacityStrategy("user-on"), ]; const cleanup = [createCleanupStrategy("survivor")]; diff --git a/packages/predict/harness/ts/strategies/meta.ts b/packages/predict/harness/ts/strategies/meta.ts index 5034aa951..b7acf0fa4 100644 --- a/packages/predict/harness/ts/strategies/meta.ts +++ b/packages/predict/harness/ts/strategies/meta.ts @@ -1,7 +1,7 @@ // Print campaign config as JSON for the Python orchestrator, from the same source of truth as the -// runtime: per-strategy runner config (tickMs/maxOps/fund/gasBudget/requiresTimeout) + the enabled cadence set (id + period + window) +// runtime: per-strategy runner config (tickMs/maxOps/fund/gasBudget/requiresTimeout/inventoryImpactMaxRate) + the enabled cadence set (id + period + window) // that every keeper runs and the oracle grid must cover. -// { "strategies": { "": { "tickMs", "maxOps", "fund", "gasBudget", "requiresTimeout" } }, "cadences": [ { "id", "windowSize", "periodMs" } ] } +// { "strategies": { "": { "tickMs", "maxOps", "fund", "gasBudget", "requiresTimeout", "inventoryImpactMaxRate" } }, "cadences": [ { "id", "windowSize", "periodMs" } ] } import { CADENCES, CADENCE_PERIOD_MS } from "../predictConfig.js"; import { DEFAULT_TRADER_GAS_BUDGET } from "../runnerConfig.js"; import { STRATEGIES } from "./index.js"; @@ -13,6 +13,7 @@ const strategies = Object.fromEntries( fund: s.fund.toString(), gasBudget: s.gasBudget ?? DEFAULT_TRADER_GAS_BUDGET, requiresTimeout: s.maxOps === 0 && !s.done, + inventoryImpactMaxRate: (s.inventoryImpactMaxRate ?? 0n).toString(), }]), ); const cadences = Object.entries(CADENCES).map(([id, c]) => ({ diff --git a/packages/predict/harness/ts/strategy.ts b/packages/predict/harness/ts/strategy.ts index f705eb614..5cbcb6a36 100644 --- a/packages/predict/harness/ts/strategy.ts +++ b/packages/predict/harness/ts/strategy.ts @@ -10,7 +10,7 @@ // need raw control (e.g. the adversarial probe sending a deliberately-over-cap order). import { readFileSync } from "node:fs"; -import { rollDownSvi } from "./pricer.js"; +import { type Snap, pricerEnvFor, readSnapshot } from "./oracleEnv.js"; import { RESOLVER_MARKET } from "./predictConfig.js"; import { type Instruction, type Resolved, resolveMint } from "./resolver.js"; import { abortInfo, appendTrace, computationOf, gasBreakdownOf, gasOf } from "./trace.js"; @@ -35,16 +35,7 @@ export interface Mkt { id: string; expiryMs: number; } -export interface Snap { - spot1e9: string; - bsSpot1e9: string; - publishedAtMs: string; - expiries: Record; -} +export { type Snap }; export interface Held { orderId: string; marketId: string; @@ -118,6 +109,9 @@ export interface Strategy { maxOps: number; // run-to-completion target (0 = unbounded; duration-only) fund: bigint; // DUSDC the keeper should fund this strategy's trader gasBudget?: number; // MIST; raise only for measurements whose PTB must reach a protocol wall + // Template `inventory_impact_max_rate` snapshotted onto markets this arm creates. + // 1e9-scaled. Zero is the protocol default and skips the quote capital walk. + inventoryImpactMaxRate?: bigint; // Declared terminal wall(s) this stress strategy is PROBING — substrings matched by `analyze` against // abort tags and the saved failed-tx `executionErrorSource`. A framework abort that IS a declared wall // (e.g. the object-cache limit "cached objects limit", which bricks a normal flush but is the whole @@ -165,40 +159,10 @@ export function makeContext(deps: ContextDeps): StrategyCtx { let plpShares = 0n; const markets = (): Mkt[] => readJson(`${deps.instanceDir}/markets.json`) ?? []; - const snapshot = (): Snap | null => readJson(`${deps.instanceDir}/snapshot.json`); - - const envFor = (market: Mkt): { pythSpot: number; bsSpot: number; bsForward: number; svi: any } | null => { - const snap = snapshot(); - const exp = snap?.expiries?.[String(market.expiryMs)]; - if (!snap || !exp) return null; - const spot = Number(snap.spot1e9) / 1e9; - const rawSvi = { - a: exp.svi.alpha, - b: exp.svi.beta, - rho: exp.svi.rho, - m: exp.svi.m, - sigma: exp.svi.sigma, - }; - // Match load_live_pricer: use Block Scholes' own signed spot for the - // basis re-anchor, then roll a/b from the ON-CHAIN batch envelope to this - // quote's wall-clock time. The updater re-signs every push under its own - // clamped envelope and writes it back as the snapshot's `publishedAtMs`, so - // that — not the upstream provider's batch timestamp, which never reaches - // the chain — is the anchor the contract will use. Using Pyth as both spots - // and leaving SVI at its anchor made near-expiry max-probability guards - // reject otherwise valid strategy quotes. - const svi = rollDownSvi(rawSvi, Number(snap.publishedAtMs), market.expiryMs, Date.now()); - if (!svi) return null; - return { - pythSpot: spot, - bsSpot: Number(snap.bsSpot1e9) / 1e9, - bsForward: Number(exp.forward), - svi, - }; - }; + const snapshot = (): Snap | null => readSnapshot(deps.instanceDir); const resolve = (inst: Instruction, market: Mkt): Resolved | null => { - const env = envFor(market); + const env = pricerEnvFor(snapshot(), market.expiryMs, Date.now()); if (!env) return null; const r = resolveMint(inst, env, RESOLVER_MARKET); return r.feasible ? r : null; diff --git a/packages/predict/predeploy/evidence/p32-cell-array-gas-2026-08-21.md b/packages/predict/predeploy/evidence/p32-cell-array-gas-2026-08-21.md new file mode 100644 index 000000000..d0f9ce6d7 --- /dev/null +++ b/packages/predict/predeploy/evidence/p32-cell-array-gas-2026-08-21.md @@ -0,0 +1,33 @@ +# P-32 inline cell-array refresh and mint cost, 2026-08-21 + +**Item:** P-32 + +**Revision:** `8277c28cf29ae7160470ba25d140e28b5163fd3f` plus the uncommitted frozen-grid implementation, ratio boundaries, and the inline `inventory_cells` mirror. + +**Instrument:** one `capacity-tree` campaign with `KEEPER_INVENTORY_GRID=1`, retained instance `capacity-tree-aug21-004952-59098`, `--timeout 600`, real-data oracle stream, prod cadence set (1m/5m/1h, window 3). + +## Question + +[`p32-refresh-gas-2026-08-20.md`](p32-refresh-gas-2026-08-20.md) showed a tree-walking refresh aborting at ~1,000 nodes against the object-runtime cached-objects limit, with computation at 31% of the 5,000M-unit cap and unused. [`p32-cell-array-sizing-2026-08-20.md`](p32-cell-array-sizing-2026-08-20.md) sized a 2,048-cell inline mirror as a substitute that reads zero children. This run asks whether that mirror, now in source, stays inside the computation cap on initialize, mint, and refresh — including at the permitted 1,000-node book — and whether per-trade writes of the 16 KB array are grossly expensive. + +## Method + +The keeper initializes a far-market grid on the roll tick and re-cuts it every subsequent tick, tracing `compGas` on `gridInit` and `gridRefresh`. The trader is unchanged `capacity-tree`: it locks the farthest advertised market and fills distinct strikes up to `max_payout_tree_nodes`. The configured inventory-impact rate remains zero, so quotes skip the capital walk and each mint still commits the order into the cell mirror. Refresh cost is joined to tree size by timestamp on the single locked market. + +## Result: refresh at a full book is 13% of the computation cap and does not touch the object-cache ceiling + +The campaign analyzer joined 30 successful refreshes. `gridInit` on an empty book cost 40,400,000 MIST (1% of the 5,000M-unit cap). Refresh computation rose with book size as the centering walk priced one digital per distinct snapped boundary, from 226,600,000 at 96 orders (4.5%) to a peak of 625,900,000 at 1,000 orders (13%). The fit is ~390,000 per order plus a 228M base and would only reach the cap near 12,000 orders, well past `max_payout_tree_nodes`. The object-runtime cached-objects limit that stopped the tree-walking refresh at this node count did not appear on this path: the refresh loads no payout-tree children. + +Two cuts were deferred: one `inventory_grid:2` (`EInvalidBucketMass`) and one version-mismatch RPC. The mass-check abort is the verified-snapshot tolerance, not computation or object-cache; a later tick on the same market succeeded. The flush still aborted `dynamic_field:0` seven times at the C-1 ceiling, which is the tree walk this refresh no longer shares. + +## Result: per-trade writes of the 16 KB mirror are not grossly expensive + +Twelve-order mint batches averaged 460,194,936 MIST computation (9.2% of the cap, 38.3M per mint, 79 samples). At the 1,000-node ceiling the strategy steps down to two-order batches at 6,399,230 (0.13% of the cap, 3.2M per mint, 26 samples). No mint aborted on computation, object size, or object-cache. The rate is zero in this run, so these numbers are the apply-into-cells cost plus ordinary mint work, not the nonzero-rate quote walk. + +## Reading + +The cell-array refresh removes the object-cache blocker by construction and leaves a comfortable computation margin at the largest book the tree is allowed to reach. The remaining refresh abort class on this run is the mass check, already bounded by the ratio-boundary budget in [`p32-ratio-boundaries-2026-08-20.md`](p32-ratio-boundaries-2026-08-20.md). Per-trade write cost is not the gate it was feared to be at the default zero rate. Nonzero-rate quote cost (two full-lattice capital walks per quote) is unmeasured here because the configured rate is still zero. + +## Limits + +One campaign, one market, rate zero. Refresh-versus-node-count is timestamp-joined rather than read on-chain. Object size of the market after the 16 KB vector is not separately metered; it is bounded only by the fact that those transactions committed. The campaign analyzer exited fail: seven `dynamic_field:0` flush aborts plus a vacuous "declared wall never reached" because `capacity-tree` still names C-1's object-cache ceiling and gRPC left `executionErrorSource` empty, so the framework tag is not accepted as proof. That verdict is C-1's, not a refresh or mint failure. diff --git a/packages/predict/predeploy/evidence/p32-cell-array-sizing-2026-08-20.md b/packages/predict/predeploy/evidence/p32-cell-array-sizing-2026-08-20.md new file mode 100644 index 000000000..cfeec9ae5 --- /dev/null +++ b/packages/predict/predeploy/evidence/p32-cell-array-sizing-2026-08-20.md @@ -0,0 +1,70 @@ +# P-32 inline cell array as a substitute for the refresh tree read, 2026-08-20 + +**Item:** P-32 + +**Revision:** `8277c28cf29ae7160470ba25d140e28b5163fd3f` plus the uncommitted frozen-grid implementation and the ratio-boundary interface. + +**Instrument:** [`simulations/inventory_cell_array_sizing.py`](../../simulations/inventory_cell_array_sizing.py), run as `baseline` and as the four robustness variants. Exact arithmetic throughout: a book's payout profile is piecewise constant, so the profile, its expectation, the continuous worst-5% average, and the 100-bucket discretisation all have closed forms over the profile's own segments, and nothing is sampled. + +## Question + +[`p32-refresh-gas-2026-08-20.md`](p32-refresh-gas-2026-08-20.md) established that a refresh cannot complete at a full book, not because of computation but because `walk_linear` loads one dynamic-field child per payout-tree node against a 1,000-child per-transaction ceiling that coincides with `constants::max_payout_tree_nodes`. + +A `Table` entry costs one child; an inline vector costs none. The grid's own `boundaries` and `bucket_maxima` are already inline and therefore free. So: if a coarse fixed-cell copy of the payout profile were held inline beside them, a refresh could rebuild both the bucket maxima and `E` while reading zero children. + +That is only worth building if the array is small enough to hold inline and still reproduces the charge. Its resolution is fixed at market creation while the settlement distribution narrows with the square root of remaining time, so resolution per bucket degrades monotonically over a market's life. This measures whether a feasible cell count survives that. + +## Method + +Fidelity is scored against the **exact grid**, not against the continuous measure. The grid's economics are already established by the value-ceiling and flat-versus-convex work, so a cell array that reproduces the grid's per-trade charge inherits them; re-deriving the grid's own discretisation error would answer a question that is already closed. The continuous worst-5% measure is carried in one column only to show where both sit. + +Cells are geometric — uniform in log price — spanning ±4 creation standard deviations, with the two open ends absorbing anything outside. Order edges snap to the nearest cell edge. A bucket's maximum is the largest cell value among cells overlapping it, and `E` is the mass-weighted sum over cells under the current law. Books are mixed flow, 140 orders, predominantly near-the-money up/down with a minority of narrower ranges, all respecting the 1%-to-99% mint admission bound. Charges are the non-refundable convex potential difference. Each point is 40 books × 60 candidate trades in the baseline, 25 × 60 in the variants, paired so every cell count sees identical books and candidates. + +## Result: 2,048 cells reproduce the tree-read charge for almost all of a market's life + +Footprint is one `u64` of net payout per cell. Unlike the payout tree this needs no signed start/end pair, because a cell holds an absolute non-negative payout and a range order adds its quantity to each covered cell directly. + +| Cells | Inline | Centre cell | +| --- | --- | --- | +| 512 | 4 KB | 1.60 bp | +| 1,024 | 8 KB | 0.80 bp | +| 2,048 | 16 KB | 0.40 bp | + +Median relative charge error against reading the tree, on trades the tree bills: + +| Elapsed | Bucket width | 512 | 1,024 | 2,048 | +| --- | --- | --- | --- | --- | +| 0% | 2.55 bp | 2.6% | 1.3% | 0.3% | +| 25% | 2.21 bp | 2.4% | 1.2% | 0.7% | +| 50% | 1.80 bp | 3.2% | 1.7% | 0.7% | +| 75% | 1.28 bp | 3.9% | 1.6% | 0.6% | +| 90% | 0.81 bp | 5.9% | 2.8% | 1.6% | +| 99% | 0.26 bp | 12.0% | 8.2% | 5.4% | + +At 2,048 cells the per-trade charge correlation with the tree read is 0.993 or better through 90% of market life, aggregate billing lands within 1.1% of the tree's total, and the trades the array bills zero while the tree bills something are worth 0.23% of total charge or less. The degradation is monotone in both cell count and elapsed time, exactly as the sqrt-of-remaining-time argument predicts, and the binding case is always the end of life rather than any drift or shape effect. + +## Result: geometric spacing removes spot drift from the question + +Repeating the sweep with the forward moved ±2 creation standard deviations reproduces the baseline to within a thousandth on every statistic, in both directions. Uniform spacing in log price gives constant basis-point resolution across the whole span, so a bucket sees the same relative cell width wherever spot has moved to, and a cut's fidelity is scale-invariant as long as spot stays inside the span. This is the same invariance that made ratio boundaries work, arriving for the same reason. + +## Result: nearest snapping beats conservative widening + +Widening every order to the cells it touches — so the stored profile dominates the true one everywhere — is worse on every measure, not merely more expensive. At 99% elapsed and 512 cells it over-bills by 26.7% with a charge correlation of 0.684, against 0.996 and 0.889 for nearest snapping. Widening inflates the tail and `E` together and the errors do not cancel, so the conservative variant is rejected: nearest snapping is both simpler and more faithful. + +## Result: the finding holds under a fat-tailed law + +Under standardised Student-t log returns with 4 degrees of freedom, at matched total variance, 2,048 cells hold median error at 0.3%, 0.9%, 2.4% and 6.0% across the same elapsed points — within a couple of points of the lognormal figures at every horizon, and with the same ordering across cell counts. + +## Reading + +A 16 KB inline array reproduces the tree-read charge closely enough that the coordinate's established economics carry over, for every part of a market's life except roughly the last one percent, and it does so under spot drift and under a fat tail. That removes the object-cache ceiling from the refresh path by construction rather than by tuning, because the refresh would then load no per-node children at all. + +Two things are unresolved and neither is answered here. + +The per-trade cost is unmeasured. The array is rewritten on every trade as part of the market object. Sui's storage rebate means overwriting an object of unchanged size is close to cost-neutral on the storage component, so the recurring charge is expected to be serialisation rather than storage, but that is an expectation and not a measurement. This is the next gate and it belongs in a localnet campaign, not in this model. + +The last percent of market life degrades and needs a policy. At 99% elapsed even 2,048 cells sit at 5.4% median error with 4.3% of billed trades missed. The options are to accept a bounded known bias there, to stop refreshing below a remaining-time threshold and leave the market on its last good grid, or to stop charging entirely near expiry. Nothing here chooses among them. + +## Limits + +This is a parametric settlement law, so it measures how a coarse fixed discretisation diverges from an exact one under decay and drift, not how either matches BTC. It uses one book shape and one order count; thickness was not swept. It scores the charge, not delivered compensation under selective flow — the earlier freeze-policy measurement's selectivity framing was deliberately not reused here, because the question is fidelity to a coordinate whose selectivity properties are already established, and a selectivity re-run at this sample size was too noisy to discriminate between cell counts. It does not model the Move implementation's fixed-point rounding, and it does not measure gas. diff --git a/packages/predict/predeploy/evidence/p32-flat-vs-convex-2026-08-20.md b/packages/predict/predeploy/evidence/p32-flat-vs-convex-2026-08-20.md new file mode 100644 index 000000000..c225ba420 --- /dev/null +++ b/packages/predict/predeploy/evidence/p32-flat-vs-convex-2026-08-20.md @@ -0,0 +1,37 @@ +# P-32 flat-versus-convex inventory charge, 2026-08-20 + +**Item:** P-32 + +**Revision:** `8277c28cf29ae7160470ba25d140e28b5163fd3f` plus the notebook and open-item changes that record this run. + +**Instrument:** Part 9 of [`inventory_impact_value_ceiling.ipynb`](../../simulations/inventory_impact_value_ceiling.ipynb). + +## Question and registered rule + +The run compares the current capped convex potential against a flat charge on positive marginal centered capital. Flat is the implementation default because it does not need the stored absolute expected-payout level, priced refresh walk, convex scale, or RP-29 runtime response. Convexity earns that state only if, at matched aggregate inventory income, it reduces the cross-shape risk-adjusted-return spread by at least 25% without increasing the maximum per-trade charge at 100, 200, and 400 trades per market. Missing the spread threshold throughout selects flat; clearing the spread threshold while failing the charge-concentration condition is unresolved and keeps the configured rate at zero. + +## Method + +The experiment generated 240 books: three thicknesses, five clustering levels from fully dispersed to fully clustered, and 16 seeds. Within each thickness and seed, every clustering level reused identical quantities, widths, independent placement draws, and clustered placement draws; clustering changed only which placement each trade selected. + +Charges used the implementation-shaped 100-bucket coordinate: each bucket represented 1% of probability mass, held the maximum payout over its 20 fine cells, and the coordinate subtracted fine-cell expected payout. Risk-adjusted return used true centered 95% expected shortfall over all 2,000 cells as its denominator. + +The convex maximum marginal rate was 2% and its scale swept 0.10, 0.25, 0.50, 1.00, and 2.00 times median final grid capital at 50% clustering for each thickness. At every thickness and scale, the flat rate was fitted to collect the same aggregate inventory income as convexity across all clustering levels and seeds. Results therefore compare where an equal aggregate trader charge lands rather than comparing nominal rate labels. + +## Result + +The best spread reduction at every thickness occurred when the convex scale equaled median central-book grid capital. + +- At 100 trades, baseline return spread was 6.99x, flat reduced it to 3.77x, and convex reduced it to 2.72x. Convex improved the spread 28.0% relative to flat, with paired-seed mean 28.0% and standard error 0.5%. Fully clustered return was 0.0212 under flat and 0.0245 under convex. The p95 charge was $19.19 under flat and $21.30 under convex; the maximum was $99.27 under flat and $173.56 under convex. +- At 200 trades, baseline return spread was 10.25x, flat reduced it to 5.25x, and convex reduced it to 3.81x. Convex improved the spread 27.4% relative to flat, with paired-seed mean 27.6% and standard error 0.3%. Fully clustered return was 0.0213 under flat and 0.0247 under convex. The p95 charge was $20.03 under flat and $22.02 under convex; the maximum was $162.45 under flat and $278.13 under convex. +- At 400 trades, baseline return spread was 12.61x, flat reduced it to 6.47x, and convex reduced it to 4.70x. Convex improved the spread 27.4% relative to flat, with paired-seed mean 27.5% and standard error 0.3%. Fully clustered return was 0.0210 under flat and 0.0244 under convex. The p95 charge was $21.14 under flat and $23.64 under convex; the maximum was $143.44 under flat and $207.19 under convex. + +At those scales the matched flat rate was 1.144%, 1.168%, and 1.153% as thickness increased. Aggregate inventory income was 85.2%, 82.8%, and 80.8% of ordinary fee income. + +## Decision + +**UNRESOLVED.** Convexity clears the registered 25% materiality threshold consistently and with small paired-seed error, so the performance loss from flat charging is real: at the central 200-trade setting the return spread worsens from 3.81x to 5.25x and fully clustered return falls from 0.0247 to 0.0213. Convexity also raises the maximum individual charge at every tested thickness, failing the registered charge-concentration condition. P-32 remains open and the configured rate remains zero pending an explicit policy choice between simpler state and stronger compensation targeting. + +## Limits + +The flow is generated rather than production replay. The experiment covers opens only, assumes no behavioral response, uses equal-probability grid boundaries with no staleness, and evaluates real-valued arithmetic rather than Move rounding. Equal aggregate income does not equalize every trader-facing cost statistic; that difference is the measured trade-off. This run does not measure refresh gas, choose a rate, or decide whether the registered maximum-charge condition is the correct product constraint. diff --git a/packages/predict/predeploy/evidence/p32-ratio-boundaries-2026-08-20.md b/packages/predict/predeploy/evidence/p32-ratio-boundaries-2026-08-20.md new file mode 100644 index 000000000..18bb54274 --- /dev/null +++ b/packages/predict/predeploy/evidence/p32-ratio-boundaries-2026-08-20.md @@ -0,0 +1,60 @@ +# P-32 forward-relative grid boundaries, 2026-08-20 + +**Item:** P-32 + +**Revision:** `8277c28cf29ae7160470ba25d140e28b5163fd3f` plus the uncommitted frozen-grid implementation and the ratio boundary interface this record measures. + +**Instrument:** the contract-faithful fixed-point mirror in `simulations/python_replay.py`, scored over the captured oracle snapshot from the `capacity-tree-aug20-154939-53024` instance, plus two paired Move tests in `tests/strike_exposure/inventory_impact_tests.move`. + +**Supersedes:** the "off-chain boundaries are not operable against a live pricer" conclusion in `p32-refresh-gas-2026-08-20.md`. That record's measurement stands as taken; the absolute-price interface it measured no longer exists. + +## Question + +The earlier run found that supplying absolute equal-mass boundaries from off-chain aborts `EInvalidBucketMass` for most cuts, because the check leaves roughly 0.25 basis points of spot drift between the generator's snapshot and the observation the contract prices against. Does expressing the boundaries relative to the forward remove that race, and what error remains if it does? + +## Why it should + +`pricing::compute_nd2` forms log-moneyness as `k = math::ln(strike).sub(ln_forward)` and every term after it — the SVI total variance, `d2`, the slope correction — is a function of `k` and the SVI parameters alone. A boundary's probability therefore depends on the strike only through its ratio to the forward. Fix the ratios and a forward move cannot change any bucket's mass. + +## Method + +The generator's absolute ladder for the longest-dated expiry in the captured snapshot (2.75 hours out, forward $72,668.11) was converted to 1e9-scaled ratios against the forward the contract's own integer arithmetic resolves. Both ladders were then scored under the fixed-point mirror at a drifted spot: the absolute ladder as-is, and the ratio ladder re-materialized against the drifted forward the way `inventory_grid::materialized_ladder` does. Tolerance is 100,000 against a 10,000,000 target. + +## Result: spot drift stops mattering + +| Spot drift | Move on the forward | Absolute: buckets outside tolerance | Ratio: worst error | Ratio: buckets outside tolerance | +| --- | --- | --- | --- | --- | +| 0 | $0 | 0 | 108 | 0 | +| 0.25 bp | $1.82 | 1 | 181 | 0 | +| 1 bp | $7.27 | 47 | 143 | 0 | +| 5 bp | $36.33 | 89 | 188 | 0 | +| 50 bp | $363.34 | 99 | 149 | 0 | +| 250 bp | $1,816.70 | 100 | 160 | 0 | + +The ratio ladder's error stays between 108 and 196 at every drift tested, which is the fixed-point residue of the ratio round trip rather than a function of drift. It does not grow with the move, and there is no drift at which a bucket leaves tolerance. + +## Result: the remaining budget is time, and it is tens of seconds + +With ratios, the only surviving error source is the SVI roll-down between the generator pricing the surface and the transaction executing. Holding the ladder fixed and advancing the pricing timestamp on the same market: + +| Submission delay | Worst bucket error | Verdict | +| --- | --- | --- | +| 0 | 120 | passes | +| 1 s | 3,340 | passes | +| 5 s | 16,537 | passes | +| 10 s | 33,034 | passes | +| 30 s | 98,901 | passes | +| 60 s | 197,348 | 4 buckets outside tolerance | + +The error grows about 3,300 per second, so the cut has roughly 30 seconds of budget on this market. Repeating each delay at 0, 10, and 50 basis points of drift changes the worst error by less than 20 units, so the two error sources are independent and drift contributes nothing. + +The operational consequence is a change of regime rather than a wider margin: the old budget was a quarter of a basis point of spot, which is gone in well under a second and cannot be met by submitting faster, and the new one is tens of seconds of wall clock on a keeper that already runs a per-tick loop. + +## Pinning tests + +- `one_ratio_ladder_stays_valid_after_the_forward_moves` initializes a grid, moves the resolved forward 1% by republishing the Block Scholes forward under an unchanged clock, and refreshes with the identical ratios. +- `the_same_move_invalidates_a_ladder_of_absolute_prices` rescales those ratios so they materialize back to the pre-move absolute prices and asserts `EInvalidBucketMass`. Without it the first test would pass on a mass check that never had teeth. + +## Limits + +The far-market budget above is measured on one 2.75-hour surface. Roll-down error scales with how fast remaining time is shrinking, so shorter cadences have proportionally less time budget; this record does not establish where the shortest workable cadence sits, and the grid lane's two-hour restriction is unchanged. The measurement is a fixed-point replay of a captured surface, not a live campaign, and no campaign has yet been re-run against the ratio interface. Nothing here bears on the object-runtime cached-objects ceiling from the superseded record, which remains the binding constraint on refreshing a market at its maximum tree size. diff --git a/packages/predict/predeploy/evidence/p32-refresh-gas-2026-08-20.md b/packages/predict/predeploy/evidence/p32-refresh-gas-2026-08-20.md new file mode 100644 index 000000000..71bfc46fd --- /dev/null +++ b/packages/predict/predeploy/evidence/p32-refresh-gas-2026-08-20.md @@ -0,0 +1,54 @@ +# P-32 inventory-grid refresh cost, 2026-08-20 + +**Item:** P-32 + +**Revision:** `8277c28cf29ae7160470ba25d140e28b5163fd3f` plus the uncommitted frozen-grid implementation, the two pricing constant-factor fixes, and the harness grid lane that records this run. + +**Instrument:** two independent `capacity-tree` campaigns with `KEEPER_INVENTORY_GRID=1`, retained instances `capacity-tree-aug20-154939-53024` and `capacity-tree-aug20-154712-46999`, each `--timeout 1500`, real-data oracle stream, prod cadence set (1m/5m/1h, window 3), keeper budget 15,000,000,000 MIST. + +## Question + +P-32 asked for the computation cost of one `refresh_inventory_grid` at a full book, measured against the 5,000M-unit per-transaction computation cap, on the assumption that the refresh's distinct dynamic-field footprint is bounded by the payout-tree node count the NAV flush already meets. Neither `initialize_inventory_grid` nor `refresh_inventory_grid` had a TypeScript caller, so the run also required a boundary generator and PTB builders. + +## Method + +The keeper cuts a grid for every market at least two hours from expiry on the tick it is rolled, then re-cuts the same markets each tick, tracing the computation cost of each call. The trader is unchanged `capacity-tree`: it locks the farthest market and fills its payout tree with distinct strikes. Refresh cost is joined to tree size per market by timestamp, because no on-chain read reports a tree's node count. Every mint in this run used a distinct strike, so tree nodes and orders are the same number. + +Boundaries are the 1%-to-99% quantiles of the settlement distribution, produced by log-space bisection on the float SVI port in `harness/ts/pricer.ts`. A float generator is adequate for a check the contract performs in fixed point: scoring float-derived boundaries with the contract-faithful mirror in `simulations/python_replay.py` puts the worst bucket-mass error at 141 to 5,472 of 1e9 across every expiry in a captured snapshot, against a tolerance of 100,000. + +## Result: computation is not the constraint + +- `initialize_inventory_grid` on an empty book costs 11,200,000 MIST, or 0.2% of the 5,000,000,000 MIST cap. It only verifies the 101-boundary probability ladder, so it does not grow with the book. +- Refresh computation against tree size: 333,200,000 at 96 nodes, 521,100,000 at 204, 1,150,000,000 at 636, 1,415,000,000 at 864, 1,533,000,000 at 952, and 1,561,000,000 at 972. The largest success is 31% of the computation cap. +- The least-squares fit is 1,384,090 MIST per node on a 226,491,152 MIST base, which would reach the computation cap at about 3,448 nodes. + +The second campaign reproduces this on a separate localnet: the same 11,200,000 initialize, 353,400,000 at 108 nodes rising to 1,532,000,000 at 956, a fitted 1,348,057 per node on a 260,934,893 base, and a projected crossing at about 3,515 nodes. Both runs peak at 31% of the cap, and the two slopes agree within 3%. + +That crossing is never reached in either run. From roughly 1,000 nodes the refresh fails instead — 48 times in the first campaign at a recorded computation cost of 1,588,000,000 MIST, 32% of the cap, so the transaction died with about two thirds of its computation budget unused. + +## Result: the failure is the object-cache ceiling, and it coincides with the tree's own cap + +The failure surfaces as `0x2::dynamic_field::borrow_child_object` with abort code 0. Attribution needs care, because that same framework location carries two very different causes: the object-runtime cached-objects limit of 1,000 dynamic-field children per transaction, and a genuine missing dynamic field. The dry-run `executionErrorSource` that normally separates them is empty for every artifact in this run (see Limits), so the attribution rests on three other pieces of evidence. + +- A missing field is ruled out for this code path. `refresh_reads_every_boundary_of_a_many_node_payout_tree` runs the same refresh over a 200-node tree and returns the analytically known tail — every one of the five tail buckets equals the whole book, because each order is `(lower, +inf]` and every lower tick sits below the fifth-highest boundary. The same test at 500 nodes exhausts the Move test VM's own step budget rather than aborting. `sui move test` does not enforce the object-cache limit, so a structural read error would have appeared at any size and did not. +- The 22 keeper flush failures in this same run have the identical shape and the same empty error source, and the flush at a large tree is the established C-1 object-cache wall recorded in `c1-object-cache-flush-2026-07-07.md`. +- The refresh's distinct-child count is dominated by `walk_linear` loading every node of the tree, so its footprint reaches 1,000 children at almost exactly the observed failure point. +- Both campaigns fail the same way at the same size, 70 and 73 occurrences, so this is a reproducible property of the call rather than one localnet's state. + +The consequence is structural rather than a matter of tuning. `constants::max_payout_tree_nodes` is 1,000, so the cache ceiling and the largest tree the contract will accept are the same number, and the refresh additionally loads the pricer's oracle children and the transaction's base children. A market at its permitted maximum therefore cannot be refreshed in a single transaction, and reducing computation per node does not move a ceiling that counts distinct children loaded rather than work done per child. + +## Result: off-chain boundaries are not operable against a live pricer + +`inventory_grid:2` (`EInvalidBucketMass`) aborted 23 cuts in the first campaign and 16 in the second, on markets whose grid the same generator had already cut successfully. + +The cause is spot movement between the snapshot the generator reads and the oracle observation the contract prices against when the transaction executes. Scoring a fixed boundary ladder at a drifted spot on the captured 2.91-hour surface, the first bucket leaves tolerance at about 0.25 basis points of drift, roughly $1.81 on a $72,584 forward, and 46 of 100 buckets are outside tolerance at one basis point. The budget is that small because an equal-mass bucket is narrow: at that horizon the central 1% bucket spans $13.73, or 1.89 basis points of the forward. Spot moved several basis points per second in this run, so most cuts missed. + +Clock skew is second-order by comparison. Holding spot fixed and moving only the pricing timestamp, the far markets stay inside tolerance at ten seconds of skew (39,022 worst error) while 1m-cadence markets fail at two seconds — which is why the grid lane is restricted to markets at least two hours out, and why near-expiry cadences cannot be gridded from off-chain boundaries at this tolerance at all. + +## Limits + +The dry-run `executionErrorSource` was absent from all 100 saved artifacts in the first run, including the flush failures whose cause is already established. The gRPC dry-run instead returns a structured `status.error` with `cleverError: "[Undefined]"`, and `runtime.ts` reads only `executionErrorSource`, so the plain-English VM cause that the harness rules identify as the thing that makes these aborts legible was unavailable. That is a harness regression, not a finding about the grid, and it is why the object-cache attribution above is corroborated rather than read directly. It also drove both campaigns to FAIL with `VACUOUS: declared wall 'cached objects limit' never reached` — the wall was reached in both runs and simply could not be evidenced, because the analyzer only admits `capacity-tree`'s declared wall when an artifact proves it. + +Roughly 40% of cuts in each run were deferred by localnet RPC contention rather than by the contract (`provided version doesn't match`, validator rejections), an artifact of a keeper re-cutting every eligible market every tick. Those are excluded from the cost curve and do not bear on the ceiling. + +This measures one refresh shape: 100 buckets, one market per cut, tree nodes as the only growing child, and a keeper re-cutting every tick, which is denser than any cadence a production keeper would run. It does not measure the per-trade quote and apply cost at a nonzero rate, which is a separate and much smaller path, and it does not measure a chunked refresh. The reported per-node slope is fitted over six attributed samples spanning 96 to 972 nodes and is used only to show that the computation cap is not what binds. The `inventory_grid:2` aborts are a property of supplying boundaries from off-chain against a live pricer and say nothing about the charge formula. diff --git a/packages/predict/predeploy/evidence/p32-user-mint-rate-on-2026-08-21.md b/packages/predict/predeploy/evidence/p32-user-mint-rate-on-2026-08-21.md new file mode 100644 index 000000000..855a39ac1 --- /dev/null +++ b/packages/predict/predeploy/evidence/p32-user-mint-rate-on-2026-08-21.md @@ -0,0 +1,27 @@ +# P-32 user-path mint computation with inventory-impact rate on, 2026-08-21 + +**Item:** P-32 + +**Revision:** `73a24e3a596a6b8b5815234e8093ceaa38525086` plus the uncommitted harness A/B (`capacity-user-off` / `capacity-user-on`) and the template-rate setter used to snapshot 2% onto the treatment markets. + +**Instrument:** one campaign of `capacity-user-off` and `capacity-user-on` in parallel, retained instances `capacity-user-off-aug21-105246-22262` and `capacity-user-on-aug21-105246-22262`, `--timeout 300`, real-data oracle stream, prod cadence set (1m/5m/1h, window 3). Each arm submitted 40 single-leg mint PTBs on one far market. The treatment keeper cut a grid and snapshotted `inventory_impact_max_rate = 20_000_000` (2%) before the first roll. + +## Question + +[`p32-cell-array-gas-2026-08-21.md`](p32-cell-array-gas-2026-08-21.md) priced initialize, refresh, and mint at the default-zero rate, so quotes skipped the capital walk. This run asks what a user mint actually costs when the rate is on: two 100-bucket `capital()` / `span_max` scans at quote plus the cell-array write at commit, compared to the same single-leg mint with no grid. + +## Method + +Both arms use the same ATM-ish probability band `[0.45, 0.6]`, the same $5–10 spend, the same 1.5s tick, and `max_cost` large enough that a growing charge cannot abort the measurement. `user-off` advertises funded markets with no grid and rate 0. `user-on` advertises only after `gridInit` and markets snapshot the 2% template. Analyzer `compGas` is `effects.gasUsed.computationCost` on each one-call PTB. + +## Result: turning the rate on adds ~21M computation per mint (0.42% of the cap) + +Forty single-leg mints on each arm. Off: mean 1,559,750 MIST (min 1,530,000, max 1,580,000). On: mean 22,427,500 MIST (min 19,500,000, max 25,300,000). Increment 20,867,750 MIST, 0.42% of the 5,000,000,000 computation cap, about 14× the ordinary mint. The on-path cost is noisy but not a steep function of book size across these 40 orders (first mint 21.5M, last 22.4M), which matches a quote that always walks 100 buckets. Keeper work on the treatment arm is separate: `gridInit` 40,700,000 on an empty book; five refreshes 75.6M → 105M as the book grew. No mint aborted. Campaign verdict clean. + +## Reading + +A live mint with the illustrative 2% rate stays far inside the per-tx computation cap. The user increment is the quote walk plus the cell write, not keeper refresh. This does not calibrate `r_K` or decide whether a nonzero rate should ship. + +## Limits + +One campaign, 40 mints per arm, books of 40 ATM-width orders. Close, multi-leg PTBs, and a 1,000-node wing-filled book are unmeasured. Charge cash amounts were not scored; only computation was. diff --git a/packages/predict/predeploy/open-items.md b/packages/predict/predeploy/open-items.md index 6a479cb08..d16cf3d7e 100644 --- a/packages/predict/predeploy/open-items.md +++ b/packages/predict/predeploy/open-items.md @@ -1,6 +1,6 @@ # Predict Predeploy Open Items -Updated 2026-08-17. This is the live work register governed by the [predeploy lifecycle and update rules](./README.md#lifecycle). +Updated 2026-08-21. This is the live work register governed by the [predeploy lifecycle and update rules](./README.md#lifecycle). ## Deploy Gates @@ -434,6 +434,32 @@ is what distinguishes this from a genuinely quiet feed. If the observed margin is thin, decide the response deliberately — a bounded tolerance on the comparison is a `response-policies.md` decision, not a silent widening. +### P-32: Implement frozen-grid inventory impact + +**Severity:** Predeploy mechanism work; the shipped rate remains zero. + +The landed mechanism is [D034](../docs/design/decisions.md). Source of truth is [`inventory_grid.move`](../sources/strike_exposure/inventory_grid.move) and [`inventory_cells.move`](../sources/strike_exposure/inventory_cells.move): the keeper inverts a 99-rung `strike / forward` ladder off-chain and the create path mass-checks it in the same transaction, later quotes rematerialize those ratios against the live forward and the frozen SVI shape, `K` is scored from the inline 2,048-cell payout mirror, and every risk-increasing mint or close pays `max(0, phi(K_after) − phi(K_before))` as ordinary expiry cash. A charged mint or quote without a grid aborts. + +`L` stays the settlement and early-exit reserve and is not stacked into the inventory fee. The admin knobs are `inventory_impact_scale` (`B_K`, default $1,000 DUSDC) and `inventory_impact_max_rate` (`r_K`, default 0); calibration of both is still open and the configured rate remains zero. + +**Implementation acceptance:** The create-time mass check and every later rematerialization verify each bucket in `[0.99%, 1.01%]`; mints and closes charge when they raise `K` and never go negative; splitting one order into many collects the same total; payout backing is unchanged; and both the trading path and the create-time check stay within gas, object-access, and object-size limits. + +**Landed measurements.** These records stay as taken; several describe keeper-refresh or off-chain-cut interfaces that source has since removed. + +- [`p32-ratio-boundaries-2026-08-20.md`](evidence/p32-ratio-boundaries-2026-08-20.md) — absolute-price rungs fail the mass check after one basis point of spot drift; `strike / forward` ratios do not. Source now takes those ratios from an off-chain invert and mass-checks them at create. +- [`p32-cell-array-sizing-2026-08-20.md`](evidence/p32-cell-array-sizing-2026-08-20.md) and [`p32-cell-array-gas-2026-08-21.md`](evidence/p32-cell-array-gas-2026-08-21.md) — the 2,048-cell inline mirror replaces a payout-tree walk. The refresh gas numbers in those records are historical; `refresh_inventory_grid` was removed. +- [`p32-user-mint-rate-on-2026-08-21.md`](evidence/p32-user-mint-rate-on-2026-08-21.md) — a 2% rate adds about 21M MIST (0.42% of the cap) to a single-leg mint. Keeper init and refresh legs in that record are historical. +- [`p32-refresh-gas-2026-08-20.md`](evidence/p32-refresh-gas-2026-08-20.md) — historical tree-walk refresh cost. The object-cache ceiling it measured is why the cell mirror exists; it is not a remaining gate. + +**Remaining work:** Two things still gate a nonzero rate. + +1. *Liquidation policy.* Decide whether a risk-increasing liquidation deducts an available inventory charge from liquidation proceeds or is explicitly treated as uncompensated forced risk. +2. *Calibration and charge shape.* `B_K` derivation in frozen-grid capital units and `r_K` calibration remain open, and the capped convex potential must still earn its extra state against a flat charge on marginal centered capital. + + **MEASURED 2026-08-20 — unresolved.** [`p32-flat-vs-convex-2026-08-20.md`](evidence/p32-flat-vs-convex-2026-08-20.md) records 240 paired generated books. At matched aggregate inventory income and the best tested scale, convexity reduced the return spread relative to flat by 28.0%, 27.4%, and 27.4% at 100, 200, and 400 trades, clearing the 25% threshold with paired-seed standard errors of 0.3–0.5 points. It also raised the maximum per-trade charge at every thickness, so it failed the charge-concentration condition. The central 200-trade result is the policy frontier: flat produced a 5.25x return spread and $162 maximum charge; convex produced 3.81x and $278. The configured rate remains zero until the simpler state versus stronger targeting trade-off is decided. + +**Output contract:** Land `B_K` derivation and `r_K` calibration, and resolve the flat-versus-convex policy call, before enabling a nonzero rate. Resolve this item with exactly one grid formula and no additional `L`-based taker charge. + ## Access and Governance ### G-1: Root admin caps have no on-chain revocation or rotation diff --git a/packages/predict/predeploy/response-policies.md b/packages/predict/predeploy/response-policies.md index 673b0d393..09c531487 100644 --- a/packages/predict/predeploy/response-policies.md +++ b/packages/predict/predeploy/response-policies.md @@ -1565,7 +1565,7 @@ worth-fixing. - **Trigger state:** not a runtime state. `move.md` requires that deleting or weakening a guard is preceded by an inventory of what it *incidentally* bounded, recorded here rather than only in a commit message. Removing DEEP staking and the trading-loss rebate deleted one solvency term and ten error constants; this entry is that inventory. - **Controller:** protocol (all of these guard protocol-written state). -- **The `+ rebate_reserve` term in `expiry_cash::required_cash` (and its mirror in `free_cash`).** Stated purpose: an expiry must hold cash for rebates it may owe on top of its payout liability. Incidental duty: it made expiry cash *strictly* conservative — the reserve was cash the market could not sweep, so it also absorbed any rounding gap between the payout liability and the actual settled payout, and it kept the settled sweep from returning cash that a later claim would need. Both duties die with the claim: no flow pays out of that reserve any more, the settled payout is derived from the same packed atoms as the liability (R1 bit-equal pairing, unchanged), and the surviving `assert_backing` still requires `balance >= payout_liability + inventory_impact_reserve` after every cash movement. The removal *loosens* what the market may sweep by exactly the amount that used to fund rebates, which is the intended behavior change; it does not loosen what backs a winner's payout. +- **The `+ rebate_reserve` term in `expiry_cash::required_cash` (and its mirror in `free_cash`).** Stated purpose: an expiry must hold cash for rebates it may owe on top of its payout liability. Incidental duty: it made expiry cash *strictly* conservative — the reserve was cash the market could not sweep, so it also absorbed any rounding gap between the payout liability and the actual settled payout, and it kept the settled sweep from returning cash that a later claim would need. Both duties die with the claim: no flow pays out of that reserve any more, the settled payout is derived from the same packed atoms as the liability (R1 bit-equal pairing, unchanged), and the surviving `assert_backing` still requires `balance >= payout_liability + inventory_impact_reserve` after every cash movement (D034 later removed the escrow term, leaving `balance >= payout_liability`). The removal *loosens* what the market may sweep by exactly the amount that used to fund rebates, which is the intended behavior change; it does not loosen what backs a winner's payout. - **`expiry_cash::ERebateBasisExceedsFee` (`rebate_fee_basis <= fee.value()`).** Stated purpose: the caller cannot designate more rebate basis than the cash it is delivering. Incidental duty: it was the trust boundary on the one two-argument fee-collection call — the only place a caller could inflate a liability without delivering the cash to back it. The two-argument form is gone; fee cash now joins through `receive`, which takes only the balance, so the inflatable argument no longer exists. - **`expiry_cash::EUnresolvedTradingFeesUnderflow` (`unresolved_trading_fees_paid >= trading_fees_paid`).** Stated purpose: a claim cannot resolve more fee basis than the expiry has unresolved. Incidental duty: it was the cross-object consistency proof between the account's per-expiry summary and the market's running basis — a double-resolve or a summary/market drift aborted here. Both sides of that pair are deleted. - **`predict_account::EExpirySummaryHasOpenPositions`.** Stated purpose: an account's rebate resolves only once every position in that expiry is closed. Incidental duty: it was the ordering gate that made the claim's fee basis final. The claim is gone; nothing else reads a per-expiry aggregate. @@ -1579,3 +1579,17 @@ worth-fixing. - **Reopen when:** a loss rebate, a fee-basis reserve, or any other per-account liability funded out of expiry cash is reintroduced — the backing term and its resolve-side underflow guard come back together, and the settled sweep must hold the reserve back again. --- + + +## RP-29: `inventory_grid::EExpectedPayoutUnderflow` deleted — duty inventory (2026-08-20) + +- **Trigger state:** not a runtime state. `move.md` requires an inventory of what a deleted guard *incidentally* bounded. The cell-mirror integral and per-span floors disagree by rounding dust, so a named underflow assert on the centering term would abort an ordinary close; this entry is that inventory. +- **Controller:** protocol. +- **The guard.** `quote_close` asserted `expected_delta <= grid.frozen_expected_payout` before subtracting a close's expected payout from the grid's running centering term. Stated purpose: the running term cannot go negative. Incidental duty: it aborted with a named code rather than at the raw `u64` subtraction one frame deeper, and while opens and closes derived their delta from the same grouping it was also an equality proof that the sum of the parts fit the whole. +- **Why it had to go.** The cell-mirror integral floors at each payout change point, while opens accumulate a per-span floor each. The two groupings legitimately disagree by rounding dust, so the last close out of a book can derive marginally more than the grid holds. Keeping the assert would abort that close — a liveness failure on the exit path, caused by rounding, with no economic content. +- **Response:** the subtraction saturates at zero in both `quote_change` and `apply_change`, a close that empties the mirror clears the term outright, and the constant is deleted. The surviving bound is the accounting identity, not an assert: the term is only a centering subtrahend inside `capital_from_components`, which already floors at zero, and understating it can only *raise* `K`. So the failure mode the guard prevented (a wrapped term collapsing `K` to nothing) is unreachable, and the residual error errs toward charging. +- **Risk profile:** `BEST-GUESS` — the dust bound is a rounding argument over the two grouping schemes, not a measured quantity. It is atoms wide per boundary and cannot accumulate across a drained book, because emptying the mirror clears the term outright. +- **Pinning tests:** `inventory_impact_tests.move` — `draining_a_seeded_book_clears_expected_payout` (drains a seeded book and requires the term to reach zero rather than abort). +- **Reopen when:** the centering term is ever read as a payable quantity rather than a subtrahend. + +--- diff --git a/packages/predict/simulations/src/sim.ts b/packages/predict/simulations/src/sim.ts index 26c04a0a3..1149997a5 100644 --- a/packages/predict/simulations/src/sim.ts +++ b/packages/predict/simulations/src/sim.ts @@ -424,6 +424,9 @@ function normalizeOrderMinted(event: any, orderRef: string | null): Record= min_inventory_impact_scale!() && value <= max_inventory_impact_scale!(), + EInvalidInventoryImpactScale, + ); +} + // === Pricing === public(package) macro fun default_base_fee(): u64 { 20_000_000 } diff --git a/packages/predict/sources/config/protocol_config.move b/packages/predict/sources/config/protocol_config.move index 2925d1324..5845007fc 100644 --- a/packages/predict/sources/config/protocol_config.move +++ b/packages/predict/sources/config/protocol_config.move @@ -134,7 +134,7 @@ public fun set_template_backing_buffer_lambda( } /// Set the maximum marginal inventory-impact rate snapshotted by newly created -/// expiry markets. `0` (the default) disables both charges and rebates. +/// expiry markets. `0` (the default) disables the charge. public fun set_template_inventory_impact_max_rate( config: &mut ProtocolConfig, _admin_cap: &AdminCap, @@ -144,6 +144,16 @@ public fun set_template_inventory_impact_max_rate( config.strike_exposure_template_config.set_inventory_impact_max_rate(value); } +/// Set the frozen-grid capital scale snapshotted by newly created expiry markets. +public fun set_template_inventory_impact_scale( + config: &mut ProtocolConfig, + _admin_cap: &AdminCap, + value: u64, +) { + config.assert_version(); + config.strike_exposure_template_config.set_inventory_impact_scale(value); +} + /// Set the minimum raw entry probability snapshotted by newly created expiry markets. public fun set_template_min_entry_probability( config: &mut ProtocolConfig, diff --git a/packages/predict/sources/config/strike_exposure_config.move b/packages/predict/sources/config/strike_exposure_config.move index c25d08273..f1f0071ee 100644 --- a/packages/predict/sources/config/strike_exposure_config.move +++ b/packages/predict/sources/config/strike_exposure_config.move @@ -35,9 +35,12 @@ public struct StrikeExposureConfig has store { expiry_fee_window_ms: u64, /// Fee multiplier reached at expiry, in FLOAT_SCALING; 1x disables the ramp. expiry_fee_max_multiplier: u64, - /// Maximum marginal rate of the path-independent inventory-impact curve, in - /// FLOAT_SCALING. `0` disables both charges and rebates. + /// Maximum marginal rate of the frozen-grid inventory-impact curve, in + /// FLOAT_SCALING. `0` disables the charge. inventory_impact_max_rate: u64, + /// Capital scale `B` of that curve, in DUSDC base units. Marginal rate + /// reaches `inventory_impact_max_rate` at this `K` and stays capped above it. + inventory_impact_scale: u64, } // === Public-Package Functions === @@ -74,6 +77,10 @@ public(package) fun inventory_impact_max_rate(config: &StrikeExposureConfig): u6 config.inventory_impact_max_rate } +public(package) fun inventory_impact_scale(config: &StrikeExposureConfig): u64 { + config.inventory_impact_scale +} + /// Returns the raw trade fee for a live probability and quantity, rounded down so the trader keeps sub-unit dust. /// /// Precondition: `timestamp_ms < expiry_ms`. Live-pricing callers enforce this @@ -128,6 +135,7 @@ public(package) fun new(): StrikeExposureConfig { expiry_fee_window_ms: config_constants::default_expiry_fee_window_ms!(), expiry_fee_max_multiplier: config_constants::default_expiry_fee_max_multiplier!(), inventory_impact_max_rate: config_constants::default_inventory_impact_max_rate!(), + inventory_impact_scale: config_constants::default_inventory_impact_scale!(), } } @@ -142,6 +150,7 @@ public(package) fun snapshot(config: &StrikeExposureConfig): StrikeExposureConfi expiry_fee_window_ms: config.expiry_fee_window_ms, expiry_fee_max_multiplier: config.expiry_fee_max_multiplier, inventory_impact_max_rate: config.inventory_impact_max_rate, + inventory_impact_scale: config.inventory_impact_scale, } } @@ -187,6 +196,11 @@ public(package) fun set_inventory_impact_max_rate(config: &mut StrikeExposureCon config.inventory_impact_max_rate = value; } +public(package) fun set_inventory_impact_scale(config: &mut StrikeExposureConfig, value: u64) { + config_constants::assert_inventory_impact_scale(value); + config.inventory_impact_scale = value; +} + /// Return the 1e9-scaled per-unit trade fee. /// /// Precondition: `timestamp_ms < expiry_ms`; callers must enforce pre-expiry diff --git a/packages/predict/sources/events/config_events.move b/packages/predict/sources/events/config_events.move index 484f8f0e8..93d1d1b71 100644 --- a/packages/predict/sources/events/config_events.move +++ b/packages/predict/sources/events/config_events.move @@ -45,6 +45,8 @@ public struct MarketCreated has copy, drop, store { expiry_fee_max_multiplier: u64, /// Maximum marginal inventory-impact rate snapshotted by this market. inventory_impact_max_rate: u64, + /// Frozen-grid capital scale `B` snapshotted by this market, in DUSDC. + inventory_impact_scale: u64, } /// Emitted when an admin updates or disables one underlying's cadence policy. @@ -132,6 +134,7 @@ public(package) fun emit_market_created( expiry_fee_window_ms: strike_exposure_config.expiry_fee_window_ms(), expiry_fee_max_multiplier: strike_exposure_config.expiry_fee_max_multiplier(), inventory_impact_max_rate: strike_exposure_config.inventory_impact_max_rate(), + inventory_impact_scale: strike_exposure_config.inventory_impact_scale(), }); } @@ -197,3 +200,8 @@ public(package) fun emit_market_settled( settled_at_ms, }); } + +#[test_only] +public fun market_created_inventory(event: &MarketCreated): (u64, u64) { + (event.inventory_impact_max_rate, event.inventory_impact_scale) +} diff --git a/packages/predict/sources/events/order_events.move b/packages/predict/sources/events/order_events.move index cfbac9b6a..a76de03e5 100644 --- a/packages/predict/sources/events/order_events.move +++ b/packages/predict/sources/events/order_events.move @@ -37,8 +37,13 @@ public struct OrderMinted has copy, drop, store { builder_fee: u64, /// EWMA gas-price congestion surcharge retained by the pool, in DUSDC base units. penalty_fee: u64, - /// Separate inventory-impact charge escrowed for live-close rebates. + /// Inventory-impact charge kept as ordinary expiry cash. Zero when the rate is off + /// or the trade does not raise frozen-grid capital. inventory_impact_charge: u64, + /// Frozen-grid capital before this mint, in DUSDC. Zero when the rate is off. + k_before: u64, + /// Frozen-grid capital after this mint, in DUSDC. + k_after: u64, /// Builder credited for `builder_fee`; `none` when no builder fee was paid /// (attribution follows the fee — applied once, in the emit helper). builder_code_id: Option, @@ -71,8 +76,14 @@ public struct LiveOrderRedeemed has copy, drop, store { builder_fee: u64, /// EWMA gas-price congestion surcharge retained by the pool, in DUSDC base units. penalty_fee: u64, - /// Separate inventory-impact rebate paid from its isolated escrow. - inventory_impact_rebate: u64, + /// Inventory-impact charge when the close raises frozen-grid capital. Zero when + /// the close lowers it; there is no rebate. + inventory_impact_charge: u64, + /// Frozen-grid capital before this close, in DUSDC. Zero when the rate is off + /// or no grid exists. + k_before: u64, + /// Frozen-grid capital after this close, in DUSDC. + k_after: u64, /// Builder credited for `builder_fee`; `none` when no builder fee was paid /// (attribution follows the fee — applied once, in the emit helper). builder_code_id: Option, @@ -114,6 +125,8 @@ public(package) fun emit_order_minted( builder_fee: u64, penalty_fee: u64, inventory_impact_charge: u64, + k_before: u64, + k_after: u64, minted_at_ms: u64, ) { event::emit(OrderMinted { @@ -132,6 +145,8 @@ public(package) fun emit_order_minted( builder_fee, penalty_fee, inventory_impact_charge, + k_before, + k_after, builder_code_id: if (builder_fee == 0) option::none() else builder_code_id, minted_at_ms, pyth_spot_source_timestamp_ms: pricer.pyth_spot_source_timestamp_ms(), @@ -155,7 +170,9 @@ public(package) fun emit_live_order_redeemed( trading_fee: u64, builder_fee: u64, penalty_fee: u64, - inventory_impact_rebate: u64, + inventory_impact_charge: u64, + k_before: u64, + k_after: u64, redeemed_at_ms: u64, ) { event::emit(LiveOrderRedeemed { @@ -171,7 +188,9 @@ public(package) fun emit_live_order_redeemed( trading_fee, builder_fee, penalty_fee, - inventory_impact_rebate, + inventory_impact_charge, + k_before, + k_after, builder_code_id: if (builder_fee == 0) option::none() else builder_code_id, redeemed_at_ms, pyth_spot_source_timestamp_ms: pricer.pyth_spot_source_timestamp_ms(), @@ -200,3 +219,13 @@ public(package) fun emit_settled_order_redeemed( redeemed_at_ms, }); } + +#[test_only] +public fun order_minted_inventory(event: &OrderMinted): (u64, u64, u64) { + (event.inventory_impact_charge, event.k_before, event.k_after) +} + +#[test_only] +public fun live_order_redeemed_inventory(event: &LiveOrderRedeemed): (u64, u64, u64) { + (event.inventory_impact_charge, event.k_before, event.k_after) +} diff --git a/packages/predict/sources/expiry_cash.move b/packages/predict/sources/expiry_cash.move index 6727bdfbc..9fd0da040 100644 --- a/packages/predict/sources/expiry_cash.move +++ b/packages/predict/sources/expiry_cash.move @@ -1,56 +1,35 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -/// Expiry-local DUSDC custody and isolated reserve accounting. +/// Expiry-local DUSDC custody. /// -/// This leaf owns cash balance arithmetic and the inventory-impact escrow used -/// only for live-close rebates. It does not decide payment eligibility, pool -/// allocation, or market phase sequencing; `ExpiryMarket` owns those policies. +/// This leaf owns cash balance arithmetic for one expiry market. It does not +/// decide payment eligibility, pool allocation, or market phase sequencing; +/// `ExpiryMarket` owns those policies. module deepbook_predict::expiry_cash; use dusdc::dusdc::DUSDC; use sui::balance::{Self, Balance}; const EInsufficientCash: u64 = 0; -const EInventoryImpactRebateExceedsReserve: u64 = 1; /// Cash custody for one expiry market. public struct ExpiryCash has store { cash_balance: Balance, - /// Collected inventory-impact charges still reserved for live-close rebates. - inventory_impact_reserve: u64, } /// Create zero-cash expiry custody. public(package) fun new(): ExpiryCash { - ExpiryCash { - cash_balance: balance::zero(), - inventory_impact_reserve: 0, - } + ExpiryCash { cash_balance: balance::zero() } } public(package) fun balance(cash: &ExpiryCash): u64 { cash.cash_balance.value() } -public(package) fun inventory_impact_reserve(cash: &ExpiryCash): u64 { - cash.inventory_impact_reserve -} - -/// Return the cash required to cover payout liability plus the impact escrow. -public(package) fun required_cash(cash: &ExpiryCash, payout_liability: u64): u64 { - payout_liability + cash.inventory_impact_reserve -} - -/// Return cash net of the inventory-impact escrow, floored at zero. Pool NAV -/// values this amount separately from payout liability. -public(package) fun free_cash(cash: &ExpiryCash): u64 { - cash.balance().saturating_sub(cash.inventory_impact_reserve) -} - -/// Abort unless current cash covers payout liability plus the impact escrow. +/// Abort unless current cash covers payout liability. public(package) fun assert_backing(cash: &ExpiryCash, payout_liability: u64) { - assert!(cash.balance() >= cash.required_cash(payout_liability), EInsufficientCash); + assert!(cash.balance() >= payout_liability, EInsufficientCash); } /// Join incoming expiry cash without interpreting why the caller is sending it. @@ -58,14 +37,14 @@ public(package) fun receive(cash: &mut ExpiryCash, funds: Balance) { cash.cash_balance.join(funds); } -/// Release caller-approved surplus while preserving payout and escrow backing. +/// Release caller-approved surplus while preserving payout backing. public(package) fun release_surplus( cash: &mut ExpiryCash, amount: u64, payout_liability: u64, ): Balance { if (amount == 0) return balance::zero(); - assert!(cash.balance() >= cash.required_cash(payout_liability) + amount, EInsufficientCash); + assert!(cash.balance() >= payout_liability + amount, EInsufficientCash); cash.cash_balance.split(amount) } @@ -77,25 +56,3 @@ public(package) fun pay_authorized(cash: &mut ExpiryCash, amount: u64): Balance< assert!(cash.balance() >= amount, EInsufficientCash); cash.cash_balance.split(amount) } - -/// Reserve a charge already received with the mint payment. It remains part of -/// `cash_balance`, but cannot be swept or counted in NAV while live. -public(package) fun credit_inventory_impact_reserve(cash: &mut ExpiryCash, amount: u64) { - cash.inventory_impact_reserve = cash.inventory_impact_reserve + amount; -} - -/// Pay an inventory-impact rebate exclusively from its isolated escrow. -public(package) fun pay_inventory_impact_rebate( - cash: &mut ExpiryCash, - amount: u64, -): Balance { - assert!(amount <= cash.inventory_impact_reserve, EInventoryImpactRebateExceedsReserve); - cash.inventory_impact_reserve = cash.inventory_impact_reserve - amount; - cash.pay_authorized(amount) -} - -/// Release the residual inventory-impact escrow after settlement, when no live -/// close can earn another rebate. Its cash then becomes normal expiry surplus. -public(package) fun release_inventory_impact_reserve(cash: &mut ExpiryCash) { - cash.inventory_impact_reserve = 0; -} diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 569bfda1c..97f557015 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -47,6 +47,7 @@ const EMintRedeemSameTimestamp: u64 = 6; const ERedeemProbabilityBelowMin: u64 = 7; const ERedeemProceedsBelowMin: u64 = 8; const EMintCostCapRequired: u64 = 9; +const ERedeemCostAboveMax: u64 = 10; /// Per-expiry market state. public struct ExpiryMarket has key { @@ -73,8 +74,8 @@ public struct ExpiryMarket has key { /// fill. `trading_fee` is the trading fee before the sponsor subsidy, and /// `all_in_cost` is the resulting account withdrawal: /// `premium + (trading_fee - fee_incentive_subsidy) + builder_fee + penalty_fee -/// + inventory_impact_charge`. Inventory impact is isolated from every ordinary -/// fee policy because it is escrowed for risk-reducing live closes. +/// + inventory_impact_charge`. Inventory impact is ordinary expiry cash, not a +/// trading-fee component, and is never rebated. public struct MintQuote has copy, drop { quantity: u64, entry_probability: u64, @@ -84,6 +85,8 @@ public struct MintQuote has copy, drop { builder_fee: u64, penalty_fee: u64, inventory_impact_charge: u64, + k_before: u64, + k_after: u64, all_in_cost: u64, } @@ -126,10 +129,10 @@ public fun cash_balance(market: &ExpiryMarket): u64 { market.cash.balance() } -/// Return the isolated inventory-impact escrow for SDK and devInspect state -/// reads. -public fun inventory_impact_reserve(market: &ExpiryMarket): u64 { - market.cash.inventory_impact_reserve() +/// Return the current frozen-grid inventory-impact potential for SDK and +/// devInspect state reads. +public fun inventory_impact_potential(market: &ExpiryMarket): u64 { + market.strike_exposure.inventory_impact_potential() } /// Return local fee incentives for SDK and devInspect state reads. @@ -164,6 +167,11 @@ public fun inventory_impact_scale(market: &ExpiryMarket): u64 { market.strike_exposure.inventory_impact_scale() } +/// True once this market has a mass-checked 1% inventory ladder. +public fun has_inventory_grid(market: &ExpiryMarket): bool { + market.strike_exposure.has_inventory_grid() +} + /// Return the strike tick size for SDK and devInspect range construction. Raw /// strikes are `tick * tick_size`. public fun tick_size(market: &ExpiryMarket): u64 { @@ -192,7 +200,7 @@ public fun payout_liability(market: &ExpiryMarket): u64 { /// Return required expiry cash for external accounting observability. public fun required_cash(market: &ExpiryMarket): u64 { - market.cash.required_cash(market.payout_liability()) + market.payout_liability() } /// Load a PTB-local live pricing snapshot for this market. @@ -229,16 +237,30 @@ public fun load_live_pricer( ) } -/// Return live marked NAV as free expiry cash minus the exposure book's marked +/// Persist a supplied 1% ladder after the mass check. Rate zero and a +/// later call are no-ops. The keeper create-with-inventory path is the +/// usual producer; this is the fallback for an already-shared market. +public fun provision_inventory_grid( + market: &mut ExpiryMarket, + config: &ProtocolConfig, + pricer: &Pricer, + ratios: vector, +) { + config.assert_version(); + market.assert_pricer_bound(pricer); + market.strike_exposure.install_inventory_grid(pricer, ratios); +} + +/// Return live marked NAV as expiry cash minus the exposure book's marked /// liability, floored at zero. This read requires a market-bound pre-expiry /// `Pricer`; an expired but unsettled market cannot be valued through this path. /// Public for PTB composition and devInspect pool valuation. public fun current_nav(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); let liability = market.strike_exposure.live_marked_liability(pricer); - // Marked liability and free cash are computed through different rounded + // Marked liability and cash are computed through different rounded // aggregates; negative marked NAV is represented as zero. - market.cash.free_cash().saturating_sub(liability) + market.cash.balance().saturating_sub(liability) } /// Return one live order's full-close range value before fees. Requires a @@ -378,6 +400,16 @@ public fun inventory_impact_charge(quote: &MintQuote): u64 { quote.inventory_impact_charge } +/// Frozen-grid capital before the quoted mint, in DUSDC. +public fun k_before(quote: &MintQuote): u64 { + quote.k_before +} + +/// Frozen-grid capital after the quoted mint, in DUSDC. +public fun k_after(quote: &MintQuote): u64 { + quote.k_after +} + /// Return the total quoted account withdrawal for SDK and devInspect consumers. public fun all_in_cost(quote: &MintQuote): u64 { quote.all_in_cost @@ -488,12 +520,11 @@ public fun mint_exact_amount( /// `redeem_settled`. /// Returns a replacement order ID only when a partial close leaves quantity open. /// -/// Two close-side slippage floors, the mirror of mint's `max_probability` / -/// `max_cost` pair; pass `0` to disable either. `min_probability` floors the +/// Close-side slippage guards mirror mint's bounds. `min_probability` floors the /// quoted per-contract range probability (same units as mint's `max_probability`). /// `min_proceeds` floors the all-in net DUSDC credited to the account -/// (`redeem_amount` minus trading fee, builder fee, and EWMA penalty), the mirror -/// of mint's all-in `max_cost`. +/// after fees and inventory transfers. `max_cost` caps any additional account +/// withdrawal required when a hedge-removing close costs more than its proceeds. public fun redeem_live( market: &mut ExpiryMarket, wrapper: &mut AccountWrapper, @@ -504,6 +535,7 @@ public fun redeem_live( close_quantity: u64, min_probability: u64, min_proceeds: u64, + max_cost: u64, root: &AccumulatorRoot, clock: &Clock, ctx: &mut TxContext, @@ -518,6 +550,7 @@ public fun redeem_live( close_quantity, min_probability, min_proceeds, + max_cost, root, clock, ctx, @@ -653,9 +686,6 @@ public fun try_settle( if (spot.is_none()) return false; let settlement_price = spot.destroy_some(); market.strike_exposure.record_settlement(settlement_price); - // Live-close rebates are no longer reachable after settlement. Release the - // residual inventory-impact escrow so the settled sweep returns it to LPs. - market.cash.release_inventory_impact_reserve(); config_events::emit_market_settled( market.id(), market.propbook_underlying_id, @@ -704,13 +734,12 @@ public(package) fun release_pool_cash(market: &mut ExpiryMarket, amount: u64): B released_cash } -/// Release settled cash above payout liability and the impact escrow. +/// Release settled cash above payout liability. public(package) fun release_settled_pool_cash(market: &mut ExpiryMarket): Balance { let settled_liability = market.payout_liability(); - let reserved_cash = market.cash.required_cash(settled_liability); market.cash.assert_backing(settled_liability); - let returned_cash_amount = market.cash.balance() - reserved_cash; + let returned_cash_amount = market.cash.balance() - settled_liability; market.release_pool_cash(returned_cash_amount) } @@ -727,13 +756,78 @@ public(package) fun create_and_share( tick_size: u64, admission_tick_size: u64, reference_tick_source_timestamp_ms: u64, - inventory_impact_scale: u64, ctx: &mut TxContext, ): ID { + share_new( + new_market( + config, + propbook_underlying_id, + expiry, + tick_size, + admission_tick_size, + reference_tick_source_timestamp_ms, + ctx, + ), + ) +} + +/// Create, mass-check a supplied inventory ladder, and share. +/// +/// The check runs before `share_object`, so it is the same transaction as +/// creation. Rate zero leaves the grid empty. A ladder that fails the 1 bp +/// mass check aborts the create. +public(package) fun create_and_share_with_inventory( + config: &ProtocolConfig, + propbook_registry: &OracleRegistry, + pyth: &PythFeed, + bs_values: &BlockScholesValueStore, + bs_svi: &BlockScholesSVIStore, + propbook_underlying_id: u32, + expiry: u64, + tick_size: u64, + admission_tick_size: u64, + reference_tick_source_timestamp_ms: u64, + ratios: vector, + clock: &Clock, + ctx: &mut TxContext, +): ID { + let mut market = new_market( + config, + propbook_underlying_id, + expiry, + tick_size, + admission_tick_size, + reference_tick_source_timestamp_ms, + ctx, + ); + let pricer = pricing::load_live_pricer( + config.pricing_config(), + propbook_registry, + pyth, + bs_values, + bs_svi, + market.id(), + market.propbook_underlying_id, + market.expiry, + clock, + ctx, + ); + market.strike_exposure.install_inventory_grid(&pricer, ratios); + share_new(market) +} + +fun new_market( + config: &ProtocolConfig, + propbook_underlying_id: u32, + expiry: u64, + tick_size: u64, + admission_tick_size: u64, + reference_tick_source_timestamp_ms: u64, + ctx: &mut TxContext, +): ExpiryMarket { let id = object::new(ctx); let expiry_market_id = id.to_inner(); - let strike_exposure_config = config.strike_exposure_config_snapshot(); - let market = ExpiryMarket { + ExpiryMarket { id, propbook_underlying_id, expiry, @@ -741,16 +835,19 @@ public(package) fun create_and_share( fee_incentive_balance: balance::zero(), strike_exposure: strike_exposure::new( expiry_market_id, - strike_exposure_config, + config.strike_exposure_config_snapshot(), tick_size, admission_tick_size, reference_tick_source_timestamp_ms, - inventory_impact_scale, ctx, ), ewma: ewma::new(ctx), mint_paused: false, - }; + } +} + +fun share_new(market: ExpiryMarket): ID { + let expiry_market_id = market.id(); transfer::share_object(market); expiry_market_id } @@ -796,6 +893,7 @@ fun mint_prepared( clock: &Clock, ctx: &mut TxContext, ): u256 { + market.strike_exposure.assert_inventory_grid_ready(); let terms = market .strike_exposure .quote_mint_terms( @@ -814,7 +912,14 @@ fun mint_prepared( assert!(quote.all_in_cost <= max_cost, EMintCostAboveMax); let minted_order = market.strike_exposure.allocate_mint_order(terms); - market.settle_mint_payment(account, &minted_order, "e, builder_code_id, clock, ctx); + market.settle_mint_payment( + account, + &minted_order, + "e, + builder_code_id, + clock, + ctx, + ); order_events::emit_order_minted( market.id(), account.account_id(), @@ -829,6 +934,8 @@ fun mint_prepared( quote.builder_fee, quote.penalty_fee, quote.inventory_impact_charge, + quote.k_before, + quote.k_after, clock.timestamp_ms(), ); minted_order.id() @@ -867,6 +974,8 @@ fun compute_mint_quote( builder_fee, penalty_fee, inventory_impact_charge, + k_before: terms.k_before(), + k_after: terms.k_after(), all_in_cost, } } @@ -907,11 +1016,9 @@ fun settle_mint_payment( let builder_fee_payment = payment.split(quote.builder_fee); send_builder_fee(builder_code_id, builder_fee_payment); // The fee, its sponsor-funded subsidy, the premium, the penalty and the - // inventory impact all land in the same custody; the impact amount is - // earmarked separately once its cash has arrived. + // inventory impact all land in the same custody. payment.join(market.fee_incentive_balance.split(quote.fee_incentive_subsidy)); market.cash.receive(payment); - market.cash.credit_inventory_impact_reserve(quote.inventory_impact_charge); market.assert_cash_backing(); } @@ -927,6 +1034,7 @@ fun redeem_live_with_auth( close_quantity: u64, min_probability: u64, min_proceeds: u64, + max_cost: u64, root: &AccumulatorRoot, clock: &Clock, ctx: &mut TxContext, @@ -934,7 +1042,6 @@ fun redeem_live_with_auth( wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); let order = order::from_order_id(order_id); - let terms = market.strike_exposure.quote_live_close(pricer, &order, close_quantity); // Block an atomic mint -> oracle-update -> redeem: reject closing a position // in the same timestamp it was opened. A single transaction reads one @@ -947,6 +1054,7 @@ fun redeem_live_with_auth( order.id(), ); assert!(clock.timestamp_ms() != opened_at_ms, EMintRedeemSameTimestamp); + let terms = market.strike_exposure.quote_live_close(pricer, &order, close_quantity); // Charge against the pre-trade EWMA distribution, then fold this gas price. let penalty_amount = market.ewma_penalty(config.ewma_config(), close_quantity, clock, ctx); @@ -980,17 +1088,17 @@ fun redeem_live_with_auth( close_quantity, ).min(redeem_amount - fee_amount); let penalty_amount = penalty_amount.min(redeem_amount - fee_amount - builder_fee_amount); - let inventory_impact_rebate = terms.inventory_impact_rebate(); + let inventory_impact_charge = terms.live_close_inventory_impact_charge(); + let k_before = terms.live_close_k_before(); + let k_after = terms.live_close_k_after(); + let deductions = fee_amount + builder_fee_amount + penalty_amount + inventory_impact_charge; + let additional_cost = deductions.saturating_sub(redeem_amount); + let net_proceeds = redeem_amount.saturating_sub(deductions); + assert!(additional_cost <= max_cost, ERedeemCostAboveMax); // Close-side all-in slippage floor: the net credited to the account is - // `redeem_amount` plus inventory rebate, minus fee, builder fee, and - // penalty. `0` disables. Mirror of mint's `max_cost`. - assert!( - redeem_amount + inventory_impact_rebate - - fee_amount - - builder_fee_amount - - penalty_amount >= min_proceeds, - ERedeemProceedsBelowMin, - ); + // `redeem_amount` minus fee, builder fee, penalty, and inventory impact. + // `0` disables. Mirror of mint's `max_cost`. + assert!(net_proceeds >= min_proceeds, ERedeemProceedsBelowMin); // Apply book and account-position mutations only after all close policy // checks. Any later abort rolls back the earlier EWMA update. @@ -1019,7 +1127,8 @@ fun redeem_live_with_auth( fee_amount, builder_fee_amount, penalty_amount, - inventory_impact_rebate, + inventory_impact_charge, + additional_cost, builder_code_id, ctx, ); @@ -1038,7 +1147,9 @@ fun redeem_live_with_auth( fee_amount, builder_fee_amount, penalty_amount, - inventory_impact_rebate, + inventory_impact_charge, + k_before, + k_after, clock.timestamp_ms(), ); replacement_order_id @@ -1086,10 +1197,10 @@ fun redeem_settled_with_auth( /// Settle a live redeem per an already-computed payment decomposition: pay out /// `redeem_amount`, route the fee and builder fee, and credit the account with -/// the remainder plus the isolated inventory-impact rebate. The caller owns the -/// decomposition and the `min_proceeds` guard. +/// the remainder. The caller owns the decomposition and the `min_proceeds` +/// guard. /// -/// The EWMA penalty is withheld from the payout and kept in expiry cash +/// The EWMA penalty and the inventory-impact charge stay in expiry cash /// as surplus. fun settle_live_redeem_payment( market: &mut ExpiryMarket, @@ -1098,22 +1209,26 @@ fun settle_live_redeem_payment( fee_amount: u64, builder_fee_amount: u64, penalty_amount: u64, - inventory_impact_rebate: u64, + inventory_impact_charge: u64, + additional_cost: u64, builder_code_id: Option, ctx: &mut TxContext, ) { - // The penalty stays in expiry cash, so it is never withdrawn: pay out net of it. + // The penalty stays in expiry cash, so it is never withdrawn. let mut payout = market.cash.pay_authorized(redeem_amount - penalty_amount); - payout.join(market.cash.pay_inventory_impact_rebate(inventory_impact_rebate)); + if (additional_cost > 0) { + payout.join(account.withdraw(additional_cost, ctx).into_balance()); + }; let fee = payout.split(fee_amount); let builder_fee = payout.split(builder_fee_amount); + let inventory_impact = payout.split(inventory_impact_charge); market.cash.receive(fee); + market.cash.receive(inventory_impact); send_builder_fee(builder_code_id, builder_fee); market.assert_cash_backing(); account.deposit(payout.into_coin(ctx)); } -// --- Shared by the mint and redeem flows --- /// Compute the congestion surcharge from pre-trade EWMA state, then fold the /// current gas price into the estimate. fun ewma_penalty( @@ -1149,8 +1264,10 @@ fun send_builder_fee(builder_code_id: Option, fee: Balance) { fun assert_cash_backing(market: &ExpiryMarket) { market.cash.assert_backing(market.payout_liability()); - assert!( - market.cash.inventory_impact_reserve() - >= market.strike_exposure.inventory_impact_potential(), - ); } + +#[test_only] +public(package) fun ensure_inventory_grid_for_testing(market: &mut ExpiryMarket, pricer: &Pricer) { + market.strike_exposure.ensure_inventory_grid(pricer); +} + diff --git a/packages/predict/sources/predict_account.move b/packages/predict/sources/predict_account.move index 60bc1e49c..fbca16f5b 100644 --- a/packages/predict/sources/predict_account.move +++ b/packages/predict/sources/predict_account.move @@ -127,7 +127,15 @@ public(package) fun add_position( let d = data_mut(account, ctx); let key = position_key(expiry_market_id, order_id); assert!(!d.positions.contains(key), EPositionAlreadyExists); - d.positions.add(key, Position { root_id: position_root_id, opened_at_ms }); + d + .positions + .add( + key, + Position { + root_id: position_root_id, + opened_at_ms, + }, + ); } /// Remove an order position and return its root order ID for event attribution. diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 9d4fba123..7f9da4195 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -25,6 +25,14 @@ use sui::clock::Clock; public struct Pricer has copy, drop { /// Expiry market this snapshot was loaded for. expiry_market_id: ID, + /// Natural log of the forward, 1e9-scaled with sign. Pricing reads the forward + /// only as `ln(strike) - ln(forward)`, so the log is taken once per pricer rather + /// than once per strike: a 100-bucket grid verification and a full payout-tree + /// walk would otherwise repeat it at every boundary they price. + ln_forward: I64, + /// The forward itself, 1e9-scaled. Pricing does not need it, but a caller + /// supplying forward-relative strikes does: the inventory grid materializes its + /// absolute boundary ladder against this exact value. forward: u64, svi: PricingSVI, /// Timestamps of the oracle observations this snapshot validated, as trade events report @@ -61,7 +69,7 @@ public struct RawSVI has copy, drop { /// a full raw unit — which a short-dated surface cannot afford, because its whole /// total variance is only about ten raw units at 1e9. Keeping the rolled values at /// 1e18 hands `variance_sqrt_and_d2` the same domain it already computes in. -public struct PricingSVI has copy, drop { +public struct PricingSVI has copy, drop, store { /// Rolled-down SVI `a`, magnitude at 1e18, sign in `a_is_negative`. a_magnitude: u128, a_is_negative: bool, @@ -72,6 +80,19 @@ public struct PricingSVI has copy, drop { sigma: u64, } +/// Immutable pricing snapshot used only by one market's frozen inventory grid. +/// +/// The SVI is the shape freeze. `forward` / `ln_forward` are the creation +/// anchor used to verify the ratio ladder; a quote rematerializes those ratios +/// against the live forward and prices the shape at that live `ln_forward`. +public struct FrozenPricer has copy, drop, store { + /// Carried as a log for the reason given on `Pricer.ln_forward`. + ln_forward: I64, + /// The forward the shape was verified against, 1e9-scaled. + forward: u64, + svi: PricingSVI, +} + const EZeroForward: u64 = 0; const ECannotBeNegative: u64 = 1; const ENonPositiveVariance: u64 = 2; @@ -114,13 +135,13 @@ macro fun max_svi_input(): u64 { 100 * math::float_scaling!() } /// Return the current UP digital probability for a typed strike. Public PTB and /// devInspect reads can compose it with a transaction-local `Pricer`. public fun up_price(pricer: &Pricer, strike: Strike): u64 { - compute_up_price(&pricer.svi, pricer.forward, strike) + compute_up_price(&pricer.svi, &pricer.ln_forward, strike) } /// Return the current probability for `(lower, higher]`, floored at zero if the /// two approximated boundary probabilities invert. public fun range_price(pricer: &Pricer, lower: Strike, higher: Strike): u64 { - compute_range_price(&pricer.svi, pricer.forward, lower, higher) + compute_range_price(&pricer.svi, &pricer.ln_forward, lower, higher) } // === Public-Package Functions === @@ -130,6 +151,15 @@ public(package) fun expiry_market_id(pricer: &Pricer): ID { pricer.expiry_market_id } +/// Return the forward this pricer resolved, 1e9-scaled. +public(package) fun forward(pricer: &Pricer): u64 { + pricer.forward +} + +public(package) fun ln_forward(pricer: &Pricer): I64 { + pricer.ln_forward +} + public(package) fun pyth_spot_source_timestamp_ms(pricer: &Pricer): u64 { pricer.pyth_spot_source_timestamp_ms } @@ -146,6 +176,48 @@ public(package) fun block_scholes_svi_source_timestamp_ms(pricer: &Pricer): u64 pricer.block_scholes_svi_source_timestamp_ms } +/// Freeze the probability inputs from a validated market-bound live pricer. +public(package) fun snapshot_for_inventory(pricer: &Pricer): FrozenPricer { + FrozenPricer { + ln_forward: pricer.ln_forward, + forward: pricer.forward, + svi: pricer.svi, + } +} + +/// The creation-time forward the shape snapshot was verified against. +public(package) fun frozen_forward(pricer: &FrozenPricer): u64 { + pricer.forward +} + +public(package) fun frozen_ln_forward(pricer: &FrozenPricer): I64 { + pricer.ln_forward +} + +/// Frozen SVI shape priced at the live forward. Bucket masses in moneyness are +/// unchanged; dollar rungs slide with `live`. +public(package) fun inventory_view(shape: &FrozenPricer, live: &Pricer): FrozenPricer { + FrozenPricer { + ln_forward: live.ln_forward, + forward: live.forward, + svi: shape.svi, + } +} + +/// Return an immutable snapshot's UP digital probability for `strike`. +/// +/// Adjacent ranges over one snapshot share a boundary, so a caller pricing a whole +/// partition should difference this ladder itself rather than call +/// `frozen_range_price` per cell, which prices every interior boundary twice. +public(package) fun frozen_up_price(pricer: &FrozenPricer, strike: Strike): u64 { + compute_up_price(&pricer.svi, &pricer.ln_forward, strike) +} + +/// Return an immutable snapshot's probability for `(lower, higher]`. +public(package) fun frozen_range_price(pricer: &FrozenPricer, lower: Strike, higher: Strike): u64 { + compute_range_price(&pricer.svi, &pricer.ln_forward, lower, higher) +} + /// Scale one 1e9-scaled SVI magnitude down by the fraction of anchored time /// remaining, returning it at 1e18. /// @@ -380,9 +452,15 @@ fun resolve_live_pricer( // spot bounds still guarantee this multiplication and result fit in u64. forward = math::mul_div_down(spot, bs_forward, bs_spot); }; + // `assert_inputs_pricing_safe` bounds the Block Scholes forward away from zero, + // but the re-anchor above is a flooring divide that can land on zero from + // in-envelope inputs. Guarded here rather than at the first digital because the + // log is taken once, and `math::ln` would otherwise abort with its own code. + assert!(forward > 0, EZeroForward); Pricer { expiry_market_id, + ln_forward: math::ln(forward), forward, svi, pyth_spot_source_timestamp_ms, @@ -495,18 +573,18 @@ fun min_svi_variance_increment(svi: &RawSVI): u64 { } /// Compute the approximated probability for `(lower, higher]`. -fun compute_range_price(svi: &PricingSVI, forward: u64, lower: Strike, higher: Strike): u64 { +fun compute_range_price(svi: &PricingSVI, ln_forward: &I64, lower: Strike, higher: Strike): u64 { assert!(lower.value() < higher.value(), EInvalidRange); - let lower_up_price = compute_up_price(svi, forward, lower); - let higher_up_price = compute_up_price(svi, forward, higher); + let lower_up_price = compute_up_price(svi, ln_forward, lower); + let higher_up_price = compute_up_price(svi, ln_forward, higher); // Fixed-point approximation or a non-monotone SVI surface can invert the // boundary prices; the range probability is floored at zero. lower_up_price.saturating_sub(higher_up_price) } /// Compute the adjusted UP digital probability for `strike`. -fun compute_up_price(svi: &PricingSVI, forward: u64, strike: Strike): u64 { +fun compute_up_price(svi: &PricingSVI, ln_forward: &I64, strike: Strike): u64 { if (strike.is_neg_inf()) { return math::float_scaling!() }; @@ -514,7 +592,7 @@ fun compute_up_price(svi: &PricingSVI, forward: u64, strike: Strike): u64 { return 0 }; - compute_nd2(svi, forward, strike.value()) + compute_nd2(svi, ln_forward, strike.value()) } /// Binary pricing from SVI total variance: @@ -522,9 +600,7 @@ fun compute_up_price(svi: &PricingSVI, forward: u64, strike: Strike): u64 { /// - w(k) = a + b * (rho * (k - m) + sqrt((k - m)^2 + sigma^2)) /// - d2 = -((k + w(k) / 2) / sqrt(w(k))) /// - price = N(d2) - phi(d2) * w'(k) / (2 * sqrt(w(k))) -fun compute_nd2(svi_params: &PricingSVI, forward: u64, strike: u64): u64 { - assert!(forward > 0, EZeroForward); - +fun compute_nd2(svi_params: &PricingSVI, ln_forward: &I64, strike: u64): u64 { // Log-moneyness as a DIFFERENCE of logarithms, never as `ln` of a fixed-point // ratio. Forming `strike * 1e9 / forward` first destroys exactly the tails it // is asked about: the quotient floors to zero once `strike` is a billionth of @@ -541,7 +617,7 @@ fun compute_nd2(svi_params: &PricingSVI, forward: u64, strike: u64): u64 { // term rather than a relative error that grows without bound as the tail // deepens. No strike needs a special case, and no surface has to be restricted // to keep a shortcut honest. - let k = math::ln(strike).sub(&math::ln(forward)); + let k = math::ln(strike).sub(ln_forward); let m = svi_params.m; let k_minus_m = k.sub(&m); let k_minus_m_squared = k_minus_m.square_scaled(); diff --git a/packages/predict/sources/registry/registry.move b/packages/predict/sources/registry/registry.move index d405a71d5..377486c49 100644 --- a/packages/predict/sources/registry/registry.move +++ b/packages/predict/sources/registry/registry.move @@ -21,7 +21,11 @@ use deepbook_predict::{ plp::PoolVault, protocol_config::{Self, ProtocolConfig} }; -use propbook::registry::OracleRegistry; +use propbook::{ + block_scholes_store::{BlockScholesSVIStore, BlockScholesValueStore}, + pyth_feed::PythFeed, + registry::OracleRegistry +}; use sui::{clock::Clock, vec_set::{Self, VecSet}}; const EPauseCapNotValid: u64 = 0; @@ -261,32 +265,90 @@ public fun create_and_share_expiry_market( tick_size, admission_tick_size, reference_tick_source_timestamp_ms, - max_expiry_allocation, ctx, ); - pool_vault.register_expiry( + record_created_market( + registry, + pool_vault, + config, expiry_market_id, + pool_vault_id, + propbook_underlying_id, + cadence_id, expiry, + tick_size, + admission_tick_size, max_expiry_allocation, initial_expiry_cash, clock, - ); - registry + ) +} + +/// Create the next deployable market and mass-check a supplied inventory +/// ladder in the same transaction. +/// +/// Used when the snapshotted inventory-impact rate is nonzero. Rate zero +/// still creates; the install is a no-op. A ladder that fails the 1 bp mass +/// check aborts the create. +public fun create_and_share_expiry_market_with_inventory_grid( + registry: &mut Registry, + pool_vault: &mut PoolVault, + config: &ProtocolConfig, + propbook_registry: &OracleRegistry, + pyth: &PythFeed, + bs_values: &BlockScholesValueStore, + bs_svi: &BlockScholesSVIStore, + lifecycle_cap: &MarketLifecycleCap, + propbook_underlying_id: u32, + cadence_id: u8, + ratios: vector, + clock: &Clock, + ctx: &mut TxContext, +): ID { + config.assert_version(); + registry.assert_valid_lifecycle_cap(lifecycle_cap); + config.assert_trading_allowed(); + config.assert_not_valuation_in_progress(); + let deployable = registry .market_manager - .record_expiry_creation(propbook_underlying_id, cadence_id, expiry, expiry_market_id); - config_events::emit_market_created( + .next_deployable_market(propbook_registry, propbook_underlying_id, cadence_id, clock); + let expiry = deployable.expiry(); + let tick_size = deployable.tick_size(); + let admission_tick_size = deployable.admission_tick_size(); + let reference_tick_source_timestamp_ms = expiry - market_manager::cadence_period_ms(cadence_id); + let max_expiry_allocation = deployable.max_expiry_allocation(); + let initial_expiry_cash = deployable.initial_expiry_cash(); + let pool_vault_id = pool_vault.id(); + let expiry_market_id = expiry_market::create_and_share_with_inventory( + config, + propbook_registry, + pyth, + bs_values, + bs_svi, + propbook_underlying_id, + expiry, + tick_size, + admission_tick_size, + reference_tick_source_timestamp_ms, + ratios, + clock, + ctx, + ); + record_created_market( + registry, + pool_vault, + config, expiry_market_id, pool_vault_id, propbook_underlying_id, + cadence_id, expiry, tick_size, admission_tick_size, max_expiry_allocation, initial_expiry_cash, - config.strike_exposure_template_config(), - ); - - expiry_market_id + clock, + ) } /// Create a derived shared BuilderCode for the caller and index. @@ -323,6 +385,45 @@ fun new_registry_and_admin_cap(ctx: &mut TxContext): (Registry, AdminCap) { ) } +fun record_created_market( + registry: &mut Registry, + pool_vault: &mut PoolVault, + config: &ProtocolConfig, + expiry_market_id: ID, + pool_vault_id: ID, + propbook_underlying_id: u32, + cadence_id: u8, + expiry: u64, + tick_size: u64, + admission_tick_size: u64, + max_expiry_allocation: u64, + initial_expiry_cash: u64, + clock: &Clock, +): ID { + pool_vault.register_expiry( + expiry_market_id, + expiry, + max_expiry_allocation, + initial_expiry_cash, + clock, + ); + registry + .market_manager + .record_expiry_creation(propbook_underlying_id, cadence_id, expiry, expiry_market_id); + config_events::emit_market_created( + expiry_market_id, + pool_vault_id, + propbook_underlying_id, + expiry, + tick_size, + admission_tick_size, + max_expiry_allocation, + initial_expiry_cash, + config.strike_exposure_template_config(), + ); + expiry_market_id +} + /// Abort unless the supplied `PauseCap` was minted by admin and not revoked. fun assert_valid_pause_cap(registry: &Registry, pause_cap: &PauseCap) { assert!(registry.allowed_pause_caps.contains(&pause_cap.id()), EPauseCapNotValid); diff --git a/packages/predict/sources/strike_exposure/inventory_cells.move b/packages/predict/sources/strike_exposure/inventory_cells.move new file mode 100644 index 000000000..e1dbe6581 --- /dev/null +++ b/packages/predict/sources/strike_exposure/inventory_cells.move @@ -0,0 +1,254 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Inline fixed-cell mirror of one expiry book's payout profile. +/// +/// The inventory coordinate needs the whole payout curve at once. Reading that +/// from `StrikePayoutTree` costs one dynamic-field child per distinct strike, +/// against a per-transaction object-cache ceiling that coincides with +/// `constants::max_payout_tree_nodes`. A `vector` held inline in the market +/// object costs no children, which removes that ceiling by construction. +/// +/// The price is resolution. The lattice is fixed at initialization while the +/// settlement distribution narrows toward expiry, so an order boundary is rounded +/// to the nearest cell edge and two boundaries inside one cell collapse into one. +/// `predeploy/evidence/p32-cell-array-sizing-2026-08-20.md` sizes that error +/// against reading the tree. +/// +/// The payout tree remains the source of truth for settlement backing and NAV. +/// Nothing here is read for solvency; it exists only to carry the inventory +/// coordinate, which is already an average of five range maxima over +/// 1%-probability buckets and so tolerates a coarse substitute that the flush +/// cannot. +module deepbook_predict::inventory_cells; + +use deepbook_predict::{constants, pricing::FrozenPricer, range_codec}; +use fixed_math::{i64::{Self, I64}, math}; + +const EInvalidCellSpan: u64 = 0; + +/// Cells per market. At the three-hour horizon the grid lane operates on, 2,048 +/// geometric cells over the span below are ~0.4 basis points each, which held +/// median per-trade charge error against a tree read to 1.6% or better through 90% +/// of market life. 512 and 1,024 were measured too and are worse at every horizon; +/// this is one `u64` each, so the whole mirror is 16 KB inline. +public(package) macro fun cell_count(): u64 { 2048 } + +/// Half-span of the lattice as a multiple of the submitted quantile ladder's own +/// half-width. The ladder spans the 1st to 99th percentile, which is ±2.326 +/// standard deviations, and the sizing run used ±4, so the lattice is stretched by +/// 4 / 2.326. Spot leaving the span is not a correctness problem — the two end +/// cells absorb everything outside it — but resolution there collapses to one +/// cell, so the span buys drift headroom at the cost of cell width. +macro fun span_numerator(): u64 { 172 } + +macro fun span_denominator(): u64 { 100 } + +/// Floor on cell width in log space, 1e9-scaled, so the lattice is never finer than +/// the logarithm that indexes it can resolve. `math::ln` targets 1e-7 relative error +/// on its result, and a result of magnitude ~25 is the worst case across the +/// representable price domain, which leaves ~2.5e-6 of absolute log error; cells +/// below that would be indexed arbitrarily. 4e-6 keeps margin. At the horizons the +/// grid lane operates on the span-derived width is an order of magnitude above this, +/// so the floor binds only on a distribution too narrow to partition usefully at +/// all, where it degrades resolution instead of destroying ordering. +macro fun min_step_ln(): u64 { 4_000 } + +/// A fixed geometric lattice plus the payout owed in each of its cells. +/// +/// Boundary `j` for `1 <= j <= cell_count - 1` sits at +/// `exp(anchor_ln + (j - 1) * step_ln)`; boundary `0` is the open bottom and +/// boundary `cell_count` the open top. Cell `i` is `(boundary_i, boundary_{i+1}]`, +/// matching the payout tree's half-open convention, and holds an absolute +/// non-negative payout rather than a signed delta, so no intermediate can +/// underflow. +/// +/// Uniform spacing in the log of price, rather than in price, is what makes every +/// cell the same width in relative terms. A bucket therefore sees the same cell +/// resolution wherever the forward has moved to, so fidelity is invariant to spot +/// inside the span. This is the same property that makes the boundary ratios work, +/// arriving for the same reason. +public struct InventoryCells has drop, store { + anchor_ln: I64, + step_ln: u64, + values: vector, + /// Count of cells with a nonzero payout. A close that returns this to zero + /// has emptied the mirror, so the centering term can be cleared rather than + /// left holding walk-versus-incremental dust. + occupied: u64, +} + +/// Build an empty lattice around the quantile ladder the caller submitted. +/// +/// Only ever called with an empty book, because grid initialization requires one, +/// so the cells start at zero and no bulk import from the tree is needed. The +/// lattice is never re-cut afterwards: re-cutting would mean re-binning the book, +/// which is the tree read this exists to avoid. +public(package) fun new(lowest_boundary: u64, highest_boundary: u64): InventoryCells { + assert!(constants::neg_inf!() < lowest_boundary, EInvalidCellSpan); + assert!(lowest_boundary < highest_boundary, EInvalidCellSpan); + assert!(highest_boundary < constants::pos_inf!(), EInvalidCellSpan); + + let lowest_ln = math::ln(lowest_boundary); + let ladder_width = math::ln(highest_boundary).sub(&lowest_ln); + assert!(!ladder_width.is_negative() && ladder_width.magnitude() > 0, EInvalidCellSpan); + + // `cell_count - 1` finite boundaries leave `cell_count - 2` gaps across the span, + // and the floor widens the whole lattice rather than truncating it, so the ladder + // always sits inside the span with the surplus split evenly either side. + let target_span = math::mul_div_down( + ladder_width.magnitude(), + span_numerator!(), + span_denominator!(), + ); + let step_ln = (target_span / (cell_count!() - 2)).max(min_step_ln!()); + let span = step_ln * (cell_count!() - 2); + let margin = i64::from_u64((span - ladder_width.magnitude()) / 2); + let anchor_ln = lowest_ln.sub(&margin); + + let mut values = vector[]; + let mut index = 0; + while (index < cell_count!()) { + values.push_back(0); + index = index + 1; + }; + + InventoryCells { anchor_ln, step_ln, values, occupied: 0 } +} + +/// The half-open cell span `[start, stop)` a raw interval `(lower, higher]` maps to. +/// +/// Both ends snap to the nearest cell boundary. Widening to every touched cell was +/// measured too and is worse on every statistic, not merely more expensive: it +/// inflates the tail and the centering term together and the errors do not cancel. +/// An interval that snaps to nothing still takes one cell, so no order can be +/// recorded as owing nothing anywhere and no bucket can be scored over no cells. +public(package) fun cell_span(cells: &InventoryCells, lower_raw: u64, higher_raw: u64): (u64, u64) { + let start = cells.boundary_index(lower_raw).min(cell_count!() - 1); + let stop = cells.boundary_index(higher_raw).max(start + 1).min(cell_count!()); + (start, stop) +} + +public(package) fun is_empty(cells: &InventoryCells): bool { + cells.occupied == 0 +} + +/// Add or remove one range order's payout across the cells it covers. +public(package) fun apply_span( + cells: &mut InventoryCells, + start: u64, + stop: u64, + quantity: u64, + adding: bool, +) { + let mut index = start; + while (index < stop) { + let current = cells.values[index]; + let next = if (adding) current + quantity else current - quantity; + if (current == 0 && next > 0) { + cells.occupied = cells.occupied + 1; + } else if (current > 0 && next == 0) { + cells.occupied = cells.occupied - 1; + }; + *cells.values.borrow_mut(index) = next; + index = index + 1; + }; +} + +/// Largest payout reachable in `[start, stop)`, optionally with `quantity` added to +/// or removed from the cells in `[range_start, range_stop)`. +/// +/// Quoting needs a bucket's maximum both before and after a candidate transition +/// without mutating anything, and the adjustment applied here is the same one +/// `apply_span` commits, so a quote and its commit cannot disagree about which +/// cells an order covers. Pass a zero-width range to read the current maximum. +public(package) fun span_max( + cells: &InventoryCells, + start: u64, + stop: u64, + range_start: u64, + range_stop: u64, + quantity: u64, + adding: bool, +): u64 { + let mut maximum = 0; + let mut index = start; + while (index < stop) { + let mut value = cells.values[index]; + if (index >= range_start && index < range_stop) { + value = if (adding) value + quantity else value - quantity; + }; + if (value > maximum) maximum = value; + index = index + 1; + }; + maximum +} + +/// Frozen probability mass of the half-open cell span `[start, stop)`. +/// +/// Survival at the start edge minus survival at the stop edge. Opens and closes +/// accumulate `quantity` times this mass as the stored centering term. Open +/// ends are exact: cell zero begins at probability one and the last cell ends +/// at probability zero. +public(package) fun span_probability( + cells: &InventoryCells, + pricer: &FrozenPricer, + start: u64, + stop: u64, +): u64 { + let lower_up = if (start == 0) { + math::float_scaling!() + } else { + pricer.frozen_up_price( + range_codec::strike_from_raw_boundary(cells.boundary_price(start)), + ) + }; + let higher_up = if (stop >= cell_count!()) { + 0 + } else { + pricer.frozen_up_price( + range_codec::strike_from_raw_boundary(cells.boundary_price(stop)), + ) + }; + lower_up.saturating_sub(higher_up) +} + +/// Nearest lattice boundary index to a raw price, in `0..cell_count`. +/// +/// Constant log spacing is what keeps this arithmetic rather than a search: the +/// index is one logarithm, one subtraction and one division, where the payout tree +/// needs a traversal and a stored child per node. Callers that already have +/// `ln(price)` should use `boundary_index_from_ln` so a 100-rung cut does not +/// repeat the logarithm. +public(package) fun boundary_index(cells: &InventoryCells, raw: u64): u64 { + if (raw == constants::neg_inf!()) return 0; + if (raw == constants::pos_inf!()) return cell_count!(); + cells.boundary_index_from_ln(&math::ln(raw)) +} + +/// Nearest lattice boundary index from a 1e9-scaled `ln(price)`. +public(package) fun boundary_index_from_ln(cells: &InventoryCells, price_ln: &I64): u64 { + let offset = price_ln.sub(&cells.anchor_ln); + // Below the anchor the nearest boundary is the open bottom: cell zero absorbs + // the whole region under the span, so a lower edge there extends to it and an + // upper edge there leaves the interval in that one cell. + if (offset.is_negative()) return 0; + let steps = (offset.magnitude() + cells.step_ln / 2) / cells.step_ln; + (steps + 1).min(cell_count!()) +} + +/// Raw price of lattice boundary `index`, for `1 <= index <= cell_count - 1`. +fun boundary_price(cells: &InventoryCells, index: u64): u64 { + let offset = i64::from_u64((index - 1) * cells.step_ln); + math::exp(&cells.anchor_ln.add(&offset)) +} + +#[test_only] +public(package) fun cell_value(cells: &InventoryCells, index: u64): u64 { + cells.values[index] +} + +#[test_only] +public(package) fun boundary_price_for_testing(cells: &InventoryCells, index: u64): u64 { + cells.boundary_price(index) +} diff --git a/packages/predict/sources/strike_exposure/inventory_grid.move b/packages/predict/sources/strike_exposure/inventory_grid.move new file mode 100644 index 000000000..352db4f22 --- /dev/null +++ b/packages/predict/sources/strike_exposure/inventory_grid.move @@ -0,0 +1,557 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Ratio-axis 1%-probability inventory grid for one expiry exposure book. +/// +/// The keeper inverts the live 1% CDF off-chain. Create mass-checks the 99 +/// `strike / forward` rungs, stores them and their logs, and freezes the SVI +/// shape. Later quotes add each stored `ln(ratio)` to the live `ln(forward)` +/// and read the book through the inline cell mirror, so spot moving does not +/// require a keeper. The grid never touches the payout tree: the tree is the +/// source of truth for settlement backing and NAV. +module deepbook_predict::inventory_grid; + +use deepbook_predict::{ + constants, + inventory_cells::{Self, InventoryCells}, + pricing::{Self, FrozenPricer, Pricer}, + range_codec +}; +use fixed_math::{i64::I64, math}; + +const EInvalidBoundaryCount: u64 = 0; +const EInvalidBoundary: u64 = 1; +const EInvalidBucketMass: u64 = 2; + +/// 100 equal-probability settlement buckets. `K` averages the worst +/// `tail_bucket_count` and subtracts expected payout. +macro fun bucket_count(): u64 { 100 } + +macro fun tail_bucket_count(): u64 { 5 } + +/// Target bucket mass and invert check, FLOAT_SCALING: 1% ± 1 bp. +macro fun target_bucket_mass(): u64 { 10_000_000 } + +macro fun bucket_mass_tolerance(): u64 { 100_000 } + +#[test_only] +macro fun bisection_passes(): u64 { 40 } + +/// Early-exit inside half the 1 bp mass check so two adjacent invert +/// residuals still pass `verified_snapshot`. +#[test_only] +macro fun invert_price_tolerance(): u64 { 50_000 } + +/// Ratio search bracket: `1 / bracket_multiple` .. `bracket_multiple`. +#[test_only] +macro fun bracket_multiple(): u64 { 10_000 } + +/// After the first rung, grow the high side by this many last-steps so a +/// widening tail still sits inside the bracket. +#[test_only] +macro fun search_high_step_multiple(): u64 { 4 } + +/// Floor on that high-side room, FLOAT_SCALING: 10 bp of forward. +#[test_only] +macro fun search_high_min_room(): u64 { 1_000_000 } + +/// One ratio ladder plus the payout mirror read under it. +/// +/// `ratios` stay fixed after initialize. Quote cuts add each stored +/// `ln(ratio)` to the live `ln(forward)` rather than logging `ratio × F` +/// again: both logs already live in 1e9-scaled value space, so ATM is +/// `ln(forward)` exactly. The cell lattice is absolute log-price, fixed at +/// initialize, and carries the book. +/// +/// `frozen_expected_payout` is the quote centering term: opens and closes move +/// it by the snapped cell-span mass under that quote's (frozen shape, live +/// forward) view. Quotes use this stored sum rather than re-integrating the +/// lattice, so a later mint does not price one digital per book edge. When +/// the forward moves, the term is the path of those increments, not the +/// current-view integral. A close that empties the mirror clears it. +public struct InventoryGrid has drop, store { + /// Interior 1%..99% rungs as `strike / forward`, 1e9-scaled. + ratios: vector, + /// `ln(ratios[i])`, taken once at initialize so a later quote does not. + ln_ratios: vector, + frozen_pricer: FrozenPricer, + frozen_expected_payout: u64, + cells: InventoryCells, +} + +/// Prospective capital facts for one range transition. +public struct InventoryChange has drop { + before_k: u64, + after_k: u64, + frozen_expected_payout_delta: u64, +} + +public(package) fun before_k(change: &InventoryChange): u64 { + change.before_k +} + +public(package) fun after_k(change: &InventoryChange): u64 { + change.after_k +} + +public(package) fun frozen_expected_payout_delta(change: &InventoryChange): u64 { + change.frozen_expected_payout_delta +} + +public(package) fun k95(grid: &InventoryGrid): u64 { + grid.capital_from_starts( + &grid.cut_bucket_cells(&grid.frozen_pricer.frozen_ln_forward()), + 0, + 0, + 0, + true, + grid.frozen_expected_payout, + ) +} + +public(package) fun frozen_expected_payout( + grid: &InventoryGrid, + lower_tick: u64, + higher_tick: u64, + quantity: u64, + tick_size: u64, +): u64 { + let (start, stop) = grid.range_cells(lower_tick, higher_tick, tick_size); + let probability = grid.cells.span_probability(&grid.frozen_pricer, start, stop); + math::mul_down(probability, quantity) +} + +/// Invert the live surface into the 99 interior 1% rungs and freeze them. +/// +/// Production pushes an off-chain ladder into `initialize`. Tests use this +/// when they need a valid grid without carrying 99 ratios. +#[test_only] +public(package) fun from_pricer(pricer: &Pricer): InventoryGrid { + initialize(pricer, invert_quantile_ratios(pricer)) +} + +/// Freeze a supplied ratio ladder after the 1% ± 1 bp mass check. +public(package) fun initialize(pricer: &Pricer, ratios: vector): InventoryGrid { + let boundaries = materialized_ladder(pricer.forward(), &ratios); + let frozen_pricer = verified_snapshot(pricer, &boundaries); + // The lattice is spanned from the finite ends of the creation ladder and is + // never re-cut: re-binning the book would be the tree read the cells avoid. + let cells = inventory_cells::new(boundaries[1], boundaries[bucket_count!() - 1]); + let ln_ratios = ln_ratio_ladder(&ratios); + + InventoryGrid { + ratios, + ln_ratios, + frozen_pricer, + frozen_expected_payout: 0, + cells, + } +} + +public(package) fun quote_open( + grid: &InventoryGrid, + pricer: &Pricer, + lower_tick: u64, + higher_tick: u64, + quantity: u64, + tick_size: u64, +): InventoryChange { + grid.quote_change(pricer, lower_tick, higher_tick, quantity, true, tick_size) +} + +/// The removed expected payout is re-derived from the quote's live-forward view +/// rather than read from a value stored at open, so a close stays consistent +/// with the pointer the rest of the transition is priced under. +public(package) fun quote_close( + grid: &InventoryGrid, + pricer: &Pricer, + lower_tick: u64, + higher_tick: u64, + quantity: u64, + tick_size: u64, +): InventoryChange { + grid.quote_change(pricer, lower_tick, higher_tick, quantity, false, tick_size) +} + +/// Commit one already-quoted transition into the payout mirror. +public(package) fun apply_change( + grid: &mut InventoryGrid, + lower_tick: u64, + higher_tick: u64, + quantity: u64, + frozen_expected_payout_delta: u64, + adding: bool, + tick_size: u64, +) { + let (start, stop) = grid.range_cells(lower_tick, higher_tick, tick_size); + grid.cells.apply_span(start, stop, quantity, adding); + + if (adding) { + grid.frozen_expected_payout = grid.frozen_expected_payout + frozen_expected_payout_delta; + } else if (grid.cells.is_empty()) { + grid.frozen_expected_payout = 0; + } else { + grid.frozen_expected_payout = + grid.frozen_expected_payout.saturating_sub(frozen_expected_payout_delta); + }; +} + +fun quote_change( + grid: &InventoryGrid, + pricer: &Pricer, + lower_tick: u64, + higher_tick: u64, + quantity: u64, + adding: bool, + tick_size: u64, +): InventoryChange { + let view = pricing::inventory_view(&grid.frozen_pricer, pricer); + let (start, stop) = grid.range_cells(lower_tick, higher_tick, tick_size); + let frozen_expected_payout_delta = math::mul_down( + grid.cells.span_probability(&view, start, stop), + quantity, + ); + // Stored increment, not a live lattice integral: re-pricing every distinct + // cell edge was the later-mint slope. Same-forward slices still telescope; + // a moved forward leaves E as the path of prior increments. + let before_expected = grid.frozen_expected_payout; + let after_expected = if (adding) { + before_expected + frozen_expected_payout_delta + } else { + before_expected.saturating_sub(frozen_expected_payout_delta) + }; + // One cut serves both coordinates: the live forward does not change between + // them, and logging the ladder twice was the later-mint gas regression. + let starts = grid.cut_bucket_cells(&pricer.ln_forward()); + + InventoryChange { + before_k: grid.capital_from_starts(&starts, 0, 0, 0, true, before_expected), + after_k: grid.capital_from_starts( + &starts, + start, + stop, + quantity, + adding, + after_expected, + ), + frozen_expected_payout_delta, + } +} + +/// Average of the five largest bucket maxima, less the centering term. +/// +/// Every bucket carries 1% of the settlement distribution's probability mass +/// along the ratio axis, so the worst 5% of outcomes is the five largest +/// buckets. `starts` is the live-forward cut, taken once per quote. Passing a +/// non-empty `[range_start, range_stop)` scores the book as if `quantity` were +/// added to or removed from those cells. +fun capital_from_starts( + grid: &InventoryGrid, + starts: &vector, + range_start: u64, + range_stop: u64, + quantity: u64, + adding: bool, + expected_payout: u64, +): u64 { + let mut maxima = vector[]; + let mut index = 0; + while (index < bucket_count!()) { + let start = starts[index].min(inventory_cells::cell_count!() - 1); + // A bucket narrower than one cell still scores that cell: late in a market's + // life the quantiles contract inside a lattice that cannot contract with them. + let stop = starts[index + 1].max(start + 1); + maxima.push_back(grid + .cells + .span_max(start, stop, range_start, range_stop, quantity, adding)); + index = index + 1; + }; + capital_from_components(maxima, expected_payout) +} + +/// The tail average less the centering term, over already-collected bucket maxima. +public(package) fun capital_from_components( + bucket_maxima: vector, + frozen_expected_payout: u64, +): u64 { + let mut top = vector[0, 0, 0, 0, 0]; + let mut index = 0; + while (index < bucket_maxima.length()) { + let value = bucket_maxima[index]; + let mut minimum_index = 0; + let mut tail_index = 1; + while (tail_index < tail_bucket_count!()) { + if (top[tail_index] < top[minimum_index]) { + minimum_index = tail_index; + }; + tail_index = tail_index + 1; + }; + if (value > top[minimum_index]) { + *top.borrow_mut(minimum_index) = value; + }; + index = index + 1; + }; + + let mut sum = 0u128; + index = 0; + while (index < tail_bucket_count!()) { + sum = sum + (top[index] as u128); + index = index + 1; + }; + let tail_average = (sum / (tail_bucket_count!() as u128)) as u64; + tail_average.saturating_sub(frozen_expected_payout) +} + +/// Invert the 1%..99% survival targets as `strike / forward`, 1e9-scaled. +/// +/// Production does not run this. Tests and the off-chain float twin produce +/// the ladder; on-chain work is the mass check in `initialize`. +#[test_only] +fun invert_quantile_ratios(pricer: &Pricer): vector { + let forward = pricer.forward(); + let scale = math::float_scaling!(); + let high_cap = high_bracket(scale); + let mut search_low = (scale / bracket_multiple!()).max(1); + let mut ratios = vector[]; + let mut index = 1; + while (index < bucket_count!()) { + let target = scale - index * target_bucket_mass!(); + // Bisect the stored ratio, and price the rematerialized strike + // `mul_down(ratio, forward)` so the mass check sees the same rung. + // The high side tracks the last step: a fixed `10_000×` cap puts + // every geometric mid near `100×` forward and wastes most digitals + // walking back. + let search_high = next_search_high(&ratios, high_cap); + let ratio = ratio_at_up_price( + pricer, + forward, + target, + search_low, + search_high, + high_cap, + ); + assert!(ratio > 0, EInvalidBoundary); + if (ratios.length() > 0) { + assert!(ratio > ratios[ratios.length() - 1], EInvalidBoundary); + }; + ratios.push_back(ratio); + search_low = ratio; + index = index + 1; + }; + ratios +} + +/// Local high for the next 1% quantile. The first rung still uses the +/// full cap; later rungs sit just above the last ratio. +#[test_only] +fun next_search_high(ratios: &vector, high_cap: u64): u64 { + let n = ratios.length(); + if (n == 0) return high_cap; + let last = ratios[n - 1]; + if (last >= high_cap) return high_cap; + let room = if (n == 1) { + (last / 4).max(1) + } else { + let step = last - ratios[n - 2]; + (step * search_high_step_multiple!()).max(search_high_min_room!()).max(1) + }; + let max_room = high_cap - last; + if (room >= max_room) high_cap else last + room +} + +#[test_only] +fun ratio_at_up_price( + pricer: &Pricer, + forward: u64, + target: u64, + mut low: u64, + mut high: u64, + high_cap: u64, +): u64 { + // A short high that is still too cheap (UP above the target) means + // the quantile is above it. Fall back to the full cap rather than + // returning a low rung that fails the mass check. + if (high < high_cap && high > low) { + let up_high = pricer.up_price( + range_codec::strike_from_raw_boundary(math::mul_down(high, forward)), + ); + if (up_high.diff(target) <= invert_price_tolerance!()) return high; + if (up_high > target) { + high = high_cap; + }; + }; + let mut pass = 0; + while (pass < bisection_passes!()) { + if (high - low <= 1) return geometric_mid(low, high); + let mid = geometric_mid(low, high); + if (mid == low || mid == high) return mid; + let up = pricer.up_price( + range_codec::strike_from_raw_boundary(math::mul_down(mid, forward)), + ); + if (up.diff(target) <= invert_price_tolerance!()) return mid; + if (up > target) { + low = mid; + } else { + high = mid; + }; + pass = pass + 1; + }; + geometric_mid(low, high) +} + +#[test_only] +fun high_bracket(scale: u64): u64 { + let max = std::u64::max_value!(); + if (scale > max / bracket_multiple!()) max else scale * bracket_multiple!() +} + +#[test_only] +fun geometric_mid(low: u64, high: u64): u64 { + (math::sqrt_u128_down((low as u128) * (high as u128)) as u64) +} + +/// Turn stored forward-relative quantiles into dollar rungs at `forward`. +/// +/// The 99 interior boundaries are `strike / forward`, 1e9-scaled, and the open +/// ends are the sentinels. Pricing reads a strike only as `ln(strike) - ln(forward)`, +/// so a bucket's mass is a function of these ratios alone. Initialize verifies +/// that once; every later quote rematerializes the same ratios against the live +/// forward. +fun materialized_ladder(forward: u64, ratios: &vector): vector { + assert!(ratios.length() == bucket_count!() - 1, EInvalidBoundaryCount); + let mut boundaries = vector[constants::neg_inf!()]; + let mut index = 0; + while (index < ratios.length()) { + // Flooring is deliberate and shared with pricing's own scaling: the residual + // is a billionth of the forward, four orders of magnitude inside the mass + // tolerance, and `verified_snapshot` re-prices whatever this produces. + boundaries.push_back(math::mul_down(ratios[index], forward)); + index = index + 1; + }; + boundaries.push_back(constants::pos_inf!()); + boundaries +} + +/// Freeze `pricer` and verify every bucket the boundaries cut carries the 1% +/// probability mass the tail average assumes. +/// +/// Length and complete coverage of the settlement line are not re-checked here: +/// `materialized_ladder` is the only producer, it owns the count, and it supplies +/// the open ends itself. Strict monotonicity is checked, because a caller's ratios +/// can still arrive out of order or collide when scaled. +fun verified_snapshot(pricer: &Pricer, boundaries: &vector): FrozenPricer { + let frozen_pricer = pricing::snapshot_for_inventory(pricer); + // The buckets partition the line, so each interior boundary is one bucket's top + // and the next one's bottom. Carrying its UP price down the ladder prices every + // boundary once; a `frozen_range_price` per bucket prices all 99 finite ones + // twice. + let mut lower_up_price = frozen_pricer.frozen_up_price( + range_codec::strike_from_raw_boundary(boundaries[0]), + ); + let mut index = 0; + while (index < bucket_count!()) { + assert!(boundaries[index] < boundaries[index + 1], EInvalidBoundary); + let higher_up_price = frozen_pricer.frozen_up_price( + range_codec::strike_from_raw_boundary(boundaries[index + 1]), + ); + // Floored for the reason `compute_range_price` floors: fixed-point error or a + // non-monotone surface can invert two adjacent boundary probabilities, and an + // inverted pair is a mass far outside tolerance either way. + let mass = lower_up_price.saturating_sub(higher_up_price); + assert!(mass.diff(target_bucket_mass!()) <= bucket_mass_tolerance!(), EInvalidBucketMass); + lower_up_price = higher_up_price; + index = index + 1; + }; + frozen_pricer +} + +/// Cell index of every boundary in the ladder, taken once per quote. +/// +/// Interior rungs are `ln(ratio_i) + ln(F)`. Both logs are already 1e9-scaled +/// values, so a unit ratio is `ln(F)` and no extra `ln(1e9)` subtract appears. +/// Sentinels are the open lattice ends and do not go through the logarithm. +fun cut_bucket_cells(grid: &InventoryGrid, ln_forward: &I64): vector { + assert!(grid.ln_ratios.length() == bucket_count!() - 1, EInvalidBoundaryCount); + let mut starts = vector[0]; + let mut index = 0; + while (index < grid.ln_ratios.length()) { + let price_ln = grid.ln_ratios[index].add(ln_forward); + starts.push_back(grid.cells.boundary_index_from_ln(&price_ln)); + index = index + 1; + }; + starts.push_back(inventory_cells::cell_count!()); + starts +} + +fun ln_ratio_ladder(ratios: &vector): vector { + let mut ln_ratios = vector[]; + let mut index = 0; + while (index < ratios.length()) { + ln_ratios.push_back(math::ln(ratios[index])); + index = index + 1; + }; + ln_ratios +} + +fun range_cells( + grid: &InventoryGrid, + lower_tick: u64, + higher_tick: u64, + tick_size: u64, +): (u64, u64) { + let lower = raw_boundary_from_tick(lower_tick, tick_size); + let higher = raw_boundary_from_tick(higher_tick, tick_size); + grid.cells.cell_span(lower, higher) +} + +fun raw_boundary_from_tick(tick: u64, tick_size: u64): u64 { + if (tick == 0) return constants::neg_inf!(); + if (tick == constants::pos_inf_tick!()) return constants::pos_inf!(); + tick * tick_size +} + +#[test_only] +public(package) fun bucket_maximum(grid: &InventoryGrid, index: u64): u64 { + let starts = grid.cut_bucket_cells(&grid.frozen_pricer.frozen_ln_forward()); + bucket_maximum_at(grid, &starts, index) +} + +#[test_only] +public(package) fun book_peak(grid: &InventoryGrid): u64 { + let starts = grid.cut_bucket_cells(&grid.frozen_pricer.frozen_ln_forward()); + let mut peak = 0; + let mut index = 0; + while (index < bucket_count!()) { + let maximum = bucket_maximum_at(grid, &starts, index); + if (maximum > peak) peak = maximum; + index = index + 1; + }; + peak +} + +#[test_only] +fun bucket_maximum_at(grid: &InventoryGrid, starts: &vector, index: u64): u64 { + let start = starts[index].min(inventory_cells::cell_count!() - 1); + let stop = starts[index + 1].max(start + 1); + grid.cells.span_max(start, stop, 0, 0, 0, true) +} + +#[test_only] +public(package) fun boundary(grid: &InventoryGrid, index: u64): u64 { + materialized_ladder(grid.frozen_pricer.frozen_forward(), &grid.ratios)[index] +} + +#[test_only] +public(package) fun current_frozen_expected_payout(grid: &InventoryGrid): u64 { + grid.frozen_expected_payout +} + +#[test_only] +public(package) fun cells(grid: &InventoryGrid): &InventoryCells { + &grid.cells +} + +#[test_only] +public(package) fun ratios(grid: &InventoryGrid): vector { + grid.ratios +} diff --git a/packages/predict/sources/strike_exposure/range_codec.move b/packages/predict/sources/strike_exposure/range_codec.move index e3adbb2ab..7f6bcf178 100644 --- a/packages/predict/sources/strike_exposure/range_codec.move +++ b/packages/predict/sources/strike_exposure/range_codec.move @@ -25,6 +25,11 @@ public fun strike_from_tick(tick: u64, tick_size: u64): Strike { Strike(tick * tick_size) } +/// Wrap a verified raw inventory-grid boundary for frozen probability pricing. +public(package) fun strike_from_raw_boundary(raw: u64): Strike { + Strike(raw) +} + /// Raw value for pricing math; consumers re-enter the raw domain only through this. public(package) fun value(strike: Strike): u64 { strike.0 diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 053e8d2e5..f33add472 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -13,6 +13,7 @@ module deepbook_predict::strike_exposure; use deepbook_predict::{ constants, + inventory_grid::{Self, InventoryChange, InventoryGrid}, order::{Self, Order}, pricing::Pricer, range_codec, @@ -28,7 +29,7 @@ const EInvalidReferenceTick: u64 = 2; const EReferenceTickAlreadySet: u64 = 3; const ETermsExposureMismatch: u64 = 4; const EMintQuantityBelowMin: u64 = 5; -const EInvalidInventoryImpactScale: u64 = 6; +const EInventoryGridRequired: u64 = 6; /// Exposure lifecycle state for one expiry market. public struct StrikeExposure has store { @@ -44,10 +45,8 @@ public struct StrikeExposure has store { reference_tick: Option, /// Snapshotted exposure and fee policy for this expiry. config: StrikeExposureConfig, - /// Immutable DUSDC scale for the inventory-impact curve. This is the - /// expiry's snapshotted maximum pool allocation: a risk-capacity parameter, - /// not live pool equity, so LP flows cannot reprice an existing book. - inventory_impact_scale: u64, + /// Ratio ladder pushed at create and mass-checked, plus the cell mirror. + inventory_grid: Option, next_order_sequence: u64, /// Terminal settlement price once the exposure has entered its settled phase. settlement_price: Option, @@ -72,6 +71,11 @@ public struct MintTerms has drop { premium: u64, /// Separate inventory-impact charge, sampled against the pre-mint book. inventory_impact_charge: u64, + /// Frozen-grid capital before and after this open, in DUSDC. + k_before: u64, + k_after: u64, + /// Cell-span expected payout this open adds to the grid's centering term. + frozen_expected_payout: u64, } /// Compute-once terms for one prospective live close. Built only by @@ -84,8 +88,13 @@ public struct LiveCloseTerms has drop { close_quantity: u64, redeem_amount: u64, range_probability: u64, - /// Separate inventory-impact rebate, sampled against the pre-close book. - inventory_impact_rebate: u64, + /// Charge when this close removes a hedge and raises the book potential. + inventory_impact_charge: u64, + /// Frozen-grid capital before and after this close, in DUSDC. + k_before: u64, + k_after: u64, + /// Cell-span expected payout this close removes from the centering term. + frozen_expected_payout: u64, } public(package) fun entry_probability(terms: &MintTerms): u64 { @@ -104,6 +113,18 @@ public(package) fun inventory_impact_charge(terms: &MintTerms): u64 { terms.inventory_impact_charge } +public(package) fun k_before(terms: &MintTerms): u64 { + terms.k_before +} + +public(package) fun k_after(terms: &MintTerms): u64 { + terms.k_after +} + +public(package) fun frozen_expected_payout(terms: &MintTerms): u64 { + terms.frozen_expected_payout +} + public(package) fun redeem_amount(terms: &LiveCloseTerms): u64 { terms.redeem_amount } @@ -112,8 +133,16 @@ public(package) fun range_probability(terms: &LiveCloseTerms): u64 { terms.range_probability } -public(package) fun inventory_impact_rebate(terms: &LiveCloseTerms): u64 { - terms.inventory_impact_rebate +public(package) fun live_close_inventory_impact_charge(terms: &LiveCloseTerms): u64 { + terms.inventory_impact_charge +} + +public(package) fun live_close_k_before(terms: &LiveCloseTerms): u64 { + terms.k_before +} + +public(package) fun live_close_k_after(terms: &LiveCloseTerms): u64 { + terms.k_after } /// Return the recorded settlement price. Aborts while the exposure is live. @@ -197,7 +226,7 @@ public(package) fun inventory_impact_max_rate(exposure: &StrikeExposure): u64 { } public(package) fun inventory_impact_scale(exposure: &StrikeExposure): u64 { - exposure.inventory_impact_scale + exposure.config.inventory_impact_scale() } public(package) fun tick_size(exposure: &StrikeExposure): u64 { @@ -237,50 +266,23 @@ public(package) fun trading_fee( ) } -/// Return the deterministic inventory-impact potential for the current live -/// payout liability. The marginal rate rises linearly from zero to +/// Return the deterministic inventory-impact potential for current frozen-grid +/// economic capital. The marginal rate rises linearly from zero to /// `inventory_impact_max_rate` over `inventory_impact_scale`, then stays capped: /// -/// `phi(L) = r_max * L^2 / (2B)` for `L <= B` -/// `phi(L) = phi(B) + r_max * (L - B)` for `L > B`. +/// `phi(K) = r_max * K^2 / (2B)` for `K <= B` +/// `phi(K) = phi(B) + r_max * (K - B)` for `K > B`. /// /// On-chain arithmetic defines `phi` by this exact sequence of rounded integer -/// operations. Trades always subtract two evaluations of the same function, so -/// charges and rebates telescope exactly even when the ideal real-valued -/// quadratic would have fractional dust. +/// operations. Every charge is a difference of two evaluations of the same +/// function, so splitting an order collects the same total even when the ideal +/// real-valued quadratic would have fractional dust. public(package) fun inventory_impact_potential(exposure: &StrikeExposure): u64 { // Preserve the zero-rate kill switch through the post-trade backing check: - // disabled markets do not perform a second payout-tree read here. + // disabled markets do not walk the grid here. if (exposure.is_settled() || exposure.config.inventory_impact_max_rate() == 0) return 0; - exposure.inventory_impact_potential_for_liability(exposure.payout_liability()) -} - -/// Price one mint (`adding`) or live close (`!adding`) as the exact change of a -/// single book-level potential. Using one state function for every range makes -/// all closed inventory cycles sum to zero before ordinary trading fees. -public(package) fun inventory_impact( - exposure: &StrikeExposure, - lower_tick: u64, - higher_tick: u64, - payout: u64, - adding: bool, -): u64 { - // Kill switch before the O(log n) range and complement reads. - if (exposure.config.inventory_impact_max_rate() == 0 || payout == 0) return 0; - - let (before, after) = exposure.payout_liabilities_after_change( - lower_tick, - higher_tick, - payout, - adding, - ); - let before_potential = exposure.inventory_impact_potential_for_liability(before); - let after_potential = exposure.inventory_impact_potential_for_liability(after); - if (adding) { - after_potential - before_potential - } else { - before_potential - after_potential - } + if (exposure.inventory_grid.is_none()) return 0; + exposure.inventory_impact_potential_for_capital(exposure.inventory_grid.borrow().k95()) } /// Price a range, choose quantity under the requested bias, and run mint @@ -321,6 +323,13 @@ public(package) fun quote_mint_terms( let premium = exposure.config.assert_mint_admission(entry_probability, quantity); // Preserve the mutation path's validation order. order::assert_valid_quantity(quantity); + let (inventory_impact_charge, frozen_expected_payout, k_before, k_after) = exposure + .quote_open_inventory( + pricer, + lower_tick, + higher_tick, + quantity, + ); MintTerms { expiry_market_id: exposure.expiry_market_id, lower_tick, @@ -328,12 +337,10 @@ public(package) fun quote_mint_terms( quantity, entry_probability, premium, - inventory_impact_charge: exposure.inventory_impact( - lower_tick, - higher_tick, - quantity, - true, - ), + inventory_impact_charge, + k_before, + k_after, + frozen_expected_payout, } } @@ -343,7 +350,14 @@ public(package) fun quote_mint_terms( /// fields are always the ones that were priced, and the market-identity assert /// rejects terms priced on another exposure. public(package) fun allocate_mint_order(exposure: &mut StrikeExposure, terms: MintTerms): Order { - let MintTerms { expiry_market_id, lower_tick, higher_tick, quantity, .. } = terms; + let MintTerms { + expiry_market_id, + lower_tick, + higher_tick, + quantity, + frozen_expected_payout, + .., + } = terms; assert!(expiry_market_id == exposure.expiry_market_id, ETermsExposureMismatch); let sequence = exposure.next_order_sequence; @@ -351,6 +365,23 @@ public(package) fun allocate_mint_order(exposure: &mut StrikeExposure, terms: Mi exposure.next_order_sequence = sequence + 1; exposure.payout.insert_range(lower_tick, higher_tick, quantity); + // Mirrored whenever a grid exists, not only when the rate is nonzero. The + // mirror is the grid's only record of the book, so a trade that skipped it + // would be invisible forever; the centering term is re-integrated from the + // mirror on the next quote and so tolerates being left behind here. + if (exposure.inventory_grid.is_some()) { + exposure + .inventory_grid + .borrow_mut() + .apply_change( + lower_tick, + higher_tick, + quantity, + frozen_expected_payout, + true, + exposure.tick_size, + ); + }; allocated_order } @@ -368,18 +399,40 @@ public(package) fun quote_live_close( assert!(close_quantity <= order.quantity(), EInvalidCloseQuantity); let range_probability = exposure.order_range_price(pricer, order); + // A charged book has a grid from create. Rate-zero books never grow one, + // so a close against them cannot raise K. + let (inventory_impact_charge, frozen_expected_payout, k_before, k_after) = if ( + exposure.config.inventory_impact_max_rate() == 0 || exposure.inventory_grid.is_none() + ) { + (0, 0, 0, 0) + } else { + let change = exposure + .inventory_grid + .borrow() + .quote_close( + pricer, + order.lower_tick(), + order.higher_tick(), + close_quantity, + exposure.tick_size, + ); + ( + exposure.inventory_impact_charge_for(&change), + change.frozen_expected_payout_delta(), + change.before_k(), + change.after_k(), + ) + }; LiveCloseTerms { expiry_market_id: exposure.expiry_market_id, order: *order, close_quantity, redeem_amount: math::mul_down(range_probability, close_quantity), range_probability, - inventory_impact_rebate: exposure.inventory_impact( - order.lower_tick(), - order.higher_tick(), - close_quantity, - false, - ), + inventory_impact_charge, + k_before, + k_after, + frozen_expected_payout, } } @@ -389,10 +442,29 @@ public(package) fun process_live_close( exposure: &mut StrikeExposure, terms: LiveCloseTerms, ): Option { - let LiveCloseTerms { expiry_market_id, order, close_quantity, .. } = terms; + let LiveCloseTerms { + expiry_market_id, + order, + close_quantity, + frozen_expected_payout, + .., + } = terms; assert!(expiry_market_id == exposure.expiry_market_id, ETermsExposureMismatch); exposure.payout.remove_range(order.lower_tick(), order.higher_tick(), close_quantity); + if (exposure.inventory_grid.is_some()) { + exposure + .inventory_grid + .borrow_mut() + .apply_change( + order.lower_tick(), + order.higher_tick(), + close_quantity, + frozen_expected_payout, + false, + exposure.tick_size, + ); + }; let remaining_quantity = order.quantity() - close_quantity; if (remaining_quantity == 0) return option::none(); @@ -415,6 +487,31 @@ public(package) fun process_settled_close(exposure: &mut StrikeExposure, order: payout } +/// Persist a supplied 1% ladder after the mass check. Rate zero and a +/// later call are no-ops. +public(package) fun install_inventory_grid( + exposure: &mut StrikeExposure, + pricer: &Pricer, + ratios: vector, +) { + if (exposure.config.inventory_impact_max_rate() == 0) return; + if (exposure.inventory_grid.is_some()) return; + exposure.inventory_grid.fill(inventory_grid::initialize(pricer, ratios)); +} + +/// Charged books must already have a grid. Production never inverts here. +public(package) fun assert_inventory_grid_ready(exposure: &StrikeExposure) { + if (exposure.config.inventory_impact_max_rate() == 0) return; + assert!(exposure.inventory_grid.is_some(), EInventoryGridRequired); +} + +#[test_only] +public(package) fun ensure_inventory_grid(exposure: &mut StrikeExposure, pricer: &Pricer) { + if (exposure.config.inventory_impact_max_rate() == 0) return; + if (exposure.inventory_grid.is_some()) return; + exposure.inventory_grid.fill(inventory_grid::from_pricer(pricer)); +} + /// Enter the settled phase by recording the terminal price and aggregate payout /// liability. The caller owns expiry and oracle validation. public(package) fun record_settlement(exposure: &mut StrikeExposure, settlement_price: u64) { @@ -447,10 +544,8 @@ public(package) fun new( tick_size: u64, admission_tick_size: u64, reference_tick_source_timestamp_ms: u64, - inventory_impact_scale: u64, ctx: &mut TxContext, ): StrikeExposure { - assert!(inventory_impact_scale > 0, EInvalidInventoryImpactScale); StrikeExposure { expiry_market_id, tick_size, @@ -458,7 +553,7 @@ public(package) fun new( reference_tick_source_timestamp_ms, reference_tick: option::none(), config, - inventory_impact_scale, + inventory_grid: option::none(), next_order_sequence: 0, settlement_price: option::none(), settled_payout_liability: 0, @@ -481,53 +576,72 @@ fun admitted_entry_probability( pricer.range_price(lower, higher) } -fun inventory_impact_potential_for_liability(exposure: &StrikeExposure, liability: u64): u64 { +/// Charge one prospective open. A stored grid is the book; a missing grid +/// aborts when the rate is on. +fun quote_open_inventory( + exposure: &StrikeExposure, + pricer: &Pricer, + lower_tick: u64, + higher_tick: u64, + quantity: u64, +): (u64, u64, u64, u64) { + if (exposure.config.inventory_impact_max_rate() == 0) return (0, 0, 0, 0); + assert!(exposure.inventory_grid.is_some(), EInventoryGridRequired); + exposure.charge_open_on( + exposure.inventory_grid.borrow(), + pricer, + lower_tick, + higher_tick, + quantity, + ) +} + +fun charge_open_on( + exposure: &StrikeExposure, + grid: &InventoryGrid, + pricer: &Pricer, + lower_tick: u64, + higher_tick: u64, + quantity: u64, +): (u64, u64, u64, u64) { + let change = grid.quote_open(pricer, lower_tick, higher_tick, quantity, exposure.tick_size); + ( + exposure.inventory_impact_charge_for(&change), + change.frozen_expected_payout_delta(), + change.before_k(), + change.after_k(), + ) +} + +fun inventory_impact_potential_for_capital(exposure: &StrikeExposure, capital: u64): u64 { let max_rate = exposure.config.inventory_impact_max_rate(); - if (max_rate == 0 || liability == 0) return 0; + if (max_rate == 0 || capital == 0) return 0; - let scale = exposure.inventory_impact_scale; - let capped_liability = liability.min(scale); + let scale = exposure.config.inventory_impact_scale(); + let capped_capital = capital.min(scale); let utilization = math::mul_div_down( - capped_liability, + capped_capital, math::float_scaling!(), scale, ); let marginal_rate = math::mul_down(max_rate, utilization); - let potential_at_capped_liability = + let potential_at_capped_capital = math::mul_down( marginal_rate, - capped_liability, + capped_capital, ) / 2; - if (liability <= scale) return potential_at_capped_liability; + if (capital <= scale) return potential_at_capped_capital; - potential_at_capped_liability + math::mul_down(max_rate, liability - scale) + potential_at_capped_capital + math::mul_down(max_rate, capital - scale) } -/// Return the exact current and prospective live liabilities for one range -/// change. Evaluating the full terms on both sides is necessary: independently -/// rounding `lambda * delta(T-M)` can miss a one-atom carry already accumulated -/// in the book's buffered gap. -fun payout_liabilities_after_change( - exposure: &StrikeExposure, - lower_tick: u64, - higher_tick: u64, - payout: u64, - adding: bool, -): (u64, u64) { - let (max_payout, total_payout) = exposure.payout.payout_reserve_terms(); - let range_max = exposure.payout.range_max_payout(lower_tick, higher_tick); - let (after_max, after_total) = if (adding) { - (max_payout.max(range_max + payout), total_payout + payout) - } else { - // Every live order contributes its complete payout at every point in its - // range, so the pre-close range maximum is at least `payout`. - let complement_max = exposure.payout.complement_max_payout(lower_tick, higher_tick); - ((range_max - payout).max(complement_max), total_payout - payout) - }; - ( - exposure.live_payout_liability_from_terms(max_payout, total_payout), - exposure.live_payout_liability_from_terms(after_max, after_total), - ) +/// Return the charge for one range transition. A trade that lowers the book's +/// capital is free rather than refunded: there is no rebate, so the potential +/// decrease is dropped here. +fun inventory_impact_charge_for(exposure: &StrikeExposure, change: &InventoryChange): u64 { + let before = exposure.inventory_impact_potential_for_capital(change.before_k()); + let after = exposure.inventory_impact_potential_for_capital(change.after_k()); + after.saturating_sub(before) } fun live_payout_liability_from_terms( @@ -562,3 +676,17 @@ fun order_range_price(exposure: &StrikeExposure, pricer: &Pricer, order: &Order) range_codec::strike_from_tick(order.higher_tick(), exposure.tick_size), ) } + +#[test_only] +public(package) fun fill_inventory_grid(exposure: &mut StrikeExposure, grid: InventoryGrid) { + exposure.inventory_grid.fill(grid); +} + +public(package) fun has_inventory_grid(exposure: &StrikeExposure): bool { + exposure.inventory_grid.is_some() +} + +#[test_only] +public(package) fun test_inventory_grid(exposure: &StrikeExposure): &InventoryGrid { + exposure.inventory_grid.borrow() +} diff --git a/packages/predict/tests/config/protocol_config_bounds_tests.move b/packages/predict/tests/config/protocol_config_bounds_tests.move index e60f90a98..65534ed97 100644 --- a/packages/predict/tests/config/protocol_config_bounds_tests.move +++ b/packages/predict/tests/config/protocol_config_bounds_tests.move @@ -185,6 +185,14 @@ fun template_inventory_impact_max_rate_above_one_aborts() { abort 999 } +#[test, expected_failure(abort_code = config_constants::EInvalidInventoryImpactScale)] +fun template_inventory_impact_scale_zero_aborts() { + let (scenario, admin_cap, config_id) = new_shared_config(); + let mut config = scenario.take_shared_by_id(config_id); + config.set_template_inventory_impact_scale(&admin_cap, 0); + abort 999 +} + // === Strike-exposure templates: boundary values round-trip === #[test] @@ -218,21 +226,22 @@ fun backing_buffer_lambda_market_snapshot_freezes_at_creation() { fun inventory_impact_rate_and_scale_snapshot_at_creation() { let mut fx = helpers::setup_market_default(); let rate = config_constants::max_inventory_impact_max_rate!(); + let scale = 2_000_000_000; fx.set_template_inventory_impact_max_rate(rate); + fx.set_template_inventory_impact_scale(scale); let expiry_id = fx.create_expiry(test_constants::default_expiry_ms()); let market = fx.take_market_bundle(expiry_id); assert_eq!(helpers::market(&market).inventory_impact_max_rate(), rate); - assert_eq!( - helpers::market(&market).inventory_impact_scale(), - test_constants::default_max_expiry_allocation(), - ); + assert_eq!(helpers::market(&market).inventory_impact_scale(), scale); helpers::return_market_bundle(market); // Later template changes do not retroactively reprice the existing book. fx.set_template_inventory_impact_max_rate(0); + fx.set_template_inventory_impact_scale(scale * 2); let market = fx.take_market_bundle(expiry_id); assert_eq!(helpers::market(&market).inventory_impact_max_rate(), rate); + assert_eq!(helpers::market(&market).inventory_impact_scale(), scale); helpers::return_market_bundle(market); fx.finish(); } diff --git a/packages/predict/tests/expiry_cash_tests.move b/packages/predict/tests/expiry_cash_tests.move index 9179a42ab..e6e9bc59e 100644 --- a/packages/predict/tests/expiry_cash_tests.move +++ b/packages/predict/tests/expiry_cash_tests.move @@ -12,10 +12,8 @@ use sui::coin; const CASH_AMOUNT: u64 = 100; const REQUIRED_PAYOUT_LIABILITY: u64 = 101; const FEE_AMOUNT: u64 = 40; -const INVENTORY_IMPACT_CHARGE: u64 = 30; -const INVENTORY_IMPACT_REBATE: u64 = 12; -/// Cash left after paying out past the earmark (5 < escrow 30). -const CASH_BELOW_ESCROW: u64 = 5; +/// Payout liability leaving exactly `FEE_AMOUNT` of `CASH_AMOUNT` releasable. +const BACKED_LIABILITY: u64 = 60; #[test, expected_failure(abort_code = expiry_cash::EInsufficientCash)] fun assert_backing_underfunded_aborts() { @@ -51,75 +49,27 @@ fun receive_and_pay_authorized_updates_balance() { } #[test] -fun free_cash_nets_out_the_impact_escrow_and_floors_at_zero() { +fun release_surplus_pays_only_cash_above_payout_backing() { let ctx = &mut tx_context::dummy(); let mut cash = expiry_cash::new(); - - // Cash 40 with 30 earmarked leaves 10 free. - cash.receive(coin::mint_for_testing(FEE_AMOUNT, ctx).into_balance()); - cash.credit_inventory_impact_reserve(INVENTORY_IMPACT_CHARGE); - assert_eq!(cash.free_cash(), FEE_AMOUNT - INVENTORY_IMPACT_CHARGE); - - // Pay out past the earmark — 5 cash against a 30 escrow. Free cash floors at - // zero instead of underflowing the subtraction. - let drained = cash.pay_authorized(FEE_AMOUNT - CASH_BELOW_ESCROW); - assert_eq!(cash.balance(), CASH_BELOW_ESCROW); - assert_eq!(cash.free_cash(), 0); - - destroy(drained); - destroy(cash); -} - -#[test] -fun inventory_impact_reserve_isolated_from_free_cash() { - let ctx = &mut tx_context::dummy(); - let mut cash = expiry_cash::new(); - - // The charge has already arrived in custody when the market earmarks it. cash.receive(coin::mint_for_testing(CASH_AMOUNT, ctx).into_balance()); - cash.credit_inventory_impact_reserve(INVENTORY_IMPACT_CHARGE); - - assert_eq!(cash.inventory_impact_reserve(), INVENTORY_IMPACT_CHARGE); - assert_eq!(cash.required_cash(REQUIRED_PAYOUT_LIABILITY), 131); - assert_eq!(cash.free_cash(), CASH_AMOUNT - INVENTORY_IMPACT_CHARGE); - let rebate = cash.pay_inventory_impact_rebate(INVENTORY_IMPACT_REBATE); - assert_eq!(rebate.value(), INVENTORY_IMPACT_REBATE); - assert_eq!(cash.inventory_impact_reserve(), INVENTORY_IMPACT_CHARGE - INVENTORY_IMPACT_REBATE); - assert_eq!(cash.balance(), CASH_AMOUNT - INVENTORY_IMPACT_REBATE); - assert_eq!(cash.free_cash(), CASH_AMOUNT - INVENTORY_IMPACT_CHARGE); + // 100 cash against a 60 liability leaves exactly 40 releasable. + let released = cash.release_surplus(FEE_AMOUNT, BACKED_LIABILITY); - destroy(rebate); - let remaining = cash.pay_authorized(CASH_AMOUNT - INVENTORY_IMPACT_REBATE); - destroy(remaining); + assert_eq!(released.value(), FEE_AMOUNT); + assert_eq!(cash.balance(), BACKED_LIABILITY); + destroy(released); destroy(cash); } -#[test, expected_failure(abort_code = expiry_cash::EInventoryImpactRebateExceedsReserve)] -fun inventory_impact_rebate_cannot_spend_ordinary_cash() { +#[test, expected_failure(abort_code = expiry_cash::EInsufficientCash)] +fun release_surplus_that_breaks_payout_backing_aborts() { let ctx = &mut tx_context::dummy(); let mut cash = expiry_cash::new(); cash.receive(coin::mint_for_testing(CASH_AMOUNT, ctx).into_balance()); - cash.credit_inventory_impact_reserve(INVENTORY_IMPACT_CHARGE); - - let unexpected = cash.pay_inventory_impact_rebate(INVENTORY_IMPACT_CHARGE + 1); - destroy(unexpected); - abort 999 -} -#[test] -fun settlement_release_turns_residual_escrow_into_surplus() { - let ctx = &mut tx_context::dummy(); - let mut cash = expiry_cash::new(); - cash.receive(coin::mint_for_testing(INVENTORY_IMPACT_CHARGE, ctx).into_balance()); - cash.credit_inventory_impact_reserve(INVENTORY_IMPACT_CHARGE); - - cash.release_inventory_impact_reserve(); - - assert_eq!(cash.inventory_impact_reserve(), 0); - assert_eq!(cash.free_cash(), INVENTORY_IMPACT_CHARGE); - let released = cash.release_surplus(INVENTORY_IMPACT_CHARGE, 0); - assert_eq!(released.value(), INVENTORY_IMPACT_CHARGE); + let released = cash.release_surplus(FEE_AMOUNT + 1, BACKED_LIABILITY); destroy(released); - destroy(cash); + abort 999 } diff --git a/packages/predict/tests/flows/backing_buffer_flow_tests.move b/packages/predict/tests/flows/backing_buffer_flow_tests.move index 6c51fa14c..2c2d1989d 100644 --- a/packages/predict/tests/flows/backing_buffer_flow_tests.move +++ b/packages/predict/tests/flows/backing_buffer_flow_tests.move @@ -146,7 +146,7 @@ fun full_close_below_remaining_reserve_aborts() { // The market is legally backed before the close, and the cushion is far // smaller than the deficit the close will open. assert_eq!(helpers::market(&market).cash_balance(), target_cash); - assert!(helpers::market(&market).cash_balance() >= required_cash(&market)); + assert!(helpers::market(&market).cash_balance() >= helpers::market(&market).payout_liability()); assert!(CASH_CUSHION < DISJOINT_GAP / 4); fx.advance_live_oracle_bundle(&mut market, test_constants::default_live_price()); @@ -154,11 +154,6 @@ fun full_close_below_remaining_reserve_aborts() { abort 999 } -fun required_cash(market: &helpers::MarketBundle): u64 { - let market = helpers::market(market); - market.payout_liability() + market.inventory_impact_reserve() -} - fun mint_down( fx: &mut helpers::Fixture, market: &mut helpers::MarketBundle, diff --git a/packages/predict/tests/flows/current_nav_flow_tests.move b/packages/predict/tests/flows/current_nav_flow_tests.move index 52f8dc7dc..2d41d8f3b 100644 --- a/packages/predict/tests/flows/current_nav_flow_tests.move +++ b/packages/predict/tests/flows/current_nav_flow_tests.move @@ -4,10 +4,10 @@ /// Differential coverage for the exact single-expiry live NAV reader /// (`expiry_market::current_nav`). Every test builds protocol state through the /// production mint flow, then asserts `current_nav` exactly equals an INDEPENDENT -/// per-order reference (`reference_nav`): `free_cash - Σ qty·P(range)`, computed +/// per-order reference (`reference_nav`): `cash - Σ qty·P(range)`, computed /// straight from each order's atoms and `pricing::range_price`. The reference -/// reuses NONE of `walk_linear` / `live_marked_liability` / `current_nav` / -/// `expiry_cash::free_cash`, so it is a genuine oracle (unit-tests rule 1): it +/// reuses NONE of `walk_linear` / `live_marked_liability` / `current_nav`, +/// so it is a genuine oracle (unit-tests rule 1): it /// sums per order, while the contract nets per boundary. /// /// All fixtures anchor every finite boundary at `strike_tick` (whose raw strike == @@ -41,7 +41,7 @@ const NON_MONOTONE_LOWER_TICK: u64 = 90; const NON_MONOTONE_HIGHER_TICK: u64 = 100; #[test] -fun empty_live_market_values_at_free_cash() { +fun empty_live_market_values_at_its_whole_cash_balance() { let (mut fx, expiry_id, _trader) = helpers::setup_everything(); fx.scenario_mut().next_tx(test_constants::alice()); let market = fx.take_market_bundle(expiry_id); @@ -197,7 +197,7 @@ fun check_nav( helpers::assert_market_backed(expiry_market); } -/// Independent NAV oracle (unit-tests rule 1): `free_cash - Σ contribution` per +/// Independent NAV oracle (unit-tests rule 1): `cash - Σ contribution` per /// open order, using only order atoms and `pricing::range_price`. Every order is /// worth `qty·P(range)` live, so each contributes exactly that. The order's ticks /// are converted to raw strikes through the same `range_codec` boundary the @@ -211,6 +211,5 @@ fun reference_nav(market: &ExpiryMarket, pricer: &Pricer, order_ids: &vector 0); assert_eq!( quote.all_in_cost(), quote.premium() + (quote.trading_fee() - quote.fee_incentive_subsidy()) + quote.builder_fee() + quote.penalty_fee() - + EXPECTED_SINGLE_ORDER_CHARGE, + + inventory_impact_charge, ); let balance_before_mint = fx.account_balance_bundle(&account); @@ -60,20 +71,24 @@ fun mint_charge_and_live_close_rebate_use_isolated_escrow() { fx.account_balance_bundle(&account), balance_before_mint - quote.all_in_cost(), ); - assert_eq!( - helpers::market(&market).cash_balance(), + let cash_after_mint = cash_before_mint - + quote.premium() - + quote.trading_fee() - + quote.penalty_fee() - + EXPECTED_SINGLE_ORDER_CHARGE, - ); - assert_eq!(helpers::market(&market).inventory_impact_reserve(), EXPECTED_SINGLE_ORDER_CHARGE); + + quote.premium() + + quote.trading_fee() + + quote.penalty_fee() + + inventory_impact_charge; + assert_eq!(helpers::market(&market).cash_balance(), cash_after_mint); + assert_eq!(helpers::market(&market).inventory_impact_potential(), inventory_impact_charge); helpers::assert_market_backed_bundle(&market); + let minted = event::events_by_type(); + assert_eq!(minted.length(), 1); + let (event_charge, k_before, k_after) = order_events::order_minted_inventory(&minted[0]); + assert_eq!(event_charge, inventory_impact_charge); + assert_eq!(k_before, 0); + assert!(k_after > 0); - // Reprice one millisecond later, then close the only position. Its full - // liability reduction returns the exact charge independently of the normal - // close fee. + // Reprice one millisecond later, then close the only position. It removes + // the book's entire capital, and pays back none of the charge. fx.advance_live_oracle_bundle(&mut market, test_constants::default_live_price()); let gross = fx.live_order_value_bundle(&market, order_id); let balance_before_close = fx.account_balance_bundle(&account); @@ -84,11 +99,64 @@ fun mint_charge_and_live_close_rebate_use_isolated_escrow() { test_constants::mint_quantity(), ); - assert_eq!(helpers::market(&market).inventory_impact_reserve(), 0); assert_eq!( fx.account_balance_bundle(&account), - balance_before_close + gross + EXPECTED_SINGLE_ORDER_CHARGE - ORDINARY_MIN_FEE, + balance_before_close + gross - ORDINARY_MIN_FEE, + ); + // The close moved only the payout and its fee, so the charge collected at + // mint is still in the market's cash. + assert_eq!(helpers::market(&market).cash_balance(), cash_after_mint - gross + ORDINARY_MIN_FEE); + assert_eq!(helpers::market(&market).inventory_impact_potential(), 0); + let redeemed = event::events_by_type(); + assert_eq!(redeemed.length(), 1); + let (close_charge, close_before, close_after) = order_events::live_order_redeemed_inventory( + &redeemed[0], + ); + assert_eq!(close_charge, 0); + assert!(close_before > 0); + assert_eq!(close_after, 0); + helpers::assert_market_backed_bundle(&market); + + helpers::return_account_bundle(account); + helpers::return_market_bundle(market); + fx.finish(); +} + +#[test] +fun partial_closes_unwind_the_potential_without_a_stored_position_payout() { + let (mut fx, expiry_id, trader) = setup_enabled_market(); + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + fx.prepare_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.ensure_inventory_grid_bundle(&mut market); + fx.seed_market_cash( + helpers::market_mut(&mut market), + test_constants::default_seeded_expiry_cash(), + ); + + let order_id = fx.mint_bundle( + &mut market, + &mut account, + helpers::strike_tick(), + constants::pos_inf_tick!(), + test_constants::mint_quantity(), ); + let potential_after_mint = helpers::market(&market).inventory_impact_potential(); + assert!(potential_after_mint > 0); + + // Each close re-derives its own expected payout from the grid snapshot, so + // two halves must unwind exactly what the whole added. + let half = test_constants::mint_quantity() / 2; + fx.advance_live_oracle_bundle(&mut market, test_constants::default_live_price()); + let replacement_id = fx + .redeem_live_bundle(&mut market, &mut account, order_id, half) + .destroy_some(); + assert!(helpers::market(&market).inventory_impact_potential() < potential_after_mint); + + fx.advance_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.redeem_live_bundle(&mut market, &mut account, replacement_id, half); + assert!(!helpers::has_position_bundle(&account, expiry_id, replacement_id)); + assert_eq!(helpers::market(&market).inventory_impact_potential(), 0); helpers::assert_market_backed_bundle(&market); helpers::return_account_bundle(account); @@ -97,16 +165,23 @@ fun mint_charge_and_live_close_rebate_use_isolated_escrow() { } #[test] -fun settlement_releases_unused_inventory_escrow_to_pool_surplus() { +fun settlement_leaves_the_collected_charge_in_market_cash() { let (mut fx, expiry_id, trader) = setup_enabled_market(); let mut market = fx.take_market_bundle(expiry_id); let mut account = fx.take_account_bundle(&trader); fx.prepare_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.ensure_inventory_grid_bundle(&mut market); fx.seed_market_cash( helpers::market_mut(&mut market), test_constants::default_seeded_expiry_cash(), ); + let quote = fx.quote_mint_bundle( + &market, + helpers::strike_tick(), + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + ); fx.mint_bundle( &mut market, &mut account, @@ -114,17 +189,90 @@ fun settlement_releases_unused_inventory_escrow_to_pool_surplus() { constants::pos_inf_tick!(), test_constants::mint_quantity(), ); - assert_eq!(helpers::market(&market).inventory_impact_reserve(), EXPECTED_SINGLE_ORDER_CHARGE); + assert!(quote.inventory_impact_charge() > 0); let cash_before_settlement = helpers::market(&market).cash_balance(); fx.set_clock_for_testing(test_constants::short_expiry_ms()); fx.insert_exact_settlement_spot_bundle(&mut market, test_constants::default_live_price()); assert!(fx.try_settle_bundle(&mut market)); - // Settlement changes only the earmark: no cash leaves the market, and no - // later close can claim an inventory rebate. - assert_eq!(helpers::market(&market).inventory_impact_reserve(), 0); + // Settlement moves no cash, so the charge the market collected while live is + // still there for the settled sweep to return to the pool. assert_eq!(helpers::market(&market).cash_balance(), cash_before_settlement); + assert_eq!(helpers::market(&market).inventory_impact_potential(), 0); + helpers::assert_market_backed_bundle(&market); + + helpers::return_account_bundle(account); + helpers::return_market_bundle(market); + fx.finish(); +} + +#[test] +fun live_close_that_removes_a_hedge_collects_inventory_charge() { + let (mut fx, expiry_id, trader) = setup_enabled_market(); + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + fx.prepare_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.ensure_inventory_grid_bundle(&mut market); + fx.seed_market_cash( + helpers::market_mut(&mut market), + test_constants::default_seeded_expiry_cash(), + ); + + let median_tick = helpers::strike_tick(); + let risky_quote = fx.quote_mint_bundle( + &market, + 0, + median_tick, + test_constants::mint_quantity(), + ); + let charge = risky_quote.inventory_impact_charge(); + assert!(charge > 0); + fx.mint_exact_quantity_bundle( + &mut market, + &mut account, + 0, + median_tick, + test_constants::mint_quantity(), + std::u64::max_value!(), + std::u64::max_value!(), + ); + + let hedge_quote = fx.quote_mint_bundle( + &market, + median_tick, + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + ); + assert_eq!(hedge_quote.inventory_impact_charge(), 0); + let hedge_order_id = fx.mint_exact_quantity_bundle( + &mut market, + &mut account, + median_tick, + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + std::u64::max_value!(), + std::u64::max_value!(), + ); + assert_eq!(helpers::market(&market).inventory_impact_potential(), 0); + let cash_before_close = helpers::market(&market).cash_balance(); + + fx.advance_live_oracle_bundle(&mut market, test_constants::default_live_price()); + let gross = fx.live_order_value_bundle(&market, hedge_order_id); + fx.redeem_live_bundle( + &mut market, + &mut account, + hedge_order_id, + test_constants::mint_quantity(), + ); + + // Removing the hedge restores the book's original capital, so the close pays + // the same charge the risky mint did, on top of the ordinary close fee. + assert_eq!(helpers::market(&market).inventory_impact_potential(), charge); + assert_eq!( + helpers::market(&market).cash_balance(), + cash_before_close - gross + ORDINARY_MIN_FEE + charge, + ); helpers::assert_market_backed_bundle(&market); helpers::return_account_bundle(account); @@ -132,6 +280,57 @@ fun settlement_releases_unused_inventory_escrow_to_pool_surplus() { fx.finish(); } +#[test, expected_failure(abort_code = expiry_market::ERedeemCostAboveMax)] +fun live_close_inventory_debit_obeys_max_cost() { + let mut fx = helpers::setup_market_default(); + fx.set_template_backing_buffer_lambda(BACKING_BUFFER_LAMBDA); + fx.set_template_inventory_impact_max_rate( + config_constants::max_inventory_impact_max_rate!(), + ); + fx.set_template_inventory_impact_scale(config_constants::min_inventory_impact_scale!()); + let expiry_id = fx.create_expiry(test_constants::short_expiry_ms()); + let trader = fx.create_funded_manager(3 * test_constants::mint_deposit()); + + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + fx.prepare_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.ensure_inventory_grid_bundle(&mut market); + fx.seed_market_cash( + helpers::market_mut(&mut market), + test_constants::default_seeded_expiry_cash(), + ); + + let median_tick = helpers::strike_tick(); + fx.mint_exact_quantity_bundle( + &mut market, + &mut account, + 0, + median_tick, + test_constants::mint_quantity(), + std::u64::max_value!(), + std::u64::max_value!(), + ); + let hedge_order_id = fx.mint_exact_quantity_bundle( + &mut market, + &mut account, + median_tick, + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + std::u64::max_value!(), + std::u64::max_value!(), + ); + + fx.advance_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.redeem_live_bundle_with_max_cost( + &mut market, + &mut account, + hedge_order_id, + test_constants::mint_quantity(), + 0, + ); + abort 999 +} + #[test] fun live_order_value_does_not_require_book_membership() { let (mut fx, expiry_id, _) = setup_enabled_market(); @@ -154,12 +353,78 @@ fun live_order_value_does_not_require_book_membership() { fx.finish(); } +#[test, expected_failure(abort_code = strike_exposure::EInventoryGridRequired)] +fun mint_without_a_grid_aborts_when_the_rate_is_on() { + let (mut fx, expiry_id, trader) = setup_enabled_market(); + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + fx.prepare_live_oracle_bundle(&mut market, test_constants::default_live_price()); + fx.seed_market_cash( + helpers::market_mut(&mut market), + test_constants::default_seeded_expiry_cash(), + ); + let _order_id = fx.mint_exact_quantity_bundle( + &mut market, + &mut account, + helpers::strike_tick(), + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + test_constants::mint_deposit(), + std::u64::max_value!(), + ); + abort 999 +} + +#[test] +fun create_with_inventory_grid_is_filled_before_the_first_mint() { + let mut fx = helpers::setup_market_default(); + fx.set_template_backing_buffer_lambda(BACKING_BUFFER_LAMBDA); + fx.set_template_inventory_impact_max_rate(IMPACT_MAX_RATE); + fx.set_template_inventory_impact_scale(IMPACT_SCALE); + let expiry_id = fx.create_expiry_with_inventory(test_constants::short_expiry_ms()); + let trader = fx.create_funded_manager(3 * test_constants::mint_deposit()); + let mut market = fx.take_market_bundle(expiry_id); + let mut account = fx.take_account_bundle(&trader); + assert!(helpers::market(&market).has_inventory_grid()); + fx.seed_market_cash( + helpers::market_mut(&mut market), + test_constants::default_seeded_expiry_cash(), + ); + + let quote = fx.quote_mint_bundle( + &market, + helpers::strike_tick(), + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + ); + let inventory_impact_charge = quote.inventory_impact_charge(); + assert!(inventory_impact_charge > 0); + + let _order_id = fx.mint_exact_quantity_bundle( + &mut market, + &mut account, + helpers::strike_tick(), + constants::pos_inf_tick!(), + test_constants::mint_quantity(), + quote.all_in_cost(), + std::u64::max_value!(), + ); + assert_eq!( + helpers::market(&market).inventory_impact_potential(), + inventory_impact_charge, + ); + + helpers::return_account_bundle(account); + helpers::return_market_bundle(market); + fx.finish(); +} + fun setup_enabled_market(): (helpers::Fixture, ID, helpers::Trader) { let mut fx = helpers::setup_market_default(); fx.set_template_backing_buffer_lambda(BACKING_BUFFER_LAMBDA); fx.set_template_inventory_impact_max_rate(IMPACT_MAX_RATE); - fx.set_default_cadence_allocation(IMPACT_SCALE, constants::expiry_cash_floor!()); + fx.set_template_inventory_impact_scale(IMPACT_SCALE); let expiry_id = fx.create_expiry(test_constants::short_expiry_ms()); - let trader = fx.create_funded_manager(test_constants::mint_deposit()); + let trader = fx.create_funded_manager(3 * test_constants::mint_deposit()); (fx, expiry_id, trader) } diff --git a/packages/predict/tests/helper/flow_test_helpers.move b/packages/predict/tests/helper/flow_test_helpers.move index 7fe979d6d..a984831cb 100644 --- a/packages/predict/tests/helper/flow_test_helpers.move +++ b/packages/predict/tests/helper/flow_test_helpers.move @@ -32,6 +32,7 @@ use deepbook_predict::{ builder_code::BuilderCode, constants, expiry_market::{ExpiryMarket, MintQuote}, + inventory_grid, market_lifecycle_cap::MarketLifecycleCap, market_manager, plp::{Self, PoolVault, PoolValuation}, @@ -55,6 +56,7 @@ use sui::{ accumulator::AccumulatorRoot, clock::{Self, Clock}, coin, + object, test_scenario::{Self as test, Scenario, return_shared}, tx_context::{Self, TxContext} }; @@ -312,6 +314,87 @@ public fun create_expiry(self: &mut Fixture, expiry: u64): ID { expiry_id } +/// Seed the live surface for `expiry`, then create through the inventory-grid +/// path so the mass-checked ladder lands in the same transaction as share. +public fun create_expiry_with_inventory(self: &mut Fixture, expiry: u64): ID { + let create_clock_ms = expiry - test_constants::default_cadence_period_ms(); + self.clock.set_for_testing(create_clock_ms); + self.scenario.next_tx(test_constants::admin()); + let mut pyth = self.scenario.take_shared_by_id(self.pyth_id); + let mut bs = self.take_bs(); + store_pyth_spot( + &mut self.scenario, + &mut pyth, + test_constants::default_live_price(), + create_clock_ms, + create_clock_ms, + ); + self.seed_bs_surface_with_svi_source( + expiry, + &mut bs, + test_constants::default_live_price(), + test_constants::default_live_price(), + test_constants::default_svi_a(), + false, + test_constants::default_svi_b(), + test_constants::default_svi_sigma(), + test_constants::default_svi_rho_magnitude(), + false, + test_constants::default_svi_m(), + false, + create_clock_ms, + create_clock_ms, + ); + return_bs(bs); + return_shared(pyth); + + self.scenario.next_tx(test_constants::admin()); + let mut vault = self.scenario.take_shared_by_id(self.vault_id); + let mut registry = self.scenario.take_shared(); + let oracle_registry = self.scenario.take_shared(); + let config = self.scenario.take_shared(); + let pyth = self.scenario.take_shared_by_id(self.pyth_id); + let bs = self.take_bs(); + let mut creation_clock = clock::create_for_testing(self.scenario.ctx()); + creation_clock.set_for_testing(create_clock_ms); + let pricer = pricing::load_live_pricer( + config.pricing_config(), + &oracle_registry, + &pyth, + bs.values(), + bs.svi(), + object::id_from_address(@0x0), + test_constants::propbook_underlying_id(), + expiry, + &creation_clock, + self.scenario.ctx(), + ); + let ratios = inventory_grid::from_pricer(&pricer).ratios(); + let expiry_id = registry.create_and_share_expiry_market_with_inventory_grid( + &mut vault, + &config, + &oracle_registry, + &pyth, + bs.values(), + bs.svi(), + &self.lifecycle_cap, + test_constants::propbook_underlying_id(), + test_constants::default_cadence_id(), + ratios, + &creation_clock, + self.scenario.ctx(), + ); + creation_clock.destroy_for_testing(); + return_bs(bs); + return_shared(pyth); + return_shared(config); + return_shared(oracle_registry); + return_shared(registry); + return_shared(vault); + self.scenario.next_tx(test_constants::admin()); + expiry_id +} + public fun create_next_expiry_for_cadence(self: &mut Fixture, cadence_id: u8): ID { self.scenario.next_tx(test_constants::admin()); let mut vault = self.scenario.take_shared_by_id(self.vault_id); @@ -503,6 +586,31 @@ public fun set_template_inventory_impact_max_rate(self: &mut Fixture, value: u64 self.scenario.next_tx(test_constants::admin()); } +/// Set the frozen-grid capital scale snapshotted by subsequently created markets. +public fun set_template_inventory_impact_scale(self: &mut Fixture, value: u64) { + self.scenario.next_tx(test_constants::admin()); + let mut config = self.scenario.take_shared(); + config.set_template_inventory_impact_scale(&self.admin_cap, value); + return_shared(config); + self.scenario.next_tx(test_constants::admin()); +} + +/// Persist a test invert so a later quote has a grid. +public fun ensure_inventory_grid_bundle(self: &mut Fixture, market: &mut MarketBundle) { + let pricer = market + .market + .load_live_pricer( + &market.config, + &market.oracle_registry, + &market.pyth, + market.bs.values(), + market.bs.svi(), + &self.clock, + self.scenario.ctx(), + ); + market.market.ensure_inventory_grid_for_testing(&pricer); +} + /// Resize the default cadence's pool allocation terms through the production /// registry admin path. Call before creating the expiry that should snapshot them. public fun set_default_cadence_allocation( @@ -1046,7 +1154,7 @@ public fun seed_bs_surface( source_timestamp_ms: u64, ) { self.seed_bs_surface_with_svi_source( - market, + market.expiry(), bs, spot, forward, @@ -1117,7 +1225,7 @@ public fun seed_bs_surface_with_svi( ) { let svi_source_timestamp_ms = self.clock.timestamp_ms(); self.seed_bs_surface_with_svi_source( - market, + market.expiry(), bs, spot, forward, @@ -1136,7 +1244,7 @@ public fun seed_bs_surface_with_svi( fun seed_bs_surface_with_svi_source( self: &mut Fixture, - market: &ExpiryMarket, + expiry: u64, bs: &mut BlockScholesFeed, spot: u64, forward: u64, @@ -1152,7 +1260,6 @@ fun seed_bs_surface_with_svi_source( svi_source_timestamp_ms: u64, ) { let (ctx, restore) = begin_seed_tx(&mut self.scenario); - let expiry = market.expiry(); let spot_sid = bs.values().spot_sid(); let forward_sid = bs.values().forward_sid(expiry); let svi_sid = bs.svi().svi_sid(expiry); @@ -1599,6 +1706,7 @@ public fun redeem_live( close_quantity: u64, min_probability: u64, min_proceeds: u64, + max_cost: u64, ): Option { let auth = account::generate_auth(self.scenario.ctx()); let pricer = market.load_live_pricer( @@ -1619,6 +1727,7 @@ public fun redeem_live( close_quantity, min_probability, min_proceeds, + max_cost, root, &self.clock, self.scenario.ctx(), @@ -1647,6 +1756,7 @@ public fun redeem_live_bundle_with_pyth( close_quantity, 0, 0, + std::u64::max_value!(), ) } @@ -1670,6 +1780,7 @@ public fun redeem_live_bundle( close_quantity, 0, 0, + std::u64::max_value!(), ) } @@ -1696,6 +1807,33 @@ public fun redeem_live_bundle_with_limits( close_quantity, min_probability, min_proceeds, + std::u64::max_value!(), + ) +} + +/// Close a live order with an explicit cap on any DUSDC debit needed when +/// deductions exceed gross redeem proceeds. +public fun redeem_live_bundle_with_max_cost( + self: &mut Fixture, + market: &mut MarketBundle, + account: &mut AccountBundle, + order_id: u256, + close_quantity: u64, + max_cost: u64, +): Option { + self.redeem_live( + &market.config, + &market.oracle_registry, + &mut account.wrapper, + &account.root, + &mut market.market, + &market.pyth, + &market.bs, + order_id, + close_quantity, + 0, + 0, + max_cost, ) } @@ -2000,11 +2138,10 @@ public fun finish_flush_bundle( // === Invariant assertions (rule 17 one-call checks) === /// S1 — expiry cash backing: the market's DUSDC custody covers its payout -/// liability plus its isolated inventory-impact escrow, mirroring the contract's -/// `expiry_cash::assert_backing`. Assert after every cash-mutating flow (mint / -/// redeem / sync). +/// liability, mirroring the contract's `expiry_cash::assert_backing`. Assert +/// after every cash-mutating flow (mint / redeem / sync). public fun assert_market_backed(market: &ExpiryMarket) { - assert!(market.cash_balance() >= market.payout_liability() + market.inventory_impact_reserve()); + assert!(market.cash_balance() >= market.payout_liability()); } /// S1 backing assertion for a market bundle. @@ -2013,8 +2150,7 @@ public fun assert_market_backed_bundle(market: &MarketBundle) { } /// Expected snapshot of one expiry market's cash and payout backing, asserted in -/// one call by `check_market_cash`. The isolated inventory-impact escrow is not a -/// field here — it ships at a zero rate, and `assert_market_backed` covers it. +/// one call by `check_market_cash`. public struct ExpectedMarketCash has copy, drop { /// DUSDC held by the expiry (`market.cash_balance()`). cash_balance: u64, diff --git a/packages/predict/tests/helper/frozen_grid_fixture.move b/packages/predict/tests/helper/frozen_grid_fixture.move new file mode 100644 index 000000000..a1fc011e2 --- /dev/null +++ b/packages/predict/tests/helper/frozen_grid_fixture.move @@ -0,0 +1,120 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Production-valid forward-relative quantile ratios for the default short-expiry +/// oracle fixture. +/// +/// Each entry is one interior bucket boundary as `strike / forward`, 1e9-scaled, so +/// `1_000_000_000` is the forward itself. The open ends are the grid's own +/// sentinels and are not supplied here. +/// +/// These are inject-ladder inputs for mass and ordering guards, not the production +/// invert. They were generated against the pinned Move pricer so a well-formed +/// ladder still verifies on-chain; production pushes its own off-chain invert. +#[test_only] +module deepbook_predict::frozen_grid_fixture; + +/// The 99 interior boundaries, ascending through the forward at index 49. +public fun ratios(): vector { + vector[ + 999_926_724, + 999_935_311, + 999_940_760, + 999_944_857, + 999_948_191, + 999_951_028, + 999_953_516, + 999_955_743, + 999_957_768, + 999_959_634, + 999_961_368, + 999_962_989, + 999_964_521, + 999_965_971, + 999_967_354, + 999_968_677, + 999_969_946, + 999_971_167, + 999_972_348, + 999_973_491, + 999_974_598, + 999_975_676, + 999_976_727, + 999_977_752, + 999_978_755, + 999_979_736, + 999_980_696, + 999_981_642, + 999_982_568, + 999_983_482, + 999_984_381, + 999_985_268, + 999_986_143, + 999_987_008, + 999_987_863, + 999_988_708, + 999_989_546, + 999_990_378, + 999_991_202, + 999_992_020, + 999_992_833, + 999_993_642, + 999_994_444, + 999_995_245, + 999_996_042, + 999_996_836, + 999_997_630, + 999_998_420, + 999_999_210, + 1_000_000_000, + 1_000_000_790, + 1_000_001_580, + 1_000_002_371, + 1_000_003_165, + 1_000_003_959, + 1_000_004_755, + 1_000_005_556, + 1_000_006_358, + 1_000_007_167, + 1_000_007_981, + 1_000_008_798, + 1_000_009_622, + 1_000_010_454, + 1_000_011_292, + 1_000_012_137, + 1_000_012_992, + 1_000_013_858, + 1_000_014_732, + 1_000_015_619, + 1_000_016_518, + 1_000_017_432, + 1_000_018_359, + 1_000_019_304, + 1_000_020_265, + 1_000_021_246, + 1_000_022_248, + 1_000_023_274, + 1_000_024_324, + 1_000_025_400, + 1_000_026_510, + 1_000_027_653, + 1_000_028_834, + 1_000_030_055, + 1_000_031_324, + 1_000_032_648, + 1_000_034_031, + 1_000_035_480, + 1_000_037_012, + 1_000_038_634, + 1_000_040_368, + 1_000_042_232, + 1_000_044_260, + 1_000_046_487, + 1_000_048_975, + 1_000_051_812, + 1_000_055_147, + 1_000_059_245, + 1_000_064_694, + 1_000_073_280, + ] +} diff --git a/packages/predict/tests/pricing/pricing_guard_tests.move b/packages/predict/tests/pricing/pricing_guard_tests.move index 3a54b1365..e0a9e6640 100644 --- a/packages/predict/tests/pricing/pricing_guard_tests.move +++ b/packages/predict/tests/pricing/pricing_guard_tests.move @@ -933,8 +933,9 @@ fun surface_with_svi_sigma_above_max_aborts() { /// A surface whose forward is tiny relative to the BS spot passes the envelope /// (there is no LOWER basis bound), but re-anchoring at a pyth spot far below the -/// BS spot floors `spot * bs_forward / bs_spot` to 0, and `compute_nd2` aborts on -/// the first finite-strike quote. +/// BS spot floors `spot * bs_forward / bs_spot` to 0, which the pricer load rejects +/// before it can take the forward's logarithm. The quote below is therefore +/// unreachable; the abort happens at `load_pricer_bundle`. #[test, expected_failure(abort_code = pricing::EZeroForward)] fun re_anchored_zero_forward_aborts() { let mut fx = oracle_fixture::setup_oracle_default(); diff --git a/packages/predict/tests/reference_tick_tests.move b/packages/predict/tests/reference_tick_tests.move index cf81ad3ae..201ae7c95 100644 --- a/packages/predict/tests/reference_tick_tests.move +++ b/packages/predict/tests/reference_tick_tests.move @@ -269,7 +269,6 @@ fun create_and_share_exposure_harness(fx: &mut OracleFixture): ID { test_constants::default_tick_size(), test_constants::default_admission_tick_size(), fx.expiry() - test_constants::default_cadence_period_ms(), - 1_000_000_000, fx.scenario_mut().ctx(), ); transfer::share_object(ExposureHarness { id, exposure }); diff --git a/packages/predict/tests/strike_exposure/close_terms_boundary_tests.move b/packages/predict/tests/strike_exposure/close_terms_boundary_tests.move index 6c4d55699..9b23989a8 100644 --- a/packages/predict/tests/strike_exposure/close_terms_boundary_tests.move +++ b/packages/predict/tests/strike_exposure/close_terms_boundary_tests.move @@ -84,7 +84,6 @@ fun create_and_share_exposure_harness( test_constants::default_tick_size(), test_constants::default_tick_size(), expiry_ms - test_constants::default_cadence_period_ms(), - 1_000_000_000, fx.scenario_mut().ctx(), ); transfer::share_object(ExposureHarness { id, exposure }); diff --git a/packages/predict/tests/strike_exposure/inventory_cells_tests.move b/packages/predict/tests/strike_exposure/inventory_cells_tests.move new file mode 100644 index 000000000..4f4f2bbad --- /dev/null +++ b/packages/predict/tests/strike_exposure/inventory_cells_tests.move @@ -0,0 +1,91 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Lattice construction, snapping, and apply/empty coverage for the inline +/// inventory-cell mirror. Economic charges live in `inventory_impact_tests`. +#[test_only] +module deepbook_predict::inventory_cells_tests; + +use deepbook_predict::{constants, inventory_cells}; +use fixed_math::math; +use std::unit_test::{assert_eq, destroy}; + +const LADDER_LOW: u64 = 90_000_000_000; +const LADDER_HIGH: u64 = 110_000_000_000; +const RANGE_LOW: u64 = 95_000_000_000; +const RANGE_HIGH: u64 = 105_000_000_000; +const QUANTITY: u64 = 1_000_000; + +#[test] +fun an_open_line_covers_every_cell() { + let cells = inventory_cells::new(LADDER_LOW, LADDER_HIGH); + let (start, stop) = cells.cell_span(constants::neg_inf!(), constants::pos_inf!()); + assert_eq!(start, 0); + assert_eq!(stop, inventory_cells::cell_count!()); + destroy(cells); +} + +#[test] +fun apply_then_close_empties_the_mirror() { + let mut cells = inventory_cells::new(LADDER_LOW, LADDER_HIGH); + let (start, stop) = cells.cell_span(RANGE_LOW, RANGE_HIGH); + assert!(stop > start); + + cells.apply_span(start, stop, QUANTITY, true); + assert!(!cells.is_empty()); + assert_eq!(cells.cell_value(start), QUANTITY); + assert_eq!(cells.cell_value(stop - 1), QUANTITY); + assert_eq!(cells.span_max(start, stop, 0, 0, 0, true), QUANTITY); + + cells.apply_span(start, stop, QUANTITY, false); + assert!(cells.is_empty()); + assert_eq!(cells.cell_value(start), 0); + assert_eq!(cells.span_max(start, stop, 0, 0, 0, true), 0); + destroy(cells); +} + +#[test] +fun a_quoted_span_matches_the_committed_one() { + let mut cells = inventory_cells::new(LADDER_LOW, LADDER_HIGH); + let (start, stop) = cells.cell_span(RANGE_LOW, RANGE_HIGH); + cells.apply_span(start, stop, QUANTITY, true); + + // Prospective max over the existing span plus a second equal order equals 2x. + assert_eq!(cells.span_max(start, stop, start, stop, QUANTITY, true), QUANTITY + QUANTITY); + cells.apply_span(start, stop, QUANTITY, true); + assert_eq!(cells.span_max(start, stop, 0, 0, 0, true), QUANTITY + QUANTITY); + destroy(cells); +} + +#[test] +fun a_boundary_price_indexes_back_to_itself() { + let cells = inventory_cells::new(LADDER_LOW, LADDER_HIGH); + let index = inventory_cells::cell_count!() / 2; + let price = cells.boundary_price_for_testing(index); + assert_eq!(cells.boundary_index(price), index); + destroy(cells); +} + +#[test] +fun a_logged_price_indexes_the_same_cell_as_the_raw_price() { + let cells = inventory_cells::new(LADDER_LOW, LADDER_HIGH); + let index = inventory_cells::cell_count!() / 2; + let price = cells.boundary_price_for_testing(index); + assert_eq!(cells.boundary_index_from_ln(&math::ln(price)), cells.boundary_index(price)); + destroy(cells); +} + +#[test, expected_failure(abort_code = inventory_cells::EInvalidCellSpan)] +fun lattice_rejects_an_open_lower_end() { + destroy(inventory_cells::new(constants::neg_inf!(), LADDER_HIGH)); +} + +#[test, expected_failure(abort_code = inventory_cells::EInvalidCellSpan)] +fun lattice_rejects_an_open_upper_end() { + destroy(inventory_cells::new(LADDER_LOW, constants::pos_inf!())); +} + +#[test, expected_failure(abort_code = inventory_cells::EInvalidCellSpan)] +fun lattice_rejects_a_non_increasing_span() { + destroy(inventory_cells::new(LADDER_HIGH, LADDER_LOW)); +} diff --git a/packages/predict/tests/strike_exposure/inventory_impact_tests.move b/packages/predict/tests/strike_exposure/inventory_impact_tests.move index 652c3f592..cb1dbb94a 100644 --- a/packages/predict/tests/strike_exposure/inventory_impact_tests.move +++ b/packages/predict/tests/strike_exposure/inventory_impact_tests.move @@ -1,298 +1,596 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -/// Economic invariants for the book-level inventory-impact potential. -/// -/// The important regression is the cross-range cycle: charges and rebates are -/// differences of one state function, so a trader cannot profit by opening one -/// probability range, changing the book with another, and closing both. +/// Economic and state-transition tests for frozen-grid inventory impact. #[test_only] module deepbook_predict::inventory_impact_tests; use deepbook_predict::{ constants, + frozen_grid_fixture, + inventory_grid::{Self, InventoryGrid}, oracle_fixture::{Self, OracleBundle, OracleFixture}, - order::Order, + pricing::Pricer, + range_codec, strike_exposure::{Self, StrikeExposure}, strike_exposure_config::{Self, StrikeExposureConfig}, test_constants }; use fixed_math::math; -use std::unit_test::assert_eq; -use sui::{clock::Clock, object::{Self, UID}, test_scenario::return_shared, tx_context}; +use std::unit_test::{assert_eq, destroy}; +use sui::{object::{Self, UID}, test_scenario::return_shared}; public struct ExposureHarness has key { id: UID, exposure: StrikeExposure, } -const IMPACT_SCALE: u64 = 4_000_000_000; -const IMPACT_MAX_RATE: u64 = 200_000_000; // 20% -const BACKING_BUFFER_LAMBDA: u64 = 500_000_000; // 50% +const TEST_TICK_SIZE: u64 = 10_000; +const TEST_ADMISSION_TICK_SIZE: u64 = 10_000; +const IMPACT_SCALE: u64 = 1_000_000_000; +const IMPACT_MAX_RATE: u64 = 20_000_000; // 2% const ONE_ORDER: u64 = 1_000_000_000; -const ROUNDING_IMPACT_SCALE: u64 = 50_000_000; -const ROUNDING_BUFFER_LAMBDA: u64 = 333_333_333; -const ROUNDING_DOMINANT_QUANTITY: u64 = 100_000_000; -const ROUNDING_SEED_QUANTITY: u64 = 10_000_000; -const ROUNDING_CARRY_QUANTITY: u64 = 30_000_000; -const ROUNDING_BEFORE_POTENTIAL: u64 = 78_333_333; -const ROUNDING_AFTER_POTENTIAL: u64 = 88_333_333; +const FIVE_BUCKET_CAPITAL: u64 = 950_000_000; +const MANY_ORDERS: u64 = 24; +const MANY_ORDER_QUANTITY: u64 = 1_000_000; +const MANY_ORDER_BASE_TICK: u64 = 9_999_400; +/// Ticks between adjacent order boundaries in the loaded-book test. One cell is +/// about forty ticks wide on this fixture, so a four-tick stride spreads these +/// orders across several distinct cells: enough that the centering walk crosses +/// many payout change points, while keeping every boundary below the tail buckets +/// so each of those still sees the whole book. +const MANY_ORDER_TICK_STRIDE: u64 = 4; #[test] fun default_zero_rate_is_a_kill_switch() { - let (mut fx, oracle, mut harness) = disabled_harness(); + let (mut fx, oracle, mut harness) = new_harness(impact_config(0)); let pricer = fx.load_pricer_bundle(&oracle); let terms = harness .exposure .quote_mint_terms( &pricer, - test_constants::default_strike_tick(), + test_constants::default_live_price() / TEST_TICK_SIZE, constants::pos_inf_tick!(), 0, ONE_ORDER, true, ); - assert_eq!(terms.inventory_impact_charge(), 0); + assert_eq!(terms.k_before(), 0); + assert_eq!(terms.k_after(), 0); + assert_eq!(terms.frozen_expected_payout(), 0); let order = harness.exposure.allocate_mint_order(terms); assert_eq!(harness.exposure.inventory_impact_potential(), 0); let close = harness.exposure.quote_live_close(&pricer, &order, order.quantity()); - assert_eq!(close.inventory_impact_rebate(), 0); - + assert_eq!(close.live_close_inventory_impact_charge(), 0); + harness.exposure.process_live_close(close); + assert_eq!(harness.exposure.inventory_impact_potential(), 0); cleanup(fx, oracle, harness); } #[test] -fun quadratic_below_scale_and_linear_above_scale() { - let (mut fx, oracle, mut harness) = enabled_harness(); +fun five_bucket_coordinate_matches_independent_reference() { + let mut maxima = vector[]; + let mut index = 0u64; + while (index < 100) { + maxima.push_back(if (index < 5) ONE_ORDER else 0); + index = index + 1; + }; + assert_eq!(inventory_grid::capital_from_components(maxima, 50_000_000), FIVE_BUCKET_CAPITAL); +} + +#[test] +fun quantile_grid_pile_on_round_trips_frozen_capital() { + let (mut fx, oracle, mut harness) = new_harness(impact_config(IMPACT_MAX_RATE)); let pricer = fx.load_pricer_bundle(&oracle); + harness.exposure.ensure_inventory_grid(&pricer); + // Interior boundary 5 is the 5% quantile rematerialized against this forward. + let five_percent_boundary = harness.exposure.test_inventory_grid().boundary(5); + let five_percent_boundary_tick = + five_percent_boundary / TEST_TICK_SIZE + + if (five_percent_boundary % TEST_TICK_SIZE == 0) 0 else 1; - // Same range means liability equals total net payout. At L=3B/4: - // marginal rate = 20% * 3/4 = 15%; phi = 15% * 3B / 2 = 225M. - let first = quote_mint(&harness.exposure, &pricer, 3_000_000_000, fx.clock()); - assert_eq!(first.inventory_impact_charge(), 225_000_000); - let first_order = harness.exposure.allocate_mint_order(first); - assert_eq!(harness.exposure.inventory_impact_potential(), 225_000_000); - - // At L=5B/4, phi(B)=20%*B/2=400M and the 1B excess costs - // the capped 20%, for 600M total. The second trade therefore costs 375M. - let second = quote_mint(&harness.exposure, &pricer, 2_000_000_000, fx.clock()); - assert_eq!(second.inventory_impact_charge(), 375_000_000); - let second_order = harness.exposure.allocate_mint_order(second); - assert_eq!(harness.exposure.inventory_impact_potential(), 600_000_000); - - // Reverse the mutations: each close returns the exact potential decrement. - let second_close = harness + let terms = harness .exposure - .quote_live_close(&pricer, &second_order, second_order.quantity()); - assert_eq!(second_close.inventory_impact_rebate(), 375_000_000); - harness.exposure.process_live_close(second_close); + .quote_mint_terms( + &pricer, + 0, + five_percent_boundary_tick, + 0, + ONE_ORDER, + true, + ); + let frozen_expected_payout = terms.frozen_expected_payout(); + let inventory_impact_charge = terms.inventory_impact_charge(); + // The range is the first five 1% buckets, so the centering term is the snapped + // cell-span mass of that tail and is strictly inside `(0, quantity)`. + assert!(frozen_expected_payout > 0); + assert!(frozen_expected_payout < ONE_ORDER); + assert!(inventory_impact_charge > 0); + let order = harness.exposure.allocate_mint_order(terms); + assert_eq!(harness.exposure.inventory_impact_potential(), inventory_impact_charge); + let close = harness.exposure.quote_live_close(&pricer, &order, order.quantity()); + // Unwinding the pile-on is free and refunds nothing: the potential returns + // to zero while the charge collected at mint stays with the pool. + assert_eq!(close.live_close_inventory_impact_charge(), 0); + harness.exposure.process_live_close(close); + assert_eq!(harness.exposure.inventory_impact_potential(), 0); + cleanup(fx, oracle, harness); +} + +#[test] +fun a_partial_close_leaves_a_disjoint_peak_standing() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let mut grid = inventory_grid::initialize(&pricer, frozen_grid_fixture::ratios()); + + // Two disjoint ranges, each a few cells wide and a few cells apart, so the + // mirror records them separately. A is initially taller; after half of A closes, + // B must stand as the book's peak rather than A's reduced height. + let (a_lower, a_higher, a_quantity) = (10_000_000, 10_000_080, ONE_ORDER); + let (b_lower, b_higher, b_quantity) = (10_000_160, 10_000_240, 900_000_000); + + open_range(&mut grid, &pricer, a_lower, a_higher, a_quantity); + open_range(&mut grid, &pricer, b_lower, b_higher, b_quantity); + assert_eq!(grid.book_peak(), ONE_ORDER); + + let a_close_quantity = a_quantity / 2; + let a_close_expected = close_range(&mut grid, &pricer, a_lower, a_higher, a_close_quantity); + + // Naively subtracting A's close from a book-level maximum would report 500m. + // Because payout is held per region, B's independent 900m peak is what remains. + assert_eq!(grid.book_peak(), b_quantity); + assert!(grid.book_peak() != ONE_ORDER - a_close_quantity); + + let a_open_expected = grid.frozen_expected_payout( + a_lower, + a_higher, + a_quantity, + TEST_TICK_SIZE, + ); + grid.apply_change( + a_lower, + a_higher, + a_quantity - a_close_quantity, + a_open_expected - a_close_expected, + false, + TEST_TICK_SIZE, + ); + close_range(&mut grid, &pricer, b_lower, b_higher, b_quantity); + assert_eq!(grid.book_peak(), 0); + assert_eq!(grid.current_frozen_expected_payout(), 0); + assert_eq!(grid.k95(), 0); + + destroy(grid); + cleanup(fx, oracle, harness); +} + +#[test] +fun closing_a_hedge_pays_the_same_potential_increase_as_opening_risk() { + let (mut fx, oracle, mut harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + harness.exposure.ensure_inventory_grid(&pricer); - let first_close = harness + let median_tick = test_constants::default_live_price() / TEST_TICK_SIZE; + let risky_terms = harness .exposure - .quote_live_close(&pricer, &first_order, first_order.quantity()); - assert_eq!(first_close.inventory_impact_rebate(), 225_000_000); - harness.exposure.process_live_close(first_close); + .quote_mint_terms(&pricer, 0, median_tick, 0, ONE_ORDER, true); + let open_charge = risky_terms.inventory_impact_charge(); + assert!(open_charge > 0); + let risky_order = harness.exposure.allocate_mint_order(risky_terms); + + // The complementary range makes payout constant across settlement states. + // A risk-reducing mint is free but does not draw an inventory refund. + let hedge_terms = harness + .exposure + .quote_mint_terms( + &pricer, + median_tick, + constants::pos_inf_tick!(), + 0, + ONE_ORDER, + true, + ); + assert_eq!(hedge_terms.inventory_impact_charge(), 0); + let hedge_order = harness.exposure.allocate_mint_order(hedge_terms); + assert_eq!(harness.exposure.inventory_impact_potential(), 0); + + let hedge_close = harness.exposure.quote_live_close(&pricer, &hedge_order, ONE_ORDER); + assert_eq!(hedge_close.live_close_inventory_impact_charge(), open_charge); + harness.exposure.process_live_close(hedge_close).destroy_none(); + + let risky_close = harness.exposure.quote_live_close(&pricer, &risky_order, ONE_ORDER); + assert_eq!(risky_close.live_close_inventory_impact_charge(), 0); + harness.exposure.process_live_close(risky_close).destroy_none(); assert_eq!(harness.exposure.inventory_impact_potential(), 0); cleanup(fx, oracle, harness); } #[test] -fun cross_range_cycle_cannot_extract_inventory_escrow() { - let (mut fx, oracle, mut harness) = enabled_harness(); +fun first_mint_inverts_and_charges_without_a_prior_cut() { + // Test-only invert. Production installs a supplied ladder. + let (mut fx, oracle, mut harness) = new_harness(impact_config(IMPACT_MAX_RATE)); let pricer = fx.load_pricer_bundle(&oracle); - let strike = test_constants::default_strike_tick(); - - // A and B are complementary ranges with different quoted probabilities. - // Charge A: L=1B -> phi=25M. - let terms_a = quote_range_mint(&harness.exposure, &pricer, 0, strike, ONE_ORDER, fx.clock()); - let charge_a = terms_a.inventory_impact_charge(); - assert_eq!(charge_a, 25_000_000); - let order_a = harness.exposure.allocate_mint_order(terms_a); - - // With both disjoint positions, M=1B and T=2B. lambda=1/2 gives - // L=1.5B and phi=56.25M, so B adds 31.25M. - let terms_b = quote_range_mint( - &harness.exposure, - &pricer, - strike, - constants::pos_inf_tick!(), - ONE_ORDER, - fx.clock(), - ); - let charge_b = terms_b.inventory_impact_charge(); - assert_eq!(charge_b, 31_250_000); - let order_b = harness.exposure.allocate_mint_order(terms_b); - - // Close in the non-reverse order that defeated a range-local skew formula. - let close_a = harness.exposure.quote_live_close(&pricer, &order_a, order_a.quantity()); - let rebate_a = close_a.inventory_impact_rebate(); - assert_eq!(rebate_a, 31_250_000); - harness.exposure.process_live_close(close_a); - - let close_b = harness.exposure.quote_live_close(&pricer, &order_b, order_b.quantity()); - let rebate_b = close_b.inventory_impact_rebate(); - assert_eq!(rebate_b, 25_000_000); - harness.exposure.process_live_close(close_b); - - assert_eq!(charge_a + charge_b, rebate_a + rebate_b); - assert_eq!(harness.exposure.inventory_impact_potential(), 0); + assert!(!harness.exposure.has_inventory_grid()); + + harness.exposure.ensure_inventory_grid(&pricer); + assert!(harness.exposure.has_inventory_grid()); + let terms = harness + .exposure + .quote_mint_terms( + &pricer, + test_constants::default_live_price() / TEST_TICK_SIZE, + constants::pos_inf_tick!(), + 0, + ONE_ORDER, + true, + ); + let charge = terms.inventory_impact_charge(); + assert!(charge > 0); + assert_eq!(terms.k_before(), 0); + assert!(terms.k_after() > 0); + harness.exposure.allocate_mint_order(terms); + assert_eq!(harness.exposure.inventory_impact_potential(), charge); + cleanup(fx, oracle, harness); } +#[test, expected_failure(abort_code = strike_exposure::EInventoryGridRequired)] +fun quote_without_a_grid_aborts_when_the_rate_is_on() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + assert!(!harness.exposure.has_inventory_grid()); + let _terms = harness + .exposure + .quote_mint_terms( + &pricer, + test_constants::default_live_price() / TEST_TICK_SIZE, + constants::pos_inf_tick!(), + 0, + ONE_ORDER, + true, + ); + abort 999 +} + +#[test, expected_failure(abort_code = inventory_grid::EInvalidBoundaryCount)] +fun grid_boundary_count_is_exact() { + let (mut fx, oracle, _harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let mut boundaries = frozen_grid_fixture::ratios(); + boundaries.pop_back(); + destroy(inventory_grid::initialize(&pricer, boundaries)); + abort 999 +} + +#[test, expected_failure(abort_code = inventory_grid::EInvalidBoundary)] +fun grid_ratios_must_strictly_increase() { + let (mut fx, oracle, _harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let mut ratios = frozen_grid_fixture::ratios(); + // Two adjacent quantiles collapsed onto one value, which is how a real ladder + // fails: near expiry the distribution narrows until neighbours round together. + // Every earlier bucket still carries its 1%, so the ordering rule is what fires. + *ratios.borrow_mut(11) = ratios[10]; + destroy(inventory_grid::initialize(&pricer, ratios)); + abort 999 +} + #[test] -fun partial_close_schedule_telescopes_without_rounding_dust() { - let (mut fx, oracle, mut harness) = enabled_harness(); +fun the_grid_ladder_closes_both_ends_and_is_read_against_the_forward() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); let pricer = fx.load_pricer_bundle(&oracle); - let mint = quote_mint(&harness.exposure, &pricer, ONE_ORDER, fx.clock()); - let charge = mint.inventory_impact_charge(); - let order = harness.exposure.allocate_mint_order(mint); + let grid = inventory_grid::from_pricer(&pricer); - let first_close = harness.exposure.quote_live_close(&pricer, &order, 400_000_000); - let first_rebate = first_close.inventory_impact_rebate(); - let survivor = harness.exposure.process_live_close(first_close).destroy_some(); + // Invert supplies interior ratios only, so no settlement price sits outside + // every bucket: the open ends are the grid's own sentinels. + assert_eq!(grid.boundary(0), constants::neg_inf!()); + assert_eq!(grid.boundary(100), constants::pos_inf!()); + // The 50% survival strike is ATM, so the median rematerialized rung is the + // forward to within a tick of this short-dated fixture. + assert!(grid.boundary(50).diff(pricer.forward()) < TEST_TICK_SIZE); - let final_close = harness.exposure.quote_live_close(&pricer, &survivor, survivor.quantity()); - let final_rebate = final_close.inventory_impact_rebate(); - harness.exposure.process_live_close(final_close); + destroy(grid); + cleanup(fx, oracle, harness); +} - assert_eq!(charge, first_rebate + final_rebate); - assert_eq!(harness.exposure.inventory_impact_potential(), 0); +#[test] +fun a_log_sum_indexes_the_same_cell_as_the_dollar_rung() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let grid = inventory_grid::from_pricer(&pricer); + let cells = grid.cells(); + let ratios = grid.ratios(); + let ln_forward = pricer.ln_forward(); + + // The quote cut is `ln(ratio) + ln(F)`. The dollar rematerialize the mass + // check still uses is `ratio × F`. They must land in the same cell at the + // creation forward, or the pointer and the verified ladder disagree. + let mut index = 0; + while (index < ratios.length()) { + let dollar = math::mul_down(ratios[index], pricer.forward()); + let price_ln = math::ln(ratios[index]).add(&ln_forward); + assert_eq!(cells.boundary_index(dollar), cells.boundary_index_from_ln(&price_ln)); + index = index + 1; + }; + + destroy(grid); cleanup(fx, oracle, harness); } #[test] -fun buffered_liability_carry_is_included_in_charge_and_rebate() { - let (mut fx, oracle, mut harness) = rounding_harness(); +fun from_pricer_hits_the_specified_quantile_targets() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); let pricer = fx.load_pricer_bundle(&oracle); - let strike = test_constants::default_strike_tick(); + let grid = inventory_grid::from_pricer(&pricer); + let ratios = grid.ratios(); + assert_eq!(ratios.length(), 99); + + // Targets are the specified 1% ladder, not the invert's own output. Each + // rematerialized strike must price to its survival quantile inside the same + // 1bp envelope the mass check uses. + let mut index = 1; + while (index < 100) { + let strike = math::mul_div_down(ratios[index - 1], pricer.forward(), math::float_scaling!()); + let up = pricer.up_price(range_codec::strike_from_raw_boundary(strike)); + let target = math::float_scaling!() - index * 10_000_000; + assert!(up.diff(target) <= 100_000); + if (index > 1) { + assert!(ratios[index - 1] > ratios[index - 2]); + }; + index = index + 1; + }; + + destroy(grid); + cleanup(fx, oracle, harness); +} - // A 100M lower-range position owns M. The first disjoint 10M upper-range - // position leaves gap=10M and floor(lambda*gap)=3,333,333. - let dominant = quote_range_mint( - &harness.exposure, - &pricer, - 0, - strike, - ROUNDING_DOMINANT_QUANTITY, - fx.clock(), - ); - harness.exposure.allocate_mint_order(dominant); - let seed = quote_range_mint( - &harness.exposure, - &pricer, - strike, +#[test, expected_failure(abort_code = inventory_grid::EInvalidBucketMass)] +fun grid_rejects_a_bucket_outside_mass_tolerance() { + let (mut fx, oracle, _harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let mut boundaries = frozen_grid_fixture::ratios(); + *boundaries.borrow_mut(1) = (boundaries[1] + boundaries[2]) / 2; + destroy(inventory_grid::initialize(&pricer, boundaries)); + abort 999 +} + +#[test] +fun draining_a_seeded_book_clears_expected_payout() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let mut grid = seeded_grid(&pricer); + + close_seeded_orders(&mut grid, &pricer); + assert_eq!(grid.current_frozen_expected_payout(), 0); + assert_eq!(grid.k95(), 0); + + destroy(grid); + cleanup(fx, oracle, harness); +} + +#[test] +fun a_loaded_book_scores_from_the_cell_mirror_without_the_payout_tree() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let mut grid = inventory_grid::initialize(&pricer, frozen_grid_fixture::ratios()); + + // Many `(lower, +inf]` orders on distinct boundaries. Against the payout tree + // each is a stored child; here they are payout change points in one inline + // vector and no children at all. Every lower tick sits below the fifth-highest + // grid boundary, so all five tail buckets settle above the whole book. + let mut index = 0; + while (index < MANY_ORDERS) { + open_range( + &mut grid, + &pricer, + MANY_ORDER_BASE_TICK + index * MANY_ORDER_TICK_STRIDE, + constants::pos_inf_tick!(), + MANY_ORDER_QUANTITY, + ); + index = index + 1; + }; + + let whole_book = MANY_ORDERS * MANY_ORDER_QUANTITY; + let mut bucket = 100 - 5; + while (bucket < 100) { + assert_eq!(grid.bucket_maximum(bucket), whole_book); + bucket = bucket + 1; + }; + // Every tail bucket carries the same peak, so the tail average is that peak. + assert_eq!(grid.k95(), whole_book - grid.current_frozen_expected_payout()); + + destroy(grid); + cleanup(fx, oracle, harness); +} + +#[test] +fun the_same_ratio_ladder_charges_after_the_forward_moves() { + let (mut fx, mut oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let opening = fx.load_pricer_bundle(&oracle); + let mut grid = inventory_grid::initialize(&opening, frozen_grid_fixture::ratios()); + let opening_atm = opening.forward() / TEST_TICK_SIZE; + open_range(&mut grid, &opening, opening_atm, constants::pos_inf_tick!(), ONE_ORDER); + let stored_expected = grid.current_frozen_expected_payout(); + assert!(stored_expected > 0); + + // 1bp forward move — inside the cell span of this short-dated fixture, and + // still wider than the absolute-price race that used to invalidate a cut. + // Dollar rungs slide with the live forward; the centering term stays the + // increment collected at the opening forward, not a re-integrated E. + let moved_forward = test_constants::default_live_price() * 10_001 / 10_000; + let moved_at = fx.clock().timestamp_ms(); + fx.set_bs_forward_for_testing_bundle(&mut oracle, moved_at, moved_forward); + let moved = fx.load_pricer_bundle(&oracle); + assert_eq!(moved.forward(), moved_forward); + assert_eq!(grid.current_frozen_expected_payout(), stored_expected); + + let atm_tick = moved_forward / TEST_TICK_SIZE; + let after_move = grid.quote_open( + &moved, + atm_tick, constants::pos_inf_tick!(), - ROUNDING_SEED_QUANTITY, - fx.clock(), + ONE_ORDER, + TEST_TICK_SIZE, ); - harness.exposure.allocate_mint_order(seed); - assert_eq!(harness.exposure.inventory_impact_potential(), ROUNDING_BEFORE_POTENTIAL); - - // Adding 30M grows the gap from 10M to 40M: - // floor(lambda*40M) - floor(lambda*10M) = 13,333,333 - 3,333,333 = 10M. - // Rounding the 30M increment alone would incorrectly produce 9,999,999. - let carried = quote_range_mint( - &harness.exposure, + assert!(after_move.after_k() > after_move.before_k()); + + destroy(grid); + cleanup(fx, oracle, harness); +} + +#[test] +fun slicing_one_order_collects_the_same_charge_as_minting_it_whole() { + let (mut fx, oracle, harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + let whole = mint_and_total_charge(&mut fx, &pricer, vector[ONE_ORDER]); + let sliced = mint_and_total_charge( + &mut fx, &pricer, - strike, - constants::pos_inf_tick!(), - ROUNDING_CARRY_QUANTITY, - fx.clock(), + vector[ONE_ORDER / 4, ONE_ORDER / 4, ONE_ORDER / 4, ONE_ORDER / 4], ); - assert_eq!(carried.inventory_impact_charge(), 10_000_000); - let carried_order = harness.exposure.allocate_mint_order(carried); - assert_eq!(harness.exposure.inventory_impact_potential(), ROUNDING_AFTER_POTENTIAL); + // The charge is a difference of one book-level potential, so the intermediate + // potentials telescope and the split collects exactly the direct transition. + assert_eq!(sliced, whole); + cleanup(fx, oracle, harness); +} - let close = harness +#[test] +fun a_book_that_pays_the_same_everywhere_carries_almost_no_capital() { + let (mut fx, oracle, mut harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + harness.exposure.ensure_inventory_grid(&pricer); + let median_tick = test_constants::default_live_price() / TEST_TICK_SIZE; + + let below = harness.exposure.quote_mint_terms(&pricer, 0, median_tick, 0, ONE_ORDER, true); + harness.exposure.allocate_mint_order(below); + let one_sided_potential = harness.exposure.inventory_impact_potential(); + assert!(one_sided_potential > 0); + + // The complementary leg pays the same amount at every settlement price, so + // the worst outcome stops exceeding the ordinary one and the capital the + // book consumes collapses even though its gross payout has doubled. + let above = harness .exposure - .quote_live_close( - &pricer, - &carried_order, - carried_order.quantity(), - ); - assert_eq!(close.inventory_impact_rebate(), 10_000_000); - harness.exposure.process_live_close(close); - assert_eq!(harness.exposure.inventory_impact_potential(), ROUNDING_BEFORE_POTENTIAL); + .quote_mint_terms(&pricer, median_tick, constants::pos_inf_tick!(), 0, ONE_ORDER, true); + harness.exposure.allocate_mint_order(above); + assert_eq!(harness.exposure.inventory_impact_potential(), 0); cleanup(fx, oracle, harness); } -#[test, expected_failure(abort_code = strike_exposure::EInvalidInventoryImpactScale)] -fun zero_inventory_impact_scale_aborts() { - let ctx = &mut tx_context::dummy(); - let _exposure = strike_exposure::new( - object::id_from_address(@0xCAFE), - strike_exposure_config::new(), - test_constants::default_tick_size(), - test_constants::default_admission_tick_size(), - 0, - 0, - ctx, +#[test] +fun placing_away_from_the_peak_costs_less_than_piling_onto_it() { + let (mut fx, oracle, mut harness) = new_harness(impact_config(IMPACT_MAX_RATE)); + let pricer = fx.load_pricer_bundle(&oracle); + harness.exposure.ensure_inventory_grid(&pricer); + let (peak_lower, peak_higher) = seeded_order_range(); + let peak = harness + .exposure + .quote_mint_terms(&pricer, peak_lower, peak_higher, 0, ONE_ORDER, true); + harness.exposure.allocate_mint_order(peak); + + // Same size, same instant, same book: only the placement differs. Adding to + // the existing peak raises the worst outcomes directly, while the disjoint + // range only lifts buckets the tail was not already resting on. + let pile_on = harness + .exposure + .quote_mint_terms(&pricer, peak_lower, peak_higher, 0, ONE_ORDER, true); + let elsewhere = harness.exposure.quote_mint_terms(&pricer, 1, peak_lower, 0, ONE_ORDER, true); + assert!(elsewhere.inventory_impact_charge() < pile_on.inventory_impact_charge()); + + destroy(pile_on); + destroy(elsewhere); + cleanup(fx, oracle, harness); +} + +/// Mint `slices` sequentially over one range into a book of their own, and +/// return the inventory impact the whole sequence collected. +fun mint_and_total_charge(fx: &mut OracleFixture, pricer: &Pricer, slices: vector): u64 { + let expiry_id = fx.expiry_id(); + let expiry_ms = fx.expiry(); + let mut exposure = strike_exposure::new( + expiry_id, + impact_config(IMPACT_MAX_RATE), + TEST_TICK_SIZE, + TEST_ADMISSION_TICK_SIZE, + expiry_ms - test_constants::default_cadence_period_ms(), + fx.scenario_mut().ctx(), ); - abort 999 + exposure.fill_inventory_grid(inventory_grid::initialize(pricer, frozen_grid_fixture::ratios())); + let (lower, higher) = seeded_order_range(); + + let mut total = 0; + slices.do!(|quantity| { + let terms = exposure.quote_mint_terms(pricer, lower, higher, 0, quantity, true); + total = total + terms.inventory_impact_charge(); + exposure.allocate_mint_order(terms); + }); + + destroy(exposure); + total } -fun quote_mint( - exposure: &StrikeExposure, - pricer: &deepbook_predict::pricing::Pricer, +/// Quote and commit one open, returning the centering delta it moved. +fun open_range( + grid: &mut InventoryGrid, + pricer: &Pricer, + lower: u64, + higher: u64, quantity: u64, - clock: &Clock, -): deepbook_predict::strike_exposure::MintTerms { - quote_range_mint( - exposure, - pricer, - test_constants::default_strike_tick(), - constants::pos_inf_tick!(), - quantity, - clock, - ) +): u64 { + let expected = grid + .quote_open(pricer, lower, higher, quantity, TEST_TICK_SIZE) + .frozen_expected_payout_delta(); + grid.apply_change(lower, higher, quantity, expected, true, TEST_TICK_SIZE); + expected } -fun quote_range_mint( - exposure: &StrikeExposure, - pricer: &deepbook_predict::pricing::Pricer, - lower_tick: u64, - higher_tick: u64, +/// Quote and commit one close, returning the centering delta it moved. +fun close_range( + grid: &mut InventoryGrid, + pricer: &Pricer, + lower: u64, + higher: u64, quantity: u64, - _clock: &Clock, -): deepbook_predict::strike_exposure::MintTerms { - exposure.quote_mint_terms( - pricer, - lower_tick, - higher_tick, - 0, - quantity, - true, - ) +): u64 { + let expected = grid + .quote_close(pricer, lower, higher, quantity, TEST_TICK_SIZE) + .frozen_expected_payout_delta(); + grid.apply_change(lower, higher, quantity, expected, false, TEST_TICK_SIZE); + expected } -fun disabled_harness(): (OracleFixture, OracleBundle, ExposureHarness) { - new_harness(impact_config(0, BACKING_BUFFER_LAMBDA), IMPACT_SCALE) +/// Two overlapping ranges opened through the ordinary incremental path. +fun seeded_grid(pricer: &Pricer): InventoryGrid { + let mut grid = inventory_grid::initialize(pricer, frozen_grid_fixture::ratios()); + let (lower, higher) = seeded_order_range(); + let mut index = 0; + while (index < 2) { + open_range(&mut grid, pricer, lower + index, higher, ONE_ORDER); + index = index + 1; + }; + grid } -fun enabled_harness(): (OracleFixture, OracleBundle, ExposureHarness) { - new_harness( - impact_config(IMPACT_MAX_RATE, BACKING_BUFFER_LAMBDA), - IMPACT_SCALE, - ) +fun close_seeded_orders(grid: &mut InventoryGrid, pricer: &Pricer) { + let (lower, higher) = seeded_order_range(); + let mut index = 0; + while (index < 2) { + close_range(grid, pricer, lower + index, higher, ONE_ORDER); + index = index + 1; + }; } -fun rounding_harness(): (OracleFixture, OracleBundle, ExposureHarness) { - new_harness( - impact_config(math::float_scaling!(), ROUNDING_BUFFER_LAMBDA), - ROUNDING_IMPACT_SCALE, - ) +fun seeded_order_range(): (u64, u64) { + (test_constants::default_live_price() / TEST_TICK_SIZE, constants::pos_inf_tick!() - 1) } -fun new_harness( - config: StrikeExposureConfig, - inventory_impact_scale: u64, -): (OracleFixture, OracleBundle, ExposureHarness) { +fun new_harness(config: StrikeExposureConfig): (OracleFixture, OracleBundle, ExposureHarness) { let mut fx = oracle_fixture::setup_oracle( test_constants::default_live_price(), - test_constants::default_tick_size(), + TEST_TICK_SIZE, test_constants::short_expiry_ms(), ); let expiry_id = fx.expiry_id(); @@ -303,10 +601,9 @@ fun new_harness( let exposure = strike_exposure::new( expiry_id, config, - test_constants::default_tick_size(), - test_constants::default_admission_tick_size(), + TEST_TICK_SIZE, + TEST_ADMISSION_TICK_SIZE, expiry_ms - test_constants::default_cadence_period_ms(), - inventory_impact_scale, fx.scenario_mut().ctx(), ); transfer::share_object(ExposureHarness { id, exposure }); @@ -323,9 +620,9 @@ fun cleanup(fx: OracleFixture, oracle: OracleBundle, harness: ExposureHarness) { fx.finish(); } -fun impact_config(max_rate: u64, backing_buffer_lambda: u64): StrikeExposureConfig { +fun impact_config(max_rate: u64): StrikeExposureConfig { let mut config = strike_exposure_config::new(); - config.set_backing_buffer_lambda(backing_buffer_lambda); config.set_inventory_impact_max_rate(max_rate); + config.set_inventory_impact_scale(IMPACT_SCALE); config } diff --git a/packages/predict/tests/strike_exposure/mint_terms_binding_tests.move b/packages/predict/tests/strike_exposure/mint_terms_binding_tests.move index 6d4e9e744..31d93de87 100644 --- a/packages/predict/tests/strike_exposure/mint_terms_binding_tests.move +++ b/packages/predict/tests/strike_exposure/mint_terms_binding_tests.move @@ -70,7 +70,6 @@ fun create_and_share_exposure_harness( test_constants::default_tick_size(), test_constants::default_tick_size(), expiry_ms - test_constants::default_cadence_period_ms(), - 1_000_000_000, fx.scenario_mut().ctx(), ); transfer::share_object(ExposureHarness { id, exposure }); diff --git a/packages/sessions/sources/sessions.move b/packages/sessions/sources/sessions.move index 11aa347b7..a8bca690e 100644 --- a/packages/sessions/sources/sessions.move +++ b/packages/sessions/sources/sessions.move @@ -287,6 +287,7 @@ public fun redeem_live( close_quantity: u64, min_probability: u64, min_proceeds: u64, + max_cost: u64, root: &AccumulatorRoot, clock: &Clock, ctx: &mut TxContext, @@ -301,6 +302,7 @@ public fun redeem_live( close_quantity, min_probability, min_proceeds, + max_cost, root, clock, ctx, diff --git a/packages/sessions/tests/sessions_tests.move b/packages/sessions/tests/sessions_tests.move index 6a65768d9..ba114c30a 100644 --- a/packages/sessions/tests/sessions_tests.move +++ b/packages/sessions/tests/sessions_tests.move @@ -526,6 +526,7 @@ fun unapproved_session_cannot_redeem_live() { CLOSE_QUANTITY, ZERO_PROBABILITY, ZERO_COST, + std::u64::max_value!(), &root, clock, scenario.ctx(), @@ -696,6 +697,7 @@ fun session_mints_exact_quantity_and_redeems_live() { test_constants::mint_quantity(), ZERO_PROBABILITY, ZERO_COST, + std::u64::max_value!(), &root, clock, scenario.ctx(),