commit
242d02b1cd
|
@ -25,6 +25,9 @@ from PyQt5.QtCore import Qt
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QWidget,
|
QWidget,
|
||||||
|
QHBoxLayout,
|
||||||
|
QVBoxLayout,
|
||||||
|
QSplitter,
|
||||||
# QSizePolicy,
|
# QSizePolicy,
|
||||||
)
|
)
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
@ -53,6 +56,7 @@ from ._style import (
|
||||||
)
|
)
|
||||||
from ..data.feed import Feed
|
from ..data.feed import Feed
|
||||||
from ..data._source import Symbol
|
from ..data._source import Symbol
|
||||||
|
from ..data._sharedmem import ShmArray
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._interaction import ChartView
|
from ._interaction import ChartView
|
||||||
from ._forms import FieldsForm
|
from ._forms import FieldsForm
|
||||||
|
@ -64,11 +68,11 @@ log = get_logger(__name__)
|
||||||
class GodWidget(QWidget):
|
class GodWidget(QWidget):
|
||||||
'''
|
'''
|
||||||
"Our lord and savior, the holy child of window-shua, there is no
|
"Our lord and savior, the holy child of window-shua, there is no
|
||||||
widget above thee." - 6|6
|
widget above thee." - 6||6
|
||||||
|
|
||||||
The highest level composed widget which contains layouts for
|
The highest level composed widget which contains layouts for
|
||||||
organizing lower level charts as well as other widgets used to
|
organizing charts as well as other sub-widgets used to control or
|
||||||
control or modify them.
|
modify them.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -80,19 +84,19 @@ class GodWidget(QWidget):
|
||||||
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
self.hbox = QtWidgets.QHBoxLayout(self)
|
self.hbox = QHBoxLayout(self)
|
||||||
self.hbox.setContentsMargins(0, 0, 0, 0)
|
self.hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
self.hbox.setSpacing(6)
|
self.hbox.setSpacing(6)
|
||||||
self.hbox.setAlignment(Qt.AlignTop)
|
self.hbox.setAlignment(Qt.AlignTop)
|
||||||
|
|
||||||
self.vbox = QtWidgets.QVBoxLayout()
|
self.vbox = QVBoxLayout()
|
||||||
self.vbox.setContentsMargins(0, 0, 0, 0)
|
self.vbox.setContentsMargins(0, 0, 0, 0)
|
||||||
self.vbox.setSpacing(2)
|
self.vbox.setSpacing(2)
|
||||||
self.vbox.setAlignment(Qt.AlignTop)
|
self.vbox.setAlignment(Qt.AlignTop)
|
||||||
|
|
||||||
self.hbox.addLayout(self.vbox)
|
self.hbox.addLayout(self.vbox)
|
||||||
|
|
||||||
# self.toolbar_layout = QtWidgets.QHBoxLayout()
|
# self.toolbar_layout = QHBoxLayout()
|
||||||
# self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
|
# self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
# self.vbox.addLayout(self.toolbar_layout)
|
# self.vbox.addLayout(self.toolbar_layout)
|
||||||
|
|
||||||
|
@ -106,25 +110,8 @@ class GodWidget(QWidget):
|
||||||
# assigned in the startup func `_async_main()`
|
# assigned in the startup func `_async_main()`
|
||||||
self._root_n: trio.Nursery = None
|
self._root_n: trio.Nursery = None
|
||||||
|
|
||||||
def set_chart_symbol(
|
|
||||||
self,
|
|
||||||
symbol_key: str, # of form <fqsn>.<providername>
|
|
||||||
linkedsplits: 'LinkedSplits', # type: ignore
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
# re-sort org cache symbol list in LIFO order
|
|
||||||
cache = self._chart_cache
|
|
||||||
cache.pop(symbol_key, None)
|
|
||||||
cache[symbol_key] = linkedsplits
|
|
||||||
|
|
||||||
def get_chart_symbol(
|
|
||||||
self,
|
|
||||||
symbol_key: str,
|
|
||||||
) -> 'LinkedSplits': # type: ignore
|
|
||||||
return self._chart_cache.get(symbol_key)
|
|
||||||
|
|
||||||
# def init_timeframes_ui(self):
|
# def init_timeframes_ui(self):
|
||||||
# self.tf_layout = QtWidgets.QHBoxLayout()
|
# self.tf_layout = QHBoxLayout()
|
||||||
# self.tf_layout.setSpacing(0)
|
# self.tf_layout.setSpacing(0)
|
||||||
# self.tf_layout.setContentsMargins(0, 12, 0, 0)
|
# self.tf_layout.setContentsMargins(0, 12, 0, 0)
|
||||||
# time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN')
|
# time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN')
|
||||||
|
@ -145,6 +132,23 @@ class GodWidget(QWidget):
|
||||||
# self.strategy_box = StrategyBoxWidget(self)
|
# self.strategy_box = StrategyBoxWidget(self)
|
||||||
# self.toolbar_layout.addWidget(self.strategy_box)
|
# self.toolbar_layout.addWidget(self.strategy_box)
|
||||||
|
|
||||||
|
def set_chart_symbol(
|
||||||
|
self,
|
||||||
|
symbol_key: str, # of form <fqsn>.<providername>
|
||||||
|
linkedsplits: 'LinkedSplits', # type: ignore
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
# re-sort org cache symbol list in LIFO order
|
||||||
|
cache = self._chart_cache
|
||||||
|
cache.pop(symbol_key, None)
|
||||||
|
cache[symbol_key] = linkedsplits
|
||||||
|
|
||||||
|
def get_chart_symbol(
|
||||||
|
self,
|
||||||
|
symbol_key: str,
|
||||||
|
) -> 'LinkedSplits': # type: ignore
|
||||||
|
return self._chart_cache.get(symbol_key)
|
||||||
|
|
||||||
async def load_symbol(
|
async def load_symbol(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
|
@ -255,7 +259,7 @@ class ChartnPane(QFrame):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
sidepane: FieldsForm
|
sidepane: FieldsForm
|
||||||
hbox: QtWidgets.QHBoxLayout
|
hbox: QHBoxLayout
|
||||||
chart: Optional['ChartPlotWidget'] = None
|
chart: Optional['ChartPlotWidget'] = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -271,7 +275,7 @@ class ChartnPane(QFrame):
|
||||||
self.sidepane = sidepane
|
self.sidepane = sidepane
|
||||||
self.chart = None
|
self.chart = None
|
||||||
|
|
||||||
hbox = self.hbox = QtWidgets.QHBoxLayout(self)
|
hbox = self.hbox = QHBoxLayout(self)
|
||||||
hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||||
hbox.setContentsMargins(0, 0, 0, 0)
|
hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
hbox.setSpacing(3)
|
hbox.setSpacing(3)
|
||||||
|
@ -281,21 +285,14 @@ class ChartnPane(QFrame):
|
||||||
|
|
||||||
class LinkedSplits(QWidget):
|
class LinkedSplits(QWidget):
|
||||||
'''
|
'''
|
||||||
Widget that holds a central chart plus derived
|
Composite that holds a central chart plus a set of (derived)
|
||||||
subcharts computed from the original data set apart
|
subcharts (usually computed from the original data) arranged in
|
||||||
by splitters for resizing.
|
a splitter for resizing.
|
||||||
|
|
||||||
A single internal references to the data is maintained
|
A single internal references to the data is maintained
|
||||||
for each chart and can be updated externally.
|
for each chart and can be updated externally.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
long_pen = pg.mkPen('#006000')
|
|
||||||
long_brush = pg.mkBrush('#00ff00')
|
|
||||||
short_pen = pg.mkPen('#600000')
|
|
||||||
short_brush = pg.mkBrush('#ff0000')
|
|
||||||
|
|
||||||
zoomIsDisabled = QtCore.pyqtSignal(bool)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
||||||
self,
|
self,
|
||||||
|
@ -325,11 +322,11 @@ class LinkedSplits(QWidget):
|
||||||
# self.xaxis_ind.setStyle(showValues=False)
|
# self.xaxis_ind.setStyle(showValues=False)
|
||||||
# self.xaxis.hide()
|
# self.xaxis.hide()
|
||||||
|
|
||||||
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical)
|
self.splitter = QSplitter(QtCore.Qt.Vertical)
|
||||||
self.splitter.setMidLineWidth(1)
|
self.splitter.setMidLineWidth(0)
|
||||||
self.splitter.setHandleWidth(0)
|
self.splitter.setHandleWidth(2)
|
||||||
|
|
||||||
self.layout = QtWidgets.QVBoxLayout(self)
|
self.layout = QVBoxLayout(self)
|
||||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.layout.addWidget(self.splitter)
|
self.layout.addWidget(self.splitter)
|
||||||
|
|
||||||
|
@ -341,20 +338,28 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
def set_split_sizes(
|
def set_split_sizes(
|
||||||
self,
|
self,
|
||||||
# prop: float = 0.375, # proportion allocated to consumer subcharts
|
prop: Optional[float] = None,
|
||||||
prop: float = 5/8,
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''Set the proportion of space allocated for linked subcharts.
|
'''Set the proportion of space allocated for linked subcharts.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
ln = len(self.subplots)
|
||||||
|
|
||||||
|
if not prop:
|
||||||
|
# proportion allocated to consumer subcharts
|
||||||
|
if ln < 2:
|
||||||
|
prop = 1/(.666 * 6)
|
||||||
|
elif ln >= 2:
|
||||||
|
prop = 3/8
|
||||||
|
|
||||||
major = 1 - prop
|
major = 1 - prop
|
||||||
min_h_ind = int((self.height() * prop) / len(self.subplots))
|
min_h_ind = int((self.height() * prop) / ln)
|
||||||
|
|
||||||
sizes = [int(self.height() * major)]
|
sizes = [int(self.height() * major)]
|
||||||
sizes.extend([min_h_ind] * len(self.subplots))
|
sizes.extend([min_h_ind] * ln)
|
||||||
|
|
||||||
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
|
self.splitter.setSizes(sizes)
|
||||||
|
|
||||||
def focus(self) -> None:
|
def focus(self) -> None:
|
||||||
if self.chart is not None:
|
if self.chart is not None:
|
||||||
|
@ -495,8 +500,9 @@ class LinkedSplits(QWidget):
|
||||||
cpw.plotItem.vb.linkedsplits = self
|
cpw.plotItem.vb.linkedsplits = self
|
||||||
cpw.setFrameStyle(
|
cpw.setFrameStyle(
|
||||||
QtWidgets.QFrame.StyledPanel
|
QtWidgets.QFrame.StyledPanel
|
||||||
# | QtWidgets.QFrame.Plain)
|
# | QtWidgets.QFrame.Plain
|
||||||
)
|
)
|
||||||
|
|
||||||
cpw.hideButtons()
|
cpw.hideButtons()
|
||||||
|
|
||||||
# XXX: gives us outline on backside of y-axis
|
# XXX: gives us outline on backside of y-axis
|
||||||
|
@ -515,7 +521,22 @@ class LinkedSplits(QWidget):
|
||||||
cpw.draw_ohlc(name, array, array_key=array_key)
|
cpw.draw_ohlc(name, array, array_key=array_key)
|
||||||
|
|
||||||
elif style == 'line':
|
elif style == 'line':
|
||||||
cpw.draw_curve(name, array, array_key=array_key)
|
cpw.draw_curve(
|
||||||
|
name,
|
||||||
|
array,
|
||||||
|
array_key=array_key,
|
||||||
|
color='default_light',
|
||||||
|
)
|
||||||
|
|
||||||
|
elif style == 'step':
|
||||||
|
cpw.draw_curve(
|
||||||
|
name,
|
||||||
|
array,
|
||||||
|
array_key=array_key,
|
||||||
|
step_mode=True,
|
||||||
|
color='davies',
|
||||||
|
fill_color='davies',
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Chart style {style} is currently unsupported")
|
raise ValueError(f"Chart style {style} is currently unsupported")
|
||||||
|
@ -523,14 +544,7 @@ class LinkedSplits(QWidget):
|
||||||
if not _is_main:
|
if not _is_main:
|
||||||
# track by name
|
# track by name
|
||||||
self.subplots[name] = cpw
|
self.subplots[name] = cpw
|
||||||
|
|
||||||
# if sidepane:
|
|
||||||
# # TODO: use a "panes" collection to manage this?
|
|
||||||
# qframe.setMaximumWidth(self.chart.sidepane.width())
|
|
||||||
# qframe.setMinimumWidth(self.chart.sidepane.width())
|
|
||||||
|
|
||||||
self.splitter.addWidget(qframe)
|
self.splitter.addWidget(qframe)
|
||||||
|
|
||||||
# scale split regions
|
# scale split regions
|
||||||
self.set_split_sizes()
|
self.set_split_sizes()
|
||||||
|
|
||||||
|
@ -600,7 +614,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# parent=None,
|
# parent=None,
|
||||||
# plotItem=None,
|
# plotItem=None,
|
||||||
# antialias=True,
|
# antialias=True,
|
||||||
useOpenGL=True,
|
# useOpenGL=True,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
self.name = name
|
self.name = name
|
||||||
|
@ -619,7 +633,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
'ohlc': array,
|
'ohlc': array,
|
||||||
}
|
}
|
||||||
self._graphics = {} # registry of underlying graphics
|
self._graphics = {} # registry of underlying graphics
|
||||||
self._overlays = set() # registry of overlay curve names
|
# registry of overlay curve names
|
||||||
|
self._overlays: dict[str, ShmArray] = {}
|
||||||
|
|
||||||
self._feeds: dict[Symbol, Feed] = {}
|
self._feeds: dict[Symbol, Feed] = {}
|
||||||
|
|
||||||
|
@ -732,6 +747,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self._vb.setXRange(
|
self._vb.setXRange(
|
||||||
min=l + 1,
|
min=l + 1,
|
||||||
max=r + 1,
|
max=r + 1,
|
||||||
|
|
||||||
# TODO: holy shit, wtf dude... why tf would this not be 0 by
|
# TODO: holy shit, wtf dude... why tf would this not be 0 by
|
||||||
# default... speechless.
|
# default... speechless.
|
||||||
padding=0,
|
padding=0,
|
||||||
|
@ -772,7 +788,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
update_func=ContentsLabel.update_from_ohlc,
|
update_func=ContentsLabel.update_from_ohlc,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._add_sticky(name)
|
self._add_sticky(name, bg_color='davies')
|
||||||
|
|
||||||
return graphics
|
return graphics
|
||||||
|
|
||||||
|
@ -784,7 +800,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
array_key: Optional[str] = None,
|
array_key: Optional[str] = None,
|
||||||
overlay: bool = False,
|
overlay: bool = False,
|
||||||
color: str = 'default_light',
|
color: Optional[str] = None,
|
||||||
add_label: bool = True,
|
add_label: bool = True,
|
||||||
|
|
||||||
**pdi_kwargs,
|
**pdi_kwargs,
|
||||||
|
@ -794,15 +810,18 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
the input array ``data``.
|
the input array ``data``.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
_pdi_defaults = {
|
color = color or self.pen_color or 'default_light'
|
||||||
'pen': pg.mkPen(hcolor(color)),
|
pdi_kwargs.update({
|
||||||
}
|
'color': color
|
||||||
pdi_kwargs.update(_pdi_defaults)
|
})
|
||||||
|
|
||||||
data_key = array_key or name
|
data_key = array_key or name
|
||||||
|
|
||||||
|
# pg internals for reference.
|
||||||
# curve = pg.PlotDataItem(
|
# curve = pg.PlotDataItem(
|
||||||
# curve = pg.PlotCurveItem(
|
# curve = pg.PlotCurveItem(
|
||||||
|
|
||||||
|
# yah, we wrote our own B)
|
||||||
curve = FastAppendCurve(
|
curve = FastAppendCurve(
|
||||||
y=data[data_key],
|
y=data[data_key],
|
||||||
x=data['index'],
|
x=data['index'],
|
||||||
|
@ -840,14 +859,14 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
if overlay:
|
if overlay:
|
||||||
anchor_at = ('bottom', 'left')
|
anchor_at = ('bottom', 'left')
|
||||||
self._overlays.add(name)
|
self._overlays[name] = None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
anchor_at = ('top', 'left')
|
anchor_at = ('top', 'left')
|
||||||
|
|
||||||
# TODO: something instead of stickies for overlays
|
# TODO: something instead of stickies for overlays
|
||||||
# (we need something that avoids clutter on x-axis).
|
# (we need something that avoids clutter on x-axis).
|
||||||
self._add_sticky(name, bg_color='default_light')
|
self._add_sticky(name, bg_color=color)
|
||||||
|
|
||||||
if self.linked.cursor:
|
if self.linked.cursor:
|
||||||
self.linked.cursor.add_curve_cursor(self, curve)
|
self.linked.cursor.add_curve_cursor(self, curve)
|
||||||
|
@ -861,6 +880,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
return curve
|
return curve
|
||||||
|
|
||||||
|
# TODO: make this a ctx mngr
|
||||||
def _add_sticky(
|
def _add_sticky(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
|
@ -941,16 +961,19 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
def _set_yrange(
|
def _set_yrange(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
||||||
yrange: Optional[tuple[float, float]] = None,
|
yrange: Optional[tuple[float, float]] = None,
|
||||||
range_margin: float = 0.06,
|
range_margin: float = 0.06,
|
||||||
|
bars_range: Optional[tuple[int, int, int, int]] = None
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the viewable y-range based on embedded data.
|
'''Set the viewable y-range based on embedded data.
|
||||||
|
|
||||||
This adds auto-scaling like zoom on the scroll wheel such
|
This adds auto-scaling like zoom on the scroll wheel such
|
||||||
that data always fits nicely inside the current view of the
|
that data always fits nicely inside the current view of the
|
||||||
data set.
|
data set.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
set_range = True
|
set_range = True
|
||||||
|
|
||||||
if self._static_yrange == 'axis':
|
if self._static_yrange == 'axis':
|
||||||
|
@ -966,7 +989,20 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# Determine max, min y values in viewable x-range from data.
|
# Determine max, min y values in viewable x-range from data.
|
||||||
# Make sure min bars/datums on screen is adhered.
|
# Make sure min bars/datums on screen is adhered.
|
||||||
|
|
||||||
l, lbar, rbar, r = self.bars_range()
|
l, lbar, rbar, r = bars_range or self.bars_range()
|
||||||
|
|
||||||
|
# TODO: we need a loop for auto-scaled subplots to all
|
||||||
|
# be triggered by one another
|
||||||
|
if self.name != 'volume':
|
||||||
|
vlm_chart = self.linked.subplots.get('volume')
|
||||||
|
if vlm_chart:
|
||||||
|
vlm_chart._set_yrange(bars_range=(l, lbar, rbar, r))
|
||||||
|
# curve = vlm_chart._graphics['volume']
|
||||||
|
# if rbar - lbar < 1500:
|
||||||
|
# # print('small range')
|
||||||
|
# curve._fill = True
|
||||||
|
# else:
|
||||||
|
# curve._fill = False
|
||||||
|
|
||||||
# figure out x-range in view such that user can scroll "off"
|
# figure out x-range in view such that user can scroll "off"
|
||||||
# the data set up to the point where ``_min_points_to_show``
|
# the data set up to the point where ``_min_points_to_show``
|
||||||
|
@ -1003,15 +1039,17 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
a = self._arrays['ohlc']
|
a = self._arrays['ohlc']
|
||||||
ifirst = a[0]['index']
|
ifirst = a[0]['index']
|
||||||
bars = a[lbar - ifirst:rbar - ifirst + 1]
|
bars = a[lbar - ifirst:rbar - ifirst + 1]
|
||||||
|
|
||||||
if not len(bars):
|
if not len(bars):
|
||||||
# likely no data loaded yet or extreme scrolling?
|
# likely no data loaded yet or extreme scrolling?
|
||||||
log.error(f"WTF bars_range = {lbar}:{rbar}")
|
log.error(f"WTF bars_range = {lbar}:{rbar}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.data_key != self.linked.symbol.key:
|
if self.data_key != self.linked.symbol.key:
|
||||||
bars = a[self.data_key]
|
bars = bars[self.data_key]
|
||||||
ylow = np.nanmin(bars)
|
ylow = np.nanmin(bars)
|
||||||
yhigh = np.nanmax((bars))
|
yhigh = np.nanmax(bars)
|
||||||
|
# print(f'{(ylow, yhigh)}')
|
||||||
else:
|
else:
|
||||||
# just the std ohlc bars
|
# just the std ohlc bars
|
||||||
ylow = np.nanmin(bars['low'])
|
ylow = np.nanmin(bars['low'])
|
||||||
|
@ -1072,7 +1110,6 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# TODO: this should go onto some sort of
|
# TODO: this should go onto some sort of
|
||||||
# data-view strimg thinger..right?
|
# data-view strimg thinger..right?
|
||||||
ohlc = self._shm.array
|
ohlc = self._shm.array
|
||||||
# ohlc = chart._shm.array
|
|
||||||
|
|
||||||
# XXX: not sure why the time is so off here
|
# XXX: not sure why the time is so off here
|
||||||
# looks like we're gonna have to do some fixing..
|
# looks like we're gonna have to do some fixing..
|
||||||
|
|
|
@ -19,12 +19,13 @@ Real-time display tasks for charting / graphics.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
# from pprint import pformat
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pydantic import BaseModel
|
from pydantic import create_model
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
|
@ -56,12 +57,14 @@ _clear_throttle_rate: int = 58 # Hz
|
||||||
_book_throttle_rate: int = 16 # Hz
|
_book_throttle_rate: int = 16 # Hz
|
||||||
|
|
||||||
|
|
||||||
async def chart_from_quotes(
|
async def update_chart_from_quotes(
|
||||||
|
|
||||||
chart: ChartPlotWidget,
|
chart: ChartPlotWidget,
|
||||||
stream: tractor.MsgStream,
|
stream: tractor.MsgStream,
|
||||||
ohlcv: np.ndarray,
|
ohlcv: np.ndarray,
|
||||||
|
|
||||||
wap_in_history: bool = False,
|
wap_in_history: bool = False,
|
||||||
|
vlm_chart: Optional[ChartPlotWidget] = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''The 'main' (price) chart real-time update loop.
|
'''The 'main' (price) chart real-time update loop.
|
||||||
|
@ -76,7 +79,8 @@ async def chart_from_quotes(
|
||||||
# - handle odd lot orders
|
# - handle odd lot orders
|
||||||
# - update last open price correctly instead
|
# - update last open price correctly instead
|
||||||
# of copying it from last bar's close
|
# of copying it from last bar's close
|
||||||
# - 5 sec bar lookback-autocorrection like tws does?
|
# - 1-5 sec bar lookback-autocorrection like tws does?
|
||||||
|
# (would require a background history checker task)
|
||||||
|
|
||||||
# update last price sticky
|
# update last price sticky
|
||||||
last_price_sticky = chart._ysticks[chart.name]
|
last_price_sticky = chart._ysticks[chart.name]
|
||||||
|
@ -84,6 +88,9 @@ async def chart_from_quotes(
|
||||||
*ohlcv.array[-1][['index', 'close']]
|
*ohlcv.array[-1][['index', 'close']]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if vlm_chart:
|
||||||
|
vlm_sticky = vlm_chart._ysticks['volume']
|
||||||
|
|
||||||
def maxmin():
|
def maxmin():
|
||||||
# TODO: implement this
|
# TODO: implement this
|
||||||
# https://arxiv.org/abs/cs/0610046
|
# https://arxiv.org/abs/cs/0610046
|
||||||
|
@ -94,7 +101,7 @@ async def chart_from_quotes(
|
||||||
|
|
||||||
last_bars_range = chart.bars_range()
|
last_bars_range = chart.bars_range()
|
||||||
l, lbar, rbar, r = last_bars_range
|
l, lbar, rbar, r = last_bars_range
|
||||||
in_view = array[lbar - ifirst:rbar - ifirst]
|
in_view = array[lbar - ifirst:rbar - ifirst + 1]
|
||||||
|
|
||||||
assert in_view.size
|
assert in_view.size
|
||||||
|
|
||||||
|
@ -105,11 +112,20 @@ async def chart_from_quotes(
|
||||||
# sym = chart.name
|
# sym = chart.name
|
||||||
# mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym])
|
# mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym])
|
||||||
|
|
||||||
return last_bars_range, mx, max(mn, 0)
|
mx_vlm_in_view = 0
|
||||||
|
if vlm_chart:
|
||||||
|
mx_vlm_in_view = np.max(in_view['volume'])
|
||||||
|
|
||||||
|
return last_bars_range, mx, max(mn, 0), mx_vlm_in_view
|
||||||
|
|
||||||
chart.default_view()
|
chart.default_view()
|
||||||
|
|
||||||
last_bars_range, last_mx, last_mn = maxmin()
|
(
|
||||||
|
last_bars_range,
|
||||||
|
last_mx,
|
||||||
|
last_mn,
|
||||||
|
last_mx_vlm,
|
||||||
|
) = maxmin()
|
||||||
|
|
||||||
last, volume = ohlcv.array[-1][['close', 'volume']]
|
last, volume = ohlcv.array[-1][['close', 'volume']]
|
||||||
|
|
||||||
|
@ -129,10 +145,10 @@ async def chart_from_quotes(
|
||||||
|
|
||||||
# - if trade volume jumps above / below prior L1 price
|
# - if trade volume jumps above / below prior L1 price
|
||||||
# levels this might be dark volume we need to
|
# levels this might be dark volume we need to
|
||||||
# present differently?
|
# present differently -> likely dark vlm
|
||||||
|
|
||||||
tick_size = chart.linked.symbol.tick_size
|
tick_size = chart.linked.symbol.tick_size
|
||||||
tick_margin = 2 * tick_size
|
tick_margin = 3 * tick_size
|
||||||
|
|
||||||
last_ask = last_bid = last_clear = time.time()
|
last_ask = last_bid = last_clear = time.time()
|
||||||
chart.show()
|
chart.show()
|
||||||
|
@ -148,9 +164,40 @@ async def chart_from_quotes(
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
|
# brange, mx_in_view, mn_in_view = maxmin()
|
||||||
|
(
|
||||||
|
brange,
|
||||||
|
mx_in_view,
|
||||||
|
mn_in_view,
|
||||||
|
mx_vlm_in_view,
|
||||||
|
) = maxmin()
|
||||||
|
l, lbar, rbar, r = brange
|
||||||
|
mx = mx_in_view + tick_margin
|
||||||
|
mn = mn_in_view - tick_margin
|
||||||
|
|
||||||
|
# NOTE: vlm may be written by the ``brokerd`` backend
|
||||||
|
# event though a tick sample is not emitted.
|
||||||
|
# TODO: show dark trades differently
|
||||||
|
# https://github.com/pikers/piker/issues/116
|
||||||
|
array = ohlcv.array
|
||||||
|
|
||||||
|
if vlm_chart:
|
||||||
|
# print(f"volume: {end['volume']}")
|
||||||
|
vlm_chart.update_curve_from_array('volume', array)
|
||||||
|
vlm_sticky.update_from_data(*array[-1][['index', 'volume']])
|
||||||
|
|
||||||
|
if (
|
||||||
|
mx_vlm_in_view != last_mx_vlm or
|
||||||
|
mx_vlm_in_view > last_mx_vlm
|
||||||
|
):
|
||||||
|
# print(f'mx vlm: {last_mx_vlm} -> {mx_vlm_in_view}')
|
||||||
|
vlm_chart._set_yrange(yrange=(0, mx_vlm_in_view * 1.375))
|
||||||
|
last_mx_vlm = mx_vlm_in_view
|
||||||
|
|
||||||
for tick in quote.get('ticks', ()):
|
for tick in quote.get('ticks', ()):
|
||||||
|
|
||||||
# print(f"CHART: {quote['symbol']}: {tick}")
|
# log.info(
|
||||||
|
# f"quotes: {pformat(quote['symbol'])}: {pformat(tick)}")
|
||||||
ticktype = tick.get('type')
|
ticktype = tick.get('type')
|
||||||
price = tick.get('price')
|
price = tick.get('price')
|
||||||
size = tick.get('size')
|
size = tick.get('size')
|
||||||
|
@ -172,16 +219,13 @@ async def chart_from_quotes(
|
||||||
# set time of last graphics update
|
# set time of last graphics update
|
||||||
last_clear = now
|
last_clear = now
|
||||||
|
|
||||||
array = ohlcv.array
|
|
||||||
|
|
||||||
# update price sticky(s)
|
# update price sticky(s)
|
||||||
end = array[-1]
|
end = array[-1]
|
||||||
last_price_sticky.update_from_data(
|
last_price_sticky.update_from_data(
|
||||||
*end[['index', 'close']]
|
*end[['index', 'close']]
|
||||||
)
|
)
|
||||||
|
|
||||||
# plot bars
|
# update ohlc sampled price bars
|
||||||
# update price bar
|
|
||||||
chart.update_ohlc_from_array(
|
chart.update_ohlc_from_array(
|
||||||
chart.name,
|
chart.name,
|
||||||
array,
|
array,
|
||||||
|
@ -214,11 +258,6 @@ async def chart_from_quotes(
|
||||||
# compute max and min trade values to display in view
|
# compute max and min trade values to display in view
|
||||||
# TODO: we need a streaming minmax algorithm here, see
|
# TODO: we need a streaming minmax algorithm here, see
|
||||||
# def above.
|
# def above.
|
||||||
brange, mx_in_view, mn_in_view = maxmin()
|
|
||||||
l, lbar, rbar, r = brange
|
|
||||||
|
|
||||||
mx = mx_in_view + tick_margin
|
|
||||||
mn = mn_in_view - tick_margin
|
|
||||||
|
|
||||||
# XXX: prettty sure this is correct?
|
# XXX: prettty sure this is correct?
|
||||||
# if ticktype in ('trade', 'last'):
|
# if ticktype in ('trade', 'last'):
|
||||||
|
@ -242,16 +281,14 @@ async def chart_from_quotes(
|
||||||
elif ticktype in ('bid', 'bsize'):
|
elif ticktype in ('bid', 'bsize'):
|
||||||
l1.bid_label.update_fields({'level': price, 'size': size})
|
l1.bid_label.update_fields({'level': price, 'size': size})
|
||||||
|
|
||||||
# update min price in view to keep bid on screen
|
# in view y-range checking for auto-scale
|
||||||
mn = min(price - tick_margin, mn)
|
# update the max/min price in view to keep bid/ask on screen
|
||||||
# update max price in view to keep ask on screen
|
|
||||||
mx = max(price + tick_margin, mx)
|
mx = max(price + tick_margin, mx)
|
||||||
|
mn = min(price - tick_margin, mn)
|
||||||
if (mx > last_mx) or (
|
if (mx > last_mx) or (
|
||||||
mn < last_mn
|
mn < last_mn
|
||||||
):
|
):
|
||||||
# print(f'new y range: {(mn, mx)}')
|
# print(f'new y range: {(mn, mx)}')
|
||||||
|
|
||||||
chart._set_yrange(
|
chart._set_yrange(
|
||||||
yrange=(mn, mx),
|
yrange=(mn, mx),
|
||||||
# TODO: we should probably scale
|
# TODO: we should probably scale
|
||||||
|
@ -345,45 +382,51 @@ async def fan_out_spawn_fsp_daemons(
|
||||||
# blocks here until all fsp actors complete
|
# blocks here until all fsp actors complete
|
||||||
|
|
||||||
|
|
||||||
class FspConfig(BaseModel):
|
|
||||||
class Config:
|
|
||||||
validate_assignment = True
|
|
||||||
|
|
||||||
name: str
|
|
||||||
period: int
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def open_sidepane(
|
async def open_sidepane(
|
||||||
|
|
||||||
linked: LinkedSplits,
|
linked: LinkedSplits,
|
||||||
display_name: str,
|
conf: dict[str, dict[str, str]],
|
||||||
|
|
||||||
) -> FspConfig:
|
) -> FieldsForm:
|
||||||
|
|
||||||
|
schema = {}
|
||||||
|
|
||||||
|
assert len(conf) == 1 # for now
|
||||||
|
|
||||||
|
# add (single) selection widget
|
||||||
|
for display_name, config in conf.items():
|
||||||
|
schema[display_name] = {
|
||||||
|
'label': '**fsp**:',
|
||||||
|
'type': 'select',
|
||||||
|
'default_value': [display_name],
|
||||||
|
}
|
||||||
|
|
||||||
|
# add parameters for selection "options"
|
||||||
|
defaults = config.get('params', {})
|
||||||
|
for name, default in defaults.items():
|
||||||
|
|
||||||
|
# add to ORM schema
|
||||||
|
schema.update({
|
||||||
|
name: {
|
||||||
|
'label': f'**{name}**:',
|
||||||
|
'type': 'edit',
|
||||||
|
'default_value': default,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
sidepane: FieldsForm = mk_form(
|
sidepane: FieldsForm = mk_form(
|
||||||
parent=linked.godwidget,
|
parent=linked.godwidget,
|
||||||
fields_schema={
|
fields_schema=schema,
|
||||||
'name': {
|
)
|
||||||
'label': '**fsp**:',
|
|
||||||
'type': 'select',
|
|
||||||
'default_value': [
|
|
||||||
f'{display_name}'
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
# TODO: generate this from input map
|
# https://pydantic-docs.helpmanual.io/usage/models/#dynamic-model-creation
|
||||||
'period': {
|
FspConfig = create_model(
|
||||||
'label': '**period**:',
|
'FspConfig',
|
||||||
'type': 'edit',
|
|
||||||
'default_value': 14,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
sidepane.model = FspConfig(
|
|
||||||
name=display_name,
|
name=display_name,
|
||||||
period=14,
|
**defaults,
|
||||||
)
|
)
|
||||||
|
sidepane.model = FspConfig()
|
||||||
|
|
||||||
# just a logger for now until we get fsp configs up and running.
|
# just a logger for now until we get fsp configs up and running.
|
||||||
async def settings_change(key: str, value: str) -> bool:
|
async def settings_change(key: str, value: str) -> bool:
|
||||||
|
@ -410,7 +453,7 @@ async def run_fsp(
|
||||||
src_shm: ShmArray,
|
src_shm: ShmArray,
|
||||||
fsp_func_name: str,
|
fsp_func_name: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
conf: dict[str, Any],
|
conf: dict[str, dict],
|
||||||
group_status_key: str,
|
group_status_key: str,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
|
@ -444,7 +487,7 @@ async def run_fsp(
|
||||||
ctx.open_stream() as stream,
|
ctx.open_stream() as stream,
|
||||||
open_sidepane(
|
open_sidepane(
|
||||||
linkedsplits,
|
linkedsplits,
|
||||||
display_name,
|
{display_name: conf},
|
||||||
) as sidepane,
|
) as sidepane,
|
||||||
):
|
):
|
||||||
|
|
||||||
|
@ -453,9 +496,10 @@ async def run_fsp(
|
||||||
if conf.get('overlay'):
|
if conf.get('overlay'):
|
||||||
chart = linkedsplits.chart
|
chart = linkedsplits.chart
|
||||||
chart.draw_curve(
|
chart.draw_curve(
|
||||||
name='vwap',
|
name=display_name,
|
||||||
data=shm.array,
|
data=shm.array,
|
||||||
overlay=True,
|
overlay=True,
|
||||||
|
color='default_light',
|
||||||
)
|
)
|
||||||
last_val_sticky = None
|
last_val_sticky = None
|
||||||
|
|
||||||
|
@ -658,22 +702,25 @@ async def maybe_open_vlm_display(
|
||||||
|
|
||||||
) -> ChartPlotWidget:
|
) -> ChartPlotWidget:
|
||||||
|
|
||||||
# make sure that the instrument supports volume history
|
|
||||||
# (sometimes this is not the case for some commodities and
|
|
||||||
# derivatives)
|
|
||||||
# volm = ohlcv.array['volume']
|
|
||||||
# if (
|
|
||||||
# np.all(np.isin(volm, -1)) or
|
|
||||||
# np.all(np.isnan(volm))
|
|
||||||
# ):
|
|
||||||
if not has_vlm(ohlcv):
|
if not has_vlm(ohlcv):
|
||||||
log.warning(f"{linked.symbol.key} does not seem to have volume info")
|
log.warning(f"{linked.symbol.key} does not seem to have volume info")
|
||||||
|
yield
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
async with open_sidepane(linked, 'volume') as sidepane:
|
async with open_sidepane(
|
||||||
|
linked, {
|
||||||
|
'volume': {
|
||||||
|
'params': {
|
||||||
|
'price_func': 'ohl3'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) as sidepane:
|
||||||
|
|
||||||
# built-in $vlm
|
# built-in $vlm
|
||||||
shm = ohlcv
|
shm = ohlcv
|
||||||
chart = linked.add_plot(
|
chart = linked.add_plot(
|
||||||
name='vlm',
|
name='volume',
|
||||||
array=shm.array,
|
array=shm.array,
|
||||||
|
|
||||||
array_key='volume',
|
array_key='volume',
|
||||||
|
@ -682,9 +729,13 @@ async def maybe_open_vlm_display(
|
||||||
# curve by default
|
# curve by default
|
||||||
ohlc=False,
|
ohlc=False,
|
||||||
|
|
||||||
# vertical bars
|
# Draw vertical bars from zero.
|
||||||
|
# we do this internally ourselves since
|
||||||
|
# the curve item internals are pretty convoluted.
|
||||||
|
style='step',
|
||||||
|
|
||||||
|
# original pyqtgraph flag for reference
|
||||||
# stepMode=True,
|
# stepMode=True,
|
||||||
# static_yrange=(0, 100),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# XXX: ONLY for sub-chart fsps, overlays have their
|
# XXX: ONLY for sub-chart fsps, overlays have their
|
||||||
|
@ -703,9 +754,23 @@ async def maybe_open_vlm_display(
|
||||||
|
|
||||||
last_val_sticky.update_from_data(-1, value)
|
last_val_sticky.update_from_data(-1, value)
|
||||||
|
|
||||||
|
chart.update_curve_from_array(
|
||||||
|
'volume',
|
||||||
|
shm.array,
|
||||||
|
)
|
||||||
|
|
||||||
# size view to data once at outset
|
# size view to data once at outset
|
||||||
chart._set_yrange()
|
chart._set_yrange()
|
||||||
|
|
||||||
|
# size pain to parent chart
|
||||||
|
# TODO: this appears to nearly fix a bug where the vlm sidepane
|
||||||
|
# could be sized correctly nearly immediately (since the
|
||||||
|
# order pane is already sized), right now it doesn't seem to
|
||||||
|
# fully align until the VWAP fsp-actor comes up...
|
||||||
|
await trio.sleep(0)
|
||||||
|
chart.linked.resize_sidepanes()
|
||||||
|
await trio.sleep(0)
|
||||||
|
|
||||||
yield chart
|
yield chart
|
||||||
|
|
||||||
|
|
||||||
|
@ -803,22 +868,13 @@ async def display_symbol_data(
|
||||||
|
|
||||||
# TODO: eventually we'll support some kind of n-compose syntax
|
# TODO: eventually we'll support some kind of n-compose syntax
|
||||||
fsp_conf = {
|
fsp_conf = {
|
||||||
'rsi': {
|
# 'rsi': {
|
||||||
'fsp_func_name': 'rsi',
|
|
||||||
'period': 14,
|
|
||||||
'chart_kwargs': {
|
|
||||||
'static_yrange': (0, 100),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
# # test for duplicate fsps on same chart
|
|
||||||
# 'rsi2': {
|
|
||||||
# 'fsp_func_name': 'rsi',
|
# 'fsp_func_name': 'rsi',
|
||||||
# 'period': 14,
|
# 'params': {'period': 14},
|
||||||
# 'chart_kwargs': {
|
# 'chart_kwargs': {
|
||||||
# 'static_yrange': (0, 100),
|
# 'static_yrange': (0, 100),
|
||||||
# },
|
# },
|
||||||
# },
|
# },
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_vlm(ohlcv):
|
if has_vlm(ohlcv):
|
||||||
|
@ -831,9 +887,16 @@ async def display_symbol_data(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async with (
|
# NOTE: we must immediately tell Qt to show the OHLC chart
|
||||||
|
# to avoid a race where the subplots get added/shown to
|
||||||
|
# the linked set *before* the main price chart!
|
||||||
|
linkedsplits.show()
|
||||||
|
linkedsplits.focus()
|
||||||
|
await trio.sleep(0)
|
||||||
|
|
||||||
|
async with (
|
||||||
trio.open_nursery() as ln,
|
trio.open_nursery() as ln,
|
||||||
|
maybe_open_vlm_display(linkedsplits, ohlcv) as vlm_chart,
|
||||||
):
|
):
|
||||||
# load initial fsp chain (otherwise known as "indicators")
|
# load initial fsp chain (otherwise known as "indicators")
|
||||||
ln.start_soon(
|
ln.start_soon(
|
||||||
|
@ -849,11 +912,12 @@ async def display_symbol_data(
|
||||||
|
|
||||||
# start graphics update loop(s)after receiving first live quote
|
# start graphics update loop(s)after receiving first live quote
|
||||||
ln.start_soon(
|
ln.start_soon(
|
||||||
chart_from_quotes,
|
update_chart_from_quotes,
|
||||||
chart,
|
chart,
|
||||||
feed.stream,
|
feed.stream,
|
||||||
ohlcv,
|
ohlcv,
|
||||||
wap_in_history,
|
wap_in_history,
|
||||||
|
vlm_chart,
|
||||||
)
|
)
|
||||||
|
|
||||||
ln.start_soon(
|
ln.start_soon(
|
||||||
|
@ -864,10 +928,6 @@ async def display_symbol_data(
|
||||||
)
|
)
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
# XXX: this slipped in during a commits refacotr,
|
|
||||||
# it's actually landing proper in #231
|
|
||||||
# maybe_open_vlm_display(linkedsplits, ohlcv),
|
|
||||||
|
|
||||||
open_order_mode(
|
open_order_mode(
|
||||||
feed,
|
feed,
|
||||||
chart,
|
chart,
|
||||||
|
|
|
@ -205,19 +205,26 @@ def hcolor(name: str) -> str:
|
||||||
'svags': '#0a0e14',
|
'svags': '#0a0e14',
|
||||||
|
|
||||||
# fifty shades
|
# fifty shades
|
||||||
|
'original': '#a9a9a9',
|
||||||
'gray': '#808080', # like the kick
|
'gray': '#808080', # like the kick
|
||||||
'grayer': '#4c4c4c',
|
'grayer': '#4c4c4c',
|
||||||
'grayest': '#3f3f3f',
|
'grayest': '#3f3f3f',
|
||||||
'i3': '#494D4F',
|
|
||||||
'jet': '#343434',
|
|
||||||
'cadet': '#91A3B0',
|
'cadet': '#91A3B0',
|
||||||
'marengo': '#91A3B0',
|
'marengo': '#91A3B0',
|
||||||
'charcoal': '#36454F',
|
|
||||||
'gunmetal': '#91A3B0',
|
'gunmetal': '#91A3B0',
|
||||||
'battleship': '#848482',
|
'battleship': '#848482',
|
||||||
'davies': '#555555',
|
|
||||||
|
# bluish
|
||||||
|
'charcoal': '#36454F',
|
||||||
|
|
||||||
|
# default bars
|
||||||
'bracket': '#666666', # like the logo
|
'bracket': '#666666', # like the logo
|
||||||
'original': '#a9a9a9',
|
|
||||||
|
# work well for filled polygons which want a 'bracket' feel
|
||||||
|
# going light to dark
|
||||||
|
'davies': '#555555',
|
||||||
|
'i3': '#494D4F',
|
||||||
|
'jet': '#343434',
|
||||||
|
|
||||||
# from ``qdarkstyle`` palette
|
# from ``qdarkstyle`` palette
|
||||||
'default_darkest': DarkPalette.COLOR_BACKGROUND_1,
|
'default_darkest': DarkPalette.COLOR_BACKGROUND_1,
|
||||||
|
|
Loading…
Reference in New Issue