From 6082954681d200cac61e2df244812e4b05606b30 Mon Sep 17 00:00:00 2001 From: MF Date: Sun, 28 Dec 2025 15:27:39 +1100 Subject: [PATCH 1/2] feat: Enhanced subplot system, swing markers, and UI improvements --- src/pycharting/api/interface.py | 66 +- src/pycharting/api/routes.py | 17 +- src/pycharting/data/ingestion.py | 73 +- src/pycharting/web/static/js/chart.js | 200 +- src/pycharting/web/static/js/viewport.js | 51 +- src/pycharting/web/static/viewport-demo.html | 2244 +++++++++++++----- 6 files changed, 1968 insertions(+), 683 deletions(-) diff --git a/src/pycharting/api/interface.py b/src/pycharting/api/interface.py index 3ccd1a0..6d86c74 100644 --- a/src/pycharting/api/interface.py +++ b/src/pycharting/api/interface.py @@ -18,6 +18,7 @@ import webbrowser import time import logging +import socket from typing import Optional, Dict, Any, Union import numpy as np import pandas as pd @@ -28,6 +29,38 @@ logger = logging.getLogger(__name__) + +def _get_local_ip() -> str: + """Get the local machine's IP address that can be reached externally. + + Uses a UDP socket trick - connects to an external address without sending data, + then reads which local IP was used. Falls back to hostname resolution or localhost. + """ + try: + # Create UDP socket (doesn't actually send anything) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.1) + # Connect to a public IP (Google DNS) - this doesn't send data, + # just determines which local interface would be used + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + return local_ip + except Exception: + pass + + # Fallback: try hostname resolution + try: + hostname = socket.gethostname() + local_ip = socket.gethostbyname(hostname) + if not local_ip.startswith("127."): + return local_ip + except Exception: + pass + + # Last resort + return "127.0.0.1" + # Global server instance _active_server: Optional[ChartServer] = None @@ -41,10 +74,12 @@ def plot( overlays: Optional[Dict[str, Union[np.ndarray, pd.Series, list]]] = None, subplots: Optional[Dict[str, Union[np.ndarray, pd.Series, list]]] = None, session_id: str = "default", + host: str = "127.0.0.1", port: Optional[int] = None, open_browser: bool = True, server_timeout: float = 2.0, block: bool = True, + external_host: Optional[str] = None, ) -> Dict[str, Any]: """ Generate and display an interactive OHLC (Open-High-Low-Close) or Line chart. @@ -66,16 +101,23 @@ def plot( low (Optional[Union[np.ndarray, pd.Series, list]]): Lowest prices during the interval. close (Optional[Union[np.ndarray, pd.Series, list]]): Closing prices. If only `close` is provided (without open/high/low), a line chart will be rendered instead of candlesticks. - overlays (Optional[Dict[str, Union[np.ndarray, pd.Series, list]]]): A dictionary of additional series to plot *over* the main price chart. - Keys are labels (e.g., "SMA 50"), values are data arrays. Useful for Moving Averages, Bollinger Bands, etc. + overlays (Optional[Dict[str, Union[np.ndarray, pd.Series, list, dict]]]): A dictionary of additional series to plot *over* the main price chart. + Keys are labels. Values can be: + - Simple: data array (renders as line) - e.g., `{"SMA 50": sma_array}` + - Styled: dict with `data`, `style`, `color`, `size` - e.g., `{"Markers": {"data": arr, "style": "marker", "color": "#00C853"}}` + Supported styles: "line" (default), "marker" (points), "dashed" (dashed line). subplots (Optional[Dict[str, Union[np.ndarray, pd.Series, list]]]): A dictionary of series to plot in separate panels *below* the main chart. Keys are labels (e.g., "RSI", "Volume"), values are data arrays. session_id (str): A unique identifier for this dataset. Use different IDs to keep multiple charts active simultaneously. Defaults to "default". + host (str): Host address to bind the server to. Use "0.0.0.0" to allow external connections (e.g., from Docker host). + Defaults to "127.0.0.1" (localhost only). port (Optional[int]): Specific port to run the server on. If `None` (default), a free port is automatically found. open_browser (bool): If `True` (default), automatically launches the system's default web browser to view the chart. server_timeout (float): Maximum time (in seconds) to wait for the server to start before proceeding. Defaults to 2.0. block (bool): If `True` (default), blocks execution until the browser page is closed. Useful in Jupyter notebooks. + external_host (Optional[str]): Override hostname/IP shown in URLs. When binding to "0.0.0.0", the local IP + is auto-detected. Use this parameter to override with a specific hostname or IP if needed. Returns: Dict[str, Any]: A dictionary containing execution details: @@ -160,9 +202,9 @@ def plot( if _active_server is None or not _active_server.is_running: logger.info("Starting ChartServer...") _active_server = ChartServer( - host="127.0.0.1", + host=host, port=port, - auto_shutdown_timeout=3.0 # 3 seconds after disconnect + auto_shutdown_timeout=60.0 # 60 seconds after disconnect (handles browser throttling) ) server_info = _active_server.start_server() @@ -177,7 +219,19 @@ def plot( # Construct chart URL with session ID and timestamp to bust cache # Use viewport demo which pulls data from the API for the given session ts = int(time.time()) - chart_url = f"{server_info['url']}/static/viewport-demo.html?session={session_id}&v={ts}" + + # Determine display host for URLs + # Priority: external_host > auto-detect (if 0.0.0.0) > server bind host + if external_host: + display_host = external_host + elif server_info['host'] == "0.0.0.0": + # Auto-detect local IP when binding to all interfaces + display_host = _get_local_ip() + else: + display_host = server_info['host'] + + display_url = f"http://{display_host}:{server_info['port']}" + chart_url = f"{display_url}/static/viewport-demo.html?session={session_id}&v={ts}" # Open browser if requested if open_browser: @@ -191,7 +245,7 @@ def plot( result = { "status": "success", "url": chart_url, - "server_url": server_info['url'], + "server_url": display_url, "session_id": session_id, "data_points": data_manager.length, "server_running": _active_server.is_running if _active_server else False, diff --git a/src/pycharting/api/routes.py b/src/pycharting/api/routes.py index bf41b70..a82c8d1 100644 --- a/src/pycharting/api/routes.py +++ b/src/pycharting/api/routes.py @@ -26,6 +26,14 @@ _data_managers: Dict[str, Any] = {} +class OverlayData(BaseModel): + """Overlay data with style configuration.""" + data: list + style: str = "line" # "line", "marker", "dashed" + color: Optional[str] = None + size: Optional[int] = None + + class DataResponse(BaseModel): """Response model for data endpoint.""" index: list @@ -33,8 +41,13 @@ class DataResponse(BaseModel): high: Optional[list] = None low: Optional[list] = None close: Optional[list] = None - overlays: Dict[str, list] = Field(default_factory=dict) - subplots: Dict[str, list] = Field(default_factory=dict) + # Overlays now include style metadata: {data, style, color, size} + overlays: Dict[str, OverlayData] = Field(default_factory=dict) + # Subplots can be: + # - Simple: {"RSI": [values...]} + # - Grouped: {"Stochastic": {"%K": [values...], "%D": [values...]}} + # - Histogram: {"Filter": {"_type": "histogram", "Buy": [values...], "Sell": [values...]}} + subplots: Dict[str, Any] = Field(default_factory=dict) start_index: int end_index: int total_length: int diff --git a/src/pycharting/data/ingestion.py b/src/pycharting/data/ingestion.py index f1edfc7..66731e7 100644 --- a/src/pycharting/data/ingestion.py +++ b/src/pycharting/data/ingestion.py @@ -10,10 +10,13 @@ The `DataManager` class is the core component here, acting as the optimized data store for a chart session. """ -from typing import Optional, Union, List, Dict, Any +from typing import Optional, Union, Dict, Any import pandas as pd import numpy as np +SubplotSeries = Union[pd.Series, np.ndarray, list] +SubplotsInput = Dict[str, Union[SubplotSeries, Dict[str, SubplotSeries]]] + class DataValidationError(Exception): """Exception raised when input data fails validation checks.""" @@ -27,7 +30,7 @@ def validate_input( low: Optional[Union[pd.Series, np.ndarray]] = None, close: Optional[Union[pd.Series, np.ndarray]] = None, overlays: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, - subplots: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, + subplots: Optional[SubplotsInput] = None, ) -> Dict[str, Any]: """ Validate and normalize input data for OHLC charting. @@ -159,16 +162,45 @@ def to_array(data: Optional[Union[pd.Series, np.ndarray, list]], name: str) -> O } # Validate and convert overlays + # Supports two formats: + # - {"SMA 20": array} # Simple line overlay + # - {"Markers": {"data": array, "style": "marker", ...}} # Styled overlay if overlays: for name, data in overlays.items(): - arr = to_array(data, f"Overlay '{name}'") - result["overlays"][name] = arr + if isinstance(data, dict) and "data" in data: + # Styled overlay format + arr = to_array(data["data"], f"Overlay '{name}'") + result["overlays"][name] = { + "data": arr, + "style": data.get("style", "line"), # "line", "marker", "dashed" + "color": data.get("color"), # Optional custom color + "size": data.get("size"), # Optional marker size + } + else: + # Simple format - just the array (backward compatible) + arr = to_array(data, f"Overlay '{name}'") + result["overlays"][name] = {"data": arr, "style": "line"} - # Validate and convert subplots + # Validate and convert subplots. + # Supports three formats: + # - {"RSI": array} # Simple line subplot + # - {"Stochastic": {"%K": array, "%D": array}} # Grouped line subplot + # - {"Filter": {"_type": "histogram", "Buy": array, "Sell": array}} # Histogram subplot if subplots: for name, data in subplots.items(): - arr = to_array(data, f"Subplot '{name}'") - result["subplots"][name] = arr + if isinstance(data, dict): + nested: dict[str, Any] = {} + for series_name, series_data in data.items(): + # Preserve _type metadata key (not an array) + if series_name == "_type": + nested["_type"] = series_data + else: + arr = to_array(series_data, f"Subplot '{name}.{series_name}'") + nested[series_name] = arr + result["subplots"][name] = nested + else: + arr = to_array(data, f"Subplot '{name}'") + result["subplots"][name] = arr return result @@ -186,7 +218,7 @@ def __init__( low: Optional[Union[pd.Series, np.ndarray]] = None, close: Optional[Union[pd.Series, np.ndarray]] = None, overlays: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, - subplots: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, + subplots: Optional[SubplotsInput] = None, ): # Validate input and get normalized arrays validated = validate_input(index, open, high, low, close, overlays, subplots) @@ -215,7 +247,7 @@ def close(self) -> Optional[np.ndarray]: return self._close @property def overlays(self) -> Dict[str, np.ndarray]: return self._overlays @property - def subplots(self) -> Dict[str, np.ndarray]: return self._subplots + def subplots(self) -> Dict[str, Any]: return self._subplots @property def length(self) -> int: return self._length def __len__(self) -> int: return self._length @@ -268,10 +300,27 @@ def slice_opt(arr): "subplots": {}, } - for name, data in self._overlays.items(): - result["overlays"][name] = data[start_index:end_index].tolist() + for name, overlay_info in self._overlays.items(): + # Overlay is now a dict with "data" array and optional style metadata + data_arr = overlay_info["data"] + result["overlays"][name] = { + "data": data_arr[start_index:end_index].tolist(), + "style": overlay_info.get("style", "line"), + "color": overlay_info.get("color"), + "size": overlay_info.get("size"), + } for name, data in self._subplots.items(): - result["subplots"][name] = data[start_index:end_index].tolist() + if isinstance(data, dict): + nested: dict[str, Any] = {} + for series_name, series_data in data.items(): + # Preserve _type metadata (not an array, just pass through) + if series_name == "_type": + nested["_type"] = series_data + else: + nested[series_name] = series_data[start_index:end_index].tolist() + result["subplots"][name] = nested + else: + result["subplots"][name] = data[start_index:end_index].tolist() return result diff --git a/src/pycharting/web/static/js/chart.js b/src/pycharting/web/static/js/chart.js index 53e0926..3629376 100644 --- a/src/pycharting/web/static/js/chart.js +++ b/src/pycharting/web/static/js/chart.js @@ -18,11 +18,12 @@ class PyChart { title: options.title || 'OHLC Chart', ...options }; - + this.chart = null; this.data = null; this.measurementButtonElement = null; this.exportButtonElement = null; // Track export button + this.lastCursorIdx = null; // Track last valid cursor index for value retention } /** @@ -113,14 +114,18 @@ class PyChart { * Set chart data and render * @param {Array} data - Chart data [xValues, open, high, low, close, ...overlays] * @param {Array} timestamps - Optional array of timestamps corresponding to xValues + * @param {Array} overlayLabels - Optional labels for overlay series (same order as overlays) + * @param {Array} overlayStyles - Optional style configs for overlays [{style, color, size}, ...] */ - setData(data, timestamps = null) { + setData(data, timestamps = null, overlayLabels = null, overlayStyles = null) { const prevLen = this.data ? this.data.length : null; const prevHadTimestamps = this.timestamps != null; const nowHasTimestamps = timestamps != null; - + this.data = data; this.timestamps = timestamps; + this.overlayLabels = overlayLabels; + this.overlayStyles = overlayStyles; // Rebuild chart if: // 1. Series count changed (e.g., overlays added) @@ -151,34 +156,54 @@ class PyChart { } } + /** + * Get grid color based on current theme (reads CSS variable) + */ + getGridColor() { + const style = getComputedStyle(document.body); + return style.getPropertyValue('--grid-color').trim() || '#2a2a4a'; + } + + /** + * Get axis color based on current theme (reads CSS variable) + */ + getAxisColor() { + const style = getComputedStyle(document.body); + return style.getPropertyValue('--text-secondary').trim() || '#888'; + } + /** * Helper to format x-axis values (indices) to dates */ formatDate(index) { + const barIdx = Math.round(index); + if (!this.timestamps) { - return index; + return `[${barIdx}]`; } - + if (this.data && this.data[0] && this.data[0].length > 0) { const startIndex = this.data[0][0]; const dataIndex = Math.round(index - startIndex); - + if (dataIndex >= 0 && dataIndex < this.timestamps.length) { const val = this.timestamps[dataIndex]; - + // Heuristic: Only format as date if value looks like a timestamp (milliseconds) // Threshold: Year 1980 (~3.15e11 ms) if (typeof val === 'number' && val > 315360000000) { const date = new Date(val); - return date.toLocaleString(undefined, { - month: 'numeric', day: 'numeric', - hour: '2-digit', minute: '2-digit' - }); + // Short format: "[barIdx] 10/21 14:30" for x-axis labels + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const mins = String(date.getMinutes()).padStart(2, '0'); + return `[${barIdx}] ${month}/${day} ${hours}:${mins}`; } - return val; + return `[${barIdx}] ${val}`; } } - return index; + return `[${barIdx}]`; } /** @@ -190,45 +215,127 @@ class PyChart { // Check if Open series exists and contains any non-null values const openSeries = data[1]; const hasOHLC = openSeries && Array.isArray(openSeries) && openSeries.some(v => v != null); - + + // Helper to get value with fallback to last cursor position + // When cursor leaves, v is null but we want to retain the last displayed value + const getValueWithRetention = (u, v, seriesIdx) => { + if (v != null) { + return v.toFixed(5); + } + // Cursor left chart - use last known cursor index to get value + if (self.lastCursorIdx != null && u.data && u.data[seriesIdx]) { + const lastVal = u.data[seriesIdx][self.lastCursorIdx]; + if (lastVal != null) { + return lastVal.toFixed(5); + } + } + return '-'; + }; + // Build series configuration const series = [ { label: 'Time', - value: (u, v) => self.formatDate(v) + value: (u, v) => { + if (v != null) { + return self.formatDate(v); + } + // Use last cursor index for time display too + if (self.lastCursorIdx != null && u.data && u.data[0]) { + const lastX = u.data[0][self.lastCursorIdx]; + if (lastX != null) { + return self.formatDate(lastX); + } + } + return '-'; + } }, { label: 'Open', show: false, + value: (u, v) => getValueWithRetention(u, v, 1), }, { label: 'High', show: false, + value: (u, v) => getValueWithRetention(u, v, 2), }, { label: 'Low', show: false, + value: (u, v) => getValueWithRetention(u, v, 3), }, { label: 'Close', stroke: hasOHLC ? 'transparent' : '#2196F3', width: hasOHLC ? 0 : 2, fill: 'transparent', + value: (u, v) => getValueWithRetention(u, v, 4), } ]; - + // Add overlay series (starting from index 5) if (data.length > 5) { + const defaultColors = ['#2196F3', '#FF9800', '#9C27B0', '#4CAF50']; + for (let i = 5; i < data.length; i++) { - const colors = ['#2196F3', '#FF9800', '#9C27B0', '#4CAF50']; - series.push({ - label: `Overlay ${i - 4}`, - stroke: colors[(i - 5) % colors.length], - width: 2, - }); + const overlayIdx = i - 5; + const label = (this.overlayLabels && this.overlayLabels[overlayIdx]) ? this.overlayLabels[overlayIdx] : `Overlay ${overlayIdx + 1}`; + + // Get style from overlayStyles if available, otherwise default to line + const styleConfig = (this.overlayStyles && this.overlayStyles[overlayIdx]) || { style: 'line' }; + const style = styleConfig.style || 'line'; + const customColor = styleConfig.color; + const customSize = styleConfig.size; + + // Determine rendering mode from style config + const renderAsMarker = style === 'marker'; + const renderAsDashed = style === 'dashed'; + + // Use custom color or fall back to default color palette + const stroke = customColor || defaultColors[overlayIdx % defaultColors.length]; + + // Capture seriesIdx for closure + const seriesIdx = i; + const seriesConfig = { + label, + stroke, + width: renderAsMarker ? 0 : 2, + points: renderAsMarker ? { + show: true, + size: customSize || 10, + fill: stroke, + stroke: '#ffffff', + width: 2, + } : { show: false }, + value: (u, v) => getValueWithRetention(u, v, seriesIdx), + }; + + // Add dashed line style if specified + if (renderAsDashed) { + seriesConfig.dash = [6, 4]; + } + + series.push(seriesConfig); } } - + + // Create cursor tracking plugin to track last valid cursor index + const cursorTrackingPlugin = () => ({ + hooks: { + setCursor: [ + (u) => { + // Track the last valid cursor index (when cursor is over data) + if (u.cursor.idx != null) { + self.lastCursorIdx = u.cursor.idx; + } + // Note: We intentionally do NOT reset lastCursorIdx when cursor leaves + // This allows values to persist until a new bar is hovered + } + ] + } + }); + return { ...this.options, series, @@ -242,23 +349,31 @@ class PyChart { }, axes: [ { - stroke: '#888', - grid: { stroke: '#eee', width: 1 }, + stroke: this.getAxisColor(), + grid: { stroke: this.getGridColor(), width: 1 }, + space: 100, // Minimum pixels between x-axis labels to prevent overlap + size: 40, // Fixed height for x-axis area values: (u, vals) => vals.map(v => { const idx = Math.round(v); return self.formatDate(idx); }), }, { - stroke: '#888', - grid: { stroke: '#eee', width: 1 }, - values: (u, vals) => vals.map(v => v.toFixed(2)), + stroke: this.getAxisColor(), + grid: { stroke: this.getGridColor(), width: 1 }, + size: 60, // Fixed width for y-axis area + values: (u, vals) => vals.map(v => v.toFixed(5)), } ], - plugins: hasOHLC ? [this.candlestickPlugin()] : [], + plugins: hasOHLC ? [this.candlestickPlugin(), cursorTrackingPlugin()] : [cursorTrackingPlugin()], cursor: { drag: { x: false, y: false }, - sync: { key: 'pycharting' } + sync: { key: 'pycharting' }, + focus: { prox: Infinity }, // Keep values visible when mouse leaves chart + }, + legend: { + live: true, // Update values on cursor move + show: true, } }; } @@ -541,8 +656,8 @@ class PyChart { const boxWidth = maxWidth + 20; const boxHeight = 60; - // Draw box background - overlayCtx.fillStyle = 'rgba(33, 33, 33, 0.9)'; + // Draw box background (dark mode) + overlayCtx.fillStyle = 'rgba(26, 26, 46, 0.95)'; overlayCtx.fillRect(boxX - boxWidth / 2, boxY, boxWidth, boxHeight); // Draw box border @@ -752,16 +867,16 @@ class PyChart { try { // Save original background const originalBg = this.container.style.backgroundColor; - - // Temporarily set white background for export - this.container.style.backgroundColor = '#ffffff'; + + // Temporarily set dark background for export (matching dark theme) + this.container.style.backgroundColor = '#1a1a2e'; // Dynamically load html2canvas const html2canvas = (await import('https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/+esm')).default; // Capture the entire container (chart + overlays) const canvas = await html2canvas(this.container, { - backgroundColor: '#ffffff', + backgroundColor: '#1a1a2e', scale: 2, // 2x resolution for better quality logging: false, useCORS: true @@ -837,17 +952,18 @@ class PyChart { top: 10px; right: 10px; padding: 8px 12px; - background: rgba(255, 255, 255, 0.9); - border: 1px solid #ccc; + background: rgba(30, 30, 50, 0.95); + border: 1px solid #3a3a5e; border-radius: 4px; cursor: pointer; font-size: 14px; z-index: 1000; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + color: #e0e0e0; `; - - btn.onmouseover = () => btn.style.background = 'rgba(255, 255, 255, 1)'; - btn.onmouseout = () => btn.style.background = 'rgba(255, 255, 255, 0.9)'; + + btn.onmouseover = () => btn.style.background = 'rgba(40, 40, 70, 1)'; + btn.onmouseout = () => btn.style.background = 'rgba(30, 30, 50, 0.95)'; btn.onclick = () => { const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); diff --git a/src/pycharting/web/static/js/viewport.js b/src/pycharting/web/static/js/viewport.js index 4f3ceb9..fe8073d 100644 --- a/src/pycharting/web/static/js/viewport.js +++ b/src/pycharting/web/static/js/viewport.js @@ -77,7 +77,7 @@ class ViewportManager { try { subplot.setScale('x', { min, max }); } catch (e) { - // Ignore sync errors per subplot + // Ignore sync errors } }); } @@ -191,8 +191,27 @@ class ViewportManager { */ async updateViewport() { const range = this.calculateVisibleRange(); - - if (!range || !this.needsFetch(range)) { + + if (!range) { + return; + } + + // Even if we don't need to fetch, update subplots with cached data + // This ensures subplots stay in sync when panning within cached range + if (!this.needsFetch(range) && this.dataCache.data) { + this.subplotCallbacks.forEach((cb) => { + if (typeof cb === 'function') { + // Pass cached data with current indices + const data = { ...this.dataCache.data }; + data.xIndices = data.index.map((_, i) => (data.start_index || 0) + i); + data.timestamps = data.index; + cb(data, this); + } + }); + return; + } + + if (!this.needsFetch(range)) { return; } @@ -255,15 +274,31 @@ class ViewportManager { ensureArray(data.close) ]; - // Add overlays if present + // Add overlays if present (preserve keys for labeling/styling) + // New format: overlay is {data: [], style: "line"|"marker"|"dashed", color: "#hex", size: N} + const overlayLabels = []; + const overlayStyles = []; if (data.overlays) { - Object.values(data.overlays).forEach(overlay => { - chartData.push(overlay); + Object.entries(data.overlays).forEach(([label, overlay]) => { + overlayLabels.push(label); + // Handle both new format (object with data) and legacy format (array) + if (overlay && typeof overlay === 'object' && overlay.data) { + chartData.push(overlay.data); + overlayStyles.push({ + style: overlay.style || 'line', + color: overlay.color || null, + size: overlay.size || null + }); + } else { + // Legacy format: overlay is just an array + chartData.push(overlay); + overlayStyles.push({ style: 'line', color: null, size: null }); + } }); } - + // Update chart with indices as X, and pass timestamps for formatting - this.chart.setData(chartData, timestamps); + this.chart.setData(chartData, timestamps, overlayLabels, overlayStyles); // Ensure listeners are set up (needed if chart was just created/recreated) this.setupEventListeners(); diff --git a/src/pycharting/web/static/viewport-demo.html b/src/pycharting/web/static/viewport-demo.html index f7c2f83..733a43d 100644 --- a/src/pycharting/web/static/viewport-demo.html +++ b/src/pycharting/web/static/viewport-demo.html @@ -1,619 +1,1637 @@ + - - - PyCharting - - + + + PyCharting + + + -
PyCharting
-
-
-
- -
-
- -
- - -
-
- - - - -
-
- - - - - +
+
+
+ + + +
+
+
+ +
+
+ + +
+
+ + + + +
+
+ + + + + - + + \ No newline at end of file From 94950d611d0d0377e399751fd0b0f9b4bc55bcec Mon Sep 17 00:00:00 2001 From: MF Date: Sun, 28 Dec 2025 16:09:06 +1100 Subject: [PATCH 2/2] test: Add tests for enhanced subplot system and host parameters New tests added (21 tests, all passing): - Styled overlays: marker, dashed, mixed formats - Grouped subplots: multiple series (e.g., Stochastic %K/%D) - Histogram subplots: _type metadata preservation - DataManager.get_chunk(): styled overlay and grouped subplot slicing - _get_local_ip(): IP detection with fallback chain - plot() host/external_host parameters for Docker support Also fixed: - Import paths updated from src.* to pycharting.* - Updated 3 existing tests to match new styled overlay format Note: 6 pre-existing test failures unrelated to this PR: - test_invalid_index_type, test_ohlc_constraint_*: validation was relaxed - test_repr_with_overlays: __repr__ doesn't show overlay count - test_package_import: src.__version__ never defined These tests were failing before feature/labels branch changes. --- src/__init__.py | 2 +- tests/test_data_ingestion.py | 259 ++++++++++++++++++++++++++++++++++- tests/test_data_slicing.py | 8 +- tests/test_interface.py | 146 +++++++++++++++++++- tests/test_lifecycle.py | 2 +- 5 files changed, 403 insertions(+), 14 deletions(-) diff --git a/src/__init__.py b/src/__init__.py index ea0ea76..a76405f 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,4 +1,4 @@ # Import main API functions -from api.interface import plot, stop_server, get_server_status +from pycharting.api.interface import plot, stop_server, get_server_status __all__ = ["plot", "stop_server", "get_server_status", "__version__"] \ No newline at end of file diff --git a/tests/test_data_ingestion.py b/tests/test_data_ingestion.py index 6aba746..ce08aba 100644 --- a/tests/test_data_ingestion.py +++ b/tests/test_data_ingestion.py @@ -49,13 +49,15 @@ def test_with_overlays(self): "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]) + # Overlays are now styled objects with 'data' and 'style' keys + assert np.array_equal(result["overlays"]["SMA20"]["data"], [101, 102, 102, 103, 103]) + assert result["overlays"]["SMA20"]["style"] == "line" def test_with_subplots(self): """Test validation with subplot data.""" @@ -191,14 +193,15 @@ def test_with_overlays_and_subplots(self): 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]) + # Overlays are now styled objects with 'data' and 'style' keys + assert np.array_equal(dm.overlays["SMA20"]["data"], [101, 102, 102, 103, 103]) def test_invalid_data_raises_error(self): """Test that invalid OHLC data raises DataValidationError.""" @@ -335,4 +338,246 @@ def test_timezone_aware_index(self): # Expected timestamp (1704067200000 for 2024-01-01 UTC) expected_ts = 1704067200000 - assert chunk["index"][0] == expected_ts \ No newline at end of file + assert chunk["index"][0] == expected_ts + + +class TestStyledOverlays: + """Tests for styled overlay support (new feature).""" + + def test_simple_overlay_becomes_styled(self): + """Test that simple overlays are converted to styled format with line style.""" + 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])} + + result = validate_input(index, open_data, high, low, close, overlays=overlays) + + # Simple overlay should be converted to styled format + assert "SMA20" in result["overlays"] + assert isinstance(result["overlays"]["SMA20"], dict) + assert "data" in result["overlays"]["SMA20"] + assert result["overlays"]["SMA20"]["style"] == "line" + assert np.array_equal(result["overlays"]["SMA20"]["data"], [101, 102, 102, 103, 103]) + + def test_styled_overlay_with_marker(self): + """Test styled overlay with marker style.""" + 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 = { + "Highlights": { + "data": np.array([np.nan, 106, np.nan, 107, np.nan]), + "style": "marker", + "color": "#00C853", + "size": 10, + } + } + + result = validate_input(index, open_data, high, low, close, overlays=overlays) + + assert "Highlights" in result["overlays"] + assert result["overlays"]["Highlights"]["style"] == "marker" + assert result["overlays"]["Highlights"]["color"] == "#00C853" + assert result["overlays"]["Highlights"]["size"] == 10 + + def test_styled_overlay_with_dashed(self): + """Test styled overlay with dashed line style.""" + index = np.arange(5) + close = np.array([104, 103, 104, 105, 104]) + overlays = { + "Threshold": { + "data": np.array([107, 107, 107, 107, 107]), + "style": "dashed", + "color": "#FF0000", + } + } + + result = validate_input(index, close, close + 1, close - 1, close, overlays=overlays) + + assert result["overlays"]["Threshold"]["style"] == "dashed" + assert result["overlays"]["Threshold"]["color"] == "#FF0000" + assert result["overlays"]["Threshold"]["size"] is None # Not provided + + def test_mixed_overlay_formats(self): + """Test mixing simple and styled overlay formats.""" + index = np.arange(5) + close = np.array([104, 103, 104, 105, 104]) + overlays = { + "SMA": np.array([101, 102, 102, 103, 103]), # Simple + "Markers": { + "data": np.array([np.nan, 106, np.nan, 107, np.nan]), + "style": "marker", + }, # Styled + } + + result = validate_input(index, close, close + 1, close - 1, close, overlays=overlays) + + assert result["overlays"]["SMA"]["style"] == "line" + assert result["overlays"]["Markers"]["style"] == "marker" + + +class TestGroupedSubplots: + """Tests for grouped subplot support (new feature).""" + + def test_simple_subplot_unchanged(self): + """Test that simple subplots still work as before.""" + index = np.arange(5) + close = np.array([104, 103, 104, 105, 104]) + subplots = {"RSI": np.array([55, 58, 52, 60, 57])} + + result = validate_input(index, close, close + 1, close - 1, close, subplots=subplots) + + assert "RSI" in result["subplots"] + # Simple subplot stays as array, not dict + assert isinstance(result["subplots"]["RSI"], np.ndarray) + assert np.array_equal(result["subplots"]["RSI"], [55, 58, 52, 60, 57]) + + def test_grouped_subplot(self): + """Test grouped subplot with multiple series (e.g., Stochastic %K/%D).""" + index = np.arange(5) + close = np.array([104, 103, 104, 105, 104]) + subplots = { + "Stochastic": { + "%K": np.array([70, 75, 72, 80, 78]), + "%D": np.array([68, 72, 70, 76, 75]), + } + } + + result = validate_input(index, close, close + 1, close - 1, close, subplots=subplots) + + assert "Stochastic" in result["subplots"] + assert isinstance(result["subplots"]["Stochastic"], dict) + assert "%K" in result["subplots"]["Stochastic"] + assert "%D" in result["subplots"]["Stochastic"] + assert np.array_equal(result["subplots"]["Stochastic"]["%K"], [70, 75, 72, 80, 78]) + assert np.array_equal(result["subplots"]["Stochastic"]["%D"], [68, 72, 70, 76, 75]) + + def test_histogram_subplot(self): + """Test histogram subplot with _type metadata.""" + index = np.arange(5) + close = np.array([104, 103, 104, 105, 104]) + subplots = { + "Signals": { + "_type": "histogram", + "Positive": np.array([1, 0, 1, 1, 0]), + "Negative": np.array([0, 1, 0, 0, 1]), + } + } + + result = validate_input(index, close, close + 1, close - 1, close, subplots=subplots) + + assert "Signals" in result["subplots"] + assert result["subplots"]["Signals"]["_type"] == "histogram" + assert np.array_equal(result["subplots"]["Signals"]["Positive"], [1, 0, 1, 1, 0]) + assert np.array_equal(result["subplots"]["Signals"]["Negative"], [0, 1, 0, 0, 1]) + + def test_grouped_subplot_length_validation(self): + """Test that grouped subplot series are validated for length.""" + index = np.arange(5) + close = np.array([104, 103, 104, 105, 104]) + subplots = { + "Stochastic": { + "%K": np.array([70, 75, 72]), # Wrong length! + "%D": np.array([68, 72, 70, 76, 75]), + } + } + + with pytest.raises(DataValidationError, match="does not match"): + validate_input(index, close, close + 1, close - 1, close, subplots=subplots) + + +class TestDataManagerStyledOverlays: + """Tests for DataManager with styled overlays.""" + + def test_get_chunk_styled_overlay(self): + """Test get_chunk returns styled overlay metadata.""" + index = np.arange(10) + close = np.random.randn(10) + 100 + overlays = { + "Markers": { + "data": np.array([np.nan] * 5 + [105, 106, 107, 108, 109]), + "style": "marker", + "color": "#00FF00", + "size": 8, + } + } + + dm = DataManager(index, close, close + 1, close - 1, close, overlays=overlays) + chunk = dm.get_chunk(5, 10) + + assert "Markers" in chunk["overlays"] + assert chunk["overlays"]["Markers"]["style"] == "marker" + assert chunk["overlays"]["Markers"]["color"] == "#00FF00" + assert chunk["overlays"]["Markers"]["size"] == 8 + assert len(chunk["overlays"]["Markers"]["data"]) == 5 + + def test_get_chunk_simple_overlay_has_style(self): + """Test that simple overlays in get_chunk have style info.""" + index = np.arange(10) + close = np.random.randn(10) + 100 + overlays = {"SMA": np.array(range(10))} + + dm = DataManager(index, close, close + 1, close - 1, close, overlays=overlays) + chunk = dm.get_chunk(0, 5) + + assert chunk["overlays"]["SMA"]["style"] == "line" + assert chunk["overlays"]["SMA"]["color"] is None + assert len(chunk["overlays"]["SMA"]["data"]) == 5 + + +class TestDataManagerGroupedSubplots: + """Tests for DataManager with grouped subplots.""" + + def test_get_chunk_grouped_subplot(self): + """Test get_chunk correctly slices grouped subplots.""" + index = np.arange(10) + close = np.random.randn(10) + 100 + subplots = { + "Stochastic": { + "%K": np.array(range(10)), + "%D": np.array(range(10, 20)), + } + } + + dm = DataManager(index, close, close + 1, close - 1, close, subplots=subplots) + chunk = dm.get_chunk(2, 5) + + assert "Stochastic" in chunk["subplots"] + assert chunk["subplots"]["Stochastic"]["%K"] == [2, 3, 4] + assert chunk["subplots"]["Stochastic"]["%D"] == [12, 13, 14] + + def test_get_chunk_histogram_subplot(self): + """Test get_chunk preserves _type metadata in histogram subplots.""" + index = np.arange(10) + close = np.random.randn(10) + 100 + subplots = { + "Signals": { + "_type": "histogram", + "Positive": np.array([1, 0, 1, 0, 1, 0, 1, 0, 1, 0]), + "Negative": np.array([0, 1, 0, 1, 0, 1, 0, 1, 0, 1]), + } + } + + dm = DataManager(index, close, close + 1, close - 1, close, subplots=subplots) + chunk = dm.get_chunk(0, 4) + + assert chunk["subplots"]["Signals"]["_type"] == "histogram" + assert chunk["subplots"]["Signals"]["Positive"] == [1, 0, 1, 0] + assert chunk["subplots"]["Signals"]["Negative"] == [0, 1, 0, 1] + + def test_get_chunk_simple_subplot(self): + """Test get_chunk with simple (non-grouped) subplot.""" + index = np.arange(10) + close = np.random.randn(10) + 100 + subplots = {"Volume": np.array(range(100, 110))} + + dm = DataManager(index, close, close + 1, close - 1, close, subplots=subplots) + chunk = dm.get_chunk(0, 3) + + # Simple subplot is just a list, not a dict + assert chunk["subplots"]["Volume"] == [100, 101, 102] \ No newline at end of file diff --git a/tests/test_data_slicing.py b/tests/test_data_slicing.py index 3135166..84966f5 100644 --- a/tests/test_data_slicing.py +++ b/tests/test_data_slicing.py @@ -3,7 +3,7 @@ import pytest import numpy as np import time -from src.data.ingestion import DataManager +from pycharting.data.ingestion import DataManager class TestGetChunk: @@ -158,8 +158,10 @@ def test_chunk_with_overlays(self): 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 + # Overlays are now styled objects with 'data', 'style', 'color', 'size' keys + assert chunk["overlays"]["SMA20"]["data"] == [103, 104, 105, 106, 107] + assert chunk["overlays"]["SMA20"]["style"] == "line" + assert len(chunk["overlays"]["EMA10"]["data"]) == 5 def test_chunk_with_subplots(self): """Test chunk includes subplot data.""" diff --git a/tests/test_interface.py b/tests/test_interface.py index 40cb640..c035b33 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -2,11 +2,12 @@ import pytest import numpy as np +import socket import time from unittest.mock import patch, MagicMock -from src.api.interface import plot, stop_server, get_server_status, _active_server -from src.api.routes import _data_managers +from pycharting.api.interface import plot, stop_server, get_server_status, _active_server, _get_local_ip +from pycharting.api.routes import _data_managers @pytest.fixture(autouse=True) @@ -307,3 +308,144 @@ def test_multiple_sessions(self): assert len(_data_managers) >= 3 for i in range(3): assert f'session_{i}' in _data_managers + + +class TestGetLocalIP: + """Tests for the _get_local_ip() helper function.""" + + def test_get_local_ip_returns_string(self): + """Test that _get_local_ip returns a valid IP string.""" + ip = _get_local_ip() + assert isinstance(ip, str) + # Should be a valid IP format (either localhost or actual IP) + parts = ip.split('.') + assert len(parts) == 4 + for part in parts: + assert part.isdigit() + assert 0 <= int(part) <= 255 + + @patch('socket.socket') + def test_get_local_ip_fallback_on_socket_error(self, mock_socket_class): + """Test fallback when socket connection fails.""" + # Mock socket to raise an exception + mock_socket = MagicMock() + mock_socket.connect.side_effect = OSError("Network error") + mock_socket_class.return_value = mock_socket + + # Should fall back to hostname resolution or localhost + with patch('socket.gethostname', return_value='testhost'): + with patch('socket.gethostbyname', return_value='192.168.1.100'): + ip = _get_local_ip() + assert ip == '192.168.1.100' + + @patch('socket.socket') + def test_get_local_ip_final_fallback_to_localhost(self, mock_socket_class): + """Test final fallback to 127.0.0.1 when all methods fail.""" + # Mock socket to fail + mock_socket = MagicMock() + mock_socket.connect.side_effect = OSError("Network error") + mock_socket_class.return_value = mock_socket + + # Mock hostname resolution to also fail + with patch('socket.gethostname', side_effect=OSError("Hostname error")): + ip = _get_local_ip() + assert ip == '127.0.0.1' + + @patch('socket.socket') + def test_get_local_ip_ignores_localhost_from_hostname(self, mock_socket_class): + """Test that localhost IPs from hostname resolution are skipped.""" + # Mock socket to fail + mock_socket = MagicMock() + mock_socket.connect.side_effect = OSError("Network error") + mock_socket_class.return_value = mock_socket + + # Mock hostname to return localhost + with patch('socket.gethostname', return_value='localhost'): + with patch('socket.gethostbyname', return_value='127.0.0.1'): + ip = _get_local_ip() + # Should return localhost as final fallback (since hostname returned 127.x) + assert ip == '127.0.0.1' + + +class TestPlotHostParameters: + """Tests for host and external_host parameters in plot().""" + + def test_plot_default_host(self): + """Test that default host is 127.0.0.1.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + result = plot( + index, data, data + 1, data - 1, data, + open_browser=False, + block=False + ) + + assert result['status'] == 'success' + # Default should bind to localhost + assert '127.0.0.1' in result['server_url'] or 'localhost' in result['server_url'] + + @patch('webbrowser.open') + def test_plot_with_custom_host(self, mock_browser): + """Test plot with custom host parameter.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + # Stop any existing server first to test fresh server with new host + stop_server() + + result = plot( + index, data, data + 1, data - 1, data, + host="127.0.0.1", # Explicitly set host + open_browser=False, + block=False + ) + + assert result['status'] == 'success' + assert '127.0.0.1' in result['server_url'] + + @patch('webbrowser.open') + def test_plot_with_external_host_override(self, mock_browser): + """Test plot with external_host parameter overriding display URL.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + # Stop any existing server first + stop_server() + + result = plot( + index, data, data + 1, data - 1, data, + host="127.0.0.1", + external_host="myhost.local", + open_browser=True, + block=False + ) + + assert result['status'] == 'success' + # Display URL should use external_host + assert 'myhost.local' in result['url'] + assert 'myhost.local' in result['server_url'] + + # Browser should be called with external_host URL + mock_browser.assert_called_once() + call_url = mock_browser.call_args[0][0] + assert 'myhost.local' in call_url + + def test_plot_url_contains_session_id(self): + """Test that the chart URL includes session ID parameter.""" + n = 50 + data = np.random.randn(n) + 100 + index = np.arange(n) + + result = plot( + index, data, data + 1, data - 1, data, + session_id='test_session_123', + open_browser=False, + block=False + ) + + assert result['status'] == 'success' + assert 'session=test_session_123' in result['url'] diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 8c7cf37..779e66c 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -3,7 +3,7 @@ import pytest import time import threading -from src.pycharting.core.lifecycle import ChartServer +from pycharting.core.lifecycle import ChartServer class TestChartServer: