|
8 | 8 | """ |
9 | 9 | from __future__ import annotations |
10 | 10 |
|
| 11 | +import logging |
| 12 | +import unittest |
11 | 13 | from datetime import datetime, timedelta |
12 | 14 | from types import SimpleNamespace |
13 | 15 |
|
14 | 16 | import polars as pl |
15 | | -import pytest |
16 | 17 |
|
17 | 18 | from investing_algorithm_framework import ( |
18 | 19 | AverageDollarVolume, |
@@ -77,120 +78,121 @@ def _make_data_sources_and_data(): |
77 | 78 | return sources, data |
78 | 79 |
|
79 | 80 |
|
80 | | -def test_inject_pipelines_no_pipelines_is_noop(): |
81 | | - sources, data = _make_data_sources_and_data() |
82 | | - strategy = _StubStrategy(data_sources=sources, pipelines=None) |
83 | | - before = dict(data) |
84 | | - |
85 | | - VectorBacktestService._inject_pipelines( |
86 | | - strategy=strategy, |
87 | | - data=data, |
88 | | - backtest_date_range=BacktestDateRange( |
89 | | - start_date=datetime(2024, 1, 2), |
90 | | - end_date=datetime(2024, 1, 4), |
91 | | - ), |
92 | | - ) |
93 | | - assert data == before |
94 | | - |
95 | | - |
96 | | -def test_inject_pipelines_adds_long_frame_keyed_by_class_name(): |
97 | | - sources, data = _make_data_sources_and_data() |
98 | | - strategy = _StubStrategy(data_sources=sources, pipelines=[_Screener]) |
99 | | - |
100 | | - VectorBacktestService._inject_pipelines( |
101 | | - strategy=strategy, |
102 | | - data=data, |
103 | | - backtest_date_range=BacktestDateRange( |
104 | | - start_date=datetime(2024, 1, 2), |
105 | | - end_date=datetime(2024, 1, 4), |
106 | | - ), |
107 | | - ) |
108 | | - |
109 | | - assert "_Screener" in data |
110 | | - out = data["_Screener"] |
111 | | - assert isinstance(out, pl.DataFrame) |
112 | | - # Long format: datetime + symbol + factor columns (universe dropped) |
113 | | - assert set(out.columns) == {"datetime", "symbol", "adv", "momentum"} |
114 | | - # Universe restricts to top-2 by ADV every bar; BBB always loses |
115 | | - # (volume=1 vs 100/200) so it must never appear in the output. |
116 | | - assert "BBB/EUR" not in out["symbol"].to_list() |
117 | | - |
118 | | - |
119 | | -def test_inject_pipelines_respects_end_date_no_lookahead(): |
120 | | - sources, data = _make_data_sources_and_data() |
121 | | - strategy = _StubStrategy(data_sources=sources, pipelines=[_Screener]) |
122 | | - |
123 | | - VectorBacktestService._inject_pipelines( |
124 | | - strategy=strategy, |
125 | | - data=data, |
126 | | - backtest_date_range=BacktestDateRange( |
127 | | - start_date=datetime(2024, 1, 2), |
128 | | - end_date=datetime(2024, 1, 3), |
129 | | - ), |
130 | | - ) |
131 | | - out = data["_Screener"] |
132 | | - # No bars beyond end_date should leak in. |
133 | | - max_dt = out["datetime"].max() |
134 | | - assert max_dt <= datetime(2024, 1, 3) |
135 | | - |
136 | | - |
137 | | -def test_inject_pipelines_skips_when_no_ohlcv_sources(caplog): |
138 | | - """A strategy with pipelines but no OHLCV data sources should log |
139 | | - and skip silently without raising.""" |
140 | | - strategy = _StubStrategy(data_sources=[], pipelines=[_Screener]) |
141 | | - data = {} |
| 81 | +class TestVectorBacktestPipelineInjection(unittest.TestCase): |
| 82 | + |
| 83 | + def test_inject_pipelines_no_pipelines_is_noop(self): |
| 84 | + sources, data = _make_data_sources_and_data() |
| 85 | + strategy = _StubStrategy(data_sources=sources, pipelines=None) |
| 86 | + before = dict(data) |
142 | 87 |
|
143 | | - with caplog.at_level( |
144 | | - "WARNING", |
145 | | - logger=( |
146 | | - "investing_algorithm_framework.infrastructure.services." |
147 | | - "backtesting.vector_backtest_service" |
148 | | - ), |
149 | | - ): |
150 | 88 | VectorBacktestService._inject_pipelines( |
151 | 89 | strategy=strategy, |
152 | 90 | data=data, |
153 | 91 | backtest_date_range=BacktestDateRange( |
154 | 92 | start_date=datetime(2024, 1, 2), |
155 | | - end_date=datetime(2024, 1, 3), |
| 93 | + end_date=datetime(2024, 1, 4), |
156 | 94 | ), |
157 | 95 | ) |
| 96 | + self.assertEqual(data, before) |
158 | 97 |
|
159 | | - assert "_Screener" not in data |
160 | | - assert any( |
161 | | - "no OHLCV data sources" in record.message |
162 | | - for record in caplog.records |
163 | | - ) |
164 | | - |
165 | | - |
166 | | -def test_inject_pipelines_re_raises_engine_errors(): |
167 | | - """Pipeline evaluation errors should propagate to the caller so |
168 | | - backtest authors see a clear failure rather than a silent skip.""" |
| 98 | + def test_inject_pipelines_adds_long_frame_keyed_by_class_name(self): |
| 99 | + sources, data = _make_data_sources_and_data() |
| 100 | + strategy = _StubStrategy(data_sources=sources, pipelines=[_Screener]) |
169 | 101 |
|
170 | | - class _Broken(Pipeline): |
171 | | - adv = AverageDollarVolume(window=2) |
172 | | - momentum = Returns(window=2) |
| 102 | + VectorBacktestService._inject_pipelines( |
| 103 | + strategy=strategy, |
| 104 | + data=data, |
| 105 | + backtest_date_range=BacktestDateRange( |
| 106 | + start_date=datetime(2024, 1, 2), |
| 107 | + end_date=datetime(2024, 1, 4), |
| 108 | + ), |
| 109 | + ) |
173 | 110 |
|
174 | | - sources, data = _make_data_sources_and_data() |
175 | | - # Corrupt one of the inputs to force a polars-level error. |
176 | | - bad_id = sources[0].get_identifier() |
177 | | - data[bad_id] = pl.DataFrame( |
178 | | - {"Datetime": [datetime(2024, 1, 1)], "Close": [10.0]} |
179 | | - ) |
| 111 | + self.assertIn("_Screener", data) |
| 112 | + out = data["_Screener"] |
| 113 | + self.assertIsInstance(out, pl.DataFrame) |
| 114 | + # Long format: datetime + symbol + factor columns (universe dropped) |
| 115 | + self.assertEqual( |
| 116 | + set(out.columns), {"datetime", "symbol", "adv", "momentum"} |
| 117 | + ) |
| 118 | + # Universe restricts to top-2 by ADV every bar; BBB always loses |
| 119 | + # (volume=1 vs 100/200) so it must never appear in the output. |
| 120 | + self.assertNotIn("BBB/EUR", out["symbol"].to_list()) |
180 | 121 |
|
181 | | - strategy = _StubStrategy(data_sources=sources, pipelines=[_Broken]) |
| 122 | + def test_inject_pipelines_respects_end_date_no_lookahead(self): |
| 123 | + sources, data = _make_data_sources_and_data() |
| 124 | + strategy = _StubStrategy(data_sources=sources, pipelines=[_Screener]) |
182 | 125 |
|
183 | | - with pytest.raises(Exception): |
184 | 126 | VectorBacktestService._inject_pipelines( |
185 | 127 | strategy=strategy, |
186 | 128 | data=data, |
187 | 129 | backtest_date_range=BacktestDateRange( |
188 | 130 | start_date=datetime(2024, 1, 2), |
189 | | - end_date=datetime(2024, 1, 4), |
| 131 | + end_date=datetime(2024, 1, 3), |
190 | 132 | ), |
191 | 133 | ) |
| 134 | + out = data["_Screener"] |
| 135 | + # No bars beyond end_date should leak in. |
| 136 | + max_dt = out["datetime"].max() |
| 137 | + self.assertLessEqual(max_dt, datetime(2024, 1, 3)) |
| 138 | + |
| 139 | + def test_inject_pipelines_skips_when_no_ohlcv_sources(self): |
| 140 | + """A strategy with pipelines but no OHLCV data sources should log |
| 141 | + and skip silently without raising.""" |
| 142 | + strategy = _StubStrategy(data_sources=[], pipelines=[_Screener]) |
| 143 | + data = {} |
| 144 | + |
| 145 | + logger_name = ( |
| 146 | + "investing_algorithm_framework.infrastructure.services." |
| 147 | + "backtesting.vector_backtest_service" |
| 148 | + ) |
| 149 | + with self.assertLogs(logger_name, level=logging.WARNING) as cm: |
| 150 | + VectorBacktestService._inject_pipelines( |
| 151 | + strategy=strategy, |
| 152 | + data=data, |
| 153 | + backtest_date_range=BacktestDateRange( |
| 154 | + start_date=datetime(2024, 1, 2), |
| 155 | + end_date=datetime(2024, 1, 3), |
| 156 | + ), |
| 157 | + ) |
| 158 | + |
| 159 | + self.assertNotIn("_Screener", data) |
| 160 | + self.assertTrue( |
| 161 | + any("no OHLCV data sources" in msg for msg in cm.output) |
| 162 | + ) |
| 163 | + |
| 164 | + def test_inject_pipelines_re_raises_engine_errors(self): |
| 165 | + """Pipeline evaluation errors should propagate to the caller so |
| 166 | + backtest authors see a clear failure rather than a silent skip.""" |
| 167 | + |
| 168 | + class _Broken(Pipeline): |
| 169 | + adv = AverageDollarVolume(window=2) |
| 170 | + momentum = Returns(window=2) |
| 171 | + |
| 172 | + sources, data = _make_data_sources_and_data() |
| 173 | + # Corrupt one of the inputs to force a polars-level error. |
| 174 | + bad_id = sources[0].get_identifier() |
| 175 | + data[bad_id] = pl.DataFrame( |
| 176 | + {"Datetime": [datetime(2024, 1, 1)], "Close": [10.0]} |
| 177 | + ) |
| 178 | + |
| 179 | + strategy = _StubStrategy(data_sources=sources, pipelines=[_Broken]) |
| 180 | + |
| 181 | + with self.assertRaises(Exception): |
| 182 | + VectorBacktestService._inject_pipelines( |
| 183 | + strategy=strategy, |
| 184 | + data=data, |
| 185 | + backtest_date_range=BacktestDateRange( |
| 186 | + start_date=datetime(2024, 1, 2), |
| 187 | + end_date=datetime(2024, 1, 4), |
| 188 | + ), |
| 189 | + ) |
192 | 190 |
|
193 | 191 |
|
194 | 192 | # Suppress the unused import warning — kept for symmetry with other |
195 | 193 | # vector-backtest tests in the suite. |
196 | 194 | _ = SimpleNamespace, DataType |
| 195 | + |
| 196 | + |
| 197 | +if __name__ == "__main__": |
| 198 | + unittest.main() |
0 commit comments