Python bindings for TradingView Lightweight Charts™, aiming at near-full API coverage with a pythonic interface.
Status: development (P0/P1/P2 hardening — 17 indicators, 13 drawing tools, numeric axes, formatters, CI with JS + Python tests and docs).
This is not a re-implementation of the charting engine in Python. It embeds the official, battle-tested JavaScript renderer in a WebView (pywebview / Qt WebEngine / wx / Jupyter / Streamlit) and drives it from Python.
Python API ──► JSON / JS bridge ──► Lib.Handler (TypeScript) ──► Lightweight Charts v5
Because the pixels and interactions are produced by the upstream library, visual fidelity and performance match the JS version.
- Python layer, GUI backends and drawing plugins are derived from lightweight-charts-python (MIT).
- The charting engine is
TradingView Lightweight Charts™ (Apache-2.0),
see
NOTICE.
pylightcharts keeps every class, method and module-level name of the original
package (enforced by tests/test_lwc_compat.py), so existing code runs with a
one-line shim:
from pylightcharts.compat import install_alias
install_alias() # before any `import lightweight_charts`
import lightweight_charts # -> pylightcharts
lightweight_charts.widgets.QWebEngineView = MyPyQt6WebEngineView # patching still worksOr change the imports directly:
- from lightweight_charts.util import Events, JSEmitter
- from lightweight_charts.widgets import QtChart
+ from pylightcharts.util import Events, JSEmitter
+ from pylightcharts.widgets import QtChartInside a desktop application, QtChart embeds the chart in a QWebEngineView:
from PyQt6.QtWidgets import QApplication
from pylightcharts.qt import prepare_qt
prepare_qt('PyQt6') # pick the binding *before* QApplication
app = QApplication([])
from pylightcharts.widgets import QtChart
chart = QtChart(toolbox=True)
chart.set(df)
chart.get_webview().show()
app.exec()QtWebEngineWidgets must be imported before the QApplication exists, which is
why prepare_qt() comes first; $PYLIGHTCHARTS_QT chooses the binding without it.
Full details - multi-chart sync(), screenshots, the Chromium network noise -
are in docs/guide/qt.md.
examples/ in the repository holds ten self-contained scripts, each with its own
data: loading OHLCV, live bars, tick updates, indicator lines, styling,
callbacks, built-in indicators and panes, headless PNG rendering, custom series
and the PyQt6 window. Start with examples/1_setting_data/setting_data.py.
# build the JS bridge + vendor the engine into pylightcharts/js/
cd jslib && npm install && npm run build
# install the python package in editable mode
cd .. && pip install -e .
# ...or use the requirement files (pyproject.toml stays the source of truth)
pip install -r requirements.txt # runtime only
pip install -r requirements-dev.txt # + tests, e2e, docs, build# unit tests: assert on the JS generated by the python bridge (no browser)
python -m pytest -m "not slow"
# e2e: run the built bundle in headless Chromium and check rendered pixels
python tests/e2e/smoke.py
# headless rendering tests (Playwright or a local Chrome/Edge)
python -m pytest tests/test_headless.py
# full stack: real pywebview window + real chart engine (needs a desktop session)
python tests/e2e/full_stack.py
# performance: JSON vs binary bulk data transfer
python tests/e2e/benchmark.py 100000CI (.github/workflows/ci.yml) builds the bundle, checks the committed artifacts
are up to date, runs the unit tests on Linux/Windows/macOS, renders in headless
Chromium and verifies the wheel ships the JS assets.
cd jslib && npm run build # regenerate pylightcharts/js/*
cd .. && python -m build # sdist + wheel (JS assets are package-data)
twine upload dist/*The built pylightcharts/js/ files are committed on purpose so that
pip install works without Node. Always run npm run build before committing.
- Fire-and-forget calls go through
Window.invoke(the transport appends;undefinedso pywebview never serialises a live chart object). - Methods that return an object must use
store_as=...or, for primitives,invoke_get. Returning a live object throughinvoke_getwould explode. - Round-trips are serialised and correlated by request id; JS errors raise
pylightcharts.util.BridgeErrorinstead of returningNone. - Chart and series share method names (
apply_options); they are deliberately kept apart (series_options,data_points) to avoid MRO shadowing. - Upgrading lightweight-charts:
npm pack lightweight-charts@X, migrate the breaking changes,npm run build, then run the e2e suite.
New features should not require touching TypeScript. Any method on any live chart object can be called from Python:
chart._invoke('addSeries', 'Area', 'my area', {'lineWidth': 2}, handle)
chart.win.invoke(f'{series.id}.series', 'applyOptions', {'lineWidth': 3})Handles are either registered via Lib.register(...) or dotted window paths
(window.abcdefgh.chart). A {'$ref': handle} argument is resolved to the live
JS object, so object-taking APIs work too:
series.attach_primitive('myPrimitive') # -> attachPrimitive({"$ref": "myPrimitive"})See jslib/src/general/rpc.ts.
apply_options(**kwargs) accepts any option of the underlying API and converts
snake_case keys to camelCase automatically:
chart.apply_options(auto_size=True, localization={'price_format': {'precision': 4}})
chart.time_scale_options(right_offset=5, bar_spacing=8) # time scale
series.apply_options(line_width=3, crosshair_marker_visible=False)
get = chart.get_price_scale('left'); get.set_mode('logarithmic')Pure-pandas implementations, no extra dependencies. Overlays go on the price pane, oscillators get their own pane automatically:
chart.add_sma('close', 20)
chart.add_ema('close', 50, color='#2196F3')
chart.add_bollinger('close', 20, 2) # -> (upper, middle, lower)
chart.add_donchian(20)
chart.add_vwap()
chart.add_rsi(14) # own pane + 70/30 guide lines
chart.add_macd() # own pane -> (macd, signal, histogram)
chart.add_stochastic() # own pane -> (%K, %D)
chart.add_atr(14)
chart.add_adx(14) # own pane -> (adx, +DI, -DI)
chart.add_obv() # own pane (needs volume)
chart.add_cci(20)
chart.add_williams_r(14)
chart.add_mfi(14)
chart.add_roc(12)
chart.add_keltner(20, 2.0) # overlay -> (upper, middle, lower)Raw values: from pylightcharts import indicators; indicators.rsi(close, 14).
RSI, OBV, CCI, %R, ROC, MFI and Bollinger match TA-Lib to floating-point
precision; ATR and ADX match within ~1e-4 and ~0.1 respectively (Wilder
accumulation rounding).
Every indicator is recomputed automatically: a full re-send on chart.set(),
and a single-point update on chart.update(bar) (recomputed from a warm-up
window so recursive indicators stay numerically identical to a full run).
add_computed_series(compute, name, ...) adds your own.
Each bridge call is one webview round-trip, so wrap bursts in chart.batch():
with chart.batch():
for tick in ticks:
chart.update(tick) # flushed as a single scriptConflation is a plain series option, so the bridge already exposes it:
chart.apply_options(enable_conflation=True, precompute_conflation_on_init=True)
series.apply_options(enable_conflation=True)The tricky part of a custom series is draw(), which runs synchronously every
frame - python cannot take part in that. So the protocol splits the work:
python computes the shapes once per data update, one generic JS renderer draws
them every frame.
from pylightcharts import shapes
series = chart.add_custom_series('range bars', pane_index='new')
series.set(df, shapes=lambda row: shapes.range_bar(row['low'], row['high']))Shape builders: rect band line circle text polyline, plus presets
range_bar box_plot error_bar. Vertical anchors are prices, horizontal
offsets are in bar units (-0.4..0.4 spans a bar).
shapes.box_plot(low, q1, median, q3, high)
shapes.polyline([(0.0, 1.0), (0.4, 2.0)], fill_color='rgba(0,0,0,0.2)')
shapes.text(price, 'label')Each data item may carry value, low, high (autoscale + last value),
color, and shapes.
line = series.create_price_line(105.0, color='#00e676', title='entry')
line.apply_options(line_visible=False)
line.remove()
series.price_to_coordinate(105) # <-> series.coordinate_to_price(y)
series.data_by_index(10) # individual bar
series.data_points() # everything the chart holds
series.pop(2); series.last_value_data(); series.bars_in_logical_range(0, 50)
series.series_type(); series.get_pane_index()
series.move_to_pane(2); series.series_order(); series.set_series_order(3)
chart.scroll_to_real_time(); chart.scroll_to_position(3, animated=False)
chart.reset_time_scale(); chart.set_visible_logical_range(0, 60)
chart.get_visible_range(); chart.get_visible_logical_range()
chart.time_to_coordinate(t); chart.coordinate_to_time(x)
chart.logical_to_coordinate(l); chart.coordinate_to_logical(x)
chart.pane_height(0); chart.set_pane_height(300, 0)
chart.pane_stretch_factor(0); chart.pane_size(); chart.pane_series_count(0)
chart.version(); chart.auto_size_active(); chart.remove()
chart.set_crosshair_position(100.0, some_time); chart.clear_crosshair_position()
chart.get_price_scale('right').get_visible_range()
chart.get_price_scale('right').set_auto_scale(False)
chart.pane_price_scale(0, 'left')
chart.events.crosshair_move += handler # handler(chart, time, price)
chart.events.dblclick += handler
chart.events.click.unsubscribe()price = Chart(); volume = Chart()
price.sync(volume) # volume follows price pan/zoom
price.sync(volume, crosshairs_only=True)chart.add_pane()
chart.create_area(name='close', pane_index=1)
chart.add_series('Line', 'ma20', pane_index=1, color='#0ff')
chart.set_pane_stretch(1, 1.5)
chart.pane_count()pane_index='new' creates a fresh pane, and passing an index beyond the
current pane count creates the panes in between.
Trend line / horizontal line / vertical line / ray / box, plus:
chart.fibonacci(t1, p1, t2, p2, levels=(0, 0.382, 0.5, 0.618, 1))
chart.measure(t1, p1, t2, p2) # price %, bar count
chart.parallel_channel(t1, p1, t2, p2, offset=5) # second line 5 price units away
chart.position(t1, entry, t2, target, risk_ratio=1.5) # profit + stop zones
chart.long_position(t1, entry, t2, target) # aliases
chart.short_position(t1, entry, t2, target)
chart.andrews_pitchfork(t1, p1, t2, p2, t3, p3) # 3 points
chart.triangle(t1, p1, t2, p2, t3, p3) # 3 points
chart.fibonacci_extension(t1, p1, t2, p2, t3, p3) # impulse + retracement
chart.gann_fan(t1, p1, t2, p2) # 1x1 plus steeper/shallower raysWith toolbox=True these are also available interactively
(Alt+F / M / C / P / A / G / X / N).
from pylightcharts import YieldCurveChart, OptionsChart
curve = YieldCurveChart() # x axis = duration in months
series = curve.add_series('Line', 'rate')
series.set(pd.DataFrame({'time': [1, 3, 12], 'rate': [4.2, 4.0, 3.6]}))
surface = OptionsChart() # x axis = strike
chart.set_price_formatter(decimals=2, thousands=True, prefix='$')
chart.set_time_formatter('YYYY-MM-DD HH:mm')
chart.register_js_formatter('eur', "value => '\u20ac' + value.toFixed(2)")
chart.set_price_formatter(name='eur')DataFrames with 2000+ rows are transferred as a base64 column-major Float64
buffer and rebuilt in the browser (Lib.decodeData) instead of being shipped as
JavaScript source. Measured 100k bars: 1.56s -> 0.54s (~3x);
500k bars: 8.9s -> 3.2s. Tune with pylightcharts.util.BINARY_DATA_THRESHOLD.
from pylightcharts.headless import HeadlessChart
chart = HeadlessChart(width=1200, height=700)
chart.set(df)
chart.add_sma('close', 20)
chart.add_rsi(14)
chart.render('report.png') # PNG bytes, no window required
chart.to_html() # or grab the standalone HTMLUses Playwright when installed, otherwise a local Chrome/Edge in headless mode
(PYLIGHTCHARTS_CHROME overrides the browser path).