Compare commits

...

17 Commits

Author SHA1 Message Date
Gud Boi 69a52d6fb1 Better doc-strings n styling in `piker.cli` eps
Add comprehensive docstrings to the top-level CLI endpoints and helpers,
explaining the purpose and structure of each (sub)command.

Deats,
- add detailed docstring to `pikerd()` explaining its role as the
  root service-actor/daemon supervisor.
- add docstring to `cli()` noting it's the root endpoint generally
  requiring a sub-cmd input.
- add extensive docstring to `services()` explaining the daemon naming
  conventions and listing a few current/common service actors.
- add docstring to `_load_clis()` explaining dynamic CLI loading.

Stylin,
- add multiline style to `and not maddrs` conditional in
  `load_trans_eps()`.
- drop commented-out `--tsdb` and `--es` click options from
  `pikerd()`, they're more or less obsolete given `nativedb`.
- add type annots where obviously handy.
- add TODO comment about UDS support in `services()`.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi 095d712443 Adjust sampler's "IPC-dropped" log msg styling
Refmt the "connection-dropped" error-log in `Sampler`'s broadcast loop
to show error type first, then the IPC context details; mks it all
easier to grok/less-noisy on console imo.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi cb2974f405 Wrap `open_autorecon_ws()` body for comms failures
Add outer `try/except` around the nursery block in
`open_autorecon_ws()` to catch any `NoBsWs.recon_errors` that
escape the inner reconnect loop, logging a warning instead of
propagating.

Also,
- correct `NoBsWs.recon_errors` typing to `tuple[Type[Exception]]`.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi 5e7dac2b90 Add timeout + shielding to `NoBsWs` reconnect logic
Add timeout param to `.reset()` and `.send_msg()` to prevent
indefinite blocking on reconnect attempts. Shield reconnect
sleeps from cancellation to ensure we avoid any "finally footgun" type
scenarios where `trio.Cancelled` masks an underlying exc per,
- https://github.com/goodboy/tractor/pull/387
- https://github.com/goodboy/tractor/pull/391

Deats,
- add `timeout` param to `.reset()`, return `bool` for success
- add `timeout=3` default to `.send_msg()` for reconnect wait
- shield `.reset()` call in `.send_msg()` error handler
- log warning when reconnect timeout exceeded
- shield throttled sleeps in `_reconnect_forever()` error paths

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi 9e877e0666 Handle `tractor.TransportClosed` as "stream-closed"
In both the ems and sampler since on new `tractor` this is the
"wrapping" exception raised when the transport layer terminates early
but in a psuedo-"graceful" way, expected when a peer actors disconnect.
Previously we were crashing in this case since old `tractor` just raised
the underlying `trio`-source-exceptions verbatim.

Also,
- use `Aid.reprol()` in log msgs vs old `.chan.uid` refs

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi 8aaaeb19ed .data.feed: move `Flume` import to avoid cycle
Move `Flume` to `TYPE_CHECKING` and add runtime imports in
`allocate_persistent_feed()` + `open_feed()` to avoid cycle
with `.flows` mod.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi c5125e7955 .fsp._engine: enable console logging in `cascade()`
Add console log setup with module name + multiline style for
desync warning msg.

Also,
- fix import: `Flume` from `.data.flows` vs `.data.feed`
- move `Feed` to `TYPE_CHECKING` block
- add TODO comment about `tractor._state` dict issue

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi 7bb80e85b0 Add order-cancel debugging and multiline kbd logs
Add verbose logging + error handling for order cancellation
hotkey path and multiline style for view-mode kb msgs.

Deats,
- add `Cursor.is_hovered()` to check hover state
- log warnings when no orders cancelled via <c> hotkey
- add try-except around `.cancel_orders_under_cursor()`
- log `cur._hovered` state in `.ui._lines` hover handlers
- change `Dialog.cancel_orders()` to return `list[Dialog]`
- fix import: `Flume` from `.data.flows` vs `.data.feed`
- comment-out multi-status msgs in order submit/cancel

Also,
- convert all multiline kbd `if` conditionals to use `and`
  on separate lines for consistency
