使用成交量参与上限进行流动性自适应执行
笔记本 《交易机器学习》
总结
本笔记本解释成交量参与限制如何约束每个时间区间内订单可占市场成交量的比例。当母单超出允许数量时,券商会先成交其中一部分,并将剩余部分留待后续执行。成交量最低阈值也可能阻止在流动性特别稀薄的时段执行。参与上限体现了执行速度与市场影响之间的权衡:上限越高,订单可能越快完成,但面临的冲击风险也越大。
演示将参与上限应用于真实的 NASDAQ-100 日内流动性数据,并汇总为 15 分钟区间。它使用较早的已完成交易时段校准订单规模,然后根据之后观测到的成交量和价格逐步执行订单。笔记本展示部分成交如何累积,以及如何将平方根冲击模型与数量限制结合:前者控制成交数量,后者调整成交价格。结果取决于历史流动性序列和所选校准期,并不能证明存在普遍最优的上限。实际中,流动性和紧迫程度的变化可能需要自适应执行规则。
核心观点
- 参与上限将每个子订单限制在可用柱线成交量的一定比例内。
- 未成交数量会留待后续区间,因此大额母单可能需要多个柱线或多个交易时段才能完成。
- 较高的参与度可以缩短完成时间,但会增加市场冲击风险。
- 成交量最低阈值可以阻止在流动性不足的区间交易。
- 数量限制和冲击模型分别处理执行的不同方面,可以结合使用。
标签
全文
# ML4T Library: Volume Participation Limits
# ML4T Library: Volume Participation Limits
**Docker image**: `ml4t`
This notebook demonstrates **VolumeParticipationLimit** from ml4t.backtest.execution
for realistic institutional order execution:
1. **Volume Participation Concept**: Why institutions limit market footprint
2. **VolumeParticipationLimit API**: Parameters and behavior
3. **Partial Fills Over Multiple Bars**: Large orders split automatically
4. **Participation Rate Comparison**: a conservative, a standard and an aggressive cap
5. **Real-World Scenario**: Large order with volume constraints
6. **Integration with Impact Models**: Full execution realism
**Key Insight**: Large institutional orders cannot be filled instantly without
moving markets. Volume participation limits enforce realistic execution by
spreading fills across multiple bars based on available liquidity.
**Learning Objectives**
- Explain why desks cap participation as a fraction of available bar volume
- Interpret `ExecutionResult` fields from the ML4T execution broker
- Simulate partial fills across bars and days under different participation caps
- Combine quantity limits with impact-adjusted fill pricing
**Book Reference:** Chapter 18, Section 18.5 (Execution Algorithms as Controls)
**Prerequisites:** Read [`06_ml4t_execution_demo`](06_ml4t_execution_demo.ipynb) for impact-model APIs and
[`04_vwap_twap_execution`](04_vwap_twap_execution.ipynb) for benchmark scheduling logic.
## Imports & Setup
```python
"""ML4T Volume Participation - Realistic execution constraints on real NASDAQ-100 liquidity."""
import plotly.graph_objects as go
import polars as pl
from IPython.display import Markdown, display
from ml4t.backtest.execution import (
SquareRootImpact,
VolumeParticipationLimit,
)
from plotly.subplots import make_subplots
from data import load_nasdaq100_bars
from utils.reproducibility import set_global_seeds
from utils.style import COLORS, show_plotly_with_alt
```
A parent order is released against a real intraday sequence of (volume, price) intervals built
from AlgoSeek NASDAQ-100 minute bars. The participation cap is applied to each interval's
*actual* traded volume, so completion time and realized price come entirely from real
liquidity - there are no synthetic volume curves anywhere in this notebook.
```python
EXEC_SYMBOLS = ["AAPL", "MSFT", "AMZN", "GOOGL", "META"] # liquid NASDAQ-100 names
PRIMARY_SYMBOL = "AAPL" # symbol whose real sessions drive the execution walk
TAQ_START_DATE = "2021-10-01"
TAQ_END_DATE = "2021-12-31"
INTERVAL_MINUTES = 15 # execution grid; 09:30-16:00 -> 26 intervals/session
CALIBRATION_SESSIONS = 20 # completed sessions used to estimate ADV before execution starts
ORDER_PCT_ADV = 0.5 # parent order as a fraction of measured ADV
PARTICIPATION_RATES = [0.05, 0.10, 0.25]
SEED = 42
```
```python
set_global_seeds(SEED)
```
## Part 1: Why Volume Participation Limits?
Institutional traders face a fundamental constraint: **you cannot execute more
than a fraction of market volume without moving prices against you**.
### The Problem
Impact scales with how much of the day's volume the order asks for. An order worth a low
single-digit percentage of daily volume is absorbed with little trace. One approaching a tenth
of it moves the price against the desk as it works. One approaching half the day's volume
cannot be filled at anything near the arrival price.
### Industry Practice
Institutional desks cap participation somewhere in the low-to-mid tens of percent, and the
choice is a speed-versus-impact trade. A stealth cap minimizes footprint and accepts a long
horizon; a standard cap balances the two; an aggressive cap finishes quickly and pays for it,
and is reserved for very liquid names or urgent situations. The three caps this notebook
compares are set in the parameters cell and labelled on every figure below.
```python
# Demonstrate the VolumeParticipationLimit API
limit = VolumeParticipationLimit(max_participation=0.10)
print("VolumeParticipationLimit Configuration")
print("=" * 50)
print(f"Max Participation Rate: {limit.max_participation:.0%}")
print(f"Min Volume Threshold: {limit.min_volume:,.0f}")
# Example calculation
order_qty = 50_000 # shares
bar_volume = 100_000 # shares
price = 150.0
result = limit.calculate(order_qty, bar_volume, price)
print(f"\nExample: {order_qty:,} share order, {bar_volume:,} bar volume")
print(f" Max fillable (10%): {bar_volume * 0.10:,.0f} shares")
print(f" Fillable quantity: {result.fillable_quantity:,.0f} shares")
print(f" Remaining quantity: {result.remaining_quantity:,.0f} shares")
print(f" Participation rate: {result.participation_rate:.1%}")
print(f" Is partial fill: {result.is_partial}")
```
```python
display(
Markdown(
f"**Finding**: A {order_qty:,.0f}-share parent order facing a "
f"{bar_volume:,.0f}-share bar releases only "
f"{result.fillable_quantity:,.0f} shares under a "
f"{limit.max_participation:.0%} participation cap."
)
)
```
## Part 2: The ExecutionResult Object
When VolumeParticipationLimit calculates fillable quantity, it returns an
`ExecutionResult` with complete execution details:
```python
@dataclass
class ExecutionResult:
fillable_quantity: float # Shares that can fill this bar
remaining_quantity: float # Shares queued for next bar
adjusted_price: float # Price (may include impact)
impact_cost: float # Market impact cost
participation_rate: float # Actual % of volume used
```
```python
# Demonstrate different execution scenarios under a 10% participation limit.
limit = VolumeParticipationLimit(max_participation=0.10)
price = 100.0
scenarios = [
("Small order (within limit)", 5_000, 100_000),
("Medium order (at limit)", 10_000, 100_000),
("Large order (exceeds limit)", 50_000, 100_000),
("Very large order (5x limit)", 100_000, 100_000),
("Low volume bar", 10_000, 10_000),
("No volume data", 10_000, None),
]
scenario_rows = []
for name, order_qty, volume in scenarios:
result = limit.calculate(order_qty, volume, price)
scenario_rows.append(
{
"scenario": name,
"order_qty": order_qty,
"bar_volume": volume,
"fill_qty": result.fillable_quantity,
"remaining_qty": result.remaining_quantity,
"participation_rate": result.participation_rate if volume else None,
}
)
scenarios_df = pl.DataFrame(scenario_rows)
scenarios_df
```
**Finding**: `ExecutionResult` turns a limit rule into operational state. The
remaining quantity is the broker's queue for the next bar whenever current
liquidity cannot absorb the order safely.
## Part 3: Real Intraday Liquidity
The participation cap is only meaningful against *real* liquidity. We load
AlgoSeek NASDAQ-100 minute bars, aggregate them onto a 15-minute execution
grid, and build a single consecutive sequence of intervals (each carrying its
actual traded volume and volume-weighted price) for one liquid name. The
parent order walks that real sequence interval by interval.
```python
def load_intraday_panel(
symbols: list[str],
start_date: str,
end_date: str,
interval_minutes: int,
) -> pl.DataFrame:
"""Aggregate regular-session minute bars onto an intraday execution grid."""
session_start = 9 * 60 + 30 # 09:30 as minute-of-day
session_end = 16 * 60 # 16:00
return (
load_nasdaq100_bars(
start_date=start_date,
end_date=end_date,
include_microstructure=True,
lazy=True,
)
.filter(pl.col("symbol").is_in(symbols))
.select("timestamp", "symbol", "volume", "last_trade_price")
.filter(pl.col("last_trade_price").is_not_null() & (pl.col("volume") > 0))
.with_columns(
minute_of_day=pl.col("timestamp").dt.hour().cast(pl.Int32) * 60
+ pl.col("timestamp").dt.minute().cast(pl.Int32)
)
.filter(
(pl.col("minute_of_day") >= session_start) & (pl.col("minute_of_day") < session_end)
)
.with_columns(timestamp=pl.col("timestamp").dt.truncate(f"{interval_minutes}m"))
.group_by("symbol", "timestamp")
.agg(
volume=pl.col("volume").sum(),
price=(pl.col("last_trade_price") * pl.col("volume")).sum() / pl.col("volume").sum(),
)
.with_columns(session=pl.col("timestamp").dt.date())
.sort("symbol", "timestamp")
.collect()
)
```
```python
panel = load_intraday_panel(EXEC_SYMBOLS, TAQ_START_DATE, TAQ_END_DATE, INTERVAL_MINUTES)
# Calibrate on completed sessions, then begin execution strictly afterward.
seq = panel.filter(pl.col("symbol") == PRIMARY_SYMBOL).sort("timestamp")
sessions = seq["session"].unique(maintain_order=True).to_list()
calibration_sessions = sessions[:CALIBRATION_SESSIONS]
execution_sessions = sessions[CALIBRATION_SESSIONS:]
calibration = seq.filter(pl.col("session").is_in(calibration_sessions))
execution = seq.filter(pl.col("session").is_in(execution_sessions))
adv = float(calibration.group_by("session").agg(dv=pl.col("volume").sum())["dv"].mean())
order_shares = int(round(ORDER_PCT_ADV * adv))
print(f"Primary symbol: {PRIMARY_SYMBOL}")
print(
f"Calibration: {len(calibration_sessions)} completed sessions through "
f"{calibration_sessions[-1]}"
)
print(f"Execution: {execution.height:,} intervals from {execution_sessions[0]}")
print(f"Calibration ADV:{adv:>14,.0f} shares")
print(f"Parent order: {order_shares:,} shares ({ORDER_PCT_ADV:.0%} of ADV)")
```
**Finding**: The parent order is a fixed fraction of the symbol's measured ADV,
so it is genuinely large relative to a single session's liquidity. That is the
regime where a participation cap actually binds - small orders clear in one
interval and never exercise the constraint.
### Walk the Parent Order Against Real Intervals
A participation-of-volume order reacts as trades print: after each market
transaction, cumulative child fills may not exceed `cap × cumulative market
volume`. Aggregating that feasible continuous process to 15-minute bars makes
the end-of-interval fill exactly `cap × realized interval volume`; the fill
price is the interval VWAP because the child order participates proportionally
throughout the interval. The simulation does not know final bar volume at the
interval open. Only the cap changes between runs.
A small frame helper adds cumulative shares, cost, and completion. Keeping this
accounting separate leaves the event loop readable as a notebook cell.
```python
def execution_frame(rows: list[dict], order_shares: int) -> pl.DataFrame:
"""Convert fill records to a cumulative execution path."""
df = pl.DataFrame(rows)
if df.height == 0:
return df
return df.with_columns(
cumulative_shares=pl.col("fill_qty").cum_sum(),
cumulative_cost=(pl.col("fill_qty") * pl.col("price")).cum_sum(),
).with_columns(pct_complete=pl.col("cumulative_shares") / order_shares * 100)
```
The walk applies the library limit at every interval, preserves the event
timestamp, and queues any unfilled inventory for the next observed interval.
```python
def participation_walk(
intervals: pl.DataFrame,
order_shares: int,
max_participation: float,
min_volume: float = 0.0,
) -> pl.DataFrame:
"""Release a parent order against a real (volume, price) interval sequence."""
limit = VolumeParticipationLimit(max_participation=max_participation, min_volume=min_volume)
remaining = order_shares
rows: list[dict] = []
sessions = intervals["session"].unique(maintain_order=True).to_list()
session_index = {session: i for i, session in enumerate(sessions)}
for i, row in enumerate(intervals.iter_rows(named=True)):
if remaining <= 0:
break
result = limit.calculate(remaining, float(row["volume"]), float(row["price"]))
if result.fillable_quantity <= 0:
continue
rows.append(
{
"bar": len(rows),
"interval": i,
"session_number": session_index[row["session"]],
"timestamp": row["timestamp"],
"bar_volume": float(row["volume"]),
"price": float(row["price"]),
"fill_qty": result.fillable_quantity,
"remaining": result.remaining_quantity,
"participation": result.participation_rate,
}
)
remaining = result.remaining_quantity
if remaining > 0:
print(
f"WARNING: parent order not fully filled - {remaining:,} of "
f"{order_shares:,} shares remain after {len(rows)} intervals "
f"(data window exhausted before completion)"
)
return execution_frame(rows, order_shares)
```
```python
# Run the walk for each participation cap against the same real interval sequence.
results = {rate: participation_walk(execution, order_shares, rate) for rate in PARTICIPATION_RATES}
print("Participation Rate Comparison")
print(f"Order: {order_shares:,} shares ({PRIMARY_SYMBOL}, {ORDER_PCT_ADV:.0%} of ADV)")
print("=" * 70)
for rate, df in results.items():
print(
f"{rate:>5.0%} limit: {df.height:>3} intervals to complete, "
f"{df['session_number'].max() + 1:>2} sessions, "
f"avg participation: {df['participation'].mean():.1%}"
)
comparison_df = pl.DataFrame(
[
{
"participation_limit": rate,
"intervals_to_complete": df.height,
"sessions_to_complete": df["session_number"].max() + 1,
"avg_participation": df["participation"].mean(),
"vwap": df["cumulative_cost"][-1] / df["cumulative_shares"][-1],
}
for rate, df in results.items()
]
)
```
**Finding**: Against real liquidity the cap is the only lever that changes, so
differences in intervals-to-complete and sessions-to-complete are attributable
directly to footprint discipline. A tighter cap stretches the same parent order
across more real intervals and more trading sessions.
### Plot Completion Paths by Participation Limit
```python
fig = make_subplots(
rows=1,
cols=3,
subplot_titles=["5% Participation", "10% Participation", "25% Participation"],
)
colors = [COLORS["blue"], COLORS["amber"], COLORS["copper"]]
for i, (rate, df) in enumerate(results.items()):
if df.height == 0:
continue
fig.add_scatter(
x=df["bar"].to_list(),
y=df["pct_complete"].to_list(),
mode="lines+markers",
name=f"{rate:.0%}",
line=dict(color=colors[i], width=2),
marker=dict(size=5),
row=1,
col=i + 1,
)
fig.add_hline(
y=100,
line_dash="dash",
line_color=COLORS["neutral"],
row=1,
col=i + 1,
)
fig.update_xaxes(title_text="Executed interval (count)")
fig.update_yaxes(title_text="Parent order filled (%)", range=[0, 105])
fig.update_layout(
title="Cumulative parent-order fill by executed interval, one panel per cap",
height=400,
showlegend=False,
)
show_plotly_with_alt(
fig,
"Three side-by-side line charts of cumulative parent-order fill against executed interval, "
"one panel per participation cap. Every curve climbs from the origin to the dashed "
"completion line at the top, and the horizontal extent each panel needs shrinks by roughly "
"an order of magnitude as the cap loosens from left to right.",
)
```
```python
summary = {row["participation_limit"]: row for row in comparison_df.iter_rows(named=True)}
clearing = ", ".join(
f"{row['intervals_to_complete']} intervals ({row['sessions_to_complete']} sessions) "
f"at a {row['participation_limit']:.0%} cap"
for row in comparison_df.iter_rows(named=True)
)
display(
Markdown(
f"**Finding**: The calibrated half-ADV order clears in {clearing}. "
"The faster schedule consumes more of each interval's liquidity and "
"therefore accepts more market-impact risk per fill."
)
)
```
## Part 4: Large Order Execution Timeline
The same real walk yields the operational outcomes a portfolio manager trades
off: intervals and sessions to completion, realized VWAP against real prices,
and the actual participation share consumed each interval.
```python
# Summarize each cap's real execution outcome.
for rate, df in results.items():
if df.height == 0:
continue
vwap = df["cumulative_cost"][-1] / df["cumulative_shares"][-1]
print(f"\n{rate:.0%} Participation Limit:")
print(f" Intervals to complete: {df.height}")
print(f" Sessions to complete: {df['session_number'].max() + 1}")
print(f" Realized VWAP: ${vwap:.4f}")
print(f" Avg participation: {df['participation'].mean():.1%}")
```
```python
realized = ", ".join(
f"${row['vwap']:.2f} at a {row['participation_limit']:.0%} cap"
for row in comparison_df.iter_rows(named=True)
)
display(
Markdown(
f"**Finding**: Realized VWAP differs across caps ({realized}) because the schedules "
"span different market-price windows. This timing or drift risk is distinct "
"from the participation footprint measured within each interval."
)
)
```
### Visualize Execution Timeline
```python
fig = make_subplots(
rows=2,
cols=2,
subplot_titles=[
"Cumulative Fill (%)",
"Fill Size per Interval",
"Execution Price Path",
"Participation Rate",
],
vertical_spacing=0.12,
horizontal_spacing=0.1,
)
colors = {0.05: COLORS["blue"], 0.10: COLORS["amber"], 0.25: COLORS["copper"]}
```
### Trace Helper for the Four-Panel Diagnostic
```python
def add_execution_traces(fig, df: pl.DataFrame, rate: float, color: str) -> None:
name = f"{rate:.0%}"
bars = df["bar"].to_list()
marker = dict(color=color, size=4, opacity=0.6)
panels = [
(df["pct_complete"].to_list(), "lines", dict(color=color, width=2), 1, 1, True),
(df["fill_qty"].to_list(), "markers", marker, 1, 2, False),
(df["price"].to_list(), "lines", dict(color=color, width=1), 2, 1, False),
([p * 100 for p in df["participation"].to_list()], "markers", marker, 2, 2, False),
]
for values, mode, style, row, col, showlegend in panels:
fig.add_scatter(
x=bars,
y=values,
mode=mode,
name=name,
row=row,
col=col,
showlegend=showlegend,
line=style if mode == "lines" else None,
marker=style if mode == "markers" else None,
)
```
```python
for rate, df in results.items():
if df.height == 0:
continue
add_execution_traces(fig, df, rate, colors[rate])
```
```python
# Finalize execution diagnostics panel
fig.add_hline(y=100, line_dash="dash", line_color=COLORS["neutral"], row=1, col=1)
fig.update_xaxes(title_text="Interval", row=2, col=1)
fig.update_xaxes(title_text="Interval", row=2, col=2)
fig.update_yaxes(title_text="% Complete", row=1, col=1)
fig.update_yaxes(title_text="Shares", row=1, col=2)
fig.update_yaxes(title_text="Price ($)", row=2, col=1)
fig.update_yaxes(title_text="Participation (%)", row=2, col=2)
fig.update_layout(
title="Fill, fill size, execution price and participation, by interval",
height=600,
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
)
show_plotly_with_alt(
fig,
"Four panels comparing the three participation caps. Cumulative fill: the loosest cap "
"reaches the dashed completion line in a short burst while the tightest one climbs almost "
"linearly over several times as many intervals. Fill size per interval: the loosest cap "
"takes large fills early that decay quickly, while the tightest cap's fills stay small and "
"even across the whole horizon. Execution price path: the traded price wanders inside a "
"narrow band with no trend. Participation rate: each schedule holds a flat horizontal line "
"at its own cap, with only isolated points below it.",
)
```
**Finding**: The execution timeline makes the trade-off visible. Conservative
limits stretch the order over more real intervals and sessions, while aggressive
limits raise fill size per interval and therefore increase the likelihood of
adverse impact when liquidity is thin.
## Part 5: Combining with Market Impact Models
For complete execution realism, combine:
1. **VolumeParticipationLimit**: Controls *how much* can fill per bar
2. **MarketImpactModel**: Adjusts *price* based on participation
The broker applies both in sequence.
### Combined Volume-Limit and Impact Pricing Example
```python
# Demonstrate combined volume limit + impact
volume_limit = VolumeParticipationLimit(max_participation=0.10)
impact_model = SquareRootImpact(coefficient=0.5, volatility=0.02)
# Scenario
order_qty = 50_000
bar_volume = 100_000
price = 100.0
is_buy = True
# Step 1: Apply volume limit
exec_result = volume_limit.calculate(order_qty, bar_volume, price)
fill_qty = exec_result.fillable_quantity
# Step 2: Apply market impact
impact = impact_model.calculate(fill_qty, price, bar_volume, is_buy)
fill_price = price + impact
```
```python
print("Combined Execution Model")
print("=" * 60)
print("\n1. Volume Participation Limit (10%):")
print(f" Order quantity: {order_qty:,} shares")
print(f" Bar volume: {bar_volume:,} shares")
print(f" Max fillable: {bar_volume * 0.10:,.0f} shares")
print(f" Actual fill: {fill_qty:,.0f} shares")
print(f" Remaining: {exec_result.remaining_quantity:,.0f} shares")
print("\n2. Market Impact (Square Root):")
print(f" Fill quantity: {fill_qty:,.0f} shares")
print(f" Base price: ${price:.4f}")
print(f" Price impact: ${impact:.4f} ({impact / price * 10000:.1f} bps)")
print(f" Fill price: ${fill_price:.4f}")
print("\n3. Total Execution Cost:")
notional = fill_qty * price
impact_cost = fill_qty * impact
print(f" Notional: ${notional:,.2f}")
print(f" Impact cost: ${impact_cost:,.2f}")
print(f" Total cost: ${notional + impact_cost:,.2f}")
```
**Finding**: Volume limits and market impact answer different questions. The
limit decides how much inventory may trade now; the impact model decides what
price concession that permitted slice should pay.
## Part 6: Minimum Volume Gate
`VolumeParticipationLimit` also takes a `min_volume` floor, which suppresses execution
entirely on any bar whose realized volume falls below it. The cell below sets both parameters
and reports which bars the floor blocks.
**Use Cases:**
- Avoid executing during illiquid periods (lunch hour)
- Prevent orders on halted or thinly-traded stocks
- Implement "volume gates" for risk management
### Minimum-Volume Gate Demonstration
```python
# Demonstrate min_volume threshold by comparing two participation limits side by side.
limit_no_gate = VolumeParticipationLimit(max_participation=0.10, min_volume=0)
limit_with_gate = VolumeParticipationLimit(max_participation=0.10, min_volume=5000)
order_qty = 10_000
price = 100.0
volume_levels = [1000, 3000, 5000, 10000, 50000]
gate_rows = []
for vol in volume_levels:
result_no_gate = limit_no_gate.calculate(order_qty, vol, price)
result_with_gate = limit_with_gate.calculate(order_qty, vol, price)
gate_rows.append(
{
"bar_volume": vol,
"no_gate_fill": result_no_gate.fillable_quantity,
"gate_5k_fill": result_with_gate.fillable_quantity,
"blocked_by_gate": result_with_gate.fillable_quantity == 0 and vol < 5000,
}
)
gate_df = pl.DataFrame(gate_rows)
```
The grouped bars isolate the gate's discontinuity: below the threshold the
permitted fill drops to zero, while both policies agree once volume recovers.
```python
fig = go.Figure()
fig.add_bar(
x=[f"{volume:,}" for volume in gate_df["bar_volume"]],
y=gate_df["no_gate_fill"].to_list(),
name="No minimum-volume gate",
marker_color=COLORS["amber"],
)
fig.add_bar(
x=[f"{volume:,}" for volume in gate_df["bar_volume"]],
y=gate_df["gate_5k_fill"].to_list(),
name="5,000-share minimum",
marker_color=COLORS["blue"],
)
fig.update_layout(
title="Permitted fill by realized bar volume, with and without a floor",
barmode="group",
xaxis_title="Realized bar volume (shares)",
xaxis_type="category",
yaxis_title="Permitted fill (shares)",
)
show_plotly_with_alt(
fig,
"Grouped bar chart of permitted fill against realized bar volume, with one bar for the "
"ungated rule and one for the minimum-volume rule at each volume. On the two thinnest bars "
"only the ungated rule fills at all; from the threshold volume upward the two rules permit "
"the same fill, and that fill keeps growing in proportion to bar volume, so the tallest pair "
"is at the deepest bar.",
)
```
**Finding**: A minimum-volume gate is a second layer of execution discipline.
It prevents the algorithm from trading mechanically through bars that are too
thin to support even a small participation rate safely.
## Summary
### VolumeParticipationLimit Key Points
1. **Purpose**: Enforce realistic execution by limiting fills to a % of volume
2. **Partial Fills**: Large orders automatically split across multiple bars
3. **Broker Integration**: the broker tracks each parent order's remaining quantity
internally and releases it across subsequent bars
### Parameter Guidelines
`max_participation` is the speed-versus-impact dial: the stealth setting minimizes footprint
and accepts the longest horizon, the standard setting is the institutional default, and the
aggressive setting is for urgent orders in liquid names. The three settings compared above are
in the parameters cell and labelled on each figure. `min_volume` is separate - it blocks
illiquid periods outright rather than sizing into them.
### Combining with Impact Models
For full realism, combine:
1. **VolumeParticipationLimit** - Controls quantity per bar
2. **SquareRootImpact** - Adjusts price based on participation
**Next**: See [`06_ml4t_execution_demo`](06_ml4t_execution_demo.ipynb) for market-impact model selection and
[`08_ml_dynamic_execution`](08_ml_dynamic_execution.ipynb) for adaptive execution policies.
```python
comparison_df
```
**Finding**: Participation caps control the speed-impact trade-off, while
minimum-volume gates prevent fills in bars where that trade-off is simply not
worth taking. Together they turn a benchmark schedule into a liquidity-aware
execution policy.
## Key Takeaways
1. **Participation caps mechanize footprint discipline**: the cap limits each
child fill to a fixed share of cumulative market volume, regardless of how
aggressive the parent order is. This turns a target schedule into a
sequence of executable child orders.
2. **Partial-fill arithmetic is additive across bars**: `ExecutionResult`
returns `fillable_quantity` for the current bar and `remaining_quantity`
for the broker queue. Sum of fills equals the parent quantity only when
the order completes; otherwise the residual is what the next bar must
absorb.
3. **Completion time scales inversely with the cap**: against real AAPL
liquidity, higher caps clear the same calibrated half-ADV order in fewer
intervals and sessions. The speedup is roughly proportional to the cap,
but so is the participation footprint consumed each interval, and impact rises
with that footprint, so the genuine cost of a higher cap is impact risk,
not a worse benchmark fill.
4. **Minimum-volume gates are a second layer of control**: caps without a
gate still execute on thin bars, where even a small participation share
is dangerous. A `min_volume` threshold blocks fills entirely until
liquidity recovers - the right rule for lunch-hour or halted markets.
5. **Volume limits and impact models answer different questions**: the
limit decides *how much* trades now; the impact model decides *what
price concession* that permitted slice should pay. Production execution
composes both in sequence.


在遵守原作品许可的前提下,附作者信息全文展示。 许可协议: MIT
此摘要由 Stratmill 研究智能体根据原文撰写,并非原文副本。