From e4459208d2dcfbacf0bcc2386c7bd9b2cb63238d Mon Sep 17 00:00:00 2001 From: louisnw Date: Sun, 16 Jul 2023 20:54:32 +0100 Subject: [PATCH] NEW FEATURE: Trendlines, Rays and the Toolbox - Added `trend_line` and `ray_line` to the Common Methods. - Added the `toolbox` parameter to chart declaration. This allows horizontal lines, trend lines and rays to be drawn on the chart using hotkeys and buttons. - cmd-Z will delete the last drawing. - Drawings can be moved by clicking and dragging. - Added the `render_drawings` parameter to `set`, which will keep and re-render the drawings displayed on the chart (useful for multiple timeframes!) Horizontal Lines - The `horizontal_line` method now returns a HorizontalLine object, containing the methods `update` and `delete`. - Added the `interactive` parameter to `horizontal_line`, allowing for callbacks to be emitted to the `on_horizontal_line_move` callback method when the line is dragged to a new price (stop losses, limit orders, etc.). Enhancements: - added the `precision` method to the Common Methods, allowing for the number of decimal places shown on the price scale to be declared. - Lines displayed on legends now have toggle switches, allowing for their visibility to be controlled directly within the chart window. - when using `set`, the column names can now be capitalised, and the `date` column can be the index. Changes: - Merged the `title` method into the `price_line` method. --- README.md | 28 +- docs/source/conf.py | 2 +- docs/source/docs.md | 108 +++- examples/4_line_indicators/line_indicators.py | 9 +- examples/6_callbacks/callbacks.py | 11 +- lightweight_charts/abstract.py | 274 +++++----- lightweight_charts/chart.py | 16 +- lightweight_charts/js/callback.js | 33 +- lightweight_charts/js/funcs.js | 326 ++++++++++-- lightweight_charts/js/toolbox.js | 484 ++++++++++++++++++ lightweight_charts/polygon.py | 11 +- lightweight_charts/util.py | 2 +- lightweight_charts/widgets.py | 46 +- setup.py | 4 +- 14 files changed, 1092 insertions(+), 262 deletions(-) create mode 100644 lightweight_charts/js/toolbox.js diff --git a/README.md b/README.md index 05a87b6..664d763 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # lightweight-charts-python [![PyPi Release](https://img.shields.io/pypi/v/lightweight-charts?color=32a852&label=PyPi)](https://pypi.org/project/lightweight-charts/) -[![Made with Python](https://img.shields.io/badge/Python-3.9+-c7a002?logo=python&logoColor=white)](https://python.org "Go to Python homepage") +[![Made with Python](https://img.shields.io/badge/Python-3.8+-c7a002?logo=python&logoColor=white)](https://python.org "Go to Python homepage") [![License](https://img.shields.io/github/license/louisnw01/lightweight-charts-python?color=9c2400)](https://github.com/louisnw01/lightweight-charts-python/blob/main/LICENSE) [![Documentation](https://img.shields.io/badge/documentation-006ee3)](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html) @@ -24,10 +24,12 @@ ___ 1. Simple and easy to use. 2. Blocking or non-blocking GUI. 3. Streamlined for live data, with methods for updating directly from tick data. -4. __Supports:__ Jupyter Notebooks, PyQt, wxPython, Streamlit, and asyncio. -5. [Callbacks](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#callbacks) allowing for timeframe (1min, 5min, 30min etc.) selectors, searching, and more. -6. Multi-Pane Charts using the [`SubChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#subchart). +4. Multi-Pane Charts using the [`SubChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#subchart). +5. The Toolbox, allowing for trendlines, rays and horizontal lines to be drawn directly onto charts. +6. [Callbacks](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#callbacks) allowing for timeframe (1min, 5min, 30min etc.) selectors, searching, and more. 7. Direct integration of market data through [Polygon.io's](https://polygon.io) market data API. + +__Supports:__ Jupyter Notebooks, PyQt, wxPython, Streamlit, and asyncio. ___ ### 1. Display data from a csv: @@ -129,19 +131,20 @@ def calculate_sma(data: pd.DataFrame, period: int = 50): result = [] for i in range(period - 1, len(data)): val = avg(data.iloc[i - period + 1:i]) - result.append({'time': data.iloc[i]['date'], 'value': val}) + result.append({'time': data.iloc[i]['date'], f'SMA {period}': val}) return pd.DataFrame(result) if __name__ == '__main__': chart = Chart() - + chart.legend(visible=True) + df = pd.read_csv('ohlcv.csv') chart.set(df) line = chart.create_line() - sma_data = calculate_sma(df) - line._set(sma_data) + sma_data = calculate_sma(df, period=50) + line.set(sma_data, name='SMA 50') chart.show(block=True) @@ -218,12 +221,15 @@ class API: if new_data.empty: return self.chart.set(new_data) + + async def on_horizontal_line_move(self, line_id, price): + print(f'Horizontal line moved to: {price}') async def main(): api = API() - chart = Chart(api=api, topbar=True, searchbox=True) + chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True) chart.legend(True) chart.topbar.textbox('corner', 'TSLA') @@ -231,6 +237,8 @@ async def main(): df = get_bar_data('TSLA', '5min') chart.set(df) + + chart.horizontal_line(200, interactive=True) await chart.show_async(block=True) @@ -245,6 +253,8 @@ ___
[![Documentation](https://img.shields.io/badge/documentation-006ee3)](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html) + +Inquiries: [shaders_worker_0e@icloud.com](mailto:shaders_worker_0e@icloud.com) ___ _This package is an independent creation and has not been endorsed, sponsored, or approved by TradingView. The author of this package does not have any official relationship with TradingView, and the package does not represent the views or opinions of TradingView._ diff --git a/docs/source/conf.py b/docs/source/conf.py index b2dfb84..f46324d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,7 @@ project = 'lightweight-charts-python' copyright = '2023, louisnw' author = 'louisnw' -release = '1.0.13.1' +release = '1.0.14' extensions = ["myst_parser"] diff --git a/docs/source/docs.md b/docs/source/docs.md index 215fc70..546da26 100644 --- a/docs/source/docs.md +++ b/docs/source/docs.md @@ -9,11 +9,11 @@ ___ ## Common Methods -These methods can be used within the [`Chart`](#chart), [`SubChart`](#subchart), [`QtChart`](#qtchart), [`WxChart`](#wxchart) and [`StreamlitChart`](#streamlitchart) objects. +The methods below can be used within all chart objects. ___ ### `set` -`data: pd.DataFrame` +`data: pd.DataFrame` `render_drawings: bool` Sets the initial data for the chart. @@ -21,7 +21,11 @@ The data must be given as a DataFrame, with the columns: `time | open | high | low | close | volume` -The `time` column can also be named `date`, and the `volume` column can be omitted if volume is not enabled. +The `time` column can also be named `date` or be the index, and the `volume` column can be omitted if volume is not enabled. + +Column names are not case sensitive. + +If `render_drawings` is `True`, any drawings made using the `toolbox` will be redrawn with the new data. This is designed to be used when switching to a different timeframe of the same symbol. ```{important} the `time` column must have rows all of the same timezone and locale. This is particularly noticeable for data which crosses over daylight saving hours on data with intervals of less than 1 day. Errors are likely to be raised if they are not converted beforehand. @@ -53,7 +57,7 @@ As before, the `time` can also be named `date`, and the `volume` can be omitted The provided ticks do not need to be rounded to an interval (1 min, 5 min etc.), as the library handles this automatically.``````` ``` -If `cumulative_volume` is used, the volume data given to this method will be added onto the latest bar of volume data. +If `cumulative_volume` is used, the volume data given will be added onto the latest bar of volume data. ___ ### `create_line` @@ -65,14 +69,29 @@ ___ ### `lines` `-> List[Line]` -Returns a list of all Line objects for the chart or subchart. +Returns a list of all lines for the chart or subchart. ___ + +### `trend_line` +`start_time: str/datetime` | `start_value: float/int` | `end_time: str/datetime` | `end_value: float/int` | `color: str` | `width: int` | `-> Line` + +Creates a trend line, drawn from the first point (`start_time`, `start_value`) to the last point (`end_time`, `end_value`). +___ + +### `ray_line` +`start_time: str/datetime` | `value: float/int` | `color: str` | `width: int` | `-> Line` + +Creates a ray line, drawn from the first point (`start_time`, `value`) and onwards. +___ + ### `marker` `time: datetime` | `position: 'above'/'below'/'inside'` | `shape: 'arrow_up'/'arrow_down'/'circle'/'square'` | `color: str` | `text: str` | `-> str` Adds a marker to the chart, and returns its id. If the `time` parameter is not given, the marker will be placed at the latest bar. + +When using multiple markers, they should be placed in chronological order or display bugs may be present. ___ ### `remove_marker` @@ -88,9 +107,11 @@ chart.remove_marker(marker) ___ ### `horizontal_line` -`price: float/int` | `color: str` | `width: int` | `style: 'solid'/'dotted'/'dashed'/'large_dashed'/'sparse_dotted'` | `text: str` | `axis_label_visible: bool` +`price: float/int` | `color: str` | `width: int` | `style: 'solid'/'dotted'/'dashed'/'large_dashed'/'sparse_dotted'` | `text: str` | `axis_label_visible: bool` | `interactive: bool` | `-> HorizontalLine` -Places a horizontal line at the given price. +Places a horizontal line at the given price, and returns a HorizontalLine object. + +If `interactive` is set to `True`, this horizontal line can be edited on the chart. Upon its movement a callback will also be emitted to an `on_horizontal_line_move` method, containing its ID and price. The toolbox should be enabled during its usage. It is designed to be used to update an order (limit, stop, etc.) directly on the chart. ___ ### `remove_horizontal_line` @@ -109,6 +130,12 @@ ___ Clears the horizontal lines displayed on the data. ___ +### `precision` +`precision: int` + +Sets the precision of the chart based on the given number of decimal places. +___ + ### `price_scale` `mode: 'normal'/'logarithmic'/'percentage'/'index100'` | `align_labels: bool` | `border_visible: bool` | `border_color: str` | `text_color: str` | `entire_text_only: bool` | `ticks_visible: bool` | `scale_margin_top: float` | `scale_margin_bottom: float` @@ -172,12 +199,6 @@ ___ Overlays a watermark on top of the chart. ___ -### `title` -`title: str` - -Sets the title label for the chart. -___ - ### `legend` `visible: bool` | `ohlc: bool` | `percent: bool` | `lines: bool` | `color: str` | `font_size: int` | `font_family: str` @@ -191,7 +212,7 @@ Shows a loading spinner on the chart, which can be used to visualise the loading ___ ### `price_line` -`label_visible: bool` | `line_visible: bool` +`label_visible: bool` | `line_visible: bool` | `title: str` Configures the visibility of the last value price line and its label. ___ @@ -212,7 +233,7 @@ Shows the hidden candles on the chart. ___ ### `polygon` -Used to access Polygon.io's API (see [here](https://lightweight-charts-python.readthedocs.io/en/latest/polygon.html)) +Used to access Polygon.io's API (see [here](https://lightweight-charts-python.readthedocs.io/en/latest/polygon.html)). ___ ### `create_subchart` @@ -227,7 +248,7 @@ Creates and returns a [SubChart](#subchart) object, placing it adjacent to the d `sync`: If given as `True`, the `SubChart`'s timescale and crosshair will follow that of the declaring `Chart` or `SubChart`. If a `str` is passed, the `SubChart` will follow the panel with the given id. Chart ids 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. +`width` and `height` should be given as a number between 0 and 1. ``` ___ @@ -235,25 +256,29 @@ ___ ## Chart `volume_enabled: bool` | `width: int` | `height: int` | `x: int` | `y: int` | `on_top: bool` | `maximize: bool` | `debug: bool` | -`api: object` | `topbar: bool` | `searchbox: bool` +`api: object` | `topbar: bool` | `searchbox: bool` | `toolbox: bool` The main object used for the normal functionality of lightweight-charts-python, built on the pywebview library. + +```{important} +The `Chart` object should be defined within an `if __name__ == '__main__'` block. +``` ___ ### `show` `block: bool` -Shows the chart window. If `block` is enabled, the method will block code execution until the window is closed. +Shows the chart window, blocking until the chart has loaded. If `block` is enabled, the method will block code execution until the window is closed. ___ ### `hide` -Hides the chart window, and can be later shown by calling `chart.show()`. +Hides the chart window, which can be later shown by calling `chart.show()`. ___ ### `exit` -Exits and destroys the chart and window. +Exits and destroys the chart window. ___ @@ -280,7 +305,7 @@ if __name__ == '__main__': ``` ```{important} -This method must be called after the chart window is open. +This method should be called after the chart window has loaded. ``` ___ @@ -288,11 +313,10 @@ ___ ## Line The `Line` object represents a `LineSeries` object in Lightweight Charts and can be used to create indicators. As well as the methods described below, the `Line` object also has access to: - -[`title`](#title), [`marker`](#marker), [`horizontal_line`](#horizontal-line) [`hide_data`](#hide-data), [`show_data`](#show-data) and[`price_line`](#price-line) methods. +[`title`](#title), [`marker`](#marker), [`horizontal_line`](#horizontal-line) [`hide_data`](#hide-data), [`show_data`](#show-data) and[`price_line`](#price-line). ```{important} -The `line` object should only be accessed from the [`create_line`](#create-line) method of `Chart`. +The `Line` object should only be accessed from the [`create_line`](#create-line) method of `Chart`. ``` ___ @@ -324,7 +348,28 @@ ___ ### `delete` -Irreversibly deletes the line on the chart as well as the Line object. +Irreversibly deletes the line. +___ + +## HorizontalLine + +The `HorizontalLine` object represents a `PriceLine` in Lightweight Charts. + +```{important} +The `HorizontalLine` object should only be accessed from the [`horizontal_line`](#horizontal-line) Common Method. +``` +___ + +### `update` +`price: float/int` + +Updates the price of the horizontal line. + +___ + +### `delete` + +Irreversibly deletes the horizontal line. ___ ## SubChart @@ -513,6 +558,19 @@ if __name__ == '__main__': ``` ___ +## Toolbox +The Toolbox allows for trendlines, ray lines and horizontal lines to be drawn and edited directly on the chart. + +It can be used within any Chart object, and is enabled by setting the `toolbox` parameter to `True` upon Chart declaration. + +The following hotkeys can also be used when the Toolbox is enabled: +* Alt+T: Trendline +* Alt+H: Horizontal Line +* Alt+R: Ray Line +* Meta+Z: Undo + +___ + ## QtChart `widget: QWidget` | `volume_enabled: bool` diff --git a/examples/4_line_indicators/line_indicators.py b/examples/4_line_indicators/line_indicators.py index 0b7e3bb..6616d4c 100644 --- a/examples/4_line_indicators/line_indicators.py +++ b/examples/4_line_indicators/line_indicators.py @@ -8,19 +8,20 @@ def calculate_sma(data: pd.DataFrame, period: int = 50): result = [] for i in range(period - 1, len(data)): val = avg(data.iloc[i - period + 1:i]) - result.append({'time': data.iloc[i]['date'], 'value': val}) + result.append({'time': data.iloc[i]['date'], f'SMA {period}': val}) return pd.DataFrame(result) if __name__ == '__main__': - chart = Chart() + chart = Chart(debug=True) + chart.legend(visible=True) df = pd.read_csv('ohlcv.csv') chart.set(df) line = chart.create_line() - sma_data = calculate_sma(df) - line.set(sma_data) + sma_data = calculate_sma(df, period=50) + line.set(sma_data, name='SMA 50') chart.show(block=True) diff --git a/examples/6_callbacks/callbacks.py b/examples/6_callbacks/callbacks.py index a27e0ba..5c9b867 100644 --- a/examples/6_callbacks/callbacks.py +++ b/examples/6_callbacks/callbacks.py @@ -13,7 +13,7 @@ def get_bar_data(symbol, timeframe): class API: def __init__(self): - self.chart = None # Changes after each callback. + self.chart: Chart = None # Changes after each callback. async def on_search(self, searched_string): # Called when the user searches. new_data = get_bar_data(searched_string, self.chart.topbar['timeframe'].value) @@ -26,13 +26,16 @@ class API: new_data = get_bar_data(self.chart.topbar['symbol'].value, self.chart.topbar['timeframe'].value) if new_data.empty: return - self.chart.set(new_data) + self.chart.set(new_data, render_drawings=True) + + async def on_horizontal_line_move(self, line_id, price): + print(f'Horizontal line moved to: {price}') async def main(): api = API() - chart = Chart(api=api, topbar=True, searchbox=True) + chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True) chart.legend(True) chart.topbar.textbox('symbol', 'TSLA') @@ -41,6 +44,8 @@ async def main(): df = get_bar_data('TSLA', '5min') chart.set(df) + chart.horizontal_line(200, interactive=True) + await chart.show_async(block=True) diff --git a/lightweight_charts/abstract.py b/lightweight_charts/abstract.py index 07e5cdf..ed4a46b 100644 --- a/lightweight_charts/abstract.py +++ b/lightweight_charts/abstract.py @@ -11,7 +11,7 @@ from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, C JS = {} current_dir = os.path.dirname(os.path.abspath(__file__)) -for file in ('pkg', 'funcs', 'callback'): +for file in ('pkg', 'funcs', 'callback', 'toolbox'): with open(os.path.join(current_dir, 'js', f'{file}.js'), 'r', encoding='utf-8') as f: JS[file] = f.read() @@ -59,10 +59,15 @@ class SeriesCommon: }} ''') - def _df_datetime_format(self, df: pd.DataFrame): + def _df_datetime_format(self, df: pd.DataFrame, exclude_lowercase=None): df = df.copy() + df.columns = df.columns.str.lower() + if exclude_lowercase: + df[exclude_lowercase] = df[exclude_lowercase.lower()] if 'date' in df.columns: df = df.rename(columns={'date': 'time'}) + elif 'time' not in df.columns: + df['time'] = df.index self._set_interval(df) df['time'] = self._datetime_format(df['time']) return df @@ -76,11 +81,14 @@ class SeriesCommon: def _datetime_format(self, arg: Union[pd.Series, str]): arg = pd.to_datetime(arg) - if self._interval != timedelta(days=1): - arg = arg.astype('int64') // 10 ** 9 if isinstance(arg, pd.Series) else arg.timestamp() - arg = self._interval.total_seconds() * (arg // self._interval.total_seconds()) + if self._interval < timedelta(days=1): + if isinstance(arg, pd.Series): + arg = arg.astype('int64') // 10 ** 9 + else: + arg = self._interval.total_seconds() * (arg.timestamp() // self._interval.total_seconds()) else: arg = arg.dt.strftime('%Y-%m-%d') if isinstance(arg, pd.Series) else arg.strftime('%Y-%m-%d') + return arg def marker(self, time: datetime = None, position: MARKER_POSITION = 'below', shape: MARKER_SHAPE = 'arrow_up', @@ -123,28 +131,21 @@ class SeriesCommon: }} }});''') - def horizontal_line(self, price: Union[float, int], color: str = 'rgb(122, 146, 202)', width: int = 1, - style: LINE_STYLE = 'solid', text: str = '', axis_label_visible=True): + def horizontal_line(self, price: Union[float, int], color: str = 'rgb(122, 146, 202)', width: int = 2, + style: LINE_STYLE = 'solid', text: str = '', axis_label_visible: bool = True, interactive: bool = False) -> 'HorizontalLine': """ Creates a horizontal line at the given price.\n """ - line_id = self._rand.generate() - self.run_script(f""" - makeHorizontalLine({self.id}, '{line_id}', {price}, '{color}', {width}, {_line_style(style)}, {_js_bool(axis_label_visible)}, '{text}') - """) - return line_id + return HorizontalLine(self, price, color, width, style, text, axis_label_visible, interactive) - def remove_horizontal_line(self, price: Union[float, int]): + def remove_horizontal_line(self, price: Union[float, int] = None): """ Removes a horizontal line at the given price. """ self.run_script(f''' {self.id}.horizontal_lines.forEach(function (line) {{ - if ({price} === line.price) {{ - {self.id}.series.removePriceLine(line.line); - {self.id}.horizontal_lines.splice({self.id}.horizontal_lines.indexOf(line), 1) - }} - }});''') + if ({price} === line.price) line.deleteLine() + }})''') def clear_markers(self): """ @@ -161,13 +162,22 @@ class SeriesCommon: {self.id}.horizontal_lines = []; ''') - def title(self, title: str): self.run_script(f'{self.id}.series.applyOptions({{title: "{title}"}})') - - def price_line(self, label_visible: bool = True, line_visible: bool = True): + def price_line(self, label_visible: bool = True, line_visible: bool = True, title: str = ''): self.run_script(f''' {self.id}.series.applyOptions({{ lastValueVisible: {_js_bool(label_visible)}, priceLineVisible: {_js_bool(line_visible)}, + title: '{title}', + }})''') + + def precision(self, precision: int): + """ + Sets the precision and minMove.\n + :param precision: The number of decimal places. + """ + self.run_script(f''' + {self.id}.series.applyOptions({{ + priceFormat: {{precision: {precision}, minMove: {1 / (10 ** precision)}}} }})''') def hide_data(self): self._toggle_data(False) @@ -180,47 +190,79 @@ class SeriesCommon: {f'{self.id}.volumeSeries.applyOptions({{visible: {_js_bool(arg)}}})' if hasattr(self, 'volume_enabled') and self.volume_enabled else ''} ''') +class HorizontalLine: + def __init__(self, chart, price, color, width, style, text, axis_label_visible, interactive): + self._chart = chart + self.id = self._chart._rand.generate() + self._chart.run_script(f''' + {self.id} = new HorizontalLine({self._chart.id}, '{self.id}', {price}, '{color}', {width}, {_line_style(style)}, {_js_bool(axis_label_visible)}, '{text}') + ''') + if not interactive: return + self._chart.run_script(f'if ("toolBox" in {self._chart.id}) {self._chart.id}.toolBox.drawings.push({self.id})') + + def update(self, price): + """ + Moves the horizontal line to the given price. + """ + self._chart.run_script(f'{self.id}.updatePrice({price})') + + def delete(self): + """ + Irreversibly deletes the horizontal line. + """ + self._chart.run_script(f'{self.id}.deleteLine()') + del self + class Line(SeriesCommon): - def __init__(self, chart, color, width, price_line, price_label): + def __init__(self, chart, color, width, price_line, price_label, crosshair_marker=True): self.color = color self.name = '' self._chart = chart self._rand = chart._rand - self.id = f'window.{self._rand.generate()}' + self.id = self._rand.generate() self.run_script = self._chart.run_script self.run_script(f''' - {self.id} = {{ - series: {self._chart.id}.chart.addLineSeries({{ - color: '{color}', - lineWidth: {width}, - lastValueVisible: {_js_bool(price_label)}, - priceLineVisible: {_js_bool(price_line)}, - {"""autoscaleInfoProvider: () => ({ - priceRange: { - minValue: 1_000_000_000, - maxValue: 0, - }, - }),""" if self._chart._scale_candles_only else ''} - }}), - markers: [], - horizontal_lines: [], - }}''') + {self.id} = {{ + series: {self._chart.id}.chart.addLineSeries({{ + color: '{color}', + lineWidth: {width}, + lastValueVisible: {_js_bool(price_label)}, + priceLineVisible: {_js_bool(price_line)}, + crosshairMarkerVisible: {_js_bool(crosshair_marker)}, + {"""autoscaleInfoProvider: () => ({ + priceRange: { + minValue: 1_000_000_000, + maxValue: 0, + }, + }),""" if self._chart._scale_candles_only else ''} + }}), + markers: [], + horizontal_lines: [], + name: '', + color: '{color}', + }} + {self._chart.id}.lines.push({self.id}) + if ('legend' in {self._chart.id}) {{ + {self._chart.id}.legend.makeLines({self._chart.id}) + }} + ''') - def set(self, data: pd.DataFrame, name=None): + + def set(self, data: pd.DataFrame, name=''): """ Sets the line data.\n :param data: If the name parameter is not used, the columns should be named: date/time, value. :param name: The column of the DataFrame to use as the line value. When used, the Line will be named after this column. """ - df = self._df_datetime_format(data) + df = self._df_datetime_format(data, exclude_lowercase=name) if name: if name not in data: raise NameError(f'No column named "{name}".') self.name = name df = df.rename(columns={name: 'value'}) self._last_bar = df.iloc[-1] - self.run_script(f'{self.id}.series.setData({df.to_dict("records")})') + self.run_script(f'{self.id}.series.setData({df.to_dict("records")}); {self.id}.name = "{name}"') def update(self, series: pd.Series): """ @@ -231,11 +273,23 @@ class Line(SeriesCommon): self._last_bar = series self.run_script(f'{self.id}.series.update({series.to_dict()})') + def _set_trend(self, start_time, start_value, end_time, end_value, ray=False): + def time_format(time_val): + time_val = self._chart._datetime_format(time_val) + return f"'{time_val}'" if isinstance(time_val, str) else time_val + self.run_script(f''' + let logical + if ({_js_bool(ray)}) logical = {self._chart.id}.chart.timeScale().getVisibleLogicalRange() + {self.id}.series.setData(calculateTrendLine({time_format(start_time)}, {start_value}, {time_format(end_time)}, {end_value}, + {self._chart._interval.total_seconds()*1000}, {self._chart.id}, {_js_bool(ray)})) + if (logical) {self._chart.id}.chart.timeScale().setVisibleLogicalRange(logical) + ''') + def delete(self): """ Irreversibly deletes the line, as well as the object that contains the line. """ - self._chart._lines.remove(self) + self._chart._lines.remove(self) if self in self._chart._lines else None self.run_script(f''' {self._chart.id}.chart.removeSeries({self.id}.series) delete {self.id} @@ -253,7 +307,7 @@ class TextWidget(Widget): def __init__(self, topbar, initial_text): super().__init__(topbar) self.value = initial_text - self.id = f"window.{self._chart._rand.generate()}" + self.id = self._chart._rand.generate() self._chart.run_script(f'''{self.id} = makeTextBoxWidget({self._chart.id}, "{initial_text}")''') def set(self, string): @@ -267,9 +321,9 @@ class SwitcherWidget(Widget): self.value = default self._method = method.__name__ self._chart.run_script(f''' - makeSwitcher({self._chart.id}, {list(options)}, '{default}', {self._chart._js_api_code}, '{method.__name__}', + makeSwitcher({self._chart.id}, {list(options)}, '{default}', '{method.__name__}', '{topbar.active_background_color}', '{topbar.active_text_color}', '{topbar.text_color}', '{topbar.hover_color}') - {self._chart.id}.chart.resize(window.innerWidth*{self._chart._inner_width}, (window.innerHeight*{self._chart._inner_height})-{self._chart.id}.topBar.offsetHeight) + reSize({self._chart.id}) ''') @@ -279,8 +333,7 @@ class TopBar: self._widgets: Dict[str, Widget] = {} self._chart.run_script(f''' makeTopBar({self._chart.id}) - {self._chart.id}.chart.resize(window.innerWidth*{self._chart._inner_width}, - (window.innerHeight*{self._chart._inner_height})-{self._chart.id}.topBar.offsetHeight) + reSize({self._chart.id}) ''') self.active_background_color = 'rgba(0, 122, 255, 0.7)' self.active_text_color = 'rgb(240, 240, 240)' @@ -302,15 +355,17 @@ class TopBar: class LWC(SeriesCommon): def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False, - scale_candles_only: bool = False): + scale_candles_only: bool = False, topbar: bool = False, searchbox: bool = False, toolbox: bool = False, + _js_api_code: str = '""', autosize=True, _run_script=None): self.volume_enabled = volume_enabled self._scale_candles_only = scale_candles_only self._inner_width = inner_width self._inner_height = inner_height self._dynamic_loading = dynamic_loading - + if _run_script: + self.run_script = _run_script self._rand = IDGen() - self.id = f'window.{self._rand.generate()}' + self.id = self._rand.generate() self._position = 'left' self.loaded = False self._html = HTML @@ -321,7 +376,7 @@ class LWC(SeriesCommon): self._interval = None self._charts = {self.id: self} self._lines = [] - self._js_api_code = None + self._js_api_code = _js_api_code self._return_q = None self._background_color = '#000000' @@ -331,6 +386,21 @@ class LWC(SeriesCommon): from lightweight_charts.polygon import PolygonAPI self.polygon: PolygonAPI = PolygonAPI(self) + self.run_script(f''' + {self.id} = makeChart({self._js_api_code}, {self._inner_width}, {self._inner_height}, autoSize={_js_bool(autosize)}) + {self.id}.id = '{self.id}' + {self.id}.wrapper.style.float = "{self._position}" + ''') + if toolbox: + self.run_script(JS['toolbox']) + self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})') + if not topbar and not searchbox: + return + self.run_script(JS['callback']) + self.run_script(f'makeSpinner({self.id})') + self.topbar = TopBar(self) if topbar else None + self.run_script(f'{self.id}.search = makeSearchBox({self.id})') if searchbox else None + def _on_js_load(self): if self.loaded: return @@ -338,16 +408,6 @@ class LWC(SeriesCommon): [self.run_script(script) for script in self._scripts] [self.run_script(script) for script in self._final_scripts] - def _create_chart(self, autosize=True): - self.run_script(f''' - {self.id} = makeChart({self._inner_width}, {self._inner_height}, autoSize={_js_bool(autosize)}) - {self.id}.id = '{self.id}' - {self.id}.wrapper.style.float = "{self._position}" - ''') - - def _make_search_box(self): - self.run_script(f'{self.id}.search = makeSearchBox({self.id}, {self._js_api_code})') - def run_script(self, script, run_last=False): """ For advanced users; evaluates JavaScript within the Webview. @@ -357,14 +417,17 @@ class LWC(SeriesCommon): return self._scripts.append(script) if not run_last else self._final_scripts.append(script) - def set(self, df: pd.DataFrame): + def set(self, df: pd.DataFrame = None, render_drawings=False): """ Sets the initial data for the chart.\n :param df: columns: date/time, open, high, low, close, volume (if volume enabled). + :param render_drawings: Re-renders any drawings made through the toolbox. Otherwise, they will be deleted. """ - if df.empty: + + if df.empty or df is None: self.run_script(f'{self.id}.series.setData([])') self.run_script(f'{self.id}.volumeSeries.setData([])') + # self.run_script(f"if ('toolBox' in {self.id}) {self.id}.toolBox.{render_or_clear}()") return bars = self._df_datetime_format(df) self._last_bar = bars.iloc[-1] @@ -416,6 +479,7 @@ class LWC(SeriesCommon): }}, 50); }}); ''') if self._dynamic_loading else self.run_script(f'{self.id}.candleData = {bars}; {self.id}.series.setData({self.id}.candleData)') + self.run_script(f"if ('toolBox' in {self.id}) {self.id}.toolBox.{'clearDrawings' if not render_drawings else 'renderDrawings'}()") def fit(self): """ @@ -446,7 +510,6 @@ class LWC(SeriesCommon): let barsInfo = {self.id}.series.barsInLogicalRange(logicalRange); if ({self.id}.candleData[{self.id}.candleData.length-1].time === {bar['time']}) {{ - {self.id}.shownData[{self.id}.shownData.length-1] = {bar} {self.id}.candleData[{self.id}.candleData.length-1] = {bar} }} @@ -461,7 +524,15 @@ class LWC(SeriesCommon): {self.id}.candleData.push({bar}) }} {self.id}.series.update({self.id}.shownData[{self.id}.shownData.length-1]) - ''') if self._dynamic_loading else self.run_script(f'{self.id}.series.update({bar})') + ''') if self._dynamic_loading else self.run_script(f''' + if (chartTimeToDate({self.id}.candleData[{self.id}.candleData.length-1].time).getTime() === chartTimeToDate({bar['time']}).getTime()) {{ + {self.id}.candleData[{self.id}.candleData.length-1] = {bar} + }} + else {{ + {self.id}.candleData.push({bar}) + }} + {self.id}.series.update({bar}) + ''') def update_from_tick(self, series, cumulative_volume=False): """ @@ -504,6 +575,16 @@ class LWC(SeriesCommon): """ return self._lines.copy() + def trend_line(self, start_time, start_value, end_time, end_value, color: str = '#1E80F0', width: int = 2) -> Line: + line = Line(self, color, width, price_line=False, price_label=False, crosshair_marker=False) + line._set_trend(start_time, start_value, end_time, end_value, ray=False) + return line + + def ray_line(self, start_time, value, color: str = '#1E80F0', width: int = 2) -> Line: + line = Line(self, color, width, price_line=False, price_label=False, crosshair_marker=False) + line._set_trend(start_time, value, start_time, value, ray=True) + return line + def price_scale(self, mode: PRICE_SCALE_MODE = 'normal', align_labels: bool = True, border_visible: bool = False, border_color: str = None, text_color: str = None, entire_text_only: bool = False, ticks_visible: bool = False, scale_margin_top: float = 0.2, scale_margin_bottom: float = 0.2): @@ -657,48 +738,16 @@ class LWC(SeriesCommon): }} }})''') - def legend(self, visible: bool = False, ohlc: bool = True, percent: bool = True, lines: bool = True, color: str = None, - font_size: int = None, font_family: str = None): + def legend(self, visible: bool = False, ohlc: bool = True, percent: bool = True, lines: bool = True, color: str = 'rgb(191, 195, 203)', + font_size: int = 11, font_family: str = 'Monaco'): """ Configures the legend of the chart. """ if not visible: return - lines_code = '' - for i, line in enumerate(self._lines): - lines_code += f'''finalString += `{f' {line.name}'} : - ${{legendItemFormat(param.seriesData.get({line.id}.series).value)}}
`;''' - self.run_script(f''' - {f"{self.id}.legend.style.color = '{color}'" if color else ''} - {f"{self.id}.legend.style.fontSize = {font_size}" if font_size else ''} - {f"{self.id}.legend.style.fontFamily = '{font_family}'" if font_family else ''} - - legendItemFormat = (num) => num.toFixed(2).toString().padStart(8, ' ') - - {self.id}.chart.subscribeCrosshairMove((param) => {{ - if (param.time){{ - let data = param.seriesData.get({self.id}.series); - let finalString = '' - if (data) {{ - let ohlc = `O ${{legendItemFormat(data.open)}} - | H ${{legendItemFormat(data.high)}} - | L ${{legendItemFormat(data.low)}} - | C ${{legendItemFormat(data.close)}} ` - let percentMove = ((data.close-data.open)/data.open)*100 - let percent = `| ${{percentMove >= 0 ? '+' : ''}}${{percentMove.toFixed(2)}} %` - - {'finalString += ohlc' if ohlc else ''} - {'finalString += percent' if percent else ''} - {'finalString += "
"' if ohlc or percent else ''} - }} - {lines_code if lines else ''} - {self.id}.legend.innerHTML = finalString+'
' - }} - else {{ - {self.id}.legend.innerHTML = '' - }} - }});''') + {self.id}.legend = new Legend({self.id}, {_js_bool(ohlc)}, {_js_bool(percent)}, {_js_bool(lines)}, '{color}', {font_size}, '{font_family}') + ''') def spinner(self, visible): self.run_script(f"{self.id}.spinner.style.display = '{'block' if visible else 'none'}'") @@ -721,30 +770,24 @@ class LWC(SeriesCommon): return b64decode(serial_data.split(',')[1]) def create_subchart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', - width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False, - topbar: bool = False, searchbox: bool = False): - subchart = SubChart(self, volume_enabled, position, width, height, sync, topbar, searchbox) + width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False, dynamic_loading: bool = False, + scale_candles_only: bool = False, topbar: bool = False, searchbox: bool = False, toolbox: bool = False): + subchart = SubChart(self, volume_enabled, position, width, height, sync, dynamic_loading, scale_candles_only, topbar, searchbox, toolbox) self._charts[subchart.id] = subchart return subchart class SubChart(LWC): - def __init__(self, parent, volume_enabled, position, width, height, sync, topbar, searchbox): - super().__init__(volume_enabled, width, height) + def __init__(self, parent, volume_enabled, position, width, height, sync, dynamic_loading, scale_candles_only, topbar, searchbox, toolbox): self._chart = parent._chart if isinstance(parent, SubChart) else parent + super().__init__(volume_enabled, width, height, dynamic_loading, scale_candles_only, topbar, searchbox, toolbox, + self._chart._js_api_code, _run_script=self._chart.run_script) self._parent = parent self._position = position - self._rand = self._chart._rand - self._js_api_code = self._chart._js_api_code self._return_q = self._chart._return_q - self.run_script = self._chart.run_script self._charts = self._chart._charts - self.id = f'window.{self._rand.generate()}' self.polygon = self._chart.polygon._subchart(self) - self._create_chart() - self.topbar = TopBar(self) if topbar else None - self._make_search_box() if searchbox else None if not sync: return sync_parent_id = self._parent.id if isinstance(sync, bool) else sync @@ -757,6 +800,3 @@ class SubChart(LWC): self.run_script(f''' {self.id}.chart.timeScale().setVisibleLogicalRange({sync_parent_id}.chart.timeScale().getVisibleLogicalRange()) ''', run_last=True) - - - diff --git a/lightweight_charts/chart.py b/lightweight_charts/chart.py index 2f9548f..d89ec19 100644 --- a/lightweight_charts/chart.py +++ b/lightweight_charts/chart.py @@ -2,7 +2,7 @@ import asyncio import multiprocessing as mp import webview -from lightweight_charts.abstract import LWC, JS, TopBar +from lightweight_charts.abstract import LWC class CallbackAPI: @@ -48,25 +48,17 @@ class PyWV: class Chart(LWC): def __init__(self, volume_enabled: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None, on_top: bool = False, maximize: bool = False, debug: bool = False, - api: object = None, topbar: bool = False, searchbox: bool = False, + api: object = None, topbar: bool = False, searchbox: bool = False, toolbox: bool = False, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False, scale_candles_only: bool = False): - super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading, scale_candles_only) + super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading, scale_candles_only, topbar, searchbox, toolbox, 'pywebview.api.callback') self._q, self._emit_q, self._return_q = (mp.Queue() for _ in range(3)) self._exit, self._loaded = mp.Event(), mp.Event() self._script_func = self._q.put self._api = api - self._js_api_code = 'pywebview.api.callback' self._process = mp.Process(target=PyWV, args=(self._q, self._exit, self._loaded, self._html, width, height, x, y, on_top, maximize, debug, self._emit_q, self._return_q), daemon=True) self._process.start() - self._create_chart() - if not topbar and not searchbox: - return - self.run_script(JS['callback']) - self.run_script(f'makeSpinner({self.id})') - self.topbar = TopBar(self) if topbar else None - self._make_search_box() if searchbox else None def show(self, block: bool = False): """ @@ -101,7 +93,7 @@ class Chart(LWC): widget.value = arg await getattr(self._api, key)() else: - await getattr(self._api, key)(arg) + await getattr(self._api, key)(*arg.split(';;;')) continue value = self.polygon._q.get() func, args = value[0], value[1:] diff --git a/lightweight_charts/js/callback.js b/lightweight_charts/js/callback.js index 3f30dbe..d552668 100644 --- a/lightweight_charts/js/callback.js +++ b/lightweight_charts/js/callback.js @@ -1,4 +1,4 @@ -function makeSearchBox(chart, callbackFunction) { +function makeSearchBox(chart) { let searchWindow = document.createElement('div') searchWindow.style.position = 'absolute' searchWindow.style.top = '0' @@ -49,39 +49,28 @@ function makeSearchBox(chart, callbackFunction) { chart.wrapper.addEventListener('mouseout', (event) => { selectedChart = false }) - document.addEventListener('keydown', function(event) { - if (!selectedChart) {return} - if (event.altKey && event.code === 'KeyH') { - let price = chart.series.coordinateToPrice(yPrice) - - let colorList = [ - 'rgba(228, 0, 16, 0.7)', - 'rgba(255, 133, 34, 0.7)', - 'rgba(164, 59, 176, 0.7)', - 'rgba(129, 59, 102, 0.7)', - 'rgba(91, 20, 248, 0.7)', - 'rgba(32, 86, 249, 0.7)', - ] - let color = colorList[Math.floor(Math.random()*colorList.length)] - - makeHorizontalLine(chart, 0, price, color, 2, LightweightCharts.LineStyle.Solid, true, '') - } + chart.commandFunctions.push((event) => { if (searchWindow.style.display === 'none') { if (/^[a-zA-Z0-9]$/.test(event.key)) { searchWindow.style.display = 'flex'; sBox.focus(); + return true } + else return false } else if (event.key === 'Enter') { - callbackFunction(`on_search__${chart.id}__${sBox.value}`) + chart.callbackFunction(`on_search__${chart.id}__${sBox.value}`) searchWindow.style.display = 'none' sBox.value = '' + return true } else if (event.key === 'Escape') { searchWindow.style.display = 'none' sBox.value = '' + return true } - }); + else return false + }) sBox.addEventListener('input', function() { sBox.value = sBox.value.toUpperCase(); }); @@ -115,7 +104,7 @@ function makeSpinner(chart) { animateSpinner(); } -function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName, activeBackgroundColor, activeColor, inactiveColor, hoverColor) { +function makeSwitcher(chart, items, activeItem, callbackName, activeBackgroundColor, activeColor, inactiveColor, hoverColor) { let switcherElement = document.createElement('div'); switcherElement.style.margin = '4px 14px' switcherElement.style.zIndex = '1000' @@ -155,7 +144,7 @@ function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName, element.style.color = items[index] === item ? 'activeColor' : inactiveColor }); activeItem = item; - callbackFunction(`${callbackName}__${chart.id}__${item}`); + chart.callbackFunction(`${callbackName}__${chart.id}__${item}`); } chart.topBar.appendChild(switcherElement) makeSeperator(chart.topBar) diff --git a/lightweight_charts/js/funcs.js b/lightweight_charts/js/funcs.js index b9ebcd2..972c116 100644 --- a/lightweight_charts/js/funcs.js +++ b/lightweight_charts/js/funcs.js @@ -1,14 +1,17 @@ -function makeChart(innerWidth, innerHeight, autoSize=true) { +function makeChart(callbackFunction, innerWidth, innerHeight, autoSize=true) { let chart = { markers: [], horizontal_lines: [], + lines: [], wrapper: document.createElement('div'), div: document.createElement('div'), - legend: document.createElement('div'), scale: { width: innerWidth, - height: innerHeight + height: innerHeight, }, + callbackFunction: callbackFunction, + candleData: [], + commandFunctions: [] } chart.chart = LightweightCharts.createChart(chart.div, { width: window.innerWidth*innerWidth, @@ -57,15 +60,6 @@ function makeChart(innerWidth, innerHeight, autoSize=true) { chart.volumeSeries.priceScale().applyOptions({ scaleMargins: {top: 0.8, bottom: 0}, }); - 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)' - chart.wrapper.style.width = `${100*innerWidth}%` chart.wrapper.style.height = `${100*innerHeight}%` chart.wrapper.style.display = 'flex' @@ -73,35 +67,190 @@ function makeChart(innerWidth, innerHeight, autoSize=true) { chart.div.style.position = 'relative' chart.div.style.display = 'flex' - - chart.div.appendChild(chart.legend) chart.wrapper.appendChild(chart.div) document.getElementById('wrapper').append(chart.wrapper) - if (!autoSize) return chart + document.addEventListener('keydown', (event) => { + for (let i=0; i reSize(chart)) return chart } -function makeHorizontalLine(chart, lineId, price, color, width, style, axisLabelVisible, text) { - let priceLine = { - price: price, - color: color, - lineWidth: width, - lineStyle: style, - axisLabelVisible: axisLabelVisible, - title: text, - }; - let line = { - line: chart.series.createPriceLine(priceLine), - price: price, - id: lineId, - }; - chart.horizontal_lines.push(line) + +function reSize(chart) { + let topBarOffset = 'topBar' in chart ? chart.topBar.offsetHeight : 0 + chart.chart.resize(window.innerWidth*chart.scale.width, (window.innerHeight*chart.scale.height)-topBarOffset) +} + +if (!window.HorizontalLine) { + class HorizontalLine { + constructor(chart, lineId, price, color, width, style, axisLabelVisible, text) { + this.updatePrice = this.updatePrice.bind(this) + this.deleteLine = this.deleteLine.bind(this) + this.chart = chart + this.price = price + this.id = lineId + this.priceLine = { + price: this.price, + color: color, + lineWidth: width, + lineStyle: style, + axisLabelVisible: axisLabelVisible, + title: text, + } + this.line = this.chart.series.createPriceLine(this.priceLine) + this.chart.horizontal_lines.push(this) + } + + updatePrice(price) { + this.chart.series.removePriceLine(this.line) + this.price = price + this.priceLine.price = this.price + this.line = this.chart.series.createPriceLine(this.priceLine) + } + + deleteLine() { + this.chart.series.removePriceLine(this.line) + this.chart.horizontal_lines.splice(this.chart.horizontal_lines.indexOf(this)) + delete this + } + } + + window.HorizontalLine = HorizontalLine + + class Legend { + constructor(chart, ohlcEnabled = true, percentEnabled = true, linesEnabled = true, + color = 'rgb(191, 195, 203)', fontSize = '11', fontFamily = 'Monaco') { + this.div = document.createElement('div') + this.div.style.position = 'absolute' + this.div.style.zIndex = '3000' + this.div.style.top = '10px' + this.div.style.left = '10px' + this.div.style.display = 'flex' + this.div.style.flexDirection = 'column' + this.div.style.width = `${(chart.scale.width * 100) - 8}vw` + this.div.style.color = color + this.div.style.fontSize = fontSize + 'px' + this.div.style.fontFamily = fontFamily + this.candle = document.createElement('div') + + this.div.appendChild(this.candle) + chart.div.appendChild(this.div) + + this.color = color + + this.linesEnabled = linesEnabled + this.makeLines(chart) + + let legendItemFormat = (num) => num.toFixed(2).toString().padStart(8, ' ') + + chart.chart.subscribeCrosshairMove((param) => { + if (param.time) { + let data = param.seriesData.get(chart.series); + let finalString = '' + if (data) { + this.candle.style.color = '' + let ohlc = `O ${legendItemFormat(data.open)} + | H ${legendItemFormat(data.high)} + | L ${legendItemFormat(data.low)} + | C ${legendItemFormat(data.close)} ` + let percentMove = ((data.close - data.open) / data.open) * 100 + let percent = `| ${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %` + + finalString += ohlcEnabled ? ohlc : '' + finalString += percentEnabled ? percent : '' + + } + this.candle.innerHTML = finalString + '' + this.lines.forEach((line) => { + if (!param.seriesData.get(line.line.series)) return + let price = legendItemFormat(param.seriesData.get(line.line.series).value) + line.div.innerHTML = ` ${line.line.name} : ${price}` + }) + + } else { + this.candle.style.color = 'transparent' + } + }); + } + + makeLines(chart) { + this.lines = [] + if (this.linesEnabled) { + chart.lines.forEach((line) => { + this.lines.push(this.makeLineRow(line)) + }) + } + } + + makeLineRow(line) { + let openEye = ` + + \` + ` + let closedEye = ` + + ` + + let row = document.createElement('div') + row.style.display = 'flex' + row.style.alignItems = 'center' + let div = document.createElement('div') + let toggle = document.createElement('div') + toggle.style.borderRadius = '4px' + toggle.style.marginLeft = '10px' + + + let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", "22"); + svg.setAttribute("height", "16"); + + let group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.innerHTML = openEye + + let on = true + toggle.addEventListener('click', (event) => { + if (on) { + on = false + group.innerHTML = closedEye + line.series.applyOptions({ + visible: false + }) + } else { + on = true + line.series.applyOptions({ + visible: true + }) + group.innerHTML = openEye + } + }) + toggle.addEventListener('mouseover', (event) => { + document.body.style.cursor = 'pointer' + toggle.style.backgroundColor = 'rgba(50, 50, 50, 0.5)' + }) + toggle.addEventListener('mouseleave', (event) => { + document.body.style.cursor = 'default' + toggle.style.backgroundColor = 'transparent' + }) + svg.appendChild(group) + toggle.appendChild(svg); + row.appendChild(div) + row.appendChild(toggle) + this.div.appendChild(row) + return { + div: div, + row: row, + toggle: toggle, + line: line, + } + } + } + + window.Legend = Legend } function syncCrosshairs(childChart, parentChart) { function crosshairHandler (e, thisChart, otherChart, otherHandler) { @@ -139,4 +288,109 @@ function syncCrosshairs(childChart, parentChart) { } parentChart.subscribeCrosshairMove(parentCrosshairHandler) childChart.subscribeCrosshairMove(childCrosshairHandler) -} \ No newline at end of file +} + +function chartTimeToDate(stampOrBusiness) { + if (typeof stampOrBusiness === 'number') { + stampOrBusiness = new Date(stampOrBusiness*1000) + } + else if (typeof stampOrBusiness === 'string') { + let [year, month, day] = stampOrBusiness.split('-').map(Number) + stampOrBusiness = new Date(Date.UTC(year, month-1, day)) + } + else { + stampOrBusiness = new Date(Date.UTC(stampOrBusiness.year, stampOrBusiness.month - 1, stampOrBusiness.day)) + } + return stampOrBusiness +} + +function dateToChartTime(date, interval) { + if (interval >= 24*60*60*1000) { + return {day: date.getUTCDate(), month: date.getUTCMonth()+1, year: date.getUTCFullYear()} + } + return Math.floor(date.getTime()/1000) +} + +function calculateTrendLine(startDate, startValue, endDate, endValue, interval, chart, ray=false) { + let reversed = false + if (chartTimeToDate(endDate).getTime() < chartTimeToDate(startDate).getTime()) { + reversed = true; + [startDate, endDate] = [endDate, startDate]; + } + let startIndex + if (chartTimeToDate(startDate).getTime() < chartTimeToDate(chart.candleData[0].time).getTime()) { + startIndex = 0 + } + else { + startIndex = chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(startDate).getTime()) + } + + if (startIndex === -1) { + return [] + } + let endIndex + if (ray) { + endIndex = chart.candleData.length+1000 + startValue = endValue + } + else { + endIndex = chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(endDate).getTime()) + if (endIndex === -1) { + let barsBetween = (chartTimeToDate(endDate)-chartTimeToDate(chart.candleData[chart.candleData.length-1].time))/interval + endIndex = chart.candleData.length-1+barsBetween + } + } + + let numBars = endIndex-startIndex + const rate_of_change = (endValue - startValue) / numBars; + const trendData = []; + let currentDate = null + let iPastData = 0 + for (let i = 0; i <= numBars; i++) { + if (chart.candleData[startIndex+i]) { + currentDate = chart.candleData[startIndex+i].time + } + else { + iPastData ++ + currentDate = dateToChartTime(new Date(chartTimeToDate(chart.candleData[chart.candleData.length-1].time).getTime()+(iPastData*interval)), interval) + + } + + const currentValue = reversed ? startValue + rate_of_change * (numBars - i) : startValue + rate_of_change * i; + trendData.push({ time: currentDate, value: currentValue }); + } + return trendData; +} + + +/* +let customMenu = document.createElement('div') +customMenu.style.position = 'absolute' +customMenu.style.zIndex = '10000' +customMenu.style.background = 'rgba(25, 25, 25, 0.7)' +customMenu.style.color = 'lightgrey' +customMenu.style.display = 'none' +customMenu.style.borderRadius = '5px' +customMenu.style.padding = '5px 10px' +document.body.appendChild(customMenu) + +function menuItem(text) { + let elem = document.createElement('div') + elem.innerText = text + customMenu.appendChild(elem) +} +menuItem('Delete drawings') +menuItem('Hide all indicators') +menuItem('Save Chart State') + +let closeMenu = (event) => {if (!customMenu.contains(event.target)) customMenu.style.display = 'none';} +document.addEventListener('contextmenu', function (event) { + event.preventDefault(); // Prevent default right-click menu + customMenu.style.left = event.clientX + 'px'; + customMenu.style.top = event.clientY + 'px'; + customMenu.style.display = 'block'; + document.removeEventListener('click', closeMenu) + document.addEventListener('click', closeMenu) + }); + +*/ diff --git a/lightweight_charts/js/toolbox.js b/lightweight_charts/js/toolbox.js new file mode 100644 index 0000000..4d0e323 --- /dev/null +++ b/lightweight_charts/js/toolbox.js @@ -0,0 +1,484 @@ +if (!window.ToolBox) { + class ToolBox { + constructor(chart) { + this.onTrendSelect = this.onTrendSelect.bind(this) + this.onHorzSelect = this.onHorzSelect.bind(this) + this.onRaySelect = this.onRaySelect.bind(this) + + this.chart = chart + this.drawings = [] + this.chart.cursor = 'default' + this.makingDrawing = false + + this.interval = 24 * 60 * 60 * 1000 + this.activeBackgroundColor = 'rgba(0, 122, 255, 0.7)' + this.activeIconColor = 'rgb(240, 240, 240)' + this.iconColor = 'lightgrey' + this.backgroundColor = 'transparent' + this.hoverColor = 'rgba(60, 60, 60, 0.7)' + + this.elem = this.makeToolBox() + this.subscribeHoverMove() + } + + makeToolBox() { + let toolBoxElem = document.createElement('div') + toolBoxElem.style.position = 'absolute' + toolBoxElem.style.zIndex = '2000' + toolBoxElem.style.display = 'flex' + toolBoxElem.style.alignItems = 'center' + toolBoxElem.style.top = '25%' + toolBoxElem.style.borderRight = '2px solid #3C434C' + toolBoxElem.style.borderTop = '2px solid #3C434C' + toolBoxElem.style.borderBottom = '2px solid #3C434C' + toolBoxElem.style.borderTopRightRadius = '4px' + toolBoxElem.style.borderBottomRightRadius = '4px' + toolBoxElem.style.backgroundColor = 'rgba(25, 27, 30, 0.5)' + toolBoxElem.style.flexDirection = 'column' + + this.chart.activeIcon = null + + let trend = this.makeToolBoxElement(this.onTrendSelect, 'KeyT', ``) + let horz = this.makeToolBoxElement(this.onHorzSelect, 'KeyH', ``) + let ray = this.makeToolBoxElement(this.onRaySelect, 'KeyR', ``) + //let testB = this.makeToolBoxElement(this.onTrendSelect, ``) + toolBoxElem.appendChild(trend) + toolBoxElem.appendChild(horz) + toolBoxElem.appendChild(ray) + //toolBoxElem.appendChild(testB) + + this.chart.div.append(toolBoxElem) + + let commandZHandler = (toDelete) => { + if (!toDelete) return + if ('price' in toDelete) { + if (toDelete.id !== 'toolBox') return commandZHandler(this.drawings[this.drawings.indexOf(toDelete) - 1]) + this.chart.series.removePriceLine(toDelete.line) + } else { + let logical + if (toDelete.ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange() + this.chart.chart.removeSeries(toDelete.line); + if (toDelete.ray) this.chart.chart.timeScale().setVisibleLogicalRange(logical) + } + this.drawings.splice(this.drawings.length - 1) + } + this.chart.commandFunctions.push((event) => { + if (event.metaKey && event.code === 'KeyZ') { + commandZHandler(this.drawings[this.drawings.length - 1]) + return true + } + }); + + return toolBoxElem + } + + makeToolBoxElement(action, keyCmd, paths) { + let elem = document.createElement('div') + elem.style.margin = '3px' + elem.style.borderRadius = '4px' + elem.style.display = 'flex' + + let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", "29"); + svg.setAttribute("height", "29"); + + let group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.innerHTML = paths + group.setAttribute("fill", this.iconColor) + + svg.appendChild(group) + elem.appendChild(svg); + + elem.addEventListener('mouseenter', () => { + elem.style.backgroundColor = elem === this.chart.activeIcon ? this.activeBackgroundColor : this.hoverColor + document.body.style.cursor = 'pointer' + }) + elem.addEventListener('mouseleave', () => { + elem.style.backgroundColor = elem === this.chart.activeIcon ? this.activeBackgroundColor : this.backgroundColor + document.body.style.cursor = this.chart.cursor + }) + elem.addEventListener('click', () => { + if (this.chart.activeIcon) { + this.chart.activeIcon.style.backgroundColor = this.backgroundColor + group.setAttribute("fill", this.iconColor) + document.body.style.cursor = 'crosshair' + this.chart.cursor = 'crosshair' + action(false) + } + if (this.chart.activeIcon === elem) { + this.chart.activeIcon = null + return + } + this.chart.activeIcon = elem + group.setAttribute("fill", this.activeIconColor) + elem.style.backgroundColor = this.activeBackgroundColor + document.body.style.cursor = 'crosshair' + this.chart.cursor = 'crosshair' + action(true) + }) + this.chart.commandFunctions.push((event) => { + if (event.altKey && event.code === keyCmd) { + if (this.chart.activeIcon) { + this.chart.activeIcon.style.backgroundColor = this.backgroundColor + group.setAttribute("fill", this.iconColor) + document.body.style.cursor = 'crosshair' + this.chart.cursor = 'crosshair' + action(false) + } + this.chart.activeIcon = elem + group.setAttribute("fill", this.activeIconColor) + elem.style.backgroundColor = this.activeBackgroundColor + document.body.style.cursor = 'crosshair' + this.chart.cursor = 'crosshair' + action(true) + return true + } + }) + return elem + } + + onTrendSelect(toggle, ray = false) { + let trendLine = { + line: null, + markers: null, + data: null, + from: null, + to: null, + ray: ray, + } + let firstTime = null + let firstPrice = null + let currentTime = null + + + if (!toggle) { + this.chart.chart.unsubscribeClick(this.chart.clickHandler) + return + } + let crosshairHandlerTrend = (param) => { + this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerTrend) + + if (!this.makingDrawing) return + + let logical + let lastCandleTime = this.chart.candleData[this.chart.candleData.length - 1].time + currentTime = this.chart.chart.timeScale().coordinateToTime(param.point.x) + if (!currentTime) { + let barsToMove = param.logical - this.chart.chart.timeScale().coordinateToLogical(this.chart.chart.timeScale().timeToCoordinate(lastCandleTime)) + logical = barsToMove <= 0 ? null : this.chart.chart.timeScale().getVisibleLogicalRange() + currentTime = dateToChartTime(new Date(chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime() + (barsToMove * this.interval)), this.interval) + } else if (chartTimeToDate(lastCandleTime).getTime() <= chartTimeToDate(currentTime).getTime()) { + logical = this.chart.chart.timeScale().getVisibleLogicalRange() + } + + let currentPrice = this.chart.series.coordinateToPrice(param.point.y) + + + if (!currentTime) return this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) + trendLine.data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart, ray) + trendLine.from = trendLine.data[0].time + trendLine.to = trendLine.data[trendLine.data.length - 1].time + + + if (ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange() + + + trendLine.line.setData(trendLine.data) + + if (logical) { + this.chart.chart.applyOptions({handleScroll: true}) + setTimeout(() => { + this.chart.chart.timeScale().setVisibleLogicalRange(logical) + }, 1) + setTimeout(() => { + this.chart.chart.applyOptions({handleScroll: false}) + }, 50) + } + if (!ray) { + trendLine.markers = [ + {time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}, + {time: currentTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1} + ] + trendLine.line.setMarkers(trendLine.markers) + } + setTimeout(() => { + this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) + }, 10); + } + + this.chart.clickHandler = (param) => { + if (!this.makingDrawing) { + this.makingDrawing = true + trendLine.line = this.chart.chart.addLineSeries({ + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + autoscaleInfoProvider: () => ({ + priceRange: { + minValue: 1_000_000_000, + maxValue: 0, + }, + }), + }) + firstPrice = this.chart.series.coordinateToPrice(param.point.y) + firstTime = !ray ? this.chart.chart.timeScale().coordinateToTime(param.point.x) : this.chart.candleData[this.chart.candleData.length - 1].time + this.chart.chart.applyOptions({ + handleScroll: false + }) + this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) + } else { + this.chart.chart.applyOptions({ + handleScroll: true + }) + this.makingDrawing = false + trendLine.line.setMarkers([]) + this.drawings.push(trendLine) + this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerTrend) + this.chart.chart.unsubscribeClick(this.chart.clickHandler) + document.body.style.cursor = 'default' + this.chart.cursor = 'default' + this.chart.activeIcon.style.backgroundColor = this.backgroundColor + this.chart.activeIcon = null + } + } + this.chart.chart.subscribeClick(this.chart.clickHandler) + } + + onHorzSelect(toggle) { + let clickHandlerHorz = (param) => { + let price = this.chart.series.coordinateToPrice(param.point.y) + let lineStyle = LightweightCharts.LineStyle.Solid + let line = new HorizontalLine(this.chart, 'toolBox', price, null, 2, lineStyle, true) + this.drawings.push(line) + this.chart.chart.unsubscribeClick(clickHandlerHorz) + document.body.style.cursor = 'default' + this.chart.cursor = 'default' + this.chart.activeIcon.style.backgroundColor = this.backgroundColor + this.chart.activeIcon = null + } + this.chart.chart.subscribeClick(clickHandlerHorz) + } + + onRaySelect(toggle) { + this.onTrendSelect(toggle, true) + } + + subscribeHoverMove() { + let hoveringOver = null + let x, y + let hoverOver = (param) => { + if (!param.point || this.makingDrawing) return + this.chart.chart.unsubscribeCrosshairMove(hoverOver) + x = param.point.x + y = param.point.y + + this.drawings.forEach((drawing) => { + let boundaryConditional + let horizontal = false + + if ('price' in drawing) { + horizontal = true + let priceCoordinate = this.chart.series.priceToCoordinate(drawing.price) + boundaryConditional = Math.abs(priceCoordinate - param.point.y) < 6 + } else { + let trendData = param.seriesData.get(drawing.line); + if (!trendData) return + let priceCoordinate = this.chart.series.priceToCoordinate(trendData.value) + let timeCoordinate = this.chart.chart.timeScale().timeToCoordinate(trendData.time) + boundaryConditional = Math.abs(priceCoordinate - param.point.y) < 6 && Math.abs(timeCoordinate - param.point.x) < 6 + } + + if (boundaryConditional) { + if (hoveringOver === drawing) return + + if (!horizontal && !drawing.ray) drawing.line.setMarkers(drawing.markers) + document.body.style.cursor = 'pointer' + document.addEventListener('mousedown', checkForClick) + document.addEventListener('mouseup', checkForRelease) + hoveringOver = drawing + } else if (hoveringOver === drawing) { + if (!horizontal && !drawing.ray) drawing.line.setMarkers([]) + document.body.style.cursor = this.chart.cursor + hoveringOver = null + } + }) + this.chart.chart.subscribeCrosshairMove(hoverOver) + } + let originalIndex + let originalTime + let originalPrice + let mouseDown = false + let clickedEnd = false + let checkForClick = (event) => { + //if (!hoveringOver) return + mouseDown = true + document.body.style.cursor = 'grabbing' + this.chart.chart.applyOptions({ + handleScroll: false + }) + + this.chart.chart.unsubscribeCrosshairMove(hoverOver) + + // let [x, y] = [event.clientX, event.clientY] + // if ('topBar' in this.chart) y = y - this.chart.topBar.offsetHeight + if ('price' in hoveringOver) { + originalPrice = hoveringOver.price + this.chart.chart.subscribeCrosshairMove(crosshairHandlerHorz) + } else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.data[0].time) - x) < 4 && !hoveringOver.ray) { + clickedEnd = 'first' + this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) + } else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.data[hoveringOver.data.length - 1].time) - x) < 4 && !hoveringOver.ray) { + clickedEnd = 'last' + this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) + } else { + originalPrice = this.chart.series.coordinateToPrice(y) + originalTime = this.chart.chart.timeScale().coordinateToTime(x * this.chart.scale.width) + this.chart.chart.subscribeCrosshairMove(checkForDrag) + } + originalIndex = this.chart.chart.timeScale().coordinateToLogical(x) + this.chart.chart.unsubscribeClick(checkForClick) + } + let checkForRelease = (event) => { + mouseDown = false + document.body.style.cursor = 'pointer' + + this.chart.chart.applyOptions({handleScroll: true}) + if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') { + this.chart.callbackFunction(`on_horizontal_line_move__${this.chart.id}__${hoveringOver.id};;;${hoveringOver.price.toFixed(8)}`); + } + hoveringOver = null + document.removeEventListener('mousedown', checkForClick) + document.removeEventListener('mouseup', checkForRelease) + this.chart.chart.subscribeCrosshairMove(hoverOver) + } + let checkForDrag = (param) => { + if (!param.point) return + this.chart.chart.unsubscribeCrosshairMove(checkForDrag) + if (!mouseDown) return + + let priceAtCursor = this.chart.series.coordinateToPrice(param.point.y) + + let priceDiff = priceAtCursor - originalPrice + let barsToMove = param.logical - originalIndex + + let startBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.data[0].time).getTime()) + let endBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.data[hoveringOver.data.length - 1].time).getTime()) + + let startBar + let endBar + if (hoveringOver.ray) { + endBar = this.chart.candleData[startBarIndex + barsToMove] + startBar = hoveringOver.data[hoveringOver.data.length - 1] + } else { + startBar = this.chart.candleData[startBarIndex + barsToMove] + endBar = endBarIndex === -1 ? null : this.chart.candleData[endBarIndex + barsToMove] + } + + let endDate = endBar ? endBar.time : dateToChartTime(new Date(chartTimeToDate(hoveringOver.data[hoveringOver.data.length - 1].time).getTime() + (barsToMove * this.interval)), this.interval) + let startDate = startBar.time + let startValue = hoveringOver.data[0].value + priceDiff + let endValue = hoveringOver.data[hoveringOver.data.length - 1].value + priceDiff + hoveringOver.data = calculateTrendLine(startDate, startValue, endDate, endValue, this.interval, this.chart, hoveringOver.ray) + + let logical + if (chartTimeToDate(hoveringOver.data[hoveringOver.data.length - 1].time).getTime() >= chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime()) { + logical = this.chart.chart.timeScale().getVisibleLogicalRange() + } + hoveringOver.from = hoveringOver.data[0].time + hoveringOver.to = hoveringOver.data[hoveringOver.data.length - 1].time + hoveringOver.line.setData(hoveringOver.data) + if (logical) this.chart.chart.timeScale().setVisibleLogicalRange(logical) + + if (!hoveringOver.ray) { + hoveringOver.markers = [ + {time: startDate, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}, + {time: endDate, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1} + ] + hoveringOver.line.setMarkers(hoveringOver.markers) + } + + originalIndex = param.logical + originalPrice = priceAtCursor + this.chart.chart.subscribeCrosshairMove(checkForDrag) + } + let crosshairHandlerTrend = (param) => { + if (!param.point) return + this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerTrend) + if (!mouseDown) return + + let currentPrice = this.chart.series.coordinateToPrice(param.point.y) + let currentTime = this.chart.chart.timeScale().coordinateToTime(param.point.x) + + let [firstTime, firstPrice] = [null, null] + if (clickedEnd === 'last') { + firstTime = hoveringOver.data[0].time + firstPrice = hoveringOver.data[0].value + } else if (clickedEnd === 'first') { + firstTime = hoveringOver.data[hoveringOver.data.length - 1].time + firstPrice = hoveringOver.data[hoveringOver.data.length - 1].value + } + + let logical + let lastCandleTime = this.chart.candleData[this.chart.candleData.length - 1].time + if (!currentTime) { + let barsToMove = param.logical - this.chart.chart.timeScale().coordinateToLogical(this.chart.chart.timeScale().timeToCoordinate(lastCandleTime)) + logical = barsToMove <= 0 ? null : this.chart.chart.timeScale().getVisibleLogicalRange() + currentTime = dateToChartTime(new Date(chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime() + (barsToMove * this.interval)), this.interval) + } else if (chartTimeToDate(lastCandleTime).getTime() <= chartTimeToDate(currentTime).getTime()) { + logical = this.chart.chart.timeScale().getVisibleLogicalRange() + } + + hoveringOver.data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart) + hoveringOver.line.setData(hoveringOver.data) + + hoveringOver.from = hoveringOver.data[0].time + hoveringOver.to = hoveringOver.data[hoveringOver.data.length - 1].time + if (logical) this.chart.chart.timeScale().setVisibleLogicalRange(logical) + + hoveringOver.markers = [ + {time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}, + {time: currentTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1} + ] + hoveringOver.line.setMarkers(hoveringOver.markers) + + setTimeout(() => { + this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) + }, 10); + } + let crosshairHandlerHorz = (param) => { + if (!param.point) return + this.chart.chart.unsubscribeCrosshairMove(crosshairHandlerHorz) + if (!mouseDown) return + hoveringOver.updatePrice(this.chart.series.coordinateToPrice(param.point.y)) + setTimeout(() => { + this.chart.chart.subscribeCrosshairMove(crosshairHandlerHorz) + }, 10) + } + this.chart.chart.subscribeCrosshairMove(hoverOver) + } + + renderDrawings() { + //let logical = this.chart.chart.timeScale().getVisibleLogicalRange() + this.drawings.forEach((item) => { + if ('price' in item) return + let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from).getTime() / this.interval) * this.interval), this.interval) + let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to).getTime() / this.interval) * this.interval), this.interval) + let data = calculateTrendLine(startDate, item.data[0].value, endDate, item.data[item.data.length - 1].value, this.interval, this.chart, item.ray) + if (data.length !== 0) item.data = data + item.line.setData(data) + }) + //this.chart.chart.timeScale().setVisibleLogicalRange(logical) + } + + clearDrawings() { + this.drawings.forEach((item) => { + if ('price' in item) this.chart.series.removePriceLine(item.line) + else this.chart.chart.removeSeries(item.line) + }) + this.drawings = [] + } + } + + window.ToolBox = ToolBox +} \ No newline at end of file diff --git a/lightweight_charts/polygon.py b/lightweight_charts/polygon.py index 3b6e37e..1ab5aa2 100644 --- a/lightweight_charts/polygon.py +++ b/lightweight_charts/polygon.py @@ -49,6 +49,7 @@ class PolygonAPI: self._using_live_data = False self._using_live = {'stocks': False, 'options': False, 'indices': False, 'crypto': False, 'forex': False} self._ws = {'stocks': None, 'options': None, 'indices': None, 'crypto': None, 'forex': None} + self._tickers = {} def log(self, info: bool): @@ -156,7 +157,9 @@ class PolygonAPI: columns.append('v') df = df[columns].rename(columns=rename) df['time'] = pd.to_datetime(df['time'], unit='ms') - chart.set(df) + + chart.set(df, render_drawings=self._tickers.get(chart) == ticker) + self._tickers[chart] = ticker if not live: return True @@ -300,10 +303,10 @@ class PolygonChart(Chart): def __init__(self, api_key: str, live: bool = False, num_bars: int = 200, end_date: str = 'now', limit: int = 5_000, timeframe_options: tuple = ('1min', '5min', '30min', 'D', 'W'), security_options: tuple = ('Stock', 'Option', 'Index', 'Forex', 'Crypto'), - width: int = 800, height: int = 600, x: int = None, y: int = None, + toolbox: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None, on_top: bool = False, maximize: bool = False, debug: bool = False): super().__init__(volume_enabled=True, width=width, height=height, x=x, y=y, on_top=on_top, maximize=maximize, debug=debug, - api=self, topbar=True, searchbox=True) + api=self, topbar=True, searchbox=True, toolbox=toolbox) self.chart = self self.num_bars = num_bars self.end_date = end_date @@ -337,7 +340,7 @@ class PolygonChart(Chart): def _polygon(self, symbol): self.spinner(True) - self.set(pd.DataFrame()) + self.set(pd.DataFrame(), True) self.crosshair(vert_visible=False, horz_visible=False) mult, span = _convert_timeframe(self.topbar['timeframe'].value) diff --git a/lightweight_charts/util.py b/lightweight_charts/util.py index dfba151..26aff9f 100644 --- a/lightweight_charts/util.py +++ b/lightweight_charts/util.py @@ -27,7 +27,7 @@ class IDGen(list): var = ''.join(choices(ascii_lowercase, k=8)) if var not in self: self.append(var) - return var + return f'window.{var}' self.generate() diff --git a/lightweight_charts/widgets.py b/lightweight_charts/widgets.py index 95f3248..0b7fa03 100644 --- a/lightweight_charts/widgets.py +++ b/lightweight_charts/widgets.py @@ -29,7 +29,7 @@ try: except ImportError: HTML = None -from lightweight_charts.abstract import LWC, TopBar, JS +from lightweight_charts.abstract import LWC, JS def _widget_message(chart, string): @@ -47,40 +47,41 @@ def _widget_message(chart, string): class WxChart(LWC): def __init__(self, parent, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, - scale_candles_only: bool = False, api: object = None, topbar: bool = False, searchbox: bool = False): + scale_candles_only: bool = False, api: object = None, topbar: bool = False, searchbox: bool = False, + toolbox: bool = False): if wx is None: raise ModuleNotFoundError('wx.html2 was not found, and must be installed to use WxChart.') self.webview: wx.html2.WebView = wx.html2.WebView.New(parent) - super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, scale_candles_only=scale_candles_only) + super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, + scale_candles_only=scale_candles_only, topbar=topbar, searchbox=searchbox, toolbox=toolbox, + _js_api_code='window.wx_msg.postMessage.bind(window.wx_msg)') self.api = api self._script_func = self.webview.RunScript - self._js_api_code = 'window.wx_msg.postMessage.bind(window.wx_msg)' self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, lambda e: wx.CallLater(500, self._on_js_load)) self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: _widget_message(self, e.GetString())) self.webview.AddScriptMessageHandler('wx_msg') self.webview.SetPage(self._html, '') - - self.webview.AddUserScript(JS['callback']) - self._create_chart() - self.topbar = TopBar(self) if topbar else None - self._make_search_box() if searchbox else None + self.webview.AddUserScript(JS['callback']) if topbar or searchbox else None + self.webview.AddUserScript(JS['toolbox']) if toolbox else None def get_webview(self): return self.webview class QtChart(LWC): - def __init__(self, widget=None, api: object = None, topbar: bool = False, searchbox: bool = False, - volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, scale_candles_only: bool = False): + def __init__(self, widget=None, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, + scale_candles_only: bool = False, api: object = None, topbar: bool = False, searchbox: bool = False, + toolbox: bool = False): if QWebEngineView is None: raise ModuleNotFoundError('QWebEngineView was not found, and must be installed to use QtChart.') self.webview = QWebEngineView(widget) - super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, scale_candles_only=scale_candles_only) + super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, + scale_candles_only=scale_candles_only, topbar=topbar, searchbox=searchbox, toolbox=toolbox, + _js_api_code='window.pythonObject.callback') self.api = api self._script_func = self.webview.page().runJavaScript - self._js_api_code = 'window.pythonObject.callback' self.web_channel = QWebChannel() self.bridge = Bridge(self) @@ -100,17 +101,12 @@ class QtChart(LWC): ''' self.webview.page().setHtml(self._html) - self.run_script(JS['callback']) - self._create_chart() - self.topbar = TopBar(self) if topbar else None - self._make_search_box() if searchbox else None - def get_webview(self): return self.webview class StaticLWC(LWC): - def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1, scale_candles_only: bool = False): - super().__init__(volume_enabled, inner_width, inner_height, scale_candles_only=scale_candles_only) + def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1, scale_candles_only: bool = False, toolbox=False, autosize=True): + super().__init__(volume_enabled, inner_width, inner_height, scale_candles_only=scale_candles_only, toolbox=toolbox, autosize=autosize) self.width = width self.height = height self._html = self._html.replace('\n\n', '') @@ -133,9 +129,8 @@ class StaticLWC(LWC): class StreamlitChart(StaticLWC): - def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1, scale_candles_only: bool = False): - super().__init__(volume_enabled, width, height, inner_width, inner_height, scale_candles_only) - self._create_chart() + def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1, scale_candles_only: bool = False, toolbox: bool = False): + super().__init__(volume_enabled, width, height, inner_width, inner_height, scale_candles_only, toolbox) def _load(self): if html is None: @@ -144,11 +139,10 @@ class StreamlitChart(StaticLWC): class JupyterChart(StaticLWC): - def __init__(self, volume_enabled=True, width=800, height=350, inner_width=1, inner_height=1, scale_candles_only: bool = False): - super().__init__(volume_enabled, width, height, inner_width, inner_height, scale_candles_only) + def __init__(self, volume_enabled=True, width: int = 800, height=350, inner_width=1, inner_height=1, scale_candles_only: bool = False, toolbox: bool = False): + super().__init__(volume_enabled, width, height, inner_width, inner_height, scale_candles_only, toolbox, autosize=False) self._position = "" - self._create_chart(autosize=False) self.run_script(f''' for (var i = 0; i < document.getElementsByClassName("tv-lightweight-charts").length; i++) {{ var element = document.getElementsByClassName("tv-lightweight-charts")[i]; diff --git a/setup.py b/setup.py index a2268cf..fe85ef3 100644 --- a/setup.py +++ b/setup.py @@ -5,9 +5,9 @@ with open('README.md', 'r', encoding='utf-8') as f: setup( name='lightweight_charts', - version='1.0.13.4', + version='1.0.14', packages=find_packages(), - python_requires='>=3.9', + python_requires='>=3.8', install_requires=[ 'pandas', 'pywebview',