- move `import tractor` to top of `._interaction`
- change `print()` to `log.debug()` in `LevelLine`
- fix type annotation spacing: `Callable|None` vs `Callable | None`

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi 46004b2fc3 .ui._app: enable console logging in `_async_main()`
Now we're actualy emitting colored-logs (again?), not sure how this got
borked but maybe it's due to `tractor.log`'s new changes?

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:50:42 -05:00
Gud Boi 0b63a73954 Move `GodWidget` to new `._widget` mod
Extract root-most widget to resolve (various) `.ui` import cycles
when the type is declared on `Struct`s..

Deats,
- flip to `from ._widget import GodWidget`.
- move `Feed` + `Flume` imports to TYPE_CHECKING in `._chart`
- drop unused `trio` import from `._chart`
- fix docstring typo: "datums```" -> "`datums``"
- change `print()` to `log.warning()` for global step msg

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:08:07 -05:00
Gud Boi 8fb47f761a Point `.types.Struct` to `tractor.msg.pretty_struct`
Drop the local (and original) `Struct` impl from `piker.types` in favour
of `tractor`'s version now that it's been upstreamed.

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:07:53 -05:00
Gud Boi ad37ebabb2 Cleanups and doc tweaks to `.ui._fsp`
Expand read-race warning log for clarity, add TODO for reading
`tractor` transport config from `conf.toml`, and reflow docstring
in `open_vlm_displays()`.

Also,
- whitespace cleanup: `Type | None` -> `Type|None`
- clarify "Volume" -> "Vlm (volume)" in docstr

