From 6237cf4d5a6e3f971376e582818ffd82d3b11f3c Mon Sep 17 00:00:00 2001 From: louisnw Date: Thu, 18 May 2023 23:14:04 +0100 Subject: [PATCH] New Feature: Multi-Pane Charts - Added the create_subchart method to Chart. - Added the SubChart class. - Added an inner_width and inner_height parameter to Chart. - The time_scale method can now disable the time scale completely. Bugs: - Fixed a bug which prevented markers from being placed on charts with a timescale less than a day. --- docs/source/docs.md | 95 +++++++- examples/5_styling/styling.py | 2 +- lightweight_charts/chart.py | 48 +++- lightweight_charts/js.py | 383 ++++++++++++++++++++------------ lightweight_charts/pywebview.py | 36 ++- lightweight_charts/util.py | 15 ++ lightweight_charts/widgets.py | 2 +- 7 files changed, 420 insertions(+), 161 deletions(-) diff --git a/docs/source/docs.md b/docs/source/docs.md index f0b7e34..29a8936 100644 --- a/docs/source/docs.md +++ b/docs/source/docs.md @@ -51,7 +51,7 @@ The provided ticks do not need to be rounded to an interval (1 min, 5 min etc.), ___ ### `create_line` -`color: str` | `width: int` +`color: str` | `width: int` | `-> Line` Creates and returns a [Line](#line) object. ___ @@ -159,6 +159,24 @@ Subscribes the given function to a chart 'click' event. The event emits a dictionary containing the bar at the time clicked, with the keys: `time | open | high | low | close` +___ + +### `create_subchart` +`volume_enabled: bool` | `position: 'left'/'right'/'top'/'bottom'`, `width: float` | `height: float` | `sync: bool/UUID` | `-> SubChart` + +Creates and returns a [SubChart](#subchart) object, placing it adjacent to the declaring `Chart` or `SubChart`. + +`position`: specifies how the `SubChart` will float within the `Chart` window. + +`height` | `width`: Specifies the size of the `SubChart`, where `1` is the width/height of the window (100%) + +`sync`: If given as `True`, the `SubChart`'s time scale will follow that of the declaring `Chart` or `SubChart`. If a `UUID` object is passed, the `SubChart` will follow the panel with the given `UUID`. + +Chart `UUID`'s can be accessed from the`chart.id` and `subchart.id` attributes. + +```{important} +`width` and `height` must be given as a number between 0 and 1. +``` ___ @@ -209,9 +227,72 @@ ___ Updates the data for the line. This should be given as a Series object, with labels akin to the `line.set()` function. - ___ +## `SubChart` + +The `SubChart` object allows for the use of multiple chart panels within the same `Chart` window. All of the [Common Methods](#common-methods) can be used within a `SubChart`. + +`SubCharts` are arranged horizontally from left to right. When the available space is no longer sufficient, the subsequent `SubChart` will be positioned on a new row, starting from the left side. +___ + +### Grid of 4 Example: +```python +import pandas as pd +from lightweight_charts import Chart + +if __name__ == '__main__': + + chart = Chart(inner_width=0.5, inner_height=0.5) + + chart2 = chart.create_subchart(position='right', width=0.5, height=0.5) + + chart3 = chart2.create_subchart(position='left', width=0.5, height=0.5) + + chart4 = chart3.create_subchart(position='right', width=0.5, height=0.5) + + chart.watermark('1') + chart2.watermark('2') + chart3.watermark('3') + chart4.watermark('4') + + df = pd.read_csv('ohlcv.csv') + chart.set(df) + chart2.set(df) + chart3.set(df) + chart4.set(df) + + chart.show(block=True) + +``` +___ + +### Synced Line Chart Example: + +```python +import pandas as pd +from lightweight_charts import Chart + +if __name__ == '__main__': + + chart = Chart(inner_width=1, inner_height=0.8) + + chart2 = chart.create_subchart(width=1, height=0.2, sync=True, volume_enabled=False) + chart2.time_scale(visible=False) + + df = pd.read_csv('ohlcv.csv') + df2 = pd.read_csv('rsi.csv') + + chart.set(df) + line = chart2.create_line() + line.set(df2) + + chart.show(block=True) + +``` +___ + + ## `QtChart` `widget: QWidget` | `volume_enabled: bool` @@ -222,7 +303,10 @@ ___ `-> QWebEngineView` -Returns the `QWebEngineView` object. For example: +Returns the `QWebEngineView` object. + +___ +### Example: ```python import pandas as pd @@ -263,7 +347,10 @@ ___ ### `get_webview` `-> wx.html2.WebView` -Returns a `wx.html2.WebView` object which can be used to for positioning and styling within wxPython. For example: +Returns a `wx.html2.WebView` object which can be used to for positioning and styling within wxPython. +___ + +### Example: ```python import wx diff --git a/examples/5_styling/styling.py b/examples/5_styling/styling.py index 2481a53..7be3c89 100644 --- a/examples/5_styling/styling.py +++ b/examples/5_styling/styling.py @@ -4,7 +4,7 @@ from lightweight_charts import Chart if __name__ == '__main__': - chart = Chart(debug=True) + chart = Chart() df = pd.read_csv('ohlcv.csv') diff --git a/lightweight_charts/chart.py b/lightweight_charts/chart.py index 3ec2e9b..9e28ace 100644 --- a/lightweight_charts/chart.py +++ b/lightweight_charts/chart.py @@ -3,7 +3,7 @@ import pandas as pd import multiprocessing as mp from uuid import UUID from datetime import datetime -from typing import Union +from typing import Union, Literal from lightweight_charts.pywebview import _loop from lightweight_charts.util import LINE_TYPE, POSITION, SHAPE, CROSSHAIR_MODE, PRICE_SCALE_MODE @@ -31,7 +31,7 @@ class Line: class Chart: def __init__(self, volume_enabled: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None, - on_top: bool = False, debug: bool = False): + on_top: bool = False, debug: bool = False, sub: bool = False, inner_width=1, inner_height=1): self.debug = debug self.volume_enabled = volume_enabled self.width = width @@ -39,16 +39,17 @@ class Chart: self.x = x self.y = y self.on_top = on_top + self.inner_width = inner_width + self.inner_height = inner_height + if sub: + return self._q = mp.Queue() self._result_q = mp.Queue() self._exit = mp.Event() - - try: - self._process = mp.Process(target=_loop, args=(self,), daemon=True) - self._process.start() - except: - pass + self._process = mp.Process(target=_loop, args=(self,), daemon=True) + self._process.start() + self.id = self._result_q.get() def _go(self, func, *args): self._q.put((func, args)) @@ -161,14 +162,15 @@ class Chart: """ self._go('config', mode, title, right_padding) - def time_scale(self, time_visible: bool = True, seconds_visible: bool = False): + def time_scale(self, visible: bool = True, time_visible: bool = True, seconds_visible: bool = False): """ Options for the time scale of the chart. + :param visible: Time scale visibility control. :param time_visible: Time visibility control. :param seconds_visible: Seconds visibility control :return: """ - self._go('time_scale', time_visible, seconds_visible) + self._go('time_scale', visible, time_visible, seconds_visible) def layout(self, background_color: str = None, text_color: str = None, font_size: int = None, font_family: str = None): @@ -227,3 +229,29 @@ class Chart: The event returns a dictionary containing the bar object at the time clicked. """ self._go('subscribe_click', function) + + def create_subchart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', + width: float = 0.5, height: float = 0.5, sync: bool | UUID = False): + c_id = self._go_return('create_sub_chart', volume_enabled, position, width, height, sync) + return SubChart(self, c_id) + + +class SubChart(Chart): + def __init__(self, parent, c_id): + self._parent = parent._parent if isinstance(parent, SubChart) else parent + + super().__init__(sub=True) + + self.id = c_id + self._q = self._parent._q + self._result_q = self._parent._result_q + + def _go(self, func, *args): + func = 'SUB'+func + args = (self.id,) + args + super()._go(func, *args) + + def _go_return(self, func, *args): + func = 'SUB' + func + args = (self.id,) + args + return super()._go_return(func, *args) diff --git a/lightweight_charts/js.py b/lightweight_charts/js.py index 5b79272..216dd09 100644 --- a/lightweight_charts/js.py +++ b/lightweight_charts/js.py @@ -1,11 +1,11 @@ import pandas as pd -import uuid +from uuid import UUID, uuid4 from datetime import timedelta, datetime -from typing import Dict, Union +from typing import Dict, Union, Literal from lightweight_charts.pkg import LWC_3_5_0 from lightweight_charts.util import LINE_TYPE, POSITION, SHAPE, CROSSHAIR_MODE, _crosshair_mode, _line_type, \ - MissingColumn, _js_bool, _price_scale_mode, PRICE_SCALE_MODE, _position, _shape, IDGen + MissingColumn, _js_bool, _price_scale_mode, PRICE_SCALE_MODE, _position, _shape, IDGen, _valid_color class Line: @@ -33,29 +33,38 @@ class Line: class API: def __init__(self): - self.click_func = None + self.click_funcs = {} def onClick(self, data): + click_func = self.click_funcs[data['id']] if isinstance(data['time'], int): data['time'] = datetime.fromtimestamp(data['time']) else: data['time'] = datetime(data['time']['year'], data['time']['month'], data['time']['day']) - self.click_func(data) if self.click_func else None + click_func(data) if click_func else None class LWC: - def __init__(self, volume_enabled): + def __init__(self, volume_enabled, inner_width=1, inner_height=1): + self.id = uuid4() self.js_queue = [] self.loaded = False - self._html = HTML + self._rand = IDGen() + self._chart_var = 'chart' self._js_api = API() + self._js_api_code = '' self.volume_enabled = volume_enabled + self.inner_width = inner_width + self.inner_height = inner_height + self._html = HTML.replace('__INNER_WIDTH__', str(self.inner_width)).replace('__INNER_HEIGHT__', str(self.inner_height)) + self.last_bar = None self.interval = None - self._lines: Dict[uuid.UUID, Line] = {} + self._lines: Dict[UUID, Line] = {} + self._subcharts: Dict[UUID, LWC] = {self.id: self} self.background_color = '#000000' self.volume_up_color = 'rgba(83,141,131,0.8)' @@ -69,8 +78,6 @@ class LWC: self.js_queue.append((func, args, kwargs)) return True - def _click_func_code(self, string): self._html = self._html.replace('// __onClick__', string) - def _set_last_bar(self, bar: pd.Series): self.last_bar = bar def _set_interval(self, df: pd.DataFrame): @@ -123,11 +130,11 @@ class LWC: volume['color'] = self.volume_down_color volume.loc[df['close'] > df['open'], 'color'] = self.volume_up_color - self.run_script(f'chart.volumeSeries.setData({volume.to_dict(orient="records")})') + self.run_script(f'{self._chart_var}.volumeSeries.setData({volume.to_dict(orient="records")})') bars = df.drop(columns=['volume']) bars = bars.to_dict(orient='records') - self.run_script(f'chart.series.setData({bars})') + self.run_script(f'{self._chart_var}.series.setData({bars})') def update(self, series, from_tick=False): """ @@ -147,11 +154,11 @@ class LWC: volume = series.drop(['open', 'high', 'low', 'close']) volume = volume.rename({'volume': 'value'}) volume['color'] = self.volume_up_color if series['close'] > series['open'] else self.volume_down_color - self.run_script(f'chart.volumeSeries.update({volume.to_dict()})') + self.run_script(f'{self._chart_var}.volumeSeries.update({volume.to_dict()})') series = series.drop(['volume']) dictionary = series.to_dict() - self.run_script(f'chart.series.update({dictionary})') + self.run_script(f'{self._chart_var}.series.update({dictionary})') def update_from_tick(self, series): """ @@ -184,14 +191,13 @@ class LWC: Creates and returns a Line object.)\n :return a Line object used to set/update the line. """ - line_id = uuid.uuid4() + line_id = uuid4() self._lines[line_id] = Line(self, line_id, color, width) return self._lines[line_id] def _set_line_data(self, line_id, df: pd.DataFrame): if self._stored('_set_line_data', line_id, df): return None - line = self._lines[line_id] if not line.loaded: @@ -202,7 +208,7 @@ class LWC: lineWidth: {line.width}, }}; let line{var} = {{ - series: chart.chart.addLineSeries(lineSeries{var}), + series: {self._chart_var}.chart.addLineSeries(lineSeries{var}), id: '{line_id}', }}; lines.push(line{var}) @@ -229,7 +235,7 @@ class LWC: }})''') def marker(self, time: datetime = None, position: POSITION = 'below', shape: SHAPE = 'arrow_up', - color: str = '#2196F3', text: str = '', m_id: uuid.UUID = None) -> uuid.UUID: + color: str = '#2196F3', text: str = '', m_id: UUID = None) -> UUID: """ Creates a new marker.\n :param time: The time that the marker will be placed at. If no time is given, it will be placed at the last bar. @@ -239,38 +245,43 @@ class LWC: :param text: The text to be placed with the marker. :return: The UUID of the marker placed. """ + _valid_color(color) if not m_id: - m_id = uuid.uuid4() + m_id = uuid4() if self._stored('marker', time, position, shape, color, text, m_id): return m_id - time = self.last_bar['time'] if not time else self._datetime_format(time) + try: + time = self.last_bar['time'] if not time else self._datetime_format(time) + except TypeError: + raise TypeError('Chart marker created before data was set.') + time = time if isinstance(time, float) else f"'{time}'" self.run_script(f""" markers.push({{ - time: '{time}', + time: {time}, position: '{_position(position)}', color: '{color}', shape: '{_shape(shape)}', text: '{text}', id: '{m_id}' }}); - chart.series.setMarkers(markers)""") + {self._chart_var}.series.setMarkers(markers)""") return m_id - def remove_marker(self, m_id: uuid.UUID): + def remove_marker(self, m_id: UUID): """ - Removes the marker with the given uuid.\n + Removes the marker with the given UUID.\n """ if self._stored('remove_marker', m_id): return None self.run_script(f''' - markers.forEach(function (marker) {{ - if ('{m_id}' === marker.id) {{ - markers.splice(markers.indexOf(marker), 1) - chart.series.setMarkers(markers) - }} - }});''') + markers.forEach(function (marker) {{ + if ('{m_id}' === marker.id) {{ + markers.splice(markers.indexOf(marker), 1) + {self._chart_var}.series.setMarkers(markers) + }} + }});''') def horizontal_line(self, price: Union[float, int], color: str = 'rgb(122, 146, 202)', width: int = 1, style: LINE_TYPE = 'solid', text: str = '', axis_label_visible=True): @@ -291,7 +302,7 @@ class LWC: title: '{text}', }}; let line{var} = {{ - line: chart.series.createPriceLine(priceLine{var}), + line: {self._chart_var}.series.createPriceLine(priceLine{var}), price: {price}, }}; horizontal_lines.push(line{var})""") @@ -306,7 +317,7 @@ class LWC: self.run_script(f''' horizontal_lines.forEach(function (line) {{ if ({price} === line.price) {{ - chart.series.removePriceLine(line.line); + {self._chart_var}.series.removePriceLine(line.line); horizontal_lines.splice(horizontal_lines.indexOf(line), 1) }} }});''') @@ -320,26 +331,29 @@ class LWC: if self._stored('config', mode, title, right_padding): return None - self.run_script(f'chart.chart.timeScale().scrollToPosition({right_padding}, false)') if right_padding else None - self.run_script(f'chart.series.applyOptions({{title: "{title}"}})') if title else None + self.run_script(f'{self._chart_var}.chart.timeScale().scrollToPosition({right_padding}, false)') if right_padding else None + self.run_script(f'{self._chart_var}.series.applyOptions({{title: "{title}"}})') if title else None self.run_script( - f"chart.chart.priceScale().applyOptions({{mode: LightweightCharts.PriceScaleMode.{_price_scale_mode(mode)}}})") if mode else None + f"{self._chart_var}.chart.priceScale().applyOptions({{mode: LightweightCharts.PriceScaleMode.{_price_scale_mode(mode)}}})") if mode else None - def time_scale(self, time_visible: bool = True, seconds_visible: bool = False): + def time_scale(self, visible: bool = True, time_visible: bool = True, seconds_visible: bool = False): """ Options for the time scale of the chart. + :param visible: Time scale visibility control. :param time_visible: Time visibility control. - :param seconds_visible: Seconds visibility control + :param seconds_visible: Seconds visibility control. :return: """ - if self._stored('time_scale', time_visible, seconds_visible): + if self._stored('time_scale', visible, time_visible, seconds_visible): return None + time_scale_visible = f'visible: {_js_bool(visible)},' time = f'timeVisible: {_js_bool(time_visible)},' - seconds = f'secondsVisible: {_js_bool(seconds_visible)}' + seconds = f'secondsVisible: {_js_bool(seconds_visible)},' self.run_script(f''' - chart.chart.applyOptions({{ + {self._chart_var}.chart.applyOptions({{ timeScale: {{ + {time_scale_visible if visible is not None else ''} {time if time_visible is not None else ''} {seconds if seconds_visible is not None else ''} }} @@ -354,16 +368,15 @@ class LWC: return None self.background_color = background_color if background_color else self.background_color - args = f"'{self.background_color}'", f"'{text_color}'", f"{font_size}", f"'{font_family}'", - for key, arg in zip(('backgroundColor', 'textColor', 'fontSize', 'fontFamily'), args): - if not arg: - continue - self.run_script(f""" - chart.chart.applyOptions({{ - layout: {{ - {key}: {arg} - }} - }})""") + self.run_script(f""" + document.body.style.backgroundColor = '{self.background_color}' + {self._chart_var}.chart.applyOptions({{ + layout: {{ + {f'backgroundColor: "{background_color}",' if background_color else ''} + {f'textColor: "{text_color}",' if text_color else ''} + {f'fontSize: {font_size},' if font_size else ''} + {f'fontFamily: "{font_family}",' if font_family else ''} + }}}})""") def candle_style(self, up_color: str = 'rgba(39, 157, 130, 100)', down_color: str = 'rgba(200, 97, 100, 100)', wick_enabled: bool = True, border_enabled: bool = True, border_up_color: str = '', @@ -375,20 +388,17 @@ class LWC: border_up_color, border_down_color, wick_up_color, wick_down_color): return None - params = None, 'upColor', 'downColor', 'wickVisible', 'borderVisible', 'borderUpColor', 'borderDownColor',\ - 'wickUpColor', 'wickDownColor' - for param, key_arg in zip(params, locals().items()): - key, arg = key_arg - if isinstance(arg, bool): - arg = _js_bool(arg) - if key == 'self' or arg is None: - continue - else: - arg = f"'{arg}'" - self.run_script( - f"""chart.series.applyOptions({{ - {param}: {arg}, - }})""") + self.run_script(f""" + {self._chart_var}.series.applyOptions({{ + {f'upColor: "{up_color}",' if up_color else ''} + {f'downColor: "{down_color}",' if down_color else ''} + {f'wickVisible: {_js_bool(wick_enabled)},' if wick_enabled else ''} + {f'borderVisible: {_js_bool(border_enabled)},' if border_enabled else ''} + {f'borderUpColor: "{border_up_color}",' if border_up_color else ''} + {f'borderDownColor: "{border_down_color}",' if border_down_color else ''} + {f'wickUpColor: "{wick_up_color}",' if wick_up_color else ''} + {f'wickDownColor: "{wick_down_color}",' if wick_down_color else ''} + }})""") def volume_config(self, scale_margin_top: float = 0.8, scale_margin_bottom: float = 0.0, up_color='rgba(83,141,131,0.8)', down_color='rgba(200,127,130,0.8)'): @@ -406,13 +416,11 @@ class LWC: self.volume_up_color = up_color if up_color else self.volume_up_color self.volume_down_color = down_color if down_color else self.volume_down_color - top = f'top: {scale_margin_top},' - bottom = f'bottom: {scale_margin_bottom},' self.run_script(f''' - chart.volumeSeries.priceScale().applyOptions({{ + {self._chart_var}.volumeSeries.priceScale().applyOptions({{ scaleMargins: {{ - {top if top else ''} - {bottom if bottom else ''} + top: {scale_margin_top}, + bottom: {scale_margin_bottom}, }} }})''') @@ -431,14 +439,13 @@ class LWC: f"'{vert_label_background_color}'}}", \ f"{horz_width}}}", f"'{horz_color}'}}", f"LightweightCharts.LineStyle.{_line_type(horz_style)}}}",\ f"'{horz_label_background_color}'}}" - for key, arg in zip( ('mode', 'vertLine: {width', 'vertLine: {color', 'vertLine: {style', 'vertLine: {labelBackgroundColor', 'horzLine: {width', 'horzLine: {color', 'horzLine: {style', 'horzLine: {labelBackgroundColor'), args): if 'None' in arg: continue self.run_script(f''' - chart.chart.applyOptions({{ + {self._chart_var}.chart.applyOptions({{ crosshair: {{ {key}: {arg} }}}})''') @@ -451,7 +458,7 @@ class LWC: return None self.run_script(f''' - chart.chart.applyOptions({{ + {self._chart_var}.chart.applyOptions({{ watermark: {{ visible: true, fontSize: {font_size}, @@ -470,27 +477,160 @@ class LWC: if self._stored('legend', visible, ohlc, percent, color, font_size, font_family): return None - scripts = f'legendToggle = {_js_bool(visible)}; legend.innerText = ""', f'legendOHLCVisible = {_js_bool(ohlc)}',\ - f'legendPercentVisible = {_js_bool(percent)}', f'legend.style.color = {color}', \ - f'legend.style.fontSize = "{font_size}px"', f'legend.style.fontFamily = "{font_family}"' - for script, arg in zip(scripts, (visible, ohlc, percent, color, font_size, font_family)): - if arg is None: - continue - self.run_script(script) + if visible: + self.run_script(f''' + {f"{self._chart_var}.legend.style.color = '{color}'" if color else ''} + {f"{self._chart_var}.legend.style.fontSize = {font_size}" if font_size else ''} + {f"{self._chart_var}.legend.style.fontFamily = '{font_family}'" if font_family else ''} + + {self._chart_var}.chart.subscribeCrosshairMove((param) => {{ + if (param.time){{ + const data = param.seriesPrices.get({self._chart_var}.series); + if (!data) {{return}} + let percentMove = ((data.close-data.open)/data.open)*100 + let ohlc = `open: ${{legendItemFormat(data.open)}} + | high: ${{legendItemFormat(data.high)}} + | low: ${{legendItemFormat(data.low)}} + | close: ${{legendItemFormat(data.close)}} ` + let percent = `| daily: ${{percentMove >= 0 ? '+' : ''}}${{percentMove.toFixed(2)}} %` + let finalString = '' + {'finalString += ohlc' if ohlc else ''} + {'finalString += percent' if percent else ''} + {self._chart_var}.legend.innerHTML = finalString + }} + else {{ + {self._chart_var}.legend.innerHTML = '' + }} + }});''') def subscribe_click(self, function: object): if self._stored('subscribe_click', function): return None - self._js_api.click_func = function - self.run_script('isSubscribed = true') + self._js_api.click_funcs[str(self.id)] = function + var = self._rand.generate() + self.run_script(f''' + {self._chart_var}.chart.subscribeClick((param) => {{ + if (!param.point) {{return}} + let prices{var} = param.seriesPrices.get({self._chart_var}.series); + let data{var} = {{ + time: param.time, + open: prices{var}.open, + high: prices{var}.high, + low: prices{var}.low, + close: prices{var}.close, + id: '{self.id}' + }} + {self._js_api_code}(data{var}) + }})''') + + def create_sub_chart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', + width: float = 0.5, height: float = 0.5, sync: bool | UUID = False): + subchart = SubChart(self, volume_enabled, position, width, height, sync) + self._subcharts[subchart.id] = subchart + return subchart + + def _pywebview_sub_chart(self, volume_enabled, position, width, height, sync, parent=None): + subchart = PyWebViewSubChart(self if not parent else parent, volume_enabled, position, width, height, sync) + self._subcharts[subchart.id] = subchart + return subchart.id + + +class SubChart(LWC): + def __init__(self, parent, volume_enabled, position, width, height, sync): + super().__init__(volume_enabled, width, height) + self._chart = parent._chart if isinstance(parent, SubChart) else parent + self._parent = parent + + self._rand = self._chart._rand + self._chart_var = self._rand.generate() + self._js_api = self._chart._js_api + self._js_api_code = self._chart._js_api_code + + self.position = position + + self._create_panel(sync) + + def _stored(self, func, *args, **kwargs): + if self._chart.loaded: + return False + self._chart.js_queue.append((f'SUB{func}', (self.id,)+args, kwargs)) + return True + + def run_script(self, script): self._chart.run_script(script) + + def _create_panel(self, sync): + if self._stored('_create_panel', sync): + return None + + parent_div = 'chartsDiv' if self._parent._chart_var == 'chart' else self._parent._chart_var+'div' + + sub_sync = '' + if sync: + sync_parent_var = self._chart._subcharts[sync]._chart_var if isinstance(sync, UUID) else self._parent._chart_var + sub_sync = f''' + {sync_parent_var}.chart.timeScale().subscribeVisibleLogicalRangeChange((timeRange) => {{ + {self._chart_var}.chart.timeScale().setVisibleLogicalRange(timeRange) + }}); + ''' + self.run_script(f''' + var {self._chart_var}div = document.createElement('div') + //{self._chart_var}div.style.position = 'relative' + {self._chart_var}div.style.float = "{self.position}" + + //chartsDiv.style.display = 'inline-block' + chartsDiv.style.float = 'left' + + var {self._chart_var} = {{}} + {self._chart_var}.scale= {{ + width: {self.inner_width}, + height: {self.inner_height} + }} + {self._chart_var}.chart = makeChart(window.innerWidth*{self._chart_var}.scale.width, + window.innerHeight*{self._chart_var}.scale.height, {self._chart_var}div) + {self._chart_var}.series = makeCandlestickSeries({self._chart_var}.chart) + {self._chart_var}.volumeSeries = makeVolumeSeries({self._chart_var}.chart) + + {self._chart_var}.legend = document.createElement('div') + {self._chart_var}.legend.style.position = 'absolute' + {self._chart_var}.legend.style.zIndex = 1000 + {self._chart_var}.legend.style.width = '{(self.inner_width*100)-8}vw' + {self._chart_var}.legend.style.top = '10px' + {self._chart_var}.legend.style.left = '10px' + {self._chart_var}.legend.style.fontFamily = 'Monaco' + {self._chart_var}.legend.style.fontSize = '11px' + {self._chart_var}.legend.style.color = 'rgb(191, 195, 203)' + {self._chart_var}div.appendChild({self._chart_var}.legend) + + {parent_div}.parentNode.insertBefore({self._chart_var}div, {parent_div}.nextSibling) + charts.push({self._chart_var}) + + {self._chart_var}.chart.priceScale('').applyOptions({{ + scaleMargins: {{ + top: 0.8, + bottom: 0, + }} + }}); + {sub_sync} + ''') + + +class PyWebViewSubChart(SubChart): + def create_sub_chart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', + width: float = 0.5, height: float = 0.5, sync: bool | UUID = False): + return self._chart._pywebview_sub_chart(volume_enabled, position, width, height, sync, parent=self) + + def create_line(self, color: str = 'rgba(214, 237, 255, 0.6)', width: int = 2): + return super().create_line(color, width).id SCRIPT = """ +document.body.style.backgroundColor = '#000000' const markers = [] const horizontal_lines = [] const lines = [] +const charts = [] const up = 'rgba(39, 157, 130, 100)' const down = 'rgba(200, 97, 100, 100)' @@ -557,25 +697,28 @@ function makeVolumeSeries(chart) { const chartsDiv = document.createElement('div') +var chart = {} + chart.chart = makeChart(window.innerWidth, window.innerHeight, chartsDiv) chart.series = makeCandlestickSeries(chart.chart) chart.volumeSeries = makeVolumeSeries(chart.chart) +chart.scale = { + width: __INNER_WIDTH__, + height: __INNER_HEIGHT__ +} document.body.appendChild(chartsDiv) -const legend = document.createElement('div') -legend.style.display = 'block' -legend.style.position = 'absolute' -legend.style.zIndex = 1000 -legend.style.width = '98vw' -legend.style.top = '10px' -legend.style.left = '10px' -legend.style.fontFamily = 'Monaco' - -legend.style.fontSize = '11px' -legend.style.color = 'rgb(191, 195, 203)' - -document.body.appendChild(legend) +chart.legend = document.createElement('div') +chart.legend.style.position = 'absolute' +chart.legend.style.zIndex = 1000 +chart.legend.style.width = `${(chart.scale.width*100)-8}vw` +chart.legend.style.top = '10px' +chart.legend.style.left = '10px' +chart.legend.style.fontFamily = 'Monaco' +chart.legend.style.fontSize = '11px' +chart.legend.style.color = 'rgb(191, 195, 203)' +document.body.appendChild(chart.legend) chart.chart.priceScale('').applyOptions({ scaleMargins: { @@ -587,56 +730,18 @@ chart.chart.priceScale('').applyOptions({ window.addEventListener('resize', function() { let width = window.innerWidth; let height = window.innerHeight; - chart.chart.resize(width, height) + chart.chart.resize(width*chart.scale.width, height*chart.scale.height) + + charts.forEach(function (subchart) {{ + subchart.chart.resize(width*subchart.scale.width, height*subchart.scale.height) + }}); + }); function legendItemFormat(num) { return num.toFixed(2).toString().padStart(8, ' ') } -let legendToggle = false -let legendOHLCVisible = true -let legendPercentVisible = true -chart.chart.subscribeCrosshairMove((param) => { - if (param.time){ - const data = param.seriesPrices.get(chart.series); - if (!data || legendToggle === false) {return} - const percentMove = ((data.close-data.open)/data.open)*100 - //legend.style.color = percentMove >= 0 ? up : down - - let ohlc = `open: ${legendItemFormat(data.open)} - | high: ${legendItemFormat(data.high)} - | low: ${legendItemFormat(data.low)} - | close: ${legendItemFormat(data.close)} ` - let percent = `| daily: ${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %` - - let finalString = '' - if (legendOHLCVisible) { - finalString += ohlc - } - if (legendPercentVisible) { - finalString += percent - } - legend.innerHTML = finalString - } - else { - legend.innerHTML = '' - } -}); -let isSubscribed = false -function clickHandler(param) { - if (!param.point || !isSubscribed) {return} - let prices = param.seriesPrices.get(chart.series); - let data = { - time: param.time, - open: prices.open, - high: prices.high, - low: prices.low, - close: prices.close, - } - // __onClick__ -} -chart.chart.subscribeClick(clickHandler) """ HTML = f""" @@ -649,6 +754,8 @@ HTML = f""" diff --git a/lightweight_charts/pywebview.py b/lightweight_charts/pywebview.py index ce8c689..a670623 100644 --- a/lightweight_charts/pywebview.py +++ b/lightweight_charts/pywebview.py @@ -1,3 +1,5 @@ +from typing import Literal +from uuid import UUID import webview from multiprocessing import Queue @@ -9,10 +11,10 @@ _result_q = Queue() class Webview(LWC): def __init__(self, chart): - super().__init__(chart.volume_enabled) + super().__init__(chart.volume_enabled, chart.inner_width, chart.inner_height) self.chart = chart self.started = False - self._click_func_code('pywebview.api.onClick(data)') + self._js_api_code = 'pywebview.api.onClick' self.webview = webview.create_window('', html=self._html, on_top=chart.on_top, js_api=self._js_api, width=chart.width, height=chart.height, x=chart.x, y=chart.y) @@ -24,8 +26,15 @@ class Webview(LWC): self.loaded = True while len(self.js_queue) > 0: func, args, kwargs = self.js_queue[0] - getattr(self, func)(*args) + + if 'SUB' in func: + c_id = args[0] + args = args[1:] + getattr(self._subcharts[c_id], func.replace('SUB', ''))(*args) + else: + getattr(self, func)(*args) del self.js_queue[0] + _loop(self.chart, controller=self) def show(self): @@ -34,27 +43,40 @@ class Webview(LWC): else: webview.start(debug=self.chart.debug) - def create_line(self, color: str = 'rgba(214, 237, 255, 0.6)', width: int = 2): - return super().create_line(color, width).id - def hide(self): self.webview.hide() def exit(self): self.webview.destroy() del self + def create_line(self, color: str = 'rgba(214, 237, 255, 0.6)', width: int = 2): + return super().create_line(color, width).id + + def create_sub_chart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', + width: float = 0.5, height: float = 0.5, sync: bool | UUID = False): + return super()._pywebview_sub_chart(volume_enabled, position, width, height, sync) + def _loop(chart, controller=None): wv = Webview(chart) if not controller else controller + chart._result_q.put(wv.id) while 1: + obj = wv func, args = chart._q.get() + + if 'SUB' in func: + obj = obj._subcharts[args[0]] + args = args[1:] + func = func.replace('SUB', '') + try: - result = getattr(wv, func)(*args) + result = getattr(obj, func)(*args) except KeyError as e: return if func == 'show': chart._exit.set() elif func == 'exit': chart._exit.set() + chart._result_q.put(result) if result is not None else None diff --git a/lightweight_charts/util.py b/lightweight_charts/util.py index f7ba8d6..9b719cf 100644 --- a/lightweight_charts/util.py +++ b/lightweight_charts/util.py @@ -12,6 +12,21 @@ class MissingColumn(KeyError): return f'{self.msg}' +class ColorError(ValueError): + def __init__(self, message): + super().__init__(message) + self.msg = message + + def __str__(self): + return f'{self.msg}' + + +def _valid_color(string): + if string[:3] == 'rgb' or string[:4] == 'rgba' or string[0] == '#': + return True + raise ColorError('Colors must be in the format of either rgb, rgba or hex.') + + LINE_TYPE = Literal['solid', 'dotted', 'dashed', 'large_dashed', 'sparse_dotted'] POSITION = Literal['above', 'below', 'inside'] diff --git a/lightweight_charts/widgets.py b/lightweight_charts/widgets.py index cf4375e..9d8a401 100644 --- a/lightweight_charts/widgets.py +++ b/lightweight_charts/widgets.py @@ -22,7 +22,7 @@ class WxChart(LWC): super().__init__(volume_enabled) self.webview.AddScriptMessageHandler('wx_msg') - self._click_func_code('window.wx_msg.postMessage(data)') + self._js_api_code = 'window.wx_msg.postMessage' self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: self._js_api.onClick(eval(e.GetString()))) self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, self._on_js_load)