NEW FEATURE: Polygon.io Full integration
- Added `polygon` to the common methods, allowing for data to be pulled from polygon.io. (`chart.polygon.<method>`)
- Added the `PolygonChart` object, which allows for a plug and play solution with the Polygon API.
- Check the docs for more details and examples!

Enhancements:
- Added `clear_markers` and `clear_horizontal_lines` to the common methods.
- Added the `maximize` parameter to the `Chart` object, which maximizes the chart window when shown.
- The Legend will now show Line values, and can be disabled using the `lines` parameter.
- Added the `name` parameter to the `set` method of line, using the column within the dataframe as the value and using its name within the legend.
- Added the `scale_candles_only` parameter to all Chart objects, which prevents the autoscaling of Lines.

- new `screenshot` method, which returns a bytes object of the displayed chart.

Fixes:
- `chart.lines()` now returns a copy of the list rather than the original.
This commit is contained in:
louisnw
2023-06-28 18:36:32 +01:00
parent adfc58a8af
commit d9c8aa3bd8
16 changed files with 932 additions and 649 deletions

View File

@ -1,4 +1,4 @@
from .js import LWC
from .abstract import LWC
from .chart import Chart
from .widgets import JupyterChart
from .polygon import PolygonChart

View File

@ -1,12 +1,51 @@
import pandas as pd
import os
from datetime import timedelta, datetime
from typing import Union, Literal, Dict
from base64 import b64decode
import pandas as pd
from typing import Union, Literal, Dict, List
from lightweight_charts.pkg import LWC_4_0_1
from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, _crosshair_mode, _line_style, \
from lightweight_charts.util import LINE_STYLE, MARKER_POSITION, MARKER_SHAPE, CROSSHAIR_MODE, _crosshair_mode, \
_line_style, \
MissingColumn, _js_bool, _price_scale_mode, PRICE_SCALE_MODE, _marker_position, _marker_shape, IDGen
JS = {}
current_dir = os.path.dirname(os.path.abspath(__file__))
for file in ('pkg', 'funcs', 'callback'):
with open(os.path.join(current_dir, 'js', f'{file}.js'), 'r') as f:
JS[file] = f.read()
HTML = f"""
<!DOCTYPE html>
<html lang="">
<head>
<title>lightweight-charts-python</title>
<script>{JS['pkg']}</script>
<meta name="viewport" content ="width=device-width, initial-scale=1">
<style>
body {{
margin: 0;
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}}
#wrapper {{
width: 100vw;
height: 100vh;
background-color: #000000;
}}
</style>
</head>
<body>
<div id="wrapper"></div>
<script>
{JS['funcs']}
</script>
</body>
</html>
"""
class SeriesCommon:
def _set_interval(self, df: pd.DataFrame):
common_interval = pd.to_datetime(df['time']).diff().value_counts()
@ -14,6 +53,11 @@ class SeriesCommon:
self._interval = common_interval.index[0]
except IndexError:
raise IndexError('Not enough bars within the given data to calculate the interval/timeframe.')
self.run_script(f'''
if ({self.id}.toolBox) {{
{self.id}.toolBox.interval = {self._interval.total_seconds()*1000}
}}
''')
def _df_datetime_format(self, df: pd.DataFrame):
df = df.copy()
@ -95,12 +139,27 @@ class SeriesCommon:
Removes a horizontal line at the given price.
"""
self.run_script(f'''
{self.id}.horizontal_lines.forEach(function (line) {{
if ({price} === line.price) {{
{self.id}.series.removePriceLine(line.line);
{self.id}.horizontal_lines.splice({self.id}.horizontal_lines.indexOf(line), 1)
}}
}});''')
{self.id}.horizontal_lines.forEach(function (line) {{
if ({price} === line.price) {{
{self.id}.series.removePriceLine(line.line);
{self.id}.horizontal_lines.splice({self.id}.horizontal_lines.indexOf(line), 1)
}}
}});''')
def clear_markers(self):
"""
Clears the markers displayed on the data.\n
"""
self.run_script(f'''{self.id}.markers = []; {self.id}.series.setMarkers([]])''')
def clear_horizontal_lines(self):
"""
Clears the horizontal lines displayed on the data.\n
"""
self.run_script(f'''
{self.id}.horizontal_lines.forEach(function (line) {{{self.id}.series.removePriceLine(line.line);}});
{self.id}.horizontal_lines = [];
''')
def title(self, title: str): self.run_script(f'{self.id}.series.applyOptions({{title: "{title}"}})')
@ -123,30 +182,43 @@ class SeriesCommon:
class Line(SeriesCommon):
def __init__(self, parent, color, width, price_line, price_label):
self._parent = parent
self._rand = self._parent._rand
def __init__(self, chart, color, width, price_line, price_label):
self.color = color
self.name = ''
self._chart = chart
self._rand = chart._rand
self.id = f'window.{self._rand.generate()}'
self.run_script = self._parent.run_script
self.run_script = self._chart.run_script
self.run_script(f'''
{self.id} = {{
series: {self._parent.id}.chart.addLineSeries({{
series: {self._chart.id}.chart.addLineSeries({{
color: '{color}',
lineWidth: {width},
lastValueVisible: {_js_bool(price_label)},
priceLineVisible: {_js_bool(price_line)},
{"""autoscaleInfoProvider: () => ({
priceRange: {
minValue: 1_000_000_000,
maxValue: 0,
},
}),""" if self._chart._scale_candles_only else ''}
}}),
markers: [],
horizontal_lines: [],
}}
''')
}}''')
def set(self, data: pd.DataFrame):
def set(self, data: pd.DataFrame, name=None):
"""
Sets the line data.\n
:param data: columns: date/time, value
:param data: If the name parameter is not used, the columns should be named: date/time, value.
:param name: The column of the DataFrame to use as the line value. When used, the Line will be named after this column.
"""
df = self._parent._df_datetime_format(data)
df = self._df_datetime_format(data)
if name:
if name not in data:
raise NameError(f'No column named "{name}".')
self.name = name
df = df.rename(columns={name: 'value'})
self._last_bar = df.iloc[-1]
self.run_script(f'{self.id}.series.setData({df.to_dict("records")})')
@ -155,7 +227,7 @@ class Line(SeriesCommon):
Updates the line data.\n
:param series: labels: date/time, value
"""
series = self._parent._series_datetime_format(series)
series = self._series_datetime_format(series)
self._last_bar = series
self.run_script(f'{self.id}.series.update({series.to_dict()})')
@ -163,9 +235,9 @@ class Line(SeriesCommon):
"""
Irreversibly deletes the line, as well as the object that contains the line.
"""
self._parent._lines.remove(self)
self._chart._lines.remove(self)
self.run_script(f'''
{self._parent.id}.chart.removeSeries({self.id}.series)
{self._chart.id}.chart.removeSeries({self.id}.series)
delete {self.id}
''')
del self
@ -174,7 +246,7 @@ class Line(SeriesCommon):
class Widget:
def __init__(self, topbar):
self._chart = topbar._chart
self.method = None
self._method = None
class TextWidget(Widget):
@ -193,7 +265,7 @@ class SwitcherWidget(Widget):
def __init__(self, topbar, method, *options, default):
super().__init__(topbar)
self.value = default
self.method = method.__name__
self._method = method.__name__
self._chart.run_script(f'''
makeSwitcher({self._chart.id}, {list(options)}, '{default}', {self._chart._js_api_code}, '{method.__name__}',
'{topbar.active_background_color}', '{topbar.active_text_color}', '{topbar.text_color}', '{topbar.hover_color}')
@ -224,13 +296,15 @@ class TopBar:
def _widget_with_method(self, method_name):
for widget in self._widgets.values():
if widget.method == method_name:
if widget._method == method_name:
return widget
class LWC(SeriesCommon):
def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False):
def __init__(self, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False,
scale_candles_only: bool = False):
self.volume_enabled = volume_enabled
self._scale_candles_only = scale_candles_only
self._inner_width = inner_width
self._inner_height = inner_height
self._dynamic_loading = dynamic_loading
@ -248,6 +322,7 @@ class LWC(SeriesCommon):
self._charts = {self.id: self}
self._lines = []
self._js_api_code = None
self._return_q = None
self._background_color = '#000000'
self._volume_up_color = 'rgba(83,141,131,0.8)'
@ -340,7 +415,7 @@ class LWC(SeriesCommon):
timer = null;
}}, 50);
}});
''') if self._dynamic_loading else self.run_script(f'{self.id}.series.setData({bars})')
''') if self._dynamic_loading else self.run_script(f'{self.id}.candleData = {bars}; {self.id}.series.setData({self.id}.candleData)')
def fit(self):
"""
@ -423,12 +498,11 @@ class LWC(SeriesCommon):
self._lines.append(Line(self, color, width, price_line, price_label))
return self._lines[-1]
def lines(self):
def lines(self) -> List[Line]:
"""
Returns all lines for the chart.
:return:
"""
return self._lines
return self._lines.copy()
def price_scale(self, mode: PRICE_SCALE_MODE = 'normal', align_labels: bool = True, border_visible: bool = False,
border_color: str = None, text_color: str = None, entire_text_only: bool = False,
@ -461,7 +535,6 @@ class LWC(SeriesCommon):
secondsVisible: {_js_bool(seconds_visible)},
borderVisible: {_js_bool(border_visible)},
{f'borderColor: "{border_color}",' if border_color else ''}
}}
}})''')
@ -584,39 +657,64 @@ class LWC(SeriesCommon):
}}
}})''')
def legend(self, visible: bool = False, ohlc: bool = True, percent: bool = True, color: str = None,
def legend(self, visible: bool = False, ohlc: bool = True, percent: bool = True, lines: bool = True, color: str = None,
font_size: int = None, font_family: str = None):
"""
Configures the legend of the chart.
"""
if visible:
self.run_script(f'''
{f"{self.id}.legend.style.color = '{color}'" if color else ''}
{f"{self.id}.legend.style.fontSize = {font_size}" if font_size else ''}
{f"{self.id}.legend.style.fontFamily = '{font_family}'" if font_family else ''}
{self.id}.chart.subscribeCrosshairMove((param) => {{
if (param.time){{
let data = param.seriesData.get({self.id}.series);
if (!data) {{return}}
let ohlc = `O ${{legendItemFormat(data.open)}}
| H ${{legendItemFormat(data.high)}}
| L ${{legendItemFormat(data.low)}}
| C ${{legendItemFormat(data.close)}} `
let percentMove = ((data.close-data.open)/data.open)*100
let percent = `| ${{percentMove >= 0 ? '+' : ''}}${{percentMove.toFixed(2)}} %`
let finalString = ''
{'finalString += ohlc' if ohlc else ''}
{'finalString += percent' if percent else ''}
{self.id}.legend.innerHTML = finalString
}}
else {{
{self.id}.legend.innerHTML = ''
}}
}});''')
if not visible:
return
lines_code = ''
for i, line in enumerate(self._lines):
lines_code += f'''finalString += `<br><span style="color: {line.color};">▨</span>{f' {line.name}'} :
${{legendItemFormat(param.seriesData.get({line.id}.series).value)}}`;'''
self.run_script(f'''
{f"{self.id}.legend.style.color = '{color}'" if color else ''}
{f"{self.id}.legend.style.fontSize = {font_size}" if font_size else ''}
{f"{self.id}.legend.style.fontFamily = '{font_family}'" if font_family else ''}
{self.id}.chart.subscribeCrosshairMove((param) => {{
if (param.time){{
let data = param.seriesData.get({self.id}.series);
if (!data) {{return}}
let ohlc = `O ${{legendItemFormat(data.open)}}
| H ${{legendItemFormat(data.high)}}
| L ${{legendItemFormat(data.low)}}
| C ${{legendItemFormat(data.close)}} `
let percentMove = ((data.close-data.open)/data.open)*100
let percent = `| ${{percentMove >= 0 ? '+' : ''}}${{percentMove.toFixed(2)}} %`
let finalString = '<span style="line-height: 1.8;">'
{'finalString += ohlc' if ohlc else ''}
{'finalString += percent' if percent else ''}
{lines_code if lines else ''}
{self.id}.legend.innerHTML = finalString+'</span>'
}}
else {{
{self.id}.legend.innerHTML = ''
}}
}});''')
def spinner(self, visible): self.run_script(f"{self.id}.spinner.style.display = '{'block' if visible else 'none'}'")
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'''
let canvas = {self.id}.chart.takeScreenshot()
canvas.toBlob(function(blob) {{
const reader = new FileReader();
reader.onload = function(event) {{
{self._js_api_code}(`return__{self.id}__${{event.target.result}}`)
}};
reader.readAsDataURL(blob);
}})
''')
serial_data = self._return_q.get()
return b64decode(serial_data.split(',')[1])
def create_subchart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left',
width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False,
topbar: bool = False, searchbox: bool = False):
@ -633,6 +731,7 @@ class SubChart(LWC):
self._position = position
self._rand = self._chart._rand
self._js_api_code = self._chart._js_api_code
self._return_q = self._chart._return_q
self.run_script = self._chart.run_script
self._charts = self._chart._charts
self.id = f'window.{self._rand.generate()}'
@ -655,407 +754,4 @@ class SubChart(LWC):
''', run_last=True)
SCRIPT = """
document.getElementById('wrapper').style.backgroundColor = '#000000'
function makeChart(innerWidth, innerHeight, autoSize=true) {
let chart = {
markers: [],
horizontal_lines: [],
div: document.createElement('div'),
wrapper: document.createElement('div'),
legend: document.createElement('div'),
scale: {
width: innerWidth,
height: innerHeight
},
}
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)'
}
},
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.legend.style.position = 'absolute'
chart.legend.style.zIndex = 1000
chart.legend.style.width = `${(chart.scale.width*100)-8}vw`
chart.legend.style.top = '10px'
chart.legend.style.left = '10px'
chart.legend.style.fontFamily = 'Monaco'
chart.legend.style.fontSize = '11px'
chart.legend.style.color = 'rgb(191, 195, 203)'
chart.wrapper.style.width = `${100*innerWidth}%`
chart.wrapper.style.height = `${100*innerHeight}%`
chart.div.style.position = 'relative'
chart.wrapper.style.display = 'flex'
chart.wrapper.style.flexDirection = 'column'
chart.div.appendChild(chart.legend)
chart.wrapper.appendChild(chart.div)
document.getElementById('wrapper').append(chart.wrapper)
if (!autoSize) {
return chart
}
let topBarOffset = 0
window.addEventListener('resize', function() {
if ('topBar' in chart) {
topBarOffset = chart.topBar.offsetHeight
}
chart.chart.resize(window.innerWidth*innerWidth, (window.innerHeight*innerHeight)-topBarOffset)
});
return chart
}
function makeHorizontalLine(chart, lineId, price, color, width, style, axisLabelVisible, text) {
let priceLine = {
price: price,
color: color,
lineWidth: width,
lineStyle: style,
axisLabelVisible: axisLabelVisible,
title: text,
};
let line = {
line: chart.series.createPriceLine(priceLine),
price: price,
id: lineId,
};
chart.horizontal_lines.push(line)
}
function legendItemFormat(num) {
return num.toFixed(2).toString().padStart(8, ' ')
}
function syncCrosshairs(childChart, parentChart) {
let parent = 0
let child = 0
let parentCrosshairHandler = (e) => {
parent ++
if (parent < 10) {
return
}
child = 0
parentChart.applyOptions({crosshair: { horzLine: {
visible: true,
labelVisible: true,
}}})
childChart.applyOptions({crosshair: { horzLine: {
visible: false,
labelVisible: false,
}}})
childChart.unsubscribeCrosshairMove(childCrosshairHandler)
if (e.time !== undefined) {
let xx = childChart.timeScale().timeToCoordinate(e.time);
childChart.setCrosshairXY(xx,300,true);
} else if (e.point !== undefined){
childChart.setCrosshairXY(e.point.x,300,false);
}
childChart.subscribeCrosshairMove(childCrosshairHandler)
}
let childCrosshairHandler = (e) => {
child ++
if (child < 10) {
return
}
parent = 0
childChart.applyOptions({crosshair: {horzLine: {
visible: true,
labelVisible: true,
}}})
parentChart.applyOptions({crosshair: {horzLine: {
visible: false,
labelVisible: false,
}}})
parentChart.unsubscribeCrosshairMove(parentCrosshairHandler)
if (e.time !== undefined) {
let xx = parentChart.timeScale().timeToCoordinate(e.time);
parentChart.setCrosshairXY(xx,300,true);
} else if (e.point !== undefined){
parentChart.setCrosshairXY(e.point.x,300,false);
}
parentChart.subscribeCrosshairMove(parentCrosshairHandler)
}
parentChart.subscribeCrosshairMove(parentCrosshairHandler)
childChart.subscribeCrosshairMove(childCrosshairHandler)
}
"""
HTML = f"""
<!DOCTYPE html>
<html lang="">
<head>
<title>lightweight-charts-python</title>
<script>{LWC_4_0_1}</script>
<meta name="viewport" content ="width=device-width, initial-scale=1">
<style>
body {{
margin: 0;
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}}
#wrapper {{
width: 100vw;
height: 100vh;
}}
</style>
</head>
<body>
<div id="wrapper"></div>
<script>
{SCRIPT}
</script>
</body>
</html>"""
CALLBACK_SCRIPT = '''
function makeSearchBox(chart, callbackFunction) {
let searchWindow = document.createElement('div')
searchWindow.style.position = 'absolute'
searchWindow.style.top = '0'
searchWindow.style.bottom = '200px'
searchWindow.style.left = '0'
searchWindow.style.right = '0'
searchWindow.style.margin = 'auto'
searchWindow.style.width = '150px'
searchWindow.style.height = '30px'
searchWindow.style.padding = '10px'
searchWindow.style.backgroundColor = 'rgba(30, 30, 30, 0.9)'
searchWindow.style.border = '2px solid #3C434C'
searchWindow.style.zIndex = '1000'
searchWindow.style.display = 'none'
searchWindow.style.borderRadius = '5px'
let magnifyingGlass = document.createElement('span');
magnifyingGlass.style.display = 'inline-block';
magnifyingGlass.style.width = '12px';
magnifyingGlass.style.height = '12px';
magnifyingGlass.style.border = '2px solid rgb(240, 240, 240)';
magnifyingGlass.style.borderRadius = '50%';
magnifyingGlass.style.position = 'relative';
let handle = document.createElement('span');
handle.style.display = 'block';
handle.style.width = '7px';
handle.style.height = '2px';
handle.style.backgroundColor = 'rgb(240, 240, 240)';
handle.style.position = 'absolute';
handle.style.top = 'calc(50% + 7px)';
handle.style.right = 'calc(50% - 11px)';
handle.style.transform = 'rotate(45deg)';
let sBox = document.createElement('input');
sBox.type = 'text';
sBox.style.position = 'relative';
sBox.style.display = 'inline-block';
sBox.style.zIndex = '1000';
sBox.style.textAlign = 'center'
sBox.style.width = '100px'
sBox.style.marginLeft = '15px'
sBox.style.backgroundColor = 'rgba(0, 122, 255, 0.3)'
sBox.style.color = 'rgb(240,240,240)'
sBox.style.fontSize = '20px'
sBox.style.border = 'none'
sBox.style.outline = 'none'
sBox.style.borderRadius = '2px'
searchWindow.appendChild(magnifyingGlass)
magnifyingGlass.appendChild(handle)
searchWindow.appendChild(sBox)
chart.div.appendChild(searchWindow);
let yPrice = null
chart.chart.subscribeCrosshairMove((param) => {
if (param.point){
yPrice = param.point.y;
}
});
let selectedChart = true
chart.wrapper.addEventListener('mouseover', (event) => {
selectedChart = true
})
chart.wrapper.addEventListener('mouseout', (event) => {
selectedChart = false
})
document.addEventListener('keydown', function(event) {
if (!selectedChart) {return}
if (event.altKey && event.code === 'KeyH') {
let price = chart.series.coordinateToPrice(yPrice)
let colorList = [
'rgba(228, 0, 16, 0.7)',
'rgba(255, 133, 34, 0.7)',
'rgba(164, 59, 176, 0.7)',
'rgba(129, 59, 102, 0.7)',
'rgba(91, 20, 248, 0.7)',
'rgba(32, 86, 249, 0.7)',
]
let color = colorList[Math.floor(Math.random()*colorList.length)]
makeHorizontalLine(chart, 0, price, color, 2, LightweightCharts.LineStyle.Solid, true, '')
}
if (searchWindow.style.display === 'none') {
if (/^[a-zA-Z0-9]$/.test(event.key)) {
searchWindow.style.display = 'block';
sBox.focus();
}
}
else if (event.key === 'Enter') {
callbackFunction(`on_search__${chart.id}__${sBox.value}`)
searchWindow.style.display = 'none'
sBox.value = ''
}
else if (event.key === 'Escape') {
searchWindow.style.display = 'none'
sBox.value = ''
}
});
sBox.addEventListener('input', function() {
sBox.value = sBox.value.toUpperCase();
});
return {
window: searchWindow,
box: sBox,
}
}
function makeSpinner(chart) {
chart.spinner = document.createElement('div')
chart.spinner.style.width = '30px'
chart.spinner.style.height = '30px'
chart.spinner.style.border = '4px solid rgba(255, 255, 255, 0.6)'
chart.spinner.style.borderTop = '4px solid rgba(0, 122, 255, 0.8)'
chart.spinner.style.borderRadius = '50%'
chart.spinner.style.position = 'absolute'
chart.spinner.style.top = '50%'
chart.spinner.style.left = '50%'
chart.spinner.style.zIndex = 1000
chart.spinner.style.transform = 'translate(-50%, -50%)'
chart.spinner.style.display = 'none'
chart.wrapper.appendChild(chart.spinner)
let rotation = 0;
const speed = 10; // Adjust this value to change the animation speed
function animateSpinner() {
rotation += speed
chart.spinner.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`
requestAnimationFrame(animateSpinner)
}
animateSpinner();
}
function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName, activeBackgroundColor, activeColor, inactiveColor, hoverColor) {
let switcherElement = document.createElement('div');
switcherElement.style.margin = '4px 14px'
switcherElement.style.zIndex = '1000'
let intervalElements = items.map(function(item) {
let itemEl = document.createElement('button');
itemEl.style.cursor = 'pointer'
itemEl.style.padding = '2px 5px'
itemEl.style.margin = '0px 4px'
itemEl.style.fontSize = '13px'
itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : 'transparent'
itemEl.style.color = item === activeItem ? activeColor : inactiveColor
itemEl.style.border = 'none'
itemEl.style.borderRadius = '4px'
itemEl.addEventListener('mouseenter', function() {
itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : hoverColor
itemEl.style.color = activeColor
})
itemEl.addEventListener('mouseleave', function() {
itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : 'transparent'
itemEl.style.color = item === activeItem ? activeColor : inactiveColor
})
itemEl.innerText = item;
itemEl.addEventListener('click', function() {
onItemClicked(item);
});
switcherElement.appendChild(itemEl);
return itemEl;
});
function onItemClicked(item) {
if (item === activeItem) {
return;
}
intervalElements.forEach(function(element, index) {
element.style.backgroundColor = items[index] === item ? activeBackgroundColor : 'transparent'
element.style.color = items[index] === item ? 'activeColor' : inactiveColor
});
activeItem = item;
callbackFunction(`${callbackName}__${chart.id}__${item}`);
}
chart.topBar.appendChild(switcherElement)
makeSeperator(chart.topBar)
return switcherElement;
}
function makeTextBoxWidget(chart, text) {
let textBox = document.createElement('div')
textBox.style.margin = '0px 18px'
textBox.style.position = 'relative'
textBox.style.fontSize = '16px'
textBox.style.color = 'rgb(220, 220, 220)'
textBox.innerText = text
chart.topBar.append(textBox)
makeSeperator(chart.topBar)
return textBox
}
function makeTopBar(chart) {
chart.topBar = document.createElement('div')
chart.topBar.style.backgroundColor = '#191B1E'
chart.topBar.style.borderBottom = '2px solid #3C434C'
chart.topBar.style.display = 'flex'
chart.topBar.style.alignItems = 'center'
chart.wrapper.prepend(chart.topBar)
}
function makeSeperator(topBar) {
let seperator = document.createElement('div')
seperator.style.width = '1px'
seperator.style.height = '20px'
seperator.style.backgroundColor = '#3C434C'
topBar.appendChild(seperator)
}
'''

