Skip to content

Commit f43b59a

Browse files
committed
Add docstrings for the vector_mapping and universe_mapping modules.
Add real benchmarking tests for vector mapping operations.
1 parent 6eca223 commit f43b59a

File tree

7 files changed

+194
-25
lines changed

7 files changed

+194
-25
lines changed

justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ docs open="" output_dir="docs/_build/html":
3232
doctest:
3333
uv sync --group docs
3434
uv run sphinx-build -b doctest -W --keep-going docs/source docs/_build/doctest
35+
36+
benchmark:
37+
uv sync --group dev
38+
uv run pytest tests/benchmark -m benchmark

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dev = [
2727
"pyrefly>=0.29.2",
2828
"pyright>=1.1.404",
2929
"pytest>=8.3.0",
30+
"pytest-benchmark>=5.2.3",
3031
"pytest-cov>=5.0.0",
3132
"ruff>=0.12.8",
3233
"ty>=0.0.1a17",
@@ -75,6 +76,9 @@ ignore = ["EM101"]
7576
[tool.ruff.format]
7677
docstring-code-format = true
7778

79+
[tool.pytest.ini_options]
80+
addopts = "-m 'not benchmark'"
81+
7882
[tool.ty.src]
7983
include = ["src"]
8084
exclude = ["src/backtest_lib/examples/*"]

src/backtest_lib/universe/universe_mapping.py

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
"""Universe-aligned numeric mappings.
2+
3+
Universe mappings are vector mappings keyed by security identifiers. Backends
4+
can use aligned key ordering to perform fast vectorized arithmetic. When two
5+
mappings share the same universe ordering, operations are fastest; mismatched
6+
ordering remains correct but incurs extra reindexing. See
7+
``tests/benchmark/polars_impl/test_universe_mapping_benchmark.py`` for benchmarks.
8+
"""
9+
110
from __future__ import annotations
211

3-
from abc import ABC
12+
from abc import ABC, abstractmethod
413
from collections.abc import Iterable, Mapping
5-
from typing import (
6-
TypeVar,
7-
abstractmethod,
8-
)
14+
from typing import TypeVar
915

1016
from backtest_lib.market._backends import _get_mapping_type_from_backend
1117
from backtest_lib.market.plotting import UniverseMappingPlotAccessor
@@ -16,33 +22,63 @@
1622

1723

1824
class UniverseMapping[Scalar: (float, int)](VectorMapping[str, Scalar], ABC):
25+
"""Vector mapping keyed by security identifiers.
26+
27+
Implementations back this interface with dense vectors aligned to a universe
28+
ordering, enabling fast arithmetic when operands share the same ordering.
29+
"""
30+
1931
@property
20-
def plot(self) -> UniverseMappingPlotAccessor: ...
32+
def plot(self) -> UniverseMappingPlotAccessor:
33+
"""Return the plotting accessor for the mapping."""
34+
...
2135

2236
@abstractmethod
2337
def __truediv__(
2438
self,
2539
other: VectorMapping | float | int | Mapping[str, int | float],
26-
) -> UniverseMapping[float]: ...
40+
) -> UniverseMapping[float]:
41+
"""Return the elementwise quotient of two mappings."""
42+
...
2743

2844
@abstractmethod
2945
def __rtruediv__(
3046
self,
3147
other: VectorMapping | float | int | Mapping[str, int | float],
32-
) -> UniverseMapping[float]: ...
48+
) -> UniverseMapping[float]:
49+
"""Return the elementwise quotient with reversed operands."""
50+
...
3351

3452
@abstractmethod
35-
def floor(self) -> UniverseMapping[int]: ...
53+
def floor(self) -> UniverseMapping[int]:
54+
"""Return a mapping with values floored to integers."""
55+
...
3656

3757
@abstractmethod
38-
def truncate(self) -> UniverseMapping[int]: ...
58+
def truncate(self) -> UniverseMapping[int]:
59+
"""Return a mapping with values truncated to integers."""
60+
...
3961

4062

