Polygon:
- Added async methods to polygon. - The `requests` library is no longer required, with `urllib` being used instead. - Added the `get_bar_data` function, which returns a dataframe of aggregate data from polygon. - Opened up the `subscribe` and `unsubscribe` functions Enhancements: - Tables will now scroll when the rows exceed table height. Bugs: - Fixed a bug preventing async functions being used with horizontal line event. - Fixed a bug causing the legend to show duplicate lines if the line was created after the legend. - Fixed a bug causing the line hide icon to persist within the legend after deletion (#75) - Fixed a bug causing the search box to be unfocused when the chart is loaded.
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
from .abstract import LWC
|
||||
from .abstract import AbstractChart, Window
|
||||
from .chart import Chart
|
||||
from .widgets import JupyterChart
|
||||
from .polygon import PolygonChart
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,42 +1,42 @@
|
||||
import asyncio
|
||||
import multiprocessing as mp
|
||||
from base64 import b64decode
|
||||
import webview
|
||||
|
||||
from lightweight_charts.abstract import LWC
|
||||
|
||||
|
||||
chart = None
|
||||
num_charts = 0
|
||||
from lightweight_charts import abstract
|
||||
from .util import parse_event_message
|
||||
|
||||
|
||||
class CallbackAPI:
|
||||
def __init__(self, emit_queue, return_queue):
|
||||
self.emit_q, self.return_q = emit_queue, return_queue
|
||||
def __init__(self, emit_queue):
|
||||
self.emit_q = emit_queue
|
||||
|
||||
def callback(self, message: str):
|
||||
name, args = message.split('_~_')
|
||||
self.return_q.put(*args) if name == 'return' else self.emit_q.put((name, args.split(';;;')))
|
||||
self.emit_q.put(message)
|
||||
|
||||
|
||||
class PyWV:
|
||||
def __init__(self, q, start: mp.Event, exit, loaded, html, width, height, x, y, on_top, maximize, debug, emit_queue, return_queue):
|
||||
if maximize:
|
||||
width, height = webview.screens[0].width, webview.screens[0].height
|
||||
def __init__(self, q, start_ev, exit_ev, loaded, emit_queue, return_queue, html, debug,
|
||||
width, height, x, y, on_top, maximize):
|
||||
self.queue = q
|
||||
self.exit = exit
|
||||
self.callback_api = CallbackAPI(emit_queue, return_queue)
|
||||
self.return_queue = return_queue
|
||||
self.exit = exit_ev
|
||||
self.callback_api = CallbackAPI(emit_queue)
|
||||
self.loaded: list = loaded
|
||||
self.html = html
|
||||
|
||||
self.windows = []
|
||||
self.create_window(html, on_top, width, height, x, y)
|
||||
self.create_window(width, height, x, y, on_top, maximize)
|
||||
|
||||
start.wait()
|
||||
start_ev.wait()
|
||||
webview.start(debug=debug)
|
||||
self.exit.set()
|
||||
|
||||
def create_window(self, html, on_top, width, height, x, y):
|
||||
def create_window(self, width, height, x, y, on_top, maximize):
|
||||
if maximize:
|
||||
width, height = webview.screens[0].width, webview.screens[0].height
|
||||
self.windows.append(webview.create_window(
|
||||
'', html=html, on_top=on_top, js_api=self.callback_api,
|
||||
'', html=self.html, on_top=on_top, js_api=self.callback_api,
|
||||
width=width, height=height, x=x, y=y, background_color='#000000'))
|
||||
self.windows[-1].events.loaded += lambda: self.loop(self.loaded[len(self.windows)-1])
|
||||
|
||||
@ -47,57 +47,62 @@ class PyWV:
|
||||
if i == 'create_window':
|
||||
self.create_window(*arg)
|
||||
elif arg in ('show', 'hide'):
|
||||
getattr(self.windows[i], arg)()
|
||||
getattr(self.windows[i], arg)()
|
||||
elif arg == 'exit':
|
||||
self.exit.set()
|
||||
else:
|
||||
try:
|
||||
self.windows[i].evaluate_js(arg)
|
||||
if '_~_~RETURN~_~_' in arg:
|
||||
self.return_queue.put(self.windows[i].evaluate_js(arg[14:]))
|
||||
else:
|
||||
self.windows[i].evaluate_js(arg)
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
|
||||
class Chart(LWC):
|
||||
class Chart(abstract.AbstractChart):
|
||||
MAX_WINDOWS = 10
|
||||
_window_num = 0
|
||||
_main_window_handlers = None
|
||||
_exit, _start = (mp.Event() for _ in range(2))
|
||||
_q, _emit_q, _return_q = (mp.Queue() for _ in range(3))
|
||||
_loaded_list = [mp.Event() for _ in range(MAX_WINDOWS)]
|
||||
|
||||
def __init__(self, width: int = 800, height: int = 600, x: int = None, y: int = None,
|
||||
on_top: bool = False, maximize: bool = False, debug: bool = False, toolbox: bool = False,
|
||||
inner_width: float = 1.0, inner_height: float = 1.0, scale_candles_only: bool = False):
|
||||
super().__init__(inner_width, inner_height, scale_candles_only, toolbox, 'pywebview.api.callback')
|
||||
global chart, num_charts
|
||||
self._i = Chart._window_num
|
||||
self._loaded = Chart._loaded_list[self._i]
|
||||
window = abstract.Window(lambda s: self._q.put((self._i, s)), 'pywebview.api.callback')
|
||||
abstract.Window._return_q = Chart._return_q
|
||||
Chart._window_num += 1
|
||||
self.is_alive = True
|
||||
|
||||
if chart:
|
||||
self._q, self._exit, self._start, self._process = chart._q, chart._exit, chart._start, chart._process
|
||||
self._emit_q, self._return_q = mp.Queue(), mp.Queue()
|
||||
for key, val in self._handlers.items():
|
||||
chart._handlers[key] = val
|
||||
self._handlers = chart._handlers
|
||||
self._loaded = chart._loaded_list[num_charts]
|
||||
self._q.put(('create_window', (self._html, on_top, width, height, x, y)))
|
||||
else:
|
||||
self._q, self._emit_q, self._return_q = (mp.Queue() for _ in range(3))
|
||||
self._loaded_list = [mp.Event() for _ in range(10)]
|
||||
self._loaded = self._loaded_list[0]
|
||||
self._exit, self._start = (mp.Event() for _ in range(2))
|
||||
self._process = mp.Process(target=PyWV, args=(self._q, self._start, self._exit, self._loaded_list, self._html,
|
||||
width, height, x, y, on_top, maximize, debug,
|
||||
self._emit_q, self._return_q), daemon=True)
|
||||
if self._i == 0:
|
||||
super().__init__(window, inner_width, inner_height, scale_candles_only, toolbox)
|
||||
Chart._main_window_handlers = self.win.handlers
|
||||
self._process = mp.Process(target=PyWV, args=(
|
||||
self._q, self._start, self._exit, Chart._loaded_list,
|
||||
self._emit_q, self._return_q, abstract.TEMPLATE, debug,
|
||||
width, height, x, y, on_top, maximize,
|
||||
), daemon=True)
|
||||
self._process.start()
|
||||
chart = self
|
||||
|
||||
self.i = num_charts
|
||||
num_charts += 1
|
||||
self._script_func = lambda s: self._q.put((self.i, s))
|
||||
else:
|
||||
window.handlers = Chart._main_window_handlers
|
||||
super().__init__(window, inner_width, inner_height, scale_candles_only, toolbox)
|
||||
self._q.put(('create_window', (abstract.TEMPLATE, on_top, width, height, x, y)))
|
||||
|
||||
def show(self, block: bool = False):
|
||||
"""
|
||||
Shows the chart window.\n
|
||||
:param block: blocks execution until the chart is closed.
|
||||
"""
|
||||
if not self.loaded:
|
||||
if not self.win.loaded:
|
||||
self._start.set()
|
||||
self._loaded.wait()
|
||||
self._on_js_load()
|
||||
self.win.on_js_load()
|
||||
else:
|
||||
self._q.put((self.i, 'show'))
|
||||
self._q.put((self._i, 'show'))
|
||||
if block:
|
||||
asyncio.run(self.show_async(block=True))
|
||||
|
||||
@ -107,20 +112,19 @@ class Chart(LWC):
|
||||
asyncio.create_task(self.show_async(block=True))
|
||||
return
|
||||
try:
|
||||
from lightweight_charts import polygon
|
||||
[asyncio.create_task(self.polygon.async_set(*args)) for args in polygon._set_on_load]
|
||||
while 1:
|
||||
while self._emit_q.empty() and not self._exit.is_set() and self.polygon._q.empty():
|
||||
while self._emit_q.empty() and not self._exit.is_set():
|
||||
await asyncio.sleep(0.05)
|
||||
if self._exit.is_set():
|
||||
self._exit.clear()
|
||||
self.is_alive = False
|
||||
return
|
||||
elif not self._emit_q.empty():
|
||||
name, args = self._emit_q.get()
|
||||
func = self._handlers[name]
|
||||
func, args = parse_event_message(self.win, self._emit_q.get())
|
||||
await func(*args) if asyncio.iscoroutinefunction(func) else func(*args)
|
||||
continue
|
||||
value = self.polygon._q.get()
|
||||
func, args = value[0], value[1:]
|
||||
func(*args)
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
|
||||
@ -128,16 +132,26 @@ class Chart(LWC):
|
||||
"""
|
||||
Hides the chart window.\n
|
||||
"""
|
||||
self._q.put((self.i, 'hide'))
|
||||
self._q.put((self._i, 'hide'))
|
||||
|
||||
def exit(self):
|
||||
"""
|
||||
Exits and destroys the chart window.\n
|
||||
"""
|
||||
global num_charts, chart
|
||||
chart = None
|
||||
num_charts = 0
|
||||
self._q.put((self.i, 'exit'))
|
||||
self._exit.wait()
|
||||
self._q.put((self._i, 'exit'))
|
||||
self._exit.wait() if self.win.loaded else None
|
||||
self._process.terminate()
|
||||
del self
|
||||
|
||||
Chart._main_window_handlers = None
|
||||
Chart._window_num = 0
|
||||
Chart._q = mp.Queue()
|
||||
self.is_alive = False
|
||||
|
||||
def screenshot(self) -> bytes:
|
||||
"""
|
||||
Takes a screenshot. This method can only be used after the chart window is visible.
|
||||
:return: a bytes object containing a screenshot of the chart.
|
||||
"""
|
||||
self.run_script(f'_~_~RETURN~_~_{self.id}.chart.takeScreenshot().toDataURL()')
|
||||
serial_data = self.win._return_q.get()
|
||||
return b64decode(serial_data.split(',')[1])
|
||||
|
||||
@ -14,6 +14,9 @@ if (!window.TopBar) {
|
||||
this.topBar.style.display = 'flex'
|
||||
this.topBar.style.alignItems = 'center'
|
||||
chart.wrapper.prepend(this.topBar)
|
||||
chart.topBar = this.topBar
|
||||
this.reSize = () => chart.reSize()
|
||||
this.reSize()
|
||||
}
|
||||
makeSwitcher(items, activeItem, callbackName) {
|
||||
let switcherElement = document.createElement('div');
|
||||
@ -45,6 +48,7 @@ if (!window.TopBar) {
|
||||
switcherElement.appendChild(itemEl);
|
||||
return itemEl;
|
||||
});
|
||||
widget.intervalElements = intervalElements
|
||||
|
||||
let onItemClicked = (item)=> {
|
||||
if (item === activeItem) return
|
||||
@ -59,6 +63,7 @@ if (!window.TopBar) {
|
||||
|
||||
this.topBar.appendChild(switcherElement)
|
||||
this.makeSeparator(this.topBar)
|
||||
this.reSize()
|
||||
return widget
|
||||
}
|
||||
makeTextBoxWidget(text) {
|
||||
@ -69,9 +74,10 @@ if (!window.TopBar) {
|
||||
textBox.innerText = text
|
||||
this.topBar.append(textBox)
|
||||
this.makeSeparator(this.topBar)
|
||||
this.reSize()
|
||||
return textBox
|
||||
}
|
||||
makeButton(defaultText, callbackName) {
|
||||
makeButton(defaultText, callbackName, separator) {
|
||||
let button = document.createElement('button')
|
||||
button.style.border = 'none'
|
||||
button.style.padding = '2px 5px'
|
||||
@ -103,7 +109,9 @@ if (!window.TopBar) {
|
||||
button.style.color = this.textColor
|
||||
button.style.fontWeight = 'normal'
|
||||
})
|
||||
if (separator) this.makeSeparator()
|
||||
this.topBar.appendChild(button)
|
||||
this.reSize()
|
||||
return widget
|
||||
}
|
||||
|
||||
@ -159,11 +167,10 @@ function makeSearchBox(chart) {
|
||||
chart.chart.subscribeCrosshairMove((param) => {
|
||||
if (param.point) yPrice = param.point.y;
|
||||
})
|
||||
let selectedChart = false
|
||||
chart.wrapper.addEventListener('mouseover', (event) => selectedChart = true)
|
||||
chart.wrapper.addEventListener('mouseout', (event) => selectedChart = false)
|
||||
window.selectedChart = chart
|
||||
chart.wrapper.addEventListener('mouseover', (event) => window.selectedChart = chart)
|
||||
chart.commandFunctions.push((event) => {
|
||||
if (!selectedChart) return false
|
||||
if (selectedChart !== chart) return false
|
||||
if (searchWindow.style.display === 'none') {
|
||||
if (/^[a-zA-Z0-9]$/.test(event.key)) {
|
||||
searchWindow.style.display = 'flex';
|
||||
|
||||
@ -1,103 +1,113 @@
|
||||
function makeChart(innerWidth, innerHeight, autoSize=true) {
|
||||
let chart = {
|
||||
markers: [],
|
||||
horizontal_lines: [],
|
||||
lines: [],
|
||||
wrapper: document.createElement('div'),
|
||||
div: document.createElement('div'),
|
||||
scale: {
|
||||
width: innerWidth,
|
||||
height: innerHeight,
|
||||
},
|
||||
candleData: [],
|
||||
commandFunctions: [],
|
||||
precision: 2,
|
||||
}
|
||||
chart.chart = LightweightCharts.createChart(chart.div, {
|
||||
width: window.innerWidth*innerWidth,
|
||||
height: window.innerHeight*innerHeight,
|
||||
layout: {
|
||||
textColor: '#d1d4dc',
|
||||
background: {
|
||||
color:'#000000',
|
||||
type: LightweightCharts.ColorType.Solid,
|
||||
},
|
||||
fontSize: 12
|
||||
},
|
||||
rightPriceScale: {
|
||||
scaleMargins: {top: 0.3, bottom: 0.25},
|
||||
},
|
||||
timeScale: {timeVisible: true, secondsVisible: false},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
vertLine: {
|
||||
labelBackgroundColor: 'rgb(46, 46, 46)'
|
||||
},
|
||||
horzLine: {
|
||||
labelBackgroundColor: 'rgb(55, 55, 55)'
|
||||
if (!window.Chart) {
|
||||
class Chart {
|
||||
constructor(chartId, innerWidth, innerHeight, position, autoSize) {
|
||||
this.makeCandlestickSeries = this.makeCandlestickSeries.bind(this)
|
||||
this.reSize = this.reSize.bind(this)
|
||||
this.id = chartId
|
||||
this.lines = []
|
||||
this.wrapper = document.createElement('div')
|
||||
this.div = document.createElement('div')
|
||||
this.scale = {
|
||||
width: innerWidth,
|
||||
height: innerHeight,
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
vertLines: {color: 'rgba(29, 30, 38, 5)'},
|
||||
horzLines: {color: 'rgba(29, 30, 58, 5)'},
|
||||
},
|
||||
handleScroll: {vertTouchDrag: true},
|
||||
})
|
||||
let up = 'rgba(39, 157, 130, 100)'
|
||||
let down = 'rgba(200, 97, 100, 100)'
|
||||
chart.series = chart.chart.addCandlestickSeries({
|
||||
color: 'rgb(0, 120, 255)', upColor: up, borderUpColor: up, wickUpColor: up,
|
||||
downColor: down, borderDownColor: down, wickDownColor: down, lineWidth: 2,
|
||||
})
|
||||
chart.volumeSeries = chart.chart.addHistogramSeries({
|
||||
color: '#26a69a',
|
||||
priceFormat: {type: 'volume'},
|
||||
priceScaleId: '',
|
||||
})
|
||||
chart.series.priceScale().applyOptions({
|
||||
scaleMargins: {top: 0.2, bottom: 0.2},
|
||||
});
|
||||
chart.volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: {top: 0.8, bottom: 0},
|
||||
});
|
||||
chart.wrapper.style.width = `${100*innerWidth}%`
|
||||
chart.wrapper.style.height = `${100*innerHeight}%`
|
||||
chart.wrapper.style.display = 'flex'
|
||||
chart.wrapper.style.flexDirection = 'column'
|
||||
chart.wrapper.style.position = 'relative'
|
||||
this.commandFunctions = []
|
||||
this.chart = LightweightCharts.createChart(this.div, {
|
||||
width: window.innerWidth * innerWidth,
|
||||
height: window.innerHeight * innerHeight,
|
||||
layout: {
|
||||
textColor: '#d1d4dc',
|
||||
background: {
|
||||
color: '#000000',
|
||||
type: LightweightCharts.ColorType.Solid,
|
||||
},
|
||||
fontSize: 12
|
||||
},
|
||||
rightPriceScale: {
|
||||
scaleMargins: {top: 0.3, bottom: 0.25},
|
||||
},
|
||||
timeScale: {timeVisible: true, secondsVisible: false},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
vertLine: {
|
||||
labelBackgroundColor: 'rgb(46, 46, 46)'
|
||||
},
|
||||
horzLine: {
|
||||
labelBackgroundColor: 'rgb(55, 55, 55)'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
vertLines: {color: 'rgba(29, 30, 38, 5)'},
|
||||
horzLines: {color: 'rgba(29, 30, 58, 5)'},
|
||||
},
|
||||
handleScroll: {vertTouchDrag: true},
|
||||
})
|
||||
this.wrapper.style.width = `${100 * innerWidth}%`
|
||||
this.wrapper.style.height = `${100 * innerHeight}%`
|
||||
this.wrapper.style.display = 'flex'
|
||||
this.wrapper.style.flexDirection = 'column'
|
||||
this.wrapper.style.position = 'relative'
|
||||
this.wrapper.style.float = position
|
||||
|
||||
chart.div.style.position = 'relative'
|
||||
chart.div.style.display = 'flex'
|
||||
chart.wrapper.appendChild(chart.div)
|
||||
document.getElementById('wrapper').append(chart.wrapper)
|
||||
this.div.style.position = 'relative'
|
||||
this.div.style.display = 'flex'
|
||||
this.wrapper.appendChild(this.div)
|
||||
document.getElementById('wrapper').append(this.wrapper)
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
for (let i=0; i<chart.commandFunctions.length; i++) {
|
||||
if (chart.commandFunctions[i](event)) break
|
||||
document.addEventListener('keydown', (event) => {
|
||||
for (let i = 0; i < this.commandFunctions.length; i++) {
|
||||
if (this.commandFunctions[i](event)) break
|
||||
}
|
||||
})
|
||||
if (!autoSize) return
|
||||
window.addEventListener('resize', () => this.reSize())
|
||||
}
|
||||
})
|
||||
reSize() {
|
||||
let topBarOffset = 'topBar' in this ? this.topBar.offsetHeight : 0
|
||||
this.chart.resize(window.innerWidth * this.scale.width, (window.innerHeight * this.scale.height) - topBarOffset)
|
||||
}
|
||||
makeCandlestickSeries() {
|
||||
this.markers = []
|
||||
this.horizontal_lines = []
|
||||
this.candleData = []
|
||||
this.precision = 2
|
||||
let up = 'rgba(39, 157, 130, 100)'
|
||||
let down = 'rgba(200, 97, 100, 100)'
|
||||
this.series = this.chart.addCandlestickSeries({
|
||||
color: 'rgb(0, 120, 255)', upColor: up, borderUpColor: up, wickUpColor: up,
|
||||
downColor: down, borderDownColor: down, wickDownColor: down, lineWidth: 2,
|
||||
})
|
||||
this.volumeSeries = this.chart.addHistogramSeries({
|
||||
color: '#26a69a',
|
||||
priceFormat: {type: 'volume'},
|
||||
priceScaleId: '',
|
||||
})
|
||||
this.series.priceScale().applyOptions({
|
||||
scaleMargins: {top: 0.2, bottom: 0.2},
|
||||
});
|
||||
this.volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: {top: 0.8, bottom: 0},
|
||||
});
|
||||
}
|
||||
toJSON() {
|
||||
// Exclude the chart attribute from serialization
|
||||
const {chart, ...serialized} = this;
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
window.Chart = Chart
|
||||
|
||||
if (!autoSize) return chart
|
||||
window.addEventListener('resize', () => reSize(chart))
|
||||
return chart
|
||||
}
|
||||
|
||||
function reSize(chart) {
|
||||
let topBarOffset = 'topBar' in chart ? chart.topBar.offsetHeight : 0
|
||||
chart.chart.resize(window.innerWidth*chart.scale.width, (window.innerHeight*chart.scale.height)-topBarOffset)
|
||||
}
|
||||
|
||||
if (!window.HorizontalLine) {
|
||||
class HorizontalLine {
|
||||
constructor(chart, lineId, price, color, width, style, axisLabelVisible, text) {
|
||||
this.updatePrice = this.updatePrice.bind(this)
|
||||
this.deleteLine = this.deleteLine.bind(this)
|
||||
this.chart = chart
|
||||
this.price = price
|
||||
this.color = color
|
||||
this.id = lineId
|
||||
this.priceLine = {
|
||||
price: this.price,
|
||||
color: color,
|
||||
color: this.color,
|
||||
lineWidth: width,
|
||||
lineStyle: style,
|
||||
axisLabelVisible: axisLabelVisible,
|
||||
@ -128,7 +138,8 @@ if (!window.HorizontalLine) {
|
||||
|
||||
updateColor(color) {
|
||||
this.chart.series.removePriceLine(this.line)
|
||||
this.priceLine.color = color
|
||||
this.color = color
|
||||
this.priceLine.color = this.color
|
||||
this.line = this.chart.series.createPriceLine(this.priceLine)
|
||||
}
|
||||
|
||||
@ -210,11 +221,7 @@ if (!window.HorizontalLine) {
|
||||
|
||||
makeLines(chart) {
|
||||
this.lines = []
|
||||
if (this.linesEnabled) {
|
||||
chart.lines.forEach((line) => {
|
||||
this.lines.push(this.makeLineRow(line))
|
||||
})
|
||||
}
|
||||
if (this.linesEnabled) chart.lines.forEach(line => this.lines.push(this.makeLineRow(line)))
|
||||
}
|
||||
|
||||
makeLineRow(line) {
|
||||
@ -322,39 +329,29 @@ function syncCrosshairs(childChart, parentChart) {
|
||||
childChart.subscribeCrosshairMove(childCrosshairHandler)
|
||||
}
|
||||
|
||||
function chartTimeToDate(stampOrBusiness) {
|
||||
if (typeof stampOrBusiness === 'number') {
|
||||
stampOrBusiness = new Date(stampOrBusiness*1000)
|
||||
}
|
||||
else if (typeof stampOrBusiness === 'string') {
|
||||
let [year, month, day] = stampOrBusiness.split('-').map(Number)
|
||||
stampOrBusiness = new Date(Date.UTC(year, month-1, day))
|
||||
}
|
||||
else {
|
||||
stampOrBusiness = new Date(Date.UTC(stampOrBusiness.year, stampOrBusiness.month - 1, stampOrBusiness.day))
|
||||
}
|
||||
return stampOrBusiness
|
||||
function stampToDate(stampOrBusiness) {
|
||||
return new Date(stampOrBusiness*1000)
|
||||
}
|
||||
function dateToStamp(date) {
|
||||
return Math.floor(date.getTime()/1000)
|
||||
}
|
||||
|
||||
function dateToChartTime(date, interval) {
|
||||
if (interval >= 24*60*60*1000) {
|
||||
return {day: date.getUTCDate(), month: date.getUTCMonth()+1, year: date.getUTCFullYear()}
|
||||
}
|
||||
return Math.floor(date.getTime()/1000)
|
||||
function lastBar(obj) {
|
||||
return obj[obj.length-1]
|
||||
}
|
||||
|
||||
function calculateTrendLine(startDate, startValue, endDate, endValue, interval, chart, ray=false) {
|
||||
let reversed = false
|
||||
if (chartTimeToDate(endDate).getTime() < chartTimeToDate(startDate).getTime()) {
|
||||
if (stampToDate(endDate).getTime() < stampToDate(startDate).getTime()) {
|
||||
reversed = true;
|
||||
[startDate, endDate] = [endDate, startDate];
|
||||
}
|
||||
let startIndex
|
||||
if (chartTimeToDate(startDate).getTime() < chartTimeToDate(chart.candleData[0].time).getTime()) {
|
||||
if (stampToDate(startDate).getTime() < stampToDate(chart.candleData[0].time).getTime()) {
|
||||
startIndex = 0
|
||||
}
|
||||
else {
|
||||
startIndex = chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(startDate).getTime())
|
||||
startIndex = chart.candleData.findIndex(item => stampToDate(item.time).getTime() === stampToDate(startDate).getTime())
|
||||
}
|
||||
|
||||
if (startIndex === -1) {
|
||||
@ -366,9 +363,9 @@ function calculateTrendLine(startDate, startValue, endDate, endValue, interval,
|
||||
startValue = endValue
|
||||
}
|
||||
else {
|
||||
endIndex = chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(endDate).getTime())
|
||||
endIndex = chart.candleData.findIndex(item => stampToDate(item.time).getTime() === stampToDate(endDate).getTime())
|
||||
if (endIndex === -1) {
|
||||
let barsBetween = (chartTimeToDate(endDate)-chartTimeToDate(chart.candleData[chart.candleData.length-1].time))/interval
|
||||
let barsBetween = (stampToDate(endDate)-stampToDate(chart.candleData[chart.candleData.length-1].time))/interval
|
||||
endIndex = chart.candleData.length-1+barsBetween
|
||||
}
|
||||
}
|
||||
@ -384,8 +381,7 @@ function calculateTrendLine(startDate, startValue, endDate, endValue, interval,
|
||||
}
|
||||
else {
|
||||
iPastData ++
|
||||
currentDate = dateToChartTime(new Date(chartTimeToDate(chart.candleData[chart.candleData.length-1].time).getTime()+(iPastData*interval)), interval)
|
||||
|
||||
currentDate = dateToStamp(new Date(stampToDate(chart.candleData[chart.candleData.length-1].time).getTime()+(iPastData*interval)))
|
||||
}
|
||||
|
||||
const currentValue = reversed ? startValue + rate_of_change * (numBars - i) : startValue + rate_of_change * i;
|
||||
@ -431,22 +427,23 @@ if (!window.ContextMenu) {
|
||||
active ? document.addEventListener('contextmenu', this.onRightClick) : document.removeEventListener('contextmenu', this.onRightClick)
|
||||
}
|
||||
menuItem(text, action, hover=false) {
|
||||
let item = document.createElement('div')
|
||||
let item = document.createElement('span')
|
||||
item.style.display = 'flex'
|
||||
item.style.alignItems = 'center'
|
||||
item.style.justifyContent = 'space-between'
|
||||
item.style.padding = '0px 10px'
|
||||
item.style.margin = '3px 0px'
|
||||
item.style.padding = '2px 10px'
|
||||
item.style.margin = '1px 0px'
|
||||
item.style.borderRadius = '3px'
|
||||
this.menu.appendChild(item)
|
||||
|
||||
let elem = document.createElement('div')
|
||||
let elem = document.createElement('span')
|
||||
elem.innerText = text
|
||||
item.appendChild(elem)
|
||||
|
||||
if (hover) {
|
||||
let arrow = document.createElement('div')
|
||||
arrow.innerHTML = `<svg width="15px" height="10px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.82054 20.7313C8.21107 21.1218 8.84423 21.1218 9.23476 20.7313L15.8792 14.0868C17.0505 12.9155 17.0508 11.0167 15.88 9.84497L9.3097 3.26958C8.91918 2.87905 8.28601 2.87905 7.89549 3.26958C7.50497 3.6601 7.50497 4.29327 7.89549 4.68379L14.4675 11.2558C14.8581 11.6464 14.8581 12.2795 14.4675 12.67L7.82054 19.317C7.43002 19.7076 7.43002 20.3407 7.82054 20.7313Z" fill="#fff"/></svg>`
|
||||
let arrow = document.createElement('span')
|
||||
arrow.innerText = `►`
|
||||
arrow.style.fontSize = '8px'
|
||||
item.appendChild(arrow)
|
||||
}
|
||||
|
||||
@ -457,13 +454,17 @@ if (!window.ContextMenu) {
|
||||
})
|
||||
elem.addEventListener('mouseout', (event) => item.style.backgroundColor = 'transparent')
|
||||
if (!hover) elem.addEventListener('click', (event) => {action(event); this.menu.style.display = 'none'})
|
||||
else elem.addEventListener('mouseover', () => action(item.getBoundingClientRect()))
|
||||
else {
|
||||
let timeout
|
||||
elem.addEventListener('mouseover', () => timeout = setTimeout(() => action(item.getBoundingClientRect()), 100))
|
||||
elem.addEventListener('mouseout', () => clearTimeout(timeout))
|
||||
}
|
||||
}
|
||||
separator() {
|
||||
let separator = document.createElement('div')
|
||||
separator.style.width = '90%'
|
||||
separator.style.height = '1px'
|
||||
separator.style.margin = '4px 0px'
|
||||
separator.style.margin = '3px 0px'
|
||||
separator.style.backgroundColor = '#3C434C'
|
||||
this.menu.appendChild(separator)
|
||||
}
|
||||
@ -471,3 +472,5 @@ if (!window.ContextMenu) {
|
||||
}
|
||||
window.ContextMenu = ContextMenu
|
||||
}
|
||||
|
||||
window.callbackFunction = () => undefined;
|
||||
@ -1,9 +1,8 @@
|
||||
if (!window.Table) {
|
||||
class Table {
|
||||
constructor(width, height, headings, widths, alignments, position, draggable = false, chart) {
|
||||
constructor(width, height, headings, widths, alignments, position, draggable = false) {
|
||||
this.container = document.createElement('div')
|
||||
this.callbackName = null
|
||||
this.chart = chart
|
||||
|
||||
if (draggable) {
|
||||
this.container.style.position = 'absolute'
|
||||
@ -15,7 +14,7 @@ if (!window.Table) {
|
||||
|
||||
this.container.style.zIndex = '2000'
|
||||
this.container.style.width = width <= 1 ? width * 100 + '%' : width + 'px'
|
||||
this.container.style.minHeight = height <= 1 ? height * 100 + '%' : height + 'px'
|
||||
this.container.style.height = height <= 1 ? height * 100 + '%' : height + 'px'
|
||||
this.container.style.display = 'flex'
|
||||
this.container.style.flexDirection = 'column'
|
||||
this.container.style.justifyContent = 'space-between'
|
||||
@ -52,7 +51,10 @@ if (!window.Table) {
|
||||
th.style.border = '1px solid rgb(70, 70, 70)'
|
||||
}
|
||||
|
||||
this.container.appendChild(this.table)
|
||||
let overflowWrapper = document.createElement('div')
|
||||
overflowWrapper.style.overflow = 'auto'
|
||||
overflowWrapper.appendChild(this.table)
|
||||
this.container.appendChild(overflowWrapper)
|
||||
document.getElementById('wrapper').appendChild(this.container)
|
||||
|
||||
if (!draggable) return
|
||||
@ -137,11 +139,6 @@ if (!window.Table) {
|
||||
this.footer[i].style.textAlign = 'center'
|
||||
}
|
||||
}
|
||||
toJSON() {
|
||||
// Exclude the chart attribute from serialization
|
||||
const {chart, ...serialized} = this;
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
window.Table = Table
|
||||
}
|
||||
|
||||
@ -174,7 +174,7 @@ if (!window.ToolBox) {
|
||||
currentTime = this.chart.chart.timeScale().coordinateToTime(param.point.x)
|
||||
if (!currentTime) {
|
||||
let barsToMove = param.logical - this.chart.chart.timeScale().coordinateToLogical(this.chart.chart.timeScale().timeToCoordinate(lastCandleTime))
|
||||
currentTime = dateToChartTime(new Date(chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime() + (barsToMove * this.interval)), this.interval)
|
||||
currentTime = dateToStamp(new Date(stampToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime() + (barsToMove * this.interval)))
|
||||
}
|
||||
let currentPrice = this.chart.series.coordinateToPrice(param.point.y)
|
||||
|
||||
@ -298,7 +298,6 @@ if (!window.ToolBox) {
|
||||
|
||||
if (boundaryConditional) {
|
||||
if (hoveringOver === drawing) return
|
||||
|
||||
if (!horizontal && !drawing.ray) drawing.line.setMarkers(drawing.markers)
|
||||
document.body.style.cursor = 'pointer'
|
||||
document.addEventListener('mousedown', checkForClick)
|
||||
@ -324,13 +323,17 @@ if (!window.ToolBox) {
|
||||
let originalPrice
|
||||
let mouseDown = false
|
||||
let clickedEnd = false
|
||||
let labelColor
|
||||
let checkForClick = (event) => {
|
||||
mouseDown = true
|
||||
document.body.style.cursor = 'grabbing'
|
||||
this.chart.chart.applyOptions({handleScroll: false})
|
||||
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
|
||||
|
||||
this.chart.chart.unsubscribeCrosshairMove(hoverOver)
|
||||
|
||||
labelColor = this.chart.chart.options().crosshair.horzLine.labelBackgroundColor
|
||||
this.chart.chart.applyOptions({crosshair: {horzLine: {labelBackgroundColor: hoveringOver.color}}})
|
||||
if ('price' in hoveringOver) {
|
||||
originalPrice = hoveringOver.price
|
||||
this.chart.chart.subscribeCrosshairMove(crosshairHandlerHorz)
|
||||
@ -353,6 +356,8 @@ if (!window.ToolBox) {
|
||||
document.body.style.cursor = this.chart.cursor
|
||||
|
||||
this.chart.chart.applyOptions({handleScroll: true})
|
||||
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true})
|
||||
this.chart.chart.applyOptions({crosshair: {horzLine: {labelBackgroundColor: labelColor}}})
|
||||
if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') {
|
||||
window.callbackFunction(`${hoveringOver.id}_~_${hoveringOver.price.toFixed(8)}`);
|
||||
}
|
||||
@ -372,8 +377,8 @@ if (!window.ToolBox) {
|
||||
let priceDiff = priceAtCursor - originalPrice
|
||||
let barsToMove = param.logical - originalIndex
|
||||
|
||||
let startBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.from[0]).getTime())
|
||||
let endBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.to[0]).getTime())
|
||||
let startBarIndex = this.chart.candleData.findIndex(item => stampToDate(item.time).getTime() === stampToDate(hoveringOver.from[0]).getTime())
|
||||
let endBarIndex = this.chart.candleData.findIndex(item => stampToDate(item.time).getTime() === stampToDate(hoveringOver.to[0]).getTime())
|
||||
|
||||
let startDate
|
||||
let endBar
|
||||
@ -385,19 +390,18 @@ if (!window.ToolBox) {
|
||||
endBar = endBarIndex === -1 ? null : this.chart.candleData[endBarIndex + barsToMove]
|
||||
}
|
||||
|
||||
let endDate = endBar ? endBar.time : dateToChartTime(new Date(chartTimeToDate(hoveringOver.to[0]).getTime() + (barsToMove * this.interval)), this.interval)
|
||||
let endDate = endBar ? endBar.time : dateToStamp(new Date(stampToDate(hoveringOver.to[0]).getTime() + (barsToMove * this.interval)))
|
||||
let startValue = hoveringOver.from[1] + priceDiff
|
||||
let endValue = hoveringOver.to[1] + priceDiff
|
||||
let data = calculateTrendLine(startDate, startValue, endDate, endValue, this.interval, this.chart, hoveringOver.ray)
|
||||
|
||||
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: false})
|
||||
|
||||
let logical = this.chart.chart.timeScale().getVisibleLogicalRange()
|
||||
|
||||
hoveringOver.from = [data[0].time, data[0].value]
|
||||
hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value]
|
||||
hoveringOver.line.setData(data)
|
||||
|
||||
this.chart.chart.timeScale().applyOptions({shiftVisibleRangeOnNewBar: true})
|
||||
this.chart.chart.timeScale().setVisibleLogicalRange(logical)
|
||||
|
||||
if (!hoveringOver.ray) {
|
||||
@ -435,7 +439,7 @@ if (!window.ToolBox) {
|
||||
let lastCandleTime = this.chart.candleData[this.chart.candleData.length - 1].time
|
||||
if (!currentTime) {
|
||||
let barsToMove = param.logical - this.chart.chart.timeScale().coordinateToLogical(this.chart.chart.timeScale().timeToCoordinate(lastCandleTime))
|
||||
currentTime = dateToChartTime(new Date(chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime() + (barsToMove * this.interval)), this.interval)
|
||||
currentTime = dateToStamp(new Date(stampToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime() + (barsToMove * this.interval)))
|
||||
}
|
||||
let data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart)
|
||||
hoveringOver.line.setData(data)
|
||||
@ -472,8 +476,8 @@ if (!window.ToolBox) {
|
||||
renderDrawings() {
|
||||
this.drawings.forEach((item) => {
|
||||
if ('price' in item) return
|
||||
let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from[0]).getTime() / this.interval) * this.interval), this.interval)
|
||||
let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to[0]).getTime() / this.interval) * this.interval), this.interval)
|
||||
let startDate = dateToStamp(new Date(Math.round(stampToDate(item.from[0]).getTime() / this.interval) * this.interval))
|
||||
let endDate = dateToStamp(new Date(Math.round(stampToDate(item.to[0]).getTime() / this.interval) * this.interval))
|
||||
let data = calculateTrendLine(startDate, item.from[1], endDate, item.to[1], this.interval, this.chart, item.ray)
|
||||
item.from = [data[0].time, data[0].value]
|
||||
item.to = [data[data.length - 1].time, data[data.length-1].value]
|
||||
@ -537,8 +541,8 @@ if (!window.ToolBox) {
|
||||
},
|
||||
}),
|
||||
})
|
||||
let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from[0]).getTime() / this.interval) * this.interval), this.interval)
|
||||
let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to[0]).getTime() / this.interval) * this.interval), this.interval)
|
||||
let startDate = dateToStamp(new Date(Math.round(stampToDate(item.from[0]).getTime() / this.interval) * this.interval))
|
||||
let endDate = dateToStamp(new Date(Math.round(stampToDate(item.to[0]).getTime() / this.interval) * this.interval))
|
||||
let data = calculateTrendLine(startDate, item.from[1], endDate, item.to[1], this.interval, this.chart, item.ray)
|
||||
item.from = [data[0].time, data[0].value]
|
||||
item.to = [data[data.length - 1].time, data[data.length-1].value]
|
||||
@ -664,7 +668,7 @@ if (!window.ColorPicker) {
|
||||
}
|
||||
openMenu(rect, drawing) {
|
||||
this.drawing = drawing
|
||||
this.rgbValues = this.extractRGB('price' in drawing ? drawing.priceLine.color : drawing.color)
|
||||
this.rgbValues = this.extractRGB(drawing.color)
|
||||
this.opacity = parseFloat(this.rgbValues[3])
|
||||
this.container.style.top = (rect.top-30)+'px'
|
||||
this.container.style.left = rect.right+'px'
|
||||
|
||||
@ -2,26 +2,43 @@ import asyncio
|
||||
import logging
|
||||
import datetime as dt
|
||||
import re
|
||||
import threading
|
||||
import queue
|
||||
import json
|
||||
import ssl
|
||||
import urllib.request
|
||||
from typing import Literal, Union, List
|
||||
import pandas as pd
|
||||
|
||||
from lightweight_charts import Chart
|
||||
from .chart import Chart
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
websockets = None
|
||||
|
||||
SEC_TYPE = Literal['stocks', 'options', 'indices', 'forex', 'crypto']
|
||||
|
||||
def convert_timeframe(timeframe):
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(logging.Formatter('%(asctime)s | [polygon.io] %(levelname)s: %(message)s', datefmt='%H:%M:%S'))
|
||||
ch.setLevel(logging.DEBUG)
|
||||
_log = logging.getLogger('polygon')
|
||||
_log.setLevel(logging.ERROR)
|
||||
_log.addHandler(ch)
|
||||
|
||||
api_key = ''
|
||||
_tickers = {}
|
||||
_set_on_load = []
|
||||
|
||||
_lasts = {}
|
||||
_ws = {'stocks': None, 'options': None, 'indices': None, 'crypto': None, 'forex': None}
|
||||
_subscription_type = {
|
||||
'stocks': ('Q', 'A'),
|
||||
'options': ('Q', 'A'),
|
||||
'indices': ('V', None),
|
||||
'forex': ('C', 'CA'),
|
||||
'crypto': ('XQ', 'XA'),
|
||||
}
|
||||
|
||||
|
||||
def _convert_timeframe(timeframe):
|
||||
spans = {
|
||||
'min': 'minute',
|
||||
'H': 'hour',
|
||||
@ -37,50 +54,211 @@ def convert_timeframe(timeframe):
|
||||
return multiplier, timespan
|
||||
|
||||
|
||||
def _get_sec_type(ticker):
|
||||
if '/' in ticker:
|
||||
return 'forex'
|
||||
for prefix, security_type in zip(('O:', 'I:', 'C:', 'X:'), ('options', 'indices', 'forex', 'crypto')):
|
||||
if ticker.startswith(prefix):
|
||||
return security_type
|
||||
else:
|
||||
return 'stocks'
|
||||
|
||||
|
||||
def _polygon_request(query_url):
|
||||
query_url = 'https://api.polygon.io'+query_url
|
||||
query_url += f'&apiKey={api_key}'
|
||||
|
||||
request = urllib.request.Request(query_url, headers={'User-Agent': 'lightweight_charts/1.0'})
|
||||
with urllib.request.urlopen(request) as response:
|
||||
if response.status != 200:
|
||||
error = response.json()
|
||||
_log.error(f'({response.status}) Request failed: {error["error"]}')
|
||||
return
|
||||
data = json.loads(response.read())
|
||||
if 'results' not in data:
|
||||
_log.error(f'No results for {query_url}')
|
||||
return
|
||||
return data['results']
|
||||
|
||||
|
||||
def get_bar_data(ticker: str, timeframe: str, start_date: str, end_date: str, limit: int = 5_000):
|
||||
end_date = dt.datetime.now().strftime('%Y-%m-%d') if end_date == 'now' else end_date
|
||||
mult, span = _convert_timeframe(timeframe)
|
||||
if '-' in ticker:
|
||||
ticker = ticker.replace('-', '')
|
||||
|
||||
query_url = f"/v2/aggs/ticker/{ticker}/range/{mult}/{span}/{start_date}/{end_date}?limit={limit}"
|
||||
results = _polygon_request(query_url)
|
||||
if not results:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(results)
|
||||
df['t'] = pd.to_datetime(df['t'], unit='ms')
|
||||
|
||||
rename = {'o': 'open', 'h': 'high', 'l': 'low', 'c': 'close', 't': 'time'}
|
||||
if not ticker.startswith('I:'):
|
||||
rename['v'] = 'volume'
|
||||
|
||||
return df[rename.keys()].rename(columns=rename)
|
||||
|
||||
|
||||
async def async_get_bar_data(ticker: str, timeframe: str, start_date: str, end_date: str, limit: int = 5_000):
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, get_bar_data, ticker, timeframe, start_date, end_date, limit)
|
||||
|
||||
|
||||
async def _send(sec_type: SEC_TYPE, action: str, params: str):
|
||||
ws = _ws[sec_type]
|
||||
while ws is None:
|
||||
await asyncio.sleep(0.05)
|
||||
ws = _ws[sec_type]
|
||||
await ws.send(json.dumps({'action': action, 'params': params}))
|
||||
|
||||
|
||||
async def subscribe(ticker: str, sec_type: SEC_TYPE, func, args, precision=2):
|
||||
if not _ws[sec_type]:
|
||||
asyncio.create_task(_websocket_connect(sec_type))
|
||||
|
||||
if sec_type in ('forex', 'crypto'):
|
||||
key = ticker[ticker.index(':')+1:]
|
||||
key = key.replace('-', '/') if sec_type == 'forex' else key
|
||||
else:
|
||||
key = ticker
|
||||
|
||||
if not _lasts.get(key):
|
||||
_lasts[key] = {
|
||||
'price': 0,
|
||||
'funcs': [],
|
||||
'precision': precision
|
||||
}
|
||||
if sec_type != 'indices':
|
||||
_lasts[key]['volume'] = 0
|
||||
|
||||
data = _lasts[key]
|
||||
|
||||
quotes, aggs = _subscription_type[sec_type]
|
||||
await _send(sec_type, 'subscribe', f'{quotes}.{ticker}')
|
||||
await _send(sec_type, 'subscribe', f'{aggs}.{ticker}') if aggs else None
|
||||
|
||||
if func in data['funcs']:
|
||||
return
|
||||
data['funcs'].append((func, args))
|
||||
|
||||
|
||||
async def unsubscribe(func):
|
||||
for key, data in _lasts.items():
|
||||
if val := next(((f, args) for f, args in data['funcs'] if f == func), None):
|
||||
break
|
||||
else:
|
||||
return
|
||||
data['funcs'].remove(val)
|
||||
|
||||
if data['funcs']:
|
||||
return
|
||||
sec_type = _get_sec_type(key)
|
||||
quotes, aggs = _subscription_type[sec_type]
|
||||
await _send(sec_type, 'unsubscribe', f'{quotes}.{key}')
|
||||
await _send(sec_type, 'unsubscribe', f'{aggs}.{key}')
|
||||
|
||||
|
||||
async def _websocket_connect(sec_type):
|
||||
if websockets is None:
|
||||
raise ImportError('The "websockets" library was not found, and must be installed to pull live data.')
|
||||
ticker_key = {
|
||||
'stocks': 'sym',
|
||||
'options': 'sym',
|
||||
'indices': 'T',
|
||||
'forex': 'p',
|
||||
'crypto': 'pair',
|
||||
}[sec_type]
|
||||
async with websockets.connect(f'wss://socket.polygon.io/{sec_type}') as ws:
|
||||
_ws[sec_type] = ws
|
||||
await _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':
|
||||
_log.info(f'{data["message"]}')
|
||||
continue
|
||||
_ticker_key = {
|
||||
'stocks': 'sym',
|
||||
'options': 'sym',
|
||||
'indices': 'T',
|
||||
'forex': 'p',
|
||||
'crypto': 'pair',
|
||||
}
|
||||
await _handle_tick(data[ticker_key], data)
|
||||
|
||||
|
||||
async def _handle_tick(ticker, data):
|
||||
lasts = _lasts[ticker]
|
||||
sec_type = _get_sec_type(ticker)
|
||||
|
||||
if data['ev'] in ('Q', 'V', 'C', 'XQ'):
|
||||
if sec_type == 'forex':
|
||||
data['bp'] = data.pop('b')
|
||||
data['ap'] = data.pop('a')
|
||||
price = (data['bp'] + data['ap']) / 2 if sec_type != 'indices' else data['val']
|
||||
if abs(price - lasts['price']) < (1/(10**lasts['precision'])):
|
||||
return
|
||||
lasts['price'] = price
|
||||
|
||||
if sec_type != 'indices':
|
||||
lasts['volume'] = 0
|
||||
|
||||
if 't' not in data:
|
||||
lasts['time'] = pd.to_datetime(data.pop('s'), unit='ms')
|
||||
else:
|
||||
lasts['time'] = pd.to_datetime(data['t'], unit='ms')
|
||||
|
||||
elif data['ev'] in ('A', 'CA', 'XA'):
|
||||
lasts['volume'] = data['v']
|
||||
if not lasts.get('time'):
|
||||
return
|
||||
lasts['symbol'] = ticker
|
||||
for func, args in lasts['funcs']:
|
||||
func(pd.Series(lasts), *args)
|
||||
|
||||
|
||||
class PolygonAPI:
|
||||
"""
|
||||
Offers direct access to Polygon API data within all Chart objects.
|
||||
|
||||
It is not designed to be initialized by the user, and should be utilised
|
||||
through the `polygon` method of `LWC` (chart.polygon.<method>).
|
||||
through the `polygon` method of `AbstractChart` (chart.polygon.<method>).
|
||||
"""
|
||||
_set_on_load = []
|
||||
|
||||
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.max_ticks_per_response = 20
|
||||
|
||||
self._chart = chart
|
||||
self._lasts = {}
|
||||
self._key = None
|
||||
|
||||
self._ws_q = queue.Queue()
|
||||
self._q = queue.Queue()
|
||||
self._lock = threading.Lock()
|
||||
def set(self, *args):
|
||||
if asyncio.get_event_loop().is_running():
|
||||
asyncio.create_task(self.async_set(*args))
|
||||
return True
|
||||
else:
|
||||
_set_on_load.append(args)
|
||||
return False
|
||||
|
||||
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._tickers = {}
|
||||
async def async_set(self, sec_type: Literal['stocks', 'options', 'indices', 'forex', 'crypto'], ticker, timeframe,
|
||||
start_date, end_date, limit, live):
|
||||
await unsubscribe(self._chart.update_from_tick)
|
||||
|
||||
df = await async_get_bar_data(ticker, timeframe, start_date, end_date, limit)
|
||||
|
||||
def log(self, info: bool):
|
||||
"""
|
||||
Streams informational messages related to Polygon.io.
|
||||
"""
|
||||
self._log.setLevel(logging.INFO) if info else self._log.setLevel(logging.ERROR)
|
||||
self._chart.set(df, render_drawings=_tickers.get(self._chart) == ticker)
|
||||
_tickers[self._chart] = ticker
|
||||
|
||||
def api_key(self, key: str):
|
||||
"""
|
||||
Sets the API key to be used with Polygon.io.
|
||||
"""
|
||||
self._key = key
|
||||
if not live:
|
||||
return True
|
||||
await subscribe(ticker, sec_type, self._chart.update_from_tick, (True,), self._chart.num_decimals)
|
||||
return True
|
||||
|
||||
def stock(self, symbol: str, timeframe: str, start_date: str, end_date='now', limit: int = 5_000, live: bool = False):
|
||||
def stock(
|
||||
self, symbol: str, timeframe: str, start_date: str, end_date='now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Requests and displays stock data pulled from Polygon.io.\n
|
||||
:param symbol: Ticker to request.
|
||||
@ -90,13 +268,17 @@ class PolygonAPI:
|
||||
: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 self._set(self._chart, 'stocks', symbol, timeframe, start_date, end_date, limit, live)
|
||||
return self.set('stocks', symbol, timeframe, start_date, end_date, limit, live)
|
||||
|
||||
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):
|
||||
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
|
||||
) -> bool:
|
||||
"""
|
||||
Requests and displays option data pulled from Polygon.io.\n
|
||||
:param symbol: The underlying ticker to request. A formatted option ticker can also be given instead of using the expiration, right, and strike parameters.
|
||||
:param symbol: The underlying ticker to request.
|
||||
A formatted option ticker can also be given instead of using the expiration, right, and strike parameters.
|
||||
:param timeframe: Timeframe to request (1min, 5min, 2H, 1D, 1W, 2M, etc).
|
||||
:param start_date: Start date of the data (YYYY-MM-DD).
|
||||
:param expiration: Expiration of the option (YYYY-MM-DD).
|
||||
@ -107,10 +289,14 @@ class PolygonAPI:
|
||||
:param live: If true, the data will be updated in real-time.
|
||||
"""
|
||||
if any((expiration, right, strike)):
|
||||
symbol = f'{symbol}{dt.datetime.strptime(expiration, "%Y-%m-%d").strftime("%y%m%d")}{right}{strike * 1000:08d}'
|
||||
return self._set(self._chart, 'options', f'O:{symbol}', timeframe, start_date, end_date, limit, live)
|
||||
expiration = dt.datetime.strptime(expiration, "%Y-%m-%d").strftime("%y%m%d")
|
||||
symbol = f'{symbol}{expiration}{right}{strike * 1000:08d}'
|
||||
return self.set('options', f'O:{symbol}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
def index(self, symbol, timeframe, start_date, end_date='now', limit: int = 5_000, live=False):
|
||||
def index(
|
||||
self, symbol: str, timeframe: str, start_date: str, end_date: str = 'now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Requests and displays index data pulled from Polygon.io.\n
|
||||
:param symbol: Ticker to request.
|
||||
@ -120,9 +306,12 @@ class PolygonAPI:
|
||||
: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 self._set(self._chart, 'indices', f'I:{symbol}', timeframe, start_date, end_date, limit, live)
|
||||
return self.set('indices', f'I:{symbol}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
def forex(self, fiat_pair, timeframe, start_date, end_date='now', limit: int = 5_000, live=False):
|
||||
def forex(
|
||||
self, fiat_pair: str, timeframe: str, start_date: str, end_date: str = 'now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Requests and displays forex data pulled from Polygon.io.\n
|
||||
:param fiat_pair: The fiat pair to request. (USD-CAD, GBP-JPY etc.)
|
||||
@ -132,9 +321,12 @@ class PolygonAPI:
|
||||
: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 self._set(self._chart, 'forex', f'C:{fiat_pair}', timeframe, start_date, end_date, limit, live)
|
||||
return self.set('forex', f'C:{fiat_pair}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
def crypto(self, crypto_pair, timeframe, start_date, end_date='now', limit: int = 5_000, live=False):
|
||||
def crypto(
|
||||
self, crypto_pair: str, timeframe: str, start_date: str, end_date: str = 'now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Requests and displays crypto data pulled from Polygon.io.\n
|
||||
:param crypto_pair: The crypto pair to request. (BTC-USD, ETH-BTC etc.)
|
||||
@ -144,174 +336,61 @@ class PolygonAPI:
|
||||
: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 self._set(self._chart, 'crypto', f'X:{crypto_pair}', timeframe, start_date, end_date, limit, live)
|
||||
return self.set('crypto', f'X:{crypto_pair}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
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.')
|
||||
async def async_stock(
|
||||
self, symbol: str, timeframe: str, start_date: str, end_date: str = 'now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
return await self.async_set('stocks', symbol, timeframe, start_date, end_date, limit, live)
|
||||
|
||||
self._ws_q.put(('_unsubscribe', chart))
|
||||
end_date = dt.datetime.now().strftime('%Y-%m-%d') if end_date == 'now' else end_date
|
||||
mult, span = convert_timeframe(timeframe)
|
||||
async def async_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
|
||||
) -> bool:
|
||||
if any((expiration, right, strike)):
|
||||
expiration = dt.datetime.strptime(expiration, "%Y-%m-%d").strftime("%y%m%d")
|
||||
symbol = f'{symbol}{expiration}{right}{strike * 1000:08d}'
|
||||
return await self.async_set('options', f'O:{symbol}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
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
|
||||
async def async_index(
|
||||
self, symbol: str, timeframe: str, start_date: str, end_date: str = 'now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
return await self.async_set('indices', f'I:{symbol}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
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')
|
||||
async def async_forex(
|
||||
self, fiat_pair: str, timeframe: str, start_date: str, end_date: str = 'now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
return await self.async_set('forex', f'C:{fiat_pair}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
chart.set(df, render_drawings=self._tickers.get(chart) == ticker)
|
||||
self._tickers[chart] = ticker
|
||||
async def async_crypto(
|
||||
self, crypto_pair: str, timeframe: str, start_date: str, end_date: str = 'now',
|
||||
limit: int = 5_000, live: bool = False
|
||||
) -> bool:
|
||||
return await self.async_set('crypto', f'X:{crypto_pair}', timeframe, start_date, end_date, limit, live)
|
||||
|
||||
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._ws_q.put(('_websocket_connect', self._key, sec_type))
|
||||
self._ws_q.put(('_subscribe', chart, ticker, sec_type))
|
||||
return True
|
||||
@staticmethod
|
||||
def log(info: bool):
|
||||
"""
|
||||
Streams informational messages related to Polygon.io.
|
||||
"""
|
||||
_log.setLevel(logging.INFO) if info else _log.setLevel(logging.ERROR)
|
||||
|
||||
async def _thread_loop(self):
|
||||
while 1:
|
||||
while self._ws_q.empty():
|
||||
await asyncio.sleep(0.05)
|
||||
value = self._ws_q.get()
|
||||
func, args = value[0], value[1:]
|
||||
asyncio.create_task(getattr(self, func)(*args))
|
||||
|
||||
async def _subscribe(self, chart, ticker, sec_type):
|
||||
key = ticker if ':' not in ticker else ticker.split(':')[1]
|
||||
if not self._lasts.get(key):
|
||||
self._lasts[key] = {
|
||||
'ticker': ticker,
|
||||
'sec_type': sec_type,
|
||||
'sub_type': {
|
||||
'stocks': ('Q', 'A'),
|
||||
'options': ('Q', 'A'),
|
||||
'indices': ('V', None),
|
||||
'forex': ('C', 'CA'),
|
||||
'crypto': ('XQ', 'XA'),
|
||||
}[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):
|
||||
for data in self._lasts.values():
|
||||
if chart in data['charts']:
|
||||
break
|
||||
else:
|
||||
return
|
||||
if chart in data['charts']:
|
||||
data['charts'].remove(chart)
|
||||
if data['charts']:
|
||||
return
|
||||
|
||||
while self._q.qsize():
|
||||
self._q.get() # Flush the queue
|
||||
quotes, aggs = data['sub_type']
|
||||
await self._send(data['sec_type'], 'unsubscribe', f'{quotes}.{data["ticker"]}')
|
||||
await self._send(data['sec_type'], 'unsubscribe', f'{aggs}.{data["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')
|
||||
if sec_type == 'indices':
|
||||
self._lasts[key]['price'] = data['val']
|
||||
else:
|
||||
self._lasts[key]['price'] = (data['bp']+data['ap'])/2
|
||||
self._lasts[key]['volume'] = 0
|
||||
elif data['ev'] in ('A', 'CA', 'XA'):
|
||||
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
|
||||
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)-self.max_ticks_per_response:
|
||||
continue
|
||||
await self._handle_tick(sec_type, data)
|
||||
|
||||
def _subchart(self, subchart):
|
||||
return PolygonAPISubChart(self, subchart)
|
||||
|
||||
|
||||
class PolygonAPISubChart(PolygonAPI):
|
||||
def __init__(self, polygon, subchart):
|
||||
super().__init__(subchart)
|
||||
self._set = polygon._set
|
||||
@staticmethod
|
||||
def api_key(key: str):
|
||||
"""
|
||||
Sets the API key to be used with Polygon.io.
|
||||
"""
|
||||
global api_key
|
||||
api_key = key
|
||||
|
||||
|
||||
class PolygonChart(Chart):
|
||||
"""
|
||||
A prebuilt callback chart object allowing for a standalone and plug-and-play
|
||||
A prebuilt callback chart object allowing for a standalone, plug-and-play
|
||||
experience of Polygon.io's API.
|
||||
|
||||
Tickers, security types and timeframes are to be defined within the chart window.
|
||||
@ -319,41 +398,44 @@ class PolygonChart(Chart):
|
||||
If using the standard `show` method, the `block` parameter must be set to True.
|
||||
When using `show_async`, either is acceptable.
|
||||
"""
|
||||
def __init__(self, api_key: str, live: bool = False, num_bars: int = 200, end_date: str = 'now', limit: int = 5_000,
|
||||
timeframe_options: tuple = ('1min', '5min', '30min', 'D', 'W'),
|
||||
security_options: tuple = ('Stock', 'Option', 'Index', 'Forex', 'Crypto'),
|
||||
toolbox: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None,
|
||||
on_top: bool = False, maximize: bool = False, debug: bool = False):
|
||||
super().__init__(width=width, height=height, x=x, y=y, on_top=on_top, maximize=maximize, debug=debug, toolbox=toolbox)
|
||||
self.chart = self
|
||||
def __init__(
|
||||
self, api_key: str, live: bool = False, num_bars: int = 200, end_date: str = 'now', limit: int = 5_000,
|
||||
timeframe_options: tuple = ('1min', '5min', '30min', 'D', 'W'),
|
||||
security_options: tuple = ('Stock', 'Option', 'Index', 'Forex', 'Crypto'),
|
||||
toolbox: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None,
|
||||
on_top: bool = False, maximize: bool = False, debug: bool = False
|
||||
):
|
||||
super().__init__(width, height, x, y, on_top, maximize, debug, toolbox)
|
||||
|
||||
self.num_bars = num_bars
|
||||
self.end_date = end_date
|
||||
self.limit = limit
|
||||
self.live = live
|
||||
|
||||
self.polygon.api_key(api_key)
|
||||
self.events.search += self.on_search
|
||||
self.legend(True)
|
||||
self.grid(False, False)
|
||||
self.crosshair(vert_visible=False, horz_visible=False)
|
||||
|
||||
self.topbar.active_background_color = 'rgb(91, 98, 246)'
|
||||
self.topbar.textbox('symbol')
|
||||
self.topbar.switcher('timeframe', timeframe_options, func=self._on_timeframe_selection)
|
||||
self.topbar.switcher('security', security_options, func=self._on_security_selection)
|
||||
self.legend(True)
|
||||
self.grid(False, False)
|
||||
self.crosshair(vert_visible=False, horz_visible=False)
|
||||
self.events.search += self.on_search
|
||||
|
||||
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 = "flex"
|
||||
{self.id}.search.box.focus()
|
||||
''')
|
||||
|
||||
def _polygon(self, symbol):
|
||||
async def _polygon(self, symbol):
|
||||
self.spinner(True)
|
||||
self.set(pd.DataFrame(), True)
|
||||
self.crosshair(vert_visible=False, horz_visible=False)
|
||||
|
||||
mult, span = convert_timeframe(self.topbar['timeframe'].value)
|
||||
mult, span = _convert_timeframe(self.topbar['timeframe'].value)
|
||||
delta = dt.timedelta(**{span + 's': int(mult)})
|
||||
short_delta = (delta < dt.timedelta(days=7))
|
||||
start_date = dt.datetime.now() if self.end_date == 'now' else dt.datetime.strptime(self.end_date, '%Y-%m-%d')
|
||||
@ -365,7 +447,7 @@ class PolygonChart(Chart):
|
||||
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())(
|
||||
success = await getattr(self.polygon, 'async_'+self.topbar['security'].value.lower())(
|
||||
symbol,
|
||||
timeframe=self.topbar['timeframe'].value,
|
||||
start_date=start_date.strftime('%Y-%m-%d'),
|
||||
@ -374,14 +456,14 @@ class PolygonChart(Chart):
|
||||
live=self.live
|
||||
)
|
||||
self.spinner(False)
|
||||
self.crosshair(vert_visible=True, horz_visible=True) if success else None
|
||||
self.crosshair() if success else None
|
||||
return success
|
||||
|
||||
async def on_search(self, chart, searched_string):
|
||||
self.topbar['symbol'].set(searched_string if self._polygon(searched_string) else '')
|
||||
chart.topbar['symbol'].set(searched_string if await self._polygon(searched_string) else '')
|
||||
|
||||
async def _on_timeframe_selection(self, chart):
|
||||
self._polygon(self.topbar['symbol'].value) if self.topbar['symbol'].value else None
|
||||
await self._polygon(chart.topbar['symbol'].value) if chart.topbar['symbol'].value else None
|
||||
|
||||
async def _on_security_selection(self, chart):
|
||||
self.precision(5 if self.topbar['security'].value == 'Forex' else 2)
|
||||
self.precision(5 if chart.topbar['security'].value == 'Forex' else 2)
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
import random
|
||||
from typing import Union
|
||||
|
||||
from .util import jbool
|
||||
from .util import jbool, Pane, NUM
|
||||
|
||||
|
||||
class Footer:
|
||||
def __init__(self, table): self._table = table
|
||||
def __init__(self, table):
|
||||
self._table = table
|
||||
|
||||
def __setitem__(self, key, value): self._table._run_script(f'{self._table.id}.footer[{key}].innerText = "{value}"')
|
||||
def __setitem__(self, key, value):
|
||||
self._table.run_script(f'{self._table.id}.footer[{key}].innerText = "{value}"')
|
||||
|
||||
def __call__(self, number_of_text_boxes): self._table._run_script(f'{self._table.id}.makeFooter({number_of_text_boxes})')
|
||||
def __call__(self, number_of_text_boxes: int):
|
||||
self._table.run_script(f'{self._table.id}.makeFooter({number_of_text_boxes})')
|
||||
|
||||
|
||||
class Row(dict):
|
||||
def __init__(self, table, id, items):
|
||||
super().__init__()
|
||||
self.run_script = table.run_script
|
||||
self._table = table
|
||||
self._run_script = table._run_script
|
||||
self.id = id
|
||||
self.meta = {}
|
||||
self._run_script(f'''{self._table.id}.newRow({list(items.values())}, '{self.id}')''')
|
||||
self.run_script(f'{self._table.id}.newRow({list(items.values())}, "{self.id}")')
|
||||
for key, val in items.items():
|
||||
self[key] = val
|
||||
|
||||
@ -29,8 +32,7 @@ class Row(dict):
|
||||
original_value = value
|
||||
if column in self._table._formatters:
|
||||
value = self._table._formatters[column].replace(self._table.VALUE, str(value))
|
||||
self._run_script(f'{self._table.id}.updateCell("{self.id}", "{column}", "{value}")')
|
||||
|
||||
self.run_script(f'{self._table.id}.updateCell("{self.id}", "{column}", "{value}")')
|
||||
return super().__setitem__(column, original_value)
|
||||
|
||||
def background_color(self, column, color): self._style('backgroundColor', column, color)
|
||||
@ -38,30 +40,29 @@ class Row(dict):
|
||||
def text_color(self, column, color): self._style('textColor', column, color)
|
||||
|
||||
def _style(self, style, column, arg):
|
||||
self._run_script(f"{self._table.id}.rows[{self.id}]['{column}'].style.{style} = '{arg}'")
|
||||
self.run_script(f"{self._table.id}.rows[{self.id}]['{column}'].style.{style} = '{arg}'")
|
||||
|
||||
def delete(self):
|
||||
self._run_script(f"{self._table.id}.deleteRow('{self.id}')")
|
||||
self.run_script(f"{self._table.id}.deleteRow('{self.id}')")
|
||||
self._table.pop(self.id)
|
||||
|
||||
class Table(dict):
|
||||
|
||||
class Table(Pane, dict):
|
||||
VALUE = 'CELL__~__VALUE__~__PLACEHOLDER'
|
||||
|
||||
def __init__(self, chart, width, height, headings, widths=None, alignments=None, position='left', draggable=False, func=None):
|
||||
super().__init__()
|
||||
self._run_script = chart.run_script
|
||||
self._chart = chart
|
||||
self.headings = headings
|
||||
def __init__(self, window, width: NUM, height: NUM, headings: tuple, widths: tuple = None, alignments: tuple = None, position='left', draggable: bool = False, func: callable = None):
|
||||
dict.__init__(self)
|
||||
Pane.__init__(self, window)
|
||||
self._formatters = {}
|
||||
self.headings = headings
|
||||
self.is_shown = True
|
||||
self.win.handlers[self.id] = lambda rId: func(self[rId])
|
||||
headings = list(headings)
|
||||
widths = list(widths) if widths else []
|
||||
alignments = list(alignments) if alignments else []
|
||||
|
||||
self.id = chart._rand.generate()
|
||||
chart._handlers[self.id] = lambda rId: func(self[rId])
|
||||
self._run_script(f'''
|
||||
{self.id} = new Table({width}, {height}, {list(headings)}, {list(widths) if widths else []}, {list(alignments) if alignments else []},
|
||||
'{position}', {jbool(draggable)}, {chart.id})
|
||||
''')
|
||||
self._run_script(f'{self.id}.callbackName = "{self.id}"') if func else None
|
||||
self.run_script(f'{self.id} = new Table({width}, {height}, {headings}, {widths}, {alignments}, "{position}", {jbool(draggable)})')
|
||||
self.run_script(f'{self.id}.callbackName = "{self.id}"') if func else None
|
||||
self.footer = Footer(self)
|
||||
|
||||
def new_row(self, *values, id=None) -> Row:
|
||||
@ -69,7 +70,7 @@ class Table(dict):
|
||||
self[row_id] = Row(self, row_id, {heading: item for heading, item in zip(self.headings, values)})
|
||||
return self[row_id]
|
||||
|
||||
def clear(self): self._run_script(f"{self.id}.clearRows()"), super().clear()
|
||||
def clear(self): self.run_script(f"{self.id}.clearRows()"), super().clear()
|
||||
|
||||
def get(self, __key: Union[int, str]) -> Row: return super().get(int(__key))
|
||||
|
||||
@ -79,7 +80,7 @@ class Table(dict):
|
||||
|
||||
def visible(self, visible: bool):
|
||||
self.is_shown = visible
|
||||
self._run_script(f"""
|
||||
self.run_script(f"""
|
||||
{self.id}.container.style.display = '{'block' if visible else 'none'}'
|
||||
{self.id}.container.{'add' if visible else 'remove'}EventListener('mousedown', {self.id}.onMouseDown)
|
||||
""")
|
||||
|
||||
47
lightweight_charts/toolbox.py
Normal file
47
lightweight_charts/toolbox.py
Normal file
@ -0,0 +1,47 @@
|
||||
import json
|
||||
|
||||
|
||||
class ToolBox:
|
||||
def __init__(self, chart):
|
||||
from lightweight_charts.abstract import JS
|
||||
self.run_script = chart.run_script
|
||||
self.id = chart.id
|
||||
self._save_under = None
|
||||
self.drawings = {}
|
||||
chart.win.handlers[f'save_drawings{self.id}'] = self._save_drawings
|
||||
self.run_script(JS['toolbox'])
|
||||
self.run_script(f'{self.id}.toolBox = new ToolBox({self.id})')
|
||||
|
||||
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.drawings.get(tag):
|
||||
return
|
||||
self.run_script(f'if ("toolBox" in {self.id}) {self.id}.toolBox.loadDrawings({json.dumps(self.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.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.drawings, f, indent=4)
|
||||
|
||||
def _save_drawings(self, drawings):
|
||||
if not self._save_under:
|
||||
return
|
||||
self.drawings[self._save_under.value] = json.loads(drawings)
|
||||
90
lightweight_charts/topbar.py
Normal file
90
lightweight_charts/topbar.py
Normal file
@ -0,0 +1,90 @@
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
|
||||
from .util import jbool, Pane
|
||||
|
||||
|
||||
class Widget(Pane):
|
||||
def __init__(self, topbar, value, func=None):
|
||||
super().__init__(topbar.win)
|
||||
self.value = value
|
||||
|
||||
def wrapper(v):
|
||||
self.value = v
|
||||
func(topbar._chart)
|
||||
|
||||
async def async_wrapper(v):
|
||||
self.value = v
|
||||
await func(topbar._chart)
|
||||
|
||||
self.win.handlers[self.id] = async_wrapper if asyncio.iscoroutinefunction(func) else wrapper
|
||||
|
||||
|
||||
class TextWidget(Widget):
|
||||
def __init__(self, topbar, initial_text):
|
||||
super().__init__(topbar, value=initial_text)
|
||||
self.run_script(f'{self.id} = {topbar.id}.makeTextBoxWidget("{initial_text}")')
|
||||
|
||||
def set(self, string):
|
||||
self.value = string
|
||||
self.run_script(f'{self.id}.innerText = "{string}"')
|
||||
|
||||
|
||||
class SwitcherWidget(Widget):
|
||||
def __init__(self, topbar, options, default, func):
|
||||
super().__init__(topbar, value=default, func=func)
|
||||
self.run_script(f'{self.id} = {topbar.id}.makeSwitcher({list(options)}, "{default}", "{self.id}")')
|
||||
|
||||
|
||||
class ButtonWidget(Widget):
|
||||
def __init__(self, topbar, button, separator, func):
|
||||
super().__init__(topbar, value=button, func=func)
|
||||
self.run_script(f'{self.id} = {topbar.id}.makeButton("{button}", "{self.id}", {jbool(separator)})')
|
||||
|
||||
def set(self, string):
|
||||
self.value = string
|
||||
self.run_script(f'{self.id}.elem.innerText = "{string}"')
|
||||
|
||||
|
||||
class TopBar(Pane):
|
||||
def __init__(self, chart):
|
||||
super().__init__(chart.win)
|
||||
self._chart = chart
|
||||
self._widgets: Dict[str, Widget] = {}
|
||||
|
||||
self.click_bg_color = '#50565E'
|
||||
self.hover_bg_color = '#3c434c'
|
||||
self.active_bg_color = 'rgba(0, 122, 255, 0.7)'
|
||||
self.active_text_color = '#ececed'
|
||||
self.text_color = '#d8d9db'
|
||||
self._created = False
|
||||
|
||||
def _create(self):
|
||||
if self._created:
|
||||
return
|
||||
from lightweight_charts.abstract import JS
|
||||
self._created = True
|
||||
self.run_script(JS['callback'])
|
||||
self.run_script(f'''
|
||||
{self.id} = new TopBar( {self._chart.id}, '{self.hover_bg_color}', '{self.click_bg_color}',
|
||||
'{self.active_bg_color}', '{self.text_color}', '{self.active_text_color}')
|
||||
''')
|
||||
|
||||
def __getitem__(self, item):
|
||||
if widget := self._widgets.get(item):
|
||||
return widget
|
||||
raise KeyError(f'Topbar widget "{item}" not found.')
|
||||
|
||||
def get(self, widget_name): return self._widgets.get(widget_name)
|
||||
|
||||
def switcher(self, name, options: tuple, default: str = None, func: callable = None):
|
||||
self._create()
|
||||
self._widgets[name] = SwitcherWidget(self, options, default if default else options[0], func)
|
||||
|
||||
def textbox(self, name: str, initial_text: str = ''):
|
||||
self._create()
|
||||
self._widgets[name] = TextWidget(self, initial_text)
|
||||
|
||||
def button(self, name, button_text: str, separator: bool = True, func: callable = None):
|
||||
self._create()
|
||||
self._widgets[name] = ButtonWidget(self, button_text, separator, func)
|
||||
@ -1,6 +1,18 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from random import choices
|
||||
from typing import Literal
|
||||
from typing import Literal, Union
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class Pane:
|
||||
def __init__(self, window):
|
||||
from lightweight_charts import Window
|
||||
self.win: Window = window
|
||||
self.run_script = window.run_script
|
||||
if hasattr(self, 'id'):
|
||||
return
|
||||
self.id = Window._id_gen.generate()
|
||||
|
||||
|
||||
class IDGen(list):
|
||||
@ -14,6 +26,13 @@ class IDGen(list):
|
||||
self.generate()
|
||||
|
||||
|
||||
def parse_event_message(window, string):
|
||||
name, args = string.split('_~_')
|
||||
args = args.split(';;;')
|
||||
func = window.handlers[name]
|
||||
return func, args
|
||||
|
||||
|
||||
def jbool(b: bool): return 'true' if b is True else 'false' if b is False else None
|
||||
|
||||
|
||||
@ -27,6 +46,12 @@ CROSSHAIR_MODE = Literal['normal', 'magnet']
|
||||
|
||||
PRICE_SCALE_MODE = Literal['normal', 'logarithmic', 'percentage', 'index100']
|
||||
|
||||
TIME = Union[datetime, pd.Timestamp, str]
|
||||
|
||||
NUM = Union[float, int]
|
||||
|
||||
FLOAT = Literal['left', 'right', 'top', 'bottom']
|
||||
|
||||
|
||||
def line_style(line: LINE_STYLE):
|
||||
js = 'LightweightCharts.LineStyle.'
|
||||
@ -65,6 +90,7 @@ class Emitter:
|
||||
def _emit(self, *args):
|
||||
self._callable(*args) if self._callable else None
|
||||
|
||||
|
||||
class JSEmitter:
|
||||
def __init__(self, chart, name, on_iadd, wrapper=None):
|
||||
self._on_iadd = on_iadd
|
||||
@ -78,7 +104,7 @@ class JSEmitter:
|
||||
async def final_async_wrapper(*arg):
|
||||
await other(self._chart, *arg) if not self._wrapper else await self._wrapper(other, self._chart, *arg)
|
||||
|
||||
self._chart._handlers[self._name] = final_async_wrapper if asyncio.iscoroutinefunction(other) else final_wrapper
|
||||
self._chart.win.handlers[self._name] = final_async_wrapper if asyncio.iscoroutinefunction(other) else final_wrapper
|
||||
self._on_iadd(other)
|
||||
return self
|
||||
|
||||
@ -89,7 +115,7 @@ class Events:
|
||||
from lightweight_charts.abstract import JS
|
||||
self.search = JSEmitter(chart, f'search{chart.id}',
|
||||
lambda o: chart.run_script(f'''
|
||||
{JS['callback'] if not chart._callbacks_enabled else ''}
|
||||
{JS['callback']}
|
||||
makeSpinner({chart.id})
|
||||
{chart.id}.search = makeSearchBox({chart.id})
|
||||
''')
|
||||
|
||||
@ -1,97 +1,85 @@
|
||||
import asyncio
|
||||
|
||||
from .util import parse_event_message
|
||||
from lightweight_charts import abstract
|
||||
|
||||
try:
|
||||
import wx.html2
|
||||
except ImportError:
|
||||
wx = None
|
||||
|
||||
try:
|
||||
from PyQt5.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt5.QtWebChannel import QWebChannel
|
||||
from PyQt5.QtCore import QObject, pyqtSlot
|
||||
|
||||
class Bridge(QObject):
|
||||
def __init__(self, chart):
|
||||
super().__init__()
|
||||
self.chart = chart
|
||||
|
||||
@pyqtSlot(str)
|
||||
def callback(self, message):
|
||||
_widget_message(self.chart, message)
|
||||
from PyQt5.QtCore import QObject, pyqtSlot as Slot
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtCore import QObject, Slot
|
||||
|
||||
class Bridge(QObject):
|
||||
def __init__(self, chart):
|
||||
super().__init__()
|
||||
self.chart = chart
|
||||
|
||||
@Slot(str)
|
||||
def callback(self, message):
|
||||
_widget_message(self.chart, message)
|
||||
except ImportError:
|
||||
QWebEngineView = None
|
||||
|
||||
if QWebEngineView:
|
||||
class Bridge(QObject):
|
||||
def __init__(self, chart):
|
||||
super().__init__()
|
||||
self.chart = chart
|
||||
|
||||
@Slot(str)
|
||||
def callback(self, message):
|
||||
emit_callback(self.chart, message)
|
||||
|
||||
try:
|
||||
from streamlit.components.v1 import html
|
||||
except ImportError:
|
||||
html = None
|
||||
|
||||
try:
|
||||
from IPython.display import HTML, display
|
||||
except ImportError:
|
||||
HTML = None
|
||||
|
||||
from lightweight_charts.abstract import LWC, JS
|
||||
|
||||
|
||||
def _widget_message(chart, string):
|
||||
name, args = string.split('_~_')
|
||||
args = args.split(';;;')
|
||||
func = chart._handlers[name]
|
||||
def emit_callback(window, string):
|
||||
func, args = parse_event_message(window, string)
|
||||
asyncio.create_task(func(*args)) if asyncio.iscoroutinefunction(func) else func(*args)
|
||||
|
||||
|
||||
class WxChart(LWC):
|
||||
class WxChart(abstract.AbstractChart):
|
||||
def __init__(self, parent, inner_width: float = 1.0, inner_height: float = 1.0,
|
||||
scale_candles_only: bool = False, toolbox: bool = False):
|
||||
if wx is None:
|
||||
raise ModuleNotFoundError('wx.html2 was not found, and must be installed to use WxChart.')
|
||||
self.webview: wx.html2.WebView = wx.html2.WebView.New(parent)
|
||||
super().__init__(abstract.Window(self.webview.RunScript, 'window.wx_msg.postMessage.bind(window.wx_msg)'),
|
||||
inner_width, inner_height, scale_candles_only, toolbox)
|
||||
|
||||
super().__init__(inner_width=inner_width, inner_height=inner_height,
|
||||
scale_candles_only=scale_candles_only, toolbox=toolbox,
|
||||
_js_api_code='window.wx_msg.postMessage.bind(window.wx_msg)')
|
||||
self._script_func = self.webview.RunScript
|
||||
|
||||
self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, lambda e: wx.CallLater(500, self._on_js_load))
|
||||
self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: _widget_message(self, e.GetString()))
|
||||
self.webview.Bind(wx.html2.EVT_WEBVIEW_LOADED, lambda e: wx.CallLater(500, self.win.on_js_load))
|
||||
self.webview.Bind(wx.html2.EVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, lambda e: emit_callback(self, e.GetString()))
|
||||
self.webview.AddScriptMessageHandler('wx_msg')
|
||||
self.webview.SetPage(self._html, '')
|
||||
self.webview.AddUserScript(JS['toolbox']) if toolbox else None
|
||||
self.webview.SetPage(abstract.TEMPLATE, '')
|
||||
self.webview.AddUserScript(abstract.JS['toolbox']) if toolbox else None
|
||||
|
||||
def get_webview(self): return self.webview
|
||||
|
||||
|
||||
class QtChart(LWC):
|
||||
class QtChart(abstract.AbstractChart):
|
||||
def __init__(self, widget=None, inner_width: float = 1.0, inner_height: float = 1.0,
|
||||
scale_candles_only: bool = False, toolbox: bool = False):
|
||||
if QWebEngineView is None:
|
||||
raise ModuleNotFoundError('QWebEngineView was not found, and must be installed to use QtChart.')
|
||||
self.webview = QWebEngineView(widget)
|
||||
|
||||
super().__init__(inner_width=inner_width, inner_height=inner_height,
|
||||
scale_candles_only=scale_candles_only, toolbox=toolbox,
|
||||
_js_api_code='window.pythonObject.callback')
|
||||
self._script_func = self.webview.page().runJavaScript
|
||||
super().__init__(abstract.Window(self.webview.page().runJavaScript, 'window.pythonObject.callback'),
|
||||
inner_width, inner_height, scale_candles_only, toolbox)
|
||||
|
||||
self.web_channel = QWebChannel()
|
||||
self.bridge = Bridge(self)
|
||||
self.web_channel.registerObject('bridge', self.bridge)
|
||||
self.webview.page().setWebChannel(self.web_channel)
|
||||
self.webview.loadFinished.connect(self._on_js_load)
|
||||
self.webview.loadFinished.connect(self.win.on_js_load)
|
||||
self._html = f'''
|
||||
{self._html[:85]}
|
||||
{abstract.TEMPLATE[:85]}
|
||||
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
|
||||
<script>
|
||||
var bridge = new QWebChannel(qt.webChannelTransport, function(channel) {{
|
||||
@ -99,32 +87,33 @@ class QtChart(LWC):
|
||||
window.pythonObject = pythonObject
|
||||
}});
|
||||
</script>
|
||||
{self._html[85:]}
|
||||
{abstract.TEMPLATE[85:]}
|
||||
'''
|
||||
self.webview.page().setHtml(self._html)
|
||||
|
||||
def get_webview(self): return self.webview
|
||||
|
||||
|
||||
class StaticLWC(LWC):
|
||||
class StaticLWC(abstract.AbstractChart):
|
||||
def __init__(self, width=None, height=None, inner_width=1, inner_height=1,
|
||||
scale_candles_only: bool = False, toolbox=False, autosize=True):
|
||||
super().__init__(inner_width, inner_height, scale_candles_only=scale_candles_only, toolbox=toolbox, autosize=autosize)
|
||||
self._html = abstract.TEMPLATE.replace('</script>\n</body>\n</html>', '')
|
||||
super().__init__(abstract.Window(run_script=self.run_script), inner_width, inner_height,
|
||||
scale_candles_only, toolbox, autosize)
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._html = self._html.replace('</script>\n</body>\n</html>', '')
|
||||
|
||||
def run_script(self, script, run_last=False):
|
||||
if run_last:
|
||||
self._final_scripts.append(script)
|
||||
self.win.final_scripts.append(script)
|
||||
else:
|
||||
self._html += '\n' + script
|
||||
|
||||
def load(self):
|
||||
if self.loaded:
|
||||
if self.win.loaded:
|
||||
return
|
||||
self.loaded = True
|
||||
for script in self._final_scripts:
|
||||
self.win.loaded = True
|
||||
for script in self.win.final_scripts:
|
||||
self._html += '\n' + script
|
||||
self._load()
|
||||
|
||||
@ -143,8 +132,7 @@ class StreamlitChart(StaticLWC):
|
||||
|
||||
class JupyterChart(StaticLWC):
|
||||
def __init__(self, width: int = 800, height=350, inner_width=1, inner_height=1, scale_candles_only: bool = False, toolbox: bool = False):
|
||||
super().__init__(width, height, inner_width, inner_height, scale_candles_only, toolbox, autosize=False)
|
||||
self._position = ""
|
||||
super().__init__(width, height, inner_width, inner_height, scale_candles_only, toolbox, False)
|
||||
|
||||
self.run_script(f'''
|
||||
for (var i = 0; i < document.getElementsByClassName("tv-lightweight-charts").length; i++) {{
|
||||
|
||||
Reference in New Issue
Block a user