diff --git a/src/ml4t/specs/market_data.py b/src/ml4t/specs/market_data.py index 7ae0a5d..8b5a2d9 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 @@ -251,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 @@ -288,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 d8c8731..4624148 100644 --- a/tests/test_market_data.py +++ b/tests/test_market_data.py @@ -339,3 +339,38 @@ 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" + + 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"