4163
def make_universe_mapping[T: (int, float)](
4264
m: Mapping[str, T],
4365
universe: Iterable[str],
4466
constructor_backend: str = "polars",
4567
) -> UniverseMapping[T]:
68+
"""Create a universe-aligned mapping from an arbitrary mapping.
69+
70+
Values are re-ordered to match ``universe`` and missing keys are filled with
71+
zeros. Keeping a stable universe ordering enables faster vectorized
72+
operations when combining mappings.
73+
74+
Args:
75+
m: Mapping of security identifiers to values.
76+
universe: Ordered universe defining the mapping alignment.
77+
constructor_backend: Backend used for the concrete mapping type.
78+
79+
Returns:
80+
UniverseMapping aligned to ``universe``.
81+
"""
4682
if not isinstance(m, UniverseMapping) and isinstance(m, Mapping):
4783
backend_mapping_type = _get_mapping_type_from_backend(constructor_backend)
4884
# TODO: check if this dictionary collection is slow.

src/backtest_lib/universe/vector_mapping.py

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
"""Vectorized mapping primitives for numeric data.
2+
3+
These mappings support elementwise arithmetic and can be implemented with
4+
backend-specific vectorized operations. Performance is best when two mappings
5+
share the same key order, because backends can apply operations without
6+
reordering. The benchmarks in
7+
``tests/benchmark/polars_impl/test_universe_mapping_benchmark.py`` illustrate the
8+
penalty for mismatched ordering.
9+
"""
10+
111
from __future__ import annotations
212

313
from abc import ABC, abstractmethod
@@ -12,6 +22,13 @@
1222

1323

