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:
@ -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
|
||||
|
||||
@ -4,7 +4,7 @@ from lightweight_charts import Chart
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
chart = Chart(debug=True)
|
||||
chart = Chart()
|
||||
|
||||
df = pd.read_csv('ohlcv.csv')
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"""
|
||||
<style>
|
||||
body {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user