Enhancements:

- Added the `screen` parameter to `Chart`, allowing for monitor selection. This should be an index from 0 (0 = primary monitor, 1=  second monitor, etc.)
- `vertical_span` method, allowing for vertical lines/spans to be drawn across the chart.
- `set_visible_range` method, which will set the visible range of the chart based on two given dates.
- `resize` method, which resizes the chart to the given size.
- `sync` will now sync both charts, regardless of which one is scrolled/zoomed.
This commit is contained in:
louisnw
2023-08-31 21:25:00 +01:00
parent 769fd8ac0a
commit a7c1dc8a30
11 changed files with 191 additions and 69 deletions

View File

@ -91,17 +91,13 @@ class Window:
return Table(self, width, height, headings, widths, alignments, position, draggable, func)
def create_subchart(self, position: FLOAT = 'left', width: float = 0.5, height: float = 0.5,
sync: str = None, scale_candles_only: bool = False, toolbox: bool = False
sync_id: str = None, scale_candles_only: bool = False, toolbox: bool = False
) -> 'AbstractChart':
subchart = AbstractChart(self, width, height, scale_candles_only, toolbox, position=position)
if not sync:
if not sync_id:
return subchart
sync_id = sync
self.run_script(f'''
syncCrosshairs({subchart.id}.chart, {sync_id}.chart)
{sync_id}.chart.timeScale().subscribeVisibleLogicalRangeChange((timeRange) => {{
{subchart.id}.chart.timeScale().setVisibleLogicalRange(timeRange)
}})
syncCharts({subchart.id}, {sync_id})
{subchart.id}.chart.timeScale().setVisibleLogicalRange(
{sync_id}.chart.timeScale().getVisibleLogicalRange()
)
@ -313,6 +309,40 @@ class HorizontalLine(Pane):
del self
class VerticalSpan(Pane):
def __init__(self, chart: 'AbstractChart', start_time: TIME, end_time: TIME = None,
color: str = 'rgba(252, 219, 3, 0.2)'):
super().__init__(chart.win)
self._chart = chart
start_date, end_date = pd.to_datetime(start_time), pd.to_datetime(end_time)
self.run_script(f'''
{self.id} = {chart.id}.chart.addHistogramSeries({{
color: '{color}',
priceFormat: {{type: 'volume'}},
priceScaleId: 'vertical_line',
lastValueVisible: false,
priceLineVisible: false,
}})
{self.id}.priceScale('').applyOptions({{
scaleMargins: {{top: 0, bottom: 0}}
}})
''')
if end_date is None:
self.run_script(f'{self.id}.setData([{{time: {start_date.timestamp()}, value: 1}}])')
else:
self.run_script(f'''
{self.id}.setData(calculateTrendLine(
{start_date.timestamp()}, 1, {end_date.timestamp()}, 1,
{chart._interval.total_seconds() * 1000}, {chart.id}))
''')
def delete(self):
"""
Irreversibly deletes the vertical span.
"""
self.run_script(f'{self._chart.id}.chart.removeSeries({self.id}.series); delete {self.id}')
class Line(SeriesCommon):
def __init__(self, chart, name, color, style, width, price_line, price_label, crosshair_marker=True):
super().__init__(chart)
@ -345,7 +375,7 @@ class Line(SeriesCommon):
{self._chart.id}.legend.lines.push({self._chart.id}.legend.makeLineRow({self.id}))
}}''')
def set(self, df: pd.DataFrame):
def set(self, df: pd.DataFrame = None):
"""
Sets the line data.\n
:param df: If the name parameter is not used, the columns should be named: date/time, value.
@ -373,15 +403,13 @@ class Line(SeriesCommon):
self.run_script(f'{self.id}.series.update({series.to_dict()})')
def _set_trend(self, start_time, start_value, end_time, end_value, ray=False):
def time_format(time_val):
time_val = pd.to_datetime(time_val).timestamp()
return f"'{time_val}'" if isinstance(time_val, str) else time_val
self.run_script(f'''
{self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: false}})
{self.id}.series.setData(
calculateTrendLine({time_format(start_time)}, {start_value}, {time_format(end_time)}, {end_value},
{self._chart._interval.total_seconds() * 1000}, {self._chart.id}, {jbool(ray)}))
calculateTrendLine({pd.to_datetime(start_time).timestamp()}, {start_value},
{pd.to_datetime(end_time).timestamp()}, {end_value},
{self._chart._interval.total_seconds() * 1000},
{self._chart.id}, {jbool(ray)}))
{self._chart.id}.chart.timeScale().applyOptions({{shiftVisibleRangeOnNewBar: true}})
''')
@ -399,7 +427,6 @@ class Line(SeriesCommon):
}}
delete {self.id}
''')
del self
class Candlestick(SeriesCommon):
@ -556,20 +583,22 @@ class Candlestick(SeriesCommon):
class AbstractChart(Candlestick, Pane):
def __init__(self, window: Window, inner_width: float = 1.0, inner_height: float = 1.0,
def __init__(self, window: Window, width: float = 1.0, height: float = 1.0,
scale_candles_only: bool = False, toolbox: bool = False,
autosize: bool = True, position: FLOAT = 'left'):
Pane.__init__(self, window)
self._lines = []
self._scale_candles_only = scale_candles_only
self._width = width
self._height = height
self.events: Events = Events(self)
from lightweight_charts.polygon import PolygonAPI
self.polygon: PolygonAPI = PolygonAPI(self)
self.run_script(
f'{self.id} = new Chart("{self.id}", {inner_width}, {inner_height}, "{position}", {jbool(autosize)})')
f'{self.id} = new Chart("{self.id}", {width}, {height}, "{position}", {jbool(autosize)})')
Candlestick.__init__(self, self)
@ -614,6 +643,37 @@ class AbstractChart(Candlestick, Pane):
line._set_trend(start_time, value, start_time, value, ray=True)
return line
def vertical_span(self, start_time: TIME, end_time: TIME = None, color: str = 'rgba(252, 219, 3, 0.2)'):
"""
Creates a vertical line or span across the chart.
:param start_time: Start time of the span.
:param end_time: End time of the span (can be omitted for a single vertical line).
:param color: CSS color.
:return:
"""
return VerticalSpan(self, start_time, end_time, color)
def set_visible_range(self, start_time: TIME, end_time: TIME):
self.run_script(f'''
{self.id}.chart.timeScale().setVisibleRange({{
from: {pd.to_datetime(start_time).timestamp()},
to: {pd.to_datetime(end_time).timestamp()}
}})
''')
def resize(self, width: float = None, height: float = None):
"""
Resizes the chart within the window.
Dimensions should be given as a float between 0 and 1.
"""
self._width = width if width is not None else self._width
self._height = height if height is not None else self._height
self.run_script(f'''
{self.id}.scale.width = {self._width}
{self.id}.scale.height = {self._height}
{self.id}.reSize()
''')
def time_scale(self, right_offset: int = 0, min_bar_spacing: float = 0.5,
visible: bool = True, time_visible: bool = True, seconds_visible: bool = False,
border_visible: bool = True, border_color: str = None):

View File

@ -17,7 +17,7 @@ class CallbackAPI:
class PyWV:
def __init__(self, q, start_ev, exit_ev, loaded, emit_queue, return_queue, html, debug,
width, height, x, y, on_top, maximize):
width, height, x, y, screen, on_top, maximize):
self.queue = q
self.return_queue = return_queue
self.exit = exit_ev
@ -26,18 +26,20 @@ class PyWV:
self.html = html
self.windows = []
self.create_window(width, height, x, y, on_top, maximize)
self.create_window(width, height, x, y, screen, on_top, maximize)
start_ev.wait()
webview.start(debug=debug)
self.exit.set()
def create_window(self, width, height, x, y, on_top, maximize):
def create_window(self, width, height, x, y, screen=0, on_top=False, maximize=False):
screen = webview.screens[screen]
if maximize:
width, height = webview.screens[0].width, webview.screens[0].height
width, height = screen.width, screen.height
self.windows.append(webview.create_window(
'', html=self.html, on_top=on_top, js_api=self.callback_api,
width=width, height=height, x=x, y=y, background_color='#000000'))
'', html=self.html, js_api=self.callback_api,
width=width, height=height, x=x, y=y, screen=screen,
on_top=on_top, background_color='#000000'))
self.windows[-1].events.loaded += lambda: self.loop(self.loaded[len(self.windows)-1])
def loop(self, loaded):
@ -68,29 +70,29 @@ class Chart(abstract.AbstractChart):
_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,
def __init__(self, width: int = 800, height: int = 600, x: int = None, y: int = None, screen: int = 0,
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):
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
window = abstract.Window(lambda s: self._q.put((self._i, s)), 'pywebview.api.callback')
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,
width, height, x, y, screen, on_top, maximize,
), daemon=True)
self._process.start()
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)))
self._q.put(('create_window', (width, height, x, y, screen, on_top, maximize)))
def show(self, block: bool = False):
"""

