Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions execution/algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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


Expand Down
41 changes: 41 additions & 0 deletions tests/test_execution/test_algorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Loading