diff --git a/.gitignore b/.gitignore
index 7e18e00..3c08606 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,8 +17,8 @@ output/
### Local demos / scratch notes
demos/
-### Local sample data
-data/
+### Local sample data (top-level only; must not swallow tests/pycharting/data/)
+/data/
__marimo__
diff --git a/mkdocs.yml b/mkdocs.yml
index b4dc22d..d9539e0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,7 +1,7 @@
INHERIT: docs/mkdocs-base.yml
site_name: pycharting
-site_description: Add your description here.
+site_description: High-performance financial charting library for OHLC data visualization with technical indicators
site_url: https://alihaskar.github.io/pycharting/
repo_url: https://github.com/alihaskar/pycharting
repo_name: alihaskar/pycharting
diff --git a/src/pycharting/api/interface.py b/src/pycharting/api/interface.py
index efa4283..528c005 100644
--- a/src/pycharting/api/interface.py
+++ b/src/pycharting/api/interface.py
@@ -17,14 +17,14 @@
import logging
import time
import webbrowser
-from typing import Any, cast
+from typing import Any, TypedDict, cast
import numpy as np
import pandas as pd
from pycharting.api.routes import _data_managers
from pycharting.core.lifecycle import ChartServer
-from pycharting.data.ingestion import DataManager, SubplotSpec
+from pycharting.data.ingestion import DataManager, DataValidationError, SubplotSpec
logger = logging.getLogger(__name__)
@@ -32,6 +32,32 @@
_active_server: ChartServer | None = None
+class PlotResult(TypedDict, total=False):
+ """Structured result returned by :func:`plot`.
+
+ ``status`` and ``session_id`` are always present. On success the URL/server
+ fields are populated; on failure ``stage`` ("validation" or "server") and
+ ``error`` describe what went wrong.
+ """
+
+ status: str
+ session_id: str
+ url: str
+ server_url: str
+ data_points: int
+ server_running: bool
+ stage: str
+ error: str
+
+
+class ServerStatus(TypedDict):
+ """Structured result returned by :func:`get_server_status`."""
+
+ running: bool
+ server_info: dict[str, Any] | None
+ active_sessions: int
+
+
def _notify(message: str) -> None:
"""Emit a user-facing console message.
@@ -60,7 +86,7 @@ def plot(
open_browser: bool = True,
server_timeout: float = 2.0,
block: bool = True,
-) -> dict[str, Any]:
+) -> PlotResult:
"""Generate and display an interactive OHLC (Open-High-Low-Close) or Line chart.
This function is the primary interface for PyCharting. It performs the following steps:
@@ -227,7 +253,7 @@ def plot(
logger.warning(f"Could not open browser: {e}")
_notify(f"Please open this URL manually: {chart_url}")
- result = {
+ result: PlotResult = {
"status": "success",
"url": chart_url,
"server_url": server_info["url"],
@@ -245,7 +271,7 @@ def plot(
_notify("")
# Block until server shutdown if requested
- if block and _active_server: # pragma: no cover
+ if block and _active_server:
logger.info("Blocking until chart is closed (press Ctrl+C to force exit)...")
_notify("Keeping server alive until you close the browser page...")
try:
@@ -259,12 +285,27 @@ def plot(
_notify("\n⚠️ Interrupted - stopping server...")
_active_server.stop_server()
+ except DataValidationError as e:
+ # Input data failed validation — the user can fix this by correcting
+ # their arrays before calling plot() again.
+ logger.error(f"Invalid input data: {e}", exc_info=True)
+ _notify(f"\n✗ Invalid input data: {e}\n")
+
+ return {
+ "status": "error",
+ "stage": "validation",
+ "error": str(e),
+ "session_id": session_id,
+ }
except Exception as e:
+ # Server startup, browser launch, or other runtime failure — not
+ # something the caller's data can fix.
logger.error(f"Error creating chart: {e}", exc_info=True)
_notify(f"\n✗ Error creating chart: {e}\n")
return {
"status": "error",
+ "stage": "server",
"error": str(e),
"session_id": session_id,
}
@@ -299,7 +340,7 @@ def stop_server() -> None:
_notify("ⓘ No active server to stop")
-def get_server_status() -> dict[str, Any]:
+def get_server_status() -> ServerStatus:
"""Retrieve the current status of the background chart server.
This is useful for debugging connection issues or checking if a session is still active.
@@ -339,8 +380,9 @@ def get_server_status() -> dict[str, Any]:
def _repr_html_() -> str:
"""Jupyter notebook representation."""
status = get_server_status()
- if status["running"]:
- url = f"http://{status['server_info']['host']}:{status['server_info']['port']}"
+ server_info = status["server_info"]
+ if status["running"] and server_info is not None:
+ url = f"http://{server_info['host']}:{server_info['port']}"
return f'''
PyCharting Server
diff --git a/src/pycharting/api/routes.py b/src/pycharting/api/routes.py
index 5336fdf..9c79fea 100644
--- a/src/pycharting/api/routes.py
+++ b/src/pycharting/api/routes.py
@@ -11,11 +11,12 @@
"""
import logging
-from typing import Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
+from pycharting.data.ingestion import DataManager
+
logger = logging.getLogger(__name__)
# Create API router
@@ -23,7 +24,7 @@
# In-memory storage for active DataManager instances
# In production, this would use a proper session/cache management
-_data_managers: dict[str, Any] = {}
+_data_managers: dict[str, DataManager] = {}
class DataResponse(BaseModel):
@@ -50,6 +51,47 @@ class ErrorResponse(BaseModel):
detail: str | None = None
+class InitDataResponse(BaseModel):
+ """Response model for the demo-data initialization endpoint."""
+
+ session_id: str
+ status: str
+ data_points: int
+ message: str
+
+
+class SessionInfo(BaseModel):
+ """Metadata describing a single active data session."""
+
+ session_id: str
+ data_points: int
+ has_overlays: bool
+ has_subplots: bool
+
+
+class SessionListResponse(BaseModel):
+ """Response model for the sessions listing endpoint."""
+
+ sessions: list[SessionInfo]
+ count: int
+
+
+class DeleteSessionResponse(BaseModel):
+ """Response model for the session deletion endpoint."""
+
+ session_id: str
+ status: str
+ message: str
+
+
+class StatusResponse(BaseModel):
+ """Response model for the API status endpoint."""
+
+ status: str
+ active_sessions: int
+ endpoints: dict[str, str]
+
+
@router.get("/data", response_model=DataResponse)
async def get_data(
start_index: int = Query(0, ge=0, description="Start index for data slice"),
@@ -96,10 +138,10 @@ async def get_data(
raise HTTPException(status_code=500, detail=f"Error fetching data: {e!s}") from e
-@router.post("/data/init")
+@router.post("/data/init", response_model=InitDataResponse)
async def initialize_data(
session_id: str = Query("default", description="Session identifier"),
-):
+) -> InitDataResponse:
"""Initialize a demo data session.
This endpoint is primarily used for testing or the standalone demo mode.
@@ -109,12 +151,10 @@ async def initialize_data(
session_id (str): The ID to assign to the new session.
Returns:
- dict: Status message and session details.
+ InitDataResponse: Status message and session details.
"""
import numpy as np
- from pycharting.data.ingestion import DataManager
-
try:
# Generate demo OHLC data
n = 1000
@@ -159,38 +199,37 @@ async def initialize_data(
logger.exception("Error initializing data")
raise HTTPException(status_code=500, detail=f"Error initializing data: {e!s}") from e
else:
- return {
- "session_id": session_id,
- "status": "initialized",
- "data_points": n,
- "message": "Demo dataset created successfully",
- }
+ return InitDataResponse(
+ session_id=session_id,
+ status="initialized",
+ data_points=n,
+ message="Demo dataset created successfully",
+ )
-@router.get("/sessions")
-async def list_sessions():
+@router.get("/sessions", response_model=SessionListResponse)
+async def list_sessions() -> SessionListResponse:
"""List all currently active data sessions.
Returns:
- dict: A dictionary containing a list of session objects, each with metadata
- like the number of data points and active features (overlays, subplots).
+ SessionListResponse: A list of session objects, each with metadata like the
+ number of data points and active features (overlays, subplots).
"""
- sessions = []
- for session_id, dm in _data_managers.items():
- sessions.append(
- {
- "session_id": session_id,
- "data_points": dm.length,
- "has_overlays": len(dm.overlays) > 0,
- "has_subplots": len(dm.subplots) > 0,
- }
+ sessions = [
+ SessionInfo(
+ session_id=session_id,
+ data_points=dm.length,
+ has_overlays=len(dm.overlays) > 0,
+ has_subplots=len(dm.subplots) > 0,
)
+ for session_id, dm in _data_managers.items()
+ ]
- return {"sessions": sessions, "count": len(sessions)}
+ return SessionListResponse(sessions=sessions, count=len(sessions))
-@router.delete("/sessions/{session_id}")
-async def delete_session(session_id: str):
+@router.delete("/sessions/{session_id}", response_model=DeleteSessionResponse)
+async def delete_session(session_id: str) -> DeleteSessionResponse:
"""Remove a data session from memory.
This frees up resources associated with a specific dataset.
@@ -199,7 +238,7 @@ async def delete_session(session_id: str):
session_id (str): The ID of the session to remove.
Returns:
- dict: Confirmation message.
+ DeleteSessionResponse: Confirmation message.
Raises:
HTTPException(404): If the session ID is not found.
@@ -210,23 +249,23 @@ async def delete_session(session_id: str):
del _data_managers[session_id]
logger.info(f"Deleted session '{session_id}'")
- return {"session_id": session_id, "status": "deleted", "message": "Session deleted successfully"}
+ return DeleteSessionResponse(session_id=session_id, status="deleted", message="Session deleted successfully")
-@router.get("/status")
-async def api_status():
+@router.get("/status", response_model=StatusResponse)
+async def api_status() -> StatusResponse:
"""Get API status and statistics.
Returns:
- API status information
+ StatusResponse: API status information.
"""
- return {
- "status": "healthy",
- "active_sessions": len(_data_managers),
- "endpoints": {
+ return StatusResponse(
+ status="healthy",
+ active_sessions=len(_data_managers),
+ endpoints={
"data": "/api/data",
"init": "/api/data/init",
"sessions": "/api/sessions",
"status": "/api/status",
},
- }
+ )
diff --git a/src/pycharting/core/lifecycle.py b/src/pycharting/core/lifecycle.py
index de6dcd9..ab32d56 100644
--- a/src/pycharting/core/lifecycle.py
+++ b/src/pycharting/core/lifecycle.py
@@ -74,7 +74,7 @@ def _add_websocket_endpoint(self):
"""Add WebSocket heartbeat endpoint to the app."""
@self.app.websocket("/ws/heartbeat")
- async def websocket_heartbeat(websocket: WebSocket): # pragma: no cover
+ async def websocket_heartbeat(websocket: WebSocket):
"""WebSocket endpoint for connection monitoring."""
await websocket.accept()
self._websocket_connected = True
@@ -101,7 +101,7 @@ def _monitor_connection(self):
while self._running and not self._shutdown_event.is_set():
time.sleep(1)
- if self._websocket_connected and self._last_heartbeat: # pragma: no cover
+ if self._websocket_connected and self._last_heartbeat:
# Check if heartbeat is stale
elapsed = (datetime.now() - self._last_heartbeat).total_seconds()
if elapsed > self.auto_shutdown_timeout:
@@ -110,7 +110,7 @@ def _monitor_connection(self):
self.stop_server()
break
- elif not self._websocket_connected and self._last_heartbeat: # pragma: no cover
+ elif not self._websocket_connected and self._last_heartbeat:
# Client disconnected, wait for timeout then shutdown
logger.info(f"Waiting {self.auto_shutdown_timeout}s before auto-shutdown")
time.sleep(self.auto_shutdown_timeout)
@@ -132,7 +132,7 @@ def _run_server(self):
try:
self._server.run()
- except Exception: # pragma: no cover
+ except Exception:
logger.exception("Server error")
finally:
self._running = False
diff --git a/tests/pycharting/api/test_interface.py b/tests/pycharting/api/test_interface.py
index 3e35a42..25445ce 100644
--- a/tests/pycharting/api/test_interface.py
+++ b/tests/pycharting/api/test_interface.py
@@ -9,6 +9,20 @@
from pycharting.api.routes import _data_managers
+@pytest.fixture(autouse=True)
+def portless_fast_server(fake_uvicorn):
+ """Make ``plot()`` fast and portless for interface tests.
+
+ Depends on ``fake_uvicorn`` (portless server, see conftest) and additionally
+ neutralises the ``time.sleep(server_timeout)`` readiness pause in
+ ``interface`` so tests neither bind a socket nor wait on wall-clock sleeps.
+ The blocking loop uses ``_shutdown_event.wait`` (not ``time.sleep``), so it is
+ unaffected by this patch.
+ """
+ with patch("pycharting.api.interface.time.sleep"):
+ yield
+
+
@pytest.fixture(autouse=True)
def cleanup_globals():
"""Clean up global state between tests.
@@ -119,6 +133,81 @@ def test_plot_with_invalid_data():
assert result["status"] == "error"
assert "error" in result
+ # A validation failure must be reported as such, distinct from server/runtime errors.
+ assert result["stage"] == "validation"
+
+
+def test_plot_server_startup_failure_reported_as_server_stage():
+ """A non-validation failure (e.g. server startup) is reported with stage='server'."""
+ n = 20
+ data = np.random.randn(n) + 100
+ index = np.arange(n)
+
+ # Valid data passes validation, so the error must come from a later stage.
+ with patch("pycharting.api.interface.ChartServer") as mock_server:
+ mock_server.side_effect = RuntimeError("boom: could not start server")
+ result = plot(index, close=data, open_browser=False, block=False)
+
+ assert result["status"] == "error"
+ assert result["stage"] == "server"
+ assert "boom" in result["error"]
+
+
+def test_plot_block_returns_when_shutdown_event_set():
+ """With block=True, plot() waits on the shutdown event and returns when it fires.
+
+ Covers the previously ``# pragma: no cover`` blocking loop. A fake shutdown
+ event reports "not set" once (so the loop body runs) then "set" to release it.
+ """
+
+ class _Event:
+ def __init__(self):
+ self._checks = 0
+
+ def is_set(self):
+ self._checks += 1
+ return self._checks > 1 # False first, then True -> exactly one iteration
+
+ def wait(self, timeout=None):
+ return None
+
+ n = 10
+ data = np.random.randn(n) + 100
+ index = np.arange(n)
+
+ with patch("pycharting.api.interface.ChartServer") as mock_cls:
+ inst = mock_cls.return_value
+ inst.is_running = True
+ inst.start_server.return_value = {"host": "127.0.0.1", "port": 1234, "url": "http://127.0.0.1:1234"}
+ inst._shutdown_event = _Event()
+ result = plot(index, close=data, open_browser=False, block=True)
+
+ assert result["status"] == "success"
+
+
+def test_plot_block_handles_keyboard_interrupt():
+ """A Ctrl+C during the blocking wait stops the server and still returns success."""
+
+ class _InterruptingEvent:
+ def is_set(self):
+ return False
+
+ def wait(self, timeout=None):
+ raise KeyboardInterrupt
+
+ n = 10
+ data = np.random.randn(n) + 100
+ index = np.arange(n)
+
+ with patch("pycharting.api.interface.ChartServer") as mock_cls:
+ inst = mock_cls.return_value
+ inst.is_running = True
+ inst.start_server.return_value = {"host": "127.0.0.1", "port": 1234, "url": "http://127.0.0.1:1234"}
+ inst._shutdown_event = _InterruptingEvent()
+ result = plot(index, close=data, open_browser=False, block=True)
+
+ inst.stop_server.assert_called_once()
+ assert result["status"] == "success"
@patch("webbrowser.open")
diff --git a/tests/pycharting/api/test_routes.py b/tests/pycharting/api/test_routes.py
index 3a439d8..4fc5bad 100644
--- a/tests/pycharting/api/test_routes.py
+++ b/tests/pycharting/api/test_routes.py
@@ -245,7 +245,7 @@ def test_initialize_data_internal_error_returns_500(client):
"""Verify the data init endpoint returns 500 when DataManager raises."""
from unittest.mock import patch
- with patch("pycharting.data.ingestion.DataManager", side_effect=RuntimeError("boom")):
+ with patch("pycharting.api.routes.DataManager", side_effect=RuntimeError("boom")):
response = client.post("/api/data/init?session_id=err2")
assert response.status_code == 500
diff --git a/tests/pycharting/conftest.py b/tests/pycharting/conftest.py
new file mode 100644
index 0000000..dc178a5
--- /dev/null
+++ b/tests/pycharting/conftest.py
@@ -0,0 +1,78 @@
+"""Shared fixtures and helpers for the pycharting test suite.
+
+Two building blocks here address the flakiness tracked in #47:
+
+* :func:`_wait_until` / the ``wait_until`` fixture replace fixed ``time.sleep``
+ readiness waits with polling, so tests no longer depend on a server (or a
+ thread) becoming ready within an arbitrarily chosen sleep window.
+* :class:`FakeUvicornServer` / the ``fake_uvicorn`` fixture let behaviour tests
+ exercise the full ``ChartServer`` start/stop/thread lifecycle without binding a
+ real TCP socket. Real servers in daemon threads under ``pytest -n auto`` were
+ the source of the intermittent xdist worker crashes; only genuine integration
+ tests now start a real server.
+"""
+
+import time
+from unittest.mock import patch
+
+import pytest
+
+
+def _wait_until(predicate, *, timeout: float = 5.0, interval: float = 0.02):
+ """Poll ``predicate`` until it returns a truthy value or ``timeout`` elapses.
+
+ Args:
+ predicate: Zero-argument callable evaluated repeatedly.
+ timeout: Maximum seconds to wait before giving up.
+ interval: Seconds to sleep between polls.
+
+ Returns:
+ The truthy value returned by ``predicate``.
+
+ Raises:
+ AssertionError: If the timeout elapses without ``predicate`` becoming truthy.
+ """
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ value = predicate()
+ if value:
+ return value
+ time.sleep(interval)
+ value = predicate()
+ if value:
+ return value
+ return pytest.fail(f"condition not met within {timeout}s")
+
+
+@pytest.fixture
+def wait_until():
+ """Provide the :func:`_wait_until` polling helper to tests."""
+ return _wait_until
+
+
+class FakeUvicornServer:
+ """Drop-in replacement for ``uvicorn.Server`` that binds no network socket.
+
+ ``run()`` blocks like the real server until ``should_exit`` is set (by
+ ``ChartServer.stop_server``), so the surrounding thread/lifecycle behaviour is
+ exercised faithfully without cross-worker port contention.
+ """
+
+ def __init__(self, config):
+ """Store the config and initialise the shutdown flag."""
+ self.config = config
+ self.should_exit = False
+ self.started = False
+
+ def run(self):
+ """Block until ``should_exit`` is set, mimicking a running server."""
+ self.started = True
+ while not self.should_exit:
+ time.sleep(0.01)
+
+
+@pytest.fixture
+def fake_uvicorn():
+ """Patch ``ChartServer``'s uvicorn server with the portless fake."""
+ with patch("pycharting.core.lifecycle.uvicorn.Server", FakeUvicornServer):
+ yield
diff --git a/tests/pycharting/core/test_lifecycle.py b/tests/pycharting/core/test_lifecycle.py
index 26b1a2b..ec72317 100644
--- a/tests/pycharting/core/test_lifecycle.py
+++ b/tests/pycharting/core/test_lifecycle.py
@@ -1,13 +1,59 @@
-"""Tests for server lifecycle management."""
+"""Tests for server lifecycle management.
+Behaviour tests use the ``fake_uvicorn`` fixture (see ``conftest.py``) so the
+ChartServer thread/lifecycle machinery is exercised without binding a real
+socket, and ``wait_until`` polling replaces fixed sleeps. Only the two explicit
+integration tests at the bottom start a real HTTP server.
+"""
+
+import asyncio
import threading
-import time
+from datetime import datetime, timedelta
+from unittest.mock import patch
import pytest
+from fastapi import WebSocketDisconnect
from pycharting.core.lifecycle import ChartServer
+def _heartbeat_endpoint(server):
+ """Return the registered ``/ws/heartbeat`` websocket endpoint callable."""
+ for route in server.app.routes:
+ if getattr(route, "path", None) == "/ws/heartbeat":
+ return route.endpoint
+ return pytest.fail("heartbeat route not registered")
+
+
+class _ScriptedWebSocket:
+ """Minimal fake WebSocket that replays scripted messages then raises.
+
+ ``receive_text`` yields each queued message in turn; once exhausted it raises
+ ``final_exc``. This drives the heartbeat handler through its ping/pong,
+ disconnect, and generic-error paths without a real connection.
+ """
+
+ def __init__(self, incoming, final_exc):
+ self._incoming = list(incoming)
+ self._final_exc = final_exc
+ self.sent: list[str] = []
+ self.accepted = False
+
+ async def accept(self):
+ """Record that the connection was accepted."""
+ self.accepted = True
+
+ async def receive_text(self):
+ """Return the next scripted message, or raise the terminal exception."""
+ if self._incoming:
+ return self._incoming.pop(0)
+ raise self._final_exc
+
+ async def send_text(self, message):
+ """Record an outbound message."""
+ self.sent.append(message)
+
+
class TestChartServer:
"""Tests for ChartServer lifecycle management."""
@@ -23,7 +69,7 @@ def test_init_with_port(self):
server = ChartServer(host="127.0.0.1", port=9999)
assert server.port == 9999
- def test_start_server(self):
+ def test_start_server(self, fake_uvicorn):
"""Test starting the server."""
server = ChartServer()
@@ -35,37 +81,28 @@ def test_start_server(self):
assert "ws_url" in info
assert info["running"]
assert info["port"] == server.port
-
- # Give server time to fully start
- time.sleep(0.5)
- assert server.is_running
-
finally:
server.stop_server()
- def test_stop_server(self):
+ def test_stop_server(self, fake_uvicorn):
"""Test stopping the server."""
server = ChartServer()
server.start_server()
- time.sleep(0.5)
assert server.is_running
server.stop_server()
- time.sleep(0.5)
assert not server.is_running
- def test_cannot_start_twice(self):
+ def test_cannot_start_twice(self, fake_uvicorn):
"""Test that server cannot be started twice."""
server = ChartServer()
try:
server.start_server()
- time.sleep(0.5)
with pytest.raises(RuntimeError, match="already running"):
server.start_server()
-
finally:
server.stop_server()
@@ -75,7 +112,7 @@ def test_stop_when_not_running(self):
# Should not raise an error
server.stop_server()
- def test_server_info(self):
+ def test_server_info(self, fake_uvicorn):
"""Test server_info property."""
server = ChartServer()
@@ -87,24 +124,20 @@ def test_server_info(self):
try:
server.start_server()
- time.sleep(0.5)
info = server.server_info
assert info["running"]
assert "websocket_connected" in info
assert "last_heartbeat" in info
-
finally:
server.stop_server()
- def test_context_manager(self):
+ def test_context_manager(self, fake_uvicorn):
"""Test using ChartServer as context manager."""
with ChartServer() as server:
- time.sleep(0.5)
assert server.is_running
# Server should be stopped after context exit
- time.sleep(0.5)
assert not server.is_running
def test_repr(self):
@@ -117,7 +150,7 @@ def test_repr(self):
assert "8888" in repr_str
assert "stopped" in repr_str
- def test_background_thread_created(self):
+ def test_background_thread_created(self, fake_uvicorn, wait_until):
"""Test that server runs in background thread."""
server = ChartServer()
@@ -126,15 +159,11 @@ def test_background_thread_created(self):
try:
server.start_server()
- time.sleep(0.5)
# Should have additional threads
- current_threads = threading.active_count()
- assert current_threads > initial_threads
-
+ wait_until(lambda: threading.active_count() > initial_threads)
finally:
server.stop_server()
- time.sleep(0.5)
def test_auto_shutdown_timeout_configured(self):
"""Test that auto_shutdown_timeout is configurable."""
@@ -151,84 +180,142 @@ def test_websocket_endpoint_added(self):
routes = [path for route in server.app.routes if (path := getattr(route, "path", None))]
assert "/ws/heartbeat" in routes
+ def test_websocket_heartbeat_ping_pong_and_disconnect(self):
+ """Heartbeat endpoint replies ``pong`` to ``ping`` and resets on disconnect.
+
+ Exercises accept, the ping/pong branch, a non-ping message (no reply), and
+ the ``WebSocketDisconnect`` cleanup path — previously ``# pragma: no cover``.
+ """
+ server = ChartServer()
+ endpoint = _heartbeat_endpoint(server)
+
+ ws = _ScriptedWebSocket(["ping", "not-a-ping"], WebSocketDisconnect())
+ asyncio.run(endpoint(ws))
+
+ assert ws.accepted
+ assert ws.sent == ["pong"] # only "ping" is answered
+ assert server._websocket_connected is False
+ assert server._last_heartbeat is not None
+
+ def test_websocket_heartbeat_generic_error_resets_state(self):
+ """A non-disconnect error in the heartbeat loop resets the connection flag."""
+ server = ChartServer()
+ endpoint = _heartbeat_endpoint(server)
+
+ ws = _ScriptedWebSocket([], RuntimeError("kaboom"))
+ asyncio.run(endpoint(ws))
-def test_multiple_operations():
+ assert server._websocket_connected is False
+
+ def test_monitor_connection_stale_heartbeat_triggers_shutdown(self):
+ """A stale heartbeat (elapsed > timeout) triggers auto-shutdown."""
+ server = ChartServer(auto_shutdown_timeout=0.1)
+ server._running = True
+ server._websocket_connected = True
+ server._last_heartbeat = datetime.now() - timedelta(seconds=5)
+
+ with patch("pycharting.core.lifecycle.time.sleep"), patch.object(server, "stop_server") as stop:
+ server._monitor_connection()
+
+ stop.assert_called_once()
+ assert server._websocket_connected is False
+
+ def test_monitor_connection_client_disconnect_triggers_shutdown(self):
+ """A disconnected client (no reconnect) triggers auto-shutdown after the wait."""
+ server = ChartServer(auto_shutdown_timeout=0.1)
+ server._running = True
+ server._websocket_connected = False
+ server._last_heartbeat = datetime.now()
+
+ with patch("pycharting.core.lifecycle.time.sleep"), patch.object(server, "stop_server") as stop:
+ server._monitor_connection()
+
+ stop.assert_called_once()
+
+ def test_run_server_handles_exception(self):
+ """``_run_server`` swallows a server crash and clears the running flag."""
+ server = ChartServer()
+ server._running = True
+
+ with patch("pycharting.core.lifecycle.uvicorn.Server") as mock_server_cls:
+ mock_server_cls.return_value.run.side_effect = RuntimeError("server boom")
+ server._run_server() # must not raise
+
+ assert server._running is False
+
+
+def test_multiple_operations(fake_uvicorn):
"""Test multiple start/stop operations."""
server = ChartServer()
for _i in range(3):
server.start_server()
- time.sleep(0.3)
assert server.is_running
server.stop_server()
- time.sleep(0.3)
assert not server.is_running
-def test_server_cleanup():
+def test_server_cleanup(fake_uvicorn, wait_until):
"""Test that server properly cleans up resources."""
server = ChartServer()
server.start_server()
- time.sleep(0.5)
# Get thread references
server_thread = server._server_thread
monitor_thread = server._monitor_thread
server.stop_server()
- time.sleep(1)
# Threads should be finished
if server_thread:
- assert not server_thread.is_alive()
+ wait_until(lambda: not server_thread.is_alive())
if monitor_thread:
- assert not monitor_thread.is_alive()
+ wait_until(lambda: not monitor_thread.is_alive())
-def test_server_responds_after_start():
- """Test that server responds to requests after starting."""
+def test_server_responds_after_start(wait_until):
+ """Integration: a real server answers the health endpoint after starting."""
import httpx
server = ChartServer()
- try:
- info = server.start_server()
- time.sleep(1) # Give server time to start
-
- # Try to access health endpoint
- response = httpx.get(f"{info['url']}/health", timeout=5)
- assert response.status_code == 200
- data = response.json()
- assert data["status"] == "healthy"
-
- except Exception as e:
- pytest.skip(f"Server integration test skipped: {e}")
+ def _healthy():
+ try:
+ response = httpx.get(f"http://{server.host}:{server.port}/health", timeout=1)
+ except httpx.HTTPError:
+ return None
+ return response if response.status_code == 200 else None
+ try:
+ server.start_server()
+ response = wait_until(_healthy, timeout=10)
+ assert response.json()["status"] == "healthy"
finally:
server.stop_server()
-def test_server_accessible_in_background():
- """Test that main thread is not blocked."""
+def test_server_accessible_in_background(wait_until):
+ """Integration: the main thread stays responsive while the server runs."""
import httpx
server = ChartServer()
+ def _healthy():
+ try:
+ response = httpx.get(f"http://{server.host}:{server.port}/health", timeout=1)
+ except httpx.HTTPError:
+ return None
+ return response if response.status_code == 200 else None
+
try:
server.start_server()
- time.sleep(1)
+ wait_until(_healthy, timeout=10)
- # Main thread should not be blocked
- # We should be able to make multiple requests
+ # Main thread should not be blocked: multiple requests succeed.
for _ in range(3):
response = httpx.get(f"http://{server.host}:{server.port}/health", timeout=5)
assert response.status_code == 200
- time.sleep(0.1)
-
- except Exception as e:
- pytest.skip(f"Integration test skipped: {e}")
-
finally:
server.stop_server()
diff --git a/tests/pycharting/data/__init__.py b/tests/pycharting/data/__init__.py
new file mode 100644
index 0000000..7be0633
--- /dev/null
+++ b/tests/pycharting/data/__init__.py
@@ -0,0 +1 @@
+"""Tests for pycharting.data."""
diff --git a/tests/pycharting/data/test_ingestion.py b/tests/pycharting/data/test_ingestion.py
new file mode 100644
index 0000000..9bae2be
--- /dev/null
+++ b/tests/pycharting/data/test_ingestion.py
@@ -0,0 +1,781 @@
+"""Tests for data ingestion, validation, and slicing (mirrors src/pycharting/data/ingestion.py)."""
+
+import json
+import time
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from pycharting.data.ingestion import DataManager, DataValidationError, validate_input
+
+# ---------------------------------------------------------------------------
+# validate_input — behaviour of the free function (unconstrained module tests)
+# ---------------------------------------------------------------------------
+
+
+def test_valid_pandas_input():
+ """Test validation with valid Pandas Series input."""
+ index = pd.date_range("2024-01-01", periods=5)
+ open_data = pd.Series([100, 102, 101, 103, 102])
+ high = pd.Series([105, 106, 105, 107, 106])
+ low = pd.Series([99, 100, 99, 101, 100])
+ close = pd.Series([104, 103, 104, 105, 104])
+
+ result = validate_input(index, open_data, high, low, close)
+
+ assert isinstance(result["index"], np.ndarray)
+ assert isinstance(result["open"], np.ndarray)
+ assert len(result["index"]) == 5
+ assert np.array_equal(result["open"], [100, 102, 101, 103, 102])
+
+
+def test_valid_numpy_input():
+ """Test validation with valid NumPy array input."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ result = validate_input(index, open_data, high, low, close)
+
+ assert isinstance(result["index"], np.ndarray)
+ assert len(result["index"]) == 5
+ assert np.array_equal(result["close"], [104, 103, 104, 105, 104])
+
+
+def test_validate_with_overlays():
+ """Test validation with overlay data."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ overlays = {
+ "SMA20": np.array([101, 102, 102, 103, 103]),
+ "EMA10": np.array([100, 101, 101, 102, 102]),
+ }
+
+ result = validate_input(index, open_data, high, low, close, overlays=overlays)
+
+ assert len(result["overlays"]) == 2
+ assert "SMA20" in result["overlays"]
+ assert "EMA10" in result["overlays"]
+ assert np.array_equal(result["overlays"]["SMA20"], [101, 102, 102, 103, 103])
+
+
+def test_validate_with_subplots():
+ """Test validation with subplot data."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ subplots = {
+ "Volume": np.array([1000, 1200, 1100, 1300, 1150]),
+ "RSI": np.array([55, 58, 52, 60, 57]),
+ }
+
+ result = validate_input(index, open_data, high, low, close, subplots=subplots)
+
+ assert len(result["subplots"]) == 2
+ assert "Volume" in result["subplots"]
+ assert "RSI" in result["subplots"]
+
+
+def test_to_array_none_returns_none():
+ """Verify omitted data series default to None in the result."""
+ index = np.arange(5)
+ close = np.array([100, 102, 103, 104, 105])
+ result = validate_input(index, close=close)
+ assert result["open"] is None
+ assert result["high"] is None
+ assert result["low"] is None
+
+
+def test_to_array_list_input():
+ """Verify plain Python lists are converted to numpy arrays."""
+ index = np.arange(5)
+ result = validate_input(
+ index,
+ [100, 102, 101, 103, 102],
+ [105, 106, 105, 107, 106],
+ [99, 100, 99, 101, 100],
+ [104, 103, 104, 105, 104],
+ )
+ assert isinstance(result["open"], np.ndarray)
+ assert np.array_equal(result["open"], [100, 102, 101, 103, 102])
+
+
+def test_single_series_mode():
+ """Verify passing only close yields a single-series result with other fields None."""
+ index = np.arange(5)
+ close = np.array([100, 102, 103, 104, 105])
+ result = validate_input(index, close=close)
+ assert result["open"] is None
+ assert result["high"] is None
+ assert result["low"] is None
+ assert np.array_equal(result["close"], close)
+
+
+def test_open_fallback_when_none():
+ """Verify open falls back to close when open is None."""
+ index = np.arange(5)
+ high = np.array([106, 108, 107, 109, 108])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ result = validate_input(index, None, high, low, close)
+ assert np.array_equal(result["open"], close)
+
+
+def test_close_fallback_when_none():
+ """Verify close falls back to open when close is None."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ result = validate_input(index, open_data, high, low, None)
+ assert np.array_equal(result["close"], open_data)
+
+
+def test_open_close_fallback_when_both_none():
+ """When neither open nor close is given, both fall back to the first provided series."""
+ index = np.arange(5)
+ high = np.array([106, 108, 107, 109, 108])
+ low = np.array([99, 100, 99, 101, 100])
+ result = validate_input(index, None, high, low, None)
+ # high is the first provided series, so open and close both default to it.
+ assert np.array_equal(result["open"], high)
+ assert np.array_equal(result["close"], high)
+
+
+def test_high_auto_computed():
+ """Verify high is auto-computed as the element-wise maximum of open and close."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ close = np.array([104, 101, 104, 102, 105])
+ result = validate_input(index, open_data, None, None, close)
+ assert np.array_equal(result["high"], np.maximum(open_data, close))
+
+
+def test_low_auto_computed():
+ """Verify low is auto-computed as the element-wise minimum of open and close."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ close = np.array([104, 101, 104, 102, 105])
+ result = validate_input(index, open_data, None, None, close)
+ assert np.array_equal(result["low"], np.minimum(open_data, close))
+
+
+def test_trades_valid():
+ """Verify a valid trades array is accepted and cast to int8."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ trades = np.array([1, 0, -1, 0, 1])
+ result = validate_input(index, open_data, high, low, close, trades=trades)
+ assert result["trades"] is not None
+ assert result["trades"].dtype == np.int8
+
+
+def test_subplot_multi_series_format():
+ """Verify a list of subplot series is split into indexed keys with metadata."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ subplots = {
+ "MACD": [
+ {"data": np.array([1.0, 1.5, 2.0, 1.5, 1.0]), "type": "line", "label": "MACD", "color": "#f00"},
+ {"data": np.array([0.5, 0.8, 1.0, 0.7, 0.5]), "type": "bar"},
+ ]
+ }
+ result = validate_input(index, open_data, high, low, close, subplots=subplots)
+ assert "MACD__0" in result["subplots"]
+ assert "MACD__1" in result["subplots"]
+ assert len(result["subplot_meta"]["MACD"]) == 2
+
+
+def test_subplot_dict_format():
+ """Verify a single-dict subplot definition is parsed with its type metadata."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ subplots = {"Volume": {"data": np.array([1000, 1200, 800, 1500, 1100]), "type": "bar", "color": "#0f0"}}
+ result = validate_input(index, open_data, high, low, close, subplots=subplots)
+ assert "Volume" in result["subplots"]
+ assert result["subplot_meta"]["Volume"][0]["type"] == "bar"
+
+
+class TestDataValidationError:
+ """Tests for the conditions that raise DataValidationError."""
+
+ def test_invalid_index_type(self):
+ """Test that invalid index type raises error."""
+ index = [1, 2, 3, 4, 5] # List instead of Index or ndarray
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ with pytest.raises(DataValidationError, match="Index must be"):
+ validate_input(index, open_data, high, low, close)
+
+ def test_length_mismatch(self):
+ """Test that mismatched lengths raise error."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101]) # Length 3
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ with pytest.raises(DataValidationError, match="does not match index length"):
+ validate_input(index, open_data, high, low, close)
+
+ def test_ohlc_constraint_high_violation(self):
+ """Test that High < max(Open, Close) raises error."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([99, 106, 105, 107, 106]) # First high < open
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ with pytest.raises(DataValidationError, match="High must be >= max"):
+ validate_input(index, open_data, high, low, close)
+
+ def test_ohlc_constraint_low_violation(self):
+ """Test that Low > min(Open, Close) raises error."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([101, 100, 99, 101, 100]) # First low > open
+ close = np.array([104, 103, 104, 105, 104])
+
+ with pytest.raises(DataValidationError, match="Low must be <= min"):
+ validate_input(index, open_data, high, low, close)
+
+ def test_overlay_length_mismatch(self):
+ """Test that mismatched overlay length raises error."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ overlays = {"SMA20": np.array([101, 102, 102])} # Length 3
+
+ with pytest.raises(DataValidationError, match=r"Overlay.*does not match"):
+ validate_input(index, open_data, high, low, close, overlays=overlays)
+
+ def test_to_array_invalid_type_raises(self):
+ """Verify passing a non-array-like type as a data series raises an error."""
+ index = np.arange(5)
+ with pytest.raises(DataValidationError):
+ validate_input(index, "invalid", np.zeros(5), np.zeros(5), np.zeros(5))
+
+ def test_no_series_raises(self):
+ """Verify calling validate_input with no data series raises an error."""
+ index = np.arange(5)
+ with pytest.raises(DataValidationError, match="At least one data series"):
+ validate_input(index)
+
+ def test_trades_invalid_values_raise(self):
+ """Verify a trades array with values outside -1, 0, 1 raises an error."""
+ index = np.arange(5)
+ close = np.array([100, 102, 103, 104, 105])
+ with pytest.raises(DataValidationError, match="Trades array must contain only"):
+ validate_input(index, close=close, trades=np.array([1, 2, -1, 0, 1]))
+
+ def test_datamanager_propagates_validation_error(self):
+ """Test that invalid OHLC data raises DataValidationError through DataManager."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([99, 106, 105, 107, 106]) # First high < open
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ with pytest.raises(DataValidationError):
+ DataManager(index, open_data, high, low, close)
+
+
+class TestDataManager:
+ """Tests for the DataManager class, including its get_chunk slicing."""
+
+ def test_init_with_numpy_arrays(self):
+ """Test initialization with NumPy arrays."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ assert len(dm) == 5
+ assert dm.length == 5
+ assert isinstance(dm.open, np.ndarray)
+ assert np.array_equal(dm.open, open_data)
+
+ def test_init_with_pandas_series(self):
+ """Test initialization with Pandas Series."""
+ index = pd.date_range("2024-01-01", periods=5)
+ open_data = pd.Series([100, 102, 101, 103, 102])
+ high = pd.Series([105, 106, 105, 107, 106])
+ low = pd.Series([99, 100, 99, 101, 100])
+ close = pd.Series([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ assert len(dm) == 5
+ assert isinstance(dm.close, np.ndarray)
+ assert dm.close[0] == 104
+
+ def test_properties(self):
+ """Test all property accessors."""
+ index = np.arange(3)
+ open_data = np.array([100, 102, 101])
+ high = np.array([105, 106, 105])
+ low = np.array([99, 100, 99])
+ close = np.array([104, 103, 104])
+
+ trades = np.array([1, 0, -1])
+ subplots = {"Volume": {"data": np.array([10, 20, 30]), "type": "bar"}}
+
+ dm = DataManager(index, open_data, high, low, close, subplots=subplots, trades=trades)
+
+ assert np.array_equal(dm.index, index)
+ assert np.array_equal(dm.open, open_data)
+ assert np.array_equal(dm.high, high)
+ assert np.array_equal(dm.low, low)
+ assert np.array_equal(dm.close, close)
+ assert isinstance(dm.overlays, dict)
+ assert isinstance(dm.subplots, dict)
+ assert np.array_equal(dm.trades, trades)
+ assert dm.subplot_meta["Volume"][0]["type"] == "bar"
+
+ def test_with_overlays_and_subplots(self):
+ """Test initialization with overlays and subplots."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ overlays = {"SMA20": np.array([101, 102, 102, 103, 103])}
+ subplots = {"Volume": np.array([1000, 1200, 1100, 1300, 1150])}
+
+ dm = DataManager(index, open_data, high, low, close, overlays, subplots)
+
+ assert len(dm.overlays) == 1
+ assert "SMA20" in dm.overlays
+ assert len(dm.subplots) == 1
+ assert "Volume" in dm.subplots
+ assert np.array_equal(dm.overlays["SMA20"], [101, 102, 102, 103, 103])
+
+ def test_repr(self):
+ """Test string representation."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+ repr_str = repr(dm)
+
+ assert "DataManager" in repr_str
+ assert "5 points" in repr_str
+
+ def test_repr_with_overlays(self):
+ """Test string representation with overlays."""
+ index = np.arange(3)
+ open_data = np.array([100, 102, 101])
+ high = np.array([105, 106, 105])
+ low = np.array([99, 100, 99])
+ close = np.array([104, 103, 104])
+ overlays = {"SMA20": np.array([101, 102, 102])}
+
+ dm = DataManager(index, open_data, high, low, close, overlays=overlays)
+ repr_str = repr(dm)
+
+ assert "1 overlays" in repr_str
+
+ def test_repr_with_subplots(self):
+ """Verify the DataManager repr reports the number of subplots."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ subplots = {"RSI": np.array([55, 58, 52, 60, 57])}
+ dm = DataManager(index, open_data, high, low, close, subplots=subplots)
+ assert "1 subplots" in repr(dm)
+
+ def test_no_data_duplication(self):
+ """Test that data is not duplicated unnecessarily."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ # Verify arrays are stored (conversion happened but data is referenced)
+ assert dm.open.dtype == open_data.dtype
+ assert len(dm.open) == len(open_data)
+
+ def test_timestamp_conversion_to_milliseconds(self):
+ """Test that DatetimeIndex is converted to Unix timestamps in milliseconds."""
+ # Create a DatetimeIndex with known timestamps
+ index = pd.date_range("2024-01-01", periods=5, freq="h")
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ # Get chunk should return timestamps in milliseconds
+ chunk = dm.get_chunk(0, 5)
+
+ # Verify that index is a list of integers (Unix timestamps in milliseconds)
+ assert isinstance(chunk["index"], list)
+ assert all(isinstance(x, int) for x in chunk["index"])
+
+ # Verify timestamps are in the correct range (milliseconds since epoch)
+ # For 2024-01-01, timestamps should be around 1704067200000 (ms)
+ expected_first_ts = int(pd.Timestamp("2024-01-01").timestamp() * 1000)
+ assert chunk["index"][0] == expected_first_ts
+
+ # Verify timestamps are 1 hour apart (3600000 ms)
+ assert chunk["index"][1] - chunk["index"][0] == 3600000
+
+ def test_numeric_index_unchanged(self):
+ """Test that numeric indices are not converted to timestamps."""
+ # Use plain numeric index
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ # Get chunk should return plain numeric indices
+ chunk = dm.get_chunk(0, 5)
+
+ # Verify that index is unchanged
+ assert chunk["index"] == [0, 1, 2, 3, 4]
+
+ def test_unix_timestamp_index_unchanged(self):
+ """Test that raw Unix timestamps (already in milliseconds) pass through unchanged."""
+ # Use Unix timestamps in milliseconds (like JavaScript Date.now())
+ base_ts = 1704067200000 # 2024-01-01 in milliseconds
+ index = np.array([base_ts + i * 3600000 for i in range(5)])
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ # Get chunk should return timestamps unchanged
+ chunk = dm.get_chunk(0, 5)
+
+ # Verify timestamps are preserved
+ assert chunk["index"] == index.tolist()
+ assert all(isinstance(x, int) for x in chunk["index"])
+
+ def test_timezone_aware_index(self):
+ """Test that timezone-aware indices are correctly converted to milliseconds."""
+ # Create a timezone-aware index (UTC)
+ index = pd.date_range("2024-01-01", periods=5, freq="h", tz="UTC")
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ # Get chunk should return valid integer timestamps, NOT Timestamps objects
+ chunk = dm.get_chunk(0, 5)
+
+ # Verify conversion
+ assert isinstance(chunk["index"], list)
+ assert all(isinstance(x, int) for x in chunk["index"])
+
+ # Expected timestamp (1704067200000 for 2024-01-01 UTC)
+ expected_ts = 1704067200000
+ assert chunk["index"][0] == expected_ts
+
+ def test_get_chunk_includes_subplot_meta(self):
+ """Verify get_chunk includes subplot_meta with subplot keys in the result."""
+ index = np.arange(5)
+ open_data = np.array([100, 102, 101, 103, 102])
+ high = np.array([105, 106, 105, 107, 106])
+ low = np.array([99, 100, 99, 101, 100])
+ close = np.array([104, 103, 104, 105, 104])
+ subplots = {"Volume": np.array([1000, 1200, 1100, 1300, 1150])}
+ dm = DataManager(index, open_data, high, low, close, subplots=subplots)
+ chunk = dm.get_chunk(0, 5)
+ assert "subplot_meta" in chunk
+ assert "Volume" in chunk["subplot_meta"]
+
+ def test_get_chunk_basic(self):
+ """Test basic chunk retrieval."""
+ index = np.arange(10)
+ open_data = np.array([100, 102, 101, 103, 102, 104, 103, 105, 104, 106])
+ high = np.array([105, 106, 105, 107, 106, 108, 107, 109, 108, 110])
+ low = np.array([99, 100, 99, 101, 100, 102, 101, 103, 102, 104])
+ close = np.array([104, 103, 104, 105, 104, 106, 105, 107, 106, 108])
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(0, 5)
+
+ assert len(chunk["index"]) == 5
+ assert chunk["index"] == [0, 1, 2, 3, 4]
+ assert chunk["open"] == [100, 102, 101, 103, 102]
+ assert chunk["close"] == [104, 103, 104, 105, 104]
+
+ def test_get_chunk_middle(self):
+ """Test retrieving a chunk from the middle of data."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(3, 7)
+
+ assert len(chunk["index"]) == 4
+ assert chunk["index"] == [3, 4, 5, 6]
+ assert chunk["open"] == [103, 104, 105, 106]
+
+ def test_get_chunk_with_none_start(self):
+ """Test chunk with None start index (from beginning)."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(None, 5)
+
+ assert len(chunk["index"]) == 5
+ assert chunk["index"] == [0, 1, 2, 3, 4]
+
+ def test_get_chunk_with_none_end(self):
+ """Test chunk with None end index (to end)."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(7, None)
+
+ assert len(chunk["index"]) == 3
+ assert chunk["index"] == [7, 8, 9]
+ assert chunk["open"] == [107, 108, 109]
+
+ def test_get_chunk_with_both_none(self):
+ """Test chunk with both indices None (entire dataset)."""
+ index = np.arange(5)
+ open_data = np.arange(100, 105)
+ high = np.arange(105, 110)
+ low = np.arange(95, 100)
+ close = np.arange(102, 107)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(None, None)
+
+ assert len(chunk["index"]) == 5
+ assert chunk["index"] == [0, 1, 2, 3, 4]
+
+ def test_get_chunk_empty(self):
+ """Test empty chunk when start equals end."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(5, 5)
+
+ assert len(chunk["index"]) == 0
+ assert chunk["open"] == []
+
+ def test_get_chunk_out_of_bounds_positive(self):
+ """Test chunk with indices beyond data length."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(8, 20) # End beyond length
+
+ assert len(chunk["index"]) == 2 # Clamped to available data
+ assert chunk["index"] == [8, 9]
+
+ def test_get_chunk_out_of_bounds_negative(self):
+ """Test chunk with negative start index."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(-5, 5) # Negative start
+
+ # Negative indices should be clamped to 0
+ assert len(chunk["index"]) == 5
+ assert chunk["index"] == [0, 1, 2, 3, 4]
+
+ def test_get_chunk_inverted_indices(self):
+ """Test chunk with start > end (should return empty)."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(7, 3) # Start > end
+
+ # Should clamp end_index to be at least start_index
+ assert len(chunk["index"]) == 0
+
+ def test_get_chunk_with_overlays(self):
+ """Test chunk includes overlay data."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+ overlays = {
+ "SMA20": np.arange(101, 111),
+ "EMA10": np.arange(100.5, 110.5),
+ }
+
+ dm = DataManager(index, open_data, high, low, close, overlays=overlays)
+ chunk = dm.get_chunk(2, 7)
+
+ assert len(chunk["overlays"]) == 2
+ assert "SMA20" in chunk["overlays"]
+ assert "EMA10" in chunk["overlays"]
+ assert chunk["overlays"]["SMA20"] == [103, 104, 105, 106, 107]
+ assert len(chunk["overlays"]["EMA10"]) == 5
+
+ def test_get_chunk_with_subplots(self):
+ """Test chunk includes subplot data."""
+ index = np.arange(10)
+ open_data = np.arange(100, 110)
+ high = np.arange(105, 115)
+ low = np.arange(95, 105)
+ close = np.arange(102, 112)
+ subplots = {
+ "Volume": np.arange(1000, 1010) * 100,
+ "RSI": np.arange(50, 60),
+ }
+
+ dm = DataManager(index, open_data, high, low, close, subplots=subplots)
+ chunk = dm.get_chunk(1, 6)
+
+ assert len(chunk["subplots"]) == 2
+ assert "Volume" in chunk["subplots"]
+ assert "RSI" in chunk["subplots"]
+ assert len(chunk["subplots"]["Volume"]) == 5
+ assert chunk["subplots"]["RSI"] == [51, 52, 53, 54, 55]
+
+ def test_get_chunk_json_serializable(self):
+ """Test that chunk output is JSON serializable."""
+ index = np.arange(5)
+ open_data = np.arange(100, 105)
+ high = np.arange(105, 110)
+ low = np.arange(95, 100)
+ close = np.arange(102, 107)
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(0, 5)
+
+ # Should not raise any exception
+ json_str = json.dumps(chunk)
+ assert isinstance(json_str, str)
+
+ # Verify round-trip
+ recovered = json.loads(json_str)
+ assert recovered["index"] == chunk["index"]
+ assert recovered["open"] == chunk["open"]
+
+ def test_get_chunk_performance_large_dataset(self):
+ """Test performance with large dataset (100k points)."""
+ n = 100000
+ index = np.arange(n)
+ open_data = np.random.uniform(100, 200, n)
+ high = open_data + np.random.uniform(0, 10, n)
+ low = open_data - np.random.uniform(0, 10, n)
+ close = np.random.uniform(low, high)
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ # Measure slicing performance
+ start_time = time.time()
+ chunk = dm.get_chunk(10000, 20000) # 10k points
+ elapsed_ms = (time.time() - start_time) * 1000
+
+ # Should be well under 100ms for 10k points
+ assert elapsed_ms < 100, f"Slicing took {elapsed_ms:.2f}ms, expected <100ms"
+ assert len(chunk["index"]) == 10000
+
+ def test_get_chunk_performance_small_slice_large_dataset(self):
+ """Test performance of small slice from large dataset."""
+ n = 100000
+ index = np.arange(n)
+ open_data = np.random.uniform(100, 200, n)
+ high = open_data + np.random.uniform(0, 10, n)
+ low = open_data - np.random.uniform(0, 10, n)
+ close = np.random.uniform(low, high)
+
+ dm = DataManager(index, open_data, high, low, close)
+
+ # Small slice should be extremely fast
+ start_time = time.time()
+ chunk = dm.get_chunk(50000, 50100) # Just 100 points
+ elapsed_ms = (time.time() - start_time) * 1000
+
+ # Should be very fast (< 10ms)
+ assert elapsed_ms < 10, f"Small slice took {elapsed_ms:.2f}ms, expected <10ms"
+ assert len(chunk["index"]) == 100
+
+ def test_get_chunk_data_types(self):
+ """Test that chunk returns proper Python types (not numpy)."""
+ index = np.arange(5)
+ open_data = np.array([100.5, 102.3, 101.7, 103.2, 102.8])
+ high = np.array([105.1, 106.2, 105.4, 107.3, 106.9])
+ low = np.array([99.2, 100.1, 99.5, 101.3, 100.7])
+ close = np.array([104.3, 103.8, 104.2, 105.6, 104.9])
+
+ dm = DataManager(index, open_data, high, low, close)
+ chunk = dm.get_chunk(0, 3)
+
+ # All values should be Python lists, not numpy arrays
+ assert isinstance(chunk["index"], list)
+ assert isinstance(chunk["open"], list)
+ assert isinstance(chunk["high"], list)
+ assert isinstance(chunk["low"], list)
+ assert isinstance(chunk["close"], list)
+
+ # Individual values should be Python numbers
+ assert isinstance(chunk["open"][0], (int, float))