From 9e5c811f6962e10a51987e016b0cc2b6913a8d15 Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Wed, 30 Sep 2026 07:33:07 +0400 Subject: [PATCH 1/2] docs: make the four skipped README Python fences executable (#101) --- README.md | 208 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 128 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index e1ce1da..4d1ca4f 100644 --- a/README.md +++ b/README.md @@ -71,33 +71,79 @@ When you run this script, PyCharting will: - register your OHLC series and overlays in a session, - open your default browser to a minimal full‑page chart UI showing price and overlays. +Nothing runs until the first `plot` call: + +```python +status = get_server_status() +print(status) +``` + +```result +{'running': False, 'server_info': None, 'active_sessions': 0} +``` + +and stopping a server that was never started is a harmless no-op: + +```python +stop_server() +``` + +```result +ⓘ No active server to stop +``` + ## Overlays vs subplots Once you have your OHLC series, you pass additional series to `plot` in two different ways: - -```python +RHIZA_SKIP +```python +import contextlib +import io + +import numpy as np +import pandas as pd + +# Synthetic OHLC data — substitute your own series. +n = 300 +rng = np.random.default_rng(0) +index = pd.date_range("2024-01-01", periods=n, freq="h") +close = 100 + np.cumsum(rng.normal(size=n)) +open_ = np.r_[close[0], close[:-1]] +high = np.maximum(open_, close) + rng.uniform(0, 1, n) +low = np.minimum(open_, close) - rng.uniform(0, 1, n) +price = pd.Series(close) + overlays = { - "SMA_50": sma(close, 50), # rendered on top of price - "EMA_200": ema(close, 200), + "SMA_50": price.rolling(50).mean().to_numpy(), # rendered on top of price + "EMA_200": price.ewm(span=200).mean().to_numpy(), } subplots = { - "RSI_like": rsi_like_series, # rendered in its own panel below price - "Stoch_like": stoch_series, + "Momentum": price.diff(14).to_numpy(), # rendered in its own panel below price + "Range": high - low, } -plot( - index, - open_, - high, - low, - close, - overlays=overlays, - subplots=subplots, -) +with contextlib.redirect_stdout(io.StringIO()): # plot() prints a per-run URL + result = plot( + index, + open_, + high, + low, + close, + overlays=overlays, + subplots=subplots, + open_browser=False, # just return the URL in result["url"] + block=False, # return straight away instead of waiting for the page to close + ) +print(result["status"], result["data_points"]) ``` +```result +success 300 +``` + +In a script you would normally drop the `redirect_stdout` and the last two arguments: `plot` then opens your browser, prints the chart URL and waits until you close the page. They are here so the README's examples can run unattended. + - **Overlays** share the same y‑axis as price and are drawn directly on the candlestick chart (moving averages, bands, signals on price). - **Subplots** are stacked independent charts below the main panel with their own y‑scales (oscillators, volume, breadth measures). @@ -105,11 +151,22 @@ plot( Each subplot value can be a plain array (line), a dict with options, or a list of dicts for multi-series panels: - -```python +RHIZA_SKIP +```python +# A few indicators derived from the data above. +delta = price.diff() +gain = delta.clip(lower=0).rolling(14).mean() +loss = (-delta.clip(upper=0)).rolling(14).mean() +rsi = (100 - 100 / (1 + gain / loss)).to_numpy() +rsi_sma = pd.Series(rsi).rolling(20).mean().to_numpy() +macd_line = (price.ewm(span=12).mean() - price.ewm(span=26).mean()).to_numpy() +signal_line = pd.Series(macd_line).ewm(span=9).mean().to_numpy() +histogram = macd_line - signal_line +volume_array = rng.normal(0, 1000, n) +events_array = np.where(rng.random(n) < 0.05, close, np.nan) + subplots = { # Simple line (default) - "RSI": rsi_array, + "RSI": rsi, # Bar chart — green if value ≥ 0, red if < 0, centered at y=0 "Volume": {"data": volume_array, "type": "bar"}, # Scatter plot @@ -126,6 +183,14 @@ subplots = { {"data": rsi_sma, "type": "line", "color": "#2196F3", "label": "RSI SMA(20)"}, ], } + +with contextlib.redirect_stdout(io.StringIO()): + result = plot(index, open_, high, low, close, subplots=subplots, open_browser=False, block=False) +print(result["status"]) +``` + +```result +success ``` Supported series types: `"line"` (default), `"bar"`, `"scatter"`. Each entry accepts optional `"color"` (hex string) and `"label"` (legend text). @@ -134,22 +199,27 @@ Supported series types: `"line"` (default), `"bar"`, `"scatter"`. Each entry acc You can overlay buy/sell arrows on the price chart by passing a `trades` array aligned with your index. Values: `1` (buy), `-1` (sell), `0` (no trade). - -```python +RHIZA_SKIP -import numpy as np - +```python trades = np.zeros(len(index), dtype=int) trades[42] = 1 # buy at bar 42 trades[100] = -1 # sell at bar 100 -plot( - index, - open=open_, - high=high, - low=low, - close=close, - trades=trades, -) +with contextlib.redirect_stdout(io.StringIO()): + result = plot( + index, + open=open_, + high=high, + low=low, + close=close, + trades=trades, + open_browser=False, + block=False, + ) +print(result["status"]) +``` + +```result +success ``` Buy signals render as green upward arrows below the low; sell signals render as red downward arrows above the high. @@ -170,30 +240,31 @@ The public API is intentionally small and focused. All functions are available f ### `plot` - -```python +RHIZA_SKIP -from typing import Dict, Any, Optional, Union +Every parameter, spelled out (array arguments accept a `np.ndarray`, `pd.Series` or `list`): -import numpy as np -import pandas as pd -from pycharting import plot - -ArrayLike = Union[np.ndarray, pd.Series, list] - -result: Dict[str, Any] = plot( - index: ArrayLike, - open: ArrayLike, - high: ArrayLike, - low: ArrayLike, - close: ArrayLike, - overlays: Optional[Dict[str, ArrayLike]] = None, - subplots: Optional[Dict[str, ArrayLike]] = None, - trades: Optional[ArrayLike] = None, - session_id: str = "default", - port: Optional[int] = None, - open_browser: bool = True, - server_timeout: float = 2.0, -) +```python +with contextlib.redirect_stdout(io.StringIO()): + result = plot( + index, + open=open_, + high=high, + low=low, + close=close, + overlays=None, + subplots=None, + trades=None, + session_id="default", + port=None, + open_browser=False, # default: True + server_timeout=2.0, + block=False, # default: True + ) + stop_server() +print(sorted(result)) +``` + +```result +['data_points', 'server_running', 'server_url', 'session_id', 'status', 'url'] ``` - **index**: datetime x-axis values — `pd.DatetimeIndex`, Unix timestamps in milliseconds (`np.int64`), or a numeric array. @@ -204,6 +275,8 @@ result: Dict[str, Any] = plot( - **session_id**: identifier for the data session; can be used to host multiple concurrent charts. - **port**: optional port override; if `None`, PyCharting picks an available port. - **open_browser**: if `False`, you get the URL back in `result["url"]` but the browser is not opened automatically. +- **server_timeout**: seconds to wait for a newly started server to come up before returning. +- **block**: if `True` (the default), `plot` waits until the chart page is closed and the server shuts down; pass `False` to return immediately. The returned dict includes: @@ -216,36 +289,11 @@ The returned dict includes: ### `stop_server` -```python -from pycharting import stop_server - -stop_server() -``` - -With no server running, it says so and does nothing: - -```result -ⓘ No active server to stop -``` - -Stops the active chart server if it is running. This is useful in long‑running processes and demos to clean up after you are done exploring charts. +Stops the active chart server if it is running, and says so when there is none (see [Quick start](#quick-start)). This is useful in long‑running processes and demos to clean up after you are done exploring charts. ### `get_server_status` -```python -from pycharting import get_server_status - -status = get_server_status() -print(status) -``` - -Before any chart has been plotted: - -```result -{'running': False, 'server_info': None, 'active_sessions': 0} -``` - -Returns a small dict with: +Returns a small dict (see [Quick start](#quick-start) for its value before any chart is plotted) with: - `running`: whether the server is alive, - `server_info`: host/port and other metadata if running, From fc820800331bc499376843bdac01de11b71fad2f Mon Sep 17 00:00:00 2001 From: Thomas Schmelzer Date: Wed, 30 Sep 2026 07:46:14 +0400 Subject: [PATCH 2/2] docs: write the README examples as pycon doctest sessions (#101) --- README.md | 274 +++++++++++++++++------------------ tests/test_readme_doctest.py | 29 ++++ 2 files changed, 165 insertions(+), 138 deletions(-) create mode 100644 tests/test_readme_doctest.py diff --git a/README.md b/README.md index 4d1ca4f..dbd8c26 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,9 @@ poetry install The primary API is a single `plot` function that takes OHLC arrays (plus optional overlays and subplots), starts a local server, and opens your default browser on the interactive chart. You normally import everything you need like this: -```python -from pycharting import plot, stop_server, get_server_status +```pycon +>>> from pycharting import plot, stop_server, get_server_status + ``` When you run this script, PyCharting will: @@ -73,76 +74,70 @@ When you run this script, PyCharting will: Nothing runs until the first `plot` call: -```python -status = get_server_status() -print(status) -``` - -```result +```pycon +>>> get_server_status() {'running': False, 'server_info': None, 'active_sessions': 0} + ``` and stopping a server that was never started is a harmless no-op: -```python -stop_server() -``` - -```result +```pycon +>>> stop_server() ⓘ No active server to stop + ``` +The examples in this README are [doctest](https://docs.python.org/3/library/doctest.html) sessions, run by the test suite. To run them yourself: `python -m doctest README.md`. + ## Overlays vs subplots Once you have your OHLC series, you pass additional series to `plot` in two different ways: -```python -import contextlib -import io - -import numpy as np -import pandas as pd - -# Synthetic OHLC data — substitute your own series. -n = 300 -rng = np.random.default_rng(0) -index = pd.date_range("2024-01-01", periods=n, freq="h") -close = 100 + np.cumsum(rng.normal(size=n)) -open_ = np.r_[close[0], close[:-1]] -high = np.maximum(open_, close) + rng.uniform(0, 1, n) -low = np.minimum(open_, close) - rng.uniform(0, 1, n) -price = pd.Series(close) - -overlays = { - "SMA_50": price.rolling(50).mean().to_numpy(), # rendered on top of price - "EMA_200": price.ewm(span=200).mean().to_numpy(), -} - -subplots = { - "Momentum": price.diff(14).to_numpy(), # rendered in its own panel below price - "Range": high - low, -} - -with contextlib.redirect_stdout(io.StringIO()): # plot() prints a per-run URL - result = plot( - index, - open_, - high, - low, - close, - overlays=overlays, - subplots=subplots, - open_browser=False, # just return the URL in result["url"] - block=False, # return straight away instead of waiting for the page to close - ) -print(result["status"], result["data_points"]) -``` +```pycon +>>> import numpy as np +>>> import pandas as pd + +>>> # Synthetic OHLC data — substitute your own series. +>>> n = 300 +>>> rng = np.random.default_rng(0) +>>> index = pd.date_range("2024-01-01", periods=n, freq="h") +>>> close = 100 + np.cumsum(rng.normal(size=n)) +>>> open_ = np.r_[close[0], close[:-1]] +>>> high = np.maximum(open_, close) + rng.uniform(0, 1, n) +>>> low = np.minimum(open_, close) - rng.uniform(0, 1, n) +>>> price = pd.Series(close) + +>>> overlays = { +... "SMA_50": price.rolling(50).mean().to_numpy(), # rendered on top of price +... "EMA_200": price.ewm(span=200).mean().to_numpy(), +... } +>>> subplots = { +... "Momentum": price.diff(14).to_numpy(), # rendered in its own panel below price +... "Range": high - low, +... } + +>>> result = plot( # doctest: +ELLIPSIS +... index, +... open_, +... high, +... low, +... close, +... overlays=overlays, +... subplots=subplots, +... open_browser=False, # just print the URL and return it in result["url"] +... block=False, # return straight away instead of waiting for the page to close +... ) + +✓ Chart created successfully! + URL: http://127.0.0.1:.../static/viewport-demo.html?session=default&v=... + Data points: 300 + Open the URL above in your browser to view the chart. + -```result -success 300 ``` -In a script you would normally drop the `redirect_stdout` and the last two arguments: `plot` then opens your browser, prints the chart URL and waits until you close the page. They are here so the README's examples can run unattended. +In a script you would normally drop the last two arguments: `plot` then opens your browser and waits until you close the page. They are here so the README's examples can run unattended. - **Overlays** share the same y‑axis as price and are drawn directly on the candlestick chart (moving averages, bands, signals on price). - **Subplots** are stacked independent charts below the main panel with their own y‑scales (oscillators, volume, breadth measures). @@ -151,46 +146,47 @@ In a script you would normally drop the `redirect_stdout` and the last two argum Each subplot value can be a plain array (line), a dict with options, or a list of dicts for multi-series panels: -```python -# A few indicators derived from the data above. -delta = price.diff() -gain = delta.clip(lower=0).rolling(14).mean() -loss = (-delta.clip(upper=0)).rolling(14).mean() -rsi = (100 - 100 / (1 + gain / loss)).to_numpy() -rsi_sma = pd.Series(rsi).rolling(20).mean().to_numpy() -macd_line = (price.ewm(span=12).mean() - price.ewm(span=26).mean()).to_numpy() -signal_line = pd.Series(macd_line).ewm(span=9).mean().to_numpy() -histogram = macd_line - signal_line -volume_array = rng.normal(0, 1000, n) -events_array = np.where(rng.random(n) < 0.05, close, np.nan) - -subplots = { - # Simple line (default) - "RSI": rsi, - # Bar chart — green if value ≥ 0, red if < 0, centered at y=0 - "Volume": {"data": volume_array, "type": "bar"}, - # Scatter plot - "Events": {"data": events_array, "type": "scatter", "color": "#9C27B0"}, - # Multi-series panel: two lines + histogram bars in one subplot - "MACD": [ - {"data": macd_line, "type": "line", "color": "#2196F3", "label": "MACD"}, - {"data": signal_line, "type": "line", "color": "#FF9800", "label": "Signal"}, - {"data": histogram, "type": "bar", "label": "Histogram"}, - ], - # RSI with its own moving average overlay - "RSI+SMA": [ - {"data": rsi, "type": "line", "color": "#FF9800", "label": "RSI"}, - {"data": rsi_sma, "type": "line", "color": "#2196F3", "label": "RSI SMA(20)"}, - ], -} - -with contextlib.redirect_stdout(io.StringIO()): - result = plot(index, open_, high, low, close, subplots=subplots, open_browser=False, block=False) -print(result["status"]) -``` +```pycon +>>> # A few indicators derived from the data above. +>>> delta = price.diff() +>>> gain = delta.clip(lower=0).rolling(14).mean() +>>> loss = (-delta.clip(upper=0)).rolling(14).mean() +>>> rsi = (100 - 100 / (1 + gain / loss)).to_numpy() +>>> rsi_sma = pd.Series(rsi).rolling(20).mean().to_numpy() +>>> macd_line = (price.ewm(span=12).mean() - price.ewm(span=26).mean()).to_numpy() +>>> signal_line = pd.Series(macd_line).ewm(span=9).mean().to_numpy() +>>> histogram = macd_line - signal_line +>>> volume_array = rng.normal(0, 1000, n) +>>> events_array = np.where(rng.random(n) < 0.05, close, np.nan) + +>>> subplots = { +... # Simple line (default) +... "RSI": rsi, +... # Bar chart — green if value ≥ 0, red if < 0, centered at y=0 +... "Volume": {"data": volume_array, "type": "bar"}, +... # Scatter plot +... "Events": {"data": events_array, "type": "scatter", "color": "#9C27B0"}, +... # Multi-series panel: two lines + histogram bars in one subplot +... "MACD": [ +... {"data": macd_line, "type": "line", "color": "#2196F3", "label": "MACD"}, +... {"data": signal_line, "type": "line", "color": "#FF9800", "label": "Signal"}, +... {"data": histogram, "type": "bar", "label": "Histogram"}, +... ], +... # RSI with its own moving average overlay +... "RSI+SMA": [ +... {"data": rsi, "type": "line", "color": "#FF9800", "label": "RSI"}, +... {"data": rsi_sma, "type": "line", "color": "#2196F3", "label": "RSI SMA(20)"}, +... ], +... } + +>>> result = plot(index, open_, high, low, close, subplots=subplots, open_browser=False, block=False) # doctest: +ELLIPSIS + +✓ Chart created successfully! + URL: http://127.0.0.1:.../static/viewport-demo.html?session=default&v=... + Data points: 300 + Open the URL above in your browser to view the chart. + -```result -success ``` Supported series types: `"line"` (default), `"bar"`, `"scatter"`. Each entry accepts optional `"color"` (hex string) and `"label"` (legend text). @@ -199,27 +195,28 @@ Supported series types: `"line"` (default), `"bar"`, `"scatter"`. Each entry acc You can overlay buy/sell arrows on the price chart by passing a `trades` array aligned with your index. Values: `1` (buy), `-1` (sell), `0` (no trade). -```python -trades = np.zeros(len(index), dtype=int) -trades[42] = 1 # buy at bar 42 -trades[100] = -1 # sell at bar 100 - -with contextlib.redirect_stdout(io.StringIO()): - result = plot( - index, - open=open_, - high=high, - low=low, - close=close, - trades=trades, - open_browser=False, - block=False, - ) -print(result["status"]) -``` +```pycon +>>> trades = np.zeros(len(index), dtype=int) +>>> trades[42] = 1 # buy at bar 42 +>>> trades[100] = -1 # sell at bar 100 + +>>> result = plot( # doctest: +ELLIPSIS +... index, +... open=open_, +... high=high, +... low=low, +... close=close, +... trades=trades, +... open_browser=False, +... block=False, +... ) + +✓ Chart created successfully! + URL: http://127.0.0.1:.../static/viewport-demo.html?session=default&v=... + Data points: 300 + Open the URL above in your browser to view the chart. + -```result -success ``` Buy signals render as green upward arrows below the low; sell signals render as red downward arrows above the high. @@ -242,29 +239,30 @@ The public API is intentionally small and focused. All functions are available f Every parameter, spelled out (array arguments accept a `np.ndarray`, `pd.Series` or `list`): -```python -with contextlib.redirect_stdout(io.StringIO()): - result = plot( - index, - open=open_, - high=high, - low=low, - close=close, - overlays=None, - subplots=None, - trades=None, - session_id="default", - port=None, - open_browser=False, # default: True - server_timeout=2.0, - block=False, # default: True - ) - stop_server() -print(sorted(result)) -``` - -```result +```pycon +>>> result = plot( # doctest: +ELLIPSIS +... index, +... open=open_, +... high=high, +... low=low, +... close=close, +... overlays=None, +... subplots=None, +... trades=None, +... session_id="default", +... port=None, +... open_browser=False, # default: True +... server_timeout=2.0, +... block=False, # default: True +... ) + +✓ Chart created successfully! +... +>>> sorted(result) ['data_points', 'server_running', 'server_url', 'session_id', 'status', 'url'] +>>> stop_server() +✓ Chart server stopped + ``` - **index**: datetime x-axis values — `pd.DatetimeIndex`, Unix timestamps in milliseconds (`np.int64`), or a numeric array. diff --git a/tests/test_readme_doctest.py b/tests/test_readme_doctest.py new file mode 100644 index 0000000..75e2501 --- /dev/null +++ b/tests/test_readme_doctest.py @@ -0,0 +1,29 @@ +"""Run the README's ``pycon`` examples as doctests (#101). + +The README documents the API as interactive sessions, which rhiza's README check +(it executes ``python`` fences only) does not run. This test does, with the exact +command the README gives readers: ``python -m doctest README.md``. + +It runs in a fresh interpreter rather than through :func:`doctest.testfile` +in-process: the examples start a real chart server and register sessions, and +neither should leak into the pytest worker that runs the rest of the suite. +""" + +import subprocess +import sys +from pathlib import Path + +README = Path(__file__).resolve().parents[1] / "README.md" + + +def test_readme_examples_pass_as_doctests(): + """Every ``>>>`` example in README.md produces its documented output.""" + result = subprocess.run( + [sys.executable, "-m", "doctest", str(README)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=False, + ) + assert result.returncode == 0, result.stdout