tractor_struct_and_godw_mod #72

Merged
goodboy merged 8 commits from tractor_struct_and_godw_mod into main 2026-02-22 23:39:11 +00:00
12 changed files with 435 additions and 568 deletions

View File

@ -21,230 +21,6 @@ Extensions to built-in or (heavily used but 3rd party) friend-lib
types. types.
''' '''
from __future__ import annotations from tractor.msg.pretty_struct import (
from collections import UserList Struct as Struct,
from pprint import (
saferepr,
) )
from typing import Any
from msgspec import (
msgpack,
Struct as _Struct,
structs,
)
class DiffDump(UserList):
'''
Very simple list delegator that repr() dumps (presumed) tuple
elements of the form `tuple[str, Any, Any]` in a nice
multi-line readable form for analyzing `Struct` diffs.
'''
def __repr__(self) -> str:
if not len(self):
return super().__repr__()
# format by displaying item pair's ``repr()`` on multiple,
# indented lines such that they are more easily visually
# comparable when printed to console when printed to
# console.
repstr: str = '[\n'
for k, left, right in self:
repstr += (
f'({k},\n'
f'\t{repr(left)},\n'
f'\t{repr(right)},\n'
')\n'
)
repstr += ']\n'
return repstr
class Struct(
_Struct,
# https://jcristharif.com/msgspec/structs.html#tagged-unions
# tag='pikerstruct',
# tag=True,
):
'''
A "human friendlier" (aka repl buddy) struct subtype.
'''
def _sin_props(self) -> Iterator[
tuple[
structs.FieldIinfo,
str,
Any,
]
]:
'''
Iterate over all non-@property fields of this struct.
'''
fi: structs.FieldInfo
for fi in structs.fields(self):
key: str = fi.name
val: Any = getattr(self, key)
yield fi, key, val
def to_dict(
self,
include_non_members: bool = True,
) -> dict:
'''
Like it sounds.. direct delegation to:
https://jcristharif.com/msgspec/api.html#msgspec.structs.asdict
BUT, by default we pop all non-member (aka not defined as
struct fields) fields by default.
'''
asdict: dict = structs.asdict(self)
if include_non_members:
return asdict
# only return a dict of the struct members
# which were provided as input, NOT anything
# added as type-defined `@property` methods!
sin_props: dict = {}
fi: structs.FieldInfo
for fi, k, v in self._sin_props():
sin_props[k] = asdict[k]
return sin_props
def pformat(
self,
field_indent: int = 2,
indent: int = 0,
) -> str:
'''
Recursion-safe `pprint.pformat()` style formatting of
a `msgspec.Struct` for sane reading by a human using a REPL.
'''
# global whitespace indent
ws: str = ' '*indent
# field whitespace indent
field_ws: str = ' '*(field_indent + indent)
# qtn: str = ws + self.__class__.__qualname__
qtn: str = self.__class__.__qualname__
obj_str: str = '' # accumulator
fi: structs.FieldInfo
k: str
v: Any
for fi, k, v in self._sin_props():
# TODO: how can we prefer `Literal['option1', 'option2,
# ..]` over .__name__ == `Literal` but still get only the
# latter for simple types like `str | int | None` etc..?
ft: type = fi.type
typ_name: str = getattr(ft, '__name__', str(ft))
# recurse to get sub-struct's `.pformat()` output Bo
if isinstance(v, Struct):
val_str: str = v.pformat(
indent=field_indent + indent,
field_indent=indent + field_indent,
)
else: # the `pprint` recursion-safe format:
# https://docs.python.org/3.11/library/pprint.html#pprint.saferepr
val_str: str = saferepr(v)
obj_str += (field_ws + f'{k}: {typ_name} = {val_str},\n')
return (
f'{qtn}(\n'
f'{obj_str}'
f'{ws})'
)
# TODO: use a pprint.PrettyPrinter instance around ONLY rendering
# inside a known tty?
# def __repr__(self) -> str:
# ...
# __str__ = __repr__ = pformat
__repr__ = pformat
def copy(
self,
update: dict | None = None,
) -> Struct:
'''
Validate-typecast all self defined fields, return a copy of
us with all such fields.
NOTE: This is kinda like the default behaviour in
`pydantic.BaseModel` except a copy of the object is
returned making it compat with `frozen=True`.
'''
if update:
for k, v in update.items():
setattr(self, k, v)
# NOTE: roundtrip serialize to validate
# - enode to msgpack binary format,
# - decode that back to a struct.
return msgpack.Decoder(type=type(self)).decode(
msgpack.Encoder().encode(self)
)
def typecast(
self,
# TODO: allow only casting a named subset?
# fields: set[str] | None = None,
) -> None:
'''
Cast all fields using their declared type annotations
(kinda like what `pydantic` does by default).
NOTE: this of course won't work on frozen types, use
``.copy()`` above in such cases.
'''
# https://jcristharif.com/msgspec/api.html#msgspec.structs.fields
fi: structs.FieldInfo
for fi in structs.fields(self):
setattr(
self,
fi.name,
fi.type(getattr(self, fi.name)),
)
def __sub__(
self,
other: Struct,
) -> DiffDump[tuple[str, Any, Any]]:
'''
Compare fields/items key-wise and return a ``DiffDump``
for easy visual REPL comparison B)
'''
diffs: DiffDump[tuple[str, Any, Any]] = DiffDump()
for fi in structs.fields(self):
attr_name: str = fi.name
ours: Any = getattr(self, attr_name)
theirs: Any = getattr(other, attr_name)
if ours != theirs:
diffs.append((
attr_name,
ours,
theirs,
))
return diffs

