fix: ExecutionPrice.VWAP fills at the feed's VWAP, or refuses - #82
Merged
Conversation
ExecutionPrice.VWAP has been declared since the enum was written and never read a VWAP. broker.py resolved it to `self._current_prices.get(asset, self._current_closes.get(asset))` - character-for-character what ExecutionPrice.PRICE returns - and there was no VWAP anywhere in the feed contract for it to read. The branch was also unreachable in practice. The use_open short-circuit excluded only BID, ASK, QUOTE_MID and QUOTE_SIDE, so under ExecutionMode.NEXT_BAR a VWAP request returned the next bar's *open* whatever that branch said. Fixing the lookup alone would have changed nothing observable while making every artifact a reviewer opens - the enum, the config, the data, the new store - read correctly. Both halves are here for that reason. - FeedSpec.vwap_col declares the column; DataFeed carries it into _AssetsData._vwaps and MarketState.vwaps beside the existing OHLCV caches. - ExecutionPrice.VWAP joins the use_open exclusion set: a VWAP is already a whole-bar price and the open must not override it. - A missing VWAP raises instead of falling back. A VWAP fill and a close fill are different assumptions, and substituting one for the other is indistinguishable from a correct run - which is how this survived since the enum was written. - _update_time keeps its existing 2- and 8-positional forms working and gains a 9-positional and a vwaps= keyword form. The compatibility snapshot is regenerated: both surface changes are additive - a nullable field and a keyword-only parameter with a default - so no existing caller breaks. Verified: 1966 passed. tests/test_vwap_execution_price.py uses an open, close and VWAP that are mutually distinct, and 6 of its 9 cases fail against the previous broker; a fixture where any two coincide could not. Depends on ml4t/specs#13, pinned here through a temporary [tool.uv.sources] git source that must be removed before merge. Closes ml4t/agent-workspace#1017.
There was a problem hiding this comment.
🟡 Changes recommended
It includes a clearly-marked temporary dependency pin to a git branch in pyproject.toml/uv.lock that should be removed (and the version floor raised) before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes ExecutionPrice.VWAP so VWAP executions actually use the feed’s VWAP series (and raise explicitly when VWAP data is unavailable), and wires VWAP through the feed → engine → broker market-state pipeline to make that pricing source reachable under NEXT_BAR execution.
Changes:
- Add
FeedSpec.vwap_colplumbed throughDataFeedcaches andMarketState, and pass VWAP caches intoBroker._update_time. - Fix
Broker.get_price_for_source(ExecutionPrice.VWAP, ...)to use VWAP data (and refuse with a targeted error when missing) and make VWAP reachable underNEXT_BARby excluding it from theuse_openshort-circuit. - Add focused regression tests ensuring VWAP differs from open/close and that missing VWAP raises, plus snapshot/state-ownership updates.
File summaries
| File | Description |
|---|---|
src/ml4t/backtest/broker.py |
Implements VWAP pricing + refusal on missing VWAP; extends _update_time to accept VWAP caches; adjusts use_open logic under NEXT_BAR. |
src/ml4t/backtest/datafeed.py |
Adds optional vwap_col support and carries VWAP into per-bar payload + cached _vwaps. |
src/ml4t/backtest/engine.py |
Threads VWAP cache into broker time updates and rebuild path. |
src/ml4t/backtest/core/state.py |
Adds VWAP cache to MarketState. |
src/ml4t/backtest/config.py |
Allows vwap_col to be configured via BacktestConfig.from_dict. |
tests/test_vwap_execution_price.py |
New regression and contract tests for VWAP execution pricing and refusal behavior. |
tests/contracts/test_broker_state_ownership.py |
Registers _current_vwaps in explicit state-ownership contract list. |
tests/compatibility/snapshots/v0.1.json |
Updates compatibility snapshot for the additive vwap_col and signature changes. |
pyproject.toml |
Temporarily pins ml4t-specs to a feature branch via [tool.uv.sources]. |
uv.lock |
Locks ml4t-specs from the same git branch/commit. |
Review details
- Files reviewed: 9/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Amends the previous commit, which conflated two different situations behind one raise. The nasdaq100_microstructure lane asked whether the raise fires upstream of the skip that implements a non-fill, and it does: the fill path is FillEngine.get_fill_price_for_order -> broker.get_price_for_source( broker.execution_price, ...), and every caller in execution_engine.py guards it with `if price is None: continue`. That `continue` is already the behaviour we want - the order does not fill, the prior position stands, no new machinery - and it needs a None. Raising there turned an ordinary no-trade bar into a stopped run. The two cases are now separated by where they are caught: - A feed that declares no vwap_col at all, under execution_price=VWAP, is a configuration error. Engine rejects it once, before the first bar. - An individual asset-bar with no VWAP is an ordinary market state: nothing traded, so there is no volume-weighted price. get_price_for_source returns None and the order goes unfilled. Neither falls back to the close, which is the original defect: on a no-trade bar the close is a stale carried print, so substituting it would invent a fill at a price nothing transacted at. Measured, because the rate decides whether this matters: on the NASDAQ-100 production universe (102 symbols, March 2021) 0.2384% of symbol-minutes inside 09:30-16:00 carry no print, worst name CTAS at 2.95%. Across all hours it is 5.02%, but that population is pre- and post-market and this case study trades the exchange session. Verified: 1970 passed. The refusal tests become non-fill tests, and three new cases cover the configuration guard: it raises on an undeclared column, accepts a declared one, and stays out of the way under any other execution price.
FeedSpec.vwap_col shipped in ml4t-specs 0.1.4 (ml4t/specs#13, b17105b), so the temporary [tool.uv.sources] git pin that let the `ty` hook resolve the field is no longer needed. Delete it and raise the floor to >=0.1.4,<0.2. The pin was also what made every Compatibility job red: those jobs run with --locked, and a git-sourced dependency cannot satisfy a lockfile check against the registry.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ExecutionPrice.VWAPhas been declared since the enum was written and has never read a VWAP.That is character-for-character what
ExecutionPrice.PRICEreturns four lines above it, and there was novwap_colanywhere in the feed contract for it to read even if it had wanted one.Why fixing the lookup alone would have been worse than leaving it
The branch is unreachable under
next_bar. Theuse_openshort-circuit excludes onlyBID,ASK,QUOTE_MIDandQUOTE_SIDE, so a VWAP request returned the next bar's open whatever the VWAP branch said. Landing a real lookup without also removing VWAP from that set would have left the behaviour exactly as it is while making the enum, the config, the data and the new store all read correctly to a reviewer. Both halves are here.What changed
FeedSpec.vwap_colDataFeed_AssetsData._vwapsandMarketState.vwaps, beside the existing OHLCV cachesuse_openexclusion setExecutionPrice.VWAP- a VWAP is already a whole-bar price and the open must not override itEngine.__init__execution_price=VWAPagainst a feed declaring novwap_col, once, before the first barNone, so the order goes unfilled_update_timevwaps=keyword formThe two cases are different, and separating them is the point
Neither falls back to the close. On a no-trade bar the close is a stale carried print, so substituting it invents a fill at a price nothing transacted at - the original defect, in a new place.
But they fail differently:
vwap_coldeclared at all is a configuration error. It cannot be right for any bar, so it is caught once atEngineconstruction rather than discovered at the first fill.get_price_for_sourcereturnsNone, and every caller inexecution_engine.pyalready guards withif price is None: continue. The order does not fill and the prior position stands - which is what happens to a real order resting in a bar with no prints, and it needs no new machinery.An earlier revision of this PR raised in both cases. The
nasdaq100_microstructurelane asked whether the raise fires upstream of thatcontinue, and it does: the fill path isFillEngine.get_fill_price_for_order→broker.get_price_for_source(broker.execution_price, ...). Raising there turned an ordinary no-trade bar into a stopped run.The rate is why this matters. On the NASDAQ-100 production universe (102 symbols, March 2021), 0.2384% of symbol-minutes inside 09:30-16:00 carry no print - worst name CTAS at 2.95%. Across all hours it is 5.02%, but that population is pre- and post-market and the case study trades the exchange session. A raise would have stopped a run reliably; a fallback would have silently mispriced one bar in 420.
Verification
1970 passed, 14 skipped.tests/test_vwap_execution_price.pysets an open of 99.0, a close of 102.0 and a VWAP of 100.5 - mutually distinct, because a fixture where any two coincide cannot fail against the old behaviour. 6 of the original 9 cases fail against the previous broker and pass against this one; I checked that by reverting the two behavioural changes and re-running, not by assuming it.The compatibility snapshot is regenerated. Both surface changes are additive - a nullable field and a keyword-only parameter with a default - so no existing caller breaks.
_current_vwapsis registered intest_broker_state_ownership's explicit-decision list, where the gate correctly demanded it.Dependency (resolved)
FeedSpec.vwap_colshipped in ml4t-specs 0.1.4 (ml4t/specs#13, merged asb17105b). The temporary[tool.uv.sources]git pin is deleted in552ee4fand the floor raised toml4t-specs>=0.1.4,<0.2. That pin was also the sole cause of the red Compatibility jobs: they run--locked, which a git-sourced dependency cannot satisfy. Nothing is outstanding before merge.Blast radius
No published number moves. No case study configures
execution_price: vwaptoday - the eight active presets usequote_side,closeoropen- so nothing has ever been filled at a substituted price. This is an API-surface defect caught before its first use.The first use is
nasdaq100_microstructure, being rebuilt on a fixed 15-minute decision clock over one-minute bars where the settled execution assumption is the next minute's VWAP (ml4t/agent-workspace#187). Without this change that case study would have filled at the open - the assumption the design explicitly rejected for implying near-zero execution latency - and the run would have looked correct.Closes ml4t/agent-workspace#1017.