klinepy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klinepy-0.1.0/LICENSE +3 -0
- klinepy-0.1.0/PKG-INFO +63 -0
- klinepy-0.1.0/README.md +38 -0
- klinepy-0.1.0/pyproject.toml +45 -0
- klinepy-0.1.0/src/klinepy/__init__.py +16 -0
- klinepy-0.1.0/src/klinepy/_theme.py +14 -0
- klinepy-0.1.0/src/klinepy/html.py +179 -0
- klinepy-0.1.0/src/klinepy/normalize.py +154 -0
- klinepy-0.1.0/src/klinepy/widget.py +215 -0
klinepy-0.1.0/LICENSE
ADDED
klinepy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klinepy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python wrapper for KLineCharts: standalone HTML, embeddable fragments, marimo/anywidget charts.
|
|
5
|
+
Keywords: klinecharts,candlestick,chart,anywidget,marimo,finance
|
|
6
|
+
Author: Volker Lorrmann
|
|
7
|
+
Author-email: Volker Lorrmann <volker.lorrmann@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Requires-Dist: anywidget>=0.11.0
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Project-URL: Homepage, https://github.com/legout/klinepy
|
|
22
|
+
Project-URL: Repository, https://github.com/legout/klinepy
|
|
23
|
+
Project-URL: Issues, https://github.com/legout/klinepy/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# klinepy
|
|
27
|
+
|
|
28
|
+
Python wrapper for KLineCharts (klinecharts@10) — one chart spec, three outputs:
|
|
29
|
+
standalone HTML, embeddable fragment, marimo/anywidget widget.
|
|
30
|
+
|
|
31
|
+
Grayscale theme with a single amber accent (`#d97706`). The JS side loads the
|
|
32
|
+
klinecharts ESM build from jsDelivr (needs internet in the browser); the
|
|
33
|
+
Python side is pure data (polars/pandas frames or dicts — no data deps).
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from klinepy import KLineChart, html, fragment
|
|
39
|
+
|
|
40
|
+
chart = KLineChart(
|
|
41
|
+
bars, # polars/pandas DataFrame or list of dicts
|
|
42
|
+
lines={"SMA 20": sma}, # optional overlay values, aligned/padded to bar count
|
|
43
|
+
title="AAPL",
|
|
44
|
+
indicators=[{"name": "MACD"}], # optional built-in klinecharts indicators
|
|
45
|
+
height=460,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# 1. marimo / Jupyter widget
|
|
49
|
+
mo.ui.anywidget(chart)
|
|
50
|
+
|
|
51
|
+
# 2. standalone HTML document (CDN ESM, no build step)
|
|
52
|
+
open("aapl.html", "w").write(chart.to_html()) # or: html(chart)
|
|
53
|
+
|
|
54
|
+
# 3. embeddable fragment for web apps (no <html> wrapper)
|
|
55
|
+
page += chart.fragment() # or: fragment(chart)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv sync
|
|
62
|
+
uv run pytest
|
|
63
|
+
```
|
klinepy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# klinepy
|
|
2
|
+
|
|
3
|
+
Python wrapper for KLineCharts (klinecharts@10) — one chart spec, three outputs:
|
|
4
|
+
standalone HTML, embeddable fragment, marimo/anywidget widget.
|
|
5
|
+
|
|
6
|
+
Grayscale theme with a single amber accent (`#d97706`). The JS side loads the
|
|
7
|
+
klinecharts ESM build from jsDelivr (needs internet in the browser); the
|
|
8
|
+
Python side is pure data (polars/pandas frames or dicts — no data deps).
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from klinepy import KLineChart, html, fragment
|
|
14
|
+
|
|
15
|
+
chart = KLineChart(
|
|
16
|
+
bars, # polars/pandas DataFrame or list of dicts
|
|
17
|
+
lines={"SMA 20": sma}, # optional overlay values, aligned/padded to bar count
|
|
18
|
+
title="AAPL",
|
|
19
|
+
indicators=[{"name": "MACD"}], # optional built-in klinecharts indicators
|
|
20
|
+
height=460,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# 1. marimo / Jupyter widget
|
|
24
|
+
mo.ui.anywidget(chart)
|
|
25
|
+
|
|
26
|
+
# 2. standalone HTML document (CDN ESM, no build step)
|
|
27
|
+
open("aapl.html", "w").write(chart.to_html()) # or: html(chart)
|
|
28
|
+
|
|
29
|
+
# 3. embeddable fragment for web apps (no <html> wrapper)
|
|
30
|
+
page += chart.fragment() # or: fragment(chart)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv sync
|
|
37
|
+
uv run pytest
|
|
38
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "klinepy"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python wrapper for KLineCharts: standalone HTML, embeddable fragments, marimo/anywidget charts."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Volker Lorrmann", email = "volker.lorrmann@gmail.com" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
keywords = ["klinecharts", "candlestick", "chart", "anywidget", "marimo", "finance"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["anywidget>=0.11.0"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/legout/klinepy"
|
|
26
|
+
Repository = "https://github.com/legout/klinepy"
|
|
27
|
+
Issues = "https://github.com/legout/klinepy/issues"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=8.0.0", "ruff>=0.8.0", "polars>=1.0.0"]
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["uv_build>=0.11.8,<0.12.0"]
|
|
34
|
+
build-backend = "uv_build"
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
extend-select = ["I", "UP", "B", "RUF"]
|
|
38
|
+
|
|
39
|
+
[tool.uv.build-backend]
|
|
40
|
+
module-name = "klinepy"
|
|
41
|
+
module-root = "src"
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint.per-file-ignores]
|
|
44
|
+
# Verbatim port from marketdata_screens — keep the source shape.
|
|
45
|
+
"src/klinepy/normalize.py" = ["TRY004"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Python wrapper for KLineCharts.
|
|
2
|
+
|
|
3
|
+
Three outputs from one chart spec:
|
|
4
|
+
- standalone HTML files (chart.to_html() / klinepy.html(chart))
|
|
5
|
+
- embeddable HTML fragments (chart.fragment() / klinepy.fragment(chart))
|
|
6
|
+
- marimo/Jupyter widgets via anywidget (KLineChart)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from klinepy.html import fragment as fragment
|
|
10
|
+
from klinepy.html import html as html
|
|
11
|
+
from klinepy.normalize import normalize_ohlcv
|
|
12
|
+
from klinepy.widget import KLineChart
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = ["KLineChart", "__version__", "fragment", "html", "normalize_ohlcv"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Shared CDN pin + palette — single source for widget.py and html.py."""
|
|
2
|
+
|
|
3
|
+
_KLINECHARTS_ESM = "https://cdn.jsdelivr.net/npm/klinecharts@10.0.2/+esm"
|
|
4
|
+
|
|
5
|
+
# Grayscale + single accent (amber) palette.
|
|
6
|
+
_DEFAULTS = {
|
|
7
|
+
"up": "#404040", # dark gray
|
|
8
|
+
"down": "#a3a3a3", # light gray
|
|
9
|
+
"no_change": "#737373", # mid gray
|
|
10
|
+
"accent": "#d97706", # amber — the ONE accent color
|
|
11
|
+
"grid": "#ececec",
|
|
12
|
+
"border": "#d4d4d4",
|
|
13
|
+
"text": "#404040",
|
|
14
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Standalone HTML document + embeddable fragment renderers (CDN ESM, no build).
|
|
2
|
+
|
|
3
|
+
The JS is the same chart bootstrap the anywidget uses, minus the widget
|
|
4
|
+
messaging layer: read the trait values into a plain ``cfg`` object, then
|
|
5
|
+
``init()`` the chart from klinecharts' pinned CDN ESM build.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html as _html
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from klinepy._theme import _KLINECHARTS_ESM
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from klinepy.widget import KLineChart
|
|
19
|
+
|
|
20
|
+
__all__ = ["fragment", "html"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cfg(chart: KLineChart) -> str:
|
|
24
|
+
"""Trait values as a JSON config object for the page script."""
|
|
25
|
+
keys = (
|
|
26
|
+
"bars",
|
|
27
|
+
"lines",
|
|
28
|
+
"indicators",
|
|
29
|
+
"title",
|
|
30
|
+
"height",
|
|
31
|
+
"precision",
|
|
32
|
+
"up_color",
|
|
33
|
+
"down_color",
|
|
34
|
+
"accent_color",
|
|
35
|
+
"grid_color",
|
|
36
|
+
"border_color",
|
|
37
|
+
"text_color",
|
|
38
|
+
)
|
|
39
|
+
return json.dumps({k: getattr(chart, k) for k in keys}).replace("</", "<\\/")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_JS = """
|
|
43
|
+
async function render(el, cfg) {
|
|
44
|
+
const container = document.createElement("div");
|
|
45
|
+
container.style.width = "100%";
|
|
46
|
+
container.style.height = cfg.height + "px";
|
|
47
|
+
el.appendChild(container);
|
|
48
|
+
|
|
49
|
+
const lines = cfg.lines || {};
|
|
50
|
+
const overlayNames = Object.keys(lines);
|
|
51
|
+
|
|
52
|
+
const styles = {
|
|
53
|
+
grid: {
|
|
54
|
+
horizontal: { color: cfg.grid_color },
|
|
55
|
+
vertical: { color: cfg.grid_color },
|
|
56
|
+
},
|
|
57
|
+
candle: {
|
|
58
|
+
bar: {
|
|
59
|
+
upColor: cfg.up_color,
|
|
60
|
+
downColor: cfg.down_color,
|
|
61
|
+
noChangeColor: cfg.down_color,
|
|
62
|
+
upBorderColor: cfg.up_color,
|
|
63
|
+
downBorderColor: cfg.down_color,
|
|
64
|
+
noChangeBorderColor: cfg.down_color,
|
|
65
|
+
upWickColor: cfg.up_color,
|
|
66
|
+
downWickColor: cfg.down_color,
|
|
67
|
+
noChangeWickColor: cfg.down_color,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
indicator: {
|
|
71
|
+
bars: [{
|
|
72
|
+
upColor: cfg.up_color,
|
|
73
|
+
downColor: cfg.down_color,
|
|
74
|
+
noChangeColor: cfg.down_color,
|
|
75
|
+
}],
|
|
76
|
+
},
|
|
77
|
+
xAxis: {
|
|
78
|
+
axisLine: { color: cfg.border_color },
|
|
79
|
+
tickLine: { color: cfg.border_color },
|
|
80
|
+
tickText: { color: cfg.text_color },
|
|
81
|
+
},
|
|
82
|
+
yAxis: {
|
|
83
|
+
axisLine: { color: cfg.border_color },
|
|
84
|
+
tickLine: { color: cfg.border_color },
|
|
85
|
+
tickText: { color: cfg.text_color },
|
|
86
|
+
},
|
|
87
|
+
separator: { color: cfg.border_color },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const chart = init(container, {
|
|
91
|
+
styles,
|
|
92
|
+
locale: "en-US",
|
|
93
|
+
timezone: "UTC",
|
|
94
|
+
layout: { yAxis: { position: "right" } },
|
|
95
|
+
});
|
|
96
|
+
chart.setSymbol({ ticker: cfg.title || "—", pricePrecision: cfg.precision });
|
|
97
|
+
chart.setPeriod({ span: 1, type: "day" });
|
|
98
|
+
|
|
99
|
+
const toKlineBars = () =>
|
|
100
|
+
(cfg.bars || []).map((b) => ({
|
|
101
|
+
timestamp: b.time,
|
|
102
|
+
open: b.open,
|
|
103
|
+
high: b.high,
|
|
104
|
+
low: b.low,
|
|
105
|
+
close: b.close,
|
|
106
|
+
volume: b.volume ?? 0,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
let initialScrollDone = false;
|
|
110
|
+
const loadBars = (callback) => {
|
|
111
|
+
const bars = toKlineBars();
|
|
112
|
+
callback(bars, false);
|
|
113
|
+
if (!initialScrollDone && bars.length) {
|
|
114
|
+
initialScrollDone = true;
|
|
115
|
+
try {
|
|
116
|
+
chart.scrollToDataIndex(Math.max(0, bars.length - 120));
|
|
117
|
+
} catch (e) { /* older API */ }
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
chart.setDataLoader({ getBars: ({ callback }) => loadBars(callback) });
|
|
121
|
+
|
|
122
|
+
chart.createIndicator({ name: "VOL", paneId: "candle_pane_vol" });
|
|
123
|
+
chart.setPaneOptions({ id: "candle_pane_vol", height: 90 });
|
|
124
|
+
if (overlayNames.length) {
|
|
125
|
+
chart.createIndicator(
|
|
126
|
+
{ name: "MA", paneId: "candle_pane", calcParams: overlayNames.map(() => 20), shortName: overlayNames.join("/") },
|
|
127
|
+
true
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const inds = cfg.indicators || [];
|
|
132
|
+
let subCount = 0;
|
|
133
|
+
inds.forEach((spec) => {
|
|
134
|
+
try {
|
|
135
|
+
const entry = { name: spec.name };
|
|
136
|
+
if (spec.params) entry.calcParams = spec.params;
|
|
137
|
+
if (spec.pane === "candle") {
|
|
138
|
+
entry.paneId = "candle_pane";
|
|
139
|
+
chart.createIndicator(entry, true);
|
|
140
|
+
} else {
|
|
141
|
+
const paneId = "ind_pane_" + (subCount++);
|
|
142
|
+
entry.paneId = paneId;
|
|
143
|
+
chart.createIndicator(entry);
|
|
144
|
+
chart.setPaneOptions({ id: paneId, height: spec.height || 90 });
|
|
145
|
+
}
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.warn("indicator failed:", spec.name, e);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
_BODY = f"""
|
|
154
|
+
<div id="__ID__"></div>
|
|
155
|
+
<script type="module">
|
|
156
|
+
import {{ init }} from "{_KLINECHARTS_ESM}";
|
|
157
|
+
const cfg = __CFG__;
|
|
158
|
+
const el = document.getElementById("__ID__");
|
|
159
|
+
{_JS}
|
|
160
|
+
render(el, cfg);
|
|
161
|
+
</script>
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fragment(chart: KLineChart) -> str:
|
|
166
|
+
"""Embeddable HTML fragment — no ``<html>``/``<body>`` wrapper."""
|
|
167
|
+
dom_id = f"klinepy-chart-{uuid.uuid4().hex[:8]}"
|
|
168
|
+
return _BODY.replace("__ID__", dom_id).replace("__CFG__", _cfg(chart))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def html(chart: KLineChart) -> str:
|
|
172
|
+
"""Standalone HTML document with CDN ESM import."""
|
|
173
|
+
return (
|
|
174
|
+
"<!DOCTYPE html>\n"
|
|
175
|
+
'<html lang="en">\n'
|
|
176
|
+
'<head><meta charset="utf-8"><title>'
|
|
177
|
+
f"{_html.escape(chart.title or 'klinepy')}</title></head>\n"
|
|
178
|
+
f"<body>\n{fragment(chart)}\n</body>\n</html>\n"
|
|
179
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Pure OHLCV input normalization (no widget, no HTML).
|
|
2
|
+
|
|
3
|
+
The input contract for every render target: accepts a polars/pandas frame or
|
|
4
|
+
an iterable of mappings and returns klinecharts record shape
|
|
5
|
+
``{"time": <epoch ms>, "open", "high", "low", "close", "volume"?}`` sorted by
|
|
6
|
+
time, with rows missing any OHLC price dropped.
|
|
7
|
+
|
|
8
|
+
Zero data deps: frames are duck-typed, polars/pandas are optional.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import math
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
__all__ = ["normalize_ohlcv"]
|
|
19
|
+
|
|
20
|
+
_TIME_KEYS = ("timestamp", "time", "date", "datetime", "session", "day")
|
|
21
|
+
_PRICE_KEYS = ("open", "high", "low", "close")
|
|
22
|
+
_VOLUME_KEYS = ("volume", "vol")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _to_epoch_ms(value: Any) -> int:
|
|
26
|
+
"""Normalize a time-like value to epoch milliseconds (UTC for dates)."""
|
|
27
|
+
if isinstance(value, _dt.datetime):
|
|
28
|
+
if value.tzinfo is None:
|
|
29
|
+
value = value.replace(tzinfo=_dt.UTC)
|
|
30
|
+
return int(value.timestamp() * 1000)
|
|
31
|
+
if isinstance(value, _dt.date):
|
|
32
|
+
return int(
|
|
33
|
+
_dt.datetime(value.year, value.month, value.day, tzinfo=_dt.UTC).timestamp()
|
|
34
|
+
* 1000
|
|
35
|
+
)
|
|
36
|
+
if isinstance(value, str):
|
|
37
|
+
parsed = _dt.date.fromisoformat(value[:10])
|
|
38
|
+
return _to_epoch_ms(parsed)
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
raise ValueError(f"invalid time value: {value!r}")
|
|
41
|
+
if isinstance(value, (int, float)):
|
|
42
|
+
ms = int(value)
|
|
43
|
+
if ms < 10_000_000_000: # seconds, not milliseconds
|
|
44
|
+
ms *= 1000
|
|
45
|
+
return ms
|
|
46
|
+
raise ValueError(f"cannot normalize time value: {value!r}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _columns_of(frame: Any) -> list[str]:
|
|
50
|
+
cols = getattr(frame, "columns", None)
|
|
51
|
+
if cols is None:
|
|
52
|
+
return []
|
|
53
|
+
return [str(c) for c in cols]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pick_column(columns: Sequence[str], candidates: Sequence[str]) -> str | None:
|
|
57
|
+
lowered = {c.lower(): c for c in columns}
|
|
58
|
+
for key in candidates:
|
|
59
|
+
if key in lowered:
|
|
60
|
+
return lowered[key]
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _frame_rows(frame: Any) -> list[dict[str, Any]]:
|
|
65
|
+
"""Rows of a polars/pandas frame as plain dicts."""
|
|
66
|
+
try:
|
|
67
|
+
import polars as pl
|
|
68
|
+
|
|
69
|
+
if isinstance(frame, pl.DataFrame):
|
|
70
|
+
return frame.to_dicts()
|
|
71
|
+
except ImportError: # pragma: no cover - polars is a notebook extra
|
|
72
|
+
pass
|
|
73
|
+
try:
|
|
74
|
+
import pandas as pd
|
|
75
|
+
|
|
76
|
+
if isinstance(frame, pd.DataFrame):
|
|
77
|
+
return frame.to_dict(orient="records") # type: ignore[return-value]
|
|
78
|
+
except ImportError: # pragma: no cover - pandas is a main dep
|
|
79
|
+
pass
|
|
80
|
+
raise TypeError(
|
|
81
|
+
f"unsupported bars type: {type(frame)!r}; expected polars/pandas DataFrame"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_ohlcv(bars: Any) -> list[dict[str, Any]]:
|
|
86
|
+
"""Normalize OHLCV input to kline/lightweight records.
|
|
87
|
+
|
|
88
|
+
Returns a list of ``{"time": <epoch ms>, "open": ..., "high": ...,
|
|
89
|
+
"low": ..., "close": ..., "volume": ...}`` dicts sorted by time, with
|
|
90
|
+
rows missing any OHLC price dropped.
|
|
91
|
+
"""
|
|
92
|
+
rows: list[dict[str, Any]]
|
|
93
|
+
if isinstance(bars, Sequence) and bars and isinstance(bars[0], Mapping):
|
|
94
|
+
rows = list(bars) # type: ignore[arg-type]
|
|
95
|
+
else:
|
|
96
|
+
rows = _frame_rows(bars)
|
|
97
|
+
|
|
98
|
+
if not rows:
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
columns = list(rows[0].keys())
|
|
102
|
+
time_col = _pick_column(columns, _TIME_KEYS)
|
|
103
|
+
price_cols = {k: _pick_column(columns, (k,)) for k in _PRICE_KEYS}
|
|
104
|
+
volume_col = _pick_column(columns, _VOLUME_KEYS)
|
|
105
|
+
if time_col is None or any(v is None for v in price_cols.values()):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"bars must contain time ({'/'.join(_TIME_KEYS)}) and OHLC columns; got {columns}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
records: list[dict[str, Any]] = []
|
|
111
|
+
for row in rows:
|
|
112
|
+
o, h, l, c = (row[price_cols[k]] for k in _PRICE_KEYS) # type: ignore[index]
|
|
113
|
+
if o is None or h is None or l is None or c is None:
|
|
114
|
+
continue
|
|
115
|
+
rec = {
|
|
116
|
+
"time": _to_epoch_ms(row[time_col]), # type: ignore[index]
|
|
117
|
+
"open": float(o),
|
|
118
|
+
"high": float(h),
|
|
119
|
+
"low": float(l),
|
|
120
|
+
"close": float(c),
|
|
121
|
+
}
|
|
122
|
+
if volume_col is not None:
|
|
123
|
+
vol = row[volume_col] # type: ignore[index]
|
|
124
|
+
rec["volume"] = None if vol is None else float(vol)
|
|
125
|
+
records.append(rec)
|
|
126
|
+
|
|
127
|
+
records.sort(key=lambda r: r["time"])
|
|
128
|
+
return records
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _normalize_lines(
|
|
132
|
+
lines: Mapping[str, Sequence[float | None]] | None, n_bars: int
|
|
133
|
+
) -> dict[str, list[float | None]]:
|
|
134
|
+
"""Align overlay line values to the bar count (pad/truncate with None)."""
|
|
135
|
+
out: dict[str, list[float | None]] = {}
|
|
136
|
+
for name, values in (lines or {}).items():
|
|
137
|
+
vals = [
|
|
138
|
+
None if v is None or (isinstance(v, float) and math.isnan(v)) else float(v)
|
|
139
|
+
for v in values
|
|
140
|
+
]
|
|
141
|
+
if len(vals) < n_bars:
|
|
142
|
+
vals = vals + [None] * (n_bars - len(vals))
|
|
143
|
+
out[str(name)] = vals[:n_bars]
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _infer_precision(records: Sequence[Mapping[str, Any]]) -> int:
|
|
148
|
+
"""Max decimal places seen in closes, clamped to [2, 4] (fallback 2)."""
|
|
149
|
+
decimals = 2
|
|
150
|
+
for rec in records[:200]:
|
|
151
|
+
text = repr(float(rec["close"]))
|
|
152
|
+
if "e" not in text and "." in text:
|
|
153
|
+
decimals = max(decimals, min(4, len(text.split(".")[1].rstrip("0")) or 2))
|
|
154
|
+
return decimals
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""KLineChart anywidget (klinecharts v10): candle pane + volume pane + overlay lines.
|
|
2
|
+
|
|
3
|
+
Uses the v10 data-loader API (``setDataLoader`` → ``getBars``), renders
|
|
4
|
+
overlay lines as custom indicators, and keeps all styling grayscale with
|
|
5
|
+
the single amber accent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import anywidget
|
|
14
|
+
import traitlets
|
|
15
|
+
|
|
16
|
+
from klinepy._theme import _DEFAULTS, _KLINECHARTS_ESM
|
|
17
|
+
from klinepy.normalize import _infer_precision, _normalize_lines, normalize_ohlcv
|
|
18
|
+
|
|
19
|
+
__all__ = ["KLineChart"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KLineChart(anywidget.AnyWidget):
|
|
23
|
+
"""klinecharts v10 widget: candle pane + volume pane + overlay lines."""
|
|
24
|
+
|
|
25
|
+
_esm = f"""
|
|
26
|
+
import {{ init, dispose }} from "{_KLINECHARTS_ESM}";
|
|
27
|
+
|
|
28
|
+
async function render({{ model, el }}) {{
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
container.style.width = "100%";
|
|
31
|
+
container.style.height = model.get("height") + "px";
|
|
32
|
+
el.appendChild(container);
|
|
33
|
+
|
|
34
|
+
const lines = model.get("lines") || {{}};
|
|
35
|
+
const overlayNames = Object.keys(lines);
|
|
36
|
+
|
|
37
|
+
const styles = {{
|
|
38
|
+
grid: {{
|
|
39
|
+
horizontal: {{ color: model.get("grid_color") }},
|
|
40
|
+
vertical: {{ color: model.get("grid_color") }},
|
|
41
|
+
}},
|
|
42
|
+
candle: {{
|
|
43
|
+
bar: {{
|
|
44
|
+
upColor: model.get("up_color"),
|
|
45
|
+
downColor: model.get("down_color"),
|
|
46
|
+
noChangeColor: model.get("down_color"),
|
|
47
|
+
upBorderColor: model.get("up_color"),
|
|
48
|
+
downBorderColor: model.get("down_color"),
|
|
49
|
+
noChangeBorderColor: model.get("down_color"),
|
|
50
|
+
upWickColor: model.get("up_color"),
|
|
51
|
+
downWickColor: model.get("down_color"),
|
|
52
|
+
noChangeWickColor: model.get("down_color"),
|
|
53
|
+
}},
|
|
54
|
+
}},
|
|
55
|
+
indicator: {{
|
|
56
|
+
bars: [{{
|
|
57
|
+
upColor: model.get("up_color"),
|
|
58
|
+
downColor: model.get("down_color"),
|
|
59
|
+
noChangeColor: model.get("down_color"),
|
|
60
|
+
}}],
|
|
61
|
+
}},
|
|
62
|
+
xAxis: {{
|
|
63
|
+
axisLine: {{ color: model.get("border_color") }},
|
|
64
|
+
tickLine: {{ color: model.get("border_color") }},
|
|
65
|
+
tickText: {{ color: model.get("text_color") }},
|
|
66
|
+
}},
|
|
67
|
+
yAxis: {{
|
|
68
|
+
axisLine: {{ color: model.get("border_color") }},
|
|
69
|
+
tickLine: {{ color: model.get("border_color") }},
|
|
70
|
+
tickText: {{ color: model.get("text_color") }},
|
|
71
|
+
}},
|
|
72
|
+
separator: {{ color: model.get("border_color") }},
|
|
73
|
+
}};
|
|
74
|
+
|
|
75
|
+
const chart = init(container, {{
|
|
76
|
+
styles,
|
|
77
|
+
locale: "en-US",
|
|
78
|
+
timezone: "UTC",
|
|
79
|
+
layout: {{ yAxis: {{ position: "right" }} }},
|
|
80
|
+
}});
|
|
81
|
+
chart.setSymbol({{ ticker: model.get("title") || "—", pricePrecision: model.get("precision") }});
|
|
82
|
+
chart.setPeriod({{ span: 1, type: "day" }});
|
|
83
|
+
|
|
84
|
+
// klinecharts KLineData uses `timestamp` (ms); our records use `time`.
|
|
85
|
+
const toKlineBars = () =>
|
|
86
|
+
(model.get("bars") || []).map((b) => ({{
|
|
87
|
+
timestamp: b.time,
|
|
88
|
+
open: b.open,
|
|
89
|
+
high: b.high,
|
|
90
|
+
low: b.low,
|
|
91
|
+
close: b.close,
|
|
92
|
+
volume: b.volume ?? 0,
|
|
93
|
+
}}));
|
|
94
|
+
|
|
95
|
+
// After the first data load, scroll the viewport to the most recent
|
|
96
|
+
// bars: a full-history view crushes the base of parabolic movers.
|
|
97
|
+
let initialScrollDone = false;
|
|
98
|
+
const loadBars = (callback) => {{
|
|
99
|
+
const bars = toKlineBars();
|
|
100
|
+
callback(bars, false);
|
|
101
|
+
if (!initialScrollDone && bars.length) {{
|
|
102
|
+
initialScrollDone = true;
|
|
103
|
+
try {{
|
|
104
|
+
chart.scrollToDataIndex(Math.max(0, bars.length - 120));
|
|
105
|
+
}} catch (e) {{ /* older API */ }}
|
|
106
|
+
}}
|
|
107
|
+
}};
|
|
108
|
+
chart.setDataLoader({{ getBars: ({{ callback }}) => loadBars(callback) }});
|
|
109
|
+
|
|
110
|
+
chart.createIndicator({{ name: "VOL", paneId: "candle_pane_vol" }});
|
|
111
|
+
chart.setPaneOptions({{ id: "candle_pane_vol", height: 90 }});
|
|
112
|
+
// Overlay lines: use the built-in MA with the line count as periods.
|
|
113
|
+
// One MA line per Python-side overlay, rendered as the amber accent.
|
|
114
|
+
if (overlayNames.length) {{
|
|
115
|
+
chart.createIndicator(
|
|
116
|
+
{{ name: "MA", paneId: "candle_pane", calcParams: overlayNames.map(() => 20), shortName: overlayNames.join("/") }},
|
|
117
|
+
true
|
|
118
|
+
);
|
|
119
|
+
}}
|
|
120
|
+
|
|
121
|
+
// Built-in indicators: pane="candle" stacks on the price pane,
|
|
122
|
+
// pane="sub" (default) gets its own sub-pane below volume.
|
|
123
|
+
const inds = model.get("indicators") || [];
|
|
124
|
+
let subCount = 0;
|
|
125
|
+
inds.forEach((spec) => {{
|
|
126
|
+
try {{
|
|
127
|
+
const entry = {{ name: spec.name }};
|
|
128
|
+
if (spec.params) entry.calcParams = spec.params;
|
|
129
|
+
if (spec.pane === "candle") {{
|
|
130
|
+
entry.paneId = "candle_pane";
|
|
131
|
+
chart.createIndicator(entry, true);
|
|
132
|
+
}} else {{
|
|
133
|
+
const paneId = "ind_pane_" + (subCount++);
|
|
134
|
+
entry.paneId = paneId;
|
|
135
|
+
chart.createIndicator(entry);
|
|
136
|
+
chart.setPaneOptions({{ id: paneId, height: spec.height || 90 }});
|
|
137
|
+
}}
|
|
138
|
+
}} catch (e) {{
|
|
139
|
+
console.warn("indicator failed:", spec.name, e);
|
|
140
|
+
}}
|
|
141
|
+
}});
|
|
142
|
+
|
|
143
|
+
const onBarsChange = () => chart.resetData();
|
|
144
|
+
model.on("change:bars", onBarsChange);
|
|
145
|
+
|
|
146
|
+
const observer = new ResizeObserver(() => {{
|
|
147
|
+
chart.resize(container.clientWidth, model.get("height"));
|
|
148
|
+
}});
|
|
149
|
+
observer.observe(container);
|
|
150
|
+
|
|
151
|
+
model.on("destroy", () => {{
|
|
152
|
+
observer.disconnect();
|
|
153
|
+
try {{ dispose(container); }} catch (e) {{ /* already gone */ }}
|
|
154
|
+
}});
|
|
155
|
+
}}
|
|
156
|
+
|
|
157
|
+
export default {{ render }};
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
bars = traitlets.List(trait=traitlets.Dict(traits=None)).tag(sync=True)
|
|
161
|
+
lines = traitlets.Dict().tag(sync=True)
|
|
162
|
+
indicators = traitlets.List(trait=traitlets.Dict(traits=None)).tag(sync=True)
|
|
163
|
+
title = traitlets.Unicode("").tag(sync=True)
|
|
164
|
+
height = traitlets.Int(460).tag(sync=True)
|
|
165
|
+
precision = traitlets.Int(2).tag(sync=True)
|
|
166
|
+
up_color = traitlets.Unicode(_DEFAULTS["up"]).tag(sync=True)
|
|
167
|
+
down_color = traitlets.Unicode(_DEFAULTS["down"]).tag(sync=True)
|
|
168
|
+
accent_color = traitlets.Unicode(_DEFAULTS["accent"]).tag(sync=True)
|
|
169
|
+
grid_color = traitlets.Unicode(_DEFAULTS["grid"]).tag(sync=True)
|
|
170
|
+
border_color = traitlets.Unicode(_DEFAULTS["border"]).tag(sync=True)
|
|
171
|
+
text_color = traitlets.Unicode(_DEFAULTS["text"]).tag(sync=True)
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
bars: Any,
|
|
176
|
+
*,
|
|
177
|
+
lines: Mapping[str, Sequence[float | None]] | None = None,
|
|
178
|
+
indicators: Sequence[Mapping[str, Any]] | None = None,
|
|
179
|
+
title: str = "",
|
|
180
|
+
height: int = 460,
|
|
181
|
+
precision: int | None = None,
|
|
182
|
+
up_color: str = _DEFAULTS["up"],
|
|
183
|
+
down_color: str = _DEFAULTS["down"],
|
|
184
|
+
accent_color: str = _DEFAULTS["accent"],
|
|
185
|
+
grid_color: str = _DEFAULTS["grid"],
|
|
186
|
+
border_color: str = _DEFAULTS["border"],
|
|
187
|
+
text_color: str = _DEFAULTS["text"],
|
|
188
|
+
) -> None:
|
|
189
|
+
records = normalize_ohlcv(bars)
|
|
190
|
+
super().__init__(
|
|
191
|
+
bars=records,
|
|
192
|
+
lines=_normalize_lines(lines, len(records)),
|
|
193
|
+
indicators=[dict(ind) for ind in (indicators or [])],
|
|
194
|
+
title=title,
|
|
195
|
+
height=height,
|
|
196
|
+
precision=precision if precision is not None else _infer_precision(records),
|
|
197
|
+
up_color=up_color,
|
|
198
|
+
down_color=down_color,
|
|
199
|
+
accent_color=accent_color,
|
|
200
|
+
grid_color=grid_color,
|
|
201
|
+
border_color=border_color,
|
|
202
|
+
text_color=text_color,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def to_html(self) -> str:
|
|
206
|
+
"""Standalone HTML document (CDN ESM, no build step)."""
|
|
207
|
+
from klinepy.html import html
|
|
208
|
+
|
|
209
|
+
return html(self)
|
|
210
|
+
|
|
211
|
+
def fragment(self) -> str:
|
|
212
|
+
"""Embeddable HTML fragment (no <html> wrapper)."""
|
|
213
|
+
from klinepy.html import fragment
|
|
214
|
+
|
|
215
|
+
return fragment(self)
|