347 lines
10 KiB
Python
347 lines
10 KiB
Python
# piker: trading gear for hackers
|
|
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
'''
|
|
Root-most (what they call a "central widget") of every Qt-UI-app's
|
|
window.
|
|
|
|
'''
|
|
from __future__ import annotations
|
|
from typing import (
|
|
Iterator,
|
|
TYPE_CHECKING,
|
|
)
|
|
|
|
import trio
|
|
|
|
from piker.ui.qt import (
|
|
QtCore,
|
|
Qt,
|
|
QWidget,
|
|
QHBoxLayout,
|
|
QVBoxLayout,
|
|
)
|
|
from ..log import get_logger
|
|
|
|
if TYPE_CHECKING:
|
|
from ._search import SearchWidget
|
|
from ._chart import (
|
|
LinkedSplits,
|
|
)
|
|
from ._cursor import (
|
|
Cursor,
|
|
)
|
|
|
|
|
|
log = get_logger(__name__)
|
|
|
|
_godw: GodWidget|None = None
|
|
|
|
def get_godw() -> GodWidget:
|
|
'''
|
|
Get the top level "god widget", the root/central-most Qt
|
|
widget-object set as `QMainWindow.setCentralWidget(_godw)`.
|
|
|
|
See `piker.ui._exec` for the runtime init details and all the
|
|
machinery for running `trio` on the Qt event loop in guest mode.
|
|
|
|
'''
|
|
if _godw is None:
|
|
raise RuntimeError(
|
|
'No god-widget initialized ??\n'
|
|
'Have you called `run_qtractor()` yet?\n'
|
|
)
|
|
return _godw
|
|
|
|
|
|
class GodWidget(QWidget):
|
|
'''
|
|
"Our lord and savior, the holy child of window-shua, there is no
|
|
widget above thee." - 6|6
|
|
|
|
The highest level composed widget which contains layouts for
|
|
organizing charts as well as other sub-widgets used to control or
|
|
modify them.
|
|
|
|
'''
|
|
search: SearchWidget
|
|
mode_name: str = 'god'
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
parent=None,
|
|
|
|
) -> None:
|
|
|
|
super().__init__(parent)
|
|
|
|
self.search: SearchWidget|None = None
|
|
|
|
self.hbox = QHBoxLayout(self)
|
|
self.hbox.setContentsMargins(0, 0, 0, 0)
|
|
self.hbox.setSpacing(6)
|
|
self.hbox.setAlignment(Qt.AlignTop)
|
|
|
|
self.vbox = QVBoxLayout()
|
|
self.vbox.setContentsMargins(0, 0, 0, 0)
|
|
self.vbox.setSpacing(2)
|
|
self.vbox.setAlignment(Qt.AlignTop)
|
|
|
|
self.hbox.addLayout(self.vbox)
|
|
|
|
self._chart_cache: dict[
|
|
str,
|
|
tuple[LinkedSplits, LinkedSplits],
|
|
] = {}
|
|
|
|
self.hist_linked: LinkedSplits|None = None
|
|
self.rt_linked: LinkedSplits|None = None
|
|
self._active_cursor: Cursor|None = None
|
|
|
|
# assigned in the startup func `_async_main()`
|
|
self._root_n: trio.Nursery = None
|
|
|
|
self._widgets: dict[str, QWidget] = {}
|
|
self._resizing: bool = False
|
|
|
|
# TODO: do we need this, when would god get resized
|
|
# and the window does not? Never right?!
|
|
# self.reg_for_resize(self)
|
|
|
|
# TODO: strat loader/saver that we don't need yet.
|
|
# def init_strategy_ui(self):
|
|
# self.toolbar_layout = QHBoxLayout()
|
|
# self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
|
|
# self.vbox.addLayout(self.toolbar_layout)
|
|
# self.strategy_box = StrategyBoxWidget(self)
|
|
# self.toolbar_layout.addWidget(self.strategy_box)
|
|
|
|
@property
|
|
def linkedsplits(self) -> LinkedSplits:
|
|
return self.rt_linked
|
|
|
|
def set_chart_symbols(
|
|
self,
|
|
group_key: tuple[str], # of form <fqme>.<providername>
|
|
all_linked: tuple[LinkedSplits, LinkedSplits], # type: ignore
|
|
|
|
) -> None:
|
|
# re-sort org cache symbol list in LIFO order
|
|
cache = self._chart_cache
|
|
cache.pop(group_key, None)
|
|
cache[group_key] = all_linked
|
|
|
|
def get_chart_symbols(
|
|
self,
|
|
symbol_key: str,
|
|
|
|
) -> tuple[LinkedSplits, LinkedSplits]: # type: ignore
|
|
return self._chart_cache.get(symbol_key)
|
|
|
|
async def load_symbols(
|
|
self,
|
|
fqmes: list[str],
|
|
loglevel: str,
|
|
reset: bool = False,
|
|
|
|
) -> trio.Event:
|
|
'''
|
|
Load a new contract into the charting app.
|
|
|
|
Expects a ``numpy`` structured array containing all the ohlcv fields.
|
|
|
|
'''
|
|
# NOTE: for now we use the first symbol in the set as the "key"
|
|
# for the overlay of feeds on the chart.
|
|
group_key: tuple[str] = tuple(fqmes)
|
|
|
|
all_linked = self.get_chart_symbols(group_key)
|
|
order_mode_started = trio.Event()
|
|
|
|
if not self.vbox.isEmpty():
|
|
|
|
# XXX: seems to make switching slower?
|
|
# qframe = self.hist_linked.chart.qframe
|
|
# if qframe.sidepane is self.search:
|
|
# qframe.hbox.removeWidget(self.search)
|
|
|
|
for linked in [self.rt_linked, self.hist_linked]:
|
|
# XXX: this is CRITICAL especially with pixel buffer caching
|
|
linked.hide()
|
|
linked.unfocus()
|
|
|
|
# XXX: pretty sure we don't need this
|
|
# remove any existing plots?
|
|
# XXX: ahh we might want to support cache unloading..
|
|
# self.vbox.removeWidget(linked)
|
|
|
|
# switching to a new viewable chart
|
|
if all_linked is None or reset:
|
|
from ._display import display_symbol_data
|
|
|
|
# we must load a fresh linked charts set
|
|
from ._chart import LinkedSplits
|
|
self.rt_linked = rt_charts = LinkedSplits(self)
|
|
self.hist_linked = hist_charts = LinkedSplits(self)
|
|
|
|
# spawn new task to start up and update new sub-chart instances
|
|
self._root_n.start_soon(
|
|
display_symbol_data,
|
|
self,
|
|
fqmes,
|
|
loglevel,
|
|
order_mode_started,
|
|
)
|
|
|
|
# self.vbox.addWidget(hist_charts)
|
|
self.vbox.addWidget(rt_charts)
|
|
self.set_chart_symbols(
|
|
group_key,
|
|
(hist_charts, rt_charts),
|
|
)
|
|
|
|
for linked in [hist_charts, rt_charts]:
|
|
linked.show()
|
|
linked.focus()
|
|
|
|
await trio.sleep(0)
|
|
|
|
else:
|
|
# symbol is already loaded and ems ready
|
|
order_mode_started.set()
|
|
|
|
self.hist_linked, self.rt_linked = all_linked
|
|
|
|
for linked in all_linked:
|
|
# TODO:
|
|
# - we'll probably want per-instrument/provider state here?
|
|
# change the order config form over to the new chart
|
|
|
|
# chart is already in memory so just focus it
|
|
linked.show()
|
|
linked.focus()
|
|
linked.graphics_cycle()
|
|
await trio.sleep(0)
|
|
|
|
# resume feeds *after* rendering chart view asap
|
|
chart = linked.chart
|
|
if chart:
|
|
chart.resume_all_feeds()
|
|
|
|
# TODO: we need a check to see if the chart
|
|
# last had the xlast in view, if so then shift so it's
|
|
# still in view, if the user was viewing history then
|
|
# do nothing yah?
|
|
self.rt_linked.chart.main_viz.default_view(
|
|
do_min_bars=True,
|
|
)
|
|
|
|
# if a history chart instance is already up then
|
|
# set the search widget as its sidepane.
|
|
hist_chart = self.hist_linked.chart
|
|
if hist_chart:
|
|
hist_chart.qframe.set_sidepane(self.search)
|
|
|
|
# NOTE: this is really stupid/hard to follow.
|
|
# we have to reposition the active position nav
|
|
# **AFTER** applying the search bar as a sidepane
|
|
# to the newly switched to symbol.
|
|
await trio.sleep(0)
|
|
|
|
# TODO: probably stick this in some kinda `LooknFeel` API?
|
|
for tracker in self.rt_linked.mode.trackers.values():
|
|
pp_nav = tracker.nav
|
|
if tracker.live_pp.cumsize:
|
|
pp_nav.show()
|
|
pp_nav.hide_info()
|
|
else:
|
|
pp_nav.hide()
|
|
|
|
# set window titlebar info
|
|
symbol = self.rt_linked.mkt
|
|
if symbol is not None:
|
|
self.window.setWindowTitle(
|
|
f'{symbol.fqme} '
|
|
f'tick:{symbol.size_tick}'
|
|
)
|
|
|
|
return order_mode_started
|
|
|
|
def focus(self) -> None:
|
|
'''
|
|
Focus the top level widget which in turn focusses the chart
|
|
ala "view mode".
|
|
|
|
'''
|
|
# go back to view-mode focus (aka chart focus)
|
|
self.clearFocus()
|
|
chart = self.rt_linked.chart
|
|
if chart:
|
|
chart.setFocus()
|
|
|
|
def reg_for_resize(
|
|
self,
|
|
widget: QWidget,
|
|
) -> None:
|
|
getattr(widget, 'on_resize')
|
|
self._widgets[widget.mode_name] = widget
|
|
|
|
def on_win_resize(self, event: QtCore.QEvent) -> None:
|
|
'''
|
|
Top level god widget handler from window (the real yaweh) resize
|
|
events such that any registered widgets which wish to be
|
|
notified are invoked using our pythonic `.on_resize()` method
|
|
api.
|
|
|
|
Where we do UX magic to make things not suck B)
|
|
|
|
'''
|
|
if self._resizing:
|
|
return
|
|
|
|
self._resizing = True
|
|
|
|
log.info('God widget resize')
|
|
for name, widget in self._widgets.items():
|
|
widget.on_resize()
|
|
|
|
self._resizing = False
|
|
|
|
# on_resize = on_win_resize
|
|
|
|
def get_cursor(self) -> Cursor:
|
|
return self._active_cursor
|
|
|
|
def iter_linked(self) -> Iterator[LinkedSplits]:
|
|
for linked in [self.hist_linked, self.rt_linked]:
|
|
yield linked
|
|
|
|
def resize_all(self) -> None:
|
|
'''
|
|
Dynamic resize sequence: adjusts all sub-widgets/charts to
|
|
sensible default ratios of what space is detected as available
|
|
on the display / window.
|
|
|
|
'''
|
|
rt_linked = self.rt_linked
|
|
rt_linked.set_split_sizes()
|
|
self.rt_linked.resize_sidepanes()
|
|
self.hist_linked.resize_sidepanes(from_linked=rt_linked)
|
|
self.search.on_resize()
|
|
|
|
|