View File

@ -63,7 +63,7 @@ if (!window.Chart) {
window.addEventListener('resize', () => this.reSize())
}
reSize() {
let topBarOffset = 'topBar' in this ? this.topBar.offsetHeight : 0
let topBarOffset = 'topBar' in this && this.scale.height !== 0 ? this.topBar.offsetHeight : 0
this.chart.resize(window.innerWidth * this.scale.width, (window.innerHeight * this.scale.height) - topBarOffset)
}
makeCandlestickSeries() {
@ -80,7 +80,7 @@ if (!window.Chart) {
this.volumeSeries = this.chart.addHistogramSeries({
color: '#26a69a',
priceFormat: {type: 'volume'},
priceScaleId: '',
priceScaleId: 'volume_scale',
})
this.series.priceScale().applyOptions({
scaleMargins: {top: 0.2, bottom: 0.2},
@ -219,6 +219,12 @@ if (!window.Chart) {
});
}
toJSON() {
// Exclude the chart attribute from serialization
const {lines, ...serialized} = this;
return serialized;
}
makeLines(chart) {
this.lines = []
if (this.linesEnabled) chart.lines.forEach(line => this.lines.push(this.makeLineRow(line)))
@ -291,6 +297,10 @@ if (!window.Chart) {
window.Legend = Legend
}
function syncCharts(childChart, parentChart) {
syncCrosshairs(childChart.chart, parentChart.chart)
syncRanges(childChart, parentChart)
}
function syncCrosshairs(childChart, parentChart) {
function crosshairHandler (e, thisChart, otherChart, otherHandler) {
thisChart.applyOptions({crosshair: { horzLine: {
@ -328,6 +338,20 @@ function syncCrosshairs(childChart, parentChart) {
parentChart.subscribeCrosshairMove(parentCrosshairHandler)
childChart.subscribeCrosshairMove(childCrosshairHandler)
}
function syncRanges(childChart, parentChart) {
let setChildRange = (timeRange) => childChart.chart.timeScale().setVisibleLogicalRange(timeRange)
let setParentRange = (timeRange) => parentChart.chart.timeScale().setVisibleLogicalRange(timeRange)
parentChart.wrapper.addEventListener('mouseover', (event) => {
childChart.chart.timeScale().unsubscribeVisibleLogicalRangeChange(setParentRange)
parentChart.chart.timeScale().subscribeVisibleLogicalRangeChange(setChildRange)
})
childChart.wrapper.addEventListener('mouseover', (event) => {
parentChart.chart.timeScale().unsubscribeVisibleLogicalRangeChange(setChildRange)
childChart.chart.timeScale().subscribeVisibleLogicalRangeChange(setParentRange)
})
parentChart.chart.timeScale().subscribeVisibleLogicalRangeChange(setChildRange)
}
function stampToDate(stampOrBusiness) {
return new Date(stampOrBusiness*1000)

View File

@ -67,7 +67,7 @@ def price_scale_mode(mode: PRICE_SCALE_MODE):
def marker_shape(shape: MARKER_SHAPE):
return shape[:shape.index('_')]+shape[shape.index('_')+1:].title() if '_' in shape else shape.title()
return shape[:shape.index('_')]+shape[shape.index('_')+1:].title() if '_' in shape else shape
def marker_position(p: MARKER_POSITION):