Skip to content

fix: ExecutionPrice.VWAP fills at the feed's VWAP, or refuses - #82

Merged
stefan-jansen merged 4 commits into
mainfrom
fix/vwap-execution-price
Sep 3, 2026
Merged

fix: ExecutionPrice.VWAP fills at the feed's VWAP, or refuses#82
stefan-jansen merged 4 commits into
mainfrom
fix/vwap-execution-price

Conversation

@stefan-jansen

@stefan-jansen stefan-jansen commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

ExecutionPrice.VWAP has been declared since the enum was written and has never read a VWAP.

# broker.py, before
if source == ExecutionPrice.VWAP:
    return self._current_prices.get(asset, self._current_closes.get(asset))

That is character-for-character what ExecutionPrice.PRICE returns four lines above it, and there was no vwap_col anywhere 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. The use_open short-circuit excludes only BID, ASK, QUOTE_MID and QUOTE_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_col declares the column (ml4t/specs#13)
DataFeed carries it into _AssetsData._vwaps and MarketState.vwaps, beside the existing OHLCV caches
use_open exclusion set gains ExecutionPrice.VWAP - a VWAP is already a whole-bar price and the open must not override it
Engine.__init__ rejects execution_price=VWAP against a feed declaring no vwap_col, once, before the first bar
a bar with no VWAP resolves to None, so the order goes unfilled
_update_time keeps its 2- and 8-positional forms; gains a 9-positional and a vwaps= keyword form

The 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:

  • No vwap_col declared at all is a configuration error. It cannot be right for any bar, so it is caught once at Engine construction rather than discovered at the first fill.
  • 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 every caller in execution_engine.py already guards with if 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_microstructure lane asked whether the raise fires upstream of that continue, and it does: the fill path is FillEngine.get_fill_price_for_orderbroker.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.py sets 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_vwaps is registered in test_broker_state_ownership's explicit-decision list, where the gate correctly demanded it.

Dependency (resolved)

FeedSpec.vwap_col shipped in ml4t-specs 0.1.4 (ml4t/specs#13, merged as b17105b). The temporary [tool.uv.sources] git pin is deleted in 552ee4f and the floor raised to ml4t-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: vwap today - the eight active presets use quote_side, close or open - 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.

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.
Copilot AI lite review requested due to automatic review settings September 3, 2026 00:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_col plumbed through DataFeed caches and MarketState, and pass VWAP caches into Broker._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 under NEXT_BAR by excluding it from the use_open short-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.

Comment thread pyproject.toml Outdated
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.
@stefan-jansen
stefan-jansen merged commit da7bf2e into main Sep 3, 2026
37 checks passed
@stefan-jansen
stefan-jansen deleted the fix/vwap-execution-price branch September 3, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants