Refactoring/Enhancements/Fixes

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.
This commit is contained in:
louisnw
2023-08-14 16:06:16 +01:00
parent 06b605d3a7
commit 34ce3f7199
22 changed files with 1024 additions and 784 deletions

101
README.md
View File

@ -21,16 +21,14 @@ pip install lightweight-charts
___ ___
## Features ## Features
1. Simple and easy to use. 1. Streamlined for live data, with methods for updating directly from tick data.
2. Blocking or non-blocking GUI. 2. Multi-pane charts using [Subcharts](https://lightweight-charts-python.readthedocs.io/en/latest/common_methods.html#create-subchart-subchart).
3. Streamlined for live data, with methods for updating directly from tick data. 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. Multi-Pane Charts using the [`SubChart`](https://lightweight-charts-python.readthedocs.io/en/latest/common_methods.html#create-subchart-subchart). 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. The Toolbox, allowing for trendlines, rays and horizontal lines to be drawn directly onto charts. 5. [Tables](https://lightweight-charts-python.readthedocs.io/en/latest/tables.html) for watchlists, order entry, and trade management.
6. [Callbacks](https://lightweight-charts-python.readthedocs.io/en/latest/callbacks.html) allowing for timeframe (1min, 5min, 30min etc.) selectors, searching, hotkeys, and more. 6. Direct integration of market data through [Polygon.io's](https://polygon.io/?utm_source=affiliate&utm_campaign=pythonlwcharts) market data API.
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.
__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) 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() 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') df = pd.read_csv('ohlcv.csv')
chart.set(df) chart.set(df)
@ -102,10 +100,10 @@ if __name__ == '__main__':
df1 = pd.read_csv('ohlc.csv') df1 = pd.read_csv('ohlc.csv')
# Columns: | time | price | volume (if volume is enabled) | # Columns: time | price
df2 = pd.read_csv('ticks.csv') df2 = pd.read_csv('ticks.csv')
chart = Chart(volume_enabled=False) chart = Chart()
chart.set(df1) chart.set(df1)
@ -114,7 +112,7 @@ if __name__ == '__main__':
for i, tick in df2.iterrows(): for i, tick in df2.iterrows():
chart.update_from_tick(tick) 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) ![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 from lightweight_charts import Chart
def calculate_sma(data: pd.DataFrame, period: int = 50): def calculate_sma(df, period: int = 50):
def avg(d: pd.DataFrame): return pd.DataFrame({
return d['close'].mean() 'time': df['date'],
f'SMA {period}': df['close'].rolling(window=period).mean()
result = [] }).dropna()
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)
if __name__ == '__main__': if __name__ == '__main__':
chart = Chart() chart = Chart()
chart.legend(visible=True) chart.legend(visible=True)
df = pd.read_csv('ohlcv.csv')
chart.set(df)
line = chart.create_line() df = pd.read_csv('ohlcv.csv')
sma_data = calculate_sma(df, period=50) chart.set(df)
line.set(sma_data, name='SMA 50')
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) ![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__': if __name__ == '__main__':
chart = Chart(debug=True) chart = Chart()
df = pd.read_csv('ohlcv.csv') df = pd.read_csv('ohlcv.csv')
@ -203,43 +197,42 @@ def get_bar_data(symbol, timeframe):
if symbol not in ('AAPL', 'GOOGL', 'TSLA'): if symbol not in ('AAPL', 'GOOGL', 'TSLA'):
print(f'No data for "{symbol}"') print(f'No data for "{symbol}"')
return pd.DataFrame() 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 on_search(chart, searched_string): # Called when the user searches.
def __init__(self): new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value)
self.chart = None # Changes after each callback. 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. def on_timeframe_selection(chart): # Called when the user changes the timeframe.
new_data = get_bar_data(self.chart.topbar['symbol'].value, self.chart.topbar['timeframe'].value) new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value)
if new_data.empty: if new_data.empty:
return return
self.chart.set(new_data, True) 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__': if __name__ == '__main__':
api = API() chart = Chart(toolbox=True)
chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True)
chart.legend(True) chart.legend(True)
chart.events.search += on_search
chart.topbar.textbox('symbol', 'TSLA') 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') df = get_bar_data('TSLA', '5min')
chart.set(df) chart.set(df)
chart.horizontal_line(200, interactive=True) chart.horizontal_line(200, func=on_horizontal_line_move)
chart.show(block=True) chart.show(block=True)

View File

@ -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. Events allow asynchronous and synchronous callbacks to be passed back into python.
`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.
___ ___
## 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.<name> += <callable>`
___
## How to use Events
Take a look at this minimal example: Take a look at this minimal example:
```python ```python
class API: from lightweight_charts import Chart
def __init__(self):
self.chart = None
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: Upon searching in a pane, the expected output would be akin to:
``` ```
Search Text: "AAPL" | Chart/SubChart ID: "window.blyjagcr" 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. The ID shown above will change depending upon which pane was used to search, allowing for access to the object in question.
`self.chart` will update upon each callback, allowing for access to the specific pane in question.
```{important} ```{important}
* When using `show` rather than `show_async`, block should be set to `True` (`chart.show(block=True)`). * 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. * Event callables can be either coroutines, methods, or functions.
* Non fixed callbacks (switchers, hotkeys) can be methods, coroutines, or regular 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` ## `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) ![topbar](https://i.imgur.com/Qu2FW9Y.png)
This class is accessed from the `topbar` attribute of the chart object (`chart.topbar.<method>`), 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.<method>`).
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 ```python
chart = Chart(api=api, topbar=True)
chart.topbar.textbox('symbol', 'AAPL') # Declares a textbox displaying 'AAPL'. chart.topbar.textbox('symbol', 'AAPL') # Declares a textbox displaying 'AAPL'.
print(chart.topbar['symbol'].value) # Prints the value within ('AAPL') print(chart.topbar['symbol'].value) # Prints the value within ('AAPL')
chart.topbar['symbol'].set('MSFT') # Sets the 'symbol' textbox to 'MSFT' chart.topbar['symbol'].set('MSFT') # Sets the 'symbol' textbox to 'MSFT'
print(chart.topbar['symbol'].value) # Prints the value again ('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` ### `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. * `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 options for each switcher item.
* `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. * `default`: The initial switcher option set.
___ ___
@ -79,6 +97,15 @@ ___
* `initial_text`: The text to show within the text box. * `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: ## Callbacks Example:
```python ```python
@ -93,40 +120,37 @@ def get_bar_data(symbol, timeframe):
return pd.read_csv(f'../examples/6_callbacks/bar_data/{symbol}_{timeframe}.csv') return pd.read_csv(f'../examples/6_callbacks/bar_data/{symbol}_{timeframe}.csv')
class API: def on_search(chart, searched_string):
def __init__(self): new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value)
self.chart = None # Changes after each callback. 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) def on_timeframe_selection(chart):
if new_data.empty: new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value)
return if new_data.empty:
self.chart.topbar['symbol'].set(searched_string) return
self.chart.set(new_data) 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) def on_horizontal_line_move(chart, line):
if new_data.empty: print(f'Horizontal line moved to: {line.price}')
return
self.chart.set(new_data, True)
def on_horizontal_line_move(self, line_id, price):
print(f'Horizontal line moved to: {price}')
if __name__ == '__main__': if __name__ == '__main__':
api = API() chart = Chart(toolbox=True)
chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True)
chart.legend(True) chart.legend(True)
chart.topbar.textbox('symbol', 'TSLA') 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') df = get_bar_data('TSLA', '5min')
chart.set(df) chart.set(df)
chart.horizontal_line(200, interactive=True) chart.horizontal_line(200, func=on_horizontal_line_move)
chart.show(block=True) chart.show(block=True)
``` ```

View File

@ -5,8 +5,7 @@ This page contains a reference to all chart objects that can be used within the
___ ___
## Chart ## Chart
`volume_enabled: bool` | `width: int` | `height: int` | `x: int` | `y: int` | `on_top: bool` | `maximize: bool` | `debug: bool` | `width: int` | `height: int` | `x: int` | `y: int` | `on_top: bool` | `maximize: bool` | `debug: bool` | `toolbox: bool`
`api: object` | `topbar: bool` | `searchbox: bool` | `toolbox: bool`
The main object used for the normal functionality of lightweight-charts-python, built on the pywebview library. 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 ## 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` ### `get_webview`
@ -107,11 +108,11 @@ app.exec_()
___ ___
## WxChart ## 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` ### `get_webview`
@ -157,7 +158,6 @@ if __name__ == '__main__':
___ ___
## StreamlitChart ## 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. 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.

View File

@ -43,32 +43,30 @@ If `cumulative_volume` is used, the volume data given will be added onto the lat
___ ___
## `create_line` (Line) ## `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: 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). [`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. Its instance should only be accessed from this method.
### `set` ### `set`
`data: pd.DataFrame` `name: str` `data: pd.DataFrame`
Sets the data for the line. 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: 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 ```python
line = chart.create_line() line = chart.create_line('SMA 50')
# DataFrame with columns: date | SMA 50 # DataFrame with columns: date | SMA 50
df = pd.read_csv('sma50.csv') df = pd.read_csv('sma50.csv')
line.set(df, name='SMA 50') line.set(df)
``` ```
### `update` ### `update`
`series: pd.Series` `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. This should be given as a Series object, with labels akin to the `line.set()` function.
### `delete` ### `delete`
Irreversibly deletes the line. Irreversibly deletes the line.
@ -263,8 +260,8 @@ ___
Shows the hidden candles on the chart. Shows the hidden candles on the chart.
___ ___
## `add_hotkey` ## `hotkey`
`modifier: 'ctrl'/'shift'/'alt'/'meta'` | `key: str/int/tuple` | `method: object` `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. 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 ```python
def place_buy_order(key): def place_buy_order(key):
print(f'Buy {key} shares.') print(f'Buy {key} shares.')
def place_sell_order(key): def place_sell_order(key):
print(f'Sell all shares, because I pressed {key}.') print(f'Sell all shares, because I pressed {key}.')
chart.add_hotkey('shift', (1, 2, 3), place_buy_order) if __name__ == '__main__':
chart.add_hotkey('shift', 'X', place_sell_order) chart = Chart()
chart.hotkey('shift', (1, 2, 3), place_buy_order)
chart.hotkey('shift', 'X', place_sell_order)
chart.show(block=True)
``` ```
___ ___
## `create_table` ## `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. Creates and returns a [`Table`](https://lightweight-charts-python.readthedocs.io/en/latest/tables.html) object.
___ ___
## `create_subchart` (SubChart) ## `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. 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: ### Synced Line Chart Example:
```python ```python
@ -346,17 +345,16 @@ if __name__ == '__main__':
chart = Chart(inner_width=1, inner_height=0.8) chart = Chart(inner_width=1, inner_height=0.8)
chart.time_scale(visible=False) 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') df = pd.read_csv('ohlcv.csv')
df2 = pd.read_csv('rsi.csv') df2 = pd.read_csv('rsi.csv')
chart.set(df) chart.set(df)
line = chart2.create_line()
line.set(df2) line.set(df2)
chart.show(block=True) chart.show(block=True)
``` ```

