diff --git a/README.md b/README.md index d9c1a35..0949135 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,17 @@ [![License](https://img.shields.io/github/license/louisnw01/lightweight-charts-python?color=9c2400)](https://github.com/louisnw01/lightweight-charts-python/blob/main/LICENSE) [![Documentation](https://img.shields.io/badge/documentation-006ee3)](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html) -![async](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/examples/6_async/async.png) ![cover](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/cover.png) lightweight-charts-python aims to provide a simple and pythonic way to access and implement [TradingView's Lightweight Charts](https://www.tradingview.com/lightweight-charts/). + ## Installation ``` pip install lightweight-charts ``` +* White screen? Having issues with pywebview? Click [here](https://github.com/louisnw01/lightweight-charts-python/issues?q=label%3A%22pywebview+issue%22+). ___ ## Features @@ -28,7 +29,8 @@ ___ * wxPython * Streamlit * asyncio -5. [Callbacks](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#chartasync) allowing for timeframe (1min, 5min, 30min etc.) selectors, searching, and more. + * Jupyter Notebooks using the [JupyterChart](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#jupyterchart) +5. [Callbacks](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#callbacks) allowing for timeframe (1min, 5min, 30min etc.) selectors, searching, and more. 6. Multi-Pane Charts using the `SubChart` ([examples](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#subchart)). ___ @@ -125,27 +127,27 @@ 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'], 'value': val}) - return pd.DataFrame(result) + 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'], 'value': val}) + return pd.DataFrame(result) if __name__ == '__main__': - - chart = Chart() - - df = pd.read_csv('ohlcv.csv') - chart.set(df) - - line = chart.create_line() - sma_data = calculate_sma(df) - line.set(sma_data) - - chart.show(block=True) + chart = Chart() + + df = pd.read_csv('ohlcv.csv') + chart.set(df) + + line = chart.create_line() + sma_data = calculate_sma(df) + 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) @@ -188,68 +190,60 @@ if __name__ == '__main__': ![styling image](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/examples/5_styling/styling.png) ___ -### 6. ChartAsync: +### 6. Callbacks: ```python import asyncio import pandas as pd -from lightweight_charts import ChartAsync +from lightweight_charts import Chart 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'bar_data/{symbol}_{timeframe}.csv') class API: def __init__(self): self.chart = None # Changes after each callback. - self.symbol = 'TSLA' - self.timeframe = '5min' async def on_search(self, searched_string): # Called when the user searches. - self.symbol = searched_string - new_data = await self.get_data() + new_data = get_bar_data(searched_string, self.chart.topbar['timeframe'].value) + if new_data.empty: + return + self.chart.topbar['corner'].set(searched_string) + self.chart.set(new_data) + + async def on_timeframe_selection(self): # Called when the user changes the timeframe. + new_data = get_bar_data(self.chart.topbar['corner'].value, self.chart.topbar['timeframe'].value) if new_data.empty: return self.chart.set(new_data) - self.chart.corner_text(searched_string) - - async def on_timeframe_selection(self, timeframe): # Called when the user changes the timeframe. - self.timeframe = timeframe - new_data = await self.get_data() - if new_data.empty: - return - self.chart.set(new_data) - - async def get_data(self): - if self.symbol not in ('AAPL', 'GOOGL', 'TSLA'): - print(f'No data for "{self.symbol}"') - return pd.DataFrame() - data = get_bar_data(self.symbol, self.timeframe) - return data async def main(): api = API() - chart = ChartAsync(api=api, debug=True) + chart = Chart(api=api, topbar=True, searchbox=True) chart.legend(True) - chart.create_switcher(api.on_timeframe_selection, '1min', '5min', '30min', default='5min') - chart.corner_text(api.symbol) + chart.topbar.textbox('corner', 'TSLA') + chart.topbar.switcher('timeframe', api.on_timeframe_selection, '1min', '5min', '30min', default='5min') - df = get_bar_data(api.symbol, api.timeframe) + df = get_bar_data('TSLA', '5min') chart.set(df) - await chart.show(block=True) + await chart.show_async(block=True) if __name__ == '__main__': asyncio.run(main()) ``` -![async gif](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/examples/6_async/async.gif) +![callbacks gif](https://raw.githubusercontent.com/louisnw01/lightweight-charts-python/main/examples/6_callbacks/callbacks.gif) ___
diff --git a/cover.png b/cover.png index 51cc215..291ef1b 100644 Binary files a/cover.png and b/cover.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index b0e782f..1ff9f68 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.9' +release = '1.0.10' extensions = ["myst_parser"] diff --git a/docs/source/docs.md b/docs/source/docs.md index 07f4dc3..84a75db 100644 --- a/docs/source/docs.md +++ b/docs/source/docs.md @@ -9,7 +9,7 @@ ___ ## Common Methods -These methods can be used within the [`Chart`](#chart), [`SubChart`](#subchart), [`ChartAsync`](#chartasync), [`QtChart`](#qtchart), [`WxChart`](#wxchart) and [`StreamlitChart`](#streamlitchart) objects. +These methods can be used within the [`Chart`](#chart), [`SubChart`](#subchart), [`QtChart`](#qtchart), [`WxChart`](#wxchart) and [`StreamlitChart`](#streamlitchart) objects. ___ ### `set` @@ -186,7 +186,8 @@ ___ ## Chart -`volume_enabled: bool` | `width: int` | `height: int` | `x: int` | `y: int` | `on_top: bool` | `debug: bool` +`volume_enabled: bool` | `width: int` | `height: int` | `x: int` | `y: int` | `on_top: bool` | `debug: bool` | +`api: object` | `topbar: bool` | `searchbox: bool` The main object used for the normal functionality of lightweight-charts-python, built on the pywebview library. ___ @@ -208,6 +209,13 @@ Exits and destroys the chart and window. ___ +### `show_async` +`block: bool` + +Show the chart asynchronously. This should be utilised when using [Callbacks](#callbacks). + +___ + ## Line The `Line` object represents a `LineSeries` object in Lightweight Charts and can be used to create indicators. As well as the methods described below, the `Line` object also has access to the [`title`](#title), [`marker`](#marker) and [`horizontal_line`](#horizontal-line) methods. @@ -241,12 +249,12 @@ The `SubChart` object allows for the use of multiple chart panels within the sam ___ ### Grid of 4 Example: + ```python import pandas as pd from lightweight_charts import Chart if __name__ == '__main__': - chart = Chart(inner_width=0.5, inner_height=0.5) chart2 = chart.create_subchart(position='right', width=0.5, height=0.5) @@ -262,9 +270,9 @@ if __name__ == '__main__': df = pd.read_csv('ohlcv.csv') chart.set(df) - chart2.set(df) - chart3.set(df) - chart4.set(df) + chart2._set(df) + chart3._set(df) + chart4._set(df) chart.show(block=True) @@ -278,34 +286,33 @@ import pandas as pd from lightweight_charts import Chart if __name__ == '__main__': - chart = Chart(inner_width=1, inner_height=0.8) - + chart2 = chart.create_subchart(width=1, height=0.2, sync=True, volume_enabled=False) chart2.time_scale(visible=False) - + df = pd.read_csv('ohlcv.csv') df2 = pd.read_csv('rsi.csv') - + chart.set(df) line = chart2.create_line() - line.set(df2) - + line._set(df2) + chart.show(block=True) ``` ___ -## ChartAsync -`api: object` | `top_bar: bool` | `search_box: bool` +## Callbacks -The `ChartAsync` object allows for asyncronous callbacks to be passed back to python, allowing for more sophisticated chart layouts including search boxes and timeframe selectors. +The `Chart` object allows for asyncronous callbacks to be passed back to python when using the `show_async` method, allowing for more sophisticated chart layouts including searching, timeframe selectors, and text boxes. -[`QtChart`](#qtchart) and [`WxChart`](#wxchart) also have access to the methods specific to `ChartAsync`, however they use their respective event loops to emit callbacks rather than asyncio. +[`QtChart`](#qtchart) and [`WxChart`](#wxchart) can also use callbacks, however they use their respective event loops to emit callbacks rather than asyncio. +A variety of the parameters below should be passed to the Chart upon decaration. * `api`: The class object that the callbacks will be emitted to (see [How to use Callbacks](#how-to-use-callbacks)). -* `top_bar`: Adds a Top Bar to the `Chart` or `SubChart` and allows use of the `create_switcher` method. -* `search_box`: Adds a search box onto the `Chart` or `SubChart` that is activated by typing. +* `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. ___ ### How to use Callbacks @@ -320,75 +327,99 @@ class API: self.chart = None async def on_search(self, string): - print(f'You searched for {string}, within the chart holding the id: "{self.chart.id}"') + print(f'Search Text: "{string}" | Chart/SubChart ID: "{self.chart.id}"') ``` -Upon searching in a `Chart` or `SubChart` window, the expected output would be akin to: +Upon searching in a pane, the expected output would be akin to: ``` -You searched for AAPL, within the chart holding the id: "window.blyjagcr" +Search Text: "AAPL" | Chart/SubChart ID: "window.blyjagcr" ``` -When using `SubChart`'s, the id 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. -This allows access to the specific [Common Methods](#common-methods) for the pane in question. +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 [Common Methods](#common-methods) for the pane in question. -Certain callback methods must be specifically named: +```{important} * Search callbacks will always be emitted to a method named `on_search` +``` ___ -### `create_switcher` -`method: function` | `*options: str` | `default: str` +### `TopBar` +The `TopBar` class represents the top bar shown on the chart when using callbacks: +![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. + +Switchers and text boxes can be created within 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') +``` +___ + +### `switcher` +`name: str` | `method: function` | `*options: str` | `default: str` + +* `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. * `default`: The initial switcher option set. ___ -### Example: +### `textbox` +`name: str` | `initial_text: str` + +* `name`: the name of the text box which can be used to access it from the `topbar` dictionary. +* `initial_text`: The text to show within the text box. +___ + +### Callbacks Example: ```python import asyncio import pandas as pd from my_favorite_broker import get_bar_data -from lightweight_charts import ChartAsync +from lightweight_charts import Chart class API: def __init__(self): self.chart = None - self.symbol = 'TSLA' - self.timeframe = '5min' - async def on_search(self, searched_string): # Called when the user searches. - self.symbol = searched_string - new_data = await self.get_data() + async def on_search(self, searched_string): # Called when the user searches. + timeframe = self.chart.topbar['timeframe'].value + new_data = await get_bar_data(searched_string, timeframe) if not new_data: return - self.chart.set(new_data) # sets data for the Chart or SubChart in question. - self.chart.corner_text(searched_string) + self.chart.set(new_data) # sets data for the Chart or SubChart in question. + self.chart.topbar['symbol'].set(searched_string) - async def on_timeframe(self, timeframe): # Called when the user changes the timeframe. - self.timeframe = timeframe - new_data = await self.get_data() + async def on_timeframe(self): # Called when the user changes the timeframe. + timeframe = self.chart.topbar['timeframe'].value + symbol = self.chart.topbar['symbol'].value + new_data = await get_bar_data(symbol, timeframe) if not new_data: return self.chart.set(new_data) - async def get_data(self): - data = await get_bar_data(self.symbol, self.timeframe) - return data - async def main(): api = API() - chart = ChartAsync(api=api, debug=True) + chart = Chart(api=api, topbar=True, searchbox=True) - chart.corner_text('TSLA') - chart.create_switcher(api.on_timeframe, '1min', '5min', '30min', 'H', 'D', 'W', default='5min') + chart.topbar.textbox('symbol', 'TSLA') + chart.topbar.switcher('timeframe', api.on_timeframe, '1min', '5min', '30min', 'H', 'D', 'W', default='5min') df = pd.read_csv('ohlcv.csv') chart.set(df) - await chart.show(block=True) + await chart.show_async(block=True) if __name__ == '__main__': @@ -517,3 +548,29 @@ chart.set(df) chart.load() ``` +___ + +## JupyterChart + +The `JupyterChart` object allows the use of charts within a notebook, and has similar functionality to the `Chart` object for manipulating data, configuring and styling. + +This object only supports the displaying of **static** data, and should not be used with the `update_from_tick` or `update` methods. Every call to the chart object must occur **before** calling `load`. +___ + +### `load` + +Renders the chart. This should be called after setting, styling, and configuring the chart, as no further calls to the `JupyterChart` will be acknowledged. +___ + +### Example: +```python +import pandas as pd +from lightweight_charts import JupyterChart + +chart = JupyterChart() + +df = pd.read_csv('ohlcv.csv') +chart.set(df) + +chart.load() +``` diff --git a/docs/source/index.md b/docs/source/index.md index 4eff9bb..d4aba20 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,7 +1,7 @@ ```{toctree} :hidden: :caption: Contents -:maxdepth: 2 +:maxdepth: 3 docs Github Repository diff --git a/examples/2_live_data/live_data.gif b/examples/2_live_data/live_data.gif index 9eb7e9d..500b3cf 100644 Binary files a/examples/2_live_data/live_data.gif and b/examples/2_live_data/live_data.gif differ diff --git a/examples/3_tick_data/tick_data.gif b/examples/3_tick_data/tick_data.gif index f18c83f..60d358e 100644 Binary files a/examples/3_tick_data/tick_data.gif and b/examples/3_tick_data/tick_data.gif differ diff --git a/examples/6_async/async.gif b/examples/6_async/async.gif deleted file mode 100644 index 6f68f3c..0000000 Binary files a/examples/6_async/async.gif and /dev/null differ diff --git a/examples/6_async/async.png b/examples/6_async/async.png deleted file mode 100644 index a58c41d..0000000 Binary files a/examples/6_async/async.png and /dev/null differ diff --git a/examples/6_async/async.py b/examples/6_async/async.py deleted file mode 100644 index 1d04c2d..0000000 --- a/examples/6_async/async.py +++ /dev/null @@ -1,56 +0,0 @@ -import asyncio -import pandas as pd - -from lightweight_charts import ChartAsync - - -def get_bar_data(symbol, timeframe): - return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') - - -class API: - def __init__(self): - self.chart = None # Changes after each callback. - self.symbol = 'TSLA' - self.timeframe = '5min' - - async def on_search(self, searched_string): # Called when the user searches. - self.symbol = searched_string - new_data = await self.get_data() - if new_data.empty: - return - self.chart.set(new_data) - self.chart.corner_text(searched_string) - - async def on_timeframe_selection(self, timeframe): # Called when the user changes the timeframe. - self.timeframe = timeframe - new_data = await self.get_data() - if new_data.empty: - return - self.chart.set(new_data) - - async def get_data(self): - if self.symbol not in ('AAPL', 'GOOGL', 'TSLA'): - print(f'No data for "{self.symbol}"') - return pd.DataFrame() - data = get_bar_data(self.symbol, self.timeframe) - return data - - -async def main(): - api = API() - - chart = ChartAsync(api=api, debug=True) - chart.legend(True) - - chart.create_switcher(api.on_timeframe_selection, '1min', '5min', '30min', default='5min') - chart.corner_text(api.symbol) - - df = get_bar_data(api.symbol, api.timeframe) - chart.set(df) - - await chart.show(block=True) - - -if __name__ == '__main__': - asyncio.run(main()) diff --git a/examples/6_async/bar_data/AAPL_1min.csv b/examples/6_callbacks/bar_data/AAPL_1min.csv similarity index 100% rename from examples/6_async/bar_data/AAPL_1min.csv rename to examples/6_callbacks/bar_data/AAPL_1min.csv diff --git a/examples/6_async/bar_data/AAPL_30min.csv b/examples/6_callbacks/bar_data/AAPL_30min.csv similarity index 100% rename from examples/6_async/bar_data/AAPL_30min.csv rename to examples/6_callbacks/bar_data/AAPL_30min.csv diff --git a/examples/6_async/bar_data/AAPL_5min.csv b/examples/6_callbacks/bar_data/AAPL_5min.csv similarity index 100% rename from examples/6_async/bar_data/AAPL_5min.csv rename to examples/6_callbacks/bar_data/AAPL_5min.csv diff --git a/examples/6_async/bar_data/GOOGL_1min.csv b/examples/6_callbacks/bar_data/GOOGL_1min.csv similarity index 100% rename from examples/6_async/bar_data/GOOGL_1min.csv rename to examples/6_callbacks/bar_data/GOOGL_1min.csv diff --git a/examples/6_async/bar_data/GOOGL_30min.csv b/examples/6_callbacks/bar_data/GOOGL_30min.csv similarity index 100% rename from examples/6_async/bar_data/GOOGL_30min.csv rename to examples/6_callbacks/bar_data/GOOGL_30min.csv diff --git a/examples/6_async/bar_data/GOOGL_5min.csv b/examples/6_callbacks/bar_data/GOOGL_5min.csv similarity index 100% rename from examples/6_async/bar_data/GOOGL_5min.csv rename to examples/6_callbacks/bar_data/GOOGL_5min.csv diff --git a/examples/6_async/bar_data/TSLA_1min.csv b/examples/6_callbacks/bar_data/TSLA_1min.csv similarity index 100% rename from examples/6_async/bar_data/TSLA_1min.csv rename to examples/6_callbacks/bar_data/TSLA_1min.csv diff --git a/examples/6_async/bar_data/TSLA_30min.csv b/examples/6_callbacks/bar_data/TSLA_30min.csv similarity index 100% rename from examples/6_async/bar_data/TSLA_30min.csv rename to examples/6_callbacks/bar_data/TSLA_30min.csv diff --git a/examples/6_async/bar_data/TSLA_5min.csv b/examples/6_callbacks/bar_data/TSLA_5min.csv similarity index 100% rename from examples/6_async/bar_data/TSLA_5min.csv rename to examples/6_callbacks/bar_data/TSLA_5min.csv diff --git a/examples/6_callbacks/callbacks.gif b/examples/6_callbacks/callbacks.gif new file mode 100644 index 0000000..8aac4bc Binary files /dev/null and b/examples/6_callbacks/callbacks.gif differ diff --git a/examples/6_callbacks/callbacks.py b/examples/6_callbacks/callbacks.py new file mode 100644 index 0000000..a27e0ba --- /dev/null +++ b/examples/6_callbacks/callbacks.py @@ -0,0 +1,48 @@ +import asyncio +import pandas as pd + +from lightweight_charts import Chart + + +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'bar_data/{symbol}_{timeframe}.csv') + + +class API: + def __init__(self): + self.chart = None # Changes after each callback. + + async def on_search(self, searched_string): # Called when the user searches. + new_data = get_bar_data(searched_string, self.chart.topbar['timeframe'].value) + if new_data.empty: + return + self.chart.topbar['symbol'].set(searched_string) + self.chart.set(new_data) + + async 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) + + +async def main(): + api = API() + + chart = Chart(api=api, topbar=True, searchbox=True) + chart.legend(True) + + chart.topbar.textbox('symbol', 'TSLA') + chart.topbar.switcher('timeframe', api.on_timeframe_selection, '1min', '5min', '30min', default='5min') + + df = get_bar_data('TSLA', '5min') + chart.set(df) + + await chart.show_async(block=True) + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/lightweight_charts/__init__.py b/lightweight_charts/__init__.py index ea976cb..12a3ade 100644 --- a/lightweight_charts/__init__.py +++ b/lightweight_charts/__init__.py @@ -1,5 +1,3 @@ from .chart import Chart from .js import LWC -from .chartasync import ChartAsync - - +from .widgets import JupyterChart diff --git a/lightweight_charts/chart.py b/lightweight_charts/chart.py index d3ec87f..d4f15ed 100644 --- a/lightweight_charts/chart.py +++ b/lightweight_charts/chart.py @@ -1,7 +1,8 @@ +import asyncio import webview import multiprocessing as mp -from lightweight_charts.js import LWC +from lightweight_charts.js import LWC, CALLBACK_SCRIPT, TopBar class CallbackAPI: @@ -15,12 +16,12 @@ class CallbackAPI: class PyWV: - def __init__(self, q, exit, loaded, html, width, height, x, y, on_top, debug, emit=None): + def __init__(self, q, exit, loaded, html, width, height, x, y, on_top, debug, emit): self.queue = q self.exit = exit self.loaded = loaded self.debug = debug - js_api = CallbackAPI(emit) if emit else None + js_api = CallbackAPI(emit) self.webview = webview.create_window('', html=html, on_top=on_top, js_api=js_api, width=width, height=height, x=x, y=y, background_color='#000000') self.webview.events.loaded += self.on_js_load @@ -44,18 +45,27 @@ 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, debug: bool = False, + on_top: bool = False, debug: bool = False, api: object = None, topbar: bool = False, searchbox: bool = False, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False): super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading) + self._emit = mp.Queue() self._q = mp.Queue() self._script_func = self._q.put self._exit = mp.Event() self._loaded = mp.Event() self._process = mp.Process(target=PyWV, args=(self._q, self._exit, self._loaded, self._html, - width, height, x, y, on_top, debug,), daemon=True) + width, height, x, y, on_top, debug, self._emit), daemon=True) self._process.start() self._create_chart() + self.api = api + self._js_api_code = 'pywebview.api.callback' + if not topbar and not searchbox: + return + self.run_script(CALLBACK_SCRIPT) + self.topbar = TopBar(self) if topbar else None + self._make_search_box() if searchbox else None + def show(self, block: bool = False): """ Shows the chart window.\n @@ -74,6 +84,31 @@ class Chart(LWC): return self._exit.clear() + async def show_async(self, block=False): + if not self.loaded: + self._q.put('start') + self._loaded.wait() + self._on_js_load() + else: + self._q.put('show') + if block: + try: + while 1: + while self._emit.empty() and not self._exit.is_set(): + await asyncio.sleep(0.1) + if self._exit.is_set(): + return + key, chart_id, arg = self._emit.get() + self.api.chart = self._charts[chart_id] + if widget := self.api.chart.topbar._widget_with_method(key): + widget.value = arg + await getattr(self.api, key)() + else: + await getattr(self.api, key)(arg) + except KeyboardInterrupt: + return + asyncio.create_task(self.show_async(block=True)) + def hide(self): """ Hides the chart window.\n diff --git a/lightweight_charts/chartasync.py b/lightweight_charts/chartasync.py deleted file mode 100644 index 5217584..0000000 --- a/lightweight_charts/chartasync.py +++ /dev/null @@ -1,246 +0,0 @@ -import asyncio -import multiprocessing as mp -from typing import Literal, Union - -from lightweight_charts import LWC -from lightweight_charts.chart import PyWV - - -class LWCAsync(LWC): - def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False): - super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading) - self._charts = {self.id: self} - - def _make_search_box(self): - self.run_script(f'makeSearchBox({self.id}, {self._js_api_code})') - - def corner_text(self, text: str): - self.run_script(f'{self.id}.cornerText.innerText = "{text}"') - - def create_switcher(self, method, *options, default=None): - self.run_script(f''' - makeSwitcher({self.id}, {list(options)}, '{default if default else options[0]}', {self._js_api_code}, '{method.__name__}') - {self.id}.chart.resize(window.innerWidth*{self._inner_width}, (window.innerHeight*{self._inner_height})-{self.id}.topBar.offsetHeight) - ''') - - def create_subchart(self, top_bar: bool = True, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', - width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False): - subchart = SubChartAsync(self, top_bar, volume_enabled, position, width, height, sync) - self._charts[subchart.id] = subchart - return subchart - - -class ChartAsync(LWCAsync): - def __init__(self, api: object, top_bar: bool = True, search_box: bool = True, volume_enabled: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None, - on_top: bool = False, debug: bool = False, - inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False): - super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading) - self.api = api - - self._js_api_code = 'pywebview.api.callback' - self._emit = mp.Queue() - - self._q = mp.Queue() - self._script_func = self._q.put - self._exit = mp.Event() - self._loaded = mp.Event() - self._process = mp.Process(target=PyWV, args=(self._q, self._exit, self._loaded, self._html, - width, height, x, y, on_top, debug, self._emit), daemon=True) - self._process.start() - - self.run_script(ASYNC_SCRIPT) - self._create_chart(top_bar) - self._make_search_box() if search_box else None - - async def show(self, block=False): - if not self.loaded: - self._q.put('start') - self._loaded.wait() - self._on_js_load() - else: - self._q.put('show') - if block: - try: - while 1: - while self._emit.empty() and not self._exit.is_set(): - await asyncio.sleep(0.1) - if self._exit.is_set(): - return - key, chart_id, args = self._emit.get() - self.api.chart = self._charts[chart_id] - await getattr(self.api, key)(args) - - except KeyboardInterrupt: - return - asyncio.create_task(self.show(block=True)) - - -class SubChartAsync(LWCAsync): - def __init__(self, parent, top_bar, volume_enabled, position, width, height, sync): - super().__init__(volume_enabled, width, height) - self._chart = parent._chart if isinstance(parent, SubChartAsync) else parent - self._parent = parent - self._position = position - self._rand = self._chart._rand - self.id = f'window.{self._rand.generate()}' - self._js_api_code = self._chart._js_api_code - self.run_script = self._chart.run_script - self._charts = self._chart._charts - self._create_chart(top_bar) - self._make_search_box() - if not sync: - return - sync_parent_var = self._parent.id if isinstance(sync, bool) else sync - self.run_script(f''' - {sync_parent_var}.chart.timeScale().subscribeVisibleLogicalRangeChange((timeRange) => {{ - {self.id}.chart.timeScale().setVisibleLogicalRange(timeRange) - }}); - ''') - - -ASYNC_SCRIPT = ''' -function makeSearchBox(chart, callbackFunction) { - let searchWindow = document.createElement('div') - searchWindow.style.position = 'absolute' - searchWindow.style.top = '30%' - searchWindow.style.left = '50%' - searchWindow.style.transform = 'translate(-50%, -30%)' - searchWindow.style.width = '200px' - searchWindow.style.height = '200px' - searchWindow.style.backgroundColor = 'rgba(30, 30, 30, 0.9)' - searchWindow.style.zIndex = '1000' - searchWindow.style.display = 'none' - searchWindow.style.borderRadius = '10px' - - let sBox = document.createElement('input'); - sBox.type = 'text'; - sBox.placeholder = 'search'; - sBox.style.position = 'absolute'; - sBox.style.zIndex = '1000'; - - sBox.style.textAlign = 'center' - sBox.style.left = '50%'; - sBox.style.top = '30%'; - sBox.style.transform = 'translate(-50%, -30%)' - sBox.style.width = '100px' - - sBox.style.backgroundColor = 'rgba(0, 122, 255, 0.2)' - sBox.style.color = 'white' - sBox.style.fontSize = '20px' - sBox.style.border = 'none' - sBox.style.borderRadius = '5px' - - searchWindow.appendChild(sBox) - chart.div.appendChild(searchWindow); - - let yPrice = null - chart.chart.subscribeCrosshairMove((param) => { - if (param.point){ - yPrice = param.point.y; - } - }); - - let selectedChart = false - chart.wrapper.addEventListener('mouseover', (event) => { - selectedChart = true - }) - chart.wrapper.addEventListener('mouseout', (event) => { - selectedChart = false - }) - document.addEventListener('keydown', function(event) { - if (!selectedChart) {return} - if (event.altKey && event.code === 'KeyH') { - let price = chart.series.coordinateToPrice(yPrice) - makeHorizontalLine(chart, price, '#FFFFFF', 1, LightweightCharts.LineStyle.Solid, true, '') - } - if (searchWindow.style.display === 'none') { - if (/^[a-zA-Z0-9]$/.test(event.key)) { - searchWindow.style.display = 'block'; - sBox.focus(); - } - } - else if (event.key === 'Enter') { - callbackFunction(`on_search__${chart.id}__${sBox.value}`) - searchWindow.style.display = 'none' - sBox.value = '' - } - else if (event.key === 'Escape') { - searchWindow.style.display = 'none' - sBox.value = '' - } - }); - sBox.addEventListener('input', function() { - sBox.value = sBox.value.toUpperCase(); - }); -} - -function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName) { - let switcherElement = document.createElement('div'); - switcherElement.style.margin = '4px 18px' - switcherElement.style.zIndex = '1000' - - let intervalElements = items.map(function(item) { - let itemEl = document.createElement('button'); - itemEl.style.cursor = 'pointer' - itemEl.style.padding = '3px 6px' - itemEl.style.margin = '0px 4px' - itemEl.style.fontSize = '14px' - itemEl.style.color = 'lightgrey' - itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'transparent' - - - itemEl.style.border = 'none' - itemEl.style.borderRadius = '4px' - itemEl.addEventListener('mouseenter', function() { - itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'rgb(19, 40, 84)' - }) - itemEl.addEventListener('mouseleave', function() { - itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'transparent' - }) - 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 ? 'rgba(0, 122, 255, 0.7)' : 'transparent' - }); - activeItem = item; - callbackFunction(`${callbackName}__${chart.id}__${item}`); - } - chart.topBar.appendChild(switcherElement) - makeSeperator(chart.topBar) - return switcherElement; -} -function makeTopBar(chart) { - chart.topBar = document.createElement('div') - chart.topBar.style.backgroundColor = '#191B1E' - chart.topBar.style.borderBottom = '3px solid #3C434C' - chart.topBar.style.borderRadius = '2px' - chart.topBar.style.display = 'flex' - chart.topBar.style.alignItems = 'center' - chart.wrapper.prepend(chart.topBar) - - chart.cornerText = document.createElement('div') - chart.cornerText.style.margin = '0px 18px' - chart.cornerText.style.position = 'relative' - chart.cornerText.style.fontFamily = 'SF Pro' - chart.cornerText.style.color = 'lightgrey' - chart.topBar.appendChild(chart.cornerText) - - makeSeperator(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.py b/lightweight_charts/js.py index 6b62828..b2725b8 100644 --- a/lightweight_charts/js.py +++ b/lightweight_charts/js.py @@ -1,6 +1,6 @@ import pandas as pd from datetime import timedelta, datetime -from typing import Union, Literal +from typing import Union, Literal, Dict from lightweight_charts.pkg import LWC_4_0_1 from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, _crosshair_mode, _line_style, \ @@ -13,14 +13,15 @@ class SeriesCommon: self._interval = common_interval.index[0] def _df_datetime_format(self, df: pd.DataFrame): + df = df.copy() if 'date' in df.columns: df = df.rename(columns={'date': 'time'}) self._set_interval(df) - # df['time'] = df['time'].apply(self._datetime_format) df['time'] = self._datetime_format(df['time']) return df def _series_datetime_format(self, series): + series = series.copy() if 'date' in series.keys(): series = series.rename({'date': 'time'}) series['time'] = self._datetime_format(series['time']) @@ -137,6 +138,57 @@ class Line(SeriesCommon): self.run_script(f'{self.id}.series.update({series.to_dict()})') +class Widget: + def __init__(self, chart): + self._chart = chart + self.method = None + + +class TextWidget(Widget): + def __init__(self, chart, initial_text): + super().__init__(chart) + self.value = initial_text + self.id = f"window.{self._chart._rand.generate()}" + self._chart.run_script(f'''{self.id} = makeTextBoxWidget({self._chart.id}, "{initial_text}")''') + + def set(self, string): + self.value = string + self._chart.run_script(f'{self.id}.innerText = "{string}"') + + +class SwitcherWidget(Widget): + def __init__(self, chart, method, *options, default): + super().__init__(chart) + self.value = default + self.method = method.__name__ + self._chart.run_script(f''' + makeSwitcher({self._chart.id}, {list(options)}, '{default}', {self._chart._js_api_code}, '{method.__name__}') + {self._chart.id}.chart.resize(window.innerWidth*{self._chart._inner_width}, (window.innerHeight*{self._chart._inner_height})-{self._chart.id}.topBar.offsetHeight) + ''') + + +class TopBar: + def __init__(self, chart): + self._chart = chart + self._widgets: Dict[str, Widget] = {} + self._chart.run_script(f''' + makeTopBar({self._chart.id}) + {self._chart.id}.chart.resize(window.innerWidth*{self._chart._inner_width}, (window.innerHeight*{self._chart._inner_height})-{self._chart.id}.topBar.offsetHeight) + ''') + + def __getitem__(self, item): return self._widgets.get(item) + + def switcher(self, name, method, *options, default=None): + self._widgets[name] = SwitcherWidget(self._chart, method, *options, default=default if default else options[0]) + + def textbox(self, name, initial_text=''): self._widgets[name] = TextWidget(self._chart, initial_text) + + def _widget_with_method(self, method_name): + for widget in self._widgets.values(): + if widget.method == method_name: + return widget + + class LWC(SeriesCommon): def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False): self._volume_enabled = volume_enabled @@ -153,24 +205,31 @@ class LWC(SeriesCommon): self._script_func = None self._last_bar = None self._interval = None + self._charts = {self.id: self} + self._js_api_code = None self._background_color = '#000000' self._volume_up_color = 'rgba(83,141,131,0.8)' self._volume_down_color = 'rgba(200,127,130,0.8)' + # self.polygon: PolygonAPI = PolygonAPI(self) + def _on_js_load(self): if self.loaded: return self.loaded = True [self.run_script(script) for script in self._scripts] - def _create_chart(self, top_bar=False): + def _create_chart(self, autosize=True): self.run_script(f''' - {self.id} = makeChart({self._inner_width}, {self._inner_height}, topBar={_js_bool(top_bar)}) + {self.id} = makeChart({self._inner_width}, {self._inner_height}, autoSize={_js_bool(autosize)}) {self.id}.id = '{self.id}' {self.id}.wrapper.style.float = "{self._position}" ''') + def _make_search_box(self): + self.run_script(f'makeSearchBox({self.id}, {self._js_api_code})') + def run_script(self, script): """ For advanced users; evaluates JavaScript within the Webview. @@ -342,7 +401,7 @@ class LWC(SeriesCommon): """ self._background_color = background_color if background_color else self._background_color self.run_script(f""" - document.body.style.backgroundColor = '{self._background_color}' + document.getElementById('wrapper').style.backgroundColor = '{self._background_color}' {self.id}.chart.applyOptions({{ layout: {{ background: {{ @@ -484,20 +543,28 @@ class LWC(SeriesCommon): }});''') 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): - return SubChart(self, volume_enabled, position, width, height, sync) + width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False, + topbar: bool = False, searchbox: bool = False): + subchart = SubChart(self, volume_enabled, position, width, height, sync, topbar, searchbox) + self._charts[subchart.id] = subchart + return subchart class SubChart(LWC): - def __init__(self, parent, volume_enabled, position, width, height, sync): + def __init__(self, parent, volume_enabled, position, width, height, sync, topbar, searchbox): super().__init__(volume_enabled, width, height) self._chart = parent._chart if isinstance(parent, SubChart) else parent self._parent = parent self._position = position self._rand = self._chart._rand - self.id = f'window.{self._rand.generate()}' + self._js_api_code = self._chart._js_api_code self.run_script = self._chart.run_script + self._charts = self._chart._charts + self.id = f'window.{self._rand.generate()}' + self._create_chart() + self.topbar = TopBar(self) if topbar else None + self._make_search_box() if searchbox else None if not sync: return sync_parent_var = self._parent.id if isinstance(sync, bool) else sync @@ -509,15 +576,9 @@ class SubChart(LWC): SCRIPT = """ -document.body.style.backgroundColor = '#000000' -const up = 'rgba(39, 157, 130, 100)' -const down = 'rgba(200, 97, 100, 100)' +document.getElementById('wrapper').style.backgroundColor = '#000000' -const wrapper = document.createElement('div') -wrapper.className = 'wrapper' -document.body.appendChild(wrapper) - -function makeChart(innerWidth, innerHeight, topBar=false) { +function makeChart(innerWidth, innerHeight, autoSize=true) { let chart = { markers: [], horizontal_lines: [], @@ -528,16 +589,10 @@ function makeChart(innerWidth, innerHeight, topBar=false) { width: innerWidth, height: innerHeight }, - } - let topBarOffset = 0 - if (topBar) { - makeTopBar(chart) - topBarOffset = chart.topBar.offsetHeight - } - + } chart.chart = LightweightCharts.createChart(chart.div, { width: window.innerWidth*innerWidth, - height: (window.innerHeight*innerHeight)-topBarOffset, + height: window.innerHeight*innerHeight, layout: { textColor: '#d1d4dc', background: { @@ -565,12 +620,8 @@ function makeChart(innerWidth, innerHeight, topBar=false) { }, handleScroll: {vertTouchDrag: true}, }) - window.addEventListener('resize', function() { - if (topBar) { - topBarOffset = chart.topBar.offsetHeight - } - chart.chart.resize(window.innerWidth*innerWidth, (window.innerHeight*innerHeight)-topBarOffset) - }); + let up = 'rgba(39, 157, 130, 100)' + let down = 'rgba(200, 97, 100, 100)' chart.series = chart.chart.addCandlestickSeries({color: 'rgb(0, 120, 255)', upColor: up, borderUpColor: up, wickUpColor: up, downColor: down, borderDownColor: down, wickDownColor: down, lineWidth: 2, }) @@ -599,8 +650,18 @@ function makeChart(innerWidth, innerHeight, topBar=false) { chart.div.appendChild(chart.legend) chart.wrapper.appendChild(chart.div) - wrapper.append(chart.wrapper) + document.getElementById('wrapper').append(chart.wrapper) + if (!autoSize) { + return chart + } + let topBarOffset = 0 + window.addEventListener('resize', function() { + if ('topBar' in chart) { + topBarOffset = chart.topBar.offsetHeight + } + chart.chart.resize(window.innerWidth*innerWidth, (window.innerHeight*innerHeight)-topBarOffset) + }); return chart } function makeHorizontalLine(chart, price, color, width, style, axisLabelVisible, text) { @@ -635,12 +696,186 @@ HTML = f""" margin: 0; padding: 0; overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + }} + #wrapper {{ + width: 100vw; + height: 100vh; }} +
""" + +CALLBACK_SCRIPT = ''' +function makeSearchBox(chart, callbackFunction) { + let searchWindow = document.createElement('div') + searchWindow.style.position = 'absolute' + searchWindow.style.top = '0' + searchWindow.style.bottom = '200px' + searchWindow.style.left = '0' + searchWindow.style.right = '0' + searchWindow.style.margin = 'auto' + searchWindow.style.width = '150px' + searchWindow.style.height = '30px' + searchWindow.style.padding = '10px' + searchWindow.style.backgroundColor = 'rgba(30, 30, 30, 0.9)' + searchWindow.style.border = '3px solid #3C434C' + searchWindow.style.zIndex = '1000' + searchWindow.style.display = 'none' + searchWindow.style.borderRadius = '5px' + + let magnifyingGlass = document.createElement('span'); + magnifyingGlass.style.display = 'inline-block'; + magnifyingGlass.style.width = '12px'; + magnifyingGlass.style.height = '12px'; + magnifyingGlass.style.border = '2px solid #FFF'; + magnifyingGlass.style.borderRadius = '50%'; + magnifyingGlass.style.position = 'relative'; + let handle = document.createElement('span'); + handle.style.display = 'block'; + handle.style.width = '7px'; + handle.style.height = '2px'; + handle.style.backgroundColor = '#FFF'; + handle.style.position = 'absolute'; + handle.style.top = 'calc(50% + 7px)'; + handle.style.right = 'calc(50% - 11px)'; + handle.style.transform = 'rotate(45deg)'; + + let sBox = document.createElement('input'); + sBox.type = 'text'; + sBox.placeholder = 'search'; + sBox.style.position = 'relative'; + sBox.style.display = 'inline-block'; + sBox.style.zIndex = '1000'; + sBox.style.textAlign = 'center' + sBox.style.width = '100px' + sBox.style.marginLeft = '15px' + sBox.style.backgroundColor = 'rgba(0, 122, 255, 0.2)' + sBox.style.color = 'lightgrey' + sBox.style.fontSize = '20px' + sBox.style.border = 'none' + sBox.style.outline = 'none' + sBox.style.borderRadius = '2px' + + searchWindow.appendChild(magnifyingGlass) + magnifyingGlass.appendChild(handle) + searchWindow.appendChild(sBox) + chart.div.appendChild(searchWindow); + + let yPrice = null + chart.chart.subscribeCrosshairMove((param) => { + if (param.point){ + yPrice = param.point.y; + } + }); + let selectedChart = false + chart.wrapper.addEventListener('mouseover', (event) => { + selectedChart = true + }) + chart.wrapper.addEventListener('mouseout', (event) => { + selectedChart = false + }) + document.addEventListener('keydown', function(event) { + if (!selectedChart) {return} + if (event.altKey && event.code === 'KeyH') { + let price = chart.series.coordinateToPrice(yPrice) + makeHorizontalLine(chart, price, '#FFFFFF', 1, LightweightCharts.LineStyle.Solid, true, '') + } + if (searchWindow.style.display === 'none') { + if (/^[a-zA-Z0-9]$/.test(event.key)) { + searchWindow.style.display = 'block'; + sBox.focus(); + } + } + else if (event.key === 'Enter') { + callbackFunction(`on_search__${chart.id}__${sBox.value}`) + searchWindow.style.display = 'none' + sBox.value = '' + } + else if (event.key === 'Escape') { + searchWindow.style.display = 'none' + sBox.value = '' + } + }); + sBox.addEventListener('input', function() { + sBox.value = sBox.value.toUpperCase(); + }); +} + +function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName) { + let switcherElement = document.createElement('div'); + switcherElement.style.margin = '4px 18px' + switcherElement.style.zIndex = '1000' + + let intervalElements = items.map(function(item) { + let itemEl = document.createElement('button'); + itemEl.style.cursor = 'pointer' + itemEl.style.padding = '3px 6px' + itemEl.style.margin = '0px 4px' + itemEl.style.fontSize = '14px' + itemEl.style.color = 'lightgrey' + itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'transparent' + + itemEl.style.border = 'none' + itemEl.style.borderRadius = '4px' + itemEl.addEventListener('mouseenter', function() { + itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'rgb(19, 40, 84)' + }) + itemEl.addEventListener('mouseleave', function() { + itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'transparent' + }) + 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 ? 'rgba(0, 122, 255, 0.7)' : 'transparent' + }); + activeItem = item; + 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.position = 'relative' + textBox.style.color = 'lightgrey' + 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 = '3px 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/widgets.py b/lightweight_charts/widgets.py index 94b27e0..0fe5e84 100644 --- a/lightweight_charts/widgets.py +++ b/lightweight_charts/widgets.py @@ -1,3 +1,6 @@ +import asyncio +from inspect import iscoroutinefunction + try: import wx.html2 except ImportError: @@ -15,16 +18,18 @@ try: @pyqtSlot(str) def callback(self, message): _widget_message(self.chart, message) - except ImportError: pass try: from streamlit.components.v1 import html except ImportError: pass +try: + from IPython.display import HTML, display +except ImportError: + pass -from lightweight_charts.chartasync import LWCAsync, ASYNC_SCRIPT -from lightweight_charts.js import LWC +from lightweight_charts.js import LWC, TopBar, CALLBACK_SCRIPT def _widget_message(chart, string): @@ -32,12 +37,13 @@ def _widget_message(chart, string): name, chart_id = messages[:2] args = messages[2:] chart.api.chart = chart._charts[chart_id] - getattr(chart.api, name)(*args) + method = getattr(chart.api, name) + asyncio.create_task(getattr(chart.api, name)(*args)) if iscoroutinefunction(method) else method(*args) -class WxChart(LWCAsync): - def __init__(self, parent, api: object = None, top_bar: bool = False, search_box: bool = False, - volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0): +class WxChart(LWC): + def __init__(self, parent, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, + api: object = None, topbar: bool = False, searchbox: bool = False): try: self.webview: wx.html2.WebView = wx.html2.WebView.New(parent) except NameError: @@ -48,20 +54,21 @@ class WxChart(LWCAsync): self._script_func = self.webview.RunScript self._js_api_code = 'window.wx_msg.postMessage.bind(window.wx_msg)' - self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, lambda e: wx.CallLater(200, self._on_js_load)) + 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(ASYNC_SCRIPT) - self._create_chart(top_bar) - self._make_search_box() if search_box else None + + self.webview.AddUserScript(CALLBACK_SCRIPT) + self._create_chart() + self.topbar = TopBar(self) if topbar else None + self._make_search_box() if searchbox else None def get_webview(self): return self.webview -class QtChart(LWCAsync): - def __init__(self, widget=None, api: object = None, top_bar: bool = False, search_box: bool = False, +class QtChart(LWC): + def __init__(self, widget=None, api: object = None, topbar: bool = False, searchbox: bool = False, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0): try: self.webview = QWebEngineView(widget) @@ -89,22 +96,21 @@ class QtChart(LWCAsync): {self._html[85:]} ''' self.webview.page().setHtml(self._html) - self.run_script(ASYNC_SCRIPT) - self._create_chart(top_bar) - self._make_search_box() if search_box else None + + self.run_script(CALLBACK_SCRIPT) + self._create_chart() + self.topbar = TopBar(self) if topbar else None + self._make_search_box() if searchbox else None def get_webview(self): return self.webview -class StreamlitChart(LWC): +class StaticLWC(LWC): def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1): super().__init__(volume_enabled, inner_width, inner_height) - self.width = width self.height = height - self._html = self._html.replace('\n\n', '') - self._create_chart() def run_script(self, script): self._html += '\n' + script @@ -112,8 +118,44 @@ class StreamlitChart(LWC): if self.loaded: return self.loaded = True + self._load() + + def _load(self): pass + + +class StreamlitChart(StaticLWC): + def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1): + super().__init__(volume_enabled, width, height, inner_width, inner_height) + self._create_chart() + + def _load(self): try: html(f'{self._html}', width=self.width, height=self.height) except NameError: raise ModuleNotFoundError('streamlit.components.v1.html was not found, and must be installed to use StreamlitChart.') + +class JupyterChart(StaticLWC): + def __init__(self, volume_enabled=True, width=800, height=350, inner_width=1, inner_height=1): + super().__init__(volume_enabled, width, height, inner_width, inner_height) + self._position = "" + + self._create_chart(autosize=False) + self.run_script(f''' + for (var i = 0; i < document.getElementsByClassName("tv-lightweight-charts").length; i++) {{ + var element = document.getElementsByClassName("tv-lightweight-charts")[i]; + element.style.overflow = "visible" + }} + document.getElementById('wrapper').style.overflow = 'hidden' + document.getElementById('wrapper').style.borderRadius = '10px' + document.getElementById('wrapper').style.width = '{self.width}px' + document.getElementById('wrapper').style.height = '100%' + ''') + self.run_script(f'{self.id}.chart.resize({width}, {height})') + + def _load(self): + try: + display(HTML(f'{self._html}')) + except NameError: + raise ModuleNotFoundError('IPython.display.HTML was not found, and must be installed to use JupyterChart.') + diff --git a/setup.py b/setup.py index a0ca04e..0e155aa 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.9', + version='1.0.10', packages=find_packages(), python_requires='>=3.9', install_requires=[