Compare commits

..

No commits in common. "datad_service" and "main" have entirely different histories.

77 changed files with 1936 additions and 3684 deletions

View File

@ -3,8 +3,7 @@
"allow": [ "allow": [
"Bash(chmod:*)", "Bash(chmod:*)",
"Bash(/tmp/piker_commits.txt)", "Bash(/tmp/piker_commits.txt)",
"Bash(python:*)", "Bash(python:*)"
"Bash(ls:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -32,14 +32,7 @@ option.log.disabled = true
[kraken] [kraken]
# the reference fiat asset as can be set key_descr = ''
# in an account's web-trading-UI prefs.
src_fiat = 'usd'
# NOTE for account defs, the following
# lines must match as follows.
accounts.spot = 'spot'
key_descr = 'spot'
api_key = '' api_key = ''
secret = '' secret = ''
# ------ kraken ------ # ------ kraken ------

View File

@ -1,26 +1,8 @@
[network] [network]
pikerd = [ pikerd = [
# TCP localhost loopback '/ipv4/127.0.0.1/tcp/6116', # std localhost daemon-actor tree
'/ip4/127.0.0.1/tcp/6116', # '/uds/6116', # TODO std uds socket file
# same but UDS
'/unix/run/user/1000/piker/pikerd.sock',
] ]
chart = [
'/ip4/127.0.0.1/tcp/3003',
'/unix/run/user/1000/piker/chart.sock',
]
# the service-actor registry endpoint;
# other pikerd trees contact this to discover
# actors.
# XXX if absent, pikerd binds the registry
# on its own tpt_bind_addrs.
# regd = ['/ip4/127.0.0.1/tcp/6116']
# chart = [
# '/ip4/127.0.0.1/tcp/3333',
# '/unix/run/user/1000/piker/chart@3333.sock',
# ]
[ui] [ui]

View File

@ -324,13 +324,10 @@ def load_ledger(
ldir: Path = ( ldir: Path = (
dirpath dirpath
or or
config.get_conf_dir() / 'accounting' / 'ledgers' config._config_dir / 'accounting' / 'ledgers'
) )
if not ldir.is_dir(): if not ldir.is_dir():
ldir.mkdir( ldir.mkdir()
parents=True,
exist_ok=True,
)
fname = f'trades_{brokername}_{acctid}.toml' fname = f'trades_{brokername}_{acctid}.toml'
fpath: Path = ldir / fname fpath: Path = ldir / fname

View File

@ -785,16 +785,9 @@ def load_account(
legacy_fn: str = f'pps.{brokername}.{acctid}.toml' legacy_fn: str = f'pps.{brokername}.{acctid}.toml'
fn: str = f'account.{brokername}.{acctid}.toml' fn: str = f'account.{brokername}.{acctid}.toml'
dirpath: Path = ( dirpath: Path = dirpath or (config._config_dir / 'accounting')
dirpath
or
(config.get_conf_dir() / 'accounting')
)
if not dirpath.is_dir(): if not dirpath.is_dir():
dirpath.mkdir( dirpath.mkdir()
parents=True,
exist_ok=True,
)
conf, path = config.load( conf, path = config.load(
path=dirpath / fn, path=dirpath / fn,

View File

@ -143,15 +143,12 @@ def sync(
# (what the EMS normally does internall) B) # (what the EMS normally does internall) B)
open_brokerd_dialog( open_brokerd_dialog(
brokermod, brokermod,
portal,
exec_mode=( exec_mode=(
'paper' 'paper'
if account == 'paper' if account == 'paper'
else 'live' else 'live'
), ),
# use our own ad-hoc-spawned actor,
# do NOT (spawn and) use the
# `brokerd.<broker>` service daemon!
portal=portal,
loglevel=loglevel, loglevel=loglevel,
) as ( ) as (
brokerd_stream, brokerd_stream,

View File

@ -31,7 +31,6 @@ from piker.log import (
from ._util import ( from ._util import (
BrokerError, BrokerError,
SymbolNotFound, SymbolNotFound,
MarketNotFound as MarketNotFound,
NoData, NoData,
DataUnavailable, DataUnavailable,
DataThrottle, DataThrottle,

View File

@ -25,8 +25,10 @@ from contextlib import (
) )
from types import ModuleType from types import ModuleType
from typing import ( from typing import (
TYPE_CHECKING,
AsyncContextManager, AsyncContextManager,
) )
import exceptiongroup as eg
import tractor import tractor
import trio import trio
@ -38,20 +40,27 @@ from piker.log import (
from . import _util from . import _util
from . import get_brokermod from . import get_brokermod
if TYPE_CHECKING:
from ..data import _FeedsBus
log = get_logger(name=__name__) log = get_logger(name=__name__)
# `brokerd`-actor-always-enabled mods. # `brokerd` enabled modules
# TODO: move this def to the `.data` subpkg..
# NOTE: keeping this list as small as possible is part of our caps-sec # NOTE: keeping this list as small as possible is part of our caps-sec
# model and should be treated with utmost care! In particular NO # model and should be treated with utmost care!
# `piker.data.*` feed mods should be enabled in this (live, _data_mods: str = [
# credentialed) trading actor; all data-feed serving is the 'piker.brokers.core',
# domain of the `datad.<broker>` sibling daemon, see 'piker.brokers.data',
# `piker.data._daemon._datad_service_mods`.
_brokerd_service_mods: list[str] = [
'piker.brokers._daemon', 'piker.brokers._daemon',
'piker.data',
'piker.data.feed',
'piker.data._sampling'
] ]
# TODO: we should rename the daemon to datad prolly once we split up
# broker vs. data tasks into separate actors?
@tractor.context @tractor.context
async def _setup_persistent_brokerd( async def _setup_persistent_brokerd(
ctx: tractor.Context, ctx: tractor.Context,
@ -60,15 +69,9 @@ async def _setup_persistent_brokerd(
) -> None: ) -> None:
''' '''
Trading-only daemon (lifetime) fixture: console logging Allocate a actor-wide service nursery in ``brokerd``
setup and a pinned-open context for service mgmt. such that feeds can be run in the background persistently by
the broker backend as needed.
All data-feed-bus state now lives in the (data-feed-only)
`datad.<brokername>` sibling daemon, see
`piker.data._daemon._setup_persistent_datad()`; this
actor hosts only the backend's `open_trade_dialog()`
(live order-control) ep-task(s) which manage their own
task trees per `tractor.Context`.
''' '''
# NOTE: we only need to setup logging once (and only) here # NOTE: we only need to setup logging once (and only) here
@ -84,13 +87,47 @@ async def _setup_persistent_brokerd(
) )
assert log.name == _util.subsys assert log.name == _util.subsys
# further, set the log level on any broker broker specific
# logger instance.
from piker.data import feed
assert not feed._bus
# allocate a nursery to the bus for spawning background
# tasks to service client IPC requests, normally
# `tractor.Context` connections to explicitly required
# `brokerd` endpoints such as:
# - `stream_quotes()`,
# - `manage_history()`,
# - `allocate_persistent_feed()`,
# - `open_symbol_search()`
# NOTE: see ep invocation details inside `.data.feed`.
try:
async with (
# tractor.trionics.collapse_eg(),
trio.open_nursery() as service_nursery
):
bus: _FeedsBus = feed.get_feed_bus(
brokername,
service_nursery,
)
assert bus is feed._bus
# unblock caller # unblock caller
await ctx.started() await ctx.started()
# we pin this task to keep the daemon active until the # we pin this task to keep the feeds manager active until the
# parent actor decides to tear it down # parent actor decides to tear it down
await trio.sleep_forever() await trio.sleep_forever()
except eg.ExceptionGroup:
# TODO: likely some underlying `brokerd` IPC connection
# broke so here we handle a respawn and re-connect attempt!
# This likely should pair with development of the OCO task
# nusery in dev over @ `tractor` B)
# https://github.com/goodboy/tractor/pull/363
raise
def broker_init( def broker_init(
brokername: str, brokername: str,
@ -110,10 +147,8 @@ def broker_init(
This includes: This includes:
- load the appropriate <brokername>.py pkg module, - load the appropriate <brokername>.py pkg module,
- reads any declared `_brokerd_mods: list[str]` (falling - reads any declared `__enable_modules__: listr[str]` which will be
back to the full `__enable_modules__` set for passed to `tractor.ActorNursery.start_actor(enabled_modules=<this>)`
not-yet-split backends) which will be passed to
`tractor.ActorNursery.start_actor(enable_modules=)`
at actor start time, at actor start time,
- deliver a references to the daemon lifetime fixture, which - deliver a references to the daemon lifetime fixture, which
for now is always the `_setup_persistent_brokerd()` context defined for now is always the `_setup_persistent_brokerd()` context defined
@ -147,15 +182,9 @@ def broker_init(
modpath, modpath,
] ]
for submodname in getattr( for submodname in getattr(
brokermod,
'_brokerd_mods',
# fallback for (flat, less mature) backends which
# don't yet declare a daemon-kind mod split.
getattr(
brokermod, brokermod,
'__enable_modules__', '__enable_modules__',
[], [],
),
): ):
subpath: str = f'{modpath}.{submodname}' subpath: str = f'{modpath}.{submodname}'
enabled.append(subpath) enabled.append(subpath)
@ -183,22 +212,6 @@ async def spawn_brokerd(
f'backend: {brokername!r}' f'backend: {brokername!r}'
) )
# fail fast on (data-only) backends which don't offer
# ANY live order-control eps; the caller should instead
# be using paper-mode (and thus never spawning us)!
from ..data.validate import get_eps
brokerd_eps: dict = get_eps(
get_brokermod(brokername),
'brokerd',
)
if not brokerd_eps:
raise RuntimeError(
f'Backend {brokername!r} offers NO `brokerd` '
f'(live order-control) eps!?\n'
f'It is likely a datad-only provider, use '
f'paper-mode for clearing instead.\n'
)
( (
brokermode, brokermode,
tractor_kwargs, tractor_kwargs,
@ -220,11 +233,7 @@ async def spawn_brokerd(
dname: str = tractor_kwargs.pop('name') # f'brokerd.{brokername}' dname: str = tractor_kwargs.pop('name') # f'brokerd.{brokername}'
portal = await Services.actor_n.start_actor( portal = await Services.actor_n.start_actor(
dname, dname,
enable_modules=list(dict.fromkeys( enable_modules=_data_mods + tractor_kwargs.pop('enable_modules'),
_brokerd_service_mods
+
tractor_kwargs.pop('enable_modules')
)),
debug_mode=Services.debug_mode, debug_mode=Services.debug_mode,
**tractor_kwargs **tractor_kwargs
) )

View File

@ -20,17 +20,10 @@ Handy cross-broker utils.
""" """
from __future__ import annotations from __future__ import annotations
# from functools import partial # from functools import partial
from typing import (
Type,
)
import json import json
import httpx import httpx
import logging import logging
from msgspec import Struct
from tractor._exceptions import (
reg_err_types,
)
from piker.log import ( from piker.log import (
colorize_json, colorize_json,
@ -66,10 +59,6 @@ class SymbolNotFound(BrokerError):
"Symbol not found by broker search" "Symbol not found by broker search"
class MarketNotFound(SymbolNotFound):
"Mkt-pair not found by broker search"
# TODO: these should probably be moved to `.tsp/.data`? # TODO: these should probably be moved to `.tsp/.data`?
class NoData(BrokerError): class NoData(BrokerError):
''' '''
@ -108,19 +97,6 @@ class DataThrottle(BrokerError):
''' '''
# TODO: add in throttle metrics/feedback # TODO: add in throttle metrics/feedback
class SchemaMismatch(BrokerError):
'''
Market `Pair` fields mismatch, likely due to provider API update.
'''
# auto-register all `BrokerError` subtypes for
# tractor IPC exc-marshalling.
reg_err_types([
BrokerError,
*BrokerError.__subclasses__(),
])
def resproc( def resproc(
resp: httpx.Response, resp: httpx.Response,
@ -147,45 +123,3 @@ def resproc(
log.debug(f"Received json contents:\n{colorize_json(msg)}") log.debug(f"Received json contents:\n{colorize_json(msg)}")
return msg if return_json else resp return msg if return_json else resp
def get_or_raise_on_pair_schema_mismatch(
pair_type: Type[Struct],
fields_data: dict,
provider_name: str,
api_url: str|None = None,
) -> Struct:
'''
Boilerplate helper around assset-`Pair` field schema mismatches,
normally due to provider API updates.
'''
try:
pair: Struct = pair_type(**fields_data)
return pair
except TypeError as err:
from tractor.devx.pformat import ppfmt
repr_data: str = ppfmt(fields_data)
report: str = (
f'Field mismatch we need to codify!\n'
f'\n'
f'{pair_type!r}({repr_data})'
f'\n'
f'^^^ {err.args[0]!r} ^^^\n'
f'\n'
f"Don't panic, prolly {provider_name!r} "
f"changed their symbology schema..\n"
)
if (
api_url
or
(api_url := pair_type._api_url)
):
report += (
f'\n'
f'Check out their API docs here:\n'
f'{api_url}\n'
)
raise SchemaMismatch(report) from err

View File

@ -52,25 +52,9 @@ __all__ = [
] ]
# per-daemon-kind (sub)mod groups: declares which of our # `brokerd` modules
# submods host the eps run by each daemon-actor kind as __enable_modules__: list[str] = [
# defined by `piker.data.validate._eps`.
# NOTE: `get_mkt_info` and `open_symbol_search` both live
# in `.feed` for this backend (no `symbols.py`).
_brokerd_mods: list[str] = [
'api',
'broker',
]
_datad_mods: list[str] = [
'api', 'api',
'feed', 'feed',
'broker',
] ]
# tractor RPC enable arg
__enable_modules__: list[str] = list(dict.fromkeys(
_brokerd_mods
+
_datad_mods
))

View File

@ -49,9 +49,6 @@ from piker import config
from piker.clearing._messages import ( from piker.clearing._messages import (
Order, Order,
) )
from piker.brokers._util import (
get_or_raise_on_pair_schema_mismatch,
)
from piker.accounting import ( from piker.accounting import (
Asset, Asset,
digits_to_dec, digits_to_dec,
@ -373,12 +370,20 @@ class Client:
item['filters'] = filters item['filters'] = filters
pair_type: Type = PAIRTYPES[venue] pair_type: Type = PAIRTYPES[venue]
pair: Pair = get_or_raise_on_pair_schema_mismatch( try:
pair_type=pair_type, pair: Pair = pair_type(**item)
fields_data=item, except Exception as e:
provider_name='binance', e.add_note(
api_url='https://binance-docs.github.io/apidocs/spot/en/#exchange-information', f'\n'
f'New or removed field we need to codify!\n'
f'pair-type: {pair_type!r}\n'
f'\n'
f"Don't panic, prolly stupid binance changed their symbology schema again..\n"
f'Check out their API docs here:\n'
f'\n'
f'https://binance-docs.github.io/apidocs/spot/en/#exchange-information\n'
) )
raise
pair_table[pair.symbol.upper()] = pair pair_table[pair.symbol.upper()] = pair
# update an additional top-level-cross-venue-table # update an additional top-level-cross-venue-table
@ -576,8 +581,8 @@ class Client:
self, self,
mkt: MktPair, mkt: MktPair,
start_dt: datetime|None = None, start_dt: datetime | None = None,
end_dt: datetime|None = None, end_dt: datetime | None = None,
as_np: bool = True, as_np: bool = True,
@ -604,11 +609,7 @@ class Client:
start_time = binance_timestamp(start_dt) start_time = binance_timestamp(start_dt)
end_time = binance_timestamp(end_dt) end_time = binance_timestamp(end_dt)
import tractor bs_pair: Pair = self._pairs[mkt.bs_fqme.upper()]
with tractor.devx.maybe_open_crash_handler():
bs_pair: Pair = self._pairs[
mkt.bs_fqme.upper()
]
# https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data # https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data
bars = await self._api( bars = await self._api(

View File

@ -48,7 +48,6 @@ import tractor
from piker.brokers import ( from piker.brokers import (
open_cached_client, open_cached_client,
NoData, NoData,
MarketNotFound,
) )
from piker._cacheables import ( from piker._cacheables import (
async_lifo_cache, async_lifo_cache,
@ -204,13 +203,9 @@ async def stream_messages(
yield 'trade', piker_quote yield 'trade', piker_quote
def make_sub( def make_sub(pairs: list[str], sub_name: str, uid: int) -> dict[str, str]:
pairs: list[str],
sub_name: str,
uid: int,
) -> dict[str, str]:
''' '''
Create a request subscription packet `dict`. Create a request subscription packet dict.
- spot: - spot:
https://binance-docs.github.io/apidocs/spot/en/#live-subscribing-unsubscribing-to-streams https://binance-docs.github.io/apidocs/spot/en/#live-subscribing-unsubscribing-to-streams
@ -306,10 +301,6 @@ async def get_mkt_info(
# uppercase since kraken bs_mktid is always upper # uppercase since kraken bs_mktid is always upper
if 'binance' not in fqme.lower(): if 'binance' not in fqme.lower():
log.warning(
f'Missing `.<provider>` part in fqme ??\n'
f'fqme: {fqme!r}\n'
)
fqme += '.binance' fqme += '.binance'
mkt_mode: str = '' mkt_mode: str = ''
@ -324,24 +315,6 @@ async def get_mkt_info(
venue: str = venue.upper() venue: str = venue.upper()
venue_lower: str = venue.lower() venue_lower: str = venue.lower()
if not venue:
if expiry:
expiry = f'.{expiry}'
expected: str = (
f'{mkt_ep}'
f'.<venue>'
f'{expiry}'
f'.{broker}'
)
raise MarketNotFound(
f'Invalid or missing .<venue> part in fqme?\n'
f'\n'
f'fqme: {fqme!r}\n'
f'expected-form>> {expected}\n'
f'\n'
f'Maybe you are missing a ".spot." ?\n'
)
# XXX TODO: we should change the usdtm_futes name to just # XXX TODO: we should change the usdtm_futes name to just
# usdm_futes (dropping the tether part) since it turns out that # usdm_futes (dropping the tether part) since it turns out that
# there are indeed USD-tokens OTHER THEN tether being used as # there are indeed USD-tokens OTHER THEN tether being used as
@ -359,8 +332,7 @@ async def get_mkt_info(
# TODO: handle coinm futes which have a margin asset that # TODO: handle coinm futes which have a margin asset that
# is some crypto token! # is some crypto token!
# https://binance-docs.github.io/apidocs/delivery/en/#exchange-information # https://binance-docs.github.io/apidocs/delivery/en/#exchange-information
or or 'btc' in venue_lower
'btc' in venue_lower
): ):
return None return None
@ -371,21 +343,16 @@ async def get_mkt_info(
if ( if (
venue venue
and and 'spot' not in venue_lower
'spot' not in venue_lower
# XXX: catch all in case user doesn't know which # XXX: catch all in case user doesn't know which
# venue they want (usdtm vs. coinm) and we can choose # venue they want (usdtm vs. coinm) and we can choose
# a default (via config?) once we support coin-m APIs. # a default (via config?) once we support coin-m APIs.
or or 'perp' in venue_lower
'perp' in venue_lower
): ):
if not mkt_mode: if not mkt_mode:
mkt_mode: str = f'{venue_lower}_futes' mkt_mode: str = f'{venue_lower}_futes'
# tracing
# await tractor.pause()
async with open_cached_client( async with open_cached_client(
'binance', 'binance',
) as client: ) as client:

View File

@ -20,7 +20,6 @@ Per market data-type definitions and schemas types.
""" """
from __future__ import annotations from __future__ import annotations
from typing import ( from typing import (
ClassVar,
Literal, Literal,
) )
from decimal import Decimal from decimal import Decimal
@ -204,8 +203,6 @@ class FutesPair(Pair):
# NOTE: see `.data._symcache.SymbologyCache.load()` for why # NOTE: see `.data._symcache.SymbologyCache.load()` for why
ns_path: str = 'piker.brokers.binance:FutesPair' ns_path: str = 'piker.brokers.binance:FutesPair'
_api_url: ClassVar[str] = 'https://binance-docs.github.io/apidocs/spot/en/#exchange-information'
# NOTE: for compat with spot pairs and `MktPair.src: Asset` # NOTE: for compat with spot pairs and `MktPair.src: Asset`
# processing.. # processing..
@property @property

View File

@ -35,7 +35,7 @@ from piker.log import (
get_logger, get_logger,
) )
from ..service import ( from ..service import (
maybe_spawn_datad, maybe_spawn_brokerd,
maybe_open_pikerd, maybe_open_pikerd,
) )
from ..brokers import ( from ..brokers import (
@ -187,7 +187,7 @@ def brokercheck(config, broker):
''' '''
async def bcheck_main(): async def bcheck_main():
async with maybe_spawn_datad(broker) as portal: async with maybe_spawn_brokerd(broker) as portal:
await portal.run(run_test, broker) await portal.run(run_test, broker)
await portal.cancel_actor() await portal.cancel_actor()
@ -317,7 +317,7 @@ def record(config, rate, name, dhost, filename):
return return
async def main(tries): async def main(tries):
async with maybe_spawn_datad( async with maybe_spawn_brokerd(
tries=tries, loglevel=loglevel tries=tries, loglevel=loglevel
) as portal: ) as portal:
# run app "main" # run app "main"

View File

@ -30,7 +30,7 @@ import trio
from piker.log import get_logger from piker.log import get_logger
from . import get_brokermod from . import get_brokermod
from ..service import maybe_spawn_datad from ..service import maybe_spawn_brokerd
from . import open_cached_client from . import open_cached_client
from ..accounting import MktPair from ..accounting import MktPair
@ -172,7 +172,7 @@ async def symbol_search(
# await tractor.devx._debug.maybe_init_greenback() # await tractor.devx._debug.maybe_init_greenback()
# tractor.pause_from_sync() # tractor.pause_from_sync()
async with maybe_spawn_datad( async with maybe_spawn_brokerd(
mod.name, mod.name,
infect_asyncio=getattr( infect_asyncio=getattr(
mod, mod,

View File

@ -425,7 +425,7 @@ class DataFeed:
async def stream_to_file( async def stream_to_file(
watchlist_name: str, watchlist_name: str,
filename: str, filename: str,
portal: tractor.Portal, portal: tractor._portal.Portal,
tickers: List[str], tickers: List[str],
brokermod: ModuleType, brokermod: ModuleType,
rate: int, rate: int,

View File

@ -47,25 +47,13 @@ __all__ = [
] ]
# per-daemon-kind (sub)mod groups: declares which of our # tractor RPC enable arg
# submods host the eps run by each daemon-actor kind as __enable_modules__: list[str] = [
# defined by `piker.data.validate._eps`.
# NOTE: datad-only backend (no `broker.py` yet)!
_brokerd_mods: list[str] = []
_datad_mods: list[str] = [
'api', 'api',
'feed', 'feed',
# 'broker',
] ]
# tractor RPC enable arg
__enable_modules__: list[str] = list(dict.fromkeys(
_brokerd_mods
+
_datad_mods
))
# passed to ``tractor.ActorNursery.start_actor()`` # passed to ``tractor.ActorNursery.start_actor()``
_spawn_kwargs = { _spawn_kwargs = {
'infect_asyncio': True, 'infect_asyncio': True,

View File

@ -23,6 +23,7 @@ from contextlib import (
asynccontextmanager as acm, asynccontextmanager as acm,
) )
from datetime import datetime from datetime import datetime
from functools import partial
import time import time
from typing import ( from typing import (
Any, Any,
@ -37,7 +38,7 @@ from rapidfuzz import process as fuzzy
import numpy as np import numpy as np
from tractor.trionics import ( from tractor.trionics import (
broadcast_receiver, broadcast_receiver,
maybe_open_context, maybe_open_context
collapse_eg, collapse_eg,
) )
from tractor import to_asyncio from tractor import to_asyncio
@ -523,12 +524,13 @@ async def maybe_open_feed_handler() -> trio.abc.ReceiveStream:
async def aio_price_feed_relay( async def aio_price_feed_relay(
chan: to_asyncio.LinkedTaskChannel,
fh: FeedHandler, fh: FeedHandler,
instrument: Symbol, instrument: Symbol,
from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
) -> None: ) -> None:
async def _trade(data: dict, receipt_timestamp): async def _trade(data: dict, receipt_timestamp):
chan.send_nowait(('trade', { to_trio.send_nowait(('trade', {
'symbol': cb_sym_to_deribit_inst( 'symbol': cb_sym_to_deribit_inst(
str_to_cb_sym(data.symbol)).lower(), str_to_cb_sym(data.symbol)).lower(),
'last': data, 'last': data,
@ -538,7 +540,7 @@ async def aio_price_feed_relay(
})) }))
async def _l1(data: dict, receipt_timestamp): async def _l1(data: dict, receipt_timestamp):
chan.send_nowait(('l1', { to_trio.send_nowait(('l1', {
'symbol': cb_sym_to_deribit_inst( 'symbol': cb_sym_to_deribit_inst(
str_to_cb_sym(data.symbol)).lower(), str_to_cb_sym(data.symbol)).lower(),
'ticks': [ 'ticks': [
@ -568,7 +570,7 @@ async def aio_price_feed_relay(
install_signal_handlers=False) install_signal_handlers=False)
# sync with trio # sync with trio
chan.started_nowait(None) to_trio.send_nowait(None)
await asyncio.sleep(float('inf')) await asyncio.sleep(float('inf'))
@ -579,9 +581,11 @@ async def open_price_feed(
) -> trio.abc.ReceiveStream: ) -> trio.abc.ReceiveStream:
async with maybe_open_feed_handler() as fh: async with maybe_open_feed_handler() as fh:
async with to_asyncio.open_channel_from( async with to_asyncio.open_channel_from(
partial(
aio_price_feed_relay, aio_price_feed_relay,
fh=fh, fh,
instrument=instrument, instrument
)
) as (chan, first): ) as (chan, first):
yield chan yield chan
@ -607,9 +611,10 @@ async def maybe_open_price_feed(
async def aio_order_feed_relay( async def aio_order_feed_relay(
chan: to_asyncio.LinkedTaskChannel,
fh: FeedHandler, fh: FeedHandler,
instrument: Symbol, instrument: Symbol,
from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
) -> None: ) -> None:
async def _fill(data: dict, receipt_timestamp): async def _fill(data: dict, receipt_timestamp):
breakpoint() breakpoint()
@ -632,7 +637,7 @@ async def aio_order_feed_relay(
install_signal_handlers=False) install_signal_handlers=False)
# sync with trio # sync with trio
chan.started_nowait(None) to_trio.send_nowait(None)
await asyncio.sleep(float('inf')) await asyncio.sleep(float('inf'))
@ -643,9 +648,11 @@ async def open_order_feed(
) -> trio.abc.ReceiveStream: ) -> trio.abc.ReceiveStream:
async with maybe_open_feed_handler() as fh: async with maybe_open_feed_handler() as fh:
async with to_asyncio.open_channel_from( async with to_asyncio.open_channel_from(
partial(
aio_order_feed_relay, aio_order_feed_relay,
fh=fh, fh,
instrument=instrument, instrument
)
) as (chan, first): ) as (chan, first):
yield chan yield chan

View File

@ -65,18 +65,17 @@ _brokerd_mods: list[str] = [
] ]
_datad_mods: list[str] = [ _datad_mods: list[str] = [
'api',
'feed', 'feed',
'symbols', 'symbols',
] ]
# tractor RPC enable arg # tractor RPC enable arg
__enable_modules__: list[str] = list(dict.fromkeys( __enable_modules__: list[str] = (
_brokerd_mods _brokerd_mods
+ +
_datad_mods _datad_mods
)) )
# passed to ``tractor.ActorNursery.start_actor()`` # passed to ``tractor.ActorNursery.start_actor()``
_spawn_kwargs = { _spawn_kwargs = {

View File

@ -95,7 +95,6 @@ from .symbols import (
) )
from ...log import get_logger from ...log import get_logger
from .venues import ( from .venues import (
is_expired,
is_venue_open, is_venue_open,
sesh_times, sesh_times,
is_venue_closure, is_venue_closure,
@ -497,7 +496,7 @@ class Client:
await self.ib.reqContractDetailsAsync(contract) await self.ib.reqContractDetailsAsync(contract)
)[0] )[0]
# convert to makt-native tz # convert to makt-native tz
tz: str = details.timeZoneId or 'EST' tz: str = details.timeZoneId
end_dt = end_dt.in_tz(tz) end_dt = end_dt.in_tz(tz)
first_dt: DateTime = from_timestamp(first).in_tz(tz) first_dt: DateTime = from_timestamp(first).in_tz(tz)
last_dt: DateTime = from_timestamp(last).in_tz(tz) last_dt: DateTime = from_timestamp(last).in_tz(tz)
@ -509,18 +508,10 @@ class Client:
_open_now: bool = is_venue_open( _open_now: bool = is_venue_open(
con_deats=details, con_deats=details,
) )
_is_expired: bool = is_expired(
con_deats=details,
)
# XXX, do gap detections. # XXX, do gap detections.
has_closure_gap: bool = False has_closure_gap: bool = False
if ( if (
# XXX, expired tracts can't be introspected
# for open/closure intervals due to ib's chitty
# details seemingly..
not _is_expired
and
last_dt.add(seconds=sample_period_s) last_dt.add(seconds=sample_period_s)
< <
end_dt end_dt
@ -1340,21 +1331,6 @@ async def load_aio_clients(
ib = None ib = None
client = None client = None
# XXX: post (brokerd vs. datad)-split BOTH per-broker
# daemons connect to the same API gw/tws endpoint(s); to
# avoid `clientId` collisions (and the long conn-timeout
# retry cycle they cause) we offset the data-daemon's
# default id-range to be disjoint from `brokerd.ib`'s
# (which also retries with `client_id + i` increments).
if client_id == 6116: # the default from above
aname: str = tractor.current_actor().name
if 'datad' in aname:
client_id += 16
# ad-hoc (test/cli) actors get their own range to
# avoid clashing with any live daemon-tree's conns.
elif 'brokerd' not in aname:
client_id += 32
# attempt to get connection info from config; if no .toml entry # attempt to get connection info from config; if no .toml entry
# exists, we try to load from a default localhost connection. # exists, we try to load from a default localhost connection.
localhost = '127.0.0.1' localhost = '127.0.0.1'

View File

@ -231,21 +231,20 @@ async def handle_order_requests(
async def recv_trade_updates( async def recv_trade_updates(
chan: tractor.to_asyncio.LinkedTaskChannel,
client: Client, client: Client,
to_trio: trio.abc.SendChannel,
) -> None: ) -> None:
''' '''
Receive and relay order control and positioning Receive and relay order control and positioning related events
related events from `ib_async`, pack as tuples and from `ib_async`, pack as tuples and push over mem-chan to our
push over mem-chan to our trio relay task for trio relay task for processing and relay to EMS.
processing and relay to EMS.
''' '''
client.inline_errors(chan) client.inline_errors(to_trio)
# sync with trio task # sync with trio task
chan.started_nowait(client.ib) to_trio.send_nowait(client.ib)
def push_tradesies( def push_tradesies(
eventkit_obj, eventkit_obj,
@ -283,7 +282,7 @@ async def recv_trade_updates(
try: try:
# emit event name + relevant ibis internal objects # emit event name + relevant ibis internal objects
chan.send_nowait((event_name, emit)) to_trio.send_nowait((event_name, emit))
except trio.BrokenResourceError: except trio.BrokenResourceError:
log.exception(f'Disconnected from {eventkit_obj} updates') log.exception(f'Disconnected from {eventkit_obj} updates')
eventkit_obj.disconnect(push_tradesies) eventkit_obj.disconnect(push_tradesies)
@ -1163,9 +1162,9 @@ async def deliver_trade_events(
case 'execDetailsEvent': case 'execDetailsEvent':
# unpack attrs pep-0526 style. # unpack attrs pep-0526 style.
trade: Trade trade: Trade
con: Contract = trade.contract
fill: Fill fill: Fill
trade, fill = item trade, fill = item
con: Contract = trade.contract
execu: Execution = fill.execution execu: Execution = fill.execution
execid: str = execu.execId execid: str = execu.execId
report: CommissionReport = fill.commissionReport report: CommissionReport = fill.commissionReport
@ -1307,15 +1306,7 @@ async def deliver_trade_events(
elif isinstance(err, str): elif isinstance(err, str):
code_part, _, reason = err.rpartition(']') code_part, _, reason = err.rpartition(']')
if code_part: if code_part:
for prefix_patt in [ _, _, code = code_part.partition('[code')
'[Errno ',
'[code ',
]:
code_part, _, code = code_part.partition()
if code:
code = int(code)
break
reqid: str = '<unknown>' reqid: str = '<unknown>'
# "Warning:" msg codes, # "Warning:" msg codes,
@ -1334,8 +1325,6 @@ async def deliver_trade_events(
# XXX known special (ignore) cases # XXX known special (ignore) cases
elif code in { elif code in {
# ^TODO, if this is it we should definitely raise
# or at least provide for reconnect attempts?
200, # uhh.. ni idea 200, # uhh.. ni idea
# hist pacing / connectivity # hist pacing / connectivity
@ -1346,18 +1335,10 @@ async def deliver_trade_events(
# 'No market data during competing live session' # 'No market data during competing live session'
1669, 1669,
}: }:
pcc: str = "Peer closed connection."
if pcc in err:
# TODO, emit and raise?
# [ ] try for reconnect?
# raise BrokerdError(
await tractor.pause()
log.error( log.error(
f'Order-API-error which is non-cancel-causing ?!\n' f'Order-API-error which is non-cancel-causing ?!\n'
f'\n' f'\n'
f'code={code!r}\n' f'{pformat(err)}\n'
f'err={pformat(err)}\n'
) )
continue continue

View File

@ -501,7 +501,7 @@ async def update_ledger_from_api_trades(
for fill in fills: for fill in fills:
con: Contract = fill.contract con: Contract = fill.contract
conid: str = con.conId conid: str = con.conId
pexch: str|None = con.primaryExchange or con.exchange pexch: str | None = con.primaryExchange
if not pexch: if not pexch:
cons = await client.get_con(conid=conid) cons = await client.get_con(conid=conid)

View File

@ -33,21 +33,13 @@ from typing import (
) )
import exchange_calendars as xcals import exchange_calendars as xcals
from exchange_calendars.errors import (
InvalidCalendarName,
)
from pendulum import ( from pendulum import (
parse,
now, now,
Duration, Duration,
Interval, Interval,
Time, Time,
) )
from piker.log import get_logger
log = get_logger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from ib_async import ( from ib_async import (
TradingSession, TradingSession,
@ -64,22 +56,6 @@ if TYPE_CHECKING:
) )
def is_expired(
con_deats: ContractDetails,
) -> bool:
'''
Simple predicate whether the provided contract-deats match and
already lifetime-terminated instrument.
'''
expiry_str: str = con_deats.realExpirationDate
if not expiry_str:
return False
expiry_dt: datetime = parse(expiry_str)
return expiry_dt.date() >= now().date()
def has_weekend( def has_weekend(
period: Interval, period: Interval,
) -> bool: ) -> bool:
@ -114,28 +90,13 @@ def has_holiday(
con.exchange con.exchange
) )
# XXX, ad-hoc handle any IB exchange which are # XXX, ad-hoc handle any IB exchange which are non-std
# non-std via lookup table.. # via lookup table..
std_exch: str = { std_exch: dict = {
'ARCA': 'ARCX', 'ARCA': 'ARCX',
}.get(exch, exch) }.get(exch, exch)
try: cal: ExchangeCalendar = xcals.get_calendar(std_exch)
cal: ExchangeCalendar = xcals.get_calendar(
std_exch
)
except InvalidCalendarName:
# venue has no `exchange_calendars` entry
# (eg. IDEALPRO for forex, PAXOS for
# crypto) -> not a holiday by default since
# weekends are already handled by
# `has_weekend()`.
log.warning(
f'No exchange cal for {std_exch!r},'
f' skipping holiday check..\n'
)
return False
end: datetime = period.end end: datetime = period.end
# _start: datetime = period.start # _start: datetime = period.start
# ?TODO, can rm ya? # ?TODO, can rm ya?
@ -209,22 +170,7 @@ def sesh_times(
get the (day-agnostic) times for the start/end. get the (day-agnostic) times for the start/end.
''' '''
# ?TODO, lookup the next front contract instead? earliest_sesh: Interval = next(iter_sessions(con_deats))
if is_expired(con_deats):
raise ValueError(
f'Contract is already expired!\n'
f'Choose an active alt contract instead.\n'
f'con_deats: {con_deats!r}\n'
)
maybe_sessions: list[Interval] = list(iter_sessions(con_deats))
if not maybe_sessions:
raise ValueError(
f'Contract has no trading-session info?\n'
f'con_deats: {con_deats!r}\n'
)
earliest_sesh: Interval = maybe_sessions[0]
return ( return (
earliest_sesh.start.time(), earliest_sesh.start.time(),
earliest_sesh.end.time(), earliest_sesh.end.time(),
@ -265,13 +211,7 @@ def is_venue_closure(
''' '''
open: Time open: Time
close: Time close: Time
maybe_oc: tuple|None = sesh_times(con_deats) open, close = sesh_times(con_deats)
if maybe_oc is None:
# XXX, should never get here.
breakpoint()
return False
open, close = maybe_oc
# ensure times are in mkt-native timezone # ensure times are in mkt-native timezone
tz: str = con_deats.timeZoneId tz: str = con_deats.timeZoneId

View File

@ -66,24 +66,10 @@ __all__ = [
] ]
# per-daemon-kind (sub)mod groups: declares which of our # tractor RPC enable arg
# submods host the eps run by each daemon-actor kind as __enable_modules__: list[str] = [
# defined by `piker.data.validate._eps`.
_brokerd_mods: list[str] = [
'api', 'api',
'broker', 'broker',
]
_datad_mods: list[str] = [
'api',
'feed', 'feed',
'symbols', 'symbols',
] ]
# tractor RPC enable arg
__enable_modules__: list[str] = list(dict.fromkeys(
_brokerd_mods
+
_datad_mods
))

View File

@ -35,7 +35,6 @@ import hashlib
import hmac import hmac
import base64 import base64
import tractor import tractor
# from tractor._exceptions import reg_err_types
import trio import trio
from piker import config from piker import config
@ -53,7 +52,6 @@ from piker.brokers._util import (
SymbolNotFound, SymbolNotFound,
BrokerError, BrokerError,
DataThrottle, DataThrottle,
get_or_raise_on_pair_schema_mismatch,
) )
from piker.accounting import Transaction from piker.accounting import Transaction
from piker.log import get_logger from piker.log import get_logger
@ -109,37 +107,15 @@ def get_kraken_signature(
return sigdigest.decode() return sigdigest.decode()
# class InvalidKey(ValueError): class InvalidKey(ValueError):
# ''' '''
# EAPI:Invalid key EAPI:Invalid key
This error is returned when the API key used for the call is
either expired or disabled, please review the API key in your
Settings -> API tab of account management or generate a new one
and update your application.
# This error is returned when the API key used for the call is '''
# either expired or disabled, please review the API key in your
# Settings -> API tab of account management or generate a new one
# and update your application.
# '''
# class InvalidSession(RuntimeError):
# '''
# ESession:Invalid session
# This error is returned when the ws API key used for an authenticated
# sub/endpoint becomes stale, normally after a sufficient network
# disconnect/outage.
# Normally the sub will need to be restarted, likely re-init of the
# auth handshake sequence.
# '''
# subscription: dict
# reg_err_types([
# InvalidKey,
# InvalidSession,
# ])
class Client: class Client:
@ -167,16 +143,18 @@ class Client:
config: dict[str, str], config: dict[str, str],
httpx_client: httpx.AsyncClient, httpx_client: httpx.AsyncClient,
key_descr: str = '', name: str = '',
api_key: str = '', api_key: str = '',
secret: str = '' secret: str = ''
) -> None: ) -> None:
self._sesh: httpx.AsyncClient = httpx_client self._sesh: httpx.AsyncClient = httpx_client
self._key_descr = key_descr
self._name = name
self._api_key = api_key self._api_key = api_key
self._secret = secret self._secret = secret
self.conf: dict[str, str] = config self.conf: dict[str, str] = config
self._ws_token: str|None = None
@property @property
def pairs(self) -> dict[str, Pair]: def pairs(self) -> dict[str, Pair]:
@ -261,39 +239,6 @@ class Client:
return balances return balances
async def get_ws_token(
self,
params: dict = {},
force_renewal: bool = False,
) -> str:
'''
Get websocket token for authenticated data stream and cache
it for reuse.
Assert a value was actually received before return.
'''
if (
not force_renewal
and
self._ws_token
):
return self._ws_token
resp = await self.endpoint(
'GetWebSocketsToken',
{},
)
if err := resp.get('error'):
raise BrokerError(err)
# resp token for ws init
token: str = resp['result']['token']
assert token
self._ws_token: str = token
return token
async def get_assets( async def get_assets(
self, self,
reload: bool = False, reload: bool = False,
@ -557,16 +502,7 @@ class Client:
# NOTE: always cache in pairs tables for faster lookup # NOTE: always cache in pairs tables for faster lookup
with tractor.devx.maybe_open_crash_handler(): # as bxerr: with tractor.devx.maybe_open_crash_handler(): # as bxerr:
# pair = Pair(xname=xkey, **data) pair = Pair(xname=xkey, **data)
pair: Pair = get_or_raise_on_pair_schema_mismatch(
pair_type=Pair,
fields_data=dict(
xname=xkey,
**data,
),
provider_name='kraken',
# api_url='https://binance-docs.github.io/apidocs/spot/en/#exchange-information',
)
# register the above `Pair` structs for all # register the above `Pair` structs for all
# key-sets/monikers: a set of 4 (frickin) tables # key-sets/monikers: a set of 4 (frickin) tables
@ -732,13 +668,7 @@ class Client:
@acm @acm
async def get_client() -> Client: async def get_client() -> Client:
'''
Load and deliver a `.kraken.api.Client`.
When defined, inject any config delivered from the user's
`brokers.toml` config file.
'''
conf: dict[str, Any] = get_config() conf: dict[str, Any] = get_config()
async with httpx.AsyncClient( async with httpx.AsyncClient(
base_url=_url, base_url=_url,
@ -749,14 +679,13 @@ async def get_client() -> Client:
# connections=4 # connections=4
) as trio_client: ) as trio_client:
if conf: if conf:
api_key_descr: str = conf['key_descr']
client = Client( client = Client(
conf, conf,
httpx_client=trio_client, httpx_client=trio_client,
# TODO: don't break these up and just do internal # TODO: don't break these up and just do internal
# conf lookups instead.. # conf lookups instead..
key_descr=api_key_descr, name=conf['key_descr'],
api_key=conf['api_key'], api_key=conf['api_key'],
secret=conf['secret'] secret=conf['secret']
) )

View File

@ -30,15 +30,12 @@ from typing import (
Any, Any,
AsyncIterator, AsyncIterator,
Iterable, Iterable,
Type,
Union, Union,
) )
from bidict import bidict from bidict import bidict
import trio import trio
import tractor import tractor
from tractor.devx.pformat import ppfmt
# from tractor._exceptions import reg_err_types
from piker.accounting import ( from piker.accounting import (
Position, Position,
@ -48,9 +45,6 @@ from piker.accounting import (
open_trade_ledger, open_trade_ledger,
open_account, open_account,
) )
from piker.config import (
ConfigurationError,
)
from piker.clearing import( from piker.clearing import(
OrderDialogs, OrderDialogs,
) )
@ -73,12 +67,13 @@ from piker.log import (
get_logger, get_logger,
) )
from piker.data import open_symcache from piker.data import open_symcache
from piker.data._web_bs import ( from .api import (
Client,
BrokerError,
)
from .feed import (
open_autorecon_ws, open_autorecon_ws,
NoBsWs, NoBsWs,
)
from . import api
from .feed import (
stream_messages, stream_messages,
) )
from .ledger import ( from .ledger import (
@ -99,7 +94,11 @@ MsgUnion = Union[
] ]
# TODO: make this wrap the `api.Client` and `ws` instances class TooFastEdit(Exception):
'Edit requests faster then api submissions'
# TODO: make this wrap the `Client` and `ws` instances
# and give it methods to submit cancel vs. add vs. edit # and give it methods to submit cancel vs. add vs. edit
# requests? # requests?
class BrokerClient: class BrokerClient:
@ -127,22 +126,23 @@ class BrokerClient:
async def handle_order_requests( async def handle_order_requests(
ws: NoBsWs, ws: NoBsWs,
client: api.Client, client: Client,
ems_order_stream: tractor.MsgStream, ems_order_stream: tractor.MsgStream,
token: str,
apiflows: OrderDialogs, apiflows: OrderDialogs,
ids: bidict[str, int], ids: bidict[str, int],
reqids2txids: dict[int, str], reqids2txids: dict[int, str],
toofastedit: set[int],
) -> None: ) -> None:
''' '''
`trio.Task` which handles order ctl requests from the EMS and Process new order submission requests from the EMS
deliver acks or errors back on that IPC dialog. and deliver acks or errors.
''' '''
# XXX: UGH, let's unify this.. with ``msgspec``!!! # XXX: UGH, let's unify this.. with ``msgspec``!!!
msg: dict|Order msg: dict | Order
async for msg in ems_order_stream: async for msg in ems_order_stream:
log.info(f'Rx order msg:\n{pformat(msg)}') log.info(f'Rx order msg:\n{pformat(msg)}')
match msg: match msg:
@ -156,13 +156,8 @@ async def handle_order_requests(
txid = reqids2txids[reqid] txid = reqids2txids[reqid]
except KeyError: except KeyError:
# XXX: not sure if this block ever gets hit now? # XXX: not sure if this block ever gets hit now?
# SEEMS TO on the race case with the update task?
# - update dark order quickly after
# triggered-submitted and then we have inavlid
# value in `reqids2txids` sent over ws.send()??
log.error('TOO FAST CANCEL/EDIT') log.error('TOO FAST CANCEL/EDIT')
toofastedit.add(reqid) reqids2txids[reqid] = TooFastEdit(reqid)
reqids2txids[reqid] = reqid
await ems_order_stream.send( await ems_order_stream.send(
BrokerdError( BrokerdError(
oid=msg['oid'], oid=msg['oid'],
@ -178,7 +173,7 @@ async def handle_order_requests(
# https://docs.kraken.com/websockets/#message-cancelOrder # https://docs.kraken.com/websockets/#message-cancelOrder
await ws.send_msg({ await ws.send_msg({
'event': 'cancelOrder', 'event': 'cancelOrder',
'token': await client.get_ws_token(), 'token': token,
'reqid': reqid, 'reqid': reqid,
'txid': [txid], # should be txid from submission 'txid': [txid], # should be txid from submission
}) })
@ -190,7 +185,7 @@ async def handle_order_requests(
# validate # validate
order = BrokerdOrder(**msg) order = BrokerdOrder(**msg)
# logic from old `api.Client.submit_limit()` # logic from old `Client.submit_limit()`
if order.oid in ids: if order.oid in ids:
ep: str = 'editOrder' ep: str = 'editOrder'
reqid: int = ids[order.oid] # integer not txid reqid: int = ids[order.oid] # integer not txid
@ -200,15 +195,13 @@ async def handle_order_requests(
# XXX: not sure if this block ever gets hit now? # XXX: not sure if this block ever gets hit now?
log.error('TOO FAST EDIT') log.error('TOO FAST EDIT')
reqids2txids[reqid] = reqid reqids2txids[reqid] = TooFastEdit(reqid)
toofastedit.add(reqid)
await tractor.pause()
await ems_order_stream.send( await ems_order_stream.send(
BrokerdError( BrokerdError(
oid=msg['oid'], oid=msg['oid'],
symbol=msg['symbol'], symbol=msg['symbol'],
reason=( reason=(
f'TooFastEdit reqid: {reqid}, cancelling..' f'TooFastEdit reqid:{reqid}, cancelling..'
), ),
) )
@ -254,7 +247,7 @@ async def handle_order_requests(
# https://docs.kraken.com/websockets/#message-addOrder # https://docs.kraken.com/websockets/#message-addOrder
req = { req = {
'event': ep, 'event': ep,
'token': await client.get_ws_token(), 'token': token,
'reqid': reqid, # remapped-to-int uid from ems 'reqid': reqid, # remapped-to-int uid from ems
# XXX: we set these to the same value since for us # XXX: we set these to the same value since for us
@ -298,15 +291,13 @@ async def handle_order_requests(
symbol=msg['symbol'], symbol=msg['symbol'],
reason=( reason=(
'Invalid request msg:\n{msg}' 'Invalid request msg:\n{msg}'
), ))
)
) )
@acm @acm
async def subscribe( async def subscribe(
ws: NoBsWs, ws: NoBsWs,
client: api.Client,
token: str, token: str,
subs: list[tuple[str, dict]] = [ subs: list[tuple[str, dict]] = [
('ownTrades', { ('ownTrades', {
@ -325,25 +316,12 @@ async def subscribe(
Setup ws api subscriptions: Setup ws api subscriptions:
https://docs.kraken.com/websockets/#message-subscribe https://docs.kraken.com/websockets/#message-subscribe
By default we sign up for trade and order (update) events per By default we sign up for trade and order update events.
`subs`.
''' '''
# more specific logic for this in kraken's sync client: # more specific logic for this in kraken's sync client:
# https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188 # https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188
latest_token: str = await client.get_ws_token() assert token
if (
token
!=
latest_token
):
log.info(
f'RE-subscribing to WS connection..\n'
f'orig-token: {token!r}\n'
f'latest-token: {latest_token!r}\n'
)
token = latest_token
subnames: set[str] = set() subnames: set[str] = set()
for name, sub_opts in subs: for name, sub_opts in subs:
@ -351,8 +329,7 @@ async def subscribe(
'event': 'subscribe', 'event': 'subscribe',
'subscription': { 'subscription': {
'name': name, 'name': name,
# 'token': await client.get_ws_token(), 'token': token,
'token': latest_token,
**sub_opts, **sub_opts,
} }
} }
@ -367,9 +344,7 @@ async def subscribe(
# wait on subscriptionn acks # wait on subscriptionn acks
with trio.move_on_after(5): with trio.move_on_after(5):
while True: while True:
msg: dict = await ws.recv_msg() match (msg := await ws.recv_msg()):
fmt_msg: str = ppfmt(msg)
match msg:
case { case {
'event': 'subscriptionStatus', 'event': 'subscriptionStatus',
'status': 'subscribed', 'status': 'subscribed',
@ -387,49 +362,10 @@ async def subscribe(
'event': 'subscriptionStatus', 'event': 'subscriptionStatus',
'status': 'error', 'status': 'error',
'errorMessage': errmsg, 'errorMessage': errmsg,
'subscription': sub_opts,
} as msg: } as msg:
if errmsg: raise RuntimeError(
etype_str, _, ev_msg = errmsg.partition(':') f'{errmsg}\n\n'
etype: Type[Exception] = getattr( f'{pformat(msg)}'
api,
etype_str,
RuntimeError,
)
exc = etype(
f'{ev_msg}\n'
f'\n'
f'{fmt_msg}'
)
# !TODO, for `InvalidSession` we should
# attempt retries to resub and ensure all
# sibling (task) `token` holders update
# their refs accoridingly!
match (etype_str, ev_msg):
case (
'ESession',
'Invalid session',
):
# attempt ws-token refresh
token: str = await client.get_ws_token(
force_renewal=True
)
await tractor.pause()
continue
case _:
log.warning(
f'Unhandled subscription-status,\n'
f'{fmt_msg}'
)
raise exc
case _:
log.warning(
f'Unknown ws event rxed?\n'
f'{fmt_msg}'
) )
yield yield
@ -525,27 +461,11 @@ async def open_trade_dialog(
# (much like the web UI let's you set an "account currency") # (much like the web UI let's you set an "account currency")
# such that all positions (nested or flat) will be translated to # such that all positions (nested or flat) will be translated to
# this source currency's terms. # this source currency's terms.
src_fiat = client.conf.get('src_fiat') src_fiat = client.conf['src_fiat']
if not src_fiat:
raise ConfigurationError(
'No `src_fiat: str` field defined in `brokers.toml`'
)
# auth required block # auth required block
conf: dict = client.conf acctid = client._name
accounts: dict = conf.get('accounts') acc_name = 'kraken.' + acctid
acctid: str = client._key_descr
if not accounts.get(acctid):
raise ConfigurationError(
f'No API-key found for account-alias defined as {acctid!r} !\n'
f'\n'
f'Did set a `kraken.accounts.*` entry in your `brokers.toml`?\n'
f'It should look something like,\n'
f'\n'
f'[kraken]\n'
f'accounts.{acctid} = {acctid!r}\n'
)
fqan: str = f'kraken.{acctid}'
# task local msg dialog tracking # task local msg dialog tracking
apiflows = OrderDialogs() apiflows = OrderDialogs()
@ -664,10 +584,7 @@ async def open_trade_dialog(
acctid, acctid,
) )
# sync with EMS delivering pps and accounts # sync with EMS delivering pps and accounts
await ctx.started(( await ctx.started((ppmsgs, [acc_name]))
ppmsgs,
[fqan],
))
# TODO: ideally this blocks the this task # TODO: ideally this blocks the this task
# as little as possible. we need to either do # as little as possible. we need to either do
@ -675,11 +592,14 @@ async def open_trade_dialog(
# async file IO api? # async file IO api?
acnt.write_config() acnt.write_config()
token: str = await client.get_ws_token() # Get websocket token for authenticated data stream
# Assert that a token was actually received.
resp = await client.endpoint('GetWebSocketsToken', {})
if err := resp.get('error'):
raise BrokerError(err)
# XXX tracks EMS orders which are updated too quickly # resp token for ws init
# on the emds side with sync-issues on the kraken side. token: str = resp['result']['token']
toofastedit: set[int] = set()
ws: NoBsWs ws: NoBsWs
async with ( async with (
@ -688,24 +608,23 @@ async def open_trade_dialog(
'wss://ws-auth.kraken.com/', 'wss://ws-auth.kraken.com/',
fixture=partial( fixture=partial(
subscribe, subscribe,
client=client,
token=token, token=token,
), ),
) as ws, ) as ws,
aclosing(stream_messages(ws)) as stream, aclosing(stream_messages(ws)) as stream,
trio.open_nursery() as tn, trio.open_nursery() as nurse,
): ):
# task for processing inbound requests from ems # task for processing inbound requests from ems
tn.start_soon(partial( nurse.start_soon(
handle_order_requests, handle_order_requests,
ws=ws, ws,
client=client, client,
ems_order_stream=ems_stream, ems_stream,
apiflows=apiflows, token,
ids=ids, apiflows,
reqids2txids=reqids2txids, ids,
toofastedit=toofastedit, reqids2txids,
)) )
# enter relay loop # enter relay loop
await handle_order_updates( await handle_order_updates(
@ -716,23 +635,22 @@ async def open_trade_dialog(
apiflows=apiflows, apiflows=apiflows,
ids=ids, ids=ids,
reqids2txids=reqids2txids, reqids2txids=reqids2txids,
toofastedit=toofastedit,
acnt=acnt, acnt=acnt,
ledger=ledger, ledger=ledger,
acctid=acctid, acctid=acctid,
acc_name=fqan, acc_name=acc_name,
token=token,
) )
async def handle_order_updates( async def handle_order_updates(
client: api.Client, # only for pairs table needed in ledger proc client: Client, # only for pairs table needed in ledger proc
ws: NoBsWs, ws: NoBsWs,
ws_stream: AsyncIterator, ws_stream: AsyncIterator,
ems_stream: tractor.MsgStream, ems_stream: tractor.MsgStream,
apiflows: OrderDialogs, apiflows: OrderDialogs,
ids: bidict[str, int], ids: bidict[str, int],
reqids2txids: bidict[int, str], reqids2txids: bidict[int, str],
toofastedit: set[int],
acnt: Account, acnt: Account,
# transaction records which will be updated # transaction records which will be updated
@ -741,6 +659,7 @@ async def handle_order_updates(
# ledger_trans: dict[str, Transaction], # ledger_trans: dict[str, Transaction],
acctid: str, acctid: str,
acc_name: str, acc_name: str,
token: str,
) -> None: ) -> None:
''' '''
@ -870,7 +789,7 @@ async def handle_order_updates(
for order_msg in order_msgs: for order_msg in order_msgs:
log.info( log.info(
f'`openOrders` msg update_{seq}:\n' f'`openOrders` msg update_{seq}:\n'
f'{ppfmt(order_msg)}' f'{pformat(order_msg)}'
) )
txid, update_msg = list(order_msg.items())[0] txid, update_msg = list(order_msg.items())[0]
@ -1040,8 +959,10 @@ async def handle_order_updates(
# <-> ems dialog. # <-> ems dialog.
if ( if (
status == 'open' status == 'open'
and and isinstance(
reqid in toofastedit reqids2txids.get(reqid),
TooFastEdit
)
): ):
# TODO: don't even allow this case # TODO: don't even allow this case
# by not moving the client side line # by not moving the client side line
@ -1056,8 +977,7 @@ async def handle_order_updates(
# https://docs.kraken.com/websockets/#message-cancelOrder # https://docs.kraken.com/websockets/#message-cancelOrder
await ws.send_msg({ await ws.send_msg({
'event': 'cancelOrder', 'event': 'cancelOrder',
# 'token': token, 'token': token,
'token': await client.get_ws_token(),
'reqid': reqid or 0, 'reqid': reqid or 0,
'txid': [txid], 'txid': [txid],
}) })
@ -1203,8 +1123,7 @@ async def handle_order_updates(
txid txid
# we throttle too-fast-requests on the ems side # we throttle too-fast-requests on the ems side
and and not isinstance(txid, TooFastEdit)
reqid in toofastedit
): ):
# client was editting too quickly # client was editting too quickly
# so we instead cancel this order # so we instead cancel this order
@ -1212,8 +1131,7 @@ async def handle_order_updates(
f'Cancelling {reqid}@{txid} due to:\n {event}') f'Cancelling {reqid}@{txid} due to:\n {event}')
await ws.send_msg({ await ws.send_msg({
'event': 'cancelOrder', 'event': 'cancelOrder',
# 'token': token, 'token': token,
'token': await client.get_ws_token(),
'reqid': reqid or 0, 'reqid': reqid or 0,
'txid': [txid], 'txid': [txid],
}) })

View File

@ -19,9 +19,6 @@ Symbology defs and search.
''' '''
from decimal import Decimal from decimal import Decimal
from typing import (
ClassVar,
)
import tractor import tractor
@ -89,14 +86,9 @@ class Pair(Struct):
short_position_limit: float = 0 short_position_limit: float = 0
long_position_limit: float = float('inf') long_position_limit: float = float('inf')
# TODO, add API note when this was added!
execution_venue: str|None = None
# TODO: should we make this a literal NamespacePath ref? # TODO: should we make this a literal NamespacePath ref?
ns_path: str = 'piker.brokers.kraken:Pair' ns_path: str = 'piker.brokers.kraken:Pair'
_api_url: ClassVar[str] = 'https://docs.kraken.com/api/docs/rest-api/get-tradable-asset-pairs'
@property @property
def bs_mktid(self) -> str: def bs_mktid(self) -> str:
''' '''

View File

@ -95,9 +95,6 @@ _time_frames = {
class QuestradeError(Exception): class QuestradeError(Exception):
"Non-200 OK response code" "Non-200 OK response code"
from tractor._exceptions import reg_err_types
reg_err_types([QuestradeError])
class ContractsKey(NamedTuple): class ContractsKey(NamedTuple):
symbol: str symbol: str

View File

@ -26,6 +26,7 @@ from collections import (
from contextlib import asynccontextmanager as acm from contextlib import asynccontextmanager as acm
from decimal import Decimal from decimal import Decimal
from math import isnan from math import isnan
from pprint import pformat
from time import time_ns from time import time_ns
from types import ModuleType from types import ModuleType
from typing import ( from typing import (
@ -42,7 +43,6 @@ import trio
from trio_typing import TaskStatus from trio_typing import TaskStatus
import tractor import tractor
from tractor import trionics from tractor import trionics
from tractor.devx.pformat import ppfmt
from ._util import ( from ._util import (
log, # sub-sys logger log, # sub-sys logger
@ -335,14 +335,9 @@ class TradesRelay(Struct):
@acm @acm
async def open_brokerd_dialog( async def open_brokerd_dialog(
brokermod: ModuleType, brokermod: ModuleType,
portal: tractor.Portal,
exec_mode: str, exec_mode: str,
fqme: str|None = None, fqme: str|None = None,
# XXX: explicit (already spawned) trading-actor override,
# currently only used by the `piker ledger` cli which
# boots its own ad-hoc `brokerd`-like actor; normally we
# (lazily) spawn/find the `brokerd.<broker>` daemon here.
portal: tractor.Portal|None = None,
loglevel: str|None = None, loglevel: str|None = None,
) -> tuple[ ) -> tuple[
@ -356,10 +351,6 @@ async def open_brokerd_dialog(
paper engine instance depending on live trading support for the paper engine instance depending on live trading support for the
broker backend, configuration, or client code usage. broker backend, configuration, or client code usage.
NOTE: this is now the ONE place where a (live, credentialed)
`brokerd.<broker>` daemon-actor gets (lazily) booted; pure
data/paper sessions should never spawn one!
''' '''
get_console_log( get_console_log(
level=loglevel, level=loglevel,
@ -425,29 +416,16 @@ async def open_brokerd_dialog(
) )
exec_mode: str = 'paper' exec_mode: str = 'paper'
@acm if (
async def acquire_live_portal(): trades_endpoint is not None
''' or
Deliver a portal to the (live, credentialed) trading exec_mode != 'paper'
actor hosting the backend's `open_trade_dialog()` ep: ):
either the caller-provided override or the (maybe # open live brokerd trades endpoint
lazily spawned) `brokerd.<broker>` service daemon. open_trades_endpoint = portal.open_context(
trades_endpoint,
'''
if portal is not None:
yield portal
return
# XXX: the ONE (normal) place a `brokerd.<broker>`
# daemon-actor gets booted in the runtime B)
from piker.brokers._daemon import (
maybe_spawn_brokerd,
)
async with maybe_spawn_brokerd(
brokermod.name,
loglevel=loglevel, loglevel=loglevel,
) as live_portal: )
yield live_portal
@acm @acm
async def maybe_open_paper_ep(): async def maybe_open_paper_ep():
@ -459,14 +437,7 @@ async def open_brokerd_dialog(
return return
# open trades-dialog endpoint with backend broker # open trades-dialog endpoint with backend broker
async with ( async with open_trades_endpoint as msg:
acquire_live_portal() as live_portal,
live_portal.open_context(
trades_endpoint,
loglevel=loglevel,
) as msg,
):
ctx, first = msg ctx, first = msg
# runtime indication that the backend can't support live # runtime indication that the backend can't support live
@ -519,7 +490,7 @@ async def open_brokerd_dialog(
msg = BrokerdPosition(**msg) msg = BrokerdPosition(**msg)
log.info( log.info(
f'loading pp for {brokermod.__name__}:\n' f'loading pp for {brokermod.__name__}:\n'
f'{ppfmt(msg.to_dict())}', f'{pformat(msg.to_dict())}',
) )
# TODO: state any mismatch here? # TODO: state any mismatch here?
@ -610,6 +581,7 @@ class Router(Struct):
async def maybe_open_brokerd_dialog( async def maybe_open_brokerd_dialog(
self, self,
brokermod: ModuleType, brokermod: ModuleType,
portal: tractor.Portal,
exec_mode: str, exec_mode: str,
fqme: str, fqme: str,
loglevel: str, loglevel: str,
@ -634,6 +606,7 @@ class Router(Struct):
async with open_brokerd_dialog( async with open_brokerd_dialog(
brokermod=brokermod, brokermod=brokermod,
portal=portal,
exec_mode=exec_mode, exec_mode=exec_mode,
fqme=fqme, fqme=fqme,
loglevel=loglevel, loglevel=loglevel,
@ -695,6 +668,7 @@ class Router(Struct):
brokername, _, _, _ = unpack_fqme(fqme) brokername, _, _, _ = unpack_fqme(fqme)
brokermod = feed.mods[brokername] brokermod = feed.mods[brokername]
broker = brokermod.name broker = brokermod.name
portal = feed.portals[brokermod]
# XXX: this should be initial price quote from target provider # XXX: this should be initial price quote from target provider
flume = feed.flumes[fqme] flume = feed.flumes[fqme]
@ -708,6 +682,7 @@ class Router(Struct):
async with self.maybe_open_brokerd_dialog( async with self.maybe_open_brokerd_dialog(
brokermod=brokermod, brokermod=brokermod,
portal=portal,
exec_mode=exec_mode, exec_mode=exec_mode,
fqme=fqme, fqme=fqme,
loglevel=loglevel, loglevel=loglevel,
@ -865,7 +840,7 @@ async def translate_and_relay_brokerd_events(
brokerd_msg: dict[str, Any] brokerd_msg: dict[str, Any]
async for brokerd_msg in brokerd_trades_stream: async for brokerd_msg in brokerd_trades_stream:
fmsg = ppfmt(brokerd_msg) fmsg = pformat(brokerd_msg)
log.info( log.info(
f'Rx brokerd trade msg:\n' f'Rx brokerd trade msg:\n'
f'{fmsg}' f'{fmsg}'
@ -1064,7 +1039,6 @@ async def translate_and_relay_brokerd_events(
) )
status_msg.reqid = reqid # THIS LINE IS CRITICAL! status_msg.reqid = reqid # THIS LINE IS CRITICAL!
if not status_msg.brokerd_msg:
status_msg.brokerd_msg = msg status_msg.brokerd_msg = msg
status_msg.src = msg.broker_details['name'] status_msg.src = msg.broker_details['name']
@ -1098,7 +1072,7 @@ async def translate_and_relay_brokerd_events(
else: # open else: # open
# relayed from backend but probably not handled so # relayed from backend but probably not handled so
# just log it # just log it
log.info(f'{broker!r} opened order {msg}') log.info(f'{broker} opened order {msg}')
# BrokerdFill # BrokerdFill
case { case {
@ -1211,7 +1185,7 @@ async def translate_and_relay_brokerd_events(
}: }:
msg = ( msg = (
f'Unhandled broker status for dialog {reqid}:\n' f'Unhandled broker status for dialog {reqid}:\n'
f'{ppfmt(brokerd_msg)}' f'{pformat(brokerd_msg)}'
) )
if ( if (
oid := book._ems2brokerd_ids.inverse.get(reqid) oid := book._ems2brokerd_ids.inverse.get(reqid)
@ -1220,7 +1194,7 @@ async def translate_and_relay_brokerd_events(
# clearable limits.. # clearable limits..
if status_msg := book._active.get(oid): if status_msg := book._active.get(oid):
msg += ( msg += (
f'last status msg: {ppfmt(status_msg)}\n\n' f'last status msg: {pformat(status_msg)}\n\n'
f'this msg:{fmsg}\n' f'this msg:{fmsg}\n'
) )
@ -1259,7 +1233,7 @@ async def process_client_order_cmds(
async for cmd in client_order_stream: async for cmd in client_order_stream:
log.info( log.info(
f'Received order cmd:\n' f'Received order cmd:\n'
f'{ppfmt(cmd)}\n' f'{pformat(cmd)}\n'
) )
# CAWT DAMN we need struct support! # CAWT DAMN we need struct support!
@ -1424,8 +1398,8 @@ async def process_client_order_cmds(
# handle relaying the ems side responses back to # handle relaying the ems side responses back to
# the client/cmd sender from this request # the client/cmd sender from this request
log.info( log.info(
f'Sending live order to {broker!r}:\n' f'Sending live order to {broker}:\n'
f'{ppfmt(msg)}' f'{pformat(msg)}'
) )
await brokerd_order_stream.send(msg) await brokerd_order_stream.send(msg)

View File

@ -27,7 +27,7 @@ from types import ModuleType
import click import click
import trio import trio
import tractor import tractor
from tractor.discovery import parse_endpoints from tractor._multiaddr import parse_maddr
from ..log import ( from ..log import (
get_console_log, get_console_log,
@ -46,25 +46,48 @@ log = get_logger('piker.cli')
def load_trans_eps( def load_trans_eps(
network: dict|None = None, network: dict | None = None,
maddrs: list[str]|None = None, maddrs: list[tuple] | None = None,
) -> dict[str, list]: ) -> dict[str, dict[str, dict]]:
'''
Load transport endpoints from a `[network]` config
table or CLI-provided multiaddr strings, delegating
to `tractor.discovery.parse_endpoints()`.
''' # transport-oriented endpoint multi-addresses
if network and not maddrs: eps: dict[
return parse_endpoints(network) str, # service name, eg. `pikerd`, `emsd`..
# libp2p style multi-addresses parsed into prot layers
list[dict[str, str | int]]
] = {}
if (
network
and
not maddrs
):
# load network section and (attempt to) connect all endpoints
# which are reachable B)
for key, maddrs in network.items():
match key:
# TODO: resolve table across multiple discov
# prots Bo
case 'resolv':
pass
case 'pikerd':
dname: str = key
for maddr in maddrs:
layers: dict = parse_maddr(maddr)
eps.setdefault(
dname,
[],
).append(layers)
elif maddrs: elif maddrs:
return parse_endpoints( # presume user is manually specifying the root actor ep.
{'pikerd': list(maddrs)} eps['pikerd'] = [parse_maddr(maddr)]
)
return {} return eps
@click.command() @click.command()
@ -85,13 +108,13 @@ def load_trans_eps(
help='Enable tractor debug mode', help='Enable tractor debug mode',
) )
@click.option( @click.option(
'--maddrs', '--maddr',
'-m', '-m',
default=None, default=None,
help='Multiaddrs to bind or contact', help='Multiaddrs to bind or contact',
) )
def pikerd( def pikerd(
maddrs: list[str]|None, maddr: list[str] | None,
loglevel: str, loglevel: str,
tl: bool, tl: bool,
pdb: bool, pdb: bool,
@ -122,8 +145,7 @@ def pikerd(
)) ))
# service-actor registry endpoint socket-address set # service-actor registry endpoint socket-address set
regaddrs: list = [] regaddrs: list[tuple[str, int]] = []
tpt_bind_addrs: list|None = None
conf, _ = config.load( conf, _ = config.load(
conf_name='conf', conf_name='conf',
@ -131,8 +153,7 @@ def pikerd(
network: dict = conf.get('network') network: dict = conf.get('network')
if ( if (
network is None network is None
and and not maddr
not maddrs
): ):
regaddrs = [( regaddrs = [(
_default_registry_host, _default_registry_host,
@ -140,23 +161,15 @@ def pikerd(
)] )]
else: else:
from tractor.devx import maybe_open_crash_handler eps: dict = load_trans_eps(
with maybe_open_crash_handler(pdb=pdb):
eps: dict[str, list] = load_trans_eps(
network, network,
maddrs, maddr,
)
# pikerd bind addresses (may differ from
# registry if regd is separate)
tpt_bind_addrs = eps.get('pikerd')
# registry: dedicated `regd` key, or fall
# back to pikerd addrs (pikerd IS the registry)
regaddrs = eps.get(
'regd',
tpt_bind_addrs,
) )
for layers in eps['pikerd']:
regaddrs.append((
layers['ipv4']['addr'],
layers['tcp']['port'],
))
from .. import service from .. import service
@ -165,7 +178,6 @@ def pikerd(
async with ( async with (
service.open_pikerd( service.open_pikerd(
registry_addrs=regaddrs, registry_addrs=regaddrs,
tpt_bind_addrs=tpt_bind_addrs,
loglevel=loglevel, loglevel=loglevel,
debug_mode=pdb, debug_mode=pdb,
# enable_transports=['uds'], # enable_transports=['uds'],
@ -250,6 +262,7 @@ def cli(
# TODO: load endpoints from `conf::[network].pikerd` # TODO: load endpoints from `conf::[network].pikerd`
# - pikerd vs. regd, separate registry daemon? # - pikerd vs. regd, separate registry daemon?
# - expose datad vs. brokerd?
# - bind emsd with certain perms on public iface? # - bind emsd with certain perms on public iface?
regaddrs: list[tuple[str, int]] = regaddr or [( regaddrs: list[tuple[str, int]] = regaddr or [(
_default_registry_host, _default_registry_host,
@ -332,7 +345,7 @@ def services(
if not ports: if not ports:
ports: list[int] = [_default_registry_port] ports: list[int] = [_default_registry_port]
addr = tractor.discovery._addr.wrap_address( addr = tractor._addr.wrap_address(
addr=(host, ports[0]) addr=(host, ports[0])
) )

View File

@ -36,8 +36,6 @@ except ModuleNotFoundError:
import tomli as tomllib import tomli as tomllib
from tractor._exceptions import reg_err_types
from tractor.devx.pformat import ppfmt
from .log import get_logger from .log import get_logger
log = get_logger('broker-config') log = get_logger('broker-config')
@ -174,12 +172,6 @@ class ConfigurationError(Exception):
class NoSignature(ConfigurationError): class NoSignature(ConfigurationError):
'No credentials setup for broker backend!' 'No credentials setup for broker backend!'
# auto-register for tractor IPC exc-marshalling.
reg_err_types([
ConfigurationError,
*ConfigurationError.__subclasses__(),
])
def _override_config_dir( def _override_config_dir(
path: str path: str
@ -188,48 +180,6 @@ def _override_config_dir(
_config_dir = path _config_dir = path
def _maybe_use_test_dir() -> None:
'''
When running under the `pytest` harness, override
the config dir to the per-test-tmp dir "passed down"
the actor tree via `tractor`'s runtime-vars
inheritance mechanism.
See the `tractor_runtime_overrides` usage in our
`tests.conftest._open_test_pikerd()` as well as
`.service._actor_runtime.open_piker_runtime()` for
the root-actor's pre-loading of the var state.
NOTE: this must be checked lazily at config-path
access time (NOT import time) since sub-actors only
receive runtime-vars once their `tractor` runtime
has fully booted.
'''
global _config_dir
import tractor
actor = tractor.current_actor(
err_on_no_runtime=False,
)
if actor is None:
return
rvs: dict = tractor.runtime._state._runtime_vars
pvars: dict|None = rvs.get('piker_vars')
if (
pvars
and
(testdir := pvars.get('piker_test_dir'))
):
testdirpath = Path(testdir)
assert testdirpath.exists(), (
f'piker test harness might be borked!?\n'
f'testdirpath: {testdirpath!r}\n'
)
if _config_dir != testdirpath:
_override_config_dir(testdirpath)
def _conf_fn_w_ext( def _conf_fn_w_ext(
name: str, name: str,
) -> str: ) -> str:
@ -243,7 +193,6 @@ def get_conf_dir() -> Path:
on the local filesystem. on the local filesystem.
''' '''
_maybe_use_test_dir()
return _config_dir return _config_dir
@ -269,7 +218,7 @@ def get_conf_path(
assert str(conf_name) in _conf_names assert str(conf_name) in _conf_names
fn = _conf_fn_w_ext(conf_name) fn = _conf_fn_w_ext(conf_name)
return get_conf_dir() / Path(fn) return _config_dir / Path(fn)
def repodir() -> Path: def repodir() -> Path:
@ -314,9 +263,8 @@ def load(
''' '''
# create the $HOME/.config/piker dir if dne # create the $HOME/.config/piker dir if dne
conf_dir: Path = get_conf_dir() if not _config_dir.is_dir():
if not conf_dir.is_dir(): _config_dir.mkdir(
conf_dir.mkdir(
parents=True, parents=True,
exist_ok=True, exist_ok=True,
) )
@ -402,56 +350,30 @@ def write(
def load_accounts( def load_accounts(
providers: list[str]|None = None providers: list[str] | None = None
) -> bidict[str, str|None]:
) -> bidict[str, str | None]:
conf, path = load( conf, path = load(
conf_name='brokers', conf_name='brokers',
) )
accounts = bidict({ accounts = bidict()
# XXX, default paper-engine entry; this MUST be set. for provider_name, section in conf.items():
'paper': None, accounts_section = section.get('accounts')
})
msg: str = (
'Loading account(s) from `brokers.toml`,\n'
)
for (
provider_name,
section,
) in conf.items():
accounts_section: dict[str, str] = section.get('accounts')
if accounts_section is None:
msg += f'No accounts declared for {provider_name!r}?\n'
continue
# msg += f'Loaded accounts for {provider_name!r}?\n'
if ( if (
providers is None providers is None or
or ( providers and provider_name in providers
providers
and
provider_name in providers
)
): ):
for ( if accounts_section is None:
label, log.warning(f'No accounts named for {provider_name}?')
value, continue
) in accounts_section.items():
account_alias: str = f'{provider_name}.{label}'
accounts[account_alias] = value
msg += f'{account_alias} = {value!r}\n'
else: else:
log.debug( for label, value in accounts_section.items():
f'NOT loading account(s) for entry in `brokers.toml`,\n' accounts[
f'The account provider was not requested for loading.\n' f'{provider_name}.{label}'
f'requested-providers: {providers!r}\n' ] = value
f'this-provider: {provider_name!r}\n'
f'\n' # our default paper engine entry
f'{ppfmt(accounts_section)}\n' accounts['paper'] = None
)
# ?TODO? mk this bp work?
# breakpoint()
log.info(msg)
return accounts return accounts

View File

@ -1,300 +0,0 @@
# 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/>.
'''
Data-daemon-actor "endpoint-hooks": the service task entry
points for `datad.<brokername>`, the per-provider real-time
and historical market-data feed daemon.
The (data vs. broker)d-split mirrors the ep-groupings in
`piker.data.validate._eps`: this daemon hosts all `'datad'`
eps (live quotes, history loading, symbology search) while
its `brokerd.<brokername>` sibling hosts only the live
order-control (and thus credentialed) `'brokerd'` eps.
'''
from __future__ import annotations
from contextlib import (
asynccontextmanager as acm,
)
from types import ModuleType
from typing import (
TYPE_CHECKING,
AsyncContextManager,
)
import tractor
import trio
from piker.log import (
get_logger,
get_console_log,
)
from . import _util
if TYPE_CHECKING:
from .feed import _FeedsBus
log = get_logger(name=__name__)
# `datad`-actor-always-enabled mods: the data-side successor
# to the old `piker.brokers._daemon._data_mods` set.
# NOTE: keeping this list as small as possible is part of
# our caps-sec model and should be treated with utmost care!
_datad_service_mods: list[str] = [
'piker.brokers.core',
'piker.brokers.data',
'piker.data',
'piker.data.feed',
'piker.data._sampling',
'piker.data._daemon',
]
@tractor.context
async def _setup_persistent_datad(
ctx: tractor.Context,
brokername: str,
loglevel: str|None = None,
) -> None:
'''
Allocate an actor-wide service nursery in this
`datad.<brokername>` actor such that data-feed tasks
(shm writer-samplers, history backfillers, symbology
loaders) can be run in the background persistently by
the provider backend as needed.
'''
# NOTE: we only need to setup logging once (and only)
# here since all hosted daemon tasks will reference
# this same log instance's (actor local) state and thus
# don't require any further (level) configuration on
# their own B)
actor: tractor.Actor = tractor.current_actor()
tll: str = actor.loglevel
log = get_console_log(
level=loglevel or tll,
name=f'{_util.subsys}.{brokername}',
with_tractor_log=bool(tll),
)
assert log.name == _util.subsys
from piker.data import feed
assert not feed._bus
# allocate a nursery to the bus for spawning background
# tasks which service client IPC requests, normally
# `tractor.Context` connections to explicitly required
# `datad` endpoints such as:
# - `stream_quotes()`,
# - `manage_history()`,
# - `allocate_persistent_feed()`,
# - `open_symbol_search()`
# NOTE: see ep invocation details inside `.data.feed`.
async with (
trio.open_nursery() as service_nursery
):
bus: _FeedsBus = feed.get_feed_bus(
brokername,
service_nursery,
)
assert bus is feed._bus
# unblock caller
await ctx.started()
# we pin this task to keep the feeds manager active
# until the parent actor decides to tear it down
await trio.sleep_forever()
def datad_init(
brokername: str,
loglevel: str|None = None,
**start_actor_kwargs,
) -> tuple[
ModuleType,
dict,
AsyncContextManager,
]:
'''
Given an input broker name, load all named arguments
which can be passed for daemon endpoint + context spawn
as required in every `datad.<brokername>` (actor)
service.
This includes:
- load the appropriate <brokername>.py pkg module,
- reads any declared `_datad_mods: list[str]` (falling
back to the full `__enable_modules__` set for
not-yet-split backends) which will be passed to
`tractor.ActorNursery.start_actor(enable_modules=)`
at actor start time,
- deliver a reference to the daemon lifetime fixture,
which for now is always the
`_setup_persistent_datad()` context defined above.
'''
from piker.brokers import get_brokermod
from .validate import get_eps
brokermod = get_brokermod(brokername)
modpath: str = brokermod.__name__
# warn (but don't bail) when the backend is missing
# some/all of the `datad` ep contract defined by
# `piker.data.validate._eps`.
datad_eps: dict = get_eps(brokermod, 'datad')
if not datad_eps:
log.warning(
f'Backend {brokername!r} offers NO `datad` '
f'(data-feed) eps!?\n'
f'Most feed/chart functionality will be '
f'broken for this provider..\n'
)
start_actor_kwargs['name'] = f'datad.{brokername}'
# XXX CRITICAL: include any backend-declared spawn
# kwargs, eg. `{'infect_asyncio': True}` required by
# `ib`'s embedded `asyncio`-mode `ib_async` usage!
start_actor_kwargs.update(
getattr(
brokermod,
'_spawn_kwargs',
{},
)
)
# lookup actor-enabled modules declared by the backend
# offering the `datad` endpoint(s).
enabled: list[str]
enabled = start_actor_kwargs['enable_modules'] = [
__name__, # so that eps from THIS mod can be invoked
modpath,
]
for submodname in getattr(
brokermod,
'_datad_mods',
# fallback for (flat, less mature) backends which
# don't yet declare a daemon-kind mod split.
getattr(
brokermod,
'__enable_modules__',
[],
),
):
subpath: str = f'{modpath}.{submodname}'
enabled.append(subpath)
return (
brokermod,
start_actor_kwargs, # to `ActorNursery.start_actor()`
# XXX see impl above; contains all (actor global)
# setup/teardown expected in all `datad` actor
# instances.
_setup_persistent_datad,
)
async def spawn_datad(
brokername: str,
loglevel: str|None = None,
**tractor_kwargs,
) -> bool:
log.info(
f'Spawning data-daemon,\n'
f'backend: {brokername!r}'
)
(
brokermod,
tractor_kwargs,
daemon_fixture_ep,
) = datad_init(
brokername,
loglevel,
**tractor_kwargs,
)
# ask `pikerd` to spawn a new sub-actor and manage it
# under its actor nursery
from piker.service import Services
dname: str = tractor_kwargs.pop('name') # f'datad.{brokername}'
enable_mods: list[str] = list(dict.fromkeys(
_datad_service_mods
+
tractor_kwargs.pop('enable_modules')
))
portal = await Services.actor_n.start_actor(
dname,
enable_modules=enable_mods,
debug_mode=Services.debug_mode,
**tractor_kwargs,
)
# NOTE: the service mngr expects an already spawned
# actor + its portal ref in order to do non-blocking
# setup of the `datad` service nursery.
await Services.start_service_task(
dname,
portal,
# signature of target root-task endpoint
daemon_fixture_ep,
brokername=brokername,
loglevel=loglevel,
)
return True
@acm
async def maybe_spawn_datad(
brokername: str,
loglevel: str|None = None,
**pikerd_kwargs,
) -> tractor.Portal:
'''
Helper to spawn a datad service *from* a client who
wishes to use the sub-actor-daemon but is fine with
re-using any existing and contactable `datad`.
Mas o menos, acts as a cached-actor-getter factory.
'''
from piker.service import maybe_spawn_daemon
async with maybe_spawn_daemon(
service_name=f'datad.{brokername}',
service_task_target=spawn_datad,
spawn_args={
'brokername': brokername,
},
loglevel=loglevel,
**pikerd_kwargs,
) as portal:
yield portal

View File

@ -168,7 +168,7 @@ async def _reconnect_forever(
nobsws: NoBsWs, nobsws: NoBsWs,
reset_after: int, # msg recv timeout before reset attempt reset_after: int, # msg recv timeout before reset attempt
fixture: AsyncContextManager|None = None, fixture: AsyncContextManager | None = None,
task_status: TaskStatus = trio.TASK_STATUS_IGNORED, task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
@ -185,7 +185,7 @@ async def _reconnect_forever(
async def proxy_msgs( async def proxy_msgs(
ws: WebSocketConnection, ws: WebSocketConnection,
rent_cs: trio.CancelScope, # parent cancel scope rent_cs: trio.CancelScope, # parent cancel scope
) -> None: ):
''' '''
Receive (under `timeout` deadline) all msgs from from underlying Receive (under `timeout` deadline) all msgs from from underlying
websocket and relay them to (calling) parent task via ``trio`` websocket and relay them to (calling) parent task via ``trio``
@ -206,7 +206,7 @@ async def _reconnect_forever(
except nobsws.recon_errors: except nobsws.recon_errors:
log.exception( log.exception(
f'{src_mod}\n' f'{src_mod}\n'
f'{url!r} connection failed\n' f'{url} connection bail with:'
) )
with trio.CancelScope(shield=True): with trio.CancelScope(shield=True):
await trio.sleep(0.5) await trio.sleep(0.5)
@ -269,7 +269,7 @@ async def _reconnect_forever(
nobsws._ws = ws nobsws._ws = ws
log.info( log.info(
f'{src_mod}\n' f'{src_mod}\n'
f'Connection success: {url!r}' f'Connection success: {url}'
) )
# begin relay loop to forward msgs # begin relay loop to forward msgs
@ -341,7 +341,7 @@ async def _reconnect_forever(
async def open_autorecon_ws( async def open_autorecon_ws(
url: str, url: str,
fixture: AsyncContextManager|None = None, fixture: AsyncContextManager | None = None,
# time in sec between msgs received before # time in sec between msgs received before
# we presume connection might need a reset. # we presume connection might need a reset.
@ -361,7 +361,7 @@ async def open_autorecon_ws(
and restarts the full http(s) handshake on catches of certain and restarts the full http(s) handshake on catches of certain
connetivity errors, or some user defined recv timeout. connetivity errors, or some user defined recv timeout.
You can provide a `fixture` async-context-manager which will be You can provide a ``fixture`` async-context-manager which will be
entered/exitted around each connection reset; eg. for entered/exitted around each connection reset; eg. for
(re)requesting subscriptions without requiring streaming setup (re)requesting subscriptions without requiring streaming setup
code to rerun. code to rerun.
@ -402,8 +402,7 @@ async def open_autorecon_ws(
except NoBsWs.recon_errors as con_err: except NoBsWs.recon_errors as con_err:
log.warning( log.warning(
f'Entire ws-channel disconnect due to,\n' f'Entire ws-channel disconnect due to,\n'
f'\n' f'con_err: {con_err!r}\n'
f'{con_err!r}\n'
) )
@ -425,7 +424,7 @@ class JSONRPCResult(Struct):
async def open_jsonrpc_session( async def open_jsonrpc_session(
url: str, url: str,
start_id: int = 0, start_id: int = 0,
response_type: Type[Struct] = JSONRPCResult, response_type: type = JSONRPCResult,
msg_recv_timeout: float = float('inf'), msg_recv_timeout: float = float('inf'),
# ^NOTE, since only `deribit` is using this jsonrpc stuff atm # ^NOTE, since only `deribit` is using this jsonrpc stuff atm
# and options mkts are generally "slow moving".. # and options mkts are generally "slow moving"..
@ -436,10 +435,7 @@ async def open_jsonrpc_session(
# broken and never restored with wtv init sequence is required to # broken and never restored with wtv init sequence is required to
# re-establish a working req-resp session. # re-establish a working req-resp session.
) -> Callable[ ) -> Callable[[str, dict], dict]:
[str, dict],
dict,
]:
''' '''
Init a json-RPC-over-websocket connection to the provided `url`. Init a json-RPC-over-websocket connection to the provided `url`.
@ -535,18 +531,14 @@ async def open_jsonrpc_session(
'id': mid, 'id': mid,
} if not rpc_results.get(mid): } if not rpc_results.get(mid):
log.warning( log.warning(
f'Unexpected ws msg?\n' f'Unexpected ws msg: {json.dumps(msg, indent=4)}'
f'{json.dumps(msg, indent=4)}'
) )
case { case {
'method': _, 'method': _,
'params': _, 'params': _,
}: }:
log.debug( log.debug(f'Recieved\n{msg}')
f'Recieved\n'
f'{msg!r}'
)
case { case {
'error': error 'error': error
@ -562,15 +554,12 @@ async def open_jsonrpc_session(
result['event'].set() result['event'].set()
log.error( log.error(
f'JSONRPC request failed\n' f'JSONRPC request failed\n'
f'req: {req_msg!r}\n' f'req: {req_msg}\n'
f'resp: {error!r}\n' f'resp: {error}\n'
) )
case _: case _:
log.warning( log.warning(f'Unhandled JSON-RPC msg!?\n{msg}')
f'Unhandled JSON-RPC msg!?\n'
f'{msg!r}'
)
tn.start_soon(recv_task) tn.start_soon(recv_task)
yield json_rpc yield json_rpc

View File

@ -17,7 +17,7 @@
''' '''
Data feed apis and infra. Data feed apis and infra.
This module is enabled for ``datad`` daemons and includes mostly This module is enabled for ``brokerd`` daemons and includes mostly
endpoints and middleware to support our real-time, provider agnostic, endpoints and middleware to support our real-time, provider agnostic,
live market quotes layer. Historical data loading and processing is also live market quotes layer. Historical data loading and processing is also
initiated in parts of the feed bus startup but business logic and initiated in parts of the feed bus startup but business logic and
@ -54,11 +54,8 @@ from piker.accounting import (
) )
from piker.types import Struct from piker.types import Struct
from piker.brokers import get_brokermod from piker.brokers import get_brokermod
# NOTE: must be a "relative-direct" import (NOT via from piker.service import (
# `piker.service`) to avoid a partial-init cycle when this maybe_spawn_brokerd,
# mod is loaded as part of `piker.service.__init__`.
from ._daemon import (
maybe_spawn_datad,
) )
from piker.calc import humanize from piker.calc import humanize
from ._util import ( from ._util import (
@ -80,7 +77,7 @@ from ._sampling import (
if TYPE_CHECKING: if TYPE_CHECKING:
from .flows import Flume from .flows import Flume
from tractor.discovery._addr import Address from tractor._addr import Address
from tractor.msg.types import Aid from tractor.msg.types import Aid
@ -113,7 +110,7 @@ class _FeedsBus(Struct):
''' '''
Data feeds broadcaster and persistence management. Data feeds broadcaster and persistence management.
This is a datad side api used to manager persistent real-time This is a brokerd side api used to manager persistent real-time
streams that can be allocated and left alive indefinitely. A bus is streams that can be allocated and left alive indefinitely. A bus is
associated one-to-one with a particular broker backend where the associated one-to-one with a particular broker backend where the
"bus" refers so a multi-symbol bus where quotes are interleaved in "bus" refers so a multi-symbol bus where quotes are interleaved in
@ -252,7 +249,7 @@ async def allocate_persistent_feed(
''' '''
Create and maintain a "feed bus" which allocates tasks for real-time Create and maintain a "feed bus" which allocates tasks for real-time
streaming and optional historical data storage per broker/data provider streaming and optional historical data storage per broker/data provider
backend; this normally task runs *in* a `datad` actor. backend; this normally task runs *in* a `brokerd` actor.
If none exists, this allocates a ``_FeedsBus`` which manages the If none exists, this allocates a ``_FeedsBus`` which manages the
lifetimes of streaming tasks created for each requested symbol. lifetimes of streaming tasks created for each requested symbol.
@ -321,8 +318,8 @@ async def allocate_persistent_feed(
# at max capacity. # at max capacity.
# - the same ideas ^ but when a local core is maxxed out (like how # - the same ideas ^ but when a local core is maxxed out (like how
# binance does often with hft XD # binance does often with hft XD
# - if a datad is non-local then we can't just allocate a mem # - if a brokerd is non-local then we can't just allocate a mem
# channel here and have the datad write it, we instead need # channel here and have the brokerd write it, we instead need
# a small streaming machine around the remote feed which can then # a small streaming machine around the remote feed which can then
# do the normal work of sampling and writing shm buffers # do the normal work of sampling and writing shm buffers
# (depending on if we want sampling done on the far end or not?) # (depending on if we want sampling done on the far end or not?)
@ -502,7 +499,7 @@ async def open_feed_bus(
# (after we also group them in a nice `/dev/shm/piker/` subdir). # (after we also group them in a nice `/dev/shm/piker/` subdir).
# ensure we are who we think we are # ensure we are who we think we are
servicename = tractor.current_actor().name servicename = tractor.current_actor().name
assert 'datad' in servicename assert 'brokerd' in servicename
assert brokername in servicename assert brokername in servicename
bus: _FeedsBus = get_feed_bus(brokername) bus: _FeedsBus = get_feed_bus(brokername)
@ -512,12 +509,12 @@ async def open_feed_bus(
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
# datad yet, start persistent stream and shm writer task in # brokerd yet, start persistent stream and shm writer task in
# service nursery # service nursery
flume = bus.feeds.get(symbol) flume = bus.feeds.get(symbol)
if flume is None: if flume is None:
# allocate a new actor-local stream bus which # allocate a new actor-local stream bus which
# will persist for this `datad`'s service lifetime. # will persist for this `brokerd`'s service lifetime.
async with bus.task_lock: async with bus.task_lock:
await bus.nursery.start( await bus.nursery.start(
partial( partial(
@ -724,7 +721,7 @@ class Feed(Struct):
mods = {name: self.mods[name] for name in brokers} mods = {name: self.mods[name] for name in brokers}
if len(mods) == 1: if len(mods) == 1:
# just pass the datad stream directly if only one provider # just pass the brokerd stream directly if only one provider
# was detected. # was detected.
stream = self.streams[list(brokers)[0]] stream = self.streams[list(brokers)[0]]
async with stream.subscribe() as bstream: async with stream.subscribe() as bstream:
@ -766,7 +763,7 @@ class Feed(Struct):
@acm @acm
async def install_datad_search( async def install_brokerd_search(
portal: tractor.Portal, portal: tractor.Portal,
brokermod: ModuleType, brokermod: ModuleType,
@ -815,7 +812,7 @@ async def maybe_open_feed(
ReceiveChannel[dict[str, Any]], ReceiveChannel[dict[str, Any]],
): ):
''' '''
Maybe open a data feed to a ``datad`` daemon only if there is no Maybe open a data to a ``brokerd`` daemon only if there is no
local one for the broker-symbol pair, if one is cached use it wrapped local one for the broker-symbol pair, if one is cached use it wrapped
in a tractor broadcast receiver. in a tractor broadcast receiver.
@ -888,14 +885,14 @@ async def open_feed(
providers.setdefault(mod, []).append(bs_fqme) providers.setdefault(mod, []).append(bs_fqme)
feed.mods[mod.name] = mod feed.mods[mod.name] = mod
# one actor per datad for now # one actor per brokerd for now
datad_ctxs = [] brokerd_ctxs = []
for brokermod, bfqmes in providers.items(): for brokermod, bfqmes in providers.items():
# if no `datad` for this backend exists yet we spawn # if no `brokerd` for this backend exists yet we spawn
# a daemon actor for it. # a daemon actor for it.
datad_ctxs.append( brokerd_ctxs.append(
maybe_spawn_datad( maybe_spawn_brokerd(
brokermod.name, brokermod.name,
loglevel=loglevel loglevel=loglevel
) )
@ -903,7 +900,7 @@ async def open_feed(
portals: tuple[tractor.Portal] portals: tuple[tractor.Portal]
async with trionics.gather_contexts( async with trionics.gather_contexts(
datad_ctxs, brokerd_ctxs,
) as portals: ) as portals:
bus_ctxs: list[AsyncContextManager] = [] bus_ctxs: list[AsyncContextManager] = []
@ -940,9 +937,9 @@ async def open_feed(
tick_throttle=tick_throttle, tick_throttle=tick_throttle,
# XXX: super important to avoid # XXX: super important to avoid
# the datad from some other # the brokerd from some other
# backend overruning the task here # backend overruning the task here
# bc some other datad took longer # bc some other brokerd took longer
# to startup before we hit the `.open_stream()` # to startup before we hit the `.open_stream()`
# loop below XD .. really we should try to do each # loop below XD .. really we should try to do each
# of these stream open sequences sequentially per # of these stream open sequences sequentially per
@ -976,6 +973,9 @@ async def open_feed(
# assert flume.mkt.fqme == fqme # assert flume.mkt.fqme == fqme
feed.flumes[fqme] = flume feed.flumes[fqme] = flume
# TODO: do we need this?
flume.feed = feed
# attach and cache shm handles # attach and cache shm handles
rt_shm = flume.rt_shm rt_shm = flume.rt_shm
assert rt_shm assert rt_shm
@ -1011,7 +1011,7 @@ async def open_feed(
assert stream assert stream
feed.streams[brokermod.name] = stream feed.streams[brokermod.name] = stream
# apply `datad`-common stream to each flume # apply `brokerd`-common stream to each flume
# tracking a live market feed from that provider. # tracking a live market feed from that provider.
for fqme, flume in feed.flumes.items(): for fqme, flume in feed.flumes.items():
if brokermod.name == flume.mkt.broker: if brokermod.name == flume.mkt.broker:

View File

@ -22,6 +22,9 @@ real-time data processing data-structures.
""" """
from __future__ import annotations from __future__ import annotations
from typing import (
TYPE_CHECKING,
)
import tractor import tractor
import pendulum import pendulum
@ -35,6 +38,9 @@ from tractor.ipc._shm import (
) )
from piker.accounting import MktPair from piker.accounting import MktPair
if TYPE_CHECKING:
from piker.data.feed import Feed
class Flume(Struct): class Flume(Struct):
''' '''
@ -74,6 +80,10 @@ class Flume(Struct):
izero_rt: int = 0 izero_rt: int = 0
throttle_rate: int | None = None throttle_rate: int | None = None
# TODO: do we need this really if we can pull the `Portal` from
# ``tractor``'s internals?
feed: Feed|None = None
@property @property
def rt_shm(self) -> ShmArray: def rt_shm(self) -> ShmArray:
@ -146,6 +156,7 @@ class Flume(Struct):
# will get instead some kind of msg-compat version # will get instead some kind of msg-compat version
# that it can load. # that it can load.
msg.pop('stream') msg.pop('stream')
msg.pop('feed')
msg.pop('_rt_shm') msg.pop('_rt_shm')
msg.pop('_hist_shm') msg.pop('_hist_shm')

View File

@ -28,7 +28,6 @@ from typing import (
) )
from msgspec import field from msgspec import field
from tractor._exceptions import reg_err_types
from piker.types import Struct from piker.types import Struct
from piker.accounting import ( from piker.accounting import (
@ -44,8 +43,6 @@ class FeedInitializationError(ValueError):
''' '''
reg_err_types([FeedInitializationError])
class FeedInit(Struct, frozen=True): class FeedInit(Struct, frozen=True):
''' '''
@ -91,24 +88,6 @@ _eps: dict[str, list[str]] = {
} }
def get_eps(
mod: ModuleType,
kind: str, # 'middleware' | 'datad' | 'brokerd'
) -> dict[str, Callable]:
'''
Return the daemon-kind's ep funcs defined by the backend
`mod`, keyed by ep name; any eps from `_eps[kind]` not
implemented by the backend are excluded.
'''
return {
name: ep
for name in _eps[kind]
if (ep := getattr(mod, name, None))
}
def validate_backend( def validate_backend(
mod: ModuleType, mod: ModuleType,
syms: list[str], syms: list[str],

View File

@ -56,7 +56,3 @@ from ..brokers._daemon import (
spawn_brokerd as spawn_brokerd, spawn_brokerd as spawn_brokerd,
maybe_spawn_brokerd as maybe_spawn_brokerd, maybe_spawn_brokerd as maybe_spawn_brokerd,
) )
from ..data._daemon import (
spawn_datad as spawn_datad,
maybe_spawn_datad as maybe_spawn_datad,
)

View File

@ -59,7 +59,6 @@ def get_runtime_vars() -> dict[str, Any]:
async def open_piker_runtime( async def open_piker_runtime(
name: str, name: str,
registry_addrs: list[tuple[str, int]] = [], registry_addrs: list[tuple[str, int]] = [],
tpt_bind_addrs: list|None = None,
enable_modules: list[str] = [], enable_modules: list[str] = [],
loglevel: str|None = None, loglevel: str|None = None,
@ -92,7 +91,7 @@ async def open_piker_runtime(
try: try:
actor = tractor.current_actor() actor = tractor.current_actor()
except tractor._exceptions.NoRuntime: except tractor._exceptions.NoRuntime:
tractor.runtime._state._runtime_vars[ tractor._state._runtime_vars[
'piker_vars' 'piker_vars'
] = tractor_runtime_overrides ] = tractor_runtime_overrides
@ -113,7 +112,6 @@ async def open_piker_runtime(
# passed through to `open_root_actor` # passed through to `open_root_actor`
registry_addrs=registry_addrs, registry_addrs=registry_addrs,
tpt_bind_addrs=tpt_bind_addrs,
name=name, name=name,
start_method=start_method, start_method=start_method,
loglevel=loglevel, loglevel=loglevel,
@ -157,7 +155,6 @@ _root_modules: list[str] = [
__name__, __name__,
'piker.service._daemon', 'piker.service._daemon',
'piker.brokers._daemon', 'piker.brokers._daemon',
'piker.data._daemon',
'piker.clearing._ems', 'piker.clearing._ems',
'piker.clearing._client', 'piker.clearing._client',
@ -169,7 +166,6 @@ _root_modules: list[str] = [
@acm @acm
async def open_pikerd( async def open_pikerd(
registry_addrs: list[tuple[str, int]], registry_addrs: list[tuple[str, int]],
tpt_bind_addrs: list|None = None,
loglevel: str|None = None, loglevel: str|None = None,
# XXX: you should pretty much never want debug mode # XXX: you should pretty much never want debug mode
@ -202,7 +198,6 @@ async def open_pikerd(
loglevel=loglevel, loglevel=loglevel,
debug_mode=debug_mode, debug_mode=debug_mode,
registry_addrs=registry_addrs, registry_addrs=registry_addrs,
tpt_bind_addrs=tpt_bind_addrs,
**kwargs, **kwargs,
@ -215,16 +210,7 @@ async def open_pikerd(
trio.open_nursery() as service_tn, trio.open_nursery() as service_tn,
): ):
for addr in reg_addrs: for addr in reg_addrs:
# normalize to a wrapped `tractor` addr-type; if addr not in root_actor.accept_addrs:
# entries may be raw `tuple`s when passed in
# from (test) client code.
wladdr = tractor.discovery._addr.wrap_address(
addr,
)
uaddr: tuple = wladdr.unwrap()
if (
uaddr not in root_actor.accept_addrs
):
raise RuntimeError( raise RuntimeError(
f'`pikerd` failed to bind on {addr}!\n' f'`pikerd` failed to bind on {addr}!\n'
'Maybe you have another daemon already running?' 'Maybe you have another daemon already running?'
@ -278,7 +264,7 @@ async def maybe_open_pikerd(
**kwargs, **kwargs,
) -> ( ) -> (
tractor.Portal tractor._portal.Portal
|ClassVar[Services] |ClassVar[Services]
): ):
''' '''

View File

@ -49,7 +49,6 @@ from requests.exceptions import (
ReadTimeout, ReadTimeout,
) )
from tractor._exceptions import reg_err_types
from piker.log import ( from piker.log import (
get_console_log, get_console_log,
get_logger, get_logger,
@ -67,11 +66,6 @@ class DockerNotStarted(Exception):
class ApplicationLogError(Exception): class ApplicationLogError(Exception):
'App in container reported an error in logs' 'App in container reported an error in logs'
reg_err_types([
DockerNotStarted,
ApplicationLogError,
])
@acm @acm
async def open_docker( async def open_docker(

View File

@ -79,17 +79,10 @@ async def maybe_spawn_daemon(
lock = Services.locks[service_name] lock = Services.locks[service_name]
await lock.acquire() await lock.acquire()
if not pikerd_kwargs:
# XXX NOTE, pin to apprope `tractor` branch!
rtvs: dict = tractor.get_runtime_vars()
registry_addrs: list[tuple] = list(
map(tuple, rtvs['_registry_addrs'])
)
try: try:
async with find_service( async with find_service(
service_name, service_name,
registry_addrs=registry_addrs, registry_addrs=[('127.0.0.1', 6116)],
) as portal: ) as portal:
if portal is not None: if portal is not None:
lock.release() lock.release()
@ -106,7 +99,6 @@ async def maybe_spawn_daemon(
# process tree # process tree
async with maybe_open_pikerd( async with maybe_open_pikerd(
loglevel=loglevel, loglevel=loglevel,
registry_addrs=registry_addrs,
**pikerd_kwargs, **pikerd_kwargs,
) as pikerd_portal: ) as pikerd_portal:
@ -150,65 +142,7 @@ async def maybe_spawn_daemon(
async with tractor.wait_for_actor(service_name) as portal: async with tractor.wait_for_actor(service_name) as portal:
lock.release() lock.release()
yield portal yield portal
# --- ---- --- await portal.cancel_actor()
# XXX NOTE XXX
# --- ---- ---
# DO NOT PUT A `portal.cancel_actor()` here (as was prior)!
#
# Doing so will cause an "out-of-band" ctxc
# (`tractor.ContextCancelled`) to be raised inside the
# `ServiceMngr.open_context_in_task()`'s call to
# `ctx.wait_for_result()` AND the internal self-ctxc
# "graceful capture" WILL NOT CATCH IT!
#
# This can cause certain types of operations to raise
# that ctxc BEFORE THEY `return`, resulting in
# a "false-negative" ctxc being raised when really
# nothing actually failed, other then our semantic
# "failure" to suppress an expected, graceful,
# self-cancel scenario..
#
# bUt wHy duZ It WorK lIKe dis..
# ------------------------------
# from the perspective of the `tractor.Context` this
# cancel request was conducted "out of band" since
# `Context.cancel()` was never called and thus the
# `._cancel_called: bool` was never set. Despite the
# remote `.canceller` being set to `pikerd` (i.e. the
# same `Actor.uid` of the raising service-mngr task) the
# service-task's ctx itself was never marked as having
# requested cancellation and thus still raises the ctxc
# bc it was unaware of any such request.
#
# How to make grokin these cases easier tho?
# ------------------------------------------
# Because `Portal.cancel_actor()` was called it requests
# "full-`Actor`-runtime-cancellation" of it's peer
# process which IS NOT THE SAME as a single inter-actor
# RPC task cancelling its local context with a remote
# peer `Task` in that same peer process.
#
# ?TODO? It might be better if we do one (or all) of the
# following:
#
# -[ ] at least set a special message for the
# `ContextCancelled` when raised locally by the
# unaware ctx task such that we check for the
# `.canceller` being *our `Actor`* and in the case
# where `Context._cancel_called == False` we specially
# note that this is likely an "out-of-band"
# runtime-cancel request triggered by some call to
# `Portal.cancel_actor()`, possibly even reporting the
# exact LOC of that caller by tracking it inside our
# portal-type?
# -[ ] possibly add another field `ContextCancelled` like
# maybe a,
# `.request_type: Literal['os', 'proc', 'actor',
# 'ctx']` type thing which would allow immediately
# being able to tell what kind of cancellation caused
# the unexpected ctxc?
# -[ ] REMOVE THIS COMMENT, once we've settled on how to
# better augment `tractor` to be more explicit on this!
except BaseException as _err: except BaseException as _err:
err = _err err = _err
@ -218,13 +152,11 @@ async def maybe_spawn_daemon(
lock.statistics().owner is current_task() lock.statistics().owner is current_task()
): ):
log.exception( log.exception(
f'Releasing stale lock after crash..?\n' f'Releasing stale lock after crash..?'
f'\n'
f'{err!r}\n' f'{err!r}\n'
) )
lock.release() lock.release()
raise err
raise
async def spawn_emsd( async def spawn_emsd(

View File

@ -48,7 +48,7 @@ log = get_logger(name=__name__)
# new actors and supervises them to completion? # new actors and supervises them to completion?
class Services: class Services:
actor_n: tractor.ActorNursery actor_n: tractor._supervise.ActorNursery
service_n: trio.Nursery service_n: trio.Nursery
debug_mode: bool # tractor sub-actor debug mode flag debug_mode: bool # tractor sub-actor debug mode flag
service_tasks: dict[ service_tasks: dict[

View File

@ -225,13 +225,10 @@ async def check_for_service(
''' '''
async with ( async with (
open_registry( open_registry(ensure_exists=False) as reg_addr,
addrs=Registry.addrs,
ensure_exists=False,
) as reg_addrs,
tractor.query_actor( tractor.query_actor(
service_name, service_name,
regaddr=reg_addrs[0], arbiter_sockaddr=reg_addr,
) as (sockaddr, _), ) as sockaddr,
): ):
return sockaddr return sockaddr

View File

@ -42,7 +42,6 @@ from msgspec.msgpack import (
# import pyqtgraph as pg # import pyqtgraph as pg
import numpy as np import numpy as np
import tractor import tractor
from tractor._exceptions import reg_err_types
from trio_websocket import open_websocket_url from trio_websocket import open_websocket_url
from anyio_marketstore import ( # noqa from anyio_marketstore import ( # noqa
open_marketstore_client, open_marketstore_client,
@ -383,8 +382,6 @@ def quote_to_marketstore_structarray(
class MarketStoreError(Exception): class MarketStoreError(Exception):
"Generic marketstore client error" "Generic marketstore client error"
reg_err_types([MarketStoreError])
# def err_on_resp(response: dict) -> None: # def err_on_resp(response: dict) -> None:
# """Raise any errors found in responses from client request. # """Raise any errors found in responses from client request.

View File

@ -42,17 +42,15 @@ from typing import (
) )
import numpy as np import numpy as np
from tractor._exceptions import reg_err_types
from piker import config from .. import config
from piker.log import ( from ..service import (
check_for_service,
)
from ..log import (
get_logger, get_logger,
get_console_log, get_console_log,
) )
from piker.service import (
check_for_service,
)
subsys: str = 'piker.storage' subsys: str = 'piker.storage'
log = get_logger(subsys) log = get_logger(subsys)
@ -153,12 +151,6 @@ class StorageConnectionError(ConnectionError):
''' '''
reg_err_types([
TimeseriesNotFound,
StorageConnectionError,
])
def get_storagemod( def get_storagemod(
name: str, name: str,

View File

@ -292,11 +292,6 @@ def ldshm(
f'Something is wrong with time period for {shm}:\n{times}' f'Something is wrong with time period for {shm}:\n{times}'
) )
period_s: float = float(max(d1, d2, med)) period_s: float = float(max(d1, d2, med))
log.info(
f'Processing shm buffer:\n'
f' file: {shmfile.name}\n'
f' period: {period_s}s\n'
)
null_segs: tuple = tsp.get_null_segs( null_segs: tuple = tsp.get_null_segs(
frame=shm.array, frame=shm.array,
@ -306,7 +301,7 @@ def ldshm(
# TODO: call null-seg fixer somehow? # TODO: call null-seg fixer somehow?
if null_segs: if null_segs:
if tractor.runtime._state.is_debug_mode(): if tractor._state.is_debug_mode():
await tractor.pause() await tractor.pause()
# async with ( # async with (
# trio.open_nursery() as tn, # trio.open_nursery() as tn,

View File

@ -276,41 +276,14 @@ def get_null_segs(
absi_zdiff: np.ndarray = np.diff(absi_zeros) absi_zdiff: np.ndarray = np.diff(absi_zeros)
if zero_t.size < 2: if zero_t.size < 2:
idx: int = zero_t['index'][0] try:
idx_before: int = idx - 1 breakpoint()
idx_after: int = idx + 1 except RuntimeError:
index = frame['index'] # XXX, if greenback not active from
before_cond = idx_before <= index # piker store ldshm cmd..
after_cond = index <= idx_after log.exception(
bars: np.ndarray = frame[ "Can't debug single-sample null!\n"
before_cond
&
after_cond
]
time: np.ndarray = bars['time']
from pendulum import (
from_timestamp,
Interval,
) )
gap: Interval = (
from_timestamp(time[-1])
-
from_timestamp(time[0])
)
log.warning(
f'Single OHLCV-bar null-segment detected??\n'
f'gap -> {gap}\n'
)
# ^^XXX, if you want to debug the above bar-gap^^
# try:
# breakpoint()
# except RuntimeError:
# # XXX, if greenback not active from
# # piker store ldshm cmd..
# log.exception(
# "Can't debug single-sample null!\n"
# )
return None return None

View File

@ -30,11 +30,6 @@ import tractor
from piker.data._formatters import BGM from piker.data._formatters import BGM
from piker.storage import log from piker.storage import log
from piker.toolz.profile import (
Profiler,
pg_profile_enabled,
ms_slower_then,
)
from piker.ui._style import get_fonts from piker.ui._style import get_fonts
if TYPE_CHECKING: if TYPE_CHECKING:
@ -97,22 +92,12 @@ async def markup_gaps(
# gap's duration. # gap's duration.
show_txt: bool = False, show_txt: bool = False,
# A/B comparison: render individual arrows alongside batch
# for visual comparison
show_individual_arrows: bool = False,
) -> dict[int, dict]: ) -> dict[int, dict]:
''' '''
Remote annotate time-gaps in a dt-fielded ts (normally OHLC) Remote annotate time-gaps in a dt-fielded ts (normally OHLC)
with rectangles. with rectangles.
''' '''
profiler = Profiler(
msg=f'markup_gaps() for {gaps.height} gaps',
disabled=False,
ms_threshold=0.0,
)
# XXX: force chart redraw FIRST to ensure PlotItem coordinate # XXX: force chart redraw FIRST to ensure PlotItem coordinate
# system is properly initialized before we position annotations! # system is properly initialized before we position annotations!
# Without this, annotations may be misaligned on first creation # Without this, annotations may be misaligned on first creation
@ -121,19 +106,6 @@ async def markup_gaps(
fqme=fqme, fqme=fqme,
timeframe=timeframe, timeframe=timeframe,
) )
profiler('first `.redraw()` before annot creation')
log.info(
f'markup_gaps() called:\n'
f' fqme: {fqme}\n'
f' timeframe: {timeframe}s\n'
f' gaps.height: {gaps.height}\n'
)
# collect all annotation specs for batch submission
rect_specs: list[dict] = []
arrow_specs: list[dict] = []
text_specs: list[dict] = []
aids: dict[int] = {} aids: dict[int] = {}
for i in range(gaps.height): for i in range(gaps.height):
@ -196,7 +168,7 @@ async def markup_gaps(
prev_r: pl.DataFrame = prev_row_by_i prev_r: pl.DataFrame = prev_row_by_i
# debug any missing pre-row # debug any missing pre-row
if tractor.runtime._state.is_debug_mode(): if tractor._state.is_debug_mode():
await tractor.pause() await tractor.pause()
istart: int = prev_r['index'][0] istart: int = prev_r['index'][0]
@ -245,38 +217,56 @@ async def markup_gaps(
# 1: 'wine', # down-gap # 1: 'wine', # down-gap
# }[sgn] # }[sgn]
# collect rect spec (no fqme/timeframe, added by batch rect_kwargs: dict[str, Any] = dict(
# API) fqme=fqme,
rect_spec: dict[str, Any] = dict( timeframe=timeframe,
meth='set_view_pos',
start_pos=lc, start_pos=lc,
end_pos=ro, end_pos=ro,
color=color, color=color,
update_label=False,
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
) )
rect_specs.append(rect_spec)
# add up/down rects
aid: int|None = await actl.add_rect(**rect_kwargs)
if aid is None:
log.error(
f'Failed to add rect for,\n'
f'{rect_kwargs!r}\n'
f'\n'
f'Skipping to next gap!\n'
)
continue
assert aid
aids[aid] = rect_kwargs
direction: str = ( direction: str = (
'down' if down_gap 'down' if down_gap
else 'up' else 'up'
) )
# TODO! mk this a `msgspec.Struct` which we deserialize
# collect arrow spec # on the server side!
# XXX: send timestamp for server-side index lookup
# to ensure alignment with current shm state
gap_time: float = row['time'][0] gap_time: float = row['time'][0]
arrow_spec: dict[str, Any] = dict( arrow_kwargs: dict[str, Any] = dict(
fqme=fqme,
timeframe=timeframe,
x=iend, # fallback if timestamp lookup fails x=iend, # fallback if timestamp lookup fails
y=cls, y=cls,
time=gap_time, # for server-side index lookup time=gap_time, # for server-side index lookup
color=color, color=color,
alpha=169, alpha=169,
pointing=direction, pointing=direction,
# TODO: expose these as params to markup_gaps()?
headLen=10, headLen=10,
headWidth=2.222, headWidth=2.222,
pxMode=True, pxMode=True,
) )
arrow_specs.append(arrow_spec)
aid: int = await actl.add_arrow(
**arrow_kwargs
)
# add duration label to RHS of arrow # add duration label to RHS of arrow
if up_gap: if up_gap:
@ -288,12 +278,15 @@ async def markup_gaps(
assert flat assert flat
anchor = (0, 0) # up from bottom anchor = (0, 0) # up from bottom
# collect text spec if enabled # use a slightly smaller font for gap label txt.
if show_txt:
font, small_font = get_fonts() font, small_font = get_fonts()
font_size: int = small_font.px_size - 1 font_size: int = small_font.px_size - 1
assert isinstance(font_size, int)
text_spec: dict[str, Any] = dict( if show_txt:
text_aid: int = await actl.add_text(
fqme=fqme,
timeframe=timeframe,
text=gap_label, text=gap_label,
x=iend + 1, # fallback if timestamp lookup fails x=iend + 1, # fallback if timestamp lookup fails
y=cls, y=cls,
@ -302,46 +295,12 @@ async def markup_gaps(
anchor=anchor, anchor=anchor,
font_size=font_size, font_size=font_size,
) )
text_specs.append(text_spec) aids[text_aid] = {'text': gap_label}
# submit all annotations in single batch IPC msg # tell chart to redraw all its
log.info( # graphics view layers Bo
f'Submitting batch annotations:\n'
f' rects: {len(rect_specs)}\n'
f' arrows: {len(arrow_specs)}\n'
f' texts: {len(text_specs)}\n'
)
profiler('built all annotation specs')
result: dict[str, list[int]] = await actl.add_batch(
fqme=fqme,
timeframe=timeframe,
rects=rect_specs,
arrows=arrow_specs,
texts=text_specs,
show_individual_arrows=show_individual_arrows,
)
profiler('batch `.add_batch()` IPC call complete')
# build aids dict from batch results
for aid in result['rects']:
aids[aid] = {'type': 'rect'}
for aid in result['arrows']:
aids[aid] = {'type': 'arrow'}
for aid in result['texts']:
aids[aid] = {'type': 'text'}
log.info(
f'Batch submission complete: {len(aids)} annotation(s) '
f'created'
)
profiler('built aids result dict')
# tell chart to redraw all its graphics view layers
await actl.redraw( await actl.redraw(
fqme=fqme, fqme=fqme,
timeframe=timeframe, timeframe=timeframe,
) )
profiler('final `.redraw()` after annot creation')
return aids return aids

View File

@ -738,21 +738,12 @@ async def start_backfill(
# including the dst[/src] source asset token. SO, # including the dst[/src] source asset token. SO,
# 'tsla.nasdaq.ib' over 'tsla/usd.nasdaq.ib' for # 'tsla.nasdaq.ib' over 'tsla/usd.nasdaq.ib' for
# historical reasons ONLY. # historical reasons ONLY.
if ( if mkt.dst.atype not in {
mkt.dst.atype not in {
'crypto', 'crypto',
'crypto_currency', 'crypto_currency',
'fiat', # a "forex pair" 'fiat', # a "forex pair"
'perpetual_future', # stupid "perps" from cex land 'perpetual_future', # stupid "perps" from cex land
} }:
and not (
mkt.src.atype == 'crypto_currency'
and
mkt.dst.atype in {
'future',
}
)
):
col_sym_key: str = mkt.get_fqme( col_sym_key: str = mkt.get_fqme(
delim_char='', delim_char='',
without_src=True, without_src=True,

View File

@ -24,11 +24,8 @@ from pyqtgraph import (
Point, Point,
functions as fn, functions as fn,
Color, Color,
GraphicsObject,
) )
from pyqtgraph.Qt import internals
import numpy as np import numpy as np
import pyqtgraph as pg
from piker.ui.qt import ( from piker.ui.qt import (
QtCore, QtCore,
@ -38,10 +35,6 @@ from piker.ui.qt import (
QRectF, QRectF,
QGraphicsPathItem, QGraphicsPathItem,
) )
from piker.ui._style import hcolor
from piker.log import get_logger
log = get_logger(__name__)
def mk_marker_path( def mk_marker_path(
@ -111,7 +104,7 @@ def mk_marker_path(
class LevelMarker(QGraphicsPathItem): class LevelMarker(QGraphicsPathItem):
''' '''
An arrow marker path graphic which redraws itself An arrow marker path graphich which redraws itself
to the specified view coordinate level on each paint cycle. to the specified view coordinate level on each paint cycle.
''' '''
@ -258,9 +251,9 @@ def qgo_draw_markers(
) -> float: ) -> float:
''' '''
Paint markers in ``pg.GraphicsItem`` style by first removing the Paint markers in ``pg.GraphicsItem`` style by first
view transform for the painter, drawing the markers in scene removing the view transform for the painter, drawing the markers
coords, then restoring the view coords. in scene coords, then restoring the view coords.
''' '''
# paint markers in native coordinate system # paint markers in native coordinate system
@ -302,449 +295,3 @@ def qgo_draw_markers(
p.setTransform(orig_tr) p.setTransform(orig_tr)
return max(sizes) return max(sizes)
class GapAnnotations(GraphicsObject):
'''
Batch-rendered gap annotations using Qt's efficient drawing
APIs.
Instead of creating individual `QGraphicsItem` instances per
gap (which is very slow for 1000+ gaps), this class stores all
gap rectangles and arrows in numpy-backed arrays and renders
them in single batch paint calls.
Performance: ~1000x faster than individual items for large gap
counts.
Based on patterns from:
- `pyqtgraph.BarGraphItem` (batch rect rendering)
- `pyqtgraph.ScatterPlotItem` (fragment rendering)
- `piker.ui._curve.FlowGraphic` (single path pattern)
'''
def __init__(
self,
gap_specs: list[dict],
array: np.ndarray|None = None,
color: str = 'dad_blue',
alpha: int = 169,
arrow_size: float = 10.0,
fqme: str|None = None,
timeframe: float|None = None,
) -> None:
'''
gap_specs: list of dicts with keys:
- start_pos: (x, y) tuple for left corner of rect
- end_pos: (x, y) tuple for right corner of rect
- arrow_x: x position for arrow
- arrow_y: y position for arrow
- pointing: 'up' or 'down' for arrow direction
- start_time: (optional) timestamp for repositioning
- end_time: (optional) timestamp for repositioning
array: optional OHLC numpy array for repositioning on
backfill updates (when abs-index changes)
fqme: symbol name for these gaps (for logging/debugging)
timeframe: period in seconds that these gaps were
detected on (used to skip reposition when
called with wrong timeframe's array)
'''
super().__init__()
self._gap_specs = gap_specs
self._array = array
self._fqme = fqme
self._timeframe = timeframe
n_gaps = len(gap_specs)
# shared pen/brush matching original SelectRect/ArrowItem style
base_color = pg.mkColor(hcolor(color))
# rect pen: base color, fully opaque for outline
self._rect_pen = pg.mkPen(base_color, width=1)
# rect brush: base color with alpha=66 (SelectRect default)
rect_fill = pg.mkColor(hcolor(color))
rect_fill.setAlpha(66)
self._rect_brush = pg.functions.mkBrush(rect_fill)
# arrow pen: same as rects
self._arrow_pen = pg.mkPen(base_color, width=1)
# arrow brush: base color with user-specified alpha (default 169)
arrow_fill = pg.mkColor(hcolor(color))
arrow_fill.setAlpha(alpha)
self._arrow_brush = pg.functions.mkBrush(arrow_fill)
# allocate rect array using Qt's efficient storage
self._rectarray = internals.PrimitiveArray(
QtCore.QRectF,
4,
)
self._rectarray.resize(n_gaps)
rect_memory = self._rectarray.ndarray()
# fill rect array from gap specs
for (
i,
spec,
) in enumerate(gap_specs):
(
start_x,
start_y,
) = spec['start_pos']
(
end_x,
end_y,
) = spec['end_pos']
# QRectF expects (x, y, width, height)
rect_memory[i, 0] = start_x
rect_memory[i, 1] = min(start_y, end_y)
rect_memory[i, 2] = end_x - start_x
rect_memory[i, 3] = abs(end_y - start_y)
# build single QPainterPath for all arrows
self._arrow_path = QtGui.QPainterPath()
self._arrow_size = arrow_size
for spec in gap_specs:
arrow_x = spec['arrow_x']
arrow_y = spec['arrow_y']
pointing = spec['pointing']
# create arrow polygon
if pointing == 'down':
# arrow points downward
arrow_poly = QtGui.QPolygonF([
QPointF(arrow_x, arrow_y), # tip
QPointF(
arrow_x - arrow_size/2,
arrow_y - arrow_size,
), # left
QPointF(
arrow_x + arrow_size/2,
arrow_y - arrow_size,
), # right
])
else: # up
# arrow points upward
arrow_poly = QtGui.QPolygonF([
QPointF(arrow_x, arrow_y), # tip
QPointF(
arrow_x - arrow_size/2,
arrow_y + arrow_size,
), # left
QPointF(
arrow_x + arrow_size/2,
arrow_y + arrow_size,
), # right
])
self._arrow_path.addPolygon(arrow_poly)
self._arrow_path.closeSubpath()
# cache bounding rect
self._br: QRectF|None = None
def boundingRect(self) -> QRectF:
'''
Compute bounding rect from rect array and arrow path.
'''
if self._br is not None:
return self._br
# get rect bounds
rect_memory = self._rectarray.ndarray()
if len(rect_memory) == 0:
self._br = QRectF()
return self._br
x_min = rect_memory[:, 0].min()
y_min = rect_memory[:, 1].min()
x_max = (rect_memory[:, 0] + rect_memory[:, 2]).max()
y_max = (rect_memory[:, 1] + rect_memory[:, 3]).max()
# expand for arrow path
arrow_br = self._arrow_path.boundingRect()
x_min = min(x_min, arrow_br.left())
y_min = min(y_min, arrow_br.top())
x_max = max(x_max, arrow_br.right())
y_max = max(y_max, arrow_br.bottom())
self._br = QRectF(
x_min,
y_min,
x_max - x_min,
y_max - y_min,
)
return self._br
def paint(
self,
p: QtGui.QPainter,
opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.QWidget,
) -> None:
'''
Batch render all rects and arrows in minimal paint calls.
'''
# draw all rects in single batch call (data coordinates)
p.setPen(self._rect_pen)
p.setBrush(self._rect_brush)
drawargs = self._rectarray.drawargs()
p.drawRects(*drawargs)
# draw arrows in scene/pixel coordinates so they maintain
# size regardless of zoom level
orig_tr = p.transform()
p.resetTransform()
# rebuild arrow path in scene coordinates
arrow_path_scene = QtGui.QPainterPath()
# arrow geometry matching pg.ArrowItem defaults
# headLen=10, headWidth=2.222
# headWidth is the half-width (center to edge distance)
head_len = self._arrow_size
head_width = head_len * 0.2222 # 2.222 at size=10
for spec in self._gap_specs:
if 'arrow_x' not in spec:
continue
arrow_x = spec['arrow_x']
arrow_y = spec['arrow_y']
pointing = spec['pointing']
# transform data coords to scene coords
scene_pt = orig_tr.map(QPointF(arrow_x, arrow_y))
sx = scene_pt.x()
sy = scene_pt.y()
# create arrow polygon in scene/pixel coords
# matching pg.ArrowItem geometry but rotated for up/down
if pointing == 'down':
# tip points downward (negative y direction)
arrow_poly = QtGui.QPolygonF([
QPointF(sx, sy), # tip
QPointF(
sx - head_width,
sy - head_len,
), # left base
QPointF(
sx + head_width,
sy - head_len,
), # right base
])
else: # up
# tip points upward (positive y direction)
arrow_poly = QtGui.QPolygonF([
QPointF(sx, sy), # tip
QPointF(
sx - head_width,
sy + head_len,
), # left base
QPointF(
sx + head_width,
sy + head_len,
), # right base
])
arrow_path_scene.addPolygon(arrow_poly)
arrow_path_scene.closeSubpath()
p.setPen(self._arrow_pen)
p.setBrush(self._arrow_brush)
p.drawPath(arrow_path_scene)
# restore original transform
p.setTransform(orig_tr)
def reposition(
self,
array: np.ndarray|None = None,
fqme: str|None = None,
timeframe: float|None = None,
) -> None:
'''
Reposition all annotations based on timestamps.
Used when viz is updated (eg during backfill) and abs-index
range changes - we need to lookup new indices from timestamps.
'''
# skip reposition if timeframe doesn't match
# (e.g., 1s gaps being repositioned with 60s array)
if (
timeframe is not None
and
self._timeframe is not None
and
timeframe != self._timeframe
):
log.debug(
f'Skipping reposition for {self._fqme} gaps:\n'
f' gap timeframe: {self._timeframe}s\n'
f' array timeframe: {timeframe}s\n'
)
return
if array is None:
array = self._array
if array is None:
log.warning(
'GapAnnotations.reposition() called but no array '
'provided'
)
return
# collect all unique timestamps we need to lookup
timestamps: set[float] = set()
for spec in self._gap_specs:
if spec.get('start_time') is not None:
timestamps.add(spec['start_time'])
if spec.get('end_time') is not None:
timestamps.add(spec['end_time'])
if spec.get('time') is not None:
timestamps.add(spec['time'])
# vectorized timestamp -> row lookup using binary search
time_to_row: dict[float, dict] = {}
if timestamps:
import numpy as np
time_arr = array['time']
ts_array = np.array(list(timestamps))
search_indices = np.searchsorted(
time_arr,
ts_array,
)
# vectorized bounds check and exact match verification
valid_mask = (
(search_indices < len(array))
& (time_arr[search_indices] == ts_array)
)
valid_indices = search_indices[valid_mask]
valid_timestamps = ts_array[valid_mask]
matched_rows = array[valid_indices]
time_to_row = {
float(ts): {
'index': float(row['index']),
'open': float(row['open']),
'close': float(row['close']),
}
for ts, row in zip(
valid_timestamps,
matched_rows,
)
}
# rebuild rect array from gap specs with new indices
rect_memory = self._rectarray.ndarray()
for (
i,
spec,
) in enumerate(self._gap_specs):
start_time = spec.get('start_time')
end_time = spec.get('end_time')
if (
start_time is None
or end_time is None
):
continue
start_row = time_to_row.get(start_time)
end_row = time_to_row.get(end_time)
if (
start_row is None
or end_row is None
):
log.warning(
f'Timestamp lookup failed for gap[{i}] during '
f'reposition:\n'
f' fqme: {fqme}\n'
f' timeframe: {timeframe}s\n'
f' start_time: {start_time}\n'
f' end_time: {end_time}\n'
f' array time range: '
f'{array["time"][0]} -> {array["time"][-1]}\n'
)
continue
start_idx = start_row['index']
end_idx = end_row['index']
start_close = start_row['close']
end_open = end_row['open']
from_idx: float = 0.16 - 0.06
start_x = start_idx + 1 - from_idx
end_x = end_idx + from_idx
# update rect in array
rect_memory[i, 0] = start_x
rect_memory[i, 1] = min(start_close, end_open)
rect_memory[i, 2] = end_x - start_x
rect_memory[i, 3] = abs(end_open - start_close)
# rebuild arrow path with new indices
self._arrow_path.clear()
for spec in self._gap_specs:
time_val = spec.get('time')
if time_val is None:
continue
arrow_row = time_to_row.get(time_val)
if arrow_row is None:
continue
arrow_x = arrow_row['index']
arrow_y = arrow_row['close']
pointing = spec['pointing']
# create arrow polygon
if pointing == 'down':
arrow_poly = QtGui.QPolygonF([
QPointF(arrow_x, arrow_y),
QPointF(
arrow_x - self._arrow_size/2,
arrow_y - self._arrow_size,
),
QPointF(
arrow_x + self._arrow_size/2,
arrow_y - self._arrow_size,
),
])
else: # up
arrow_poly = QtGui.QPolygonF([
QPointF(arrow_x, arrow_y),
QPointF(
arrow_x - self._arrow_size/2,
arrow_y + self._arrow_size,
),
QPointF(
arrow_x + self._arrow_size/2,
arrow_y + self._arrow_size,
),
])
self._arrow_path.addPolygon(arrow_poly)
self._arrow_path.closeSubpath()
# invalidate bounding rect cache
self._br = None
self.prepareGeometryChange()
self.update()

View File

@ -32,12 +32,12 @@ from . import _event
from . import _search from . import _search
from ..accounting import unpack_fqme from ..accounting import unpack_fqme
from ..data._symcache import open_symcache from ..data._symcache import open_symcache
from ..data.feed import install_datad_search from ..data.feed import install_brokerd_search
from ..log import ( from ..log import (
get_logger, get_logger,
get_console_log, get_console_log,
) )
from ..service import maybe_spawn_datad from ..service import maybe_spawn_brokerd
from ._exec import run_qtractor from ._exec import run_qtractor
log = get_logger(__name__) log = get_logger(__name__)
@ -50,16 +50,16 @@ async def load_provider_search(
) -> None: ) -> None:
name = brokermod.name name = brokermod.name
log.info(f'loading datad for {name}..') log.info(f'loading brokerd for {name}..')
async with ( async with (
maybe_spawn_datad( maybe_spawn_brokerd(
name, name,
loglevel=loglevel loglevel=loglevel
) as portal, ) as portal,
install_datad_search( install_brokerd_search(
portal, portal,
brokermod, brokermod,
), ),
@ -131,11 +131,11 @@ async def _async_main(
async with ( async with (
tractor.trionics.collapse_eg(), tractor.trionics.collapse_eg(),
trio.open_nursery() as tn, trio.open_nursery() as root_n,
): ):
# set root nursery and task stack for spawning other charts/feeds # set root nursery and task stack for spawning other charts/feeds
# that run cached in the bg # that run cached in the bg
godwidget._root_n = tn godwidget._root_n = root_n
# setup search widget and focus main chart view at startup # setup search widget and focus main chart view at startup
# search widget is a singleton alongside the godwidget # search widget is a singleton alongside the godwidget
@ -165,7 +165,7 @@ async def _async_main(
# load other providers into search **after** # load other providers into search **after**
# the chart's select cache # the chart's select cache
for brokername, mod in needed_brokermods.items(): for brokername, mod in needed_brokermods.items():
tn.start_soon( root_n.start_soon(
load_provider_search, load_provider_search,
mod, mod,
loglevel, loglevel,

View File

@ -20,9 +20,8 @@ Chart axes graphics and behavior.
""" """
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from math import floor
import platform
from typing import Callable from typing import Callable
from math import floor
import polars as pl import polars as pl
import pyqtgraph as pg import pyqtgraph as pg
@ -43,7 +42,6 @@ from ._style import DpiAwareFont, hcolor, _font
from ._interaction import ChartView from ._interaction import ChartView
from ._dataviz import Viz from ._dataviz import Viz
_friggin_macos: bool = platform.system() == 'Darwin'
_axis_pen = pg.mkPen(hcolor('bracket')) _axis_pen = pg.mkPen(hcolor('bracket'))
@ -77,9 +75,6 @@ class Axis(pg.AxisItem):
self.pi = plotitem self.pi = plotitem
self._dpi_font = _font self._dpi_font = _font
# store for later recalculation on zoom
self._typical_max_str = typical_max_str
self.setTickFont(_font.font) self.setTickFont(_font.font)
font_size = self._dpi_font.font.pixelSize() font_size = self._dpi_font.font.pixelSize()
@ -161,42 +156,6 @@ class Axis(pg.AxisItem):
def size_to_values(self) -> None: def size_to_values(self) -> None:
pass pass
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate axis sizing after zoom change.'''
# IMPORTANT: tell Qt we're about to change geometry
self.prepareGeometryChange()
self._dpi_font = font
self.setTickFont(font.font)
font_size = font.font.pixelSize()
# recalculate text offset based on new font size
text_offset = None
if self.orientation in ('bottom',):
text_offset = floor(0.25 * font_size)
elif self.orientation in ('left', 'right'):
text_offset = floor(font_size / 2)
if text_offset:
self.setStyle(tickTextOffset=text_offset)
# recalculate bounding rect with new font
# Note: typical_max_str should be stored from init
if not hasattr(self, '_typical_max_str'):
self._typical_max_str = '100 000.000 ' # fallback default
self.typical_br = font._qfm.boundingRect(self._typical_max_str)
# Update PyQtGraph's internal text size tracking
# This is critical - PyQtGraph uses these internally for auto-expand
if self.orientation in ['left', 'right']:
self.textWidth = self.typical_br.width()
else:
self.textHeight = self.typical_br.height()
# resize axis to fit new font - this triggers PyQtGraph's auto-expand
self.size_to_values()
def txt_offsets(self) -> tuple[int, int]: def txt_offsets(self) -> tuple[int, int]:
return tuple(self.style['tickTextOffset']) return tuple(self.style['tickTextOffset'])
@ -296,23 +255,9 @@ class PriceAxis(Axis):
) -> None: ) -> None:
self._min_tick = size self._min_tick = size
if _friggin_macos:
def size_to_values(self) -> None: def size_to_values(self) -> None:
# Call PyQtGraph's internal width update mechanism
# This respects autoExpandTextSpace and updates min/max constraints
self._updateWidth()
# tell Qt our preferred size changed so layout recalculates
self.updateGeometry()
# force parent plot item to recalculate its layout
if self.pi and hasattr(self.pi, 'updateGeometry'):
self.pi.updateGeometry()
else:
def size_to_values(self) -> None:
# XXX, old code!
self.setWidth(self.typical_br.width()) self.setWidth(self.typical_br.width())
# XXX: drop for now since it just eats up h space # XXX: drop for now since it just eats up h space
def tickStrings( def tickStrings(
@ -354,20 +299,7 @@ class DynamicDateAxis(Axis):
1: '%H:%M:%S', 1: '%H:%M:%S',
} }
if _friggin_macos:
def size_to_values(self) -> None: def size_to_values(self) -> None:
# Call PyQtGraph's internal height update mechanism
# This respects autoExpandTextSpace and updates min/max constraints
self._updateHeight()
# tell Qt our preferred size changed so layout recalculates
self.updateGeometry()
# force parent plot item to recalculate its layout
if self.pi and hasattr(self.pi, 'updateGeometry'):
self.pi.updateGeometry()
else:
def size_to_values(self) -> None:
# XXX, old code!
self.setHeight(self.typical_br.height() + 1) self.setHeight(self.typical_br.height() + 1)
def _indexes_to_timestrs( def _indexes_to_timestrs(

View File

@ -1346,6 +1346,7 @@ async def display_symbol_data(
fqmes, fqmes,
loglevel=loglevel, loglevel=loglevel,
tick_throttle=cycles_per_feed, tick_throttle=cycles_per_feed,
) as feed, ) as feed,
): ):
@ -1460,7 +1461,7 @@ async def display_symbol_data(
async with ( async with (
tractor.trionics.collapse_eg(), tractor.trionics.collapse_eg(),
trio.open_nursery() as tn, trio.open_nursery() as ln,
): ):
# if available load volume related built-in display(s) # if available load volume related built-in display(s)
vlm_charts: dict[ vlm_charts: dict[
@ -1471,7 +1472,7 @@ async def display_symbol_data(
flume.has_vlm() flume.has_vlm()
and vlm_chart is None and vlm_chart is None
): ):
vlm_chart = vlm_charts[fqme] = await tn.start( vlm_chart = vlm_charts[fqme] = await ln.start(
open_vlm_displays, open_vlm_displays,
rt_linked, rt_linked,
flume, flume,
@ -1479,7 +1480,7 @@ async def display_symbol_data(
# load (user's) FSP set (otherwise known as "indicators") # load (user's) FSP set (otherwise known as "indicators")
# from an input config. # from an input config.
tn.start_soon( ln.start_soon(
start_fsp_displays, start_fsp_displays,
rt_linked, rt_linked,
flume, flume,
@ -1603,11 +1604,11 @@ async def display_symbol_data(
# start update loop task # start update loop task
dss: dict[str, DisplayState] = {} dss: dict[str, DisplayState] = {}
tn.start_soon( ln.start_soon(
partial( partial(
graphics_update_loop, graphics_update_loop,
dss=dss, dss=dss,
nurse=tn, nurse=ln,
godwidget=godwidget, godwidget=godwidget,
feed=feed, feed=feed,
# min_istream, # min_istream,
@ -1622,6 +1623,7 @@ async def display_symbol_data(
order_ctl_fqme: str = fqmes[0] order_ctl_fqme: str = fqmes[0]
mode: OrderMode mode: OrderMode
async with ( async with (
open_order_mode( open_order_mode(
feed, feed,
godwidget, godwidget,

View File

@ -168,7 +168,7 @@ class ArrowEditor(Struct):
''' '''
uid: str = arrow._uid uid: str = arrow._uid
arrows: list[pg.ArrowItem] = self._arrows[uid] arrows: list[pg.ArrowItem] = self._arrows[uid]
log.debug( log.info(
f'Removing arrow from views\n' f'Removing arrow from views\n'
f'uid: {uid!r}\n' f'uid: {uid!r}\n'
f'{arrow!r}\n' f'{arrow!r}\n'
@ -286,9 +286,7 @@ class LineEditor(Struct):
for line in lines: for line in lines:
line.show_labels() line.show_labels()
line.hide_markers() line.hide_markers()
log.debug( log.debug(f'Level active for level: {line.value()}')
f'Line active @ level: {line.value()!r}'
)
# TODO: other flashy things to indicate the order is active # TODO: other flashy things to indicate the order is active
return lines return lines
@ -331,11 +329,7 @@ class LineEditor(Struct):
if line in hovered: if line in hovered:
hovered.remove(line) hovered.remove(line)
log.debug( log.debug(f'deleting {line} with oid: {uuid}')
f'Deleting level-line\n'
f'line: {line!r}\n'
f'oid: {uuid!r}\n'
)
line.delete() line.delete()
# make sure the xhair doesn't get left off # make sure the xhair doesn't get left off
@ -343,11 +337,7 @@ class LineEditor(Struct):
cursor.show_xhair() cursor.show_xhair()
else: else:
log.warning( log.warning(f'Could not find line for {line}')
f'Could not find line for removal ??\n'
f'\n'
f'{line!r}\n'
)
return lines return lines
@ -579,11 +569,11 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
if update_label: if update_label:
self.init_label(view_rect) self.init_label(view_rect)
log.debug( print(
f'SelectRect modify,\n' 'SelectRect modify:\n'
f'QRectF: {view_rect}\n' f'QRectF: {view_rect}\n'
f'start_pos: {start_pos!r}\n' f'start_pos: {start_pos}\n'
f'end_pos: {end_pos!r}\n' f'end_pos: {end_pos}\n'
) )
self.show() self.show()
@ -650,11 +640,8 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
dmn=dmn, dmn=dmn,
)) ))
# tracing # print(f'x2, y2: {(x2, y2)}')
# log.info( # print(f'xmn, ymn: {(xmn, ymx)}')
# f'x2, y2: {(x2, y2)}\n'
# f'xmn, ymn: {(xmn, ymx)}\n'
# )
label_anchor = Point( label_anchor = Point(
xmx + 2, xmx + 2,

View File

@ -203,9 +203,6 @@ def run_qtractor(
if is_windows: if is_windows:
window.configure_to_desktop() window.configure_to_desktop()
# install global keyboard shortcuts for UI zoom
window.install_global_zoom_filter()
# actually render to screen # actually render to screen
window.show() window.show()
app.exec_() app.exec_()

View File

@ -124,13 +124,6 @@ class Edit(QLineEdit):
self.sizeHint() self.sizeHint()
self.update() self.update()
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate widget size.'''
self.dpi_font = font
self.setFont(font.font)
# tell Qt our size hint changed so it recalculates layout
self.updateGeometry()
def focus(self) -> None: def focus(self) -> None:
self.selectAll() self.selectAll()
self.show() self.show()
@ -248,14 +241,6 @@ class Selection(QComboBox):
icon_size = round(h * 0.75) icon_size = round(h * 0.75)
self.setIconSize(QSize(icon_size, icon_size)) self.setIconSize(QSize(icon_size, icon_size))
def update_fonts(self, font: DpiAwareFont) -> None:
'''Update font and recalculate widget size.'''
self.setFont(font.font)
# recalculate heights with new font
self.resize()
# tell Qt our size hint changed so it recalculates layout
self.updateGeometry()
def set_items( def set_items(
self, self,
keys: list[str], keys: list[str],
@ -446,39 +431,6 @@ class FieldsForm(QWidget):
self.fields[key] = select self.fields[key] = select
return select return select
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
from ._style import _font, _font_small
# update stored font size
self._font_size = _font_small.px_size - 2
# update all labels
for name, label in self.labels.items():
if hasattr(label, 'update_font'):
label.update_font(_font.font, self._font_size - 1)
# update all fields (edits, selects)
for key, field in self.fields.items():
# first check for our custom update_fonts method (Edit, Selection)
if hasattr(field, 'update_fonts'):
field.update_fonts(_font)
# then handle stylesheet updates for those without custom methods
elif hasattr(field, 'setStyleSheet'):
# regenerate stylesheet with new font size
field.setStyleSheet(
f"""QLineEdit {{
color : {hcolor('gunmetal')};
font-size : {self._font_size}px;
}}
"""
)
field.setFont(_font.font)
# for Selection widgets that need style updates
if hasattr(field, 'set_style'):
field.set_style(color='gunmetal', font_size=self._font_size)
async def handle_field_input( async def handle_field_input(
@ -681,37 +633,6 @@ class FillStatusBar(QProgressBar):
self.setRange(0, int(slots)) self.setRange(0, int(slots))
self.setValue(value) self.setValue(value)
def update_fonts(self, font_size: int) -> None:
'''Update font size after zoom change.'''
from ._style import _font_small
self.font_size = font_size
# regenerate stylesheet with new font size
self.setStyleSheet(
f"""
QProgressBar {{
text-align: center;
font-size : {self.font_size - 2}px;
background-color: {hcolor('papas_special')};
color : {hcolor('papas_special')};
border: {self.border_px}px solid {hcolor('default_light')};
border-radius: 2px;
}}
QProgressBar::chunk {{
background-color: {hcolor('default_spotlight')};
color: {hcolor('bracket')};
border-radius: 2px;
}}
"""
)
self.setFont(_font_small.font)
def mk_fill_status_bar( def mk_fill_status_bar(

View File

@ -334,19 +334,3 @@ class FormatLabel(QLabel):
out = self.fmt_str.format(**fields) out = self.fmt_str.format(**fields)
self.setText(out) self.setText(out)
return out return out
def update_font(
self,
font: QtGui.QFont,
font_size: int,
font_color: str = 'default_lightest',
) -> None:
'''Update font after zoom change.'''
self.setStyleSheet(
f"""QLabel {{
color : {hcolor(font_color)};
font-size : {font_size}px;
}}
"""
)
self.setFont(font)

View File

@ -38,6 +38,7 @@ from piker.ui.qt import (
QtGui, QtGui,
QGraphicsPathItem, QGraphicsPathItem,
QStyleOptionGraphicsItem, QStyleOptionGraphicsItem,
QGraphicsItem,
QGraphicsScene, QGraphicsScene,
QWidget, QWidget,
QPointF, QPointF,

View File

@ -178,26 +178,6 @@ class SettingsPane:
# encompasing high level namespace # encompasing high level namespace
order_mode: OrderMode | None = None # typing: ignore # noqa order_mode: OrderMode | None = None # typing: ignore # noqa
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
from ._style import _font_small
# update form fields
if self.form and hasattr(self.form, 'update_fonts'):
self.form.update_fonts()
# update fill status bar
if self.fill_bar and hasattr(self.fill_bar, 'update_fonts'):
self.fill_bar.update_fonts(_font_small.px_size)
# update labels with new fonts
if self.step_label:
self.step_label.setFont(_font_small.font)
if self.pnl_label:
self.pnl_label.setFont(_font_small.font)
if self.limit_label:
self.limit_label.setFont(_font_small.font)
def set_accounts( def set_accounts(
self, self,
names: list[str], names: list[str],

View File

@ -22,7 +22,6 @@ a chart from some other actor.
from __future__ import annotations from __future__ import annotations
from contextlib import ( from contextlib import (
asynccontextmanager as acm, asynccontextmanager as acm,
contextmanager as cm,
AsyncExitStack, AsyncExitStack,
) )
from functools import partial from functools import partial
@ -47,7 +46,6 @@ from piker.log import get_logger
from piker.types import Struct from piker.types import Struct
from piker.service import find_service from piker.service import find_service
from piker.brokers import SymbolNotFound from piker.brokers import SymbolNotFound
from piker.toolz import Profiler
from piker.ui.qt import ( from piker.ui.qt import (
QGraphicsItem, QGraphicsItem,
) )
@ -100,8 +98,6 @@ def rm_annot(
annot: ArrowEditor|SelectRect|pg.TextItem annot: ArrowEditor|SelectRect|pg.TextItem
) -> bool: ) -> bool:
global _editors global _editors
from piker.ui._annotate import GapAnnotations
match annot: match annot:
case pg.ArrowItem(): case pg.ArrowItem():
editor = _editors[annot._uid] editor = _editors[annot._uid]
@ -126,35 +122,9 @@ def rm_annot(
scene.removeItem(annot) scene.removeItem(annot)
return True return True
case GapAnnotations():
scene = annot.scene()
if scene:
scene.removeItem(annot)
return True
return False return False
@cm
def no_qt_updates(*items):
'''
Disable Qt widget/item updates during context to batch
render operations and only trigger single repaint on exit.
Accepts both QWidgets and QGraphicsItems.
'''
for item in items:
if hasattr(item, 'setUpdatesEnabled'):
item.setUpdatesEnabled(False)
try:
yield
finally:
for item in items:
if hasattr(item, 'setUpdatesEnabled'):
item.setUpdatesEnabled(True)
async def serve_rc_annots( async def serve_rc_annots(
ipc_key: str, ipc_key: str,
annot_req_stream: MsgStream, annot_req_stream: MsgStream,
@ -459,333 +429,6 @@ async def serve_rc_annots(
aids.add(aid) aids.add(aid)
await annot_req_stream.send(aid) await annot_req_stream.send(aid)
case {
'cmd': 'batch',
'fqme': fqme,
'timeframe': timeframe,
'rects': list(rect_specs),
'arrows': list(arrow_specs),
'texts': list(text_specs),
'show_individual_arrows': bool(show_individual_arrows),
}:
# batch submission handler - process multiple
# annotations in single IPC round-trip
ds: DisplayState = _dss[fqme]
try:
chart: ChartPlotWidget = {
60: ds.hist_chart,
1: ds.chart,
}[timeframe]
except KeyError:
msg: str = (
f'No chart for timeframe={timeframe}s, '
f'skipping batch annotation'
)
log.error(msg)
await annot_req_stream.send({'error': msg})
continue
cv: ChartView = chart.cv
viz: Viz = chart.get_viz(fqme)
shm = viz.shm
arr = shm.array
result: dict[str, list[int]] = {
'rects': [],
'arrows': [],
'texts': [],
}
profiler = Profiler(
msg=(
f'Batch annotate {len(rect_specs)} gaps '
f'on {fqme}@{timeframe}s'
),
disabled=False,
delayed=False,
)
aids_set: set[int] = ctxs[ipc_key][1]
# build unified gap_specs for GapAnnotations class
from piker.ui._annotate import GapAnnotations
gap_specs: list[dict] = []
n_gaps: int = max(
len(rect_specs),
len(arrow_specs),
)
profiler('setup batch annot creation')
# collect all unique timestamps for vectorized lookup
timestamps: list[float] = []
for rect_spec in rect_specs:
if start_time := rect_spec.get('start_time'):
timestamps.append(start_time)
if end_time := rect_spec.get('end_time'):
timestamps.append(end_time)
for arrow_spec in arrow_specs:
if time_val := arrow_spec.get('time'):
timestamps.append(time_val)
profiler('collect `timestamps: list` complet!')
# build timestamp -> row mapping using binary search
# O(m log n) instead of O(n*m) with np.isin
time_to_row: dict[float, dict] = {}
if timestamps:
import numpy as np
time_arr = arr['time']
ts_array = np.array(timestamps)
# binary search for each timestamp in sorted time array
search_indices = np.searchsorted(
time_arr,
ts_array,
)
profiler('`np.searchsorted()` complete!')
# vectorized bounds check and exact match verification
valid_mask = (
(search_indices < len(arr))
& (time_arr[search_indices] == ts_array)
)
# get all valid indices and timestamps
valid_indices = search_indices[valid_mask]
valid_timestamps = ts_array[valid_mask]
# use fancy indexing to get all rows at once
matched_rows = arr[valid_indices]
# extract fields to plain arrays BEFORE dict building
indices_arr = matched_rows['index'].astype(float)
opens_arr = matched_rows['open'].astype(float)
closes_arr = matched_rows['close'].astype(float)
profiler('extracted field arrays')
# build dict from plain arrays (much faster)
time_to_row: dict[float, dict] = {
float(ts): {
'index': idx,
'open': opn,
'close': cls,
}
for (
ts,
idx,
opn,
cls,
) in zip(
valid_timestamps,
indices_arr,
opens_arr,
closes_arr,
)
}
profiler('`time_to_row` creation complete!')
profiler(f'built timestamp lookup for {len(timestamps)} times')
# build gap_specs from rect+arrow specs
for i in range(n_gaps):
gap_spec: dict = {}
# get rect spec for this gap
if i < len(rect_specs):
rect_spec: dict = rect_specs[i].copy()
start_time = rect_spec.get('start_time')
end_time = rect_spec.get('end_time')
if (
start_time is not None
and end_time is not None
):
# lookup from pre-built mapping
start_row = time_to_row.get(start_time)
end_row = time_to_row.get(end_time)
if (
start_row is None
or end_row is None
):
log.warning(
f'Timestamp lookup failed for '
f'gap[{i}], skipping'
)
continue
start_idx = start_row['index']
end_idx = end_row['index']
start_close = start_row['close']
end_open = end_row['open']
from_idx: float = 0.16 - 0.06
gap_spec['start_pos'] = (
start_idx + 1 - from_idx,
start_close,
)
gap_spec['end_pos'] = (
end_idx + from_idx,
end_open,
)
gap_spec['start_time'] = start_time
gap_spec['end_time'] = end_time
gap_spec['color'] = rect_spec.get(
'color',
'dad_blue',
)
# get arrow spec for this gap
if i < len(arrow_specs):
arrow_spec: dict = arrow_specs[i].copy()
x: float = float(arrow_spec.get('x', 0))
y: float = float(arrow_spec.get('y', 0))
time_val: float|None = arrow_spec.get('time')
# timestamp-based index lookup (only for x, NOT y!)
# y is already set to the PREVIOUS bar's close
if time_val is not None:
arrow_row = time_to_row.get(time_val)
if arrow_row is not None:
x = arrow_row['index']
# NOTE: do NOT update y! it's the
# previous bar's close, not current
else:
log.warning(
f'Arrow timestamp {time_val} not '
f'found for gap[{i}], using x={x}'
)
gap_spec['arrow_x'] = x
gap_spec['arrow_y'] = y
gap_spec['time'] = time_val
gap_spec['pointing'] = arrow_spec.get(
'pointing',
'down',
)
gap_spec['alpha'] = arrow_spec.get('alpha', 169)
gap_specs.append(gap_spec)
profiler(f'built {len(gap_specs)} gap_specs')
# create single GapAnnotations item for all gaps
if gap_specs:
gaps_item = GapAnnotations(
gap_specs=gap_specs,
array=arr,
color=gap_specs[0].get('color', 'dad_blue'),
alpha=gap_specs[0].get('alpha', 169),
arrow_size=10.0,
fqme=fqme,
timeframe=timeframe,
)
chart.plotItem.addItem(gaps_item)
# register single item for repositioning
aid: int = id(gaps_item)
annots[aid] = gaps_item
aids_set.add(aid)
result['rects'].append(aid)
profiler(
f'created GapAnnotations item for {len(gap_specs)} '
f'gaps'
)
# A/B comparison: optionally create individual arrows
# alongside batch for visual comparison
if show_individual_arrows:
godw = chart.linked.godwidget
arrows: ArrowEditor = ArrowEditor(godw=godw)
for i, spec in enumerate(gap_specs):
if 'arrow_x' not in spec:
continue
aid_str: str = str(uuid4())
arrow: pg.ArrowItem = arrows.add(
plot=chart.plotItem,
uid=aid_str,
x=spec['arrow_x'],
y=spec['arrow_y'],
pointing=spec['pointing'],
color='bracket', # different color
alpha=spec.get('alpha', 169),
headLen=10.0,
headWidth=2.222,
pxMode=True,
)
arrow._abs_x = spec['arrow_x']
arrow._abs_y = spec['arrow_y']
annots[aid_str] = arrow
_editors[aid_str] = arrows
aids_set.add(aid_str)
result['arrows'].append(aid_str)
profiler(
f'created {len(gap_specs)} individual arrows '
f'for comparison'
)
# handle text items separately (less common, keep
# individual items)
n_texts: int = 0
for text_spec in text_specs:
kwargs: dict = text_spec.copy()
text: str = kwargs.pop('text')
x: float = float(kwargs.pop('x'))
y: float = float(kwargs.pop('y'))
time_val: float|None = kwargs.pop('time', None)
# timestamp-based index lookup
if time_val is not None:
matches = arr[arr['time'] == time_val]
if len(matches) > 0:
x = float(matches[0]['index'])
y = float(matches[0]['close'])
color = kwargs.pop('color', 'dad_blue')
anchor = kwargs.pop('anchor', (0, 1))
font_size = kwargs.pop('font_size', None)
text_item: pg.TextItem = pg.TextItem(
text,
color=hcolor(color),
anchor=anchor,
)
if font_size is None:
from ._style import get_fonts
font, font_small = get_fonts()
font_size = font_small.px_size - 1
qfont: QFont = text_item.textItem.font()
qfont.setPixelSize(font_size)
text_item.setFont(qfont)
text_item.setPos(float(x), float(y))
chart.plotItem.addItem(text_item)
text_item._abs_x = float(x)
text_item._abs_y = float(y)
aid: str = str(uuid4())
annots[aid] = text_item
aids_set.add(aid)
result['texts'].append(aid)
n_texts += 1
profiler(
f'created text annotations: {n_texts} texts'
)
profiler.finish()
await annot_req_stream.send(result)
case { case {
'cmd': 'remove', 'cmd': 'remove',
'aid': int(aid)|str(aid), 'aid': int(aid)|str(aid),
@ -828,26 +471,10 @@ async def serve_rc_annots(
# XXX: reposition all annotations to ensure they # XXX: reposition all annotations to ensure they
# stay aligned with viz data after reset (eg during # stay aligned with viz data after reset (eg during
# backfill when abs-index range changes) # backfill when abs-index range changes)
chart: ChartPlotWidget = {
60: ds.hist_chart,
1: ds.chart,
}[timeframe]
viz: Viz = chart.get_viz(fqme)
arr = viz.shm.array
n_repositioned: int = 0 n_repositioned: int = 0
for aid, annot in annots.items(): for aid, annot in annots.items():
# GapAnnotations batch items have .reposition()
if hasattr(annot, 'reposition'):
annot.reposition(
array=arr,
fqme=fqme,
timeframe=timeframe,
)
n_repositioned += 1
# arrows and text items use abs x,y coords # arrows and text items use abs x,y coords
elif ( if (
hasattr(annot, '_abs_x') hasattr(annot, '_abs_x')
and and
hasattr(annot, '_abs_y') hasattr(annot, '_abs_y')
@ -912,21 +539,12 @@ async def remote_annotate(
finally: finally:
# ensure all annots for this connection are deleted # ensure all annots for this connection are deleted
# on any final teardown # on any final teardown
profiler = Profiler(
msg=f'Annotation teardown for ctx {ctx.cid}',
disabled=False,
ms_threshold=0.0,
)
(_ctx, aids) = _ctxs[ctx.cid] (_ctx, aids) = _ctxs[ctx.cid]
assert _ctx is ctx assert _ctx is ctx
profiler(f'got {len(aids)} aids to remove')
for aid in aids: for aid in aids:
annot: QGraphicsItem = _annots[aid] annot: QGraphicsItem = _annots[aid]
assert rm_annot(annot) assert rm_annot(annot)
profiler(f'removed all {len(aids)} annotations')
class AnnotCtl(Struct): class AnnotCtl(Struct):
''' '''
@ -1128,64 +746,6 @@ class AnnotCtl(Struct):
) )
return aid return aid
async def add_batch(
self,
fqme: str,
timeframe: float,
rects: list[dict]|None = None,
arrows: list[dict]|None = None,
texts: list[dict]|None = None,
show_individual_arrows: bool = False,
from_acm: bool = False,
) -> dict[str, list[int]]:
'''
Batch submit multiple annotations in single IPC msg for
much faster remote annotation vs. per-annot round-trips.
Returns dict of annotation IDs:
{
'rects': [aid1, aid2, ...],
'arrows': [aid3, aid4, ...],
'texts': [aid5, aid6, ...],
}
'''
ipc: MsgStream = self._get_ipc(fqme)
with trio.fail_after(10):
await ipc.send({
'fqme': fqme,
'cmd': 'batch',
'timeframe': timeframe,
'rects': rects or [],
'arrows': arrows or [],
'texts': texts or [],
'show_individual_arrows': show_individual_arrows,
})
result: dict = await ipc.receive()
match result:
case {'error': str(msg)}:
log.error(msg)
return {
'rects': [],
'arrows': [],
'texts': [],
}
# register all AIDs with their IPC streams
for aid_list in result.values():
for aid in aid_list:
self._ipcs[aid] = ipc
if not from_acm:
self._annot_stack.push_async_callback(
partial(
self.remove,
aid,
)
)
return result
async def add_text( async def add_text(
self, self,
fqme: str, fqme: str,
@ -1321,14 +881,3 @@ async def open_annot_ctl(
_annot_stack=annots_stack, _annot_stack=annots_stack,
) )
yield client yield client
# client exited, measure teardown time
teardown_profiler = Profiler(
msg='Client AnnotCtl teardown',
disabled=False,
ms_threshold=0.0,
)
teardown_profiler('exiting annots_stack')
teardown_profiler('annots_stack exited')
teardown_profiler('exiting gather_contexts')

View File

@ -174,13 +174,6 @@ class CompleterView(QTreeView):
self.setStyleSheet(f"font: {size}px") self.setStyleSheet(f"font: {size}px")
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
self.set_font_size(_font.px_size)
self.setIndentation(_font.px_size)
self.setFont(_font.font)
self.updateGeometry()
def resize_to_results( def resize_to_results(
self, self,
w: float | None = 0, w: float | None = 0,
@ -637,29 +630,6 @@ class SearchWidget(QtWidgets.QWidget):
| align_flag.AlignLeft, | align_flag.AlignLeft,
) )
def update_fonts(self) -> None:
'''Update font sizes after zoom change.'''
# regenerate label stylesheet with new font size
self.label.setStyleSheet(
f"""QLabel {{
color : {hcolor('default_lightest')};
font-size : {_font.px_size - 2}px;
}}
"""
)
self.label.setFont(_font.font)
# update search bar and view fonts
if hasattr(self.bar, 'update_fonts'):
self.bar.update_fonts(_font)
elif hasattr(self.bar, 'setFont'):
self.bar.setFont(_font.font)
if hasattr(self.view, 'update_fonts'):
self.view.update_fonts()
self.updateGeometry()
def focus(self) -> None: def focus(self) -> None:
self.show() self.show()
self.bar.focus() self.bar.focus()

View File

@ -79,13 +79,9 @@ class DpiAwareFont:
self._font_inches: float = None self._font_inches: float = None
self._screen = None self._screen = None
def _set_qfont_px_size( def _set_qfont_px_size(self, px_size: int) -> None:
self, self._qfont.setPixelSize(px_size)
px_size: int,
) -> int:
self._qfont.setPixelSize(int(px_size))
self._qfm = QtGui.QFontMetrics(self._qfont) self._qfm = QtGui.QFontMetrics(self._qfont)
return self.px_size
@property @property
def screen(self) -> QtGui.QScreen: def screen(self) -> QtGui.QScreen:
@ -128,22 +124,17 @@ class DpiAwareFont:
return size return size
def configure_to_dpi( def configure_to_dpi(self, screen: QtGui.QScreen | None = None):
self,
screen: QtGui.QScreen | None = None,
zoom_level: float = 1.0,
) -> int:
''' '''
Set an appropriately sized font size depending on the screen DPI Set an appropriately sized font size depending on the screen DPI.
or scale the size according to `zoom_level`.
If we end up needing to generalize this more here there are If we end up needing to generalize this more here there are resources
resources listed in the script in listed in the script in ``snippets/qt_screen_info.py``.
``snippets/qt_screen_info.py``.
''' '''
if self._font_size is not None: if self._font_size is not None:
return self._set_qfont_px_size(self._font_size * zoom_level) self._set_qfont_px_size(self._font_size)
return
# NOTE: if no font size set either in the [ui] section of the # NOTE: if no font size set either in the [ui] section of the
# config or not yet computed from our magic scaling calcs, # config or not yet computed from our magic scaling calcs,
@ -162,7 +153,7 @@ class DpiAwareFont:
ldpi = pdpi ldpi = pdpi
mx_dpi = max(pdpi, ldpi) mx_dpi = max(pdpi, ldpi)
# mn_dpi = min(pdpi, ldpi) mn_dpi = min(pdpi, ldpi)
scale = round(ldpi/pdpi, ndigits=2) scale = round(ldpi/pdpi, ndigits=2)
if mx_dpi <= 97: # for low dpi use larger font sizes if mx_dpi <= 97: # for low dpi use larger font sizes
@ -171,7 +162,7 @@ class DpiAwareFont:
else: # hidpi use smaller font sizes else: # hidpi use smaller font sizes
inches = _font_sizes['hi'][self._font_size_calc_key] inches = _font_sizes['hi'][self._font_size_calc_key]
# dpi = mn_dpi dpi = mn_dpi
mult = 1.0 mult = 1.0
@ -206,25 +197,24 @@ class DpiAwareFont:
# always going to hit that error in range mapping from inches: # always going to hit that error in range mapping from inches:
# float to px size: int. # float to px size: int.
self._font_inches = inches self._font_inches = inches
font_size = math.floor(inches * pdpi) font_size = math.floor(inches * dpi)
# apply zoom level multiplier
font_size = int(font_size * zoom_level)
log.debug( log.debug(
f"screen:{screen.name()}\n" f"screen:{screen.name()}\n"
f"pDPI: {pdpi}, lDPI: {ldpi}, scale: {scale}\n" f"pDPI: {pdpi}, lDPI: {ldpi}, scale: {scale}\n"
f"zoom_level: {zoom_level}\n"
f"\nOur best guess font size is {font_size}\n" f"\nOur best guess font size is {font_size}\n"
) )
# apply the size # apply the size
return self._set_qfont_px_size(font_size) self._set_qfont_px_size(font_size)
def boundingRect(self, value: str) -> QtCore.QRectF: def boundingRect(self, value: str) -> QtCore.QRectF:
if self.screen is None:
screen = self.screen
if screen is None:
raise RuntimeError("You must call .configure_to_dpi() first!") raise RuntimeError("You must call .configure_to_dpi() first!")
unscaled_br: QtCore.QRectF = self._qfm.boundingRect(value) unscaled_br = self._qfm.boundingRect(value)
return QtCore.QRectF( return QtCore.QRectF(
0, 0,
0, 0,
@ -238,22 +228,12 @@ _font = DpiAwareFont()
_font_small = DpiAwareFont(_font_size_key='small') _font_small = DpiAwareFont(_font_size_key='small')
def _config_fonts_to_screen( def _config_fonts_to_screen() -> None:
zoom_level: float = 1.0 'configure global DPI aware font sizes'
) -> int:
'''
Configure global DPI aware font size(s).
If `zoom_level` is provided we apply it to auto-calculated
DPI-aware font.
Return the new `DpiAwareFont.px_size`.
'''
global _font, _font_small global _font, _font_small
_font.configure_to_dpi(zoom_level=zoom_level) _font.configure_to_dpi()
_font_small.configure_to_dpi(zoom_level=zoom_level) _font_small.configure_to_dpi()
return _font.px_size
def get_fonts() -> tuple[ def get_fonts() -> tuple[

View File

@ -18,7 +18,6 @@
Qt main window singletons and stuff. Qt main window singletons and stuff.
""" """
from __future__ import annotations
import os import os
import signal import signal
import time import time
@ -39,107 +38,15 @@ from piker.ui.qt import (
QScreen, QScreen,
QCloseEvent, QCloseEvent,
QSettings, QSettings,
QEvent,
QObject,
) )
from ..log import get_logger from ..log import get_logger
from . import _style from ._style import _font_small, hcolor
from ._style import (
_font_small,
hcolor,
)
from ._widget import GodWidget from ._widget import GodWidget
log = get_logger(__name__) log = get_logger(__name__)
class GlobalZoomEventFilter(QObject):
'''
Application-level event filter for global UI zoom shortcuts.
This filter intercepts keyboard events BEFORE they reach widgets,
allowing us to implement global UI zoom shortcuts that take precedence
over widget-specific shortcuts.
Shortcuts:
- Ctrl+Shift+Plus/Equal: Zoom in
- Ctrl+Shift+Minus: Zoom out
- Ctrl+Shift+0: Reset zoom
'''
def __init__(self, main_window: MainWindow):
super().__init__()
self.main_window = main_window
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
'''
Filter keyboard events for global zoom shortcuts.
Returns True to filter out (consume) the event, False to pass through.
'''
if event.type() == QEvent.Type.KeyPress:
key = event.key()
mods = event.modifiers()
# Mask out the KeypadModifier which Qt sometimes adds
mods = mods & ~Qt.KeyboardModifier.KeypadModifier
# Check if we have Ctrl+Shift (both required)
has_ctrl = bool(
mods
&
Qt.KeyboardModifier.ControlModifier
)
_has_shift = bool(
mods
&
Qt.KeyboardModifier.ShiftModifier
)
# Only handle UI zoom if BOTH Ctrl and Shift are pressed
# For Plus key: user presses Cmd+Shift+Equal (which makes Plus)
# For Minus key: user presses Cmd+Shift+Minus
if (
has_ctrl
# and
# has_shift
):
# Zoom in: Ctrl+Shift+Plus
# Note: Plus key usually comes as Key_Equal with Shift modifier
if key in (
Qt.Key.Key_Plus,
Qt.Key.Key_Equal,
):
self.main_window.zoom_in()
return True # consume event
# Zoom out: Ctrl+Shift+Minus
# Note: On some keyboards Shift+Minus produces '_' (Underscore)
elif key in (
Qt.Key.Key_Minus,
Qt.Key.Key_Underscore,
):
self.main_window.zoom_out()
return True # consume event
# Reset zoom: Ctrl+Shift+0
# Note: On some keyboards Shift+0 produces ')' (ParenRight)
elif key in (
Qt.Key.Key_0,
Qt.Key.Key_ParenRight,
):
self.main_window.reset_zoom()
return True # consume event
# Pass through if only Ctrl (no Shift) - this goes to chart zoom
# Pass through all other events too
return False
return False
class MultiStatus: class MultiStatus:
bar: QStatusBar bar: QStatusBar
@ -282,24 +189,6 @@ class MainWindow(QMainWindow):
self.restoreGeometry(geometry) self.restoreGeometry(geometry)
log.debug('Restored window geometry from previous session') log.debug('Restored window geometry from previous session')
# zoom level for UI scaling (1.0 = 100%, 1.5 = 150%, etc)
# Change this value to set the default startup zoom level
self._zoom_level: float = 1.0 # Start at 100% (normal)
self._min_zoom: float = 0.5
self._max_zoom: float = 3.0 # Reduced from 10.0 to prevent extreme cropping
self._zoom_step: float = 0.2 # 20% per keypress
# event filter for global zoom shortcuts
self._zoom_filter: GlobalZoomEventFilter | None = None
def install_global_zoom_filter(self) -> None:
'''Install application-level event filter for global UI zoom shortcuts.'''
if self._zoom_filter is None:
self._zoom_filter = GlobalZoomEventFilter(self)
app = QApplication.instance()
app.installEventFilter(self._zoom_filter)
log.info('Installed global zoom shortcuts: Ctrl+Shift+Plus/Minus/0')
@property @property
def mode_label(self) -> QLabel: def mode_label(self) -> QLabel:
@ -342,10 +231,7 @@ class MainWindow(QMainWindow):
log.debug('Saved window geometry for next session') log.debug('Saved window geometry for next session')
# raising KBI seems to get intercepted by by Qt so just use the system. # raising KBI seems to get intercepted by by Qt so just use the system.
os.kill( os.kill(os.getpid(), signal.SIGINT)
os.getpid(),
signal.SIGINT,
)
@property @property
def status_bar(self) -> QStatusBar: def status_bar(self) -> QStatusBar:
@ -471,262 +357,14 @@ class MainWindow(QMainWindow):
self.godwidget.on_win_resize(event) self.godwidget.on_win_resize(event)
event.accept() event.accept()
def zoom_in(self) -> None:
'''
Increase overall UI-widgets zoom level by scaling it the
global font sizes.
'''
new_zoom: float = min(
self._zoom_level + self._zoom_step,
self._max_zoom,
)
if new_zoom != self._zoom_level:
self._zoom_level = new_zoom
font_size: int = self._apply_zoom()
log.info(
f'Zoomed in UI\n'
f'zoom_step: {self._zoom_step!r}\n'
f'zoom_level(%): {self._zoom_level:.1%}\n'
f'font_size: {font_size!r}'
)
def zoom_out(self) -> float:
'''
Decrease UI zoom level.
'''
new_zoom: float = max(self._zoom_level - self._zoom_step, self._min_zoom)
if new_zoom != self._zoom_level:
self._zoom_level = new_zoom
font_size: int = self._apply_zoom()
log.info(
f'Zoomed out UI\n'
f'zoom_step: {self._zoom_step!r}\n'
f'zoom_level(%): {self._zoom_level:.1%}\n'
f'font_size: {font_size!r}'
)
return new_zoom
def reset_zoom(self) -> None:
'''
Reset UI zoom to 100%.
'''
if self._zoom_level != 1.0:
self._zoom_level = 1.0
font_size: int = self._apply_zoom()
log.info(
f'Reset zoom level\n'
f'zoom_step: {self._zoom_step!r}\n'
f'zoom_level(%): {self._zoom_level:.1%}\n'
f'font_size: {font_size!r}'
)
return self._zoom_level
def _apply_zoom(self) -> int:
'''
Apply current zoom level to all UI elements.
'''
# reconfigure fonts with zoom multiplier
font_size: int = _style._config_fonts_to_screen(
zoom_level=self._zoom_level
)
# update status bar styling with new font size
if self._status_bar:
sb = self.statusBar()
sb.setStyleSheet((
f"color : {hcolor('gunmetal')};"
f"background : {hcolor('default_dark')};"
f"font-size : {_style._font_small.px_size}px;"
"padding : 0px;"
))
# force update of mode label if it exists
if self._status_label:
self._status_label.setFont(_style._font_small.font)
# update godwidget and its children
if self.godwidget:
# update search bg if it exists
if search := getattr(self.godwidget, 'search', None):
search.update_fonts()
# update order mode panes in all chart views
self._update_chart_order_panes()
# recursively update all other widgets with stylesheets
self._refresh_widget_fonts(self.godwidget)
self.godwidget.update()
return font_size
def _update_chart_order_panes(self) -> None:
'''
Update order entry panels in all charts.
'''
if not self.godwidget:
return
# iterate through all linked splits (hist and rt)
for splits_name in [
'hist_linked',
'rt_linked',
]:
splits = getattr(self.godwidget, splits_name, None)
if not splits:
continue
# get main chart
chart = getattr(splits, 'chart', None)
if chart:
# update axes
self._update_chart_axes(chart)
# update order pane
if (
(view := getattr(chart, 'view', None))
and
(order_mode := getattr(view, 'order_mode', None))
and
(pane := getattr(order_mode, 'pane', None))
):
pane.update_fonts()
# also check subplots
subplots = getattr(splits, 'subplots', {})
for name, subplot_chart in subplots.items():
# update subplot axes
self._update_chart_axes(subplot_chart)
# update subplot order pane
if (
(view := getattr(subplot_chart, 'view', None))
and
(order_mode := getattr(
view, 'order_mode', None,
))
and
(pane := getattr(order_mode, 'pane', None))
):
pane.update_fonts()
# resize all sidepanes to match the
# main chart's sidepane width; ensures
# volume/subplot sidepanes match.
if (
splits
and
(resize := getattr(
splits, 'resize_sidepanes', None,
))
):
resize()
def _update_chart_axes(self, chart) -> None:
'''
Update axis fonts and sizing for a chart.
'''
from . import _style
# update price axis (right side)
if plot_item := getattr(chart, 'pi', None):
# get all axes from plot item
for axis_name in [
'left',
'right',
'bottom',
'top',
]:
if (
(axis := plot_item.getAxis(axis_name))
and
(update_fonts := getattr(
axis, 'update_fonts', None,
))
):
update_fonts(_style._font)
# force plot item to recalculate
# its entire layout
plot_item.updateGeometry()
# force chart widget to update
if update_geom := getattr(chart, 'updateGeometry', None):
update_geom()
# trigger a full scene update
if update := getattr(chart, 'update', None):
update()
def _refresh_widget_fonts(
self,
widget: QWidget,
) -> None:
'''
Recursively update font sizes in all
child widgets.
Handles widgets that have font-size
hardcoded in their stylesheets.
'''
from . import _style
# recursively process all children
for child in widget.findChildren(QWidget):
# skip widgets that have custom update
# methods; handled separately below.
if getattr(child, 'update_fonts', None):
log.debug(
f'Skipping sub-widget with'
f' custom font-update meth..\n'
f'{child!r}\n'
)
continue
# update child's stylesheet if it has font-size
child_stylesheet = child.styleSheet()
if child_stylesheet and 'font-size' in child_stylesheet:
# for labels and simple widgets, regenerate stylesheet
# this is a heuristic - may need refinement
try:
child.setFont(_style._font.font)
except (
AttributeError,
RuntimeError,
):
log.exception(
'Failed to update sub-widget font?\n'
)
# update child's font
try:
child.setFont(_style._font.font)
except (
AttributeError,
RuntimeError,
):
log.exception(
'Failed to update sub-widget font?\n'
)
# singleton app per actor # singleton app per actor
_qt_win: QMainWindow|None = None _qt_win: QMainWindow = None
def main_window() -> MainWindow: def main_window() -> MainWindow:
''' 'Return the actor-global Qt window.'
Return the actor-global Qt window.
'''
global _qt_win global _qt_win
assert _qt_win assert _qt_win
return _qt_win return _qt_win

View File

@ -22,12 +22,9 @@ import os
import click import click
import tractor import tractor
from ..cli import ( from ..cli import cli
cli,
load_trans_eps,
)
from .. import watchlists as wl from .. import watchlists as wl
from ..service import maybe_spawn_datad from ..service import maybe_spawn_brokerd
_config_dir = click.get_app_dir('piker') _config_dir = click.get_app_dir('piker')
@ -69,7 +66,7 @@ def monitor(config, rate, name, dhost, test, tl):
from .kivy.monitor import _async_main from .kivy.monitor import _async_main
async def main(): async def main():
async with maybe_spawn_datad( async with maybe_spawn_brokerd(
brokername=brokermod.name, brokername=brokermod.name,
loglevel=loglevel loglevel=loglevel
) as portal: ) as portal:
@ -118,7 +115,7 @@ def optschain(
from .kivy.option_chain import _async_main from .kivy.option_chain import _async_main
async def main(): async def main():
async with maybe_spawn_datad( async with maybe_spawn_brokerd(
loglevel=loglevel loglevel=loglevel
): ):
# run app "main" # run app "main"
@ -162,9 +159,6 @@ def chart(
needed if not discovered via [network] config. needed if not discovered via [network] config.
''' '''
from tractor.devx import maybe_open_crash_handler
pdb: bool = config['pdb']
with maybe_open_crash_handler(pdb=pdb):
# eg. ``--profile 3`` reports profiling for anything slower then 3 ms. # eg. ``--profile 3`` reports profiling for anything slower then 3 ms.
if profile is not None: if profile is not None:
from .. import _profile from .. import _profile
@ -189,35 +183,43 @@ def chart(
tractorloglevel = config['tractorloglevel'] tractorloglevel = config['tractorloglevel']
pikerloglevel = config['loglevel'] pikerloglevel = config['loglevel']
# !TODO, allow declaring bind addr via maddr! maddrs: list[tuple[str, int]] = config.get(
'maddrs',
[],
)
# if maddrs:
# from tractor._multiaddr import parse_maddr
# for addr in maddrs:
# breakpoint()
# layers: dict = parse_maddr(addr)
regaddrs: list[tuple[str, int]] = config.get(
'registry_addrs',
[],
)
from ..config import load from ..config import load
conf, _ = load( conf, _ = load(
conf_name='conf', conf_name='conf',
) )
regaddrs: list = config.get(
'registry_addrs',
[],
)
network: dict = conf.get('network') network: dict = conf.get('network')
if network: if network:
from ..cli import load_trans_eps
eps: dict = load_trans_eps( eps: dict = load_trans_eps(
network, network,
maddrs=None, maddrs,
) )
for layers in eps['pikerd']:
regaddrs.append((
layers['ipv4']['addr'],
layers['tcp']['port'],
))
# registry addrs: prefer `regd`, fall back # breakpoint()
# to `pikerd` eps from tractor.devx import maybe_open_crash_handler
reg_eps = eps.get( pdb: bool = config['pdb']
'regd', with maybe_open_crash_handler(pdb=pdb):
eps.get('pikerd')
)
assert reg_eps
regaddrs = [
addr.unwrap() for addr in reg_eps
]
chart_eps = eps.get('chart')
_main( _main(
syms=symbols, syms=symbols,
brokermods=brokermods, brokermods=brokermods,
@ -227,7 +229,6 @@ def chart(
'loglevel': tractorloglevel, 'loglevel': tractorloglevel,
'name': 'chart', 'name': 'chart',
'registry_addrs': list(set(regaddrs)), 'registry_addrs': list(set(regaddrs)),
'tpt_bind_addrs': chart_eps,
'enable_modules': [ 'enable_modules': [
# remote data-view annotations Bo # remote data-view annotations Bo

View File

@ -167,7 +167,7 @@ async def stream_symbol_selection():
async def _async_main( async def _async_main(
name: str, name: str,
portal: tractor.Portal, portal: tractor._portal.Portal,
symbols: List[str], symbols: List[str],
brokermod: ModuleType, brokermod: ModuleType,
loglevel: str = 'info', loglevel: str = 'info',

View File

@ -436,7 +436,7 @@ class OptionChain(object):
async def new_chain_ui( async def new_chain_ui(
portal: tractor.Portal, portal: tractor._portal.Portal,
symbol: str, symbol: str,
brokermod: types.ModuleType, brokermod: types.ModuleType,
rate: int = 1, rate: int = 1,
@ -495,7 +495,7 @@ async def _async_main(
async with trio.open_nursery() as nursery: async with trio.open_nursery() as nursery:
# get a portal to the data feed daemon # get a portal to the data feed daemon
async with tractor.wait_for_actor('datad') as portal: async with tractor.wait_for_actor('brokerd') as portal:
# set up a pager view for large ticker lists # set up a pager view for large ticker lists
chain = await new_chain_ui( chain = await new_chain_ui(

View File

@ -1022,22 +1022,13 @@ async def open_order_mode(
started.set() started.set()
for oid, msg in ems_dialog_msgs.items(): for oid, msg in ems_dialog_msgs.items():
# HACK ALERT: ensure a resp field is filled out since # HACK ALERT: ensure a resp field is filled out since
# techincally the call below expects a ``Status``. TODO: # techincally the call below expects a ``Status``. TODO:
# parse into proper ``Status`` equivalents ems-side? # parse into proper ``Status`` equivalents ems-side?
# msg.setdefault('resp', msg['broker_details']['resp']) # msg.setdefault('resp', msg['broker_details']['resp'])
# msg.setdefault('oid', msg['broker_details']['oid']) # msg.setdefault('oid', msg['broker_details']['oid'])
ya_msg: dict = msg.setdefault( msg['brokerd_msg'] = msg
'brokerd_msg',
msg,
)
if msg is not ya_msg:
log.warning(
f'A `.brokerd_msg` was already set for ems-dialog msg?\n'
f'oid: {oid!r}\n'
f'ya_msg: {ya_msg!r}\n'
f'msg: {ya_msg!r}\n'
)
await process_trade_msg( await process_trade_msg(
mode, mode,

View File

@ -42,7 +42,6 @@ from PyQt6.QtCore import (
QSize, QSize,
QModelIndex, QModelIndex,
QItemSelectionModel, QItemSelectionModel,
QObject,
pyqtBoundSignal, pyqtBoundSignal,
pyqtRemoveInputHook, pyqtRemoveInputHook,
QSettings, QSettings,

1263
poetry.lock generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -77,7 +77,6 @@ dependencies = [
"exchange-calendars>=4.13.1", "exchange-calendars>=4.13.1",
"ib-async>=2.1.0", "ib-async>=2.1.0",
"aeventkit>=2.1.0", # XXX, imports as eventkit? "aeventkit>=2.1.0", # XXX, imports as eventkit?
"xonsh>=0.23.8",
] ]
# ------ dependencies ------ # ------ dependencies ------
# NOTE, by default we ship only a "headless" deps set bc # NOTE, by default we ship only a "headless" deps set bc
@ -107,7 +106,7 @@ default-groups = [
[dependency-groups] [dependency-groups]
uis = [ uis = [
"pyqtgraph >= 0.14.0", "pyqtgraph",
"qdarkstyle >=3.0.2, <4.0.0", "qdarkstyle >=3.0.2, <4.0.0",
"pyqt6 >=6.7.0, <7.0.0", "pyqt6 >=6.7.0, <7.0.0",
@ -133,8 +132,8 @@ repl = [
"greenback >=1.1.1, <2.0.0", "greenback >=1.1.1, <2.0.0",
# @goodboy's preferred console toolz # @goodboy's preferred console toolz
"xonsh>=0.23.0", "xonsh>=0.22.2",
"prompt-toolkit>=3.0.50", "prompt-toolkit ==3.0.40",
"pyperclip>=1.9.0", "pyperclip>=1.9.0",
# for @claude's `snippets/claude_debug_helper.py` it uses to do # for @claude's `snippets/claude_debug_helper.py` it uses to do
@ -194,12 +193,9 @@ include = ["piker"]
[tool.uv.sources] [tool.uv.sources]
pyqtgraph = { git = "https://github.com/pikers/pyqtgraph.git" }
tomlkit = { git = "https://github.com/pikers/tomlkit.git", branch ="piker_pin" } tomlkit = { git = "https://github.com/pikers/tomlkit.git", branch ="piker_pin" }
pyvnc = { git = "https://github.com/regulad/pyvnc.git" } pyvnc = { git = "https://github.com/regulad/pyvnc.git" }
# pyqtgraph = { git = "https://github.com/pyqtgraph/pyqtgraph.git", branch = 'master' }
# pyqtgraph = { path = '../pyqtgraph', editable = true }
# ?TODO, resync our fork?
# pyqtgraph = { git = "https://github.com/pikers/pyqtgraph.git" }
# to get fancy next-cmd/suggestion feats prior to 0.22.2 B) # to get fancy next-cmd/suggestion feats prior to 0.22.2 B)
# https://github.com/xonsh/xonsh/pull/6037 # https://github.com/xonsh/xonsh/pull/6037

View File

@ -1,64 +0,0 @@
#!env xonsh
'''
Compute the pxs-per-inch (PPI) naively for the local DE.
NOTE, currently this only supports the `sway`-TWM on wayland.
!TODO!
- [ ] support Xorg (and possibly other OSs as well?
- [ ] conver this to pure py code, dropping the `.xsh` specifics
instead for `subprocess` API calls?
- [ ] possibly unify all this with `./qt_screen_info.py` as part of
a "PPI config wizard" or something, but more then likely we'll
have lib-ified version inside modden/piker by then?
'''
import math
import json
# XXX, xonsh part using "subprocess mode"
disp_infos: list[dict] = json.loads($(wlr-randr --json))
lappy: dict = disp_infos[0]
dims: dict[str, int] = lappy['physical_size']
w_cm: int = dims['width']
h_cm: int = dims['height']
# cm per inch
cpi: float = 25.4
# compute "diagonal" size (aka hypot)
diag_inches: float = math.sqrt((h_cm/cpi)**2 + (w_cm/cpi)**2)
# compute reso-hypot / inches-hypot
hi_res: dict[str, float|bool] = lappy['modes'][0]
w_px: int = hi_res['width']
h_px: int = hi_res['height']
diag_pxs: float = math.sqrt(h_px**2 + w_px**2)
unscaled_ppi: float = diag_pxs/diag_inches
# retrieve TWM info on the display (including scaling info)
sway_disp_info: dict = json.loads($(swaymsg -r -t get_outputs))[0]
scale: float = sway_disp_info['scale']
print(
f'output: {sway_disp_info["name"]!r}\n'
f'--- DIMENSIONS ---\n'
f'w_cm: {w_cm!r}\n'
f'h_cm: {h_cm!r}\n'
f'w_px: {w_px!r}\n'
f'h_cm: {h_px!r}\n'
f'\n'
f'--- DIAGONALS ---\n'
f'diag_inches: {diag_inches!r}\n'
f'diag_pxs: {diag_pxs!r}\n'
f'\n'
f'--- PPI-related-info ---\n'
f'(DE reported) scale: {scale!r}\n'
f'unscaled PPI: {unscaled_ppi!r}\n'
f'|_ =sqrt(h_px**2 + w_px**2) / sqrt(h_in**2 + w_in**2)\n'
f'scaled PPI: {unscaled_ppi/scale!r}\n'
f'|_ =unscaled_ppi/scale\n'
)

View File

@ -31,8 +31,8 @@ Resource list for mucking with DPIs on multiple screens:
- https://doc.qt.io/qt-5/qguiapplication.html#screenAt - https://doc.qt.io/qt-5/qguiapplication.html#screenAt
''' '''
import os
from pyqtgraph import QtGui
from PyQt6 import ( from PyQt6 import (
QtCore, QtCore,
QtWidgets, QtWidgets,
@ -43,11 +43,6 @@ from PyQt6.QtCore import (
QSize, QSize,
QRect, QRect,
) )
from pyqtgraph import QtGui
# https://doc.qt.io/qt-6/highdpi.html#environment-variable-reference
os.environ['QT_USE_PHYSICAL_DPI'] = '1'
# Proper high DPI scaling is available in Qt >= 5.6.0. This attibute # Proper high DPI scaling is available in Qt >= 5.6.0. This attibute
# must be set before creating the application # must be set before creating the application
@ -63,22 +58,13 @@ if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
True, True,
) )
# NOTE, inherits `QGuiApplication`
# https://doc.qt.io/qt-6/qapplication.html
# https://doc.qt.io/qt-6/qguiapplication.html
app = QtWidgets.QApplication([]) app = QtWidgets.QApplication([])
#
# ^TODO? various global DPI settings?
# [ ] DPI rounding policy,
# - https://doc.qt.io/qt-6/qt.html#HighDpiScaleFactorRoundingPolicy-enum
# - https://doc.qt.io/qt-6/qguiapplication.html#setHighDpiScaleFactorRoundingPolicy
window = QtWidgets.QMainWindow() window = QtWidgets.QMainWindow()
main_widget = QtWidgets.QWidget() main_widget = QtWidgets.QWidget()
window.setCentralWidget(main_widget) window.setCentralWidget(main_widget)
window.show() window.show()
_main_pxr: float = main_widget.devicePixelRatioF() pxr: float = main_widget.devicePixelRatioF()
# explicitly get main widget and primary displays # explicitly get main widget and primary displays
current_screen: QtGui.QScreen = app.screenAt( current_screen: QtGui.QScreen = app.screenAt(
@ -91,13 +77,7 @@ for screen in app.screens():
name: str = screen.name() name: str = screen.name()
model: str = screen.model().rstrip() model: str = screen.model().rstrip()
size: QSize = screen.size() size: QSize = screen.size()
geo: QRect = screen.geometry() geo: QRect = screen.availableGeometry()
# device-pixel-ratio
# https://doc.qt.io/qt-6/highdpi.html
pxr: float = screen.devicePixelRatio()
unscaled_size: QSize = pxr * size
phydpi: float = screen.physicalDotsPerInch() phydpi: float = screen.physicalDotsPerInch()
logdpi: float = screen.logicalDotsPerInch() logdpi: float = screen.logicalDotsPerInch()
is_primary: bool = screen is primary_screen is_primary: bool = screen is primary_screen
@ -108,12 +88,11 @@ for screen in app.screens():
f'|_primary: {is_primary}\n' f'|_primary: {is_primary}\n'
f' _current: {is_current}\n' f' _current: {is_current}\n'
f' _model: {model}\n' f' _model: {model}\n'
f' _size: {size}\n' f' _screen size: {size}\n'
f' _geometry: {geo}\n' f' _screen geometry: {geo}\n'
f' _devicePixelRatio(): {pxr}\n' f' _devicePixelRationF(): {pxr}\n'
f' _unscaled-size: {unscaled_size!r}\n' f' _physical dpi: {phydpi}\n'
f' _physical-dpi: {phydpi}\n' f' _logical dpi: {logdpi}\n'
f' _logical-dpi: {logdpi}\n'
) )
# app-wide font info # app-wide font info
@ -131,8 +110,8 @@ str_w: int = str_br.width()
print( print(
f'------ global font settings ------\n' f'------ global font settings ------\n'
f'font dpi: {fontdpi!r}\n' f'font dpi: {fontdpi}\n'
f'font height: {font_h!r}\n' f'font height: {font_h}\n'
f'string bounding rect: {str_br!r}\n' f'string bounding rect: {str_br}\n'
f'string width : {str_w!r}\n' f'string width : {str_w}\n'
) )

View File

@ -92,7 +92,8 @@ def log(
@acm @acm
async def _open_test_pikerd( async def _open_test_pikerd(
tmpconfdir: str, tmpconfdir: str,
reg_addr: tuple[str, int|str],
reg_addr: tuple[str, int] | None = None,
loglevel: str = 'warning', loglevel: str = 'warning',
debug_mode: bool = False, debug_mode: bool = False,
@ -112,10 +113,16 @@ async def _open_test_pikerd(
to boot the root actor / tractor runtime. to boot the root actor / tractor runtime.
''' '''
import random
from piker.service import maybe_open_pikerd from piker.service import maybe_open_pikerd
if reg_addr is None:
port = random.randint(6e3, 7e3)
reg_addr = ('127.0.0.1', port)
async with ( async with (
maybe_open_pikerd( maybe_open_pikerd(
registry_addrs=[reg_addr], registry_addr=reg_addr,
loglevel=loglevel, loglevel=loglevel,
tractor_runtime_overrides={ tractor_runtime_overrides={
@ -132,14 +139,13 @@ async def _open_test_pikerd(
async with tractor.wait_for_actor( async with tractor.wait_for_actor(
'pikerd', 'pikerd',
registry_addr=reg_addr, arbiter_sockaddr=reg_addr,
) as portal: ) as portal:
raddr = portal.chan.raddr raddr = portal.channel.raddr
uw_raddr: tuple = raddr.unwrap() assert raddr == reg_addr
assert uw_raddr == reg_addr
yield ( yield (
raddr._host, raddr[0],
raddr._port, raddr[1],
portal, portal,
service_manager, service_manager,
) )
@ -196,10 +202,7 @@ def open_test_pikerd(
request: pytest.FixtureRequest, request: pytest.FixtureRequest,
tmp_path: Path, tmp_path: Path,
tmpconfdir: Path, tmpconfdir: Path,
# XXX from `tractor._testing.pytest` plugin
loglevel: str, loglevel: str,
reg_addr: tuple,
): ):
tmpconfdir_str: str = str(tmpconfdir) tmpconfdir_str: str = str(tmpconfdir)
@ -233,13 +236,10 @@ def open_test_pikerd(
# bwitout clobbering each other's config state. # bwitout clobbering each other's config state.
tmpconfdir=tmpconfdir_str, tmpconfdir=tmpconfdir_str,
# NOTE these come verbatim from `tractor`'s builtin plugin! # bind in level from fixture, which is itself set by
# # `--ll <value>` cli flag.
# per-tpt compat registrar address.
reg_addr=reg_addr,
# bind in level from fixture.
# (can be set with `--ll <value>` flag to `pytest`).
loglevel=loglevel, loglevel=loglevel,
debug_mode=debug_mode, debug_mode=debug_mode,
) )

View File

@ -1,36 +0,0 @@
import pytest
from piker.ui._style import DpiAwareFont
class MockScreen:
def __init__(self, pdpi, ldpi, name="MockScreen"):
self._pdpi = pdpi
self._ldpi = ldpi
self._name = name
def physicalDotsPerInch(self):
return self._pdpi
def logicalDotsPerInch(self):
return self._ldpi
def name(self):
return self._name
@pytest.mark.parametrize(
"pdpi, ldpi, expected_px",
[
(96, 96, 9), # normal DPI
(169, 96, 15), # HiDPI
(120, 96, 10), # mid-DPI
]
)
def test_font_px_size(pdpi, ldpi, expected_px):
font = DpiAwareFont()
font.configure_to_dpi(screen=MockScreen(pdpi, ldpi))
px = font.px_size
print(f"{pdpi}x{ldpi} DPI -> Computed pixel size: {px}")
assert px == expected_px

View File

@ -151,7 +151,7 @@ def load_and_check_pos(
# is the same the fqme. # is the same the fqme.
pp: Position = table.pps[ppmsg.symbol] pp: Position = table.pps[ppmsg.symbol]
assert ppmsg.size == pp.cumsize assert ppmsg.size == pp.size
assert ppmsg.avg_price == pp.ppu assert ppmsg.avg_price == pp.ppu
yield pp yield pp
@ -179,7 +179,7 @@ def test_ems_err_on_bad_broker(
# NOTE: emsd should error on the actor's enabled modules # NOTE: emsd should error on the actor's enabled modules
# import phase, when looking for a backend named `doggy`. # import phase, when looking for a backend named `doggy`.
except tractor.RemoteActorError as re: except tractor.RemoteActorError as re:
assert re.boxed_type is ModuleNotFoundError assert re.type is ModuleNotFoundError
run_and_tollerate_cancels(load_bad_fqme) run_and_tollerate_cancels(load_bad_fqme)

View File

@ -23,35 +23,13 @@ from piker.accounting import (
'fqmes', 'fqmes',
[ [
# binance # binance
(100, { (100, {'btcusdt.binance', 'ethusdt.binance'}, False),
# !TODO, write a suite which validates raising against
# bad/legacy fqmes such as this!
# 'btcusdt.binance',
'btcusdt.spot.binance',
'ethusdt.spot.binance',
}, False),
# kraken # kraken
(20, { (20, {'ethusdt.kraken', 'xbtusd.kraken'}, True),
# !TODO, write a suite which validates raising against
# bad/legacy fqmes such as this!
# 'ethusdt.kraken',
# 'xbtusd.kraken',
'ethusdt.spot.kraken',
'xbtusd.spot.kraken',
}, True),
# binance + kraken # binance + kraken
(100, { (100, {'btcusdt.binance', 'xbtusd.kraken'}, False),
# !TODO, write a suite which validates raising against
# bad/legacy fqmes such as this!
# 'btcusdt.binance',
# 'xbtusd.kraken',
'btcusdt.spot.binance',
'xbtusd.spot.kraken',
}, False),
], ],
ids=lambda param: f'quotes={param[0]}@fqmes={param[1]}', ids=lambda param: f'quotes={param[0]}@fqmes={param[1]}',
) )
@ -70,17 +48,12 @@ def test_multi_fqsn_feed(
if ( if (
ci_env ci_env
and and not run_in_ci
not run_in_ci
): ):
pytest.skip( pytest.skip('Skipping CI disabled test due to feed restrictions')
'CI-disabled-test due to live-feed restrictions'
)
brokers = set() brokers = set()
for fqme in fqmes: for fqme in fqmes:
# ?TODO, add this unpack + normalize check to a symbology
# helper fn?
brokername, *_ = unpack_fqme(fqme) brokername, *_ = unpack_fqme(fqme)
brokers.add(brokername) brokers.add(brokername)

View File

@ -53,12 +53,11 @@ def test_runtime_boot(
tractor.wait_for_actor( tractor.wait_for_actor(
'pikerd', 'pikerd',
registry_addr=daemon_addr, arbiter_sockaddr=daemon_addr,
) as portal, ) as portal,
): ):
uw_raddr: tuple = pikerd_portal.chan.raddr.unwrap() assert pikerd_portal.channel.raddr == daemon_addr
assert uw_raddr == daemon_addr assert pikerd_portal.channel.raddr == portal.channel.raddr
assert uw_raddr == portal.chan.raddr.unwrap()
# no service tasks should be started # no service tasks should be started
assert not services.service_tasks assert not services.service_tasks
@ -66,41 +65,6 @@ def test_runtime_boot(
trio.run(main) trio.run(main)
def test_datad_spawn(
open_test_pikerd: AsyncContextManager,
loglevel: str,
) -> None:
'''
Verify the new (data-feed-only) `datad.<broker>` daemon
can be spawned/registered as a `pikerd` sub-service via
the `maybe_spawn_datad()` factory.
'''
from piker.service import maybe_spawn_datad
backend: str = 'kraken'
datad_name: str = f'datad.{backend}'
async def main():
async with (
open_test_pikerd() as (_, _, _, services),
maybe_spawn_datad(
backend,
loglevel=loglevel,
) as portal,
):
assert portal
async with ensure_service(datad_name):
assert (
datad_name in services.service_tasks
)
trio.run(main)
def test_ensure_datafeed_actors( def test_ensure_datafeed_actors(
open_test_pikerd: AsyncContextManager, open_test_pikerd: AsyncContextManager,
loglevel: str, loglevel: str,
@ -108,14 +72,14 @@ def test_ensure_datafeed_actors(
) -> None: ) -> None:
''' '''
Verify that booting a data feed starts a `datad` Verify that booting a data feed starts a `brokerd`
actor and a singleton global `samplerd` and opening actor and a singleton global `samplerd` and opening
an order mode in paper opens the `paperboi` service. an order mode in paper opens the `paperboi` service.
''' '''
actor_name: str = 'datad' actor_name: str = 'brokerd'
backend: str = 'kraken' backend: str = 'kraken'
datad_name: str = f'{actor_name}.{backend}' brokerd_name: str = f'{actor_name}.{backend}'
async def main(): async def main():
async with ( async with (
@ -130,7 +94,7 @@ def test_ensure_datafeed_actors(
await feed.pause() await feed.pause()
async with ( async with (
ensure_service(datad_name), ensure_service(brokerd_name),
ensure_service('samplerd'), ensure_service('samplerd'),
): ):
await trio.sleep(0.1) await trio.sleep(0.1)
@ -144,7 +108,7 @@ async def ensure_service(
sockaddr: tuple[str, int] | None = None, sockaddr: tuple[str, int] | None = None,
) -> None: ) -> None:
async with find_service(name) as portal: async with find_service(name) as portal:
remote_sockaddr: tuple = portal.chan.raddr.unwrap() remote_sockaddr = portal.channel.raddr
print(f'FOUND `{name}` @ {remote_sockaddr}') print(f'FOUND `{name}` @ {remote_sockaddr}')
if sockaddr: if sockaddr:
@ -167,50 +131,41 @@ def run_test_w_cancel_method(
"was remotely cancelled by remote actor (\'pikerd\'") "was remotely cancelled by remote actor (\'pikerd\'")
if cancel_method == 'sigint': if cancel_method == 'sigint':
# XXX: with modern `tractor` the (single-exc) with pytest.raises(
# group is collapsed so a bare KBI normally
# propagates; tolerate either form.
with pytest.raises((
KeyboardInterrupt,
BaseExceptionGroup, BaseExceptionGroup,
)) as exc_info: ) as exc_info:
trio.run(main) trio.run(main)
err = exc_info.value multi = exc_info.value
match err:
case BaseExceptionGroup(): for suberr in multi.exceptions:
for suberr in err.exceptions:
match suberr: match suberr:
# ensure we receive a remote # ensure we receive a remote cancellation error caused
# cancellation error caused by the # by the pikerd root actor since we used the
# pikerd root actor. # `.cancel_service()` API above B)
case tractor.ContextCancelled(): case tractor.ContextCancelled():
assert ( assert cancelled_msg in suberr.args[0]
cancelled_msg
in
suberr.args[0]
)
case KeyboardInterrupt(): case KeyboardInterrupt():
pass pass
case _: case _:
pytest.fail( pytest.fail(f'Unexpected error {suberr}')
f'Unexpected error {suberr}'
)
case KeyboardInterrupt():
pass
elif cancel_method == 'services': elif cancel_method == 'services':
# XXX: cancelling our own sub-service via
# `Services.cancel_service()` is a *self* # XXX NOTE: oddly, when you pass --pdb to pytest, i think since
# requested cancel: modern `tractor` absorbs the # we also use that to enable the underlying tractor debug mode,
# resulting `ContextCancelled` (canceller is our # it causes this to not raise for some reason? So if you see
# own actor) so the runtime tears down gracefully # that while changing this test.. it's prolly that.
# with NO error raised to the opener.
with pytest.raises(
tractor.ContextCancelled
) as exc_info:
trio.run(main) trio.run(main)
assert cancelled_msg in exc_info.value.args[0]
else: else:
pytest.fail(f'Test is broken due to {cancel_method}') pytest.fail(f'Test is broken due to {cancel_method}')
@ -227,9 +182,9 @@ def test_ensure_ems_in_paper_actors(
) -> None: ) -> None:
actor_name: str = 'brokerd'
backend: str = 'kraken' backend: str = 'kraken'
datad_name: str = f'datad.{backend}' brokerd_name: str = f'{actor_name}.{backend}'
brokerd_name: str = f'brokerd.{backend}'
async def main(): async def main():
@ -242,9 +197,7 @@ def test_ensure_ems_in_paper_actors(
# ensure we timeout after is startup is too slow. # ensure we timeout after is startup is too slow.
# TODO: something like this should be our start point for # TODO: something like this should be our start point for
# benchmarking end-to-end startup B) # benchmarking end-to-end startup B)
# NOTE: includes a live (kraken) symbology fetch so with trio.fail_after(9):
# the budget needs some headroom for net latency..
with trio.fail_after(19):
async with ( async with (
open_test_pikerd() as (_, _, _, services), open_test_pikerd() as (_, _, _, services),
@ -273,25 +226,15 @@ def test_ensure_ems_in_paper_actors(
async with ( async with (
ensure_service('emsd'), ensure_service('emsd'),
ensure_service(datad_name), ensure_service(brokerd_name),
ensure_service(f'paperboi.{backend}'), ensure_service(f'paperboi.{backend}'),
): ):
for name in pikerd_subservices: for name in pikerd_subservices:
assert name in services.service_tasks assert name in services.service_tasks
# datad.kraken actor should have been # brokerd.kraken actor should have been started
# started implicitly by the feed layer. # implicitly by the ems.
assert datad_name in services.service_tasks assert brokerd_name in services.service_tasks
# XXX: paper-mode sessions should NEVER
# boot a (live, credentialed) `brokerd`;
# only emsd's `open_brokerd_dialog()`
# live-ep path is allowed to spawn it!
assert (
brokerd_name
not in
services.service_tasks
)
print('ALL SERVICES STARTED, cancelling runtime with:\n' print('ALL SERVICES STARTED, cancelling runtime with:\n'
f'-> {cancel_method}') f'-> {cancel_method}')

280
uv.lock
View File

@ -117,15 +117,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/44/827b2a91a5816512fcaf3cc4ebc465ccd5d598c45cefa6703fcf4a79018f/attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1", size = 60752, upload-time = "2023-12-31T06:30:30.772Z" }, { url = "https://files.pythonhosted.org/packages/e0/44/827b2a91a5816512fcaf3cc4ebc465ccd5d598c45cefa6703fcf4a79018f/attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1", size = 60752, upload-time = "2023-12-31T06:30:30.772Z" },
] ]
[[package]]
name = "base58"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" },
]
[[package]] [[package]]
name = "bidict" name = "bidict"
version = "0.23.1" version = "0.23.1"
@ -135,50 +126,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
] ]
[[package]]
name = "blake3"
version = "1.0.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/aa/abcd75e9600987a0bc6cfe9b6b2ff3f0e2cb08c170addc6e76035b5c4cb3/blake3-1.0.8.tar.gz", hash = "sha256:513cc7f0f5a7c035812604c2c852a0c1468311345573de647e310aca4ab165ba", size = 117308, upload-time = "2025-10-14T06:47:48.83Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/a0/b7b6dff04012cfd6e665c09ee446f749bd8ea161b00f730fe1bdecd0f033/blake3-1.0.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8da4233984d51471bd4e4366feda1d90d781e712e0a504ea54b1f2b3577557b", size = 347983, upload-time = "2025-10-14T06:45:47.214Z" },
{ url = "https://files.pythonhosted.org/packages/5b/a2/264091cac31d7ae913f1f296abc20b8da578b958ffb86100a7ce80e8bf5c/blake3-1.0.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1257be19f2d381c868a34cc822fc7f12f817ddc49681b6d1a2790bfbda1a9865", size = 325415, upload-time = "2025-10-14T06:45:48.482Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7d/85a4c0782f613de23d114a7a78fcce270f75b193b3ff3493a0de24ba104a/blake3-1.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:269f255b110840e52b6ce9db02217e39660ebad3e34ddd5bca8b8d378a77e4e1", size = 371296, upload-time = "2025-10-14T06:45:49.674Z" },
{ url = "https://files.pythonhosted.org/packages/e3/20/488475254976ed93fab57c67aa80d3b40df77f7d9db6528c9274bff53e08/blake3-1.0.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66ca28a673025c40db3eba21a9cac52f559f83637efa675b3f6bd8683f0415f3", size = 374516, upload-time = "2025-10-14T06:45:51.23Z" },
{ url = "https://files.pythonhosted.org/packages/7b/21/2a1c47fedb77fb396512677ec6d46caf42ac6e9a897db77edd0a2a46f7bb/blake3-1.0.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcb04966537777af56c1f399b35525aa70a1225816e121ff95071c33c0f7abca", size = 447911, upload-time = "2025-10-14T06:45:52.637Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7d/db0626df16029713e7e61b67314c4835e85c296d82bd907c21c6ea271da2/blake3-1.0.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5b5da177d62cc4b7edf0cea08fe4dec960c9ac27f916131efa890a01f747b93", size = 505420, upload-time = "2025-10-14T06:45:54.445Z" },
{ url = "https://files.pythonhosted.org/packages/5b/55/6e737850c2d58a6d9de8a76dad2ae0f75b852a23eb4ecb07a0b165e6e436/blake3-1.0.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38209b10482c97e151681ea3e91cc7141f56adbbf4820a7d701a923124b41e6a", size = 394189, upload-time = "2025-10-14T06:45:55.719Z" },
{ url = "https://files.pythonhosted.org/packages/5b/94/eafaa5cdddadc0c9c603a6a6d8339433475e1a9f60c8bb9c2eed2d8736b6/blake3-1.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504d1399b7fb91dfe5c25722d2807990493185faa1917456455480c36867adb5", size = 388001, upload-time = "2025-10-14T06:45:57.067Z" },
{ url = "https://files.pythonhosted.org/packages/17/81/735fa00d13de7f68b25e1b9cb36ff08c6f165e688d85d8ec2cbfcdedccc5/blake3-1.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c84af132aa09abeadf9a0118c8fb26f4528f3f42c10ef8be0fcf31c478774ec4", size = 550302, upload-time = "2025-10-14T06:45:58.657Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c6/d1fe8bdea4a6088bd54b5a58bc40aed89a4e784cd796af7722a06f74bae7/blake3-1.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a25db3d36b55f5ed6a86470155cc749fc9c5b91c949b8d14f48658f9d960d9ec", size = 554211, upload-time = "2025-10-14T06:46:00.269Z" },
{ url = "https://files.pythonhosted.org/packages/55/d1/ca74aa450cbe10e396e061f26f7a043891ffa1485537d6b30d3757e20995/blake3-1.0.8-cp312-cp312-win32.whl", hash = "sha256:e0fee93d5adcd44378b008c147e84f181f23715307a64f7b3db432394bbfce8b", size = 228343, upload-time = "2025-10-14T06:46:01.533Z" },
{ url = "https://files.pythonhosted.org/packages/4d/42/bbd02647169e3fbed27558555653ac2578c6f17ccacf7d1956c58ef1d214/blake3-1.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:6a6eafc29e4f478d365a87d2f25782a521870c8514bb43734ac85ae9be71caf7", size = 215704, upload-time = "2025-10-14T06:46:02.79Z" },
{ url = "https://files.pythonhosted.org/packages/55/b8/11de9528c257f7f1633f957ccaff253b706838d22c5d2908e4735798ec01/blake3-1.0.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:46dc20976bd6c235959ef0246ec73420d1063c3da2839a9c87ca395cf1fd7943", size = 347771, upload-time = "2025-10-14T06:46:04.248Z" },
{ url = "https://files.pythonhosted.org/packages/50/26/f7668be55c909678b001ecacff11ad7016cd9b4e9c7cc87b5971d638c5a9/blake3-1.0.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d17eb6382634b3a5bc0c0e0454d5265b0becaeeadb6801ed25150b39a999d0cc", size = 325431, upload-time = "2025-10-14T06:46:06.136Z" },
{ url = "https://files.pythonhosted.org/packages/77/57/e8a85fa261894bf7ce7af928ff3408aab60287ab8d58b55d13a3f700b619/blake3-1.0.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19fc6f2b7edab8acff6895fc6e38c19bd79f4c089e21153020c75dfc7397d52d", size = 370994, upload-time = "2025-10-14T06:46:07.398Z" },
{ url = "https://files.pythonhosted.org/packages/62/cd/765b76bb48b8b294fea94c9008b0d82b4cfa0fa2f3c6008d840d01a597e4/blake3-1.0.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f54cff7f15d91dc78a63a2dd02a3dccdc932946f271e2adb4130e0b4cf608ba", size = 374372, upload-time = "2025-10-14T06:46:08.698Z" },
{ url = "https://files.pythonhosted.org/packages/36/7a/32084eadbb28592bb07298f0de316d2da586c62f31500a6b1339a7e7b29b/blake3-1.0.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7e12a777f6b798eb8d06f875d6e108e3008bd658d274d8c676dcf98e0f10537", size = 447627, upload-time = "2025-10-14T06:46:10.002Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f4/3788a1d86e17425eea147e28d7195d7053565fc279236a9fd278c2ec495e/blake3-1.0.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddfc59b0176fb31168f08d5dd536e69b1f4f13b5a0f4b0c3be1003efd47f9308", size = 507536, upload-time = "2025-10-14T06:46:11.614Z" },
{ url = "https://files.pythonhosted.org/packages/fe/01/4639cba48513b94192681b4da472cdec843d3001c5344d7051ee5eaef606/blake3-1.0.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2336d5b2a801a7256da21150348f41610a6c21dae885a3acb1ebbd7333d88d8", size = 394105, upload-time = "2025-10-14T06:46:12.808Z" },
{ url = "https://files.pythonhosted.org/packages/21/ae/6e55c19c8460fada86cd1306a390a09b0c5a2e2e424f9317d2edacea439f/blake3-1.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4072196547484c95a5a09adbb952e9bb501949f03f9e2a85e7249ef85faaba8", size = 386928, upload-time = "2025-10-14T06:46:16.284Z" },
{ url = "https://files.pythonhosted.org/packages/ee/6c/05b7a5a907df1be53a8f19e7828986fc6b608a44119641ef9c0804fbef15/blake3-1.0.8-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0eab3318ec02f8e16fe549244791ace2ada2c259332f0c77ab22cf94dfff7130", size = 550003, upload-time = "2025-10-14T06:46:17.791Z" },
{ url = "https://files.pythonhosted.org/packages/b4/03/f0ea4adfedc1717623be6460b3710fcb725ca38082c14274369803f727e1/blake3-1.0.8-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a33b9a1fb6d1d559a8e0d04b041e99419a6bb771311c774f6ff57ed7119c70ed", size = 553857, upload-time = "2025-10-14T06:46:19.088Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6f/e5410d2e2a30c8aba8389ffc1c0061356916bf5ecd0a210344e7b69b62ab/blake3-1.0.8-cp313-cp313-win32.whl", hash = "sha256:e171b169cb7ea618e362a4dddb7a4d4c173bbc08b9ba41ea3086dd1265530d4f", size = 228315, upload-time = "2025-10-14T06:46:20.391Z" },
{ url = "https://files.pythonhosted.org/packages/79/ef/d9c297956dfecd893f29f59e7b22445aba5b47b7f6815d9ba5dcd73fcae6/blake3-1.0.8-cp313-cp313-win_amd64.whl", hash = "sha256:3168c457255b5d2a2fc356ba696996fcaff5d38284f968210d54376312107662", size = 215477, upload-time = "2025-10-14T06:46:21.542Z" },
{ url = "https://files.pythonhosted.org/packages/20/ba/eaa7723d66dd8ab762a3e85e139bb9c46167b751df6e950ad287adb8fb61/blake3-1.0.8-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4d672c24dc15ec617d212a338a4ca14b449829b6072d09c96c63b6e6b621aed", size = 347289, upload-time = "2025-10-14T06:46:22.772Z" },
{ url = "https://files.pythonhosted.org/packages/47/b3/6957f6ee27f0d5b8c4efdfda68a1298926a88c099f4dd89c711049d16526/blake3-1.0.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1af0e5a29aa56d4fba904452ae784740997440afd477a15e583c38338e641f41", size = 324444, upload-time = "2025-10-14T06:46:24.729Z" },
{ url = "https://files.pythonhosted.org/packages/13/da/722cebca11238f3b24d3cefd2361c9c9ea47cfa0ad9288eeb4d1e0b7cf93/blake3-1.0.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef153c5860d5bf1cc71aece69b28097d2a392913eb323d6b52555c875d0439fc", size = 370441, upload-time = "2025-10-14T06:46:26.29Z" },
{ url = "https://files.pythonhosted.org/packages/2e/d5/2f7440c8e41c0af995bad3a159e042af0f4ed1994710af5b4766ca918f65/blake3-1.0.8-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8ae3689f0c7bfa6ce6ae45cab110e4c3442125c4c23b28f1f097856de26e4d1", size = 374312, upload-time = "2025-10-14T06:46:27.451Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6c/fb6a7812e60ce3e110bcbbb11f167caf3e975c589572c41e1271f35f2c41/blake3-1.0.8-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb83532f7456ddeb68dae1b36e1f7c52f9cb72852ac01159bbcb1a12b0f8be0", size = 447007, upload-time = "2025-10-14T06:46:29.056Z" },
{ url = "https://files.pythonhosted.org/packages/13/3b/c99b43fae5047276ea9d944077c190fc1e5f22f57528b9794e21f7adedc6/blake3-1.0.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae7754c7d96e92a70a52e07c732d594cf9924d780f49fffd3a1e9235e0f5ba7", size = 507323, upload-time = "2025-10-14T06:46:30.661Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bb/ba90eddd592f8c074a0694cb0a744b6bd76bfe67a14c2b490c8bdfca3119/blake3-1.0.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bacaae75e98dee3b7da6c5ee3b81ee21a3352dd2477d6f1d1dbfd38cdbf158a", size = 393449, upload-time = "2025-10-14T06:46:31.805Z" },
{ url = "https://files.pythonhosted.org/packages/25/ed/58a2acd0b9e14459cdaef4344db414d4a36e329b9720921b442a454dd443/blake3-1.0.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9456c829601d72852d8ba0af8dae0610f7def1d59f5942efde1e2ef93e8a8b57", size = 386844, upload-time = "2025-10-14T06:46:33.195Z" },
{ url = "https://files.pythonhosted.org/packages/4a/04/fed09845b18d90862100c8e48308261e2f663aab25d3c71a6a0bdda6618b/blake3-1.0.8-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:497ef8096ec4ac1ffba9a66152cee3992337cebf8ea434331d8fd9ce5423d227", size = 549550, upload-time = "2025-10-14T06:46:35.23Z" },
{ url = "https://files.pythonhosted.org/packages/d6/65/1859fddfabc1cc72548c2269d988819aad96d854e25eae00531517925901/blake3-1.0.8-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:511133bab85ff60ed143424ce484d08c60894ff7323f685d7a6095f43f0c85c3", size = 553805, upload-time = "2025-10-14T06:46:36.532Z" },
{ url = "https://files.pythonhosted.org/packages/c1/c7/2969352017f62378e388bb07bb2191bc9a953f818dc1cd6b9dd5c24916e1/blake3-1.0.8-cp313-cp313t-win32.whl", hash = "sha256:9c9fbdacfdeb68f7ca53bb5a7a5a593ec996eaf21155ad5b08d35e6f97e60877", size = 228068, upload-time = "2025-10-14T06:46:37.826Z" },
{ url = "https://files.pythonhosted.org/packages/d8/fc/923e25ac9cadfff1cd20038bcc0854d0f98061eb6bc78e42c43615f5982d/blake3-1.0.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3cec94ed5676821cf371e9c9d25a41b4f3ebdb5724719b31b2749653b7cc1dfa", size = 215369, upload-time = "2025-10-14T06:46:39.054Z" },
]
[[package]] [[package]]
name = "caio" name = "caio"
version = "0.9.24" version = "0.9.24"
@ -389,15 +336,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/f2/98fd8d0b514622a789fd2824b59bd6041b799aaeeba14a8d92d52f6654dd/cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb", size = 1255106, upload-time = "2025-11-30T12:48:18.454Z" }, { url = "https://files.pythonhosted.org/packages/76/f2/98fd8d0b514622a789fd2824b59bd6041b799aaeeba14a8d92d52f6654dd/cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb", size = 1255106, upload-time = "2025-11-30T12:48:18.454Z" },
] ]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]] [[package]]
name = "elastic-transport" name = "elastic-transport"
version = "8.17.1" version = "8.17.1"
@ -699,59 +637,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
] ]
[[package]]
name = "mmh3"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" },
{ url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" },
{ url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" },
{ url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" },
{ url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" },
{ url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" },
{ url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" },
{ url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" },
{ url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" },
{ url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" },
{ url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" },
{ url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" },
{ url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" },
{ url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" },
{ url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" },
{ url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" },
{ url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" },
{ url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" },
{ url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" },
{ url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" },
{ url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" },
{ url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" },
{ url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" },
{ url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" },
{ url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" },
{ url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" },
{ url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" },
{ url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" },
]
[[package]]
name = "morphys"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/4f/cb781d0ac5d079adabc77dc4f0bc99fc81c390029bd33c6e70552139e762/morphys-1.0-py2.py3-none-any.whl", hash = "sha256:76d6dbaa4d65f597e59d332c81da786d83e4669387b9b2a750cfec74e7beec20", size = 5618, upload-time = "2017-01-10T20:08:56.872Z" },
]
[[package]] [[package]]
name = "msgspec" name = "msgspec"
version = "0.19.0" version = "0.19.0"
@ -774,29 +659,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432, upload-time = "2024-12-27T17:40:16.256Z" }, { url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432, upload-time = "2024-12-27T17:40:16.256Z" },
] ]
[[package]]
name = "multiaddr"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "base58" },
{ name = "dnspython" },
{ name = "idna" },
{ name = "netaddr" },
{ name = "psutil" },
{ name = "py-cid" },
{ name = "py-multibase" },
{ name = "py-multicodec" },
{ name = "py-multihash" },
{ name = "trio" },
{ name = "trio-typing" },
{ name = "varint" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/10/4e26a8577cfce1c0febc8d83087e1373e93c695c6e73ad010546fb67e229/multiaddr-0.2.0.tar.gz", hash = "sha256:acb6b25c332ec1b2f1f8fef8d03a8c63385d34a87d690df0f4bba43cdf6efe8d", size = 58356, upload-time = "2026-03-17T21:51:00.274Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/13/56e503d01218d1ca27ea9fda862045a4b400cae5e756f47315f5aaba0eee/multiaddr-0.2.0-py3-none-any.whl", hash = "sha256:bcff7bf3d7de3d6da0b865b25423bcb411de1d20d70cc6abfacf75170d17866c", size = 40424, upload-time = "2026-03-17T21:50:58.833Z" },
]
[[package]] [[package]]
name = "multidict" name = "multidict"
version = "6.7.0" version = "6.7.0"
@ -878,15 +740,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
] ]
[[package]]
name = "netaddr"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504, upload-time = "2024-05-28T21:30:37.743Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023, upload-time = "2024-05-28T21:30:34.191Z" },
]
[[package]] [[package]]
name = "numba" name = "numba"
version = "0.62.1" version = "0.62.1"
@ -1111,7 +964,6 @@ dependencies = [
{ name = "trio-websocket" }, { name = "trio-websocket" },
{ name = "typer" }, { name = "typer" },
{ name = "websockets" }, { name = "websockets" },
{ name = "xonsh" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -1189,7 +1041,6 @@ requires-dist = [
{ name = "trio-websocket", specifier = ">=0.10.3,<0.11.0" }, { name = "trio-websocket", specifier = ">=0.10.3,<0.11.0" },
{ name = "typer", specifier = ">=0.9.0,<1.0.0" }, { name = "typer", specifier = ">=0.9.0,<1.0.0" },
{ name = "websockets", specifier = "==12.0" }, { name = "websockets", specifier = "==12.0" },
{ name = "xonsh", specifier = ">=0.23.8" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@ -1201,28 +1052,28 @@ dev = [
{ name = "i3ipc", specifier = ">=2.2.1" }, { name = "i3ipc", specifier = ">=2.2.1" },
{ name = "pdbp", specifier = ">=1.8.2,<2.0.0" }, { name = "pdbp", specifier = ">=1.8.2,<2.0.0" },
{ name = "pexpect", specifier = ">=4.9.0" }, { name = "pexpect", specifier = ">=4.9.0" },
{ name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "prompt-toolkit", specifier = "==3.0.40" },
{ name = "pyperclip", specifier = ">=1.9.0" }, { name = "pyperclip", specifier = ">=1.9.0" },
{ name = "pyqt6", specifier = ">=6.7.0,<7.0.0" }, { name = "pyqt6", specifier = ">=6.7.0,<7.0.0" },
{ name = "pyqtgraph", specifier = ">=0.14.0" }, { name = "pyqtgraph", git = "https://github.com/pikers/pyqtgraph.git" },
{ name = "pytest" }, { name = "pytest" },
{ name = "qdarkstyle", specifier = ">=3.0.2,<4.0.0" }, { name = "qdarkstyle", specifier = ">=3.0.2,<4.0.0" },
{ name = "rapidfuzz", specifier = ">=3.2.0,<4.0.0" }, { name = "rapidfuzz", specifier = ">=3.2.0,<4.0.0" },
{ name = "xonsh", specifier = ">=0.23.0" }, { name = "xonsh", specifier = ">=0.22.2" },
] ]
lint = [{ name = "ruff", specifier = ">=0.9.6" }] lint = [{ name = "ruff", specifier = ">=0.9.6" }]
repl = [ repl = [
{ name = "greenback", specifier = ">=1.1.1,<2.0.0" }, { name = "greenback", specifier = ">=1.1.1,<2.0.0" },
{ name = "pdbp", specifier = ">=1.8.2,<2.0.0" }, { name = "pdbp", specifier = ">=1.8.2,<2.0.0" },
{ name = "pexpect", specifier = ">=4.9.0" }, { name = "pexpect", specifier = ">=4.9.0" },
{ name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "prompt-toolkit", specifier = "==3.0.40" },
{ name = "pyperclip", specifier = ">=1.9.0" }, { name = "pyperclip", specifier = ">=1.9.0" },
{ name = "xonsh", specifier = ">=0.23.0" }, { name = "xonsh", specifier = ">=0.22.2" },
] ]
testing = [{ name = "pytest" }] testing = [{ name = "pytest" }]
uis = [ uis = [
{ name = "pyqt6", specifier = ">=6.7.0,<7.0.0" }, { name = "pyqt6", specifier = ">=6.7.0,<7.0.0" },
{ name = "pyqtgraph", specifier = ">=0.14.0" }, { name = "pyqtgraph", git = "https://github.com/pikers/pyqtgraph.git" },
{ name = "qdarkstyle", specifier = ">=3.0.2,<4.0.0" }, { name = "qdarkstyle", specifier = ">=3.0.2,<4.0.0" },
{ name = "rapidfuzz", specifier = ">=3.2.0,<4.0.0" }, { name = "rapidfuzz", specifier = ">=3.2.0,<4.0.0" },
] ]
@ -1289,14 +1140,14 @@ wheels = [
[[package]] [[package]]
name = "prompt-toolkit" name = "prompt-toolkit"
version = "3.0.52" version = "3.0.40"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "wcwidth" }, { name = "wcwidth" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } sdist = { url = "https://files.pythonhosted.org/packages/99/ce/172e474a87241a69baad1ce46bc8f31eae590a770cb138b9b73812c8234d/prompt_toolkit-3.0.40.tar.gz", hash = "sha256:a371c06bb1d66cd499fecd708e50c0b6ae00acba9822ba33c586e2f16d1b739e", size = 423978, upload-time = "2023-11-10T11:22:24.529Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, { url = "https://files.pythonhosted.org/packages/ed/29/cd63ff872dfc213e1cd8131f8060262db184b975868cef33302f44616c3e/prompt_toolkit-3.0.40-py3-none-any.whl", hash = "sha256:99ba3dfb23d5b5af89712f89e60a5f3d9b8b67a9482ca377c5771d0e9047a34b", size = 385150, upload-time = "2023-11-10T11:22:20.72Z" },
] ]
[[package]] [[package]]
@ -1353,28 +1204,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
] ]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]] [[package]]
name = "ptyprocess" name = "ptyprocess"
version = "0.7.0" version = "0.7.0"
@ -1384,64 +1213,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
] ]
[[package]]
name = "py-cid"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "morphys" },
{ name = "py-multibase" },
{ name = "py-multicodec" },
{ name = "py-multihash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/68c2bd0346247570e8e01e8c170a0237884e95cdfa43989527b71adaa978/py_cid-0.5.0.tar.gz", hash = "sha256:93c62586c672353a9862f3fce13c9848ea39a00378e0980e2f0eed91631f3d28", size = 38028, upload-time = "2026-02-13T19:03:28.603Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/18/eaea1571ae8b4fa490793a4b78a9641c4579a884f7a26f3d1b019d7e91c2/py_cid-0.5.0-py3-none-any.whl", hash = "sha256:2fbad437384534e2a0ab0c4068aac3e510c4cb710c89c8f6bf98f4b07ed54e3e", size = 16046, upload-time = "2026-02-13T19:03:27.516Z" },
]
[[package]]
name = "py-multibase"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "morphys" },
{ name = "python-baseconv" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/52/5ed393ab49df7e3b03995d3c4e53bae1e8c2ca40909cf25a41b346c09a38/py_multibase-2.0.0.tar.gz", hash = "sha256:58c1a264195fa1ae29ea707c6fc8196446f4bdb92e0f9a0f131e0f280b238839", size = 26857, upload-time = "2025-12-18T02:24:49.132Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/c7/38035079d9978b32b962f996f1cccaa166ecfe38723ab4349ab32166c037/py_multibase-2.0.0-py3-none-any.whl", hash = "sha256:b29ce489b556134e73998a11712c406b70950812955df64084754e0774e40900", size = 10608, upload-time = "2025-12-18T02:24:47.827Z" },
]
[[package]]
name = "py-multicodec"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "varint" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/26/ef24db0fbfec080b72c5ac4a1000da3a4d696a1e31862c695d683097a1b5/py_multicodec-1.0.0.tar.gz", hash = "sha256:78e4e3e47b6288cf635c3ca987152e6cb5510bdcdab307e7690c76ec3d5bbfeb", size = 44668, upload-time = "2025-12-18T20:41:37.976Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/da/768d07490faeae88ac361184164be9c262fececc3c6241b5fc471be4f659/py_multicodec-1.0.0-py3-none-any.whl", hash = "sha256:ae2e687bac8fdf54e3f5b3feded36b61a304d5e3c3af9438f7481f543ec15b8d", size = 26200, upload-time = "2025-12-18T20:41:37.055Z" },
]
[[package]]
name = "py-multihash"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "base58" },
{ name = "blake3" },
{ name = "mmh3" },
{ name = "morphys" },
{ name = "six" },
{ name = "varint" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/3d/ed68b0eccd0654f7f3c163d9b3d428f903e5e3e884ab1f0d0a16ba6a4f11/py_multihash-3.0.0.tar.gz", hash = "sha256:2e848941de5ef0533ca26b81940e2ffcf7b4322a3f803e8c97f4f0eca8767aa7", size = 41630, upload-time = "2025-12-17T19:30:00.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/e2/d65606db8369916fb5a9b4fe14df7e6072970d919300f3fb1c989a1d8e7d/py_multihash-3.0.0-py3-none-any.whl", hash = "sha256:3863ec1313b4eac1e5169137c143d40bf77456e57388f839441deba089f87326", size = 21215, upload-time = "2025-12-17T19:29:59.322Z" },
]
[[package]] [[package]]
name = "pyarrow" name = "pyarrow"
version = "22.0.0" version = "22.0.0"
@ -1594,15 +1365,11 @@ wheels = [
[[package]] [[package]]
name = "pyqtgraph" name = "pyqtgraph"
version = "0.14.0" version = "0.12.3"
source = { registry = "https://pypi.org/simple" } source = { git = "https://github.com/pikers/pyqtgraph.git#373f9561ea8ec4fef9b4e8bdcdd4bbf372dd6512" }
dependencies = [ dependencies = [
{ name = "colorama" },
{ name = "numpy" }, { name = "numpy" },
] ]
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" },
]
[[package]] [[package]]
name = "pyreadline3" name = "pyreadline3"
@ -1629,12 +1396,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
] ]
[[package]]
name = "python-baseconv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/d0/9297d7d8dd74767b4d5560d834b30b2fff17d39987c23ed8656f476e0d9b/python-baseconv-1.2.2.tar.gz", hash = "sha256:0539f8bd0464013b05ad62e0a1673f0ac9086c76b43ebf9f833053527cd9931b", size = 4929, upload-time = "2019-04-04T19:28:57.17Z" }
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@ -1915,13 +1676,12 @@ wheels = [
[[package]] [[package]]
name = "tractor" name = "tractor"
version = "0.1.0a6.dev0" version = "0.1.0a6.dev0"
source = { git = "https://github.com/goodboy/tractor.git?branch=main#5c98ab1fb6e2cb7781aa75845c7f323a52853c46" } source = { git = "https://github.com/goodboy/tractor.git?branch=main#e77198bb64f0467a50e251ed140daee439752354" }
dependencies = [ dependencies = [
{ name = "bidict" }, { name = "bidict" },
{ name = "cffi" }, { name = "cffi" },
{ name = "colorlog" }, { name = "colorlog" },
{ name = "msgspec" }, { name = "msgspec" },
{ name = "multiaddr" },
{ name = "pdbp" }, { name = "pdbp" },
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "tricycle" }, { name = "tricycle" },
@ -2064,12 +1824,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
] ]
[[package]]
name = "varint"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/fe/1ea0ba0896dfa47186692655b86db3214c4b7c9e0e76c7b1dc257d101ab1/varint-1.0.2.tar.gz", hash = "sha256:a6ecc02377ac5ee9d65a6a8ad45c9ff1dac8ccee19400a5950fb51d594214ca5", size = 1886, upload-time = "2016-02-24T20:42:38.5Z" }
[[package]] [[package]]
name = "wcwidth" name = "wcwidth"
version = "0.2.14" version = "0.2.14"
@ -2142,13 +1896,13 @@ wheels = [
[[package]] [[package]]
name = "xonsh" name = "xonsh"
version = "0.23.8" version = "0.22.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/77/0c4c39ad866d4ea1ef553f325d16e804d1bf1eeecc591f0e81b057aa37db/xonsh-0.23.8.tar.gz", hash = "sha256:541bb976c93a81571792644403bae8737145023da5f48d4c493909ab5c04ba0f", size = 1172271, upload-time = "2026-05-30T04:47:22.53Z" } sdist = { url = "https://files.pythonhosted.org/packages/48/df/1fc9ed62b3d7c14612e1713e9eb7bd41d54f6ad1028a8fbb6b7cddebc345/xonsh-0.22.4.tar.gz", hash = "sha256:6be346563fec2db75778ba5d2caee155525e634e99d9cc8cc347626025c0b3fa", size = 826665, upload-time = "2026-02-17T07:53:39.424Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/4a/2aab8300ad218dfc7678c34d5f703f09df5681fecc6e66d48c951ef58049/xonsh-0.23.8-py311-none-any.whl", hash = "sha256:4bab3e405643df2cc78ec2cac13241471841796fe710386d2179666aae8a5f9c", size = 799846, upload-time = "2026-05-30T04:47:21.211Z" }, { url = "https://files.pythonhosted.org/packages/2e/00/7cbc0c1fb64365a0a317c54ce3a151c9644eea5a509d9cbaae61c9fd1426/xonsh-0.22.4-py311-none-any.whl", hash = "sha256:38b29b29fa85aa756462d9d9bbcaa1d85478c2108da3de6cc590a69a4bcd1a01", size = 654375, upload-time = "2026-02-17T07:53:37.702Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/aa66ef6046f90769dd8fcb3ddca9d00282d12e3d73645abbf12f190f17cf/xonsh-0.23.8-py312-none-any.whl", hash = "sha256:c7d0f0fba0cafe0bd75bf202820aeffc74b52943fa27d98d3b4346793f6ba493", size = 799868, upload-time = "2026-05-30T04:47:19.158Z" }, { url = "https://files.pythonhosted.org/packages/2e/c2/3dd498dc28d8f89cdd52e39950c5e591499ae423f61694c0bb4d03ed1d82/xonsh-0.22.4-py312-none-any.whl", hash = "sha256:4e538fac9f4c3d866ddbdeca068f0c0515469c997ed58d3bfee963878c6df5a5", size = 654300, upload-time = "2026-02-17T07:53:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/12/fe/2d757d82b57332f1c6cd3f8c168fbcf060a275895a763542255ae1c53d75/xonsh-0.23.8-py313-none-any.whl", hash = "sha256:1b7335522a6ecd63f0d84151977a7a9050874d3ecec00cf79919d0770bebb1b4", size = 800388, upload-time = "2026-05-30T04:47:18.47Z" }, { url = "https://files.pythonhosted.org/packages/82/7d/1f9c7147518e9f03f6ce081b5bfc4f1aceb6ec5caba849024d005e41d3be/xonsh-0.22.4-py313-none-any.whl", hash = "sha256:cc5fabf0ad0c56a2a11bed1e6a43c4ec6416a5b30f24f126b8e768547c3793e2", size = 654818, upload-time = "2026-02-17T07:53:33.477Z" },
] ]
[[package]] [[package]]