View File

@ -27,15 +27,15 @@ import trio
from piker.ui.qt import ( from piker.ui.qt import (
QEvent, QEvent,
) )
from ..service import maybe_spawn_brokerd from . import _chart
from . import _event from . import _event
from ._exec import run_qtractor
from ..data.feed import install_brokerd_search
from ..data._symcache import open_symcache
from ..accounting import unpack_fqme
from . import _search from . import _search
from ._chart import GodWidget from ..accounting import unpack_fqme
from ..data._symcache import open_symcache
from ..data.feed import install_brokerd_search
from ..log import get_logger from ..log import get_logger
from ..service import maybe_spawn_brokerd
from ._exec import run_qtractor
log = get_logger(__name__) log = get_logger(__name__)
@ -73,8 +73,8 @@ async def load_provider_search(
async def _async_main( async def _async_main(
# implicit required argument provided by ``qtractor_run()`` # implicit required argument provided by `qtractor_run()`
main_widget: GodWidget, main_widget: _chart.GodWidget,
syms: list[str], syms: list[str],
brokers: dict[str, ModuleType], brokers: dict[str, ModuleType],
@ -87,6 +87,9 @@ async def _async_main(
Provision the "main" widget with initial symbol data and root nursery. Provision the "main" widget with initial symbol data and root nursery.
""" """
# set as singleton
_chart._godw = main_widget
from . import _display from . import _display
from ._pg_overrides import _do_overrides from ._pg_overrides import _do_overrides
_do_overrides() _do_overrides()
@ -201,6 +204,6 @@ def _main(
brokermods, brokermods,
piker_loglevel, piker_loglevel,
), ),
main_widget_type=GodWidget, main_widget_type=_chart.GodWidget,
tractor_kwargs=tractor_kwargs, tractor_kwargs=tractor_kwargs,
) )

View File

@ -29,7 +29,6 @@ from typing import (
) )
import pyqtgraph as pg import pyqtgraph as pg
import trio
from piker.ui.qt import ( from piker.ui.qt import (
QtCore, QtCore,
@ -41,6 +40,7 @@ from piker.ui.qt import (
QVBoxLayout, QVBoxLayout,
QSplitter, QSplitter,
) )
from ._widget import GodWidget
from ._axes import ( from ._axes import (
DynamicDateAxis, DynamicDateAxis,
PriceAxis, PriceAxis,
@ -61,10 +61,6 @@ from ._style import (
_xaxis_at, _xaxis_at,
# _min_points_to_show, # _min_points_to_show,
) )
from ..data.feed import (
Feed,
Flume,
)
from ..accounting import ( from ..accounting import (
MktPair, MktPair,
) )
@ -78,286 +74,12 @@ from . import _pg_overrides as pgo
if TYPE_CHECKING: if TYPE_CHECKING:
from ._display import DisplayState from ._display import DisplayState
from ..data.flows import Flume
from ..data.feed import Feed
log = get_logger(__name__) log = get_logger(__name__)
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
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()
class ChartnPane(QFrame): class ChartnPane(QFrame):
''' '''
One-off ``QFrame`` composite which pairs a chart One-off ``QFrame`` composite which pairs a chart
@ -419,7 +141,6 @@ class LinkedSplits(QWidget):
''' '''
def __init__( def __init__(
self, self,
godwidget: GodWidget, godwidget: GodWidget,
@ -567,8 +288,8 @@ class LinkedSplits(QWidget):
# style? # style?
self.chart.setFrameStyle( self.chart.setFrameStyle(
QFrame.Shape.StyledPanel | QFrame.Shape.StyledPanel
QFrame.Shadow.Plain |QFrame.Shadow.Plain
) )
return self.chart return self.chart
@ -1031,7 +752,7 @@ class ChartPlotWidget(pg.PlotWidget):
) -> None: ) -> None:
''' '''
Increment the data view ``datums``` steps toward y-axis thus Increment the data view `datums`` steps toward y-axis thus
"following" the current time slot/step/bar. "following" the current time slot/step/bar.
''' '''
@ -1041,7 +762,7 @@ class ChartPlotWidget(pg.PlotWidget):
x_shift = viz.index_step() * datums x_shift = viz.index_step() * datums
if datums >= 300: if datums >= 300:
print("FUCKING FIX THE GLOBAL STEP BULLSHIT") log.warning('FUCKING FIX THE GLOBAL STEP BULLSHIT')
# breakpoint() # breakpoint()
return return

View File

@ -27,7 +27,6 @@ import pyqtgraph as pg
from piker.ui.qt import ( from piker.ui.qt import (
QtWidgets, QtWidgets,
QGraphicsItem,
Qt, Qt,
QLineF, QLineF,
QRectF, QRectF,

View File

@ -54,6 +54,11 @@ from ._style import (
from ._lines import LevelLine from ._lines import LevelLine
from ..log import get_logger from ..log import get_logger
# TODO, rm the cycle here!
from ._widget import (
GodWidget,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ._chart import ( from ._chart import (
GodWidget, GodWidget,

View File

@ -56,7 +56,7 @@ from . import _style
if TYPE_CHECKING: if TYPE_CHECKING:
from ._chart import GodWidget from ._widget import GodWidget
log = get_logger(__name__) log = get_logger(__name__)
@ -91,6 +91,10 @@ def run_qtractor(
window_type: QMainWindow = None, window_type: QMainWindow = None,
) -> None: ) -> None:
'''
Run the Qt event loop and embed `trio` via guest mode on it.
'''
# avoids annoying message when entering debugger from qt loop # avoids annoying message when entering debugger from qt loop
pyqtRemoveInputHook() pyqtRemoveInputHook()
@ -170,7 +174,7 @@ def run_qtractor(
# hook into app focus change events # hook into app focus change events
app.focusChanged.connect(window.on_focus_change) app.focusChanged.connect(window.on_focus_change)
instance = main_widget_type() instance: GodWidget = main_widget_type()
instance.window = window instance.window = window
# override tractor's defaults # override tractor's defaults

View File

@ -87,7 +87,11 @@ def update_fsp_chart(
# guard against unreadable case # guard against unreadable case
if not last_row: if not last_row:
log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}') log.warning(
f'Read-race on shm array,\n'
f'graphics_name: {graphics_name!r}\n'
f'shm.token: {shm.token}\n'
)
return return
# update graphics # update graphics
@ -203,7 +207,6 @@ async def open_fsp_actor_cluster(
async def run_fsp_ui( async def run_fsp_ui(
linkedsplits: LinkedSplits, linkedsplits: LinkedSplits,
flume: Flume, flume: Flume,
started: trio.Event, started: trio.Event,
@ -623,8 +626,10 @@ async def open_fsp_admin(
event.set() event.set()
# TODO, passing in `pikerd` related settings here!
# [ ] read in the `tractor` setting for `enable_transports: list`
# from the root `conf.toml`!
async def open_vlm_displays( async def open_vlm_displays(
linked: LinkedSplits, linked: LinkedSplits,
flume: Flume, flume: Flume,
dvlm: bool = True, dvlm: bool = True,
@ -634,12 +639,12 @@ async def open_vlm_displays(
) -> None: ) -> None:
''' '''
Volume subchart displays. Vlm (volume) subchart displays.
Since "volume" is often included directly alongside OHLCV price Since "volume" is often included directly alongside OHLCV price
data, we don't really need a separate FSP-actor + shm array for it data, we don't really need a separate FSP-actor + shm array for
since it's likely already directly adjacent to OHLC samples from the it since it's likely already directly adjacent to OHLC samples
data provider. from the data provider.
Further only if volume data is detected (it sometimes isn't provided Further only if volume data is detected (it sometimes isn't provided
eg. forex, certain commodities markets) will volume dependent FSPs eg. forex, certain commodities markets) will volume dependent FSPs

View File

@ -237,8 +237,8 @@ class LevelLabel(YAxisLabel):
class L1Label(LevelLabel): class L1Label(LevelLabel):
text_flags = ( text_flags = (
QtCore.Qt.TextDontClip QtCore.Qt.TextFlag.TextDontClip
| QtCore.Qt.AlignLeft | QtCore.Qt.AlignmentFlag.AlignLeft
) )
def set_label_str( def set_label_str(

View File

@ -308,6 +308,7 @@ def hcolor(name: str) -> str:
'cool_green': '#33b864', 'cool_green': '#33b864',
'dull_green': '#74a662', 'dull_green': '#74a662',
'hedge_green': '#518360', 'hedge_green': '#518360',
'lilypad_green': '#839c84',
# orders and alerts # orders and alerts
'alert_yellow': '#e2d083', 'alert_yellow': '#e2d083',
@ -335,6 +336,7 @@ def hcolor(name: str) -> str:
'sell_red': '#b6003f', 'sell_red': '#b6003f',
# 'sell_red': '#d00048', # 'sell_red': '#d00048',
'sell_red_light': '#f85462', 'sell_red_light': '#f85462',
'wine': '#69212d',
# 'sell_red': '#f85462', # 'sell_red': '#f85462',
# 'sell_red_light': '#ff4d5c', # 'sell_red_light': '#ff4d5c',

346
piker/ui/_widget.py 100644
View File

@ -0,0 +1,346 @@
# 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()

View File

@ -40,7 +40,7 @@ from piker.ui.qt import (
) )
from ..log import get_logger from ..log import get_logger
from ._style import _font_small, hcolor from ._style import _font_small, hcolor
from ._chart import GodWidget from ._widget import GodWidget
log = get_logger(__name__) log = get_logger(__name__)

View File

@ -59,8 +59,14 @@ from piker.data import (
from piker.types import Struct from piker.types import Struct
from piker.log import get_logger from piker.log import get_logger
from piker.ui.qt import Qt from piker.ui.qt import Qt
from ._editors import LineEditor, ArrowEditor from ._editors import (
from ._lines import order_line, LevelLine LineEditor,
ArrowEditor,
)
from ._lines import (
order_line,
LevelLine,
)
from ._position import ( from ._position import (
PositionTracker, PositionTracker,
SettingsPane, SettingsPane,