bn-lightweight-charts 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1001 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ from base64 import b64decode
5
+ from datetime import datetime
6
+ from typing import Callable, Union, Literal, List, Optional
7
+ import pandas as pd
8
+
9
+ from .table import Table
10
+ from .toolbox import ToolBox
11
+ from .drawings import Box, HorizontalLine, RayLine, TrendLine, TwoPointDrawing, VerticalLine, VerticalSpan
12
+ from .topbar import TopBar
13
+ from .util import (
14
+ BulkRunScript, Pane, Events, IDGen, as_enum, jbool, js_json, TIME, NUM, FLOAT,
15
+ LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE,
16
+ PRICE_SCALE_MODE, marker_position, marker_shape, js_data,
17
+ )
18
+
19
+ current_dir = os.path.dirname(os.path.abspath(__file__))
20
+ INDEX = os.path.join(current_dir, 'js', 'index.html')
21
+ INDEX_BN = os.path.join(current_dir, 'js', 'index_bn.html')
22
+
23
+
24
+ class Window:
25
+ _id_gen = IDGen()
26
+ handlers = {}
27
+
28
+ def __init__(
29
+ self,
30
+ script_func: Optional[Callable] = None,
31
+ js_api_code: Optional[str] = None,
32
+ run_script: Optional[Callable] = None
33
+ ):
34
+ self.loaded = False
35
+ self.script_func = script_func
36
+ self.scripts = []
37
+ self.final_scripts = []
38
+ self.bulk_run = BulkRunScript(script_func)
39
+
40
+ if run_script:
41
+ self.run_script = run_script
42
+
43
+ if js_api_code:
44
+ self.run_script(f'window.callbackFunction = {js_api_code}')
45
+
46
+ def on_js_load(self):
47
+ if self.loaded:
48
+ return
49
+ self.loaded = True
50
+
51
+ if hasattr(self, '_return_q'):
52
+ while not self.run_script_and_get('document.readyState == "complete"'):
53
+ continue # scary, but works
54
+
55
+ initial_script = ''
56
+ self.scripts.extend(self.final_scripts)
57
+ for script in self.scripts:
58
+ initial_script += f'\n{script}'
59
+ self.script_func(initial_script)
60
+
61
+ def run_script(self, script: str, run_last: bool = False):
62
+ """
63
+ For advanced users; evaluates JavaScript within the Webview.
64
+ """
65
+ if self.script_func is None:
66
+ raise AttributeError("script_func has not been set")
67
+ if self.loaded:
68
+ if self.bulk_run.enabled:
69
+ self.bulk_run.add_script(script)
70
+ else:
71
+ self.script_func(script)
72
+ elif run_last:
73
+ self.final_scripts.append(script)
74
+ else:
75
+ self.scripts.append(script)
76
+
77
+ def run_script_and_get(self, script: str):
78
+ self.run_script(f'_~_~RETURN~_~_{script}')
79
+ return self._return_q.get()
80
+
81
+ def create_table(
82
+ self,
83
+ width: NUM,
84
+ height: NUM,
85
+ headings: tuple,
86
+ widths: Optional[tuple] = None,
87
+ alignments: Optional[tuple] = None,
88
+ position: FLOAT = 'left',
89
+ draggable: bool = False,
90
+ background_color: str = '#121417',
91
+ border_color: str = 'rgb(70, 70, 70)',
92
+ border_width: int = 1,
93
+ heading_text_colors: Optional[tuple] = None,
94
+ heading_background_colors: Optional[tuple] = None,
95
+ return_clicked_cells: bool = False,
96
+ func: Optional[Callable] = None
97
+ ) -> 'Table':
98
+ return Table(*locals().values())
99
+
100
+ def create_subchart(
101
+ self,
102
+ position: FLOAT = 'left',
103
+ width: float = 0.5,
104
+ height: float = 0.5,
105
+ sync_id: Optional[str] = None,
106
+ scale_candles_only: bool = False,
107
+ sync_crosshairs_only: bool = False,
108
+ toolbox: bool = False
109
+ ) -> 'AbstractChart':
110
+ subchart = AbstractChart(
111
+ self, width, height, scale_candles_only, toolbox, position=position
112
+ )
113
+ if not sync_id:
114
+ return subchart
115
+ # self.run_script(f'''
116
+ # Lib.Handler.syncCharts(
117
+ # {subchart.id},
118
+ # {sync_id},
119
+ # {jbool(sync_crosshairs_only)}
120
+ # )
121
+ # ''', run_last=True
122
+ # )
123
+ return subchart
124
+
125
+ def style(
126
+ self,
127
+ background_color: str = '#0c0d0f',
128
+ hover_background_color: str = '#3c434c',
129
+ click_background_color: str = '#50565E',
130
+ active_background_color: str = 'rgba(0, 122, 255, 0.7)',
131
+ muted_background_color: str = 'rgba(0, 122, 255, 0.3)',
132
+ border_color: str = '#3C434C',
133
+ color: str = '#d8d9db',
134
+ active_color: str = '#ececed'
135
+ ):
136
+ self.run_script(f'Lib.Handler.setRootStyles({js_json(locals())});')
137
+
138
+
139
+ class SeriesCommon(Pane):
140
+ def __init__(self, chart: 'AbstractChart', name: str = '', pane_index: int = 0):
141
+ super().__init__(chart.win)
142
+ self._chart = chart
143
+ if hasattr(chart, '_interval'):
144
+ self._interval = chart._interval
145
+ else:
146
+ self._interval = 1
147
+ self._last_bar = None
148
+ self.name = name
149
+ self.num_decimals = 2
150
+ self.offset = 0
151
+ self.data = pd.DataFrame()
152
+ self.markers = {}
153
+ self.pane_index = pane_index
154
+
155
+ def _set_interval(self, df: pd.DataFrame):
156
+ if not pd.api.types.is_datetime64_any_dtype(df['time']):
157
+ df['time'] = pd.to_datetime(df['time'])
158
+ common_interval = df['time'].diff().value_counts()
159
+ if common_interval.empty:
160
+ return
161
+ self._interval = common_interval.index[0].total_seconds()
162
+
163
+ units = [
164
+ pd.Timedelta(microseconds=df['time'].dt.microsecond.value_counts().index[0]),
165
+ pd.Timedelta(seconds=df['time'].dt.second.value_counts().index[0]),
166
+ pd.Timedelta(minutes=df['time'].dt.minute.value_counts().index[0]),
167
+ pd.Timedelta(hours=df['time'].dt.hour.value_counts().index[0]),
168
+ pd.Timedelta(days=df['time'].dt.day.value_counts().index[0]),
169
+ ]
170
+ self.offset = 0
171
+ for value in units:
172
+ value = value.total_seconds()
173
+ if value == 0:
174
+ continue
175
+ elif value >= self._interval:
176
+ break
177
+ self.offset = value
178
+ break
179
+
180
+ @staticmethod
181
+ def _format_labels(data, labels, index, exclude_lowercase):
182
+ def rename(la, mapper):
183
+ return [mapper[key] if key in mapper else key for key in la]
184
+
185
+ if 'date' not in labels and 'time' not in labels:
186
+ labels = labels.str.lower()
187
+ if exclude_lowercase:
188
+ labels = rename(labels, {exclude_lowercase.lower(): exclude_lowercase})
189
+ if 'date' in labels:
190
+ labels = rename(labels, {'date': 'time'})
191
+ elif 'time' not in labels:
192
+ data['time'] = index
193
+ labels = [*labels, 'time']
194
+ return labels
195
+
196
+ def _df_datetime_format(self, df: pd.DataFrame, exclude_lowercase=None):
197
+ df = df.copy()
198
+ df.columns = self._format_labels(df, df.columns, df.index, exclude_lowercase)
199
+ self._set_interval(df)
200
+ if not pd.api.types.is_datetime64_any_dtype(df['time']):
201
+ df['time'] = pd.to_datetime(df['time'])
202
+ df['time'] = df['time'].astype('int64') // 10**9
203
+ return df
204
+
205
+ def _series_datetime_format(self, series: pd.Series, exclude_lowercase=None):
206
+ series = series.copy()
207
+ series.index = self._format_labels(series, series.index, series.name, exclude_lowercase)
208
+ series['time'] = self._single_datetime_format(series['time'])
209
+ return series
210
+
211
+ def _single_datetime_format(self, arg) -> float:
212
+ if isinstance(arg, (str, int, float)) or not pd.api.types.is_datetime64_any_dtype(arg):
213
+ try:
214
+ arg = pd.to_datetime(arg, unit='ms')
215
+ except ValueError:
216
+ arg = pd.to_datetime(arg)
217
+ arg = self._interval * (arg.timestamp() // self._interval)+self.offset
218
+ return arg
219
+
220
+ def set(self, df: Optional[pd.DataFrame] = None, format_cols: bool = True):
221
+ if df is None or df.empty:
222
+ self.run_script(f'{self.id}.series.setData([])')
223
+ self.data = pd.DataFrame()
224
+ return
225
+ if format_cols:
226
+ df = self._df_datetime_format(df, exclude_lowercase=self.name)
227
+ if self.name:
228
+ if self.name not in df:
229
+ raise NameError(f'No column named "{self.name}".')
230
+ df = df.rename(columns={self.name: 'value'})
231
+ self.data = df.copy()
232
+ self._last_bar = df.iloc[-1]
233
+ self.run_script(f'{self.id}.series.setData({js_data(df)}); ')
234
+
235
+ def update(self, series: pd.Series):
236
+ series = self._series_datetime_format(series, exclude_lowercase=self.name)
237
+ if self.name in series.index:
238
+ series.rename({self.name: 'value'}, inplace=True)
239
+ if self._last_bar is not None and series['time'] != self._last_bar['time']:
240
+ self.data.loc[self.data.index[-1]] = self._last_bar
241
+ self.data = pd.concat([self.data, series.to_frame().T], ignore_index=True)
242
+ self._last_bar = series
243
+ self.run_script(f'{self.id}.series.update({js_data(series)})')
244
+
245
+ def _update_markers(self):
246
+ self.run_script(f'{self.id}.seriesMarkers.setMarkers({json.dumps(list(self.markers.values()))})')
247
+
248
+ def marker_list(self, markers: list):
249
+ """
250
+ Creates multiple markers.\n
251
+ :param markers: The list of markers to set. These should be in the format:\n
252
+ [
253
+ {"time": "2021-01-21", "position": "below", "shape": "circle", "color": "#2196F3", "text": ""},
254
+ {"time": "2021-01-22", "position": "below", "shape": "circle", "color": "#2196F3", "text": ""},
255
+ ...
256
+ ]
257
+ :return: a list of marker ids.
258
+ """
259
+ markers = markers.copy()
260
+ marker_ids = []
261
+ for marker in markers:
262
+ marker_id = self.win._id_gen.generate()
263
+ self.markers[marker_id] = {
264
+ "time": self._single_datetime_format(marker['time']),
265
+ "position": marker_position(marker['position']),
266
+ "color": marker['color'],
267
+ "shape": marker_shape(marker['shape']),
268
+ "text": marker['text'],
269
+ "price": marker.get('price', None),
270
+ "size": marker.get('size', 1),
271
+ }
272
+ marker_ids.append(marker_id)
273
+ self._update_markers()
274
+ return marker_ids
275
+
276
+ def _clear_marker_list(self):
277
+ self.markers = {}
278
+
279
+ def marker(self, time: Optional[datetime] = None, position: MARKER_POSITION = 'below',
280
+ shape: MARKER_SHAPE = 'arrow_up', color: str = '#2196F3', text: str = ''
281
+ ) -> str:
282
+ """
283
+ Creates a new marker.\n
284
+ :param time: Time location of the marker. If no time is given, it will be placed at the last bar.
285
+ :param position: The position of the marker.
286
+ :param color: The color of the marker (rgb, rgba or hex).
287
+ :param shape: The shape of the marker.
288
+ :param text: The text to be placed with the marker.
289
+ :return: The id of the marker placed.
290
+ """
291
+ try:
292
+ formatted_time = self._last_bar['time'] if not time else self._single_datetime_format(time)
293
+ except TypeError:
294
+ raise TypeError('Chart marker created before data was set.')
295
+ marker_id = self.win._id_gen.generate()
296
+
297
+ self.markers[marker_id] = {
298
+ "time": int(formatted_time),
299
+ "position": marker_position(position),
300
+ "color": color,
301
+ "shape": marker_shape(shape),
302
+ "text": text,
303
+ }
304
+ self._update_markers()
305
+ return marker_id
306
+
307
+ def remove_marker(self, marker_id: str):
308
+ """
309
+ Removes the marker with the given id.\n
310
+ """
311
+ self.markers.pop(marker_id)
312
+ self._update_markers()
313
+
314
+ def horizontal_line(self, price: NUM, color: str = 'rgb(122, 146, 202)', width: int = 2,
315
+ style: LINE_STYLE = 'solid', text: str = '', axis_label_visible: bool = True,
316
+ func: Optional[Callable] = None
317
+ ) -> 'HorizontalLine':
318
+ """
319
+ Creates a horizontal line at the given price.
320
+ """
321
+ return HorizontalLine(self, price, color, width, style, text, axis_label_visible, func)
322
+
323
+ def trend_line(
324
+ self,
325
+ start_time: TIME,
326
+ start_value: NUM,
327
+ end_time: TIME,
328
+ end_value: NUM,
329
+ round: bool = False,
330
+ line_color: str = '#1E80F0',
331
+ width: int = 2,
332
+ style: LINE_STYLE = 'solid',
333
+ ) -> TwoPointDrawing:
334
+ return TrendLine(*locals().values())
335
+
336
+ def box(
337
+ self,
338
+ start_time: TIME,
339
+ start_value: NUM,
340
+ end_time: TIME,
341
+ end_value: NUM,
342
+ round: bool = False,
343
+ color: str = '#1E80F0',
344
+ fill_color: str = 'rgba(255, 255, 255, 0.2)',
345
+ width: int = 2,
346
+ style: LINE_STYLE = 'solid',
347
+ ) -> TwoPointDrawing:
348
+ return Box(*locals().values())
349
+
350
+ def ray_line(
351
+ self,
352
+ start_time: TIME,
353
+ value: NUM,
354
+ round: bool = False,
355
+ color: str = '#1E80F0',
356
+ width: int = 2,
357
+ style: LINE_STYLE = 'solid',
358
+ text: str = ''
359
+ ) -> RayLine:
360
+ # TODO
361
+ return RayLine(*locals().values())
362
+
363
+ def vertical_line(
364
+ self,
365
+ time: TIME,
366
+ color: str = '#1E80F0',
367
+ width: int = 2,
368
+ style: LINE_STYLE ='solid',
369
+ text: str = ''
370
+ ) -> VerticalLine:
371
+ return VerticalLine(*locals().values())
372
+
373
+ def clear_markers(self):
374
+ """
375
+ Clears the markers displayed on the data.\n
376
+ """
377
+ self.markers.clear()
378
+ self._update_markers()
379
+
380
+ def create_price_line(self, price: float = 0.0, color: str = 'rgba(214, 237, 255, 0.6)',
381
+ style: LINE_STYLE = 'large_dashed', width: int = 1, price_label: bool = False,
382
+ title: str = ''):
383
+ self.run_script(f'''
384
+ {self.id}.series.createPriceLine(
385
+ {{
386
+ price: {price},
387
+ color: '{color}',
388
+ lineStyle: {as_enum(style, LINE_STYLE)},
389
+ lineWidth: {width},
390
+ axisLabelVisible: {jbool(price_label)},
391
+ title: '{title}',
392
+ }},
393
+ )
394
+ ''')
395
+
396
+ def precision(self, precision: int):
397
+ """
398
+ Sets the precision and minMove.\n
399
+ :param precision: The number of decimal places.
400
+ """
401
+ min_move = 1 / (10**precision)
402
+ self.run_script(f'''
403
+ {self.id}.series.applyOptions({{
404
+ priceFormat: {{precision: {precision}, minMove: {min_move}}}
405
+ }})''')
406
+ self.num_decimals = precision
407
+
408
+ def hide_data(self):
409
+ self._toggle_data(False)
410
+
411
+ def show_data(self):
412
+ self._toggle_data(True)
413
+
414
+ def _toggle_data(self, arg):
415
+ self.run_script(f'''
416
+ {self.id}.series.applyOptions({{visible: {jbool(arg)}}})
417
+ if ('volumeSeries' in {self.id}) {self.id}.volumeSeries.applyOptions({{visible: {jbool(arg)}}})
418
+ ''')
419
+
420
+ def vertical_span(
421
+ self,
422
+ start_time: Union[TIME, tuple, list],
423
+ end_time: Optional[TIME] = None,
424
+ color: str = 'rgba(252, 219, 3, 0.2)',
425
+ round: bool = False
426
+ ):
427
+ """
428
+ Creates a vertical line or span across the chart.\n
429
+ Start time and end time can be used together, or end_time can be
430
+ omitted and a single time or a list of times can be passed to start_time.
431
+ """
432
+ if round:
433
+ start_time = self._single_datetime_format(start_time)
434
+ end_time = self._single_datetime_format(end_time) if end_time else None
435
+ return VerticalSpan(self, start_time, end_time, color)
436
+
437
+
438
+ class Line(SeriesCommon):
439
+ def __init__(self, chart, name, color, style, width, price_line, price_label, price_scale_id=None,
440
+ crosshair_marker=True, pane_index: int = 0,
441
+ ):
442
+
443
+ super().__init__(chart, name, pane_index)
444
+ self.color = color
445
+
446
+ self.run_script(f'''
447
+ {self.id} = {self._chart.id}.createLineSeries(
448
+ "{name}",
449
+ {{
450
+ color: '{color}',
451
+ lineStyle: {as_enum(style, LINE_STYLE)},
452
+ lineWidth: {width},
453
+ lastValueVisible: {jbool(price_label)},
454
+ priceLineVisible: {jbool(price_line)},
455
+ crosshairMarkerVisible: {jbool(crosshair_marker)},
456
+ priceScaleId: {f'"{price_scale_id}"' if price_scale_id else 'undefined'},
457
+ {"""autoscaleInfoProvider: () => ({
458
+ priceRange: {
459
+ minValue: 1_000_000_000,
460
+ maxValue: 0,
461
+ },
462
+ }),
463
+ """ if chart._scale_candles_only else ''}
464
+ }},
465
+ {pane_index}
466
+ )
467
+ null''')
468
+
469
+ def delete(self):
470
+ """
471
+ Irreversibly deletes the line, as well as the object that contains the line.
472
+ """
473
+ self._chart._lines.remove(self) if self in self._chart._lines else None
474
+ self.run_script(f'''
475
+ {self.id}legendItem = {self._chart.id}.legend._lines.find((line) => line.series == {self.id}.series)
476
+ {self._chart.id}.legend._lines = {self._chart.id}.legend._lines.filter((item) => item != {self.id}legendItem)
477
+
478
+ if ({self.id}legendItem) {{
479
+ {self._chart.id}.legend.div.removeChild({self.id}legendItem.row)
480
+ }}
481
+
482
+ {self._chart.id}.chart.removeSeries({self.id}.series)
483
+ delete {self.id}legendItem
484
+ delete {self.id}
485
+ ''')
486
+
487
+
488
+ class Histogram(SeriesCommon):
489
+ def __init__(self, chart, name, color, price_line, price_label, scale_margin_top, scale_margin_bottom,
490
+ pane_index: int = 0
491
+ ):
492
+ super().__init__(chart, name, pane_index)
493
+ self.color = color
494
+ self.run_script(f'''
495
+ {self.id} = {chart.id}.createHistogramSeries(
496
+ "{name}",
497
+ {{
498
+ color: '{color}',
499
+ lastValueVisible: {jbool(price_label)},
500
+ priceLineVisible: {jbool(price_line)},
501
+ priceScaleId: {'undefined'},
502
+ priceFormat: {{type: "volume"}},
503
+ }},
504
+ {pane_index}
505
+ )
506
+ {self.id}.series.priceScale().applyOptions({{
507
+ scaleMargins: {{top:{scale_margin_top}, bottom: {scale_margin_bottom}}}
508
+ }})''')
509
+
510
+ def delete(self):
511
+ """
512
+ Irreversibly deletes the histogram.
513
+ """
514
+ self.run_script(f'''
515
+ {self.id}legendItem = {self._chart.id}.legend._lines.find((line) => line.series == {self.id}.series)
516
+ {self._chart.id}.legend._lines = {self._chart.id}.legend._lines.filter((item) => item != {self.id}legendItem)
517
+
518
+ if ({self.id}legendItem) {{
519
+ {self._chart.id}.legend.div.removeChild({self.id}legendItem.row)
520
+ }}
521
+
522
+ {self._chart.id}.chart.removeSeries({self.id}.series)
523
+ delete {self.id}legendItem
524
+ delete {self.id}
525
+ ''')
526
+
527
+ def scale(self, scale_margin_top: float = 0.0, scale_margin_bottom: float = 0.0):
528
+ self.run_script(f'''
529
+ {self.id}.series.priceScale().applyOptions({{
530
+ scaleMargins: {{top: {scale_margin_top}, bottom: {scale_margin_bottom}}}
531
+ }})''')
532
+
533
+
534
+ class Candlestick(SeriesCommon):
535
+ def __init__(self, chart: 'AbstractChart'):
536
+ super().__init__(chart)
537
+ self._volume_up_color = 'rgba(83,141,131,0.8)'
538
+ self._volume_down_color = 'rgba(200,127,130,0.8)'
539
+
540
+ self.candle_data = pd.DataFrame()
541
+ # self.run_script(f'{self.id}.makeCandlestickSeries()')
542
+
543
+ def _prepare_data(self, df: pd.DataFrame):
544
+ if df is None or df.empty:
545
+ return None, None
546
+ df = self._df_datetime_format(df)
547
+ candle_df = df[['time', 'open', 'high', 'low', 'close']]
548
+
549
+ if 'volume' not in df:
550
+ return candle_df, None
551
+ volume = df[['time','volume']].rename(columns={'volume': 'value'})
552
+ volume['color'] = self._volume_down_color
553
+ volume.loc[df['close'] > df['open'], 'color'] = self._volume_up_color
554
+ return candle_df, volume
555
+
556
+ def set(self, df: Optional[pd.DataFrame] = None, keep_drawings=False):
557
+ """
558
+ Sets the initial data for the chart.\n
559
+ :param df: columns: date/time, open, high, low, close, volume (if volume enabled).
560
+ :param keep_drawings: keeps any drawings made through the toolbox. Otherwise, they will be deleted.
561
+ """
562
+ if df is None or df.empty:
563
+ self.run_script(f'{self.id}.series.setData([])')
564
+ self.run_script(f'{self.id}.volumeSeries.setData([])')
565
+ self.candle_data = pd.DataFrame()
566
+ return
567
+ df = self._df_datetime_format(df)
568
+ df_copy = df.copy()
569
+ self.candle_data = df_copy[['time', 'open', 'high', 'low', 'close']]
570
+ self._last_bar = df.iloc[-1]
571
+ candle_js_data = js_data(df[['time', 'open', 'high', 'low', 'close']])
572
+ self.run_script(f'{self.id}.series.setData({candle_js_data})')
573
+
574
+ if 'volume' not in df:
575
+ return
576
+ volume = df[['time', 'volume']].rename(columns={'volume': 'value'})
577
+ volume['color'] = self._volume_down_color
578
+ volume.loc[df['close'] > df['open'], 'color'] = self._volume_up_color
579
+ volume_js_data = js_data(volume)
580
+ self.run_script(f'{self.id}.volumeSeries.setData({volume_js_data})')
581
+
582
+ for line in self._lines:
583
+ if line.name not in df.columns:
584
+ continue
585
+ line.set(df[['time', line.name]], format_cols=False)
586
+ # set autoScale to true in case the user has dragged the price scale
587
+ self.run_script(f'''
588
+ if (!{self.id}.chart.priceScale("right").options.autoScale)
589
+ {self.id}.chart.priceScale("right").applyOptions({{autoScale: true}})
590
+ ''')
591
+ # TODO keep drawings doesn't work consistenly w
592
+ if keep_drawings:
593
+ self.run_script(f'{self._chart.id}.toolBox?._drawingTool.repositionOnTime()')
594
+ else:
595
+ self.run_script(f"{self._chart.id}.toolBox?.clearDrawings()")
596
+
597
+ def update(self, series: pd.Series, _from_tick=False):
598
+ """
599
+ Updates the data from a bar;
600
+ if series['time'] is the same time as the last bar, the last bar will be overwritten.\n
601
+ :param series: labels: date/time, open, high, low, close, volume (if using volume).
602
+ """
603
+ series = self._series_datetime_format(series) if not _from_tick else series
604
+ if series['time'] != self._last_bar['time']:
605
+ self.candle_data.loc[self.candle_data.index[-1]] = self._last_bar
606
+ self.candle_data = pd.concat([self.candle_data, series.to_frame().T], ignore_index=True)
607
+ self._chart.events.new_bar._emit(self)
608
+
609
+ self._last_bar = series
610
+ self.run_script(f'{self.id}.series.update({js_data(series)})')
611
+ if 'volume' not in series:
612
+ return
613
+ volume = series.drop(['open', 'high', 'low', 'close']).rename({'volume': 'value'})
614
+ volume['color'] = self._volume_up_color if series['close'] > series['open'] else self._volume_down_color
615
+ self.run_script(f'{self.id}.volumeSeries.update({js_data(volume)})')
616
+
617
+ def update_from_tick(self, series: pd.Series, cumulative_volume: bool = False):
618
+ """
619
+ Updates the data from a tick.\n
620
+ :param series: labels: date/time, price, volume (if using volume).
621
+ :param cumulative_volume: Adds the given volume onto the latest bar.
622
+ """
623
+ series = self._series_datetime_format(series)
624
+ if series['time'] < self._last_bar['time']:
625
+ raise ValueError(f'Trying to update tick of time "{pd.to_datetime(series["time"])}", which occurs before the last bar time of "{pd.to_datetime(self._last_bar["time"])}".')
626
+ bar = pd.Series(dtype='float64')
627
+ if series['time'] == self._last_bar['time']:
628
+ bar = self._last_bar
629
+ bar['high'] = max(self._last_bar['high'], series['price'])
630
+ bar['low'] = min(self._last_bar['low'], series['price'])
631
+ bar['close'] = series['price']
632
+ if 'volume' in series:
633
+ if cumulative_volume:
634
+ bar['volume'] += series['volume']
635
+ else:
636
+ bar['volume'] = series['volume']
637
+ else:
638
+ for key in ('open', 'high', 'low', 'close'):
639
+ bar[key] = series['price']
640
+ bar['time'] = series['time']
641
+ if 'volume' in series:
642
+ bar['volume'] = series['volume']
643
+ self.update(bar, _from_tick=True)
644
+
645
+ def price_scale(
646
+ self,
647
+ auto_scale: bool = True,
648
+ mode: PRICE_SCALE_MODE = 'normal',
649
+ invert_scale: bool = False,
650
+ align_labels: bool = True,
651
+ scale_margin_top: float = 0.2,
652
+ scale_margin_bottom: float = 0.2,
653
+ border_visible: bool = False,
654
+ border_color: Optional[str] = None,
655
+ text_color: Optional[str] = None,
656
+ entire_text_only: bool = False,
657
+ visible: bool = True,
658
+ ticks_visible: bool = False,
659
+ minimum_width: int = 0,
660
+ perm_width: int = 0
661
+ ):
662
+ self.run_script(f'''
663
+ {self.id}.series.priceScale().applyOptions({{
664
+ autoScale: {jbool(auto_scale)},
665
+ mode: {as_enum(mode, PRICE_SCALE_MODE)},
666
+ invertScale: {jbool(invert_scale)},
667
+ alignLabels: {jbool(align_labels)},
668
+ scaleMargins: {{top: {scale_margin_top}, bottom: {scale_margin_bottom}}},
669
+ borderVisible: {jbool(border_visible)},
670
+ {f'borderColor: "{border_color}",' if border_color else ''}
671
+ {f'textColor: "{text_color}",' if text_color else ''}
672
+ entireTextOnly: {jbool(entire_text_only)},
673
+ visible: {jbool(visible)},
674
+ ticksVisible: {jbool(ticks_visible)},
675
+ minimumWidth: {minimum_width},
676
+ permWidth: {perm_width}
677
+ }})''')
678
+
679
+ def candle_style(
680
+ self, up_color: str = 'rgba(39, 157, 130, 100)', down_color: str = 'rgba(200, 97, 100, 100)',
681
+ wick_visible: bool = True, border_visible: bool = True, border_up_color: str = '',
682
+ border_down_color: str = '', wick_up_color: str = '', wick_down_color: str = ''):
683
+ """
684
+ Candle styling for each of its parts.\n
685
+ If only `up_color` and `down_color` are passed, they will color all parts of the candle.
686
+ """
687
+ border_up_color = border_up_color if border_up_color else up_color
688
+ border_down_color = border_down_color if border_down_color else down_color
689
+ wick_up_color = wick_up_color if wick_up_color else up_color
690
+ wick_down_color = wick_down_color if wick_down_color else down_color
691
+ self.run_script(f"{self.id}.series.applyOptions({js_json(locals())})")
692
+
693
+ def volume_config(self, scale_margin_top: float = 0.8, scale_margin_bottom: float = 0.0,
694
+ up_color='rgba(83,141,131,0.8)', down_color='rgba(200,127,130,0.8)'):
695
+ """
696
+ Configure volume settings.\n
697
+ Numbers for scaling must be greater than 0 and less than 1.\n
698
+ Volume colors must be applied prior to setting/updating the bars.\n
699
+ """
700
+ self._volume_up_color = up_color if up_color else self._volume_up_color
701
+ self._volume_down_color = down_color if down_color else self._volume_down_color
702
+ self.run_script(f'''
703
+ {self.id}.volumeSeries.priceScale().applyOptions({{
704
+ scaleMargins: {{
705
+ top: {scale_margin_top},
706
+ bottom: {scale_margin_bottom},
707
+ }}
708
+ }})''')
709
+
710
+
711
+ class AbstractChart(Candlestick, Pane):
712
+
713
+ def __init__(self, window: Window, width: float = 1.0, height: float = 1.0,
714
+ scale_candles_only: bool = False, toolbox: bool = False,
715
+ autosize: bool = True, position: FLOAT = 'left', pane_index:int = 0
716
+ ):
717
+ Pane.__init__(self, window)
718
+
719
+ self._lines = []
720
+ self.subcharts = []
721
+ self._scale_candles_only = scale_candles_only
722
+ self._width = width
723
+ self._height = height
724
+ self.events: Events = Events(self)
725
+
726
+ from .polygon import PolygonAPI
727
+
728
+ self.polygon: PolygonAPI = PolygonAPI(self)
729
+
730
+ self._html_chart_init = f'{self.id} = new Lib.Handler("{self.id}", {width}, {height}, "{position}", {jbool(autosize)})'
731
+ self.run_script(self._html_chart_init)
732
+
733
+ Candlestick.__init__(self, self)
734
+ self.subcharts.append(self.id)
735
+
736
+ self.topbar: TopBar = TopBar(self)
737
+ if toolbox:
738
+ self.toolbox: ToolBox = ToolBox(self)
739
+
740
+ def fit(self):
741
+ """
742
+ Fits the maximum amount of the chart data within the viewport.
743
+ """
744
+ self.run_script(f'{self.id}.chart.timeScale().fitContent()')
745
+
746
+ def create_line(
747
+ self, name: str = '', color: str = 'rgba(214, 237, 255, 0.6)',
748
+ style: LINE_STYLE = 'solid', width: int = 2,
749
+ price_line: bool = True, price_label: bool = True, price_scale_id: Optional[str] = None,
750
+ pane_index: int = 0
751
+ ) -> Line:
752
+ """
753
+ Creates and returns a Line object.
754
+ """
755
+ line = Line(self, name, color, style, width, price_line, price_label, price_scale_id, pane_index=pane_index)
756
+ self._lines.append(line)
757
+ return self._lines[-1]
758
+
759
+ def create_histogram(
760
+ self, name: str = '', color: str = 'rgba(214, 237, 255, 0.6)',
761
+ price_line: bool = True, price_label: bool = True,
762
+ scale_margin_top: float = 0.0, scale_margin_bottom: float = 0.0,
763
+ pane_index: int = 0,
764
+ ) -> Histogram:
765
+ """
766
+ Creates and returns a Histogram object.
767
+ """
768
+ hist = Histogram(self, name, color, price_line, price_label, scale_margin_top, scale_margin_bottom, pane_index)
769
+ return hist
770
+
771
+ def lines(self) -> List[Line]:
772
+ """
773
+ Returns all lines for the chart.
774
+ """
775
+ return self._lines.copy()
776
+
777
+ def set_visible_range(self, start_time: TIME, end_time: TIME):
778
+ self.run_script(f'''
779
+ {self.id}.chart.timeScale().setVisibleRange({{
780
+ from: {pd.to_datetime(start_time).timestamp()},
781
+ to: {pd.to_datetime(end_time).timestamp()}
782
+ }})
783
+ ''')
784
+
785
+ def resize(self, width: Optional[float] = None, height: Optional[float] = None):
786
+ """
787
+ Resizes the chart within the window.
788
+ Dimensions should be given as a float between 0 and 1.
789
+ """
790
+ self._width = width if width is not None else self._width
791
+ self._height = height if height is not None else self._height
792
+ self.run_script(f'''
793
+ {self.id}.scale.width = {self._width}
794
+ {self.id}.scale.height = {self._height}
795
+ {self.id}.reSize()
796
+ ''')
797
+
798
+ def time_scale(self, right_offset: int = 0, min_bar_spacing: float = 0.5,
799
+ visible: bool = True, time_visible: bool = True, seconds_visible: bool = False,
800
+ border_visible: bool = True, border_color: Optional[str] = None):
801
+ """
802
+ Options for the timescale of the chart.
803
+ """
804
+ self.run_script(f'''{self.id}.chart.applyOptions({{timeScale: {js_json(locals())}}})''')
805
+
806
+ def layout(self, background_color: str = '#000000', text_color: Optional[str] = None,
807
+ font_size: Optional[int] = None, font_family: Optional[str] = None):
808
+ """
809
+ Global layout options for the chart.
810
+ """
811
+ self.run_script(f"""
812
+ document.getElementById('container').style.backgroundColor = '{background_color}'
813
+ {self.id}.chart.applyOptions({{
814
+ layout: {{
815
+ background: {{color: "{background_color}"}},
816
+ {f'textColor: "{text_color}",' if text_color else ''}
817
+ {f'fontSize: {font_size},' if font_size else ''}
818
+ {f'fontFamily: "{font_family}",' if font_family else ''}
819
+ }}}})""")
820
+
821
+ def grid(self, vert_enabled: bool = True, horz_enabled: bool = True,
822
+ color: str = 'rgba(29, 30, 38, 5)', style: LINE_STYLE = 'solid'):
823
+ """
824
+ Grid styling for the chart.
825
+ """
826
+ self.run_script(f"""
827
+ {self.id}.chart.applyOptions({{
828
+ grid: {{
829
+ vertLines: {{
830
+ visible: {jbool(vert_enabled)},
831
+ color: "{color}",
832
+ style: {as_enum(style, LINE_STYLE)},
833
+ }},
834
+ horzLines: {{
835
+ visible: {jbool(horz_enabled)},
836
+ color: "{color}",
837
+ style: {as_enum(style, LINE_STYLE)},
838
+ }},
839
+ }}
840
+ }})""")
841
+
842
+ def crosshair(
843
+ self,
844
+ mode: CROSSHAIR_MODE = 'normal',
845
+ vert_visible: bool = True,
846
+ vert_width: int = 1,
847
+ vert_color: Optional[str] = None,
848
+ vert_style: LINE_STYLE = 'large_dashed',
849
+ vert_label_background_color: str = 'rgb(46, 46, 46)',
850
+ horz_visible: bool = True,
851
+ horz_width: int = 1,
852
+ horz_color: Optional[str] = None,
853
+ horz_style: LINE_STYLE = 'large_dashed',
854
+ horz_label_background_color: str = 'rgb(55, 55, 55)'
855
+ ):
856
+ """
857
+ Crosshair formatting for its vertical and horizontal axes.
858
+ """
859
+ self.run_script(f'''
860
+ {self.id}.chart.applyOptions({{
861
+ crosshair: {{
862
+ mode: {as_enum(mode, CROSSHAIR_MODE)},
863
+ vertLine: {{
864
+ visible: {jbool(vert_visible)},
865
+ width: {vert_width},
866
+ {f'color: "{vert_color}",' if vert_color else ''}
867
+ style: {as_enum(vert_style, LINE_STYLE)},
868
+ labelBackgroundColor: "{vert_label_background_color}"
869
+ }},
870
+ horzLine: {{
871
+ visible: {jbool(horz_visible)},
872
+ width: {horz_width},
873
+ {f'color: "{horz_color}",' if horz_color else ''}
874
+ style: {as_enum(horz_style, LINE_STYLE)},
875
+ labelBackgroundColor: "{horz_label_background_color}"
876
+ }}
877
+ }}
878
+ }})''')
879
+
880
+ def watermark(self, text: str, font_size: int = 44, color: str = 'rgba(180, 180, 200, 0.5)'):
881
+ """
882
+ Adds a watermark to the chart.
883
+ """
884
+ self.run_script(f'''{self._chart.id}.createWatermark('{text}', {font_size}, '{color}')''')
885
+
886
+ def legend(self, visible: bool = False, ohlc: bool = True, percent: bool = False, lines: bool = True,
887
+ color: str = 'rgb(191, 195, 203)', font_size: int = 11, font_family: str = 'Monaco',
888
+ text: str = '', color_based_on_candle: bool = False):
889
+ """
890
+ Configures the legend of the chart.
891
+ """
892
+ l_id = f'{self.id}.legend'
893
+ if not visible:
894
+ self.run_script(f'''
895
+ {l_id}.div.style.display = "none"
896
+ {l_id}.ohlcEnabled = false
897
+ {l_id}.percentEnabled = false
898
+ {l_id}.linesEnabled = false
899
+ ''')
900
+ return
901
+ self.run_script(f'''
902
+ {l_id}.div.style.display = 'flex'
903
+ {l_id}.ohlcEnabled = {jbool(ohlc)}
904
+ {l_id}.percentEnabled = {jbool(percent)}
905
+ {l_id}.linesEnabled = {jbool(lines)}
906
+ {l_id}.colorBasedOnCandle = {jbool(color_based_on_candle)}
907
+ {l_id}.div.style.color = '{color}'
908
+ {l_id}.color = '{color}'
909
+ {l_id}.div.style.fontSize = '{font_size}px'
910
+ {l_id}.div.style.fontFamily = '{font_family}'
911
+ {l_id}.text.innerText = '{text}'
912
+ ''')
913
+
914
+ def spinner(self, visible):
915
+ self.run_script(f"{self.id}.spinner.style.display = '{'block' if visible else 'none'}'")
916
+
917
+ def hotkey(self, modifier_key: Literal['ctrl', 'alt', 'shift', 'meta', None],
918
+ keys: Union[str, tuple, int], func: Callable):
919
+ if not isinstance(keys, tuple):
920
+ keys = (keys,)
921
+ for key in keys:
922
+ key = str(key)
923
+ if key.isalnum() and len(key) == 1:
924
+ key_code = f'Digit{key}' if key.isdigit() else f'Key{key.upper()}'
925
+ key_condition = f'event.code === "{key_code}"'
926
+ else:
927
+ key_condition = f'event.key === "{key}"'
928
+ if modifier_key is not None:
929
+ key_condition += f'&& event.{modifier_key}Key'
930
+
931
+ self.run_script(f'''
932
+ {self.id}.commandFunctions.unshift((event) => {{
933
+ if ({key_condition}) {{
934
+ event.preventDefault()
935
+ window.callbackFunction(`{modifier_key, keys}_~_{key}`)
936
+ return true
937
+ }}
938
+ else return false
939
+ }})''')
940
+ self.win.handlers[f'{modifier_key, keys}'] = func
941
+
942
+ def create_table(
943
+ self,
944
+ width: NUM,
945
+ height: NUM,
946
+ headings: tuple,
947
+ widths: Optional[tuple] = None,
948
+ alignments: Optional[tuple] = None,
949
+ position: FLOAT = 'left',
950
+ draggable: bool = False,
951
+ background_color: str = '#121417',
952
+ border_color: str = 'rgb(70, 70, 70)',
953
+ border_width: int = 1,
954
+ heading_text_colors: Optional[tuple] = None,
955
+ heading_background_colors: Optional[tuple] = None,
956
+ return_clicked_cells: bool = False,
957
+ func: Optional[Callable] = None
958
+ ) -> Table:
959
+ args = locals()
960
+ del args['self']
961
+ return self.win.create_table(*args.values())
962
+
963
+ def screenshot(self) -> bytes:
964
+ """
965
+ Takes a screenshot. This method can only be used after the chart window is visible.
966
+ :return: a bytes object containing a screenshot of the chart.
967
+ """
968
+ serial_data = self.win.run_script_and_get(f'{self.id}.chart.takeScreenshot().toDataURL()')
969
+ return b64decode(serial_data.split(',')[1])
970
+
971
+ def create_subchart(self, position: FLOAT = 'left', width: float = 0.5, height: float = 0.5,
972
+ sync: Optional[Union[str, bool]] = None, scale_candles_only: bool = False,
973
+ sync_crosshairs_only: bool = False,
974
+ toolbox: bool = False) -> 'AbstractChart':
975
+ if sync is True:
976
+ sync = self.id
977
+ chart = self.win.create_subchart(position, width, height, sync, scale_candles_only,
978
+ sync_crosshairs_only, toolbox)
979
+ self.subcharts.append(chart.id)
980
+ return chart
981
+
982
+ def sync_charts(self, sync_crosshairs_only: bool = False):
983
+ if (len(self.subcharts) > 1):
984
+ self.run_script(f'''
985
+ Lib.Handler.syncChartsAll
986
+ ([{', '.join(self.subcharts)}],
987
+ {'true' if sync_crosshairs_only else 'false'}
988
+ )
989
+ ''', run_last=True)
990
+
991
+ def resize_pane(self, pane_index: int, height: int):
992
+ self.run_script(f'''
993
+ if ({self.id}.chart.panes().length > {pane_index}) {{
994
+ {self.id}.chart.panes()[{pane_index}].setHeight({height});
995
+ }}
996
+ ''')
997
+
998
+ def remove_pane(self, pane_index: int):
999
+ self.run_script(f'''
1000
+ {self.id}.chart.removePane({pane_index});
1001
+ ''')