From 385fbab35f2186c0bd50e83659fc506d5f8bdb05 Mon Sep 17 00:00:00 2001 From: Builder Date: Sun, 13 Sep 2026 14:10:37 +0800 Subject: [PATCH] fix(execution): stop VWAP emitting negative slice quantities (BUG-15) allocated = 0 for i in range(n_slices): if i == n_slices - 1: slice_qty = order.quantity - allocated # remainder else: slice_qty = max(100, int(order.quantity * volume_profile[i])) slice_qty = (slice_qty // 100) * 100 allocated += slice_qty plan.slices.append(ExecutionSlice( quantity=min(slice_qty, order.quantity - (allocated - slice_qty)), ...)) The lot floor can exceed what is left on a small order, and the clamp meant to catch it compared against `allocated` *after* it had already been incremented, so it never bit. A 200-share parent over 10 slices came out as [100, 100, 0, -100, -200, -300, -400, -500, -600, -700] summing to -2600. Any consumer executing those slices trades phantom short quantities. `SmartRouter` routes to VWAP when 0.15 <= quantity/ADV < 0.30, so an illiquid name with a small order reaches it. Fix: clamp against what actually remains, stop rather than emit a non-positive slice, and set `plan.num_slices` to the number of slices actually produced rather than the number requested. If the order is too small to spread across lot-sized slices, that is logged rather than silently mis-allocated. before 200 shares -> [100, 100, 0, -100, ..., -700] sum -2600 after 200 shares -> [100, 100] sum 200 after 10000 shares -> [1500, 1300, 1100, 900, 900, 900, 900, 700, 700, 1100] Tests (5, parametrized over quantities): slices are positive, lot-sized, and sum exactly to the parent; a 200-share order is not split into ten slices; and a plan never exceeds its parent. Verified against the old implementation -- the 200 and 500 cases and the over-split case fail there; 100, 1000 and 10000 pass both ways, because they are large enough not to trip the floor. 1341 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- execution/algorithms.py | 31 ++++++++++++++++--- tests/test_execution/test_algorithms.py | 41 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/execution/algorithms.py b/execution/algorithms.py index 7a2c55b..e50b4a8 100644 --- a/execution/algorithms.py +++ b/execution/algorithms.py @@ -180,11 +180,26 @@ def create_plan( allocated = 0 for i in range(n_slices): + remaining = order.quantity - allocated + if remaining <= 0: + break + if i == n_slices - 1: - slice_qty = order.quantity - allocated # Last slice gets remainder + slice_qty = remaining # last slice takes the remainder else: + # The lot floor can exceed what is left on a small order: a flat + # volume profile with a 200-share order over 10 slices wants + # max(100, 20) on every slice. The previous clamp compared + # against `allocated` *after* incrementing it, so it never bit, + # and the plan came out as + # [100, 100, 0, -100, -200, ..., -700] + # -- negative child quantities for a 200-share parent. slice_qty = max(100, int(order.quantity * volume_profile[i])) - slice_qty = (slice_qty // 100) * 100 # Round to lot size + slice_qty = (slice_qty // 100) * 100 # round down to a lot + slice_qty = min(slice_qty, remaining) + + if slice_qty <= 0: + break allocated += slice_qty target_time = start_time + timedelta(minutes=i * interval) @@ -193,14 +208,22 @@ def create_plan( parent_order_id=order.order_id, ticker=order.ticker, side=order.side.value, - quantity=min(slice_qty, order.quantity - (allocated - slice_qty)), + quantity=slice_qty, target_time=target_time.isoformat(), participation_rate=volume_profile[i], )) + plan.num_slices = len(plan.slices) + if allocated != order.quantity: + logger.warning( + "VWAP plan for %s allocated %d of %d shares -- the order is too " + "small to spread across %d lot-sized slices", + order.ticker, allocated, order.quantity, n_slices, + ) + logger.info("VWAP plan: %s %s x%d over %d min, %d slices", order.side.value, order.ticker, order.quantity, - duration_minutes, n_slices) + duration_minutes, len(plan.slices)) return plan diff --git a/tests/test_execution/test_algorithms.py b/tests/test_execution/test_algorithms.py index c728a21..f73bf85 100644 --- a/tests/test_execution/test_algorithms.py +++ b/tests/test_execution/test_algorithms.py @@ -2,6 +2,11 @@ from datetime import datetime +import pytest + +# Fixed timestamp so slice target times are deterministic. +_START = datetime(2026, 1, 5, 9, 30) + from quant_platform.execution.algorithms import ( ExecutionPlan, IcebergAlgorithm, @@ -111,3 +116,39 @@ def test_high_urgency_fewer_slices(self): plan_normal = SmartRouter.execute(order, avg_daily_volume=1_000_000, urgency="normal") plan_high = SmartRouter.execute(order, avg_daily_volume=1_000_000, urgency="high") assert plan_high.num_slices <= plan_normal.num_slices + + +class TestVWAPSliceQuantities: + """Regression for BUG-15. + + The lot-size floor (`max(100, ...)`) can exceed what is left on a small + order, and the clamp that was meant to catch it compared against + `allocated` *after* incrementing it, so it never bit. A 200-share order over + 10 slices produced [100, 100, 0, -100, -200, ..., -700] -- a plan summing to + -2600 for a 200-share parent. + """ + + @pytest.mark.parametrize("quantity", [100, 200, 500, 1000, 10_000]) + def test_slices_are_positive_lots_summing_to_the_parent(self, quantity): + order = Order(ticker="600519", side=OrderSide.BUY, quantity=quantity) + plan = VWAPAlgorithm(num_slices=10).create_plan(order, start_time=_START) + + quantities = [s.quantity for s in plan.slices] + assert quantities, "expected at least one slice" + assert all(q > 0 for q in quantities), f"non-positive slice in {quantities}" + assert all(q % 100 == 0 for q in quantities), f"non-lot slice in {quantities}" + assert sum(quantities) == quantity, ( + f"slices sum to {sum(quantities)}, parent is {quantity}" + ) + + def test_small_order_is_not_over_split(self): + """200 shares is two lots, so it cannot fill ten lot-sized slices.""" + order = Order(ticker="600519", side=OrderSide.BUY, quantity=200) + plan = VWAPAlgorithm(num_slices=10).create_plan(order, start_time=_START) + assert len(plan.slices) == 2 + assert plan.num_slices == 2 + + def test_plan_never_exceeds_the_parent_quantity(self): + order = Order(ticker="600519", side=OrderSide.BUY, quantity=300) + plan = VWAPAlgorithm(num_slices=10).create_plan(order, start_time=_START) + assert sum(s.quantity for s in plan.slices) <= order.quantity