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.
This commit is contained in:
louisnw
2023-05-18 23:14:04 +01:00
parent 0f061ae803
commit 6237cf4d5a
7 changed files with 420 additions and 161 deletions

View File

@ -51,7 +51,7 @@ The provided ticks do not need to be rounded to an interval (1 min, 5 min etc.),
___ ___
### `create_line` ### `create_line`
`color: str` | `width: int` `color: str` | `width: int` | `-> Line`
Creates and returns a [Line](#line) object. 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: The event emits a dictionary containing the bar at the time clicked, with the keys:
`time | open | high | low | close` `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. Updates the data for the line.
This should be given as a Series object, with labels akin to the `line.set()` function. 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` ## `QtChart`
`widget: QWidget` | `volume_enabled: bool` `widget: QWidget` | `volume_enabled: bool`
@ -222,7 +303,10 @@ ___
`-> QWebEngineView` `-> QWebEngineView`
Returns the `QWebEngineView` object. For example: Returns the `QWebEngineView` object.
___
### Example:
```python ```python
import pandas as pd import pandas as pd
@ -263,7 +347,10 @@ ___
### `get_webview` ### `get_webview`
`-> wx.html2.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 ```python
import wx import wx

View File

@ -4,7 +4,7 @@ from lightweight_charts import Chart
if __name__ == '__main__': if __name__ == '__main__':
chart = Chart(debug=True) chart = Chart()
df = pd.read_csv('ohlcv.csv') df = pd.read_csv('ohlcv.csv')

View File

@ -3,7 +3,7 @@ import pandas as pd
import multiprocessing as mp import multiprocessing as mp
from uuid import UUID from uuid import UUID
from datetime import datetime from datetime import datetime
from typing import Union from typing import Union, Literal
from lightweight_charts.pywebview import _loop from lightweight_charts.pywebview import _loop
from lightweight_charts.util import LINE_TYPE, POSITION, SHAPE, CROSSHAIR_MODE, PRICE_SCALE_MODE from lightweight_charts.util import LINE_TYPE, POSITION, SHAPE, CROSSHAIR_MODE, PRICE_SCALE_MODE
@ -31,7 +31,7 @@ class Line:
class Chart: class Chart:
def __init__(self, volume_enabled: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None, 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.debug = debug
self.volume_enabled = volume_enabled self.volume_enabled = volume_enabled
self.width = width self.width = width
@ -39,16 +39,17 @@ class Chart:
self.x = x self.x = x
self.y = y self.y = y
self.on_top = on_top self.on_top = on_top
self.inner_width = inner_width
self.inner_height = inner_height
if sub:
return
self._q = mp.Queue() self._q = mp.Queue()
self._result_q = mp.Queue() self._result_q = mp.Queue()
self._exit = mp.Event() self._exit = mp.Event()
self._process = mp.Process(target=_loop, args=(self,), daemon=True)
try: self._process.start()
self._process = mp.Process(target=_loop, args=(self,), daemon=True) self.id = self._result_q.get()
self._process.start()
except:
pass
def _go(self, func, *args): self._q.put((func, args)) def _go(self, func, *args): self._q.put((func, args))
@ -161,14 +162,15 @@ class Chart:
""" """
self._go('config', mode, title, right_padding) 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. Options for the time scale of the chart.
:param visible: Time scale visibility control.
:param time_visible: Time visibility control. :param time_visible: Time visibility control.
:param seconds_visible: Seconds visibility control :param seconds_visible: Seconds visibility control
:return: :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, def layout(self, background_color: str = None, text_color: str = None, font_size: int = None,
font_family: str = None): font_family: str = None):
@ -227,3 +229,29 @@ class Chart:
The event returns a dictionary containing the bar object at the time clicked. The event returns a dictionary containing the bar object at the time clicked.
""" """
self._go('subscribe_click', function) 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)

View File

