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))