From 34ce3f7199b894e53b9cb07fc78adfb728eb2edf Mon Sep 17 00:00:00 2001 From: louisnw Date: Mon, 14 Aug 2023 16:06:16 +0100 Subject: [PATCH] Refactoring/Enhancements/Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking Changes: - Removed the `api` parameter; callbacks no longer need to be in a specific class. - Topbar callbacks now take a chart as an argument (see updated callback examples) - Removed the `topbar` parameter from chart declaration. The Topbar will be automatically created upon declaration of a topbar widget. - Removed the `searchbox` parameter from charts. It will be created upon subscribing to it in `chart.events`. - Removed `dynamic_loading`. - Removed ‘volume_enabled’ parameter. Volume will be enabled if the volumn column is present in the dataframe. - Widgets’ `func` parameter is now declared last. - Switchers take a tuple of options rather than a variable number of arguments. - `add_hotkey` renamed to `hotkey` - Horizontal lines now take a `func` argument rather than `interactive`. This event will emit the Line object that was moved. - Removed the `name` parameter from `line.set`. Line object names are now declared upon creation. Enhancements: - Added the `button` widget to the Topbar. - Added the color picker to the drawing context menu. - Charts now have a `candle_data` method, which returns the current data displayed on the chart as a DataFrame. - Fixed callbacks are now located in the `chart.events` object: - search (e.g `chart.events.search += on_search`) - new_bar - range_change - Added volume to the legend - Drawings can now be accessed through `chart.toolbox.drawings` - added the `style` and `name` parameters to `create_line` Bug Fixes: - Fixed a bug causing new charts not to load after `exit` was called. - Refactored rayline placement to ensure they do not move the visible range. - Fixed a bug causing the visible range to shift when trendlines are moved past the final candlestick. - Fixed a bug preventing trendlines and raylines on irregular timeframes. - Fixed a bug causing the legend to prevent mouse input into the chart. --- README.md | 101 ++-- docs/source/callbacks.md | 132 +++--- docs/source/charts.md | 18 +- docs/source/common_methods.md | 38 +- docs/source/conf.py | 2 +- docs/source/tables.md | 28 +- docs/source/toolbox.md | 48 +- examples/1_setting_data/setting_data.py | 3 +- examples/3_tick_data/tick_data.py | 7 +- examples/4_line_indicators/line_indicators.py | 20 +- examples/6_callbacks/callbacks.py | 43 +- lightweight_charts/abstract.py | 436 +++++++++--------- lightweight_charts/chart.py | 50 +- lightweight_charts/js/callback.js | 222 +++++---- lightweight_charts/js/funcs.js | 94 +++- lightweight_charts/js/table.js | 26 +- lightweight_charts/js/toolbox.js | 246 +++++++--- lightweight_charts/polygon.py | 63 +-- lightweight_charts/table.py | 50 +- lightweight_charts/util.py | 113 +++-- lightweight_charts/widgets.py | 66 +-- setup.py | 2 +- 22 files changed, 1024 insertions(+), 784 deletions(-) diff --git a/README.md b/README.md index 556fdf1..f9751a3 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,14 @@ pip install lightweight-charts ___ ## Features -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. Multi-Pane Charts using the [`SubChart`](https://lightweight-charts-python.readthedocs.io/en/latest/common_methods.html#create-subchart-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/callbacks.html) allowing for timeframe (1min, 5min, 30min etc.) selectors, searching, hotkeys, and more. -7. Tables for watchlists, order entry, and trade management. -7. Direct integration of market data through [Polygon.io's](https://polygon.io/?utm_source=affiliate&utm_campaign=pythonlwcharts) market data API. +1. Streamlined for live data, with methods for updating directly from tick data. +2. Multi-pane charts using [Subcharts](https://lightweight-charts-python.readthedocs.io/en/latest/common_methods.html#create-subchart-subchart). +3. The [Toolbox](https://lightweight-charts-python.readthedocs.io/en/latest/toolbox.html), allowing for trendlines, rays and horizontal lines to be drawn directly onto charts. +4. [Callbacks](https://lightweight-charts-python.readthedocs.io/en/latest/callbacks.html) allowing for timeframe selectors (1min, 5min, 30min etc.), searching, hotkeys, and more. +5. [Tables](https://lightweight-charts-python.readthedocs.io/en/latest/tables.html) for watchlists, order entry, and trade management. +6. Direct integration of market data through [Polygon.io's](https://polygon.io/?utm_source=affiliate&utm_campaign=pythonlwcharts) market data API. -__Supports:__ Jupyter Notebooks, PyQt, wxPython, Streamlit, and asyncio. +__Supports:__ Jupyter Notebooks, PyQt5, PySide6, wxPython, Streamlit, and asyncio. PartTimeLarry: [Interactive Brokers API and TradingView Charts in Python](https://www.youtube.com/watch?v=TlhDI3PforA) ___ @@ -46,7 +44,7 @@ if __name__ == '__main__': chart = Chart() - # Columns: | time | open | high | low | close | volume (if volume is enabled) | + # Columns: time | open | high | low | close | volume df = pd.read_csv('ohlcv.csv') chart.set(df) @@ -102,10 +100,10 @@ if __name__ == '__main__': df1 = pd.read_csv('ohlc.csv') - # Columns: | time | price | volume (if volume is enabled) | + # Columns: time | price df2 = pd.read_csv('ticks.csv') - chart = Chart(volume_enabled=False) + chart = Chart() chart.set(df1) @@ -114,7 +112,7 @@ if __name__ == '__main__': for i, tick in df2.iterrows(): chart.update_from_tick(tick) - sleep(0.3) + sleep(0.03) ``` ![tick data gif](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/examples/3_tick_data/tick_data.gif) @@ -127,29 +125,25 @@ import pandas as pd from lightweight_charts import Chart -def calculate_sma(data: pd.DataFrame, period: int = 50): - def avg(d: pd.DataFrame): - return d['close'].mean() - - result = [] - for i in range(period - 1, len(data)): - val = avg(data.iloc[i - period + 1:i]) - result.append({'time': data.iloc[i]['date'], f'SMA {period}': val}) - return pd.DataFrame(result) +def calculate_sma(df, period: int = 50): + return pd.DataFrame({ + 'time': df['date'], + f'SMA {period}': df['close'].rolling(window=period).mean() + }).dropna() if __name__ == '__main__': - chart = Chart() - chart.legend(visible=True) - - df = pd.read_csv('ohlcv.csv') - chart.set(df) + chart = Chart() + chart.legend(visible=True) - line = chart.create_line() - sma_data = calculate_sma(df, period=50) - line.set(sma_data, name='SMA 50') + df = pd.read_csv('ohlcv.csv') + chart.set(df) - chart.show(block=True) + line = chart.create_line('SMA 50') + sma_data = calculate_sma(df, period=50) + line.set(sma_data) + + chart.show(block=True) ``` ![line indicators image](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/examples/4_line_indicators/line_indicators.png) @@ -164,7 +158,7 @@ from lightweight_charts import Chart if __name__ == '__main__': - chart = Chart(debug=True) + chart = Chart() df = pd.read_csv('ohlcv.csv') @@ -203,43 +197,42 @@ def get_bar_data(symbol, timeframe): if symbol not in ('AAPL', 'GOOGL', 'TSLA'): print(f'No data for "{symbol}"') return pd.DataFrame() - return pd.read_csv(f'../examples/6_callbacks/bar_data/{symbol}_{timeframe}.csv') + return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') -class API: - def __init__(self): - self.chart = None # Changes after each callback. +def on_search(chart, searched_string): # Called when the user searches. + new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.topbar['symbol'].set(searched_string) + chart.set(new_data) - def on_search(self, searched_string): # Called when the user searches. - new_data = get_bar_data(searched_string, self.chart.topbar['timeframe'].value) - if new_data.empty: - return - self.chart.topbar['symbol'].set(searched_string) - self.chart.set(new_data) - def on_timeframe_selection(self): # Called when the user changes the timeframe. - 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, True) +def on_timeframe_selection(chart): # Called when the user changes the timeframe. + new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.set(new_data, True) - def on_horizontal_line_move(self, line_id, price): - print(f'Horizontal line moved to: {price}') + +def on_horizontal_line_move(chart, line): + print(f'Horizontal line moved to: {line.price}') if __name__ == '__main__': - api = API() - - chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True) + chart = Chart(toolbox=True) chart.legend(True) + chart.events.search += on_search + chart.topbar.textbox('symbol', 'TSLA') - chart.topbar.switcher('timeframe', api.on_timeframe_selection, '1min', '5min', '30min', default='5min') + chart.topbar.switcher('timeframe', ('1min', '5min', '30min'), default='5min', + func=on_timeframe_selection) df = get_bar_data('TSLA', '5min') chart.set(df) - chart.horizontal_line(200, interactive=True) + chart.horizontal_line(200, func=on_horizontal_line_move) chart.show(block=True) diff --git a/docs/source/callbacks.md b/docs/source/callbacks.md index 48ee7fd..1fa14e4 100644 --- a/docs/source/callbacks.md +++ b/docs/source/callbacks.md @@ -1,74 +1,92 @@ -# Callbacks +# Events -The `Chart` object allows for asynchronous and synchronous callbacks to be passed back to python, allowing for more sophisticated chart layouts including searching, timeframe selectors text boxes, and hotkeys using the `add_hotkey` method. - -`QtChart`and `WxChart` can also use callbacks. - -A variety of the parameters below should be passed to the Chart upon decaration. -* `api`: The class object that the fixed callbacks will always be emitted to. -* `topbar`: Adds a [TopBar](#topbar) to the `Chart` or `SubChart` and allows use of the `create_switcher` method. -* `searchbox`: Adds a search box onto the `Chart` or `SubChart` that is activated by typing. +Events allow asynchronous and synchronous callbacks to be passed back into python. ___ -## How to use Callbacks +## `chart.events` -Fixed Callbacks are emitted to the class given as the `api` parameter shown above. +`events.search` `->` `chart` | `string`: Fires upon searching. Searchbox will be automatically created. + +`events.new_bar` `->` `chart`: Fires when a new candlestick is added to the chart. + +`events.range_change` `->` `chart` | `bars_before` | `bars_after`: Fires when the range (visibleLogicalRange) changes. + +Chart events can be subscribed to using: `chart.events. += ` +___ + +## How to use Events Take a look at this minimal example: ```python -class API: - def __init__(self): - self.chart = None +from lightweight_charts import Chart + + +def on_search(chart, string): + print(f'Search Text: "{string}" | Chart/SubChart ID: "{chart.id}"') + + +if __name__ == '__main__': + chart = Chart() + chart.events.search += on_search + chart.show(block=True) - def on_search(self, string): - print(f'Search Text: "{string}" | Chart/SubChart ID: "{self.chart.id}"') ``` Upon searching in a pane, the expected output would be akin to: ``` Search Text: "AAPL" | Chart/SubChart ID: "window.blyjagcr" ``` -The ID shown above will change depending upon which pane was used to search, due to the instance of `self.chart` dynamically updating to the latest pane which triggered the callback. -`self.chart` will update upon each callback, allowing for access to the specific pane in question. +The ID shown above will change depending upon which pane was used to search, allowing for access to the object in question. ```{important} * When using `show` rather than `show_async`, block should be set to `True` (`chart.show(block=True)`). -* `API` class methods can be either coroutines or normal methods. -* Non fixed callbacks (switchers, hotkeys) can be methods, coroutines, or regular functions. +* Event callables can be either coroutines, methods, or functions. ``` -There are certain callbacks which are always emitted to a specifically named method of API: -* Search callbacks: `on_search` -* Interactive Horizontal Line callbacks: `on_horizontal_line_move` - ___ ## `TopBar` -The `TopBar` class represents the top bar shown on the chart when using callbacks: +The `TopBar` class represents the top bar shown on the chart: ![topbar](https://i.imgur.com/Qu2FW9Y.png) -This class is accessed from the `topbar` attribute of the chart object (`chart.topbar.`), after setting the topbar parameter to `True` upon declaration of the chart. +This object is accessed from the `topbar` attribute of the chart object (`chart.topbar.`). -Switchers and text boxes can be created within the top bar, and their instances can be accessed through the `topbar` dictionary. For example: +Switchers, text boxes and buttons can be added to the top bar, and their instances can be accessed through the `topbar` dictionary. For example: ```python -chart = Chart(api=api, topbar=True) - chart.topbar.textbox('symbol', 'AAPL') # Declares a textbox displaying 'AAPL'. print(chart.topbar['symbol'].value) # Prints the value within ('AAPL') chart.topbar['symbol'].set('MSFT') # Sets the 'symbol' textbox to 'MSFT' print(chart.topbar['symbol'].value) # Prints the value again ('MSFT') ``` + +Events can also be emitted from the topbar. For example: + +```python +from lightweight_charts import Chart + +def on_button_press(chart): + new_button_value = 'On' if chart.topbar['my_button'].value == 'Off' else 'Off' + chart.topbar['my_button'].set(new_button_value) + print(f'Turned something {new_button_value.lower()}.') + + +if __name__ == '__main__': + chart = Chart() + chart.topbar.button('my_button', 'Off', func=on_button_press) + chart.show(block=True) + +``` + ___ ### `switcher` -`name: str` | `method: function` | `*options: str` | `default: str` +`name: str` | `options: tuple` | `default: str` | `func: callable` * `name`: the name of the switcher which can be used to access it from the `topbar` dictionary. -* `method`: The function from the `api` class given to the constructor that will receive the callback. -* `options`: The strings to be displayed within the switcher. This may be a variety of timeframes, security types, or whatever needs to be updated directly from the chart. +* `options`: The options for each switcher item. * `default`: The initial switcher option set. ___ @@ -79,6 +97,15 @@ ___ * `initial_text`: The text to show within the text box. ___ +### `button` +`name: str` | `button_text: str` | `separator: bool` | `func: callable` + +* `name`: the name of the text box to access it from the `topbar` dictionary. +* `button_text`: Text to show within the button. +* `separator`: places a separator line to the right of the button. +* `func`: The event handler which will be executed upon a button click. +___ + ## Callbacks Example: ```python @@ -93,40 +120,37 @@ def get_bar_data(symbol, timeframe): return pd.read_csv(f'../examples/6_callbacks/bar_data/{symbol}_{timeframe}.csv') -class API: - def __init__(self): - self.chart = None # Changes after each callback. +def on_search(chart, searched_string): + new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.topbar['symbol'].set(searched_string) + chart.set(new_data) - def on_search(self, searched_string): # Called when the user searches. - new_data = get_bar_data(searched_string, self.chart.topbar['timeframe'].value) - if new_data.empty: - return - self.chart.topbar['symbol'].set(searched_string) - self.chart.set(new_data) + +def on_timeframe_selection(chart): + new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.set(new_data, True) - def on_timeframe_selection(self): # Called when the user changes the timeframe. - 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, True) - - def on_horizontal_line_move(self, line_id, price): - print(f'Horizontal line moved to: {price}') + +def on_horizontal_line_move(chart, line): + print(f'Horizontal line moved to: {line.price}') if __name__ == '__main__': - api = API() - - chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True) + chart = Chart(toolbox=True) chart.legend(True) chart.topbar.textbox('symbol', 'TSLA') - chart.topbar.switcher('timeframe', api.on_timeframe_selection, '1min', '5min', '30min', default='5min') + chart.topbar.switcher('timeframe', ('1min', '5min', '30min'), default='5min', + func=on_timeframe_selection) df = get_bar_data('TSLA', '5min') chart.set(df) - chart.horizontal_line(200, interactive=True) + chart.horizontal_line(200, func=on_horizontal_line_move) chart.show(block=True) ``` diff --git a/docs/source/charts.md b/docs/source/charts.md index 322bd5c..59a17b9 100644 --- a/docs/source/charts.md +++ b/docs/source/charts.md @@ -5,8 +5,7 @@ This page contains a reference to all chart objects that can be used within the ___ ## 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` | `toolbox: bool` +`width: int` | `height: int` | `x: int` | `y: int` | `on_top: bool` | `maximize: bool` | `debug: bool` | `toolbox: bool` The main object used for the normal functionality of lightweight-charts-python, built on the pywebview library. @@ -61,11 +60,13 @@ This method should be called after the chart window has loaded. ___ ## QtChart -`widget: QWidget` | `volume_enabled: bool` +`widget: QWidget` -The `QtChart` object allows the use of charts within a `QMainWindow` object, and has similar functionality to the `Chart` and `ChartAsync` objects for manipulating data, configuring and styling. +The `QtChart` object allows the use of charts within a `QMainWindow` object, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. -Callbacks can be recieved through the Qt event loop. +Either the `PyQt5` or `PySide6` libraries will work with this chart. + +Callbacks can be received through the Qt event loop. ___ ### `get_webview` @@ -107,11 +108,11 @@ app.exec_() ___ ## WxChart -`parent: wx.Panel` | `volume_enabled: bool` +`parent: wx.Panel` -The WxChart object allows the use of charts within a `wx.Frame` object, and has similar functionality to the `Chart` and `ChartAsync` objects for manipulating data, configuring and styling. +The WxChart object allows the use of charts within a `wx.Frame` object, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. -Callbacks can be recieved through the Wx event loop. +Callbacks can be received through the Wx event loop. ___ ### `get_webview` @@ -157,7 +158,6 @@ if __name__ == '__main__': ___ ## StreamlitChart -`parent: wx.Panel` | `volume_enabled: bool` The `StreamlitChart` object allows the use of charts within a Streamlit app, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. diff --git a/docs/source/common_methods.md b/docs/source/common_methods.md index 70965bf..861546c 100644 --- a/docs/source/common_methods.md +++ b/docs/source/common_methods.md @@ -43,32 +43,30 @@ If `cumulative_volume` is used, the volume data given will be added onto the lat ___ ## `create_line` (Line) -`color: str` | `width: int` | `price_line: bool` | `price_label: bool` | `-> Line` +`name: str` | `color: str` | `style: LINE_STYLE`| `width: int` | `price_line: bool` | `price_label: bool` | `-> Line` Creates and returns a `Line` object, representing 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). Its instance should only be accessed from this method. - ### `set` -`data: pd.DataFrame` `name: str` +`data: pd.DataFrame` Sets the data for the line. -When not using the `name` parameter, the columns should be named: `time | value` (Not case sensitive). +When a name has not been set upon declaration, the columns should be named: `time | value` (Not case sensitive). Otherwise, the method will use the column named after the string given in `name`. This name will also be used within the legend of the chart. For example: ```python -line = chart.create_line() +line = chart.create_line('SMA 50') # DataFrame with columns: date | SMA 50 df = pd.read_csv('sma50.csv') -line.set(df, name='SMA 50') +line.set(df) ``` - ### `update` `series: pd.Series` @@ -76,7 +74,6 @@ Updates the data for the line. This should be given as a Series object, with labels akin to the `line.set()` function. - ### `delete` Irreversibly deletes the line. @@ -263,8 +260,8 @@ ___ Shows the hidden candles on the chart. ___ -## `add_hotkey` -`modifier: 'ctrl'/'shift'/'alt'/'meta'` | `key: str/int/tuple` | `method: object` +## `hotkey` +`modifier: 'ctrl'/'shift'/'alt'/'meta'` | `key: str/int/tuple` | `func: callable` Adds a global hotkey to the chart window, which will execute the method or function given. @@ -273,26 +270,29 @@ When using a number in `key`, it should be given as an integer. If multiple key ```python def place_buy_order(key): print(f'Buy {key} shares.') - - + + def place_sell_order(key): print(f'Sell all shares, because I pressed {key}.') -chart.add_hotkey('shift', (1, 2, 3), place_buy_order) -chart.add_hotkey('shift', 'X', place_sell_order) +if __name__ == '__main__': + chart = Chart() + chart.hotkey('shift', (1, 2, 3), place_buy_order) + chart.hotkey('shift', 'X', place_sell_order) + chart.show(block=True) ``` ___ ## `create_table` -`width: int/float` | `height: int/float` | `headings: tuple[str]` | `widths: tuple[float]` | `alignments: tuple[str]` | `position: 'left'/'right'/'top'/'bottom'` | `draggable: bool` | `method: object` +`width: int/float` | `height: int/float` | `headings: tuple[str]` | `widths: tuple[float]` | `alignments: tuple[str]` | `position: 'left'/'right'/'top'/'bottom'` | `draggable: bool` | `func: callable` | `-> Table` Creates and returns a [`Table`](https://lightweight-charts-python.readthedocs.io/en/latest/tables.html) object. ___ ## `create_subchart` (SubChart) -`volume_enabled: bool` | `position: 'left'/'right'/'top'/'bottom'`, `width: float` | `height: float` | `sync: bool/str` | `-> SubChart` +`position: 'left'/'right'/'top'/'bottom'`, `width: float` | `height: float` | `sync: bool/str` | `scale_candles_only: bool`|`toolbox: bool` | `-> SubChart` Creates and returns a `SubChart` object, placing it adjacent to the previous `Chart` or `SubChart`. This allows for the use of multiple chart panels within the same `Chart` window. Its instance should only be accessed by using this method. @@ -335,7 +335,6 @@ if __name__ == '__main__': ``` - ### Synced Line Chart Example: ```python @@ -346,17 +345,16 @@ if __name__ == '__main__': chart = Chart(inner_width=1, inner_height=0.8) chart.time_scale(visible=False) - chart2 = chart.create_subchart(width=1, height=0.2, sync=True, volume_enabled=False) + chart2 = chart.create_subchart(width=1, height=0.2, sync=True) + line = chart2.create_line() 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) - ``` diff --git a/docs/source/conf.py b/docs/source/conf.py index 57feba5..60b009e 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.15' +release = '1.0.16' extensions = ["myst_parser"] diff --git a/docs/source/tables.md b/docs/source/tables.md index 95015cd..dd68626 100644 --- a/docs/source/tables.md +++ b/docs/source/tables.md @@ -1,11 +1,9 @@ # Table -`width: int/float` | `height: int/float` | `headings: tuple[str]` | `widths: tuple[float]` | `alignments: tuple[str]` | `position: 'left'/'right'/'top'/'bottom'` | `draggable: bool` | `method: object` +`width: int/float` | `height: int/float` | `headings: tuple[str]` | `widths: tuple[float]` | `alignments: tuple[str]` | `position: 'left'/'right'/'top'/'bottom'` | `draggable: bool` | `func: callable` Tables are panes that can be used to gain further functionality from charts. They are intended to be used for watchlists, order management, or position management. It should be accessed from the `create_table` common method. -The `Table` and `Row` objects inherit from dictionaries, and can be manipulated as such. - - +The `Table` and `Row` objects act as dictionaries, and can be manipulated as such. `width`/`height`: Either given as a percentage (a `float` between 0 and 1) or as an integer representing pixel size. @@ -15,7 +13,7 @@ The `Table` and `Row` objects inherit from dictionaries, and can be manipulated `draggable`: If `True`, then the window can be dragged to any position within the window. -`method`: If given this will be called when a row is clicked. +`func`: If given this will be called when a row is clicked, returning the `Row` object in question. ___ ## `new_row` (Row) @@ -25,11 +23,22 @@ Creates a new row within the table, and returns a `Row` object. if `id` is passed it should be unique to all other rows. Otherwise, the `id` will be randomly generated. +Rows can be passed a string (header) item or a tuple to set multiple headings: + +```python +row['Symbol'] = 'AAPL' +row['Symbol', 'Action'] = 'AAPL', 'BUY' +``` ### `background_color` `column: str` | `color: str` -Sets the background color of the Row cell at the given column. +Sets the background color of the row cell. + +### `text_color` +`column: str` | `color: str` + +Sets the foreground color of the row cell. ### `delete` Deletes the row. @@ -77,10 +86,7 @@ ___ import pandas as pd from lightweight_charts import Chart -def on_row_click(row_id): - row = table.get(row_id) - print(row) - +def on_row_click(row): row['PL'] = round(row['PL']+1, 2) row.background_color('PL', 'green' if row['PL'] > 0 else 'red') @@ -97,7 +103,7 @@ if __name__ == '__main__': headings=('Ticker', 'Quantity', 'Status', '%', 'PL'), widths=(0.2, 0.1, 0.2, 0.2, 0.3), alignments=('center', 'center', 'right', 'right', 'right'), - position='left', method=on_row_click) + position='left', func=on_row_click) table.format('PL', f'£ {table.VALUE}') table.format('%', f'{table.VALUE} %') diff --git a/docs/source/toolbox.md b/docs/source/toolbox.md index 62adc41..f766ae3 100644 --- a/docs/source/toolbox.md +++ b/docs/source/toolbox.md @@ -3,15 +3,15 @@ The Toolbox allows for trendlines, ray lines and horizontal lines to be drawn an It can be used within any Chart object, and is enabled by setting the `toolbox` parameter to `True` upon Chart declaration. -The Toolbox should only be accessed from the `toolbox` attribute of the chart object. (`chart.toolbox.`) - 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 or Ctrl+Z: Undo -Drawings can also be deleted by right-clicking on them, which brings up a context menu. + +Right-clicking on a drawing will open a context menu, allowing for color selection and deletion. + ___ ## `save_drawings_under` @@ -58,33 +58,29 @@ def get_bar_data(symbol, timeframe): return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') -class API: - def __init__(self): - self.chart: Chart = None +def on_search(chart, searched_string): + new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.topbar['symbol'].set(searched_string) + chart.set(new_data) + chart.toolbox.load_drawings(searched_string) # Loads the drawings saved under the symbol. + + +def on_timeframe_selection(chart): + new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.set(new_data, render_drawings=True) # The symbol has not changed, so we want to re-render the drawings. - def on_search(self, searched_string): - new_data = get_bar_data(searched_string, self.chart.topbar['timeframe'].value) - if new_data.empty: - return - self.chart.topbar['symbol'].set(searched_string) - self.chart.set(new_data) - self.chart.toolbox.load_drawings(searched_string) # Loads the drawings saved under the symbol. - def on_timeframe_selection(self): - 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, render_drawings=True) # The symbol has not changed, so we want to re-render the drawings. - - if __name__ == '__main__': - api = API() - - chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True) + chart = Chart(toolbox=True) chart.legend(True) + chart.events.search += on_search chart.topbar.textbox('symbol', 'TSLA') - chart.topbar.switcher('timeframe', api.on_timeframe_selection, '1min', '5min', '30min', default='5min') + chart.topbar.switcher('timeframe', ('1min', '5min', '30min'), default='5min', func=on_timeframe_selection) df = get_bar_data('TSLA', '5min') @@ -94,9 +90,9 @@ if __name__ == '__main__': chart.toolbox.load_drawings(chart.topbar['symbol'].value) # Loads the drawings under the default symbol. chart.toolbox.save_drawings_under(chart.topbar['symbol']) # Saves drawings based on the symbol. - + chart.show(block=True) - chart.toolbox.export_drawings('drawings.json') # Exports the drawings to the JSON file. + chart.toolbox.export_drawings('drawings.json') # Exports the drawings to the JSON file upon close. ``` diff --git a/examples/1_setting_data/setting_data.py b/examples/1_setting_data/setting_data.py index 2943766..c0f0cd4 100644 --- a/examples/1_setting_data/setting_data.py +++ b/examples/1_setting_data/setting_data.py @@ -2,10 +2,9 @@ import pandas as pd from lightweight_charts import Chart if __name__ == '__main__': - chart = Chart() - # Columns: | time | open | high | low | close | volume (if volume is enabled) | + # Columns: time | open | high | low | close | volume df = pd.read_csv('ohlcv.csv') chart.set(df) diff --git a/examples/3_tick_data/tick_data.py b/examples/3_tick_data/tick_data.py index 3130038..5d3217d 100644 --- a/examples/3_tick_data/tick_data.py +++ b/examples/3_tick_data/tick_data.py @@ -2,15 +2,14 @@ import pandas as pd from time import sleep from lightweight_charts import Chart - if __name__ == '__main__': df1 = pd.read_csv('ohlc.csv') - # Columns: | time | price | volume (if volume is enabled) | + # Columns: time | price df2 = pd.read_csv('ticks.csv') - chart = Chart(volume_enabled=False) + chart = Chart() chart.set(df1) @@ -18,6 +17,4 @@ if __name__ == '__main__': for i, tick in df2.iterrows(): chart.update_from_tick(tick) - sleep(0.03) - diff --git a/examples/4_line_indicators/line_indicators.py b/examples/4_line_indicators/line_indicators.py index 6616d4c..44ecbbf 100644 --- a/examples/4_line_indicators/line_indicators.py +++ b/examples/4_line_indicators/line_indicators.py @@ -2,26 +2,22 @@ import pandas as pd from lightweight_charts import Chart -def calculate_sma(data: pd.DataFrame, period: int = 50): - def avg(d: pd.DataFrame): - return d['close'].mean() - result = [] - for i in range(period - 1, len(data)): - val = avg(data.iloc[i - period + 1:i]) - result.append({'time': data.iloc[i]['date'], f'SMA {period}': val}) - return pd.DataFrame(result) +def calculate_sma(df, period: int = 50): + return pd.DataFrame({ + 'time': df['date'], + f'SMA {period}': df['close'].rolling(window=period).mean() + }).dropna() if __name__ == '__main__': - - chart = Chart(debug=True) + chart = Chart() chart.legend(visible=True) df = pd.read_csv('ohlcv.csv') chart.set(df) - line = chart.create_line() + line = chart.create_line('SMA 50') sma_data = calculate_sma(df, period=50) - line.set(sma_data, name='SMA 50') + line.set(sma_data) chart.show(block=True) diff --git a/examples/6_callbacks/callbacks.py b/examples/6_callbacks/callbacks.py index e7c4120..85b85c0 100644 --- a/examples/6_callbacks/callbacks.py +++ b/examples/6_callbacks/callbacks.py @@ -9,39 +9,38 @@ def get_bar_data(symbol, timeframe): return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') -class API: - def __init__(self): - self.chart: Chart = None # Changes after each callback. +def on_search(chart, searched_string): # Called when the user searches. + new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.topbar['symbol'].set(searched_string) + chart.set(new_data) - def on_search(self, searched_string): # Called when the user searches. - new_data = get_bar_data(searched_string, self.chart.topbar['timeframe'].value) - if new_data.empty: - return - self.chart.topbar['symbol'].set(searched_string) - self.chart.set(new_data) - def on_timeframe_selection(self): # Called when the user changes the timeframe. - 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, True) +def on_timeframe_selection(chart): # Called when the user changes the timeframe. + new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value) + if new_data.empty: + return + chart.set(new_data, True) - def on_horizontal_line_move(self, line_id, price): - print(f'Horizontal line moved to: {price}') + +def on_horizontal_line_move(chart, line): + print(f'Horizontal line moved to: {line.price}') if __name__ == '__main__': - api = API() - - chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True) + chart = Chart(toolbox=True) chart.legend(True) + chart.events.search += on_search + chart.topbar.textbox('symbol', 'TSLA') - chart.topbar.switcher('timeframe', api.on_timeframe_selection, '1min', '5min', '30min', default='5min') + chart.topbar.switcher('timeframe', ('1min', '5min', '30min'), default='5min', + func=on_timeframe_selection) df = get_bar_data('TSLA', '5min') chart.set(df) - chart.horizontal_line(200, interactive=True) + chart.horizontal_line(200, func=on_horizontal_line_move) - chart.show(block=True) \ No newline at end of file + chart.show(block=True) diff --git a/lightweight_charts/abstract.py b/lightweight_charts/abstract.py index 81144eb..200cc91 100644 --- a/lightweight_charts/abstract.py +++ b/lightweight_charts/abstract.py @@ -1,3 +1,4 @@ +import asyncio import json import os from datetime import timedelta, datetime @@ -6,10 +7,8 @@ import pandas as pd from typing import Union, Literal, Dict, List from lightweight_charts.table import Table -from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, _crosshair_mode, \ - _line_style, \ - MissingColumn, _js_bool, _price_scale_mode, PRICE_SCALE_MODE, _marker_position, _marker_shape, IDGen - +from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, crosshair_mode, \ + line_style, jbool, price_scale_mode, PRICE_SCALE_MODE, marker_position, marker_shape, IDGen, Events JS = {} current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -51,7 +50,9 @@ HTML = f""" class SeriesCommon: def _set_interval(self, df: pd.DataFrame): - common_interval = pd.to_datetime(df['time']).diff().value_counts() + if not pd.api.types.is_datetime64_any_dtype(df['time']): + df['time'] = pd.to_datetime(df['time']) + common_interval = df['time'].diff().value_counts() try: self._interval = common_interval.index[0] except IndexError: @@ -97,12 +98,14 @@ class SeriesCommon: return series def _datetime_format(self, arg: Union[pd.Series, str]): - arg = pd.to_datetime(arg) + if not pd.api.types.is_datetime64_any_dtype(arg): + arg = pd.to_datetime(arg) 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()) + interval_seconds = self._interval.total_seconds() + arg = interval_seconds * (arg.timestamp() // interval_seconds) else: arg = arg.dt.strftime('%Y-%m-%d') if isinstance(arg, pd.Series) else arg.strftime('%Y-%m-%d') @@ -127,9 +130,9 @@ class SeriesCommon: self.run_script(f""" {self.id}.markers.push({{ time: {time if isinstance(time, float) else f"'{time}'"}, - position: '{_marker_position(position)}', + position: '{marker_position(position)}', color: '{color}', - shape: '{_marker_shape(shape)}', + shape: '{marker_shape(shape)}', text: '{text}', id: '{marker_id}' }}); @@ -149,11 +152,11 @@ class SeriesCommon: }});''') 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': + style: LINE_STYLE = 'solid', text: str = '', axis_label_visible: bool = True, func: callable = None) -> 'HorizontalLine': """ - Creates a horizontal line at the given price.\n + Creates a horizontal line at the given price. """ - return HorizontalLine(self, price, color, width, style, text, axis_label_visible, interactive) + return HorizontalLine(self, price, color, width, style, text, axis_label_visible, func) def remove_horizontal_line(self, price: Union[float, int] = None): """ @@ -182,8 +185,8 @@ class SeriesCommon: 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)}, + lastValueVisible: {jbool(label_visible)}, + priceLineVisible: {jbool(line_visible)}, title: '{title}', }})''') @@ -193,6 +196,7 @@ class SeriesCommon: :param precision: The number of decimal places. """ self.run_script(f''' + {self.id}.precision = {precision} {self.id}.series.applyOptions({{ priceFormat: {{precision: {precision}, minMove: {1 / (10 ** precision)}}} }})''') @@ -203,18 +207,23 @@ class SeriesCommon: def _toggle_data(self, arg): self.run_script(f''' - {self.id}.series.applyOptions({{visible: {_js_bool(arg)}}}) - {f'{self.id}.volumeSeries.applyOptions({{visible: {_js_bool(arg)}}})' if hasattr(self, 'volume_enabled') and self.volume_enabled else ''} + {self.id}.series.applyOptions({{visible: {jbool(arg)}}}) + {f'{self.id}.volumeSeries.applyOptions({{visible: {jbool(arg)}}})' if hasattr(self, 'loaded') else ''} ''') class HorizontalLine: - def __init__(self, chart, price, color, width, style, text, axis_label_visible, interactive): + def __init__(self, chart, price, color, width, style, text, axis_label_visible, func): self._chart = chart self.id = self._chart._rand.generate() + self.price = price 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}') + {self.id} = new HorizontalLine({self._chart.id}, '{self.id}', {price}, '{color}', {width}, {line_style(style)}, {jbool(axis_label_visible)}, '{text}') ''') - if not interactive: return + if not func: return + def wrapper(p): + self.price = p + func(chart, self) + chart._handlers[self.id] = wrapper self._chart.run_script(f'if ("toolBox" in {self._chart.id}) {self._chart.id}.toolBox.drawings.push({self.id})') def update(self, price): @@ -222,6 +231,7 @@ class HorizontalLine: Moves the horizontal line to the given price. """ self._chart.run_script(f'{self.id}.updatePrice({price})') + self.price = price def label(self, text: str): self._chart.run_script(f'{self.id}.updateLabel("{text}")') @@ -235,9 +245,9 @@ class HorizontalLine: class Line(SeriesCommon): - def __init__(self, chart, color, width, price_line, price_label, crosshair_marker=True): + def __init__(self, chart, name, color, style, width, price_line, price_label, crosshair_marker=True): self.color = color - self.name = '' + self.name = name self._chart = chart self._rand = chart._rand self.id = self._rand.generate() @@ -246,21 +256,23 @@ class Line(SeriesCommon): {self.id} = {{ series: {self._chart.id}.chart.addLineSeries({{ color: '{color}', + lineStyle: {line_style(style)}, lineWidth: {width}, - lastValueVisible: {_js_bool(price_label)}, - priceLineVisible: {_js_bool(price_line)}, - crosshairMarkerVisible: {_js_bool(crosshair_marker)}, + lastValueVisible: {jbool(price_label)}, + priceLineVisible: {jbool(price_line)}, + crosshairMarkerVisible: {jbool(crosshair_marker)}, {"""autoscaleInfoProvider: () => ({ priceRange: { minValue: 1_000_000_000, maxValue: 0, - }, - }),""" if self._chart._scale_candles_only else ''} - }}), + }, + }),""" if self._chart._scale_candles_only else ''} + }}), markers: [], horizontal_lines: [], - name: '', + name: '{name}', color: '{color}', + precision: 2, }} {self._chart.id}.lines.push({self.id}) if ('legend' in {self._chart.id}) {{ @@ -269,23 +281,22 @@ class Line(SeriesCommon): ''') - def set(self, data: pd.DataFrame, name=''): + def set(self, data: pd.DataFrame): """ 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. """ if data.empty or data is None: - self.run_script(f'{self.id}.series.setData([]); {self.id}.name = "{name}"') + self.run_script(f'{self.id}.series.setData([])') return - 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'}) + df = self._df_datetime_format(data, exclude_lowercase=self.name) + if self.name: + if self.name not in data: + raise NameError(f'No column named "{self.name}".') + df = df.rename(columns={self.name: 'value'}) self._last_bar = df.iloc[-1] - self.run_script(f'{self.id}.series.setData({df.to_dict("records")}); {self.id}.name = "{name}"') + self.run_script(f'{self.id}.series.setData({df.to_dict("records")})') def update(self, series: pd.Series): """ @@ -300,14 +311,14 @@ class Line(SeriesCommon): 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) + time_val = pd.to_datetime(time_val) + time_val = time_val.timestamp() if self._chart._interval < pd.Timedelta(days=1) else time_val.strftime('%Y-%m-%d') 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._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: false}}) {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) + {self._chart._interval.total_seconds()*1000}, {self._chart.id}, {jbool(ray)})) + {self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: true}}) ''') def delete(self): @@ -323,17 +334,24 @@ class Line(SeriesCommon): class Widget: - def __init__(self, topbar): + def __init__(self, topbar, value, func=None): self._chart = topbar._chart - self._method = None + self.id = topbar._chart._rand.generate() + self.value = value + self._handler = func + def wrapper(v): + self.value = v + func(topbar._chart) + async def async_wrapper(v): + self.value = v + await func(topbar._chart) + self._chart._handlers[self.id] = async_wrapper if asyncio.iscoroutinefunction(func) else wrapper class TextWidget(Widget): def __init__(self, topbar, initial_text): - super().__init__(topbar) - self.value = initial_text - self.id = self._chart._rand.generate() - self._chart.run_script(f'''{self.id} = makeTextBoxWidget({self._chart.id}, "{initial_text}")''') + super().__init__(topbar, value=initial_text) + self._chart.run_script(f'{self.id} = {topbar.id}.makeTextBoxWidget("{initial_text}")') def set(self, string): self.value = string @@ -341,42 +359,75 @@ class TextWidget(Widget): class SwitcherWidget(Widget): - def __init__(self, topbar, method, *options, default): - super().__init__(topbar) - self.value = default - self._method = str(method) + def __init__(self, topbar, options, default, func): + super().__init__(topbar, value=default, func=func) self._chart.run_script(f''' - makeSwitcher({self._chart.id}, {list(options)}, '{default}', '{self._method}', - '{topbar.active_background_color}', '{topbar.active_text_color}', '{topbar.text_color}', '{topbar.hover_color}') - reSize({self._chart.id}) + {self.id} = {topbar.id}.makeSwitcher({list(options)}, '{default}', '{self.id}') + reSize({self._chart.id}) ''') +class ButtonWidget(Widget): + def __init__(self, topbar, button, separator, func): + super().__init__(topbar, value=button, func=func) + self._chart.run_script(f''' + {self.id} = {topbar.id}.makeButton('{button}', '{self.id}') + {f'{topbar.id}.makeSeparator()' if separator else ''} + reSize({self._chart.id}) + ''') + + def set(self, string): + self.value = string + self._chart.run_script(f'{self.id}.elem.innerText = "{string}"') + + class TopBar: def __init__(self, chart): self._chart = chart + self.id = chart._rand.generate() self._widgets: Dict[str, Widget] = {} + + self.click_bg_color = '#50565E' + self.hover_bg_color = '#3c434c' + self.active_bg_color = 'rgba(0, 122, 255, 0.7)' + self.active_text_color = '#ececed' + self.text_color = '#d8d9db' + self._created = False + + def _create(self): + if self._created: + return + self._created = True + if not self._chart._callbacks_enabled: + self._chart._callbacks_enabled = True + self._chart.run_script(JS['callback']) self._chart.run_script(f''' - makeTopBar({self._chart.id}) - reSize({self._chart.id}) + {self.id} = new TopBar({self._chart.id}, '{self.hover_bg_color}', '{self.click_bg_color}', + '{self.active_bg_color}', '{self.text_color}', '{self.active_text_color}') + {self._chart.id}.topBar = {self.id}.topBar + reSize({self._chart.id}) ''') - self.active_background_color = 'rgba(0, 122, 255, 0.7)' - self.active_text_color = 'rgb(240, 240, 240)' - self.text_color = 'lightgrey' - self.hover_color = 'rgb(60, 60, 60)' - def __getitem__(self, item): return self._widgets.get(item) + def __getitem__(self, item): + if widget := self._widgets.get(item): + return widget + raise KeyError(f'Topbar widget "{item}" not found.') - def switcher(self, name, method, *options, default=None): - self._chart._methods[str(method)] = method - self._widgets[name] = SwitcherWidget(self, method, *options, default=default if default else options[0]) + def get(self, widget_name): return self._widgets.get(widget_name) - def textbox(self, name, initial_text=''): self._widgets[name] = TextWidget(self, initial_text) + def __setitem__(self, key, value): self._widgets[key] = value - def _widget_with_method(self, method_name): - for widget in self._widgets.values(): - if widget._method == method_name: - return widget + def switcher(self, name, options: tuple, default: str = None, func: callable = None): + self._create() + self._widgets[name] = SwitcherWidget(self, options, default if default else options[0], func) + + def textbox(self, name: str, initial_text: str = ''): + self._create() + self._widgets[name] = TextWidget(self, initial_text) + + def button(self, name, button_text: str, separator: bool = True, func: callable = None): + self._create() + self._widgets[name] = ButtonWidget(self, button_text, separator, func) class ToolBox: @@ -385,7 +436,8 @@ class ToolBox: self.id = chart.id self._return_q = chart._return_q self._save_under = None - self._saved_drawings = {} + self.drawings = {} + chart._handlers[f'save_drawings{self.id}'] = self._save_drawings def save_drawings_under(self, widget: Widget): """ @@ -397,9 +449,9 @@ class ToolBox: """ Loads and displays the drawings on the chart stored under the tag given. """ - if not self._saved_drawings.get(tag): + if not self.drawings.get(tag): return - self.run_script(f'if ("toolBox" in {self.id}) {self.id}.toolBox.loadDrawings({json.dumps(self._saved_drawings[tag])})') + self.run_script(f'if ("toolBox" in {self.id}) {self.id}.toolBox.loadDrawings({json.dumps(self.drawings[tag])})') def import_drawings(self, file_path): """ @@ -407,47 +459,48 @@ class ToolBox: """ with open(file_path, 'r') as f: json_data = json.load(f) - self._saved_drawings = json_data + self.drawings = json_data def export_drawings(self, file_path): """ Exports the current list of drawings to the given file path. """ with open(file_path, 'w+') as f: - json.dump(self._saved_drawings, f, indent=4) + json.dump(self.drawings, f, indent=4) def _save_drawings(self, drawings): if not self._save_under: return - self._saved_drawings[self._save_under.value] = json.loads(drawings) + self.drawings[self._save_under.value] = json.loads(drawings) 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, topbar: bool = False, searchbox: bool = False, toolbox: bool = False, - _js_api_code: str = None, autosize=True, _run_script=None): - self.volume_enabled = volume_enabled + def __init__(self, inner_width: float = 1.0, inner_height: float = 1.0, + scale_candles_only: bool = False, toolbox: bool = False, _js_api_code: str = None, + autosize: bool = True, _run_script=None): + self.loaded = False + self._scripts = [] + self._final_scripts = [] + if _run_script: + self.run_script = _run_script + self.run_script(f'window.callbackFunction = {_js_api_code}') if _js_api_code else None 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 = self._rand.generate() self._position = 'left' - self.loaded = False self._html = HTML - self._scripts = [] - self._final_scripts = [] self._script_func = None + self.candle_data = pd.DataFrame() self._last_bar = None self._interval = None - self._charts = {self.id: self} self._lines = [] - self.run_script(f'window.callbackFunction = {_js_api_code}') if _js_api_code else None - self._methods = {} + self.events: Events = Events(self) + self._handlers = {} self._return_q = None + self._callbacks_enabled = False + self.topbar: TopBar = TopBar(self) self._background_color = '#000000' self._volume_up_color = 'rgba(83,141,131,0.8)' @@ -457,7 +510,7 @@ class LWC(SeriesCommon): self.polygon: PolygonAPI = PolygonAPI(self) self.run_script(f''' - {self.id} = makeChart({self._inner_width}, {self._inner_height}, autoSize={_js_bool(autosize)}) + {self.id} = makeChart({self._inner_width}, {self._inner_height}, autoSize={jbool(autosize)}) {self.id}.id = '{self.id}' {self.id}.wrapper.style.float = "{self._position}" ''') @@ -465,12 +518,6 @@ class LWC(SeriesCommon): self.run_script(JS['toolbox']) self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})') self.toolbox: ToolBox = ToolBox(self) - 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: @@ -479,7 +526,7 @@ class LWC(SeriesCommon): [self.run_script(script) for script in self._scripts] [self.run_script(script) for script in self._final_scripts] - def run_script(self, script, run_last=False): + def run_script(self, script: str, run_last: bool = False): """ For advanced users; evaluates JavaScript within the Webview. """ @@ -498,14 +545,13 @@ class LWC(SeriesCommon): 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}()") + self.candle_data = pd.DataFrame() return bars = self._df_datetime_format(df) + self.candle_data = bars.copy() self._last_bar = bars.iloc[-1] - if self.volume_enabled: - if 'volume' not in bars: - raise MissingColumn("Volume enabled, but 'volume' column was not found.") + if 'volume' in bars: volume = bars.drop(columns=['open', 'high', 'low', 'close']).rename(columns={'volume': 'value'}) volume['color'] = self._volume_down_color volume.loc[bars['close'] > bars['open'], 'color'] = self._volume_up_color @@ -513,89 +559,39 @@ class LWC(SeriesCommon): bars = bars.drop(columns=['volume']) bars = bars.to_dict(orient='records') - self.run_script(f''' - {self.id}.candleData = {bars} - {self.id}.shownData = ({self.id}.candleData.length >= 190) ? {self.id}.candleData.slice(-190) : {self.id}.candleData - {self.id}.series.setData({self.id}.shownData); - - var timer = null; - {self.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {{ - if (timer !== null) {{ - return; - }} - timer = setTimeout(() => {{ - let chart = {self.id} - let logicalRange = chart.chart.timeScale().getVisibleLogicalRange(); - if (logicalRange !== null) {{ - let barsInfo = chart.series.barsInLogicalRange(logicalRange); - if (barsInfo === null || barsInfo.barsBefore === null || barsInfo.barsAfter === null) {{return}} - if (barsInfo !== null && barsInfo.barsBefore < 20 || barsInfo.barsAfter < 20) {{ - let newBeginning = chart.candleData.indexOf(chart.shownData[0])+Math.round(barsInfo.barsBefore)-20 - let newEnd = chart.candleData.indexOf(chart.shownData[chart.shownData.length-2])-Math.round(barsInfo.barsAfter)+20 - if (newBeginning < 0) {{ - newBeginning = 0 - }} - chart.shownData = chart.candleData.slice(newBeginning, newEnd) - if (newEnd-17 <= chart.candleData.length-1) {{ - chart.shownData[chart.shownData.length - 1] = Object.assign({{}}, chart.shownData[chart.shownData.length - 1]); - chart.shownData[chart.shownData.length - 1].open = chart.candleData[chart.candleData.length - 1].close; - chart.shownData[chart.shownData.length - 1].high = chart.candleData[chart.candleData.length - 1].close; - chart.shownData[chart.shownData.length - 1].low = chart.candleData[chart.candleData.length - 1].close; - chart.shownData[chart.shownData.length - 1].close = chart.candleData[chart.candleData.length - 1].close; - }} - chart.series.setData(chart.shownData); - }} - }} - timer = null; - }}, 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'{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'}()") + # for line in self._lines: + # if line.name in df.columns: + # line.set() + def fit(self): """ Fits the maximum amount of the chart data within the viewport. """ self.run_script(f'{self.id}.chart.timeScale().fitContent()') - def update(self, series, from_tick=False): + def update(self, series: pd.Series, _from_tick=False): """ Updates the data from a bar; if series['time'] is the same time as the last bar, the last bar will be overwritten.\n :param series: labels: date/time, open, high, low, close, volume (if volume enabled). """ - series = self._series_datetime_format(series) if not from_tick else series + series = self._series_datetime_format(series) if not _from_tick else series + if series['time'] != self._last_bar['time']: + self.candle_data.loc[self.candle_data.index[-1]] = self._last_bar + self.candle_data = pd.concat([self.candle_data, series.to_frame().T], ignore_index=True) + self.events.new_bar._emit(self) self._last_bar = series - if self.volume_enabled: - if 'volume' not in series: - raise MissingColumn("Volume enabled, but 'volume' column was not found.") + if 'volume' in series: volume = series.drop(['open', 'high', 'low', 'close']).rename({'volume': 'value'}) volume['color'] = self._volume_up_color if series['close'] > series['open'] else self._volume_down_color self.run_script(f'{self.id}.volumeSeries.update({volume.to_dict()})') series = series.drop(['volume']) bar = series.to_dict() self.run_script(f''' - - let logicalRange = {self.id}.chart.timeScale().getVisibleLogicalRange(); - 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} - }} - else {{ - if (barsInfo.barsAfter > 0) {{ - {self.id}.shownData[{self.id}.shownData.length-1] = {bar} - }} - else {{ - {self.id}.shownData.push({bar}) - }} - - {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''' 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} }} @@ -605,7 +601,7 @@ class LWC(SeriesCommon): {self.id}.series.update({bar}) ''') - def update_from_tick(self, series, cumulative_volume=False): + def update_from_tick(self, series: pd.Series, cumulative_volume: bool = False): """ Updates the data from a tick.\n :param series: labels: date/time, price, volume (if volume enabled). @@ -618,10 +614,8 @@ class LWC(SeriesCommon): bar['high'] = max(self._last_bar['high'], series['price']) bar['low'] = min(self._last_bar['low'], series['price']) bar['close'] = series['price'] - if self.volume_enabled: - if 'volume' not in series: - raise MissingColumn("Volume enabled, but 'volume' column was not found.") - elif cumulative_volume: + if 'volume' in series: + if cumulative_volume: bar['volume'] += series['volume'] else: bar['volume'] = series['volume'] @@ -629,15 +623,16 @@ class LWC(SeriesCommon): for key in ('open', 'high', 'low', 'close'): bar[key] = series['price'] bar['time'] = series['time'] - bar['volume'] = 0 - self.update(bar, from_tick=True) + if 'volume' in series: + bar['volume'] = series['volume'] + self.update(bar, _from_tick=True) - def create_line(self, color: str = 'rgba(214, 237, 255, 0.6)', width: int = 2, + def create_line(self, name: str = '', color: str = 'rgba(214, 237, 255, 0.6)', style: LINE_STYLE = 'solid', width: int = 2, price_line: bool = True, price_label: bool = True) -> Line: """ Creates and returns a Line object.)\n """ - self._lines.append(Line(self, color, width, price_line, price_label)) + self._lines.append(Line(self, name, color, style, width, price_line, price_label)) return self._lines[-1] def lines(self) -> List[Line]: @@ -647,12 +642,12 @@ 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 = Line(self, '', color, 'solid', 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 = Line(self, '', color, 'solid', width, price_line=False, price_label=False, crosshair_marker=False) line._set_trend(start_time, value, start_time, value, ray=True) return line @@ -661,13 +656,13 @@ class LWC(SeriesCommon): ticks_visible: bool = False, scale_margin_top: float = 0.2, scale_margin_bottom: float = 0.2): self.run_script(f''' {self.id}.series.priceScale().applyOptions({{ - mode: {_price_scale_mode(mode)}, - alignLabels: {_js_bool(align_labels)}, - borderVisible: {_js_bool(border_visible)}, + mode: {price_scale_mode(mode)}, + alignLabels: {jbool(align_labels)}, + borderVisible: {jbool(border_visible)}, {f'borderColor: "{border_color}",' if border_color else ''} {f'textColor: "{text_color}",' if text_color else ''} - entireTextOnly: {_js_bool(entire_text_only)}, - ticksVisible: {_js_bool(ticks_visible)}, + entireTextOnly: {jbool(entire_text_only)}, + ticksVisible: {jbool(ticks_visible)}, scaleMargins: {{top: {scale_margin_top}, bottom: {scale_margin_bottom}}} }})''') @@ -682,10 +677,10 @@ class LWC(SeriesCommon): timeScale: {{ rightOffset: {right_offset}, minBarSpacing: {min_bar_spacing}, - visible: {_js_bool(visible)}, - timeVisible: {_js_bool(time_visible)}, - secondsVisible: {_js_bool(seconds_visible)}, - borderVisible: {_js_bool(border_visible)}, + visible: {jbool(visible)}, + timeVisible: {jbool(time_visible)}, + secondsVisible: {jbool(seconds_visible)}, + borderVisible: {jbool(border_visible)}, {f'borderColor: "{border_color}",' if border_color else ''} }} }})''') @@ -716,14 +711,14 @@ class LWC(SeriesCommon): {self.id}.chart.applyOptions({{ grid: {{ vertLines: {{ - visible: {_js_bool(vert_enabled)}, + visible: {jbool(vert_enabled)}, color: "{color}", - style: {_line_style(style)}, + style: {line_style(style)}, }}, horzLines: {{ - visible: {_js_bool(horz_enabled)}, + visible: {jbool(horz_enabled)}, color: "{color}", - style: {_line_style(style)}, + style: {line_style(style)}, }}, }} }})""") @@ -732,18 +727,19 @@ class LWC(SeriesCommon): wick_enabled: bool = True, border_enabled: bool = True, border_up_color: str = '', border_down_color: str = '', wick_up_color: str = '', wick_down_color: str = ''): """ - Candle styling for each of its parts. + Candle styling for each of its parts.\n + If only `up_color` and `down_color` are passed, they will color all parts of the candle. """ self.run_script(f""" {self.id}.series.applyOptions({{ upColor: "{up_color}", downColor: "{down_color}", - wickVisible: {_js_bool(wick_enabled)}, - borderVisible: {_js_bool(border_enabled)}, - {f'borderUpColor: "{border_up_color}",' if border_up_color else up_color if border_enabled else ''} - {f'borderDownColor: "{border_down_color}",' if border_down_color else down_color if border_enabled else ''} - {f'wickUpColor: "{wick_up_color}",' if wick_up_color else wick_up_color if wick_enabled else ''} - {f'wickDownColor: "{wick_down_color}",' if wick_down_color else wick_down_color if wick_enabled else ''} + wickVisible: {jbool(wick_enabled)}, + borderVisible: {jbool(border_enabled)}, + {f'borderUpColor: "{border_up_color if border_up_color else up_color}",' if border_enabled else ''} + {f'borderDownColor: "{border_down_color if border_down_color else down_color}",' if border_enabled else ''} + {f'wickUpColor: "{wick_up_color if wick_up_color else up_color}",' if wick_enabled else ''} + {f'wickDownColor: "{wick_down_color if wick_down_color else down_color}",' if wick_enabled else ''} }})""") def volume_config(self, scale_margin_top: float = 0.8, scale_margin_bottom: float = 0.0, @@ -776,19 +772,19 @@ class LWC(SeriesCommon): self.run_script(f''' {self.id}.chart.applyOptions({{ crosshair: {{ - mode: {_crosshair_mode(mode)}, + mode: {crosshair_mode(mode)}, vertLine: {{ - visible: {_js_bool(vert_visible)}, + visible: {jbool(vert_visible)}, width: {vert_width}, {f'color: "{vert_color}",' if vert_color else ''} - style: {_line_style(vert_style)}, + style: {line_style(vert_style)}, labelBackgroundColor: "{vert_label_background_color}" }}, horzLine: {{ - visible: {_js_bool(horz_visible)}, + visible: {jbool(horz_visible)}, width: {horz_width}, {f'color: "{horz_color}",' if horz_color else ''} - style: {_line_style(horz_style)}, + style: {line_style(horz_style)}, labelBackgroundColor: "{horz_label_background_color}" }} }}}})''') @@ -817,7 +813,7 @@ class LWC(SeriesCommon): if not visible: return self.run_script(f''' - {self.id}.legend = new Legend({self.id}, {_js_bool(ohlc)}, {_js_bool(percent)}, {_js_bool(lines)}, '{color}', {font_size}, '{font_family}') + {self.id}.legend = new Legend({self.id}, {jbool(ohlc)}, {jbool(percent)}, {jbool(lines)}, '{color}', {font_size}, '{font_family}') ''') def spinner(self, visible): self.run_script(f"{self.id}.spinner.style.display = '{'block' if visible else 'none'}'") @@ -840,42 +836,40 @@ class LWC(SeriesCommon): serial_data = self._return_q.get() return b64decode(serial_data.split(',')[1]) - def add_hotkey(self, modifier_key: Literal['ctrl', 'alt', 'shift', 'meta'], keys: Union[str, tuple, int], method): - self._methods[str(method)] = method + def hotkey(self, modifier_key: Literal['ctrl', 'alt', 'shift', 'meta'], keys: Union[str, tuple, int], func: callable): if not isinstance(keys, tuple): keys = (keys,) for key in keys: key_code = 'Key' + key.upper() if isinstance(key, str) else 'Digit' + str(key) self.run_script(f''' - {self.id}.commandFunctions.unshift((event) => {{ - if (event.{modifier_key + 'Key'} && event.code === '{key_code}') {{ - event.preventDefault() - window.callbackFunction(`{str(method)}_~_{self.id}_~_{key}`) - return true - }} - else return false + {self.id}.commandFunctions.unshift((event) => {{ + if (event.{modifier_key + 'Key'} && event.code === '{key_code}') {{ + event.preventDefault() + window.callbackFunction(`{modifier_key, keys}_~_{key}`) + return true + }} + else return false }})''') + self._handlers[f'{modifier_key, keys}'] = func def create_table(self, width: Union[float, int], height: Union[float, int], headings: tuple, widths: tuple = None, alignments: tuple = None, - position: str = 'left', draggable: bool = False, method: object = None): - self._methods[str(method)] = method - return Table(self, width, height, headings, widths, alignments, position, draggable, method) + position: str = 'left', draggable: bool = False, func: callable = None) -> Table: + return Table(self, width, height, headings, widths, alignments, position, draggable, func) - 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, 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 + def create_subchart(self, position: Literal['left', 'right', 'top', 'bottom'] = 'left', width: float = 0.5, height: float = 0.5, + sync: Union[bool, str] = False, scale_candles_only: bool = False, toolbox: bool = False): + return SubChart(self, position, width, height, sync, scale_candles_only, toolbox) class SubChart(LWC): - def __init__(self, parent, volume_enabled, position, width, height, sync, dynamic_loading, scale_candles_only, topbar, searchbox, toolbox): + def __init__(self, parent, position, width, height, sync, scale_candles_only, 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, _run_script=self._chart.run_script) + super().__init__(width, height, scale_candles_only, toolbox, _run_script=self._chart.run_script) self._parent = parent self._position = position self._return_q = self._chart._return_q - self._charts = self._chart._charts + for key, val in self._handlers.items(): + self._chart._handlers[key] = val + self._handlers = self._chart._handlers self.polygon = self._chart.polygon._subchart(self) if not sync: diff --git a/lightweight_charts/chart.py b/lightweight_charts/chart.py index 9613581..56f6470 100644 --- a/lightweight_charts/chart.py +++ b/lightweight_charts/chart.py @@ -14,10 +14,8 @@ class CallbackAPI: self.emit_q, self.return_q = emit_queue, return_queue def callback(self, message: str): - messages = message.split('_~_') - name, chart_id = messages[:2] - args = messages[2:] - self.return_q.put(*args) if name == 'return' else self.emit_q.put((name, chart_id, *args)) + name, args = message.split('_~_') + self.return_q.put(*args) if name == 'return' else self.emit_q.put((name, args.split(';;;'))) class PyWV: @@ -60,22 +58,21 @@ 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, 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, topbar, searchbox, toolbox, 'pywebview.api.callback') + def __init__(self, width: int = 800, height: int = 600, x: int = None, y: int = None, + on_top: bool = False, maximize: bool = False, debug: bool = False, toolbox: bool = False, + inner_width: float = 1.0, inner_height: float = 1.0, scale_candles_only: bool = False): + super().__init__(inner_width, inner_height, scale_candles_only, toolbox, 'pywebview.api.callback') global chart, num_charts if chart: self._q, self._exit, self._start, self._process = chart._q, chart._exit, chart._start, chart._process self._emit_q, self._return_q = mp.Queue(), mp.Queue() - chart._charts[self.id] = self - self._api = chart._api + for key, val in self._handlers.items(): + chart._handlers[key] = val + self._handlers = chart._handlers self._loaded = chart._loaded_list[num_charts] self._q.put(('create_window', (self._html, on_top, width, height, x, y))) else: - self._api = api self._q, self._emit_q, self._return_q = (mp.Queue() for _ in range(3)) self._loaded_list = [mp.Event() for _ in range(10)] self._loaded = self._loaded_list[0] @@ -117,20 +114,9 @@ class Chart(LWC): self._exit.clear() return elif not self._emit_q.empty(): - name, chart_id, arg = self._emit_q.get() - if self._api: - self._api.chart = self._charts[chart_id] - if self._api and name == 'save_drawings': - func = self._api.chart.toolbox._save_drawings - elif name in ('on_search', 'on_horizontal_line_move'): - func = getattr(self._api, name) - else: - func = self._methods[name] - if self._api and hasattr(self._api.chart, 'topbar') and (widget := self._api.chart.topbar._widget_with_method(name)): - widget.value = arg - await func() if asyncio.iscoroutinefunction(func) else func() - else: - await func(*arg.split(';;;')) if asyncio.iscoroutinefunction(func) else func(*arg.split(';;;')) + name, args = self._emit_q.get() + func = self._handlers[name] + await func(*args) if asyncio.iscoroutinefunction(func) else func(*args) continue value = self.polygon._q.get() func, args = value[0], value[1:] @@ -148,12 +134,10 @@ class Chart(LWC): """ Exits and destroys the chart window.\n """ - if not self.loaded: - global num_charts, chart - chart = None - num_charts = 0 - else: - self._q.put((self.i, 'exit')) - self._exit.wait() + global num_charts, chart + chart = None + num_charts = 0 + self._q.put((self.i, 'exit')) + self._exit.wait() self._process.terminate() del self diff --git a/lightweight_charts/js/callback.js b/lightweight_charts/js/callback.js index 662305d..f23eaa4 100644 --- a/lightweight_charts/js/callback.js +++ b/lightweight_charts/js/callback.js @@ -1,3 +1,123 @@ +if (!window.TopBar) { + class TopBar { + constructor(chart, hoverBackgroundColor, clickBackgroundColor, activeBackgroundColor, textColor, activeTextColor) { + this.makeSwitcher = this.makeSwitcher.bind(this) + this.hoverBackgroundColor = hoverBackgroundColor + this.clickBackgroundColor = clickBackgroundColor + this.activeBackgroundColor = activeBackgroundColor + this.textColor = textColor + this.activeTextColor = activeTextColor + + this.topBar = document.createElement('div') + this.topBar.style.backgroundColor = '#0c0d0f' + this.topBar.style.borderBottom = '2px solid #3C434C' + this.topBar.style.display = 'flex' + this.topBar.style.alignItems = 'center' + chart.wrapper.prepend(this.topBar) + } + makeSwitcher(items, activeItem, callbackName) { + let switcherElement = document.createElement('div'); + switcherElement.style.margin = '4px 12px' + let widget = { + elem: switcherElement, + callbackName: callbackName, + } + let intervalElements = items.map((item)=> { + let itemEl = document.createElement('button'); + itemEl.style.border = 'none' + itemEl.style.padding = '2px 5px' + itemEl.style.margin = '0px 2px' + itemEl.style.fontSize = '13px' + itemEl.style.borderRadius = '4px' + itemEl.style.backgroundColor = item === activeItem ? this.activeBackgroundColor : 'transparent' + itemEl.style.color = item === activeItem ? this.activeTextColor : this.textColor + itemEl.innerText = item; + document.body.appendChild(itemEl) + itemEl.style.minWidth = itemEl.clientWidth + 1 + 'px' + document.body.removeChild(itemEl) + + itemEl.addEventListener('mouseenter', () => itemEl.style.backgroundColor = item === activeItem ? this.activeBackgroundColor : this.hoverBackgroundColor) + itemEl.addEventListener('mouseleave', () => itemEl.style.backgroundColor = item === activeItem ? this.activeBackgroundColor : 'transparent') + itemEl.addEventListener('mousedown', () => itemEl.style.backgroundColor = item === activeItem ? this.activeBackgroundColor : this.clickBackgroundColor) + itemEl.addEventListener('mouseup', () => itemEl.style.backgroundColor = item === activeItem ? this.activeBackgroundColor : this.hoverBackgroundColor) + itemEl.addEventListener('click', () => onItemClicked(item)) + + switcherElement.appendChild(itemEl); + return itemEl; + }); + + let onItemClicked = (item)=> { + if (item === activeItem) return + intervalElements.forEach((element, index) => { + element.style.backgroundColor = items[index] === item ? this.activeBackgroundColor : 'transparent' + element.style.color = items[index] === item ? this.activeTextColor : this.textColor + element.style.fontWeight = items[index] === item ? '500' : 'normal' + }) + activeItem = item; + window.callbackFunction(`${widget.callbackName}_~_${item}`); + } + + this.topBar.appendChild(switcherElement) + this.makeSeparator(this.topBar) + return widget + } + makeTextBoxWidget(text) { + let textBox = document.createElement('div') + textBox.style.margin = '0px 18px' + textBox.style.fontSize = '16px' + textBox.style.color = 'rgb(220, 220, 220)' + textBox.innerText = text + this.topBar.append(textBox) + this.makeSeparator(this.topBar) + return textBox + } + makeButton(defaultText, callbackName) { + let button = document.createElement('button') + button.style.border = 'none' + button.style.padding = '2px 5px' + button.style.margin = '4px 18px' + button.style.fontSize = '13px' + button.style.backgroundColor = 'transparent' + button.style.color = this.textColor + button.style.borderRadius = '4px' + button.innerText = defaultText; + document.body.appendChild(button) + button.style.minWidth = button.clientWidth+1+'px' + document.body.removeChild(button) + + let widget = { + elem: button, + callbackName: callbackName + } + + button.addEventListener('mouseenter', () => button.style.backgroundColor = this.hoverBackgroundColor) + button.addEventListener('mouseleave', () => button.style.backgroundColor = 'transparent') + button.addEventListener('click', () => window.callbackFunction(`${widget.callbackName}_~_${button.innerText}`)); + button.addEventListener('mousedown', () => { + button.style.backgroundColor = this.activeBackgroundColor + button.style.color = this.activeTextColor + button.style.fontWeight = '500' + }) + button.addEventListener('mouseup', () => { + button.style.backgroundColor = this.hoverBackgroundColor + button.style.color = this.textColor + button.style.fontWeight = 'normal' + }) + this.topBar.appendChild(button) + return widget + } + + makeSeparator() { + let seperator = document.createElement('div') + seperator.style.width = '1px' + seperator.style.height = '20px' + seperator.style.backgroundColor = '#3C434C' + this.topBar.appendChild(seperator) + } + } + window.TopBar = TopBar +} + function makeSearchBox(chart) { let searchWindow = document.createElement('div') searchWindow.style.position = 'absolute' @@ -37,19 +157,13 @@ function makeSearchBox(chart) { let yPrice = null chart.chart.subscribeCrosshairMove((param) => { - if (param.point){ - yPrice = param.point.y; - } - }); + if (param.point) yPrice = param.point.y; + }) let selectedChart = false - chart.wrapper.addEventListener('mouseover', (event) => { - selectedChart = true - }) - chart.wrapper.addEventListener('mouseout', (event) => { - selectedChart = false - }) + chart.wrapper.addEventListener('mouseover', (event) => selectedChart = true) + chart.wrapper.addEventListener('mouseout', (event) => selectedChart = false) chart.commandFunctions.push((event) => { - if (!selectedChart) return + if (!selectedChart) return false if (searchWindow.style.display === 'none') { if (/^[a-zA-Z0-9]$/.test(event.key)) { searchWindow.style.display = 'flex'; @@ -58,22 +172,15 @@ function makeSearchBox(chart) { } else return false } - else if (event.key === 'Enter') { - window.callbackFunction(`on_search_~_${chart.id}_~_${sBox.value}`) - searchWindow.style.display = 'none' - sBox.value = '' - return true - } - else if (event.key === 'Escape') { + else if (event.key === 'Enter' || event.key === 'Escape') { + if (event.key === 'Enter') window.callbackFunction(`search${chart.id}_~_${sBox.value}`) searchWindow.style.display = 'none' sBox.value = '' return true } else return false }) - sBox.addEventListener('input', function() { - sBox.value = sBox.value.toUpperCase(); - }); + sBox.addEventListener('input', () => sBox.value = sBox.value.toUpperCase()) return { window: searchWindow, box: sBox, @@ -104,77 +211,4 @@ function makeSpinner(chart) { animateSpinner(); } -function makeSwitcher(chart, items, activeItem, callbackName, activeBackgroundColor, activeColor, inactiveColor, hoverColor) { - let switcherElement = document.createElement('div'); - switcherElement.style.margin = '4px 14px' - switcherElement.style.zIndex = '1000' - let intervalElements = items.map(function(item) { - let itemEl = document.createElement('button'); - itemEl.style.cursor = 'pointer' - itemEl.style.padding = '2px 5px' - itemEl.style.margin = '0px 4px' - itemEl.style.fontSize = '13px' - itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : 'transparent' - itemEl.style.color = item === activeItem ? activeColor : inactiveColor - itemEl.style.border = 'none' - itemEl.style.borderRadius = '4px' - - itemEl.addEventListener('mouseenter', function() { - itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : hoverColor - itemEl.style.color = activeColor - }) - itemEl.addEventListener('mouseleave', function() { - itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : 'transparent' - itemEl.style.color = item === activeItem ? activeColor : inactiveColor - }) - itemEl.innerText = item; - itemEl.addEventListener('click', function() { - onItemClicked(item); - }); - switcherElement.appendChild(itemEl); - return itemEl; - }); - function onItemClicked(item) { - if (item === activeItem) { - return; - } - intervalElements.forEach(function(element, index) { - element.style.backgroundColor = items[index] === item ? activeBackgroundColor : 'transparent' - element.style.color = items[index] === item ? 'activeColor' : inactiveColor - }); - activeItem = item; - window.callbackFunction(`${callbackName}_~_${chart.id}_~_${item}`); - } - chart.topBar.appendChild(switcherElement) - makeSeperator(chart.topBar) - return switcherElement; -} - -function makeTextBoxWidget(chart, text) { - let textBox = document.createElement('div') - textBox.style.margin = '0px 18px' - textBox.style.fontSize = '16px' - textBox.style.color = 'rgb(220, 220, 220)' - textBox.innerText = text - chart.topBar.append(textBox) - makeSeperator(chart.topBar) - return textBox -} - -function makeTopBar(chart) { - chart.topBar = document.createElement('div') - chart.topBar.style.backgroundColor = '#191B1E' - chart.topBar.style.borderBottom = '2px solid #3C434C' - chart.topBar.style.display = 'flex' - chart.topBar.style.alignItems = 'center' - chart.wrapper.prepend(chart.topBar) -} - -function makeSeperator(topBar) { - let seperator = document.createElement('div') - seperator.style.width = '1px' - seperator.style.height = '20px' - seperator.style.backgroundColor = '#3C434C' - topBar.appendChild(seperator) - } diff --git a/lightweight_charts/js/funcs.js b/lightweight_charts/js/funcs.js index 5b4cbf9..ff6d953 100644 --- a/lightweight_charts/js/funcs.js +++ b/lightweight_charts/js/funcs.js @@ -10,7 +10,8 @@ function makeChart(innerWidth, innerHeight, autoSize=true) { height: innerHeight, }, candleData: [], - commandFunctions: [] + commandFunctions: [], + precision: 2, } chart.chart = LightweightCharts.createChart(chart.div, { width: window.innerWidth*innerWidth, @@ -125,6 +126,12 @@ if (!window.HorizontalLine) { this.line = this.chart.series.createPriceLine(this.priceLine) } + updateColor(color) { + this.chart.series.removePriceLine(this.line) + this.priceLine.color = color + 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)) @@ -135,16 +142,17 @@ if (!window.HorizontalLine) { window.HorizontalLine = HorizontalLine class Legend { - constructor(chart, ohlcEnabled = true, percentEnabled = true, linesEnabled = true, + constructor(chart, ohlcEnabled, percentEnabled, linesEnabled, 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.pointerEvents = 'none' 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.maxWidth = `${(chart.scale.width * 100) - 8}vw` this.div.style.color = color this.div.style.fontSize = fontSize + 'px' this.div.style.fontFamily = fontFamily @@ -158,7 +166,16 @@ if (!window.HorizontalLine) { this.linesEnabled = linesEnabled this.makeLines(chart) - let legendItemFormat = (num) => num.toFixed(2).toString().padStart(8, ' ') + let legendItemFormat = (num, decimal) => num.toFixed(decimal).toString().padStart(8, ' ') + + let shorthandFormat = (num) => { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K'; + } + return num.toString().padStart(8, ' '); + } chart.chart.subscribeCrosshairMove((param) => { if (param.time) { @@ -166,22 +183,23 @@ if (!window.HorizontalLine) { 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 ohlc = `O ${legendItemFormat(data.open, chart.precision)} + | H ${legendItemFormat(data.high, chart.precision)} + | L ${legendItemFormat(data.low, chart.precision)} + | C ${legendItemFormat(data.close, chart.precision)} ` let percentMove = ((data.close - data.open) / data.open) * 100 let percent = `| ${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %` - finalString += ohlcEnabled ? ohlc : '' finalString += percentEnabled ? percent : '' + let volumeData = param.seriesData.get(chart.volumeSeries) + if (volumeData) finalString += ohlcEnabled ? `
V ${shorthandFormat(volumeData.value)}` : '' } 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}` + let price = legendItemFormat(param.seriesData.get(line.line.series).value, line.line.precision) + line.div.innerHTML = ` ${line.line.name} : ${price}` }) } else { @@ -215,6 +233,7 @@ if (!window.HorizontalLine) { let toggle = document.createElement('div') toggle.style.borderRadius = '4px' toggle.style.marginLeft = '10px' + toggle.style.pointerEvents = 'auto' let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -258,6 +277,7 @@ if (!window.HorizontalLine) { row: row, toggle: toggle, line: line, + solid: line.color.startsWith('rgba') ? line.color.replace(/[^,]+(?=\))/, '1') : line.color } } } @@ -382,16 +402,20 @@ if (!window.ContextMenu) { this.menu.style.position = 'absolute' this.menu.style.zIndex = '10000' this.menu.style.background = 'rgb(50, 50, 50)' - this.menu.style.color = 'lightgrey' + this.menu.style.color = '#ececed' this.menu.style.display = 'none' this.menu.style.borderRadius = '5px' this.menu.style.padding = '3px 3px' - this.menu.style.fontSize = '14px' + this.menu.style.fontSize = '13px' this.menu.style.cursor = 'default' document.body.appendChild(this.menu) + this.hoverItem = null let closeMenu = (event) => { - if (!this.menu.contains(event.target)) this.menu.style.display = 'none'; + if (!this.menu.contains(event.target)) { + this.menu.style.display = 'none'; + this.listen(false) + } } this.onRightClick = (event) => { @@ -406,16 +430,44 @@ if (!window.ContextMenu) { listen(active) { active ? document.addEventListener('contextmenu', this.onRightClick) : document.removeEventListener('contextmenu', this.onRightClick) } - menuItem(text, action) { + menuItem(text, action, hover=false) { + let item = document.createElement('div') + item.style.display = 'flex' + item.style.alignItems = 'center' + item.style.justifyContent = 'space-between' + item.style.padding = '0px 10px' + item.style.margin = '3px 0px' + item.style.borderRadius = '3px' + this.menu.appendChild(item) + let elem = document.createElement('div') elem.innerText = text - elem.style.padding = '0px 10px' - elem.style.borderRadius = '3px' - this.menu.appendChild(elem) - elem.addEventListener('mouseover', (event) => elem.style.backgroundColor = 'rgba(0, 122, 255, 0.3)') - elem.addEventListener('mouseout', (event) => elem.style.backgroundColor = 'transparent') - elem.addEventListener('click', (event) => {action(); this.menu.style.display = 'none'}) + item.appendChild(elem) + + if (hover) { + let arrow = document.createElement('div') + arrow.innerHTML = `` + item.appendChild(arrow) + } + + elem.addEventListener('mouseover', (event) => { + item.style.backgroundColor = 'rgba(0, 122, 255, 0.3)' + if (this.hoverItem && this.hoverItem.closeAction) this.hoverItem.closeAction() + this.hoverItem = {elem: elem, action: action, closeAction: hover} + }) + elem.addEventListener('mouseout', (event) => item.style.backgroundColor = 'transparent') + if (!hover) elem.addEventListener('click', (event) => {action(event); this.menu.style.display = 'none'}) + else elem.addEventListener('mouseover', () => action(item.getBoundingClientRect())) } + separator() { + let separator = document.createElement('div') + separator.style.width = '90%' + separator.style.height = '1px' + separator.style.margin = '4px 0px' + separator.style.backgroundColor = '#3C434C' + this.menu.appendChild(separator) + } + } window.ContextMenu = ContextMenu } diff --git a/lightweight_charts/js/table.js b/lightweight_charts/js/table.js index a07e951..0a2e7d3 100644 --- a/lightweight_charts/js/table.js +++ b/lightweight_charts/js/table.js @@ -1,8 +1,8 @@ if (!window.Table) { class Table { - constructor(width, height, headings, widths, alignments, position, draggable = false, pythonMethod, chart) { + constructor(width, height, headings, widths, alignments, position, draggable = false, chart) { this.container = document.createElement('div') - this.pythonMethod = pythonMethod + this.callbackName = null this.chart = chart if (draggable) { @@ -15,12 +15,12 @@ if (!window.Table) { this.container.style.zIndex = '2000' this.container.style.width = width <= 1 ? width * 100 + '%' : width + 'px' - this.container.style.height = height <= 1 ? height * 100 + '%' : height + 'px' + this.container.style.minHeight = height <= 1 ? height * 100 + '%' : height + 'px' this.container.style.display = 'flex' this.container.style.flexDirection = 'column' this.container.style.justifyContent = 'space-between' - this.container.style.backgroundColor = 'rgb(45, 45, 45)' + this.container.style.backgroundColor = '#121417' this.container.style.borderRadius = '5px' this.container.style.color = 'white' this.container.style.fontSize = '12px' @@ -29,7 +29,8 @@ if (!window.Table) { this.table = document.createElement('table') this.table.style.width = '100%' this.table.style.borderCollapse = 'collapse' - this.table.style.border = '1px solid rgb(70, 70, 70)'; + this.container.style.overflow = 'hidden' + this.rows = {} this.headings = headings @@ -43,6 +44,9 @@ if (!window.Table) { let th = document.createElement('th') th.textContent = this.headings[i] th.style.width = this.widths[i] + th.style.letterSpacing = '0.03rem' + th.style.padding = '0.2rem 0px' + th.style.fontWeight = '500' th.style.textAlign = 'center' row.appendChild(th) th.style.border = '1px solid rgb(70, 70, 70)' @@ -93,10 +97,9 @@ if (!window.Table) { } row.addEventListener('mouseover', () => row.style.backgroundColor = 'rgba(60, 60, 60, 0.6)') row.addEventListener('mouseout', () => row.style.backgroundColor = 'transparent') - row.addEventListener('mousedown', () => { - row.style.backgroundColor = 'rgba(60, 60, 60)' - window.callbackFunction(`${this.pythonMethod}_~_${this.chart.id}_~_${id}`) - }) + row.addEventListener('mousedown', () => row.style.backgroundColor = 'rgba(60, 60, 60)') + + row.addEventListener('click', () => window.callbackFunction(`${this.callbackName}_~_${id}`)) row.addEventListener('mouseup', () => row.style.backgroundColor = 'rgba(60, 60, 60, 0.6)') this.rows[id] = row @@ -134,6 +137,11 @@ if (!window.Table) { this.footer[i].style.textAlign = 'center' } } + toJSON() { + // Exclude the chart attribute from serialization + const {chart, ...serialized} = this; + return serialized; + } } window.Table = Table } diff --git a/lightweight_charts/js/toolbox.js b/lightweight_charts/js/toolbox.js index 2fff304..ec30ff1 100644 --- a/lightweight_charts/js/toolbox.js +++ b/lightweight_charts/js/toolbox.js @@ -4,6 +4,7 @@ if (!window.ToolBox) { this.onTrendSelect = this.onTrendSelect.bind(this) this.onHorzSelect = this.onHorzSelect.bind(this) this.onRaySelect = this.onRaySelect.bind(this) + this.saveDrawings = this.saveDrawings.bind(this) this.chart = chart this.drawings = [] @@ -15,7 +16,8 @@ if (!window.ToolBox) { this.activeIconColor = 'rgb(240, 240, 240)' this.iconColor = 'lightgrey' this.backgroundColor = 'transparent' - this.hoverColor = 'rgba(60, 60, 60, 0.7)' + this.hoverColor = 'rgba(80, 86, 94, 0.7)' + this.clickBackgroundColor = 'rgba(90, 106, 104, 0.7)' this.elem = this.makeToolBox() this.subscribeHoverMove() @@ -92,11 +94,15 @@ if (!window.ToolBox) { icon.elem.addEventListener('mouseenter', () => { icon.elem.style.backgroundColor = icon === this.chart.activeIcon ? this.activeBackgroundColor : this.hoverColor - document.body.style.cursor = 'pointer' }) icon.elem.addEventListener('mouseleave', () => { icon.elem.style.backgroundColor = icon === this.chart.activeIcon ? this.activeBackgroundColor : this.backgroundColor - document.body.style.cursor = this.chart.cursor + }) + icon.elem.addEventListener('mousedown', () => { + icon.elem.style.backgroundColor = icon === this.chart.activeIcon ? this.activeBackgroundColor : this.clickBackgroundColor + }) + icon.elem.addEventListener('mouseup', () => { + icon.elem.style.backgroundColor = icon === this.chart.activeIcon ? this.activeBackgroundColor : 'transparent' }) icon.elem.addEventListener('click', () => { if (this.chart.activeIcon) { @@ -118,6 +124,7 @@ if (!window.ToolBox) { }) this.chart.commandFunctions.push((event) => { if (event.altKey && event.code === keyCmd) { + event.preventDefault() if (this.chart.activeIcon) { this.chart.activeIcon.elem.style.backgroundColor = this.backgroundColor group.setAttribute("fill", this.iconColor) @@ -140,6 +147,7 @@ if (!window.ToolBox) { onTrendSelect(toggle, ray = false) { let trendLine = { line: null, + color: 'rgb(15, 139, 237)', markers: null, data: null, from: null, @@ -160,17 +168,14 @@ if (!window.ToolBox) { if (!this.makingDrawing) return - let logical + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false}) + let logical = this.chart.chart.timeScale().getVisibleLogicalRange() 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) @@ -179,19 +184,11 @@ if (!window.ToolBox) { trendLine.from = [data[0].time, data[0].value] trendLine.to = [data[data.length - 1].time, data[data.length-1].value] - if (ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange() - trendLine.line.setData(data) - if (logical) { - this.chart.chart.applyOptions({handleScroll: false}) - setTimeout(() => { - this.chart.chart.timeScale().setVisibleLogicalRange(logical) - }, 1) - setTimeout(() => { - this.chart.chart.applyOptions({handleScroll: true}) - }, 50) - } + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true}) + this.chart.chart.timeScale().setVisibleLogicalRange(logical) + if (!ray) { trendLine.markers = [ {time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}, @@ -208,6 +205,7 @@ if (!window.ToolBox) { if (!this.makingDrawing) { this.makingDrawing = true trendLine.line = this.chart.chart.addLineSeries({ + color: 'rgb(15, 139, 237)', lineWidth: 2, lastValueVisible: false, priceLineVisible: false, @@ -221,14 +219,11 @@ if (!window.ToolBox) { }) 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.applyOptions({handleScroll: false}) this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) - } else { - this.chart.chart.applyOptions({ - handleScroll: true - }) + } + else { + this.chart.chart.applyOptions({handleScroll: true}) this.makingDrawing = false trendLine.line.setMarkers([]) this.drawings.push(trendLine) @@ -247,7 +242,7 @@ if (!window.ToolBox) { 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) + let line = new HorizontalLine(this.chart, 'toolBox', price,'red', 2, lineStyle, true) this.drawings.push(line) this.chart.chart.unsubscribeClick(this.clickHandlerHorz) document.body.style.cursor = 'default' @@ -267,9 +262,16 @@ if (!window.ToolBox) { subscribeHoverMove() { let hoveringOver = null let x, y + let colorPicker = new ColorPicker(this.saveDrawings) let onClickDelete = () => this.deleteDrawing(contextMenu.drawing) + let onClickColor = (rect) => colorPicker.openMenu(rect, contextMenu.drawing) let contextMenu = new ContextMenu() + contextMenu.menuItem('Color Picker', onClickColor, () =>{ + document.removeEventListener('click', colorPicker.closeMenu) + colorPicker.container.style.display = 'none' + }) + contextMenu.separator() contextMenu.menuItem('Delete Drawing', onClickDelete) let hoverOver = (param) => { @@ -325,9 +327,7 @@ if (!window.ToolBox) { let checkForClick = (event) => { mouseDown = true document.body.style.cursor = 'grabbing' - this.chart.chart.applyOptions({ - handleScroll: false - }) + this.chart.chart.applyOptions({handleScroll: false}) this.chart.chart.unsubscribeCrosshairMove(hoverOver) @@ -354,7 +354,7 @@ if (!window.ToolBox) { this.chart.chart.applyOptions({handleScroll: true}) if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') { - window.callbackFunction(`on_horizontal_line_move_~_${this.chart.id}_~_${hoveringOver.id};;;${hoveringOver.price.toFixed(8)}`); + window.callbackFunction(`${hoveringOver.id}_~_${hoveringOver.price.toFixed(8)}`); } hoveringOver = null document.removeEventListener('mousedown', checkForClick) @@ -390,14 +390,15 @@ if (!window.ToolBox) { let endValue = hoveringOver.to[1] + priceDiff let data = calculateTrendLine(startDate, startValue, endDate, endValue, this.interval, this.chart, hoveringOver.ray) - let logical - if (chartTimeToDate(data[data.length - 1].time).getTime() >= chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime()) { - logical = this.chart.chart.timeScale().getVisibleLogicalRange() - } + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false}) + let logical = this.chart.chart.timeScale().getVisibleLogicalRange() + hoveringOver.from = [data[0].time, data[0].value] hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value] hoveringOver.line.setData(data) - if (logical) this.chart.chart.timeScale().setVisibleLogicalRange(logical) + + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true}) + this.chart.chart.timeScale().setVisibleLogicalRange(logical) if (!hoveringOver.ray) { hoveringOver.markers = [ @@ -428,22 +429,23 @@ if (!window.ToolBox) { firstPrice = hoveringOver.to[1] } - let logical + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false}) + let logical = this.chart.chart.timeScale().getVisibleLogicalRange() + 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() } - let data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart) hoveringOver.line.setData(data) + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true}) + this.chart.chart.timeScale().setVisibleLogicalRange(logical) + hoveringOver.from = [data[0].time, data[0].value] hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value] - if (logical) this.chart.chart.timeScale().setVisibleLogicalRange(logical) + hoveringOver.markers = [ {time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}, @@ -468,7 +470,6 @@ if (!window.ToolBox) { } 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[0]).getTime() / this.interval) * this.interval), this.interval) @@ -478,7 +479,6 @@ if (!window.ToolBox) { item.to = [data[data.length - 1].time, data[data.length-1].value] item.line.setData(data) }) - //this.chart.chart.timeScale().setVisibleLogicalRange(logical) } deleteDrawing(drawing) { @@ -486,10 +486,9 @@ if (!window.ToolBox) { this.chart.series.removePriceLine(drawing.line) } else { - let logical - if (drawing.ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange() + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false}) this.chart.chart.removeSeries(drawing.line); - if (drawing.ray) this.chart.chart.timeScale().setVisibleLogicalRange(logical) + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true}) } this.drawings.splice(this.drawings.indexOf(drawing), 1) this.saveDrawings() @@ -512,22 +511,22 @@ if (!window.ToolBox) { } return value; }); - window.callbackFunction(`save_drawings_~_${this.chart.id}_~_${drawingsString}`) + window.callbackFunction(`save_drawings${this.chart.id}_~_${drawingsString}`) } loadDrawings(drawings) { this.drawings = drawings - this.chart.chart.applyOptions({ - handleScroll: false - }) - let logical = this.chart.chart.timeScale().getVisibleLogicalRange() + this.chart.chart.applyOptions({handleScroll: false}) + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false}) this.drawings.forEach((item) => { + let idx = this.drawings.indexOf(item) if ('price' in item) { - this.drawings[this.drawings.indexOf(item)] = new HorizontalLine(this.chart, 'toolBox', item.priceLine.price, item.priceLine.color, 2, item.priceLine.lineStyle, item.priceLine.axisLabelVisible) + this.drawings[idx] = new HorizontalLine(this.chart, 'toolBox', item.priceLine.price, item.priceLine.color, 2, item.priceLine.lineStyle, item.priceLine.axisLabelVisible) } else { - this.drawings[this.drawings.indexOf(item)].line = this.chart.chart.addLineSeries({ + this.drawings[idx].line = this.chart.chart.addLineSeries({ lineWidth: 2, + color: this.drawings[idx].color, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: false, @@ -546,11 +545,140 @@ if (!window.ToolBox) { item.line.setData(data) } }) - this.chart.chart.applyOptions({ - handleScroll: true - }) - this.chart.chart.timeScale().setVisibleLogicalRange(logical) + this.chart.chart.applyOptions({handleScroll: true}) + this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true}) } } window.ToolBox = ToolBox -} \ No newline at end of file +} + +if (!window.ColorPicker) { + class ColorPicker { + constructor(saveDrawings) { + this.saveDrawings = saveDrawings + + this.container = document.createElement('div') + this.container.style.maxWidth = '170px' + this.container.style.backgroundColor = '#191B1E' + this.container.style.position = 'absolute' + this.container.style.zIndex = '10000' + this.container.style.display = 'none' + this.container.style.flexDirection = 'column' + this.container.style.alignItems = 'center' + this.container.style.border = '2px solid #3C434C' + this.container.style.borderRadius = '8px' + this.container.style.cursor = 'default' + + let colorPicker = document.createElement('div') + colorPicker.style.margin = '10px' + colorPicker.style.display = 'flex' + colorPicker.style.flexWrap = 'wrap' + + let colors = [ + '#EBB0B0','#E9CEA1','#E5DF80','#ADEB97','#A3C3EA','#D8BDED', + '#E15F5D','#E1B45F','#E2D947','#4BE940','#639AE1','#D7A0E8', + '#E42C2A','#E49D30','#E7D827','#3CFF0A','#3275E4','#B06CE3', + '#F3000D','#EE9A14','#F1DA13','#2DFC0F','#1562EE','#BB00EF', + '#B50911','#E3860E','#D2BD11','#48DE0E','#1455B4','#6E009F', + '#7C1713','#B76B12','#8D7A13','#479C12','#165579','#51007E', + ] + + colors.forEach((color) => colorPicker.appendChild(this.makeColorBox(color))) + + let separator = document.createElement('div') + separator.style.backgroundColor = '#3C434C' + separator.style.height = '1px' + separator.style.width = '130px' + + let opacity = document.createElement('div') + opacity.style.margin = '10px' + + let opacityText = document.createElement('div') + opacityText.style.color = 'lightgray' + opacityText.style.fontSize = '12px' + opacityText.innerText = 'Opacity' + + let opacityValue = document.createElement('div') + opacityValue.style.color = 'lightgray' + opacityValue.style.fontSize = '12px' + + let opacitySlider = document.createElement('input') + opacitySlider.type = 'range' + opacitySlider.value = this.opacity*100 + opacityValue.innerText = opacitySlider.value+'%' + opacitySlider.oninput = () => { + opacityValue.innerText = opacitySlider.value+'%' + this.opacity = opacitySlider.value/100 + this.updateColor() + } + + opacity.appendChild(opacityText) + opacity.appendChild(opacitySlider) + opacity.appendChild(opacityValue) + + this.container.appendChild(colorPicker) + this.container.appendChild(separator) + this.container.appendChild(opacity) + document.getElementById('wrapper').appendChild(this.container) + + } + makeColorBox(color) { + let box = document.createElement('div') + box.style.width = '18px' + box.style.height = '18px' + box.style.borderRadius = '3px' + box.style.margin = '3px' + box.style.boxSizing = 'border-box' + box.style.backgroundColor = color + + box.addEventListener('mouseover', (event) => box.style.border = '2px solid lightgray') + box.addEventListener('mouseout', (event) => box.style.border = 'none') + + let rgbValues = this.extractRGB(color) + + box.addEventListener('click', (event) => { + this.rgbValues = rgbValues + this.updateColor() + }) + return box + } + extractRGB = (anyColor) => { + let dummyElem = document.createElement('div'); + dummyElem.style.color = anyColor; + document.body.appendChild(dummyElem); + let computedColor = getComputedStyle(dummyElem).color; + document.body.removeChild(dummyElem); + let colorValues = computedColor.match(/\d+/g).map(Number); + let isRgba = computedColor.includes('rgba'); + let opacity = isRgba ? parseFloat(computedColor.split(',')[3]) : 1 + return [colorValues[0], colorValues[1], colorValues[2], opacity] + } + updateColor() { + let oColor = `rgba(${this.rgbValues[0]}, ${this.rgbValues[1]}, ${this.rgbValues[2]}, ${this.opacity})` + if ('price' in this.drawing) this.drawing.updateColor(oColor) + else { + this.drawing.color = oColor + this.drawing.line.applyOptions({color: oColor}) + } + this.saveDrawings() + } + openMenu(rect, drawing) { + this.drawing = drawing + this.rgbValues = this.extractRGB('price' in drawing ? drawing.priceLine.color : drawing.color) + this.opacity = parseFloat(this.rgbValues[3]) + this.container.style.top = (rect.top-30)+'px' + this.container.style.left = rect.right+'px' + this.container.style.display = 'flex' + setTimeout(() => document.addEventListener('mousedown', (event) => { + if (!this.container.contains(event.target)) { + this.closeMenu() + } + }), 10) + } + closeMenu(event) { + document.removeEventListener('click', this.closeMenu) + this.container.style.display = 'none' + } + } + window.ColorPicker = ColorPicker +} diff --git a/lightweight_charts/polygon.py b/lightweight_charts/polygon.py index 1ab5aa2..615f49c 100644 --- a/lightweight_charts/polygon.py +++ b/lightweight_charts/polygon.py @@ -1,6 +1,7 @@ import asyncio import logging import datetime as dt +import re import threading import queue import json @@ -8,7 +9,6 @@ import ssl from typing import Literal, Union, List import pandas as pd -from lightweight_charts.util import _convert_timeframe from lightweight_charts import Chart try: @@ -21,6 +21,22 @@ except ImportError: websockets = None +def convert_timeframe(timeframe): + spans = { + 'min': 'minute', + 'H': 'hour', + 'D': 'day', + 'W': 'week', + 'M': 'month', + } + try: + multiplier = re.findall(r'\d+', timeframe)[0] + except IndexError: + return 1, spans[timeframe] + timespan = spans[timeframe.replace(multiplier, '')] + return multiplier, timespan + + class PolygonAPI: """ Offers direct access to Polygon API data within all Chart objects. @@ -136,7 +152,7 @@ class PolygonAPI: self._ws_q.put(('_unsubscribe', chart)) end_date = dt.datetime.now().strftime('%Y-%m-%d') if end_date == 'now' else end_date - mult, span = _convert_timeframe(timeframe) + mult, span = convert_timeframe(timeframe) query_url = f"https://api.polygon.io/v2/aggs/ticker/{ticker.replace('-', '')}/range/{mult}/{span}/{start_date}/{end_date}?limit={limit}&apiKey={self._key}" response = requests.get(query_url, headers={'User-Agent': 'lightweight_charts/1.0'}) @@ -250,8 +266,11 @@ class PolygonAPI: if sec_type == 'forex': data['bp'] = data.pop('b') data['ap'] = data.pop('a') - self._lasts[key]['price'] = (data['bp']+data['ap'])/2 if sec_type != 'indices' else data['val'] - self._lasts[key]['volume'] = 0 + if sec_type == 'indices': + self._lasts[key]['price'] = data['val'] + else: + self._lasts[key]['price'] = (data['bp']+data['ap'])/2 + self._lasts[key]['volume'] = 0 elif data['ev'] in ('A', 'CA', 'XA'): self._lasts[key]['volume'] = data['v'] if not self._lasts[key].get('time'): @@ -305,8 +324,7 @@ class PolygonChart(Chart): security_options: tuple = ('Stock', 'Option', 'Index', 'Forex', 'Crypto'), 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, toolbox=toolbox) + super().__init__(width=width, height=height, x=x, y=y, on_top=on_top, maximize=maximize, debug=debug, toolbox=toolbox) self.chart = self self.num_bars = num_bars self.end_date = end_date @@ -316,26 +334,18 @@ class PolygonChart(Chart): self.topbar.active_background_color = 'rgb(91, 98, 246)' self.topbar.textbox('symbol') - self.topbar.switcher('timeframe', self._on_timeframe_selection, *timeframe_options) - self.topbar.switcher('security', self._on_security_selection, *security_options) + self.topbar.switcher('timeframe', timeframe_options, func=self._on_timeframe_selection) + self.topbar.switcher('security', security_options, func=self._on_security_selection) self.legend(True) self.grid(False, False) self.crosshair(vert_visible=False, horz_visible=False) + self.events.search += self.on_search self.run_script(f''' {self.id}.search.box.style.backgroundColor = 'rgba(91, 98, 246, 0.5)' {self.id}.spinner.style.borderTop = '4px solid rgba(91, 98, 246, 0.8)' {self.id}.search.window.style.display = "flex" {self.id}.search.box.focus() - - //let polyLogo = document.createElement('div') - //polyLogo.innerHTML = '' - //polyLogo.style.position = 'absolute' - //polyLogo.style.width = '28px' - //polyLogo.style.zIndex = 10000 - //polyLogo.style.right = '18px' - //polyLogo.style.top = '-1px' - //{self.id}.wrapper.appendChild(polyLogo) ''') def _polygon(self, symbol): @@ -343,7 +353,7 @@ class PolygonChart(Chart): self.set(pd.DataFrame(), True) self.crosshair(vert_visible=False, horz_visible=False) - mult, span = _convert_timeframe(self.topbar['timeframe'].value) + mult, span = convert_timeframe(self.topbar['timeframe'].value) delta = dt.timedelta(**{span + 's': int(mult)}) short_delta = (delta < dt.timedelta(days=7)) start_date = dt.datetime.now() if self.end_date == 'now' else dt.datetime.strptime(self.end_date, '%Y-%m-%d') @@ -367,18 +377,11 @@ class PolygonChart(Chart): self.crosshair(vert_visible=True, horz_visible=True) if success else None return success - async def on_search(self, searched_string): self.topbar['symbol'].set(searched_string if self._polygon(searched_string) else '') + async def on_search(self, chart, searched_string): + self.topbar['symbol'].set(searched_string if self._polygon(searched_string) else '') - async def _on_timeframe_selection(self): + async def _on_timeframe_selection(self, chart): self._polygon(self.topbar['symbol'].value) if self.topbar['symbol'].value else None - async def _on_security_selection(self): - sec_type = self.topbar['security'].value - self.volume_enabled = False if sec_type == 'Index' else True - - precision = 5 if sec_type == 'Forex' else 2 - min_move = 1 / (10 ** precision) # 2 -> 0.1, 5 -> 0.00005 etc. - self.run_script(f''' - {self.chart.id}.series.applyOptions({{ - priceFormat: {{precision: {precision}, minMove: {min_move}}} - }})''') + async def _on_security_selection(self, chart): + self.precision(5 if self.topbar['security'].value == 'Forex' else 2) diff --git a/lightweight_charts/table.py b/lightweight_charts/table.py index 17e1c15..3559fcd 100644 --- a/lightweight_charts/table.py +++ b/lightweight_charts/table.py @@ -1,59 +1,67 @@ import random from typing import Union -from lightweight_charts.util import _js_bool +from .util import jbool class Footer: - def __init__(self, table): - self._table = table - self._chart = table._chart + def __init__(self, table): self._table = table - def __setitem__(self, key, value): self._chart.run_script(f'{self._table.id}.footer[{key}].innerText = "{value}"') + def __setitem__(self, key, value): self._table._run_script(f'{self._table.id}.footer[{key}].innerText = "{value}"') - def __call__(self, number_of_text_boxes): self._chart.run_script(f'{self._table.id}.makeFooter({number_of_text_boxes})') + def __call__(self, number_of_text_boxes): self._table._run_script(f'{self._table.id}.makeFooter({number_of_text_boxes})') class Row(dict): def __init__(self, table, id, items): super().__init__() self._table = table - self._chart = table._chart + self._run_script = table._run_script self.id = id self.meta = {} - self._table._chart.run_script(f'''{self._table.id}.newRow({list(items.values())}, '{self.id}')''') + self._run_script(f'''{self._table.id}.newRow({list(items.values())}, '{self.id}')''') for key, val in items.items(): self[key] = val def __setitem__(self, column, value): - str_value = str(value) + if isinstance(column, tuple): + return [self.__setitem__(col, val) for col, val in zip(column, value)] + original_value = value if column in self._table._formatters: - str_value = self._table._formatters[column].replace(self._table.VALUE, str_value) - self._chart.run_script(f'''{self._table.id}.updateCell('{self.id}', '{column}', '{str_value}')''') + value = self._table._formatters[column].replace(self._table.VALUE, str(value)) + self._run_script(f'{self._table.id}.updateCell("{self.id}", "{column}", "{value}")') - return super().__setitem__(column, value) + return super().__setitem__(column, original_value) - def background_color(self, column, color): - self._chart.run_script(f"{self._table.id}.rows[{self.id}]['{column}'].style.backgroundColor = '{color}'") + def background_color(self, column, color): self._style('backgroundColor', column, color) + + def text_color(self, column, color): self._style('textColor', column, color) + + def _style(self, style, column, arg): + self._run_script(f"{self._table.id}.rows[{self.id}]['{column}'].style.{style} = '{arg}'") def delete(self): - self._chart.run_script(f"{self._table.id}.deleteRow('{self.id}')") + self._run_script(f"{self._table.id}.deleteRow('{self.id}')") self._table.pop(self.id) class Table(dict): VALUE = 'CELL__~__VALUE__~__PLACEHOLDER' - def __init__(self, chart, width, height, headings, widths=None, alignments=None, position='left', draggable=False, method=None): + def __init__(self, chart, width, height, headings, widths=None, alignments=None, position='left', draggable=False, func=None): super().__init__() + self._run_script = chart.run_script self._chart = chart self.headings = headings self._formatters = {} self.is_shown = True - self.id = self._chart._rand.generate() - self._chart.run_script(f''' - {self.id} = new Table({width}, {height}, {list(headings)}, {list(widths)}, {list(alignments)}, '{position}', {_js_bool(draggable)}, '{method}', {chart.id}) + self.id = chart._rand.generate() + chart._handlers[self.id] = lambda rId: func(self[rId]) + self._run_script(f''' + {self.id} = new Table({width}, {height}, {list(headings)}, {list(widths) if widths else []}, {list(alignments) if alignments else []}, + '{position}', {jbool(draggable)}, {chart.id}) ''') + self._run_script(f'{self.id}.callbackName = "{self.id}"') if func else None self.footer = Footer(self) def new_row(self, *values, id=None) -> Row: @@ -61,7 +69,7 @@ class Table(dict): self[row_id] = Row(self, row_id, {heading: item for heading, item in zip(self.headings, values)}) return self[row_id] - def clear(self): self._chart.run_script(f"{self.id}.clearRows()"), super().clear() + def clear(self): self._run_script(f"{self.id}.clearRows()"), super().clear() def get(self, __key: Union[int, str]) -> Row: return super().get(int(__key)) @@ -71,7 +79,7 @@ class Table(dict): def visible(self, visible: bool): self.is_shown = visible - self._chart.run_script(f""" + self._run_script(f""" {self.id}.container.style.display = '{'block' if visible else 'none'}' {self.id}.container.{'add' if visible else 'remove'}EventListener('mousedown', {self.id}.onMouseDown) """) diff --git a/lightweight_charts/util.py b/lightweight_charts/util.py index 26aff9f..fbe32b3 100644 --- a/lightweight_charts/util.py +++ b/lightweight_charts/util.py @@ -1,43 +1,20 @@ -import re +import asyncio from random import choices -from string import ascii_lowercase from typing import Literal -class MissingColumn(KeyError): - def __init__(self, message): - super().__init__(message) - self.msg = message - - def __str__(self): - return f'{self.msg}' - - -class ColorError(ValueError): - def __init__(self, message): - super().__init__(message) - self.msg = message - - def __str__(self): - return f'{self.msg}' - - class IDGen(list): + ascii = 'abcdefghijklmnopqrstuvwxyz' + def generate(self): - var = ''.join(choices(ascii_lowercase, k=8)) + var = ''.join(choices(self.ascii, k=8)) if var not in self: self.append(var) return f'window.{var}' self.generate() -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.') - - -def _js_bool(b: bool): return 'true' if b is True else 'false' if b is False else None +def jbool(b: bool): return 'true' if b is True else 'false' if b is False else None LINE_STYLE = Literal['solid', 'dotted', 'dashed', 'large_dashed', 'sparse_dotted'] @@ -51,24 +28,24 @@ CROSSHAIR_MODE = Literal['normal', 'magnet'] PRICE_SCALE_MODE = Literal['normal', 'logarithmic', 'percentage', 'index100'] -def _line_style(line: LINE_STYLE): +def line_style(line: LINE_STYLE): js = 'LightweightCharts.LineStyle.' return js+line[:line.index('_')].title() + line[line.index('_') + 1:].title() if '_' in line else js+line.title() -def _crosshair_mode(mode: CROSSHAIR_MODE): +def crosshair_mode(mode: CROSSHAIR_MODE): return f'LightweightCharts.CrosshairMode.{mode.title()}' if mode else None -def _price_scale_mode(mode: PRICE_SCALE_MODE): +def price_scale_mode(mode: PRICE_SCALE_MODE): return f"LightweightCharts.PriceScaleMode.{'IndexedTo100' if mode == 'index100' else mode.title() if mode else None}" -def _marker_shape(shape: MARKER_SHAPE): +def marker_shape(shape: MARKER_SHAPE): return shape[:shape.index('_')]+shape[shape.index('_')+1:].title() if '_' in shape else shape.title() -def _marker_position(p: MARKER_POSITION): +def marker_position(p: MARKER_POSITION): return { 'above': 'aboveBar', 'below': 'belowBar', @@ -77,17 +54,59 @@ def _marker_position(p: MARKER_POSITION): }[p] -def _convert_timeframe(timeframe): - spans = { - 'min': 'minute', - 'H': 'hour', - 'D': 'day', - 'W': 'week', - 'M': 'month', - } - try: - multiplier = re.findall(r'\d+', timeframe)[0] - except IndexError: - return 1, spans[timeframe] - timespan = spans[timeframe.replace(multiplier, '')] - return multiplier, timespan +class Emitter: + def __init__(self): + self._callable = None + + def __iadd__(self, other): + self._callable = other + return self + + def _emit(self, *args): + self._callable(*args) if self._callable else None + +class JSEmitter: + def __init__(self, chart, name, on_iadd, wrapper=None): + self._on_iadd = on_iadd + self._chart = chart + self._name = name + self._wrapper = wrapper + + def __iadd__(self, other): + def final_wrapper(*arg): + other(self._chart, *arg) if not self._wrapper else self._wrapper(other, self._chart, *arg) + async def final_async_wrapper(*arg): + await other(self._chart, *arg) if not self._wrapper else await self._wrapper(other, self._chart, *arg) + + self._chart._handlers[self._name] = final_async_wrapper if asyncio.iscoroutinefunction(other) else final_wrapper + self._on_iadd(other) + return self + + +class Events: + def __init__(self, chart): + self.new_bar = Emitter() + from lightweight_charts.abstract import JS + self.search = JSEmitter(chart, f'search{chart.id}', + lambda o: chart.run_script(f''' + {JS['callback'] if not chart._callbacks_enabled else ''} + makeSpinner({chart.id}) + {chart.id}.search = makeSearchBox({chart.id}) + ''') + ) + self.range_change = JSEmitter(chart, f'range_change{chart.id}', + lambda o: chart.run_script(f''' + let checkLogicalRange = (logical) => {{ + {chart.id}.chart.timeScale().unsubscribeVisibleLogicalRangeChange(checkLogicalRange) + + let barsInfo = {chart.id}.series.barsInLogicalRange(logical) + if (barsInfo) window.callbackFunction(`range_change{chart.id}_~_${{barsInfo.barsBefore}};;;${{barsInfo.barsAfter}}`) + + setTimeout(() => {chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange), 50) + }} + {chart.id}.chart.timeScale().subscribeVisibleLogicalRangeChange(checkLogicalRange) + '''), + wrapper=lambda o, c, *arg: o(c, *[float(a) for a in arg]) + ) + + diff --git a/lightweight_charts/widgets.py b/lightweight_charts/widgets.py index f6a3de5..2321e68 100644 --- a/lightweight_charts/widgets.py +++ b/lightweight_charts/widgets.py @@ -1,5 +1,4 @@ import asyncio -from inspect import iscoroutinefunction try: import wx.html2 @@ -19,7 +18,21 @@ try: def callback(self, message): _widget_message(self.chart, message) except ImportError: - QWebEngineView = None + try: + from PySide6.QtWebEngineWidgets import QWebEngineView + from PySide6.QtWebChannel import QWebChannel + from PySide6.QtCore import QObject, Slot + + class Bridge(QObject): + def __init__(self, chart): + super().__init__() + self.chart = chart + + @Slot(str) + def callback(self, message): + _widget_message(self.chart, message) + except ImportError: + QWebEngineView = None try: from streamlit.components.v1 import html except ImportError: @@ -33,55 +46,43 @@ from lightweight_charts.abstract import LWC, JS def _widget_message(chart, string): - messages = string.split('_~_') - name, chart_id = messages[:2] - arg = messages[2] - chart.api.chart = chart._charts[chart_id] - fixed_callbacks = ('on_search', 'on_horizontal_line_move') - func = chart._methods[name] if name not in fixed_callbacks else getattr(chart._api, name) - if hasattr(chart._api.chart, 'topbar') and (widget := chart._api.chart.topbar._widget_with_method(name)): - widget.value = arg - asyncio.create_task(func()) if asyncio.iscoroutinefunction(func) else func() - else: - asyncio.create_task(func(*arg.split(';;;'))) if asyncio.iscoroutinefunction(func) else func(*arg.split(';;;')) + name, args = string.split('_~_') + args = args.split(';;;') + func = chart._handlers[name] + asyncio.create_task(func(*args)) if asyncio.iscoroutinefunction(func) else func(*args) 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, - toolbox: bool = False): + def __init__(self, parent, inner_width: float = 1.0, inner_height: float = 1.0, + scale_candles_only: 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, topbar=topbar, searchbox=searchbox, toolbox=toolbox, + super().__init__(inner_width=inner_width, inner_height=inner_height, + scale_candles_only=scale_candles_only, toolbox=toolbox, _js_api_code='window.wx_msg.postMessage.bind(window.wx_msg)') - self.api = api self._script_func = self.webview.RunScript 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']) 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, 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): + def __init__(self, widget=None, inner_width: float = 1.0, inner_height: float = 1.0, + scale_candles_only: 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, topbar=topbar, searchbox=searchbox, toolbox=toolbox, + super().__init__(inner_width=inner_width, inner_height=inner_height, + scale_candles_only=scale_candles_only, toolbox=toolbox, _js_api_code='window.pythonObject.callback') - self.api = api self._script_func = self.webview.page().runJavaScript self.web_channel = QWebChannel() @@ -106,8 +107,9 @@ class QtChart(LWC): class StaticLWC(LWC): - 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) + def __init__(self, width=None, height=None, inner_width=1, inner_height=1, + scale_candles_only: bool = False, toolbox=False, autosize=True): + super().__init__(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', '') @@ -130,8 +132,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, toolbox: bool = False): - super().__init__(volume_enabled, width, height, inner_width, inner_height, scale_candles_only, toolbox) + def __init__(self, width=None, height=None, inner_width=1, inner_height=1, scale_candles_only: bool = False, toolbox: bool = False): + super().__init__(width, height, inner_width, inner_height, scale_candles_only, toolbox) def _load(self): if html is None: @@ -140,8 +142,8 @@ class StreamlitChart(StaticLWC): class JupyterChart(StaticLWC): - 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) + def __init__(self, width: int = 800, height=350, inner_width=1, inner_height=1, scale_candles_only: bool = False, toolbox: bool = False): + super().__init__(width, height, inner_width, inner_height, scale_candles_only, toolbox, autosize=False) self._position = "" self.run_script(f''' diff --git a/setup.py b/setup.py index 8e78989..681084a 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open('README.md', 'r', encoding='utf-8') as f: setup( name='lightweight_charts', - version='1.0.15', + version='1.0.16', packages=find_packages(), python_requires='>=3.8', install_requires=[