Block allocation

One parent order, many client accounts, fair fills. Pro-rata, rotational, or random.

An advisor with 80 client accounts does not submit 80 separate orders for the same position. The operationally correct path is to aggregate: one parent block order at the broker (one quote, one price), split the fill across accounts using a pre-disclosed policy. The result is fair per-share economics per client and a single audit record tying parent to children.

When hz.run() sees a registry with 2 or more accounts, this flow runs automatically. See Running.

The flow

text
OrderAction (from dispatcher)
       │
       ▼
_build_parent_order_with_gates
    For each account in registry:
       1. Tag with account_id and weighted quantity
       2. Run Locates, PDT, full risk engine
       3. If pass: include as AccountTarget
       │
       ▼
ParentOrder = (market_id, side, total_quantity, account_targets, method)
       │
       ▼ submitted ONCE to the venue
       │
       ▼ fill arrives
Allocator.allocate(parent, fill)
       │
       ▼
List[ChildAllocation]: one per account, proportionally
       │
       ▼ applied to each account independently:
Ledger.apply_fill(account_id=child.account_id, ...)
LotBook[child.account_id].open_lot / close_quantity
PDTCounter.on_fill(account_id=child.account_id, ...)

Every step emits audit events. BlockAllocation on parent submission. OrderFilled per child allocation via the AuditedVenue wrapper.

Usage

python
from horizon.execution.allocation import ProRataAllocator, RotationalAllocator

# Default: pro-rata by funding weight
result = hz.run(
    mode="backtest",
    strategies=[MyStrategy],
    universe=["AAPL", "MSFT"],
    accounts=registry,             # 2+ accounts triggers the block path
    audit_log=audit_log,
)

# Opt into a specific allocator
result = hz.run(
    ...,
    accounts=registry,
    allocator=RotationalAllocator(),
)

Target weighting

Each account’s share of total parent quantity is driven by initial_funding_usd.

python
r = AccountRegistry()
r.add_account(Account(account_id="acc_a", initial_funding_usd=50_000, ...))
r.add_account(Account(account_id="acc_b", initial_funding_usd=30_000, ...))
r.add_account(Account(account_id="acc_c", initial_funding_usd=20_000, ...))
# Weights: a=0.5, b=0.3, c=0.2. A 100-share order splits 50/30/20.

If all accounts have initial_funding_usd=0, the fallback is equal weight (1/N). Weighting by live equity, separate risk budgets, or per-account portfolio sizers is an L1 follow-up.

Per-account gates

Before an account is added to the parent, every gate runs against that account’s policy:

text
For each account in registry:
    action.account_id = acct.account_id
    action.quantity = total * weight[acct]
    │
1. LocatesGate       short locate required?
2. PDTGate           this account at PDT threshold?
3. RiskEngine:
     per-order caps
     NBBO fat-finger + stale-quote
     IPS gate        this account's exclusions and asset-class rules
     bucket limits   this account's existing positions
     buying power    this account's venue BP

If all pass: account is included in account_targets.
If any fails: account excluded; audit event records why.

One account’s TSLA exclusion blocks that account’s slice. The parent still submits for the others.

Allocators

ProRataAllocator (default)

Splits each fill proportionally to each account’s target share. Partial fills split the same way.

python
# 100-share target split 50/30/20
# fill of 70  -> 35/21/14
# fill of 100 -> 50/30/20

Rounding. When allow_fractional=False (default), whole-share allocation uses largest-remainder rounding so children sum exactly to the fill. Accounts with the biggest fractional remainder get the extra share. Set allow_fractional=True for pure proportional splits (brokers with fractional support).

Why default. SEC compliance guidance (2006, 2009) cites pro-rata as the default-acceptable method. Deviations are allowed but must be documented.

RotationalAllocator

One account is “primary” per parent and gets its full target first. Remainder spills to the next in rotation. Primary advances after each parent.

python
alloc = RotationalAllocator()

# Parent 1 (a=10, b=10, c=10; fill=15):
#   primary a: 10. Remainder 5 -> b: 5.
# Parent 2 (rotation advances):
#   primary b: 10. Remainder 5 -> c: 5.

Useful for small-block trading where pro-rata would fragment economics into odd lots. Fairness is maintained over time, not within each order.

RandomAllocator

Shuffles account order per fill, then fills in that order. Deterministic with a seed.

python
allocator = RandomAllocator(seed=42)

Custom allocator

Implement the Allocator Protocol:

python
from horizon.execution.allocation import Allocator, AllocationMethod

class HighWatermarkAllocator:
    method = AllocationMethod.HighWatermark

    def allocate(self, parent, fill):
        # Your policy: prioritize accounts furthest below their
        # target quantity, weighted by watermark distance.
        ...

Pass it as allocator=MyAllocator() to hz.run().

Fees

Venue fees are apportioned per child proportional to allocated quantity.

python
# Fill: 100 shares, fee=$10
# Pro-rata split 50/30/20:
#   acc_a: 50 shares, fee $5.00
#   acc_b: 30 shares, fee $3.00
#   acc_c: 20 shares, fee $2.00

Audit trail

Each parent submission produces one BlockAllocation event with the full target list. Each child fill produces one OrderFilled event via AuditedVenue, keyed by child account_id. See Audit trail.

BlockAllocation (parent_123): buy 100 AAPL across 3 accounts (pro_rata)
    targets: a=50, b=30, c=20
    venue submitted: alpaca_ord_xyz
OrderSubmitted   (alpaca_ord_xyz)
OrderAcknowledged (alpaca_ord_xyz)
OrderFilled (acc_a): 50 @ 180.02
OrderFilled (acc_b): 30 @ 180.02
OrderFilled (acc_c): 20 @ 180.02

All events are chain-linked. Any tamper breaks AuditChain.verify.

Partial fills

If the venue reports 70 of a 100-share parent, the allocator splits 70 proportionally (35/21/14 pro-rata, or primary-first rotational). When the rest fills later, another allocation runs against the same parent. Parents persist in the run loop’s parent_orders map until all fills land or the order is canceled.

Status

ItemState
Parent persists across ticksAutomatic
Per-account IPS gates in block flowAutomatic
Weighted by initial_funding_usd, equal-weight fallbackAutomatic
Weighted by live equityL1 follow-up
Per-account strategy / alphaL2
Aggregate across markets into a basketL3
Must-include constraintsL2
Manual compliance override: SpecID allocationL2