View File

@ -1,7 +1,7 @@
project = 'lightweight-charts-python' project = 'lightweight-charts-python'
copyright = '2023, louisnw' copyright = '2023, louisnw'
author = 'louisnw' author = 'louisnw'
release = '1.0.15' release = '1.0.16'
extensions = ["myst_parser"] extensions = ["myst_parser"]

View File

@ -1,11 +1,9 @@
# Table # 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. 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. `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. `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) ## `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. 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` ### `background_color`
`column: str` | `color: str` `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` ### `delete`
Deletes the row. Deletes the row.
@ -77,10 +86,7 @@ ___
import pandas as pd import pandas as pd
from lightweight_charts import Chart from lightweight_charts import Chart
def on_row_click(row_id): def on_row_click(row):
row = table.get(row_id)
print(row)
row['PL'] = round(row['PL']+1, 2) row['PL'] = round(row['PL']+1, 2)
row.background_color('PL', 'green' if row['PL'] > 0 else 'red') row.background_color('PL', 'green' if row['PL'] > 0 else 'red')
@ -97,7 +103,7 @@ if __name__ == '__main__':
headings=('Ticker', 'Quantity', 'Status', '%', 'PL'), headings=('Ticker', 'Quantity', 'Status', '%', 'PL'),
widths=(0.2, 0.1, 0.2, 0.2, 0.3), widths=(0.2, 0.1, 0.2, 0.2, 0.3),
alignments=('center', 'center', 'right', 'right', 'right'), 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('PL', f{table.VALUE}')
table.format('%', f'{table.VALUE} %') table.format('%', f'{table.VALUE} %')

View File

@ -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. 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.<method>`)
The following hotkeys can also be used when the Toolbox is enabled: The following hotkeys can also be used when the Toolbox is enabled:
* Alt+T: Trendline * Alt+T: Trendline
* Alt+H: Horizontal Line * Alt+H: Horizontal Line
* Alt+R: Ray Line * Alt+R: Ray Line
* Meta+Z or Ctrl+Z: Undo * 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` ## `save_drawings_under`
@ -58,33 +58,29 @@ def get_bar_data(symbol, timeframe):
return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv')
class API: def on_search(chart, searched_string):
def __init__(self): new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value)
self.chart: Chart = None 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__': if __name__ == '__main__':
api = API() chart = Chart(toolbox=True)
chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True)
chart.legend(True) chart.legend(True)
chart.events.search += on_search
chart.topbar.textbox('symbol', 'TSLA') 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') 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.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.toolbox.save_drawings_under(chart.topbar['symbol']) # Saves drawings based on the symbol.
chart.show(block=True) 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.
``` ```

View File

@ -2,10 +2,9 @@ import pandas as pd
from lightweight_charts import Chart from lightweight_charts import Chart
if __name__ == '__main__': if __name__ == '__main__':
chart = Chart() 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') df = pd.read_csv('ohlcv.csv')
chart.set(df) chart.set(df)

View File

@ -2,15 +2,14 @@ import pandas as pd
from time import sleep from time import sleep
from lightweight_charts import Chart from lightweight_charts import Chart
if __name__ == '__main__': if __name__ == '__main__':
df1 = pd.read_csv('ohlc.csv') df1 = pd.read_csv('ohlc.csv')
# Columns: | time | price | volume (if volume is enabled) | # Columns: time | price
df2 = pd.read_csv('ticks.csv') df2 = pd.read_csv('ticks.csv')
chart = Chart(volume_enabled=False) chart = Chart()
chart.set(df1) chart.set(df1)
@ -18,6 +17,4 @@ if __name__ == '__main__':
for i, tick in df2.iterrows(): for i, tick in df2.iterrows():
chart.update_from_tick(tick) chart.update_from_tick(tick)
sleep(0.03) sleep(0.03)

View File

@ -2,26 +2,22 @@ import pandas as pd
from lightweight_charts import Chart from lightweight_charts import Chart
def calculate_sma(data: pd.DataFrame, period: int = 50): def calculate_sma(df, period: int = 50):
def avg(d: pd.DataFrame): return pd.DataFrame({
return d['close'].mean() 'time': df['date'],
result = [] f'SMA {period}': df['close'].rolling(window=period).mean()
for i in range(period - 1, len(data)): }).dropna()
val = avg(data.iloc[i - period + 1:i])
result.append({'time': data.iloc[i]['date'], f'SMA {period}': val})
return pd.DataFrame(result)
if __name__ == '__main__': if __name__ == '__main__':
chart = Chart()
chart = Chart(debug=True)
chart.legend(visible=True) chart.legend(visible=True)
df = pd.read_csv('ohlcv.csv') df = pd.read_csv('ohlcv.csv')
chart.set(df) chart.set(df)
line = chart.create_line() line = chart.create_line('SMA 50')
sma_data = calculate_sma(df, period=50) sma_data = calculate_sma(df, period=50)
line.set(sma_data, name='SMA 50') line.set(sma_data)
chart.show(block=True) chart.show(block=True)

View File

@ -9,39 +9,38 @@ def get_bar_data(symbol, timeframe):
return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv') return pd.read_csv(f'bar_data/{symbol}_{timeframe}.csv')
class API: def on_search(chart, searched_string): # Called when the user searches.
def __init__(self): new_data = get_bar_data(searched_string, chart.topbar['timeframe'].value)
self.chart: Chart = None # Changes after each callback. 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. def on_timeframe_selection(chart): # Called when the user changes the timeframe.
new_data = get_bar_data(self.chart.topbar['symbol'].value, self.chart.topbar['timeframe'].value) new_data = get_bar_data(chart.topbar['symbol'].value, chart.topbar['timeframe'].value)
if new_data.empty: if new_data.empty:
return return
self.chart.set(new_data, True) 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__': if __name__ == '__main__':
api = API() chart = Chart(toolbox=True)
chart = Chart(api=api, topbar=True, searchbox=True, toolbox=True)
chart.legend(True) chart.legend(True)
chart.events.search += on_search
chart.topbar.textbox('symbol', 'TSLA') 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') df = get_bar_data('TSLA', '5min')
chart.set(df) chart.set(df)
chart.horizontal_line(200, interactive=True) chart.horizontal_line(200, func=on_horizontal_line_move)
chart.show(block=True) chart.show(block=True)

View File