(this commit msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:06:54 -05:00
Gud Boi 5020266bd5 Add `get_godw()` singleton getter for `GodWidget`
Expose `get_godw()` helper to retrieve the central `GodWidget`
instance from anywhere in the UI code. Set the singleton in
`_async_main()` on startup.

Also,
- add docstring to `run_qtractor()` explaining trio guest mode
- type annotate `instance: GodWidget` in `run_qtractor()`
- import reorg in `._app` for cleaner grouping
- whitespace cleanup: `Type | None` -> `Type|None` throughout
- fix bitwise-or alignment: `Flag | Other` -> `Flag|Other`

(this commit-msg was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-02-22 16:06:28 -05:00
Gud Boi d6a56d87bf Rm unused import in `.ui._curve` 2026-02-22 16:05:52 -05:00
Gud Boi e8152b8534 Add a couple cooler "cooler"/"muted" red and greens 2026-02-22 16:04:00 -05:00
Gud Boi bb81c74353 .ui.order_mode: multiline import styling 2026-02-22 16:03:42 -05:00
Gud Boi 7eaf28479c Fix `Qt6` types for new sub-namespaces 2026-02-22 16:03:19 -05:00
22 changed files with 702 additions and 671 deletions

View File

@ -729,6 +729,7 @@ class Router(Struct):
except ( except (
trio.ClosedResourceError, trio.ClosedResourceError,
trio.BrokenResourceError, trio.BrokenResourceError,
tractor.TransportClosed,
): ):
to_remove.add(client_stream) to_remove.add(client_stream)
log.warning( log.warning(
@ -1699,5 +1700,5 @@ async def _emsd_main(
if not client_streams: if not client_streams:
log.warning( log.warning(
f'Order dialog is not being monitored:\n' f'Order dialog is not being monitored:\n'
f'{oid} ->\n{client_stream._ctx.chan.uid}' f'{oid!r} <-> {client_stream.chan.aid.reprol()}\n'
) )

View File

@ -61,7 +61,8 @@ def load_trans_eps(
if ( if (
network network
and not maddrs and
not maddrs
): ):
# load network section and (attempt to) connect all endpoints # load network section and (attempt to) connect all endpoints
# which are reachable B) # which are reachable B)
@ -112,26 +113,19 @@ def load_trans_eps(
default=None, default=None,
help='Multiaddrs to bind or contact', help='Multiaddrs to bind or contact',
) )
# @click.option(
# '--tsdb',
# is_flag=True,
# help='Enable local ``marketstore`` instance'
# )
# @click.option(
# '--es',
# is_flag=True,
# help='Enable local ``elasticsearch`` instance'
# )
def pikerd( def pikerd(
maddr: list[str] | None, maddr: list[str] | None,
loglevel: str, loglevel: str,
tl: bool, tl: bool,
pdb: bool, pdb: bool,
# tsdb: bool,
# es: bool,
): ):
''' '''
Spawn the piker broker-daemon. Start the "root service actor", `pikerd`, run it until
cancellation.
This "root daemon" operates as the top most service-mngr and
subsys-as-subactor supervisor, think of it as the "init proc" of
any of any `piker` application or daemon-process tree.
''' '''
# from tractor.devx import maybe_open_crash_handler # from tractor.devx import maybe_open_crash_handler
@ -237,6 +231,14 @@ def cli(
regaddr: str, regaddr: str,
) -> None: ) -> None:
'''
The "root" `piker`-cmd CLI endpoint.
NOTE, this def generally relies on and requires a sub-cmd to be
provided by the user, OW only a `--help` msg (listing said
subcmds) will be dumped to console.
'''
if configdir is not None: if configdir is not None:
assert os.path.isdir(configdir), f"`{configdir}` is not a valid path" assert os.path.isdir(configdir), f"`{configdir}` is not a valid path"
config._override_config_dir(configdir) config._override_config_dir(configdir)
@ -295,17 +297,50 @@ def cli(
@click.option('--tl', is_flag=True, help='Enable tractor logging') @click.option('--tl', is_flag=True, help='Enable tractor logging')
@click.argument('ports', nargs=-1, required=False) @click.argument('ports', nargs=-1, required=False)
@click.pass_obj @click.pass_obj
def services(config, tl, ports): def services(
config,
tl: bool,
ports: list[int],
):
'''
List all `piker` "service deamons" to the console in
a `json`-table which maps each actor's UID in the form,
from ..service import ( `{service_name}.{subservice_name}.{UUID}`
to its (primary) IPC server address.
(^TODO, should be its multiaddr form once we support it)
Note that by convention actors which operate as "headless"
processes (those without GUIs/graphics, and which generally
parent some noteworthy subsystem) are normally suffixed by
a "d" such as,
- pikerd: the root runtime supervisor
- brokerd: a broker-backend order ctl daemon
- emsd: the internal dark-clearing and order routing daemon
- datad: a data-provider-backend data feed daemon
- samplerd: the real-time data sampling and clock-syncing daemon
"Headed units" are normally just given an obvious app-like name
with subactors indexed by `.` such as,
- chart: the primary modal charting iface, a Qt app
- chart.fsp_0: a financial-sig-proc cascade instance which
delivers graphics to a parent `chart` app.
- polars_boi: some (presumably) `polars` using console app.
'''
from piker.service import (
open_piker_runtime, open_piker_runtime,
_default_registry_port, _default_registry_port,
_default_registry_host, _default_registry_host,
) )
host = _default_registry_host # !TODO, mk this to work with UDS!
host: str = _default_registry_host
if not ports: if not ports:
ports = [_default_registry_port] ports: list[int] = [_default_registry_port]
addr = tractor._addr.wrap_address( addr = tractor._addr.wrap_address(
addr=(host, ports[0]) addr=(host, ports[0])
@ -336,7 +371,15 @@ def services(config, tl, ports):
def _load_clis() -> None: def _load_clis() -> None:
# from ..service import elastic # noqa '''
Dynamically load and register all subsys CLI endpoints (at call
time).
NOTE, obviously this is normally expected to be called at
`import` time and implicitly relies on our use of various
`click`/`typer` decorator APIs.
'''
from ..brokers import cli # noqa from ..brokers import cli # noqa
from ..ui import cli # noqa from ..ui import cli # noqa
from ..watchlists import cli # noqa from ..watchlists import cli # noqa
@ -346,5 +389,5 @@ def _load_clis() -> None:
from ..accounting import cli # noqa from ..accounting import cli # noqa
# load downstream cli modules # load all subsytem cli eps
_load_clis() _load_clis()

View File

@ -99,6 +99,7 @@ class Sampler:
trio.BrokenResourceError, trio.BrokenResourceError,
trio.ClosedResourceError, trio.ClosedResourceError,
trio.EndOfChannel, trio.EndOfChannel,
tractor.TransportClosed,
) )
# holds all the ``tractor.Context`` remote subscriptions for # holds all the ``tractor.Context`` remote subscriptions for
@ -291,9 +292,10 @@ class Sampler:
except self.bcast_errors as err: except self.bcast_errors as err:
log.error( log.error(
f'Connection dropped for IPC ctx\n' f'Connection dropped for IPC ctx due to,\n'
f'{stream._ctx}\n\n' f'{type(err)!r}\n'
f'Due to {type(err)}' f'\n'
f'{stream._ctx}'
) )
borked.add(stream) borked.add(stream)
else: else:
@ -741,7 +743,7 @@ async def sample_and_broadcast(
log.warning( log.warning(
f'Feed OVERRUN {sub_key}' f'Feed OVERRUN {sub_key}'
f'@{bus.brokername} -> \n' f'@{bus.brokername} -> \n'
f'feed @ {chan.uid}\n' f'feed @ {chan.aid.reprol()}\n'
f'throttle = {throttle} Hz' f'throttle = {throttle} Hz'
) )

View File

@ -31,6 +31,7 @@ from typing import (
AsyncContextManager, AsyncContextManager,
AsyncGenerator, AsyncGenerator,
Iterable, Iterable,
Type,
) )
import json import json
@ -67,7 +68,7 @@ class NoBsWs:
''' '''
# apparently we can QoS for all sorts of reasons..so catch em. # apparently we can QoS for all sorts of reasons..so catch em.
recon_errors = ( recon_errors: tuple[Type[Exception]] = (
ConnectionClosed, ConnectionClosed,
DisconnectionTimeout, DisconnectionTimeout,
ConnectionRejected, ConnectionRejected,
@ -105,7 +106,10 @@ class NoBsWs:
def connected(self) -> bool: def connected(self) -> bool:
return self._connected.is_set() return self._connected.is_set()
async def reset(self) -> None: async def reset(
self,
timeout: float,
) -> bool:
''' '''
Reset the underlying ws connection by cancelling Reset the underlying ws connection by cancelling
the bg relay task and waiting for it to signal the bg relay task and waiting for it to signal
@ -114,18 +118,31 @@ class NoBsWs:
''' '''
self._connected = trio.Event() self._connected = trio.Event()
self._cs.cancel() self._cs.cancel()
with trio.move_on_after(timeout) as cs:
await self._connected.wait() await self._connected.wait()
return True
assert cs.cancelled_caught
return False
async def send_msg( async def send_msg(
self, self,
data: Any, data: Any,
timeout: float = 3,
) -> None: ) -> None:
while True: while True:
try: try:
msg: Any = self._dumps(data) msg: Any = self._dumps(data)
return await self._ws.send_message(msg) return await self._ws.send_message(msg)
except self.recon_errors: except self.recon_errors:
await self.reset() with trio.CancelScope(shield=True):
reconnected: bool = await self.reset(
timeout=timeout,
)
if not reconnected:
log.warning(
'Failed to reconnect after {timeout!r}s ??'
)
async def recv_msg(self) -> Any: async def recv_msg(self) -> Any:
msg: Any = await self._rx.receive() msg: Any = await self._rx.receive()
@ -191,7 +208,9 @@ async def _reconnect_forever(
f'{src_mod}\n' f'{src_mod}\n'
f'{url} connection bail with:' f'{url} connection bail with:'
) )
with trio.CancelScope(shield=True):
await trio.sleep(0.5) await trio.sleep(0.5)
rent_cs.cancel() rent_cs.cancel()
# go back to reonnect loop in parent task # go back to reonnect loop in parent task
@ -291,6 +310,7 @@ async def _reconnect_forever(
log.exception( log.exception(
'Reconnect-attempt failed ??\n' 'Reconnect-attempt failed ??\n'
) )
with trio.CancelScope(shield=True):
await trio.sleep(0.2) # throttle await trio.sleep(0.2) # throttle
raise berr raise berr
@ -351,6 +371,7 @@ async def open_autorecon_ws(
rcv: trio.MemoryReceiveChannel rcv: trio.MemoryReceiveChannel
snd, rcv = trio.open_memory_channel(616) snd, rcv = trio.open_memory_channel(616)
try:
async with ( async with (
tractor.trionics.collapse_eg(), tractor.trionics.collapse_eg(),
trio.open_nursery() as tn trio.open_nursery() as tn
@ -378,6 +399,12 @@ async def open_autorecon_ws(
finally: finally:
tn.cancel_scope.cancel() tn.cancel_scope.cancel()
except NoBsWs.recon_errors as con_err:
log.warning(
f'Entire ws-channel disconnect due to,\n'
f'con_err: {con_err!r}\n'
)
''' '''
JSONRPC response-request style machinery for transparent multiplexing JSONRPC response-request style machinery for transparent multiplexing

View File

@ -62,7 +62,6 @@ from ._util import (
log, log,
get_console_log, get_console_log,
) )
from .flows import Flume
from .validate import ( from .validate import (
FeedInit, FeedInit,
validate_backend, validate_backend,
@ -77,6 +76,7 @@ from ._sampling import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from .flows import Flume
from tractor._addr import Address from tractor._addr import Address
from tractor.msg.types import Aid from tractor.msg.types import Aid
@ -362,6 +362,8 @@ async def allocate_persistent_feed(
) )
await some_data_ready.wait() await some_data_ready.wait()
# XXX, avoid cycle; it imports this mod.
from .flows import Flume
flume = Flume( flume = Flume(
# TODO: we have to use this for now since currently the # TODO: we have to use this for now since currently the
@ -500,7 +502,6 @@ async def open_feed_bus(
sub_registered = trio.Event() sub_registered = trio.Event()
flumes: dict[str, Flume] = {} flumes: dict[str, Flume] = {}
for symbol in symbols: for symbol in symbols:
# if no cached feed for this symbol has been created for this # if no cached feed for this symbol has been created for this
@ -684,6 +685,7 @@ class Feed(Struct):
''' '''
mods: dict[str, ModuleType] = {} mods: dict[str, ModuleType] = {}
portals: dict[ModuleType, tractor.Portal] = {} portals: dict[ModuleType, tractor.Portal] = {}
flumes: dict[ flumes: dict[
str, # FQME str, # FQME
Flume, Flume,
@ -951,6 +953,8 @@ async def open_feed(
assert len(feed.mods) == len(feed.portals) assert len(feed.mods) == len(feed.portals)
# XXX, avoid cycle; it imports this mod.
from .flows import Flume
async with ( async with (
trionics.gather_contexts(bus_ctxs) as ctxs, trionics.gather_contexts(bus_ctxs) as ctxs,
): ):

View File

@ -24,6 +24,7 @@ from functools import partial
from typing import ( from typing import (
AsyncIterator, AsyncIterator,
Callable, Callable,
TYPE_CHECKING,
) )
import numpy as np import numpy as np
@ -33,12 +34,12 @@ import tractor
from tractor.msg import NamespacePath from tractor.msg import NamespacePath
from piker.types import Struct from piker.types import Struct
from ..log import get_logger, get_console_log from ..log import (
from .. import data get_logger,
from ..data.feed import ( get_console_log,
Flume,
Feed,
) )
from .. import data
from ..data.flows import Flume
from ..data._sharedmem import ShmArray from ..data._sharedmem import ShmArray
from ..data._sampling import ( from ..data._sampling import (
_default_delay_s, _default_delay_s,
@ -52,6 +53,9 @@ from ._api import (
) )
from ..toolz import Profiler from ..toolz import Profiler
if TYPE_CHECKING:
from ..data.feed import Feed
log = get_logger(__name__) log = get_logger(__name__)
@ -169,8 +173,10 @@ class Cascade(Struct):
if not synced: if not synced:
fsp: Fsp = self.fsp fsp: Fsp = self.fsp
log.warning( log.warning(
'***DESYNCED FSP***\n' f'***DESYNCED fsp***\n'
f'{fsp.ns_path}@{src_shm.token}\n' f'------------------\n'
f'ns-path: {fsp.ns_path!r}\n'
f'shm-token: {src_shm.token}\n'
f'step_diff: {step_diff}\n' f'step_diff: {step_diff}\n'
f'len_diff: {len_diff}\n' f'len_diff: {len_diff}\n'
) )
@ -398,7 +404,6 @@ async def connect_streams(
@tractor.context @tractor.context
async def cascade( async def cascade(
ctx: tractor.Context, ctx: tractor.Context,
# data feed key # data feed key
@ -426,7 +431,17 @@ async def cascade(
) )
if loglevel: if loglevel:
get_console_log(loglevel) log = get_console_log(
loglevel,
name=__name__,
)
# XXX TODO!
# figure out why this writes a dict to,
# `tractor._state._runtime_vars['_root_mailbox']`
# XD .. wtf
# TODO, solve this as reported in,
# https://www.pikers.dev/pikers/piker/issues/70
# await tractor.pause()
src: Flume = Flume.from_msg(src_flume_addr) src: Flume = Flume.from_msg(src_flume_addr)
dst: Flume = Flume.from_msg( dst: Flume = Flume.from_msg(

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,18 @@ 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 ..log import get_logger from ..data._symcache import open_symcache
from ..data.feed import install_brokerd_search
from ..log import (
get_logger,
get_console_log,
)
from ..service import maybe_spawn_brokerd
from ._exec import run_qtractor
log = get_logger(__name__) log = get_logger(__name__)
@ -73,8 +76,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 +90,16 @@ 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.
""" """
# enable chart's console logging
if loglevel:
get_console_log(
level=loglevel,
name=__name__,
)
# 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 +214,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

@ -413,9 +413,18 @@ class Cursor(pg.GraphicsObject):
self, self,
item: pg.GraphicsObject, item: pg.GraphicsObject,
) -> None: ) -> None:
assert getattr(item, 'delete'), f"{item} must define a ``.delete()``" assert getattr(
item,
'delete',
), f"{item} must define a ``.delete()``"
self._hovered.add(item) self._hovered.add(item)
def is_hovered(
self,
item: pg.GraphicsObject,
) -> bool:
return item in self._hovered
def add_plot( def add_plot(
self, self,
plot: ChartPlotWidget, # noqa plot: ChartPlotWidget, # noqa

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

@ -45,7 +45,7 @@ from piker.ui.qt import QLineF
from ..data._sharedmem import ( from ..data._sharedmem import (
ShmArray, ShmArray,
) )
from ..data.feed import Flume from ..data.flows import Flume
from ..data._formatters import ( from ..data._formatters import (
IncrementalFormatter, IncrementalFormatter,
OHLCBarsFmtr, # Plain OHLC renderer OHLCBarsFmtr, # Plain OHLC renderer

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

@ -43,6 +43,7 @@ from pyqtgraph import (
functions as fn, functions as fn,
) )
import numpy as np import numpy as np
import tractor
import trio import trio
from piker.ui.qt import ( from piker.ui.qt import (
@ -72,7 +73,10 @@ if TYPE_CHECKING:
GodWidget, GodWidget,
) )
from ._dataviz import Viz from ._dataviz import Viz
from .order_mode import OrderMode from .order_mode import (
OrderMode,
Dialog,
)
from ._display import DisplayState from ._display import DisplayState
@ -130,7 +134,12 @@ async def handle_viewmode_kb_inputs(
async for kbmsg in recv_chan: async for kbmsg in recv_chan:
event, etype, key, mods, text = kbmsg.to_tuple() event, etype, key, mods, text = kbmsg.to_tuple()
log.debug(f'key: {key}, mods: {mods}, text: {text}') log.debug(
f'View-mode kb-msg received,\n'
f'mods: {mods!r}\n'
f'key: {key!r}\n'
f'text: {text!r}\n'
)
now = time.time() now = time.time()
period = now - last period = now - last
@ -158,8 +167,12 @@ async def handle_viewmode_kb_inputs(
# have no previous keys or we do and the min_tap period is # have no previous keys or we do and the min_tap period is
# met # met
if ( if (
not fast_key_seq or not fast_key_seq
period <= min_tap and fast_key_seq or (
period <= min_tap
and
fast_key_seq
)
): ):
fast_key_seq.append(text) fast_key_seq.append(text)
log.debug(f'fast keys seqs {fast_key_seq}') log.debug(f'fast keys seqs {fast_key_seq}')
@ -174,7 +187,8 @@ async def handle_viewmode_kb_inputs(
# UI REPL-shell, with ctrl-p (for "pause") # UI REPL-shell, with ctrl-p (for "pause")
if ( if (
ctrl ctrl
and key in { and
key in {
Qt.Key_P, Qt.Key_P,
} }
): ):
@ -184,7 +198,6 @@ async def handle_viewmode_kb_inputs(
vlm_chart = chart.linked.subplots['volume'] # noqa vlm_chart = chart.linked.subplots['volume'] # noqa
vlm_viz = vlm_chart.main_viz # noqa vlm_viz = vlm_chart.main_viz # noqa
dvlm_pi = vlm_chart._vizs['dolla_vlm'].plot # noqa dvlm_pi = vlm_chart._vizs['dolla_vlm'].plot # noqa
import tractor
await tractor.pause() await tractor.pause()
view.interact_graphics_cycle() view.interact_graphics_cycle()
@ -192,7 +205,8 @@ async def handle_viewmode_kb_inputs(
# shown data `Viz`s for the current chart app. # shown data `Viz`s for the current chart app.
if ( if (
ctrl ctrl
and key in { and
key in {
Qt.Key_R, Qt.Key_R,
} }
): ):
@ -231,7 +245,8 @@ async def handle_viewmode_kb_inputs(
key == Qt.Key_Escape key == Qt.Key_Escape
or ( or (
ctrl ctrl
and key == Qt.Key_C and
key == Qt.Key_C
) )
): ):
# ctrl-c as cancel # ctrl-c as cancel
@ -242,17 +257,35 @@ async def handle_viewmode_kb_inputs(
# cancel order or clear graphics # cancel order or clear graphics
if ( if (
key == Qt.Key_C key == Qt.Key_C
or key == Qt.Key_Delete or
key == Qt.Key_Delete
): ):
# log.info('Handling <c> hotkey!')
try:
dialogs: list[Dialog] = order_mode.cancel_orders_under_cursor()
except BaseException:
log.exception('Failed to cancel orders !?\n')
await tractor.pause()
order_mode.cancel_orders_under_cursor() if not dialogs:
log.warning(
'No orders were cancelled?\n'
'Is there an order-line under the cursor?\n'
'If you think there IS your DE might be "hiding the mouse" before '
'we rx the keyboard input via Qt..\n'
'=> Check your DE and/or TWM settings to be sure! <=\n'
)
# ^TODO?, some way to detect if there's lines and
# the DE is cuckin with things?
# await tractor.pause()
# View modes # View modes
if ( if (
ctrl ctrl
and ( and (
key == Qt.Key_Equal key == Qt.Key_Equal
or key == Qt.Key_I or
key == Qt.Key_I
) )
): ):
view.wheelEvent( view.wheelEvent(
@ -264,7 +297,8 @@ async def handle_viewmode_kb_inputs(
ctrl ctrl
and ( and (
key == Qt.Key_Minus key == Qt.Key_Minus
or key == Qt.Key_O or
key == Qt.Key_O
) )
): ):
view.wheelEvent( view.wheelEvent(
@ -275,7 +309,8 @@ async def handle_viewmode_kb_inputs(
elif ( elif (
not ctrl not ctrl
and key == Qt.Key_R and
key == Qt.Key_R
): ):
# NOTE: seems that if we don't yield a Qt render # NOTE: seems that if we don't yield a Qt render
# cycle then the m4 downsampled curves will show here # cycle then the m4 downsampled curves will show here
@ -477,7 +512,8 @@ async def handle_viewmode_mouse(
# view.raiseContextMenu(event) # view.raiseContextMenu(event)
if ( if (
view.order_mode.active and view.order_mode.active
and
button == QtCore.Qt.LeftButton button == QtCore.Qt.LeftButton
): ):
# when in order mode, submit execution # when in order mode, submit execution
@ -781,7 +817,8 @@ class ChartView(ViewBox):
# Scale or translate based on mouse button # Scale or translate based on mouse button
if btn & ( if btn & (
QtCore.Qt.LeftButton | QtCore.Qt.MidButton QtCore.Qt.LeftButton
| QtCore.Qt.MidButton
): ):
# zoom y-axis ONLY when click-n-drag on it # zoom y-axis ONLY when click-n-drag on it
# if axis == 1: # if axis == 1:

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

@ -52,10 +52,13 @@ from ._anchors import (
from ..calc import humanize from ..calc import humanize
from ._label import Label from ._label import Label
from ._style import hcolor, _font from ._style import hcolor, _font
from ..log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from ._cursor import Cursor from ._cursor import Cursor
log = get_logger(__name__)
# TODO: probably worth investigating if we can # TODO: probably worth investigating if we can
# make .boundingRect() faster: # make .boundingRect() faster:
@ -347,7 +350,7 @@ class LevelLine(pg.InfiniteLine):
) -> None: ) -> None:
# TODO: enter labels edit mode # TODO: enter labels edit mode
print(f'double click {ev}') log.debug(f'double click {ev}')
def paint( def paint(
self, self,
@ -461,10 +464,19 @@ class LevelLine(pg.InfiniteLine):
# hovered # hovered
if ( if (
not ev.isExit() not ev.isExit()
and ev.acceptDrags(QtCore.Qt.LeftButton) and
ev.acceptDrags(QtCore.Qt.LeftButton)
): ):
# if already hovered we don't need to run again # if already hovered we don't need to run again
if self.mouseHovering is True: if (
self.mouseHovering is True
and
cur.is_hovered(self)
):
log.debug(
f'Already hovering ??\n'
f'cur._hovered: {cur._hovered!r}\n'
)
return return
if self.only_show_markers_on_hover: if self.only_show_markers_on_hover:
@ -481,6 +493,7 @@ class LevelLine(pg.InfiniteLine):
cur._y_label_update = False cur._y_label_update = False
# add us to cursor state # add us to cursor state
log.debug(f'Adding line {self!r}\n')
cur.add_hovered(self) cur.add_hovered(self)
if self._hide_xhair_on_hover: if self._hide_xhair_on_hover:
@ -508,6 +521,7 @@ class LevelLine(pg.InfiniteLine):
self.currentPen = self.pen self.currentPen = self.pen
log.debug(f'Removing line {self!r}\n')
cur._hovered.remove(self) cur._hovered.remove(self)
if self.only_show_markers_on_hover: if self.only_show_markers_on_hover:

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,
@ -71,7 +77,6 @@ from ._style import _font
from ._forms import open_form_input_handling from ._forms import open_form_input_handling
from ._notify import notify_from_ems_status_msg from ._notify import notify_from_ems_status_msg
if TYPE_CHECKING: if TYPE_CHECKING:
from ._chart import ( from ._chart import (
ChartPlotWidget, ChartPlotWidget,
@ -430,7 +435,7 @@ class OrderMode:
lines=lines, lines=lines,
last_status_close=self.multistatus.open_status( last_status_close=self.multistatus.open_status(
f'submitting {order.exec_mode}-{order.action}', f'submitting {order.exec_mode}-{order.action}',
final_msg=f'submitted {order.exec_mode}-{order.action}', # final_msg=f'submitted {order.exec_mode}-{order.action}',
clear_on_next=True, clear_on_next=True,
) )
) )
@ -652,7 +657,7 @@ class OrderMode:
return True return True
def cancel_orders_under_cursor(self) -> list[str]: def cancel_orders_under_cursor(self) -> list[Dialog]:
return self.cancel_orders( return self.cancel_orders(
self.oids_from_lines( self.oids_from_lines(
self.lines.lines_under_cursor() self.lines.lines_under_cursor()
@ -681,24 +686,28 @@ class OrderMode:
self, self,
oids: list[str], oids: list[str],
) -> None: ) -> list[Dialog]:
''' '''
Cancel all orders from a list of order ids: `oids`. Cancel all orders from a list of order ids: `oids`.
''' '''
key = self.multistatus.open_status( # key = self.multistatus.open_status(
f'cancelling {len(oids)} orders', # f'cancelling {len(oids)} orders',
final_msg=f'cancelled orders:\n{oids}', # final_msg=f'cancelled orders:\n{oids}',
group_key=True # group_key=True
) # )
dialogs: list[Dialog] = []
for oid in oids: for oid in oids:
if dialog := self.dialogs.get(oid): if dialog := self.dialogs.get(oid):
self.client.cancel_nowait(uuid=oid) self.client.cancel_nowait(uuid=oid)
cancel_status_close = self.multistatus.open_status( # cancel_status_close = self.multistatus.open_status(
f'cancelling order {oid}', # f'cancelling order {oid}',
group_key=key, # group_key=key,
) # )
dialog.last_status_close = cancel_status_close # dialog.last_status_close = cancel_status_close
dialogs.append(dialog)
return dialogs
def cancel_all_orders(self) -> None: def cancel_all_orders(self) -> None:
''' '''
@ -770,7 +779,6 @@ class OrderMode:
@asynccontextmanager @asynccontextmanager
async def open_order_mode( async def open_order_mode(
feed: Feed, feed: Feed,
godw: GodWidget, godw: GodWidget,
fqme: str, fqme: str,