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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 157 additions & 111 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -71,61 +72,121 @@ 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:

```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:

```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:

<!-- Not executed: a fragment referencing the indicator functions and series you supply. -->
```python +RHIZA_SKIP
overlays = {
"SMA_50": sma(close, 50), # rendered on top of price
"EMA_200": ema(close, 200),
}

subplots = {
"RSI_like": rsi_like_series, # rendered in its own panel below price
"Stoch_like": stoch_series,
}

plot(
index,
open_,
high,
low,
close,
overlays=overlays,
subplots=subplots,
)
```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
... )
<BLANKLINE>
✓ 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.
<BLANKLINE>

```

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

### Subplot series types

Each subplot value can be a plain array (line), a dict with options, or a list of dicts for multi-series panels:

<!-- Not executed: a fragment referencing the indicator arrays you supply. -->
```python +RHIZA_SKIP
subplots = {
# Simple line (default)
"RSI": rsi_array,
# 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)"},
],
}
```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
<BLANKLINE>
✓ 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.
<BLANKLINE>

```

Supported series types: `"line"` (default), `"bar"`, `"scatter"`. Each entry accepts optional `"color"` (hex string) and `"label"` (legend text).
Expand All @@ -134,22 +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).

<!-- Not executed: references your own index/OHLC series, and plot() starts a server and opens a browser. -->
```python +RHIZA_SKIP
import numpy as np

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,
)
```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,
... )
<BLANKLINE>
✓ 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.
<BLANKLINE>

```

Buy signals render as green upward arrows below the low; sell signals render as red downward arrows above the high.
Expand All @@ -170,30 +237,32 @@ The public API is intentionally small and focused. All functions are available f

### `plot`

<!-- Not executed: an annotated signature for reference, not valid Python at a call site. -->
```python +RHIZA_SKIP
from typing import Dict, Any, Optional, Union

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,
)
Every parameter, spelled out (array arguments accept a `np.ndarray`, `pd.Series` or `list`):

```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
... )
<BLANKLINE>
✓ 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.
Expand All @@ -204,6 +273,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:

Expand All @@ -216,36 +287,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,
Expand Down
29 changes: 29 additions & 0 deletions tests/test_readme_doctest.py
Original file line number Diff line number Diff line change
@@ -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
Loading