From a06d5a17115d8d13523b38d2e6a56d12a70e3d3f Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 09:24:58 -0400 Subject: [PATCH 01/23] predict: refresh NAV per market and flush from stored marks (DBU-557) The full-pool flush no longer walks any payout tree: a lifecycle-gated per-market refresh (settle-or-rebalance + exact liability walk) stores a ValuationMark on the market, and the flush reads marks off read-only markets under a freshness ceiling and a sqrt(min-total-variance)-scaled oracle-drift guard, then drains the LP queues unchanged. Trade flows write their exact liability delta through to the mark; the pool-wide valuation lock is removed; the payout-node cap drops to 950 so one market's refresh walk fits the per-transaction object budget. Co-Authored-By: Claude Fable 5 --- .../sources/config/config_constants.move | 26 ++ .../sources/config/protocol_config.move | 71 +++--- .../sources/config/valuation_config.move | 49 ++++ packages/predict/sources/constants.move | 5 +- .../predict/sources/events/vault_events.move | 25 ++ packages/predict/sources/expiry_market.move | 102 +++++++- packages/predict/sources/plp/plp.move | 222 ++++++++++-------- packages/predict/sources/pricing/pricing.move | 68 +++++- .../predict/sources/registry/registry.move | 1 - 9 files changed, 423 insertions(+), 146 deletions(-) create mode 100644 packages/predict/sources/config/valuation_config.move diff --git a/packages/predict/sources/config/config_constants.move b/packages/predict/sources/config/config_constants.move index c4452941f..1c1256f8b 100644 --- a/packages/predict/sources/config/config_constants.move +++ b/packages/predict/sources/config/config_constants.move @@ -30,6 +30,8 @@ const EInvalidBackingBufferLambda: u64 = 19; const EInvalidMaxAdmissionLeverage: u64 = 20; const EInvalidCadenceWindowSize: u64 = 21; const EMarketTickSizeTooLarge: u64 = 22; +const EInvalidNavMarkFreshnessMs: u64 = 23; +const EInvalidNavMarkDriftEpsilon: u64 = 24; // === Fees === @@ -359,3 +361,27 @@ public(package) fun assert_upper_benefit_power(value: u64) { EInvalidUpperBenefitPower, ); } + +public(package) macro fun default_nav_mark_freshness_ms(): u64 { 60_000 } +public(package) macro fun min_nav_mark_freshness_ms(): u64 { 1_000 } +public(package) macro fun max_nav_mark_freshness_ms(): u64 { 600_000 } + +public(package) fun assert_nav_mark_freshness_ms(value: u64) { + assert!( + value >= min_nav_mark_freshness_ms!() && value <= max_nav_mark_freshness_ms!(), + EInvalidNavMarkFreshnessMs, + ); +} + +public(package) macro fun default_nav_mark_drift_epsilon(): u64 { 20_000_000 } +// Floor: epsilon = 0 would reject every stored mark and brick the flush; ceiling: +// 0.1 keeps the tolerated per-order price drift within ~4% of face. +public(package) macro fun min_nav_mark_drift_epsilon(): u64 { 1_000_000 } +public(package) macro fun max_nav_mark_drift_epsilon(): u64 { 100_000_000 } + +public(package) fun assert_nav_mark_drift_epsilon(value: u64) { + assert!( + value >= min_nav_mark_drift_epsilon!() && value <= max_nav_mark_drift_epsilon!(), + EInvalidNavMarkDriftEpsilon, + ); +} diff --git a/packages/predict/sources/config/protocol_config.move b/packages/predict/sources/config/protocol_config.move index 3f172263e..bf0b7fcb0 100644 --- a/packages/predict/sources/config/protocol_config.move +++ b/packages/predict/sources/config/protocol_config.move @@ -3,9 +3,9 @@ /// Protocol-wide configuration and flow gates for Predict. /// -/// This shared object owns the admin-tunable config structs, the trading pause -/// gate, and the transaction-local full-pool valuation lock. Flow modules decide -/// which gates apply before they mutate expiry, oracle, pool, or account state. +/// This shared object owns the admin-tunable config structs and the trading pause +/// gate. Flow modules decide which gates apply before they mutate expiry, oracle, +/// pool, or account state. module deepbook_predict::protocol_config; use deepbook_predict::{ @@ -17,14 +17,13 @@ use deepbook_predict::{ expiry_cash_config::{Self, ExpiryCashConfig}, pricing_config::{Self, PricingConfig}, stake_config::{Self, StakeConfig}, - strike_exposure_config::{Self, StrikeExposureConfig} + strike_exposure_config::{Self, StrikeExposureConfig}, + valuation_config::{Self, ValuationConfig} }; const ETradingPaused: u64 = 0; -const EValuationInProgress: u64 = 1; -const EValuationNotInProgress: u64 = 2; -const EPackageVersionDisabled: u64 = 3; -const EVersionWatermarkNotAdvanced: u64 = 4; +const EPackageVersionDisabled: u64 = 1; +const EVersionWatermarkNotAdvanced: u64 = 2; /// Shared protocol policy and config state. public struct ProtocolConfig has key { @@ -47,9 +46,7 @@ public struct ProtocolConfig has key { version_watermark: u64, /// Blocks new risk creation while true. trading_paused: bool, - /// Transaction-local lock held while a full-pool valuation is assembled, so no - /// NAV-changing op can interleave between per-market value steps in the PTB. - valuation_in_progress: bool, + valuation_config: ValuationConfig, } // === Public Functions === @@ -165,7 +162,6 @@ public fun set_pyth_spot_freshness_ms( value: u64, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); config.pricing_config.set_pyth_spot_freshness_ms(value); } @@ -176,7 +172,6 @@ public fun set_block_scholes_price_freshness_ms( value: u64, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); config.pricing_config.set_block_scholes_price_freshness_ms(value); } @@ -187,10 +182,29 @@ public fun set_block_scholes_svi_freshness_ms( value: u64, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); config.pricing_config.set_block_scholes_svi_freshness_ms(value); } +/// Set the hard freshness ceiling on stored valuation marks at the pool flush. +public fun set_nav_mark_freshness_ms( + config: &mut ProtocolConfig, + _admin_cap: &AdminCap, + value: u64, +) { + config.assert_version(); + config.valuation_config.set_nav_mark_freshness_ms(value); +} + +/// Set the oracle-drift tolerance for stored valuation marks at the pool flush. +public fun set_nav_mark_drift_epsilon( + config: &mut ProtocolConfig, + _admin_cap: &AdminCap, + value: u64, +) { + config.assert_version(); + config.valuation_config.set_nav_mark_drift_epsilon(value); +} + /// Set the trading loss rebate rate template used by future expiry markets. public fun set_template_trading_loss_rebate_rate( config: &mut ProtocolConfig, @@ -260,7 +274,6 @@ public fun set_protocol_reserve_profit_share( protocol_reserve_profit_share: u64, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); config_constants::assert_protocol_reserve_profit_share(protocol_reserve_profit_share); config.protocol_reserve_profit_share = protocol_reserve_profit_share; } @@ -305,6 +318,10 @@ public(package) fun ewma_config(config: &ProtocolConfig): &EwmaConfig { &config.ewma_config } +public(package) fun valuation_config(config: &ProtocolConfig): &ValuationConfig { + &config.valuation_config +} + /// Abort unless the running package version is at or above the watermark floor. /// /// The single version gate for the package: every version-gated flow threads the @@ -322,16 +339,6 @@ public(package) fun assert_trading_allowed(config: &ProtocolConfig) { config.assert_not_trading_paused(); } -/// Abort unless a valuation lock is currently active. -public(package) fun assert_valuation_in_progress(config: &ProtocolConfig) { - assert!(config.valuation_in_progress, EValuationNotInProgress); -} - -/// Abort unless no valuation lock is currently active. -public(package) fun assert_not_valuation_in_progress(config: &ProtocolConfig) { - assert!(!config.valuation_in_progress, EValuationInProgress); -} - /// Create and share the protocol-wide configuration object. public(package) fun create_and_share(ctx: &mut TxContext): ID { let config = new(ctx); @@ -346,18 +353,6 @@ public(package) fun pause_trading(config: &mut ProtocolConfig) { config.set_trading_paused_internal(true); } -/// Begin a transaction-local full-pool valuation lock. -public(package) fun begin_valuation(config: &mut ProtocolConfig) { - config.assert_not_valuation_in_progress(); - config.valuation_in_progress = true; -} - -/// End a transaction-local full-pool valuation lock. -public(package) fun end_valuation(config: &mut ProtocolConfig) { - config.assert_valuation_in_progress(); - config.valuation_in_progress = false; -} - fun set_trading_paused_internal(config: &mut ProtocolConfig, paused: bool) { config.trading_paused = paused; config_events::emit_trading_paused_updated(config.id(), paused); @@ -380,7 +375,7 @@ fun new(ctx: &mut TxContext): ProtocolConfig { ewma_config: ewma_config::new(), version_watermark: constants::current_version!(), trading_paused: false, - valuation_in_progress: false, + valuation_config: valuation_config::new(), } } diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move new file mode 100644 index 000000000..c4cc2fc92 --- /dev/null +++ b/packages/predict/sources/config/valuation_config.move @@ -0,0 +1,49 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Stored flush-acceptance policy for per-market valuation marks. +/// +/// ProtocolConfig owns this mutable policy. The pool flush reads it when deciding +/// whether a market's stored valuation mark is still usable: a hard freshness +/// ceiling on mark age plus the oracle-drift tolerance the drift guard scales by. +module deepbook_predict::valuation_config; + +use deepbook_predict::config_constants; + +/// Acceptance parameters for stored valuation marks at the pool flush. +public struct ValuationConfig has store { + /// Hard ceiling on stored-mark age in milliseconds, regardless of oracle drift. + nav_mark_freshness_ms: u64, + /// Oracle-drift tolerance in FLOAT_SCALING. A mark is rejected when the + /// relative forward move exceeds `epsilon * sqrt(min total variance)` or the + /// relative sqrt-min-variance move exceeds `epsilon`, bounding any single + /// order's price drift to roughly `0.4 * epsilon` of face at every expiry. + nav_mark_drift_epsilon: u64, +} + +// === Public-Package Functions === + +public(package) fun nav_mark_freshness_ms(config: &ValuationConfig): u64 { + config.nav_mark_freshness_ms +} + +public(package) fun nav_mark_drift_epsilon(config: &ValuationConfig): u64 { + config.nav_mark_drift_epsilon +} + +public(package) fun new(): ValuationConfig { + ValuationConfig { + nav_mark_freshness_ms: config_constants::default_nav_mark_freshness_ms!(), + nav_mark_drift_epsilon: config_constants::default_nav_mark_drift_epsilon!(), + } +} + +public(package) fun set_nav_mark_freshness_ms(config: &mut ValuationConfig, value: u64) { + config_constants::assert_nav_mark_freshness_ms(value); + config.nav_mark_freshness_ms = value; +} + +public(package) fun set_nav_mark_drift_epsilon(config: &mut ValuationConfig, value: u64) { + config_constants::assert_nav_mark_drift_epsilon(value); + config.nav_mark_drift_epsilon = value; +} diff --git a/packages/predict/sources/constants.move b/packages/predict/sources/constants.move index 0490e36d3..14bced571 100644 --- a/packages/predict/sources/constants.move +++ b/packages/predict/sources/constants.move @@ -83,7 +83,10 @@ public(package) macro fun max_executable_plp_price(): u64 { 100_000_000 } public(package) macro fun max_live_expiry_markets(): u64 { 24 } /// Maximum finite payout-tree boundary nodes one expiry market may carry into NAV. -public(package) macro fun max_payout_tree_nodes(): u64 { 1_000 } +/// Held below Sui's ~1,000 cached-object per-transaction wall (measured single-market +/// crossing ~982 nodes, `predeploy/evidence/c1-object-cache-flush-2026-07-07.md`) so +/// one market's refresh walk always fits its own transaction. +public(package) macro fun max_payout_tree_nodes(): u64 { 950 } /// Maximum active leveraged orders one expiry market may carry into NAV. public(package) macro fun max_active_leveraged_orders(): u64 { 5_000 } diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index 5471ebee9..9daa10973 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -158,6 +158,17 @@ public struct WithdrawFilled has copy, drop, store { /// plus `active_market_nav` over `market_count` active markets), how many of each /// kind filled, how many queue heads spent per-flush budget (filled, /// protocol-refunded, or live limit-missed), and the idle balance after the drain. +/// Emitted when a live market's valuation mark is refreshed: the exact +/// per-order liability stored for the pool flush to read, at its landing time. +public struct NavRefreshed has copy, drop, store { + pool_vault_id: ID, + expiry_market_id: ID, + /// Exact oracle-priced per-order liability stored in the mark. + liability: u64, + /// On-chain landing time of the refresh that computed the mark. + computed_at_ms: u64, +} + public struct FlushExecuted has copy, drop, store { pool_vault_id: ID, epoch: u64, @@ -425,6 +436,20 @@ public(package) fun emit_withdraw_filled( }); } +public(package) fun emit_nav_refreshed( + pool_vault_id: ID, + expiry_market_id: ID, + liability: u64, + computed_at_ms: u64, +) { + event::emit(NavRefreshed { + pool_vault_id, + expiry_market_id, + liability, + computed_at_ms, + }); +} + public(package) fun emit_flush_executed( pool_vault_id: ID, epoch: u64, diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 72a5b1b2b..73e5aff7a 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -25,7 +25,8 @@ use deepbook_predict::{ pricing::{Self, Pricer}, protocol_config::ProtocolConfig, strike_exposure::{Self, MintTerms, StrikeExposure}, - strike_exposure_config + strike_exposure_config, + valuation_config::ValuationConfig }; use dusdc::dusdc::DUSDC; use fixed_math::math; @@ -51,6 +52,8 @@ const EReferenceTickTimestampMismatch: u64 = 9; const EMintRedeemSameTimestamp: u64 = 10; const ERedeemProbabilityBelowMin: u64 = 11; const ERedeemProceedsBelowMin: u64 = 12; +const EValuationMarkMissing: u64 = 13; +const EValuationMarkStale: u64 = 14; /// Per-expiry market state. public struct ExpiryMarket has key { @@ -72,6 +75,25 @@ public struct ExpiryMarket has key { /// Admin sets/unsets it (version-gated); a `PauseCap` holder can force it /// true one-way through the registry (ungated kill switch). mint_paused: bool, + /// Stored valuation mark the pool flush reads (`None` until first refresh). + valuation_mark: Option, +} + +/// Stored valuation mark for the pool flush: this market's exact oracle-priced +/// per-order liability as of `computed_at_ms`, plus the pricing anchors the flush +/// compares against live feeds to bound oracle drift. Trade flows write their +/// bit-exact liability delta through (mint adds `net_premium`, live redeem +/// subtracts `redeem_amount`); liquidation removes only orders whose live value +/// already nets to zero, so it leaves the mark unchanged. Free cash is never +/// stored — the flush reads it live, so cash moves need no mark maintenance. +public struct ValuationMark has copy, drop, store { + liability: u64, + /// On-chain landing time of the refresh that computed this mark. + computed_at_ms: u64, + /// Forward the mark was priced at (drift-guard anchor). + forward: u64, + /// sqrt of the SVI minimum total variance at refresh (drift-guard tolerance scale). + sqrt_min_total_variance: u64, } /// Read-only all-in cost quote for a prospective live mint, in DUSDC base units. @@ -648,7 +670,6 @@ public fun set_reference_tick( pyth: &PythFeed, ): u64 { config.assert_version(); - config.assert_not_valuation_in_progress(); market.assert_pyth_bound(propbook_registry, pyth); let source_timestamp_ms = market.strike_exposure.reference_tick_source_timestamp_ms(); @@ -719,6 +740,55 @@ public(package) fun ensure_settled( true } +/// Read the flush-consumable NAV off the stored valuation mark: assert the mark +/// exists, is within the freshness ceiling, and that the live oracle inputs in +/// `pricer` have not drifted beyond the configured tolerance since it was +/// computed; then return live free cash minus the marked liability. The payout +/// tree is never walked here — that is the point: the walk ran in the refresh +/// that stored the mark, and this read loads no per-order objects. +public(package) fun read_flushable_nav( + market: &ExpiryMarket, + valuation_config: &ValuationConfig, + pricer: &Pricer, + clock: &Clock, +): u64 { + market.assert_pricer_bound(pricer); + assert!(market.valuation_mark.is_some(), EValuationMarkMissing); + let mark = market.valuation_mark.borrow(); + assert!( + clock.timestamp_ms() - mark.computed_at_ms <= valuation_config.nav_mark_freshness_ms(), + EValuationMarkStale, + ); + pricer.assert_mark_drift_within( + mark.forward, + mark.sqrt_min_total_variance, + valuation_config.nav_mark_drift_epsilon(), + ); + // Same degenerate-underwater / ulp-dust clamp as `current_nav`: a market whose + // marked liability exceeds free cash has zero limited-recourse value. + market.cash.free_cash().saturating_sub(mark.liability) +} + +/// Recompute this market's exact per-order live liability and store it as the +/// valuation mark the pool flush reads, replacing any prior mark. Returns the +/// stored liability. +public(package) fun record_valuation_mark( + market: &mut ExpiryMarket, + pricer: &Pricer, + clock: &Clock, +): u64 { + market.assert_pricer_bound(pricer); + let liability = market.strike_exposure.exact_live_liability(pricer); + market.valuation_mark = + option::some(ValuationMark { + liability, + computed_at_ms: clock.timestamp_ms(), + forward: pricer.forward(), + sqrt_min_total_variance: pricer.sqrt_min_total_variance(), + }); + liability +} + /// Force `mint_paused = true`. Reserved for `PauseCap` holders going through /// `registry::pause_expiry_market_mint_pause_cap`; cannot unpause. Deliberately /// not version-gated so the kill switch survives a version freeze. @@ -848,6 +918,7 @@ public(package) fun create_and_share( ), ewma: ewma::new(ctx), mint_paused: false, + valuation_mark: option::none(), }; transfer::share_object(market); expiry_market_id @@ -916,13 +987,11 @@ fun assert_pyth_bound(market: &ExpiryMarket, propbook_registry: &OracleRegistry, fun assert_live_flow_allowed(market: &ExpiryMarket, config: &ProtocolConfig, pricer: &Pricer) { config.assert_version(); - config.assert_not_valuation_in_progress(); market.assert_pricer_bound(pricer); } fun assert_live_mint_allowed(market: &ExpiryMarket, config: &ProtocolConfig, pricer: &Pricer) { config.assert_version(); - config.assert_not_valuation_in_progress(); market.assert_pricer_bound(pricer); config.assert_trading_allowed(); assert!(!market.mint_paused, EMintPaused); @@ -932,6 +1001,28 @@ fun assert_pricer_bound(market: &ExpiryMarket, pricer: &Pricer) { assert!(pricer.expiry_market_id() == market.id(), EWrongPricer); } +/// Write a mint's bit-exact liability delta through to the stored valuation mark. +/// The marginal live liability of a freshly admitted order at its own pricer is +/// exactly `net_premium` (`entry_value - floor`); no-op until the first refresh +/// establishes a mark. +fun mark_liability_added(market: &mut ExpiryMarket, amount: u64) { + if (market.valuation_mark.is_some()) { + let mark = market.valuation_mark.borrow_mut(); + mark.liability = mark.liability + amount; + }; +} + +/// Write a live redeem's liability delta through to the stored valuation mark. +/// The delta is priced at the op's oracle while the mark's sum is anchored at its +/// refresh oracle (drift-bounded), so clamp the mixed-anchor residual rather than +/// abort a user exit. +fun mark_liability_removed(market: &mut ExpiryMarket, amount: u64) { + if (market.valuation_mark.is_some()) { + let mark = market.valuation_mark.borrow_mut(); + mark.liability = mark.liability.saturating_sub(amount); + }; +} + /// Return the congestion surcharge (in DUSDC) for `quantity` from the pre-trade /// EWMA stats, then fold the current gas price into the estimate. Penalty before /// fold on every trade path (mint and live redeem): the trade's gas is judged @@ -1021,6 +1112,7 @@ fun mint_prepared_exact_quantity( let leverage = terms.leverage(); let minted_order = market.strike_exposure.allocate_mint_order(terms); market.settle_mint_payment(account, &minted_order, "e, builder_code_id, clock, ctx); + market.mark_liability_added(quote.net_premium); order_events::emit_order_minted( market.id(), account.account_id(), @@ -1143,6 +1235,7 @@ fun redeem_live_internal( redeem_amount - fee_amount - builder_fee_amount - penalty_amount >= min_proceeds, ERedeemProceedsBelowMin, ); + market.mark_liability_removed(redeem_amount); order_events::emit_live_order_redeemed( market.id(), @@ -1173,7 +1266,6 @@ fun redeem_settled_internal( ctx: &mut TxContext, ): (u256, Option) { config.assert_version(); - config.assert_not_valuation_in_progress(); let redeemed_order = order::from_order_id(order_id); assert!(close_quantity == redeemed_order.quantity(), EFullCloseRequired); assert!(market.ensure_settled(propbook_registry, pyth, clock), EMarketNotSettled); diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 53931d888..1a9957c7e 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -5,11 +5,13 @@ /// /// PoolVault owns the PLP treasury cap, the pooled DEEP staked by accounts, idle /// DUSDC, the protocol reserve, sponsor-funded fee incentives, per-expiry cash -/// accounting, and the async LP supply/withdraw queues. It coordinates the -/// full-pool NAV valuation (a hot-potato aggregation over every active market) and +/// accounting, and the async LP supply/withdraw queues. It coordinates per-market +/// NAV refreshes (`refresh_expiry_nav` walks one market's payout tree and stores a +/// valuation mark on it), the full-pool flush (a hot-potato completeness proof +/// that reads every active market's stored mark without walking any tree), and /// the unified per-market cash flow (initial funding, live rebalance/sweep, and /// settled-market sweep with terminal profit materialization). LPs queue -/// supply/withdraw requests routed through a loaded Account; the daily flush +/// supply/withdraw requests routed through a loaded Account; the flush /// (`finish_flush`) drains them at the frozen pool NAV, minting/burning PLP and /// delivering fills to each account via the balance accumulator. PLP incentives /// moved to a separate staking contract; DEEP staking is an unrelated trading @@ -77,20 +79,22 @@ public struct PoolVault has key { expiry_accounting: Ledger, } -/// Transaction-local full-pool NAV valuation hot potato. +/// Transaction-local full-pool flush completeness proof. /// -/// `start_pool_valuation` snapshots the active expiry set; each `value_expiry` -/// runs the per-market cash flush then folds that market's NAV into `total_nav` -/// exactly once (a swept settled market contributes 0); `finish_flush` proves every -/// snapshotted market was valued, returns the LP-attributable pool NAV, and drains -/// the LP queues against it. Has no abilities, so it must be consumed by the finisher. +/// `start_pool_valuation` snapshots the active expiry set; each `collect_expiry_nav` +/// folds one market's stored valuation mark into `total_nav` exactly once, reading +/// the market read-only (the payout-tree walk ran in that market's +/// `refresh_expiry_nav`, so the flush loads no per-order objects and stays under +/// the per-transaction object budget); `finish_flush` proves every snapshotted +/// market was counted, returns the LP-attributable pool NAV, and drains the LP +/// queues against it. Has no abilities, so it must be consumed by the finisher. public struct PoolValuation { pool_vault_id: ID, - /// Active expiry markets snapshotted at start; every one must be valued. + /// Active expiry markets snapshotted at start; every one must be counted. expected_expiry_markets: vector, - /// Markets valued so far this flow; folded against `expected` at finish. + /// Markets counted so far this flow; folded against `expected` at finish. valued_expiry_markets: vector, - /// Running Σ of each valued market's NAV (settled markets contribute 0). + /// Running Σ of each counted market's mark-derived NAV. total_nav: u64, } @@ -203,48 +207,102 @@ public fun pending_protocol_profit(vault: &PoolVault): u64 { vault.expiry_accounting.pending_protocol_profit() } -/// Begin a full-pool flush (NAV valuation + LP queue drain) as a market deployer, -/// using a registry-generated `MarketLifecycleProof`. This is the sole flush start: -/// it is cron-driven and PRIVILEGED, not permissionless (audit L8). Engages the -/// protocol valuation lock — so no NAV-changing op can interleave between value -/// steps — and snapshots the active expiry set every `value_expiry` must cover. The -/// hot potato can only be created here, so gating the start gates the whole flush. +/// Refresh one market's stored valuation mark as a market deployer, using a +/// registry-generated `MarketLifecycleProof`. A settled market is swept +/// (deactivated, cash returned, profit materialized — it leaves the active set +/// and needs no mark); a live market is rebalanced to target, its exact per-order +/// liability recomputed at the live oracle, and the mark stored for +/// `collect_expiry_nav` to read. Several markets may be refreshed in one PTB +/// subject to the per-transaction object budget (each refresh walks its market's +/// payout tree). /// -/// The flush prices the pool NAV off the live oracle and `finish_flush` drains the -/// LP queues at that mark, and Pyth updates (`pyth_feed::update`) are permissionless -/// — so a flush-capable cap-holder who manipulates the live oracle in a preceding -/// tx, then flushes, could fill their own queued supply/withdraw request at a mark -/// they chose. The start is therefore gated on both current registry allowlisting -/// and trust in every flush-capable holder not to manipulate the live oracle. The -/// revocable `MarketLifecycleCap` (not the root `AdminCap`) carries this authority; -/// admin retains a break-glass route by minting itself a lifecycle cap. +/// PRIVILEGED like the flush start (audit L8): the mark's refresh instant prices +/// the pool NAV the queues later drain at, and Pyth updates are permissionless — +/// so refresh authority carries the same oracle-timing trust as the flush itself. +public fun refresh_expiry_nav( + vault: &mut PoolVault, + market: &mut ExpiryMarket, + lifecycle_proof: MarketLifecycleProof, + config: &ProtocolConfig, + propbook_registry: &OracleRegistry, + pyth: &PythFeed, + bs_spot: &BlockScholesSpotFeed, + bs_forward: &BlockScholesForwardFeed, + bs_svi: &BlockScholesSVIFeed, + clock: &Clock, +) { + config.assert_version(); + lifecycle_proof.destroy_proof(); + let expiry_market_id = market.id(); + vault.expiry_accounting.assert_registered_expiry(expiry_market_id); + if (vault.settle_or_rebalance_expiry(market, config, propbook_registry, pyth, clock)) { + return + }; + let pricer = market.load_live_pricer( + config, + propbook_registry, + pyth, + bs_spot, + bs_forward, + bs_svi, + clock, + ); + let liability = market.record_valuation_mark(&pricer, clock); + vault_events::emit_nav_refreshed( + vault.id(), + expiry_market_id, + liability, + clock.timestamp_ms(), + ); +} + +/// Begin a full-pool flush (stored-mark aggregation + LP queue drain) as a market +/// deployer, using a registry-generated `MarketLifecycleProof`. This is the sole +/// flush start: it is cron-driven and PRIVILEGED, not permissionless (audit L8). +/// Snapshots the active expiry set every `collect_expiry_nav` must cover; the hot +/// potato can only be created here, so gating the start gates the whole flush. +/// +/// The flush prices the pool NAV off marks refreshed at operator-chosen instants +/// and `finish_flush` drains the LP queues at that mark, and Pyth updates +/// (`pyth_feed::update`) are permissionless — so a cap-holder who manipulates the +/// live oracle before refreshing, then flushes, could fill their own queued +/// supply/withdraw request at a mark they chose. The start is therefore gated on +/// both current registry allowlisting and trust in every flush-capable holder not +/// to manipulate the live oracle. The revocable `MarketLifecycleCap` (not the root +/// `AdminCap`) carries this authority; admin retains a break-glass route by +/// minting itself a lifecycle cap. public fun start_pool_valuation( - config: &mut ProtocolConfig, + config: &ProtocolConfig, vault: &PoolVault, lifecycle_proof: MarketLifecycleProof, ): PoolValuation { config.assert_version(); lifecycle_proof.destroy_proof(); - start_pool_valuation_internal(config, vault) + assert!(vault.lp.total_supply() > 0, ENotBootstrapped); + PoolValuation { + pool_vault_id: vault.id(), + expected_expiry_markets: vault.expiry_accounting.active_expiry_markets(), + valued_expiry_markets: vector[], + total_nav: 0, + } } -/// Run the per-market cash flow for one snapshotted market, then fold its NAV into -/// the running total. The market must be in the snapshot and not already valued -/// (the exactly-once proof). The flush IS the valuation: a settled market is swept -/// (deactivated, cash returned, profit materialized) and contributes 0; a live -/// market is rebalanced to target and valued on its current cash. +/// Fold one snapshotted market's stored valuation mark into the running pool NAV. +/// The market must be in the snapshot and not already counted (the exactly-once +/// proof), and is a READ-ONLY input: no cash moves, no settlement, no tree walk — +/// the mark must exist, be within the freshness ceiling, and its oracle anchors +/// must be within the drift tolerance of the live feeds +/// (`expiry_market::read_flushable_nav`), else this aborts and the operator +/// re-refreshes the market and retries the flush. /// -/// Before branching, this passively records terminal settlement from Propbook's -/// exact Pyth timestamp if available: a past-expiry market is normally settled here -/// and swept (contributing 0), so `current_nav` is only reached for a still-live -/// market. Only in the bounded pending-settlement window (past expiry but the -/// exact-expiry spot not yet inserted) does the live branch still abort through -/// `current_nav`; there is no solvency-safe substitute mark for an unsettled expired -/// market, and the abort clears once anyone lands the exact spot. -public fun value_expiry( +/// A settled or past-expiry market cannot produce the fresh pricer this read +/// requires (`load_live_pricer` rejects past-expiry): sweep it via +/// `refresh_expiry_nav` or `rebalance_expiry_cash` so it leaves the active set, +/// then start a new flush. There is still no solvency-safe substitute mark for an +/// unsettled expired market; the abort clears once anyone lands the exact spot. +public fun collect_expiry_nav( valuation: &mut PoolValuation, - vault: &mut PoolVault, - market: &mut ExpiryMarket, + market: &ExpiryMarket, config: &ProtocolConfig, propbook_registry: &OracleRegistry, pyth: &PythFeed, @@ -254,36 +312,28 @@ public fun value_expiry( clock: &Clock, ) { config.assert_version(); - config.assert_valuation_in_progress(); let expiry_market_id = market.id(); valuation.assert_expiry_ready_to_value(expiry_market_id); - vault.expiry_accounting.assert_registered_expiry(expiry_market_id); - let nav = if ( - vault.settle_or_rebalance_expiry(market, config, propbook_registry, pyth, clock) - ) { - 0 - } else { - let pricer = market.load_live_pricer( - config, - propbook_registry, - pyth, - bs_spot, - bs_forward, - bs_svi, - clock, - ); - market.current_nav(&pricer) - }; + let pricer = market.load_live_pricer( + config, + propbook_registry, + pyth, + bs_spot, + bs_forward, + bs_svi, + clock, + ); + let nav = market.read_flushable_nav(config.valuation_config(), &pricer, clock); valuation.valued_expiry_markets.push_back(expiry_market_id); valuation.total_nav = valuation.total_nav + nav; } /// Finish a full-pool valuation and run the LP flush: prove every snapshotted market -/// was valued exactly once, price the pool NAV, then drain the supply/withdraw queues +/// was counted exactly once, price the pool NAV, then drain the supply/withdraw queues /// at that frozen mark (mint PLP for supplies, burn PLP and pay DUSDC for -/// withdrawals), release the valuation lock, consume the potato, and return the -/// LP-attributable pool-wide DUSDC NAV (idle + Σ active NAV, net of the -/// pending-protocol-profit exclusion priced from the aggregate profit basis). +/// withdrawals), consume the potato, and return the LP-attributable pool-wide DUSDC +/// NAV (idle + Σ active NAV, net of the pending-protocol-profit exclusion priced +/// from the aggregate profit basis). /// /// `supply_budget` / `withdraw_budget` bound how many requests each queue may /// process this flush (`None` = unbounded). Filled heads, protocol-refunded @@ -295,13 +345,12 @@ public fun value_expiry( public fun finish_flush( valuation: PoolValuation, vault: &mut PoolVault, - config: &mut ProtocolConfig, + config: &ProtocolConfig, supply_budget: Option, withdraw_budget: Option, ctx: &mut TxContext, ): u64 { config.assert_version(); - config.assert_valuation_in_progress(); valuation.assert_pool_vault(vault); assert_all_expected_valued( &valuation.expected_expiry_markets, @@ -318,10 +367,9 @@ public fun finish_flush( let total_supply = vault.lp.total_supply(); let market_count = valued_expiry_markets.length(); - // Snapshot the share price once (frozen pair), drain both queues against it, then - // release the valuation lock at the very end. The flush IS the full-pool - // valuation, so the single FlushExecuted event carries the priced mark and its - // idle + active-NAV breakdown. + // Snapshot the share price once (frozen pair), then drain both queues against + // it. The flush IS the full-pool valuation, so the single FlushExecuted event + // carries the priced mark and its idle + active-NAV breakdown. let vault_id = vault.id(); let mark = lp_book::new_flush_mark(pool_nav, total_supply); let drain_summary = vault @@ -334,7 +382,6 @@ public fun finish_flush( withdraw_budget, ctx, ); - config.end_valuation(); vault_events::emit_flush_executed( vault_id, ctx.epoch(), @@ -410,8 +457,8 @@ public fun unstake_deep( /// settled-market sweep (deactivate, return all free cash, materialize profit). /// Mint asserts backing but never pulls pool cash, so this is what makes a market /// mintable. The market must already be registered to this vault -/// (`registry::create_and_share_expiry_market`). Blocked while a full-pool valuation is in -/// progress. +/// (`registry::create_and_share_expiry_market`). Cash moves need no valuation-mark +/// maintenance: the flush reads free cash live. public fun rebalance_expiry_cash( vault: &mut PoolVault, market: &mut ExpiryMarket, @@ -421,7 +468,6 @@ public fun rebalance_expiry_cash( clock: &Clock, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); let expiry_market_id = market.id(); vault.expiry_accounting.assert_registered_expiry(expiry_market_id); vault.settle_or_rebalance_expiry(market, config, propbook_registry, pyth, clock); @@ -441,7 +487,6 @@ public fun claim_trading_loss_rebate( ctx: &mut TxContext, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); vault.claim_trading_loss_rebate_internal( @@ -471,7 +516,6 @@ public fun claim_trading_loss_rebate_permissionless( ctx: &mut TxContext, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); wrapper.settle(root, clock); let auth = predict_account::generate_auth_as_app(account_registry); let account = wrapper.load_account_mut(auth); @@ -496,7 +540,6 @@ public fun sponsor_fee_incentives( ctx: &TxContext, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); let amount = payment.value(); assert!( amount >= constants::min_fee_incentive_sponsorship!(), @@ -552,7 +595,6 @@ public fun request_supply( ctx: &mut TxContext, ): u64 { config.assert_version(); - config.assert_not_valuation_in_progress(); assert!(vault.lp.total_supply() > 0, ENotBootstrapped); wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); @@ -590,7 +632,6 @@ public fun request_withdraw( ctx: &mut TxContext, ): u64 { config.assert_version(); - config.assert_not_valuation_in_progress(); assert!(vault.lp.total_supply() > 0, ENotBootstrapped); wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); @@ -623,7 +664,6 @@ public fun cancel_supply_request( ctx: &mut TxContext, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); let vault_id = vault.id(); wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); @@ -654,7 +694,6 @@ public fun cancel_withdraw_request( ctx: &mut TxContext, ) { config.assert_version(); - config.assert_not_valuation_in_progress(); let vault_id = vault.id(); wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); @@ -974,21 +1013,6 @@ fun materialize_expiry_profit( ); } -/// Engage the valuation lock and snapshot the active expiry set for the single -/// flush entrypoint, `start_pool_valuation` (lifecycle-cap-gated; the root-AdminCap -/// flush path was removed — admin's break-glass is minting itself a lifecycle cap). -/// Gated on a bootstrapped pool so the flush mark always has nonzero PLP supply. -fun start_pool_valuation_internal(config: &mut ProtocolConfig, vault: &PoolVault): PoolValuation { - assert!(vault.lp.total_supply() > 0, ENotBootstrapped); - config.begin_valuation(); - PoolValuation { - pool_vault_id: vault.id(), - expected_expiry_markets: vault.expiry_accounting.active_expiry_markets(), - valued_expiry_markets: vector[], - total_nav: 0, - } -} - /// Abort unless this valuation belongs to `vault`. fun assert_pool_vault(valuation: &PoolValuation, vault: &PoolVault) { assert!(valuation.pool_vault_id == vault.id(), EWrongPoolVault); @@ -1003,8 +1027,8 @@ fun assert_expiry_ready_to_value(valuation: &PoolValuation, expiry_market_id: ID ); } -/// The exactly-once completeness proof: the valued set must equal the snapshot -/// (a missed market means a wrong pool NAV). `value_expiry` already rejects +/// The exactly-once completeness proof: the counted set must equal the snapshot +/// (a missed market means a wrong pool NAV). `collect_expiry_nav` already rejects /// non-snapshot and duplicate ids, so equal lengths plus full coverage suffice. fun assert_all_expected_valued(expected: &vector, valued: &vector) { assert!(valued.length() == expected.length(), EMissingExpiryValuation); diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 453654a20..2a5e62b02 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -61,6 +61,8 @@ const EWrongBlockScholesSVIFeed: u64 = 12; const ETickNotInPriceMemo: u64 = 13; const EBlockScholesPriceUnavailable: u64 = 14; const EBlockScholesSVIUnavailable: u64 = 15; +const EMarkForwardDrifted: u64 = 16; +const EMarkVarianceDrifted: u64 = 17; /// Predict's private pricing envelope for raw propbook BS inputs. These are not /// oracle-source validity rules; they only bound the forward/basis and SVI inputs @@ -92,6 +94,65 @@ public(package) fun expiry_market_id(pricer: &Pricer): ID { pricer.expiry_market_id } +/// Return the forward this pricer snapshotted (drift-guard anchor). +public(package) fun forward(pricer: &Pricer): u64 { + pricer.forward +} + +/// Return sqrt of this pricer's SVI minimum total variance, +/// `sqrt(a + b * sigma * sqrt(1 - rho^2))`, in FLOAT_SCALING. +/// +/// This is the surface's global variance floor over all strikes. The drift guard +/// uses it as the tolerance scale for forward moves: a digital's price sensitivity +/// to log-forward moves is ~`phi(d2)/sqrt(w)`, so bounding `|Δln F|` by a multiple +/// of the minimum `sqrt(w)` bounds every order's price drift uniformly — and the +/// tolerance auto-tightens as expiry approaches and variance decays. +public(package) fun sqrt_min_total_variance(pricer: &Pricer): u64 { + let rho = pricer.svi.rho(); + let rho_squared = rho.mul_scaled(&rho).magnitude(); + // |rho| <= 1 inside the pricing-safe envelope; saturate the complement against + // fixed-point rounding dust at the |rho| = 1 corner. + let one_minus_rho_squared = math::float_scaling!().saturating_sub(rho_squared); + let root = math::sqrt(one_minus_rho_squared, math::float_scaling!()); + let min_total_var = + pricer.svi.a() + math::mul(pricer.svi.b(), math::mul(pricer.svi.sigma(), root)); + math::sqrt(min_total_var, math::float_scaling!()) +} + +/// Abort unless this pricer's oracle inputs are still within `epsilon` of the +/// anchors a stored valuation mark was computed at. +/// +/// Two legs, one tolerance: the relative forward move must stay within +/// `epsilon * anchor_sqrt_min_total_variance` (log-forward drift scaled by the +/// surface's variance floor), and the relative sqrt-min-variance move must stay +/// within `epsilon` (surface reshaping). Together they bound any single order's +/// price drift since the mark to roughly `0.4 * epsilon` of face. `|ΔF|/F` stands +/// in for `|Δln F|`: the two agree to second order at the small moves epsilon +/// admits. A degenerate zero anchor variance rejects every forward move — the +/// mark simply requires a re-refresh (fail-closed). +public(package) fun assert_mark_drift_within( + pricer: &Pricer, + anchor_forward: u64, + anchor_sqrt_min_total_variance: u64, + epsilon: u64, +) { + let forward_move = math::mul_div_down( + pricer.forward.diff(anchor_forward), + math::float_scaling!(), + anchor_forward, + ); + assert!( + forward_move <= math::mul(epsilon, anchor_sqrt_min_total_variance), + EMarkForwardDrifted, + ); + let sqrt_min_total_variance = sqrt_min_total_variance(pricer); + assert!( + sqrt_min_total_variance.diff(anchor_sqrt_min_total_variance) + <= math::mul(epsilon, anchor_sqrt_min_total_variance), + EMarkVarianceDrifted, + ); +} + // === Public-Package Functions === /// Validate the current live pricing boundary and snapshot oracle inputs for @@ -397,8 +458,11 @@ fun compute_nd2(svi_params: &SVIParams, forward: u64, strike: u64): u64 { let nd2 = math::normal_cdf(&d2); if (w_prime.is_zero()) return nd2; - let correction_magnitude = - math::mul_div_down(math::normal_pdf(&d2), w_prime.magnitude(), 2 * sqrt_var); + let correction_magnitude = math::mul_div_down( + math::normal_pdf(&d2), + w_prime.magnitude(), + 2 * sqrt_var, + ); let correction = i64::from_parts(correction_magnitude, w_prime.is_negative()); let adjusted = i64::from_u64(nd2).sub(&correction); if (adjusted.is_negative()) return 0; diff --git a/packages/predict/sources/registry/registry.move b/packages/predict/sources/registry/registry.move index cc01f2f5a..87383abed 100644 --- a/packages/predict/sources/registry/registry.move +++ b/packages/predict/sources/registry/registry.move @@ -235,7 +235,6 @@ public fun create_and_share_expiry_market( 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 .next_deployable_market(propbook_registry, propbook_underlying_id, cadence_id, clock); From 4f65463dead42e337cb43ec56991aad859db06ca Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 09:34:30 -0400 Subject: [PATCH 02/23] predict: plain-language doc for the drift-epsilon knob Co-Authored-By: Claude Fable 5 --- .../predict/sources/config/valuation_config.move | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index c4cc2fc92..2cdef7e43 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -14,10 +14,16 @@ use deepbook_predict::config_constants; public struct ValuationConfig has store { /// Hard ceiling on stored-mark age in milliseconds, regardless of oracle drift. nav_mark_freshness_ms: u64, - /// Oracle-drift tolerance in FLOAT_SCALING. A mark is rejected when the - /// relative forward move exceeds `epsilon * sqrt(min total variance)` or the - /// relative sqrt-min-variance move exceeds `epsilon`, bounding any single - /// order's price drift to roughly `0.4 * epsilon` of face at every expiry. + /// How far the oracle may move before a stored mark is rejected, in + /// FLOAT_SCALING (0.02 = 2%). Feeds keep moving after a market's refresh, + /// so the flush re-reads them and rejects the mark when contract prices + /// could have moved materially since it was computed. Two checks, one knob: + /// the forward may move at most `epsilon` of one standard deviation of the + /// price move still expected before expiry, and that expected-move level + /// itself may shift at most `epsilon` relative. Near expiry the expected + /// move is small, so the allowed forward drift tightens automatically. + /// Within tolerance, no contract's fair value can have drifted by more + /// than about `0.4 * epsilon` of its full payout (~0.8% at the default). nav_mark_drift_epsilon: u64, } From 1ffacb4df930820f8b766610f8e205061a44c3e5 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 09:37:36 -0400 Subject: [PATCH 03/23] predict: take Pricer directly in refresh_expiry_nav and collect_expiry_nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the priced trade flows: callers load the Pricer once per market via load_live_pricer and pass it in. A Pricer cannot outlive its transaction and cannot be loaded for a past-expiry market, so holding one proves the market is live here — the refresh's settle branch drops out, and terminal markets keep their existing sweep path (rebalance_expiry_cash). plp no longer touches the Block Scholes feed objects at all. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/plp/plp.move | 88 +++++++++------------------ 1 file changed, 30 insertions(+), 58 deletions(-) diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 1a9957c7e..eee5724cc 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -27,18 +27,13 @@ use deepbook_predict::{ market_lifecycle_cap::MarketLifecycleProof, pool_accounting::{Self, Ledger}, predict_account, + pricing::Pricer, protocol_config::ProtocolConfig, vault_events }; use dusdc::dusdc::DUSDC; use fixed_math::math; -use propbook::{ - block_scholes_forward_feed::BlockScholesForwardFeed, - block_scholes_spot_feed::BlockScholesSpotFeed, - block_scholes_svi_feed::BlockScholesSVIFeed, - pyth_feed::PythFeed, - registry::OracleRegistry -}; +use propbook::{pyth_feed::PythFeed, registry::OracleRegistry}; use sui::{ accumulator::AccumulatorRoot, balance::{Self, Balance}, @@ -207,14 +202,18 @@ public fun pending_protocol_profit(vault: &PoolVault): u64 { vault.expiry_accounting.pending_protocol_profit() } -/// Refresh one market's stored valuation mark as a market deployer, using a -/// registry-generated `MarketLifecycleProof`. A settled market is swept -/// (deactivated, cash returned, profit materialized — it leaves the active set -/// and needs no mark); a live market is rebalanced to target, its exact per-order -/// liability recomputed at the live oracle, and the mark stored for -/// `collect_expiry_nav` to read. Several markets may be refreshed in one PTB -/// subject to the per-transaction object budget (each refresh walks its market's -/// payout tree). +/// Refresh one live market's stored valuation mark as a market deployer, using a +/// registry-generated `MarketLifecycleProof` and a `Pricer` loaded in this +/// transaction (`expiry_market::load_live_pricer`, like every priced flow). The +/// market is rebalanced to target, its exact per-order liability recomputed at +/// the pricer's oracle inputs, and the mark stored for `collect_expiry_nav` to +/// read. Several markets may be refreshed in one PTB subject to the +/// per-transaction object budget (each refresh walks its market's payout tree). +/// +/// Live-markets-only by construction: a `Pricer` cannot outlive its transaction +/// and `load_live_pricer` rejects past-expiry markets, so holding one proves the +/// market is pre-expiry here — no settle branch needed. Terminal markets are +/// settled and swept out of the active set by `rebalance_expiry_cash`. /// /// PRIVILEGED like the flush start (audit L8): the mark's refresh instant prices /// the pool NAV the queues later drain at, and Pyth updates are permissionless — @@ -224,30 +223,15 @@ public fun refresh_expiry_nav( market: &mut ExpiryMarket, lifecycle_proof: MarketLifecycleProof, config: &ProtocolConfig, - propbook_registry: &OracleRegistry, - pyth: &PythFeed, - bs_spot: &BlockScholesSpotFeed, - bs_forward: &BlockScholesForwardFeed, - bs_svi: &BlockScholesSVIFeed, + pricer: &Pricer, clock: &Clock, ) { config.assert_version(); lifecycle_proof.destroy_proof(); let expiry_market_id = market.id(); vault.expiry_accounting.assert_registered_expiry(expiry_market_id); - if (vault.settle_or_rebalance_expiry(market, config, propbook_registry, pyth, clock)) { - return - }; - let pricer = market.load_live_pricer( - config, - propbook_registry, - pyth, - bs_spot, - bs_forward, - bs_svi, - clock, - ); - let liability = market.record_valuation_mark(&pricer, clock); + vault.rebalance_live_expiry(market, expiry_market_id); + let liability = market.record_valuation_mark(pricer, clock); vault_events::emit_nav_refreshed( vault.id(), expiry_market_id, @@ -287,43 +271,31 @@ public fun start_pool_valuation( } } -/// Fold one snapshotted market's stored valuation mark into the running pool NAV. -/// The market must be in the snapshot and not already counted (the exactly-once -/// proof), and is a READ-ONLY input: no cash moves, no settlement, no tree walk — -/// the mark must exist, be within the freshness ceiling, and its oracle anchors -/// must be within the drift tolerance of the live feeds +/// Fold one snapshotted market's stored valuation mark into the running pool NAV, +/// using a `Pricer` loaded in this transaction (its live oracle inputs are what +/// the drift guard compares the mark's anchors against). The market must be in +/// the snapshot and not already counted (the exactly-once proof), and is a +/// READ-ONLY input: no cash moves, no settlement, no tree walk — the mark must +/// exist, be within the freshness ceiling, and within the drift tolerance /// (`expiry_market::read_flushable_nav`), else this aborts and the operator /// re-refreshes the market and retries the flush. /// -/// A settled or past-expiry market cannot produce the fresh pricer this read -/// requires (`load_live_pricer` rejects past-expiry): sweep it via -/// `refresh_expiry_nav` or `rebalance_expiry_cash` so it leaves the active set, -/// then start a new flush. There is still no solvency-safe substitute mark for an -/// unsettled expired market; the abort clears once anyone lands the exact spot. +/// A settled or past-expiry market cannot produce the pricer this read requires +/// (`load_live_pricer` rejects past-expiry): sweep it via `rebalance_expiry_cash` +/// so it leaves the active set, then start a new flush. There is still no +/// solvency-safe substitute mark for an unsettled expired market; the abort +/// clears once anyone lands the exact spot. public fun collect_expiry_nav( valuation: &mut PoolValuation, market: &ExpiryMarket, config: &ProtocolConfig, - propbook_registry: &OracleRegistry, - pyth: &PythFeed, - bs_spot: &BlockScholesSpotFeed, - bs_forward: &BlockScholesForwardFeed, - bs_svi: &BlockScholesSVIFeed, + pricer: &Pricer, clock: &Clock, ) { config.assert_version(); let expiry_market_id = market.id(); valuation.assert_expiry_ready_to_value(expiry_market_id); - let pricer = market.load_live_pricer( - config, - propbook_registry, - pyth, - bs_spot, - bs_forward, - bs_svi, - clock, - ); - let nav = market.read_flushable_nav(config.valuation_config(), &pricer, clock); + let nav = market.read_flushable_nav(config.valuation_config(), pricer, clock); valuation.valued_expiry_markets.push_back(expiry_market_id); valuation.total_nav = valuation.total_nav + nav; } From 6a17030939a2892727cb6b1dc7fc2c8d8562d2cd Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 10:39:07 -0400 Subject: [PATCH 04/23] predict: net NAV at the pool, hook liquidation write-through, extract ValuationMark (DBU-557) Review + design follow-ups on the draft: - Markets return raw flush atoms (free_cash, marked liability) with no per-market floor; the potato sums both and lp_pool_value applies the single zero floor at the end of the full chain. An underwater market (legitimate transient at backing lambda < 1) now nets against the pool instead of silently overstating the mark; FlushExecuted emits both raw sums so aggregate-underwater is observable. - Liquidation writes its removed live value through to the mark (review finding, High): knock-out fires above the floor, so a knocked-out order carries ~F*(1/ltv - 1) of value; all five liquidation paths now return it and decrement the mark. - ValuationMark moves to its own module owning construction, the freshness/drift acceptance, and write-through arithmetic; expiry_market keeps only the seams touching its private children. - Comment corrections: the falsified limited-recourse justification, the misleading no-liquidation-pass wording, plain-language drift-guard docs, and an explicit note on the wing-reshape blind spot (open item). Co-Authored-By: Claude Fable 5 --- .../sources/config/valuation_config.move | 4 +- .../predict/sources/events/vault_events.move | 28 ++-- packages/predict/sources/expiry_market.move | 124 +++++++++--------- packages/predict/sources/plp/plp.move | 60 +++++---- packages/predict/sources/pricing/pricing.move | 42 +++--- .../strike_exposure/strike_exposure.move | 65 +++++---- packages/predict/sources/valuation_mark.move | 86 ++++++++++++ 7 files changed, 273 insertions(+), 136 deletions(-) create mode 100644 packages/predict/sources/valuation_mark.move diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index 2cdef7e43..59a137cdf 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -23,7 +23,9 @@ public struct ValuationConfig has store { /// itself may shift at most `epsilon` relative. Near expiry the expected /// move is small, so the allowed forward drift tightens automatically. /// Within tolerance, no contract's fair value can have drifted by more - /// than about `0.4 * epsilon` of its full payout (~0.8% at the default). + /// than about `0.4 * epsilon` of its full payout (~0.8% at the default) — + /// for the moves these checks see; a wing reshape at a fixed variance floor + /// passes unexamined (`pricing::assert_mark_drift_within`, known blind spot). nav_mark_drift_epsilon: u64, } diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index 9daa10973..bf2f862f2 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -154,10 +154,11 @@ public struct WithdrawFilled has copy, drop, store { /// Emitted once per flush after both queues drain. The flush IS the full-pool /// valuation, so this single event carries the frozen mark every fill was priced at -/// (`pool_value` over `total_supply`), its valuation breakdown (`idle_balance_before` -/// plus `active_market_nav` over `market_count` active markets), how many of each -/// kind filled, how many queue heads spent per-flush budget (filled, -/// protocol-refunded, or live limit-missed), and the idle balance after the drain. +/// (`pool_value` over `total_supply`), its raw valuation breakdown +/// (`idle_balance_before` plus `total_free_cash` owing `total_liability` over +/// `market_count` active markets), how many of each kind filled, how many queue +/// heads spent per-flush budget (filled, protocol-refunded, or live limit-missed), +/// and the idle balance after the drain. /// Emitted when a live market's valuation mark is refreshed: the exact /// per-order liability stored for the pool flush to read, at its landing time. public struct NavRefreshed has copy, drop, store { @@ -172,12 +173,17 @@ public struct NavRefreshed has copy, drop, store { public struct FlushExecuted has copy, drop, store { pool_vault_id: ID, epoch: u64, - /// LP-attributable pool NAV every fill was priced at: idle plus - /// `active_market_nav`, excluding unrealized and pending protocol profit. + /// LP-attributable pool NAV every fill was priced at: idle plus the raw + /// market sums below, excluding unrealized and pending protocol profit, + /// floored once at zero. pool_value: u64, total_supply: u64, - /// Σ of each active market's exact NAV at valuation (settled markets contribute 0). - active_market_nav: u64, + /// Σ of each counted market's live free cash at the flush (raw, unfloored). + /// Together with `total_liability` this exposes aggregate-underwater + /// conditions that a netted NAV would hide. + total_free_cash: u64, + /// Σ of each counted market's marked liability at the flush (raw, unfloored). + total_liability: u64, /// Number of active markets valued for this flush. market_count: u64, /// Idle DUSDC held by the pool at valuation time, before the drain. @@ -455,7 +461,8 @@ public(package) fun emit_flush_executed( epoch: u64, pool_value: u64, total_supply: u64, - active_market_nav: u64, + total_free_cash: u64, + total_liability: u64, market_count: u64, idle_balance_before: u64, supplies_filled: u64, @@ -468,7 +475,8 @@ public(package) fun emit_flush_executed( epoch, pool_value, total_supply, - active_market_nav, + total_free_cash, + total_liability, market_count, idle_balance_before, supplies_filled, diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 73e5aff7a..d09427f1d 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -26,7 +26,8 @@ use deepbook_predict::{ protocol_config::ProtocolConfig, strike_exposure::{Self, MintTerms, StrikeExposure}, strike_exposure_config, - valuation_config::ValuationConfig + valuation_config::ValuationConfig, + valuation_mark::{Self, ValuationMark} }; use dusdc::dusdc::DUSDC; use fixed_math::math; @@ -53,7 +54,6 @@ const EMintRedeemSameTimestamp: u64 = 10; const ERedeemProbabilityBelowMin: u64 = 11; const ERedeemProceedsBelowMin: u64 = 12; const EValuationMarkMissing: u64 = 13; -const EValuationMarkStale: u64 = 14; /// Per-expiry market state. public struct ExpiryMarket has key { @@ -79,21 +79,14 @@ public struct ExpiryMarket has key { valuation_mark: Option, } -/// Stored valuation mark for the pool flush: this market's exact oracle-priced -/// per-order liability as of `computed_at_ms`, plus the pricing anchors the flush -/// compares against live feeds to bound oracle drift. Trade flows write their -/// bit-exact liability delta through (mint adds `net_premium`, live redeem -/// subtracts `redeem_amount`); liquidation removes only orders whose live value -/// already nets to zero, so it leaves the mark unchanged. Free cash is never -/// stored — the flush reads it live, so cash moves need no mark maintenance. -public struct ValuationMark has copy, drop, store { - liability: u64, - /// On-chain landing time of the refresh that computed this mark. - computed_at_ms: u64, - /// Forward the mark was priced at (drift-guard anchor). - forward: u64, - /// sqrt of the SVI minimum total variance at refresh (drift-guard tolerance scale). - sqrt_min_total_variance: u64, +/// One market's raw flush inputs: live free cash and the stored marked +/// liability. Deliberately unclamped — a market can legitimately owe more than +/// it holds (a backing lambda below 1 admits transient shortfalls backstopped by +/// pool rebalancing), so netting across markets and the single zero floor happen +/// at the pool level in `plp`, the policy owner. +public struct FlushAtoms has copy, drop { + free_cash: u64, + marked_liability: u64, } /// Read-only all-in cost quote for a prospective live mint, in DUSDC base units. @@ -269,12 +262,12 @@ public fun load_live_pricer( public fun current_nav(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); let liability = market.strike_exposure.exact_live_liability(pricer); - // Floor at 0 rather than abort: a degenerate underwater market has zero - // limited-recourse value, and partial-close `walk_linear` survivors can leave - // residual ulp dust that makes liability exceed free cash by ~1-2 ulp/order. - // This is a ROUNDING_POLICY R1/R2 liveness/dust clamp, not a conservative - // supply mark: a lower pool mark would mint more PLP to new suppliers, so the - // exact-mark invariant remains the governing safety property. + // Floor at 0 for this single-market READ only. A market can legitimately owe + // more than it holds (a backing lambda below 1 admits transient shortfalls + // that pool rebalancing later refills), so an unfloored per-market value is + // meaningful — which is why the flush no longer consumes this function: it + // aggregates raw `flushable_atoms` and nets shortfalls at the pool level. + // Here the floor only keeps a devInspect/SDK read total-ordered at zero. market.cash.free_cash().saturating_sub(liability) } @@ -426,12 +419,13 @@ public fun mint_exact_quantity( wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - market + let sweep = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); + market.mark_liability_removed(sweep.removed_live_value()); market.mint_prepared_exact_quantity( account, @@ -477,12 +471,13 @@ public fun mint_exact_amount( let amount = amount.min(wrapper.load_account().balance(root, clock)); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - market + let sweep = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); + market.mark_liability_removed(sweep.removed_live_value()); let quantity = market.max_mint_quantity_for_amount( pricer, @@ -544,8 +539,13 @@ public fun redeem_live( let account = wrapper.load_account_mut(auth); let redeemed_order = order::from_order_id(order_id); - market.strike_exposure.liquidate_live_orders(pricer, config.trade_liquidation_budget()); - market.strike_exposure.liquidate_live_order(pricer, &redeemed_order); + let sweep = market + .strike_exposure + .liquidate_live_orders(pricer, config.trade_liquidation_budget()); + let knocked_out = market.strike_exposure.liquidate_live_order(pricer, &redeemed_order); + market.mark_liability_removed( + sweep.removed_live_value() + knocked_out.get_with_default(0), + ); if (market.strike_exposure.is_liquidated_order(&redeemed_order)) { market.redeem_liquidated_order(account, &redeemed_order, close_quantity, ctx); return (redeemed_order.id(), option::none()) @@ -643,7 +643,9 @@ public fun liquidate( budget: u64, ): u64 { market.assert_live_flow_allowed(config, pricer); - market.strike_exposure.liquidate_live_orders(pricer, budget) + let sweep = market.strike_exposure.liquidate_live_orders(pricer, budget); + market.mark_liability_removed(sweep.removed_live_value()); + sweep.liquidated_count() } /// Try to liquidate one active leveraged order by ID. @@ -656,7 +658,9 @@ public fun liquidate_order( market.assert_live_flow_allowed(config, pricer); let order = order::from_order_id(order_id); - market.strike_exposure.liquidate_live_order(pricer, &order) + let removed = market.strike_exposure.liquidate_live_order(pricer, &order); + market.mark_liability_removed(removed.get_with_default(0)); + removed.is_some() } /// Set this expiry's reference fine-grid tick from the exact previous-window @@ -740,33 +744,34 @@ public(package) fun ensure_settled( true } -/// Read the flush-consumable NAV off the stored valuation mark: assert the mark -/// exists, is within the freshness ceiling, and that the live oracle inputs in -/// `pricer` have not drifted beyond the configured tolerance since it was -/// computed; then return live free cash minus the marked liability. The payout -/// tree is never walked here — that is the point: the walk ran in the refresh -/// that stored the mark, and this read loads no per-order objects. -public(package) fun read_flushable_nav( +/// Read one market's flush inputs off the stored valuation mark: assert the mark +/// exists, delegate the freshness/drift acceptance to the mark, and return live +/// free cash plus the marked liability as raw atoms. The payout tree is never +/// walked here — that is the point: the walk ran in the refresh that stored the +/// mark, and this read loads no per-order objects. No clamp either — see +/// `FlushAtoms` for why the floor lives at the pool level. +public(package) fun flushable_atoms( market: &ExpiryMarket, valuation_config: &ValuationConfig, pricer: &Pricer, clock: &Clock, -): u64 { +): FlushAtoms { market.assert_pricer_bound(pricer); assert!(market.valuation_mark.is_some(), EValuationMarkMissing); let mark = market.valuation_mark.borrow(); - assert!( - clock.timestamp_ms() - mark.computed_at_ms <= valuation_config.nav_mark_freshness_ms(), - EValuationMarkStale, - ); - pricer.assert_mark_drift_within( - mark.forward, - mark.sqrt_min_total_variance, - valuation_config.nav_mark_drift_epsilon(), - ); - // Same degenerate-underwater / ulp-dust clamp as `current_nav`: a market whose - // marked liability exceeds free cash has zero limited-recourse value. - market.cash.free_cash().saturating_sub(mark.liability) + mark.assert_flushable(valuation_config, pricer, clock); + FlushAtoms { + free_cash: market.cash.free_cash(), + marked_liability: mark.liability(), + } +} + +public(package) fun free_cash(atoms: &FlushAtoms): u64 { + atoms.free_cash +} + +public(package) fun marked_liability(atoms: &FlushAtoms): u64 { + atoms.marked_liability } /// Recompute this market's exact per-order live liability and store it as the @@ -779,13 +784,7 @@ public(package) fun record_valuation_mark( ): u64 { market.assert_pricer_bound(pricer); let liability = market.strike_exposure.exact_live_liability(pricer); - market.valuation_mark = - option::some(ValuationMark { - liability, - computed_at_ms: clock.timestamp_ms(), - forward: pricer.forward(), - sqrt_min_total_variance: pricer.sqrt_min_total_variance(), - }); + market.valuation_mark = option::some(valuation_mark::new(liability, pricer, clock)); liability } @@ -1007,19 +1006,16 @@ fun assert_pricer_bound(market: &ExpiryMarket, pricer: &Pricer) { /// establishes a mark. fun mark_liability_added(market: &mut ExpiryMarket, amount: u64) { if (market.valuation_mark.is_some()) { - let mark = market.valuation_mark.borrow_mut(); - mark.liability = mark.liability + amount; + market.valuation_mark.borrow_mut().add_liability(amount); }; } -/// Write a live redeem's liability delta through to the stored valuation mark. -/// The delta is priced at the op's oracle while the mark's sum is anchored at its -/// refresh oracle (drift-bounded), so clamp the mixed-anchor residual rather than -/// abort a user exit. +/// Write a liability decrease through to the stored valuation mark: a live +/// redeem's `redeem_amount`, or the live value a liquidation pass removed. +/// No-op until the first refresh establishes a mark. fun mark_liability_removed(market: &mut ExpiryMarket, amount: u64) { if (market.valuation_mark.is_some()) { - let mark = market.valuation_mark.borrow_mut(); - mark.liability = mark.liability.saturating_sub(amount); + market.valuation_mark.borrow_mut().remove_liability(amount); }; } diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index eee5724cc..abd7935f1 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -89,8 +89,13 @@ public struct PoolValuation { expected_expiry_markets: vector, /// Markets counted so far this flow; folded against `expected` at finish. valued_expiry_markets: vector, - /// Running Σ of each counted market's mark-derived NAV. - total_nav: u64, + /// Running Σ of each counted market's live free cash. Kept separate from the + /// liability sum (raw atoms, no per-market netting or floor) so underwater + /// markets net against the whole pool and the single zero floor is applied + /// once, in `lp_pool_value`. + total_free_cash: u64, + /// Running Σ of each counted market's marked liability. + total_liability: u64, } // === Package Initializer === @@ -267,7 +272,8 @@ public fun start_pool_valuation( pool_vault_id: vault.id(), expected_expiry_markets: vault.expiry_accounting.active_expiry_markets(), valued_expiry_markets: vector[], - total_nav: 0, + total_free_cash: 0, + total_liability: 0, } } @@ -295,9 +301,10 @@ public fun collect_expiry_nav( config.assert_version(); let expiry_market_id = market.id(); valuation.assert_expiry_ready_to_value(expiry_market_id); - let nav = market.read_flushable_nav(config.valuation_config(), pricer, clock); + let atoms = market.flushable_atoms(config.valuation_config(), pricer, clock); valuation.valued_expiry_markets.push_back(expiry_market_id); - valuation.total_nav = valuation.total_nav + nav; + valuation.total_free_cash = valuation.total_free_cash + atoms.free_cash(); + valuation.total_liability = valuation.total_liability + atoms.marked_liability(); } /// Finish a full-pool valuation and run the LP flush: prove every snapshotted market @@ -328,13 +335,14 @@ public fun finish_flush( &valuation.expected_expiry_markets, &valuation.valued_expiry_markets, ); - let PoolValuation { total_nav, valued_expiry_markets, .. } = valuation; + let PoolValuation { total_free_cash, total_liability, valued_expiry_markets, .. } = valuation; let idle_balance_before = vault.expiry_accounting.idle_balance(); let pool_nav = lp_pool_value( vault, config.protocol_reserve_profit_share(), - total_nav, + total_free_cash, + total_liability, ); let total_supply = vault.lp.total_supply(); let market_count = valued_expiry_markets.length(); @@ -359,7 +367,8 @@ public fun finish_flush( ctx.epoch(), pool_nav, total_supply, - total_nav, + total_free_cash, + total_liability, market_count, idle_balance_before, drain_summary.supplies_filled(), @@ -751,9 +760,11 @@ fun claim_trading_loss_rebate_internal( /// LP-attributable DUSDC pool value used to price PLP supply/withdraw. /// -/// `gross = idle_balance + active_expiry_value`. NAV prices the protocol's -/// not-yet-materialized profit share before terminal materialization and excludes -/// it from LP value: `exclusion = share * max(0, (credits + active) - debits)` +/// Assets and liabilities arrive as raw per-market sums (no per-market netting or +/// floor — see `FlushAtoms`): `gross = idle_balance + Σ free_cash`, owing +/// `Σ liability`. NAV prices the protocol's not-yet-materialized profit share +/// before terminal materialization and excludes it from LP value: +/// `exclusion = share * max(0, (credits + Σ free_cash) - (debits + Σ liability))` /// (live cash returns update credits, but reserve custody waits for terminal /// profit). A cut already materialized but not yet physically moved (idle was /// deployed elsewhere) has left that debit-basis exclusion, so the carried @@ -762,25 +773,30 @@ fun claim_trading_loss_rebate_internal( fun lp_pool_value( vault: &PoolVault, protocol_reserve_profit_share: u64, - active_expiry_value: u64, + total_free_cash: u64, + total_liability: u64, ): u64 { let idle_balance = vault.expiry_accounting.idle_balance(); let profit_basis_credits = vault.expiry_accounting.profit_basis_credits(); let profit_basis_debits = vault.expiry_accounting.profit_basis_debits(); let pending_protocol_profit = vault.expiry_accounting.pending_protocol_profit(); - let gross_pool_value = idle_balance + active_expiry_value; - let aggregate_credits = profit_basis_credits + active_expiry_value; + let gross_pool_value = idle_balance + total_free_cash; + let aggregate_credits = profit_basis_credits + total_free_cash; + let aggregate_debits = profit_basis_debits + total_liability; let exclusion = math::mul( - aggregate_credits.saturating_sub(profit_basis_debits), + aggregate_credits.saturating_sub(aggregate_debits), protocol_reserve_profit_share, ); - // The realized `credits - debits` term is sticky: it does not shrink when LPs - // withdraw idle cash, so when an active mark they withdrew against later - // collapses, the held-out total (`exclusion + pending_protocol_profit`) can - // exceed gross. LP value can never be negative, so floor it at 0 to keep the - // subtraction from underflow-aborting. A 0/dust pool NAV makes non-executable - // LP queue heads refund inside `lp_book::drain`, rather than aborting the flush. - gross_pool_value.saturating_sub(exclusion + pending_protocol_profit) + // THE single policy floor for pool NAV — per-market values are deliberately + // never floored (an underwater market at backing lambda < 1 is a legitimate + // transient that must net against the rest of the pool). Two ways the + // subtraction can exceed gross: an aggregate-underwater book, and the sticky + // realized `credits - debits` term (it does not shrink when LPs withdraw idle + // cash, so a later mark collapse can push the held-out total past gross). LP + // value can never be negative, so floor at 0; a 0/dust pool NAV makes + // non-executable LP queue heads refund inside `lp_book::drain`, rather than + // aborting the flush. + gross_pool_value.saturating_sub(total_liability + exclusion + pending_protocol_profit) } /// Shared settle-or-rebalance dispatch: passively settle the market off Propbook's diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 2a5e62b02..71494f2b2 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -99,14 +99,17 @@ public(package) fun forward(pricer: &Pricer): u64 { pricer.forward } -/// Return sqrt of this pricer's SVI minimum total variance, -/// `sqrt(a + b * sigma * sqrt(1 - rho^2))`, in FLOAT_SCALING. +/// Return the size of one standard deviation of the price move this surface +/// still expects before expiry, at the strike where that expectation is +/// smallest — the surface's variance floor. In FLOAT_SCALING; the formula is +/// `sqrt(a + b * sigma * sqrt(1 - rho^2))`, the global minimum of SVI total +/// variance over strikes. /// -/// This is the surface's global variance floor over all strikes. The drift guard -/// uses it as the tolerance scale for forward moves: a digital's price sensitivity -/// to log-forward moves is ~`phi(d2)/sqrt(w)`, so bounding `|Δln F|` by a multiple -/// of the minimum `sqrt(w)` bounds every order's price drift uniformly — and the -/// tolerance auto-tightens as expiry approaches and variance decays. +/// The drift guard scales its forward tolerance by this number: contract prices +/// react to forward moves roughly in units of "expected move remaining", so a +/// tolerance expressed in those units bounds every order's price drift the same +/// amount whether expiry is a month or a minute away — near expiry the expected +/// move shrinks and the allowed forward drift tightens with it automatically. public(package) fun sqrt_min_total_variance(pricer: &Pricer): u64 { let rho = pricer.svi.rho(); let rho_squared = rho.mul_scaled(&rho).magnitude(); @@ -119,17 +122,22 @@ public(package) fun sqrt_min_total_variance(pricer: &Pricer): u64 { math::sqrt(min_total_var, math::float_scaling!()) } -/// Abort unless this pricer's oracle inputs are still within `epsilon` of the -/// anchors a stored valuation mark was computed at. +/// Abort unless the oracle has stayed close enough to where it was when a stored +/// valuation mark was computed — "close enough" meaning contract prices cannot +/// have moved materially in between. /// -/// Two legs, one tolerance: the relative forward move must stay within -/// `epsilon * anchor_sqrt_min_total_variance` (log-forward drift scaled by the -/// surface's variance floor), and the relative sqrt-min-variance move must stay -/// within `epsilon` (surface reshaping). Together they bound any single order's -/// price drift since the mark to roughly `0.4 * epsilon` of face. `|ΔF|/F` stands -/// in for `|Δln F|`: the two agree to second order at the small moves epsilon -/// admits. A degenerate zero anchor variance rejects every forward move — the -/// mark simply requires a re-refresh (fail-closed). +/// Two checks, one `epsilon` knob: the forward may move at most `epsilon` of one +/// standard deviation of the price move still expected before expiry +/// (`epsilon * anchor_sqrt_min_total_variance`), and that expected-move level +/// itself may shift at most `epsilon` relative. For oracle moves these checks +/// see, any single order's price drift since the mark is bounded to roughly +/// `0.4 * epsilon` of its full payout. Known blind spot: both checks key on the +/// surface's variance FLOOR, so a wing reshape that leaves the floor and the +/// forward unchanged passes unexamined — an open pre-deploy item on whether a +/// strike-range leg is needed. `|ΔF|/F` stands in for `|Δln F|` (they agree to +/// second order at these move sizes), and a degenerate zero anchor variance +/// rejects every forward move — the mark simply requires a re-refresh +/// (fail-closed). public(package) fun assert_mark_drift_within( pricer: &Pricer, anchor_forward: u64, diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 787f83ff1..a9c6c67eb 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -112,6 +112,23 @@ public(package) fun range_probability(quote: &CloseQuote): u64 { quote.range_probability } +/// Result of one bounded liquidation pass: how many orders were knocked out and +/// the total live value they carried at the moment of removal (each order's +/// floor-capped `gross - floor`). The value total exists so callers can write the +/// liability removal through to the market's stored valuation mark. +public struct LiquidationSweep has copy, drop { + liquidated_count: u64, + removed_live_value: u64, +} + +public(package) fun liquidated_count(sweep: &LiquidationSweep): u64 { + sweep.liquidated_count +} + +public(package) fun removed_live_value(sweep: &LiquidationSweep): u64 { + sweep.removed_live_value +} + /// Return the buffered live reserve, or exact remaining settled payout liability once materialized. /// /// Live reserve is the settlement floor (max single-point net payout) plus a @@ -131,8 +148,12 @@ public(package) fun payout_liability(exposure: &StrikeExposure): u64 { /// Value this book's exact live liability for one live price snapshot: /// `linear - correction`, where `linear = Σ_orders qty·P` is the full payout-tree /// walk and `correction = Σ_leveraged min(qty·P, floor_shares)` is the static-floor -/// scan over the active leveraged set. The per-order floor cap makes a knocked-out -/// leveraged order net to zero, so no liquidation pass is needed for an exact mark. +/// scan over the active leveraged set. The per-order floor cap zeroes any order +/// priced at or below its floor, so the walk needs no liquidation pass to stay +/// exact — but note a liquidatable-yet-unliquidated order between `floor` and +/// `floor / ltv` still carries `gross - floor` of value here (the P-10 band), and +/// actually liquidating it removes that value (which is why liquidation writes its +/// removal through to the stored valuation mark). /// `correction <= linear` for any mint-admitted book (each leveraged order's `min` /// is capped at its own linear contribution), so the saturating_sub floors only the /// bounded valuation ulp dust the linear walk can carry, rather than aborting. A @@ -353,11 +374,7 @@ public(package) fun quote_mint_entry_probability( /// /// Already-liquidated and currently-liquidatable orders have zero holder value; /// otherwise this returns the order's current range value net of its static floor. -public(package) fun order_value( - exposure: &StrikeExposure, - pricer: &Pricer, - order: &Order, -): u64 { +public(package) fun order_value(exposure: &StrikeExposure, pricer: &Pricer, order: &Order): u64 { if (exposure.is_liquidated_order(order)) return 0; let gross_value = exposure.gross_order_value(pricer, order); @@ -436,8 +453,8 @@ public(package) fun liquidate_live_order( exposure: &mut StrikeExposure, pricer: &Pricer, order: &Order, -): bool { - if (!exposure.liquidation.contains_active_order(order)) return false; +): Option { + if (!exposure.liquidation.contains_active_order(order)) return option::none(); let liquidation_ltv = exposure.config.liquidation_ltv(); exposure.liquidate_order_if_under_floor(pricer, order, liquidation_ltv) } @@ -447,20 +464,24 @@ public(package) fun liquidate_live_orders( exposure: &mut StrikeExposure, pricer: &Pricer, budget: u64, -): u64 { +): LiquidationSweep { let candidates = exposure.liquidation.select_liquidation_candidates(budget); - if (candidates.is_empty()) return 0; + if (candidates.is_empty()) { + return LiquidationSweep { liquidated_count: 0, removed_live_value: 0 } + }; let liquidation_ltv = exposure.config.liquidation_ltv(); let mut liquidated_count = 0; + let mut removed_live_value = 0; candidates.do!(|candidate| { let order = order::from_order_id(candidate); - let liquidated = exposure.liquidate_order_if_under_floor(pricer, &order, liquidation_ltv); - if (liquidated) { + let removed = exposure.liquidate_order_if_under_floor(pricer, &order, liquidation_ltv); + if (removed.is_some()) { liquidated_count = liquidated_count + 1; + removed_live_value = removed_live_value + removed.destroy_some(); }; }); - liquidated_count + LiquidationSweep { liquidated_count, removed_live_value } } /// Cache terminal settled payout liability. @@ -485,11 +506,7 @@ public(package) fun materialize_settled_liability( settled_liability } -fun gross_order_value( - exposure: &StrikeExposure, - pricer: &Pricer, - order: &Order, -): u64 { +fun gross_order_value(exposure: &StrikeExposure, pricer: &Pricer, order: &Order): u64 { let (lower, higher) = exposure.order_boundaries(order); let range_probability = pricer.range_price(lower, higher); math::mul(range_probability, order.quantity()) @@ -505,13 +522,13 @@ fun liquidate_order_if_under_floor( pricer: &Pricer, order: &Order, liquidation_ltv: u64, -): bool { +): Option { let quantity = order.quantity(); let floor_amount = order.floor_shares(); let gross_value = exposure.gross_order_value(pricer, order); let liquidation_threshold = math::div(floor_amount, liquidation_ltv); let can_liquidate = gross_value <= liquidation_threshold; - if (!can_liquidate) return false; + if (!can_liquidate) return option::none(); exposure.liquidation.mark_liquidated(order); exposure @@ -532,7 +549,11 @@ fun liquidate_order_if_under_floor( liquidation_ltv, ); - true + // The knocked-out order's live value under the floor cap: an order between + // its floor and floor/ltv still carries `gross - floor`; one at or below the + // floor carries zero. Returned so the caller can write the removal through + // to the market's stored valuation mark. + option::some(gross_value.saturating_sub(floor_amount)) } /// Decode an order into `(lower, higher)` raw strike boundaries for pricing and diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move new file mode 100644 index 000000000..42630ffae --- /dev/null +++ b/packages/predict/sources/valuation_mark.move @@ -0,0 +1,86 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Stored per-market valuation mark and its flush-acceptance policy. +/// +/// A mark memoizes one market's exact oracle-priced per-order liability (the +/// payout-tree walk) plus the pricing anchors it was computed at, so the pool +/// flush can read the market without walking any tree. This module owns the +/// mark's lifecycle: construction at refresh, the freshness/drift acceptance +/// checks, and the write-through maintenance trade flows apply (mint adds its +/// `net_premium`, live redeem subtracts its `redeem_amount`, liquidation +/// subtracts the knocked-out order's live value). It does not own the walk or +/// the market's cash — `expiry_market` composes those. +module deepbook_predict::valuation_mark; + +use deepbook_predict::{pricing::Pricer, valuation_config::ValuationConfig}; +use sui::clock::Clock; + +const EValuationMarkStale: u64 = 0; + +/// One market's stored valuation mark. Free cash is never stored — the flush +/// reads it live, so cash moves need no mark maintenance. +public struct ValuationMark has copy, drop, store { + /// Exact oracle-priced per-order liability as of `computed_at_ms`, kept + /// current by trade write-through between refreshes. + liability: u64, + /// On-chain landing time of the refresh that computed this mark. + computed_at_ms: u64, + /// Forward the mark was priced at (drift-guard anchor). + forward: u64, + /// sqrt of the SVI minimum total variance at refresh (drift-guard tolerance scale). + sqrt_min_total_variance: u64, +} + +// === Public-Package Functions === + +public(package) fun liability(mark: &ValuationMark): u64 { + mark.liability +} + +public(package) fun computed_at_ms(mark: &ValuationMark): u64 { + mark.computed_at_ms +} + +/// Snapshot a fresh mark: the just-walked liability plus the pricer's anchors. +public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): ValuationMark { + ValuationMark { + liability, + computed_at_ms: clock.timestamp_ms(), + forward: pricer.forward(), + sqrt_min_total_variance: pricer.sqrt_min_total_variance(), + } +} + +/// Abort unless this mark is still usable by the flush: younger than the +/// freshness ceiling, and its oracle anchors within the drift tolerance of the +/// live inputs in `pricer`. +public(package) fun assert_flushable( + mark: &ValuationMark, + valuation_config: &ValuationConfig, + pricer: &Pricer, + clock: &Clock, +) { + assert!( + clock.timestamp_ms() - mark.computed_at_ms <= valuation_config.nav_mark_freshness_ms(), + EValuationMarkStale, + ); + pricer.assert_mark_drift_within( + mark.forward, + mark.sqrt_min_total_variance, + valuation_config.nav_mark_drift_epsilon(), + ); +} + +/// Write a trade's exact liability increase through to the mark. +public(package) fun add_liability(mark: &mut ValuationMark, amount: u64) { + mark.liability = mark.liability + amount; +} + +/// Write a trade's liability decrease through to the mark. The delta is priced +/// at the op's oracle while the mark's sum is anchored at its refresh oracle +/// (drift-bounded), so clamp the mixed-anchor residual rather than abort a user +/// exit or liquidation pass. +public(package) fun remove_liability(mark: &mut ValuationMark, amount: u64) { + mark.liability = mark.liability.saturating_sub(amount); +} From 4c3924d99b17793457ff24129d9b5332d8cc46bc Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 10:45:20 -0400 Subject: [PATCH 05/23] predict: correct the drift-bound doc claim to the measured ~0.8*epsilon Measured worst case over shape-preserving just-under-tolerance moves is 0.77-0.79 * epsilon of face (~2x the earlier ~0.4 estimate): 1.5% at the 0.02 default, ~8% at the 0.1 envelope ceiling. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/config/config_constants.move | 6 ++++-- packages/predict/sources/config/valuation_config.move | 3 ++- packages/predict/sources/pricing/pricing.move | 3 ++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/predict/sources/config/config_constants.move b/packages/predict/sources/config/config_constants.move index 1c1256f8b..822ee347f 100644 --- a/packages/predict/sources/config/config_constants.move +++ b/packages/predict/sources/config/config_constants.move @@ -374,8 +374,10 @@ public(package) fun assert_nav_mark_freshness_ms(value: u64) { } public(package) macro fun default_nav_mark_drift_epsilon(): u64 { 20_000_000 } -// Floor: epsilon = 0 would reject every stored mark and brick the flush; ceiling: -// 0.1 keeps the tolerated per-order price drift within ~4% of face. +// Floor: epsilon = 0 would reject every stored mark and brick the flush (the +// floor setting is de-facto "always re-refresh" but stays fail-closed); ceiling: +// 0.1 keeps the tolerated per-order price drift within ~8% of face (measured +// worst case ~0.8 * epsilon over shape-preserving moves). public(package) macro fun min_nav_mark_drift_epsilon(): u64 { 1_000_000 } public(package) macro fun max_nav_mark_drift_epsilon(): u64 { 100_000_000 } diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index 59a137cdf..aac538b50 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -23,7 +23,8 @@ public struct ValuationConfig has store { /// itself may shift at most `epsilon` relative. Near expiry the expected /// move is small, so the allowed forward drift tightens automatically. /// Within tolerance, no contract's fair value can have drifted by more - /// than about `0.4 * epsilon` of its full payout (~0.8% at the default) — + /// than about `0.8 * epsilon` of its full payout (~1.5% at the default; + /// measured worst case over shape-preserving moves, `0.77-0.79 * epsilon`) — /// for the moves these checks see; a wing reshape at a fixed variance floor /// passes unexamined (`pricing::assert_mark_drift_within`, known blind spot). nav_mark_drift_epsilon: u64, diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 71494f2b2..e2ad1257b 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -131,7 +131,8 @@ public(package) fun sqrt_min_total_variance(pricer: &Pricer): u64 { /// (`epsilon * anchor_sqrt_min_total_variance`), and that expected-move level /// itself may shift at most `epsilon` relative. For oracle moves these checks /// see, any single order's price drift since the mark is bounded to roughly -/// `0.4 * epsilon` of its full payout. Known blind spot: both checks key on the +/// `0.8 * epsilon` of its full payout (measured worst case over +/// shape-preserving moves: `0.77-0.79 * epsilon`). Known blind spot: both checks key on the /// surface's variance FLOOR, so a wing reshape that leaves the floor and the /// forward unchanged passes unexamined — an open pre-deploy item on whether a /// strike-range leg is needed. `|ΔF|/F` stands in for `|Δln F|` (they agree to From 83b4cc73a97653c7fefe74e241d2ab745f0193a3 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 11:00:04 -0400 Subject: [PATCH 06/23] =?UTF-8?q?predict:=20third=20drift-guard=20leg=20?= =?UTF-8?q?=E2=80=94=20surface=20probes=20close=20the=20wing-reshape=20hol?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured verdict: SVI param-trading on the a + b*sigma*sqrt(1-rho^2) level set (the observed BS refit regime-jump shape) repriced ATM strikes 84-89% of face with both scalar legs passing on zero measured move, at any epsilon; the real-data admitted-error tail was this hole firing. The mark now stores sqrt total variance sampled at seven fixed log-moneyness probes ({-4,-2,-1,0,1,2,4} x anchor sqrt-min-variance); the flush re-samples the live surface at the stored positions and rejects the mark when any probe moves more than epsilon relative. Probe evaluation never aborts (clamped wing term), so refresh and the mandatory flush stay live. Residual: between-probe reshapes and deep wings (prices pinned near 0/1), documented on VarianceProbes. Co-Authored-By: Claude Fable 5 --- .../sources/config/valuation_config.move | 22 ++-- packages/predict/sources/pricing/pricing.move | 105 +++++++++++++++--- packages/predict/sources/valuation_mark.move | 6 +- 3 files changed, 109 insertions(+), 24 deletions(-) diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index aac538b50..0cf33b1e6 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -17,16 +17,18 @@ public struct ValuationConfig has store { /// How far the oracle may move before a stored mark is rejected, in /// FLOAT_SCALING (0.02 = 2%). Feeds keep moving after a market's refresh, /// so the flush re-reads them and rejects the mark when contract prices - /// could have moved materially since it was computed. Two checks, one knob: - /// the forward may move at most `epsilon` of one standard deviation of the - /// price move still expected before expiry, and that expected-move level - /// itself may shift at most `epsilon` relative. Near expiry the expected - /// move is small, so the allowed forward drift tightens automatically. - /// Within tolerance, no contract's fair value can have drifted by more - /// than about `0.8 * epsilon` of its full payout (~1.5% at the default; - /// measured worst case over shape-preserving moves, `0.77-0.79 * epsilon`) — - /// for the moves these checks see; a wing reshape at a fixed variance floor - /// passes unexamined (`pricing::assert_mark_drift_within`, known blind spot). + /// could have moved materially since it was computed. Three checks, one + /// knob: the forward may move at most `epsilon` of one standard deviation + /// of the price move still expected before expiry, that expected-move level + /// itself may shift at most `epsilon` relative, and the surface's shape — + /// sampled at seven stored probe strikes — may shift at most `epsilon` + /// relative per probe. Near expiry the expected move is small, so the + /// allowed forward drift tightens automatically. Within tolerance, no + /// contract's fair value at the probes can have drifted by more than about + /// `0.8 * epsilon` of its full payout (~1.5% at the default; measured + /// shape-preserving worst case `0.77-0.79 * epsilon`); reshapes confined + /// strictly between probes are the accepted sampling residual + /// (`pricing::VarianceProbes`). nav_mark_drift_epsilon: u64, } diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index e2ad1257b..af83297e8 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -11,7 +11,7 @@ module deepbook_predict::pricing; use deepbook_predict::{constants, pricing_config::PricingConfig}; -use fixed_math::{i64, math}; +use fixed_math::{i64::{Self, I64}, math}; use propbook::{ block_scholes_forward_feed::BlockScholesForwardFeed, block_scholes_spot_feed::BlockScholesSpotFeed, @@ -29,6 +29,26 @@ public struct Pricer has copy, drop { svi: SVIParams, } +/// Surface-shape anchors for the drift guard's third leg: sqrt of SVI total +/// variance sampled at fixed log-moneyness probes around the anchor forward. +/// Stored in a market's valuation mark at refresh; the flush re-samples the live +/// surface at the same positions and rejects the mark when any probe moved more +/// than epsilon relative. This closes the hole the two scalar legs cannot see — +/// a wing reshape at fixed variance floor and fixed forward (measured repricing +/// ATM strikes 84-89% of face with both scalar legs passing). Residual accepted: +/// probes sample the curve, so a reshape confined strictly between probe +/// positions passes; deep wings beyond the outermost probes self-limit because +/// prices there are pinned near 0 or 1. +public struct VarianceProbes has copy, drop, store { + /// Probe positions as log-moneyness relative to the anchor forward, at + /// {-4, -2, -1, 0, +1, +2, +4} times the anchor's sqrt-min-variance + /// (0 covers the floor region; ±1/±2 the measured worst-damage band; + /// ±4 the inner wings). + log_moneyness: vector, + /// sqrt of SVI total variance at each probe, parallel to `log_moneyness`. + sqrt_total_variance: vector, +} + /// Per-flush cache of `up_price` results keyed by finite boundary tick, ascending. /// /// The NAV linear walk (`strike_payout_tree::walk_linear`) fills it once per node @@ -63,6 +83,7 @@ const EBlockScholesPriceUnavailable: u64 = 14; const EBlockScholesSVIUnavailable: u64 = 15; const EMarkForwardDrifted: u64 = 16; const EMarkVarianceDrifted: u64 = 17; +const EMarkProbeVarianceDrifted: u64 = 18; /// Predict's private pricing envelope for raw propbook BS inputs. These are not /// oracle-source validity rules; they only bound the forward/basis and SVI inputs @@ -122,27 +143,55 @@ public(package) fun sqrt_min_total_variance(pricer: &Pricer): u64 { math::sqrt(min_total_var, math::float_scaling!()) } +/// Sample this pricer's surface at the drift-guard probe grid (see +/// `VarianceProbes`). Computed at refresh and stored in the valuation mark. +public(package) fun variance_probes(pricer: &Pricer): VarianceProbes { + let scale = sqrt_min_total_variance(pricer); + let multipliers = vector[ + i64::from_parts(4, true), + i64::from_parts(2, true), + i64::from_parts(1, true), + i64::from_u64(0), + i64::from_u64(1), + i64::from_u64(2), + i64::from_u64(4), + ]; + let mut log_moneyness = vector[]; + let mut sqrt_total_variance = vector[]; + multipliers.do!(|multiplier| { + let k = i64::from_parts(multiplier.magnitude() * scale, multiplier.is_negative()); + log_moneyness.push_back(k); + sqrt_total_variance.push_back( + math::sqrt(svi_total_variance_at(&pricer.svi, &k), math::float_scaling!()), + ); + }); + VarianceProbes { log_moneyness, sqrt_total_variance } +} + /// Abort unless the oracle has stayed close enough to where it was when a stored /// valuation mark was computed — "close enough" meaning contract prices cannot /// have moved materially in between. /// -/// Two checks, one `epsilon` knob: the forward may move at most `epsilon` of one +/// Three checks, one `epsilon` knob. Forward: may move at most `epsilon` of one /// standard deviation of the price move still expected before expiry -/// (`epsilon * anchor_sqrt_min_total_variance`), and that expected-move level -/// itself may shift at most `epsilon` relative. For oracle moves these checks -/// see, any single order's price drift since the mark is bounded to roughly -/// `0.8 * epsilon` of its full payout (measured worst case over -/// shape-preserving moves: `0.77-0.79 * epsilon`). Known blind spot: both checks key on the -/// surface's variance FLOOR, so a wing reshape that leaves the floor and the -/// forward unchanged passes unexamined — an open pre-deploy item on whether a -/// strike-range leg is needed. `|ΔF|/F` stands in for `|Δln F|` (they agree to -/// second order at these move sizes), and a degenerate zero anchor variance -/// rejects every forward move — the mark simply requires a re-refresh -/// (fail-closed). +/// (`epsilon * anchor_sqrt_min_total_variance`). Variance floor: that +/// expected-move level itself may shift at most `epsilon` relative. Surface +/// shape: the live surface is re-sampled at the anchor's stored probe positions +/// and every probe's sqrt-variance must be within `epsilon` relative of its +/// anchor — without this leg, a wing reshape at fixed floor and fixed forward +/// passed unexamined while repricing ATM strikes by most of their face. For +/// moves within all three, a single order's price drift since the mark is +/// bounded to roughly `0.8 * epsilon` of its full payout at the probes +/// (measured shape-preserving worst case `0.77-0.79 * epsilon`), with +/// between-probe reshapes as the sampling residual. `|ΔF|/F` stands in for +/// `|Δln F|` (they agree to second order at these move sizes), and a degenerate +/// zero anchor value rejects any move at that anchor — the mark simply requires +/// a re-refresh (fail-closed). public(package) fun assert_mark_drift_within( pricer: &Pricer, anchor_forward: u64, anchor_sqrt_min_total_variance: u64, + anchor_probes: &VarianceProbes, epsilon: u64, ) { let forward_move = math::mul_div_down( @@ -160,6 +209,20 @@ public(package) fun assert_mark_drift_within( <= math::mul(epsilon, anchor_sqrt_min_total_variance), EMarkVarianceDrifted, ); + let probe_count = anchor_probes.log_moneyness.length(); + let mut i = 0; + while (i < probe_count) { + let anchor_sqrt = anchor_probes.sqrt_total_variance[i]; + let live_sqrt = math::sqrt( + svi_total_variance_at(&pricer.svi, &anchor_probes.log_moneyness[i]), + math::float_scaling!(), + ); + assert!( + live_sqrt.diff(anchor_sqrt) <= math::mul(epsilon, anchor_sqrt), + EMarkProbeVarianceDrifted, + ); + i = i + 1; + }; } // === Public-Package Functions === @@ -478,3 +541,19 @@ fun compute_nd2(svi_params: &SVIParams, forward: u64, strike: u64): u64 { if (adjusted.magnitude() > math::float_scaling!()) return math::float_scaling!(); adjusted.magnitude() } + +/// SVI total variance at log-moneyness `k`: +/// `w(k) = a + b * (rho * (k - m) + sqrt((k - m)^2 + sigma^2))`. +/// +/// Probe evaluator for the drift guard; unlike `compute_nd2` it must never abort +/// (it runs inside refresh and the mandatory flush), so the analytically +/// non-negative wing term is clamped against |rho| = 1 rounding dust instead of +/// asserted. +fun svi_total_variance_at(svi: &SVIParams, k: &I64): u64 { + let k_minus_m = k.sub(&svi.m()); + let sqrt_input = k_minus_m.square_scaled() + math::mul(svi.sigma(), svi.sigma()); + let sq = math::sqrt(sqrt_input, math::float_scaling!()); + let inner = svi.rho().mul_scaled(&k_minus_m).add(&i64::from_u64(sq)); + if (inner.is_negative()) return svi.a(); + svi.a() + math::mul(svi.b(), inner.magnitude()) +} diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index 42630ffae..6d12f0c2e 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -13,7 +13,7 @@ /// the market's cash — `expiry_market` composes those. module deepbook_predict::valuation_mark; -use deepbook_predict::{pricing::Pricer, valuation_config::ValuationConfig}; +use deepbook_predict::{pricing::{Pricer, VarianceProbes}, valuation_config::ValuationConfig}; use sui::clock::Clock; const EValuationMarkStale: u64 = 0; @@ -30,6 +30,8 @@ public struct ValuationMark has copy, drop, store { forward: u64, /// sqrt of the SVI minimum total variance at refresh (drift-guard tolerance scale). sqrt_min_total_variance: u64, + /// Surface-shape samples at fixed log-moneyness probes (drift-guard third leg). + probes: VarianceProbes, } // === Public-Package Functions === @@ -49,6 +51,7 @@ public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): Valuati computed_at_ms: clock.timestamp_ms(), forward: pricer.forward(), sqrt_min_total_variance: pricer.sqrt_min_total_variance(), + probes: pricer.variance_probes(), } } @@ -68,6 +71,7 @@ public(package) fun assert_flushable( pricer.assert_mark_drift_within( mark.forward, mark.sqrt_min_total_variance, + &mark.probes, valuation_config.nav_mark_drift_epsilon(), ); } From c97b96264adbf4f7f960fb98fe98967027490115 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 11:44:08 -0400 Subject: [PATCH 07/23] =?UTF-8?q?predict:=20API=20review=20cleanups=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20liquidate=20returns,=20split=20flush?= =?UTF-8?q?=20reads,=20delta-accumulator=20mark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - liquidate/liquidate_order return nothing (nothing consumed the count/bool on-chain; OrderLiquidated events carry the data), collapsing LiquidationSweep to a plain removed-live-value u64. - FlushAtoms replaced by two single-purpose reads: ungated live free_cash and gated flushable_marked_liability — each function returns the value it is named for. - ValuationMark keeps the walked liability immutable and accumulates trade write-through as added/removed deltas, netted once (saturating) at read: order-independent, no destructive per-op clamp, and the walk output stays auditable against a fresh walk for the invariant tests. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/expiry_market.move | 80 ++++++++----------- packages/predict/sources/plp/plp.move | 10 ++- .../strike_exposure/strike_exposure.move | 33 ++------ packages/predict/sources/valuation_mark.move | 45 +++++++---- 4 files changed, 78 insertions(+), 90 deletions(-) diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index d09427f1d..959ab1b8d 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -79,16 +79,6 @@ public struct ExpiryMarket has key { valuation_mark: Option, } -/// One market's raw flush inputs: live free cash and the stored marked -/// liability. Deliberately unclamped — a market can legitimately owe more than -/// it holds (a backing lambda below 1 admits transient shortfalls backstopped by -/// pool rebalancing), so netting across markets and the single zero floor happen -/// at the pool level in `plp`, the policy owner. -public struct FlushAtoms has copy, drop { - free_cash: u64, - marked_liability: u64, -} - /// Read-only all-in cost quote for a prospective live mint, in DUSDC base units. /// `trading_fee` is the post-stake-discount fee before the sponsor subsidy, and /// `all_in_cost` is the exact account withdrawal the same-state mint would make: @@ -419,13 +409,13 @@ public fun mint_exact_quantity( wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let sweep = market + let removed_live_value = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_liability_removed(sweep.removed_live_value()); + market.mark_liability_removed(removed_live_value); market.mint_prepared_exact_quantity( account, @@ -471,13 +461,13 @@ public fun mint_exact_amount( let amount = amount.min(wrapper.load_account().balance(root, clock)); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let sweep = market + let removed_live_value = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_liability_removed(sweep.removed_live_value()); + market.mark_liability_removed(removed_live_value); let quantity = market.max_mint_quantity_for_amount( pricer, @@ -539,13 +529,11 @@ public fun redeem_live( let account = wrapper.load_account_mut(auth); let redeemed_order = order::from_order_id(order_id); - let sweep = market + let swept_live_value = market .strike_exposure .liquidate_live_orders(pricer, config.trade_liquidation_budget()); let knocked_out = market.strike_exposure.liquidate_live_order(pricer, &redeemed_order); - market.mark_liability_removed( - sweep.removed_live_value() + knocked_out.get_with_default(0), - ); + market.mark_liability_removed(swept_live_value + knocked_out.get_with_default(0)); if (market.strike_exposure.is_liquidated_order(&redeemed_order)) { market.redeem_liquidated_order(account, &redeemed_order, close_quantity, ctx); return (redeemed_order.id(), option::none()) @@ -632,35 +620,34 @@ public fun redeem_settled_permissionless( /// Run one bounded liquidation pass over active leveraged orders. /// -/// The liquidation book selects up to `budget` candidates and returns the -/// number of orders liquidated. It does not touch accounts; users clear -/// their liquidated position later through `redeem_live` or `redeem_settled`, +/// The liquidation book selects up to `budget` candidates; each knock-out emits +/// an `OrderLiquidated` event. It does not touch accounts; users clear their +/// liquidated position later through `redeem_live` or `redeem_settled`, /// receiving no payout. public fun liquidate( market: &mut ExpiryMarket, config: &ProtocolConfig, pricer: &Pricer, budget: u64, -): u64 { +) { market.assert_live_flow_allowed(config, pricer); - let sweep = market.strike_exposure.liquidate_live_orders(pricer, budget); - market.mark_liability_removed(sweep.removed_live_value()); - sweep.liquidated_count() + let removed_live_value = market.strike_exposure.liquidate_live_orders(pricer, budget); + market.mark_liability_removed(removed_live_value); } -/// Try to liquidate one active leveraged order by ID. +/// Try to liquidate one active leveraged order by ID; a knock-out emits an +/// `OrderLiquidated` event, and an ineligible order is a no-op. public fun liquidate_order( market: &mut ExpiryMarket, config: &ProtocolConfig, pricer: &Pricer, order_id: u256, -): bool { +) { market.assert_live_flow_allowed(config, pricer); let order = order::from_order_id(order_id); let removed = market.strike_exposure.liquidate_live_order(pricer, &order); market.mark_liability_removed(removed.get_with_default(0)); - removed.is_some() } /// Set this expiry's reference fine-grid tick from the exact previous-window @@ -744,34 +731,31 @@ public(package) fun ensure_settled( true } -/// Read one market's flush inputs off the stored valuation mark: assert the mark -/// exists, delegate the freshness/drift acceptance to the mark, and return live -/// free cash plus the marked liability as raw atoms. The payout tree is never -/// walked here — that is the point: the walk ran in the refresh that stored the -/// mark, and this read loads no per-order objects. No clamp either — see -/// `FlushAtoms` for why the floor lives at the pool level. -public(package) fun flushable_atoms( +/// Return this market's live free cash (DUSDC custody net of the rebate +/// reserve) — always current, no gate needed; cash moves never stale it. +public(package) fun free_cash(market: &ExpiryMarket): u64 { + market.cash.free_cash() +} + +/// Read the stored marked liability for the pool flush: assert the mark exists, +/// then delegate the freshness/drift acceptance to the mark. The payout tree is +/// never walked here — that is the point: the walk ran in the refresh that +/// stored the mark, and this read loads no per-order objects. Deliberately +/// unclamped against cash — a market can legitimately owe more than it holds (a +/// backing lambda below 1 admits transient shortfalls backstopped by pool +/// rebalancing), so netting across markets and the single zero floor happen at +/// the pool level in `plp`, the policy owner. +public(package) fun flushable_marked_liability( market: &ExpiryMarket, valuation_config: &ValuationConfig, pricer: &Pricer, clock: &Clock, -): FlushAtoms { +): u64 { market.assert_pricer_bound(pricer); assert!(market.valuation_mark.is_some(), EValuationMarkMissing); let mark = market.valuation_mark.borrow(); mark.assert_flushable(valuation_config, pricer, clock); - FlushAtoms { - free_cash: market.cash.free_cash(), - marked_liability: mark.liability(), - } -} - -public(package) fun free_cash(atoms: &FlushAtoms): u64 { - atoms.free_cash -} - -public(package) fun marked_liability(atoms: &FlushAtoms): u64 { - atoms.marked_liability + mark.liability() } /// Recompute this market's exact per-order live liability and store it as the diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index abd7935f1..2b8cd3f16 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -301,10 +301,14 @@ public fun collect_expiry_nav( config.assert_version(); let expiry_market_id = market.id(); valuation.assert_expiry_ready_to_value(expiry_market_id); - let atoms = market.flushable_atoms(config.valuation_config(), pricer, clock); + let marked_liability = market.flushable_marked_liability( + config.valuation_config(), + pricer, + clock, + ); valuation.valued_expiry_markets.push_back(expiry_market_id); - valuation.total_free_cash = valuation.total_free_cash + atoms.free_cash(); - valuation.total_liability = valuation.total_liability + atoms.marked_liability(); + valuation.total_free_cash = valuation.total_free_cash + market.free_cash(); + valuation.total_liability = valuation.total_liability + marked_liability; } /// Finish a full-pool valuation and run the LP flush: prove every snapshotted market diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index a9c6c67eb..3ffa62a88 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -112,23 +112,6 @@ public(package) fun range_probability(quote: &CloseQuote): u64 { quote.range_probability } -/// Result of one bounded liquidation pass: how many orders were knocked out and -/// the total live value they carried at the moment of removal (each order's -/// floor-capped `gross - floor`). The value total exists so callers can write the -/// liability removal through to the market's stored valuation mark. -public struct LiquidationSweep has copy, drop { - liquidated_count: u64, - removed_live_value: u64, -} - -public(package) fun liquidated_count(sweep: &LiquidationSweep): u64 { - sweep.liquidated_count -} - -public(package) fun removed_live_value(sweep: &LiquidationSweep): u64 { - sweep.removed_live_value -} - /// Return the buffered live reserve, or exact remaining settled payout liability once materialized. /// /// Live reserve is the settlement floor (max single-point net payout) plus a @@ -459,29 +442,29 @@ public(package) fun liquidate_live_order( exposure.liquidate_order_if_under_floor(pricer, order, liquidation_ltv) } -/// Run one bounded liquidation pass using exact per-candidate pricing. +/// Run one bounded liquidation pass using exact per-candidate pricing. Returns +/// the total live value the knocked-out orders carried at removal (each order's +/// floor-capped `gross - floor`), so the caller can write the liability removal +/// through to the market's stored valuation mark. Per-order details are on the +/// `OrderLiquidated` events. public(package) fun liquidate_live_orders( exposure: &mut StrikeExposure, pricer: &Pricer, budget: u64, -): LiquidationSweep { +): u64 { let candidates = exposure.liquidation.select_liquidation_candidates(budget); - if (candidates.is_empty()) { - return LiquidationSweep { liquidated_count: 0, removed_live_value: 0 } - }; + if (candidates.is_empty()) return 0; let liquidation_ltv = exposure.config.liquidation_ltv(); - let mut liquidated_count = 0; let mut removed_live_value = 0; candidates.do!(|candidate| { let order = order::from_order_id(candidate); let removed = exposure.liquidate_order_if_under_floor(pricer, &order, liquidation_ltv); if (removed.is_some()) { - liquidated_count = liquidated_count + 1; removed_live_value = removed_live_value + removed.destroy_some(); }; }); - LiquidationSweep { liquidated_count, removed_live_value } + removed_live_value } /// Cache terminal settled payout liability. diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index 6d12f0c2e..a068b9379 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -19,11 +19,22 @@ use sui::clock::Clock; const EValuationMarkStale: u64 = 0; /// One market's stored valuation mark. Free cash is never stored — the flush -/// reads it live, so cash moves need no mark maintenance. +/// reads it live, so cash moves need no mark maintenance. Trade write-through +/// accumulates in the two delta fields rather than mutating the walked number: +/// the walk's output stays auditable against a fresh walk at the same anchors, +/// and the mixed-anchor netting is applied once at read time, order-independent, +/// instead of destructively clamping op by op. public struct ValuationMark has copy, drop, store { - /// Exact oracle-priced per-order liability as of `computed_at_ms`, kept - /// current by trade write-through between refreshes. - liability: u64, + /// Exact oracle-priced per-order liability the refresh walk computed at + /// `computed_at_ms`. Never mutated between refreshes. + computed_liability: u64, + /// Σ liability added by trades since the walk (mint `net_premium`s), each + /// priced at its own op's oracle. + added_since_compute: u64, + /// Σ liability removed by trades since the walk (live-redeem + /// `redeem_amount`s and liquidated orders' live values), each priced at its + /// own op's oracle. + removed_since_compute: u64, /// On-chain landing time of the refresh that computed this mark. computed_at_ms: u64, /// Forward the mark was priced at (drift-guard anchor). @@ -36,18 +47,26 @@ public struct ValuationMark has copy, drop, store { // === Public-Package Functions === +/// The mark's current liability: the walked number plus all trade deltas since, +/// netted once here. The deltas are priced at their ops' oracles while the +/// walked sum is anchored at the refresh oracle (drift-bounded), so the netting +/// can exceed the sum by that bounded residual — clamp it rather than abort a +/// read in the mandatory flush path. public(package) fun liability(mark: &ValuationMark): u64 { - mark.liability + (mark.computed_liability + mark.added_since_compute).saturating_sub(mark.removed_since_compute) } public(package) fun computed_at_ms(mark: &ValuationMark): u64 { mark.computed_at_ms } -/// Snapshot a fresh mark: the just-walked liability plus the pricer's anchors. +/// Snapshot a fresh mark: the just-walked liability plus the pricer's anchors, +/// with zeroed trade deltas. public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): ValuationMark { ValuationMark { - liability, + computed_liability: liability, + added_since_compute: 0, + removed_since_compute: 0, computed_at_ms: clock.timestamp_ms(), forward: pricer.forward(), sqrt_min_total_variance: pricer.sqrt_min_total_variance(), @@ -76,15 +95,13 @@ public(package) fun assert_flushable( ); } -/// Write a trade's exact liability increase through to the mark. +/// Write a trade's liability increase through to the mark's delta accumulator. public(package) fun add_liability(mark: &mut ValuationMark, amount: u64) { - mark.liability = mark.liability + amount; + mark.added_since_compute = mark.added_since_compute + amount; } -/// Write a trade's liability decrease through to the mark. The delta is priced -/// at the op's oracle while the mark's sum is anchored at its refresh oracle -/// (drift-bounded), so clamp the mixed-anchor residual rather than abort a user -/// exit or liquidation pass. +/// Write a trade's liability decrease through to the mark's delta accumulator. +/// Never clamps here — the mixed-anchor netting happens once, in `liability`. public(package) fun remove_liability(mark: &mut ValuationMark, amount: u64) { - mark.liability = mark.liability.saturating_sub(amount); + mark.removed_since_compute = mark.removed_since_compute + amount; } From dcf6da01c4007f8ac6870651bee3b28209773ce8 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 12:00:52 -0400 Subject: [PATCH 08/23] =?UTF-8?q?predict:=20drift=20guard=20probes=20price?= =?UTF-8?q?s,=20not=20variances=20=E2=80=94=20one=20leg,=20face-unit=20tol?= =?UTF-8?q?erance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark now stores seven (strike, fair price) probe pairs fanned around the refresh forward at {-4..+4} expected-moves; the flush reprices the same strikes on the live surface and rejects the mark when any probe price moved more than epsilon of full payout. One check subsumes the forward, variance-floor, and shape legs: any oracle change that matters to contract prices moves a probe, and the knob becomes the literal face-error bound at the probes (no 0.8x conversion-factor analysis). Deletes VarianceProbes, the scalar legs, and their error codes; caps the strike exponent at e^2 so envelope-extreme surfaces cannot abort refresh. Co-Authored-By: Claude Fable 5 --- .../sources/config/config_constants.move | 4 +- .../sources/config/valuation_config.move | 25 +-- packages/predict/sources/pricing/pricing.move | 178 ++++++------------ packages/predict/sources/valuation_mark.move | 34 ++-- 4 files changed, 82 insertions(+), 159 deletions(-) diff --git a/packages/predict/sources/config/config_constants.move b/packages/predict/sources/config/config_constants.move index 822ee347f..1a02a61e0 100644 --- a/packages/predict/sources/config/config_constants.move +++ b/packages/predict/sources/config/config_constants.move @@ -376,8 +376,8 @@ public(package) fun assert_nav_mark_freshness_ms(value: u64) { public(package) macro fun default_nav_mark_drift_epsilon(): u64 { 20_000_000 } // Floor: epsilon = 0 would reject every stored mark and brick the flush (the // floor setting is de-facto "always re-refresh" but stays fail-closed); ceiling: -// 0.1 keeps the tolerated per-order price drift within ~8% of face (measured -// worst case ~0.8 * epsilon over shape-preserving moves). +// 0.1 lets probe contract prices drift up to 10% of full payout before the +// mark is rejected. public(package) macro fun min_nav_mark_drift_epsilon(): u64 { 1_000_000 } public(package) macro fun max_nav_mark_drift_epsilon(): u64 { 100_000_000 } diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index 0cf33b1e6..b8d6e98fc 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -14,21 +14,16 @@ use deepbook_predict::config_constants; public struct ValuationConfig has store { /// Hard ceiling on stored-mark age in milliseconds, regardless of oracle drift. nav_mark_freshness_ms: u64, - /// How far the oracle may move before a stored mark is rejected, in - /// FLOAT_SCALING (0.02 = 2%). Feeds keep moving after a market's refresh, - /// so the flush re-reads them and rejects the mark when contract prices - /// could have moved materially since it was computed. Three checks, one - /// knob: the forward may move at most `epsilon` of one standard deviation - /// of the price move still expected before expiry, that expected-move level - /// itself may shift at most `epsilon` relative, and the surface's shape — - /// sampled at seven stored probe strikes — may shift at most `epsilon` - /// relative per probe. Near expiry the expected move is small, so the - /// allowed forward drift tightens automatically. Within tolerance, no - /// contract's fair value at the probes can have drifted by more than about - /// `0.8 * epsilon` of its full payout (~1.5% at the default; measured - /// shape-preserving worst case `0.77-0.79 * epsilon`); reshapes confined - /// strictly between probes are the accepted sampling residual - /// (`pricing::VarianceProbes`). + /// How far any probe contract's fair price may move before a stored mark + /// is rejected, as a fraction of full payout in FLOAT_SCALING (0.02 = 2% + /// of face). Feeds keep moving after a market's refresh, so the flush + /// reprices the mark's seven stored probe contracts on the live surface + /// and rejects the mark when any moved more than this — one check that + /// catches forward moves, expiry decay, and surface reshaping alike. The + /// bound is literal: a mark the flush accepts cannot have drifted more + /// than `epsilon` of face at the probes; reshapes confined strictly + /// between probes are the accepted sampling residual + /// (`pricing::price_probes`). nav_mark_drift_epsilon: u64, } diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index af83297e8..ac1a2b4da 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -11,7 +11,7 @@ module deepbook_predict::pricing; use deepbook_predict::{constants, pricing_config::PricingConfig}; -use fixed_math::{i64::{Self, I64}, math}; +use fixed_math::{i64, math}; use propbook::{ block_scholes_forward_feed::BlockScholesForwardFeed, block_scholes_spot_feed::BlockScholesSpotFeed, @@ -29,26 +29,6 @@ public struct Pricer has copy, drop { svi: SVIParams, } -/// Surface-shape anchors for the drift guard's third leg: sqrt of SVI total -/// variance sampled at fixed log-moneyness probes around the anchor forward. -/// Stored in a market's valuation mark at refresh; the flush re-samples the live -/// surface at the same positions and rejects the mark when any probe moved more -/// than epsilon relative. This closes the hole the two scalar legs cannot see — -/// a wing reshape at fixed variance floor and fixed forward (measured repricing -/// ATM strikes 84-89% of face with both scalar legs passing). Residual accepted: -/// probes sample the curve, so a reshape confined strictly between probe -/// positions passes; deep wings beyond the outermost probes self-limit because -/// prices there are pinned near 0 or 1. -public struct VarianceProbes has copy, drop, store { - /// Probe positions as log-moneyness relative to the anchor forward, at - /// {-4, -2, -1, 0, +1, +2, +4} times the anchor's sqrt-min-variance - /// (0 covers the floor region; ±1/±2 the measured worst-damage band; - /// ±4 the inner wings). - log_moneyness: vector, - /// sqrt of SVI total variance at each probe, parallel to `log_moneyness`. - sqrt_total_variance: vector, -} - /// Per-flush cache of `up_price` results keyed by finite boundary tick, ascending. /// /// The NAV linear walk (`strike_payout_tree::walk_linear`) fills it once per node @@ -81,9 +61,7 @@ const EWrongBlockScholesSVIFeed: u64 = 12; const ETickNotInPriceMemo: u64 = 13; const EBlockScholesPriceUnavailable: u64 = 14; const EBlockScholesSVIUnavailable: u64 = 15; -const EMarkForwardDrifted: u64 = 16; -const EMarkVarianceDrifted: u64 = 17; -const EMarkProbeVarianceDrifted: u64 = 18; +const EMarkPriceDrifted: u64 = 16; /// Predict's private pricing envelope for raw propbook BS inputs. These are not /// oracle-source validity rules; they only bound the forward/basis and SVI inputs @@ -115,37 +93,17 @@ public(package) fun expiry_market_id(pricer: &Pricer): ID { pricer.expiry_market_id } -/// Return the forward this pricer snapshotted (drift-guard anchor). -public(package) fun forward(pricer: &Pricer): u64 { - pricer.forward -} - -/// Return the size of one standard deviation of the price move this surface -/// still expects before expiry, at the strike where that expectation is -/// smallest — the surface's variance floor. In FLOAT_SCALING; the formula is -/// `sqrt(a + b * sigma * sqrt(1 - rho^2))`, the global minimum of SVI total -/// variance over strikes. -/// -/// The drift guard scales its forward tolerance by this number: contract prices -/// react to forward moves roughly in units of "expected move remaining", so a -/// tolerance expressed in those units bounds every order's price drift the same -/// amount whether expiry is a month or a minute away — near expiry the expected -/// move shrinks and the allowed forward drift tightens with it automatically. -public(package) fun sqrt_min_total_variance(pricer: &Pricer): u64 { - let rho = pricer.svi.rho(); - let rho_squared = rho.mul_scaled(&rho).magnitude(); - // |rho| <= 1 inside the pricing-safe envelope; saturate the complement against - // fixed-point rounding dust at the |rho| = 1 corner. - let one_minus_rho_squared = math::float_scaling!().saturating_sub(rho_squared); - let root = math::sqrt(one_minus_rho_squared, math::float_scaling!()); - let min_total_var = - pricer.svi.a() + math::mul(pricer.svi.b(), math::mul(pricer.svi.sigma(), root)); - math::sqrt(min_total_var, math::float_scaling!()) -} - -/// Sample this pricer's surface at the drift-guard probe grid (see -/// `VarianceProbes`). Computed at refresh and stored in the valuation mark. -public(package) fun variance_probes(pricer: &Pricer): VarianceProbes { +/// Sample seven probe contracts for the drift guard: strikes fanned around the +/// forward at {-4, -2, -1, 0, +1, +2, +4} expected-moves-to-expiry, each with +/// its current fair UP price. Stored in a market's valuation mark at refresh; +/// the flush reprices the same strikes on the live surface and rejects the mark +/// when any probe price moved more than the configured fraction of full payout +/// — one check that catches forward moves, expiry decay, and wing reshapes +/// alike, because any oracle change that matters to contract prices moves a +/// probe. Residual accepted: reshapes confined strictly between probes; deep +/// wings beyond the outermost probes self-limit because prices there sit +/// pinned near 0 or 1. Returns `(strikes, prices)`, parallel. +public(package) fun price_probes(pricer: &Pricer): (vector, vector) { let scale = sqrt_min_total_variance(pricer); let multipliers = vector[ i64::from_parts(4, true), @@ -156,71 +114,42 @@ public(package) fun variance_probes(pricer: &Pricer): VarianceProbes { i64::from_u64(2), i64::from_u64(4), ]; - let mut log_moneyness = vector[]; - let mut sqrt_total_variance = vector[]; + let mut strikes = vector[]; + let mut prices = vector[]; multipliers.do!(|multiplier| { - let k = i64::from_parts(multiplier.magnitude() * scale, multiplier.is_negative()); - log_moneyness.push_back(k); - sqrt_total_variance.push_back( - math::sqrt(svi_total_variance_at(&pricer.svi, &k), math::float_scaling!()), + // Cap the exponent so a max-envelope surface (sqrt variance up to ~10) + // cannot abort the refresh through EExpOverflow: strikes past + // e^±2 x forward are deep wings whose probe prices pin near 0/1 anyway. + let exponent_magnitude = (multiplier.magnitude() * scale).min( + 2 * math::float_scaling!(), ); + let exponent = i64::from_parts(exponent_magnitude, multiplier.is_negative()); + let strike = math::mul(pricer.forward, math::exp(&exponent)); + strikes.push_back(strike); + prices.push_back(up_price(pricer, strike)); }); - VarianceProbes { log_moneyness, sqrt_total_variance } + (strikes, prices) } -/// Abort unless the oracle has stayed close enough to where it was when a stored -/// valuation mark was computed — "close enough" meaning contract prices cannot -/// have moved materially in between. -/// -/// Three checks, one `epsilon` knob. Forward: may move at most `epsilon` of one -/// standard deviation of the price move still expected before expiry -/// (`epsilon * anchor_sqrt_min_total_variance`). Variance floor: that -/// expected-move level itself may shift at most `epsilon` relative. Surface -/// shape: the live surface is re-sampled at the anchor's stored probe positions -/// and every probe's sqrt-variance must be within `epsilon` relative of its -/// anchor — without this leg, a wing reshape at fixed floor and fixed forward -/// passed unexamined while repricing ATM strikes by most of their face. For -/// moves within all three, a single order's price drift since the mark is -/// bounded to roughly `0.8 * epsilon` of its full payout at the probes -/// (measured shape-preserving worst case `0.77-0.79 * epsilon`), with -/// between-probe reshapes as the sampling residual. `|ΔF|/F` stands in for -/// `|Δln F|` (they agree to second order at these move sizes), and a degenerate -/// zero anchor value rejects any move at that anchor — the mark simply requires -/// a re-refresh (fail-closed). -public(package) fun assert_mark_drift_within( +/// Abort unless every stored probe contract still prices within `epsilon` of +/// its anchored fair price on the live surface. `epsilon` is a fraction of +/// full payout in FLOAT_SCALING, so this check IS the guard's face-error bound +/// at the probes: a mark the flush accepts cannot have drifted by more than +/// `epsilon` of face there, whatever mix of forward move, variance decay, or +/// surface reshape produced the drift. Near expiry a given oracle move +/// produces larger price moves, so the guard tightens automatically in the +/// only units that matter. +public(package) fun assert_probe_prices_within( pricer: &Pricer, - anchor_forward: u64, - anchor_sqrt_min_total_variance: u64, - anchor_probes: &VarianceProbes, + probe_strikes: &vector, + probe_prices: &vector, epsilon: u64, ) { - let forward_move = math::mul_div_down( - pricer.forward.diff(anchor_forward), - math::float_scaling!(), - anchor_forward, - ); - assert!( - forward_move <= math::mul(epsilon, anchor_sqrt_min_total_variance), - EMarkForwardDrifted, - ); - let sqrt_min_total_variance = sqrt_min_total_variance(pricer); - assert!( - sqrt_min_total_variance.diff(anchor_sqrt_min_total_variance) - <= math::mul(epsilon, anchor_sqrt_min_total_variance), - EMarkVarianceDrifted, - ); - let probe_count = anchor_probes.log_moneyness.length(); + let probe_count = probe_strikes.length(); let mut i = 0; while (i < probe_count) { - let anchor_sqrt = anchor_probes.sqrt_total_variance[i]; - let live_sqrt = math::sqrt( - svi_total_variance_at(&pricer.svi, &anchor_probes.log_moneyness[i]), - math::float_scaling!(), - ); - assert!( - live_sqrt.diff(anchor_sqrt) <= math::mul(epsilon, anchor_sqrt), - EMarkProbeVarianceDrifted, - ); + let live_price = up_price(pricer, probe_strikes[i]); + assert!(live_price.diff(probe_prices[i]) <= epsilon, EMarkPriceDrifted); i = i + 1; }; } @@ -542,18 +471,19 @@ fun compute_nd2(svi_params: &SVIParams, forward: u64, strike: u64): u64 { adjusted.magnitude() } -/// SVI total variance at log-moneyness `k`: -/// `w(k) = a + b * (rho * (k - m) + sqrt((k - m)^2 + sigma^2))`. -/// -/// Probe evaluator for the drift guard; unlike `compute_nd2` it must never abort -/// (it runs inside refresh and the mandatory flush), so the analytically -/// non-negative wing term is clamped against |rho| = 1 rounding dust instead of -/// asserted. -fun svi_total_variance_at(svi: &SVIParams, k: &I64): u64 { - let k_minus_m = k.sub(&svi.m()); - let sqrt_input = k_minus_m.square_scaled() + math::mul(svi.sigma(), svi.sigma()); - let sq = math::sqrt(sqrt_input, math::float_scaling!()); - let inner = svi.rho().mul_scaled(&k_minus_m).add(&i64::from_u64(sq)); - if (inner.is_negative()) return svi.a(); - svi.a() + math::mul(svi.b(), inner.magnitude()) +/// The size of one standard deviation of the price move this surface still +/// expects before expiry, at the strike where that expectation is smallest — +/// `sqrt(a + b * sigma * sqrt(1 - rho^2))`, the global minimum of SVI total +/// variance over strikes. Sets the probe-grid spacing so the drift guard's +/// probe contracts span the price-relevant strike range at every expiry. +fun sqrt_min_total_variance(pricer: &Pricer): u64 { + let rho = pricer.svi.rho(); + let rho_squared = rho.mul_scaled(&rho).magnitude(); + // |rho| <= 1 inside the pricing-safe envelope; saturate the complement against + // fixed-point rounding dust at the |rho| = 1 corner. + let one_minus_rho_squared = math::float_scaling!().saturating_sub(rho_squared); + let root = math::sqrt(one_minus_rho_squared, math::float_scaling!()); + let min_total_var = + pricer.svi.a() + math::mul(pricer.svi.b(), math::mul(pricer.svi.sigma(), root)); + math::sqrt(min_total_var, math::float_scaling!()) } diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index a068b9379..9d9fe5ee2 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -13,7 +13,7 @@ /// the market's cash — `expiry_market` composes those. module deepbook_predict::valuation_mark; -use deepbook_predict::{pricing::{Pricer, VarianceProbes}, valuation_config::ValuationConfig}; +use deepbook_predict::{pricing::Pricer, valuation_config::ValuationConfig}; use sui::clock::Clock; const EValuationMarkStale: u64 = 0; @@ -37,12 +37,11 @@ public struct ValuationMark has copy, drop, store { removed_since_compute: u64, /// On-chain landing time of the refresh that computed this mark. computed_at_ms: u64, - /// Forward the mark was priced at (drift-guard anchor). - forward: u64, - /// sqrt of the SVI minimum total variance at refresh (drift-guard tolerance scale). - sqrt_min_total_variance: u64, - /// Surface-shape samples at fixed log-moneyness probes (drift-guard third leg). - probes: VarianceProbes, + /// Probe contract strikes fanned around the refresh-time forward + /// (drift-guard anchors; `pricing::price_probes`). + probe_strikes: vector, + /// Fair UP price of each probe at refresh, parallel to `probe_strikes`. + probe_prices: vector, } // === Public-Package Functions === @@ -60,23 +59,23 @@ public(package) fun computed_at_ms(mark: &ValuationMark): u64 { mark.computed_at_ms } -/// Snapshot a fresh mark: the just-walked liability plus the pricer's anchors, -/// with zeroed trade deltas. +/// Snapshot a fresh mark: the just-walked liability plus the pricer's probe +/// anchors, with zeroed trade deltas. public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): ValuationMark { + let (probe_strikes, probe_prices) = pricer.price_probes(); ValuationMark { computed_liability: liability, added_since_compute: 0, removed_since_compute: 0, computed_at_ms: clock.timestamp_ms(), - forward: pricer.forward(), - sqrt_min_total_variance: pricer.sqrt_min_total_variance(), - probes: pricer.variance_probes(), + probe_strikes, + probe_prices, } } /// Abort unless this mark is still usable by the flush: younger than the -/// freshness ceiling, and its oracle anchors within the drift tolerance of the -/// live inputs in `pricer`. +/// freshness ceiling, and every stored probe contract still pricing within the +/// drift tolerance on the live surface in `pricer`. public(package) fun assert_flushable( mark: &ValuationMark, valuation_config: &ValuationConfig, @@ -87,10 +86,9 @@ public(package) fun assert_flushable( clock.timestamp_ms() - mark.computed_at_ms <= valuation_config.nav_mark_freshness_ms(), EValuationMarkStale, ); - pricer.assert_mark_drift_within( - mark.forward, - mark.sqrt_min_total_variance, - &mark.probes, + pricer.assert_probe_prices_within( + &mark.probe_strikes, + &mark.probe_prices, valuation_config.nav_mark_drift_epsilon(), ); } From 231f78168af7a5f44ede5b34aa5c8eb12ffe7892 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 12:32:04 -0400 Subject: [PATCH 09/23] predict: drift becomes a measured fact; acceptance moves to the pool level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure per the PLP-price framing: the guarantee we actually want is 'flush PLP price within a bound of the fresh-walk price', whose only correct denominator is pool NAV — so per-market acceptance is the wrong altitude. - valuation_mark is now a dumb tracker: liability walk + trade deltas + drift(pricer) measuring potential dollar NAV drift since the walk. It owns no acceptance policy and no config. - expiry_market exposes three dumb facts via individual getters: free_cash, marked_liability, mark_drift(pricer), plus mark_computed_at_ms. - plp aggregates all three per market, asserts the freshness ceiling per mark (the one guard a stalled feed cannot fool), and judges drift ONLY in aggregate at finish_flush: total_drift vs pool NAV. - Probe machinery removed from this head; it returns as the internal implementation of drift() when the model lands. INTERIM: drift() returns 0 and the aggregate check is a documented no-op — this head has freshness-only protection. Draft-only state; the drift model and threshold land next. Co-Authored-By: Claude Fable 5 --- .../sources/config/valuation_config.move | 16 ++-- packages/predict/sources/expiry_market.move | 42 +++++----- packages/predict/sources/plp/plp.move | 54 +++++++++---- packages/predict/sources/pricing/pricing.move | 79 ------------------- packages/predict/sources/valuation_mark.move | 67 ++++++---------- 5 files changed, 95 insertions(+), 163 deletions(-) diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index b8d6e98fc..e5d88eac5 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -14,16 +14,12 @@ use deepbook_predict::config_constants; public struct ValuationConfig has store { /// Hard ceiling on stored-mark age in milliseconds, regardless of oracle drift. nav_mark_freshness_ms: u64, - /// How far any probe contract's fair price may move before a stored mark - /// is rejected, as a fraction of full payout in FLOAT_SCALING (0.02 = 2% - /// of face). Feeds keep moving after a market's refresh, so the flush - /// reprices the mark's seven stored probe contracts on the live surface - /// and rejects the mark when any moved more than this — one check that - /// catches forward moves, expiry decay, and surface reshaping alike. The - /// bound is literal: a mark the flush accepts cannot have drifted more - /// than `epsilon` of face at the probes; reshapes confined strictly - /// between probes are the accepted sampling residual - /// (`pricing::price_probes`). + /// Acceptance threshold for aggregate mark drift at the flush, in + /// FLOAT_SCALING. Intended interpretation: the counted marks' combined + /// measured dollar drift may not exceed this fraction of pool NAV, putting + /// the bound directly in PLP-price units + /// (`plp::assert_aggregate_drift_acceptable`). PLACEHOLDER semantics while + /// the drift model (`valuation_mark::drift`) is stubbed. nav_mark_drift_epsilon: u64, } diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 959ab1b8d..34d23bc91 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -26,7 +26,6 @@ use deepbook_predict::{ protocol_config::ProtocolConfig, strike_exposure::{Self, MintTerms, StrikeExposure}, strike_exposure_config, - valuation_config::ValuationConfig, valuation_mark::{Self, ValuationMark} }; use dusdc::dusdc::DUSDC; @@ -737,25 +736,32 @@ public(package) fun free_cash(market: &ExpiryMarket): u64 { market.cash.free_cash() } -/// Read the stored marked liability for the pool flush: assert the mark exists, -/// then delegate the freshness/drift acceptance to the mark. The payout tree is -/// never walked here — that is the point: the walk ran in the refresh that -/// stored the mark, and this read loads no per-order objects. Deliberately -/// unclamped against cash — a market can legitimately owe more than it holds (a -/// backing lambda below 1 admits transient shortfalls backstopped by pool -/// rebalancing), so netting across markets and the single zero floor happen at -/// the pool level in `plp`, the policy owner. -public(package) fun flushable_marked_liability( - market: &ExpiryMarket, - valuation_config: &ValuationConfig, - pricer: &Pricer, - clock: &Clock, -): u64 { +/// Return the stored mark's current liability: the refresh walk's number plus +/// trade write-through since. The payout tree is never walked here — that is +/// the point: the walk ran in the refresh that stored the mark, and this read +/// loads no per-order objects. Deliberately unclamped against cash — a market +/// can legitimately owe more than it holds (a backing lambda below 1 admits +/// transient shortfalls backstopped by pool rebalancing) — and deliberately a +/// dumb fact: whether the mark is fresh enough and its drift acceptable is +/// judged by `plp`, which aggregates across markets. +public(package) fun marked_liability(market: &ExpiryMarket): u64 { + assert!(market.valuation_mark.is_some(), EValuationMarkMissing); + market.valuation_mark.borrow().liability() +} + +/// Return the landing time of the refresh that computed the stored mark. +public(package) fun mark_computed_at_ms(market: &ExpiryMarket): u64 { + assert!(market.valuation_mark.is_some(), EValuationMarkMissing); + market.valuation_mark.borrow().computed_at_ms() +} + +/// Measure the stored mark's potential oracle drift against the live inputs in +/// `pricer`, in DUSDC base units (`valuation_mark::drift`). A measurement, not +/// a judgment — `plp` aggregates drift across markets and enforces the bound. +public(package) fun mark_drift(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); assert!(market.valuation_mark.is_some(), EValuationMarkMissing); - let mark = market.valuation_mark.borrow(); - mark.assert_flushable(valuation_config, pricer, clock); - mark.liability() + market.valuation_mark.borrow().drift(pricer) } /// Recompute this market's exact per-order live liability and store it as the diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 2b8cd3f16..d63a86a84 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -53,6 +53,7 @@ const EBelowMinBootstrapLiquidity: u64 = 6; const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; +const EValuationMarkStale: u64 = 10; /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -96,6 +97,11 @@ public struct PoolValuation { total_free_cash: u64, /// Running Σ of each counted market's marked liability. total_liability: u64, + /// Running Σ of each counted market's measured oracle drift, in DUSDC — + /// the dollar amount pool NAV could be off by from marks aging against + /// moving feeds. Judged in aggregate at `finish_flush`, where the PLP-price + /// error it implies has its denominator. + total_drift: u64, } // === Package Initializer === @@ -274,17 +280,19 @@ public fun start_pool_valuation( valued_expiry_markets: vector[], total_free_cash: 0, total_liability: 0, + total_drift: 0, } } -/// Fold one snapshotted market's stored valuation mark into the running pool NAV, -/// using a `Pricer` loaded in this transaction (its live oracle inputs are what -/// the drift guard compares the mark's anchors against). The market must be in -/// the snapshot and not already counted (the exactly-once proof), and is a -/// READ-ONLY input: no cash moves, no settlement, no tree walk — the mark must -/// exist, be within the freshness ceiling, and within the drift tolerance -/// (`expiry_market::read_flushable_nav`), else this aborts and the operator -/// re-refreshes the market and retries the flush. +/// Fold one snapshotted market's flush facts into the running pool aggregates, +/// using a `Pricer` loaded in this transaction (the live oracle inputs its +/// drift is measured against). The market must be in the snapshot and not +/// already counted (the exactly-once proof), and is a READ-ONLY input: no cash +/// moves, no settlement, no tree walk. Three facts are collected — live free +/// cash, the stored marked liability, and the mark's measured dollar drift — +/// and only one gate applies here: the mark must be younger than the freshness +/// ceiling (the sole guard a stalled feed cannot fool). Drift is judged in +/// aggregate at `finish_flush`, not per market. /// /// A settled or past-expiry market cannot produce the pricer this read requires /// (`load_live_pricer` rejects past-expiry): sweep it via `rebalance_expiry_cash` @@ -301,14 +309,15 @@ public fun collect_expiry_nav( config.assert_version(); let expiry_market_id = market.id(); valuation.assert_expiry_ready_to_value(expiry_market_id); - let marked_liability = market.flushable_marked_liability( - config.valuation_config(), - pricer, - clock, + assert!( + clock.timestamp_ms() - market.mark_computed_at_ms() + <= config.valuation_config().nav_mark_freshness_ms(), + EValuationMarkStale, ); valuation.valued_expiry_markets.push_back(expiry_market_id); valuation.total_free_cash = valuation.total_free_cash + market.free_cash(); - valuation.total_liability = valuation.total_liability + marked_liability; + valuation.total_liability = valuation.total_liability + market.marked_liability(); + valuation.total_drift = valuation.total_drift + market.mark_drift(pricer); } /// Finish a full-pool valuation and run the LP flush: prove every snapshotted market @@ -339,7 +348,13 @@ public fun finish_flush( &valuation.expected_expiry_markets, &valuation.valued_expiry_markets, ); - let PoolValuation { total_free_cash, total_liability, valued_expiry_markets, .. } = valuation; + let PoolValuation { + total_free_cash, + total_liability, + total_drift, + valued_expiry_markets, + .., + } = valuation; let idle_balance_before = vault.expiry_accounting.idle_balance(); let pool_nav = lp_pool_value( @@ -348,6 +363,7 @@ public fun finish_flush( total_free_cash, total_liability, ); + assert_aggregate_drift_acceptable(total_drift, pool_nav); let total_supply = vault.lp.total_supply(); let market_count = valued_expiry_markets.length(); @@ -1005,6 +1021,16 @@ fun materialize_expiry_profit( ); } +/// The pool-level drift acceptance: the flush must reject itself when the +/// counted marks' combined measured drift could move the PLP price by more +/// than an acceptable bound — shape: `total_drift <= threshold * pool_nav`, +/// putting the guarantee directly in PLP-price units (the per-market +/// measurements have no denominator to judge against). +/// +/// PLACEHOLDER: accepts everything until the drift model +/// (`valuation_mark::drift`) and the threshold interpretation land. +fun assert_aggregate_drift_acceptable(_total_drift: u64, _pool_nav: u64) {} + /// Abort unless this valuation belongs to `vault`. fun assert_pool_vault(valuation: &PoolValuation, vault: &PoolVault) { assert!(valuation.pool_vault_id == vault.id(), EWrongPoolVault); diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index ac1a2b4da..0e40ffa2b 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -61,7 +61,6 @@ const EWrongBlockScholesSVIFeed: u64 = 12; const ETickNotInPriceMemo: u64 = 13; const EBlockScholesPriceUnavailable: u64 = 14; const EBlockScholesSVIUnavailable: u64 = 15; -const EMarkPriceDrifted: u64 = 16; /// Predict's private pricing envelope for raw propbook BS inputs. These are not /// oracle-source validity rules; they only bound the forward/basis and SVI inputs @@ -93,67 +92,6 @@ public(package) fun expiry_market_id(pricer: &Pricer): ID { pricer.expiry_market_id } -/// Sample seven probe contracts for the drift guard: strikes fanned around the -/// forward at {-4, -2, -1, 0, +1, +2, +4} expected-moves-to-expiry, each with -/// its current fair UP price. Stored in a market's valuation mark at refresh; -/// the flush reprices the same strikes on the live surface and rejects the mark -/// when any probe price moved more than the configured fraction of full payout -/// — one check that catches forward moves, expiry decay, and wing reshapes -/// alike, because any oracle change that matters to contract prices moves a -/// probe. Residual accepted: reshapes confined strictly between probes; deep -/// wings beyond the outermost probes self-limit because prices there sit -/// pinned near 0 or 1. Returns `(strikes, prices)`, parallel. -public(package) fun price_probes(pricer: &Pricer): (vector, vector) { - let scale = sqrt_min_total_variance(pricer); - let multipliers = vector[ - i64::from_parts(4, true), - i64::from_parts(2, true), - i64::from_parts(1, true), - i64::from_u64(0), - i64::from_u64(1), - i64::from_u64(2), - i64::from_u64(4), - ]; - let mut strikes = vector[]; - let mut prices = vector[]; - multipliers.do!(|multiplier| { - // Cap the exponent so a max-envelope surface (sqrt variance up to ~10) - // cannot abort the refresh through EExpOverflow: strikes past - // e^±2 x forward are deep wings whose probe prices pin near 0/1 anyway. - let exponent_magnitude = (multiplier.magnitude() * scale).min( - 2 * math::float_scaling!(), - ); - let exponent = i64::from_parts(exponent_magnitude, multiplier.is_negative()); - let strike = math::mul(pricer.forward, math::exp(&exponent)); - strikes.push_back(strike); - prices.push_back(up_price(pricer, strike)); - }); - (strikes, prices) -} - -/// Abort unless every stored probe contract still prices within `epsilon` of -/// its anchored fair price on the live surface. `epsilon` is a fraction of -/// full payout in FLOAT_SCALING, so this check IS the guard's face-error bound -/// at the probes: a mark the flush accepts cannot have drifted by more than -/// `epsilon` of face there, whatever mix of forward move, variance decay, or -/// surface reshape produced the drift. Near expiry a given oracle move -/// produces larger price moves, so the guard tightens automatically in the -/// only units that matter. -public(package) fun assert_probe_prices_within( - pricer: &Pricer, - probe_strikes: &vector, - probe_prices: &vector, - epsilon: u64, -) { - let probe_count = probe_strikes.length(); - let mut i = 0; - while (i < probe_count) { - let live_price = up_price(pricer, probe_strikes[i]); - assert!(live_price.diff(probe_prices[i]) <= epsilon, EMarkPriceDrifted); - i = i + 1; - }; -} - // === Public-Package Functions === /// Validate the current live pricing boundary and snapshot oracle inputs for @@ -470,20 +408,3 @@ fun compute_nd2(svi_params: &SVIParams, forward: u64, strike: u64): u64 { if (adjusted.magnitude() > math::float_scaling!()) return math::float_scaling!(); adjusted.magnitude() } - -/// The size of one standard deviation of the price move this surface still -/// expects before expiry, at the strike where that expectation is smallest — -/// `sqrt(a + b * sigma * sqrt(1 - rho^2))`, the global minimum of SVI total -/// variance over strikes. Sets the probe-grid spacing so the drift guard's -/// probe contracts span the price-relevant strike range at every expiry. -fun sqrt_min_total_variance(pricer: &Pricer): u64 { - let rho = pricer.svi.rho(); - let rho_squared = rho.mul_scaled(&rho).magnitude(); - // |rho| <= 1 inside the pricing-safe envelope; saturate the complement against - // fixed-point rounding dust at the |rho| = 1 corner. - let one_minus_rho_squared = math::float_scaling!().saturating_sub(rho_squared); - let root = math::sqrt(one_minus_rho_squared, math::float_scaling!()); - let min_total_var = - pricer.svi.a() + math::mul(pricer.svi.b(), math::mul(pricer.svi.sigma(), root)); - math::sqrt(min_total_var, math::float_scaling!()) -} diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index 9d9fe5ee2..06762562e 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -1,23 +1,24 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -/// Stored per-market valuation mark and its flush-acceptance policy. +/// Stored per-market valuation mark: a dumb measurement component. /// /// A mark memoizes one market's exact oracle-priced per-order liability (the -/// payout-tree walk) plus the pricing anchors it was computed at, so the pool -/// flush can read the market without walking any tree. This module owns the -/// mark's lifecycle: construction at refresh, the freshness/drift acceptance -/// checks, and the write-through maintenance trade flows apply (mint adds its -/// `net_premium`, live redeem subtracts its `redeem_amount`, liquidation -/// subtracts the knocked-out order's live value). It does not own the walk or -/// the market's cash — `expiry_market` composes those. +/// payout-tree walk) so the pool flush can read the market without walking any +/// tree. This module owns the mark's lifecycle: construction at refresh, the +/// write-through maintenance trade flows apply (mint adds its `net_premium`, +/// live redeem subtracts its `redeem_amount`, liquidation subtracts the +/// knocked-out order's live value), and measuring `drift` — the potential +/// dollar impact of oracle movement since the walk, against a fresh `Pricer`. +/// It deliberately owns NO acceptance policy: whether a mark is fresh enough or +/// the measured drift acceptable is decided downstream by `plp`, which +/// aggregates across markets. It does not own the walk or the market's cash — +/// `expiry_market` composes those. module deepbook_predict::valuation_mark; -use deepbook_predict::{pricing::Pricer, valuation_config::ValuationConfig}; +use deepbook_predict::pricing::Pricer; use sui::clock::Clock; -const EValuationMarkStale: u64 = 0; - /// One market's stored valuation mark. Free cash is never stored — the flush /// reads it live, so cash moves need no mark maintenance. Trade write-through /// accumulates in the two delta fields rather than mutating the walked number: @@ -37,11 +38,6 @@ public struct ValuationMark has copy, drop, store { removed_since_compute: u64, /// On-chain landing time of the refresh that computed this mark. computed_at_ms: u64, - /// Probe contract strikes fanned around the refresh-time forward - /// (drift-guard anchors; `pricing::price_probes`). - probe_strikes: vector, - /// Fair UP price of each probe at refresh, parallel to `probe_strikes`. - probe_prices: vector, } // === Public-Package Functions === @@ -59,40 +55,27 @@ public(package) fun computed_at_ms(mark: &ValuationMark): u64 { mark.computed_at_ms } -/// Snapshot a fresh mark: the just-walked liability plus the pricer's probe -/// anchors, with zeroed trade deltas. -public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): ValuationMark { - let (probe_strikes, probe_prices) = pricer.price_probes(); +/// Measure this mark's potential oracle drift against the live inputs in +/// `pricer`: the dollar amount (DUSDC base units) by which this market's NAV +/// could have moved since the walk, from oracle movement alone (trade deltas +/// are already inside `liability`). The caller aggregates and judges — this +/// component measures, it does not accept or reject. +/// +/// PLACEHOLDER: the drift model is pending; returns 0 until it lands. +public(package) fun drift(_mark: &ValuationMark, _pricer: &Pricer): u64 { + 0 +} + +/// Snapshot a fresh mark: the just-walked liability with zeroed trade deltas. +public(package) fun new(liability: u64, _pricer: &Pricer, clock: &Clock): ValuationMark { ValuationMark { computed_liability: liability, added_since_compute: 0, removed_since_compute: 0, computed_at_ms: clock.timestamp_ms(), - probe_strikes, - probe_prices, } } -/// Abort unless this mark is still usable by the flush: younger than the -/// freshness ceiling, and every stored probe contract still pricing within the -/// drift tolerance on the live surface in `pricer`. -public(package) fun assert_flushable( - mark: &ValuationMark, - valuation_config: &ValuationConfig, - pricer: &Pricer, - clock: &Clock, -) { - assert!( - clock.timestamp_ms() - mark.computed_at_ms <= valuation_config.nav_mark_freshness_ms(), - EValuationMarkStale, - ); - pricer.assert_probe_prices_within( - &mark.probe_strikes, - &mark.probe_prices, - valuation_config.nav_mark_drift_epsilon(), - ); -} - /// Write a trade's liability increase through to the mark's delta accumulator. public(package) fun add_liability(mark: &mut ValuationMark, amount: u64) { mark.added_since_compute = mark.added_since_compute + amount; From f84535153f4946ed5cd3915d2f0469ef396aec97 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 13:01:51 -0400 Subject: [PATCH 10/23] =?UTF-8?q?predict:=20fill=20in=20drift(pricer)=20?= =?UTF-8?q?=E2=80=94=20closed-form=20oracle-drift=20envelope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark snapshots raw anchors at the walk (forward + SVI params; raw so the formula can evolve without stored state aging, and both sides derive through the same code at read time). drift(pricer) returns pricing::drift_envelope between anchor and live: an upper bound on any single contract's price move, in fractions of full payout, built from the normal curve's max slope (0.4) times a term-by-term bound on the worst d2 move — forward shift over the variance floor, plus one Lipschitz charge per SVI parameter delta over the strike band where prices are not pinned. Every term is zero when nothing moved; degenerate surfaces (zero floor, wing slope near 1, absurd band) return full face, fail-closed. Bound by construction: the surface IS these six numbers, so there is no strike between samples to hide a reshape in. Not yet charged (documented): the skew-correction term's own drift and the ulp tail beyond the pad — both to be priced by the envelope validation measurements. plp aggregation/enforcement unchanged (stub). Co-Authored-By: Claude Fable 5 --- packages/predict/sources/expiry_market.move | 6 +- packages/predict/sources/pricing/pricing.move | 154 +++++++++++++++++- packages/predict/sources/valuation_mark.move | 30 ++-- 3 files changed, 177 insertions(+), 13 deletions(-) diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 34d23bc91..53349d6d5 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -756,8 +756,10 @@ public(package) fun mark_computed_at_ms(market: &ExpiryMarket): u64 { } /// Measure the stored mark's potential oracle drift against the live inputs in -/// `pricer`, in DUSDC base units (`valuation_mark::drift`). A measurement, not -/// a judgment — `plp` aggregates drift across markets and enforces the bound. +/// `pricer`: the worst-case single-contract price move since the walk, as a +/// fraction of full payout in FLOAT_SCALING (`valuation_mark::drift`). A +/// measurement, not a judgment — dollarization by this market's open interest +/// and the aggregate enforcement land with the `plp` aggregation fill-in. public(package) fun mark_drift(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); assert!(market.valuation_mark.is_some(), EValuationMarkMissing); diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 0e40ffa2b..4a874b2ef 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -73,6 +73,18 @@ macro fun max_pricing_spot(): u64 { std::u64::max_value!() / 100 } macro fun min_svi_sigma(): u64 { 1_000_000 } macro fun max_svi_input(): u64 { 100 * math::float_scaling!() } +// Drift-envelope constants. A price never moves more than the normal curve's +// max slope times its d2 move; 0.4 rounds 1/sqrt(2*pi) up so the fixed-point +// round-down stays a bound. +macro fun drift_phi_max(): u64 { 400_000_000 } +// Beyond |d2| = 4 both snapshots pin a strike's price to the same side of 0/1 +// within 2*N(-4) ~ 6.3e-5 of face; padded for fixed-point dust. +macro fun drift_tail_pad(): u64 { 100_000 } +macro fun drift_band_d(): u64 { 4 * math::float_scaling!() } +// No real strike ladder sits e^100 away from the forward; a wider computed +// band means degenerate params, so the envelope fails closed instead. +macro fun drift_band_cap(): u64 { 100 * math::float_scaling!() } + // === Public Functions === /// Return the current UP tail price for one strike. Public read for @@ -92,7 +104,95 @@ public(package) fun expiry_market_id(pricer: &Pricer): ID { pricer.expiry_market_id } -// === Public-Package Functions === +/// Return the forward this pricer snapshotted (stored as a drift anchor). +public(package) fun forward(pricer: &Pricer): u64 { + pricer.forward +} + +/// Return a copy of the SVI params this pricer snapshotted (stored as a drift anchor). +public(package) fun svi_params(pricer: &Pricer): SVIParams { + pricer.svi +} + +/// Worst-case repricing between two oracle snapshots: an upper bound on how far +/// ANY contract's fair price can have moved between the anchor oracle state and +/// the live one, as a fraction of full payout in FLOAT_SCALING (capped at 1.0). +/// +/// This is a bound by construction, not a sample: an SVI surface IS five +/// numbers plus the forward, and every term below charges one of them, so a +/// surface change large enough to move some price somewhere must show up in at +/// least one charged term — there is no strike "between probes" to hide in. +/// The chain: a contract's price can never move more than ~0.4 x its move in +/// d2 (the normal curve's maximum slope — a property of the pricing function, +/// not of the oracle); the worst d2 move over all strikes is then bounded +/// term-by-term — the forward shift scaled by the surface's variance floor, +/// and the surface reshape (each SVI parameter's delta, Lipschitz-bounded over +/// the strike band where prices are not pinned to 0/1). Every term is zero +/// when nothing moved, and degenerate inputs (zero variance floor, wing slope +/// at/above 1, an absurd strike band) return full face — fail-closed, the mark +/// just needs a re-refresh. +/// +/// Deliberately conservative: the terms stack worst cases that cannot all +/// happen at the same strike, so benign moves are overstated (costing +/// re-refreshes, never understating drift). NOT yet charged: the skew +/// correction term's own drift (small and mostly cancelling between snapshots; +/// to be priced by the envelope-validation measurements) and fixed-point ulps +/// (absorbed by the tail pad). +public(package) fun drift_envelope( + pricer: &Pricer, + anchor_forward: u64, + anchor_svi: &SVIParams, +): u64 { + let full = math::float_scaling!(); + let s0 = sqrt_min_total_variance(anchor_svi); + let s1 = sqrt_min_total_variance(&pricer.svi); + if (s0 == 0 || s1 == 0 || anchor_forward == 0) return full; + let s_product = math::mul(s0, s1); + if (s_product == 0) return full; + + // Forward leg: a forward move shifts every strike's log-moneyness by + // |ln(F1/F0)|, and d2 divides that by at least the variance floor. + let forward_ratio = math::div(pricer.forward, anchor_forward); + if (forward_ratio == 0) return full; + let delta_forward = math::ln(forward_ratio).magnitude(); + + // Strike band where prices are not pinned to 0/1 on either surface; beyond + // it both snapshots agree to within the tail pad. Degenerate/absurd bands + // fail closed. + let band0 = unpinned_band(anchor_svi); + let band1 = unpinned_band(&pricer.svi); + if (band0.is_none() || band1.is_none()) return full; + let k_band = band0.destroy_some().max(band1.destroy_some()); + if (k_band > drift_band_cap!()) return full; + + // Surface leg: the worst total-variance gap between the snapshots over the + // band, one Lipschitz charge per SVI parameter delta (plus the band shift + // the forward move causes), converted to a sqrt-variance gap and then to a + // d2 move. + let m1_abs = pricer.svi.m().magnitude(); + let g1_max = 2 * (k_band + m1_abs) + pricer.svi.sigma(); + let delta_rho = pricer.svi.rho().sub(&anchor_svi.rho()).magnitude(); + let delta_m = pricer.svi.m().sub(&anchor_svi.m()).magnitude(); + let delta_sigma = pricer.svi.sigma().diff(anchor_svi.sigma()); + let delta_a = pricer.svi.a().diff(anchor_svi.a()); + let delta_b = pricer.svi.b().diff(anchor_svi.b()); + let sup_wing_gap = + math::mul(delta_rho, k_band + m1_abs) + math::mul(anchor_svi.rho().magnitude(), delta_m) + + delta_m + delta_sigma; + let slope1_max = math::mul( + pricer.svi.b(), + math::float_scaling!() + pricer.svi.rho().magnitude(), + ); + let sup_delta_w = + delta_a + math::mul(delta_b, g1_max) + math::mul(anchor_svi.b(), sup_wing_gap) + + math::mul(slope1_max, delta_forward); + let sup_delta_sqrt_w = math::div(sup_delta_w, s0 + s1); + + let d2_bound = + math::div(delta_forward, s1) + + math::mul(sup_delta_sqrt_w, math::div(k_band, s_product) + full / 2); + (math::mul(drift_phi_max!(), d2_bound) + drift_tail_pad!()).min(full) +} /// Validate the current live pricing boundary and snapshot oracle inputs for /// one market's repeated quote calculations. @@ -176,6 +276,58 @@ fun cached_up_price(memo: &PriceMemo, tick: u64): u64 { abort ETickNotInPriceMemo } +/// The size of one standard deviation of the price move this surface still +/// expects before expiry, at the strike where that expectation is smallest — +/// `sqrt(a + b * sigma * sqrt(1 - rho^2))`, the global minimum of SVI total +/// variance over strikes. The drift envelope's forward leg divides by it. +fun sqrt_min_total_variance(svi: &SVIParams): u64 { + let rho_squared = svi.rho().mul_scaled(&svi.rho()).magnitude(); + // |rho| <= 1 inside the pricing-safe envelope; saturate the complement against + // fixed-point rounding dust at the |rho| = 1 corner. + let one_minus_rho_squared = math::float_scaling!().saturating_sub(rho_squared); + let root = math::sqrt(one_minus_rho_squared, math::float_scaling!()); + let min_total_var = svi.a() + math::mul(svi.b(), math::mul(svi.sigma(), root)); + math::sqrt(min_total_var, math::float_scaling!()) +} + +/// Log-moneyness band `K` outside which this surface pins every price within +/// the tail pad of 0 or 1 (|d2| > D on both tails). Solves +/// `K = D * sqrt(W) + W / 2` against the surface's own linear overshoot +/// `W = A + B * K` (with `A = a + b * (2|m| + sigma)`, `B = 2b`, from +/// `w(k) <= A + B|k|`), so the band covers every strike the surface can price +/// away from the pins. `None` when the surface is too degenerate to band +/// (wing slope at/above ~1, or a band past the cap) — the caller fails closed. +fun unpinned_band(svi: &SVIParams): Option { + let one = math::float_scaling!(); + let d = drift_band_d!(); + let cap = drift_band_cap!(); + let a_overshoot = svi.a() + math::mul(svi.b(), 2 * svi.m().magnitude() + svi.sigma()); + let b_slope = svi.b(); + // Near/above slope 1 the quadratic degenerates (denominator -> 0); no real + // surface is close (observed b <= ~0.03), so fail closed rather than solve. + if (b_slope > 999_000_000) return option::none(); + + if (b_slope < 1_000) { + // Slope negligible: W ~ A. The 10% headroom covers the ignored slope. + let k = math::mul(d, math::sqrt(a_overshoot, one)) + a_overshoot / 2; + let k = k + k / 10; + if (k > cap) return option::none(); + return option::some(k) + }; + + let b_coeff = 2 * b_slope; + let bd = math::mul(b_coeff, d); + let discriminant = math::mul(bd, bd) + 4 * math::mul(a_overshoot, one - b_slope); + let sqrt_w = math::div(bd + math::sqrt(discriminant, one), 2 * (one - b_slope)); + let w = math::mul(sqrt_w, sqrt_w); + let w_minus_a = w.saturating_sub(a_overshoot); + // K = (W - A) / B; refuse before dividing when K would exceed the cap. + if (w_minus_a > math::mul(b_coeff, cap)) return option::none(); + let k = math::div(w_minus_a, b_coeff); + if (k > cap) return option::none(); + option::some(k) +} + fun assert_current_oracles( propbook_registry: &OracleRegistry, propbook_underlying_id: u32, diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index 06762562e..9d4a65201 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -16,7 +16,8 @@ /// `expiry_market` composes those. module deepbook_predict::valuation_mark; -use deepbook_predict::pricing::Pricer; +use deepbook_predict::pricing::{Self, Pricer}; +use propbook::block_scholes_svi_feed::SVIParams; use sui::clock::Clock; /// One market's stored valuation mark. Free cash is never stored — the flush @@ -38,6 +39,12 @@ public struct ValuationMark has copy, drop, store { removed_since_compute: u64, /// On-chain landing time of the refresh that computed this mark. computed_at_ms: u64, + /// Forward the walk priced at (drift anchor). Raw snapshot, not derived + /// terms: `drift` derives both sides with the same code at read time, so + /// the envelope formula can change without stored state aging. + anchor_forward: u64, + /// SVI params the walk priced at (drift anchor). + anchor_svi: SVIParams, } // === Public-Package Functions === @@ -56,23 +63,26 @@ public(package) fun computed_at_ms(mark: &ValuationMark): u64 { } /// Measure this mark's potential oracle drift against the live inputs in -/// `pricer`: the dollar amount (DUSDC base units) by which this market's NAV -/// could have moved since the walk, from oracle movement alone (trade deltas -/// are already inside `liability`). The caller aggregates and judges — this +/// `pricer`: an upper bound on how far ANY single contract's fair price can +/// have moved since the walk, as a fraction of full payout in FLOAT_SCALING +/// (`pricing::drift_envelope` between the stored anchors and the live +/// snapshot). Oracle movement only — trade deltas are already inside +/// `liability`. The caller converts to dollars and judges in aggregate; this /// component measures, it does not accept or reject. -/// -/// PLACEHOLDER: the drift model is pending; returns 0 until it lands. -public(package) fun drift(_mark: &ValuationMark, _pricer: &Pricer): u64 { - 0 +public(package) fun drift(mark: &ValuationMark, pricer: &Pricer): u64 { + pricer.drift_envelope(mark.anchor_forward, &mark.anchor_svi) } -/// Snapshot a fresh mark: the just-walked liability with zeroed trade deltas. -public(package) fun new(liability: u64, _pricer: &Pricer, clock: &Clock): ValuationMark { +/// Snapshot a fresh mark: the just-walked liability with zeroed trade deltas, +/// anchored to the oracle inputs the walk priced at. +public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): ValuationMark { ValuationMark { computed_liability: liability, added_since_compute: 0, removed_since_compute: 0, computed_at_ms: clock.timestamp_ms(), + anchor_forward: pricer.forward(), + anchor_svi: pricer.svi_params(), } } From 800306081143835fe5c9c0e473df6a09b9626a87 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 13:26:15 -0400 Subject: [PATCH 11/23] predict: charge the skew correction in the drift envelope; complete the bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The implemented price subtracts phi(d2) * w'(k) / (2 sqrt(w)); the clamp is 1-Lipschitz, so |dP| <= |dN(d2)| + |d correction|. Three new legs charge the correction's drift: phi_max times the w' change (per-parameter Lipschitz, sigma-floored), phi_max times the sqrt(w) change scaled by the correction's max height, and that height times the density's own move (phi's slope <= 0.25 times the already-bounded d2 move). Structural fix found during derivation: 'prices pinned outside |d2| = 4' is false for the skew-adjusted price — deep-wing corrections can pull prices off their pins (the P-11 mechanism) — so the band threshold now widens dynamically until phi(D) decays past the correction's max height. Hardening: the term assembly moves to u128 with a single fail-closed cap at full face, so envelope-corner surfaces saturate the drift instead of aborting mid-flush; sub-1e-4 variance floors (sub-seconds to expiry or degenerate surfaces) fail closed early. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/pricing/pricing.move | 103 ++++++++++++++---- 1 file changed, 80 insertions(+), 23 deletions(-) diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 4a874b2ef..a01aa76e2 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -77,13 +77,23 @@ macro fun max_svi_input(): u64 { 100 * math::float_scaling!() } // max slope times its d2 move; 0.4 rounds 1/sqrt(2*pi) up so the fixed-point // round-down stays a bound. macro fun drift_phi_max(): u64 { 400_000_000 } -// Beyond |d2| = 4 both snapshots pin a strike's price to the same side of 0/1 -// within 2*N(-4) ~ 6.3e-5 of face; padded for fixed-point dust. -macro fun drift_tail_pad(): u64 { 100_000 } +// The normal density's own slope never exceeds phi(1) ~ 0.242; 0.25 rounds up. +macro fun drift_phi_prime_max(): u64 { 250_000_000 } +// Tail allowance outside the banded strike region: 2*N(-4) ~ 6.3e-5 of face +// from the pinned digitals, plus the skew-correction tail held under +// 2 * drift_ctail_target by the dynamic band threshold, plus fixed-point dust. +macro fun drift_tail_pad(): u64 { 150_000 } +// Per-snapshot skew-correction tail target: the band widens until +// phi(D) * (max wing slope / (2 * variance floor)) <= this. +macro fun drift_ctail_target(): u64 { 25_000 } macro fun drift_band_d(): u64 { 4 * math::float_scaling!() } // No real strike ladder sits e^100 away from the forward; a wider computed // band means degenerate params, so the envelope fails closed instead. macro fun drift_band_cap(): u64 { 100 * math::float_scaling!() } +// Variance floors below 1e-4 mean sub-seconds-to-expiry or a degenerate +// surface: marks there are unusable at any tolerance, so fail closed early +// (this also keeps every division below inside u64/u128 headroom). +macro fun drift_min_floor(): u64 { 100_000 } // === Public Functions === @@ -134,21 +144,30 @@ public(package) fun svi_params(pricer: &Pricer): SVIParams { /// /// Deliberately conservative: the terms stack worst cases that cannot all /// happen at the same strike, so benign moves are overstated (costing -/// re-refreshes, never understating drift). NOT yet charged: the skew -/// correction term's own drift (small and mostly cancelling between snapshots; -/// to be priced by the envelope-validation measurements) and fixed-point ulps -/// (absorbed by the tail pad). +/// re-refreshes, never understating drift). The implemented price also +/// subtracts a skew correction `phi(d2) * w'(k) / (2 sqrt(w))`; its own drift +/// is charged by the three skew legs below (the price clamp is 1-Lipschitz, so +/// `|dP| <= |dN(d2)| + |d correction|`), and the band threshold widens +/// dynamically until the correction's tails die under the tail allowance — +/// without that, deep-wing corrections can pull prices off their 0/1 pins (the +/// P-11 mechanism) outside a fixed band. Residual: fixed-point round-down in +/// the term arithmetic (absorbed by the tail pad; the envelope-validation +/// measurements adversarially attack the whole bound). public(package) fun drift_envelope( pricer: &Pricer, anchor_forward: u64, anchor_svi: &SVIParams, ): u64 { let full = math::float_scaling!(); + let scale = full as u128; let s0 = sqrt_min_total_variance(anchor_svi); let s1 = sqrt_min_total_variance(&pricer.svi); - if (s0 == 0 || s1 == 0 || anchor_forward == 0) return full; + let s_lo = s0.min(s1); + if (s_lo < drift_min_floor!() || anchor_forward == 0) return full; let s_product = math::mul(s0, s1); if (s_product == 0) return full; + let sigma_lo = anchor_svi.sigma().min(pricer.svi.sigma()); + if (sigma_lo == 0) return full; // Forward leg: a forward move shifts every strike's log-moneyness by // |ln(F1/F0)|, and d2 divides that by at least the variance floor. @@ -156,11 +175,27 @@ public(package) fun drift_envelope( if (forward_ratio == 0) return full; let delta_forward = math::ln(forward_ratio).magnitude(); - // Strike band where prices are not pinned to 0/1 on either surface; beyond - // it both snapshots agree to within the tail pad. Degenerate/absurd bands - // fail closed. - let band0 = unpinned_band(anchor_svi); - let band1 = unpinned_band(&pricer.svi); + // Band threshold: |d2| = 4 pins the digitals, but the skew correction's + // tail dies only once phi(D) has decayed past the correction's maximum + // height r_max = max wing slope / (2 * variance floor) — widen D until + // phi(D) * r_max fits the per-snapshot tail target. + let slope0_max = math::mul(anchor_svi.b(), full + anchor_svi.rho().magnitude()); + let slope1_max = math::mul(pricer.svi.b(), full + pricer.svi.rho().magnitude()); + let r0 = math::div(slope0_max, 2 * s0); + let r1 = math::div(slope1_max, 2 * s1); + let r_max = r0.max(r1); + let mut d_threshold = drift_band_d!(); + if (r_max > drift_ctail_target!()) { + let decay_needed = math::div(math::div(drift_ctail_target!(), r_max), drift_phi_max!()); + if (decay_needed == 0) return full; + let d_ctail = math::sqrt(2 * math::ln(decay_needed).magnitude(), full); + d_threshold = d_threshold.max(d_ctail); + }; + + // Strike band where neither snapshot prices any strike away from its 0/1 + // pin by more than the tail allowance. Degenerate/absurd bands fail closed. + let band0 = unpinned_band(anchor_svi, d_threshold); + let band1 = unpinned_band(&pricer.svi, d_threshold); if (band0.is_none() || band1.is_none()) return full; let k_band = band0.destroy_some().max(band1.destroy_some()); if (k_band > drift_band_cap!()) return full; @@ -179,19 +214,42 @@ public(package) fun drift_envelope( let sup_wing_gap = math::mul(delta_rho, k_band + m1_abs) + math::mul(anchor_svi.rho().magnitude(), delta_m) + delta_m + delta_sigma; - let slope1_max = math::mul( - pricer.svi.b(), - math::float_scaling!() + pricer.svi.rho().magnitude(), - ); let sup_delta_w = delta_a + math::mul(delta_b, g1_max) + math::mul(anchor_svi.b(), sup_wing_gap) + math::mul(slope1_max, delta_forward); - let sup_delta_sqrt_w = math::div(sup_delta_w, s0 + s1); + let sup_delta_sqrt_w = + ((sup_delta_w as u128) * scale / ((s0 + s1) as u128)).min(100 * scale) as u64; + // Everything below assembles in u128 with a single cap at full face: + // fail-closed saturation, never an arithmetic abort in the flush path. + let half = (full / 2) as u128; + let moneyness_ratio = (k_band as u128) * scale / (s_product as u128); let d2_bound = - math::div(delta_forward, s1) - + math::mul(sup_delta_sqrt_w, math::div(k_band, s_product) + full / 2); - (math::mul(drift_phi_max!(), d2_bound) + drift_tail_pad!()).min(full) + (delta_forward as u128) * scale / (s1 as u128) + + (sup_delta_sqrt_w as u128) * (moneyness_ratio + half) / scale; + let n_leg = (drift_phi_max!() as u128) * d2_bound / scale; + if (n_leg >= scale) return full; + + // Skew legs. The correction is phi(d2) * R(k) with R = w' / (2 sqrt(w)): + // charge phi_max times R's change (split into the w' change and the + // sqrt(w) change), plus R's height times the density's own move (the + // density's slope never exceeds ~0.25, and d2's move is already bounded). + let delta_u = math::div(delta_m + delta_sigma, sigma_lo); + let sup_delta_w_prime = + 2 * delta_b + math::mul(anchor_svi.b(), delta_rho + delta_u) + + math::mul(pricer.svi.b(), math::div(delta_forward, pricer.svi.sigma())); + let skew_w_prime_leg = + (drift_phi_max!() as u128) * (sup_delta_w_prime as u128) / ((2 * s_lo) as u128); + let skew_sqrt_w_leg = + (drift_phi_max!() as u128) * ((r_max as u128) * (sup_delta_sqrt_w as u128) / (s_lo as u128)) + / scale; + let skew_density_leg = + (drift_phi_prime_max!() as u128) * ((r0 as u128) * d2_bound / scale) / scale; + + let total = + n_leg + skew_w_prime_leg + skew_sqrt_w_leg + skew_density_leg + + (drift_tail_pad!() as u128); + total.min(scale) as u64 } /// Validate the current live pricing boundary and snapshot oracle inputs for @@ -297,9 +355,8 @@ fun sqrt_min_total_variance(svi: &SVIParams): u64 { /// `w(k) <= A + B|k|`), so the band covers every strike the surface can price /// away from the pins. `None` when the surface is too degenerate to band /// (wing slope at/above ~1, or a band past the cap) — the caller fails closed. -fun unpinned_band(svi: &SVIParams): Option { +fun unpinned_band(svi: &SVIParams, d: u64): Option { let one = math::float_scaling!(); - let d = drift_band_d!(); let cap = drift_band_cap!(); let a_overshoot = svi.a() + math::mul(svi.b(), 2 * svi.m().magnitude() + svi.sigma()); let b_slope = svi.b(); From 9399623bf60c34b6f07da13ad4f9a3f70d3a8273 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 13:42:02 -0400 Subject: [PATCH 12/23] predict: dollarize drift by live face quantity; enforce the aggregate budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the source contracts end to end. The payout tree already maintains the book's total face quantity in its treap summaries (every order contributes its quantity to exactly one start side), so the drift dollarization multiplier is a new O(1) getter over existing state — no counter, no walk change, and it reads live, covering post-anchor mints. mark_drift now returns dollars (live quantity x envelope fraction), and finish_flush enforces the budget for real: total accepted worst-case drift <= nav_mark_drift_epsilon x pool NAV, so the PLP price the queues fill at is within the configured fraction of the fresh-recalculation price (plus documented envelope residuals). FlushExecuted emits total_drift as the budget-headroom signal. Knob value unchanged (2%) pending the envelope-validation measurements; the pool_nav == 0 edge is documented as a deliberate open decision. Co-Authored-By: Claude Fable 5 --- .../sources/config/valuation_config.move | 13 ++++---- .../predict/sources/events/vault_events.move | 6 ++++ packages/predict/sources/expiry_market.move | 16 ++++++---- packages/predict/sources/plp/plp.move | 30 ++++++++++++------- .../index/strike_payout_tree.move | 15 ++++++++++ .../strike_exposure/strike_exposure.move | 6 ++++ 6 files changed, 65 insertions(+), 21 deletions(-) diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index e5d88eac5..e1d138972 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -14,12 +14,13 @@ use deepbook_predict::config_constants; public struct ValuationConfig has store { /// Hard ceiling on stored-mark age in milliseconds, regardless of oracle drift. nav_mark_freshness_ms: u64, - /// Acceptance threshold for aggregate mark drift at the flush, in - /// FLOAT_SCALING. Intended interpretation: the counted marks' combined - /// measured dollar drift may not exceed this fraction of pool NAV, putting - /// the bound directly in PLP-price units - /// (`plp::assert_aggregate_drift_acceptable`). PLACEHOLDER semantics while - /// the drift model (`valuation_mark::drift`) is stubbed. + /// The flush's aggregate drift budget, in FLOAT_SCALING: the counted + /// marks' combined worst-case dollar drift may not exceed this fraction of + /// pool NAV (`plp::assert_aggregate_drift_acceptable`), so the PLP price + /// the queues fill at is guaranteed within this bound of the + /// fresh-recalculation price (plus the envelope's documented residuals). + /// The current default predates the aggregate semantics and is re-derived + /// by the envelope-validation measurements before deploy. nav_mark_drift_epsilon: u64, } diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index bf2f862f2..51f75b68f 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -184,6 +184,10 @@ public struct FlushExecuted has copy, drop, store { total_free_cash: u64, /// Σ of each counted market's marked liability at the flush (raw, unfloored). total_liability: u64, + /// Σ of each counted market's worst-case dollar drift accepted by this + /// flush — the budget headroom signal (compare against the configured + /// fraction of `pool_value`). + total_drift: u64, /// Number of active markets valued for this flush. market_count: u64, /// Idle DUSDC held by the pool at valuation time, before the drain. @@ -463,6 +467,7 @@ public(package) fun emit_flush_executed( total_supply: u64, total_free_cash: u64, total_liability: u64, + total_drift: u64, market_count: u64, idle_balance_before: u64, supplies_filled: u64, @@ -477,6 +482,7 @@ public(package) fun emit_flush_executed( total_supply, total_free_cash, total_liability, + total_drift, market_count, idle_balance_before, supplies_filled, diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 53349d6d5..b74b1a97e 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -756,14 +756,20 @@ public(package) fun mark_computed_at_ms(market: &ExpiryMarket): u64 { } /// Measure the stored mark's potential oracle drift against the live inputs in -/// `pricer`: the worst-case single-contract price move since the walk, as a -/// fraction of full payout in FLOAT_SCALING (`valuation_mark::drift`). A -/// measurement, not a judgment — dollarization by this market's open interest -/// and the aggregate enforcement land with the `plp` aggregation fill-in. +/// `pricer`, in DUSDC base units: the worst-case single-contract price move +/// since the walk (`valuation_mark::drift`, a fraction of full payout) times +/// the book's live face quantity — per-order value is quantity-Lipschitz in +/// price, so this bounds how far this market's NAV can have drifted. The +/// quantity is read live, so orders minted after the walk (whose write-through +/// deltas also sit on drifting prices) are covered. A measurement, not a +/// judgment — `plp` aggregates across markets and enforces the budget. public(package) fun mark_drift(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); assert!(market.valuation_mark.is_some(), EValuationMarkMissing); - market.valuation_mark.borrow().drift(pricer) + math::mul( + market.strike_exposure.total_live_quantity(), + market.valuation_mark.borrow().drift(pricer), + ) } /// Recompute this market's exact per-order live liability and store it as the diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index d63a86a84..727e6157d 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -54,6 +54,7 @@ const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; const EValuationMarkStale: u64 = 10; +const EAggregateDriftExceeded: u64 = 11; /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -97,7 +98,7 @@ public struct PoolValuation { total_free_cash: u64, /// Running Σ of each counted market's marked liability. total_liability: u64, - /// Running Σ of each counted market's measured oracle drift, in DUSDC — + /// Running Σ of each counted market's measured worst-case drift, in DUSDC — /// the dollar amount pool NAV could be off by from marks aging against /// moving feeds. Judged in aggregate at `finish_flush`, where the PLP-price /// error it implies has its denominator. @@ -363,7 +364,11 @@ public fun finish_flush( total_free_cash, total_liability, ); - assert_aggregate_drift_acceptable(total_drift, pool_nav); + assert_aggregate_drift_acceptable( + total_drift, + config.valuation_config().nav_mark_drift_epsilon(), + pool_nav, + ); let total_supply = vault.lp.total_supply(); let market_count = valued_expiry_markets.length(); @@ -389,6 +394,7 @@ public fun finish_flush( total_supply, total_free_cash, total_liability, + total_drift, market_count, idle_balance_before, drain_summary.supplies_filled(), @@ -1021,15 +1027,19 @@ fun materialize_expiry_profit( ); } -/// The pool-level drift acceptance: the flush must reject itself when the -/// counted marks' combined measured drift could move the PLP price by more -/// than an acceptable bound — shape: `total_drift <= threshold * pool_nav`, -/// putting the guarantee directly in PLP-price units (the per-market -/// measurements have no denominator to judge against). +/// The pool-level drift acceptance: the flush rejects itself when the counted +/// marks' combined worst-case dollar drift could move the PLP price by more +/// than the configured fraction of pool NAV — the guarantee lives directly in +/// PLP-price units, which no per-market check can express (the per-contract to +/// PLP translation floats with book size over pool equity). On rejection the +/// operator re-refreshes the largest-drift markets and retries. /// -/// PLACEHOLDER: accepts everything until the drift model -/// (`valuation_mark::drift`) and the threshold interpretation land. -fun assert_aggregate_drift_acceptable(_total_drift: u64, _pool_nav: u64) {} +/// Open edge (deliberate, undecided): at `pool_nav == 0` the budget is zero, +/// so any nonzero measured drift blocks the flush even though a zero mark only +/// refunds queue heads rather than pricing fills. +fun assert_aggregate_drift_acceptable(total_drift: u64, drift_budget: u64, pool_nav: u64) { + assert!(total_drift <= math::mul(drift_budget, pool_nav), EAggregateDriftExceeded); +} /// Abort unless this valuation belongs to `vault`. fun assert_pool_vault(valuation: &PoolValuation, vault: &PoolVault) { diff --git a/packages/predict/sources/strike_exposure/index/strike_payout_tree.move b/packages/predict/sources/strike_exposure/index/strike_payout_tree.move index 8e4bd667f..cdc426222 100644 --- a/packages/predict/sources/strike_exposure/index/strike_payout_tree.move +++ b/packages/predict/sources/strike_exposure/index/strike_payout_tree.move @@ -59,6 +59,21 @@ public struct PayoutNode has copy, drop, store { summary: PayoutSummary, } +/// Return the total quantity of all live orders in the tree. Every order +/// contributes its quantity to exactly one start side (`base` for `-inf` +/// lower boundaries, a node otherwise), so the root summary's start total plus +/// the base is the whole book's face outstanding — an O(1) read of state the +/// treap already maintains. This is the book's price-sensitivity constant: +/// per-order value is quantity-Lipschitz in price, so the worst dollar NAV +/// drift for a price move `d` is `total_quantity * d`. +public(package) fun total_quantity(tree: &StrikePayoutTree): u64 { + let mut total = tree.base.quantity; + if (tree.root.is_some()) { + total = total + tree.nodes[*tree.root.borrow()].summary.total_start.quantity; + }; + total +} + /// Return `(max_net_payout, total_net_payout)` for pre-settlement reserve math. public(package) fun net_payout_reserve_terms(tree: &StrikePayoutTree): (u64, u64) { let mut max_net_payout = net_payout(tree.base); diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 3ffa62a88..3ef24d47d 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -112,6 +112,12 @@ public(package) fun range_probability(quote: &CloseQuote): u64 { quote.range_probability } +/// Return the total face quantity of this book's live orders (O(1), from the +/// payout tree's maintained aggregates). The drift dollarization multiplier. +public(package) fun total_live_quantity(exposure: &StrikeExposure): u64 { + exposure.payout.total_quantity() +} + /// Return the buffered live reserve, or exact remaining settled payout liability once materialized. /// /// Live reserve is the settlement floor (max single-point net payout) plus a From 6d57200d1183131d2fbd2cdbfff0669ea8125d27 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 14:16:32 -0400 Subject: [PATCH 13/23] predict: price drift into the flush mark as a bid/ask spread; kill the threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flush mark becomes two-sided: supplies price the pool at mid NAV plus the counted marks' combined worst-case drift, withdrawals at mid minus it. The true pool value provably sits inside the interval, so the transacting party bears the marks' staleness, incumbents can be neither diluted nor drained, and a same-flush round trip strictly loses the spread — stale-NAV arbitrage dies by construction. Fully fresh marks collapse the spread to the exact single mark. Deleted: the aggregate drift threshold, its error code, and the nav_mark_drift_epsilon knob with its constants and setter — the spread is fully determined by the measured bound, so there is no tolerance left to configure. ValuationConfig shrinks to the freshness ceiling (the stalled- feed backstop no spread can replace). The pool_nav == 0 open edge dissolves: nothing asserts against a zero budget; a degenerate bid just refunds through the existing non-executable path. Wide spreads self- resolve through each request's own min-out limit (miss, carry, refund). Flush availability becomes unconditional. Co-Authored-By: Claude Fable 5 --- .../sources/config/config_constants.move | 16 ------ .../sources/config/protocol_config.move | 10 ---- .../sources/config/valuation_config.move | 27 +++------- packages/predict/sources/plp/lp_book.move | 49 ++++++++++++------- packages/predict/sources/plp/plp.move | 40 ++++++--------- 5 files changed, 52 insertions(+), 90 deletions(-) diff --git a/packages/predict/sources/config/config_constants.move b/packages/predict/sources/config/config_constants.move index 1a02a61e0..5c3aa9073 100644 --- a/packages/predict/sources/config/config_constants.move +++ b/packages/predict/sources/config/config_constants.move @@ -31,7 +31,6 @@ const EInvalidMaxAdmissionLeverage: u64 = 20; const EInvalidCadenceWindowSize: u64 = 21; const EMarketTickSizeTooLarge: u64 = 22; const EInvalidNavMarkFreshnessMs: u64 = 23; -const EInvalidNavMarkDriftEpsilon: u64 = 24; // === Fees === @@ -372,18 +371,3 @@ public(package) fun assert_nav_mark_freshness_ms(value: u64) { EInvalidNavMarkFreshnessMs, ); } - -public(package) macro fun default_nav_mark_drift_epsilon(): u64 { 20_000_000 } -// Floor: epsilon = 0 would reject every stored mark and brick the flush (the -// floor setting is de-facto "always re-refresh" but stays fail-closed); ceiling: -// 0.1 lets probe contract prices drift up to 10% of full payout before the -// mark is rejected. -public(package) macro fun min_nav_mark_drift_epsilon(): u64 { 1_000_000 } -public(package) macro fun max_nav_mark_drift_epsilon(): u64 { 100_000_000 } - -public(package) fun assert_nav_mark_drift_epsilon(value: u64) { - assert!( - value >= min_nav_mark_drift_epsilon!() && value <= max_nav_mark_drift_epsilon!(), - EInvalidNavMarkDriftEpsilon, - ); -} diff --git a/packages/predict/sources/config/protocol_config.move b/packages/predict/sources/config/protocol_config.move index bf0b7fcb0..4d47ad662 100644 --- a/packages/predict/sources/config/protocol_config.move +++ b/packages/predict/sources/config/protocol_config.move @@ -195,16 +195,6 @@ public fun set_nav_mark_freshness_ms( config.valuation_config.set_nav_mark_freshness_ms(value); } -/// Set the oracle-drift tolerance for stored valuation marks at the pool flush. -public fun set_nav_mark_drift_epsilon( - config: &mut ProtocolConfig, - _admin_cap: &AdminCap, - value: u64, -) { - config.assert_version(); - config.valuation_config.set_nav_mark_drift_epsilon(value); -} - /// Set the trading loss rebate rate template used by future expiry markets. public fun set_template_trading_loss_rebate_rate( config: &mut ProtocolConfig, diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move index e1d138972..b47bed656 100644 --- a/packages/predict/sources/config/valuation_config.move +++ b/packages/predict/sources/config/valuation_config.move @@ -3,9 +3,12 @@ /// Stored flush-acceptance policy for per-market valuation marks. /// -/// ProtocolConfig owns this mutable policy. The pool flush reads it when deciding -/// whether a market's stored valuation mark is still usable: a hard freshness -/// ceiling on mark age plus the oracle-drift tolerance the drift guard scales by. +/// ProtocolConfig owns this mutable policy. The pool flush reads it when +/// deciding whether a market's stored valuation mark is still usable: a hard +/// freshness ceiling on mark age — the backstop against a stalled feed, which +/// shows zero measured drift and so cannot be caught by the drift spread. +/// Oracle drift itself needs no tolerance knob: the measured worst-case drift +/// is priced into the flush mark as a bid/ask spread borne by the transactor. module deepbook_predict::valuation_config; use deepbook_predict::config_constants; @@ -14,14 +17,6 @@ use deepbook_predict::config_constants; public struct ValuationConfig has store { /// Hard ceiling on stored-mark age in milliseconds, regardless of oracle drift. nav_mark_freshness_ms: u64, - /// The flush's aggregate drift budget, in FLOAT_SCALING: the counted - /// marks' combined worst-case dollar drift may not exceed this fraction of - /// pool NAV (`plp::assert_aggregate_drift_acceptable`), so the PLP price - /// the queues fill at is guaranteed within this bound of the - /// fresh-recalculation price (plus the envelope's documented residuals). - /// The current default predates the aggregate semantics and is re-derived - /// by the envelope-validation measurements before deploy. - nav_mark_drift_epsilon: u64, } // === Public-Package Functions === @@ -30,14 +25,9 @@ public(package) fun nav_mark_freshness_ms(config: &ValuationConfig): u64 { config.nav_mark_freshness_ms } -public(package) fun nav_mark_drift_epsilon(config: &ValuationConfig): u64 { - config.nav_mark_drift_epsilon -} - public(package) fun new(): ValuationConfig { ValuationConfig { nav_mark_freshness_ms: config_constants::default_nav_mark_freshness_ms!(), - nav_mark_drift_epsilon: config_constants::default_nav_mark_drift_epsilon!(), } } @@ -45,8 +35,3 @@ public(package) fun set_nav_mark_freshness_ms(config: &mut ValuationConfig, valu config_constants::assert_nav_mark_freshness_ms(value); config.nav_mark_freshness_ms = value; } - -public(package) fun set_nav_mark_drift_epsilon(config: &mut ValuationConfig, value: u64) { - config_constants::assert_nav_mark_drift_epsilon(value); - config.nav_mark_drift_epsilon = value; -} diff --git a/packages/predict/sources/plp/lp_book.move b/packages/predict/sources/plp/lp_book.move index d0feaffce..a67591397 100644 --- a/packages/predict/sources/plp/lp_book.move +++ b/packages/predict/sources/plp/lp_book.move @@ -67,13 +67,22 @@ public struct RequestQueue has store { escrow: Balance, } -/// Frozen flush share-price mark: `pool_value` over `total_supply`. Carried as one -/// value so the supply/withdraw pricing helpers read each term by name and cannot -/// transpose the numerator and denominator. +/// Frozen flush share-price mark, two-sided: supplies price the pool at +/// `supply_value` (the mid NAV plus the flush's measured worst-case drift) and +/// withdrawals at `withdraw_value` (the mid minus it). The true pool value +/// provably sits inside that interval, so each transactor prices as if the +/// pool were worth the end least favorable to them — the party moving bears +/// the marks' staleness, incumbents can be neither diluted nor drained, and a +/// same-flush round trip strictly loses the spread. Fully fresh marks collapse +/// the spread to a single exact mark. Each side carries its own executability +/// (one side can refund while the other fills). Terms are read by name so the +/// pricing helpers cannot transpose numerator and denominator. public struct FlushMark has drop { - pool_value: u64, + supply_value: u64, + withdraw_value: u64, total_supply: u64, - executable: bool, + supply_executable: bool, + withdraw_executable: bool, } /// Result of draining both LP queues at one frozen mark. @@ -153,11 +162,17 @@ public(package) fun cancel_withdraw_request( (request.account_id, request.amount, refund) } -public(package) fun new_flush_mark(pool_value: u64, total_supply: u64): FlushMark { +public(package) fun new_flush_mark( + supply_value: u64, + withdraw_value: u64, + total_supply: u64, +): FlushMark { FlushMark { - pool_value, + supply_value, + withdraw_value, total_supply, - executable: is_executable_mark(pool_value, total_supply), + supply_executable: is_executable_mark(supply_value, total_supply), + withdraw_executable: is_executable_mark(withdraw_value, total_supply), } } @@ -579,24 +594,24 @@ fun entry_offset(entries: &vector, index: u64): u64 { // === Pricing Helpers === -/// LP shares minted for `amount` DUSDC at the frozen flush mark. `None` means the +/// LP shares minted for `amount` DUSDC at the mark's supply side. `None` means the /// mark/request pair is not executable and the queued request must be refunded. fun quote_supply_shares(mark: &FlushMark, amount: u64): Option { - if (!mark.executable) return option::none(); - // = amount * total_supply / pool_value, round down (supplier mints ≤1 ulp + if (!mark.supply_executable) return option::none(); + // = amount * total_supply / supply_value, round down (supplier mints ≤1 ulp // fewer shares; the pool keeps the dust). - math::try_mul_div_down(amount, mark.total_supply, mark.pool_value).and!(|shares| { + math::try_mul_div_down(amount, mark.total_supply, mark.supply_value).and!(|shares| { if (shares == 0) option::none() else option::some(shares) }) } -/// DUSDC owed for `shares` LP at the frozen flush mark. `None` means the +/// DUSDC owed for `shares` LP at the mark's withdraw side. `None` means the /// mark/request pair is not executable and the queued request must be refunded. fun quote_withdraw_dusdc(mark: &FlushMark, shares: u64): Option { - if (!mark.executable) return option::none(); - // = shares * pool_value / total_supply, round down (withdrawer is paid ≤1 ulp - // less; the pool keeps the dust). - math::try_mul_div_down(shares, mark.pool_value, mark.total_supply).and!(|payout| { + if (!mark.withdraw_executable) return option::none(); + // = shares * withdraw_value / total_supply, round down (withdrawer is paid + // ≤1 ulp less; the pool keeps the dust). + math::try_mul_div_down(shares, mark.withdraw_value, mark.total_supply).and!(|payout| { if (payout == 0) option::none() else option::some(payout) }) } diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 727e6157d..f8701132b 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -54,7 +54,6 @@ const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; const EValuationMarkStale: u64 = 10; -const EAggregateDriftExceeded: u64 = 11; /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -100,8 +99,8 @@ public struct PoolValuation { total_liability: u64, /// Running Σ of each counted market's measured worst-case drift, in DUSDC — /// the dollar amount pool NAV could be off by from marks aging against - /// moving feeds. Judged in aggregate at `finish_flush`, where the PLP-price - /// error it implies has its denominator. + /// moving feeds. Becomes the flush mark's half-spread at `finish_flush`: + /// supplies price at the mid NAV plus it, withdrawals at the mid minus it. total_drift: u64, } @@ -364,19 +363,22 @@ public fun finish_flush( total_free_cash, total_liability, ); - assert_aggregate_drift_acceptable( - total_drift, - config.valuation_config().nav_mark_drift_epsilon(), - pool_nav, - ); let total_supply = vault.lp.total_supply(); let market_count = valued_expiry_markets.length(); - // Snapshot the share price once (frozen pair), then drain both queues against - // it. The flush IS the full-pool valuation, so the single FlushExecuted event - // carries the priced mark and its idle + active-NAV breakdown. + // Price the two-sided mark: the counted marks' combined worst-case drift is + // the half-spread — supplies price the pool at the mid plus it, withdrawals + // at the mid minus it — so the true pool value provably sits between the + // sides and the transacting party bears the marks' staleness. No drift + // threshold: a wide spread self-resolves through each request's own + // min-out limit (miss, carry, refund), and fully fresh marks collapse the + // spread to an exact single mark. let vault_id = vault.id(); - let mark = lp_book::new_flush_mark(pool_nav, total_supply); + let mark = lp_book::new_flush_mark( + pool_nav + total_drift, + pool_nav.saturating_sub(total_drift), + total_supply, + ); let drain_summary = vault .lp .drain( @@ -1027,20 +1029,6 @@ fun materialize_expiry_profit( ); } -/// The pool-level drift acceptance: the flush rejects itself when the counted -/// marks' combined worst-case dollar drift could move the PLP price by more -/// than the configured fraction of pool NAV — the guarantee lives directly in -/// PLP-price units, which no per-market check can express (the per-contract to -/// PLP translation floats with book size over pool equity). On rejection the -/// operator re-refreshes the largest-drift markets and retries. -/// -/// Open edge (deliberate, undecided): at `pool_nav == 0` the budget is zero, -/// so any nonzero measured drift blocks the flush even though a zero mark only -/// refunds queue heads rather than pricing fills. -fun assert_aggregate_drift_acceptable(total_drift: u64, drift_budget: u64, pool_nav: u64) { - assert!(total_drift <= math::mul(drift_budget, pool_nav), EAggregateDriftExceeded); -} - /// Abort unless this valuation belongs to `vault`. fun assert_pool_vault(valuation: &PoolValuation, vault: &PoolVault) { assert!(valuation.pool_vault_id == vault.id(), EWrongPoolVault); From 88c1bf602c09479433dc5e803c709fdcf4ad66c1 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 14:50:50 -0400 Subject: [PATCH 14/23] =?UTF-8?q?predict:=20fix=20review=20Criticals=20?= =?UTF-8?q?=E2=80=94=20anchor-priced=20write-through,=20band=20under-cover?= =?UTF-8?q?age;=20add=20vault=20revision=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the drift machinery (2 Critical understatements, 1 High isolation defect, 1 Medium abort path, comment drift): - CRITICAL anchor consistency: write-through deltas were valued at each op's own oracle, which endpoint drift cannot bound (an oracle round trip between refresh and flush leaves near-zero endpoint drift while a peak-priced mint stays in the sum). Deltas are now valued at the mark's STORED anchors: hooks pass the added/removed orders themselves and valuation_mark prices their ranges at anchor_svi/anchor_forward, so the marked liability is a single-anchor valuation of the current book and quantity x endpoint-envelope provably bounds it. Liquidation paths return the knocked-out Orders; redeems remove the original and re-add any partial-close remainder, both anchor-priced. - CRITICAL band under-coverage: unpinned_band's round-down chain could collapse the quadratic to W <= A and return K = 0 at low variance (review counterexample: drift understated ~13.6x). The band now takes max(quadratic + 10% + pad, analytic slope-free floor D*sqrt(A) + A/2 + 10% + pad) — over-covering is conservative-safe. - HIGH same-PTB isolation: with the valuation lock gone, a vault-cash op (rebalance/sweep/claim) composed between collection and finish could desynchronize collected atoms from live idle (double-count or omit moved cash). The ledger now carries a revision counter bumped on every cash movement; the potato snapshots it at start and finish_flush asserts it unchanged. Trade flows (account<->market cash) remain outside the guard: their collected-atom miss nets to fee dust. - MEDIUM: extreme forward ratios guard-return full face before math::div can overflow-abort (increase direction now fails closed like decrease). - Identical snapshots return exactly 0 drift (no tail pad), so an atomic refresh-and-flush PTB reproduces the old exact-single-mark behavior. - Stale comments: budget/threshold language updated to spread semantics; FlushExecuted doc re-attached to the right struct. Co-Authored-By: Claude Fable 5 --- .../predict/sources/events/vault_events.move | 15 ++-- packages/predict/sources/expiry_market.move | 74 ++++++++++++------- packages/predict/sources/plp/plp.move | 17 ++++- .../predict/sources/plp/pool_accounting.move | 17 +++++ packages/predict/sources/pricing/pricing.move | 39 ++++++++-- .../strike_exposure/strike_exposure.move | 35 +++++---- packages/predict/sources/valuation_mark.move | 58 ++++++++++++--- 7 files changed, 184 insertions(+), 71 deletions(-) diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index 51f75b68f..770f36564 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -152,13 +152,6 @@ public struct WithdrawFilled has copy, drop, store { dusdc_amount: u64, } -/// Emitted once per flush after both queues drain. The flush IS the full-pool -/// valuation, so this single event carries the frozen mark every fill was priced at -/// (`pool_value` over `total_supply`), its raw valuation breakdown -/// (`idle_balance_before` plus `total_free_cash` owing `total_liability` over -/// `market_count` active markets), how many of each kind filled, how many queue -/// heads spent per-flush budget (filled, protocol-refunded, or live limit-missed), -/// and the idle balance after the drain. /// Emitted when a live market's valuation mark is refreshed: the exact /// per-order liability stored for the pool flush to read, at its landing time. public struct NavRefreshed has copy, drop, store { @@ -170,6 +163,14 @@ public struct NavRefreshed has copy, drop, store { computed_at_ms: u64, } +/// Emitted once per flush after both queues drain. The flush IS the full-pool +/// valuation, so this single event carries the mid mark every fill was spread +/// around (`pool_value` over `total_supply`, with `total_drift` as the bid/ask +/// half-spread), its raw valuation breakdown (`idle_balance_before` plus +/// `total_free_cash` owing `total_liability` over `market_count` active +/// markets), how many of each kind filled, how many queue heads spent per-flush +/// budget (filled, protocol-refunded, or live limit-missed), and the idle +/// balance after the drain. public struct FlushExecuted has copy, drop, store { pool_vault_id: ID, epoch: u64, diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index b74b1a97e..84e7fea71 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -408,13 +408,13 @@ public fun mint_exact_quantity( wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let removed_live_value = market + let swept_orders = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_liability_removed(removed_live_value); + market.mark_orders_removed(&swept_orders); market.mint_prepared_exact_quantity( account, @@ -460,13 +460,13 @@ public fun mint_exact_amount( let amount = amount.min(wrapper.load_account().balance(root, clock)); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let removed_live_value = market + let swept_orders = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_liability_removed(removed_live_value); + market.mark_orders_removed(&swept_orders); let quantity = market.max_mint_quantity_for_amount( pricer, @@ -528,11 +528,14 @@ public fun redeem_live( let account = wrapper.load_account_mut(auth); let redeemed_order = order::from_order_id(order_id); - let swept_live_value = market + let swept_orders = market .strike_exposure .liquidate_live_orders(pricer, config.trade_liquidation_budget()); + market.mark_orders_removed(&swept_orders); let knocked_out = market.strike_exposure.liquidate_live_order(pricer, &redeemed_order); - market.mark_liability_removed(swept_live_value + knocked_out.get_with_default(0)); + if (knocked_out.is_some()) { + market.mark_order_removed(&knocked_out.destroy_some()); + }; if (market.strike_exposure.is_liquidated_order(&redeemed_order)) { market.redeem_liquidated_order(account, &redeemed_order, close_quantity, ctx); return (redeemed_order.id(), option::none()) @@ -630,8 +633,8 @@ public fun liquidate( budget: u64, ) { market.assert_live_flow_allowed(config, pricer); - let removed_live_value = market.strike_exposure.liquidate_live_orders(pricer, budget); - market.mark_liability_removed(removed_live_value); + let swept_orders = market.strike_exposure.liquidate_live_orders(pricer, budget); + market.mark_orders_removed(&swept_orders); } /// Try to liquidate one active leveraged order by ID; a knock-out emits an @@ -646,7 +649,9 @@ public fun liquidate_order( let order = order::from_order_id(order_id); let removed = market.strike_exposure.liquidate_live_order(pricer, &order); - market.mark_liability_removed(removed.get_with_default(0)); + if (removed.is_some()) { + market.mark_order_removed(&removed.destroy_some()); + }; } /// Set this expiry's reference fine-grid tick from the exact previous-window @@ -760,9 +765,10 @@ public(package) fun mark_computed_at_ms(market: &ExpiryMarket): u64 { /// since the walk (`valuation_mark::drift`, a fraction of full payout) times /// the book's live face quantity — per-order value is quantity-Lipschitz in /// price, so this bounds how far this market's NAV can have drifted. The -/// quantity is read live, so orders minted after the walk (whose write-through -/// deltas also sit on drifting prices) are covered. A measurement, not a -/// judgment — `plp` aggregates across markets and enforces the budget. +/// quantity is read live and the write-through deltas are valued at the mark's +/// own anchors, so the whole current book shares one valuation time and this +/// endpoint bound covers it. A measurement, not a judgment — `plp` sums across +/// markets and prices the total into the flush mark's bid/ask spread. public(package) fun mark_drift(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); assert!(market.valuation_mark.is_some(), EValuationMarkMissing); @@ -998,25 +1004,38 @@ fun assert_pricer_bound(market: &ExpiryMarket, pricer: &Pricer) { assert!(pricer.expiry_market_id() == market.id(), EWrongPricer); } -/// Write a mint's bit-exact liability delta through to the stored valuation mark. -/// The marginal live liability of a freshly admitted order at its own pricer is -/// exactly `net_premium` (`entry_value - floor`); no-op until the first refresh -/// establishes a mark. -fun mark_liability_added(market: &mut ExpiryMarket, amount: u64) { +/// Write an added order through to the stored valuation mark, valued at the +/// mark's own anchors — never at the op's oracle, so the marked liability stays +/// a single-anchor valuation of the current book and the endpoint drift +/// envelope bounds it. No-op until the first refresh establishes a mark. +fun mark_order_added(market: &mut ExpiryMarket, order: &Order) { if (market.valuation_mark.is_some()) { - market.valuation_mark.borrow_mut().add_liability(amount); + let (lower, higher) = market.strike_exposure.order_boundaries(order); + market + .valuation_mark + .borrow_mut() + .add_order(lower, higher, order.quantity(), order.floor_shares()); }; } -/// Write a liability decrease through to the stored valuation mark: a live -/// redeem's `redeem_amount`, or the live value a liquidation pass removed. -/// No-op until the first refresh establishes a mark. -fun mark_liability_removed(market: &mut ExpiryMarket, amount: u64) { +/// Write a removed order (live redeem or liquidation) through to the stored +/// valuation mark, valued at the mark's own anchors. No-op until the first +/// refresh establishes a mark. +fun mark_order_removed(market: &mut ExpiryMarket, order: &Order) { if (market.valuation_mark.is_some()) { - market.valuation_mark.borrow_mut().remove_liability(amount); + let (lower, higher) = market.strike_exposure.order_boundaries(order); + market + .valuation_mark + .borrow_mut() + .remove_order(lower, higher, order.quantity(), order.floor_shares()); }; } +/// Write a liquidation sweep's knocked-out orders through to the stored mark. +fun mark_orders_removed(market: &mut ExpiryMarket, orders: &vector) { + orders.do_ref!(|order| market.mark_order_removed(order)); +} + /// Return the congestion surcharge (in DUSDC) for `quantity` from the pre-trade /// EWMA stats, then fold the current gas price into the estimate. Penalty before /// fold on every trade path (mint and live redeem): the trade's gas is judged @@ -1106,7 +1125,7 @@ fun mint_prepared_exact_quantity( let leverage = terms.leverage(); let minted_order = market.strike_exposure.allocate_mint_order(terms); market.settle_mint_payment(account, &minted_order, "e, builder_code_id, clock, ctx); - market.mark_liability_added(quote.net_premium); + market.mark_order_added(&minted_order); order_events::emit_order_minted( market.id(), account.account_id(), @@ -1229,7 +1248,12 @@ fun redeem_live_internal( redeem_amount - fee_amount - builder_fee_amount - penalty_amount >= min_proceeds, ERedeemProceedsBelowMin, ); - market.mark_liability_removed(redeem_amount); + // Anchor-priced write-through: remove the original order and re-add any + // partial-close remainder, both valued at the mark's stored anchors. + market.mark_order_removed(order); + if (resulting_order.id() != order.id()) { + market.mark_order_added(&resulting_order); + }; order_events::emit_live_order_redeemed( market.id(), diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index f8701132b..efe932d9f 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -54,6 +54,7 @@ const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; const EValuationMarkStale: u64 = 10; +const EVaultMutatedDuringValuation: u64 = 11; /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -102,6 +103,11 @@ public struct PoolValuation { /// moving feeds. Becomes the flush mark's half-spread at `finish_flush`: /// supplies price at the mid NAV plus it, withdrawals at the mid minus it. total_drift: u64, + /// Vault cash-accounting revision at start. `finish_flush` asserts it + /// unchanged, so no vault-cash op (rebalance, sweep, claim) can be composed + /// into the flush PTB between collection and pricing to desynchronize the + /// collected atoms from live idle. + vault_revision: u64, } // === Package Initializer === @@ -281,6 +287,7 @@ public fun start_pool_valuation( total_free_cash: 0, total_liability: 0, total_drift: 0, + vault_revision: vault.expiry_accounting.revision(), } } @@ -291,8 +298,9 @@ public fun start_pool_valuation( /// moves, no settlement, no tree walk. Three facts are collected — live free /// cash, the stored marked liability, and the mark's measured dollar drift — /// and only one gate applies here: the mark must be younger than the freshness -/// ceiling (the sole guard a stalled feed cannot fool). Drift is judged in -/// aggregate at `finish_flush`, not per market. +/// ceiling (the sole guard a stalled feed cannot fool). Drift is never +/// rejected — `finish_flush` prices the aggregate as the flush mark's +/// bid/ask half-spread, borne by the transacting party. /// /// A settled or past-expiry market cannot produce the pricer this read requires /// (`load_live_pricer` rejects past-expiry): sweep it via `rebalance_expiry_cash` @@ -353,8 +361,13 @@ public fun finish_flush( total_liability, total_drift, valued_expiry_markets, + vault_revision, .., } = valuation; + // No vault-cash op may interleave between start and here — otherwise the + // collected market atoms and the live idle read below describe different + // states and the mark double- or under-counts moved cash. + assert!(vault.expiry_accounting.revision() == vault_revision, EVaultMutatedDuringValuation); let idle_balance_before = vault.expiry_accounting.idle_balance(); let pool_nav = lp_pool_value( diff --git a/packages/predict/sources/plp/pool_accounting.move b/packages/predict/sources/plp/pool_accounting.move index f61b64308..4d90c23fd 100644 --- a/packages/predict/sources/plp/pool_accounting.move +++ b/packages/predict/sources/plp/pool_accounting.move @@ -38,6 +38,13 @@ public struct Ledger has store { /// physically moved to the reserve because idle was deployed in other active /// markets at materialization. Excluded from LP value until drained. pending_protocol_profit: u64, + /// Bumped on every cash movement through this ledger. The flush snapshots + /// it at start and asserts it unchanged at finish, so no vault-cash + /// operation can be composed between collection and pricing inside the + /// flush PTB to desynchronize the collected atoms from live idle (the one + /// duty the removed pool-wide valuation lock covered that stored marks do + /// not). + revision: u64, } /// Active valuation entry with the expiry timestamp needed to classify live NAV cost. @@ -73,9 +80,14 @@ public(package) fun new(ctx: &mut TxContext): Ledger { profit_basis_credits: 0, net_losses_to_fill: 0, pending_protocol_profit: 0, + revision: 0, } } +public(package) fun revision(ledger: &Ledger): u64 { + ledger.revision +} + public(package) fun idle_balance(ledger: &Ledger): u64 { ledger.idle_balance.value() } @@ -166,11 +178,13 @@ public(package) fun deactivate_expiry_if_present(ledger: &mut Ledger, expiry_mar /// Join idle DUSDC; the LP-supply flush drains filled supply requests here. public(package) fun receive_idle(ledger: &mut Ledger, cash: Balance) { + ledger.revision = ledger.revision + 1; ledger.idle_balance.join(cash); } /// Split idle DUSDC. public(package) fun withdraw_idle(ledger: &mut Ledger, amount: u64): Balance { + ledger.revision = ledger.revision + 1; ledger.idle_balance.split(amount) } @@ -182,6 +196,7 @@ public(package) fun send_expiry_cash( amount: u64, ): Balance { if (amount == 0) return balance::zero(); + ledger.revision = ledger.revision + 1; ledger.record_sent_to_expiry(expiry_market_id, amount); ledger.idle_balance.split(amount) } @@ -214,6 +229,7 @@ public(package) fun receive_expiry_cash( cash.destroy_zero(); return 0 }; + ledger.revision = ledger.revision + 1; ledger.idle_balance.join(cash); ledger.record_received_from_expiry(expiry_market_id, amount); amount @@ -258,6 +274,7 @@ public(package) fun materialize_expiry_profit(ledger: &mut Ledger, expiry_market /// temporarily deployed in other active markets; the uncovered remainder stays in /// `pending_protocol_profit` and is realized on a later sweep that refills idle. public(package) fun realize_pending_protocol_profit(ledger: &mut Ledger): Balance { + ledger.revision = ledger.revision + 1; let draw = ledger.pending_protocol_profit.min(ledger.idle_balance.value()); ledger.pending_protocol_profit = ledger.pending_protocol_profit - draw; ledger.idle_balance.split(draw) diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index a01aa76e2..2f28b7d44 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -124,6 +124,14 @@ public(package) fun svi_params(pricer: &Pricer): SVIParams { pricer.svi } +/// Price a live range at EXPLICIT oracle inputs rather than a loaded pricer. +/// Used by the valuation mark to value trade write-through at its stored +/// anchors, so every component of the marked liability shares one valuation +/// time and the endpoint drift envelope bounds the whole book. +public(package) fun range_price_at(svi: &SVIParams, forward: u64, lower: u64, higher: u64): u64 { + compute_range_price(svi, forward, lower, higher) +} + /// Worst-case repricing between two oracle snapshots: an upper bound on how far /// ANY contract's fair price can have moved between the anchor oracle state and /// the live one, as a fraction of full payout in FLOAT_SCALING (capped at 1.0). @@ -160,6 +168,10 @@ public(package) fun drift_envelope( ): u64 { let full = math::float_scaling!(); let scale = full as u128; + // Identical snapshots price identically — exactly zero drift, no tail pad + // (the pad covers tails between DIFFERENT snapshots). This also lets an + // atomic refresh-and-flush PTB reproduce the exact single-mark behavior. + if (anchor_forward == pricer.forward && *anchor_svi == pricer.svi) return 0; let s0 = sqrt_min_total_variance(anchor_svi); let s1 = sqrt_min_total_variance(&pricer.svi); let s_lo = s0.min(s1); @@ -170,7 +182,11 @@ public(package) fun drift_envelope( if (sigma_lo == 0) return full; // Forward leg: a forward move shifts every strike's log-moneyness by - // |ln(F1/F0)|, and d2 divides that by at least the variance floor. + // |ln(F1/F0)|, and d2 divides that by at least the variance floor. Guard the + // ratio in plain integer division first: a ratio this size is full-face + // drift regardless, and `math::div` would overflow-abort near u64::MAX + // rather than fail closed. + if (pricer.forward / anchor_forward >= 100) return full; let forward_ratio = math::div(pricer.forward, anchor_forward); if (forward_ratio == 0) return full; let delta_forward = math::ln(forward_ratio).magnitude(); @@ -364,12 +380,20 @@ fun unpinned_band(svi: &SVIParams, d: u64): Option { // surface is close (observed b <= ~0.03), so fail closed rather than solve. if (b_slope > 999_000_000) return option::none(); + // Analytic floor on the true band: with any slope, w(k) >= the slope-free + // case, so K >= D * sqrt(A) + A / 2. Every step below rounds DOWN, and at + // low variance the rounding can swallow the whole band (review + // counterexample: the quadratic collapsed to W <= A, returning K = 0 and + // understating drift ~13.6x) — so the floor plus generous headroom is + // load-bearing for soundness, not a nicety. Over-covering only makes the + // envelope more conservative. + let k_floor = math::mul(d, math::sqrt(a_overshoot, one)) + a_overshoot / 2; + let k_floor = k_floor + k_floor / 10 + 1_000_000; + if (b_slope < 1_000) { - // Slope negligible: W ~ A. The 10% headroom covers the ignored slope. - let k = math::mul(d, math::sqrt(a_overshoot, one)) + a_overshoot / 2; - let k = k + k / 10; - if (k > cap) return option::none(); - return option::some(k) + // Slope negligible: W ~ A; the floor's headroom covers the ignored slope. + if (k_floor > cap) return option::none(); + return option::some(k_floor) }; let b_coeff = 2 * b_slope; @@ -380,7 +404,8 @@ fun unpinned_band(svi: &SVIParams, d: u64): Option { let w_minus_a = w.saturating_sub(a_overshoot); // K = (W - A) / B; refuse before dividing when K would exceed the cap. if (w_minus_a > math::mul(b_coeff, cap)) return option::none(); - let k = math::div(w_minus_a, b_coeff); + let k_quadratic = math::div(w_minus_a, b_coeff); + let k = (k_quadratic + k_quadratic / 10 + 1_000_000).max(k_floor); if (k > cap) return option::none(); option::some(k) } diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 3ef24d47d..70fdf65c3 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -442,35 +442,34 @@ public(package) fun liquidate_live_order( exposure: &mut StrikeExposure, pricer: &Pricer, order: &Order, -): Option { +): Option { if (!exposure.liquidation.contains_active_order(order)) return option::none(); let liquidation_ltv = exposure.config.liquidation_ltv(); exposure.liquidate_order_if_under_floor(pricer, order, liquidation_ltv) } /// Run one bounded liquidation pass using exact per-candidate pricing. Returns -/// the total live value the knocked-out orders carried at removal (each order's -/// floor-capped `gross - floor`), so the caller can write the liability removal -/// through to the market's stored valuation mark. Per-order details are on the -/// `OrderLiquidated` events. +/// the knocked-out orders so the caller can write each removal through to the +/// market's stored valuation mark at the mark's own anchors. Per-order details +/// are on the `OrderLiquidated` events. public(package) fun liquidate_live_orders( exposure: &mut StrikeExposure, pricer: &Pricer, budget: u64, -): u64 { +): vector { let candidates = exposure.liquidation.select_liquidation_candidates(budget); - if (candidates.is_empty()) return 0; + let mut removed_orders = vector[]; + if (candidates.is_empty()) return removed_orders; let liquidation_ltv = exposure.config.liquidation_ltv(); - let mut removed_live_value = 0; candidates.do!(|candidate| { let order = order::from_order_id(candidate); let removed = exposure.liquidate_order_if_under_floor(pricer, &order, liquidation_ltv); if (removed.is_some()) { - removed_live_value = removed_live_value + removed.destroy_some(); + removed_orders.push_back(removed.destroy_some()); }; }); - removed_live_value + removed_orders } /// Cache terminal settled payout liability. @@ -511,7 +510,7 @@ fun liquidate_order_if_under_floor( pricer: &Pricer, order: &Order, liquidation_ltv: u64, -): Option { +): Option { let quantity = order.quantity(); let floor_amount = order.floor_shares(); let gross_value = exposure.gross_order_value(pricer, order); @@ -538,16 +537,16 @@ fun liquidate_order_if_under_floor( liquidation_ltv, ); - // The knocked-out order's live value under the floor cap: an order between - // its floor and floor/ltv still carries `gross - floor`; one at or below the - // floor carries zero. Returned so the caller can write the removal through - // to the market's stored valuation mark. - option::some(gross_value.saturating_sub(floor_amount)) + // Return the removed order itself: the caller values it at the stored + // valuation-mark anchors (not at this op's pricer) so the mark stays + // single-anchor. + option::some(*order) } /// Decode an order into `(lower, higher)` raw strike boundaries for pricing and -/// settlement comparison, mapping the open-ended sentinels. -fun order_boundaries(exposure: &StrikeExposure, order: &Order): (u64, u64) { +/// settlement comparison, mapping the open-ended sentinels. Package-visible so +/// the market can value orders at the stored valuation-mark anchors. +public(package) fun order_boundaries(exposure: &StrikeExposure, order: &Order): (u64, u64) { range_codec::strikes_from_ticks(order.lower_tick(), order.higher_tick(), exposure.tick_size) } diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index 9d4a65201..9c3cd3a0b 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -17,6 +17,7 @@ module deepbook_predict::valuation_mark; use deepbook_predict::pricing::{Self, Pricer}; +use fixed_math::math; use propbook::block_scholes_svi_feed::SVIParams; use sui::clock::Clock; @@ -30,12 +31,16 @@ public struct ValuationMark has copy, drop, store { /// Exact oracle-priced per-order liability the refresh walk computed at /// `computed_at_ms`. Never mutated between refreshes. computed_liability: u64, - /// Σ liability added by trades since the walk (mint `net_premium`s), each - /// priced at its own op's oracle. + /// Σ liability added by trades since the walk: each added order's value + /// priced at THIS MARK'S STORED ANCHORS, never at the op's own oracle — + /// so every component of `liability` shares one valuation time and the + /// anchor-to-live drift envelope bounds the whole current book. (Op-priced + /// deltas would be path-dependent: an oracle round trip between refresh + /// and flush leaves endpoint drift near zero while an op priced at the + /// peak stays in the sum.) added_since_compute: u64, - /// Σ liability removed by trades since the walk (live-redeem - /// `redeem_amount`s and liquidated orders' live values), each priced at its - /// own op's oracle. + /// Σ liability removed by trades since the walk (live redeems and + /// liquidated orders), each priced at this mark's stored anchors. removed_since_compute: u64, /// On-chain landing time of the refresh that computed this mark. computed_at_ms: u64, @@ -86,13 +91,42 @@ public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): Valuati } } -/// Write a trade's liability increase through to the mark's delta accumulator. -public(package) fun add_liability(mark: &mut ValuationMark, amount: u64) { - mark.added_since_compute = mark.added_since_compute + amount; +/// Write an added order through to the mark, valued at the stored anchors. +public(package) fun add_order( + mark: &mut ValuationMark, + lower: u64, + higher: u64, + quantity: u64, + floor_amount: u64, +) { + let value = mark.order_value_at_anchor(lower, higher, quantity, floor_amount); + mark.added_since_compute = mark.added_since_compute + value; } -/// Write a trade's liability decrease through to the mark's delta accumulator. -/// Never clamps here — the mixed-anchor netting happens once, in `liability`. -public(package) fun remove_liability(mark: &mut ValuationMark, amount: u64) { - mark.removed_since_compute = mark.removed_since_compute + amount; +/// Write a removed order (live redeem or liquidation) through to the mark, +/// valued at the stored anchors. Never clamps here — the residual netting +/// happens once, in `liability`. +public(package) fun remove_order( + mark: &mut ValuationMark, + lower: u64, + higher: u64, + quantity: u64, + floor_amount: u64, +) { + let value = mark.order_value_at_anchor(lower, higher, quantity, floor_amount); + mark.removed_since_compute = mark.removed_since_compute + value; +} + +/// One order's live value under this mark's anchors: the anchor-priced range +/// value net of the static floor, floored at zero (the same per-order shape the +/// walk aggregates). +fun order_value_at_anchor( + mark: &ValuationMark, + lower: u64, + higher: u64, + quantity: u64, + floor_amount: u64, +): u64 { + let price = pricing::range_price_at(&mark.anchor_svi, mark.anchor_forward, lower, higher); + math::mul(quantity, price).saturating_sub(floor_amount) } From 963d0cf5baecb63a3542a55bba184f5fd8294c5f Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 15:08:11 -0400 Subject: [PATCH 15/23] =?UTF-8?q?Revert=20"predict:=20fix=20review=20Criti?= =?UTF-8?q?cals=20=E2=80=94=20anchor-priced=20write-through,=20band=20unde?= =?UTF-8?q?r-coverage;=20add=20vault=20revision=20guard"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 88c1bf602c09479433dc5e803c709fdcf4ad66c1. --- .../predict/sources/events/vault_events.move | 15 ++-- packages/predict/sources/expiry_market.move | 74 +++++++------------ packages/predict/sources/plp/plp.move | 17 +---- .../predict/sources/plp/pool_accounting.move | 17 ----- packages/predict/sources/pricing/pricing.move | 39 ++-------- .../strike_exposure/strike_exposure.move | 35 ++++----- packages/predict/sources/valuation_mark.move | 58 +++------------ 7 files changed, 71 insertions(+), 184 deletions(-) diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index 770f36564..51f75b68f 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -152,6 +152,13 @@ public struct WithdrawFilled has copy, drop, store { dusdc_amount: u64, } +/// Emitted once per flush after both queues drain. The flush IS the full-pool +/// valuation, so this single event carries the frozen mark every fill was priced at +/// (`pool_value` over `total_supply`), its raw valuation breakdown +/// (`idle_balance_before` plus `total_free_cash` owing `total_liability` over +/// `market_count` active markets), how many of each kind filled, how many queue +/// heads spent per-flush budget (filled, protocol-refunded, or live limit-missed), +/// and the idle balance after the drain. /// Emitted when a live market's valuation mark is refreshed: the exact /// per-order liability stored for the pool flush to read, at its landing time. public struct NavRefreshed has copy, drop, store { @@ -163,14 +170,6 @@ public struct NavRefreshed has copy, drop, store { computed_at_ms: u64, } -/// Emitted once per flush after both queues drain. The flush IS the full-pool -/// valuation, so this single event carries the mid mark every fill was spread -/// around (`pool_value` over `total_supply`, with `total_drift` as the bid/ask -/// half-spread), its raw valuation breakdown (`idle_balance_before` plus -/// `total_free_cash` owing `total_liability` over `market_count` active -/// markets), how many of each kind filled, how many queue heads spent per-flush -/// budget (filled, protocol-refunded, or live limit-missed), and the idle -/// balance after the drain. public struct FlushExecuted has copy, drop, store { pool_vault_id: ID, epoch: u64, diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index 84e7fea71..b74b1a97e 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -408,13 +408,13 @@ public fun mint_exact_quantity( wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let swept_orders = market + let removed_live_value = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_orders_removed(&swept_orders); + market.mark_liability_removed(removed_live_value); market.mint_prepared_exact_quantity( account, @@ -460,13 +460,13 @@ public fun mint_exact_amount( let amount = amount.min(wrapper.load_account().balance(root, clock)); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let swept_orders = market + let removed_live_value = market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_orders_removed(&swept_orders); + market.mark_liability_removed(removed_live_value); let quantity = market.max_mint_quantity_for_amount( pricer, @@ -528,14 +528,11 @@ public fun redeem_live( let account = wrapper.load_account_mut(auth); let redeemed_order = order::from_order_id(order_id); - let swept_orders = market + let swept_live_value = market .strike_exposure .liquidate_live_orders(pricer, config.trade_liquidation_budget()); - market.mark_orders_removed(&swept_orders); let knocked_out = market.strike_exposure.liquidate_live_order(pricer, &redeemed_order); - if (knocked_out.is_some()) { - market.mark_order_removed(&knocked_out.destroy_some()); - }; + market.mark_liability_removed(swept_live_value + knocked_out.get_with_default(0)); if (market.strike_exposure.is_liquidated_order(&redeemed_order)) { market.redeem_liquidated_order(account, &redeemed_order, close_quantity, ctx); return (redeemed_order.id(), option::none()) @@ -633,8 +630,8 @@ public fun liquidate( budget: u64, ) { market.assert_live_flow_allowed(config, pricer); - let swept_orders = market.strike_exposure.liquidate_live_orders(pricer, budget); - market.mark_orders_removed(&swept_orders); + let removed_live_value = market.strike_exposure.liquidate_live_orders(pricer, budget); + market.mark_liability_removed(removed_live_value); } /// Try to liquidate one active leveraged order by ID; a knock-out emits an @@ -649,9 +646,7 @@ public fun liquidate_order( let order = order::from_order_id(order_id); let removed = market.strike_exposure.liquidate_live_order(pricer, &order); - if (removed.is_some()) { - market.mark_order_removed(&removed.destroy_some()); - }; + market.mark_liability_removed(removed.get_with_default(0)); } /// Set this expiry's reference fine-grid tick from the exact previous-window @@ -765,10 +760,9 @@ public(package) fun mark_computed_at_ms(market: &ExpiryMarket): u64 { /// since the walk (`valuation_mark::drift`, a fraction of full payout) times /// the book's live face quantity — per-order value is quantity-Lipschitz in /// price, so this bounds how far this market's NAV can have drifted. The -/// quantity is read live and the write-through deltas are valued at the mark's -/// own anchors, so the whole current book shares one valuation time and this -/// endpoint bound covers it. A measurement, not a judgment — `plp` sums across -/// markets and prices the total into the flush mark's bid/ask spread. +/// quantity is read live, so orders minted after the walk (whose write-through +/// deltas also sit on drifting prices) are covered. A measurement, not a +/// judgment — `plp` aggregates across markets and enforces the budget. public(package) fun mark_drift(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); assert!(market.valuation_mark.is_some(), EValuationMarkMissing); @@ -1004,38 +998,25 @@ fun assert_pricer_bound(market: &ExpiryMarket, pricer: &Pricer) { assert!(pricer.expiry_market_id() == market.id(), EWrongPricer); } -/// Write an added order through to the stored valuation mark, valued at the -/// mark's own anchors — never at the op's oracle, so the marked liability stays -/// a single-anchor valuation of the current book and the endpoint drift -/// envelope bounds it. No-op until the first refresh establishes a mark. -fun mark_order_added(market: &mut ExpiryMarket, order: &Order) { +/// Write a mint's bit-exact liability delta through to the stored valuation mark. +/// The marginal live liability of a freshly admitted order at its own pricer is +/// exactly `net_premium` (`entry_value - floor`); no-op until the first refresh +/// establishes a mark. +fun mark_liability_added(market: &mut ExpiryMarket, amount: u64) { if (market.valuation_mark.is_some()) { - let (lower, higher) = market.strike_exposure.order_boundaries(order); - market - .valuation_mark - .borrow_mut() - .add_order(lower, higher, order.quantity(), order.floor_shares()); + market.valuation_mark.borrow_mut().add_liability(amount); }; } -/// Write a removed order (live redeem or liquidation) through to the stored -/// valuation mark, valued at the mark's own anchors. No-op until the first -/// refresh establishes a mark. -fun mark_order_removed(market: &mut ExpiryMarket, order: &Order) { +/// Write a liability decrease through to the stored valuation mark: a live +/// redeem's `redeem_amount`, or the live value a liquidation pass removed. +/// No-op until the first refresh establishes a mark. +fun mark_liability_removed(market: &mut ExpiryMarket, amount: u64) { if (market.valuation_mark.is_some()) { - let (lower, higher) = market.strike_exposure.order_boundaries(order); - market - .valuation_mark - .borrow_mut() - .remove_order(lower, higher, order.quantity(), order.floor_shares()); + market.valuation_mark.borrow_mut().remove_liability(amount); }; } -/// Write a liquidation sweep's knocked-out orders through to the stored mark. -fun mark_orders_removed(market: &mut ExpiryMarket, orders: &vector) { - orders.do_ref!(|order| market.mark_order_removed(order)); -} - /// Return the congestion surcharge (in DUSDC) for `quantity` from the pre-trade /// EWMA stats, then fold the current gas price into the estimate. Penalty before /// fold on every trade path (mint and live redeem): the trade's gas is judged @@ -1125,7 +1106,7 @@ fun mint_prepared_exact_quantity( let leverage = terms.leverage(); let minted_order = market.strike_exposure.allocate_mint_order(terms); market.settle_mint_payment(account, &minted_order, "e, builder_code_id, clock, ctx); - market.mark_order_added(&minted_order); + market.mark_liability_added(quote.net_premium); order_events::emit_order_minted( market.id(), account.account_id(), @@ -1248,12 +1229,7 @@ fun redeem_live_internal( redeem_amount - fee_amount - builder_fee_amount - penalty_amount >= min_proceeds, ERedeemProceedsBelowMin, ); - // Anchor-priced write-through: remove the original order and re-add any - // partial-close remainder, both valued at the mark's stored anchors. - market.mark_order_removed(order); - if (resulting_order.id() != order.id()) { - market.mark_order_added(&resulting_order); - }; + market.mark_liability_removed(redeem_amount); order_events::emit_live_order_redeemed( market.id(), diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index efe932d9f..f8701132b 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -54,7 +54,6 @@ const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; const EValuationMarkStale: u64 = 10; -const EVaultMutatedDuringValuation: u64 = 11; /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -103,11 +102,6 @@ public struct PoolValuation { /// moving feeds. Becomes the flush mark's half-spread at `finish_flush`: /// supplies price at the mid NAV plus it, withdrawals at the mid minus it. total_drift: u64, - /// Vault cash-accounting revision at start. `finish_flush` asserts it - /// unchanged, so no vault-cash op (rebalance, sweep, claim) can be composed - /// into the flush PTB between collection and pricing to desynchronize the - /// collected atoms from live idle. - vault_revision: u64, } // === Package Initializer === @@ -287,7 +281,6 @@ public fun start_pool_valuation( total_free_cash: 0, total_liability: 0, total_drift: 0, - vault_revision: vault.expiry_accounting.revision(), } } @@ -298,9 +291,8 @@ public fun start_pool_valuation( /// moves, no settlement, no tree walk. Three facts are collected — live free /// cash, the stored marked liability, and the mark's measured dollar drift — /// and only one gate applies here: the mark must be younger than the freshness -/// ceiling (the sole guard a stalled feed cannot fool). Drift is never -/// rejected — `finish_flush` prices the aggregate as the flush mark's -/// bid/ask half-spread, borne by the transacting party. +/// ceiling (the sole guard a stalled feed cannot fool). Drift is judged in +/// aggregate at `finish_flush`, not per market. /// /// A settled or past-expiry market cannot produce the pricer this read requires /// (`load_live_pricer` rejects past-expiry): sweep it via `rebalance_expiry_cash` @@ -361,13 +353,8 @@ public fun finish_flush( total_liability, total_drift, valued_expiry_markets, - vault_revision, .., } = valuation; - // No vault-cash op may interleave between start and here — otherwise the - // collected market atoms and the live idle read below describe different - // states and the mark double- or under-counts moved cash. - assert!(vault.expiry_accounting.revision() == vault_revision, EVaultMutatedDuringValuation); let idle_balance_before = vault.expiry_accounting.idle_balance(); let pool_nav = lp_pool_value( diff --git a/packages/predict/sources/plp/pool_accounting.move b/packages/predict/sources/plp/pool_accounting.move index 4d90c23fd..f61b64308 100644 --- a/packages/predict/sources/plp/pool_accounting.move +++ b/packages/predict/sources/plp/pool_accounting.move @@ -38,13 +38,6 @@ public struct Ledger has store { /// physically moved to the reserve because idle was deployed in other active /// markets at materialization. Excluded from LP value until drained. pending_protocol_profit: u64, - /// Bumped on every cash movement through this ledger. The flush snapshots - /// it at start and asserts it unchanged at finish, so no vault-cash - /// operation can be composed between collection and pricing inside the - /// flush PTB to desynchronize the collected atoms from live idle (the one - /// duty the removed pool-wide valuation lock covered that stored marks do - /// not). - revision: u64, } /// Active valuation entry with the expiry timestamp needed to classify live NAV cost. @@ -80,14 +73,9 @@ public(package) fun new(ctx: &mut TxContext): Ledger { profit_basis_credits: 0, net_losses_to_fill: 0, pending_protocol_profit: 0, - revision: 0, } } -public(package) fun revision(ledger: &Ledger): u64 { - ledger.revision -} - public(package) fun idle_balance(ledger: &Ledger): u64 { ledger.idle_balance.value() } @@ -178,13 +166,11 @@ public(package) fun deactivate_expiry_if_present(ledger: &mut Ledger, expiry_mar /// Join idle DUSDC; the LP-supply flush drains filled supply requests here. public(package) fun receive_idle(ledger: &mut Ledger, cash: Balance) { - ledger.revision = ledger.revision + 1; ledger.idle_balance.join(cash); } /// Split idle DUSDC. public(package) fun withdraw_idle(ledger: &mut Ledger, amount: u64): Balance { - ledger.revision = ledger.revision + 1; ledger.idle_balance.split(amount) } @@ -196,7 +182,6 @@ public(package) fun send_expiry_cash( amount: u64, ): Balance { if (amount == 0) return balance::zero(); - ledger.revision = ledger.revision + 1; ledger.record_sent_to_expiry(expiry_market_id, amount); ledger.idle_balance.split(amount) } @@ -229,7 +214,6 @@ public(package) fun receive_expiry_cash( cash.destroy_zero(); return 0 }; - ledger.revision = ledger.revision + 1; ledger.idle_balance.join(cash); ledger.record_received_from_expiry(expiry_market_id, amount); amount @@ -274,7 +258,6 @@ public(package) fun materialize_expiry_profit(ledger: &mut Ledger, expiry_market /// temporarily deployed in other active markets; the uncovered remainder stays in /// `pending_protocol_profit` and is realized on a later sweep that refills idle. public(package) fun realize_pending_protocol_profit(ledger: &mut Ledger): Balance { - ledger.revision = ledger.revision + 1; let draw = ledger.pending_protocol_profit.min(ledger.idle_balance.value()); ledger.pending_protocol_profit = ledger.pending_protocol_profit - draw; ledger.idle_balance.split(draw) diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 2f28b7d44..a01aa76e2 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -124,14 +124,6 @@ public(package) fun svi_params(pricer: &Pricer): SVIParams { pricer.svi } -/// Price a live range at EXPLICIT oracle inputs rather than a loaded pricer. -/// Used by the valuation mark to value trade write-through at its stored -/// anchors, so every component of the marked liability shares one valuation -/// time and the endpoint drift envelope bounds the whole book. -public(package) fun range_price_at(svi: &SVIParams, forward: u64, lower: u64, higher: u64): u64 { - compute_range_price(svi, forward, lower, higher) -} - /// Worst-case repricing between two oracle snapshots: an upper bound on how far /// ANY contract's fair price can have moved between the anchor oracle state and /// the live one, as a fraction of full payout in FLOAT_SCALING (capped at 1.0). @@ -168,10 +160,6 @@ public(package) fun drift_envelope( ): u64 { let full = math::float_scaling!(); let scale = full as u128; - // Identical snapshots price identically — exactly zero drift, no tail pad - // (the pad covers tails between DIFFERENT snapshots). This also lets an - // atomic refresh-and-flush PTB reproduce the exact single-mark behavior. - if (anchor_forward == pricer.forward && *anchor_svi == pricer.svi) return 0; let s0 = sqrt_min_total_variance(anchor_svi); let s1 = sqrt_min_total_variance(&pricer.svi); let s_lo = s0.min(s1); @@ -182,11 +170,7 @@ public(package) fun drift_envelope( if (sigma_lo == 0) return full; // Forward leg: a forward move shifts every strike's log-moneyness by - // |ln(F1/F0)|, and d2 divides that by at least the variance floor. Guard the - // ratio in plain integer division first: a ratio this size is full-face - // drift regardless, and `math::div` would overflow-abort near u64::MAX - // rather than fail closed. - if (pricer.forward / anchor_forward >= 100) return full; + // |ln(F1/F0)|, and d2 divides that by at least the variance floor. let forward_ratio = math::div(pricer.forward, anchor_forward); if (forward_ratio == 0) return full; let delta_forward = math::ln(forward_ratio).magnitude(); @@ -380,20 +364,12 @@ fun unpinned_band(svi: &SVIParams, d: u64): Option { // surface is close (observed b <= ~0.03), so fail closed rather than solve. if (b_slope > 999_000_000) return option::none(); - // Analytic floor on the true band: with any slope, w(k) >= the slope-free - // case, so K >= D * sqrt(A) + A / 2. Every step below rounds DOWN, and at - // low variance the rounding can swallow the whole band (review - // counterexample: the quadratic collapsed to W <= A, returning K = 0 and - // understating drift ~13.6x) — so the floor plus generous headroom is - // load-bearing for soundness, not a nicety. Over-covering only makes the - // envelope more conservative. - let k_floor = math::mul(d, math::sqrt(a_overshoot, one)) + a_overshoot / 2; - let k_floor = k_floor + k_floor / 10 + 1_000_000; - if (b_slope < 1_000) { - // Slope negligible: W ~ A; the floor's headroom covers the ignored slope. - if (k_floor > cap) return option::none(); - return option::some(k_floor) + // Slope negligible: W ~ A. The 10% headroom covers the ignored slope. + let k = math::mul(d, math::sqrt(a_overshoot, one)) + a_overshoot / 2; + let k = k + k / 10; + if (k > cap) return option::none(); + return option::some(k) }; let b_coeff = 2 * b_slope; @@ -404,8 +380,7 @@ fun unpinned_band(svi: &SVIParams, d: u64): Option { let w_minus_a = w.saturating_sub(a_overshoot); // K = (W - A) / B; refuse before dividing when K would exceed the cap. if (w_minus_a > math::mul(b_coeff, cap)) return option::none(); - let k_quadratic = math::div(w_minus_a, b_coeff); - let k = (k_quadratic + k_quadratic / 10 + 1_000_000).max(k_floor); + let k = math::div(w_minus_a, b_coeff); if (k > cap) return option::none(); option::some(k) } diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 70fdf65c3..3ef24d47d 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -442,34 +442,35 @@ public(package) fun liquidate_live_order( exposure: &mut StrikeExposure, pricer: &Pricer, order: &Order, -): Option { +): Option { if (!exposure.liquidation.contains_active_order(order)) return option::none(); let liquidation_ltv = exposure.config.liquidation_ltv(); exposure.liquidate_order_if_under_floor(pricer, order, liquidation_ltv) } /// Run one bounded liquidation pass using exact per-candidate pricing. Returns -/// the knocked-out orders so the caller can write each removal through to the -/// market's stored valuation mark at the mark's own anchors. Per-order details -/// are on the `OrderLiquidated` events. +/// the total live value the knocked-out orders carried at removal (each order's +/// floor-capped `gross - floor`), so the caller can write the liability removal +/// through to the market's stored valuation mark. Per-order details are on the +/// `OrderLiquidated` events. public(package) fun liquidate_live_orders( exposure: &mut StrikeExposure, pricer: &Pricer, budget: u64, -): vector { +): u64 { let candidates = exposure.liquidation.select_liquidation_candidates(budget); - let mut removed_orders = vector[]; - if (candidates.is_empty()) return removed_orders; + if (candidates.is_empty()) return 0; let liquidation_ltv = exposure.config.liquidation_ltv(); + let mut removed_live_value = 0; candidates.do!(|candidate| { let order = order::from_order_id(candidate); let removed = exposure.liquidate_order_if_under_floor(pricer, &order, liquidation_ltv); if (removed.is_some()) { - removed_orders.push_back(removed.destroy_some()); + removed_live_value = removed_live_value + removed.destroy_some(); }; }); - removed_orders + removed_live_value } /// Cache terminal settled payout liability. @@ -510,7 +511,7 @@ fun liquidate_order_if_under_floor( pricer: &Pricer, order: &Order, liquidation_ltv: u64, -): Option { +): Option { let quantity = order.quantity(); let floor_amount = order.floor_shares(); let gross_value = exposure.gross_order_value(pricer, order); @@ -537,16 +538,16 @@ fun liquidate_order_if_under_floor( liquidation_ltv, ); - // Return the removed order itself: the caller values it at the stored - // valuation-mark anchors (not at this op's pricer) so the mark stays - // single-anchor. - option::some(*order) + // The knocked-out order's live value under the floor cap: an order between + // its floor and floor/ltv still carries `gross - floor`; one at or below the + // floor carries zero. Returned so the caller can write the removal through + // to the market's stored valuation mark. + option::some(gross_value.saturating_sub(floor_amount)) } /// Decode an order into `(lower, higher)` raw strike boundaries for pricing and -/// settlement comparison, mapping the open-ended sentinels. Package-visible so -/// the market can value orders at the stored valuation-mark anchors. -public(package) fun order_boundaries(exposure: &StrikeExposure, order: &Order): (u64, u64) { +/// settlement comparison, mapping the open-ended sentinels. +fun order_boundaries(exposure: &StrikeExposure, order: &Order): (u64, u64) { range_codec::strikes_from_ticks(order.lower_tick(), order.higher_tick(), exposure.tick_size) } diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index 9c3cd3a0b..9d4a65201 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -17,7 +17,6 @@ module deepbook_predict::valuation_mark; use deepbook_predict::pricing::{Self, Pricer}; -use fixed_math::math; use propbook::block_scholes_svi_feed::SVIParams; use sui::clock::Clock; @@ -31,16 +30,12 @@ public struct ValuationMark has copy, drop, store { /// Exact oracle-priced per-order liability the refresh walk computed at /// `computed_at_ms`. Never mutated between refreshes. computed_liability: u64, - /// Σ liability added by trades since the walk: each added order's value - /// priced at THIS MARK'S STORED ANCHORS, never at the op's own oracle — - /// so every component of `liability` shares one valuation time and the - /// anchor-to-live drift envelope bounds the whole current book. (Op-priced - /// deltas would be path-dependent: an oracle round trip between refresh - /// and flush leaves endpoint drift near zero while an op priced at the - /// peak stays in the sum.) + /// Σ liability added by trades since the walk (mint `net_premium`s), each + /// priced at its own op's oracle. added_since_compute: u64, - /// Σ liability removed by trades since the walk (live redeems and - /// liquidated orders), each priced at this mark's stored anchors. + /// Σ liability removed by trades since the walk (live-redeem + /// `redeem_amount`s and liquidated orders' live values), each priced at its + /// own op's oracle. removed_since_compute: u64, /// On-chain landing time of the refresh that computed this mark. computed_at_ms: u64, @@ -91,42 +86,13 @@ public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): Valuati } } -/// Write an added order through to the mark, valued at the stored anchors. -public(package) fun add_order( - mark: &mut ValuationMark, - lower: u64, - higher: u64, - quantity: u64, - floor_amount: u64, -) { - let value = mark.order_value_at_anchor(lower, higher, quantity, floor_amount); - mark.added_since_compute = mark.added_since_compute + value; +/// Write a trade's liability increase through to the mark's delta accumulator. +public(package) fun add_liability(mark: &mut ValuationMark, amount: u64) { + mark.added_since_compute = mark.added_since_compute + amount; } -/// Write a removed order (live redeem or liquidation) through to the mark, -/// valued at the stored anchors. Never clamps here — the residual netting -/// happens once, in `liability`. -public(package) fun remove_order( - mark: &mut ValuationMark, - lower: u64, - higher: u64, - quantity: u64, - floor_amount: u64, -) { - let value = mark.order_value_at_anchor(lower, higher, quantity, floor_amount); - mark.removed_since_compute = mark.removed_since_compute + value; -} - -/// One order's live value under this mark's anchors: the anchor-priced range -/// value net of the static floor, floored at zero (the same per-order shape the -/// walk aggregates). -fun order_value_at_anchor( - mark: &ValuationMark, - lower: u64, - higher: u64, - quantity: u64, - floor_amount: u64, -): u64 { - let price = pricing::range_price_at(&mark.anchor_svi, mark.anchor_forward, lower, higher); - math::mul(quantity, price).saturating_sub(floor_amount) +/// Write a trade's liability decrease through to the mark's delta accumulator. +/// Never clamps here — the mixed-anchor netting happens once, in `liability`. +public(package) fun remove_liability(mark: &mut ValuationMark, amount: u64) { + mark.removed_since_compute = mark.removed_since_compute + amount; } From b37a7f1c0df006609bb59bfb07082b883bf79621 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 15:15:18 -0400 Subject: [PATCH 16/23] predict: fail closed when band rounding swallows the strike band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a real wing slope the true band W strictly exceeds A, so a computed w_minus_a of zero is always a round-down artifact (the low-variance ill-conditioned corner; reviewed counterexample understated drift 13.6x through an empty band). Detect it and return none — drift_envelope charges full face, the market prices at its worst case, and a refresh re-anchors it. No abort: the flush lands, the degenerate market's uncertainty is priced into the spread, and other markets are unaffected. Partial rounding shrinkage (band reduced but nonzero) is deliberately left to the envelope-validation measurement before any further hardening. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/pricing/pricing.move | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index a01aa76e2..c32cc5cd7 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -378,6 +378,12 @@ fun unpinned_band(svi: &SVIParams, d: u64): Option { let sqrt_w = math::div(bd + math::sqrt(discriminant, one), 2 * (one - b_slope)); let w = math::mul(sqrt_w, sqrt_w); let w_minus_a = w.saturating_sub(a_overshoot); + // With a real slope the true W strictly exceeds A; a computed zero means + // the round-down chain swallowed the band (ill-conditioned at low + // variance — reviewed counterexample understated drift ~13.6x). No tight + // bound exists here, so fail closed: the caller charges full face and the + // market prices at its worst case until a refresh re-anchors it. + if (w_minus_a == 0) return option::none(); // K = (W - A) / B; refuse before dividing when K would exceed the cap. if (w_minus_a > math::mul(b_coeff, cap)) return option::none(); let k = math::div(w_minus_a, b_coeff); From 947739c68af15526af2c4b44a60833773a6a25e1 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 15:39:50 -0400 Subject: [PATCH 17/23] predict: guard the flush potato against interleaved cash moves The flush potato reads each market's free cash at its collect and pool idle at finish. An internal cash move composed between those commands in the flush PTB itself (rebalance_expiry_cash is permissionless; PTB atomicity blocks everyone else) lands the moved amount in both reads or neither, double- or under-counting it in the priced NAV and letting a pre-queued LP request fill at the corrupted mark. Add a cash_revision counter on the vault Ledger, bumped inside the internal-transfer primitives (send_expiry_cash, receive_expiry_cash, realize_pending_protocol_profit) so an untracked move is unrepresentable. start_pool_valuation snapshots it into the potato; finish_flush asserts it unchanged (ECashMovedDuringValuation), aborting the malformed PTB. Mint/redeem stay exempt: they move external cash and its liability write-through in one bucket at one instant, coherent whenever read. Review finding 3 (flush cash isolation), approved disposition. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/plp/plp.move | 12 ++++++++++++ packages/predict/sources/plp/pool_accounting.move | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index f8701132b..fb3dc959d 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -54,6 +54,7 @@ const EBelowMinFeeIncentiveSponsorship: u64 = 7; const EMarketNotSettled: u64 = 8; const EMaxLiveExpiryMarketsExceeded: u64 = 9; const EValuationMarkStale: u64 = 10; +const ECashMovedDuringValuation: u64 = 11; /// One-time witness type for Predict LP token registration. public struct PLP has drop {} @@ -102,6 +103,12 @@ public struct PoolValuation { /// moving feeds. Becomes the flush mark's half-spread at `finish_flush`: /// supplies price at the mid NAV plus it, withdrawals at the mid minus it. total_drift: u64, + /// The vault ledger's cash revision at `start_pool_valuation`. `finish_flush` + /// asserts it unchanged: the potato reads each market's cash at its collect + /// and idle at finish, so an internal cash move interleaved between those + /// reads (only the flush PTB's own commands can do this — PTBs are atomic) + /// would double- or under-count the moved amount in the priced NAV. + cash_revision: u64, } // === Package Initializer === @@ -281,6 +288,7 @@ public fun start_pool_valuation( total_free_cash: 0, total_liability: 0, total_drift: 0, + cash_revision: vault.expiry_accounting.cash_revision(), } } @@ -348,6 +356,10 @@ public fun finish_flush( &valuation.expected_expiry_markets, &valuation.valued_expiry_markets, ); + assert!( + vault.expiry_accounting.cash_revision() == valuation.cash_revision, + ECashMovedDuringValuation, + ); let PoolValuation { total_free_cash, total_liability, diff --git a/packages/predict/sources/plp/pool_accounting.move b/packages/predict/sources/plp/pool_accounting.move index f61b64308..ca9a05ca4 100644 --- a/packages/predict/sources/plp/pool_accounting.move +++ b/packages/predict/sources/plp/pool_accounting.move @@ -38,6 +38,12 @@ public struct Ledger has store { /// physically moved to the reserve because idle was deployed in other active /// markets at materialization. Excluded from LP value until drained. pending_protocol_profit: u64, + /// Bumped on every internal cash move (idle<->expiry transfers and + /// protocol-profit draws from idle). The flush potato snapshots it at start + /// and `finish_flush` asserts it unchanged: the potato reads each market's + /// cash at its collect and idle at finish, so a move between those reads + /// would double- or under-count the moved amount in the priced NAV. + cash_revision: u64, } /// Active valuation entry with the expiry timestamp needed to classify live NAV cost. @@ -73,6 +79,7 @@ public(package) fun new(ctx: &mut TxContext): Ledger { profit_basis_credits: 0, net_losses_to_fill: 0, pending_protocol_profit: 0, + cash_revision: 0, } } @@ -80,6 +87,10 @@ public(package) fun idle_balance(ledger: &Ledger): u64 { ledger.idle_balance.value() } +public(package) fun cash_revision(ledger: &Ledger): u64 { + ledger.cash_revision +} + /// Return the expiry market IDs still contributing active pool valuation/risk. public(package) fun active_expiry_markets(ledger: &Ledger): vector { ledger.active_expiry_markets.map_ref!(|m| m.expiry_market_id) @@ -183,6 +194,7 @@ public(package) fun send_expiry_cash( ): Balance { if (amount == 0) return balance::zero(); ledger.record_sent_to_expiry(expiry_market_id, amount); + ledger.cash_revision = ledger.cash_revision + 1; ledger.idle_balance.split(amount) } @@ -216,6 +228,7 @@ public(package) fun receive_expiry_cash( }; ledger.idle_balance.join(cash); ledger.record_received_from_expiry(expiry_market_id, amount); + ledger.cash_revision = ledger.cash_revision + 1; amount } @@ -260,6 +273,7 @@ public(package) fun materialize_expiry_profit(ledger: &mut Ledger, expiry_market public(package) fun realize_pending_protocol_profit(ledger: &mut Ledger): Balance { let draw = ledger.pending_protocol_profit.min(ledger.idle_balance.value()); ledger.pending_protocol_profit = ledger.pending_protocol_profit - draw; + if (draw > 0) ledger.cash_revision = ledger.cash_revision + 1; ledger.idle_balance.split(draw) } From da83644bfe497320338e2997261a9b22f6abc378 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 17:06:13 -0400 Subject: [PATCH 18/23] predict: anchor-price the valuation-mark write-through, move the mark into strike_exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark's write-through deltas were the op's own priced amounts (net_premium, redeem_amount, swept liquidation values). Endpoint drift between the stored anchors and the flush oracle cannot bound values priced at a third, intermediate oracle state: a round trip that spikes at trade time and returns by flush leaves near-zero endpoint drift while the peak-priced delta stays in the sum (review Critical 1). Re-express every delta in the anchor basis instead. An order's terms (boundary ticks, quantity, floor) fully determine its value at any oracle state, so hooks now value the affected orders at the mark's stored anchors: the marked liability stays exactly the current book valued at one snapshot, and quantity x endpoint-envelope provably bounds its revaluation. The op's execution price touches only cash, which the flush reads live and exact. pricing::from_anchors rebuilds a Pricer from the stored anchors, so the existing pricing surface (range_price, gross_order_value) is the single evaluator — no parallel anchor-pricing formula. The delta uses walk semantics (gross - floor, floored at zero), not order_value's holder-close zeroing of liquidatable orders: the walk carries a liquidatable-yet-unliquidated order's value until an actual knock-out removes it, and the write-through must round-trip the walk. The mark also moves from ExpiryMarket into StrikeExposure, next to the only state it tracks: every book mutation (mint insert, live-redeem close/replace, liquidation knock-out) now writes through inside one module, deleting expiry_market's six scattered hook sites and the u64 plumbing that threaded liquidation values out solely to feed the mark. ExpiryMarket keeps thin forwarders and the pricer-to-market binding check; the plp flush surface is unchanged. Review finding 1 (write-through anchoring), approved disposition. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/expiry_market.move | 82 ++--------- packages/predict/sources/pricing/pricing.move | 8 ++ .../strike_exposure/strike_exposure.move | 131 ++++++++++++++---- packages/predict/sources/valuation_mark.move | 87 +++++++----- 4 files changed, 176 insertions(+), 132 deletions(-) diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index b74b1a97e..b731a25a0 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -25,8 +25,7 @@ use deepbook_predict::{ pricing::{Self, Pricer}, protocol_config::ProtocolConfig, strike_exposure::{Self, MintTerms, StrikeExposure}, - strike_exposure_config, - valuation_mark::{Self, ValuationMark} + strike_exposure_config }; use dusdc::dusdc::DUSDC; use fixed_math::math; @@ -52,7 +51,6 @@ const EReferenceTickTimestampMismatch: u64 = 9; const EMintRedeemSameTimestamp: u64 = 10; const ERedeemProbabilityBelowMin: u64 = 11; const ERedeemProceedsBelowMin: u64 = 12; -const EValuationMarkMissing: u64 = 13; /// Per-expiry market state. public struct ExpiryMarket has key { @@ -74,8 +72,6 @@ public struct ExpiryMarket has key { /// Admin sets/unsets it (version-gated); a `PauseCap` holder can force it /// true one-way through the registry (ungated kill switch). mint_paused: bool, - /// Stored valuation mark the pool flush reads (`None` until first refresh). - valuation_mark: Option, } /// Read-only all-in cost quote for a prospective live mint, in DUSDC base units. @@ -408,13 +404,12 @@ public fun mint_exact_quantity( wrapper.settle(root, clock); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let removed_live_value = market + market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_liability_removed(removed_live_value); market.mint_prepared_exact_quantity( account, @@ -460,13 +455,12 @@ public fun mint_exact_amount( let amount = amount.min(wrapper.load_account().balance(root, clock)); let account = wrapper.load_account_mut(auth); let active_stake = predict_account::roll_active_stake(account, ctx); - let removed_live_value = market + market .strike_exposure .liquidate_live_orders( pricer, config.trade_liquidation_budget(), ); - market.mark_liability_removed(removed_live_value); let quantity = market.max_mint_quantity_for_amount( pricer, @@ -528,11 +522,8 @@ public fun redeem_live( let account = wrapper.load_account_mut(auth); let redeemed_order = order::from_order_id(order_id); - let swept_live_value = market - .strike_exposure - .liquidate_live_orders(pricer, config.trade_liquidation_budget()); - let knocked_out = market.strike_exposure.liquidate_live_order(pricer, &redeemed_order); - market.mark_liability_removed(swept_live_value + knocked_out.get_with_default(0)); + market.strike_exposure.liquidate_live_orders(pricer, config.trade_liquidation_budget()); + market.strike_exposure.liquidate_live_order(pricer, &redeemed_order); if (market.strike_exposure.is_liquidated_order(&redeemed_order)) { market.redeem_liquidated_order(account, &redeemed_order, close_quantity, ctx); return (redeemed_order.id(), option::none()) @@ -630,8 +621,7 @@ public fun liquidate( budget: u64, ) { market.assert_live_flow_allowed(config, pricer); - let removed_live_value = market.strike_exposure.liquidate_live_orders(pricer, budget); - market.mark_liability_removed(removed_live_value); + market.strike_exposure.liquidate_live_orders(pricer, budget); } /// Try to liquidate one active leveraged order by ID; a knock-out emits an @@ -645,8 +635,7 @@ public fun liquidate_order( market.assert_live_flow_allowed(config, pricer); let order = order::from_order_id(order_id); - let removed = market.strike_exposure.liquidate_live_order(pricer, &order); - market.mark_liability_removed(removed.get_with_default(0)); + market.strike_exposure.liquidate_live_order(pricer, &order); } /// Set this expiry's reference fine-grid tick from the exact previous-window @@ -736,40 +725,23 @@ public(package) fun free_cash(market: &ExpiryMarket): u64 { market.cash.free_cash() } -/// Return the stored mark's current liability: the refresh walk's number plus -/// trade write-through since. The payout tree is never walked here — that is -/// the point: the walk ran in the refresh that stored the mark, and this read -/// loads no per-order objects. Deliberately unclamped against cash — a market -/// can legitimately owe more than it holds (a backing lambda below 1 admits -/// transient shortfalls backstopped by pool rebalancing) — and deliberately a -/// dumb fact: whether the mark is fresh enough and its drift acceptable is -/// judged by `plp`, which aggregates across markets. +/// Return the stored mark's current liability +/// (`strike_exposure::marked_liability` owns the semantics). public(package) fun marked_liability(market: &ExpiryMarket): u64 { - assert!(market.valuation_mark.is_some(), EValuationMarkMissing); - market.valuation_mark.borrow().liability() + market.strike_exposure.marked_liability() } /// Return the landing time of the refresh that computed the stored mark. public(package) fun mark_computed_at_ms(market: &ExpiryMarket): u64 { - assert!(market.valuation_mark.is_some(), EValuationMarkMissing); - market.valuation_mark.borrow().computed_at_ms() + market.strike_exposure.mark_computed_at_ms() } /// Measure the stored mark's potential oracle drift against the live inputs in -/// `pricer`, in DUSDC base units: the worst-case single-contract price move -/// since the walk (`valuation_mark::drift`, a fraction of full payout) times -/// the book's live face quantity — per-order value is quantity-Lipschitz in -/// price, so this bounds how far this market's NAV can have drifted. The -/// quantity is read live, so orders minted after the walk (whose write-through -/// deltas also sit on drifting prices) are covered. A measurement, not a -/// judgment — `plp` aggregates across markets and enforces the budget. +/// `pricer`, in DUSDC base units (`strike_exposure::mark_drift` owns the +/// semantics; this composition point owns the pricer-to-market binding check). public(package) fun mark_drift(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); - assert!(market.valuation_mark.is_some(), EValuationMarkMissing); - math::mul( - market.strike_exposure.total_live_quantity(), - market.valuation_mark.borrow().drift(pricer), - ) + market.strike_exposure.mark_drift(pricer) } /// Recompute this market's exact per-order live liability and store it as the @@ -781,9 +753,7 @@ public(package) fun record_valuation_mark( clock: &Clock, ): u64 { market.assert_pricer_bound(pricer); - let liability = market.strike_exposure.exact_live_liability(pricer); - market.valuation_mark = option::some(valuation_mark::new(liability, pricer, clock)); - liability + market.strike_exposure.refresh_valuation_mark(pricer, clock) } /// Force `mint_paused = true`. Reserved for `PauseCap` holders going through @@ -915,7 +885,6 @@ public(package) fun create_and_share( ), ewma: ewma::new(ctx), mint_paused: false, - valuation_mark: option::none(), }; transfer::share_object(market); expiry_market_id @@ -998,25 +967,6 @@ fun assert_pricer_bound(market: &ExpiryMarket, pricer: &Pricer) { assert!(pricer.expiry_market_id() == market.id(), EWrongPricer); } -/// Write a mint's bit-exact liability delta through to the stored valuation mark. -/// The marginal live liability of a freshly admitted order at its own pricer is -/// exactly `net_premium` (`entry_value - floor`); no-op until the first refresh -/// establishes a mark. -fun mark_liability_added(market: &mut ExpiryMarket, amount: u64) { - if (market.valuation_mark.is_some()) { - market.valuation_mark.borrow_mut().add_liability(amount); - }; -} - -/// Write a liability decrease through to the stored valuation mark: a live -/// redeem's `redeem_amount`, or the live value a liquidation pass removed. -/// No-op until the first refresh establishes a mark. -fun mark_liability_removed(market: &mut ExpiryMarket, amount: u64) { - if (market.valuation_mark.is_some()) { - market.valuation_mark.borrow_mut().remove_liability(amount); - }; -} - /// Return the congestion surcharge (in DUSDC) for `quantity` from the pre-trade /// EWMA stats, then fold the current gas price into the estimate. Penalty before /// fold on every trade path (mint and live redeem): the trade's gas is judged @@ -1106,7 +1056,6 @@ fun mint_prepared_exact_quantity( let leverage = terms.leverage(); let minted_order = market.strike_exposure.allocate_mint_order(terms); market.settle_mint_payment(account, &minted_order, "e, builder_code_id, clock, ctx); - market.mark_liability_added(quote.net_premium); order_events::emit_order_minted( market.id(), account.account_id(), @@ -1229,7 +1178,6 @@ fun redeem_live_internal( redeem_amount - fee_amount - builder_fee_amount - penalty_amount >= min_proceeds, ERedeemProceedsBelowMin, ); - market.mark_liability_removed(redeem_amount); order_events::emit_live_order_redeemed( market.id(), diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index c32cc5cd7..7c40a00c2 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -124,6 +124,14 @@ public(package) fun svi_params(pricer: &Pricer): SVIParams { pricer.svi } +/// Rebuild a `Pricer` from a valuation mark's stored anchors. Deliberately +/// bypasses `load_live_pricer`'s feed-freshness and binding guards: the +/// anchors were validated by the refresh that stored them, and this pricer +/// exists to value orders AT that stored snapshot, never at live state. +public(package) fun from_anchors(expiry_market_id: ID, forward: u64, svi: SVIParams): Pricer { + Pricer { expiry_market_id, forward, svi } +} + /// Worst-case repricing between two oracle snapshots: an upper bound on how far /// ANY contract's fair price can have moved between the anchor oracle state and /// the live one, as a fraction of full payout in FLOAT_SCALING (capped at 1.0). diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 3ef24d47d..7b32384af 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -21,7 +21,8 @@ use deepbook_predict::{ pricing::{Self, Pricer}, range_codec, strike_exposure_config::StrikeExposureConfig, - strike_payout_tree::{Self, StrikePayoutTree} + strike_payout_tree::{Self, StrikePayoutTree}, + valuation_mark::{Self, ValuationMark} }; use fixed_math::math; use sui::clock::Clock; @@ -31,6 +32,7 @@ const EInvalidAdmissionTick: u64 = 1; const EInvalidReferenceTick: u64 = 2; const EReferenceTickAlreadySet: u64 = 3; const ETermsExposureMismatch: u64 = 4; +const EValuationMarkMissing: u64 = 5; /// Exposure lifecycle state for one expiry market. public struct StrikeExposure has store { @@ -56,6 +58,10 @@ public struct StrikeExposure has store { liquidation: LiquidationBook, /// Sparse payout tree for live cash backing and settled liability. payout: StrikePayoutTree, + /// Stored valuation mark the pool flush reads (`None` until first refresh). + /// Owned here so every book mutation writes its anchor-priced value + /// through in the same module — a trade path cannot forget the mark. + valuation_mark: Option, } /// Pure mint terms for one prospective live mint: the priced tick range, @@ -159,6 +165,39 @@ public(package) fun exact_live_liability(exposure: &StrikeExposure, pricer: &Pri linear.saturating_sub(correction) } +/// Return the stored mark's current liability: the refresh walk's number plus +/// anchor-priced trade write-through since. The payout tree is never walked +/// here — that is the point: the walk ran in the refresh that stored the mark, +/// and this read loads no per-order objects. Deliberately unclamped against +/// cash — a market can legitimately owe more than it holds (a backing lambda +/// below 1 admits transient shortfalls backstopped by pool rebalancing) — and +/// deliberately a dumb fact: whether the mark is fresh enough is judged by +/// `plp`, which aggregates across markets. +public(package) fun marked_liability(exposure: &StrikeExposure): u64 { + assert!(exposure.valuation_mark.is_some(), EValuationMarkMissing); + exposure.valuation_mark.borrow().liability() +} + +/// Return the landing time of the refresh that computed the stored mark. +public(package) fun mark_computed_at_ms(exposure: &StrikeExposure): u64 { + assert!(exposure.valuation_mark.is_some(), EValuationMarkMissing); + exposure.valuation_mark.borrow().computed_at_ms() +} + +/// Measure the stored mark's potential oracle drift against the live inputs +/// in `pricer`, in DUSDC base units: the worst-case single-contract price move +/// since the walk (`valuation_mark::drift`, a fraction of full payout) times +/// the book's live face quantity — per-order value is quantity-Lipschitz in +/// price, so this bounds how far this book's liability can have drifted. The +/// quantity is read live and the write-through deltas are valued at the mark's +/// own anchors, so the whole current book shares one valuation time and this +/// endpoint bound covers it. A measurement, not a judgment — `plp` sums across +/// markets and prices the total into the flush mark's bid/ask spread. +public(package) fun mark_drift(exposure: &StrikeExposure, pricer: &Pricer): u64 { + assert!(exposure.valuation_mark.is_some(), EValuationMarkMissing); + math::mul(exposure.payout.total_quantity(), exposure.valuation_mark.borrow().drift(pricer)) +} + /// Return the liquidation LTV snapshotted for this exposure book. public(package) fun liquidation_ltv(exposure: &StrikeExposure): u64 { exposure.config.liquidation_ltv() @@ -246,6 +285,7 @@ public(package) fun new( settled_liability_materialized: false, liquidation: liquidation_book::new(ctx), payout: strike_payout_tree::new(ctx), + valuation_mark: option::none(), } } @@ -327,6 +367,7 @@ public(package) fun allocate_mint_order(exposure: &mut StrikeExposure, terms: Mi exposure.liquidation.insert_order(&allocated_order); exposure.payout.insert_range(lower_tick, higher_tick, quantity, floor_shares); + exposure.mark_order_added(&allocated_order); allocated_order } @@ -411,6 +452,9 @@ public(package) fun close_and_quote_live_order( remove_floor_shares, ); exposure.liquidation.remove_order(order); + // Anchor-priced write-through: remove the original order and (below) + // re-add any partial-close remainder, both at the mark's stored anchors. + exposure.mark_order_removed(order); let range_probability = pricer.range_price(lower, higher); let gross_redeem_amount = math::mul(range_probability, close_quantity); @@ -428,6 +472,7 @@ public(package) fun close_and_quote_live_order( ); exposure.liquidation.insert_order(&replacement_order); exposure.next_order_sequence = exposure.next_order_sequence + 1; + exposure.mark_order_added(&replacement_order); CloseQuote { resulting_order: replacement_order, redeem_amount, range_probability } } @@ -437,40 +482,35 @@ public(package) fun clear_liquidated_order(exposure: &mut StrikeExposure, order: exposure.liquidation.clear_liquidated(order); } -/// Try to liquidate one active leveraged order using exact live pricing. +/// Try to liquidate one active leveraged order using exact live pricing; a +/// knock-out is written through to the stored valuation mark and emits an +/// `OrderLiquidated` event, and an ineligible order is a no-op. public(package) fun liquidate_live_order( exposure: &mut StrikeExposure, pricer: &Pricer, order: &Order, -): Option { - if (!exposure.liquidation.contains_active_order(order)) return option::none(); +) { + if (!exposure.liquidation.contains_active_order(order)) return; let liquidation_ltv = exposure.config.liquidation_ltv(); - exposure.liquidate_order_if_under_floor(pricer, order, liquidation_ltv) + exposure.liquidate_order_if_under_floor(pricer, order, liquidation_ltv); } -/// Run one bounded liquidation pass using exact per-candidate pricing. Returns -/// the total live value the knocked-out orders carried at removal (each order's -/// floor-capped `gross - floor`), so the caller can write the liability removal -/// through to the market's stored valuation mark. Per-order details are on the -/// `OrderLiquidated` events. +/// Run one bounded liquidation pass using exact per-candidate pricing. Each +/// knock-out is written through to the stored valuation mark; per-order +/// details are on the `OrderLiquidated` events. public(package) fun liquidate_live_orders( exposure: &mut StrikeExposure, pricer: &Pricer, budget: u64, -): u64 { +) { let candidates = exposure.liquidation.select_liquidation_candidates(budget); - if (candidates.is_empty()) return 0; + if (candidates.is_empty()) return; let liquidation_ltv = exposure.config.liquidation_ltv(); - let mut removed_live_value = 0; candidates.do!(|candidate| { let order = order::from_order_id(candidate); - let removed = exposure.liquidate_order_if_under_floor(pricer, &order, liquidation_ltv); - if (removed.is_some()) { - removed_live_value = removed_live_value + removed.destroy_some(); - }; + exposure.liquidate_order_if_under_floor(pricer, &order, liquidation_ltv); }); - removed_live_value } /// Cache terminal settled payout liability. @@ -495,12 +535,55 @@ public(package) fun materialize_settled_liability( settled_liability } +/// Recompute this book's exact live liability and store it as the valuation +/// mark the pool flush reads, replacing any prior mark. Returns the stored +/// liability. +public(package) fun refresh_valuation_mark( + exposure: &mut StrikeExposure, + pricer: &Pricer, + clock: &Clock, +): u64 { + let liability = exposure.exact_live_liability(pricer); + exposure.valuation_mark = option::some(valuation_mark::new(liability, pricer, clock)); + liability +} + fun gross_order_value(exposure: &StrikeExposure, pricer: &Pricer, order: &Order): u64 { let (lower, higher) = exposure.order_boundaries(order); let range_probability = pricer.range_price(lower, higher); math::mul(range_probability, order.quantity()) } +/// Value one order exactly as the liability walk counts it: anchor-priced +/// range value net of the static floor, floored at zero (this order's +/// `walk_linear` minus `correction_value` contribution). Deliberately no +/// liquidation-state zeroing — unlike `order_value`'s holder-close policy, the +/// walk carries a liquidatable-yet-unliquidated order's `gross - floor` until +/// an actual knock-out removes it. +fun mark_order_value(exposure: &StrikeExposure, order: &Order): u64 { + let anchor_pricer = exposure.valuation_mark.borrow().anchor_pricer(exposure.expiry_market_id); + exposure.gross_order_value(&anchor_pricer, order).saturating_sub(order.floor_shares()) +} + +/// Write an added order through to the stored mark, valued at the mark's own +/// anchors — never at the op's oracle — so the marked liability stays a +/// single-anchor valuation of the current book and the endpoint drift envelope +/// bounds it. No-op until the first refresh establishes a mark. +fun mark_order_added(exposure: &mut StrikeExposure, order: &Order) { + if (exposure.valuation_mark.is_none()) return; + let value = exposure.mark_order_value(order); + exposure.valuation_mark.borrow_mut().add_value(value); +} + +/// Write a removed order (live redeem or liquidation) through to the stored +/// mark, valued at the mark's own anchors. No-op until the first refresh +/// establishes a mark. +fun mark_order_removed(exposure: &mut StrikeExposure, order: &Order) { + if (exposure.valuation_mark.is_none()) return; + let value = exposure.mark_order_value(order); + exposure.valuation_mark.borrow_mut().remove_value(value); +} + /// Liquidate (knock out) `order` when its live value has reached the static floor: /// `qty·P <= floor_shares / liquidation_ltv`. The LTV buffer is the anti-arbitrage /// enforcement margin — knock out a hair before zero equity so a missed barrier @@ -511,13 +594,12 @@ fun liquidate_order_if_under_floor( pricer: &Pricer, order: &Order, liquidation_ltv: u64, -): Option { +) { let quantity = order.quantity(); let floor_amount = order.floor_shares(); let gross_value = exposure.gross_order_value(pricer, order); let liquidation_threshold = math::div(floor_amount, liquidation_ltv); - let can_liquidate = gross_value <= liquidation_threshold; - if (!can_liquidate) return option::none(); + if (gross_value > liquidation_threshold) return; exposure.liquidation.mark_liquidated(order); exposure @@ -528,6 +610,7 @@ fun liquidate_order_if_under_floor( quantity, floor_amount, ); + exposure.mark_order_removed(order); order_events::emit_order_liquidated( exposure.expiry_market_id, @@ -537,12 +620,6 @@ fun liquidate_order_if_under_floor( floor_amount, liquidation_ltv, ); - - // The knocked-out order's live value under the floor cap: an order between - // its floor and floor/ltv still carries `gross - floor`; one at or below the - // floor carries zero. Returned so the caller can write the removal through - // to the market's stored valuation mark. - option::some(gross_value.saturating_sub(floor_amount)) } /// Decode an order into `(lower, higher)` raw strike boundaries for pricing and diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index 9d4a65201..aeb4a72eb 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -5,15 +5,15 @@ /// /// A mark memoizes one market's exact oracle-priced per-order liability (the /// payout-tree walk) so the pool flush can read the market without walking any -/// tree. This module owns the mark's lifecycle: construction at refresh, the -/// write-through maintenance trade flows apply (mint adds its `net_premium`, -/// live redeem subtracts its `redeem_amount`, liquidation subtracts the -/// knocked-out order's live value), and measuring `drift` — the potential -/// dollar impact of oracle movement since the walk, against a fresh `Pricer`. -/// It deliberately owns NO acceptance policy: whether a mark is fresh enough or -/// the measured drift acceptable is decided downstream by `plp`, which -/// aggregates across markets. It does not own the walk or the market's cash — -/// `expiry_market` composes those. +/// tree. This module owns the mark's data discipline: construction at refresh, +/// accumulating the anchor-priced write-through values `strike_exposure` +/// maintains as its book mutates, and measuring `drift` — the potential dollar +/// impact of oracle movement since the walk, against a fresh `Pricer`. It +/// deliberately owns NO acceptance policy (whether a mark is fresh enough is +/// decided downstream by `plp`, which aggregates across markets) and NO order +/// valuation (the walk-consistent per-order formula is `strike_exposure`'s; +/// this component hands back its anchors as a `Pricer` and accumulates what +/// the owner valued). module deepbook_predict::valuation_mark; use deepbook_predict::pricing::{Self, Pricer}; @@ -21,22 +21,23 @@ use propbook::block_scholes_svi_feed::SVIParams; use sui::clock::Clock; /// One market's stored valuation mark. Free cash is never stored — the flush -/// reads it live, so cash moves need no mark maintenance. Trade write-through -/// accumulates in the two delta fields rather than mutating the walked number: -/// the walk's output stays auditable against a fresh walk at the same anchors, -/// and the mixed-anchor netting is applied once at read time, order-independent, -/// instead of destructively clamping op by op. +/// reads it live, so cash moves need no mark maintenance. Every component is +/// valued at the SAME stored anchors: the walked number by the refresh, the +/// trade write-through by `strike_exposure` pricing each added/removed order +/// via `anchor_pricer` — never at an op's own oracle. One valuation basis is +/// what lets the endpoint drift envelope bound the whole current book +/// (op-priced deltas are path-dependent: an oracle round trip between refresh +/// and flush leaves endpoint drift near zero while an op priced at the peak +/// stays in the sum). public struct ValuationMark has copy, drop, store { /// Exact oracle-priced per-order liability the refresh walk computed at /// `computed_at_ms`. Never mutated between refreshes. computed_liability: u64, - /// Σ liability added by trades since the walk (mint `net_premium`s), each - /// priced at its own op's oracle. - added_since_compute: u64, - /// Σ liability removed by trades since the walk (live-redeem - /// `redeem_amount`s and liquidated orders' live values), each priced at its - /// own op's oracle. - removed_since_compute: u64, + /// Σ anchor-priced value of orders added since the walk. + added_at_anchor: u64, + /// Σ anchor-priced value of orders removed since the walk (live redeems + /// and liquidations). + removed_at_anchor: u64, /// On-chain landing time of the refresh that computed this mark. computed_at_ms: u64, /// Forward the walk priced at (drift anchor). Raw snapshot, not derived @@ -49,13 +50,14 @@ public struct ValuationMark has copy, drop, store { // === Public-Package Functions === -/// The mark's current liability: the walked number plus all trade deltas since, -/// netted once here. The deltas are priced at their ops' oracles while the -/// walked sum is anchored at the refresh oracle (drift-bounded), so the netting -/// can exceed the sum by that bounded residual — clamp it rather than abort a -/// read in the mandatory flush path. +/// The mark's current liability: the walked number plus all trade deltas +/// since, netted once here. All components share the anchor basis, but the +/// walk aggregates quantities per tree range before multiplying while +/// write-through prices per order, so netting can exceed the sum by +/// fixed-point association dust — clamp that dust rather than abort a read in +/// the mandatory flush path. public(package) fun liability(mark: &ValuationMark): u64 { - (mark.computed_liability + mark.added_since_compute).saturating_sub(mark.removed_since_compute) + (mark.computed_liability + mark.added_at_anchor).saturating_sub(mark.removed_at_anchor) } public(package) fun computed_at_ms(mark: &ValuationMark): u64 { @@ -67,32 +69,41 @@ public(package) fun computed_at_ms(mark: &ValuationMark): u64 { /// have moved since the walk, as a fraction of full payout in FLOAT_SCALING /// (`pricing::drift_envelope` between the stored anchors and the live /// snapshot). Oracle movement only — trade deltas are already inside -/// `liability`. The caller converts to dollars and judges in aggregate; this -/// component measures, it does not accept or reject. +/// `liability`, in the anchor basis this envelope re-values from. The caller +/// converts to dollars and judges in aggregate; this component measures, it +/// does not accept or reject. public(package) fun drift(mark: &ValuationMark, pricer: &Pricer): u64 { pricer.drift_envelope(mark.anchor_forward, &mark.anchor_svi) } +/// Rebuild the stored anchors as a `Pricer` so the mark's owner can value +/// write-through orders at the SAME snapshot the walk priced. +public(package) fun anchor_pricer(mark: &ValuationMark, expiry_market_id: ID): Pricer { + pricing::from_anchors(expiry_market_id, mark.anchor_forward, mark.anchor_svi) +} + /// Snapshot a fresh mark: the just-walked liability with zeroed trade deltas, /// anchored to the oracle inputs the walk priced at. public(package) fun new(liability: u64, pricer: &Pricer, clock: &Clock): ValuationMark { ValuationMark { computed_liability: liability, - added_since_compute: 0, - removed_since_compute: 0, + added_at_anchor: 0, + removed_at_anchor: 0, computed_at_ms: clock.timestamp_ms(), anchor_forward: pricer.forward(), anchor_svi: pricer.svi_params(), } } -/// Write a trade's liability increase through to the mark's delta accumulator. -public(package) fun add_liability(mark: &mut ValuationMark, amount: u64) { - mark.added_since_compute = mark.added_since_compute + amount; +/// Accumulate an added order's anchor-priced value (produced against +/// `anchor_pricer`). +public(package) fun add_value(mark: &mut ValuationMark, value: u64) { + mark.added_at_anchor = mark.added_at_anchor + value; } -/// Write a trade's liability decrease through to the mark's delta accumulator. -/// Never clamps here — the mixed-anchor netting happens once, in `liability`. -public(package) fun remove_liability(mark: &mut ValuationMark, amount: u64) { - mark.removed_since_compute = mark.removed_since_compute + amount; +/// Accumulate a removed order's anchor-priced value (produced against +/// `anchor_pricer`). Never clamps here — the dust netting happens once, in +/// `liability`. +public(package) fun remove_value(mark: &mut ValuationMark, value: u64) { + mark.removed_at_anchor = mark.removed_at_anchor + value; } From 737f45574fa6eccf27f3a3a7a67ea3432da98078 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 17:07:41 -0400 Subject: [PATCH 19/23] predict: drift-envelope edge guards and stale flush comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-land the uncontroversial remainder of the reverted review-fix package: - Identical snapshots return exactly 0 drift (no tail pad), so an atomic refresh-and-flush PTB reproduces the old exact-single-mark behavior. - Extreme forward ratios (>= 100x) guard-return full face in plain integer division before math::div can overflow-abort — the increase direction now fails closed like the decrease direction (review Medium 5). - FlushExecuted's doc block re-attached to its own struct (it sat above NavRefreshed) and updated with bid/ask half-spread semantics; collect_expiry_nav's doc updated from the dead drift-threshold language to spread semantics (review Low 7). Co-Authored-By: Claude Fable 5 --- packages/predict/sources/events/vault_events.move | 15 ++++++++------- packages/predict/sources/plp/plp.move | 5 +++-- packages/predict/sources/pricing/pricing.move | 11 ++++++++++- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index 51f75b68f..4f3938272 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -152,13 +152,6 @@ public struct WithdrawFilled has copy, drop, store { dusdc_amount: u64, } -/// Emitted once per flush after both queues drain. The flush IS the full-pool -/// valuation, so this single event carries the frozen mark every fill was priced at -/// (`pool_value` over `total_supply`), its raw valuation breakdown -/// (`idle_balance_before` plus `total_free_cash` owing `total_liability` over -/// `market_count` active markets), how many of each kind filled, how many queue -/// heads spent per-flush budget (filled, protocol-refunded, or live limit-missed), -/// and the idle balance after the drain. /// Emitted when a live market's valuation mark is refreshed: the exact /// per-order liability stored for the pool flush to read, at its landing time. public struct NavRefreshed has copy, drop, store { @@ -170,6 +163,14 @@ public struct NavRefreshed has copy, drop, store { computed_at_ms: u64, } +/// Emitted once per flush after both queues drain. The flush IS the full-pool +/// valuation, so this single event carries the mid mark every fill was spread +/// around (`pool_value` over `total_supply`, with `total_drift` as the bid/ask +/// half-spread), its raw valuation breakdown (`idle_balance_before` plus +/// `total_free_cash` owing `total_liability` over `market_count` active +/// markets), how many of each kind filled, how many queue heads spent +/// per-flush budget (filled, protocol-refunded, or live limit-missed), and the +/// idle balance after the drain. public struct FlushExecuted has copy, drop, store { pool_vault_id: ID, epoch: u64, diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index fb3dc959d..09e685f39 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -299,8 +299,9 @@ public fun start_pool_valuation( /// moves, no settlement, no tree walk. Three facts are collected — live free /// cash, the stored marked liability, and the mark's measured dollar drift — /// and only one gate applies here: the mark must be younger than the freshness -/// ceiling (the sole guard a stalled feed cannot fool). Drift is judged in -/// aggregate at `finish_flush`, not per market. +/// ceiling (the sole guard a stalled feed cannot fool). Drift is never +/// rejected — `finish_flush` prices the aggregate as the flush mark's bid/ask +/// half-spread, borne by the transacting party. /// /// A settled or past-expiry market cannot produce the pricer this read requires /// (`load_live_pricer` rejects past-expiry): sweep it via `rebalance_expiry_cash` diff --git a/packages/predict/sources/pricing/pricing.move b/packages/predict/sources/pricing/pricing.move index 7c40a00c2..5c931209e 100644 --- a/packages/predict/sources/pricing/pricing.move +++ b/packages/predict/sources/pricing/pricing.move @@ -166,6 +166,11 @@ public(package) fun drift_envelope( anchor_forward: u64, anchor_svi: &SVIParams, ): u64 { + // Identical snapshots price identically — exactly zero drift, no tail pad + // (the pad covers tails between DIFFERENT snapshots). This also lets an + // atomic refresh-and-flush PTB reproduce the exact single-mark behavior. + if (anchor_forward == pricer.forward && *anchor_svi == pricer.svi) return 0; + let full = math::float_scaling!(); let scale = full as u128; let s0 = sqrt_min_total_variance(anchor_svi); @@ -178,7 +183,11 @@ public(package) fun drift_envelope( if (sigma_lo == 0) return full; // Forward leg: a forward move shifts every strike's log-moneyness by - // |ln(F1/F0)|, and d2 divides that by at least the variance floor. + // |ln(F1/F0)|, and d2 divides that by at least the variance floor. Guard + // the ratio in plain integer division first: a ratio this size is + // full-face drift regardless, and `math::div` would overflow-abort near + // u64::MAX rather than fail closed. + if (pricer.forward / anchor_forward >= 100) return full; let forward_ratio = math::div(pricer.forward, anchor_forward); if (forward_ratio == 0) return full; let delta_forward = math::ln(forward_ratio).magnitude(); From 5c630e5e6d10f3d79cabd88eece5032cfd1e03c8 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 17:12:27 -0400 Subject: [PATCH 20/23] predict: fold FlushMark executability into the value's type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supply_value/supply_executable (and the withdraw pair) were a data clump read together at exactly one site each, and the split shape let a future consumer price off the value without consulting the bool. Each side is now executable_*_value: Option — None when that side is non-executable this flush — so a degenerate mark cannot be priced by construction. Constructor derivation and quote/refund behavior unchanged. Co-Authored-By: Claude Fable 5 --- packages/predict/sources/plp/lp_book.move | 58 +++++++++++++---------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/packages/predict/sources/plp/lp_book.move b/packages/predict/sources/plp/lp_book.move index a67591397..d0d876eb6 100644 --- a/packages/predict/sources/plp/lp_book.move +++ b/packages/predict/sources/plp/lp_book.move @@ -74,15 +74,17 @@ public struct RequestQueue has store { /// pool were worth the end least favorable to them — the party moving bears /// the marks' staleness, incumbents can be neither diluted nor drained, and a /// same-flush round trip strictly loses the spread. Fully fresh marks collapse -/// the spread to a single exact mark. Each side carries its own executability -/// (one side can refund while the other fills). Terms are read by name so the -/// pricing helpers cannot transpose numerator and denominator. +/// the spread to a single exact mark. Executability is per side, in the type: +/// a side's value is `None` when that side is not executable this flush (one +/// side can refund while the other fills), so no consumer can price off a +/// degenerate mark. Terms are read by name so the pricing helpers cannot +/// transpose numerator and denominator. public struct FlushMark has drop { - supply_value: u64, - withdraw_value: u64, + /// Mid NAV plus the flush's measured worst-case drift. + executable_supply_value: Option, + /// Mid NAV minus the flush's measured worst-case drift. + executable_withdraw_value: Option, total_supply: u64, - supply_executable: bool, - withdraw_executable: bool, } /// Result of draining both LP queues at one frozen mark. @@ -168,11 +170,9 @@ public(package) fun new_flush_mark( total_supply: u64, ): FlushMark { FlushMark { - supply_value, - withdraw_value, + executable_supply_value: executable_mark_value(supply_value, total_supply), + executable_withdraw_value: executable_mark_value(withdraw_value, total_supply), total_supply, - supply_executable: is_executable_mark(supply_value, total_supply), - withdraw_executable: is_executable_mark(withdraw_value, total_supply), } } @@ -597,27 +597,33 @@ fun entry_offset(entries: &vector, index: u64): u64 { /// LP shares minted for `amount` DUSDC at the mark's supply side. `None` means the /// mark/request pair is not executable and the queued request must be refunded. fun quote_supply_shares(mark: &FlushMark, amount: u64): Option { - if (!mark.supply_executable) return option::none(); - // = amount * total_supply / supply_value, round down (supplier mints ≤1 ulp - // fewer shares; the pool keeps the dust). - math::try_mul_div_down(amount, mark.total_supply, mark.supply_value).and!(|shares| { - if (shares == 0) option::none() else option::some(shares) + mark.executable_supply_value.and_ref!(|supply_value| { + // = amount * total_supply / supply_value, round down (supplier mints ≤1 ulp + // fewer shares; the pool keeps the dust). + math::try_mul_div_down(amount, mark.total_supply, *supply_value).and!(|shares| { + if (shares == 0) option::none() else option::some(shares) + }) }) } /// DUSDC owed for `shares` LP at the mark's withdraw side. `None` means the /// mark/request pair is not executable and the queued request must be refunded. fun quote_withdraw_dusdc(mark: &FlushMark, shares: u64): Option { - if (!mark.withdraw_executable) return option::none(); - // = shares * withdraw_value / total_supply, round down (withdrawer is paid - // ≤1 ulp less; the pool keeps the dust). - math::try_mul_div_down(shares, mark.withdraw_value, mark.total_supply).and!(|payout| { - if (payout == 0) option::none() else option::some(payout) + mark.executable_withdraw_value.and_ref!(|withdraw_value| { + // = shares * withdraw_value / total_supply, round down (withdrawer is paid + // ≤1 ulp less; the pool keeps the dust). + math::try_mul_div_down(shares, *withdraw_value, mark.total_supply).and!(|payout| { + if (payout == 0) option::none() else option::some(payout) + }) }) } -fun is_executable_mark(pool_value: u64, total_supply: u64): bool { - if (total_supply == 0) return false; +/// `Some(pool_value)` when a queue side may execute at this value: a non-empty +/// supply whose per-share price computes cleanly on both rounding sides and +/// stays inside the executable price envelope; `None` marks the side +/// non-executable for the whole flush. +fun executable_mark_value(pool_value: u64, total_supply: u64): Option { + if (total_supply == 0) return option::none(); let price_floor = math::try_mul_div_down( pool_value, constants::plp_price_unit!(), @@ -628,8 +634,10 @@ fun is_executable_mark(pool_value: u64, total_supply: u64): bool { constants::plp_price_unit!(), total_supply, ); - price_floor.is_some() + let executable = + price_floor.is_some() && price_ceil.is_some() && *price_floor.borrow() >= constants::min_executable_plp_price!() - && *price_ceil.borrow() <= constants::max_executable_plp_price!() + && *price_ceil.borrow() <= constants::max_executable_plp_price!(); + if (executable) option::some(pool_value) else option::none() } From 768894139de01182ca5057e8fb814d1c0e1a9239 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 17:37:26 -0400 Subject: [PATCH 21/23] =?UTF-8?q?predict:=20review=20cleanup=20=E2=80=94?= =?UTF-8?q?=20stale=20references,=20dead=20code,=20comment=20de-duplicatio?= =?UTF-8?q?n,=20config=20fold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent simplification review of the branch (comment directness + surface reduction); all ten findings applied: - Fix comments referencing code the branch itself renamed or deleted: flushable_atoms/FlushAtoms ghosts, plp::value_expiry (now collect_expiry_nav), FlushExecuted.total_drift pointing at the deleted drift-budget config, current_nav/finish_flush describing the pre-spread one-mark world. - Delete dead strike_exposure::total_live_quantity (zero callers since the mark moved in; mark_drift reads the tree aggregate directly). - Write each core design argument once at its owner and point elsewhere: anchor basis on ValuationMark, two-sided spread on FlushMark, pool-level floor in lp_pool_value, cash-move guard on PoolValuation.cash_revision. - Collapse the thrice-repeated mark-presence assert into one private mark() borrow helper; drop NavRefreshed field docs restating the struct doc; drop the tree getter's imported consumer rationale. - Rename the expiry_market forwarder record_valuation_mark -> refresh_valuation_mark to match the operation it delegates to. - Fold nav_mark_freshness_ms into ProtocolConfig as a bare validated field and delete the one-field valuation_config module — the second knob that licensed the struct (drift epsilon) was deleted when drift became the bid/ask spread. Co-Authored-By: Claude Fable 5 --- .../sources/config/protocol_config.move | 17 +++-- .../sources/config/valuation_config.move | 37 ---------- packages/predict/sources/constants.move | 8 +-- .../predict/sources/events/vault_events.move | 12 ++-- packages/predict/sources/expiry_market.move | 24 +++---- packages/predict/sources/plp/plp.move | 33 ++++----- .../predict/sources/plp/pool_accounting.move | 6 +- .../index/strike_payout_tree.move | 4 +- .../strike_exposure/strike_exposure.move | 67 ++++++++----------- packages/predict/sources/valuation_mark.move | 17 ++--- 10 files changed, 78 insertions(+), 147 deletions(-) delete mode 100644 packages/predict/sources/config/valuation_config.move diff --git a/packages/predict/sources/config/protocol_config.move b/packages/predict/sources/config/protocol_config.move index 4d47ad662..dbb5a9e3d 100644 --- a/packages/predict/sources/config/protocol_config.move +++ b/packages/predict/sources/config/protocol_config.move @@ -17,8 +17,7 @@ use deepbook_predict::{ expiry_cash_config::{Self, ExpiryCashConfig}, pricing_config::{Self, PricingConfig}, stake_config::{Self, StakeConfig}, - strike_exposure_config::{Self, StrikeExposureConfig}, - valuation_config::{Self, ValuationConfig} + strike_exposure_config::{Self, StrikeExposureConfig} }; const ETradingPaused: u64 = 0; @@ -46,7 +45,10 @@ public struct ProtocolConfig has key { version_watermark: u64, /// Blocks new risk creation while true. trading_paused: bool, - valuation_config: ValuationConfig, + /// Hard ceiling on stored valuation-mark age at the pool flush, in + /// milliseconds — the backstop against a stalled feed, which shows zero + /// measured drift and so cannot be caught by the drift spread. + nav_mark_freshness_ms: u64, } // === Public Functions === @@ -192,7 +194,8 @@ public fun set_nav_mark_freshness_ms( value: u64, ) { config.assert_version(); - config.valuation_config.set_nav_mark_freshness_ms(value); + config_constants::assert_nav_mark_freshness_ms(value); + config.nav_mark_freshness_ms = value; } /// Set the trading loss rebate rate template used by future expiry markets. @@ -308,8 +311,8 @@ public(package) fun ewma_config(config: &ProtocolConfig): &EwmaConfig { &config.ewma_config } -public(package) fun valuation_config(config: &ProtocolConfig): &ValuationConfig { - &config.valuation_config +public(package) fun nav_mark_freshness_ms(config: &ProtocolConfig): u64 { + config.nav_mark_freshness_ms } /// Abort unless the running package version is at or above the watermark floor. @@ -365,7 +368,7 @@ fun new(ctx: &mut TxContext): ProtocolConfig { ewma_config: ewma_config::new(), version_watermark: constants::current_version!(), trading_paused: false, - valuation_config: valuation_config::new(), + nav_mark_freshness_ms: config_constants::default_nav_mark_freshness_ms!(), } } diff --git a/packages/predict/sources/config/valuation_config.move b/packages/predict/sources/config/valuation_config.move deleted file mode 100644 index b47bed656..000000000 --- a/packages/predict/sources/config/valuation_config.move +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Mysten Labs, Inc. -// SPDX-License-Identifier: Apache-2.0 - -/// Stored flush-acceptance policy for per-market valuation marks. -/// -/// ProtocolConfig owns this mutable policy. The pool flush reads it when -/// deciding whether a market's stored valuation mark is still usable: a hard -/// freshness ceiling on mark age — the backstop against a stalled feed, which -/// shows zero measured drift and so cannot be caught by the drift spread. -/// Oracle drift itself needs no tolerance knob: the measured worst-case drift -/// is priced into the flush mark as a bid/ask spread borne by the transactor. -module deepbook_predict::valuation_config; - -use deepbook_predict::config_constants; - -/// Acceptance parameters for stored valuation marks at the pool flush. -public struct ValuationConfig has store { - /// Hard ceiling on stored-mark age in milliseconds, regardless of oracle drift. - nav_mark_freshness_ms: u64, -} - -// === Public-Package Functions === - -public(package) fun nav_mark_freshness_ms(config: &ValuationConfig): u64 { - config.nav_mark_freshness_ms -} - -public(package) fun new(): ValuationConfig { - ValuationConfig { - nav_mark_freshness_ms: config_constants::default_nav_mark_freshness_ms!(), - } -} - -public(package) fun set_nav_mark_freshness_ms(config: &mut ValuationConfig, value: u64) { - config_constants::assert_nav_mark_freshness_ms(value); - config.nav_mark_freshness_ms = value; -} diff --git a/packages/predict/sources/constants.move b/packages/predict/sources/constants.move index 14bced571..064f9f753 100644 --- a/packages/predict/sources/constants.move +++ b/packages/predict/sources/constants.move @@ -160,10 +160,10 @@ public(package) macro fun min_fee_incentive_sponsorship(): u64 { 10_000_000 } /// resolution relayer sources that key from Pyth Lazer's exact-timestamp /// resolution endpoints and inserts it on this millisecond grid. /// Cadence periods are multiples of this value, so cadence-created expiries stay -/// on a settling key the relayer can produce. An off-grid expiry could never settle -/// and would block the pool flush indefinitely -/// (`plp::value_expiry` aborts on a past-expiry market that has no settling -/// observation yet). +/// on a settling key the relayer can produce. An off-grid expiry could never +/// settle and would block the pool flush indefinitely (a past-expiry market +/// cannot refresh its valuation mark — `pricing::load_live_pricer` rejects it — +/// so `plp::collect_expiry_nav` aborts stale until settlement sweeps it). public(package) macro fun resolution_period_ms(): u64 { one_minute_ms!() } // === Strike Tick Domain === diff --git a/packages/predict/sources/events/vault_events.move b/packages/predict/sources/events/vault_events.move index 4f3938272..49384df24 100644 --- a/packages/predict/sources/events/vault_events.move +++ b/packages/predict/sources/events/vault_events.move @@ -157,9 +157,7 @@ public struct WithdrawFilled has copy, drop, store { public struct NavRefreshed has copy, drop, store { pool_vault_id: ID, expiry_market_id: ID, - /// Exact oracle-priced per-order liability stored in the mark. liability: u64, - /// On-chain landing time of the refresh that computed the mark. computed_at_ms: u64, } @@ -179,15 +177,13 @@ public struct FlushExecuted has copy, drop, store { /// floored once at zero. pool_value: u64, total_supply: u64, - /// Σ of each counted market's live free cash at the flush (raw, unfloored). - /// Together with `total_liability` this exposes aggregate-underwater - /// conditions that a netted NAV would hide. + /// Σ of each counted market's live free cash at the flush (raw, unfloored; + /// with `total_liability` this exposes aggregate-underwater conditions). total_free_cash: u64, /// Σ of each counted market's marked liability at the flush (raw, unfloored). total_liability: u64, - /// Σ of each counted market's worst-case dollar drift accepted by this - /// flush — the budget headroom signal (compare against the configured - /// fraction of `pool_value`). + /// Σ of each counted market's worst-case dollar drift, applied at + /// `finish_flush` as the flush mark's bid/ask half-spread. total_drift: u64, /// Number of active markets valued for this flush. market_count: u64, diff --git a/packages/predict/sources/expiry_market.move b/packages/predict/sources/expiry_market.move index b731a25a0..95657aa8e 100644 --- a/packages/predict/sources/expiry_market.move +++ b/packages/predict/sources/expiry_market.move @@ -237,22 +237,18 @@ public fun load_live_pricer( /// negative. `load_live_pricer` binds the propbook feeds to this market's current /// Propbook registry mapping, rejects a past-expiry market, and gates oracle freshness. /// -/// A past-expiry market that has not settled cannot produce this pricer. There is -/// no solvency-safe NAV for an unsettled past-expiry market: the flush uses one -/// mark for both supply and withdraw, so the mark must equal the -/// settlement-dependent true value. Flows that branch on settlement call -/// `ensure_settled` first, using Propbook's exact Pyth timestamp at expiry; if no -/// exact spot exists yet, the live-pricing liveness abort remains the correct -/// failure mode. +/// A past-expiry market that has not settled cannot produce this pricer: its +/// true value is settlement-dependent, so it has no solvency-safe live NAV. +/// Flows that branch on settlement call `ensure_settled` first, using +/// Propbook's exact Pyth timestamp at expiry; if no exact spot exists yet, the +/// live-pricing liveness abort remains the correct failure mode. public fun current_nav(market: &ExpiryMarket, pricer: &Pricer): u64 { market.assert_pricer_bound(pricer); let liability = market.strike_exposure.exact_live_liability(pricer); - // Floor at 0 for this single-market READ only. A market can legitimately owe - // more than it holds (a backing lambda below 1 admits transient shortfalls - // that pool rebalancing later refills), so an unfloored per-market value is - // meaningful — which is why the flush no longer consumes this function: it - // aggregates raw `flushable_atoms` and nets shortfalls at the pool level. - // Here the floor only keeps a devInspect/SDK read total-ordered at zero. + // Floor at 0: a devInspect/SDK read of one market's limited-recourse value + // is never negative. The flush does not read this — it sums each market's + // unfloored free cash and marked liability and floors once at the pool + // (`plp::lp_pool_value`). market.cash.free_cash().saturating_sub(liability) } @@ -747,7 +743,7 @@ public(package) fun mark_drift(market: &ExpiryMarket, pricer: &Pricer): u64 { /// Recompute this market's exact per-order live liability and store it as the /// valuation mark the pool flush reads, replacing any prior mark. Returns the /// stored liability. -public(package) fun record_valuation_mark( +public(package) fun refresh_valuation_mark( market: &mut ExpiryMarket, pricer: &Pricer, clock: &Clock, diff --git a/packages/predict/sources/plp/plp.move b/packages/predict/sources/plp/plp.move index 09e685f39..8edabeece 100644 --- a/packages/predict/sources/plp/plp.move +++ b/packages/predict/sources/plp/plp.move @@ -91,17 +91,14 @@ public struct PoolValuation { expected_expiry_markets: vector, /// Markets counted so far this flow; folded against `expected` at finish. valued_expiry_markets: vector, - /// Running Σ of each counted market's live free cash. Kept separate from the - /// liability sum (raw atoms, no per-market netting or floor) so underwater - /// markets net against the whole pool and the single zero floor is applied - /// once, in `lp_pool_value`. + /// Running Σ of each counted market's live free cash — raw and unfloored, + /// so underwater markets net against the whole pool; the single zero floor + /// is `lp_pool_value`'s. total_free_cash: u64, /// Running Σ of each counted market's marked liability. total_liability: u64, - /// Running Σ of each counted market's measured worst-case drift, in DUSDC — - /// the dollar amount pool NAV could be off by from marks aging against - /// moving feeds. Becomes the flush mark's half-spread at `finish_flush`: - /// supplies price at the mid NAV plus it, withdrawals at the mid minus it. + /// Running Σ of each counted market's measured worst-case dollar drift; + /// becomes the flush mark's bid/ask half-spread at `finish_flush`. total_drift: u64, /// The vault ledger's cash revision at `start_pool_valuation`. `finish_flush` /// asserts it unchanged: the potato reads each market's cash at its collect @@ -249,7 +246,7 @@ public fun refresh_expiry_nav( let expiry_market_id = market.id(); vault.expiry_accounting.assert_registered_expiry(expiry_market_id); vault.rebalance_live_expiry(market, expiry_market_id); - let liability = market.record_valuation_mark(pricer, clock); + let liability = market.refresh_valuation_mark(pricer, clock); vault_events::emit_nav_refreshed( vault.id(), expiry_market_id, @@ -320,7 +317,7 @@ public fun collect_expiry_nav( valuation.assert_expiry_ready_to_value(expiry_market_id); assert!( clock.timestamp_ms() - market.mark_computed_at_ms() - <= config.valuation_config().nav_mark_freshness_ms(), + <= config.nav_mark_freshness_ms(), EValuationMarkStale, ); valuation.valued_expiry_markets.push_back(expiry_market_id); @@ -333,8 +330,8 @@ public fun collect_expiry_nav( /// was counted exactly once, price the pool NAV, then drain the supply/withdraw queues /// at that frozen mark (mint PLP for supplies, burn PLP and pay DUSDC for /// withdrawals), consume the potato, and return the LP-attributable pool-wide DUSDC -/// NAV (idle + Σ active NAV, net of the pending-protocol-profit exclusion priced -/// from the aggregate profit basis). +/// NAV (idle + Σ free cash − Σ marked liability, net of the pending-protocol-profit +/// exclusion priced from the aggregate profit basis). /// /// `supply_budget` / `withdraw_budget` bound how many requests each queue may /// process this flush (`None` = unbounded). Filled heads, protocol-refunded @@ -379,13 +376,9 @@ public fun finish_flush( let total_supply = vault.lp.total_supply(); let market_count = valued_expiry_markets.length(); - // Price the two-sided mark: the counted marks' combined worst-case drift is - // the half-spread — supplies price the pool at the mid plus it, withdrawals - // at the mid minus it — so the true pool value provably sits between the - // sides and the transacting party bears the marks' staleness. No drift - // threshold: a wide spread self-resolves through each request's own - // min-out limit (miss, carry, refund), and fully fresh marks collapse the - // spread to an exact single mark. + // Two-sided mark: total_drift is the bid/ask half-spread; no drift + // threshold — a wide spread self-resolves through each request's own + // min-out limit (see `FlushMark`). let vault_id = vault.id(); let mark = lp_book::new_flush_mark( pool_nav + total_drift, @@ -802,7 +795,7 @@ fun claim_trading_loss_rebate_internal( /// LP-attributable DUSDC pool value used to price PLP supply/withdraw. /// /// Assets and liabilities arrive as raw per-market sums (no per-market netting or -/// floor — see `FlushAtoms`): `gross = idle_balance + Σ free_cash`, owing +/// floor — see `PoolValuation`): `gross = idle_balance + Σ free_cash`, owing /// `Σ liability`. NAV prices the protocol's not-yet-materialized profit share /// before terminal materialization and excludes it from LP value: /// `exclusion = share * max(0, (credits + Σ free_cash) - (debits + Σ liability))` diff --git a/packages/predict/sources/plp/pool_accounting.move b/packages/predict/sources/plp/pool_accounting.move index ca9a05ca4..a78b6fb56 100644 --- a/packages/predict/sources/plp/pool_accounting.move +++ b/packages/predict/sources/plp/pool_accounting.move @@ -39,10 +39,8 @@ public struct Ledger has store { /// markets at materialization. Excluded from LP value until drained. pending_protocol_profit: u64, /// Bumped on every internal cash move (idle<->expiry transfers and - /// protocol-profit draws from idle). The flush potato snapshots it at start - /// and `finish_flush` asserts it unchanged: the potato reads each market's - /// cash at its collect and idle at finish, so a move between those reads - /// would double- or under-count the moved amount in the priced NAV. + /// protocol-profit draws from idle). Snapshotted and re-checked by the + /// flush potato (`PoolValuation.cash_revision`). cash_revision: u64, } diff --git a/packages/predict/sources/strike_exposure/index/strike_payout_tree.move b/packages/predict/sources/strike_exposure/index/strike_payout_tree.move index cdc426222..d86dffe12 100644 --- a/packages/predict/sources/strike_exposure/index/strike_payout_tree.move +++ b/packages/predict/sources/strike_exposure/index/strike_payout_tree.move @@ -63,9 +63,7 @@ public struct PayoutNode has copy, drop, store { /// contributes its quantity to exactly one start side (`base` for `-inf` /// lower boundaries, a node otherwise), so the root summary's start total plus /// the base is the whole book's face outstanding — an O(1) read of state the -/// treap already maintains. This is the book's price-sensitivity constant: -/// per-order value is quantity-Lipschitz in price, so the worst dollar NAV -/// drift for a price move `d` is `total_quantity * d`. +/// treap already maintains. public(package) fun total_quantity(tree: &StrikePayoutTree): u64 { let mut total = tree.base.quantity; if (tree.root.is_some()) { diff --git a/packages/predict/sources/strike_exposure/strike_exposure.move b/packages/predict/sources/strike_exposure/strike_exposure.move index 7b32384af..6855e435e 100644 --- a/packages/predict/sources/strike_exposure/strike_exposure.move +++ b/packages/predict/sources/strike_exposure/strike_exposure.move @@ -118,12 +118,6 @@ public(package) fun range_probability(quote: &CloseQuote): u64 { quote.range_probability } -/// Return the total face quantity of this book's live orders (O(1), from the -/// payout tree's maintained aggregates). The drift dollarization multiplier. -public(package) fun total_live_quantity(exposure: &StrikeExposure): u64 { - exposure.payout.total_quantity() -} - /// Return the buffered live reserve, or exact remaining settled payout liability once materialized. /// /// Live reserve is the settlement floor (max single-point net payout) plus a @@ -166,36 +160,29 @@ public(package) fun exact_live_liability(exposure: &StrikeExposure, pricer: &Pri } /// Return the stored mark's current liability: the refresh walk's number plus -/// anchor-priced trade write-through since. The payout tree is never walked -/// here — that is the point: the walk ran in the refresh that stored the mark, -/// and this read loads no per-order objects. Deliberately unclamped against -/// cash — a market can legitimately owe more than it holds (a backing lambda -/// below 1 admits transient shortfalls backstopped by pool rebalancing) — and -/// deliberately a dumb fact: whether the mark is fresh enough is judged by -/// `plp`, which aggregates across markets. +/// anchor-priced trade write-through since. Never walks the payout tree — the +/// walk ran in the refresh that stored the mark, so this read loads no +/// per-order objects. Deliberately unclamped against cash: a market can +/// legitimately owe more than it holds (a backing lambda below 1 admits +/// transient shortfalls backstopped by pool rebalancing); `plp::lp_pool_value` +/// owns the single pool-level floor, and `plp` judges freshness. public(package) fun marked_liability(exposure: &StrikeExposure): u64 { - assert!(exposure.valuation_mark.is_some(), EValuationMarkMissing); - exposure.valuation_mark.borrow().liability() + exposure.mark().liability() } /// Return the landing time of the refresh that computed the stored mark. public(package) fun mark_computed_at_ms(exposure: &StrikeExposure): u64 { - assert!(exposure.valuation_mark.is_some(), EValuationMarkMissing); - exposure.valuation_mark.borrow().computed_at_ms() -} - -/// Measure the stored mark's potential oracle drift against the live inputs -/// in `pricer`, in DUSDC base units: the worst-case single-contract price move -/// since the walk (`valuation_mark::drift`, a fraction of full payout) times -/// the book's live face quantity — per-order value is quantity-Lipschitz in -/// price, so this bounds how far this book's liability can have drifted. The -/// quantity is read live and the write-through deltas are valued at the mark's -/// own anchors, so the whole current book shares one valuation time and this -/// endpoint bound covers it. A measurement, not a judgment — `plp` sums across -/// markets and prices the total into the flush mark's bid/ask spread. + exposure.mark().computed_at_ms() +} + +/// The stored mark's worst-case oracle drift against the live inputs in +/// `pricer`, in DUSDC base units: the endpoint envelope +/// (`valuation_mark::drift`, a fraction of full payout) times the book's live +/// face quantity — per-order value is quantity-Lipschitz in price, so this +/// bounds how far this book's liability can have drifted. `plp` sums it +/// across markets into the flush mark's bid/ask half-spread. public(package) fun mark_drift(exposure: &StrikeExposure, pricer: &Pricer): u64 { - assert!(exposure.valuation_mark.is_some(), EValuationMarkMissing); - math::mul(exposure.payout.total_quantity(), exposure.valuation_mark.borrow().drift(pricer)) + math::mul(exposure.payout.total_quantity(), exposure.mark().drift(pricer)) } /// Return the liquidation LTV snapshotted for this exposure book. @@ -554,6 +541,12 @@ fun gross_order_value(exposure: &StrikeExposure, pricer: &Pricer, order: &Order) math::mul(range_probability, order.quantity()) } +/// Borrow the stored valuation mark; aborts before the first refresh. +fun mark(exposure: &StrikeExposure): &ValuationMark { + assert!(exposure.valuation_mark.is_some(), EValuationMarkMissing); + exposure.valuation_mark.borrow() +} + /// Value one order exactly as the liability walk counts it: anchor-priced /// range value net of the static floor, floored at zero (this order's /// `walk_linear` minus `correction_value` contribution). Deliberately no @@ -561,23 +554,21 @@ fun gross_order_value(exposure: &StrikeExposure, pricer: &Pricer, order: &Order) /// walk carries a liquidatable-yet-unliquidated order's `gross - floor` until /// an actual knock-out removes it. fun mark_order_value(exposure: &StrikeExposure, order: &Order): u64 { - let anchor_pricer = exposure.valuation_mark.borrow().anchor_pricer(exposure.expiry_market_id); + let anchor_pricer = exposure.mark().anchor_pricer(exposure.expiry_market_id); exposure.gross_order_value(&anchor_pricer, order).saturating_sub(order.floor_shares()) } -/// Write an added order through to the stored mark, valued at the mark's own -/// anchors — never at the op's oracle — so the marked liability stays a -/// single-anchor valuation of the current book and the endpoint drift envelope -/// bounds it. No-op until the first refresh establishes a mark. +/// Write an added order through to the stored mark at the mark's own anchors +/// (one valuation basis — see `ValuationMark`). No-op until the first refresh +/// establishes a mark. fun mark_order_added(exposure: &mut StrikeExposure, order: &Order) { if (exposure.valuation_mark.is_none()) return; let value = exposure.mark_order_value(order); exposure.valuation_mark.borrow_mut().add_value(value); } -/// Write a removed order (live redeem or liquidation) through to the stored -/// mark, valued at the mark's own anchors. No-op until the first refresh -/// establishes a mark. +/// Counterpart of `mark_order_added` for removals (live redeems and +/// liquidations). fun mark_order_removed(exposure: &mut StrikeExposure, order: &Order) { if (exposure.valuation_mark.is_none()) return; let value = exposure.mark_order_value(order); diff --git a/packages/predict/sources/valuation_mark.move b/packages/predict/sources/valuation_mark.move index aeb4a72eb..7dcbe4022 100644 --- a/packages/predict/sources/valuation_mark.move +++ b/packages/predict/sources/valuation_mark.move @@ -5,15 +5,10 @@ /// /// A mark memoizes one market's exact oracle-priced per-order liability (the /// payout-tree walk) so the pool flush can read the market without walking any -/// tree. This module owns the mark's data discipline: construction at refresh, -/// accumulating the anchor-priced write-through values `strike_exposure` -/// maintains as its book mutates, and measuring `drift` — the potential dollar -/// impact of oracle movement since the walk, against a fresh `Pricer`. It -/// deliberately owns NO acceptance policy (whether a mark is fresh enough is -/// decided downstream by `plp`, which aggregates across markets) and NO order -/// valuation (the walk-consistent per-order formula is `strike_exposure`'s; -/// this component hands back its anchors as a `Pricer` and accumulates what -/// the owner valued). +/// tree. This module owns only the mark's data discipline; acceptance policy +/// (freshness) is `plp`'s, and order valuation is `strike_exposure`'s — the +/// mark hands back its anchors as a `Pricer` and accumulates what the owner +/// valued. module deepbook_predict::valuation_mark; use deepbook_predict::pricing::{Self, Pricer}; @@ -69,9 +64,7 @@ public(package) fun computed_at_ms(mark: &ValuationMark): u64 { /// have moved since the walk, as a fraction of full payout in FLOAT_SCALING /// (`pricing::drift_envelope` between the stored anchors and the live /// snapshot). Oracle movement only — trade deltas are already inside -/// `liability`, in the anchor basis this envelope re-values from. The caller -/// converts to dollars and judges in aggregate; this component measures, it -/// does not accept or reject. +/// `liability`. public(package) fun drift(mark: &ValuationMark, pricer: &Pricer): u64 { pricer.drift_envelope(mark.anchor_forward, &mark.anchor_svi) } From b3b3ff9f5ee37d53c500297a95e0954c73642191 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 18:39:57 -0400 Subject: [PATCH 22/23] predict: drift-replica parity vectors (M0 gate for the DBU-557 measurement) Generated fidelity vectors (drift_reference_data, 88 rows) + a parity test that seeds each vector's two oracle snapshots through the production ingest and asserts the chain's drift_envelope/up_price equal the DBU-557 measurement replica's outputs EXACTLY. Explicitly a two-sided consistency pin, not correctness coverage (pricing_exact_tests owns that via its independent erf reference): a green run proves the off-chain measurement computes what the chain computes, and any pricing.move change that shifts a covered value breaks loudly, flagging fixture regeneration. Coverage: zero early-out, every reachable fail-closed corner (incl. the round-down band collapse, found reachable in pricing-safe space), real captured SVI surfaces under perturbation ladders, one-ulp deltas, wing/grid strikes. Generator: predict-research scripts/drift_fixture_gen.py. Adds the missing set_bs_svi_for_testing fixture setter (lanes keep the newest source timestamp, so seeding a second surface snapshot needs an advanced one). Co-Authored-By: Claude Fable 5 --- .../predict/tests/helper/oracle_fixture.move | 62 ++++++ .../tests/pricing/drift_reference_data.move | 187 ++++++++++++++++++ .../pricing/drift_replica_parity_tests.move | 160 +++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 packages/predict/tests/pricing/drift_reference_data.move create mode 100644 packages/predict/tests/pricing/drift_replica_parity_tests.move diff --git a/packages/predict/tests/helper/oracle_fixture.move b/packages/predict/tests/helper/oracle_fixture.move index 39d57b124..5421185e5 100644 --- a/packages/predict/tests/helper/oracle_fixture.move +++ b/packages/predict/tests/helper/oracle_fixture.move @@ -495,6 +495,68 @@ public fun set_bs_forward_for_testing_bundle( self.set_bs_forward_for_testing(&mut oracle.bs, source_timestamp_ms, forward); } +/// Overwrite only the BS SVI row for this fixture's expiry through the real +/// ingest path. Lets a test seed a SECOND surface snapshot: the oracle lane +/// keeps the newest source timestamp, so re-seeding requires advancing it. +public fun set_bs_svi_for_testing( + self: &mut OracleFixture, + bs: &mut BlockScholesFeed, + source_timestamp_ms: u64, + svi_a: u64, + svi_b: u64, + svi_sigma: u64, + svi_rho_magnitude: u64, + svi_rho_is_negative: bool, + svi_m_magnitude: u64, + svi_m_is_negative: bool, +) { + let bs_source_id = bs.svi().bs_source_id(); + bs + .svi_mut() + .update( + update::new_svi_update( + bs_source_id, + self.expiry, + source_timestamp_ms, + svi_a, + svi_b, + svi_sigma, + svi_rho_magnitude, + svi_rho_is_negative, + svi_m_magnitude, + svi_m_is_negative, + ), + &self.clock, + self.scenario.ctx(), + ); +} + +/// Overwrite only the BS SVI row through an oracle bundle. +public fun set_bs_svi_for_testing_bundle( + self: &mut OracleFixture, + oracle: &mut OracleBundle, + source_timestamp_ms: u64, + svi_a: u64, + svi_b: u64, + svi_sigma: u64, + svi_rho_magnitude: u64, + svi_rho_is_negative: bool, + svi_m_magnitude: u64, + svi_m_is_negative: bool, +) { + self.set_bs_svi_for_testing( + &mut oracle.bs, + source_timestamp_ms, + svi_a, + svi_b, + svi_sigma, + svi_rho_magnitude, + svi_rho_is_negative, + svi_m_magnitude, + svi_m_is_negative, + ); +} + /// Overwrite the Pyth spot directly (for staleness / pricing-source tests), keeping /// the fixture clock as the on-chain landing timestamp. public fun set_pyth( diff --git a/packages/predict/tests/pricing/drift_reference_data.move b/packages/predict/tests/pricing/drift_reference_data.move new file mode 100644 index 000000000..03e827dc4 --- /dev/null +++ b/packages/predict/tests/pricing/drift_reference_data.move @@ -0,0 +1,187 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +// +// @generated by predict-research scripts/drift_fixture_gen.py — DO NOT EDIT. +// Fidelity vectors for the drift envelope: each row's two snapshots are seeded +// through the production oracle ingest and the chain's drift_envelope/up_price +// must equal the embedded values exactly. The same vectors are committed to +// predict-research as scripts/fixtures/drift_vectors_v1.csv, where the Python +// measurement replica asserts the identical outputs — a green run on both +// sides proves the replica computes what the chain computes (M0 gate for the +// DBU-557 drift measurements). +#[test_only] +module deepbook_predict::drift_reference_data; + +/// One fidelity vector: two oracle snapshots, a probe strike, and the exact +/// expected chain outputs. `has_price` is false for degenerate surfaces whose +/// up_price aborts (EZeroVariance) — those rows pin only the envelope. +public struct DriftVector has copy, drop { + spot0: u64, + bs_forward0: u64, + a0: u64, + b0: u64, + sigma0: u64, + rho0_mag: u64, + rho0_neg: bool, + m0_mag: u64, + m0_neg: bool, + spot1: u64, + bs_forward1: u64, + a1: u64, + b1: u64, + sigma1: u64, + rho1_mag: u64, + rho1_neg: bool, + m1_mag: u64, + m1_neg: bool, + strike: u64, + has_price: bool, + forward0: u64, + forward1: u64, + envelope: u64, + up0: u64, + up1: u64, +} + +public fun vectors(): vector { + vector[ + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 0, up0: 586463895, up1: 586463895 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107001000000000, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 107000000000000, bs_forward1: 107001000000000, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000999915000, forward1: 107000999915000, envelope: 0, up0: 586545171, up1: 586545171 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 0, b0: 0, sigma0: 1000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: false, forward0: 107000000000000, forward1: 107000000000000, envelope: 1000000000, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 0, b1: 0, sigma1: 1000000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: false, forward0: 107000000000000, forward1: 107000000000000, envelope: 1000000000, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 10700000000000000, bs_forward1: 10700000000000000, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 10700000000000000, envelope: 1000000000, up0: 586463895, up1: 1000000000 }, + DriftVector { spot0: 5350000000000000, bs_forward0: 5350000000000000, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 2000000000, bs_forward1: 2000000000, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 5350000000000000, forward1: 2000000000, envelope: 1000000000, up0: 1000000000, up1: 0 }, + DriftVector { spot0: 100000000000000000, bs_forward0: 100000000000000000, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 2, bs_forward1: 2, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 100000000000000000, forward1: 2, envelope: 1000000000, up0: 1000000000, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 0, b0: 10000000000, sigma0: 1000000, rho0_mag: 999999999, rho0_neg: true, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 1, b1: 10000000000, sigma1: 1000000, rho1_mag: 999999999, rho1_neg: true, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 0, b0: 30000000000, sigma0: 1000000, rho0_mag: 999999999, rho0_neg: true, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 1, b1: 30000000000, sigma1: 1000000, rho1_mag: 999999999, rho1_neg: true, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 0, b0: 100000000000, sigma0: 1000000, rho0_mag: 999999999, rho0_neg: true, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 1, b1: 100000000000, sigma1: 1000000, rho1_mag: 999999999, rho1_neg: true, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 50000000000, b0: 999500000, sigma0: 10000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 50000000001, b1: 999500000, sigma1: 10000000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 203203, up1: 203254 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 9, b0: 1000, sigma0: 3000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 8, b1: 1000, sigma1: 3000000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 499978150, up1: 535482459 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 9, b0: 1500, sigma0: 1500000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 8, b1: 1500, sigma1: 1500000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 499980982, up1: 537221006 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 9, b0: 1500, sigma0: 3000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 8, b1: 1500, sigma1: 3000000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 499979007, up1: 533973664 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 9, b0: 2000, sigma0: 1000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 8, b1: 2000, sigma1: 1000000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 499980982, up1: 537238880 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 5000, b0: 50000, sigma0: 2000000, rho0_mag: 500000000, rho0_neg: true, m0_mag: 0, m0_neg: false, spot1: 321000000000000, bs_forward1: 321000000000000, a1: 5000, b1: 50000, sigma1: 2000000, rho1_mag: 500000000, rho1_neg: true, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 321000000000000, envelope: 1000000000, up0: 501757713, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 5000, b0: 50000, sigma0: 2000000, rho0_mag: 500000000, rho0_neg: true, m0_mag: 0, m0_neg: false, spot1: 109140000000000, bs_forward1: 109140000000000, a1: 5000, b1: 50000, sigma1: 2000000, rho1_mag: 500000000, rho1_neg: true, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 109140000000000, envelope: 1000000000, up0: 501757713, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 100000000000, b0: 500, sigma0: 1000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 1000, b1: 50000000, sigma1: 1000000, rho1_mag: 950000000, rho1_neg: true, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 286, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 50000000000, b0: 500000000, sigma0: 1000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 49999999999, b1: 500000000, sigma1: 1000000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 203462, up1: 203717 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 100000000000, b0: 100000000, sigma0: 1000000, rho0_mag: 0, rho0_neg: false, m0_mag: 0, m0_neg: false, spot1: 107001000000000, bs_forward1: 107001000000000, a1: 99999999999, b1: 100000000, sigma1: 1000000, rho1_mag: 0, rho1_neg: false, m1_mag: 0, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107001000000000, envelope: 1000000000, up0: 286, up1: 286 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 558, b0: 12069, sigma0: 1000000, rho0_mag: 849637021, rho0_neg: true, m0_mag: 511621, m0_neg: true, spot1: 107113238581319, bs_forward1: 107113238581319, a1: 560, b1: 12071, sigma1: 1000001, rho1_mag: 849636563, rho1_neg: true, m1_mag: 511621, m1_neg: true, strike: 105716000000000, has_price: true, forward0: 107000000000000, forward1: 107113238581319, envelope: 609264965, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 4103, b0: 62222, sigma0: 1000000, rho0_mag: 668836139, rho0_neg: true, m0_mag: 2434727, m0_neg: true, spot1: 106862148601245, bs_forward1: 106862148601245, a1: 4104, b1: 62216, sigma1: 1000097, rho1_mag: 668871050, rho1_neg: true, m1_mag: 2434510, m1_neg: true, strike: 106743000000000, has_price: true, forward0: 107000000000000, forward1: 106862148601245, envelope: 295961769, up0: 882461526, up1: 706958183 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 273413, b0: 15232521, sigma0: 22951218, rho0_mag: 210693199, rho0_neg: true, m0_mag: 13811427, m0_neg: false, spot1: 107065330190083, bs_forward1: 107065330190083, a1: 271703, b1: 15374435, sigma1: 22943995, rho1_mag: 212226226, rho1_neg: true, m1_mag: 13916433, m1_neg: false, strike: 109247000000000, has_price: true, forward0: 107000000000000, forward1: 107065330190083, envelope: 1000000000, up0: 190986634, up1: 200340149 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2878, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 958156, m0_neg: true, spot1: 106946808616692, bs_forward1: 106946808616692, a1: 3361, b1: 102825, sigma1: 1000000, rho1_mag: 963900696, rho1_neg: true, m1_mag: 1091885, m1_neg: true, strike: 110006000000000, has_price: true, forward0: 107000000000000, forward1: 106946808616692, envelope: 369012750, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 3301, b0: 212468, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1400582, m0_neg: true, spot1: 107178923889270, bs_forward1: 107178923889270, a1: 3301, b1: 212469, sigma1: 1000000, rho1_mag: 939999870, rho1_neg: true, m1_mag: 1400582, m1_neg: true, strike: 107716000000000, has_price: true, forward0: 107000000000000, forward1: 107178923889270, envelope: 722797952, up0: 151684, up1: 3314590 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1104, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 44973, m0_neg: false, spot1: 107144515187123, bs_forward1: 107144515187123, a1: 1104, b1: 99990, sigma1: 1000000, rho1_mag: 939936308, rho1_neg: true, m1_mag: 44971, m1_neg: false, strike: 104956000000000, has_price: true, forward0: 107000000000000, forward1: 107144515187123, envelope: 850129662, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 55728, b0: 7779162, sigma0: 10177339, rho0_mag: 291291200, rho0_neg: true, m0_mag: 102783, m0_neg: false, spot1: 106886924073889, bs_forward1: 106886924073889, a1: 56153, b1: 7708980, sigma1: 10132075, rho1_mag: 289091630, rho1_neg: true, m1_mag: 101877, m1_neg: false, strike: 108626000000000, has_price: true, forward0: 107000000000000, forward1: 106886924073889, envelope: 1000000000, up0: 85079194, up1: 74350771 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 6069, b0: 115917, sigma0: 2843876, rho0_mag: 940851141, rho0_neg: true, m0_mag: 11276306, m0_neg: false, spot1: 107077921977012, bs_forward1: 107077921977012, a1: 5781, b1: 123173, sigma1: 3445575, rho1_mag: 934285662, rho1_neg: true, m1_mag: 11755781, m1_neg: false, strike: 109600000000000, has_price: true, forward0: 107000000000000, forward1: 107077921977012, envelope: 339457813, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 3238, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 2949756, m0_neg: false, spot1: 107203411212307, bs_forward1: 107203411212307, a1: 3236, b1: 99998, sigma1: 1000001, rho1_mag: 940000853, rho1_neg: true, m1_mag: 2949756, m1_neg: false, strike: 104464000000000, has_price: true, forward0: 107000000000000, forward1: 107203411212307, envelope: 581825921, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 4739, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1284911, m0_neg: true, spot1: 107149208044636, bs_forward1: 107149208044636, a1: 4740, b1: 99993, sigma1: 1000073, rho1_mag: 940021080, rho1_neg: true, m1_mag: 1284792, m1_neg: true, strike: 107952000000000, has_price: true, forward0: 107000000000000, forward1: 107149208044636, envelope: 333141377, up0: 26305, up1: 324785 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 305464, b0: 20690976, sigma0: 23791399, rho0_mag: 170679679, rho0_neg: true, m0_mag: 13306193, m0_neg: false, spot1: 106901910748084, bs_forward1: 106901910748084, a1: 305636, b1: 20868287, sigma1: 23858961, rho1_mag: 169507463, rho1_neg: true, m1_mag: 13394906, m1_neg: false, strike: 105930000000000, has_price: true, forward0: 107000000000000, forward1: 106901910748084, envelope: 1000000000, up0: 719012795, up1: 708895757 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 714370, b0: 20537765, sigma0: 29570036, rho0_mag: 631329040, rho0_neg: true, m0_mag: 2384674, m0_neg: false, spot1: 106859418349832, bs_forward1: 106859418349832, a1: 500081, b1: 17399200, sigma1: 28556544, rho1_mag: 532853076, rho1_neg: true, m1_mag: 2934990, m1_neg: false, strike: 108433000000000, has_price: true, forward0: 107000000000000, forward1: 106859418349832, envelope: 1000000000, up0: 376065412, up1: 325853203 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1417, b0: 48275, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 809113, m0_neg: false, spot1: 106955280679957, bs_forward1: 106955280679957, a1: 1417, b1: 48275, sigma1: 1000000, rho1_mag: 940000668, rho1_neg: true, m1_mag: 809113, m1_neg: false, strike: 109247000000000, has_price: true, forward0: 107000000000000, forward1: 106955280679957, envelope: 173821497, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 416, b0: 13610, sigma0: 1000000, rho0_mag: 923179193, rho0_neg: true, m0_mag: 2398616, m0_neg: true, spot1: 106931690945152, bs_forward1: 106931690945152, a1: 416, b1: 13612, sigma1: 1000096, rho1_mag: 923250696, rho1_neg: true, m1_mag: 2398515, m1_neg: true, strike: 104539000000000, has_price: true, forward0: 107000000000000, forward1: 106931690945152, envelope: 437019084, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2254, b0: 107550, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1668891, m0_neg: true, spot1: 107125898720136, bs_forward1: 107125898720136, a1: 2255, b1: 107137, sigma1: 1000000, rho1_mag: 949077059, rho1_neg: true, m1_mag: 1652619, m1_neg: true, strike: 108123000000000, has_price: true, forward0: 107000000000000, forward1: 107125898720136, envelope: 480616534, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 587, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 82148, m0_neg: false, spot1: 106889727226653, bs_forward1: 106889727226653, a1: 479, b1: 125455, sigma1: 1278737, rho1_mag: 853857893, rho1_neg: true, m1_mag: 71128, m1_neg: false, strike: 105502000000000, has_price: true, forward0: 107000000000000, forward1: 106889727226653, envelope: 1000000000, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 79, b0: 21123, sigma0: 67569915, rho0_mag: 811376173, rho0_neg: true, m0_mag: 19710886, m0_neg: false, spot1: 107170580698443, bs_forward1: 107170580698443, a1: 80, b1: 21121, sigma1: 67569863, rho1_mag: 811375560, rho1_neg: true, m1_mag: 19710900, m1_neg: false, strike: 104881000000000, has_price: true, forward0: 107000000000000, forward1: 107170580698443, envelope: 766163037, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1840, b0: 77500, sigma0: 1000000, rho0_mag: 652336704, rho0_neg: true, m0_mag: 1408644, m0_neg: true, spot1: 107135758185467, bs_forward1: 107135758185467, a1: 1841, b1: 77503, sigma1: 1000000, rho1_mag: 652345932, rho1_neg: true, m1_mag: 1408777, m1_neg: true, strike: 106679000000000, has_price: true, forward0: 107000000000000, forward1: 107135758185467, envelope: 486408598, up0: 983489900, up1: 998198482 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 108545, b0: 7287824, sigma0: 58786474, rho0_mag: 478579309, rho0_neg: true, m0_mag: 27614318, m0_neg: false, spot1: 106952207732757, bs_forward1: 106952207732757, a1: 109543, b1: 7320648, sigma1: 58256355, rho1_mag: 481480918, rho1_neg: true, m1_mag: 27534963, m1_neg: false, strike: 108861000000000, has_price: true, forward0: 107000000000000, forward1: 106952207732757, envelope: 281136165, up0: 263468317, up1: 256829678 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 701, b0: 53125, sigma0: 1000000, rho0_mag: 481819375, rho0_neg: true, m0_mag: 391786, m0_neg: true, spot1: 107009106670317, bs_forward1: 107009106670317, a1: 807, b1: 40494, sigma1: 1262896, rho1_mag: 588888386, rho1_neg: true, m1_mag: 320426, m1_neg: true, strike: 109685000000000, has_price: true, forward0: 107000000000000, forward1: 107009106670317, envelope: 436974659, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 513048, b0: 8962514, sigma0: 23914793, rho0_mag: 652532402, rho0_neg: true, m0_mag: 794308, m0_neg: true, spot1: 107172571198171, bs_forward1: 107172571198171, a1: 513046, b1: 8962512, sigma1: 23914807, rho1_mag: 652532468, rho1_neg: true, m1_mag: 794309, m1_neg: true, strike: 108958000000000, has_price: true, forward0: 107000000000000, forward1: 107172571198171, envelope: 146710668, up0: 240371313, up1: 262080627 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 3842, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 783096, m0_neg: false, spot1: 107081054199303, bs_forward1: 107081054199303, a1: 3843, b1: 100003, sigma1: 1000080, rho1_mag: 939975415, rho1_neg: true, m1_mag: 783028, m1_neg: false, strike: 107973000000000, has_price: true, forward0: 107000000000000, forward1: 107081054199303, envelope: 205925571, up0: 2238, up1: 13000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 882194, b0: 13111553, sigma0: 47569051, rho0_mag: 832705553, rho0_neg: true, m0_mag: 12445785, m0_neg: false, spot1: 107175646754438, bs_forward1: 107175646754438, a1: 874694, b1: 13180258, sigma1: 47538466, rho1_mag: 831425974, rho1_neg: true, m1_mag: 12410579, m1_neg: false, strike: 106251000000000, has_price: true, forward0: 107000000000000, forward1: 107175646754438, envelope: 439852007, up0: 632582987, up1: 648374215 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 297799, b0: 11222024, sigma0: 29189514, rho0_mag: 134207954, rho0_neg: true, m0_mag: 19730286, m0_neg: false, spot1: 106885483513608, bs_forward1: 106885483513608, a1: 310321, b1: 9664166, sigma1: 20740132, rho1_mag: 163918783, rho1_neg: true, m1_mag: 16755193, m1_neg: false, strike: 104464000000000, has_price: true, forward0: 107000000000000, forward1: 106885483513608, envelope: 1000000000, up0: 829171792, up1: 838204963 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 9393, b0: 78272, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 7421957, m0_neg: true, spot1: 106929376499472, bs_forward1: 106929376499472, a1: 9391, b1: 78270, sigma1: 1000002, rho1_mag: 939999540, rho1_neg: true, m1_mag: 7421961, m1_neg: true, strike: 105181000000000, has_price: true, forward0: 107000000000000, forward1: 106929376499472, envelope: 101260924, up0: 999999912, up1: 999999774 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 0, b0: 30000, sigma0: 40050468, rho0_mag: 941096823, rho0_neg: true, m0_mag: 47055, m0_neg: false, spot1: 106884522924718, bs_forward1: 106884522924718, a1: 0, b1: 30002, sigma1: 40053539, rho1_mag: 941017796, rho1_neg: true, m1_mag: 47054, m1_neg: false, strike: 109589000000000, has_price: true, forward0: 107000000000000, forward1: 106884522924718, envelope: 939559276, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 7778, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 2024067, m0_neg: false, spot1: 107064505386046, bs_forward1: 107064505386046, a1: 7782, b1: 100607, sigma1: 1000000, rho1_mag: 944368432, rho1_neg: true, m1_mag: 2020877, m1_neg: false, strike: 105170000000000, has_price: true, forward0: 107000000000000, forward1: 107064505386046, envelope: 111080007, up0: 999999841, up1: 999999926 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1908, b0: 51613, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1940813, m0_neg: false, spot1: 107077594612659, bs_forward1: 107077594612659, a1: 1692, b1: 44476, sigma1: 1257887, rho1_mag: 907922798, rho1_neg: true, m1_mag: 2365575, m1_neg: false, strike: 105106000000000, has_price: true, forward0: 107000000000000, forward1: 107077594612659, envelope: 513400338, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2133, b0: 70588, sigma0: 1000000, rho0_mag: 293697176, rho0_neg: true, m0_mag: 1225154, m0_neg: true, spot1: 106873041334751, bs_forward1: 106873041334751, a1: 2132, b1: 70586, sigma1: 1000000, rho1_mag: 293696951, rho1_neg: true, m1_mag: 1225153, m1_neg: true, strike: 107224000000000, has_price: true, forward0: 107000000000000, forward1: 106873041334751, envelope: 388508766, up0: 81825644, up1: 15816380 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 154630, b0: 4324846, sigma0: 14282671, rho0_mag: 106829328, rho0_neg: true, m0_mag: 11383262, m0_neg: false, spot1: 107125618349539, bs_forward1: 107125618349539, a1: 154636, b1: 4324742, sigma1: 14282203, rho1_mag: 106820611, rho1_neg: true, m1_mag: 11382416, m1_neg: false, strike: 108262000000000, has_price: true, forward0: 107000000000000, forward1: 107125618349539, envelope: 107926618, up0: 214017874, up1: 242005211 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 4541, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1521070, m0_neg: false, spot1: 106990352379175, bs_forward1: 106990352379175, a1: 4546, b1: 99061, sigma1: 1000000, rho1_mag: 948639134, rho1_neg: true, m1_mag: 1521258, m1_neg: false, strike: 105095000000000, has_price: true, forward0: 107000000000000, forward1: 106990352379175, envelope: 30983215, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 318484, b0: 10087573, sigma0: 19724542, rho0_mag: 459396385, rho0_neg: true, m0_mag: 5344332, m0_neg: false, spot1: 106908667442851, bs_forward1: 106908667442851, a1: 339753, b1: 10727877, sigma1: 16352821, rho1_mag: 415737442, rho1_neg: true, m1_mag: 5708856, m1_neg: false, strike: 105352000000000, has_price: true, forward0: 107000000000000, forward1: 106908667442851, envelope: 1000000000, up0: 792486577, up1: 787375657 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 5409, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1658142, m0_neg: false, spot1: 106848781592200, bs_forward1: 106848781592200, a1: 5411, b1: 99998, sigma1: 1000000, rho1_mag: 939999176, rho1_neg: true, m1_mag: 1658141, m1_neg: false, strike: 108765000000000, has_price: true, forward0: 107000000000000, forward1: 106848781592200, envelope: 312277578, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 8449, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 342221, m0_neg: false, spot1: 107198558691608, bs_forward1: 107198558691608, a1: 8449, b1: 99995, sigma1: 1000000, rho1_mag: 939987079, rho1_neg: true, m1_mag: 342234, m1_neg: false, strike: 105320000000000, has_price: true, forward0: 107000000000000, forward1: 107198558691608, envelope: 313010915, up0: 999998550, up1: 999999865 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 245, b0: 6251, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1137446, m0_neg: false, spot1: 106790792278231, bs_forward1: 106790792278231, a1: 244, b1: 6278, sigma1: 1000000, rho1_mag: 933831660, rho1_neg: true, m1_mag: 1127622, m1_neg: false, strike: 107588000000000, has_price: true, forward0: 107000000000000, forward1: 106790792278231, envelope: 1000000000, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1649, b0: 100004, sigma0: 42129114, rho0_mag: 950000000, rho0_neg: true, m0_mag: 995228, m0_neg: true, spot1: 106929209645549, bs_forward1: 106929209645549, a1: 1840, b1: 129926, sigma1: 36810625, rho1_mag: 863466989, rho1_neg: true, m1_mag: 712964, m1_neg: true, strike: 109107000000000, has_price: true, forward0: 107000000000000, forward1: 106929209645549, envelope: 1000000000, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 183727, b0: 10148460, sigma0: 19531867, rho0_mag: 82477637, rho0_neg: true, m0_mag: 9913429, m0_neg: false, spot1: 107204603525988, bs_forward1: 107204603525988, a1: 183727, b1: 10148457, sigma1: 19531852, rho1_mag: 82477653, rho1_neg: true, m1_mag: 9913426, m1_neg: false, strike: 108583000000000, has_price: true, forward0: 107000000000000, forward1: 107204603525988, envelope: 308665634, up0: 211795788, up1: 248239489 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2035, b0: 100000, sigma0: 1452290, rho0_mag: 578321225, rho0_neg: true, m0_mag: 2001871, m0_neg: true, spot1: 106833677578891, bs_forward1: 106833677578891, a1: 2036, b1: 100009, sigma1: 1452362, rho1_mag: 578312230, rho1_neg: true, m1_mag: 2001706, m1_neg: true, strike: 109065000000000, has_price: true, forward0: 107000000000000, forward1: 106833677578891, envelope: 586523642, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2489, b0: 100002, sigma0: 1400998, rho0_mag: 833864641, rho0_neg: true, m0_mag: 2236012, m0_neg: true, spot1: 107070013646230, bs_forward1: 107070013646230, a1: 2474, b1: 99850, sigma1: 1387199, rho1_mag: 831505684, rho1_neg: true, m1_mag: 2234238, m1_neg: true, strike: 109782000000000, has_price: true, forward0: 107000000000000, forward1: 107070013646230, envelope: 239386290, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2847, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 455016, m0_neg: false, spot1: 107056846279615, bs_forward1: 107056846279615, a1: 2014, b1: 96249, sigma1: 1000000, rho1_mag: 1000000000, rho1_neg: true, m1_mag: 438994, m1_neg: false, strike: 109236000000000, has_price: true, forward0: 107000000000000, forward1: 107056846279615, envelope: 685576405, up0: 0, up1: 0 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 4967813, b0: 33132355, sigma0: 107214859, rho0_mag: 739442806, rho0_neg: true, m0_mag: 2419918, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 4967814, b1: 33132355, sigma1: 107214859, rho1_mag: 739442806, rho1_neg: true, m1_mag: 2419918, m1_neg: true, strike: 106893000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150449, up0: 538030245, up1: 538030238 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2567086, b0: 21346560, sigma0: 90603142, rho0_mag: 356387287, rho0_neg: true, m0_mag: 1230994, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 2567087, b1: 21346560, sigma1: 90603142, rho1_mag: 356387287, rho1_neg: true, m1_mag: 1230994, m1_neg: true, strike: 106721000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150611, up0: 525664952, up1: 525664942 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 4705046, b0: 30732002, sigma0: 14622276, rho0_mag: 61103397, rho0_neg: false, m0_mag: 55536, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 4705047, b1: 30732002, sigma1: 14622276, rho1_mag: 61103397, rho1_neg: false, m1_mag: 55536, m1_neg: false, strike: 107278000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150622, up0: 451532831, up1: 451532831 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 3526273, b0: 2367578, sigma0: 94426494, rho0_mag: 2230746, rho0_neg: false, m0_mag: 2692688, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 3526273, b1: 2367579, sigma1: 94426494, rho1_mag: 2230746, rho1_neg: false, m1_mag: 2692688, m1_neg: true, strike: 106796000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150006, up0: 500136577, up1: 500136577 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 692982, b0: 24784509, sigma0: 72213155, rho0_mag: 698633347, rho0_neg: false, m0_mag: 2408367, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 692982, b1: 24784510, sigma1: 72213155, rho1_mag: 698633347, rho1_neg: false, m1_mag: 2408367, m1_neg: false, strike: 107470000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 155792, up0: 384159327, up1: 384159327 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 196559, b0: 18782526, sigma0: 119933968, rho0_mag: 945177297, rho0_neg: true, m0_mag: 2879451, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 196559, b1: 18782527, sigma1: 119933968, rho1_mag: 945177297, rho1_neg: true, m1_mag: 2879451, m1_neg: true, strike: 106860000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 157502, up0: 571763440, up1: 571763444 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1321660, b0: 20043202, sigma0: 53468944, rho0_mag: 313520092, rho0_neg: true, m0_mag: 2927133, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 1321660, b1: 20043202, sigma1: 53468944, rho1_mag: 313520091, rho1_neg: true, m1_mag: 2927133, m1_neg: false, strike: 106743000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 543065598, up1: 543065598 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 925326, b0: 31392101, sigma0: 58791810, rho0_mag: 494691427, rho0_neg: true, m0_mag: 191846, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 925326, b1: 31392101, sigma1: 58791810, rho1_mag: 494691426, rho1_neg: true, m1_mag: 191846, m1_neg: true, strike: 107449000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 507899362, up1: 507899362 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1380045, b0: 1430659, sigma0: 2638463, rho0_mag: 824832670, rho0_neg: false, m0_mag: 1001596, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 1380045, b1: 1430659, sigma1: 2638463, rho1_mag: 824832671, rho1_neg: false, m1_mag: 1001596, m1_neg: true, strike: 106486000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 544146574, up1: 544146574 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 3996021, b0: 6730029, sigma0: 53809261, rho0_mag: 245217770, rho0_neg: true, m0_mag: 1343446, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 3996021, b1: 6730029, sigma1: 53809261, rho1_mag: 245217770, rho1_neg: true, m1_mag: 1343445, m1_neg: true, strike: 107160000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 481726899, up1: 481726901 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 2010803, b0: 31075635, sigma0: 62433486, rho0_mag: 932788283, rho0_neg: false, m0_mag: 351416, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 2010803, b1: 31075635, sigma1: 62433486, rho1_mag: 932788283, rho1_neg: false, m1_mag: 351417, m1_neg: false, strike: 107214000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 380971823, up1: 380971823 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 334555, b0: 13867931, sigma0: 20371496, rho0_mag: 810604133, rho0_neg: false, m0_mag: 2992423, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 334555, b1: 13867931, sigma1: 20371496, rho1_mag: 810604133, rho1_neg: false, m1_mag: 2992424, m1_neg: false, strike: 106657000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 489041071, up1: 489041071 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1441796, b0: 11865343, sigma0: 100480424, rho0_mag: 119475919, rho0_neg: false, m0_mag: 2967445, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 1441796, b1: 11865343, sigma1: 100480425, rho1_mag: 119475919, rho1_neg: false, m1_mag: 2967445, m1_neg: false, strike: 107064000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 480699255, up1: 480699255 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 1175348, b0: 32638439, sigma0: 50139153, rho0_mag: 453979185, rho0_neg: false, m0_mag: 2876253, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 1175348, b1: 32638439, sigma1: 50139154, rho1_mag: 453979185, rho1_neg: false, m1_mag: 2876253, m1_neg: false, strike: 107160000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 425727068, up1: 425727068 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 578000, b0: 26327939, sigma0: 74555942, rho0_mag: 549924499, rho0_neg: true, m0_mag: 2881559, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 578000, b1: 26327939, sigma1: 74555943, rho1_mag: 549924499, rho1_neg: true, m1_mag: 2881559, m1_neg: false, strike: 106486000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 150000, up0: 593181179, up1: 593181179 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000001, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 0, up0: 586463895, up1: 586463895 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000001, a0: 206963, b0: 33862461, sigma0: 75865281, rho0_mag: 731456866, rho0_neg: true, m0_mag: 2694344, m0_neg: false, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 206963, b1: 33862461, sigma1: 75865281, rho1_mag: 731456866, rho1_neg: true, m1_mag: 2694344, m1_neg: false, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 0, up0: 586463895, up1: 586463895 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 53500000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 101650000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 849624189, up1: 852589780 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 106465000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 468023927, up1: 472370829 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 106989000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 425683556, up1: 429844968 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 107000000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 424816935, up1: 428973931 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 107010000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 424029956, up1: 428182904 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 107535000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 383918094, up1: 387840794 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 112350000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 141953542, up1: 143575426 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 160500000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 255055, up1: 258084 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 1000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 72792205, up0: 1000000000, up1: 1000000000 }, + DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 9630000000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 72792205, up0: 0, up1: 0 }, + ] +} + +public fun spot0(v: &DriftVector): u64 { v.spot0 } + +public fun bs_forward0(v: &DriftVector): u64 { v.bs_forward0 } + +public fun a0(v: &DriftVector): u64 { v.a0 } + +public fun b0(v: &DriftVector): u64 { v.b0 } + +public fun sigma0(v: &DriftVector): u64 { v.sigma0 } + +public fun rho0_mag(v: &DriftVector): u64 { v.rho0_mag } + +public fun rho0_neg(v: &DriftVector): bool { v.rho0_neg } + +public fun m0_mag(v: &DriftVector): u64 { v.m0_mag } + +public fun m0_neg(v: &DriftVector): bool { v.m0_neg } + +public fun spot1(v: &DriftVector): u64 { v.spot1 } + +public fun bs_forward1(v: &DriftVector): u64 { v.bs_forward1 } + +public fun a1(v: &DriftVector): u64 { v.a1 } + +public fun b1(v: &DriftVector): u64 { v.b1 } + +public fun sigma1(v: &DriftVector): u64 { v.sigma1 } + +public fun rho1_mag(v: &DriftVector): u64 { v.rho1_mag } + +public fun rho1_neg(v: &DriftVector): bool { v.rho1_neg } + +public fun m1_mag(v: &DriftVector): u64 { v.m1_mag } + +public fun m1_neg(v: &DriftVector): bool { v.m1_neg } + +public fun strike(v: &DriftVector): u64 { v.strike } + +public fun has_price(v: &DriftVector): bool { v.has_price } + +public fun forward0(v: &DriftVector): u64 { v.forward0 } + +public fun forward1(v: &DriftVector): u64 { v.forward1 } + +public fun envelope(v: &DriftVector): u64 { v.envelope } + +public fun up0(v: &DriftVector): u64 { v.up0 } + +public fun up1(v: &DriftVector): u64 { v.up1 } diff --git a/packages/predict/tests/pricing/drift_replica_parity_tests.move b/packages/predict/tests/pricing/drift_replica_parity_tests.move new file mode 100644 index 000000000..e74370c1c --- /dev/null +++ b/packages/predict/tests/pricing/drift_replica_parity_tests.move @@ -0,0 +1,160 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// PARITY pin (not correctness coverage): bit-exact agreement between the +/// chain's `drift_envelope`/`up_price` and the DBU-557 measurement replica. +/// +/// The expected values here are the measurement replica's own outputs +/// (predict-research `scripts/drift_replica.py`, a deliberate fixed-point +/// parity model of pricing.move; generator `scripts/drift_fixture_gen.py`). +/// Per the unit-test rules that makes this a two-sided consistency contract, +/// NOT an independent correctness oracle: if pricing.move were wrong, both +/// sides would agree on the wrong number. Correctness is owned elsewhere — +/// `pricing_exact_tests` pins prices against an independent erf reference, +/// and the envelope's soundness claim is validated empirically by the +/// DBU-557 measurement (envelope >= realized sup over historical oracle +/// pairs). What THIS file guarantees: the off-chain measurement computes +/// exactly what the chain computes (the M0 fidelity gate — without it the +/// measurement's conclusions would be about the replica, not the contract), +/// and any future pricing.move change that shifts a covered value breaks +/// loudly, flagging that the measurement fixture must be regenerated. +/// +/// Each vector seeds its two oracle snapshots through the production ingest +/// (`prepare_real_oracle_bundle`, driving the fresh-Pyth forward round-trip), +/// loads real `Pricer`s, and asserts exact equality. Coverage: the zero +/// early-out, every reachable fail-closed reason (variance floor, forward +/// ratio guards, decay/band degeneracies incl. the round-down band collapse), +/// real captured SVI surfaces under perturbation ladders, one-ulp parameter +/// deltas, and wing/grid strikes. +#[test_only] +module deepbook_predict::drift_replica_parity_tests; + +use deepbook_predict::{drift_reference_data as ref_data, oracle_fixture, pricing, test_constants}; +use propbook::block_scholes_svi_feed::SVIParams; +use std::unit_test::assert_eq; + +/// Seed one snapshot's oracle values through the real ingest paths at an +/// explicit source timestamp (the oracle lanes keep the newest source +/// timestamp, so seeding two snapshots requires advancing it) and return the +/// loaded pricer's forward, SVI params, and the UP price at `strike` (0 when +/// the vector's surface cannot price). +fun snapshot( + fx: &mut oracle_fixture::OracleFixture, + oracle: &mut oracle_fixture::OracleBundle, + source_ts_ms: u64, + spot: u64, + bs_forward: u64, + a: u64, + b: u64, + sigma: u64, + rho_mag: u64, + rho_neg: bool, + m_mag: u64, + m_neg: bool, + strike: u64, + price: bool, +): (u64, SVIParams, u64) { + fx.set_pyth_bundle(oracle, spot, source_ts_ms); + fx.set_bs_spot_for_testing_bundle(oracle, source_ts_ms, spot); + fx.set_bs_forward_for_testing_bundle(oracle, source_ts_ms, bs_forward); + fx.set_bs_svi_for_testing_bundle( + oracle, + source_ts_ms, + a, + b, + sigma, + rho_mag, + rho_neg, + m_mag, + m_neg, + ); + let pricer = fx.load_pricer_bundle(oracle); + let up = if (price) pricer.up_price(strike) else 0; + (pricer.forward(), pricer.svi_params(), up) +} + +fun run_range(start: u64, end: u64) { + let mut fx = oracle_fixture::setup_oracle_default(); + let mut oracle = fx.take_oracle_bundle(); + let vectors = ref_data::vectors(); + let stop = end.min(vectors.length()); + let base_ts = test_constants::live_source_timestamp_ms(); + let mut i = start; + while (i < stop) { + let v = &vectors[i]; + let (forward0, svi0, up0) = snapshot( + &mut fx, + &mut oracle, + base_ts + 2 * (i - start), + v.spot0(), + v.bs_forward0(), + v.a0(), + v.b0(), + v.sigma0(), + v.rho0_mag(), + v.rho0_neg(), + v.m0_mag(), + v.m0_neg(), + v.strike(), + v.has_price(), + ); + let (forward1, svi1, up1) = snapshot( + &mut fx, + &mut oracle, + base_ts + 2 * (i - start) + 1, + v.spot1(), + v.bs_forward1(), + v.a1(), + v.b1(), + v.sigma1(), + v.rho1_mag(), + v.rho1_neg(), + v.m1_mag(), + v.m1_neg(), + v.strike(), + v.has_price(), + ); + assert_eq!(forward0, v.forward0()); + assert_eq!(forward1, v.forward1()); + let live = pricing::from_anchors( + sui::object::id_from_address(@0xD41F7), + forward1, + svi1, + ); + assert_eq!(live.drift_envelope(forward0, &svi0), v.envelope()); + if (v.has_price()) { + assert_eq!(up0, v.up0()); + assert_eq!(up1, v.up1()); + }; + i = i + 1; + }; + oracle_fixture::return_oracle_bundle(oracle); + fx.finish(); +} + +#[test] +fun drift_vectors_00_09() { run_range(0, 10) } + +#[test] +fun drift_vectors_10_19() { run_range(10, 20) } + +#[test] +fun drift_vectors_20_29() { run_range(20, 30) } + +#[test] +fun drift_vectors_30_39() { run_range(30, 40) } + +#[test] +fun drift_vectors_40_49() { run_range(40, 50) } + +#[test] +fun drift_vectors_50_59() { run_range(50, 60) } + +#[test] +fun drift_vectors_60_69() { run_range(60, 70) } + +#[test] +fun drift_vectors_70_79() { run_range(70, 80) } + +#[test] +fun drift_vectors_80_end() { run_range(80, 1000) } From dc030f09e03ab51b54e3a4c05c401d83a8f8d3e1 Mon Sep 17 00:00:00 2001 From: Aslan Tashtanov Date: Tue, 14 Jul 2026 18:56:48 -0400 Subject: [PATCH 23/23] predict: pin the 15 measured drift-envelope violations in the parity vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DBU-557 sweep over 11 days of captured BTC oracle history found 15 exact-confirmed pairs where the chain's own fixed-point up_price moves MORE than drift_envelope allows — all in one regime: near-expiry 5m surfaces at ultra-low total variance (w ~ 1e-7, sigma at the pricing-safe minimum), identical SVI between snapshots, forward moves of ~2e-8 relative. Mechanism: one ulp of fixed-point total variance moves the price by ~phi(d2)*|k|/(2*w^1.5)*1e-9, which exceeds the envelope's 1.5e-4 tail pad below w ~ 5e-6; observed exceedances reach 4.3e-4 of face. These vectors embed the violating pairs (real forwards, violating strike), so this test now certifies the violating numbers through the production oracle ingest: |up0 - up1| > envelope on chain. The envelope needs a quantization-aware term or a higher variance floor — tracked with the measurement report. Co-Authored-By: Claude Fable 5 --- .../tests/pricing/drift_reference_data.move | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/predict/tests/pricing/drift_reference_data.move b/packages/predict/tests/pricing/drift_reference_data.move index 03e827dc4..9355a3316 100644 --- a/packages/predict/tests/pricing/drift_reference_data.move +++ b/packages/predict/tests/pricing/drift_reference_data.move @@ -133,6 +133,21 @@ public fun vectors(): vector { DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107053500000000, bs_forward1: 107053500000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 160500000000000, has_price: true, forward0: 107000000000000, forward1: 107053500000000, envelope: 125636791, up0: 255055, up1: 258084 }, DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 1000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 72792205, up0: 1000000000, up1: 1000000000 }, DriftVector { spot0: 107000000000000, bs_forward0: 107000000000000, a0: 931739, b0: 19719548, sigma0: 86579291, rho0_mag: 842701059, rho0_neg: false, m0_mag: 609387, m0_neg: true, spot1: 107000000000000, bs_forward1: 107000000000000, a1: 931444, b1: 19733724, sigma1: 86520951, rho1_mag: 842911295, rho1_neg: false, m1_mag: 609851, m1_neg: true, strike: 9630000000000000, has_price: true, forward0: 107000000000000, forward1: 107000000000000, envelope: 72792205, up0: 0, up1: 0 }, + DriftVector { spot0: 63998382758170, bs_forward0: 63998382758170, a0: 413, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 87733, m0_neg: true, spot1: 63998399269771, bs_forward1: 63998399269771, a1: 413, b1: 100000, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 87733, m1_neg: true, strike: 64030000000000, has_price: true, forward0: 63998382758170, forward1: 63998399269771, envelope: 324526, up0: 246020872, up1: 246372415 }, + DriftVector { spot0: 60319118724041, bs_forward0: 60319118724041, a0: 292, b0: 30233, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1826, m0_neg: true, spot1: 60319113207562, bs_forward1: 60319113207562, a1: 292, b1: 30233, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 1826, m1_neg: true, strike: 60374000000000, has_price: true, forward0: 60319118724041, forward1: 60319113207562, envelope: 218419, up0: 51086664, up1: 50789780 }, + DriftVector { spot0: 62799923415357, bs_forward0: 62799923415357, a0: 157, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 158464, m0_neg: true, spot1: 62799929426718, bs_forward1: 62799929426718, a1: 157, b1: 100000, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 158464, m1_neg: true, strike: 62773000000000, has_price: true, forward0: 62799923415357, forward1: 62799929426718, envelope: 252086, up0: 821510697, up1: 821133223 }, + DriftVector { spot0: 62771724282041, bs_forward0: 62771724282041, a0: 16, b0: 57140, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 95509, m0_neg: true, spot1: 62771769041949, bs_forward1: 62771769041949, a1: 16, b1: 57140, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 95509, m1_neg: true, strike: 62795000000000, has_price: true, forward0: 62771724282041, forward1: 62771769041949, envelope: 1996967, up0: 60651406, up1: 62794008 }, + DriftVector { spot0: 64116940097212, bs_forward0: 64116940097212, a0: 57, b0: 100000, sigma0: 1259903, rho0_mag: 931065023, rho0_neg: true, m0_mag: 1628538, m0_neg: false, spot1: 64116947004578, bs_forward1: 64116947004578, a1: 57, b1: 100000, sigma1: 1259903, rho1_mag: 931065023, rho1_neg: true, m1_mag: 1628538, m1_neg: false, strike: 64178000000000, has_price: true, forward0: 64116940097212, forward1: 64116947004578, envelope: 312852, up0: 41157630, up1: 41472897 }, + DriftVector { spot0: 60781923466668, bs_forward0: 60781923466668, a0: 375, b0: 66668, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 116554, m0_neg: true, spot1: 60781918195520, bs_forward1: 60781918195520, a1: 375, b1: 66668, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 116554, m1_neg: true, strike: 60802000000000, has_price: true, forward0: 60781923466668, forward1: 60781918195520, envelope: 209685, up0: 314642835, up1: 314389208 }, + DriftVector { spot0: 60777205891002, bs_forward0: 60777205891002, a0: 167, b0: 57140, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 129406, m0_neg: false, spot1: 60777240004559, bs_forward1: 60777240004559, a1: 167, b1: 57140, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 129406, m1_neg: false, strike: 60801000000000, has_price: true, forward0: 60777205891002, forward1: 60777240004559, envelope: 725480, up0: 208854521, up1: 209765959 }, + DriftVector { spot0: 59826336033859, bs_forward0: 59826336033859, a0: 189, b0: 33332, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 177988, m0_neg: false, spot1: 59826290229979, bs_forward1: 59826290229979, a1: 189, b1: 33332, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 177988, m1_neg: false, strike: 59856000000000, has_price: true, forward0: 59826336033859, forward1: 59826290229979, envelope: 875400, up0: 147059642, up1: 146109121 }, + DriftVector { spot0: 60734432572933, bs_forward0: 60734432572933, a0: 393, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 64518, m0_neg: false, spot1: 60734451625736, bs_forward1: 60734451625736, a1: 393, b1: 100000, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 64518, m1_neg: false, strike: 60771000000000, has_price: true, forward0: 60734432572933, forward1: 60734451625736, envelope: 368191, up0: 195281347, up1: 195675220 }, + DriftVector { spot0: 59968614807915, bs_forward0: 59968614807915, a0: 421, b0: 28572, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 1828683, m0_neg: true, spot1: 59968615145306, bs_forward1: 59968615145306, a1: 421, b1: 28572, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 1828683, m1_neg: true, strike: 59988000000000, has_price: true, forward0: 59968614807915, forward1: 59968615145306, envelope: 152503, up0: 311187727, up1: 311392987 }, + DriftVector { spot0: 62421793004534, bs_forward0: 62421793004534, a0: 126, b0: 39999, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 89719, m0_neg: true, spot1: 62421794246025, bs_forward1: 62421794246025, a1: 126, b1: 39999, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 89719, m1_neg: true, strike: 62389000000000, has_price: true, forward0: 62421793004534, forward1: 62421794246025, envelope: 170567, up0: 900837817, up1: 900237907 }, + DriftVector { spot0: 60342057303501, bs_forward0: 60342057303501, a0: 79, b0: 14286, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 227184, m0_neg: false, spot1: 60341924573180, bs_forward1: 60341924573180, a1: 79, b1: 14286, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 227184, m1_neg: false, strike: 60357000000000, has_price: true, forward0: 60342057303501, forward1: 60341924573180, envelope: 3315009, up0: 214555030, up1: 211188768 }, + DriftVector { spot0: 60638964859297, bs_forward0: 60638964859297, a0: 382, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 17545, m0_neg: true, spot1: 60638943333977, bs_forward1: 60638943333977, a1: 382, b1: 100000, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 17545, m1_neg: true, strike: 60676000000000, has_price: true, forward0: 60638964859297, forward1: 60638943333977, envelope: 401029, up0: 186882602, up1: 186460934 }, + DriftVector { spot0: 60239708983718, bs_forward0: 60239708983718, a0: 68, b0: 11764, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 218388, m0_neg: true, spot1: 60239835069067, bs_forward1: 60239835069067, a1: 68, b1: 11764, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 218388, m1_neg: true, strike: 60253000000000, has_price: true, forward0: 60239708983718, forward1: 60239835069067, envelope: 3369347, up0: 213541996, up1: 217306065 }, + DriftVector { spot0: 62547346956125, bs_forward0: 62547346956125, a0: 21, b0: 100000, sigma0: 1000000, rho0_mag: 940000000, rho0_neg: true, m0_mag: 57925, m0_neg: true, spot1: 62547335629767, bs_forward1: 62547335629767, a1: 21, b1: 100000, sigma1: 1000000, rho1_mag: 940000000, rho1_neg: true, m1_mag: 57925, m1_neg: true, strike: 62530000000000, has_price: true, forward0: 62547346956125, forward1: 62547335629767, envelope: 551724, up0: 813999031, up1: 814717627 }, ] }