@ -1,11 +1,11 @@
import pandas as pd import pandas as pd
import uuid from uuid import UUID, uuid4
from datetime import timedelta, datetime 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.pkg import LWC_3_5_0
from lightweight_charts.util import LINE_TYPE, POSITION, SHAPE, CROSSHAIR_MODE, _crosshair_mode, _line_type, \ 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: class Line:
@ -33,29 +33,38 @@ class Line:
class API: class API:
def __init__(self): def __init__(self):
self.click_func = None self.click_funcs = {}
def onClick(self, data): def onClick(self, data):
click_func = self.click_funcs[data['id']]
if isinstance(data['time'], int): if isinstance(data['time'], int):
data['time'] = datetime.fromtimestamp(data['time']) data['time'] = datetime.fromtimestamp(data['time'])
else: else:
data['time'] = datetime(data['time']['year'], data['time']['month'], data['time']['day']) 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: 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.js_queue = []
self.loaded = False self.loaded = False
self._html = HTML
self._rand = IDGen() self._rand = IDGen()
self._chart_var = 'chart'
self._js_api = API() self._js_api = API()
self._js_api_code = ''
self.volume_enabled = volume_enabled 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.last_bar = None
self.interval = 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.background_color = '#000000'
self.volume_up_color = 'rgba(83,141,131,0.8)' self.volume_up_color = 'rgba(83,141,131,0.8)'
@ -69,8 +78,6 @@ class LWC:
self.js_queue.append((func, args, kwargs)) self.js_queue.append((func, args, kwargs))
return True 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_last_bar(self, bar: pd.Series): self.last_bar = bar
def _set_interval(self, df: pd.DataFrame): def _set_interval(self, df: pd.DataFrame):
@ -123,11 +130,11 @@ class LWC:
volume['color'] = self.volume_down_color volume['color'] = self.volume_down_color
volume.loc[df['close'] > df['open'], 'color'] = self.volume_up_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 = df.drop(columns=['volume'])
bars = bars.to_dict(orient='records') 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): def update(self, series, from_tick=False):
""" """
@ -147,11 +154,11 @@ class LWC:
volume = series.drop(['open', 'high', 'low', 'close']) volume = series.drop(['open', 'high', 'low', 'close'])
volume = volume.rename({'volume': 'value'}) volume = volume.rename({'volume': 'value'})
volume['color'] = self.volume_up_color if series['close'] > series['open'] else self.volume_down_color 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']) series = series.drop(['volume'])
dictionary = series.to_dict() 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): def update_from_tick(self, series):
""" """
@ -184,14 +191,13 @@ class LWC:
Creates and returns a Line object.)\n Creates and returns a Line object.)\n
:return a Line object used to set/update the line. :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) self._lines[line_id] = Line(self, line_id, color, width)
return self._lines[line_id] return self._lines[line_id]
def _set_line_data(self, line_id, df: pd.DataFrame): def _set_line_data(self, line_id, df: pd.DataFrame):
if self._stored('_set_line_data', line_id, df): if self._stored('_set_line_data', line_id, df):
return None return None
line = self._lines[line_id] line = self._lines[line_id]
if not line.loaded: if not line.loaded:
@ -202,7 +208,7 @@ class LWC:
lineWidth: {line.width}, lineWidth: {line.width},
}}; }};
let line{var} = {{ let line{var} = {{
series: chart.chart.addLineSeries(lineSeries{var}), series: {self._chart_var}.chart.addLineSeries(lineSeries{var}),
id: '{line_id}', id: '{line_id}',
}}; }};
lines.push(line{var}) lines.push(line{var})
@ -229,7 +235,7 @@ class LWC:
}})''') }})''')
def marker(self, time: datetime = None, position: POSITION = 'below', shape: SHAPE = 'arrow_up', 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 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. :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. :param text: The text to be placed with the marker.
:return: The UUID of the marker placed. :return: The UUID of the marker placed.
""" """
_valid_color(color)
if not m_id: if not m_id:
m_id = uuid.uuid4() m_id = uuid4()
if self._stored('marker', time, position, shape, color, text, m_id): if self._stored('marker', time, position, shape, color, text, m_id):
return 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""" self.run_script(f"""
markers.push({{ markers.push({{
time: '{time}', time: {time},
position: '{_position(position)}', position: '{_position(position)}',
color: '{color}', shape: '{_shape(shape)}', color: '{color}', shape: '{_shape(shape)}',
text: '{text}', text: '{text}',
id: '{m_id}' id: '{m_id}'
}}); }});
chart.series.setMarkers(markers)""") {self._chart_var}.series.setMarkers(markers)""")
return m_id 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): if self._stored('remove_marker', m_id):
return None return None
self.run_script(f''' self.run_script(f'''
markers.forEach(function (marker) {{ markers.forEach(function (marker) {{
if ('{m_id}' === marker.id) {{ if ('{m_id}' === marker.id) {{
markers.splice(markers.indexOf(marker), 1) markers.splice(markers.indexOf(marker), 1)
chart.series.setMarkers(markers) {self._chart_var}.series.setMarkers(markers)
}} }}
}});''') }});''')
def horizontal_line(self, price: Union[float, int], color: str = 'rgb(122, 146, 202)', width: int = 1, 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): style: LINE_TYPE = 'solid', text: str = '', axis_label_visible=True):
@ -291,7 +302,7 @@ class LWC:
title: '{text}', title: '{text}',
}}; }};
let line{var} = {{ let line{var} = {{
line: chart.series.createPriceLine(priceLine{var}), line: {self._chart_var}.series.createPriceLine(priceLine{var}),
price: {price}, price: {price},
}}; }};
horizontal_lines.push(line{var})""") horizontal_lines.push(line{var})""")
@ -306,7 +317,7 @@ class LWC:
self.run_script(f''' self.run_script(f'''
horizontal_lines.forEach(function (line) {{ horizontal_lines.forEach(function (line) {{
if ({price} === line.price) {{ if ({price} === line.price) {{
chart.series.removePriceLine(line.line); {self._chart_var}.series.removePriceLine(line.line);
horizontal_lines.splice(horizontal_lines.indexOf(line), 1) horizontal_lines.splice(horizontal_lines.indexOf(line), 1)
}} }}
}});''') }});''')
@ -320,26 +331,29 @@ class LWC:
if self._stored('config', mode, title, right_padding): if self._stored('config', mode, title, right_padding):
return None return None
self.run_script(f'chart.chart.timeScale().scrollToPosition({right_padding}, false)') if right_padding else None self.run_script(f'{self._chart_var}.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}.series.applyOptions({{title: "{title}"}})') if title else None
self.run_script( 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. Options for the time scale of the chart.
:param visible: Time scale visibility control.
:param time_visible: Time visibility control. :param time_visible: Time visibility control.
:param seconds_visible: Seconds visibility control :param seconds_visible: Seconds visibility control.
:return: :return:
""" """
if self._stored('time_scale', time_visible, seconds_visible): if self._stored('time_scale', visible, time_visible, seconds_visible):
return None return None
time_scale_visible = f'visible: {_js_bool(visible)},'
time = f'timeVisible: {_js_bool(time_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''' self.run_script(f'''
chart.chart.applyOptions({{ {self._chart_var}.chart.applyOptions({{
timeScale: {{ timeScale: {{
{time_scale_visible if visible is not None else ''}
{time if time_visible is not None else ''} {time if time_visible is not None else ''}
{seconds if seconds_visible is not None else ''} {seconds if seconds_visible is not None else ''}
}} }}
@ -354,16 +368,15 @@ class LWC:
return None return None
self.background_color = background_color if background_color else self.background_color 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}'", self.run_script(f"""
for key, arg in zip(('backgroundColor', 'textColor', 'fontSize', 'fontFamily'), args): document.body.style.backgroundColor = '{self.background_color}'
if not arg: {self._chart_var}.chart.applyOptions({{
continue layout: {{
self.run_script(f""" {f'backgroundColor: "{background_color}",' if background_color else ''}
chart.chart.applyOptions({{ {f'textColor: "{text_color}",' if text_color else ''}
layout: {{ {f'fontSize: {font_size},' if font_size else ''}
{key}: {arg} {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)', 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 = '', 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): border_up_color, border_down_color, wick_up_color, wick_down_color):
return None return None
params = None, 'upColor', 'downColor', 'wickVisible', 'borderVisible', 'borderUpColor', 'borderDownColor',\ self.run_script(f"""
'wickUpColor', 'wickDownColor' {self._chart_var}.series.applyOptions({{
for param, key_arg in zip(params, locals().items()): {f'upColor: "{up_color}",' if up_color else ''}
key, arg = key_arg {f'downColor: "{down_color}",' if down_color else ''}
if isinstance(arg, bool): {f'wickVisible: {_js_bool(wick_enabled)},' if wick_enabled else ''}
arg = _js_bool(arg) {f'borderVisible: {_js_bool(border_enabled)},' if border_enabled else ''}
if key == 'self' or arg is None: {f'borderUpColor: "{border_up_color}",' if border_up_color else ''}
continue {f'borderDownColor: "{border_down_color}",' if border_down_color else ''}
else: {f'wickUpColor: "{wick_up_color}",' if wick_up_color else ''}
arg = f"'{arg}'" {f'wickDownColor: "{wick_down_color}",' if wick_down_color else ''}
self.run_script( }})""")
f"""chart.series.applyOptions({{
{param}: {arg},
}})""")
def volume_config(self, scale_margin_top: float = 0.8, scale_margin_bottom: float = 0.0, 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)'): 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_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 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''' self.run_script(f'''
chart.volumeSeries.priceScale().applyOptions({{ {self._chart_var}.volumeSeries.priceScale().applyOptions({{
scaleMargins: {{ scaleMargins: {{
{top if top else ''} top: {scale_margin_top},
{bottom if bottom else ''} bottom: {scale_margin_bottom},
}} }}
}})''') }})''')
@ -431,14 +439,13 @@ class LWC:
f"'{vert_label_background_color}'}}", \ f"'{vert_label_background_color}'}}", \
f"{horz_width}}}", f"'{horz_color}'}}", f"LightweightCharts.LineStyle.{_line_type(horz_style)}}}",\ f"{horz_width}}}", f"'{horz_color}'}}", f"LightweightCharts.LineStyle.{_line_type(horz_style)}}}",\
f"'{horz_label_background_color}'}}" f"'{horz_label_background_color}'}}"
for key, arg in zip( for key, arg in zip(
('mode', 'vertLine: {width', 'vertLine: {color', 'vertLine: {style', 'vertLine: {labelBackgroundColor', ('mode', 'vertLine: {width', 'vertLine: {color', 'vertLine: {style', 'vertLine: {labelBackgroundColor',
'horzLine: {width', 'horzLine: {color', 'horzLine: {style', 'horzLine: {labelBackgroundColor'), args): 'horzLine: {width', 'horzLine: {color', 'horzLine: {style', 'horzLine: {labelBackgroundColor'), args):
if 'None' in arg: if 'None' in arg:
continue continue
self.run_script(f''' self.run_script(f'''
chart.chart.applyOptions({{ {self._chart_var}.chart.applyOptions({{
crosshair: {{ crosshair: {{
{key}: {arg} {key}: {arg}
}}}})''') }}}})''')
@ -451,7 +458,7 @@ class LWC:
return None return None
self.run_script(f''' self.run_script(f'''
chart.chart.applyOptions({{ {self._chart_var}.chart.applyOptions({{
watermark: {{ watermark: {{
visible: true, visible: true,
fontSize: {font_size}, fontSize: {font_size},
@ -470,27 +477,160 @@ class LWC:
if self._stored('legend', visible, ohlc, percent, color, font_size, font_family): if self._stored('legend', visible, ohlc, percent, color, font_size, font_family):
return None return None
scripts = f'legendToggle = {_js_bool(visible)}; legend.innerText = ""', f'legendOHLCVisible = {_js_bool(ohlc)}',\ if visible:
f'legendPercentVisible = {_js_bool(percent)}', f'legend.style.color = {color}', \ self.run_script(f'''
f'legend.style.fontSize = "{font_size}px"', f'legend.style.fontFamily = "{font_family}"' {f"{self._chart_var}.legend.style.color = '{color}'" if color else ''}
for script, arg in zip(scripts, (visible, ohlc, percent, color, font_size, font_family)): {f"{self._chart_var}.legend.style.fontSize = {font_size}" if font_size else ''}
if arg is None: {f"{self._chart_var}.legend.style.fontFamily = '{font_family}'" if font_family else ''}
continue
self.run_script(script) {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): def subscribe_click(self, function: object):
if self._stored('subscribe_click', function): if self._stored('subscribe_click', function):
return None return None
self._js_api.click_func = function self._js_api.click_funcs[str(self.id)] = function
self.run_script('isSubscribed = true') 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 = """ SCRIPT = """
document.body.style.backgroundColor = '#000000'
const markers = [] const markers = []
const horizontal_lines = [] const horizontal_lines = []
const lines = [] const lines = []
const charts = []
const up = 'rgba(39, 157, 130, 100)' const up = 'rgba(39, 157, 130, 100)'
const down = 'rgba(200, 97, 100, 100)' const down = 'rgba(200, 97, 100, 100)'
@ -557,25 +697,28 @@ function makeVolumeSeries(chart) {
const chartsDiv = document.createElement('div') const chartsDiv = document.createElement('div')
var chart = {}
chart.chart = makeChart(window.innerWidth, window.innerHeight, chartsDiv) chart.chart = makeChart(window.innerWidth, window.innerHeight, chartsDiv)
chart.series = makeCandlestickSeries(chart.chart) chart.series = makeCandlestickSeries(chart.chart)
chart.volumeSeries = makeVolumeSeries(chart.chart) chart.volumeSeries = makeVolumeSeries(chart.chart)
chart.scale = {
width: __INNER_WIDTH__,
height: __INNER_HEIGHT__
}
document.body.appendChild(chartsDiv) document.body.appendChild(chartsDiv)
const legend = document.createElement('div') chart.legend = document.createElement('div')
legend.style.display = 'block' chart.legend.style.position = 'absolute'
legend.style.position = 'absolute' chart.legend.style.zIndex = 1000
legend.style.zIndex = 1000 chart.legend.style.width = `${(chart.scale.width*100)-8}vw`
legend.style.width = '98vw' chart.legend.style.top = '10px'
legend.style.top = '10px' chart.legend.style.left = '10px'
legend.style.left = '10px' chart.legend.style.fontFamily = 'Monaco'
legend.style.fontFamily = 'Monaco' chart.legend.style.fontSize = '11px'
chart.legend.style.color = 'rgb(191, 195, 203)'
legend.style.fontSize = '11px' document.body.appendChild(chart.legend)
legend.style.color = 'rgb(191, 195, 203)'
document.body.appendChild(legend)
chart.chart.priceScale('').applyOptions({ chart.chart.priceScale('').applyOptions({
scaleMargins: { scaleMargins: {
@ -587,56 +730,18 @@ chart.chart.priceScale('').applyOptions({
window.addEventListener('resize', function() { window.addEventListener('resize', function() {
let width = window.innerWidth; let width = window.innerWidth;
let height = window.innerHeight; 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) { function legendItemFormat(num) {
return num.toFixed(2).toString().padStart(8, ' ') 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""" HTML = f"""
@ -649,6 +754,8 @@ HTML = f"""
<style> <style>
body {{ body {{
margin: 0; margin: 0;
padding: 0;
overflow: hidden;
}} }}
</style> </style>
</head> </head>

View File

@ -1,3 +1,5 @@
from typing import Literal
from uuid import UUID
import webview import webview
from multiprocessing import Queue from multiprocessing import Queue
@ -9,10 +11,10 @@ _result_q = Queue()
class Webview(LWC): class Webview(LWC):
def __init__(self, chart): def __init__(self, chart):
super().__init__(chart.volume_enabled) super().__init__(chart.volume_enabled, chart.inner_width, chart.inner_height)
self.chart = chart self.chart = chart
self.started = False 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, 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) width=chart.width, height=chart.height, x=chart.x, y=chart.y)
@ -24,8 +26,15 @@ class Webview(LWC):
self.loaded = True self.loaded = True
while len(self.js_queue) > 0: while len(self.js_queue) > 0:
func, args, kwargs = 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] del self.js_queue[0]
_loop(self.chart, controller=self) _loop(self.chart, controller=self)
def show(self): def show(self):
@ -34,27 +43,40 @@ class Webview(LWC):
else: else:
webview.start(debug=self.chart.debug) 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 hide(self): self.webview.hide()
def exit(self): def exit(self):
self.webview.destroy() self.webview.destroy()
del self 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): def _loop(chart, controller=None):
wv = Webview(chart) if not controller else controller wv = Webview(chart) if not controller else controller
chart._result_q.put(wv.id)
while 1: while 1:
obj = wv
func, args = chart._q.get() func, args = chart._q.get()
if 'SUB' in func:
obj = obj._subcharts[args[0]]
args = args[1:]
func = func.replace('SUB', '')
try: try:
result = getattr(wv, func)(*args) result = getattr(obj, func)(*args)
except KeyError as e: except KeyError as e:
return return
if func == 'show': if func == 'show':
chart._exit.set() chart._exit.set()
elif func == 'exit': elif func == 'exit':
chart._exit.set() chart._exit.set()
chart._result_q.put(result) if result is not None else None chart._result_q.put(result) if result is not None else None

View File

@ -12,6 +12,21 @@ class MissingColumn(KeyError):
return f'{self.msg}' 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'] LINE_TYPE = Literal['solid', 'dotted', 'dashed', 'large_dashed', 'sparse_dotted']
POSITION = Literal['above', 'below', 'inside'] POSITION = Literal['above', 'below', 'inside']

View File

@ -22,7 +22,7 @@ class WxChart(LWC):
super().__init__(volume_enabled) super().__init__(volume_enabled)
self.webview.AddScriptMessageHandler('wx_msg') 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_SCRIPT_MESSAGE_RECEIVED, lambda e: self._js_api.onClick(eval(e.GetString())))
self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, self._on_js_load) self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, self._on_js_load)