Skip to content

Commit 839863a

Browse files
committed
Prune settled payout tree nodes
1 parent 6f3c7c5 commit 839863a

3 files changed

Lines changed: 145 additions & 28 deletions

File tree

packages/predict/sources/strike_exposure/index/strike_payout_tree.move

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,16 @@ public(package) fun remove_range(
174174
);
175175
}
176176

177+
#[test_only]
178+
public(package) fun debug_contains_node(tree: &StrikePayoutTree, tick: u64): bool {
179+
tree.nodes.contains(tick)
180+
}
181+
182+
#[test_only]
183+
public(package) fun debug_node_count(tree: &StrikePayoutTree): u64 {
184+
debug_node_count_subtree(&tree.nodes, tree.root)
185+
}
186+
177187
fun apply_range(
178188
tree: &mut StrikePayoutTree,
179189
lower_tick: u64,
@@ -210,7 +220,7 @@ fun apply_boundary_delta(
210220
is_start,
211221
add,
212222
);
213-
tree.root = option::some(new_root);
223+
tree.root = new_root;
214224
}
215225

216226
fun apply_at(
@@ -220,12 +230,12 @@ fun apply_at(
220230
terms: PayoutTerms,
221231
is_start: bool,
222232
add: bool,
223-
): u64 {
233+
): Option<u64> {
224234
if (root.is_none()) {
225235
assert!(add, EInsufficientPayoutTerms);
226236
let leaf = new_leaf(tick, terms, is_start);
227237
nodes.add(tick, leaf);
228-
return tick
238+
return option::some(tick)
229239
};
230240

231241
let root_tick = *root.borrow();
@@ -237,8 +247,12 @@ fun apply_at(
237247
} else {
238248
apply_terms_delta(&mut node.local_end, terms, add);
239249
};
250+
if (is_empty_node(node)) {
251+
let _removed = nodes.remove(root_tick);
252+
return merge_subtrees(nodes, node.left, node.right)
253+
};
240254
resummarize(nodes, root_tick, node);
241-
return root_tick
255+
return option::some(root_tick)
242256
};
243257

244258
if (tick < root_tick) {
@@ -250,11 +264,15 @@ fun apply_at(
250264
is_start,
251265
add,
252266
);
253-
let left_node = nodes[new_left];
254-
if (left_node.priority > node.priority) {
255-
return rotate_right(nodes, root_tick, node, new_left, left_node)
267+
if (add && new_left.is_some()) {
268+
let left_tick = *new_left.borrow();
269+
let left_node = nodes[left_tick];
270+
if (left_node.priority > node.priority) {
271+
let rotated = rotate_right(nodes, root_tick, node, left_tick, left_node);
272+
return option::some(rotated)
273+
};
256274
};
257-
node.left = option::some(new_left);
275+
node.left = new_left;
258276
} else {
259277
let new_right = apply_at(
260278
nodes,
@@ -264,15 +282,19 @@ fun apply_at(
264282
is_start,
265283
add,
266284
);
267-
let right_node = nodes[new_right];
268-
if (right_node.priority > node.priority) {
269-
return rotate_left(nodes, root_tick, node, new_right, right_node)
285+
if (add && new_right.is_some()) {
286+
let right_tick = *new_right.borrow();
287+
let right_node = nodes[right_tick];
288+
if (right_node.priority > node.priority) {
289+
let rotated = rotate_left(nodes, root_tick, node, right_tick, right_node);
290+
return option::some(rotated)
291+
};
270292
};
271-
node.right = option::some(new_right);
293+
node.right = new_right;
272294
};
273295

274296
resummarize(nodes, root_tick, node);
275-
root_tick
297+
option::some(root_tick)
276298
}
277299

278300
fun new_leaf(tick: u64, terms: PayoutTerms, is_start: bool): PayoutNode {
@@ -327,6 +349,29 @@ fun rotate_left(
327349
right_tick
328350
}
329351

352+
fun merge_subtrees(
353+
nodes: &mut Table<u64, PayoutNode>,
354+
left: Option<u64>,
355+
right: Option<u64>,
356+
): Option<u64> {
357+
if (left.is_none()) return right;
358+
if (right.is_none()) return left;
359+
360+
let left_tick = *left.borrow();
361+
let right_tick = *right.borrow();
362+
let mut left_node = nodes[left_tick];
363+
let mut right_node = nodes[right_tick];
364+
if (left_node.priority > right_node.priority) {
365+
left_node.right = merge_subtrees(nodes, left_node.right, right);
366+
resummarize(nodes, left_tick, left_node);
367+
option::some(left_tick)
368+
} else {
369+
right_node.left = merge_subtrees(nodes, left, right_node.left);
370+
resummarize(nodes, right_tick, right_node);
371+
option::some(right_tick)
372+
}
373+
}
374+
330375
fun settlement_prefix_terms(
331376
nodes: &Table<u64, PayoutNode>,
332377
root: Option<u64>,
@@ -362,8 +407,7 @@ fun settlement_prefix_terms(
362407
/// Two collapses keep the eval count down:
363408
/// - skip-zero-delta: when `local_start.quantity == local_end.quantity` the two
364409
/// sides contribute the same `P·q` and cancel in `walk_linear`'s top-level
365-
/// subtraction, so the `up_price` eval is skipped (exact; also drops the
366-
/// fully-redeemed boundaries the treap never GCs).
410+
/// subtraction, so the `up_price` eval is skipped (exact).
367411
/// - bounded interpolation, only when `tolerance > 0`: if the subtree's exact
368412
/// price span (`up_price(min_tick·ts) - up_price(max_tick·ts)`, monotone so
369413
/// non-negative) is within `tolerance`, the whole subtree is priced at the
@@ -496,6 +540,24 @@ fun payout_terms(quantity: u64, floor_shares: u64): PayoutTerms {
496540
PayoutTerms { quantity, floor_shares }
497541
}
498542

543+
fun is_zero_terms(terms: PayoutTerms): bool {
544+
terms.quantity == 0 && terms.floor_shares == 0
545+
}
546+
547+
fun is_empty_node(node: PayoutNode): bool {
548+
is_zero_terms(node.local_start) && is_zero_terms(node.local_end)
549+
}
550+
551+
#[test_only]
552+
fun debug_node_count_subtree(nodes: &Table<u64, PayoutNode>, root: Option<u64>): u64 {
553+
if (root.is_none()) return 0;
554+
let tick = *root.borrow();
555+
let node = nodes[tick];
556+
1
557+
+ debug_node_count_subtree(nodes, node.left)
558+
+ debug_node_count_subtree(nodes, node.right)
559+
}
560+
499561
fun apply_terms_delta(value: &mut PayoutTerms, delta: PayoutTerms, add: bool) {
500562
if (add) {
501563
value.quantity = value.quantity + delta.quantity;

packages/predict/tests/strike_exposure/index/payout_tree_walk_tests.move

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
/// driven by a real live `Pricer` over standalone trees. These exercise paths
66
/// `current_nav` cannot reach directly: the bounded-interpolation gate (the
77
/// production `nav_interpolation_price_tolerance` is 0, so `current_nav` always
8-
/// walks exactly), the skip-zero-delta path over a dead (fully-removed) boundary
9-
/// that the treap never garbage-collects, and the boundary-aggregation dust clamp
10-
/// — the flat-price-tail integer underflow the ATM `current_nav` fixtures miss.
8+
/// walks exactly), the skip-zero-delta path over an equal live start/end
9+
/// boundary, and the boundary-aggregation dust clamp — the flat-price-tail integer
10+
/// underflow the ATM `current_nav` fixtures miss.
1111
///
1212
/// The tree keys boundaries by absolute tick; the walk recovers each raw strike as
1313
/// `tick * tick_size`. These tests use the default `tick_size` (1e9) so tick `100`
@@ -42,8 +42,7 @@ const HIGH_VARIANCE_A: u64 = 100_000_000;
4242
const Q0: u64 = 10_000_000_000;
4343
const Q1: u64 = 2_000_000_000;
4444
const Q2: u64 = 2_000_000_000;
45-
const DEAD_QUANTITY: u64 = 3_000_000_000;
46-
const LIVE_QUANTITY: u64 = 5_000_000_000;
45+
const ADJACENT_QUANTITY: u64 = 5_000_000_000;
4746
/// Forward far above the grid so low strikes sit in the deep-ITM flat price tail
4847
/// where adjacent ticks price within a floor bucket — the dust-underflow regime.
4948
const FLAT_REGION_FORWARD: u64 = 435_000_000_000;
@@ -105,20 +104,27 @@ fun interpolation_collapses_subtree_within_bound() {
105104
}
106105

107106
#[test]
108-
fun skip_zero_delta_ignores_dead_boundaries() {
107+
fun skip_zero_delta_keeps_adjacent_live_ranges_exact() {
109108
let (mut fixture, oracle, pricer) = live_pricer();
110109
let mut tree = strike_payout_tree::new(fixture.scenario_mut().ctx());
111110

112111
let (t0, t1, t2) = clustered_ticks();
113-
// Insert then fully remove a finite range: its boundary nodes persist (the
114-
// treap never GCs) with zeroed local quantity -> skip-zero-delta must skip them.
115-
tree.insert_range(t1, t2, DEAD_QUANTITY, 0);
116-
tree.remove_range(t1, t2, DEAD_QUANTITY, 0);
117-
insert_up(&mut tree, t0, LIVE_QUANTITY);
112+
// Adjacent live ranges with the same quantity leave an equal nonzero start/end
113+
// at the shared boundary. The exact walk may skip pricing that boundary because
114+
// the two sides cancel.
115+
tree.insert_range(t0, t1, ADJACENT_QUANTITY, 0);
116+
tree.insert_range(t1, t2, ADJACENT_QUANTITY, 0);
118117

119-
// Only the live order is valued; the dead t1/t2 nodes contribute nothing.
120118
let walk = tree.walk_linear(&pricer, tick_size(), 0);
121-
assert_eq!(walk, up_reference(&pricer, vector[t0], vector[LIVE_QUANTITY]));
119+
assert_eq!(
120+
walk,
121+
range_reference(
122+
&pricer,
123+
vector[t0, t1],
124+
vector[t1, t2],
125+
vector[ADJACENT_QUANTITY, ADJACENT_QUANTITY],
126+
),
127+
);
122128

123129
destroy(tree);
124130
cleanup(fixture, oracle);
@@ -185,6 +191,22 @@ fun up_reference(pricer: &Pricer, ticks: vector<u64>, quantities: vector<u64>):
185191
total
186192
}
187193

194+
/// Independent finite-range reference: `Σ mul(range_price(lower·ts, higher·ts), quantity)`.
195+
/// Uses `range_price` (a different pricer path than the walk's `up_price`).
196+
fun range_reference(
197+
pricer: &Pricer,
198+
lower_ticks: vector<u64>,
199+
higher_ticks: vector<u64>,
200+
quantities: vector<u64>,
201+
): u64 {
202+
let mut total = 0;
203+
lower_ticks.length().do!(|i| {
204+
let range_price = pricer.range_price(raw(lower_ticks[i]), raw(higher_ticks[i]));
205+
total = total + math::mul(range_price, quantities[i]);
206+
});
207+
total
208+
}
209+
188210
/// A live market at the default ATM forward with an inflated base variance so
189211
/// adjacent strikes are clustered in price, plus a `Pricer` snapshot over it.
190212
fun live_pricer(): (OracleFixture, OracleBundle, Pricer) {

packages/predict/tests/strike_exposure/index/strike_payout_tree_tests.move

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ fun new_returns_empty_tree() {
102102
// Empty tree has zero conservative backing and zero settled liability at any
103103
// settlement price.
104104
assert_reserve_terms(&tree, 0, 0);
105+
assert_eq!(tree.debug_node_count(), 0);
105106
assert_eq!(tree.settled_payout_liability(settle_at_tick(0), TICK_SIZE), 0);
106107
assert_eq!(tree.settled_payout_liability(settle_at_tick(HIGH_SETTLEMENT_TICK), TICK_SIZE), 0);
107108
destroy(tree);
@@ -250,6 +251,7 @@ fun insert_with_both_terms_zero_is_no_op() {
250251
insert_range(&mut tree, 2, 6, 0, 0);
251252

252253
assert_reserve_terms(&tree, 0, 0);
254+
assert_eq!(tree.debug_node_count(), 0);
253255
destroy(tree);
254256
}
255257

@@ -262,8 +264,15 @@ fun insert_then_remove_restores_empty_state() {
262264

263265
insert_range(&mut tree, 2, 6, 50, 0);
264266
assert_reserve_terms(&tree, 50, 50);
267+
assert!(tree.debug_contains_node(2));
268+
assert!(tree.debug_contains_node(6));
269+
assert_eq!(tree.debug_node_count(), 2);
270+
265271
remove_range(&mut tree, 2, 6, 50, 0);
266272
assert_reserve_terms(&tree, 0, 0);
273+
assert!(!tree.debug_contains_node(2));
274+
assert!(!tree.debug_contains_node(6));
275+
assert_eq!(tree.debug_node_count(), 0);
267276
destroy(tree);
268277
}
269278

@@ -275,9 +284,33 @@ fun insert_two_then_remove_one_leaves_other() {
275284
insert_range(&mut tree, 1, 6, 40, 0);
276285
insert_range(&mut tree, 3, 7, 30, 0);
277286
assert_reserve_terms(&tree, 70, 70);
287+
assert_eq!(tree.debug_node_count(), 4);
278288

279289
remove_range(&mut tree, 3, 7, 30, 0);
280290
assert_reserve_terms(&tree, 40, 40);
291+
assert!(tree.debug_contains_node(1));
292+
assert!(tree.debug_contains_node(6));
293+
assert!(!tree.debug_contains_node(3));
294+
assert!(!tree.debug_contains_node(7));
295+
assert_eq!(tree.debug_node_count(), 2);
296+
destroy(tree);
297+
}
298+
299+
#[test]
300+
fun remove_adjacent_range_preserves_shared_live_boundary() {
301+
let ctx = &mut tx_context::dummy();
302+
let mut tree = new_tree(ctx);
303+
304+
insert_range(&mut tree, 1, 3, 40, 0);
305+
insert_range(&mut tree, 3, 7, 30, 0);
306+
assert_eq!(tree.debug_node_count(), 3);
307+
308+
remove_range(&mut tree, 1, 3, 40, 0);
309+
assert_reserve_terms(&tree, 30, 30);
310+
assert!(!tree.debug_contains_node(1));
311+
assert!(tree.debug_contains_node(3));
312+
assert!(tree.debug_contains_node(7));
313+
assert_eq!(tree.debug_node_count(), 2);
281314
destroy(tree);
282315
}
283316

0 commit comments

Comments
 (0)