diff --git a/README.md b/README.md index 3528d68..0ed62ba 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@ ___ 2. Blocking or non-blocking GUI. 3. Streamlined for live data, with methods for updating directly from tick data. 4. Supports: - * PyQt - * wxPython - * Streamlit - * asyncio - * Jupyter Notebooks using the [`JupyterChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#jupyterchart) + * PyQt -> [`QtChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#qtchart) + * wxPython -> [`WxChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#wxchart) + * Streamlit -> [`StreamlitChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#streamlitchart) + * asyncio -> [`show_async()`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#show-async) + * Jupyter Notebooks -> [`JupyterChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#jupyterchart) 5. [Callbacks](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#callbacks) allowing for timeframe (1min, 5min, 30min etc.) selectors, searching, and more. 6. Multi-Pane Charts using the [`SubChart`](https://lightweight-charts-python.readthedocs.io/en/latest/docs.html#subchart). ___ diff --git a/docs/source/docs.md b/docs/source/docs.md index 73e51d6..1c44083 100644 --- a/docs/source/docs.md +++ b/docs/source/docs.md @@ -26,6 +26,8 @@ The `time` column can also be named `date`, and the `volume` column can be omitt ```{important} the `time` column must have rows all of the same timezone and locale. This is particularly noticeable for data which crosses over daylight saving hours on data with intervals of less than 1 day. Errors are likely to be raised if they are not converted beforehand. ``` + +An empty `DataFrame` object can also be given to this method, which will erase all candle and volume data displayed on the chart. ___ ### `update` @@ -37,7 +39,7 @@ The bar should contain values with labels of the same name as the columns requir ___ ### `update_from_tick` -`series: pd.Series` +`series: pd.Series` | `cumulative_volume: bool` Updates the chart from a tick. @@ -51,6 +53,7 @@ As before, the `time` can also be named `date`, and the `volume` can be omitted The provided ticks do not need to be rounded to an interval (1 min, 5 min etc.), as the library handles this automatically.``````` ``` +If `cumulative_volume` is used, the volume data given to this method will be added onto the latest bar of volume data. ___ ### `create_line` @@ -59,6 +62,11 @@ ___ Creates and returns a [Line](#line) object. ___ +### `lines` +`-> List[Line]` + +Returns a list of all Line objects for the chart or subchart. +___ ### `marker` `time: datetime` | `position: 'above'/'below'/'inside'` | `shape: 'arrow_up'/'arrow_down'/'circle'/'square'` | `color: str` | `text: str` | `-> str` @@ -141,7 +149,7 @@ The float values given to scale the margins must be greater than 0 and less than ___ ### `crosshair` -`mode` | `vert_width: int` | `vert_color: str` | `vert_style: str` | `vert_label_background_color: str` | `horz_width: int` | `horz_color: str` | `horz_style: str` | `horz_label_background_color: str` +`mode` | `vert_visible: bool` | `vert_width: int` | `vert_color: str` | `vert_style: str` | `vert_label_background_color: str` | `horz_visible: bool` | `horz_width: int` | `horz_color: str` | `horz_style: str` | `horz_label_background_color: str` Crosshair formatting for its vertical and horizontal axes. @@ -166,6 +174,34 @@ ___ Configures the legend of the chart. ___ +### `spinner` +`visible: bool` + +Shows a loading spinner on the chart, which can be used to visualise the loading of large datasets, API calls, etc. +___ + +### `price_line` +`label_visible: bool` | `line_visible: bool` + +Configures the visibility of the last value price line and its label. +___ + +### `fit` + +Attempts to fit all data displayed on the chart within the viewport (`fitContent()`). +___ + +### `hide_data` + +Hides the candles on the chart. +___ + +### `show_data` + +Shows the hidden candles on the chart. +___ + + ### `create_subchart` `volume_enabled: bool` | `position: 'left'/'right'/'top'/'bottom'`, `width: float` | `height: float` | `sync: bool/str` | `-> SubChart` @@ -218,7 +254,9 @@ ___ ## Line -The `Line` object represents a `LineSeries` object in Lightweight Charts and can be used to create indicators. As well as the methods described below, the `Line` object also has access to the [`title`](#title), [`marker`](#marker) and [`horizontal_line`](#horizontal-line) methods. +The `Line` object represents a `LineSeries` object in Lightweight Charts and can be used to create indicators. As well as the methods described below, the `Line` object also has access to: + +[`title`](#title), [`marker`](#marker), [`horizontal_line`](#horizontal-line) [`hide_data`](#hide-data), [`show_data`](#show-data) and[`price_line`](#price-line) methods. ```{important} The `line` object should only be accessed from the [`create_line`](#create-line) method of `Chart`. @@ -241,6 +279,11 @@ Updates the data for the line. This should be given as a Series object, with labels akin to the `line.set()` function. ___ +### `delete` + +Irreversibly deletes the line on the chart as well as the Line object. +___ + ## SubChart The `SubChart` object allows for the use of multiple chart panels within the same `Chart` window. All of the [Common Methods](#common-methods) can be used within a `SubChart`. Its instance should be accessed using the [create_subchart](#create-subchart) method. diff --git a/lightweight_charts/__init__.py b/lightweight_charts/__init__.py index 12a3ade..f42ac51 100644 --- a/lightweight_charts/__init__.py +++ b/lightweight_charts/__init__.py @@ -1,3 +1,4 @@ -from .chart import Chart from .js import LWC +from .chart import Chart from .widgets import JupyterChart +from .polygon import PolygonChart diff --git a/lightweight_charts/chart.py b/lightweight_charts/chart.py index d4f15ed..079eb0c 100644 --- a/lightweight_charts/chart.py +++ b/lightweight_charts/chart.py @@ -1,6 +1,7 @@ import asyncio -import webview +import time import multiprocessing as mp +import webview from lightweight_charts.js import LWC, CALLBACK_SCRIPT, TopBar @@ -63,6 +64,7 @@ class Chart(LWC): if not topbar and not searchbox: return self.run_script(CALLBACK_SCRIPT) + self.run_script(f'makeSpinner({self.id})') self.topbar = TopBar(self) if topbar else None self._make_search_box() if searchbox else None @@ -79,10 +81,18 @@ class Chart(LWC): self._q.put('show') if block: try: - self._exit.wait() + while 1: + while not self._exit.is_set() and self.polygon._q.empty(): + time.sleep(0.05) + continue + if self._exit.is_set(): + self._exit.clear() + return + value = self.polygon._q.get_nowait() + func, args = value[0], value[1:] + func(*args) except KeyboardInterrupt: return - self._exit.clear() async def show_async(self, block=False): if not self.loaded: @@ -94,17 +104,23 @@ class Chart(LWC): if block: try: while 1: - while self._emit.empty() and not self._exit.is_set(): - await asyncio.sleep(0.1) + while self._emit.empty() and not self._exit.is_set() and self.polygon._q.empty(): + await asyncio.sleep(0.05) if self._exit.is_set(): + self._exit.clear() return - key, chart_id, arg = self._emit.get() - self.api.chart = self._charts[chart_id] - if widget := self.api.chart.topbar._widget_with_method(key): - widget.value = arg - await getattr(self.api, key)() - else: - await getattr(self.api, key)(arg) + elif not self._emit.empty(): + key, chart_id, arg = self._emit.get() + self.api.chart = self._charts[chart_id] + if widget := self.api.chart.topbar._widget_with_method(key): + widget.value = arg + await getattr(self.api, key)() + else: + await getattr(self.api, key)(arg) + continue + value = self.polygon._q.get() + func, args = value[0], value[1:] + func(*args) except KeyboardInterrupt: return asyncio.create_task(self.show_async(block=True)) @@ -123,4 +139,3 @@ class Chart(LWC): self._exit.wait() self._process.terminate() del self - diff --git a/lightweight_charts/js.py b/lightweight_charts/js.py index b2725b8..b7a0e99 100644 --- a/lightweight_charts/js.py +++ b/lightweight_charts/js.py @@ -10,7 +10,10 @@ from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, C class SeriesCommon: def _set_interval(self, df: pd.DataFrame): common_interval = pd.to_datetime(df['time']).diff().value_counts() - self._interval = common_interval.index[0] + try: + self._interval = common_interval.index[0] + except IndexError: + raise IndexError('Not enough bars within the given data to calculate the interval/timeframe.') def _df_datetime_format(self, df: pd.DataFrame): df = df.copy() @@ -81,9 +84,11 @@ class SeriesCommon: """ Creates a horizontal line at the given price.\n """ + line_id = self._rand.generate() self.run_script(f""" - makeHorizontalLine({self.id}, {price}, '{color}', {width}, {_line_style(style)}, {_js_bool(axis_label_visible)}, '{text}') + makeHorizontalLine({self.id}, '{line_id}', {price}, '{color}', {width}, {_line_style(style)}, {_js_bool(axis_label_visible)}, '{text}') """) + return line_id def remove_horizontal_line(self, price: Union[float, int]): """ @@ -97,8 +102,24 @@ class SeriesCommon: }} }});''') - def title(self, title: str): - self.run_script(f'{self.id}.series.applyOptions({{title: "{title}"}})') + def title(self, title: str): self.run_script(f'{self.id}.series.applyOptions({{title: "{title}"}})') + + def price_line(self, label_visible: bool = True, line_visible: bool = True): + self.run_script(f''' + {self.id}.series.applyOptions({{ + lastValueVisible: {_js_bool(label_visible)}, + priceLineVisible: {_js_bool(line_visible)}, + }})''') + + def hide_data(self): self._toggle_data(False) + + def show_data(self): self._toggle_data(True) + + def _toggle_data(self, arg): + self.run_script(f''' + {self.id}.series.applyOptions({{visible: {_js_bool(arg)}}}) + {f'{self.id}.volumeSeries.applyOptions({{visible: {_js_bool(arg)}}})' if hasattr(self, 'volume_enabled') and self.volume_enabled else ''} + ''') class Line(SeriesCommon): @@ -137,16 +158,27 @@ class Line(SeriesCommon): self._last_bar = series self.run_script(f'{self.id}.series.update({series.to_dict()})') + def delete(self): + """ + Irreversibly deletes the line, as well as the object that contains the line. + """ + self._parent._lines.remove(self) + self.run_script(f''' + {self._parent.id}.chart.removeSeries({self.id}.series) + delete {self.id} + ''') + del self + class Widget: - def __init__(self, chart): - self._chart = chart + def __init__(self, topbar): + self._chart = topbar._chart self.method = None class TextWidget(Widget): - def __init__(self, chart, initial_text): - super().__init__(chart) + def __init__(self, topbar, initial_text): + super().__init__(topbar) self.value = initial_text self.id = f"window.{self._chart._rand.generate()}" self._chart.run_script(f'''{self.id} = makeTextBoxWidget({self._chart.id}, "{initial_text}")''') @@ -157,12 +189,13 @@ class TextWidget(Widget): class SwitcherWidget(Widget): - def __init__(self, chart, method, *options, default): - super().__init__(chart) + def __init__(self, topbar, method, *options, default): + super().__init__(topbar) self.value = default self.method = method.__name__ self._chart.run_script(f''' - makeSwitcher({self._chart.id}, {list(options)}, '{default}', {self._chart._js_api_code}, '{method.__name__}') + makeSwitcher({self._chart.id}, {list(options)}, '{default}', {self._chart._js_api_code}, '{method.__name__}', + '{topbar.active_background_color}', '{topbar.active_text_color}', '{topbar.text_color}', '{topbar.hover_color}') {self._chart.id}.chart.resize(window.innerWidth*{self._chart._inner_width}, (window.innerHeight*{self._chart._inner_height})-{self._chart.id}.topBar.offsetHeight) ''') @@ -173,15 +206,20 @@ class TopBar: self._widgets: Dict[str, Widget] = {} self._chart.run_script(f''' makeTopBar({self._chart.id}) - {self._chart.id}.chart.resize(window.innerWidth*{self._chart._inner_width}, (window.innerHeight*{self._chart._inner_height})-{self._chart.id}.topBar.offsetHeight) + {self._chart.id}.chart.resize(window.innerWidth*{self._chart._inner_width}, + (window.innerHeight*{self._chart._inner_height})-{self._chart.id}.topBar.offsetHeight) ''') + 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 switcher(self, name, method, *options, default=None): - self._widgets[name] = SwitcherWidget(self._chart, method, *options, default=default if default else options[0]) + 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._chart, initial_text) + def textbox(self, name, initial_text=''): self._widgets[name] = TextWidget(self, initial_text) def _widget_with_method(self, method_name): for widget in self._widgets.values(): @@ -191,7 +229,7 @@ class TopBar: class LWC(SeriesCommon): def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False): - self._volume_enabled = volume_enabled + self.volume_enabled = volume_enabled self._inner_width = inner_width self._inner_height = inner_height self._dynamic_loading = dynamic_loading @@ -206,13 +244,15 @@ class LWC(SeriesCommon): self._last_bar = None self._interval = None self._charts = {self.id: self} + self._lines = [] self._js_api_code = None self._background_color = '#000000' self._volume_up_color = 'rgba(83,141,131,0.8)' self._volume_down_color = 'rgba(200,127,130,0.8)' - # self.polygon: PolygonAPI = PolygonAPI(self) + from lightweight_charts.polygon import PolygonAPI + self.polygon: PolygonAPI = PolygonAPI(self) def _on_js_load(self): if self.loaded: @@ -228,7 +268,7 @@ class LWC(SeriesCommon): ''') def _make_search_box(self): - self.run_script(f'makeSearchBox({self.id}, {self._js_api_code})') + self.run_script(f'{self.id}.search = makeSearchBox({self.id}, {self._js_api_code})') def run_script(self, script): """ @@ -241,9 +281,13 @@ class LWC(SeriesCommon): Sets the initial data for the chart.\n :param df: columns: date/time, open, high, low, close, volume (if volume enabled). """ + if df.empty: + self.run_script(f'{self.id}.series.setData([])') + self.run_script(f'{self.id}.volumeSeries.setData([])') + return bars = self._df_datetime_format(df) self._last_bar = bars.iloc[-1] - if self._volume_enabled: + if self.volume_enabled: if 'volume' not in bars: raise MissingColumn("Volume enabled, but 'volume' column was not found.") @@ -292,6 +336,12 @@ class LWC(SeriesCommon): }}); ''') if self._dynamic_loading else self.run_script(f'{self.id}.series.setData({bars})') + def fit(self): + """ + Fits the maximum amount of the chart data within the viewport. + """ + self.run_script(f'{self.id}.chart.timeScale().fitContent()') + def update(self, series, from_tick=False): """ Updates the data from a bar; @@ -300,7 +350,7 @@ class LWC(SeriesCommon): """ series = self._series_datetime_format(series) if not from_tick else series self._last_bar = series - if self._volume_enabled: + if self.volume_enabled: if 'volume' not in series: raise MissingColumn("Volume enabled, but 'volume' column was not found.") @@ -332,10 +382,11 @@ class LWC(SeriesCommon): {self.id}.series.update({self.id}.shownData[{self.id}.shownData.length-1]) ''') if self._dynamic_loading else self.run_script(f'{self.id}.series.update({bar})') - def update_from_tick(self, series): + def update_from_tick(self, series, cumulative_volume=False): """ Updates the data from a tick.\n :param series: labels: date/time, price, volume (if volume enabled). + :param cumulative_volume: Adds the given volume onto the latest bar. """ series = self._series_datetime_format(series) bar = pd.Series() @@ -344,10 +395,13 @@ class LWC(SeriesCommon): bar['high'] = max(self._last_bar['high'], series['price']) bar['low'] = min(self._last_bar['low'], series['price']) bar['close'] = series['price'] - if self._volume_enabled: + if self.volume_enabled: if 'volume' not in series: raise MissingColumn("Volume enabled, but 'volume' column was not found.") - bar['volume'] = series['volume'] + elif cumulative_volume: + bar['volume'] += series['volume'] + else: + bar['volume'] = series['volume'] else: for key in ('open', 'high', 'low', 'close'): bar[key] = series['price'] @@ -355,11 +409,19 @@ class LWC(SeriesCommon): bar['volume'] = 0 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, color: str = 'rgba(214, 237, 255, 0.6)', width: int = 2) -> Line: """ Creates and returns a Line object.)\n """ - return Line(self, color, width) + self._lines.append(Line(self, color, width)) + return self._lines[-1] + + def lines(self): + """ + Returns all lines for the chart. + :return: + """ + return self._lines def price_scale(self, mode: PRICE_SCALE_MODE = 'normal', align_labels: bool = True, border_visible: bool = False, border_color: str = None, text_color: str = None, entire_text_only: bool = False, ticks_visible: bool = False): @@ -471,9 +533,9 @@ class LWC(SeriesCommon): }} }})''') - def crosshair(self, mode: CROSSHAIR_MODE = 'normal', vert_width: int = 1, vert_color: str = None, - vert_style: LINE_STYLE = 'dashed', vert_label_background_color: str = 'rgb(46, 46, 46)', horz_width: int = 1, - horz_color: str = None, horz_style: LINE_STYLE = 'dashed', horz_label_background_color: str = 'rgb(55, 55, 55)'): + def crosshair(self, mode: CROSSHAIR_MODE = 'normal', vert_visible: bool = True, vert_width: int = 1, vert_color: str = None, + vert_style: LINE_STYLE = 'large_dashed', vert_label_background_color: str = 'rgb(46, 46, 46)', horz_visible: bool = True, + horz_width: int = 1, horz_color: str = None, horz_style: LINE_STYLE = 'large_dashed', horz_label_background_color: str = 'rgb(55, 55, 55)'): """ Crosshair formatting for its vertical and horizontal axes. """ @@ -482,12 +544,14 @@ class LWC(SeriesCommon): crosshair: {{ mode: {_crosshair_mode(mode)}, vertLine: {{ + visible: {_js_bool(vert_visible)}, width: {vert_width}, {f'color: "{vert_color}",' if vert_color else ''} style: {_line_style(vert_style)}, labelBackgroundColor: "{vert_label_background_color}" }}, horzLine: {{ + visible: {_js_bool(horz_visible)}, width: {horz_width}, {f'color: "{horz_color}",' if horz_color else ''} style: {_line_style(horz_style)}, @@ -524,13 +588,13 @@ class LWC(SeriesCommon): {self.id}.chart.subscribeCrosshairMove((param) => {{ if (param.time){{ - const data = param.seriesData.get({self.id}.series); + let data = param.seriesData.get({self.id}.series); if (!data) {{return}} - let percentMove = ((data.close-data.open)/data.open)*100 let ohlc = `O ${{legendItemFormat(data.open)}} | H ${{legendItemFormat(data.high)}} | L ${{legendItemFormat(data.low)}} | C ${{legendItemFormat(data.close)}} ` + let percentMove = ((data.close-data.open)/data.open)*100 let percent = `| ${{percentMove >= 0 ? '+' : ''}}${{percentMove.toFixed(2)}} %` let finalString = '' {'finalString += ohlc' if ohlc else ''} @@ -542,6 +606,8 @@ class LWC(SeriesCommon): }} }});''') + def spinner(self, visible): self.run_script(f"{self.id}.spinner.style.display = '{'block' if visible else 'none'}'") + def create_subchart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False, topbar: bool = False, searchbox: bool = False): @@ -561,17 +627,19 @@ class SubChart(LWC): self.run_script = self._chart.run_script self._charts = self._chart._charts self.id = f'window.{self._rand.generate()}' + self.polygon = self._chart.polygon._subchart(self) self._create_chart() self.topbar = TopBar(self) if topbar else None self._make_search_box() if searchbox else None if not sync: return - sync_parent_var = self._parent.id if isinstance(sync, bool) else sync + sync_parent_id = self._parent.id if isinstance(sync, bool) else sync self.run_script(f''' - {sync_parent_var}.chart.timeScale().subscribeVisibleLogicalRangeChange((timeRange) => {{ + {sync_parent_id}.chart.timeScale().subscribeVisibleLogicalRangeChange((timeRange) => {{ {self.id}.chart.timeScale().setVisibleLogicalRange(timeRange) }}); + {self.id}.chart.timeScale().setVisibleLogicalRange({sync_parent_id}.chart.timeScale().getVisibleLogicalRange()) ''') @@ -664,7 +732,7 @@ function makeChart(innerWidth, innerHeight, autoSize=true) { }); return chart } -function makeHorizontalLine(chart, price, color, width, style, axisLabelVisible, text) { +function makeHorizontalLine(chart, lineId, price, color, width, style, axisLabelVisible, text) { let priceLine = { price: price, color: color, @@ -676,6 +744,7 @@ function makeHorizontalLine(chart, price, color, width, style, axisLabelVisible, let line = { line: chart.series.createPriceLine(priceLine), price: price, + id: lineId, }; chart.horizontal_lines.push(line) } @@ -725,7 +794,7 @@ function makeSearchBox(chart, callbackFunction) { searchWindow.style.height = '30px' searchWindow.style.padding = '10px' searchWindow.style.backgroundColor = 'rgba(30, 30, 30, 0.9)' - searchWindow.style.border = '3px solid #3C434C' + searchWindow.style.border = '2px solid #3C434C' searchWindow.style.zIndex = '1000' searchWindow.style.display = 'none' searchWindow.style.borderRadius = '5px' @@ -734,14 +803,14 @@ function makeSearchBox(chart, callbackFunction) { magnifyingGlass.style.display = 'inline-block'; magnifyingGlass.style.width = '12px'; magnifyingGlass.style.height = '12px'; - magnifyingGlass.style.border = '2px solid #FFF'; + magnifyingGlass.style.border = '2px solid rgb(240, 240, 240)'; magnifyingGlass.style.borderRadius = '50%'; magnifyingGlass.style.position = 'relative'; let handle = document.createElement('span'); handle.style.display = 'block'; handle.style.width = '7px'; handle.style.height = '2px'; - handle.style.backgroundColor = '#FFF'; + handle.style.backgroundColor = 'rgb(240, 240, 240)'; handle.style.position = 'absolute'; handle.style.top = 'calc(50% + 7px)'; handle.style.right = 'calc(50% - 11px)'; @@ -749,15 +818,14 @@ function makeSearchBox(chart, callbackFunction) { let sBox = document.createElement('input'); sBox.type = 'text'; - sBox.placeholder = 'search'; sBox.style.position = 'relative'; sBox.style.display = 'inline-block'; sBox.style.zIndex = '1000'; sBox.style.textAlign = 'center' sBox.style.width = '100px' sBox.style.marginLeft = '15px' - sBox.style.backgroundColor = 'rgba(0, 122, 255, 0.2)' - sBox.style.color = 'lightgrey' + sBox.style.backgroundColor = 'rgba(0, 122, 255, 0.3)' + sBox.style.color = 'rgb(240,240,240)' sBox.style.fontSize = '20px' sBox.style.border = 'none' sBox.style.outline = 'none' @@ -774,7 +842,7 @@ function makeSearchBox(chart, callbackFunction) { yPrice = param.point.y; } }); - let selectedChart = false + let selectedChart = true chart.wrapper.addEventListener('mouseover', (event) => { selectedChart = true }) @@ -785,7 +853,18 @@ function makeSearchBox(chart, callbackFunction) { if (!selectedChart) {return} if (event.altKey && event.code === 'KeyH') { let price = chart.series.coordinateToPrice(yPrice) - makeHorizontalLine(chart, price, '#FFFFFF', 1, LightweightCharts.LineStyle.Solid, true, '') + + let colorList = [ + 'rgba(228, 0, 16, 0.7)', + 'rgba(255, 133, 34, 0.7)', + 'rgba(164, 59, 176, 0.7)', + 'rgba(129, 59, 102, 0.7)', + 'rgba(91, 20, 248, 0.7)', + 'rgba(32, 86, 249, 0.7)', + ] + let color = colorList[Math.floor(Math.random()*colorList.length)] + + makeHorizontalLine(chart, 0, price, color, 2, LightweightCharts.LineStyle.Solid, true, '') } if (searchWindow.style.display === 'none') { if (/^[a-zA-Z0-9]$/.test(event.key)) { @@ -806,29 +885,58 @@ function makeSearchBox(chart, callbackFunction) { sBox.addEventListener('input', function() { sBox.value = sBox.value.toUpperCase(); }); + return { + window: searchWindow, + box: sBox, + } } -function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName) { +function makeSpinner(chart) { + chart.spinner = document.createElement('div') + chart.spinner.style.width = '30px' + chart.spinner.style.height = '30px' + chart.spinner.style.border = '4px solid rgba(255, 255, 255, 0.6)' + chart.spinner.style.borderTop = '4px solid rgba(0, 122, 255, 0.8)' + chart.spinner.style.borderRadius = '50%' + chart.spinner.style.position = 'absolute' + chart.spinner.style.top = '50%' + chart.spinner.style.left = '50%' + chart.spinner.style.zIndex = 1000 + chart.spinner.style.transform = 'translate(-50%, -50%)' + chart.spinner.style.display = 'none' + chart.wrapper.appendChild(chart.spinner) + let rotation = 0; + const speed = 10; // Adjust this value to change the animation speed + function animateSpinner() { + rotation += speed + chart.spinner.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)` + requestAnimationFrame(animateSpinner) + } + animateSpinner(); +} +function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName, activeBackgroundColor, activeColor, inactiveColor, hoverColor) { let switcherElement = document.createElement('div'); - switcherElement.style.margin = '4px 18px' + 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 = '3px 6px' + itemEl.style.padding = '2px 5px' itemEl.style.margin = '0px 4px' - itemEl.style.fontSize = '14px' - itemEl.style.color = 'lightgrey' - itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'transparent' - + itemEl.style.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 ? 'rgba(0, 122, 255, 0.7)' : 'rgb(19, 40, 84)' + itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : hoverColor + itemEl.style.color = activeColor }) itemEl.addEventListener('mouseleave', function() { - itemEl.style.backgroundColor = item === activeItem ? 'rgba(0, 122, 255, 0.7)' : 'transparent' + itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : 'transparent' + itemEl.style.color = item === activeItem ? activeColor : inactiveColor }) itemEl.innerText = item; itemEl.addEventListener('click', function() { @@ -842,7 +950,8 @@ function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName) return; } intervalElements.forEach(function(element, index) { - element.style.backgroundColor = items[index] === item ? 'rgba(0, 122, 255, 0.7)' : 'transparent' + element.style.backgroundColor = items[index] === item ? activeBackgroundColor : 'transparent' + element.style.color = items[index] === item ? 'activeColor' : inactiveColor }); activeItem = item; callbackFunction(`${callbackName}__${chart.id}__${item}`); @@ -856,7 +965,8 @@ function makeTextBoxWidget(chart, text) { let textBox = document.createElement('div') textBox.style.margin = '0px 18px' textBox.style.position = 'relative' - textBox.style.color = 'lightgrey' + textBox.style.fontSize = '16px' + textBox.style.color = 'rgb(220, 220, 220)' textBox.innerText = text chart.topBar.append(textBox) makeSeperator(chart.topBar) @@ -865,7 +975,7 @@ function makeTextBoxWidget(chart, text) { function makeTopBar(chart) { chart.topBar = document.createElement('div') chart.topBar.style.backgroundColor = '#191B1E' - chart.topBar.style.borderBottom = '3px solid #3C434C' + chart.topBar.style.borderBottom = '2px solid #3C434C' chart.topBar.style.display = 'flex' chart.topBar.style.alignItems = 'center' chart.wrapper.prepend(chart.topBar) @@ -878,4 +988,3 @@ function makeSeperator(topBar) { topBar.appendChild(seperator) } ''' - diff --git a/lightweight_charts/polygon.py b/lightweight_charts/polygon.py new file mode 100644 index 0000000..7418915 --- /dev/null +++ b/lightweight_charts/polygon.py @@ -0,0 +1,335 @@ +import asyncio +import logging +import datetime as dt +import threading +import queue +import json +import ssl +from typing import Literal, Union, List +import pandas as pd + +from lightweight_charts.util import _convert_timeframe +from lightweight_charts import Chart + +try: + import requests +except ImportError: + requests = None +try: + import websockets +except ImportError: + websockets = None + + +class PolygonAPI: + def __init__(self, chart): + ch = logging.StreamHandler() + ch.setFormatter(logging.Formatter('%(asctime)s | [polygon.io] %(levelname)s: %(message)s', datefmt='%H:%M:%S')) + ch.setLevel(logging.DEBUG) + self._log = logging.getLogger('polygon') + self._log.setLevel(logging.ERROR) + self._log.addHandler(ch) + + self._chart = chart + self._lasts = {} # $$ + self._key = None + self._using_live_data = False + self._using_live = {'stocks': False, 'options': False, 'indices': False, 'crypto': False, 'forex': False} + self._ws = {'stocks': None, 'options': None, 'indices': None, 'crypto': None, 'forex': None} + self._send_q = queue.Queue() + self._q = queue.Queue() + self._lock = threading.Lock() + + def _subchart(self, subchart): + return PolygonAPISubChart(self, subchart) + + def log(self, info: bool): + self._log.setLevel(logging.INFO) if info else self._log.setLevel(logging.ERROR) + + def api_key(self, key: str): self._key = key + + def stock(self, symbol: str, timeframe: str, start_date: str, end_date='now', limit: int = 5_000, live: bool = False): + """ + Requests and displays stock data pulled from Polygon.io.\n + :param symbol: Ticker to request. + :param timeframe: Timeframe to request (1min, 5min, 2H, 1D, 1W, 2M, etc). + :param start_date: Start date of the data (YYYY-MM-DD). + :param end_date: End date of the data (YYYY-MM-DD). If left blank, this will be set to today. + :param limit: The limit of base aggregates queried to create the timeframe given (max 50_000) + :param live: If true, the data will be updated in real-time. + """ + return True if self._set(self._chart, 'stocks', symbol, timeframe, start_date, end_date, limit, live) else False + + def option(self, symbol: str, timeframe: str, start_date: str, expiration: str = None, right: Literal['C', 'P'] = None, strike: Union[int, float] = None, + end_date: str = 'now', limit: int = 5_000, live: bool = False): + if any((expiration, right, strike)): + symbol = f'O:{symbol}{dt.datetime.strptime(expiration, "%Y-%m-%d").strftime("%y%m%d")}{right}{strike * 1000:08d}' + return True if self._set(self._chart, 'options', symbol, timeframe, start_date, end_date, limit, live) else False + + def index(self, symbol, timeframe, start_date, end_date='now', limit: int = 5_000, live=False): + return True if self._set(self._chart, 'indices', f'I:{symbol}', timeframe, start_date, end_date, limit, live) else False + + def forex(self, fiat_pair, timeframe, start_date, end_date='now', limit: int = 5_000, live=False): + return True if self._set(self._chart, 'forex', f'C:{fiat_pair}', timeframe, start_date, end_date, limit, live) else False + + def crypto(self, crypto_pair, timeframe, start_date, end_date='now', limit: int = 5_000, live=False): + return True if self._set(self._chart, 'crypto', f'X:{crypto_pair}', timeframe, start_date, end_date, limit, live) else False + + def _set(self, chart, sec_type, ticker, timeframe, start_date, end_date, limit, live): + if requests is None: + raise ImportError('The "requests" library was not found, and must be installed to use polygon.io.') + + end_date = dt.datetime.now().strftime('%Y-%m-%d') if end_date == 'now' else end_date + mult, span = _convert_timeframe(timeframe) + query_url = f"https://api.polygon.io/v2/aggs/ticker/{ticker.replace('-', '')}/range/{mult}/{span}/{start_date}/{end_date}?limit={limit}&apiKey={self._key}" + + response = requests.get(query_url, headers={'User-Agent': 'lightweight_charts/1.0'}) + if response.status_code != 200: + error = response.json() + self._log.error(f'({response.status_code}) Request failed: {error["error"]}') + return + data = response.json() + if 'results' not in data: + self._log.error(f'No results for "{ticker}" ({sec_type})') + return + + for child in self._lasts: + for subbed_chart in child['charts']: + if subbed_chart == chart: + self._send_q.put(('_unsubscribe', chart, sec_type, ticker)) + + df = pd.DataFrame(data['results']) + columns = ['t', 'o', 'h', 'l', 'c'] + rename = {'o': 'open', 'h': 'high', 'l': 'low', 'c': 'close', 't': 'time'} + if sec_type != 'indices': + rename['v'] = 'volume' + columns.append('v') + df = df[columns].rename(columns=rename) + df['time'] = pd.to_datetime(df['time'], unit='ms') + + chart.set(df) + if not live: + return True + + if not self._using_live_data: + threading.Thread(target=asyncio.run, args=[self._thread_loop()], daemon=True).start() + self._using_live_data = True + with self._lock: + if not self._ws[sec_type]: + self._send_q.put(('_websocket_connect', self._key, sec_type)) + self._send_q.put(('_subscribe', chart, sec_type, ticker)) + return True + + async def _thread_loop(self): + while 1: + while self._send_q.empty(): + await asyncio.sleep(0.05) + value = self._send_q.get() + func, args = value[0], value[1:] + asyncio.create_task(getattr(self, func)(*args)) + + def unsubscribe(self, symbol): + self._send_q.put(('_unsubscribe', self._chart, symbol)) + + async def _subscribe(self, chart, sec_type, ticker): + key = ticker if '.' not in ticker else ticker.split('.')[1] + key = key if ':' not in key else key.split(':')[1] + if not self._lasts.get(key): + sub_type = { + 'stocks': ('Q', 'A'), + 'options': ('Q', 'A'), + 'indices': ('V', None), + 'forex': ('C', 'CA'), + 'crypto': ('XQ', 'XA'), + } + self._lasts[key] = { + 'sec_type': sec_type, + 'sub_type': sub_type[sec_type], + 'price': chart._last_bar['close'], + 'charts': [], + } + quotes, aggs = self._lasts[key]['sub_type'] + await self._send(self._lasts[key]['sec_type'], 'subscribe', f'{quotes}.{ticker}') + await self._send(self._lasts[key]['sec_type'], 'subscribe', f'{aggs}.{ticker}') if aggs else None + + if sec_type != 'indices': + self._lasts[key]['volume'] = chart._last_bar['volume'] + if chart in self._lasts[key]['charts']: + return + self._lasts[key]['charts'].append(chart) + + async def _unsubscribe(self, chart, ticker): + key = ticker if '.' not in ticker else ticker.split('.')[1] + key = key if ':' not in key else key.split(':')[1] + if chart in self._lasts[key]['charts']: + self._lasts[key]['charts'].remove(chart) + if self._lasts[key]['charts']: + return + while self._q.qsize(): + self._q.get() # Flush the queue + quotes, aggs = self._lasts[key]['sub_type'] + await self._send(self._lasts[key]['sec_type'], 'unsubscribe', f'{quotes}.{ticker}') + await self._send(self._lasts[key]['sec_type'], 'unsubscribe', f'{aggs}.{ticker}') + + async def _send(self, sec_type, action, params): + while 1: + with self._lock: + ws = self._ws[sec_type] + if ws: + break + await asyncio.sleep(0.1) + await ws.send(json.dumps({'action': action, 'params': params})) + + async def _handle_tick(self, sec_type, data): + data['ticker_key'] = { + 'stocks': 'sym', + 'options': 'sym', + 'indices': 'T', + 'forex': 'p', + 'crypto': 'pair', + }[sec_type] + key = data[data['ticker_key']].replace('/', '-') + if ':' in key: + key = key[key.index(':')+1:] + data['t'] = pd.to_datetime(data.pop('s'), unit='ms') if 't' not in data else pd.to_datetime(data['t'], unit='ms') + + if data['ev'] in ('Q', 'V', 'C', 'XQ'): + self._lasts[key]['time'] = data['t'] + if sec_type == 'forex': + data['bp'] = data.pop('b') + data['ap'] = data.pop('a') + self._lasts[key]['price'] = (data['bp']+data['ap'])/2 if sec_type != 'indices' else data['val'] + self._lasts[key]['volume'] = 0 + elif data['ev'] in ('A', 'CA', 'XA'): + self._lasts[key]['volume'] = data['v'] + if not self._lasts[key].get('time'): + return + for chart in self._lasts[key]['charts']: + self._q.put((chart.update_from_tick, pd.Series(self._lasts[key]), True)) + + async def _websocket_connect(self, api_key, sec_type): + if websockets is None: + raise ImportError('The "websockets" library was not found, and must be installed to pull live data.') + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + max_ticks = 20 + async with websockets.connect(f'wss://socket.polygon.io/{sec_type}', ssl=ssl_context) as ws: + with self._lock: + self._ws[sec_type] = ws + await self._send(sec_type, 'auth', api_key) + while 1: + response = await ws.recv() + data_list: List[dict] = json.loads(response) + for i, data in enumerate(data_list): + if data['ev'] == 'status': + self._log.info(f'{data["message"]}') + continue + elif data_list.index(data) < len(data_list)-max_ticks: + continue + await self._handle_tick(sec_type, data) + + +class PolygonAPISubChart(PolygonAPI): + def __init__(self, polygon, subchart): + super().__init__(subchart) + self._set = polygon._set + + +class PolygonChart(Chart): + def __init__(self, api_key: str, live: bool = False, num_bars: int = 200, limit: int = 5_000, + timeframe_options: tuple = ('1min', '5min', '30min', 'D', 'W'), + security_options: tuple = ('Stock', 'Option', 'Index', 'Forex', 'Crypto'), + width: int = 800, height: int = 600, x: int = None, y: int = None, on_top: bool = False, debug=False): + super().__init__(volume_enabled=True, width=width, height=height, x=x, y=y, on_top=on_top, debug=debug, + api=self, topbar=True, searchbox=True) + self.chart = self + self.num_bars = num_bars + self.limit = limit + self.live = live + self.polygon.api_key(api_key) + + self.topbar.active_background_color = 'rgb(91, 98, 246)' + self.topbar.textbox('symbol') + self.topbar.switcher('timeframe', self.on_timeframe_selection, *timeframe_options) + self.topbar.switcher('security', self.on_security_selection, *security_options) + self.legend(True) + self.grid(False, False) + self.crosshair(vert_visible=False, horz_visible=False) + self.run_script(f''' + {self.id}.search.box.style.backgroundColor = 'rgba(91, 98, 246, 0.5)' + {self.id}.spinner.style.borderTop = '4px solid rgba(91, 98, 246, 0.8)' + + {self.id}.search.window.style.display = "block" + {self.id}.search.box.focus() + + window.stat = document.createElement('div') + window.stat.style.position = 'absolute' + window.stat.style.backgroundColor = '#E35C58' + window.stat.style.borderRadius = '50%' + window.stat.style.height = '8px' + window.stat.style.width = '8px' + window.stat.style.top = '10px' + window.stat.style.right = '25px' + {self.id}.topBar.appendChild(window.stat) + ''') + + def show(self): + """ + Shows the PolygonChart window (this method will block). + """ + asyncio.run(self.show_async(block=True)) + + def _polygon(self, symbol): + self.spinner(True) + self.set(pd.DataFrame()) + self.crosshair(vert_visible=False, horz_visible=False) + if self.topbar['symbol'].value and self.topbar['symbol'].value != symbol: + self.polygon.unsubscribe(self.topbar['symbol'].value) + + mult, span = _convert_timeframe(self.topbar['timeframe'].value) + delta = dt.timedelta(**{span + 's': int(mult)}) + start_date = dt.datetime.now() + remaining_bars = self.num_bars + while remaining_bars > 0: + start_date -= delta + if start_date.weekday() > 4: # Monday to Friday (0 to 4) + continue + remaining_bars -= 1 + epoch = dt.datetime.fromtimestamp(0) + start_date = epoch if start_date < epoch else start_date + success = getattr(self.polygon, self.topbar['security'].value.lower())( + symbol, + timeframe=self.topbar['timeframe'].value, + start_date=start_date.strftime('%Y-%m-%d'), + limit=self.limit, + live=self.live + ) + self.spinner(False) + self.crosshair(vert_visible=True, horz_visible=True) if success else None + if not success: + self.run_script(f'window.stat.style.backgroundColor = "#E35C58"') + return False + self.run_script(f'window.stat.style.backgroundColor = "#4CDE67"') if self.live else None + return True + + async def on_search(self, searched_string): + self.topbar['symbol'].set(searched_string if self._polygon(searched_string) else '') + + async def on_timeframe_selection(self): + self._polygon(self.topbar['symbol'].value) + + async def on_security_selection(self): + sec_type = self.topbar['security'].value + self.volume_enabled = False if sec_type == 'Index' else True + + precision = 5 if sec_type == 'Forex' else 2 + min_move = 1 / (10 ** precision) # 2 -> 0.1, 5 -> 0.00005 etc. + self.run_script(f''' + {self.chart.id}.series.applyOptions({{ + priceFormat: {{precision: {precision}, minMove: {min_move}}} + }})''') + + + + diff --git a/lightweight_charts/util.py b/lightweight_charts/util.py index b649750..c8c000f 100644 --- a/lightweight_charts/util.py +++ b/lightweight_charts/util.py @@ -1,3 +1,4 @@ +import re from random import choices from string import ascii_lowercase from typing import Literal @@ -73,4 +74,20 @@ def _marker_position(p: MARKER_POSITION): 'below': 'belowBar', 'inside': 'inBar', None: None, - }[p] \ No newline at end of file + }[p] + + +def _convert_timeframe(timeframe): + spans = { + 'min': 'minute', + 'H': 'hour', + 'D': 'day', + 'W': 'week', + 'M': 'month', + } + try: + multiplier = re.findall(r'\d+', timeframe)[0] + except IndexError: + return 1, spans[timeframe] + timespan = spans[timeframe.replace(multiplier, '')] + return multiplier, timespan \ No newline at end of file diff --git a/lightweight_charts/widgets.py b/lightweight_charts/widgets.py index 0fe5e84..d06248a 100644 --- a/lightweight_charts/widgets.py +++ b/lightweight_charts/widgets.py @@ -35,10 +35,14 @@ from lightweight_charts.js import LWC, TopBar, CALLBACK_SCRIPT def _widget_message(chart, string): messages = string.split('__') name, chart_id = messages[:2] - args = messages[2:] + arg = messages[2] chart.api.chart = chart._charts[chart_id] method = getattr(chart.api, name) - asyncio.create_task(getattr(chart.api, name)(*args)) if iscoroutinefunction(method) else method(*args) + if widget := chart.api.chart.topbar._widget_with_method(name): + widget.value = arg + asyncio.create_task(getattr(chart.api, name)()) if iscoroutinefunction(method) else method() + else: + asyncio.create_task(getattr(chart.api, name)(arg)) if iscoroutinefunction(method) else method(arg) class WxChart(LWC):