Skip to content

Commit 522992c

Browse files
authored
Merge pull request #90 from tschm/fix/86-benchmark-stress-tiers
Populate the benchmark, stress and property-based test tiers
2 parents d46f7e2 + 32828de commit 522992c

7 files changed

Lines changed: 353 additions & 0 deletions

File tree

‎pyproject.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ test = [
4040
"pytest>=8.3.0,<9.0.0",
4141
"pytest-cov>=6.0.0",
4242
"httpx>=0.27.0,<0.29.0",
43+
# Property-based tests for validate_input live in
44+
# tests/pycharting/data/test_ingestion.py. Also supplied by rhiza's
45+
# .rhiza/requirements/tests.txt, but declared here so `uv run pytest`
46+
# works without `make install` having run first.
47+
"hypothesis>=6.100.0",
4348
]
4449
lint = [
4550
"ruff>=0.6.0",

‎tests/benchmarks/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Performance benchmarks, run by ``make benchmark``.
2+
3+
Excluded from ``make test`` (``--ignore=tests/benchmarks``) because benchmarks
4+
measure rather than assert, and are unreliable under ``pytest-xdist``.
5+
"""
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Benchmarks for the hot path: validation on ingest, and slicing on every viewport move.
2+
3+
``DataManager.get_chunk`` is called once per pan/zoom in the browser, so its cost
4+
is what the user feels as lag. ``validate_input`` is paid once per ``plot()`` call
5+
but walks every series, so it dominates start-up on large inputs.
6+
7+
Run with ``make benchmark``. These are excluded from ``make test``.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import numpy as np
13+
import pandas as pd
14+
import pytest
15+
16+
from pycharting.data.ingestion import DataManager, validate_input
17+
18+
# Large enough to be dominated by the array work rather than call overhead,
19+
# small enough that the suite stays inside pytest.ini's 60s timeout.
20+
N = 250_000
21+
CHUNK = 5_000
22+
23+
24+
@pytest.fixture(scope="module")
25+
def ohlc() -> dict[str, np.ndarray]:
26+
"""A dense OHLC series of ``N`` bars with a datetime index and two overlays."""
27+
rng = np.random.default_rng(seed=0)
28+
close = np.cumsum(rng.standard_normal(N)) + 1_000.0
29+
return {
30+
"index": pd.date_range("2020-01-01", periods=N, freq="min"),
31+
"open": close - 0.5,
32+
"high": close + 1.0,
33+
"low": close - 1.0,
34+
"close": close,
35+
}
36+
37+
38+
@pytest.fixture(scope="module")
39+
def manager(ohlc: dict[str, np.ndarray]) -> DataManager:
40+
"""A ``DataManager`` over the dense series, built once for the module."""
41+
return DataManager(**ohlc)
42+
43+
44+
def test_validate_input_on_dense_ohlc(benchmark, ohlc: dict[str, np.ndarray]) -> None:
45+
"""Cost of normalizing a dense OHLC series — paid once per plot() call."""
46+
result = benchmark(lambda: validate_input(**ohlc))
47+
assert len(result["close"]) == N
48+
49+
50+
def test_get_chunk_viewport_slice(benchmark, manager: DataManager) -> None:
51+
"""Cost of one viewport slice — paid on every pan and zoom."""
52+
mid = N // 2
53+
result = benchmark(lambda: manager.get_chunk(mid, mid + CHUNK))
54+
assert len(result["index"]) == CHUNK
55+
56+
57+
def test_get_chunk_full_range(benchmark, manager: DataManager) -> None:
58+
"""Cost of serializing the whole series, the worst case on first paint."""
59+
result = benchmark(lambda: manager.get_chunk())
60+
assert len(result["index"]) == N
61+
62+
63+
def test_data_manager_construction(benchmark, ohlc: dict[str, np.ndarray]) -> None:
64+
"""End-to-end ingest cost: validation plus array retention."""
65+
result = benchmark(lambda: DataManager(**ohlc))
66+
assert result.length == N

‎tests/pycharting/data/test_ingestion.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import numpy as np
77
import pandas as pd
88
import pytest
9+
from hypothesis import given, settings
10+
from hypothesis import strategies as st
11+
from hypothesis.extra.numpy import arrays
912

1013
from pycharting.data.ingestion import DataManager, DataValidationError, validate_input
1114

@@ -779,3 +782,80 @@ def test_get_chunk_data_types(self):
779782

780783
# Individual values should be Python numbers
781784
assert isinstance(chunk["open"][0], (int, float))
785+
786+
787+
# ---------------------------------------------------------------------------
788+
# Property-based tests — run by `make hypothesis-test` (-m "hypothesis or property")
789+
# and, being under tests/pycharting/, also by the regular `make test` run.
790+
#
791+
# validate_input's contract is stated as invariants rather than examples: every
792+
# output is a length-n ndarray, length disagreement always raises, and the
793+
# single-vs-multi series mode is decided purely by how many of OHLC are present.
794+
# Those are properties, so they are tested as properties.
795+
# ---------------------------------------------------------------------------
796+
797+
798+
@pytest.mark.property
799+
@given(
800+
values=arrays(
801+
dtype=np.float64,
802+
shape=st.integers(min_value=1, max_value=200),
803+
elements=st.floats(min_value=-1e6, max_value=1e6, allow_nan=False),
804+
),
805+
)
806+
@settings(max_examples=50, deadline=None)
807+
def test_close_only_input_always_normalizes_to_line_mode(values):
808+
"""Any single series is mapped to `close`, leaving open/high/low unset."""
809+
result = validate_input(np.arange(len(values)), close=values)
810+
811+
assert isinstance(result["close"], np.ndarray)
812+
assert len(result["close"]) == len(values)
813+
assert result["open"] is None
814+
assert result["high"] is None
815+
assert result["low"] is None
816+
817+
818+
@pytest.mark.property
819+
@given(
820+
n=st.integers(min_value=1, max_value=200),
821+
delta=st.integers(min_value=1, max_value=20),
822+
)
823+
@settings(max_examples=50, deadline=None)
824+
def test_length_mismatch_always_raises(n, delta):
825+
"""A series whose length differs from the index is always rejected."""
826+
index = np.arange(n)
827+
close = np.zeros(n + delta)
828+
829+
with pytest.raises(DataValidationError, match="does not match index length"):
830+
validate_input(index, close=close)
831+
832+
833+
@pytest.mark.property
834+
@given(
835+
values=arrays(
836+
dtype=np.float64,
837+
shape=st.integers(min_value=1, max_value=100),
838+
elements=st.floats(min_value=1.0, max_value=1e5, allow_nan=False),
839+
),
840+
)
841+
@settings(max_examples=50, deadline=None)
842+
def test_ohlc_high_is_never_below_low(values):
843+
"""With open and close supplied, the auto-filled high never falls below low."""
844+
result = validate_input(np.arange(len(values)), open=values, close=values + 1.0)
845+
846+
assert np.all(result["high"] >= result["low"])
847+
848+
849+
@pytest.mark.property
850+
@given(
851+
values=st.lists(st.floats(min_value=-1e6, max_value=1e6, allow_nan=False), min_size=1, max_size=100),
852+
)
853+
@settings(max_examples=50, deadline=None)
854+
def test_list_series_and_ndarray_series_agree(values):
855+
"""Passing a list and the equivalent ndarray produce the same normalized output."""
856+
index = np.arange(len(values))
857+
858+
from_list = validate_input(index, close=values)
859+
from_array = validate_input(index, close=np.array(values, dtype=np.float64))
860+
861+
assert np.array_equal(from_list["close"], from_array["close"])

‎tests/stress/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Stress and load tests, run by ``make stress`` (selected via ``-m stress``).
2+
3+
Excluded from ``make test`` (``--ignore=tests/stress``) because these push
4+
volume and concurrency rather than assert a single behaviour, and are slower
5+
than the unit suite.
6+
"""
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Stress tests for the session registry and the slicing path under concurrency.
2+
3+
``_data_managers`` is a plain module-level dict shared by every request handler,
4+
and the chart frontend issues overlapping ``/api/data`` requests while the user
5+
pans. These tests push that path harder than the unit suite does: many sessions
6+
at once, churn of create/delete, and concurrent reads of the same manager.
7+
8+
Run with ``make stress``. Excluded from ``make test``.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from concurrent.futures import ThreadPoolExecutor
14+
15+
import numpy as np
16+
import pytest
17+
from fastapi.testclient import TestClient
18+
19+
from pycharting.api.routes import _data_managers
20+
from pycharting.core.server import create_app
21+
from pycharting.data.ingestion import DataManager
22+
23+
pytestmark = pytest.mark.stress
24+
25+
BARS = 20_000
26+
SESSIONS = 100
27+
WORKERS = 16
28+
29+
30+
@pytest.fixture
31+
def client() -> TestClient:
32+
"""A test client over a fresh app, with the session registry cleared around it."""
33+
_data_managers.clear()
34+
try:
35+
yield TestClient(create_app())
36+
finally:
37+
_data_managers.clear()
38+
39+
40+
def _series(n: int = BARS) -> dict[str, np.ndarray]:
41+
"""A deterministic OHLC payload of ``n`` bars."""
42+
rng = np.random.default_rng(seed=1)
43+
close = np.cumsum(rng.standard_normal(n)) + 500.0
44+
return {
45+
"index": np.arange(n),
46+
"open": close - 0.5,
47+
"high": close + 1.0,
48+
"low": close - 1.0,
49+
"close": close,
50+
}
51+
52+
53+
def test_many_concurrent_sessions_stay_isolated(client: TestClient) -> None:
54+
"""Registering many sessions at once keeps each one's data distinct."""
55+
payload = _series(1_000)
56+
57+
def register(i: int) -> str:
58+
# Shift the whole bar, not just close — validate_input enforces
59+
# high >= max(open, close), so offsetting one series in isolation
60+
# is rejected.
61+
session = f"stress-{i}"
62+
shifted = {k: (v + i if k != "index" else v) for k, v in payload.items()}
63+
_data_managers[session] = DataManager(**shifted)
64+
return session
65+
66+
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
67+
sessions = list(pool.map(register, range(SESSIONS)))
68+
69+
assert len(_data_managers) == SESSIONS
70+
# Each session kept its own offset rather than aliasing a shared array.
71+
for i, session in enumerate(sessions):
72+
assert _data_managers[session].close[0] == pytest.approx(payload["close"][0] + i)
73+
74+
75+
def test_concurrent_chunk_reads_are_consistent(client: TestClient) -> None:
76+
"""Overlapping viewport reads of one session all return the same bytes."""
77+
_data_managers["shared"] = DataManager(**_series())
78+
manager = _data_managers["shared"]
79+
expected = manager.get_chunk(0, 500)["close"]
80+
81+
def read(_: int) -> list[float]:
82+
return manager.get_chunk(0, 500)["close"]
83+
84+
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
85+
results = list(pool.map(read, range(200)))
86+
87+
assert all(r == expected for r in results)
88+
89+
90+
def test_session_churn_does_not_leak(client: TestClient) -> None:
91+
"""Repeated create/delete cycles leave the registry empty."""
92+
for i in range(SESSIONS):
93+
session = f"churn-{i}"
94+
_data_managers[session] = DataManager(**_series(500))
95+
response = client.delete(f"/api/sessions/{session}")
96+
assert response.status_code == 200
97+
98+
assert _data_managers == {}
99+
100+
101+
def test_full_range_slice_of_a_large_series(client: TestClient) -> None:
102+
"""A whole-series slice serializes every bar without truncating."""
103+
_data_managers["big"] = DataManager(**_series())
104+
105+
chunk = _data_managers["big"].get_chunk()
106+
107+
assert len(chunk["index"]) == BARS
108+
assert len(chunk["close"]) == BARS

0 commit comments

Comments
 (0)