View File

@ -1,28 +1,30 @@
import asyncio
import time
import multiprocessing as mp
import webview
from lightweight_charts.js import LWC, CALLBACK_SCRIPT, TopBar
from lightweight_charts.abstract import LWC, JS, TopBar
class CallbackAPI:
def __init__(self, emit): self.emit = emit
def __init__(self, emit_queue, return_queue):
self.emit_q, self.return_q = emit_queue, return_queue
def callback(self, message: str):
messages = message.split('__')
name, chart_id = messages[:2]
args = messages[2:]
self.emit.put((name, chart_id, *args))
self.return_q.put(*args) if name == 'return' else self.emit_q.put((name, chart_id, *args))
class PyWV:
def __init__(self, q, exit, loaded, html, width, height, x, y, on_top, debug, emit):
def __init__(self, q, 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
self.queue = q
self.exit = exit
self.loaded = loaded
self.debug = debug
js_api = CallbackAPI(emit)
js_api = CallbackAPI(emit_queue, return_queue)
self.webview = webview.create_window('', html=html, on_top=on_top, js_api=js_api, width=width, height=height,
x=x, y=y, background_color='#000000')
self.webview.events.loaded += self.on_js_load
@ -40,30 +42,28 @@ class PyWV:
except KeyError:
return
def on_js_load(self):
self.loaded.set(), self.loop()
def on_js_load(self): self.loaded.set(), self.loop()
class Chart(LWC):
def __init__(self, volume_enabled: bool = True, width: int = 800, height: int = 600, x: int = None, y: int = None,
on_top: bool = False, debug: bool = False, api: object = None, topbar: bool = False, searchbox: bool = False,
inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False):
super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading)
self._emit = mp.Queue()
self._q = mp.Queue()
on_top: bool = False, maximize: bool = False, debug: bool = False,
api: object = None, topbar: bool = False, searchbox: bool = False,
inner_width: float = 1.0, inner_height: float = 1.0, dynamic_loading: bool = False, scale_candles_only: bool = False):
super().__init__(volume_enabled, inner_width, inner_height, dynamic_loading, scale_candles_only)
self._q, self._emit_q, self._return_q = (mp.Queue() for _ in range(3))
self._exit, self._loaded = mp.Event(), mp.Event()
self._script_func = self._q.put
self._exit = mp.Event()
self._loaded = mp.Event()
self._api = api
self._js_api_code = 'pywebview.api.callback'
self._process = mp.Process(target=PyWV, args=(self._q, self._exit, self._loaded, self._html,
width, height, x, y, on_top, debug, self._emit), daemon=True)
width, height, x, y, on_top, maximize, debug,
self._emit_q, self._return_q), daemon=True)
self._process.start()
self._create_chart()
self.api = api
self._js_api_code = 'pywebview.api.callback'
if not topbar and not searchbox:
return
self.run_script(CALLBACK_SCRIPT)
self.run_script(JS['callback'])
self.run_script(f'makeSpinner({self.id})')
self.topbar = TopBar(self) if topbar else None
self._make_search_box() if searchbox else None
@ -80,50 +80,35 @@ class Chart(LWC):
else:
self._q.put('show')
if block:
try:
while 1:
while not self._exit.is_set() and self.polygon._q.empty():
time.sleep(0.05)
continue
if self._exit.is_set():
self._exit.clear()
return
value = self.polygon._q.get_nowait()
func, args = value[0], value[1:]
func(*args)
except KeyboardInterrupt:
return
asyncio.run(self.show_async(block=True))
async def show_async(self, block=False):
if not self.loaded:
self._q.put('start')
self._loaded.wait()
self._on_js_load()
else:
self._q.put('show')
if block:
try:
while 1:
while self._emit.empty() and not self._exit.is_set() and self.polygon._q.empty():
await asyncio.sleep(0.05)
if self._exit.is_set():
self._exit.clear()
return
elif not self._emit.empty():
key, chart_id, arg = self._emit.get()
self.api.chart = self._charts[chart_id]
if widget := self.api.chart.topbar._widget_with_method(key):
widget.value = arg
await getattr(self.api, key)()
else:
await getattr(self.api, key)(arg)
continue
value = self.polygon._q.get()
func, args = value[0], value[1:]
func(*args)
except KeyboardInterrupt:
return
asyncio.create_task(self.show_async(block=True))
self.show(block=False)
if not block:
asyncio.create_task(self.show_async(block=True))
return
try:
while 1:
while self._emit_q.empty() and not self._exit.is_set() and self.polygon._q.empty():
await asyncio.sleep(0.05)
if self._exit.is_set():
self._exit.clear()
return
elif not self._emit_q.empty():
key, chart_id, arg = self._emit_q.get()
self._api.chart = self._charts[chart_id]
if widget := self._api.chart.topbar._widget_with_method(key):
widget.value = arg
await getattr(self._api, key)()
else:
await getattr(self._api, key)(arg)
continue
value = self.polygon._q.get()
func, args = value[0], value[1:]
func(*args)
except KeyboardInterrupt:
return
def hide(self):
"""

View File

@ -0,0 +1,208 @@
function makeSearchBox(chart, callbackFunction) {
let searchWindow = document.createElement('div')
searchWindow.style.position = 'absolute'
searchWindow.style.top = '0'
searchWindow.style.bottom = '200px'
searchWindow.style.left = '0'
searchWindow.style.right = '0'
searchWindow.style.margin = 'auto'
searchWindow.style.width = '150px'
searchWindow.style.height = '30px'
searchWindow.style.padding = '10px'
searchWindow.style.backgroundColor = 'rgba(30, 30, 30, 0.9)'
searchWindow.style.border = '2px solid #3C434C'
searchWindow.style.zIndex = '1000'
searchWindow.style.display = 'none'
searchWindow.style.borderRadius = '5px'
let magnifyingGlass = document.createElement('span');
magnifyingGlass.style.display = 'inline-block';
magnifyingGlass.style.width = '12px';
magnifyingGlass.style.height = '12px';
magnifyingGlass.style.border = '2px solid rgb(240, 240, 240)';
magnifyingGlass.style.borderRadius = '50%';
magnifyingGlass.style.position = 'relative';
let handle = document.createElement('span');
handle.style.display = 'block';
handle.style.width = '7px';
handle.style.height = '2px';
handle.style.backgroundColor = 'rgb(240, 240, 240)';
handle.style.position = 'absolute';
handle.style.top = 'calc(50% + 7px)';
handle.style.right = 'calc(50% - 11px)';
handle.style.transform = 'rotate(45deg)';
let sBox = document.createElement('input');
sBox.type = 'text';
sBox.style.position = 'relative';
sBox.style.display = 'inline-block';
sBox.style.zIndex = '1000';
sBox.style.textAlign = 'center'
sBox.style.width = '100px'
sBox.style.marginLeft = '15px'
sBox.style.backgroundColor = 'rgba(0, 122, 255, 0.3)'
sBox.style.color = 'rgb(240,240,240)'
sBox.style.fontSize = '20px'
sBox.style.border = 'none'
sBox.style.outline = 'none'
sBox.style.borderRadius = '2px'
searchWindow.appendChild(magnifyingGlass)
magnifyingGlass.appendChild(handle)
searchWindow.appendChild(sBox)
chart.div.appendChild(searchWindow);
let yPrice = null
chart.chart.subscribeCrosshairMove((param) => {
if (param.point){
yPrice = param.point.y;
}
});
let selectedChart = true
chart.wrapper.addEventListener('mouseover', (event) => {
selectedChart = true
})
chart.wrapper.addEventListener('mouseout', (event) => {
selectedChart = false
})
document.addEventListener('keydown', function(event) {
if (!selectedChart) {return}
if (event.altKey && event.code === 'KeyH') {
let price = chart.series.coordinateToPrice(yPrice)
let colorList = [
'rgba(228, 0, 16, 0.7)',
'rgba(255, 133, 34, 0.7)',
'rgba(164, 59, 176, 0.7)',
'rgba(129, 59, 102, 0.7)',
'rgba(91, 20, 248, 0.7)',
'rgba(32, 86, 249, 0.7)',
]
let color = colorList[Math.floor(Math.random()*colorList.length)]
makeHorizontalLine(chart, 0, price, color, 2, LightweightCharts.LineStyle.Solid, true, '')
}
if (searchWindow.style.display === 'none') {
if (/^[a-zA-Z0-9]$/.test(event.key)) {
searchWindow.style.display = 'block';
sBox.focus();
}
}
else if (event.key === 'Enter') {
callbackFunction(`on_search__${chart.id}__${sBox.value}`)
searchWindow.style.display = 'none'
sBox.value = ''
}
else if (event.key === 'Escape') {
searchWindow.style.display = 'none'
sBox.value = ''
}
});
sBox.addEventListener('input', function() {
sBox.value = sBox.value.toUpperCase();
});
return {
window: searchWindow,
box: sBox,
}
}
function makeSpinner(chart) {
chart.spinner = document.createElement('div')
chart.spinner.style.width = '30px'
chart.spinner.style.height = '30px'
chart.spinner.style.border = '4px solid rgba(255, 255, 255, 0.6)'
chart.spinner.style.borderTop = '4px solid rgba(0, 122, 255, 0.8)'
chart.spinner.style.borderRadius = '50%'
chart.spinner.style.position = 'absolute'
chart.spinner.style.top = '50%'
chart.spinner.style.left = '50%'
chart.spinner.style.zIndex = 1000
chart.spinner.style.transform = 'translate(-50%, -50%)'
chart.spinner.style.display = 'none'
chart.wrapper.appendChild(chart.spinner)
let rotation = 0;
const speed = 10; // Adjust this value to change the animation speed
function animateSpinner() {
rotation += speed
chart.spinner.style.transform = `translate(-50%, -50%) rotate(${rotation}deg)`
requestAnimationFrame(animateSpinner)
}
animateSpinner();
}
function makeSwitcher(chart, items, activeItem, callbackFunction, callbackName, activeBackgroundColor, activeColor, inactiveColor, hoverColor) {
let switcherElement = document.createElement('div');
switcherElement.style.margin = '4px 14px'
switcherElement.style.zIndex = '1000'
let intervalElements = items.map(function(item) {
let itemEl = document.createElement('button');
itemEl.style.cursor = 'pointer'
itemEl.style.padding = '2px 5px'
itemEl.style.margin = '0px 4px'
itemEl.style.fontSize = '13px'
itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : 'transparent'
itemEl.style.color = item === activeItem ? activeColor : inactiveColor
itemEl.style.border = 'none'
itemEl.style.borderRadius = '4px'
itemEl.addEventListener('mouseenter', function() {
itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : hoverColor
itemEl.style.color = activeColor
})
itemEl.addEventListener('mouseleave', function() {
itemEl.style.backgroundColor = item === activeItem ? activeBackgroundColor : 'transparent'
itemEl.style.color = item === activeItem ? activeColor : inactiveColor
})
itemEl.innerText = item;
itemEl.addEventListener('click', function() {
onItemClicked(item);
});
switcherElement.appendChild(itemEl);
return itemEl;
});
function onItemClicked(item) {
if (item === activeItem) {
return;
}
intervalElements.forEach(function(element, index) {
element.style.backgroundColor = items[index] === item ? activeBackgroundColor : 'transparent'
element.style.color = items[index] === item ? 'activeColor' : inactiveColor
});
activeItem = item;
callbackFunction(`${callbackName}__${chart.id}__${item}`);
}
chart.topBar.appendChild(switcherElement)
makeSeperator(chart.topBar)
return switcherElement;
}
function makeTextBoxWidget(chart, text) {
let textBox = document.createElement('div')
textBox.style.margin = '0px 18px'
textBox.style.position = 'relative'
textBox.style.fontSize = '16px'
textBox.style.color = 'rgb(220, 220, 220)'
textBox.innerText = text
chart.topBar.append(textBox)
makeSeperator(chart.topBar)
return textBox
}
function makeTopBar(chart) {
chart.topBar = document.createElement('div')
chart.topBar.style.backgroundColor = '#191B1E'
chart.topBar.style.borderBottom = '2px solid #3C434C'
chart.topBar.style.display = 'flex'
chart.topBar.style.alignItems = 'center'
chart.wrapper.prepend(chart.topBar)
}
function makeSeperator(topBar) {
let seperator = document.createElement('div')
seperator.style.width = '1px'
seperator.style.height = '20px'
seperator.style.backgroundColor = '#3C434C'
topBar.appendChild(seperator)
}

View File

@ -0,0 +1,167 @@
function makeChart(innerWidth, innerHeight, autoSize=true) {
let chart = {
markers: [],
horizontal_lines: [],
wrapper: document.createElement('div'),
div: document.createElement('div'),
legend: document.createElement('div'),
scale: {
width: innerWidth,
height: innerHeight
},
}
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)'
}
},
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.legend.style.position = 'absolute'
chart.legend.style.zIndex = 1000
chart.legend.style.width = `${(chart.scale.width*100)-8}vw`
chart.legend.style.top = '10px'
chart.legend.style.left = '10px'
chart.legend.style.fontFamily = 'Monaco'
chart.legend.style.fontSize = '11px'
chart.legend.style.color = 'rgb(191, 195, 203)'
chart.wrapper.style.width = `${100*innerWidth}%`
chart.wrapper.style.height = `${100*innerHeight}%`
chart.wrapper.style.display = 'flex'
chart.wrapper.style.flexDirection = 'column'
chart.div.style.position = 'relative'
chart.div.style.display = 'flex'
chart.div.appendChild(chart.legend)
chart.wrapper.appendChild(chart.div)
document.getElementById('wrapper').append(chart.wrapper)
if (!autoSize) {
return chart
}
let topBarOffset = 0
window.addEventListener('resize', function() {
if ('topBar' in chart) {
topBarOffset = chart.topBar.offsetHeight
}
chart.chart.resize(window.innerWidth*innerWidth, (window.innerHeight*innerHeight)-topBarOffset)
});
return chart
}
function makeHorizontalLine(chart, lineId, price, color, width, style, axisLabelVisible, text) {
let priceLine = {
price: price,
color: color,
lineWidth: width,
lineStyle: style,
axisLabelVisible: axisLabelVisible,
title: text,
};
let line = {
line: chart.series.createPriceLine(priceLine),
price: price,
id: lineId,
};
chart.horizontal_lines.push(line)
}
function legendItemFormat(num) {
return num.toFixed(2).toString().padStart(8, ' ')
}
function syncCrosshairs(childChart, parentChart) {
let parent = 0
let child = 0
let parentCrosshairHandler = (e) => {
parent ++
if (parent < 10) {
return
}
child = 0
parentChart.applyOptions({crosshair: { horzLine: {
visible: true,
labelVisible: true,
}}})
childChart.applyOptions({crosshair: { horzLine: {
visible: false,
labelVisible: false,
}}})
childChart.unsubscribeCrosshairMove(childCrosshairHandler)
if (e.time !== undefined) {
let xx = childChart.timeScale().timeToCoordinate(e.time);
childChart.setCrosshairXY(xx,300,true);
} else if (e.point !== undefined){
childChart.setCrosshairXY(e.point.x,300,false);
}
childChart.subscribeCrosshairMove(childCrosshairHandler)
}
let childCrosshairHandler = (e) => {
child ++
if (child < 10) {
return
}
parent = 0
childChart.applyOptions({crosshair: {horzLine: {
visible: true,
labelVisible: true,
}}})
parentChart.applyOptions({crosshair: {horzLine: {
visible: false,
labelVisible: false,
}}})
parentChart.unsubscribeCrosshairMove(parentCrosshairHandler)
if (e.time !== undefined) {
let xx = parentChart.timeScale().timeToCoordinate(e.time);
parentChart.setCrosshairXY(xx,300,true);
} else if (e.point !== undefined){
parentChart.setCrosshairXY(e.point.x,300,false);
}
parentChart.subscribeCrosshairMove(parentCrosshairHandler)
}
parentChart.subscribeCrosshairMove(parentCrosshairHandler)
childChart.subscribeCrosshairMove(childCrosshairHandler)
}

File diff suppressed because one or more lines are too long

View File

@ -22,6 +22,12 @@ except ImportError:
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>).
"""
def __init__(self, chart):
ch = logging.StreamHandler()
ch.setFormatter(logging.Formatter('%(asctime)s | [polygon.io] %(levelname)s: %(message)s', datefmt='%H:%M:%S'))
@ -30,23 +36,32 @@ class PolygonAPI:
self._log.setLevel(logging.ERROR)
self._log.addHandler(ch)
self.max_ticks_per_response = 20
self._chart = chart
self._lasts = {} # $$
self._lasts = {}
self._key = None
self._using_live_data = False
self._using_live = {'stocks': False, 'options': False, 'indices': False, 'crypto': False, 'forex': False}
self._ws = {'stocks': None, 'options': None, 'indices': None, 'crypto': None, 'forex': None}
self._send_q = queue.Queue()
self._ws_q = queue.Queue()
self._q = queue.Queue()
self._lock = threading.Lock()
def _subchart(self, subchart):
return PolygonAPISubChart(self, subchart)
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}
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)
def api_key(self, key: str): self._key = key
def api_key(self, key: str):
"""
Sets the API key to be used with Polygon.io.
"""
self._key = key
def stock(self, symbol: str, timeframe: str, start_date: str, end_date='now', limit: int = 5_000, live: bool = False):
"""
@ -55,34 +70,74 @@ class PolygonAPI:
:param timeframe: Timeframe to request (1min, 5min, 2H, 1D, 1W, 2M, etc).
:param start_date: Start date of the data (YYYY-MM-DD).
:param end_date: End date of the data (YYYY-MM-DD). If left blank, this will be set to today.
:param limit: The limit of base aggregates queried to create the timeframe given (max 50_000)
:param limit: The limit of base aggregates queried to create the timeframe given (max 50_000).
:param live: If true, the data will be updated in real-time.
"""
return True if self._set(self._chart, 'stocks', symbol, timeframe, start_date, end_date, limit, live) else False
return self._set(self._chart, '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):
"""
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 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).
:param right: Right of the option (C, P).
:param strike: The strike price of the option.
:param end_date: End date of the data (YYYY-MM-DD). If left blank, this will be set to today.
:param limit: The limit of base aggregates queried to create the timeframe given (max 50_000).
:param live: If true, the data will be updated in real-time.
"""
if any((expiration, right, strike)):
symbol = f'O:{symbol}{dt.datetime.strptime(expiration, "%Y-%m-%d").strftime("%y%m%d")}{right}{strike * 1000:08d}'
return True if self._set(self._chart, 'options', symbol, timeframe, start_date, end_date, limit, live) else False
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)
def index(self, symbol, timeframe, start_date, end_date='now', limit: int = 5_000, live=False):
return True if self._set(self._chart, 'indices', f'I:{symbol}', timeframe, start_date, end_date, limit, live) else False
"""
Requests and displays index data pulled from Polygon.io.\n
:param symbol: Ticker to request.
:param timeframe: Timeframe to request (1min, 5min, 2H, 1D, 1W, 2M, etc).
:param start_date: Start date of the data (YYYY-MM-DD).
:param end_date: End date of the data (YYYY-MM-DD). If left blank, this will be set to today.
:param limit: The limit of base aggregates queried to create the timeframe given (max 50_000).
:param live: If true, the data will be updated in real-time.
"""
return self._set(self._chart, '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):
return True if self._set(self._chart, 'forex', f'C:{fiat_pair}', timeframe, start_date, end_date, limit, live) else False
"""
Requests and displays forex data pulled from Polygon.io.\n
:param fiat_pair: The fiat pair to request. (USD-CAD, GBP-JPY etc.)
:param timeframe: Timeframe to request (1min, 5min, 2H, 1D, 1W, 2M, etc).
:param start_date: Start date of the data (YYYY-MM-DD).
:param end_date: End date of the data (YYYY-MM-DD). If left blank, this will be set to today.
:param limit: The limit of base aggregates queried to create the timeframe given (max 50_000).
:param live: If true, the data will be updated in real-time.
"""
return self._set(self._chart, '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):
return True if self._set(self._chart, 'crypto', f'X:{crypto_pair}', timeframe, start_date, end_date, limit, live) else False
"""
Requests and displays crypto data pulled from Polygon.io.\n
:param crypto_pair: The crypto pair to request. (BTC-USD, ETH-BTC etc.)
:param timeframe: Timeframe to request (1min, 5min, 2H, 1D, 1W, 2M, etc).
:param start_date: Start date of the data (YYYY-MM-DD).
:param end_date: End date of the data (YYYY-MM-DD). If left blank, this will be set to today.
:param limit: The limit of base aggregates queried to create the timeframe given (max 50_000).
:param live: If true, the data will be updated in real-time.
"""
return self._set(self._chart, '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.')
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)
query_url = f"https://api.polygon.io/v2/aggs/ticker/{ticker.replace('-', '')}/range/{mult}/{span}/{start_date}/{end_date}?limit={limit}&apiKey={self._key}"
query_url = f"https://api.polygon.io/v2/aggs/ticker/{ticker.replace('-', '')}/range/{mult}/{span}/{start_date}/{end_date}?limit={limit}&apiKey={self._key}"
response = requests.get(query_url, headers={'User-Agent': 'lightweight_charts/1.0'})
if response.status_code != 200:
error = response.json()
@ -93,11 +148,6 @@ class PolygonAPI:
self._log.error(f'No results for "{ticker}" ({sec_type})')
return
for child in self._lasts.values():
for subbed_chart in child['charts']:
if subbed_chart == chart:
self._send_q.put(('_unsubscribe', chart, ticker))
df = pd.DataFrame(data['results'])
columns = ['t', 'o', 'h', 'l', 'c']
rename = {'o': 'open', 'h': 'high', 'l': 'low', 'c': 'close', 't': 'time'}
@ -106,45 +156,40 @@ class PolygonAPI:
columns.append('v')
df = df[columns].rename(columns=rename)
df['time'] = pd.to_datetime(df['time'], unit='ms')
chart.set(df)
if not live:
return True
if not self._using_live_data:
threading.Thread(target=asyncio.run, args=[self._thread_loop()], daemon=True).start()
self._using_live_data = True
with self._lock:
if not self._ws[sec_type]:
self._send_q.put(('_websocket_connect', self._key, sec_type))
self._send_q.put(('_subscribe', chart, sec_type, ticker))
self._ws_q.put(('_websocket_connect', self._key, sec_type))
self._ws_q.put(('_subscribe', chart, ticker, sec_type))
return True
async def _thread_loop(self):
while 1:
while self._send_q.empty():
while self._ws_q.empty():
await asyncio.sleep(0.05)
value = self._send_q.get()
value = self._ws_q.get()
func, args = value[0], value[1:]
asyncio.create_task(getattr(self, func)(*args))
def unsubscribe(self, symbol):
self._send_q.put(('_unsubscribe', self._chart, symbol))
async def _subscribe(self, chart, sec_type, ticker):
key = ticker if '.' not in ticker else ticker.split('.')[1]
key = key if ':' not in key else key.split(':')[1]
async def _subscribe(self, chart, ticker, sec_type):
key = ticker if ':' not in ticker else ticker.split(':')[1]
if not self._lasts.get(key):
sub_type = {
'stocks': ('Q', 'A'),
'options': ('Q', 'A'),
'indices': ('V', None),
'forex': ('C', 'CA'),
'crypto': ('XQ', 'XA'),
}
self._lasts[key] = {
'ticker': ticker,
'sec_type': sec_type,
'sub_type': sub_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': [],
}
@ -158,18 +203,22 @@ class PolygonAPI:
return
self._lasts[key]['charts'].append(chart)
async def _unsubscribe(self, chart, ticker):
key = ticker if '.' not in ticker else ticker.split('.')[1]
key = key if ':' not in key else key.split(':')[1]
if chart in self._lasts[key]['charts']:
self._lasts[key]['charts'].remove(chart)
if self._lasts[key]['charts']:
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 = self._lasts[key]['sub_type']
await self._send(self._lasts[key]['sec_type'], 'unsubscribe', f'{quotes}.{ticker}')
await self._send(self._lasts[key]['sec_type'], 'unsubscribe', f'{aggs}.{ticker}')
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:
@ -213,7 +262,6 @@ class PolygonAPI:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
max_ticks = 20
async with websockets.connect(f'wss://socket.polygon.io/{sec_type}', ssl=ssl_context) as ws:
with self._lock:
self._ws[sec_type] = ws
@ -225,10 +273,13 @@ class PolygonAPI:
if data['ev'] == 'status':
self._log.info(f'{data["message"]}')
continue
elif data_list.index(data) < len(data_list)-max_ticks:
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):
@ -237,22 +288,33 @@ class PolygonAPISubChart(PolygonAPI):
class PolygonChart(Chart):
def __init__(self, api_key: str, live: bool = False, num_bars: int = 200, limit: int = 5_000,
"""
A prebuilt callback chart object allowing for a standalone and plug-and-play
experience of Polygon.io's API.
Tickers, security types and timeframes are to be defined within the chart window.
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'),
width: int = 800, height: int = 600, x: int = None, y: int = None, on_top: bool = False, debug=False):
super().__init__(volume_enabled=True, width=width, height=height, x=x, y=y, on_top=on_top, debug=debug,
width: int = 800, height: int = 600, x: int = None, y: int = None,
on_top: bool = False, maximize: bool = False, debug: bool = False):
super().__init__(volume_enabled=True, width=width, height=height, x=x, y=y, on_top=on_top, maximize=maximize, debug=debug,
api=self, topbar=True, searchbox=True)
self.chart = self
self.num_bars = num_bars
self.end_date = end_date
self.limit = limit
self.live = live
self.polygon.api_key(api_key)
self.topbar.active_background_color = 'rgb(91, 98, 246)'
self.topbar.textbox('symbol')
self.topbar.switcher('timeframe', self.on_timeframe_selection, *timeframe_options)
self.topbar.switcher('security', self.on_security_selection, *security_options)
self.topbar.switcher('timeframe', self._on_timeframe_selection, *timeframe_options)
self.topbar.switcher('security', self._on_security_selection, *security_options)
self.legend(True)
self.grid(False, False)
self.crosshair(vert_visible=False, horz_visible=False)
@ -262,25 +324,26 @@ class PolygonChart(Chart):
{self.id}.search.window.style.display = "block"
{self.id}.search.box.focus()
//let polyLogo = document.createElement('div')
//polyLogo.innerHTML = '<svg><g transform="scale(0.9)"><path d="M17.9821362,6 L24,12.1195009 L22.9236698,13.5060353 L17.9524621,27 L14.9907916,17.5798557 L12,12.0454987 L17.9821362,6 Z M21.437,15.304 L18.3670383,19.1065035 L18.367,23.637 L21.437,15.304 Z M18.203,7.335 L15.763,17.462 L17.595,23.287 L17.5955435,18.8249858 L22.963,12.176 L18.203,7.335 Z M17.297,7.799 L12.9564162,12.1857947 L15.228,16.389 L17.297,7.799 Z" fill="#FFFFFF"></path></g></svg>'
//polyLogo.style.position = 'absolute'
//polyLogo.style.width = '28px'
//polyLogo.style.zIndex = 10000
//polyLogo.style.right = '18px'
//polyLogo.style.top = '-1px'
//{self.id}.wrapper.appendChild(polyLogo)
''')
def show(self):
"""
Shows the PolygonChart window (this method will block).
"""
asyncio.run(self.show_async(block=True))
def _polygon(self, symbol):
self.spinner(True)
self.set(pd.DataFrame())
self.crosshair(vert_visible=False, horz_visible=False)
if self.topbar['symbol'].value and self.topbar['symbol'].value != symbol:
self.polygon.unsubscribe(self.topbar['symbol'].value)
mult, span = _convert_timeframe(self.topbar['timeframe'].value)
delta = dt.timedelta(**{span + 's': int(mult)})
short_delta = (delta < dt.timedelta(days=7))
start_date = dt.datetime.now()
start_date = dt.datetime.now() if self.end_date == 'now' else dt.datetime.strptime(self.end_date, '%Y-%m-%d')
remaining_bars = self.num_bars
while remaining_bars > 0:
start_date -= delta
@ -293,20 +356,20 @@ class PolygonChart(Chart):
symbol,
timeframe=self.topbar['timeframe'].value,
start_date=start_date.strftime('%Y-%m-%d'),
end_date=self.end_date,
limit=self.limit,
live=self.live
)
self.spinner(False)
self.crosshair(vert_visible=True, horz_visible=True) if success else None
return True if success else False
return success
async def on_search(self, searched_string):
self.topbar['symbol'].set(searched_string if self._polygon(searched_string) else '')
async def on_search(self, searched_string): self.topbar['symbol'].set(searched_string if self._polygon(searched_string) else '')
async def on_timeframe_selection(self):
self._polygon(self.topbar['symbol'].value)
async def _on_timeframe_selection(self):
self._polygon(self.topbar['symbol'].value) if self.topbar['symbol'].value else None
async def on_security_selection(self):
async def _on_security_selection(self):
sec_type = self.topbar['security'].value
self.volume_enabled = False if sec_type == 'Index' else True
@ -316,7 +379,3 @@ class PolygonChart(Chart):
{self.chart.id}.series.applyOptions({{
priceFormat: {{precision: {precision}, minMove: {min_move}}}
}})''')

View File

@ -90,4 +90,4 @@ def _convert_timeframe(timeframe):
except IndexError:
return 1, spans[timeframe]
timespan = spans[timeframe.replace(multiplier, '')]
return multiplier, timespan
return multiplier, timespan

View File

@ -4,7 +4,7 @@ from inspect import iscoroutinefunction
try:
import wx.html2
except ImportError:
pass
wx = None
try:
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWebChannel import QWebChannel
@ -19,17 +19,17 @@ try:
def callback(self, message):
_widget_message(self.chart, message)
except ImportError:
pass
QWebEngineView = None
try:
from streamlit.components.v1 import html
except ImportError:
pass
html = None
try:
from IPython.display import HTML, display
except ImportError:
pass
HTML = None
from lightweight_charts.js import LWC, TopBar, CALLBACK_SCRIPT
from lightweight_charts.abstract import LWC, TopBar, JS
def _widget_message(chart, string):
@ -47,13 +47,12 @@ def _widget_message(chart, string):
class WxChart(LWC):
def __init__(self, parent, volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0,
api: object = None, topbar: bool = False, searchbox: bool = False):
try:
self.webview: wx.html2.WebView = wx.html2.WebView.New(parent)
except NameError:
scale_candles_only: bool = False, api: object = None, topbar: bool = False, searchbox: 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__(volume_enabled, inner_width=inner_width, inner_height=inner_height)
super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, scale_candles_only=scale_candles_only)
self.api = api
self._script_func = self.webview.RunScript
self._js_api_code = 'window.wx_msg.postMessage.bind(window.wx_msg)'
@ -63,7 +62,7 @@ class WxChart(LWC):
self.webview.AddScriptMessageHandler('wx_msg')
self.webview.SetPage(self._html, '')
self.webview.AddUserScript(CALLBACK_SCRIPT)
self.webview.AddUserScript(JS['callback'])
self._create_chart()
self.topbar = TopBar(self) if topbar else None
self._make_search_box() if searchbox else None
@ -73,12 +72,12 @@ class WxChart(LWC):
class QtChart(LWC):
def __init__(self, widget=None, api: object = None, topbar: bool = False, searchbox: bool = False,
volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0):
try:
self.webview = QWebEngineView(widget)
except NameError:
volume_enabled: bool = True, inner_width: float = 1.0, inner_height: float = 1.0, scale_candles_only: bool = False):
if QWebEngineView is None:
raise ModuleNotFoundError('QWebEngineView was not found, and must be installed to use QtChart.')
super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height)
self.webview = QWebEngineView(widget)
super().__init__(volume_enabled, inner_width=inner_width, inner_height=inner_height, scale_candles_only=scale_candles_only)
self.api = api
self._script_func = self.webview.page().runJavaScript
self._js_api_code = 'window.pythonObject.callback'
@ -101,7 +100,7 @@ class QtChart(LWC):
'''
self.webview.page().setHtml(self._html)
self.run_script(CALLBACK_SCRIPT)
self.run_script(JS['callback'])
self._create_chart()
self.topbar = TopBar(self) if topbar else None
self._make_search_box() if searchbox else None
@ -110,8 +109,8 @@ class QtChart(LWC):
class StaticLWC(LWC):
def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1):
super().__init__(volume_enabled, inner_width, inner_height)
def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1, scale_candles_only: bool = False):
super().__init__(volume_enabled, inner_width, inner_height, scale_candles_only=scale_candles_only)
self.width = width
self.height = height
self._html = self._html.replace('</script>\n</body>\n</html>', '')
@ -134,20 +133,19 @@ class StaticLWC(LWC):
class StreamlitChart(StaticLWC):
def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1):
super().__init__(volume_enabled, width, height, inner_width, inner_height)
def __init__(self, volume_enabled=True, width=None, height=None, inner_width=1, inner_height=1, scale_candles_only: bool = False):
super().__init__(volume_enabled, width, height, inner_width, inner_height, scale_candles_only)
self._create_chart()
def _load(self):
try:
html(f'{self._html}</script></body></html>', width=self.width, height=self.height)
except NameError:
if html is None:
raise ModuleNotFoundError('streamlit.components.v1.html was not found, and must be installed to use StreamlitChart.')
html(f'{self._html}</script></body></html>', width=self.width, height=self.height)
class JupyterChart(StaticLWC):
def __init__(self, volume_enabled=True, width=800, height=350, inner_width=1, inner_height=1):
super().__init__(volume_enabled, width, height, inner_width, inner_height)
def __init__(self, volume_enabled=True, width=800, height=350, inner_width=1, inner_height=1, scale_candles_only: bool = False):
super().__init__(volume_enabled, width, height, inner_width, inner_height, scale_candles_only)
self._position = ""
self._create_chart(autosize=False)
@ -164,8 +162,6 @@ class JupyterChart(StaticLWC):
self.run_script(f'{self.id}.chart.resize({width}, {height})')
def _load(self):
try:
display(HTML(f'{self._html}</script></body></html>'))
except NameError:
if HTML is None:
raise ModuleNotFoundError('IPython.display.HTML was not found, and must be installed to use JupyterChart.')
display(HTML(f'{self._html}</script></body></html>'))