@ -1,3 +1,4 @@
import asyncio
import json import json
import os import os
from datetime import timedelta, datetime from datetime import timedelta, datetime
@ -6,10 +7,8 @@ import pandas as pd
from typing import Union, Literal, Dict, List from typing import Union, Literal, Dict, List
from lightweight_charts.table import Table from lightweight_charts.table import Table
from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, _crosshair_mode, \ from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, crosshair_mode, \
_line_style, \ line_style, jbool, price_scale_mode, PRICE_SCALE_MODE, marker_position, marker_shape, IDGen, Events
MissingColumn, _js_bool, _price_scale_mode, PRICE_SCALE_MODE, _marker_position, _marker_shape, IDGen
JS = {} JS = {}
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
@ -51,7 +50,9 @@ HTML = f"""
class SeriesCommon: class SeriesCommon:
def _set_interval(self, df: pd.DataFrame): 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: try:
self._interval = common_interval.index[0] self._interval = common_interval.index[0]
except IndexError: except IndexError:
@ -97,12 +98,14 @@ class SeriesCommon:
return series return series
def _datetime_format(self, arg: Union[pd.Series, str]): 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 self._interval < timedelta(days=1):
if isinstance(arg, pd.Series): if isinstance(arg, pd.Series):
arg = arg.astype('int64') // 10 ** 9 arg = arg.astype('int64') // 10 ** 9
else: 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: else:
arg = arg.dt.strftime('%Y-%m-%d') if isinstance(arg, pd.Series) else arg.strftime('%Y-%m-%d') 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.run_script(f"""
{self.id}.markers.push({{ {self.id}.markers.push({{
time: {time if isinstance(time, float) else f"'{time}'"}, time: {time if isinstance(time, float) else f"'{time}'"},
position: '{_marker_position(position)}', position: '{marker_position(position)}',
color: '{color}', color: '{color}',
shape: '{_marker_shape(shape)}', shape: '{marker_shape(shape)}',
text: '{text}', text: '{text}',
id: '{marker_id}' 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, 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): 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 = ''): def price_line(self, label_visible: bool = True, line_visible: bool = True, title: str = ''):
self.run_script(f''' self.run_script(f'''
{self.id}.series.applyOptions({{ {self.id}.series.applyOptions({{
lastValueVisible: {_js_bool(label_visible)}, lastValueVisible: {jbool(label_visible)},
priceLineVisible: {_js_bool(line_visible)}, priceLineVisible: {jbool(line_visible)},
title: '{title}', title: '{title}',
}})''') }})''')
@ -193,6 +196,7 @@ class SeriesCommon:
:param precision: The number of decimal places. :param precision: The number of decimal places.
""" """
self.run_script(f''' self.run_script(f'''
{self.id}.precision = {precision}
{self.id}.series.applyOptions({{ {self.id}.series.applyOptions({{
priceFormat: {{precision: {precision}, minMove: {1 / (10 ** precision)}}} priceFormat: {{precision: {precision}, minMove: {1 / (10 ** precision)}}}
}})''') }})''')
@ -203,18 +207,23 @@ class SeriesCommon:
def _toggle_data(self, arg): def _toggle_data(self, arg):
self.run_script(f''' self.run_script(f'''
{self.id}.series.applyOptions({{visible: {_js_bool(arg)}}}) {self.id}.series.applyOptions({{visible: {jbool(arg)}}})
{f'{self.id}.volumeSeries.applyOptions({{visible: {_js_bool(arg)}}})' if hasattr(self, 'volume_enabled') and self.volume_enabled else ''} {f'{self.id}.volumeSeries.applyOptions({{visible: {jbool(arg)}}})' if hasattr(self, 'loaded') else ''}
''') ''')
class HorizontalLine: 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._chart = chart
self.id = self._chart._rand.generate() self.id = self._chart._rand.generate()
self.price = price
self._chart.run_script(f''' 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})') self._chart.run_script(f'if ("toolBox" in {self._chart.id}) {self._chart.id}.toolBox.drawings.push({self.id})')
def update(self, price): def update(self, price):
@ -222,6 +231,7 @@ class HorizontalLine:
Moves the horizontal line to the given price. Moves the horizontal line to the given price.
""" """
self._chart.run_script(f'{self.id}.updatePrice({price})') self._chart.run_script(f'{self.id}.updatePrice({price})')
self.price = price
def label(self, text: str): def label(self, text: str):
self._chart.run_script(f'{self.id}.updateLabel("{text}")') self._chart.run_script(f'{self.id}.updateLabel("{text}")')
@ -235,9 +245,9 @@ class HorizontalLine:
class Line(SeriesCommon): 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.color = color
self.name = '' self.name = name
self._chart = chart self._chart = chart
self._rand = chart._rand self._rand = chart._rand
self.id = self._rand.generate() self.id = self._rand.generate()
@ -246,21 +256,23 @@ class Line(SeriesCommon):
{self.id} = {{ {self.id} = {{
series: {self._chart.id}.chart.addLineSeries({{ series: {self._chart.id}.chart.addLineSeries({{
color: '{color}', color: '{color}',
lineStyle: {line_style(style)},
lineWidth: {width}, lineWidth: {width},
lastValueVisible: {_js_bool(price_label)}, lastValueVisible: {jbool(price_label)},
priceLineVisible: {_js_bool(price_line)}, priceLineVisible: {jbool(price_line)},
crosshairMarkerVisible: {_js_bool(crosshair_marker)}, crosshairMarkerVisible: {jbool(crosshair_marker)},
{"""autoscaleInfoProvider: () => ({ {"""autoscaleInfoProvider: () => ({
priceRange: { priceRange: {
minValue: 1_000_000_000, minValue: 1_000_000_000,
maxValue: 0, maxValue: 0,
}, },
}),""" if self._chart._scale_candles_only else ''} }),""" if self._chart._scale_candles_only else ''}
}}), }}),
markers: [], markers: [],
horizontal_lines: [], horizontal_lines: [],
name: '', name: '{name}',
color: '{color}', color: '{color}',
precision: 2,
}} }}
{self._chart.id}.lines.push({self.id}) {self._chart.id}.lines.push({self.id})
if ('legend' in {self._chart.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 Sets the line data.\n
:param data: If the name parameter is not used, the columns should be named: date/time, value. :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. :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: 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 return
df = self._df_datetime_format(data, exclude_lowercase=name) df = self._df_datetime_format(data, exclude_lowercase=self.name)
if name: if self.name:
if name not in data: if self.name not in data:
raise NameError(f'No column named "{name}".') raise NameError(f'No column named "{self.name}".')
self.name = name df = df.rename(columns={self.name: 'value'})
df = df.rename(columns={name: 'value'})
self._last_bar = df.iloc[-1] 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): 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 _set_trend(self, start_time, start_value, end_time, end_value, ray=False):
def time_format(time_val): 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 return f"'{time_val}'" if isinstance(time_val, str) else time_val
self.run_script(f''' self.run_script(f'''
let logical {self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: false}})
if ({_js_bool(ray)}) logical = {self._chart.id}.chart.timeScale().getVisibleLogicalRange()
{self.id}.series.setData(calculateTrendLine({time_format(start_time)}, {start_value}, {time_format(end_time)}, {end_value}, {self.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)})) {self._chart._interval.total_seconds()*1000}, {self._chart.id}, {jbool(ray)}))
if (logical) {self._chart.id}.chart.timeScale().setVisibleLogicalRange(logical) {self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: true}})
''') ''')
def delete(self): def delete(self):
@ -323,17 +334,24 @@ class Line(SeriesCommon):
class Widget: class Widget:
def __init__(self, topbar): def __init__(self, topbar, value, func=None):
self._chart = topbar._chart 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): class TextWidget(Widget):
def __init__(self, topbar, initial_text): def __init__(self, topbar, initial_text):
super().__init__(topbar) super().__init__(topbar, value=initial_text)
self.value = initial_text self._chart.run_script(f'{self.id} = {topbar.id}.makeTextBoxWidget("{initial_text}")')
self.id = self._chart._rand.generate()
self._chart.run_script(f'''{self.id} = makeTextBoxWidget({self._chart.id}, "{initial_text}")''')
def set(self, string): def set(self, string):
self.value = string self.value = string
@ -341,42 +359,75 @@ class TextWidget(Widget):
class SwitcherWidget(Widget): class SwitcherWidget(Widget):
def __init__(self, topbar, method, *options, default): def __init__(self, topbar, options, default, func):
super().__init__(topbar) super().__init__(topbar, value=default, func=func)
self.value = default
self._method = str(method)
self._chart.run_script(f''' self._chart.run_script(f'''
makeSwitcher({self._chart.id}, {list(options)}, '{default}', '{self._method}', {self.id} = {topbar.id}.makeSwitcher({list(options)}, '{default}', '{self.id}')
'{topbar.active_background_color}', '{topbar.active_text_color}', '{topbar.text_color}', '{topbar.hover_color}') reSize({self._chart.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: class TopBar:
def __init__(self, chart): def __init__(self, chart):
self._chart = chart self._chart = chart
self.id = chart._rand.generate()
self._widgets: Dict[str, Widget] = {} 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''' self._chart.run_script(f'''
makeTopBar({self._chart.id}) {self.id} = new TopBar({self._chart.id}, '{self.hover_bg_color}', '{self.click_bg_color}',
reSize({self._chart.id}) '{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): def get(self, widget_name): return self._widgets.get(widget_name)
self._chart._methods[str(method)] = method
self._widgets[name] = SwitcherWidget(self, method, *options, default=default if default else options[0])
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): def switcher(self, name, options: tuple, default: str = None, func: callable = None):
for widget in self._widgets.values(): self._create()
if widget._method == method_name: self._widgets[name] = SwitcherWidget(self, options, default if default else options[0], func)
return widget
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: class ToolBox:
@ -385,7 +436,8 @@ class ToolBox:
self.id = chart.id self.id = chart.id
self._return_q = chart._return_q self._return_q = chart._return_q
self._save_under = None 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): 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. 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 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): def import_drawings(self, file_path):
""" """
@ -407,47 +459,48 @@ class ToolBox:
""" """
with open(file_path, 'r') as f: with open(file_path, 'r') as f:
json_data = json.load(f) json_data = json.load(f)
self._saved_drawings = json_data self.drawings = json_data
def export_drawings(self, file_path): def export_drawings(self, file_path):
""" """
Exports the current list of drawings to the given file path. Exports the current list of drawings to the given file path.
""" """
with open(file_path, 'w+') as f: 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): def _save_drawings(self, drawings):
if not self._save_under: if not self._save_under:
return return
self._saved_drawings[self._save_under.value] = json.loads(drawings) self.drawings[self._save_under.value] = json.loads(drawings)
class LWC(SeriesCommon): class LWC(SeriesCommon):
def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False, def __init__(self, inner_width: float = 1.0, inner_height: float = 1.0,
scale_candles_only: bool = False, topbar: bool = False, searchbox: bool = False, toolbox: bool = False, scale_candles_only: bool = False, toolbox: bool = False, _js_api_code: str = None,
_js_api_code: str = None, autosize=True, _run_script=None): autosize: bool = True, _run_script=None):
self.volume_enabled = volume_enabled 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._scale_candles_only = scale_candles_only
self._inner_width = inner_width self._inner_width = inner_width
self._inner_height = inner_height self._inner_height = inner_height
self._dynamic_loading = dynamic_loading
if _run_script:
self.run_script = _run_script
self._rand = IDGen() self._rand = IDGen()
self.id = self._rand.generate() self.id = self._rand.generate()
self._position = 'left' self._position = 'left'
self.loaded = False
self._html = HTML self._html = HTML
self._scripts = []
self._final_scripts = []
self._script_func = None self._script_func = None
self.candle_data = pd.DataFrame()
self._last_bar = None self._last_bar = None
self._interval = None self._interval = None
self._charts = {self.id: self}
self._lines = [] self._lines = []
self.run_script(f'window.callbackFunction = {_js_api_code}') if _js_api_code else None self.events: Events = Events(self)
self._methods = {} self._handlers = {}
self._return_q = None self._return_q = None
self._callbacks_enabled = False
self.topbar: TopBar = TopBar(self)
self._background_color = '#000000' self._background_color = '#000000'
self._volume_up_color = 'rgba(83,141,131,0.8)' self._volume_up_color = 'rgba(83,141,131,0.8)'
@ -457,7 +510,7 @@ class LWC(SeriesCommon):
self.polygon: PolygonAPI = PolygonAPI(self) self.polygon: PolygonAPI = PolygonAPI(self)
self.run_script(f''' 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}.id = '{self.id}'
{self.id}.wrapper.style.float = "{self._position}" {self.id}.wrapper.style.float = "{self._position}"
''') ''')
@ -465,12 +518,6 @@ class LWC(SeriesCommon):
self.run_script(JS['toolbox']) self.run_script(JS['toolbox'])
self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})') self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})')
self.toolbox: ToolBox = ToolBox(self) 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): def _on_js_load(self):
if self.loaded: 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._scripts]
[self.run_script(script) for script in self._final_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. For advanced users; evaluates JavaScript within the Webview.
""" """
@ -498,14 +545,13 @@ class LWC(SeriesCommon):
if df.empty or df is None: if df.empty or df is None:
self.run_script(f'{self.id}.series.setData([])') self.run_script(f'{self.id}.series.setData([])')
self.run_script(f'{self.id}.volumeSeries.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 return
bars = self._df_datetime_format(df) bars = self._df_datetime_format(df)
self.candle_data = bars.copy()
self._last_bar = bars.iloc[-1] 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 = bars.drop(columns=['open', 'high', 'low', 'close']).rename(columns={'volume': 'value'})
volume['color'] = self._volume_down_color volume['color'] = self._volume_down_color
volume.loc[bars['close'] > bars['open'], 'color'] = self._volume_up_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.drop(columns=['volume'])
bars = bars.to_dict(orient='records') bars = bars.to_dict(orient='records')
self.run_script(f''' self.run_script(f'{self.id}.candleData = {bars}; {self.id}.series.setData({self.id}.candleData)')
{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"if ('toolBox' in {self.id}) {self.id}.toolBox.{'clearDrawings' if not render_drawings else 'renderDrawings'}()") 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): def fit(self):
""" """
Fits the maximum amount of the chart data within the viewport. Fits the maximum amount of the chart data within the viewport.
""" """
self.run_script(f'{self.id}.chart.timeScale().fitContent()') 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; Updates the data from a bar;
if series['time'] is the same time as the last bar, the last bar will be overwritten.\n 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). :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 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 = 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 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()})') self.run_script(f'{self.id}.volumeSeries.update({volume.to_dict()})')
series = series.drop(['volume']) series = series.drop(['volume'])
bar = series.to_dict() bar = series.to_dict()
self.run_script(f''' 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()) {{ 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} {self.id}.candleData[{self.id}.candleData.length-1] = {bar}
}} }}
@ -605,7 +601,7 @@ class LWC(SeriesCommon):
{self.id}.series.update({bar}) {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 Updates the data from a tick.\n
:param series: labels: date/time, price, volume (if volume enabled). :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['high'] = max(self._last_bar['high'], series['price'])
bar['low'] = min(self._last_bar['low'], series['price']) bar['low'] = min(self._last_bar['low'], series['price'])
bar['close'] = series['price'] bar['close'] = series['price']
if self.volume_enabled: if 'volume' in series:
if 'volume' not in series: if cumulative_volume:
raise MissingColumn("Volume enabled, but 'volume' column was not found.")
elif cumulative_volume:
bar['volume'] += series['volume'] bar['volume'] += series['volume']
else: else:
bar['volume'] = series['volume'] bar['volume'] = series['volume']
@ -629,15 +623,16 @@ class LWC(SeriesCommon):
for key in ('open', 'high', 'low', 'close'): for key in ('open', 'high', 'low', 'close'):
bar[key] = series['price'] bar[key] = series['price']
bar['time'] = series['time'] bar['time'] = series['time']
bar['volume'] = 0 if 'volume' in series:
self.update(bar, from_tick=True) 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: price_line: bool = True, price_label: bool = True) -> Line:
""" """
Creates and returns a Line object.)\n 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] return self._lines[-1]
def lines(self) -> List[Line]: def lines(self) -> List[Line]:
@ -647,12 +642,12 @@ class LWC(SeriesCommon):
return self._lines.copy() return self._lines.copy()
def trend_line(self, start_time, start_value, end_time, end_value, color: str = '#1E80F0', width: int = 2) -> Line: 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) line._set_trend(start_time, start_value, end_time, end_value, ray=False)
return line return line
def ray_line(self, start_time, value, color: str = '#1E80F0', width: int = 2) -> 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) line._set_trend(start_time, value, start_time, value, ray=True)
return line 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): ticks_visible: bool = False, scale_margin_top: float = 0.2, scale_margin_bottom: float = 0.2):
self.run_script(f''' self.run_script(f'''
{self.id}.series.priceScale().applyOptions({{ {self.id}.series.priceScale().applyOptions({{
mode: {_price_scale_mode(mode)}, mode: {price_scale_mode(mode)},
alignLabels: {_js_bool(align_labels)}, alignLabels: {jbool(align_labels)},
borderVisible: {_js_bool(border_visible)}, borderVisible: {jbool(border_visible)},
{f'borderColor: "{border_color}",' if border_color else ''} {f'borderColor: "{border_color}",' if border_color else ''}
{f'textColor: "{text_color}",' if text_color else ''} {f'textColor: "{text_color}",' if text_color else ''}
entireTextOnly: {_js_bool(entire_text_only)}, entireTextOnly: {jbool(entire_text_only)},
ticksVisible: {_js_bool(ticks_visible)}, ticksVisible: {jbool(ticks_visible)},
scaleMargins: {{top: {scale_margin_top}, bottom: {scale_margin_bottom}}} scaleMargins: {{top: {scale_margin_top}, bottom: {scale_margin_bottom}}}
}})''') }})''')
@ -682,10 +677,10 @@ class LWC(SeriesCommon):
timeScale: {{ timeScale: {{
rightOffset: {right_offset}, rightOffset: {right_offset},
minBarSpacing: {min_bar_spacing}, minBarSpacing: {min_bar_spacing},
visible: {_js_bool(visible)}, visible: {jbool(visible)},
timeVisible: {_js_bool(time_visible)}, timeVisible: {jbool(time_visible)},
secondsVisible: {_js_bool(seconds_visible)}, secondsVisible: {jbool(seconds_visible)},
borderVisible: {_js_bool(border_visible)}, borderVisible: {jbool(border_visible)},
{f'borderColor: "{border_color}",' if border_color else ''} {f'borderColor: "{border_color}",' if border_color else ''}
}} }}
}})''') }})''')
@ -716,14 +711,14 @@ class LWC(SeriesCommon):
{self.id}.chart.applyOptions({{ {self.id}.chart.applyOptions({{
grid: {{ grid: {{
vertLines: {{ vertLines: {{
visible: {_js_bool(vert_enabled)}, visible: {jbool(vert_enabled)},
color: "{color}", color: "{color}",
style: {_line_style(style)}, style: {line_style(style)},
}}, }},
horzLines: {{ horzLines: {{
visible: {_js_bool(horz_enabled)}, visible: {jbool(horz_enabled)},
color: "{color}", 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 = '', wick_enabled: bool = True, border_enabled: bool = True, border_up_color: str = '',
border_down_color: str = '', wick_up_color: str = '', wick_down_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.run_script(f"""
{self.id}.series.applyOptions({{ {self.id}.series.applyOptions({{
upColor: "{up_color}", upColor: "{up_color}",
downColor: "{down_color}", downColor: "{down_color}",
wickVisible: {_js_bool(wick_enabled)}, wickVisible: {jbool(wick_enabled)},
borderVisible: {_js_bool(border_enabled)}, borderVisible: {jbool(border_enabled)},
{f'borderUpColor: "{border_up_color}",' if border_up_color else up_color if border_enabled else ''} {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'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'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 wick_down_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, 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.run_script(f'''
{self.id}.chart.applyOptions({{ {self.id}.chart.applyOptions({{
crosshair: {{ crosshair: {{
mode: {_crosshair_mode(mode)}, mode: {crosshair_mode(mode)},
vertLine: {{ vertLine: {{
visible: {_js_bool(vert_visible)}, visible: {jbool(vert_visible)},
width: {vert_width}, width: {vert_width},
{f'color: "{vert_color}",' if vert_color else ''} {f'color: "{vert_color}",' if vert_color else ''}
style: {_line_style(vert_style)}, style: {line_style(vert_style)},
labelBackgroundColor: "{vert_label_background_color}" labelBackgroundColor: "{vert_label_background_color}"
}}, }},
horzLine: {{ horzLine: {{
visible: {_js_bool(horz_visible)}, visible: {jbool(horz_visible)},
width: {horz_width}, width: {horz_width},
{f'color: "{horz_color}",' if horz_color else ''} {f'color: "{horz_color}",' if horz_color else ''}
style: {_line_style(horz_style)}, style: {line_style(horz_style)},
labelBackgroundColor: "{horz_label_background_color}" labelBackgroundColor: "{horz_label_background_color}"
}} }}
}}}})''') }}}})''')
@ -817,7 +813,7 @@ class LWC(SeriesCommon):
if not visible: if not visible:
return return
self.run_script(f''' 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'}'") 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() serial_data = self._return_q.get()
return b64decode(serial_data.split(',')[1]) return b64decode(serial_data.split(',')[1])
def add_hotkey(self, modifier_key: Literal['ctrl', 'alt', 'shift', 'meta'], keys: Union[str, tuple, int], method): def hotkey(self, modifier_key: Literal['ctrl', 'alt', 'shift', 'meta'], keys: Union[str, tuple, int], func: callable):
self._methods[str(method)] = method
if not isinstance(keys, tuple): keys = (keys,) if not isinstance(keys, tuple): keys = (keys,)
for key in keys: for key in keys:
key_code = 'Key' + key.upper() if isinstance(key, str) else 'Digit' + str(key) key_code = 'Key' + key.upper() if isinstance(key, str) else 'Digit' + str(key)
self.run_script(f''' self.run_script(f'''
{self.id}.commandFunctions.unshift((event) => {{ {self.id}.commandFunctions.unshift((event) => {{
if (event.{modifier_key + 'Key'} && event.code === '{key_code}') {{ if (event.{modifier_key + 'Key'} && event.code === '{key_code}') {{
event.preventDefault() event.preventDefault()
window.callbackFunction(`{str(method)}_~_{self.id}_~_{key}`) window.callbackFunction(`{modifier_key, keys}_~_{key}`)
return true return true
}} }}
else return false 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, 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): position: str = 'left', draggable: bool = False, func: callable = None) -> Table:
self._methods[str(method)] = method return Table(self, width, height, headings, widths, alignments, position, draggable, func)
return Table(self, width, height, headings, widths, alignments, position, draggable, method)
def create_subchart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', def create_subchart(self, position: Literal['left', 'right', 'top', 'bottom'] = 'left', width: float = 0.5, height: float = 0.5,
width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False, dynamic_loading: bool = False, sync: Union[bool, str] = False, scale_candles_only: bool = False, toolbox: bool = False):
scale_candles_only: bool = False, topbar: bool = False, searchbox: bool = False, toolbox: bool = False): return SubChart(self, position, width, height, sync, scale_candles_only, toolbox)
subchart = SubChart(self, volume_enabled, position, width, height, sync, dynamic_loading, scale_candles_only, topbar, searchbox, toolbox)
self._charts[subchart.id] = subchart
return subchart
class SubChart(LWC): 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 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._parent = parent
self._position = position self._position = position
self._return_q = self._chart._return_q 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) self.polygon = self._chart.polygon._subchart(self)
if not sync: if not sync:

View File

@ -14,10 +14,8 @@ class CallbackAPI:
self.emit_q, self.return_q = emit_queue, return_queue self.emit_q, self.return_q = emit_queue, return_queue
def callback(self, message: str): def callback(self, message: str):
messages = message.split('_~_') name, args = message.split('_~_')
name, chart_id = messages[:2] self.return_q.put(*args) if name == 'return' else self.emit_q.put((name, args.split(';;;')))
args = messages[2:]
self.return_q.put(*args) if name == 'return' else self.emit_q.put((name, chart_id, *args))
class PyWV: class PyWV:
@ -60,22 +58,21 @@ class PyWV:
class Chart(LWC): class Chart(LWC):
def __init__(self, volume_enabled: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None, 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, on_top: bool = False, maximize: bool = False, debug: bool = False, toolbox: bool = False,
api: object = None, topbar: bool = False, searchbox: bool = False, toolbox: bool = False, inner_width: float = 1.0, inner_height: float = 1.0, scale_candles_only: bool = False):
inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False, scale_candles_only: bool = False): super().__init__(inner_width, inner_height, scale_candles_only, toolbox, 'pywebview.api.callback')
super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading, scale_candles_only, topbar, searchbox, toolbox, 'pywebview.api.callback')
global chart, num_charts global chart, num_charts
if chart: if chart:
self._q, self._exit, self._start, self._process = chart._q, chart._exit, chart._start, chart._process 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() self._emit_q, self._return_q = mp.Queue(), mp.Queue()
chart._charts[self.id] = self for key, val in self._handlers.items():
self._api = chart._api chart._handlers[key] = val
self._handlers = chart._handlers
self._loaded = chart._loaded_list[num_charts] self._loaded = chart._loaded_list[num_charts]
self._q.put(('create_window', (self._html, on_top, width, height, x, y))) self._q.put(('create_window', (self._html, on_top, width, height, x, y)))
else: else:
self._api = api
self._q, self._emit_q, self._return_q = (mp.Queue() for _ in range(3)) 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_list = [mp.Event() for _ in range(10)]
self._loaded = self._loaded_list[0] self._loaded = self._loaded_list[0]
@ -117,20 +114,9 @@ class Chart(LWC):
self._exit.clear() self._exit.clear()
return return
elif not self._emit_q.empty(): elif not self._emit_q.empty():
name, chart_id, arg = self._emit_q.get() name, args = self._emit_q.get()
if self._api: func = self._handlers[name]
self._api.chart = self._charts[chart_id] await func(*args) if asyncio.iscoroutinefunction(func) else func(*args)
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(';;;'))
continue continue
value = self.polygon._q.get() value = self.polygon._q.get()
func, args = value[0], value[1:] func, args = value[0], value[1:]
@ -148,12 +134,10 @@ class Chart(LWC):
""" """
Exits and destroys the chart window.\n Exits and destroys the chart window.\n
""" """
if not self.loaded: global num_charts, chart
global num_charts, chart chart = None
chart = None num_charts = 0
num_charts = 0 self._q.put((self.i, 'exit'))
else: self._exit.wait()
self._q.put((self.i, 'exit'))
self._exit.wait()
self._process.terminate() self._process.terminate()
del self del self

View File

@ -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) { function makeSearchBox(chart) {
let searchWindow = document.createElement('div') let searchWindow = document.createElement('div')
searchWindow.style.position = 'absolute' searchWindow.style.position = 'absolute'
@ -37,19 +157,13 @@ function makeSearchBox(chart) {
let yPrice = null let yPrice = null
chart.chart.subscribeCrosshairMove((param) => { chart.chart.subscribeCrosshairMove((param) => {
if (param.point){ if (param.point) yPrice = param.point.y;
yPrice = param.point.y; })
}
});
let selectedChart = false let selectedChart = false
chart.wrapper.addEventListener('mouseover', (event) => { chart.wrapper.addEventListener('mouseover', (event) => selectedChart = true)
selectedChart = true chart.wrapper.addEventListener('mouseout', (event) => selectedChart = false)
})
chart.wrapper.addEventListener('mouseout', (event) => {
selectedChart = false
})
chart.commandFunctions.push((event) => { chart.commandFunctions.push((event) => {
if (!selectedChart) return if (!selectedChart) return false
if (searchWindow.style.display === 'none') { if (searchWindow.style.display === 'none') {
if (/^[a-zA-Z0-9]$/.test(event.key)) { if (/^[a-zA-Z0-9]$/.test(event.key)) {
searchWindow.style.display = 'flex'; searchWindow.style.display = 'flex';
@ -58,22 +172,15 @@ function makeSearchBox(chart) {
} }
else return false else return false
} }
else if (event.key === 'Enter') { else if (event.key === 'Enter' || event.key === 'Escape') {
window.callbackFunction(`on_search_~_${chart.id}_~_${sBox.value}`) if (event.key === 'Enter') window.callbackFunction(`search${chart.id}_~_${sBox.value}`)
searchWindow.style.display = 'none'
sBox.value = ''
return true
}
else if (event.key === 'Escape') {
searchWindow.style.display = 'none' searchWindow.style.display = 'none'
sBox.value = '' sBox.value = ''
return true return true
} }
else return false else return false
}) })
sBox.addEventListener('input', function() { sBox.addEventListener('input', () => sBox.value = sBox.value.toUpperCase())
sBox.value = sBox.value.toUpperCase();
});
return { return {
window: searchWindow, window: searchWindow,
box: sBox, box: sBox,
@ -104,77 +211,4 @@ function makeSpinner(chart) {
animateSpinner(); 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)
}

View File

@ -10,7 +10,8 @@ function makeChart(innerWidth, innerHeight, autoSize=true) {
height: innerHeight, height: innerHeight,
}, },
candleData: [], candleData: [],
commandFunctions: [] commandFunctions: [],
precision: 2,
} }
chart.chart = LightweightCharts.createChart(chart.div, { chart.chart = LightweightCharts.createChart(chart.div, {
width: window.innerWidth*innerWidth, width: window.innerWidth*innerWidth,
@ -125,6 +126,12 @@ if (!window.HorizontalLine) {
this.line = this.chart.series.createPriceLine(this.priceLine) 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() { deleteLine() {
this.chart.series.removePriceLine(this.line) this.chart.series.removePriceLine(this.line)
this.chart.horizontal_lines.splice(this.chart.horizontal_lines.indexOf(this)) this.chart.horizontal_lines.splice(this.chart.horizontal_lines.indexOf(this))
@ -135,16 +142,17 @@ if (!window.HorizontalLine) {
window.HorizontalLine = HorizontalLine window.HorizontalLine = HorizontalLine
class Legend { class Legend {
constructor(chart, ohlcEnabled = true, percentEnabled = true, linesEnabled = true, constructor(chart, ohlcEnabled, percentEnabled, linesEnabled,
color = 'rgb(191, 195, 203)', fontSize = '11', fontFamily = 'Monaco') { color = 'rgb(191, 195, 203)', fontSize = '11', fontFamily = 'Monaco') {
this.div = document.createElement('div') this.div = document.createElement('div')
this.div.style.position = 'absolute' this.div.style.position = 'absolute'
this.div.style.zIndex = '3000' this.div.style.zIndex = '3000'
this.div.style.pointerEvents = 'none'
this.div.style.top = '10px' this.div.style.top = '10px'
this.div.style.left = '10px' this.div.style.left = '10px'
this.div.style.display = 'flex' this.div.style.display = 'flex'
this.div.style.flexDirection = 'column' 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.color = color
this.div.style.fontSize = fontSize + 'px' this.div.style.fontSize = fontSize + 'px'
this.div.style.fontFamily = fontFamily this.div.style.fontFamily = fontFamily
@ -158,7 +166,16 @@ if (!window.HorizontalLine) {
this.linesEnabled = linesEnabled this.linesEnabled = linesEnabled
this.makeLines(chart) 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) => { chart.chart.subscribeCrosshairMove((param) => {
if (param.time) { if (param.time) {
@ -166,22 +183,23 @@ if (!window.HorizontalLine) {
let finalString = '<span style="line-height: 1.8;">' let finalString = '<span style="line-height: 1.8;">'
if (data) { if (data) {
this.candle.style.color = '' this.candle.style.color = ''
let ohlc = `O ${legendItemFormat(data.open)} let ohlc = `O ${legendItemFormat(data.open, chart.precision)}
| H ${legendItemFormat(data.high)} | H ${legendItemFormat(data.high, chart.precision)}
| L ${legendItemFormat(data.low)} | L ${legendItemFormat(data.low, chart.precision)}
| C ${legendItemFormat(data.close)} ` | C ${legendItemFormat(data.close, chart.precision)} `
let percentMove = ((data.close - data.open) / data.open) * 100 let percentMove = ((data.close - data.open) / data.open) * 100
let percent = `| ${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %` let percent = `| ${percentMove >= 0 ? '+' : ''}${percentMove.toFixed(2)} %`
finalString += ohlcEnabled ? ohlc : '' finalString += ohlcEnabled ? ohlc : ''
finalString += percentEnabled ? percent : '' finalString += percentEnabled ? percent : ''
let volumeData = param.seriesData.get(chart.volumeSeries)
if (volumeData) finalString += ohlcEnabled ? `<br>V ${shorthandFormat(volumeData.value)}` : ''
} }
this.candle.innerHTML = finalString + '</span>' this.candle.innerHTML = finalString + '</span>'
this.lines.forEach((line) => { this.lines.forEach((line) => {
if (!param.seriesData.get(line.line.series)) return if (!param.seriesData.get(line.line.series)) return
let price = legendItemFormat(param.seriesData.get(line.line.series).value) let price = legendItemFormat(param.seriesData.get(line.line.series).value, line.line.precision)
line.div.innerHTML = `<span style="color: ${line.line.color};">▨</span> ${line.line.name} : ${price}` line.div.innerHTML = `<span style="color: ${line.solid};">▨</span> ${line.line.name} : ${price}`
}) })
} else { } else {
@ -215,6 +233,7 @@ if (!window.HorizontalLine) {
let toggle = document.createElement('div') let toggle = document.createElement('div')
toggle.style.borderRadius = '4px' toggle.style.borderRadius = '4px'
toggle.style.marginLeft = '10px' toggle.style.marginLeft = '10px'
toggle.style.pointerEvents = 'auto'
let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); let svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
@ -258,6 +277,7 @@ if (!window.HorizontalLine) {
row: row, row: row,
toggle: toggle, toggle: toggle,
line: line, 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.position = 'absolute'
this.menu.style.zIndex = '10000' this.menu.style.zIndex = '10000'
this.menu.style.background = 'rgb(50, 50, 50)' 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.display = 'none'
this.menu.style.borderRadius = '5px' this.menu.style.borderRadius = '5px'
this.menu.style.padding = '3px 3px' this.menu.style.padding = '3px 3px'
this.menu.style.fontSize = '14px' this.menu.style.fontSize = '13px'
this.menu.style.cursor = 'default' this.menu.style.cursor = 'default'
document.body.appendChild(this.menu) document.body.appendChild(this.menu)
this.hoverItem = null
let closeMenu = (event) => { 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) => { this.onRightClick = (event) => {
@ -406,16 +430,44 @@ if (!window.ContextMenu) {
listen(active) { listen(active) {
active ? document.addEventListener('contextmenu', this.onRightClick) : document.removeEventListener('contextmenu', this.onRightClick) 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') let elem = document.createElement('div')
elem.innerText = text elem.innerText = text
elem.style.padding = '0px 10px' item.appendChild(elem)
elem.style.borderRadius = '3px'
this.menu.appendChild(elem) if (hover) {
elem.addEventListener('mouseover', (event) => elem.style.backgroundColor = 'rgba(0, 122, 255, 0.3)') let arrow = document.createElement('div')
elem.addEventListener('mouseout', (event) => elem.style.backgroundColor = 'transparent') arrow.innerHTML = `<svg width="15px" height="10px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.82054 20.7313C8.21107 21.1218 8.84423 21.1218 9.23476 20.7313L15.8792 14.0868C17.0505 12.9155 17.0508 11.0167 15.88 9.84497L9.3097 3.26958C8.91918 2.87905 8.28601 2.87905 7.89549 3.26958C7.50497 3.6601 7.50497 4.29327 7.89549 4.68379L14.4675 11.2558C14.8581 11.6464 14.8581 12.2795 14.4675 12.67L7.82054 19.317C7.43002 19.7076 7.43002 20.3407 7.82054 20.7313Z" fill="#fff"/></svg>`
elem.addEventListener('click', (event) => {action(); this.menu.style.display = 'none'}) 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 window.ContextMenu = ContextMenu
} }

View File

@ -1,8 +1,8 @@
if (!window.Table) { if (!window.Table) {
class 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.container = document.createElement('div')
this.pythonMethod = pythonMethod this.callbackName = null
this.chart = chart this.chart = chart
if (draggable) { if (draggable) {
@ -15,12 +15,12 @@ if (!window.Table) {
this.container.style.zIndex = '2000' this.container.style.zIndex = '2000'
this.container.style.width = width <= 1 ? width * 100 + '%' : width + 'px' 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.display = 'flex'
this.container.style.flexDirection = 'column' this.container.style.flexDirection = 'column'
this.container.style.justifyContent = 'space-between' 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.borderRadius = '5px'
this.container.style.color = 'white' this.container.style.color = 'white'
this.container.style.fontSize = '12px' this.container.style.fontSize = '12px'
@ -29,7 +29,8 @@ if (!window.Table) {
this.table = document.createElement('table') this.table = document.createElement('table')
this.table.style.width = '100%' this.table.style.width = '100%'
this.table.style.borderCollapse = 'collapse' this.table.style.borderCollapse = 'collapse'
this.table.style.border = '1px solid rgb(70, 70, 70)'; this.container.style.overflow = 'hidden'
this.rows = {} this.rows = {}
this.headings = headings this.headings = headings
@ -43,6 +44,9 @@ if (!window.Table) {
let th = document.createElement('th') let th = document.createElement('th')
th.textContent = this.headings[i] th.textContent = this.headings[i]
th.style.width = this.widths[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' th.style.textAlign = 'center'
row.appendChild(th) row.appendChild(th)
th.style.border = '1px solid rgb(70, 70, 70)' 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('mouseover', () => row.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
row.addEventListener('mouseout', () => row.style.backgroundColor = 'transparent') row.addEventListener('mouseout', () => row.style.backgroundColor = 'transparent')
row.addEventListener('mousedown', () => { row.addEventListener('mousedown', () => row.style.backgroundColor = 'rgba(60, 60, 60)')
row.style.backgroundColor = 'rgba(60, 60, 60)'
window.callbackFunction(`${this.pythonMethod}_~_${this.chart.id}_~_${id}`) row.addEventListener('click', () => window.callbackFunction(`${this.callbackName}_~_${id}`))
})
row.addEventListener('mouseup', () => row.style.backgroundColor = 'rgba(60, 60, 60, 0.6)') row.addEventListener('mouseup', () => row.style.backgroundColor = 'rgba(60, 60, 60, 0.6)')
this.rows[id] = row this.rows[id] = row
@ -134,6 +137,11 @@ if (!window.Table) {
this.footer[i].style.textAlign = 'center' this.footer[i].style.textAlign = 'center'
} }
} }
toJSON() {
// Exclude the chart attribute from serialization
const {chart, ...serialized} = this;
return serialized;
}
} }
window.Table = Table window.Table = Table
} }

View File

@ -4,6 +4,7 @@ if (!window.ToolBox) {
this.onTrendSelect = this.onTrendSelect.bind(this) this.onTrendSelect = this.onTrendSelect.bind(this)
this.onHorzSelect = this.onHorzSelect.bind(this) this.onHorzSelect = this.onHorzSelect.bind(this)
this.onRaySelect = this.onRaySelect.bind(this) this.onRaySelect = this.onRaySelect.bind(this)
this.saveDrawings = this.saveDrawings.bind(this)
this.chart = chart this.chart = chart
this.drawings = [] this.drawings = []
@ -15,7 +16,8 @@ if (!window.ToolBox) {
this.activeIconColor = 'rgb(240, 240, 240)' this.activeIconColor = 'rgb(240, 240, 240)'
this.iconColor = 'lightgrey' this.iconColor = 'lightgrey'
this.backgroundColor = 'transparent' 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.elem = this.makeToolBox()
this.subscribeHoverMove() this.subscribeHoverMove()
@ -92,11 +94,15 @@ if (!window.ToolBox) {
icon.elem.addEventListener('mouseenter', () => { icon.elem.addEventListener('mouseenter', () => {
icon.elem.style.backgroundColor = icon === this.chart.activeIcon ? this.activeBackgroundColor : this.hoverColor icon.elem.style.backgroundColor = icon === this.chart.activeIcon ? this.activeBackgroundColor : this.hoverColor
document.body.style.cursor = 'pointer'
}) })
icon.elem.addEventListener('mouseleave', () => { icon.elem.addEventListener('mouseleave', () => {
icon.elem.style.backgroundColor = icon === this.chart.activeIcon ? this.activeBackgroundColor : this.backgroundColor 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', () => { icon.elem.addEventListener('click', () => {
if (this.chart.activeIcon) { if (this.chart.activeIcon) {
@ -118,6 +124,7 @@ if (!window.ToolBox) {
}) })
this.chart.commandFunctions.push((event) => { this.chart.commandFunctions.push((event) => {
if (event.altKey && event.code === keyCmd) { if (event.altKey && event.code === keyCmd) {
event.preventDefault()
if (this.chart.activeIcon) { if (this.chart.activeIcon) {
this.chart.activeIcon.elem.style.backgroundColor = this.backgroundColor this.chart.activeIcon.elem.style.backgroundColor = this.backgroundColor
group.setAttribute("fill", this.iconColor) group.setAttribute("fill", this.iconColor)
@ -140,6 +147,7 @@ if (!window.ToolBox) {
onTrendSelect(toggle, ray = false) { onTrendSelect(toggle, ray = false) {
let trendLine = { let trendLine = {
line: null, line: null,
color: 'rgb(15, 139, 237)',
markers: null, markers: null,
data: null, data: null,
from: null, from: null,
@ -160,17 +168,14 @@ if (!window.ToolBox) {
if (!this.makingDrawing) return 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 let lastCandleTime = this.chart.candleData[this.chart.candleData.length - 1].time
currentTime = this.chart.chart.timeScale().coordinateToTime(param.point.x) currentTime = this.chart.chart.timeScale().coordinateToTime(param.point.x)
if (!currentTime) { if (!currentTime) {
let barsToMove = param.logical - this.chart.chart.timeScale().coordinateToLogical(this.chart.chart.timeScale().timeToCoordinate(lastCandleTime)) 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) 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) 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.from = [data[0].time, data[0].value]
trendLine.to = [data[data.length - 1].time, data[data.length-1].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) trendLine.line.setData(data)
if (logical) { this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true})
this.chart.chart.applyOptions({handleScroll: false}) this.chart.chart.timeScale().setVisibleLogicalRange(logical)
setTimeout(() => {
this.chart.chart.timeScale().setVisibleLogicalRange(logical)
}, 1)
setTimeout(() => {
this.chart.chart.applyOptions({handleScroll: true})
}, 50)
}
if (!ray) { if (!ray) {
trendLine.markers = [ trendLine.markers = [
{time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}, {time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1},
@ -208,6 +205,7 @@ if (!window.ToolBox) {
if (!this.makingDrawing) { if (!this.makingDrawing) {
this.makingDrawing = true this.makingDrawing = true
trendLine.line = this.chart.chart.addLineSeries({ trendLine.line = this.chart.chart.addLineSeries({
color: 'rgb(15, 139, 237)',
lineWidth: 2, lineWidth: 2,
lastValueVisible: false, lastValueVisible: false,
priceLineVisible: false, priceLineVisible: false,
@ -221,14 +219,11 @@ if (!window.ToolBox) {
}) })
firstPrice = this.chart.series.coordinateToPrice(param.point.y) 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 firstTime = !ray ? this.chart.chart.timeScale().coordinateToTime(param.point.x) : this.chart.candleData[this.chart.candleData.length - 1].time
this.chart.chart.applyOptions({ this.chart.chart.applyOptions({handleScroll: false})
handleScroll: false
})
this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend)
} else { }
this.chart.chart.applyOptions({ else {
handleScroll: true this.chart.chart.applyOptions({handleScroll: true})
})
this.makingDrawing = false this.makingDrawing = false
trendLine.line.setMarkers([]) trendLine.line.setMarkers([])
this.drawings.push(trendLine) this.drawings.push(trendLine)
@ -247,7 +242,7 @@ if (!window.ToolBox) {
clickHandlerHorz = (param) => { clickHandlerHorz = (param) => {
let price = this.chart.series.coordinateToPrice(param.point.y) let price = this.chart.series.coordinateToPrice(param.point.y)
let lineStyle = LightweightCharts.LineStyle.Solid 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.drawings.push(line)
this.chart.chart.unsubscribeClick(this.clickHandlerHorz) this.chart.chart.unsubscribeClick(this.clickHandlerHorz)
document.body.style.cursor = 'default' document.body.style.cursor = 'default'
@ -267,9 +262,16 @@ if (!window.ToolBox) {
subscribeHoverMove() { subscribeHoverMove() {
let hoveringOver = null let hoveringOver = null
let x, y let x, y
let colorPicker = new ColorPicker(this.saveDrawings)
let onClickDelete = () => this.deleteDrawing(contextMenu.drawing) let onClickDelete = () => this.deleteDrawing(contextMenu.drawing)
let onClickColor = (rect) => colorPicker.openMenu(rect, contextMenu.drawing)
let contextMenu = new ContextMenu() 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) contextMenu.menuItem('Delete Drawing', onClickDelete)
let hoverOver = (param) => { let hoverOver = (param) => {
@ -325,9 +327,7 @@ if (!window.ToolBox) {
let checkForClick = (event) => { let checkForClick = (event) => {
mouseDown = true mouseDown = true
document.body.style.cursor = 'grabbing' document.body.style.cursor = 'grabbing'
this.chart.chart.applyOptions({ this.chart.chart.applyOptions({handleScroll: false})
handleScroll: false
})
this.chart.chart.unsubscribeCrosshairMove(hoverOver) this.chart.chart.unsubscribeCrosshairMove(hoverOver)
@ -354,7 +354,7 @@ if (!window.ToolBox) {
this.chart.chart.applyOptions({handleScroll: true}) this.chart.chart.applyOptions({handleScroll: true})
if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') { 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 hoveringOver = null
document.removeEventListener('mousedown', checkForClick) document.removeEventListener('mousedown', checkForClick)
@ -390,14 +390,15 @@ if (!window.ToolBox) {
let endValue = hoveringOver.to[1] + priceDiff let endValue = hoveringOver.to[1] + priceDiff
let data = calculateTrendLine(startDate, startValue, endDate, endValue, this.interval, this.chart, hoveringOver.ray) let data = calculateTrendLine(startDate, startValue, endDate, endValue, this.interval, this.chart, hoveringOver.ray)
let logical this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
if (chartTimeToDate(data[data.length - 1].time).getTime() >= chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime()) { let logical = this.chart.chart.timeScale().getVisibleLogicalRange()
logical = this.chart.chart.timeScale().getVisibleLogicalRange()
}
hoveringOver.from = [data[0].time, data[0].value] hoveringOver.from = [data[0].time, data[0].value]
hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value] hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value]
hoveringOver.line.setData(data) 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) { if (!hoveringOver.ray) {
hoveringOver.markers = [ hoveringOver.markers = [
@ -428,22 +429,23 @@ if (!window.ToolBox) {
firstPrice = hoveringOver.to[1] 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 let lastCandleTime = this.chart.candleData[this.chart.candleData.length - 1].time
if (!currentTime) { if (!currentTime) {
let barsToMove = param.logical - this.chart.chart.timeScale().coordinateToLogical(this.chart.chart.timeScale().timeToCoordinate(lastCandleTime)) 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) 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) let data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart)
hoveringOver.line.setData(data) 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.from = [data[0].time, data[0].value]
hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value] hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value]
if (logical) this.chart.chart.timeScale().setVisibleLogicalRange(logical)
hoveringOver.markers = [ hoveringOver.markers = [
{time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1}, {time: firstTime, position: 'inBar', color: '#1E80F0', shape: 'circle', size: 0.1},
@ -468,7 +470,6 @@ if (!window.ToolBox) {
} }
renderDrawings() { renderDrawings() {
//let logical = this.chart.chart.timeScale().getVisibleLogicalRange()
this.drawings.forEach((item) => { this.drawings.forEach((item) => {
if ('price' in item) return if ('price' in item) return
let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from[0]).getTime() / this.interval) * this.interval), this.interval) 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.to = [data[data.length - 1].time, data[data.length-1].value]
item.line.setData(data) item.line.setData(data)
}) })
//this.chart.chart.timeScale().setVisibleLogicalRange(logical)
} }
deleteDrawing(drawing) { deleteDrawing(drawing) {
@ -486,10 +486,9 @@ if (!window.ToolBox) {
this.chart.series.removePriceLine(drawing.line) this.chart.series.removePriceLine(drawing.line)
} }
else { else {
let logical this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
if (drawing.ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange()
this.chart.chart.removeSeries(drawing.line); 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.drawings.splice(this.drawings.indexOf(drawing), 1)
this.saveDrawings() this.saveDrawings()
@ -512,22 +511,22 @@ if (!window.ToolBox) {
} }
return value; return value;
}); });
window.callbackFunction(`save_drawings_~_${this.chart.id}_~_${drawingsString}`) window.callbackFunction(`save_drawings${this.chart.id}_~_${drawingsString}`)
} }
loadDrawings(drawings) { loadDrawings(drawings) {
this.drawings = drawings this.drawings = drawings
this.chart.chart.applyOptions({ this.chart.chart.applyOptions({handleScroll: false})
handleScroll: false this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
})
let logical = this.chart.chart.timeScale().getVisibleLogicalRange()
this.drawings.forEach((item) => { this.drawings.forEach((item) => {
let idx = this.drawings.indexOf(item)
if ('price' in 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 { else {
this.drawings[this.drawings.indexOf(item)].line = this.chart.chart.addLineSeries({ this.drawings[idx].line = this.chart.chart.addLineSeries({
lineWidth: 2, lineWidth: 2,
color: this.drawings[idx].color,
lastValueVisible: false, lastValueVisible: false,
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
@ -546,11 +545,140 @@ if (!window.ToolBox) {
item.line.setData(data) item.line.setData(data)
} }
}) })
this.chart.chart.applyOptions({ this.chart.chart.applyOptions({handleScroll: true})
handleScroll: true this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true})
})
this.chart.chart.timeScale().setVisibleLogicalRange(logical)
} }
} }
window.ToolBox = ToolBox window.ToolBox = ToolBox
} }
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
}

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
import datetime as dt import datetime as dt
import re
import threading import threading
import queue import queue
import json import json
@ -8,7 +9,6 @@ import ssl
from typing import Literal, Union, List from typing import Literal, Union, List
import pandas as pd import pandas as pd
from lightweight_charts.util import _convert_timeframe
from lightweight_charts import Chart from lightweight_charts import Chart
try: try:
@ -21,6 +21,22 @@ except ImportError:
websockets = None 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: class PolygonAPI:
""" """
Offers direct access to Polygon API data within all Chart objects. Offers direct access to Polygon API data within all Chart objects.
@ -136,7 +152,7 @@ class PolygonAPI:
self._ws_q.put(('_unsubscribe', chart)) self._ws_q.put(('_unsubscribe', chart))
end_date = dt.datetime.now().strftime('%Y-%m-%d') if end_date == 'now' else end_date 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}" 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'}) response = requests.get(query_url, headers={'User-Agent': 'lightweight_charts/1.0'})
@ -250,8 +266,11 @@ class PolygonAPI:
if sec_type == 'forex': if sec_type == 'forex':
data['bp'] = data.pop('b') data['bp'] = data.pop('b')
data['ap'] = data.pop('a') data['ap'] = data.pop('a')
self._lasts[key]['price'] = (data['bp']+data['ap'])/2 if sec_type != 'indices' else data['val'] if sec_type == 'indices':
self._lasts[key]['volume'] = 0 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'): elif data['ev'] in ('A', 'CA', 'XA'):
self._lasts[key]['volume'] = data['v'] self._lasts[key]['volume'] = data['v']
if not self._lasts[key].get('time'): if not self._lasts[key].get('time'):
@ -305,8 +324,7 @@ class PolygonChart(Chart):
security_options: tuple = ('Stock', 'Option', 'Index', 'Forex', 'Crypto'), security_options: tuple = ('Stock', 'Option', 'Index', 'Forex', 'Crypto'),
toolbox: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None, toolbox: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None,
on_top: bool = False, maximize: bool = False, debug: bool = False): 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, super().__init__(width=width, height=height, x=x, y=y, on_top=on_top, maximize=maximize, debug=debug, toolbox=toolbox)
api=self, topbar=True, searchbox=True, toolbox=toolbox)
self.chart = self self.chart = self
self.num_bars = num_bars self.num_bars = num_bars
self.end_date = end_date self.end_date = end_date
@ -316,26 +334,18 @@ class PolygonChart(Chart):
self.topbar.active_background_color = 'rgb(91, 98, 246)' self.topbar.active_background_color = 'rgb(91, 98, 246)'
self.topbar.textbox('symbol') self.topbar.textbox('symbol')
self.topbar.switcher('timeframe', self._on_timeframe_selection, *timeframe_options) self.topbar.switcher('timeframe', timeframe_options, func=self._on_timeframe_selection)
self.topbar.switcher('security', self._on_security_selection, *security_options) self.topbar.switcher('security', security_options, func=self._on_security_selection)
self.legend(True) self.legend(True)
self.grid(False, False) self.grid(False, False)
self.crosshair(vert_visible=False, horz_visible=False) self.crosshair(vert_visible=False, horz_visible=False)
self.events.search += self.on_search
self.run_script(f''' self.run_script(f'''
{self.id}.search.box.style.backgroundColor = 'rgba(91, 98, 246, 0.5)' {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}.spinner.style.borderTop = '4px solid rgba(91, 98, 246, 0.8)'
{self.id}.search.window.style.display = "flex" {self.id}.search.window.style.display = "flex"
{self.id}.search.box.focus() {self.id}.search.box.focus()
//let polyLogo = document.createElement('div')
//polyLogo.innerHTML = '<svg><g transform="scale(0.9)"><path d="M17.9821362,6 L24,12.1195009 L22.9236698,13.5060353 L17.9524621,27 L14.9907916,17.5798557 L12,12.0454987 L17.9821362,6 Z M21.437,15.304 L18.3670383,19.1065035 L18.367,23.637 L21.437,15.304 Z M18.203,7.335 L15.763,17.462 L17.595,23.287 L17.5955435,18.8249858 L22.963,12.176 L18.203,7.335 Z M17.297,7.799 L12.9564162,12.1857947 L15.228,16.389 L17.297,7.799 Z" fill="#FFFFFF"></path></g></svg>'
//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): def _polygon(self, symbol):
@ -343,7 +353,7 @@ class PolygonChart(Chart):
self.set(pd.DataFrame(), True) self.set(pd.DataFrame(), True)
self.crosshair(vert_visible=False, horz_visible=False) 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)}) delta = dt.timedelta(**{span + 's': int(mult)})
short_delta = (delta < dt.timedelta(days=7)) 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') 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 self.crosshair(vert_visible=True, horz_visible=True) if success else None
return success 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 self._polygon(self.topbar['symbol'].value) if self.topbar['symbol'].value else None
async def _on_security_selection(self): async def _on_security_selection(self, chart):
sec_type = self.topbar['security'].value self.precision(5 if self.topbar['security'].value == 'Forex' else 2)
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}}}
}})''')

View File

@ -1,59 +1,67 @@
import random import random
from typing import Union from typing import Union
from lightweight_charts.util import _js_bool from .util import jbool
class Footer: class Footer:
def __init__(self, table): def __init__(self, table): self._table = table
self._table = table
self._chart = table._chart
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): class Row(dict):
def __init__(self, table, id, items): def __init__(self, table, id, items):
super().__init__() super().__init__()
self._table = table self._table = table
self._chart = table._chart self._run_script = table._run_script
self.id = id self.id = id
self.meta = {} 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(): for key, val in items.items():
self[key] = val self[key] = val
def __setitem__(self, column, value): 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: if column in self._table._formatters:
str_value = self._table._formatters[column].replace(self._table.VALUE, str_value) 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}')''') 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): def background_color(self, column, color): self._style('backgroundColor', column, color)
self._chart.run_script(f"{self._table.id}.rows[{self.id}]['{column}'].style.backgroundColor = '{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): 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) self._table.pop(self.id)
class Table(dict): class Table(dict):
VALUE = 'CELL__~__VALUE__~__PLACEHOLDER' 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__() super().__init__()
self._run_script = chart.run_script
self._chart = chart self._chart = chart
self.headings = headings self.headings = headings
self._formatters = {} self._formatters = {}
self.is_shown = True self.is_shown = True
self.id = self._chart._rand.generate() self.id = chart._rand.generate()
self._chart.run_script(f''' chart._handlers[self.id] = lambda rId: func(self[rId])
{self.id} = new Table({width}, {height}, {list(headings)}, {list(widths)}, {list(alignments)}, '{position}', {_js_bool(draggable)}, '{method}', {chart.id}) 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) self.footer = Footer(self)
def new_row(self, *values, id=None) -> Row: 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)}) self[row_id] = Row(self, row_id, {heading: item for heading, item in zip(self.headings, values)})
return self[row_id] 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)) 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): def visible(self, visible: bool):
self.is_shown = visible 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.style.display = '{'block' if visible else 'none'}'
{self.id}.container.{'add' if visible else 'remove'}EventListener('mousedown', {self.id}.onMouseDown) {self.id}.container.{'add' if visible else 'remove'}EventListener('mousedown', {self.id}.onMouseDown)
""") """)

View File

@ -1,43 +1,20 @@
import re import asyncio
from random import choices from random import choices
from string import ascii_lowercase
from typing import Literal 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): class IDGen(list):
ascii = 'abcdefghijklmnopqrstuvwxyz'
def generate(self): def generate(self):
var = ''.join(choices(ascii_lowercase, k=8)) var = ''.join(choices(self.ascii, k=8))
if var not in self: if var not in self:
self.append(var) self.append(var)
return f'window.{var}' return f'window.{var}'
self.generate() self.generate()
def _valid_color(string): def jbool(b: bool): return 'true' if b is True else 'false' if b is False else None
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
LINE_STYLE = Literal['solid', 'dotted', 'dashed', 'large_dashed', 'sparse_dotted'] 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'] PRICE_SCALE_MODE = Literal['normal', 'logarithmic', 'percentage', 'index100']
def _line_style(line: LINE_STYLE): def line_style(line: LINE_STYLE):
js = 'LightweightCharts.LineStyle.' js = 'LightweightCharts.LineStyle.'
return js+line[:line.index('_')].title() + line[line.index('_') + 1:].title() if '_' in line else js+line.title() 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 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}" 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() 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 { return {
'above': 'aboveBar', 'above': 'aboveBar',
'below': 'belowBar', 'below': 'belowBar',
@ -77,17 +54,59 @@ def _marker_position(p: MARKER_POSITION):
}[p] }[p]
def _convert_timeframe(timeframe): class Emitter:
spans = { def __init__(self):
'min': 'minute', self._callable = None
'H': 'hour',
'D': 'day', def __iadd__(self, other):
'W': 'week', self._callable = other
'M': 'month', return self
}
try: def _emit(self, *args):
multiplier = re.findall(r'\d+', timeframe)[0] self._callable(*args) if self._callable else None
except IndexError:
return 1, spans[timeframe] class JSEmitter:
timespan = spans[timeframe.replace(multiplier, '')] def __init__(self, chart, name, on_iadd, wrapper=None):
return multiplier, timespan 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])
)

View File

@ -1,5 +1,4 @@
import asyncio import asyncio
from inspect import iscoroutinefunction
try: try:
import wx.html2 import wx.html2
@ -19,7 +18,21 @@ try:
def callback(self, message): def callback(self, message):
_widget_message(self.chart, message) _widget_message(self.chart, message)
except ImportError: 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: try:
from streamlit.components.v1 import html from streamlit.components.v1 import html
except ImportError: except ImportError:
@ -33,55 +46,43 @@ from lightweight_charts.abstract import LWC, JS
def _widget_message(chart, string): def _widget_message(chart, string):
messages = string.split('_~_') name, args = string.split('_~_')
name, chart_id = messages[:2] args = args.split(';;;')
arg = messages[2] func = chart._handlers[name]
chart.api.chart = chart._charts[chart_id] asyncio.create_task(func(*args)) if asyncio.iscoroutinefunction(func) else func(*args)
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(';;;'))
class WxChart(LWC): class WxChart(LWC):
def __init__(self, parent, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, def __init__(self, parent, inner_width: float = 1.0, inner_height: float = 1.0,
scale_candles_only: bool = False, api: object = None, topbar: bool = False, searchbox: bool = False, scale_candles_only: bool = False, toolbox: bool = False):
toolbox: bool = False):
if wx is None: if wx is None:
raise ModuleNotFoundError('wx.html2 was not found, and must be installed to use WxChart.') raise ModuleNotFoundError('wx.html2 was not found, and must be installed to use WxChart.')
self.webview: wx.html2.WebView = wx.html2.WebView.New(parent) self.webview: wx.html2.WebView = wx.html2.WebView.New(parent)
super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, super().__init__(inner_width=inner_width, inner_height=inner_height,
scale_candles_only=scale_candles_only, topbar=topbar, searchbox=searchbox, toolbox=toolbox, scale_candles_only=scale_candles_only, toolbox=toolbox,
_js_api_code='window.wx_msg.postMessage.bind(window.wx_msg)') _js_api_code='window.wx_msg.postMessage.bind(window.wx_msg)')
self.api = api
self._script_func = self.webview.RunScript 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_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.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: _widget_message(self, e.GetString()))
self.webview.AddScriptMessageHandler('wx_msg') self.webview.AddScriptMessageHandler('wx_msg')
self.webview.SetPage(self._html, '') 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 self.webview.AddUserScript(JS['toolbox']) if toolbox else None
def get_webview(self): return self.webview def get_webview(self): return self.webview
class QtChart(LWC): class QtChart(LWC):
def __init__(self, widget=None, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, def __init__(self, widget=None, inner_width: float = 1.0, inner_height: float = 1.0,
scale_candles_only: bool = False, api: object = None, topbar: bool = False, searchbox: bool = False, scale_candles_only: bool = False, toolbox: bool = False):
toolbox: bool = False):
if QWebEngineView is None: if QWebEngineView is None:
raise ModuleNotFoundError('QWebEngineView was not found, and must be installed to use QtChart.') raise ModuleNotFoundError('QWebEngineView was not found, and must be installed to use QtChart.')
self.webview = QWebEngineView(widget) self.webview = QWebEngineView(widget)
super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, super().__init__(inner_width=inner_width, inner_height=inner_height,
scale_candles_only=scale_candles_only, topbar=topbar, searchbox=searchbox, toolbox=toolbox, scale_candles_only=scale_candles_only, toolbox=toolbox,
_js_api_code='window.pythonObject.callback') _js_api_code='window.pythonObject.callback')
self.api = api
self._script_func = self.webview.page().runJavaScript self._script_func = self.webview.page().runJavaScript
self.web_channel = QWebChannel() self.web_channel = QWebChannel()
@ -106,8 +107,9 @@ class QtChart(LWC):
class StaticLWC(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): def __init__(self, width=None, height=None, inner_width=1, inner_height=1,
super().__init__(volume_enabled, inner_width, inner_height, scale_candles_only=scale_candles_only, toolbox=toolbox, autosize=autosize) 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.width = width
self.height = height self.height = height
self._html = self._html.replace('</script>\n</body>\n</html>', '') self._html = self._html.replace('</script>\n</body>\n</html>', '')
@ -130,8 +132,8 @@ class StaticLWC(LWC):
class StreamlitChart(StaticLWC): 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): def __init__(self, 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) super().__init__(width, height, inner_width, inner_height, scale_candles_only, toolbox)
def _load(self): def _load(self):
if html is None: if html is None:
@ -140,8 +142,8 @@ class StreamlitChart(StaticLWC):
class JupyterChart(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): def __init__(self, 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) super().__init__(width, height, inner_width, inner_height, scale_candles_only, toolbox, autosize=False)
self._position = "" self._position = ""
self.run_script(f''' self.run_script(f'''

View File

@ -5,7 +5,7 @@ with open('README.md', 'r', encoding='utf-8') as f:
setup( setup(
name='lightweight_charts', name='lightweight_charts',
version='1.0.15', version='1.0.16',
packages=find_packages(), packages=find_packages(),
python_requires='>=3.8', python_requires='>=3.8',
install_requires=[ install_requires=[