From 1b1f432eece5c3e85b978caf4f4657cadaaa3059 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Wed, 2 Sep 2026 20:23:09 -0400 Subject: [PATCH 1/2] feat: FeedSpec carries vwap_col so a feed can declare a volume-weighted price ExecutionPrice.VWAP has existed in ml4t-backtest since the enum was written and has never had a column to read: there is no vwap_col anywhere in the feed contract, so the broker resolved it to the close and, under next_bar execution, to the open. Neither substitution was reported. vwap_col defaults to None rather than to a guessed column name. Open, high, low, close and volume all have conventional names worth defaulting to; VWAP does not, and a consumer asking for it is asking for a price no other column approximates. The close is not a worse VWAP, it is a different quantity, so a feed that does not carry one must say so and let the consumer refuse. Refs ml4t/agent-workspace#1017. --- src/ml4t/specs/market_data.py | 6 ++++++ tests/test_market_data.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/ml4t/specs/market_data.py b/src/ml4t/specs/market_data.py index 7ae0a5d..d41e2d2 100644 --- a/src/ml4t/specs/market_data.py +++ b/src/ml4t/specs/market_data.py @@ -32,6 +32,7 @@ class FeedSpec: "high_col": ("high_col",), "low_col": ("low_col",), "volume_col": ("volume_col",), + "vwap_col": ("vwap_col",), "bid_col": ("bid_col",), "ask_col": ("ask_col",), "mid_col": ("mid_col",), @@ -63,6 +64,11 @@ class FeedSpec: low_col: str = "low" close_col: str = "close" volume_col: str = "volume" + # No default. A feed that does not carry a volume-weighted average price must not + # silently supply a substitute for one: a consumer asking for VWAP is asking for a + # price no other column approximates, and the close is not a worse VWAP, it is a + # different quantity. + vwap_col: str | None = None bid_col: str | None = None ask_col: str | None = None mid_col: str | None = None diff --git a/tests/test_market_data.py b/tests/test_market_data.py index d8c8731..3b38f44 100644 --- a/tests/test_market_data.py +++ b/tests/test_market_data.py @@ -339,3 +339,28 @@ def test_feed_spec_rejects_indirect_metadata_cycles() -> None: with pytest.raises(ValueError, match="reference cycles"): FeedSpec.from_object(first) + + +class TestVwapCol: + """A feed declares its VWAP column or has none; nothing substitutes for one.""" + + def test_defaults_to_none(self): + # Unlike open/high/low/close/volume, VWAP has no conventional column name to + # guess at, and guessing wrong is worse than not carrying it. + assert FeedSpec().vwap_col is None + + def test_round_trips_through_a_mapping(self): + assert FeedSpec.from_mapping({"vwap_col": "vwap"}).vwap_col == "vwap" + + def test_round_trips_through_an_object(self): + spec = FeedSpec.from_any(SimpleNamespace(vwap_col="finra_vwap")) + assert spec.vwap_col == "finra_vwap" + + def test_survives_with_overrides(self): + assert FeedSpec().with_overrides(vwap_col="vwap").vwap_col == "vwap" + + def test_an_override_of_none_leaves_a_declared_column_alone(self): + # with_overrides treats None as "not supplied" for every other optional column, + # so a caller passing vwap_col=None must not clear one already declared. + spec = FeedSpec(vwap_col="vwap").with_overrides(vwap_col=None) + assert spec.vwap_col == "vwap" From 9d7dbfa5bf88b3fa7f1e0c63b1373775da213d96 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Wed, 2 Sep 2026 20:50:01 -0400 Subject: [PATCH 2/2] fix: preserve VWAP columns in market data specs --- src/ml4t/specs/market_data.py | 2 ++ tests/test_market_data.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/ml4t/specs/market_data.py b/src/ml4t/specs/market_data.py index d41e2d2..8b5a2d9 100644 --- a/src/ml4t/specs/market_data.py +++ b/src/ml4t/specs/market_data.py @@ -257,6 +257,7 @@ class MarketDataSchema: low_col: str = "low" close_col: str = "close" volume_col: str = "volume" + vwap_col: str | None = None bid_col: str | None = None ask_col: str | None = None mid_col: str | None = None @@ -294,6 +295,7 @@ def from_mapping(cls, mapping: Mapping[str, Any] | None) -> MarketDataSchema: low_col=mapping.get("low_col", "low"), close_col=mapping.get("close_col", "close"), volume_col=mapping.get("volume_col", "volume"), + vwap_col=optional_str(mapping.get("vwap_col")), bid_col=optional_str(mapping.get("bid_col")), ask_col=optional_str(mapping.get("ask_col")), mid_col=optional_str(mapping.get("mid_col")), diff --git a/tests/test_market_data.py b/tests/test_market_data.py index 3b38f44..4624148 100644 --- a/tests/test_market_data.py +++ b/tests/test_market_data.py @@ -364,3 +364,13 @@ def test_an_override_of_none_leaves_a_declared_column_alone(self): # so a caller passing vwap_col=None must not clear one already declared. spec = FeedSpec(vwap_col="vwap").with_overrides(vwap_col=None) assert spec.vwap_col == "vwap" + + def test_survives_market_data_spec_projection(self): + spec = MarketDataSpec.from_mapping( + {"artifact_id": "nasdaq", "schema": {"vwap_col": "finra_vwap"}} + ) + + assert spec.schema.vwap_col == "finra_vwap" + assert spec.to_dict()["schema"]["vwap_col"] == "finra_vwap" + assert spec.to_feed_spec().vwap_col == "finra_vwap" + assert FeedSpec.from_any(spec.to_dict()).vwap_col == "finra_vwap"