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 ADDED
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Volker Lorrmann
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
+ ```
@@ -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)