Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ test = [
"pytest>=8.3.0,<9.0.0",
"pytest-cov>=6.0.0",
"httpx>=0.27.0,<0.29.0",
# Property-based tests for validate_input live in
# tests/pycharting/data/test_ingestion.py. Also supplied by rhiza's
# .rhiza/requirements/tests.txt, but declared here so `uv run pytest`
# works without `make install` having run first.
"hypothesis>=6.100.0",
]
lint = [
"ruff>=0.6.0",
Expand Down
5 changes: 5 additions & 0 deletions tests/benchmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Performance benchmarks, run by ``make benchmark``.

Excluded from ``make test`` (``--ignore=tests/benchmarks``) because benchmarks
measure rather than assert, and are unreliable under ``pytest-xdist``.
"""
66 changes: 66 additions & 0 deletions tests/benchmarks/test_ingestion_benchmarks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Benchmarks for the hot path: validation on ingest, and slicing on every viewport move.

``DataManager.get_chunk`` is called once per pan/zoom in the browser, so its cost
is what the user feels as lag. ``validate_input`` is paid once per ``plot()`` call
but walks every series, so it dominates start-up on large inputs.

Run with ``make benchmark``. These are excluded from ``make test``.
"""

from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

from pycharting.data.ingestion import DataManager, validate_input

# Large enough to be dominated by the array work rather than call overhead,
# small enough that the suite stays inside pytest.ini's 60s timeout.
N = 250_000
CHUNK = 5_000


@pytest.fixture(scope="module")
def ohlc() -> dict[str, np.ndarray]:
"""A dense OHLC series of ``N`` bars with a datetime index and two overlays."""
rng = np.random.default_rng(seed=0)
Comment on lines +25 to +27
close = np.cumsum(rng.standard_normal(N)) + 1_000.0
return {
"index": pd.date_range("2020-01-01", periods=N, freq="min"),
"open": close - 0.5,
"high": close + 1.0,
"low": close - 1.0,
"close": close,
}


@pytest.fixture(scope="module")
def manager(ohlc: dict[str, np.ndarray]) -> DataManager:
"""A ``DataManager`` over the dense series, built once for the module."""
return DataManager(**ohlc)
Comment on lines +39 to +41


def test_validate_input_on_dense_ohlc(benchmark, ohlc: dict[str, np.ndarray]) -> None:
"""Cost of normalizing a dense OHLC series — paid once per plot() call."""
result = benchmark(lambda: validate_input(**ohlc))
assert len(result["close"]) == N


def test_get_chunk_viewport_slice(benchmark, manager: DataManager) -> None:
"""Cost of one viewport slice — paid on every pan and zoom."""
mid = N // 2
result = benchmark(lambda: manager.get_chunk(mid, mid + CHUNK))
assert len(result["index"]) == CHUNK


def test_get_chunk_full_range(benchmark, manager: DataManager) -> None:
"""Cost of serializing the whole series, the worst case on first paint."""
result = benchmark(lambda: manager.get_chunk())
assert len(result["index"]) == N


def test_data_manager_construction(benchmark, ohlc: dict[str, np.ndarray]) -> None:
"""End-to-end ingest cost: validation plus array retention."""
result = benchmark(lambda: DataManager(**ohlc))
assert result.length == N
80 changes: 80 additions & 0 deletions tests/pycharting/data/test_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import numpy as np
import pandas as pd
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from hypothesis.extra.numpy import arrays

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

Expand Down Expand Up @@ -779,3 +782,80 @@ def test_get_chunk_data_types(self):

# Individual values should be Python numbers
assert isinstance(chunk["open"][0], (int, float))


# ---------------------------------------------------------------------------
# Property-based tests — run by `make hypothesis-test` (-m "hypothesis or property")
# and, being under tests/pycharting/, also by the regular `make test` run.
#
# validate_input's contract is stated as invariants rather than examples: every
# output is a length-n ndarray, length disagreement always raises, and the
# single-vs-multi series mode is decided purely by how many of OHLC are present.
# Those are properties, so they are tested as properties.
# ---------------------------------------------------------------------------


@pytest.mark.property
@given(
values=arrays(
dtype=np.float64,
shape=st.integers(min_value=1, max_value=200),
elements=st.floats(min_value=-1e6, max_value=1e6, allow_nan=False),
),
)
@settings(max_examples=50, deadline=None)
def test_close_only_input_always_normalizes_to_line_mode(values):
"""Any single series is mapped to `close`, leaving open/high/low unset."""
result = validate_input(np.arange(len(values)), close=values)

assert isinstance(result["close"], np.ndarray)
assert len(result["close"]) == len(values)
assert result["open"] is None
assert result["high"] is None
assert result["low"] is None


@pytest.mark.property
@given(
n=st.integers(min_value=1, max_value=200),
delta=st.integers(min_value=1, max_value=20),
)
@settings(max_examples=50, deadline=None)
def test_length_mismatch_always_raises(n, delta):
"""A series whose length differs from the index is always rejected."""
index = np.arange(n)
close = np.zeros(n + delta)

with pytest.raises(DataValidationError, match="does not match index length"):
validate_input(index, close=close)


@pytest.mark.property
@given(
values=arrays(
dtype=np.float64,
shape=st.integers(min_value=1, max_value=100),
elements=st.floats(min_value=1.0, max_value=1e5, allow_nan=False),
),
)
@settings(max_examples=50, deadline=None)
def test_ohlc_high_is_never_below_low(values):
"""With open and close supplied, the auto-filled high never falls below low."""
result = validate_input(np.arange(len(values)), open=values, close=values + 1.0)

assert np.all(result["high"] >= result["low"])


@pytest.mark.property
@given(
values=st.lists(st.floats(min_value=-1e6, max_value=1e6, allow_nan=False), min_size=1, max_size=100),
)
@settings(max_examples=50, deadline=None)
def test_list_series_and_ndarray_series_agree(values):
"""Passing a list and the equivalent ndarray produce the same normalized output."""
index = np.arange(len(values))

from_list = validate_input(index, close=values)
from_array = validate_input(index, close=np.array(values, dtype=np.float64))

assert np.array_equal(from_list["close"], from_array["close"])
6 changes: 6 additions & 0 deletions tests/stress/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Stress and load tests, run by ``make stress`` (selected via ``-m stress``).

Excluded from ``make test`` (``--ignore=tests/stress``) because these push
volume and concurrency rather than assert a single behaviour, and are slower
than the unit suite.
"""
108 changes: 108 additions & 0 deletions tests/stress/test_session_registry_stress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Stress tests for the session registry and the slicing path under concurrency.

``_data_managers`` is a plain module-level dict shared by every request handler,
and the chart frontend issues overlapping ``/api/data`` requests while the user
pans. These tests push that path harder than the unit suite does: many sessions
at once, churn of create/delete, and concurrent reads of the same manager.

Run with ``make stress``. Excluded from ``make test``.
"""

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor

import numpy as np
import pytest
from fastapi.testclient import TestClient

from pycharting.api.routes import _data_managers
from pycharting.core.server import create_app
from pycharting.data.ingestion import DataManager

pytestmark = pytest.mark.stress

BARS = 20_000
SESSIONS = 100
WORKERS = 16


@pytest.fixture
def client() -> TestClient:
"""A test client over a fresh app, with the session registry cleared around it."""
_data_managers.clear()
try:
yield TestClient(create_app())
finally:
_data_managers.clear()


def _series(n: int = BARS) -> dict[str, np.ndarray]:
"""A deterministic OHLC payload of ``n`` bars."""
rng = np.random.default_rng(seed=1)
close = np.cumsum(rng.standard_normal(n)) + 500.0
return {
"index": np.arange(n),
"open": close - 0.5,
"high": close + 1.0,
"low": close - 1.0,
"close": close,
}


def test_many_concurrent_sessions_stay_isolated(client: TestClient) -> None:
"""Registering many sessions at once keeps each one's data distinct."""
payload = _series(1_000)

def register(i: int) -> str:
# Shift the whole bar, not just close — validate_input enforces
# high >= max(open, close), so offsetting one series in isolation
# is rejected.
session = f"stress-{i}"
shifted = {k: (v + i if k != "index" else v) for k, v in payload.items()}
_data_managers[session] = DataManager(**shifted)
return session

with ThreadPoolExecutor(max_workers=WORKERS) as pool:
sessions = list(pool.map(register, range(SESSIONS)))

assert len(_data_managers) == SESSIONS
# Each session kept its own offset rather than aliasing a shared array.
for i, session in enumerate(sessions):
assert _data_managers[session].close[0] == pytest.approx(payload["close"][0] + i)


def test_concurrent_chunk_reads_are_consistent(client: TestClient) -> None:
"""Overlapping viewport reads of one session all return the same bytes."""
_data_managers["shared"] = DataManager(**_series())
manager = _data_managers["shared"]
expected = manager.get_chunk(0, 500)["close"]

def read(_: int) -> list[float]:
return manager.get_chunk(0, 500)["close"]

with ThreadPoolExecutor(max_workers=WORKERS) as pool:
results = list(pool.map(read, range(200)))

assert all(r == expected for r in results)


def test_session_churn_does_not_leak(client: TestClient) -> None:
"""Repeated create/delete cycles leave the registry empty."""
for i in range(SESSIONS):
session = f"churn-{i}"
_data_managers[session] = DataManager(**_series(500))
response = client.delete(f"/api/sessions/{session}")
assert response.status_code == 200

assert _data_managers == {}


def test_full_range_slice_of_a_large_series(client: TestClient) -> None:
"""A whole-series slice serializes every bar without truncating."""
_data_managers["big"] = DataManager(**_series())

chunk = _data_managers["big"].get_chunk()

assert len(chunk["index"]) == BARS
assert len(chunk["close"]) == BARS
Loading
Loading