diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1e234b4..3adba77 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,2 @@ -# These are supported funding model platforms - github: louisnw01 -custom: https://www.buymeacoffee.com/7wzcr2p9vxM +custom: https://www.buymeacoffee.com/7wzcr2p9vxM/ diff --git a/README.md b/README.md index b06bc3c..942717f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ ___ 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. + +PartTimeLarry: [Interactive Brokers API and TradingView Charts in Python](https://www.youtube.com/watch?v=TlhDI3PforA) ___ ### 1. Display data from a csv: diff --git a/docs/source/conf.py b/docs/source/conf.py index c2da5d6..bba8005 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,7 @@ project = 'lightweight-charts-python' copyright = '2023, louisnw' author = 'louisnw' -release = '1.0.14.1' +release = '1.0.14.2' extensions = ["myst_parser"] diff --git a/docs/source/docs.md b/docs/source/docs.md index 3186715..2d4ee4d 100644 --- a/docs/source/docs.md +++ b/docs/source/docs.md @@ -325,7 +325,7 @@ ___ Sets the data for the line. -When not using the `name` parameter, the columns should be named: `time | value`. +When not using the `name` parameter, the columns should be named: `time | value` (Not case sensitive). Otherwise, the method will use the column named after the string given in `name`. This name will also be used within the legend of the chart. For example: ```python @@ -469,6 +469,7 @@ The ID shown above will change depending upon which pane was used to search, due ```{important} * Search callbacks will always be emitted to a method named `on_search` +* `API` class methods can be either coroutines or normal methods. ``` ___ @@ -568,7 +569,33 @@ The following hotkeys can also be used when the Toolbox is enabled: * Alt+H: Horizontal Line * Alt+R: Ray Line * Meta+Z or Ctrl+Z: Undo +___ +### `save_drawings_under` +`widget: Widget` + +Saves drawings under a specific `topbar` text widget. For example: + +```python +chart.toolbox.save_drawings_under(chart.topbar['symbol']) +``` +___ + +### `load_drawings` +`tag: str` + +Loads and displays drawings stored under the tag given. +___ +### `import_drawings` +`file_path: str` + +Imports the drawings stored at the JSON file given in `file_path`. + +___ +### `export_drawings` +`file_path: str` + +Exports all currently saved drawings to the JSON file given in `file_path`. ___ ## QtChart diff --git a/lightweight_charts/abstract.py b/lightweight_charts/abstract.py index ed4a46b..2312989 100644 --- a/lightweight_charts/abstract.py +++ b/lightweight_charts/abstract.py @@ -1,3 +1,4 @@ +import json import os from datetime import timedelta, datetime from base64 import b64decode @@ -59,23 +60,35 @@ class SeriesCommon: }} ''') + @staticmethod + def _rename(data, mapper, is_dataframe): + if is_dataframe: + data.columns = [mapper[key] if key in mapper else key for key in data.columns] + else: + data.index = [mapper[key] if key in mapper else key for key in data.index] + def _df_datetime_format(self, df: pd.DataFrame, exclude_lowercase=None): df = df.copy() - df.columns = df.columns.str.lower() - if exclude_lowercase: - df[exclude_lowercase] = df[exclude_lowercase.lower()] + if 'date' not in df.columns and 'time' not in df.columns: + df.columns = df.columns.str.lower() + if exclude_lowercase: + df[exclude_lowercase] = df[exclude_lowercase.lower()] if 'date' in df.columns: - df = df.rename(columns={'date': 'time'}) + self._rename(df, {'date': 'time'}, True) elif 'time' not in df.columns: df['time'] = df.index self._set_interval(df) df['time'] = self._datetime_format(df['time']) return df - def _series_datetime_format(self, series): + def _series_datetime_format(self, series: pd.Series, exclude_lowercase=None): series = series.copy() - if 'date' in series.keys(): - series = series.rename({'date': 'time'}) + if 'date' not in series.index and 'time' not in series.index: + series.index = series.index.str.lower() + if exclude_lowercase: + self._rename(series, {exclude_lowercase.lower(): exclude_lowercase}, False) + if 'date' in series.index: + self._rename(series, {'date': 'time'}, False) series['time'] = self._datetime_format(series['time']) return series @@ -353,6 +366,47 @@ class TopBar: return widget +class ToolBox: + def __init__(self, chart): + self.run_script = chart.run_script + self.id = chart.id + self._return_q = chart._return_q + + self._saved_drawings = {} + + def save_drawings_under(self, widget: Widget): + """ + Drawings made on charts will be saved under the widget given. eg `chart.toolbox.save_drawings_under(chart.topbar['symbol'])`. + """ + self._save_under = widget + + def load_drawings(self, tag: str): + """ + Loads and displays the drawings on the chart stored under the tag given. + """ + if not self._saved_drawings.get(tag): + return + self.run_script(f'if ("toolBox" in {self.id}) {self.id}.toolBox.loadDrawings({json.dumps(self._saved_drawings[tag])})') + + def import_drawings(self, file_path): + """ + Imports a list of drawings stored at the given file path. + """ + with open(file_path, 'r') as f: + json_data = json.load(f) + self._saved_drawings = json_data + + def export_drawings(self, file_path): + """ + Exports the current list of drawings to the given file path. + """ + with open(file_path, 'w+') as f: + json.dump(self._saved_drawings, f) + + def _save_drawings(self, drawings): + self._saved_drawings[self._save_under.value] = json.loads(drawings) + + class LWC(SeriesCommon): def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False, scale_candles_only: bool = False, topbar: bool = False, searchbox: bool = False, toolbox: bool = False, @@ -394,6 +448,7 @@ class LWC(SeriesCommon): if toolbox: self.run_script(JS['toolbox']) self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})') + self.toolbox: ToolBox = ToolBox(self) if not topbar and not searchbox: return self.run_script(JS['callback']) diff --git a/lightweight_charts/chart.py b/lightweight_charts/chart.py index d89ec19..ed8c39f 100644 --- a/lightweight_charts/chart.py +++ b/lightweight_charts/chart.py @@ -87,13 +87,17 @@ class Chart(LWC): self._exit.clear() return elif not self._emit_q.empty(): - key, chart_id, arg = self._emit_q.get() + name, chart_id, arg = self._emit_q.get() self._api.chart = self._charts[chart_id] - if widget := self._api.chart.topbar._widget_with_method(key): + if name == 'save_drawings': + self._api.chart.toolbox._save_drawings(arg) + continue + method = getattr(self._api, name) + if hasattr(self._api.chart, 'topbar') and (widget := self._api.chart.topbar._widget_with_method(name)): widget.value = arg - await getattr(self._api, key)() + await method() if asyncio.iscoroutinefunction(method) else method() else: - await getattr(self._api, key)(*arg.split(';;;')) + await method(*arg.split(';;;')) if asyncio.iscoroutinefunction(method) else method(arg) continue value = self.polygon._q.get() func, args = value[0], value[1:] diff --git a/lightweight_charts/js/callback.js b/lightweight_charts/js/callback.js index d552668..c07e871 100644 --- a/lightweight_charts/js/callback.js +++ b/lightweight_charts/js/callback.js @@ -42,7 +42,7 @@ function makeSearchBox(chart) { yPrice = param.point.y; } }); - let selectedChart = true + let selectedChart = false chart.wrapper.addEventListener('mouseover', (event) => { selectedChart = true }) @@ -50,6 +50,7 @@ function makeSearchBox(chart) { selectedChart = false }) chart.commandFunctions.push((event) => { + if (!selectedChart) return if (searchWindow.style.display === 'none') { if (/^[a-zA-Z0-9]$/.test(event.key)) { searchWindow.style.display = 'flex'; diff --git a/lightweight_charts/js/funcs.js b/lightweight_charts/js/funcs.js index 972c116..82ddf86 100644 --- a/lightweight_charts/js/funcs.js +++ b/lightweight_charts/js/funcs.js @@ -106,6 +106,12 @@ if (!window.HorizontalLine) { this.chart.horizontal_lines.push(this) } + toJSON() { + // Exclude the chart attribute from serialization + const {chart, line, ...serialized} = this; + return serialized; + } + updatePrice(price) { this.chart.series.removePriceLine(this.line) this.price = price @@ -361,36 +367,3 @@ function calculateTrendLine(startDate, startValue, endDate, endValue, interval, } return trendData; } - - -/* -let customMenu = document.createElement('div') -customMenu.style.position = 'absolute' -customMenu.style.zIndex = '10000' -customMenu.style.background = 'rgba(25, 25, 25, 0.7)' -customMenu.style.color = 'lightgrey' -customMenu.style.display = 'none' -customMenu.style.borderRadius = '5px' -customMenu.style.padding = '5px 10px' -document.body.appendChild(customMenu) - -function menuItem(text) { - let elem = document.createElement('div') - elem.innerText = text - customMenu.appendChild(elem) -} -menuItem('Delete drawings') -menuItem('Hide all indicators') -menuItem('Save Chart State') - -let closeMenu = (event) => {if (!customMenu.contains(event.target)) customMenu.style.display = 'none';} -document.addEventListener('contextmenu', function (event) { - event.preventDefault(); // Prevent default right-click menu - customMenu.style.left = event.clientX + 'px'; - customMenu.style.top = event.clientY + 'px'; - customMenu.style.display = 'block'; - document.removeEventListener('click', closeMenu) - document.addEventListener('click', closeMenu) - }); - -*/ diff --git a/lightweight_charts/js/toolbox.js b/lightweight_charts/js/toolbox.js index 6370741..69a1481 100644 --- a/lightweight_charts/js/toolbox.js +++ b/lightweight_charts/js/toolbox.js @@ -66,7 +66,8 @@ if (!window.ToolBox) { this.chart.chart.removeSeries(toDelete.line); if (toDelete.ray) this.chart.chart.timeScale().setVisibleLogicalRange(logical) } - this.drawings.splice(this.drawings.length - 1) + this.drawings.splice(this.drawings.indexOf(toDelete)) + this.saveDrawings() } this.chart.commandFunctions.push((event) => { if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') { @@ -248,6 +249,7 @@ if (!window.ToolBox) { this.chart.cursor = 'default' this.chart.activeIcon.elem.style.backgroundColor = this.backgroundColor this.chart.activeIcon = null + this.saveDrawings() } } this.chart.chart.subscribeClick(this.clickHandler) @@ -263,6 +265,7 @@ if (!window.ToolBox) { this.chart.cursor = 'default' this.chart.activeIcon.elem.style.backgroundColor = this.backgroundColor this.chart.activeIcon = null + this.saveDrawings() } onHorzSelect(toggle) { !toggle ? this.chart.chart.unsubscribeClick(this.clickHandlerHorz) : this.chart.chart.subscribeClick(this.clickHandlerHorz) @@ -319,7 +322,6 @@ if (!window.ToolBox) { let mouseDown = false let clickedEnd = false let checkForClick = (event) => { - //if (!hoveringOver) return mouseDown = true document.body.style.cursor = 'grabbing' this.chart.chart.applyOptions({ @@ -345,11 +347,11 @@ if (!window.ToolBox) { this.chart.chart.subscribeCrosshairMove(checkForDrag) } originalIndex = this.chart.chart.timeScale().coordinateToLogical(x) - this.chart.chart.unsubscribeClick(checkForClick) + document.removeEventListener('mousedown', checkForClick) } let checkForRelease = (event) => { mouseDown = false - document.body.style.cursor = 'pointer' + document.body.style.cursor = this.chart.cursor this.chart.chart.applyOptions({handleScroll: true}) if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') { @@ -359,6 +361,7 @@ if (!window.ToolBox) { document.removeEventListener('mousedown', checkForClick) document.removeEventListener('mouseup', checkForRelease) this.chart.chart.subscribeCrosshairMove(hoverOver) + this.saveDrawings() } let checkForDrag = (param) => { if (!param.point) return @@ -486,6 +489,54 @@ if (!window.ToolBox) { }) this.drawings = [] } + + saveDrawings() { + let drawingsString = JSON.stringify(this.drawings, (key, value) => { + if (key === '' && Array.isArray(value)) { + return value.filter(item => !(item && typeof item === 'object' && 'priceLine' in item && item.id !== 'toolBox')); + } else if (key === 'line' || (value && typeof value === 'object' && 'priceLine' in value && value.id !== 'toolBox')) { + return undefined; + } + return value; + }); + this.chart.callbackFunction(`save_drawings__${this.chart.id}__${drawingsString}`) + } + + loadDrawings(drawings) { + this.drawings = drawings + this.chart.chart.applyOptions({ + handleScroll: false + }) + let logical = this.chart.chart.timeScale().getVisibleLogicalRange() + this.drawings.forEach((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) + } + else { + this.drawings[this.drawings.indexOf(item)].line = this.chart.chart.addLineSeries({ + lineWidth: 2, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false, + autoscaleInfoProvider: () => ({ + priceRange: { + minValue: 1_000_000_000, + maxValue: 0, + }, + }), + }) + let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from).getTime() / this.interval) * this.interval), this.interval) + let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to).getTime() / this.interval) * this.interval), this.interval) + let data = calculateTrendLine(startDate, item.data[0].value, endDate, item.data[item.data.length - 1].value, this.interval, this.chart, item.ray) + if (data.length !== 0) item.data = data + item.line.setData(data) + } + }) + this.chart.chart.applyOptions({ + handleScroll: true + }) + this.chart.chart.timeScale().setVisibleLogicalRange(logical) + } } window.ToolBox = ToolBox diff --git a/setup.py b/setup.py index 5e1175f..4641c02 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open('README.md', 'r', encoding='utf-8') as f: setup( name='lightweight_charts', - version='1.0.14.1', + version='1.0.14.2', packages=find_packages(), python_requires='>=3.8', install_requires=[