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
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
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.
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:
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.
# 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.
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.
allocator = RandomAllocator(seed=42)
Custom allocator
Implement the Allocator Protocol:
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.
# 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
| Item | State |
|---|---|
| Parent persists across ticks | Automatic |
| Per-account IPS gates in block flow | Automatic |
Weighted by initial_funding_usd, equal-weight fallback | Automatic |
| Weighted by live equity | L1 follow-up |
| Per-account strategy / alpha | L2 |
| Aggregate across markets into a basket | L3 |
| Must-include constraints | L2 |
| Manual compliance override: SpecID allocation | L2 |