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