1424
class VectorMapping[K, V_co](Mapping[K, V_co], ABC):
25+
"""Mapping with vectorized arithmetic semantics.
26+
27+
Backends may rely on matching key order to perform fast vector operations.
28+
When key order differs between two mappings, operations remain correct but
29+
typically require reindexing and incur a performance cost.
30+
"""
31+
1532
@overload
1633
def __add__(
1734
self: VectorMapping[K, int],
@@ -32,7 +49,9 @@ def __add__(
3249
| Mapping[K, int | float],
3350
) -> VectorMapping[K, float]: ...
3451
@abstractmethod
35-
def __add__(self, other) -> VectorMapping: ...
52+
def __add__(self, other) -> VectorMapping:
53+
"""Return the elementwise sum of two mappings."""
54+
...
3655

3756
@overload
3857
def __radd__(
@@ -54,7 +73,9 @@ def __radd__(
5473
| Mapping[K, int | float],
5574
) -> VectorMapping[K, float]: ...
5675
@abstractmethod
57-
def __radd__(self, other) -> VectorMapping: ...
76+
def __radd__(self, other) -> VectorMapping:
77+
"""Return the elementwise sum with reversed operands."""
78+
...
5879

5980
@overload
6081
def __sub__(
@@ -76,7 +97,9 @@ def __sub__(
7697
| Mapping[K, int | float],
7798
) -> VectorMapping[K, float]: ...
7899
@abstractmethod
79-
def __sub__(self, other) -> VectorMapping: ...
100+
def __sub__(self, other) -> VectorMapping:
101+
"""Return the elementwise difference of two mappings."""
102+
...
80103

81104
@overload
82105
def __rsub__(
@@ -98,7 +121,9 @@ def __rsub__(
98121
| Mapping[K, int | float],
99122
) -> VectorMapping[K, float]: ...
100123
@abstractmethod
101-
def __rsub__(self, other) -> VectorMapping: ...
124+
def __rsub__(self, other) -> VectorMapping:
125+
"""Return the elementwise difference with reversed operands."""
126+
...
102127

103128
@overload
104129
def __mul__(
@@ -120,7 +145,9 @@ def __mul__(
120145
| Mapping[K, int | float],
121146
) -> VectorMapping[K, float]: ...
122147
@abstractmethod
123-
def __mul__(self, other) -> VectorMapping: ...
148+
def __mul__(self, other) -> VectorMapping:
149+
"""Return the elementwise product of two mappings."""
150+
...
124151

125152
@overload
126153
def __rmul__(
@@ -142,21 +169,29 @@ def __rmul__(
142169
| Mapping[K, int | float],
143170
) -> VectorMapping[K, float]: ...
144171
@abstractmethod
145-
def __rmul__(self, other) -> VectorMapping: ...
172+
def __rmul__(self, other) -> VectorMapping:
173+
"""Return the elementwise product with reversed operands."""
174+
...
146175

147176
@abstractmethod
148177
def __truediv__(
149178
self,
150179
other: VectorMapping | float | int | Mapping[K, int | float],
151-
) -> VectorMapping[K, float]: ...
180+
) -> VectorMapping[K, float]:
181+
"""Return the elementwise quotient of two mappings."""
182+
...
152183

153184
@abstractmethod
154185
def __rtruediv__(
155186
self, other: VectorMapping | float | int | Mapping[K, int | float]
156-
) -> VectorMapping[K, float]: ...
187+
) -> VectorMapping[K, float]:
188+
"""Return the elementwise quotient with reversed operands."""
189+
...
157190

158191
@abstractmethod
159-
def sum(self) -> float: ...
192+
def sum(self) -> float:
193+
"""Return the sum of all values."""
194+
...
160195

161196
# Default implementations of mean(), override this
162197
# where possible with faster vector operations
@@ -167,20 +202,32 @@ def mean(self) -> float:
167202
return self.sum() / n
168203

169204
@abstractmethod
170-
def abs(self) -> Self: ...
205+
def abs(self) -> Self:
206+
"""Return a mapping with absolute values."""
207+
...
171208

172209
@abstractmethod
173-
def truncate(self) -> VectorMapping[K, int]: ...
210+
def truncate(self) -> VectorMapping[K, int]:
211+
"""Return a mapping with values truncated to integers."""
212+
...
174213

175214
@abstractmethod
176-
def floor(self) -> VectorMapping[K, int]: ...
215+
def floor(self) -> VectorMapping[K, int]:
216+
"""Return a mapping with values floored to integers."""
217+
...
177218

178219
@abstractmethod
179-
def __getitem__(self, key: K) -> V_co: ...
220+
def __getitem__(self, key: K) -> V_co:
221+
"""Return the value for a key."""
222+
...
180223

181224
@abstractmethod
182-
def __iter__(self) -> Iterator[K]: ...
225+
def __iter__(self) -> Iterator[K]:
226+
"""Return an iterator over keys in order."""
227+
...
183228

184229
@classmethod
185230
@abstractmethod
186-
def from_vectors(cls, keys: Iterable[K_contra], values: Iterable[V_co]) -> Self: ...
231+
def from_vectors(cls, keys: Iterable[K_contra], values: Iterable[V_co]) -> Self:
232+
"""Create a mapping from ordered key/value vectors."""
233+
...
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Benchmarks for UniverseMapping ordering behavior.
2+
3+
These timings highlight the performance impact when key order differs between mappings.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import pytest
9+
from pytest_benchmark.fixture import BenchmarkFixture
10+
11+
from backtest_lib.market.polars_impl import PolarsUniverseMapping
12+
13+
14+
def _benchmark_addition(
15+
keys: list[str],
16+
other_keys: list[str],
17+
) -> PolarsUniverseMapping:
18+
values = [1] * len(keys)
19+
acc = PolarsUniverseMapping.from_vectors(keys, values)
20+
other = PolarsUniverseMapping.from_vectors(other_keys, values)
21+
return acc + other
22+
23+
24+
@pytest.mark.benchmark
25+
def test_small_ordering_same(benchmark: BenchmarkFixture) -> None:
26+
keys = ["a", "b", "c"]
27+
benchmark(_benchmark_addition, keys, keys)
28+
29+
30+
@pytest.mark.benchmark
31+
def test_small_ordering_diff(benchmark: BenchmarkFixture) -> None:
32+
keys = ["a", "b", "c"]
33+
diff_keys = ["c", "a", "b"]
34+
benchmark(_benchmark_addition, keys, diff_keys)
35+
36+
37+
@pytest.mark.benchmark
38+
def test_large_ordering_same(benchmark: BenchmarkFixture) -> None:
39+
keys = [str(i) for i in range(1000)]
40+
benchmark(_benchmark_addition, keys, keys)
41+
42+
43+
@pytest.mark.benchmark
44+
def test_large_ordering_diff(benchmark: BenchmarkFixture) -> None:
45+
keys = [str(i) for i in range(1000)]
46+
diff_keys = list(reversed(keys))
47+
benchmark(_benchmark_addition, keys, diff_keys)

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
from backtest_lib import MarketView
77

88

9+
def pytest_configure(config: pytest.Config) -> None:
10+
config.addinivalue_line(
11+
"markers",
12+
"benchmark: performance benchmarks (opt-in)",
13+
)
14+
15+
916
@pytest.fixture(scope="session")
1017
def test_data_dir() -> Path:
1118
return Path(__file__).resolve().parent / "data"

uv.lock

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)