Compare commits

..

44 Commits

Author SHA1 Message Date
Nelson Torres 2256cdf190 Mask `ruff` config and pin `websockets=0.12`
- the `ruff` section in the `pyproject.toml` is somehow borked? (even
  though it def was working a while back..)
- `websockets` is completely broken in latest version since it's using
  old-ass `asyncio` APIs of some sort i think??
2025-02-11 16:18:20 -03:00
Tyler Goodlet 8d00151aee `deribit.feed`: fix "trade" event streaming
The main change needed to make `piker.data.feed._FeedsBus` work was
to correctly format the `'trade'` msgs with the (new schema) expected
`'ticks': list[dict]` field which,
- we compute the `piker` quote-msg-`dict` from the (now directly proxied through)
  `cryptofeed.types.Trade`'s fields inside the body of `stream_quotes()`.
- similarly, move the `'l1'` msg processing, **out of** the `asyncio`-side
  `_l1()` callback (defined as a closure in `.api.aio_price_feed_relay()`
  and passed to the `cryptofeed.FeedHandler`) and instead mod the
  callback to simply pass through the `.types.L1Book` ref directly to
  the `piker`/`trio` side task for conversion.

In support of all that,
- mask-to-drop the alt-branch to wait on a first rt event when the
  `cryptofeed.LastTradesResult.trades: list[Trade]` is empty; doesn't
  seem like this ever even happens?
- add a buncha typing, comments and doc-strs to the routines in
  `.deribit.api` including notes on where we can choose to mod the
  `.bs_fqme` for our eventually preferred `piker` style format.
- simplify some nested `@acm` enters to the new single `async with
  <tuple>)` style.
- be particularly pedantic about typing
  `tractor.to_asyncio.LinkedTaskChannel`
- bit of pep8 line-spacing fixes in `.venues`.
2025-02-11 16:09:13 -03:00
Tyler Goodlet 05bdec7d1d Ignore non-`.parquet` (suffixed) paths for now during tsdb fs-indexing 2025-02-11 16:08:52 -03:00
Tyler Goodlet 5aad43378c `.deribit.feed`: get live quotes workin (again)
The quote-msg `'topic'` field was being set and sent as the
`OptionPair.symbol: str` value instead of as the `MktPair.bs_fqme: str`
as is required for matching on the `piker.data.feed` side. So change to
that and simplify the actual `.bs_fqme: str` value to NOT include the
ISO-format time (for now) since it's a big ugly and longer term we need
a `piker`-fqme friendly-on-ze-eyes format/style anyway..
2025-02-11 16:08:43 -03:00
Tyler Goodlet ab9789d35e Bit more `cryptofeed` adapter formatting and typing for clarity.. 2025-02-11 16:08:38 -03:00
Tyler Goodlet a6d6700b07 .deribit.venues: add todo for an ideal `OptionPair.expiry` fmt/value 2025-02-11 16:08:31 -03:00
Tyler Goodlet 8ecfbd77ac `.data._sampling`: warn about subscriber-less msgs
Since it usually means the data-provider backend is keying the msgs
incorrectly (not using the equivalent `MktPair.bs_fqme` which as
would be rendered from the delivered `FeedInit.mkt` instance..) and
reporting the subs list should make it clear how the fqme matching is
off.

Deats,
- use the new `.log.mk_repr()` for a formatter.
- add a commented info emission that can be unmasked to help debug any
  such cases as mentioned in the summary ^^.
2025-02-11 16:08:25 -03:00
Tyler Goodlet a8ca901ddd Add `.log.mk_repr()` to create `reprlib.Repr`s 2025-02-11 16:08:18 -03:00
Tyler Goodlet 4dbf5b1c10 Report the closest (via fuzzy match) pairs on unmatched input 2025-02-11 16:08:09 -03:00
Tyler Goodlet 4d92d925b6 Signal hist start using `OptionPair.creation_timestamp`
Such that the `get_hist()` query func raises `DataUnavailable` with an
explicit message regarding the start of the (option) contract's
lifetime.

Other,
- mask some unused imports (for now?)
- drop a duplicate `tractor.get_console_log()` call which was causing
  duplicate console emits (it's already setup by brokerd init now).
- comment various unused code bits i found.
- add a info log around live quotes so we can see for the moment when
  they actually occur.. XD
2025-02-11 16:08:03 -03:00
Tyler Goodlet fc40612ca6 `.deribit.api` bit of tidying/typing
There were some imports missing or unused as well as a variety of spots
that had grokability issues due to missing type hints.

Other tweaks as part some more thorough manual testing:
- always raise when not `brokers.toml` section since the API can never
  work (no free data without keys).
- inline the `Asset.atype='crypto_currency` field despite it maybe not
  being the best value for `OptionPair` instruments..
- tossed in a now-masked pause block for debugging history queries in
  `Client.bars()`.
- commented out all the live order ctl (internal) endpoints for now
  since they're unused.
2025-02-11 16:07:57 -03:00
Tyler Goodlet d0a12069eb 'Fix `Optional` and use `'linear/reverse'` in `OptionPair.venue`' 2025-02-11 16:07:46 -03:00
Tyler Goodlet d2f6a31883 Mk jsronrpc's underlying ws timeout `float('inf')`
Since currently we're only using this IPC subsys for `deribit`, and
generally speaking we're primarly supporting options markets (which are
fairly "slow moving"), flip to a default of NOT resetting the `NoBsWs`
on timeout since doing so normally breaks the jsron-rpc IPC session.
Without a proper `fixture` passed to `open_autorecon_ws()` (which we
should eventually implement!!) relying on a timeout-to-reset more or
less will just cause breakage issues - a proper reconnect sequence must
be implemented before using that feature.

Deats,
- expose and proxy through the `msg_recv_timeout` from
  `open_jsonrpc_session()` into the underlying `open_autorecon_ws()`
  call.
2025-02-11 16:07:37 -03:00
Tyler Goodlet 4f4cb29a4c Refine history gap/termination signalling
Namely handling backends which do not provide a default "frame
size-duration" in their init-config by making the backfiller guess the
value based on the first frame received.

Deats,
- adjust `start_backfill()` to take a more explicit
  `def_frame_duration: Duration` expected to be unpacked from any
  backend hist init-config by the `tsdb_backfill()` caller which now
  also computes a value from the first received frame when the config
  section isn't provided.
- in `start_backfill()` we now always expect the `def_frame_duration`
  input and always decrement the query range by this value whenever
  a `NoData` is raised by the provider-backend paired with an explicit
  `log.warning()` about the handling.
- also relay any `DataUnavailable.args[0]` message from the provider
  in the handler.
- repair "gap reporting" which checks for expected frame duration vs.
  that received with much better humanized logging on the missing
  segment using `pendulum.Interval/Duration.in_words()` output.
2025-02-11 16:07:28 -03:00
Tyler Goodlet d30cfeab2c Only use `frame_types` if delivered during enter
The `open_history_client()` provider endpoint can *optionally*
deliver a `frame_types: dict[int, pendulum.Duration]` subsection in its
`config: dict[str, dict]` (as was implemented with the `ib` backend).
This allows the `tsp` backfilling machinery to use this "recommended
frame duration" to subtract from the `last_start_dt` any time a `NoData`
gap is signalled by the `get_hist()` call allowing gaps to be ignored
safely without missing history by knowing the next earliest dt we can
query from using the `end_dt`. However, currently all crypto$ providers
haven't implemented this feat yet..

As such only try to use the `frame_types` feature if provided when
handling `NoData` conditions inside `tsp.start_backfill()` and otherwise
raise as normal.
2025-02-11 16:07:17 -03:00
Nelson Torres 8165c684e5 Deribit's feed fix
- `FeedInit` for init_msgs in `stream_quotes`.

- new cache is `client_pairs` so is replacing the old `client.cache_symbols`.

- `get_mkt_info` added

- `get_ohlc` fixed to comply the new ways of the feed.
2025-02-03 19:22:26 -03:00
Nelson Torres ddf47c4555 Deribit's api fix
key changes:

- Resolved the issue with the expiration dates from deribits, now we int instead of the crazy custom deribits format.

- The client now has a new  `_json_rpc_auth_wrapper` that adquires a first access token and then will refresh the access token when this expires.

- `get_assets` fixed, now  we use the public endpoint to check the availables assets, in the future probably this will change, but for now is working just fine.

- `get_mkt_pairs` added.

- `exch_info` added.

- `cache_symbols` fixed.

- Also a lot of reformat made in api.
2025-02-03 19:22:26 -03:00
Nelson Torres c196188eaa Venues
Moved from api to venues all the msgspecs structs, also added critical imports in api, feed and __init__ mods.
2025-02-03 19:22:26 -03:00
Tyler Goodlet dd0d2bd07f Allow ledger passes to ignore (symcache) unknown fqmes
For example in the paper-eng, if you have a backend that doesn't fully
support a symcache (yet) it's handy to be able to ignore processing
other paper-eng txns when all you care about at the moment is the
simulated symbol.

NOTE, that currently this will still result in a key-error when you load
more then one mkt with the paper engine (for which the backend does not
have the symcache implemented) since no fqme ad-hoc query was made for
the 2nd symbol (and i'm not sure we should support that kinda hackery
over just encouraging the sym-cache being added?). Def needs a little
more thought depending on how many backends are never going to be able
to (easily) support caching..
2025-02-03 19:08:13 -03:00
Tyler Goodlet 3a7a983451 .clearing._ems: Don't require `first_quote['last']`
Instead just check for the field (which i'm not huge on the key-name for
anyway) and if not found get the "last price" from the real-time shm
buffer's latest 'close' sample.

Unrelatedly, use a `subs.copy()` in the `Router.client_broadcast()` loop
such that if a `client_stream` is popped on connection failure, we don't
RTE for the "size changed on iteration".
2025-02-03 19:08:13 -03:00
Tyler Goodlet 7d62b9d5e7 `kucoin`: repair live quotes streaming..
This must have broke at some point during the new `MktPair` and thus
`.fqme: str` updates; mas-o-menos the symbol key in the quote-msg-`dict`
was NOT set to the `MktPair.bs_fqme: str` value and thus wasn't being
processed by the downstream sampling and feed subsys.

So fix that as well as a few other refinements,
- set the `topic: mkt.bs_fqme` in quote msgs obvi.
- drop the "wait for first clearing vlm" quote poll loop; going to fix
  the sampler to handle a `first_quote` without a `'last'` key.
- add some typing around calls to `get_mkt_info()`.
- rename `stream_messages()` -> `iter_normed_quotes()`.
2025-02-03 19:08:13 -03:00
Nelson Torres c96e3d377a Deleted settlePlan field from binance FutesPair. 2025-02-03 19:08:13 -03:00
Nelson Torres 04e03be475 Added missing fields for kucoin.
feeCategory, makerFeeCoefficient, takerFeeCoefficient and st.
2025-02-03 19:08:13 -03:00
Tyler Goodlet e87939e1f2 data._web_bs: try to raise jsonrpc errors in parent task 2025-02-03 18:52:11 -03:00
Nelson Torres 3630c02f48 Updated tractor method name. 2025-02-03 18:52:11 -03:00
Tyler Goodlet fdf34e51bb Drop `.cancel_actor()` from `maybe_spawn_daemon()`
Since `tractor`'s new and improved inter-actor cancellation semantics
are much more pedantic, AND bc we use the `ServiceMngr` for spawning
service actors on-demand, the caller of `maybe_spawn_daemon()` should
NEVER conduct a so called "out of band" `Actor`-runtime cancel request
since this is precisely the job of our `ServiceMngr` XD

Add a super in depth note explaining the underlying issue and adding
a todo list of how we should prolly augment `tractor` to make such cases
easier to grok and fix in the future!
2025-02-03 18:52:11 -03:00
Tyler Goodlet a0b540022e Prep service mngr for move to `tractor.hilevel`
Given it's a fairly simple yet useful abstraction, it makes sense to
offer this sub-sys alongside the core `tractor` runtime lib.

Without going into extreme detail on the impl changes (it'll come in
the commit that moves to the other repo) here is the high level summary:
------ - ------
- rename `Services` -> `ServiceMngr` and use an factory `@acm`
  to guarantee a single-instance-per-actor using a niche approach for a
  singleton object using a default keyword-arg B)
  - the mod level `open_service_mngr()` and `get_service_mngr()` are the
    new allocation/access API.
- add a `ServiceMngr.start_service()` method which does the work of both
  spawning a new subactor (for the daemon) and uses its portal to start
  the mngr side supervision task.
- open actor/task nurseries inside the `@acm` allocator.

Adjust other dependent subsystems to match:
------ - ------
- use `open_service_mngr()` when first allocated in `open_pikerd()`.
- use `get_service_mngr()` instead of importing the class ref inside
  `.service.maybe_spawn_daemon()`, `.brokers._daemon.spawn_brokerd()`
  and `.data._sampling.spawn_samplerd()` using a `partial` to pack in
  the endpoint ctx kwargs (unpacked inside `.start_service()` XD).
2025-02-03 18:52:11 -03:00
Tyler Goodlet 20f6343be2 Lel, forgot to add a `SPOT` venue for `binance`.. 2025-02-03 18:52:11 -03:00
Tyler Goodlet 2f30fc4fb8 Mask no-data pause-point, add perps to sig.
Was orig for debugging an issue with `kucoin` i think but definitely
shouldn't be left in XD

Also add `'perpetual_future'` to `start_backfill()` input literal set.
2025-02-03 18:52:11 -03:00
Nelson Torres 76b5547c2d uv migration 2025-02-03 18:49:07 -03:00
Nelson Torres 08811f9a61 Default.nix fix:
Since the last `poetry` update the command `poetry shell` have been
moved to a plugin, this affects the entire instalation, so now we
need to add more deps to the `buildInputs` and added to the
`LD_LIBRARY_PATH`

To run a command now you need to do something like this:

`poetry run piker ...`
2025-02-03 18:46:20 -03:00
Tyler Goodlet b577180773 ib: `.api` mod and log-fmt cleaning
About time we tidy'd a buncha status logging in this backend..
particularly for boot-up where there's lots of client-try-connect poll
looping with account detection from the user config.

`.api.Client` pprint and logging fmt improvements:
- add `Client.__repr__()` which shows the minimally useful set of info
  from the underlying `.ib: IB` as well as a new `.acnts: list[str]`
  of the account aliases defined in the user's `brokers.toml`.
- mk `.bars()` define a comprehensive `query_info: str` with all the
  request deats but only display if there's a problem with the response
  data.
- mk `.get_config()` report both the config file path and the acnt
  aliases (NOT the actual account #s).
- move all `.load_aio_clients()` client poll loop requests do
  `log.runtime()` statuses, only falling through to a `.warning()` when
  the loop fails to connect the client to the spec-ed API-gw addr, and
 |_ don't allow loading accounts for which the user has not defined an
    alias in `brokers.toml::[ib]`; raise a value-error in such cases
    with a message indicating how to mod the config.
 |_ only `log.info()` about acnts if some were loaded..

Other mod logging de-noising:
- better status fmting in `.symbols.open_symbol_search()` with
  `repr(Client)`.
- for `.feed.stream_quotes()` first quote reporting use `.runtime()`.
2024-06-20 14:40:21 -04:00
Tyler Goodlet f12c452d96 ib: warn about mkt precision cuckups that `Contract`s clearly deliver wrong.. 2024-06-18 12:42:21 -04:00
Tyler Goodlet 3531c2edc1 ib: mask out trade and vlm rates for now 2024-06-18 10:03:34 -04:00
Tyler Goodlet 97dd7e766a ib: more trade record edge case handling
- timestamps came as `'date'`-keyed from 2022 and before but now are
  `'datetime'`..
- some symbols seem to have no commission field, so handle that..
- when no `'price'` field found return `None` from `norm_trade()`.
- add a warn log on mid-fill commission updates.
2024-06-18 10:00:18 -04:00
Tyler Goodlet ab1463d942 Port binance to `httpx`
Like other backends use the `AsyncClient` for all venue specific
client-sessions but change to allocating them inside `get_client()`
using an `AsyncExitStack` and inserting directly in the
`Client.venue_sesh: dict` table during init.

Supporting impl tweaks:
- remove most of the API client session building logic and instead make
  `Client.__init__()` take in a `venue_sessions: dict` (set it to
  `.venue_sesh`) and `conf: dict`, instead opting to do the http client
  configuration inside `get_client()` since all that code only needs to
  be run once.
 |_load config inside `get_client()` once.
 |_move session token creation into a new util func `init_api_keys()` and
  also call it from `get_client()` factory; toss in an ex. toml section
  config to the doc string.
- define `_venue_urls: dict[str, str]` (content taken from old static
  `.venue_sesh` dict) at module level and feed them as `base_url: str`
  inputs to the client create loop.
- adjust all call sigs in httpx-sesh-using methods, namely just
  `._api()`.
- do a `.exch_info()` call in `get_client()` to cache the symbology
  set.

Unrelated changes for various other outstanding buggers:
- to get futures feeds correctly loading when selected
  from search (like 'XMRUSDT.USDTM.PERP'), expect a `MktPair` input to
  `Client.bars()` such that the exact venue-key can be looked up (via
  a new `.pair2venuekey()` meth) and then passed to `._api()`.
- adjust `.broker.open_trade_dialog()` to failover to paper engine when
  there's no `api_key` key set for the `subconf` venue-key.
2024-06-12 09:41:23 -04:00
Nelson Torres 5314cb79d4 Added note to exception when missing field in SpotPair class 2024-06-11 16:57:59 -04:00
Nelson Torres 0c0b7116e3 Added new fields to SpotPair class in venues 2024-06-11 16:57:59 -04:00
Tyler Goodlet 19c343e8b2 binance: raise `NoData` on null hist arrays
Like we do with other history backends to indicate lack of a data set.
This avoids any raise that will will bring down the backloader task with
some downstream error.

Raise a `ValueError` on no time index for now.
2024-06-11 10:28:56 -04:00
Tyler Goodlet b7883325a9 Woops, `data` can be an empty list XD 2024-05-28 16:19:28 -04:00
Tyler Goodlet 37ca081555 Woops, fix missing `api_url` ref in error log 2024-05-24 12:24:25 -04:00
Tyler Goodlet 44b8c70521 Change type-annots to use `httpx.Response` 2024-05-20 12:55:45 -04:00
Tyler Goodlet e6af97c596 Port `kucoin` backend to `httpx` 2024-05-20 11:09:30 -04:00
Tyler Goodlet 95ace5acb8 Port `kraken` backend to `httpx` 2024-05-20 11:09:10 -04:00
113 changed files with 6455 additions and 9788 deletions

View File

@ -1,199 +1,162 @@
piker piker
----- -----
trading gear for hackers trading gear for hackers.
|gh_actions| |gh_actions|
.. |gh_actions| image:: https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fpikers%2Fpiker%2Fbadge&style=popout-square .. |gh_actions| image:: https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fpikers%2Fpiker%2Fbadge&style=popout-square
:target: https://actions-badge.atrox.dev/piker/pikers/goto :target: https://actions-badge.atrox.dev/piker/pikers/goto
``piker`` is a broker agnostic, next-gen FOSS toolset and runtime for ``piker`` is a broker agnostic, next-gen FOSS toolset for real-time
real-time computational trading targeted at `hardcore Linux users computational trading targeted at `hardcore Linux users <comp_trader>`_ .
<comp_trader>`_ .
we use much bleeding edge tech including (but not limited to): we use as much bleeding edge tech as possible including (but not limited to):
- latest python for glue_ - latest python for glue_
- uv_ for packaging and distribution - trio_ & tractor_ for our distributed, multi-core, real-time streaming
- trio_ & tractor_ for our distributed `structured concurrency`_ runtime `structured concurrency`_ runtime B)
- Qt_ for pristine low latency UIs - Qt_ for pristine high performance UIs
- pyqtgraph_ (which we've extended) for real-time charting and graphics - pyqtgraph_ for real-time charting
- ``polars`` ``numpy`` and ``numba`` for redic `fast numerics`_ - ``polars`` ``numpy`` and ``numba`` for `fast numerics`_
- `apache arrow and parquet`_ for time-series storage - `apache arrow and parquet`_ for time series history management
persistence and sharing
- (prototyped) techtonicdb_ for L2 book storage
potential projects we might integrate with soon, .. |travis| image:: https://img.shields.io/travis/pikers/piker/master.svg
:target: https://travis-ci.org/pikers/piker
- (already prototyped in ) techtonicdb_ for L2 book storage
.. _comp_trader: https://jfaleiro.wordpress.com/2019/10/09/computational-trader/
.. _glue: https://numpy.org/doc/stable/user/c-info.python-as-glue.html#using-python-as-glue
.. _uv: https://docs.astral.sh/uv/
.. _trio: https://github.com/python-trio/trio .. _trio: https://github.com/python-trio/trio
.. _tractor: https://github.com/goodboy/tractor .. _tractor: https://github.com/goodboy/tractor
.. _structured concurrency: https://trio.discourse.group/ .. _structured concurrency: https://trio.discourse.group/
.. _marketstore: https://github.com/alpacahq/marketstore
.. _techtonicdb: https://github.com/0b01/tectonicdb
.. _Qt: https://www.qt.io/ .. _Qt: https://www.qt.io/
.. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph .. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph
.. _glue: https://numpy.org/doc/stable/user/c-info.python-as-glue.html#using-python-as-glue
.. _apache arrow and parquet: https://arrow.apache.org/faq/ .. _apache arrow and parquet: https://arrow.apache.org/faq/
.. _fast numerics: https://zerowithdot.com/python-numpy-and-pandas-performance/ .. _fast numerics: https://zerowithdot.com/python-numpy-and-pandas-performance/
.. _techtonicdb: https://github.com/0b01/tectonicdb .. _comp_trader: https://jfaleiro.wordpress.com/2019/10/09/computational-trader/
focus and feats: focus and features:
**************** *******************
fitting with these tenets, we're always open to new - 100% federated: your code, your hardware, your data feeds, your broker fills.
framework/lib/service interop suggestions and ideas! - zero web: low latency, native software that doesn't try to re-invent the OS
- maximal **privacy**: prevent brokers and mms from knowing your
planz; smack their spreads with dark volume.
- zero clutter: modal, context oriented UIs that echew minimalism, reduce
thought noise and encourage un-emotion.
- first class parallelism: built from the ground up on next-gen structured concurrency
primitives.
- traders first: broker/exchange/asset-class agnostic
- systems grounded: real-time financial signal processing that will
make any queuing or DSP eng juice their shorts.
- non-tina UX: sleek, powerful keyboard driven interaction with expected use in tiling wms
- data collaboration: every process and protocol is multi-host scalable.
- fight club ready: zero interest in adoption by suits; no corporate friendly license, ever.
- **100% federated**: fitting with these tenets, we're always open to new framework suggestions and ideas.
your code, your hardware, your data feeds, your broker fills.
- **zero web**: building the best looking, most reliable, keyboard friendly trading
low latency as a prime objective, native UIs and modern IPC platform is the dream; join the cause.
protocols without trying to re-invent the "OS-as-an-app"..
- **maximal privacy**:
prevent brokers and mms from knowing your planz; smack their
spreads with dark volume from a VPN tunnel.
- **zero clutter**:
modal, context oriented UIs that echew minimalism, reduce thought
noise and encourage un-emotion.
- **first class parallelism**:
built from the ground up on a next-gen structured concurrency
supervision sys.
- **traders first**:
broker/exchange/venue/asset-class/money-sys agnostic
- **systems grounded**:
real-time financial signal processing (fsp) that will make any
queuing or DSP eng juice their shorts.
- **non-tina UX**:
sleek, powerful keyboard driven interaction with expected use in
tiling wms (or maybe even a DDE).
- **data collab at scale**:
every actor-process and protocol is multi-host aware.
- **fight club ready**:
zero interest in adoption by suits; no corporate friendly license,
ever.
building the hottest looking, fastest, most reliable, keyboard
friendly FOSS trading platform is the dream; join the cause.
a sane install with `uv` sane install with `poetry`
************************ **************************
bc why install with `python` when you can faster with `rust` :: TODO!
uv sync
# ^ astral's docs,
# https://docs.astral.sh/uv/concepts/projects/sync/
include all GUIs (ex. for charting)::
uv sync --group uis
AND with **all** our normal hacking tools::
uv sync --dev
AND if you want to try WIP integrations::
uv sync --all-groups
Ensure you can run the root-daemon::
uv run pikerd [-l info --pdb]
install on nix(os) rigorous install on ``nixos`` using ``poetry2nix``
****************** **************************************************
``NixOS`` is our core devs' distro of choice for which we offer TODO!
a stringently defined development shell envoirment that can currently
be applied in one of 2 ways::
# ONLY if running on X11
nix-shell default.nix
Or if you prefer flakes style and a modern DE::
# ONLY if also running on Wayland
nix develop # for default bash
nix develop -c uv run xonsh # for @goodboy's preferred sh B)
start a chart hacky install on nixos
************* **********************
run a realtime OHLCV chart stand-alone:: `NixOS` is our core devs' distro of choice for which we offer
a stringently defined development shell envoirment that can be loaded with::
[uv run] piker -l info chart btcusdt.spot.binance xmrusdt.spot.kraken nix-shell develop.nix
# ^^^ iff you haven't activated the py-env, this will setup the required python environment to run piker, make sure to
# - https://docs.astral.sh/uv/concepts/projects/run/ run::
#
# in order to create an explicit virt-env see,
# - https://docs.astral.sh/uv/concepts/projects/layout/#the-project-environment
# - https://docs.astral.sh/uv/pip/environments/
#
# use $UV_PROJECT_ENVIRONMENT to select any non-`.venv/`
# as the venv sudir in the repo's root.
# - https://docs.astral.sh/uv/reference/environment/#uv_project_environment
this runs a chart UI (with 1m sampled OHLCV) and shows 2 spot markets from 2 diff cexes pip install -r requirements.txt -e .
overlayed on the same graph. Use of `piker` without first starting
a daemon (`pikerd` - see below) means there is an implicit spawning of the
multi-actor-runtime (implemented as a `tractor` app).
For additional subsystem feats available through our chart UI see the once after loading the shell
various sub-readmes:
- order control using a mouse-n-keyboard UX B)
- cross venue market-pair (what most call "symbol") search, select, overlay Bo
- financial-signal-processing (`piker.fsp`) write-n-reload to sub-chart BO
- src-asset derivatives scan for anal, like the infamous "max pain" XO
spawn a daemon standalone install wild-west style via `pip`
************************* *********************************
we call the root actor-process the ``pikerd``. it can be (and is ``piker`` is currently under heavy pre-alpha development and as such
recommended normally to be) started separately from the ``piker should be cloned from this repo and hacked on directly.
chart`` program::
for a development install::
git clone git@github.com:pikers/piker.git
cd piker
virtualenv env
source ./env/bin/activate
pip install -r requirements.txt -e .
check out our charts
********************
bet you weren't expecting this from the foss::
piker -l info -b kraken -b binance chart btcusdt.binance --pdb
this runs the main chart (currently with 1m sampled OHLC) in in debug
mode and you can practice paper trading using the following
micro-manual:
``order_mode`` (
edge triggered activation by any of the following keys,
``mouse-click`` on y-level to submit at that price
):
- ``f``/ ``ctl-f`` to stage buy
- ``d``/ ``ctl-d`` to stage sell
- ``a`` to stage alert
``search_mode`` (
``ctl-l`` or ``ctl-space`` to open,
``ctl-c`` or ``ctl-space`` to close
) :
- begin typing to have symbol search automatically lookup
symbols from all loaded backend (broker) providers
- arrow keys and mouse click to navigate selection
- vi-like ``ctl-[hjkl]`` for navigation
you can also configure your position allocation limits from the
sidepane.
run in distributed mode
***********************
start the service manager and data feed daemon in the background and
connect to it::
pikerd -l info --pdb pikerd -l info --pdb
the daemon does nothing until a ``piker``-client (like ``piker
chart``) connects and requests some particular sub-system. for
a connecting chart ``pikerd`` will spawn and manage at least,
- a data-feed daemon: ``datad`` which does all the work of comms with connect your chart::
the backend provider (in this case the ``binance`` cex).
- a paper-trading engine instance, ``paperboi.binance``, (if no live
account has been configured) which allows for auto/manual order
control against the live quote stream.
*using* an actor-service (aka micro-daemon) manager which dynamically piker -l info -b kraken -b binance chart xmrusdt.binance --pdb
supervises various sub-subsystems-as-services throughout the ``piker``
runtime-stack.
now you can (implicitly) connect your chart::
piker chart btcusdt.spot.binance enjoy persistent real-time data feeds tied to daemon lifetime. the next
time you spawn a chart it will load much faster since the data feed has
since ``pikerd`` was started separately you can now enjoy a persistent been cached and is now always running live in the background until you
real-time data stream tied to the daemon-tree's lifetime. i.e. the next kill ``pikerd``.
time you spawn a chart it will obviously not only load much faster
(since the underlying ``datad.binance`` is left running with its
in-memory IPC data structures) but also the data-feed and any order
mgmt states should be persistent until you finally cancel ``pikerd``.
if anyone asks you what this project is about if anyone asks you what this project is about
********************************************* *********************************************
you don't talk about it; just use it. you don't talk about it.
how do i get involved? how do i get involved?
@ -203,15 +166,6 @@ enter the matrix.
how come there ain't that many docs how come there ain't that many docs
*********************************** ***********************************
i mean we want/need them but building the core right has been higher suck it up, learn the code; no one is trying to sell you on anything.
prio then marketting (and likely will stay that way Bp). also, we need lotsa help so if you want to start somewhere and can't
necessarily write serious code, this might be the place for you!
soo, suck it up bc,
- no one is trying to sell you on anything
- learning the code base is prolly way more valuable
- the UI/UXs are intended to be "intuitive" for any hacker..
we obviously need tonz help so if you want to start somewhere and
can't necessarily write "advanced" concurrent python/rust code, this
helping document literally anything might be the place for you!

View File

@ -1,5 +1,6 @@
################
# ---- CEXY ---- # ---- CEXY ----
################
[binance] [binance]
accounts.paper = 'paper' accounts.paper = 'paper'
@ -12,41 +13,28 @@ accounts.spot = 'spot'
spot.use_testnet = false spot.use_testnet = false
spot.api_key = '' spot.api_key = ''
spot.api_secret = '' spot.api_secret = ''
# ------ binance ------
[deribit] [deribit]
# std assets
key_id = '' key_id = ''
key_secret = '' key_secret = ''
# options
accounts.option = 'option'
option.use_testnet = false
option.key_id = ''
option.key_secret = ''
# aux logging from `cryptofeed`
option.log.filename = 'cryptofeed.log'
option.log.level = 'DEBUG'
option.log.disabled = true
# ------ deribit ------
[kraken] [kraken]
key_descr = '' key_descr = ''
api_key = '' api_key = ''
secret = '' secret = ''
# ------ kraken ------
[kucoin] [kucoin]
key_id = '' key_id = ''
key_secret = '' key_secret = ''
key_passphrase = '' key_passphrase = ''
# ------ kucoin ------
################
# -- BROKERZ --- # -- BROKERZ ---
################
[questrade] [questrade]
refresh_token = '' refresh_token = ''
access_token = '' access_token = ''
@ -54,55 +42,44 @@ api_server = 'https://api06.iq.questrade.com/'
expires_in = 1800 expires_in = 1800
token_type = 'Bearer' token_type = 'Bearer'
expires_at = 1616095326.355846 expires_at = 1616095326.355846
# ------ questrade ------
[ib] [ib]
# define the (set of) host-port socketaddrs that
# brokerd.ib will scan to connect to an API endpoint
# (ib-gw or ib-tws listening instances)
hosts = [ hosts = [
'127.0.0.1', '127.0.0.1',
] ]
# XXX: the order in which ports will be scanned
# (by the `brokerd` daemon-actor)
# is determined # by the line order here.
# TODO: when we eventually spawn gateways in our
# container, we can just dynamically allocate these
# using IBC.
ports = [ ports = [
4002, # gw 4002, # gw
7497, # tws 7497, # tws
] ]
# When API endpoints are being scanned durin startup, the order # XXX: for a paper account the flex web query service
# of user-defined-account "names" (as defined below) here # is not supported so you have to manually download
# determines which py-client connection is given priority to be # and XML report and put it in a location that can be
# used for data-feed-requests by according to whichever client # accessed by the ``brokerd.ib`` backend code for parsing.
# connected to an API endpoing which reported the equivalent flex_token = ''
# account number for that name. flex_trades_query_id = '' # live account
# when clients are being scanned this determines
# which clients are preferred to be used for data
# feeds based on the order of account names, if
# detected as active on an API client.
prefer_data_account = [ prefer_data_account = [
'paper', 'paper',
'margin', 'margin',
'ira', 'ira',
] ]
# For long-term trades txn (transaction) history
# processing (i.e your txn ledger with IB) you can
# (automatically for live accounts) query the FLEX
# report system for past history.
#
# (For paper accounts the web query service
# is not supported so you have to manually download
# an XML report and put it in a location that can be
# accessed by our `brokerd.ib` backend code for parsing).
#
flex_token = ''
flex_trades_query_id = '' # live account
# define "aliases" (names) for each account number
# such that the names can be reffed and logged throughout
# `piker.accounting` subsys and more easily
# referred to by the user.
#
# These keys will be the set exposed through the order-mode
# account-selection UI so that numbers are never shown.
[ib.accounts] [ib.accounts]
paper = 'DU0000000' # <- literal account # # the order in which accounts will be selectable
margin = 'U0000000' # in the order mode UI (if found via clients during
ira = 'U0000000' # API-app scanning)when a new symbol is loaded.
# ------ ib ------ paper = 'XX0000000'
margin = 'X0000000'
ira = 'X0000000'

View File

@ -1,9 +1,7 @@
[network] [network]
pikerd = [ tsdb.backend = 'marketstore'
'/ipv4/127.0.0.1/tcp/6116', # std localhost daemon-actor tree tsdb.host = 'localhost'
# '/uds/6116', # TODO std uds socket file tsdb.grpc_port = 5995
]
[ui] [ui]
# set custom font + size which will scale entire UI # set custom font + size which will scale entire UI

View File

@ -11,12 +11,11 @@ let
libxkbcommonStorePath = lib.getLib libxkbcommon; libxkbcommonStorePath = lib.getLib libxkbcommon;
xcbutilcursorStorePath = lib.getLib xcb-util-cursor; xcbutilcursorStorePath = lib.getLib xcb-util-cursor;
pypkgs = python313Packages; qtpyStorePath = lib.getLib python312Packages.qtpy;
qtpyStorePath = lib.getLib pypkgs.qtpy; pyqt6StorePath = lib.getLib python312Packages.pyqt6;
pyqt6StorePath = lib.getLib pypkgs.pyqt6; pyqt6SipStorePath = lib.getLib python312Packages.pyqt6-sip;
pyqt6SipStorePath = lib.getLib pypkgs.pyqt6-sip; rapidfuzzStorePath = lib.getLib python312Packages.rapidfuzz;
rapidfuzzStorePath = lib.getLib pypkgs.rapidfuzz; qdarkstyleStorePath = lib.getLib python312Packages.qdarkstyle;
qdarkstyleStorePath = lib.getLib pypkgs.qdarkstyle;
xorgLibX11StorePath = lib.getLib xorg.libX11; xorgLibX11StorePath = lib.getLib xorg.libX11;
xorgLibxcbStorePath = lib.getLib xorg.libxcb; xorgLibxcbStorePath = lib.getLib xorg.libxcb;
@ -52,12 +51,12 @@ stdenv.mkDerivation {
xorg.xcbutilrenderutil xorg.xcbutilrenderutil
# Python requirements. # Python requirements.
python313 python312Full
uv python312Packages.uv
pypkgs.qdarkstyle python312Packages.qdarkstyle
pypkgs.rapidfuzz python312Packages.rapidfuzz
pypkgs.pyqt6 python312Packages.pyqt6
pypkgs.qtpy python312Packages.qtpy
]; ];
src = null; src = null;
shellHook = '' shellHook = ''
@ -114,11 +113,11 @@ stdenv.mkDerivation {
export LD_LIBRARY_PATH export LD_LIBRARY_PATH
RPDFUZZ_PATH="${rapidfuzzStorePath}/lib/python3.13/site-packages" RPDFUZZ_PATH="${rapidfuzzStorePath}/lib/python3.12/site-packages"
QDRKSTYLE_PATH="${qdarkstyleStorePath}/lib/python3.13/site-packages" QDRKSTYLE_PATH="${qdarkstyleStorePath}/lib/python3.12/site-packages"
QTPY_PATH="${qtpyStorePath}/lib/python3.13/site-packages" QTPY_PATH="${qtpyStorePath}/lib/python3.12/site-packages"
PYQT6_PATH="${pyqt6StorePath}/lib/python3.13/site-packages" PYQT6_PATH="${pyqt6StorePath}/lib/python3.12/site-packages"
PYQT6_SIP_PATH="${pyqt6SipStorePath}/lib/python3.13/site-packages" PYQT6_SIP_PATH="${pyqt6SipStorePath}/lib/python3.12/site-packages"
PATCH="$PATCH:$RPDFUZZ_PATH" PATCH="$PATCH:$RPDFUZZ_PATH"
PATCH="$PATCH:$QDRKSTYLE_PATH" PATCH="$PATCH:$QDRKSTYLE_PATH"
@ -128,8 +127,8 @@ stdenv.mkDerivation {
export PATCH export PATCH
# install all dev and extras # Install deps
uv sync --dev --all-extras uv lock
''; '';
} }

View File

@ -1,138 +1,30 @@
running ``ib`` gateway in ``docker`` running ``ib`` gateway in ``docker``
------------------------------------ ------------------------------------
We have a config based on a well maintained community We have a config based on the (now defunct)
image from `@gnzsnz`: image from "waytrade":
https://github.com/gnzsnz/ib-gateway-docker https://github.com/waytrade/ib-gateway-docker
To startup this image with our custom settings
To startup this image simply run the command:: simply run the command::
docker compose up docker compose up
(For further usage^ see the official `docker-compose`_ docs) And you should have the following socket-available services:
- ``x11vnc1@127.0.0.1:3003``
And you should have the following socket-available services by
default:
- ``x11vnc1 @ 127.0.0.1:5900``
- ``ib-gw@127.0.0.1:4002`` - ``ib-gw@127.0.0.1:4002``
You can now attach to the container via a VNC client with password-auth; You can attach to the container via a VNC client
here is an example using ``vncclient`` on ``linux``:: without password auth.
vncviewer localhost:5900 SECURITY STUFF!?!?!
-------------------
now enter the pw (password) you set via an (see second code blob) Though "``ib``" claims they host filter connections outside
`.env file`_ or pw-file according to the `credentials section`_. localhost (aka ``127.0.0.1``) it's probably better if you filter
the socket at the OS level using a stateless firewall rule::
If you want to change away from their default config see the example
`docker-compose.yml`-config issue and config-section of the readme,
- https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#configuration
- https://github.com/gnzsnz/ib-gateway-docker/discussions/103
.. _.env file: https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#how-to-use-it
.. _docker-compose: https://docs.docker.com/compose/
.. _credentials section: https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#credentials
Connecting to the API from `piker`
---------------------------------
In order to expose the container's API endpoint to the
`brokerd/datad/ib` actor, we need to add a section to the user's
`brokers.toml` config (note the below is similar to the repo-shipped
template file),
.. code:: toml
[ib]
# define the (set of) host-port socketaddrs that
# brokerd.ib will scan to connect to an API endpoint
# (ib-gw or ib-tws listening instances)
hosts = [
'127.0.0.1',
]
ports = [
4002, # gw
7497, # tws
]
# When API endpoints are being scanned durin startup, the order
# of user-defined-account "names" (as defined below) here
# determines which py-client connection is given priority to be
# used for data-feed-requests by according to whichever client
# connected to an API endpoing which reported the equivalent
# account number for that name.
prefer_data_account = [
'paper',
'margin',
'ira',
]
# define "aliases" (names) for each account number
# such that the names can be reffed and logged throughout
# `piker.accounting` subsys and more easily
# referred to by the user.
#
# These keys will be the set exposed through the order-mode
# account-selection UI so that numbers are never shown.
[ib.accounts]
paper = 'XX0000000'
margin = 'X0000000'
ira = 'X0000000'
the broker daemon can also connect to the container's VNC server for
added functionalies including,
- viewing the API endpoint program's GUI for manual interventions,
- workarounds for historical data throttling using hotkey hacks,
Add a further section to `brokers.toml` which maps each API-ep's
port to a table of VNC server connection info like,
.. code:: toml
[ib.vnc_addrs]
4002 = {host = 'localhost', port = 5900, pw = 'doggy'}
The `pw = 'doggy'` here ^ should the same value as the particular
container instances `.env` file setting (when it was run),
.. code:: ini
VNC_SERVER_PASSWORD='doggy'
IF you also want to run ``TWS``
-------------------------------
You can also run it containerized,
https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#using-tws
SECURITY stuff (advanced, only if you're paranoid)
--------------------------------------------------
First and foremost if doing a "distributed" container setup where you
run the ``ib-gw`` docker container and your connecting API client
(likely ``ib_async`` from python) on **different hosts** be sure to
read the `security considerations`_ section!
And for a further (somewhat paranoid) perspective from
a long-time-ago serious devops eng..
Though "``ib``" claims they filter remote host connections outside
``localhost`` (aka ``127.0.0.1`` on ipv4) it's prolly justified if
you'd like to filter the socket at the *OS level* using a stateless
firewall rule::
ip rule add not unicast iif lo to 0.0.0.0/0 dport 4002 ip rule add not unicast iif lo to 0.0.0.0/0 dport 4002
We will soon have this baked into our own custom image but for
We will soon have this either baked into our own custom derivative now you'll have to do it urself dawgy.
image (or patched into the current upstream one after further testin)
but for now you'll have to do it urself, diggity dawg.
.. _security considerations: https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#security-considerations

View File

@ -1,15 +1,10 @@
# a community maintained IB API container! # rework from the original @
# # https://github.com/waytrade/ib-gateway-docker/blob/master/docker-compose.yml
# https://github.com/gnzsnz/ib-gateway-docker version: "3.5"
#
# For piker we (currently) include some minor deviations
# for some config files in the `volumes` section.
#
# See full configuration settings @
# - https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#configuration
# - https://github.com/gnzsnz/ib-gateway-docker/discussions/103
services: services:
ib_gw_paper: ib_gw_paper:
# apparently java is a mega cukc: # apparently java is a mega cukc:
@ -55,22 +50,16 @@ services:
target: /root/scripts/run_x11_vnc.sh target: /root/scripts/run_x11_vnc.sh
read_only: true read_only: true
# NOTE: an alt method to fill these out is to # NOTE:to fill these out, define an `.env` file in the same dir as
# define an `.env` file in the same dir as # this compose file which looks something like:
# this compose file. # TWS_USERID='myuser'
# TWS_PASSWORD='guest'
environment: environment:
TWS_USERID: ${TWS_USERID} TWS_USERID: ${TWS_USERID}
# TWS_USERID: 'myuser'
TWS_PASSWORD: ${TWS_PASSWORD} TWS_PASSWORD: ${TWS_PASSWORD}
# TWS_PASSWORD: 'guest' TRADING_MODE: 'paper'
TRADING_MODE: ${TRADING_MODE} VNC_SERVER_PASSWORD: 'doggy'
# TRADING_MODE: 'paper' VNC_SERVER_PORT: '3003'
VNC_SERVER_PASSWORD: ${VNC_SERVER_PASSWORD}
# VNC_SERVER_PASSWORD: 'doggy'
# TODO, see if we can get this supported like it
# was on the old `waytrade` image?
# VNC_SERVER_PORT: '3003'
# ports: # ports:
# - target: 4002 # - target: 4002
@ -87,9 +76,6 @@ services:
# - "127.0.0.1:4002:4002" # - "127.0.0.1:4002:4002"
# - "127.0.0.1:5900:5900" # - "127.0.0.1:5900:5900"
# TODO, a masked but working example of dual paper + live
# ib-gw instances running in a single app run!
#
# ib_gw_live: # ib_gw_live:
# image: waytrade/ib-gateway:1012.2i # image: waytrade/ib-gateway:1012.2i
# restart: no # restart: no

View File

@ -121,7 +121,6 @@ async def bot_main():
# tick_throttle=10, # tick_throttle=10,
) as feed, ) as feed,
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn, trio.open_nursery() as tn,
): ):
assert accounts assert accounts

View File

@ -1,24 +1,135 @@
{ {
"nodes": { "nodes": {
"nixpkgs": { "flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": { "locked": {
"lastModified": 1765779637, "lastModified": 1689068808,
"narHash": "sha256-KJ2wa/BLSrTqDjbfyNx70ov/HdgNBCBBSQP3BIzKnv4=", "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "nixos", "owner": "numtide",
"repo": "nixpkgs", "repo": "flake-utils",
"rev": "1306659b587dc277866c7b69eb97e5f07864d8c4", "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1688870561,
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1692174805,
"narHash": "sha256-xmNPFDi/AUMIxwgOH/IVom55Dks34u1g7sFKKebxUm0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "caac0eb6bdcad0b32cb2522e03e4002c8975c62e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils_2",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1692048894,
"narHash": "sha256-cDw03rso2V4CDc3Mll0cHN+ztzysAvdI8pJ7ybbz714=",
"ref": "refs/heads/pyqt6",
"rev": "b059ad4c3051f45d6c912e17747aae37a9ec1544",
"revCount": 2276,
"type": "git",
"url": "file:///home/lord_fomo/repos/poetry2nix"
},
"original": {
"type": "git",
"url": "file:///home/lord_fomo/repos/poetry2nix"
}
},
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"poetry2nix": "poetry2nix"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
} }
} }
}, },

243
flake.nix
View File

@ -1,103 +1,180 @@
# An "impure" template thx to `pyproject.nix`, # NOTE: to convert to a poetry2nix env like this here are the
# https://pyproject-nix.github.io/pyproject.nix/templates.html#impure # steps:
# https://github.com/pyproject-nix/pyproject.nix/blob/master/templates/impure/flake.nix # - install poetry in your system nix config
{ # - convert the repo to use poetry using `poetry init`:
description = "An impure `piker` overlay using `uv` with Nix(OS)"; # https://python-poetry.org/docs/basic-usage/#initialising-a-pre-existing-project
# - then manually ensuring all deps are converted over:
# - add this file to the repo and commit it
# -
inputs = { # GROKin tips:
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; # - CLI eps are (ostensibly) added via an `entry_points.txt`:
# - https://packaging.python.org/en/latest/specifications/entry-points/#file-format
# - https://github.com/nix-community/poetry2nix/blob/master/editable.nix#L49
{
description = "piker: trading gear for hackers (pkged with poetry2nix)";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# see https://github.com/nix-community/poetry2nix/tree/master#api
inputs.poetry2nix = {
# url = "github:nix-community/poetry2nix";
# url = "github:K900/poetry2nix/qt5-explicit-deps";
url = "/home/lord_fomo/repos/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = outputs = {
{ nixpkgs, ... }: self,
nixpkgs,
flake-utils,
poetry2nix,
}:
# TODO: build cross-OS and use the `${system}` var thingy..
flake-utils.lib.eachDefaultSystem (system:
let let
inherit (nixpkgs) lib; # use PWD as sources
forAllSystems = lib.genAttrs lib.systems.flakeExposed; projectDir = ./.;
in pyproject = ./pyproject.toml;
{ poetrylock = ./poetry.lock;
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
# do store-path extractions # TODO: port to 3.11 and support both versions?
qt6baseStorePath = lib.getLib pkgs.qt6.qtbase; python = "python3.10";
# ?TODO? can remove below since manual linking not needed?
# qt6QtWaylandStorePath = lib.getLib pkgs.qt6.qtwayland;
# XXX NOTE XXX, for now we overlay specific pkgs via # for more functions and examples.
# a major-version-pinned-`cpython` # inherit
cpython = "python313"; # (poetry2nix.legacyPackages.${system})
pypkgs = pkgs."${cpython}Packages"; # mkPoetryApplication;
in # pkgs = nixpkgs.legacyPackages.${system};
{
default = pkgs.mkShell {
packages = with pkgs; [ pkgs = nixpkgs.legacyPackages.x86_64-linux;
# XXX, ensure sh completions active! lib = pkgs.lib;
bashInteractive p2npkgs = poetry2nix.legacyPackages.x86_64-linux;
bash-completion
# dev utils # define all pkg overrides per dep, see edgecases.md:
ruff # https://github.com/nix-community/poetry2nix/blob/master/docs/edgecases.md
pypkgs.ruff # TODO: add these into the json file:
# https://github.com/nix-community/poetry2nix/blob/master/overrides/build-systems.json
pypkgs-build-requirements = {
asyncvnc = [ "setuptools" ];
eventkit = [ "setuptools" ];
ib-insync = [ "setuptools" "flake8" ];
msgspec = [ "setuptools"];
pdbp = [ "setuptools" ];
pyqt6-sip = [ "setuptools" ];
tabcompleter = [ "setuptools" ];
tractor = [ "setuptools" ];
tricycle = [ "setuptools" ];
trio-typing = [ "setuptools" ];
trio-util = [ "setuptools" ];
xonsh = [ "setuptools" ];
};
qt6.qtwayland # auto-generate override entries
qt6.qtbase p2n-overrides = p2npkgs.defaultPoetryOverrides.extend (self: super:
builtins.mapAttrs (package: build-requirements:
(builtins.getAttr package super).overridePythonAttrs (old: {
buildInputs = (
old.buildInputs or [ ]
) ++ (
builtins.map (
pkg: if builtins.isString pkg then builtins.getAttr pkg super else pkg
) build-requirements
);
})
) pypkgs-build-requirements
);
uv # override some ahead-of-time compiled extensions
python313 # ?TODO^ how to set from `cpython` above? # to be built with their wheels.
pypkgs.pyqt6 ahot_overrides = p2n-overrides.extend(
pypkgs.pyqt6-sip final: prev: {
pypkgs.qtpy
pypkgs.qdarkstyle
pypkgs.rapidfuzz
];
shellHook = '' # llvmlite = prev.llvmlite.override {
# unmask to debug **this** dev-shell-hook # preferWheel = false;
# set -e # };
# set qt-base/plugin path(s) # TODO: get this workin with p2n and nixpkgs..
QTBASE_PATH="${qt6baseStorePath}/lib" # pyqt6 = prev.pyqt6.override {
QT_PLUGIN_PATH="${qt6baseStorePath}/lib/qt-6/plugins" # preferWheel = true;
QT_QPA_PLATFORM_PLUGIN_PATH="$QT_PLUGIN_PATH/platforms" # };
# link in Qt cc lib paths from <nixpkgs> # NOTE: this DOESN'T work atm but after a fix
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$QTBASE_PATH" # to poetry2nix, it will and actually this line
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$QT_PLUGIN_PATH" # won't be needed - thanks @k900:
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$QT_QPA_PLATFORM_PLUGIN_PATH" # https://github.com/nix-community/poetry2nix/pull/1257
pyqt5 = prev.pyqt5.override {
# withWebkit = false;
preferWheel = true;
};
# link-in c++ stdlib for various AOT-ext-pkgs (numpy, etc.) # see PR from @k900:
LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH" # https://github.com/nix-community/poetry2nix/pull/1257
# pyqt5-qt5 = prev.pyqt5-qt5.override {
# withWebkit = false;
# preferWheel = true;
# };
export LD_LIBRARY_PATH # TODO: patch in an override for polars to build
# from src! See the details likely needed from
# RUNTIME-SETTINGS # the cryptography entry:
# # https://github.com/nix-community/poetry2nix/blob/master/overrides/default.nix#L426-L435
# ------ Qt ------ polars = prev.polars.override {
# XXX, unmask to debug qt .so linking/loading deats preferWheel = true;
# export QT_DEBUG_PLUGINS=1
#
# ALSO, for *modern linux* DEs,
# - maybe set wayland-mode (TODO, parametrtize this!)
# * a chosen wayland-mode shell-integration
export QT_QPA_PLATFORM="wayland"
export QT_WAYLAND_SHELL_INTEGRATION="xdg-shell"
# ------ uv ------
# - always use the ./py313/ venv-subdir
export UV_PROJECT_ENVIRONMENT="py313"
# sync project-env with all extras
uv sync --dev --all-extras --no-group lint
# ------ TIPS ------
# NOTE, to launch the py-venv installed `xonsh` (like @goodboy)
# run the `nix develop` cmd with,
# >> nix develop -c uv run xonsh
'';
}; };
} }
); );
# WHY!? -> output-attrs that `nix develop` scans for:
# https://nixos.org/manual/nix/stable/command-ref/new-cli/nix3-develop.html#flake-output-attributes
in
rec {
packages = {
# piker = poetry2nix.legacyPackages.x86_64-linux.mkPoetryEditablePackage {
# editablePackageSources = { piker = ./piker; };
piker = p2npkgs.mkPoetryApplication {
projectDir = projectDir;
# SEE ABOVE for auto-genned input set, override
# buncha deps with extras.. like `setuptools` mostly.
# TODO: maybe propose a patch to p2n to show that you
# can even do this in the edgecases docs?
overrides = ahot_overrides;
# XXX: won't work on llvmlite..
# preferWheels = true;
};
};
# devShells.default = pkgs.mkShell {
# projectDir = projectDir;
# python = "python3.10";
# overrides = ahot_overrides;
# inputsFrom = [ self.packages.x86_64-linux.piker ];
# packages = packages;
# # packages = [ poetry2nix.packages.${system}.poetry ];
# };
# TODO: grok the difference here..
# - avoid re-cloning git repos on every develop entry..
# - ideally allow hacking on the src code of some deps
# (tractor, pyqtgraph, tomlkit, etc.) WITHOUT having to
# re-install them every time a change is made.
# - boot a usable xonsh inside the poetry virtualenv when
# defined via a custom entry point?
devShells.default = p2npkgs.mkPoetryEnv {
# env = p2npkgs.mkPoetryEnv {
projectDir = projectDir;
python = pkgs.python310;
overrides = ahot_overrides;
editablePackageSources = packages;
# piker = "./";
# tractor = "../tractor/";
# }; # wut?
}; };
} }
); # end of .outputs scope
}

View File

@ -19,10 +19,8 @@
for tendiez. for tendiez.
''' '''
from piker.log import ( from ..log import get_logger
get_console_log,
get_logger,
)
from .calc import ( from .calc import (
iter_by_dt, iter_by_dt,
) )
@ -35,6 +33,7 @@ from ._pos import (
Account, Account,
load_account, load_account,
load_account_from_ledger, load_account_from_ledger,
open_pps,
open_account, open_account,
Position, Position,
) )
@ -43,6 +42,7 @@ from ._mktinfo import (
dec_digits, dec_digits,
digits_to_dec, digits_to_dec,
MktPair, MktPair,
Symbol,
unpack_fqme, unpack_fqme,
_derivs as DerivTypes, _derivs as DerivTypes,
) )
@ -53,23 +53,14 @@ from ._allocate import (
log = get_logger(__name__) log = get_logger(__name__)
# ?TODO, enable console on import
# [ ] necessary? or `open_brokerd_dialog()` doing it is sufficient?
#
# bc might as well enable whenev imported by
# other sub-sys code (namely `.clearing`).
get_console_log(
level='warning',
name=__name__,
)
# TODO, the `as <samename>` style?
__all__ = [ __all__ = [
'Account', 'Account',
'Allocator', 'Allocator',
'Asset', 'Asset',
'MktPair', 'MktPair',
'Position', 'Position',
'Symbol',
'Transaction', 'Transaction',
'TransactionLedger', 'TransactionLedger',
'dec_digits', 'dec_digits',
@ -79,6 +70,7 @@ __all__ = [
'load_account_from_ledger', 'load_account_from_ledger',
'mk_allocator', 'mk_allocator',
'open_account', 'open_account',
'open_pps',
'open_trade_ledger', 'open_trade_ledger',
'unpack_fqme', 'unpack_fqme',
'DerivTypes', 'DerivTypes',

View File

@ -40,7 +40,7 @@ import tomli_w # for fast ledger writing
from piker.types import Struct from piker.types import Struct
from piker import config from piker import config
from piker.log import get_logger from ..log import get_logger
from .calc import ( from .calc import (
iter_by_dt, iter_by_dt,
) )
@ -239,9 +239,7 @@ class TransactionLedger(UserDict):
symcache: SymbologyCache = self._symcache symcache: SymbologyCache = self._symcache
towrite: dict[str, Any] = {} towrite: dict[str, Any] = {}
for tid, txdict in self.tx_sort( for tid, txdict in self.tx_sort(self.data.copy()):
self.data.copy()
):
# write blank-str expiry for non-expiring assets # write blank-str expiry for non-expiring assets
if ( if (
'expiry' in txdict 'expiry' in txdict
@ -379,7 +377,7 @@ def open_trade_ledger(
account, account,
dirpath=_fp, dirpath=_fp,
) )
cpy: dict = ledger_dict.copy() cpy = ledger_dict.copy()
# XXX NOTE: if not provided presume we are being called from # XXX NOTE: if not provided presume we are being called from
# sync code and need to maybe run `trio` to generate.. # sync code and need to maybe run `trio` to generate..
@ -408,13 +406,7 @@ def open_trade_ledger(
account=account, account=account,
mod=mod, mod=mod,
symcache=symcache, symcache=symcache,
tx_sort=getattr(mod, 'tx_sort', tx_sort),
# NOTE: allow backends to provide custom ledger sorting
tx_sort=getattr(
mod,
'tx_sort',
tx_sort,
),
) )
try: try:
yield ledger yield ledger

View File

@ -305,8 +305,8 @@ class MktPair(Struct, frozen=True):
# config right? # config right?
# src_type: AssetTypeName # src_type: AssetTypeName
# for derivs, info describing contract, egs. strike price, call # for derivs, info describing contract, egs.
# or put, swap type, exercise model, etc. # strike price, call or put, swap type, exercise model, etc.
contract_info: list[str] | None = None contract_info: list[str] | None = None
# TODO: rename to sectype since all of these can # TODO: rename to sectype since all of these can
@ -677,3 +677,90 @@ def unpack_fqme(
# '.'.join([mkt_ep, venue]), # '.'.join([mkt_ep, venue]),
suffix, suffix,
) )
class Symbol(Struct):
'''
I guess this is some kinda container thing for dealing with
all the different meta-data formats from brokers?
'''
key: str
broker: str = ''
venue: str = ''
# precision descriptors for price and vlm
tick_size: Decimal = Decimal('0.01')
lot_tick_size: Decimal = Decimal('0.0')
suffix: str = ''
broker_info: dict[str, dict[str, Any]] = {}
@classmethod
def from_fqme(
cls,
fqsn: str,
info: dict[str, Any],
) -> Symbol:
broker, mktep, venue, suffix = unpack_fqme(fqsn)
tick_size = info.get('price_tick_size', 0.01)
lot_size = info.get('lot_tick_size', 0.0)
return Symbol(
broker=broker,
key=mktep,
tick_size=tick_size,
lot_tick_size=lot_size,
venue=venue,
suffix=suffix,
broker_info={broker: info},
)
@property
def type_key(self) -> str:
return list(self.broker_info.values())[0]['asset_type']
@property
def tick_size_digits(self) -> int:
return float_digits(self.tick_size)
@property
def lot_size_digits(self) -> int:
return float_digits(self.lot_tick_size)
@property
def price_tick(self) -> Decimal:
return Decimal(str(self.tick_size))
@property
def size_tick(self) -> Decimal:
return Decimal(str(self.lot_tick_size))
@property
def broker(self) -> str:
return list(self.broker_info.keys())[0]
@property
def fqme(self) -> str:
return maybe_cons_tokens([
self.key, # final "pair name" (eg. qqq[/usd], btcusdt)
self.venue,
self.suffix, # includes expiry and other con info
self.broker,
])
def quantize(
self,
size: float,
) -> Decimal:
digits = float_digits(self.lot_tick_size)
return Decimal(size).quantize(
Decimal(f'1.{"0".ljust(digits, "0")}'),
rounding=ROUND_HALF_EVEN
)
# NOTE: when cast to `str` return fqme
def __str__(self) -> str:
return self.fqme

View File

@ -60,16 +60,12 @@ from ..clearing._messages import (
BrokerdPosition, BrokerdPosition,
) )
from piker.types import Struct from piker.types import Struct
from piker.log import ( from piker.log import get_logger
get_logger,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from piker.data._symcache import SymbologyCache from piker.data._symcache import SymbologyCache
log = get_logger( log = get_logger(__name__)
name=__name__,
)
class Position(Struct): class Position(Struct):
@ -360,20 +356,17 @@ class Position(Struct):
) -> bool: ) -> bool:
''' '''
Update clearing table by calculating the rolling ppu and Update clearing table by calculating the rolling ppu and
(accumulative) size in both the clears entry and local attrs (accumulative) size in both the clears entry and local
state. attrs state.
Inserts are always done in datetime sorted order. Inserts are always done in datetime sorted order.
''' '''
# added: bool = False
tid: str = t.tid tid: str = t.tid
if tid in self._events: if tid in self._events:
log.debug( log.warning(f'{t} is already added?!')
f'Txn is already added?\n' # return added
f'\n'
f'{t}\n'
)
return False
# TODO: apparently this IS possible with a dict but not # TODO: apparently this IS possible with a dict but not
# common and probably not that beneficial unless we're also # common and probably not that beneficial unless we're also
@ -454,12 +447,6 @@ class Position(Struct):
# def suggest_split(self) -> float: # def suggest_split(self) -> float:
# ... # ...
# ?TODO, for sending rendered state over the wire?
# def summary(self) -> PositionSummary:
# do minimal conversion to a subset of fields
# currently defined in `.clearing._messages.BrokerdPosition`
class Account(Struct): class Account(Struct):
''' '''
@ -744,7 +731,7 @@ class Account(Struct):
else: else:
# TODO: we reallly need a diff set of # TODO: we reallly need a diff set of
# loglevels/colors per subsys. # loglevels/colors per subsys.
log.debug( log.warning(
f'Recent position for {fqme} was closed!' f'Recent position for {fqme} was closed!'
) )
@ -758,7 +745,7 @@ class Account(Struct):
# XXX WTF: if we use a tomlkit.Integer here we get this # XXX WTF: if we use a tomlkit.Integer here we get this
# super weird --1 thing going on for cumsize!?1! # super weird --1 thing going on for cumsize!?1!
# NOTE: the fix was to always float() the size value loaded # NOTE: the fix was to always float() the size value loaded
# in open_account() below! # in open_pps() below!
config.write( config.write(
config=self.conf, config=self.conf,
path=self.conf_path, path=self.conf_path,
@ -942,6 +929,7 @@ def open_account(
clears_table['dt'] = dt clears_table['dt'] = dt
trans.append(Transaction( trans.append(Transaction(
fqme=bs_mktid, fqme=bs_mktid,
# sym=mkt,
bs_mktid=bs_mktid, bs_mktid=bs_mktid,
tid=tid, tid=tid,
# XXX: not sure why sometimes these are loaded as # XXX: not sure why sometimes these are loaded as
@ -964,18 +952,7 @@ def open_account(
): ):
expiry: pendulum.DateTime = pendulum.parse(expiry) expiry: pendulum.DateTime = pendulum.parse(expiry)
# !XXX, should never be duplicates over pp = pp_objs[bs_mktid] = Position(
# a backend-(broker)-system's unique market-IDs!
if pos := pp_objs.get(bs_mktid):
if mkt != pos.mkt:
log.warning(
f'Duplicated position but diff `MktPair.fqme` ??\n'
f'bs_mktid: {bs_mktid!r}\n'
f'pos.mkt: {pos.mkt}\n'
f'mkt: {mkt}\n'
)
else:
pos = pp_objs[bs_mktid] = Position(
mkt, mkt,
split_ratio=split_ratio, split_ratio=split_ratio,
bs_mktid=bs_mktid, bs_mktid=bs_mktid,
@ -987,13 +964,8 @@ def open_account(
# state, since today's records may have already been # state, since today's records may have already been
# processed! # processed!
for t in trans: for t in trans:
added: bool = pos.add_clear(t) pp.add_clear(t)
if not added:
log.warning(
f'Txn already recorded in pp ??\n'
f'\n'
f'{t}\n'
)
try: try:
yield acnt yield acnt
finally: finally:
@ -1001,6 +973,20 @@ def open_account(
acnt.write_config() acnt.write_config()
# TODO: drop the old name and THIS!
@cm
def open_pps(
*args,
**kwargs,
) -> Generator[Account, None, None]:
log.warning(
'`open_pps()` is now deprecated!\n'
'Please use `with open_account() as cnt:`'
)
with open_account(*args, **kwargs) as acnt:
yield acnt
def load_account_from_ledger( def load_account_from_ledger(
brokername: str, brokername: str,

View File

@ -22,9 +22,7 @@ you know when you're losing money (if possible) XD
from __future__ import annotations from __future__ import annotations
from collections.abc import ValuesView from collections.abc import ValuesView
from contextlib import contextmanager as cm from contextlib import contextmanager as cm
from functools import partial
from math import copysign from math import copysign
from pprint import pformat
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@ -32,7 +30,6 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
from tractor.devx import maybe_open_crash_handler
import polars as pl import polars as pl
from pendulum import ( from pendulum import (
DateTime, DateTime,
@ -40,16 +37,12 @@ from pendulum import (
parse, parse,
) )
from ..log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from ._ledger import ( from ._ledger import (
Transaction, Transaction,
TransactionLedger, TransactionLedger,
) )
log = get_logger(__name__)
def ppu( def ppu(
clears: Iterator[Transaction], clears: Iterator[Transaction],
@ -245,9 +238,6 @@ def iter_by_dt(
def dyn_parse_to_dt( def dyn_parse_to_dt(
tx: tuple[str, dict[str, Any]] | Transaction, tx: tuple[str, dict[str, Any]] | Transaction,
debug: bool = False,
_invalid: list|None = None,
) -> DateTime: ) -> DateTime:
# handle `.items()` inputs # handle `.items()` inputs
@ -260,90 +250,33 @@ def iter_by_dt(
# get best parser for this record.. # get best parser for this record..
for k in parsers: for k in parsers:
if ( if (
(v := getattr(tx, k, None)) isdict and k in tx
or or getattr(tx, k, None)
(
isdict
and
(v := tx.get(k))
)
): ):
v = tx[k] if isdict else tx.dt
assert v is not None, f'No valid value for `{k}`!?'
# only call parser on the value if not None from # only call parser on the value if not None from
# the `parsers` table above (when NOT using # the `parsers` table above (when NOT using
# `.get()`), otherwise pass through the value and # `.get()`), otherwise pass through the value and
# sort on it directly # sort on it directly
if ( if (
not isinstance(v, DateTime) not isinstance(v, DateTime)
and and (parser := parsers.get(k))
(parser := parsers.get(k))
): ):
ret = parser(v) return parser(v)
else: else:
ret = v return v
return ret
else: else:
log.debug( # XXX: should never get here..
f'Parser-field not found in txn\n' breakpoint()
f'\n'
f'parser-field: {k!r}\n'
f'txn: {tx!r}\n'
f'\n'
f'Trying next..\n'
)
continue
# XXX: we should never really get here bc it means some kinda
# bad txn-record (field) data..
#
# -> set the `debug_mode = True` if you want to trace such
# cases from REPL ;)
else:
# XXX: we should really never get here..
# only if a ledger record has no expected sort(able)
# field will we likely hit this.. like with ze IB.
# if no sortable field just deliver epoch?
log.warning(
'No (time) sortable field for TXN:\n'
f'{tx!r}\n'
)
report: str = (
f'No supported time-field found in txn !?\n'
f'\n'
f'supported-time-fields: {parsers!r}\n'
f'\n'
f'txn: {tx!r}\n'
)
if debug:
with maybe_open_crash_handler(
pdb=debug,
raise_on_exit=False,
):
raise ValueError(report)
else:
log.error(report)
if _invalid is not None:
_invalid.append(tx)
return from_timestamp(0.)
entry: tuple[str, dict] | Transaction entry: tuple[str, dict] | Transaction
invalid: list = []
for entry in sorted( for entry in sorted(
records, records,
key=key or partial( key=key or dyn_parse_to_dt,
dyn_parse_to_dt,
_invalid=invalid,
),
): ):
if entry in invalid:
log.warning(
f'Ignoring txn w invalid timestamp ??\n'
f'{pformat(entry)}\n'
)
continue
# NOTE the type sig above; either pairs or txns B) # NOTE the type sig above; either pairs or txns B)
yield entry yield entry
@ -406,7 +339,6 @@ def open_ledger_dfs(
acctname: str, acctname: str,
ledger: TransactionLedger | None = None, ledger: TransactionLedger | None = None,
debug_mode: bool = False,
**kwargs, **kwargs,
@ -421,10 +353,8 @@ def open_ledger_dfs(
can update the ledger on exit. can update the ledger on exit.
''' '''
with maybe_open_crash_handler( from piker.toolz import open_crash_handler
pdb=debug_mode, with open_crash_handler():
# raise_on_exit=False,
):
if not ledger: if not ledger:
import time import time
from ._ledger import open_trade_ledger from ._ledger import open_trade_ledger
@ -516,7 +446,7 @@ def ledger_to_dfs(
df = dfs[key] = ldf.with_columns([ df = dfs[key] = ldf.with_columns([
pl.cum_sum('size').alias('cumsize'), pl.cumsum('size').alias('cumsize'),
# amount of source asset "sent" (via buy txns in # amount of source asset "sent" (via buy txns in
# the market) to acquire the dst asset, PER txn. # the market) to acquire the dst asset, PER txn.
@ -531,7 +461,7 @@ def ledger_to_dfs(
]).with_columns([ ]).with_columns([
# rolling balance in src asset units # rolling balance in src asset units
(pl.col('dst_bot').cum_sum() * -1).alias('src_balance'), (pl.col('dst_bot').cumsum() * -1).alias('src_balance'),
# "position operation type" in terms of increasing the # "position operation type" in terms of increasing the
# amount in the dst asset (entering) or decreasing the # amount in the dst asset (entering) or decreasing the
@ -673,7 +603,7 @@ def ledger_to_dfs(
# cost that was included in the least-recently # cost that was included in the least-recently
# entered txn that is still part of the current CSi # entered txn that is still part of the current CSi
# set. # set.
# => we look up the cost-per-unit cum_sum and apply # => we look up the cost-per-unit cumsum and apply
# if over the current txn size (by multiplication) # if over the current txn size (by multiplication)
# and then reverse that previusly applied cost on # and then reverse that previusly applied cost on
# the txn_cost for this record. # the txn_cost for this record.

View File

@ -21,6 +21,7 @@ CLI front end for trades ledger and position tracking management.
from __future__ import annotations from __future__ import annotations
from pprint import pformat from pprint import pformat
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
import polars as pl import polars as pl
@ -28,10 +29,7 @@ import tractor
import trio import trio
import typer import typer
from piker.log import ( from ..log import get_logger
get_console_log,
get_logger,
)
from ..service import ( from ..service import (
open_piker_runtime, open_piker_runtime,
) )
@ -47,7 +45,6 @@ from .calc import (
open_ledger_dfs, open_ledger_dfs,
) )
log = get_logger(name=__name__)
ledger = typer.Typer() ledger = typer.Typer()
@ -82,10 +79,7 @@ def sync(
"-l", "-l",
), ),
): ):
log = get_console_log( log = get_logger(loglevel)
level=loglevel,
name=__name__,
)
console = Console() console = Console()
pair: tuple[str, str] pair: tuple[str, str]
@ -306,8 +300,7 @@ def disect(
assert not df.is_empty() assert not df.is_empty()
# muck around in pdbp REPL # muck around in pdbp REPL
# tractor.devx.mk_pdb().set_trace() breakpoint()
# breakpoint()
# TODO: we REALLY need a better console REPL for this # TODO: we REALLY need a better console REPL for this
# kinda thing.. # kinda thing..

View File

@ -25,16 +25,15 @@ from types import ModuleType
from tractor.trionics import maybe_open_context from tractor.trionics import maybe_open_context
from piker.log import (
get_logger,
)
from ._util import ( from ._util import (
log,
BrokerError, BrokerError,
SymbolNotFound, SymbolNotFound,
NoData, NoData,
DataUnavailable, DataUnavailable,
DataThrottle, DataThrottle,
resproc, resproc,
get_logger,
) )
__all__: list[str] = [ __all__: list[str] = [
@ -44,6 +43,7 @@ __all__: list[str] = [
'DataUnavailable', 'DataUnavailable',
'DataThrottle', 'DataThrottle',
'resproc', 'resproc',
'get_logger',
] ]
__brokers__: list[str] = [ __brokers__: list[str] = [
@ -51,6 +51,7 @@ __brokers__: list[str] = [
'ib', 'ib',
'kraken', 'kraken',
'kucoin', 'kucoin',
'deribit',
# broken but used to work # broken but used to work
# 'questrade', # 'questrade',
@ -61,14 +62,9 @@ __brokers__: list[str] = [
# wstrade # wstrade
# iex # iex
# deribit
# bitso # bitso
] ]
log = get_logger(
name=__name__,
)
def get_brokermod(brokername: str) -> ModuleType: def get_brokermod(brokername: str) -> ModuleType:
''' '''
@ -102,14 +98,13 @@ async def open_cached_client(
If one has not been setup do it and cache it. If one has not been setup do it and cache it.
''' '''
brokermod: ModuleType = get_brokermod(brokername) brokermod = get_brokermod(brokername)
# TODO: make abstract or `typing.Protocol`
# client: Client
async with maybe_open_context( async with maybe_open_context(
acm_func=brokermod.get_client, acm_func=brokermod.get_client,
kwargs=kwargs, kwargs=kwargs,
) as (cache_hit, client): ) as (cache_hit, client):
if cache_hit: if cache_hit:
log.runtime(f'Reusing existing {client}') log.runtime(f'Reusing existing {client}')

View File

@ -23,6 +23,7 @@ from __future__ import annotations
from contextlib import ( from contextlib import (
asynccontextmanager as acm, asynccontextmanager as acm,
) )
from functools import partial
from types import ModuleType from types import ModuleType
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@ -33,18 +34,12 @@ import exceptiongroup as eg
import tractor import tractor
import trio import trio
from piker.log import (
get_logger,
get_console_log,
)
from . import _util from . import _util
from . import get_brokermod from . import get_brokermod
if TYPE_CHECKING: if TYPE_CHECKING:
from ..data import _FeedsBus from ..data import _FeedsBus
log = get_logger(name=__name__)
# `brokerd` enabled modules # `brokerd` enabled modules
# TODO: move this def to the `.data` subpkg.. # 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
@ -78,14 +73,13 @@ async def _setup_persistent_brokerd(
# since all hosted daemon tasks will reference this same # since all hosted daemon tasks will reference this same
# log instance's (actor local) state and thus don't require # log instance's (actor local) state and thus don't require
# any further (level) configuration on their own B) # any further (level) configuration on their own B)
actor: tractor.Actor = tractor.current_actor() log = _util.get_console_log(
tll: str = actor.loglevel loglevel or tractor.current_actor().loglevel,
log = get_console_log(
level=loglevel or tll,
name=f'{_util.subsys}.{brokername}', name=f'{_util.subsys}.{brokername}',
with_tractor_log=bool(tll),
) )
assert log.name == _util.subsys
# set global for this actor to this new process-wide instance B)
_util.log = log
# further, set the log level on any broker broker specific # further, set the log level on any broker broker specific
# logger instance. # logger instance.
@ -103,10 +97,7 @@ async def _setup_persistent_brokerd(
# - `open_symbol_search()` # - `open_symbol_search()`
# NOTE: see ep invocation details inside `.data.feed`. # NOTE: see ep invocation details inside `.data.feed`.
try: try:
async with ( async with trio.open_nursery() as service_nursery:
# tractor.trionics.collapse_eg(),
trio.open_nursery() as service_nursery
):
bus: _FeedsBus = feed.get_feed_bus( bus: _FeedsBus = feed.get_feed_bus(
brokername, brokername,
service_nursery, service_nursery,
@ -206,11 +197,13 @@ async def spawn_brokerd(
**tractor_kwargs, **tractor_kwargs,
) -> bool: ) -> bool:
'''
Spawn a `brokerd.<backendname>` subactor service daemon
using `pikerd`'s service mngr.
log.info( '''
f'Spawning broker-daemon,\n' from piker.service._util import log # use service mngr log
f'backend: {brokername!r}' log.info(f'Spawning {brokername} broker daemon')
)
( (
brokermode, brokermode,
@ -228,27 +221,35 @@ async def spawn_brokerd(
# ask `pikerd` to spawn a new sub-actor and manage it under its # ask `pikerd` to spawn a new sub-actor and manage it under its
# actor nursery # actor nursery
from piker.service import Services from piker.service import (
get_service_mngr,
dname: str = tractor_kwargs.pop('name') # f'brokerd.{brokername}' ServiceMngr,
portal = await Services.actor_n.start_actor(
dname,
enable_modules=_data_mods + tractor_kwargs.pop('enable_modules'),
debug_mode=Services.debug_mode,
**tractor_kwargs
) )
dname: str = tractor_kwargs.pop('name') # f'brokerd.{brokername}'
# NOTE: the service mngr expects an already spawned actor + its mngr: ServiceMngr = get_service_mngr()
# portal ref in order to do non-blocking setup of brokerd ctx: tractor.Context = await mngr.start_service(
# service nursery. daemon_name=dname,
await Services.start_service_task( ctx_ep=partial(
dname,
portal,
# signature of target root-task endpoint # signature of target root-task endpoint
daemon_fixture_ep, daemon_fixture_ep,
# passed to daemon_fixture_ep(**kwargs)
brokername=brokername, brokername=brokername,
loglevel=loglevel, loglevel=loglevel,
),
debug_mode=mngr.debug_mode,
loglevel=loglevel,
enable_modules=(
_data_mods
+
tractor_kwargs.pop('enable_modules')
),
**tractor_kwargs
)
assert (
not ctx.cancel_called
and ctx.portal # parent side
and dname in ctx.chan.uid # subactor is named as desired
) )
return True return True

View File

@ -19,13 +19,15 @@ Handy cross-broker utils.
""" """
from __future__ import annotations from __future__ import annotations
# from functools import partial from functools import partial
import json import json
import httpx import httpx
import logging import logging
from piker.log import ( from ..log import (
get_logger,
get_console_log,
colorize_json, colorize_json,
) )
subsys: str = 'piker.brokers' subsys: str = 'piker.brokers'
@ -33,22 +35,12 @@ subsys: str = 'piker.brokers'
# NOTE: level should be reset by any actor that is spawned # NOTE: level should be reset by any actor that is spawned
# as well as given a (more) explicit name/key such # as well as given a (more) explicit name/key such
# as `piker.brokers.binance` matching the subpkg. # as `piker.brokers.binance` matching the subpkg.
# log = get_logger(subsys) log = get_logger(subsys)
# ?TODO?? we could use this approach, but we need to be able get_console_log = partial(
# to pass multiple `name=` values so for example we can include the get_console_log,
# emissions in `.accounting._pos` and others! name=subsys,
# [ ] maybe we could do the `log = get_logger()` above, )
# then cycle through the list of subsys mods we depend on
# and then get all their loggers and pass them to
# `get_console_log(logger=)`??
# [ ] OR just write THIS `get_console_log()` as a hook which does
# that based on who calls it?.. i dunno
#
# get_console_log = partial(
# get_console_log,
# name=subsys,
# )
class BrokerError(Exception): class BrokerError(Exception):

View File

@ -374,14 +374,9 @@ class Client:
pair: Pair = pair_type(**item) pair: Pair = pair_type(**item)
except Exception as e: except Exception as e:
e.add_note( e.add_note(
f'\n' "\nDon't panic, prolly stupid binance changed their symbology schema again..\n"
f'New or removed field we need to codify!\n' 'Check out their API docs here:\n\n'
f'pair-type: {pair_type!r}\n' 'https://binance-docs.github.io/apidocs/spot/en/#exchange-information'
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 raise
pair_table[pair.symbol.upper()] = pair pair_table[pair.symbol.upper()] = pair

View File

@ -37,9 +37,8 @@ import trio
from piker.accounting import ( from piker.accounting import (
Asset, Asset,
) )
from piker.log import ( from piker.brokers._util import (
get_logger, get_logger,
get_console_log,
) )
from piker.data._web_bs import ( from piker.data._web_bs import (
open_autorecon_ws, open_autorecon_ws,
@ -70,9 +69,7 @@ from .venues import (
) )
from .api import Client from .api import Client
log = get_logger( log = get_logger('piker.brokers.binance')
name=__name__,
)
# Fee schedule template, mostly for paper engine fees modelling. # Fee schedule template, mostly for paper engine fees modelling.
@ -248,16 +245,9 @@ async def handle_order_requests(
@tractor.context @tractor.context
async def open_trade_dialog( async def open_trade_dialog(
ctx: tractor.Context, ctx: tractor.Context,
loglevel: str = 'warning',
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
# enable piker.clearing console log for *this* `brokerd` subactor
get_console_log(
level=loglevel,
name=__name__,
)
# TODO: how do we set this from the EMS such that # TODO: how do we set this from the EMS such that
# positions are loaded from the correct venue on the user # positions are loaded from the correct venue on the user
# stream at startup? (that is in an attempt to support both # stream at startup? (that is in an attempt to support both
@ -450,7 +440,6 @@ async def open_trade_dialog(
# - ledger: TransactionLedger # - ledger: TransactionLedger
async with ( async with (
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn, trio.open_nursery() as tn,
ctx.open_stream() as ems_stream, ctx.open_stream() as ems_stream,
): ):

View File

@ -42,6 +42,7 @@ from trio_typing import TaskStatus
from pendulum import ( from pendulum import (
from_timestamp, from_timestamp,
) )
from rapidfuzz import process as fuzzy
import numpy as np import numpy as np
import tractor import tractor
@ -64,9 +65,9 @@ from piker.data._web_bs import (
open_autorecon_ws, open_autorecon_ws,
NoBsWs, NoBsWs,
) )
from piker.log import get_logger
from piker.brokers._util import ( from piker.brokers._util import (
DataUnavailable, DataUnavailable,
get_logger,
) )
from .api import ( from .api import (
@ -78,7 +79,7 @@ from .venues import (
get_api_eps, get_api_eps,
) )
log = get_logger(name=__name__) log = get_logger('piker.brokers.binance')
class L1(Struct): class L1(Struct):
@ -94,26 +95,22 @@ class L1(Struct):
# validation type # validation type
# https://developers.binance.com/docs/derivatives/usds-margined-futures/websocket-market-streams/Aggregate-Trade-Streams#response-example
class AggTrade(Struct, frozen=True): class AggTrade(Struct, frozen=True):
e: str # Event type e: str # Event type
E: int # Event time E: int # Event time
s: str # Symbol s: str # Symbol
a: int # Aggregate trade ID a: int # Aggregate trade ID
p: float # Price p: float # Price
q: float # Quantity with all the market trades q: float # Quantity
f: int # First trade ID f: int # First trade ID
l: int # noqa Last trade ID l: int # noqa Last trade ID
T: int # Trade time T: int # Trade time
m: bool # Is the buyer the market maker? m: bool # Is the buyer the market maker?
M: bool | None = None # Ignore M: bool | None = None # Ignore
nq: float|None = None # Normal quantity without the trades involving RPI orders
# ^XXX https://developers.binance.com/docs/derivatives/change-log#2025-12-29
async def stream_messages( async def stream_messages(
ws: NoBsWs, ws: NoBsWs,
) -> AsyncGenerator[NoBsWs, dict]: ) -> AsyncGenerator[NoBsWs, dict]:
# TODO: match syntax here! # TODO: match syntax here!
@ -224,8 +221,6 @@ def make_sub(pairs: list[str], sub_name: str, uid: int) -> dict[str, str]:
} }
# TODO, why aren't frame resp `log.info()`s showing in upstream
# code?!
@acm @acm
async def open_history_client( async def open_history_client(
mkt: MktPair, mkt: MktPair,
@ -275,15 +270,9 @@ async def open_history_client(
f'{times}' f'{times}'
) )
# XXX, debug any case where the latest 1m bar we get is
# already another "sample's-step-old"..
if end_dt is None: if end_dt is None:
inow: int = round(time.time()) inow: int = round(time.time())
if ( if (inow - times[-1]) > 60:
_time_step := (inow - times[-1])
>
timeframe * 2
):
await tractor.pause() await tractor.pause()
start_dt = from_timestamp(times[0]) start_dt = from_timestamp(times[0])
@ -457,6 +446,7 @@ async def subscribe(
async def stream_quotes( async def stream_quotes(
send_chan: trio.abc.SendChannel, send_chan: trio.abc.SendChannel,
symbols: list[str], symbols: list[str],
feed_is_live: trio.Event, feed_is_live: trio.Event,
@ -468,14 +458,11 @@ async def stream_quotes(
) -> None: ) -> None:
async with ( async with (
tractor.trionics.maybe_raise_from_masking_exc(),
send_chan as send_chan, send_chan as send_chan,
open_cached_client('binance') as client, open_cached_client('binance') as client,
): ):
init_msgs: list[FeedInit] = [] init_msgs: list[FeedInit] = []
for sym in symbols: for sym in symbols:
mkt: MktPair
pair: Pair
mkt, pair = await get_mkt_info(sym) mkt, pair = await get_mkt_info(sym)
# build out init msgs according to latest spec # build out init msgs according to latest spec
@ -524,6 +511,7 @@ async def stream_quotes(
# start streaming # start streaming
async for typ, quote in msg_gen: async for typ, quote in msg_gen:
# period = time.time() - last # period = time.time() - last
# hz = 1/period if period else float('inf') # hz = 1/period if period else float('inf')
# if hz > 60: # if hz > 60:
@ -559,7 +547,7 @@ async def open_symbol_search(
) )
# repack in fqme-keyed table # repack in fqme-keyed table
byfqme: dict[str, Pair] = {} byfqme: dict[start, Pair] = {}
for pair in pairs.values(): for pair in pairs.values():
byfqme[pair.bs_fqme] = pair byfqme[pair.bs_fqme] = pair

View File

@ -97,16 +97,6 @@ class Pair(Struct, frozen=True, kw_only=True):
baseAsset: str baseAsset: str
baseAssetPrecision: int baseAssetPrecision: int
permissionSets: list[list[str]]
# https://developers.binance.com/docs/binance-spot-api-docs#2025-08-26
# will become non-optional 2025-08-28?
# https://developers.binance.com/docs/binance-spot-api-docs#future-changes
pegInstructionsAllowed: bool = False
# https://developers.binance.com/docs/binance-spot-api-docs#2025-12-02
opoAllowed: bool = False
filters: dict[ filters: dict[
str, str,
str | int | float, str | int | float,
@ -152,11 +142,7 @@ class SpotPair(Pair, frozen=True):
defaultSelfTradePreventionMode: str defaultSelfTradePreventionMode: str
allowedSelfTradePreventionModes: list[str] allowedSelfTradePreventionModes: list[str]
permissions: list[str] permissions: list[str]
permissionSets: list[list[str]]
# can the paint botz creat liq gaps even easier on this asset?
# Bp
# https://developers.binance.com/docs/binance-spot-api-docs/faqs/order_amend_keep_priority
amendAllowed: bool
# NOTE: see `.data._symcache.SymbologyCache.load()` for why # NOTE: see `.data._symcache.SymbologyCache.load()` for why
ns_path: str = 'piker.brokers.binance:SpotPair' ns_path: str = 'piker.brokers.binance:SpotPair'
@ -223,10 +209,7 @@ class FutesPair(Pair):
assert pair == self.pair # sanity assert pair == self.pair # sanity
return f'{expiry}' return f'{expiry}'
case ( case 'PERPETUAL':
'PERPETUAL'
| 'TRADIFI_PERPETUAL'
):
return 'PERP' return 'PERP'
case '': case '':
@ -255,10 +238,7 @@ class FutesPair(Pair):
margin: str = self.marginAsset margin: str = self.marginAsset
match ctype: match ctype:
case ( case 'PERPETUAL':
'PERPETUAL'
| 'TRADIFI_PERPETUAL'
):
return f'{margin}M' return f'{margin}M'
case ( case (

View File

@ -27,12 +27,14 @@ import click
import trio import trio
import tractor import tractor
from piker.cli import cli from ..cli import cli
from piker import watchlists as wl from .. import watchlists as wl
from piker.log import ( from ..log import (
colorize_json, colorize_json,
)
from ._util import (
log,
get_console_log, get_console_log,
get_logger,
) )
from ..service import ( from ..service import (
maybe_spawn_brokerd, maybe_spawn_brokerd,
@ -43,15 +45,12 @@ from ..brokers import (
get_brokermod, get_brokermod,
data, data,
) )
log = get_logger(
name=__name__,
)
DEFAULT_BROKER = 'binance' DEFAULT_BROKER = 'binance'
_config_dir = click.get_app_dir('piker') _config_dir = click.get_app_dir('piker')
_watchlists_data_path = os.path.join(_config_dir, 'watchlists.json') _watchlists_data_path = os.path.join(_config_dir, 'watchlists.json')
OK = '\033[92m' OK = '\033[92m'
WARNING = '\033[93m' WARNING = '\033[93m'
FAIL = '\033[91m' FAIL = '\033[91m'
@ -346,10 +345,7 @@ def contracts(ctx, loglevel, broker, symbol, ids):
''' '''
brokermod = get_brokermod(broker) brokermod = get_brokermod(broker)
get_console_log( get_console_log(loglevel)
level=loglevel,
name=__name__,
)
contracts = trio.run(partial(core.contracts, brokermod, symbol)) contracts = trio.run(partial(core.contracts, brokermod, symbol))
if not ids: if not ids:
@ -475,18 +471,13 @@ def search(
''' '''
# global opts # global opts
brokermods: list[ModuleType] = list(config['brokermods'].values()) brokermods = list(config['brokermods'].values())
# TODO: this is coming from the `search --pdb` NOT from
# the `piker --pdb` XD ..
# -[ ] pull from the parent click ctx's values..dumdum
# assert pdb
loglevel: str = config['loglevel']
# define tractor entrypoint # define tractor entrypoint
async def main(func): async def main(func):
async with maybe_open_pikerd( async with maybe_open_pikerd(
loglevel=loglevel, loglevel=config['loglevel'],
debug_mode=pdb, debug_mode=pdb,
): ):
return await func() return await func()
@ -499,7 +490,6 @@ def search(
core.symbol_search, core.symbol_search,
brokermods, brokermods,
pattern, pattern,
loglevel=loglevel,
), ),
) )

View File

@ -22,26 +22,20 @@ routines should be primitive data types where possible.
""" """
import inspect import inspect
from types import ModuleType from types import ModuleType
from typing import ( from typing import List, Dict, Any, Optional
Any,
)
import trio import trio
from piker.log import get_logger from ._util import log
from . import get_brokermod from . import get_brokermod
from ..service import maybe_spawn_brokerd from ..service import maybe_spawn_brokerd
from . import open_cached_client from . import open_cached_client
from ..accounting import MktPair from ..accounting import MktPair
log = get_logger(name=__name__)
async def api(brokername: str, methname: str, **kwargs) -> dict: async def api(brokername: str, methname: str, **kwargs) -> dict:
''' """Make (proxy through) a broker API call by name and return its result.
Make (proxy through) a broker API call by name and return its result. """
'''
brokermod = get_brokermod(brokername) brokermod = get_brokermod(brokername)
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
meth = getattr(client, methname, None) meth = getattr(client, methname, None)
@ -68,14 +62,10 @@ async def api(brokername: str, methname: str, **kwargs) -> dict:
async def stocks_quote( async def stocks_quote(
brokermod: ModuleType, brokermod: ModuleType,
tickers: list[str] tickers: List[str]
) -> Dict[str, Dict[str, Any]]:
) -> dict[str, dict[str, Any]]: """Return quotes dict for ``tickers``.
''' """
Return a `dict` of snapshot quotes for the provided input
`tickers`: a `list` of fqmes.
'''
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
return await client.quote(tickers) return await client.quote(tickers)
@ -84,15 +74,13 @@ async def stocks_quote(
async def option_chain( async def option_chain(
brokermod: ModuleType, brokermod: ModuleType,
symbol: str, symbol: str,
date: str|None = None, date: Optional[str] = None,
) -> dict[str, dict[str, dict[str, Any]]]: ) -> Dict[str, Dict[str, Dict[str, Any]]]:
''' """Return option chain for ``symbol`` for ``date``.
Return option chain for ``symbol`` for ``date``.
By default all expiries are returned. If ``date`` is provided By default all expiries are returned. If ``date`` is provided
then contract quotes for that single expiry are returned. then contract quotes for that single expiry are returned.
"""
'''
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
if date: if date:
id = int((await client.tickers2ids([symbol]))[symbol]) id = int((await client.tickers2ids([symbol]))[symbol])
@ -110,7 +98,7 @@ async def option_chain(
# async def contracts( # async def contracts(
# brokermod: ModuleType, # brokermod: ModuleType,
# symbol: str, # symbol: str,
# ) -> dict[str, dict[str, dict[str, Any]]]: # ) -> Dict[str, Dict[str, Dict[str, Any]]]:
# """Return option contracts (all expiries) for ``symbol``. # """Return option contracts (all expiries) for ``symbol``.
# """ # """
# async with brokermod.get_client() as client: # async with brokermod.get_client() as client:
@ -122,24 +110,15 @@ async def bars(
brokermod: ModuleType, brokermod: ModuleType,
symbol: str, symbol: str,
**kwargs, **kwargs,
) -> dict[str, dict[str, dict[str, Any]]]: ) -> Dict[str, Dict[str, Dict[str, Any]]]:
''' """Return option contracts (all expiries) for ``symbol``.
Return option contracts (all expiries) for ``symbol``. """
'''
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
return await client.bars(symbol, **kwargs) return await client.bars(symbol, **kwargs)
async def search_w_brokerd( async def search_w_brokerd(name: str, pattern: str) -> dict:
name: str,
pattern: str,
) -> dict:
# TODO: WHY NOT WORK!?!
# when we `step` through the next block?
# import tractor
# await tractor.pause()
async with open_cached_client(name) as client: async with open_cached_client(name) as client:
# TODO: support multiple asset type concurrent searches. # TODO: support multiple asset type concurrent searches.
@ -149,15 +128,14 @@ async def search_w_brokerd(
async def symbol_search( async def symbol_search(
brokermods: list[ModuleType], brokermods: list[ModuleType],
pattern: str, pattern: str,
loglevel: str = 'warning',
**kwargs, **kwargs,
) -> dict[str, dict[str, dict[str, Any]]]: ) -> Dict[str, Dict[str, Dict[str, Any]]]:
''' '''
Return symbol info from broker. Return symbol info from broker.
''' '''
results: list[str] = [] results = []
async def search_backend( async def search_backend(
brokermod: ModuleType brokermod: ModuleType
@ -165,13 +143,6 @@ async def symbol_search(
brokername: str = mod.name brokername: str = mod.name
# TODO: figure this the FUCK OUT
# -> ok so obvi in the root actor any async task that's
# spawned outside the main tractor-root-actor task needs to
# call this..
# await tractor.devx._debug.maybe_init_greenback()
# tractor.pause_from_sync()
async with maybe_spawn_brokerd( async with maybe_spawn_brokerd(
mod.name, mod.name,
infect_asyncio=getattr( infect_asyncio=getattr(
@ -179,7 +150,6 @@ async def symbol_search(
'_infect_asyncio', '_infect_asyncio',
False, False,
), ),
loglevel=loglevel
) as portal: ) as portal:
results.append(( results.append((
@ -192,6 +162,7 @@ async def symbol_search(
)) ))
async with trio.open_nursery() as n: async with trio.open_nursery() as n:
for mod in brokermods: for mod in brokermods:
n.start_soon(search_backend, mod.name) n.start_soon(search_backend, mod.name)
@ -201,13 +172,11 @@ async def symbol_search(
async def mkt_info( async def mkt_info(
brokermod: ModuleType, brokermod: ModuleType,
fqme: str, fqme: str,
**kwargs, **kwargs,
) -> MktPair: ) -> MktPair:
''' '''
Return the `piker.accounting.MktPair` info struct from a given Return MktPair info from broker including src and dst assets.
backend broker tradable src/dst asset pair.
''' '''
async with open_cached_client(brokermod.name) as client: async with open_cached_client(brokermod.name) as client:

View File

@ -41,15 +41,12 @@ import tractor
from tractor.experimental import msgpub from tractor.experimental import msgpub
from async_generator import asynccontextmanager from async_generator import asynccontextmanager
from piker.log import( from ._util import (
get_logger, log,
get_console_log, get_console_log,
) )
from . import get_brokermod from . import get_brokermod
log = get_logger(
name='piker.brokers.binance',
)
async def wait_for_network( async def wait_for_network(
net_func: Callable, net_func: Callable,
@ -246,10 +243,7 @@ async def start_quote_stream(
''' '''
# XXX: why do we need this again? # XXX: why do we need this again?
get_console_log( get_console_log(tractor.current_actor().loglevel)
level=tractor.current_actor().loglevel,
name=__name__,
)
# pull global vars from local actor # pull global vars from local actor
symbols = list(symbols) symbols = list(symbols)

View File

@ -25,6 +25,7 @@ from .api import (
get_client, get_client,
) )
from .feed import ( from .feed import (
get_mkt_info,
open_history_client, open_history_client,
open_symbol_search, open_symbol_search,
stream_quotes, stream_quotes,
@ -34,15 +35,20 @@ from .feed import (
# open_trade_dialog, # open_trade_dialog,
# norm_trade_records, # norm_trade_records,
# ) # )
from .venues import (
OptionPair,
)
log = get_logger(__name__) log = get_logger(__name__)
__all__ = [ __all__ = [
'get_client', 'get_client',
# 'trades_dialogue', # 'trades_dialogue',
'get_mkt_info',
'open_history_client', 'open_history_client',
'open_symbol_search', 'open_symbol_search',
'stream_quotes', 'stream_quotes',
'OptionPair',
# 'norm_trade_records', # 'norm_trade_records',
] ]

File diff suppressed because it is too large Load Diff

View File

@ -18,38 +18,59 @@
Deribit backend. Deribit backend.
''' '''
from __future__ import annotations
from contextlib import asynccontextmanager as acm from contextlib import asynccontextmanager as acm
from datetime import datetime from datetime import datetime
from typing import Any, Optional, Callable from typing import (
# Any,
# Optional,
Callable,
)
# from pprint import pformat
import time import time
import cryptofeed
import trio import trio
from trio_typing import TaskStatus from trio_typing import TaskStatus
import pendulum from pendulum import (
from rapidfuzz import process as fuzzy from_timestamp,
)
import numpy as np import numpy as np
import tractor import tractor
from piker.brokers import open_cached_client from piker.accounting import (
from piker.log import get_logger, get_console_log Asset,
from piker.data import ShmArray MktPair,
from piker.brokers._util import ( unpack_fqme,
BrokerError, )
from piker.brokers import (
open_cached_client,
NoData,
DataUnavailable, DataUnavailable,
) )
from piker._cacheables import (
from cryptofeed import FeedHandler async_lifo_cache,
from cryptofeed.defines import (
DERIBIT, L1_BOOK, TRADES, OPTION, CALL, PUT
) )
from cryptofeed.symbols import Symbol from piker.log import (
get_logger,
mk_repr,
)
from piker.data.validate import FeedInit
from .api import ( from .api import (
Client, Trade, Client,
get_config, # get_config,
str_to_cb_sym, piker_sym_to_cb_sym, cb_sym_to_deribit_inst, piker_sym_to_cb_sym,
cb_sym_to_deribit_inst,
str_to_cb_sym,
maybe_open_price_feed maybe_open_price_feed
) )
from .venues import (
Pair,
OptionPair,
Trade,
)
_spawn_kwargs = { _spawn_kwargs = {
'infect_asyncio': True, 'infect_asyncio': True,
@ -64,90 +85,215 @@ async def open_history_client(
mkt: MktPair, mkt: MktPair,
) -> tuple[Callable, int]: ) -> tuple[Callable, int]:
fnstrument: str = mkt.bs_fqme
# TODO implement history getter for the new storage layer. # TODO implement history getter for the new storage layer.
async with open_cached_client('deribit') as client: async with open_cached_client('deribit') as client:
pair: OptionPair = client._pairs[mkt.dst.name]
# XXX NOTE, the cuckers use ms !!!
creation_time_s: int = pair.creation_timestamp/1000
async def get_ohlc( async def get_ohlc(
end_dt: Optional[datetime] = None, timeframe: float,
start_dt: Optional[datetime] = None, end_dt: datetime | None = None,
start_dt: datetime | None = None,
) -> tuple[ ) -> tuple[
np.ndarray, np.ndarray,
datetime, # start datetime, # start
datetime, # end datetime, # end
]: ]:
if timeframe != 60:
raise DataUnavailable('Only 1m bars are supported')
array = await client.bars( array: np.ndarray = await client.bars(
instrument, mkt,
start_dt=start_dt, start_dt=start_dt,
end_dt=end_dt, end_dt=end_dt,
) )
if len(array) == 0: if len(array) == 0:
raise DataUnavailable if (
end_dt is None
):
raise DataUnavailable(
'No history seems to exist yet?\n\n'
f'{mkt}'
)
elif (
end_dt
and
end_dt.timestamp() < creation_time_s
):
# the contract can't have history
# before it was created.
pair_type_str: str = type(pair).__name__
create_dt: datetime = from_timestamp(creation_time_s)
raise DataUnavailable(
f'No history prior to\n'
f'`{pair_type_str}.creation_timestamp: int = '
f'{pair.creation_timestamp}\n\n'
f'------ deribit sux ------\n'
f'WHICH IN "NORMAL PEOPLE WHO USE EPOCH TIME" form is,\n'
f'creation_time_s: {creation_time_s}\n'
f'create_dt: {create_dt}\n'
)
raise NoData(
f'No frame for {start_dt} -> {end_dt}\n'
)
start_dt = pendulum.from_timestamp(array[0]['time']) start_dt = from_timestamp(array[0]['time'])
end_dt = pendulum.from_timestamp(array[-1]['time']) end_dt = from_timestamp(array[-1]['time'])
times = array['time']
if not times.any():
raise ValueError(
'Bad frame with null-times?\n\n'
f'{times}'
)
if end_dt is None:
inow: int = round(time.time())
if (inow - times[-1]) > 60:
await tractor.pause()
return array, start_dt, end_dt return array, start_dt, end_dt
yield get_ohlc, {'erlangs': 3, 'rate': 3} yield (
get_ohlc,
{ # backfill config
'erlangs': 3,
'rate': 3,
}
)
@async_lifo_cache()
async def get_mkt_info(
fqme: str,
) -> tuple[MktPair, Pair|OptionPair] | None:
# uppercase since kraken bs_mktid is always upper
if 'deribit' not in fqme.lower():
fqme += '.deribit'
mkt_mode: str = ''
broker, mkt_ep, venue, expiry = unpack_fqme(fqme)
# NOTE: we always upper case all tokens to be consistent with
# binance's symbology style for pairs, like `BTCUSDT`, but in
# theory we could also just keep things lower case; as long as
# we're consistent and the symcache matches whatever this func
# returns, always!
expiry: str = expiry.upper()
venue: str = venue.upper()
# venue_lower: str = venue.lower()
mkt_mode: str = 'option'
async with open_cached_client(
'deribit',
) as client:
assets: dict[str, Asset] = await client.get_assets()
pair_str: str = mkt_ep.lower()
pair: Pair = await client.exch_info(
sym=pair_str,
)
mkt_mode = pair.venue
client.mkt_mode = mkt_mode
dst: Asset | None = assets.get(pair.bs_dst_asset)
src: Asset | None = assets.get(pair.bs_src_asset)
mkt = MktPair(
dst=dst,
src=src,
price_tick=pair.price_tick,
size_tick=pair.size_tick,
bs_mktid=pair.symbol,
venue=mkt_mode,
broker='deribit',
_atype=mkt_mode,
_fqme_without_src=True,
# expiry=pair.expiry,
# XXX TODO, currently we don't use it since it's
# already "described" in the `OptionPair.symbol: str`
# and if we slap in the ISO repr it's kinda hideous..
# -[ ] figure out the best either std
)
return mkt, pair
async def stream_quotes( async def stream_quotes(
send_chan: trio.abc.SendChannel, send_chan: trio.abc.SendChannel,
symbols: list[str], symbols: list[str],
feed_is_live: trio.Event, feed_is_live: trio.Event,
loglevel: str = None,
# startup sync # startup sync
task_status: TaskStatus[tuple[dict, dict]] = trio.TASK_STATUS_IGNORED, task_status: TaskStatus[tuple[dict, dict]] = trio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
# XXX: required to propagate ``tractor`` loglevel to piker logging '''
get_console_log(loglevel or tractor.current_actor().loglevel) Open a live quote stream for the market set defined by `symbols`.
sym = symbols[0] Internally this starts a `cryptofeed.FeedHandler` inside an `asyncio`-side
task and relays through L1 and `Trade` msgs here to our `trio.Task`.
'''
sym = symbols[0].split('.')[0]
init_msgs: list[FeedInit] = []
# multiline nested `dict` formatter (since rn quote-msgs are
# just that).
pfmt: Callable[[str], str] = mk_repr(
# so we can see `deribit`'s delightfully mega-long bs fields..
maxstring=100,
)
async with ( async with (
open_cached_client('deribit') as client, open_cached_client('deribit') as client,
send_chan as send_chan send_chan as send_chan
): ):
mkt: MktPair
pair: Pair
mkt, pair = await get_mkt_info(sym)
init_msgs = { # build out init msgs according to latest spec
# pass back token, and bool, signalling if we're the writer init_msgs.append(
# and that history has been written FeedInit(
sym: { mkt_info=mkt,
'symbol_info': { )
'asset_type': 'option', )
'price_tick_size': 0.0005 # build `cryptofeed` feed-handle
}, cf_sym: cryptofeed.Symbol = piker_sym_to_cb_sym(sym)
'shm_write_opts': {'sum_tick_vml': False},
'fqsn': sym,
},
}
nsym = piker_sym_to_cb_sym(sym) from_cf: tractor.to_asyncio.LinkedTaskChannel
async with maybe_open_price_feed(sym) as from_cf:
async with maybe_open_price_feed(sym) as stream: # load the "last trades" summary
last_trades_res: cryptofeed.LastTradesResult = await client.last_trades(
cb_sym_to_deribit_inst(cf_sym),
count=1,
)
last_trades: list[Trade] = last_trades_res.trades
cache = await client.cache_symbols() # TODO, do we even need this or will the above always
# work?
# if not last_trades:
# await tractor.pause()
# async for typ, quote in from_cf:
# if typ == 'trade':
# last_trade = Trade(**(quote['data']))
# break
last_trades = (await client.last_trades( # else:
cb_sym_to_deribit_inst(nsym), count=1)).trades last_trade = Trade(
**(last_trades[0])
)
if len(last_trades) == 0: first_quote: dict = {
last_trade = None
async for typ, quote in stream:
if typ == 'trade':
last_trade = Trade(**(quote['data']))
break
else:
last_trade = Trade(**(last_trades[0]))
first_quote = {
'symbol': sym, 'symbol': sym,
'last': last_trade.price, 'last': last_trade.price,
'brokerd_ts': last_trade.timestamp, 'brokerd_ts': last_trade.timestamp,
@ -158,13 +304,84 @@ async def stream_quotes(
'broker_ts': last_trade.timestamp 'broker_ts': last_trade.timestamp
}] }]
} }
task_status.started((init_msgs, first_quote)) task_status.started((
init_msgs,
first_quote,
))
feed_is_live.set() feed_is_live.set()
async for typ, quote in stream: # NOTE XXX, static for now!
topic = quote['symbol'] # => since this only handles ONE mkt feed at a time we
await send_chan.send({topic: quote}) # don't need a lookup table to map interleaved quotes
# from multiple possible mkt-pairs
topic: str = mkt.bs_fqme
# deliver until cancelled
async for typ, ref in from_cf:
match typ:
case 'trade':
trade: cryptofeed.types.Trade = ref
# TODO, re-impl this according to teh ideal
# fqme for opts that we choose!!
bs_fqme: str = cb_sym_to_deribit_inst(
str_to_cb_sym(trade.symbol)
).lower()
piker_quote: dict = {
'symbol': bs_fqme,
'last': trade.price,
'broker_ts': time.time(),
# ^TODO, name this `brokerd/datad_ts` and
# use `time.time_ns()` ??
'ticks': [{
'type': 'trade',
'price': float(trade.price),
'size': float(trade.amount),
'broker_ts': trade.timestamp,
}],
}
log.info(
f'deribit {typ!r} quote for {sym!r}\n\n'
f'{trade}\n\n'
f'{pfmt(piker_quote)}\n'
)
case 'l1':
book: cryptofeed.types.L1Book = ref
# TODO, so this is where we can possibly change things
# and instead lever the `MktPair.bs_fqme: str` output?
bs_fqme: str = cb_sym_to_deribit_inst(
str_to_cb_sym(book.symbol)
).lower()
piker_quote: dict = {
'symbol': bs_fqme,
'ticks': [
{'type': 'bid',
'price': float(book.bid_price),
'size': float(book.bid_size)},
{'type': 'bsize',
'price': float(book.bid_price),
'size': float(book.bid_size),},
{'type': 'ask',
'price': float(book.ask_price),
'size': float(book.ask_size),},
{'type': 'asize',
'price': float(book.ask_price),
'size': float(book.ask_size),}
]
}
await send_chan.send({
topic: piker_quote,
})
@tractor.context @tractor.context
@ -174,12 +391,21 @@ async def open_symbol_search(
async with open_cached_client('deribit') as client: async with open_cached_client('deribit') as client:
# load all symbols locally for fast search # load all symbols locally for fast search
cache = await client.cache_symbols() # cache = client._pairs
await ctx.started() await ctx.started()
async with ctx.open_stream() as stream: async with ctx.open_stream() as stream:
pattern: str
async for pattern in stream: async for pattern in stream:
# repack in dict form
await stream.send( # NOTE: pattern fuzzy-matching is done within
await client.search_symbols(pattern)) # the methd impl.
pairs: dict[str, Pair] = await client.search_symbols(
pattern,
)
# repack in fqme-keyed table
byfqme: dict[str, Pair] = {}
for pair in pairs.values():
byfqme[pair.bs_fqme] = pair
await stream.send(byfqme)

View File

@ -0,0 +1,196 @@
# 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/>.
"""
Per market data-type definitions and schemas types.
"""
from __future__ import annotations
import pendulum
from typing import (
Literal,
Optional,
)
from decimal import Decimal
from piker.types import Struct
# API endpoint paths by venue / sub-API
_domain: str = 'deribit.com'
_url = f'https://www.{_domain}'
# WEBsocketz
_ws_url: str = f'wss://www.{_domain}/ws/api/v2'
# test nets
_testnet_ws_url: str = f'wss://test.{_domain}/ws/api/v2'
MarketType = Literal[
'option'
]
def get_api_eps(venue: MarketType) -> tuple[str, str]:
'''
Return API ep root paths per venue.
'''
return {
'option': (
_ws_url,
),
}[venue]
class Pair(Struct, frozen=True, kw_only=True):
symbol: str
# src
quote_currency: str # 'BTC'
# dst
base_currency: str # "BTC",
tick_size: float # 0.0001 # [{'above_price': 0.005, 'tick_size': 0.0005}]
tick_size_steps: list[dict[str, float]]
@property
def price_tick(self) -> Decimal:
return Decimal(str(self.tick_size_steps[0]['above_price']))
@property
def size_tick(self) -> Decimal:
return Decimal(str(self.tick_size))
@property
def bs_fqme(self) -> str:
return f'{self.symbol}'
@property
def bs_mktid(self) -> str:
return f'{self.symbol}.{self.venue}'
class OptionPair(Pair, frozen=True):
taker_commission: float # 0.0003
strike: float # 5000.0
settlement_period: str # 'day'
settlement_currency: str # "BTC",
rfq: bool # false
price_index: str # 'btc_usd'
option_type: str # 'call'
min_trade_amount: float # 0.1
maker_commission: float # 0.0003
kind: str # 'option'
is_active: bool # true
instrument_type: str # 'reversed'
instrument_name: str # 'BTC-1SEP24-55000-C'
instrument_id: int # 364671
expiration_timestamp: int # 1725177600000
creation_timestamp: int # 1724918461000
counter_currency: str # 'USD'
contract_size: float # '1.0'
block_trade_tick_size: float # '0.0001'
block_trade_min_trade_amount: int # '25'
block_trade_commission: float # '0.003'
# NOTE: see `.data._symcache.SymbologyCache.load()` for why
ns_path: str = 'piker.brokers.deribit:OptionPair'
# TODO, impl this without the MM:SS part of
# the `'THH:MM:SS..'` etc..
@property
def expiry(self) -> str:
iso_date = pendulum.from_timestamp(
self.expiration_timestamp / 1000
).isoformat()
return iso_date
@property
def venue(self) -> str:
return f'{self.instrument_type}_option'
@property
def bs_fqme(self) -> str:
return f'{self.symbol}'
@property
def bs_src_asset(self) -> str:
return f'{self.quote_currency}'
@property
def bs_dst_asset(self) -> str:
return f'{self.symbol}'
PAIRTYPES: dict[MarketType, Pair] = {
'option': OptionPair,
}
class JSONRPCResult(Struct):
id: int
usIn: int
usOut: int
usDiff: int
testnet: bool
jsonrpc: str = '2.0'
error: Optional[dict] = None
result: Optional[list[dict]] = None
class JSONRPCChannel(Struct):
method: str
params: dict
jsonrpc: str = '2.0'
class KLinesResult(Struct):
low: list[float]
cost: list[float]
high: list[float]
open: list[float]
close: list[float]
ticks: list[int]
status: str
volume: list[float]
class Trade(Struct):
iv: float
price: float
amount: float
trade_id: str
contracts: float
direction: str
trade_seq: int
timestamp: int
mark_price: float
index_price: float
tick_direction: int
instrument_name: str
combo_id: Optional[str] = '',
combo_trade_id: Optional[int] = 0,
block_trade_id: Optional[str] = '',
block_trade_leg_count: Optional[int] = 0,
class LastTradesResult(Struct):
trades: list[Trade]
has_more: bool

View File

@ -20,11 +20,6 @@ runnable script-programs.
''' '''
from __future__ import annotations from __future__ import annotations
from datetime import ( # noqa
datetime,
date,
tzinfo as TzInfo,
)
from functools import partial from functools import partial
from typing import ( from typing import (
Literal, Literal,
@ -34,13 +29,13 @@ import subprocess
import tractor import tractor
from piker.log import get_logger from piker.brokers._util import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from .api import Client from .api import Client
import i3ipc from ib_insync import IB
log = get_logger(name=__name__) log = get_logger('piker.brokers.ib')
_reset_tech: Literal[ _reset_tech: Literal[
'vnc', 'vnc',
@ -53,39 +48,8 @@ _reset_tech: Literal[
] = 'vnc' ] = 'vnc'
no_setup_msg:str = (
'No data reset hack test setup for {vnc_sockaddr}!\n'
'See config setup tips @\n'
'https://github.com/pikers/piker/tree/master/piker/brokers/ib'
)
def try_xdo_manual(
client: Client,
):
'''
Do the "manual" `xdo`-based screen switch + click
combo since apparently the `asyncvnc` client ain't workin..
Note this is only meant as a backup method for Xorg users,
ideally you can use a real vnc client and the `vnc_click_hack()`
impl!
'''
global _reset_tech
try:
i3ipc_xdotool_manual_click_hack()
_reset_tech = 'i3ipc_xdotool'
return True
except OSError:
vnc_sockaddr: str = client.conf.vnc_addrs
log.exception(
no_setup_msg.format(vnc_sockaddr=vnc_sockaddr)
)
return False
async def data_reset_hack( async def data_reset_hack(
# vnc_host: str,
client: Client, client: Client,
reset_type: Literal['data', 'connection'], reset_type: Literal['data', 'connection'],
@ -117,60 +81,65 @@ async def data_reset_hack(
that need to be wrangle. that need to be wrangle.
''' '''
ib_client: IB = client.ib
# look up any user defined vnc socket address mapped from # look up any user defined vnc socket address mapped from
# a particular API socket port. # a particular API socket port.
vnc_addrs: tuple[str]|None = client.conf.get('vnc_addrs') api_port: str = str(ib_client.client.port)
if not vnc_addrs: vnc_host: str
vnc_port: int
vnc_sockaddr: tuple[str] | None = client.conf.get('vnc_addrs')
no_setup_msg:str = (
f'No data reset hack test setup for {vnc_sockaddr}!\n'
'See config setup tips @\n'
'https://github.com/pikers/piker/tree/master/piker/brokers/ib'
)
if not vnc_sockaddr:
log.warning( log.warning(
no_setup_msg.format(vnc_sockaddr=client.conf) no_setup_msg
+ +
'REQUIRES A `vnc_addrs: array` ENTRY' 'REQUIRES A `vnc_addrs: array` ENTRY'
) )
vnc_host, vnc_port = vnc_sockaddr.get(
api_port,
('localhost', 3003)
)
global _reset_tech global _reset_tech
match _reset_tech: match _reset_tech:
case 'vnc': case 'vnc':
try: try:
await tractor.to_asyncio.run_task( await tractor.to_asyncio.run_task(
partial( partial(
vnc_click_hack, vnc_click_hack,
client=client, host=vnc_host,
port=vnc_port,
) )
) )
except ( except OSError:
OSError, # no VNC server avail.. if vnc_host != 'localhost':
PermissionError, # asyncvnc pw fail.. log.warning(no_setup_msg)
): return False
try: try:
import i3ipc # noqa (since a deps dynamic check) import i3ipc # noqa (since a deps dynamic check)
except ModuleNotFoundError: except ModuleNotFoundError:
log.warning( log.warning(no_setup_msg)
no_setup_msg.format(vnc_sockaddr=client.conf)
)
return False return False
# XXX, Xorg only workaround.. try:
# TODO? remove now that we have `pyvnc`? i3ipc_xdotool_manual_click_hack()
# if vnc_host not in { _reset_tech = 'i3ipc_xdotool'
# 'localhost', return True
# '127.0.0.1', except OSError:
# }: log.exception(no_setup_msg)
# focussed, matches = i3ipc_fin_wins_titled() return False
# if not matches:
# log.warning(
# no_setup_msg.format(vnc_sockaddr=vnc_sockaddr)
# )
# return False
# else:
# try_xdo_manual(vnc_sockaddr)
# localhost but no vnc-client or it borked..
else:
try_xdo_manual(client)
case 'i3ipc_xdotool': case 'i3ipc_xdotool':
try_xdo_manual(client) i3ipc_xdotool_manual_click_hack()
# i3ipc_xdotool_manual_click_hack()
case _ as tech: case _ as tech:
raise RuntimeError(f'{tech} is not supported for reset tech!?') raise RuntimeError(f'{tech} is not supported for reset tech!?')
@ -180,66 +149,21 @@ async def data_reset_hack(
async def vnc_click_hack( async def vnc_click_hack(
client: Client, host: str,
reset_type: str = 'data', port: int,
pw: str|None = None, reset_type: str = 'data'
) -> None: ) -> None:
''' '''
Reset the data or network connection for the VNC attached Reset the data or network connection for the VNC attached
ib-gateway using a (magic) keybinding combo. ib gateway using magic combos.
A vnc-server password can be set either by an input `pw` param or
set in the client's config with the latter loaded from the user's
`brokers.toml` in a vnc-addrs-port-mapping section,
.. code:: toml
[ib.vnc_addrs]
4002 = {host = 'localhost', port = 5900, pw = 'doggy'}
''' '''
api_port: str = str(client.ib.client.port)
conf: dict = client.conf
vnc_addrs: dict[int, tuple] = conf.get('vnc_addrs')
if not vnc_addrs:
return None
addr_entry: dict|tuple = vnc_addrs.get(
api_port,
('localhost', 5900) # a typical default
)
if pw is None:
match addr_entry:
case (
host,
port,
):
pass
case {
'host': host,
'port': port,
'pw': pw
}:
pass
case _:
raise ValueError(
f'Invalid `ib.vnc_addrs` entry ?\n'
f'{addr_entry!r}\n'
)
try: try:
from pyvnc import ( import asyncvnc
AsyncVNCClient,
VNCConfig,
Point,
MOUSE_BUTTON_LEFT,
)
except ModuleNotFoundError: except ModuleNotFoundError:
log.warning( log.warning(
"In order to leverage `piker`'s built-in data reset hacks, install " "In order to leverage `piker`'s built-in data reset hacks, install "
"the `pyvnc` project: https://github.com/regulad/pyvnc.git" "the `asyncvnc` project: https://github.com/barneygale/asyncvnc"
) )
return return
@ -250,80 +174,24 @@ async def vnc_click_hack(
'connection': 'r' 'connection': 'r'
}[reset_type] }[reset_type]
with tractor.devx.open_crash_handler( async with asyncvnc.connect(
ignore={TimeoutError,}, host,
):
client = await AsyncVNCClient.connect(
VNCConfig(
host=host,
port=port, port=port,
password=pw,
) # TODO: doesn't work see:
) # https://github.com/barneygale/asyncvnc/issues/7
async with client: # password='ibcansmbz',
) as client:
# move to middle of screen # move to middle of screen
# 640x1800 # 640x1800
await client.move( client.mouse.move(
Point( x=500,
500, y=500,
500,
)
)
# ensure the ib-gw window is active
await client.click(MOUSE_BUTTON_LEFT)
# send the hotkeys combo B)
await client.press('Ctrl', 'Alt', key) # keys are stacked
def i3ipc_fin_wins_titled(
titles: list[str] = [
'Interactive Brokers', # tws running in i3
'IB Gateway', # gw running in i3
# 'IB', # gw running in i3 (newer version?)
# !TODO, remote vnc instance
# -[ ] something in title (or other Con-props) that indicates
# this is explicitly for ibrk sw?
# |_[ ] !can use modden spawn eventually!
'TigerVNC',
# 'vncviewer', # the terminal..
],
) -> tuple[
i3ipc.Con, # orig focussed win
list[tuple[str, i3ipc.Con]], # matching wins by title
]:
'''
Attempt to find a local-DE window titled with an entry in
`titles`.
If found deliver the current focussed window and all matching
`i3ipc.Con`s in a list.
'''
import i3ipc
ipc = i3ipc.Connection()
# TODO: might be worth offering some kinda api for grabbing
# the window id from the pid?
# https://stackoverflow.com/a/2250879
tree = ipc.get_tree()
focussed: i3ipc.Con = tree.find_focused()
matches: list[i3ipc.Con] = []
for name in titles:
results = tree.find_titled(name)
print(f'results for {name}: {results}')
if results:
con = results[0]
matches.append((
name,
con,
))
return (
focussed,
matches,
) )
client.mouse.click()
client.keyboard.press('Ctrl', 'Alt', key) # keys are stacked
def i3ipc_xdotool_manual_click_hack() -> None: def i3ipc_xdotool_manual_click_hack() -> None:
@ -331,17 +199,29 @@ def i3ipc_xdotool_manual_click_hack() -> None:
Do the data reset hack but expecting a local X-window using `xdotool`. Do the data reset hack but expecting a local X-window using `xdotool`.
''' '''
focussed, matches = i3ipc_fin_wins_titled() import i3ipc
try: i3 = i3ipc.Connection()
orig_win_id = focussed.window
except AttributeError: # TODO: might be worth offering some kinda api for grabbing
# XXX if .window cucks we prolly aren't intending to # the window id from the pid?
# use this and/or just woke up from suspend.. # https://stackoverflow.com/a/2250879
log.exception('xdotool invalid usage ya ??\n') t = i3.get_tree()
return
orig_win_id = t.find_focused().window
# for tws
win_names: list[str] = [
'Interactive Brokers', # tws running in i3
'IB Gateway', # gw running in i3
# 'IB', # gw running in i3 (newer version?)
]
try: try:
for name, con in matches: for name in win_names:
results = t.find_titled(name)
print(f'results for {name}: {results}')
if results:
con = results[0]
print(f'Resetting data feed for {name}') print(f'Resetting data feed for {name}')
win_id = str(con.window) win_id = str(con.window)
w, h = con.rect.width, con.rect.height w, h = con.rect.width, con.rect.height

View File

@ -48,13 +48,11 @@ from bidict import bidict
import trio import trio
import tractor import tractor
from tractor import to_asyncio from tractor import to_asyncio
from tractor import trionics
from pendulum import ( from pendulum import (
from_timestamp,
DateTime, DateTime,
Duration, Duration,
duration as mk_duration, duration as mk_duration,
from_timestamp,
Interval,
) )
from eventkit import Event from eventkit import Event
from ib_insync import ( from ib_insync import (
@ -92,15 +90,10 @@ from .symbols import (
_exch_skip_list, _exch_skip_list,
_futes_venues, _futes_venues,
) )
from ...log import get_logger from ._util import (
from .venues import ( log,
is_venue_open, # only for the ib_sync internal logging
sesh_times, get_logger,
is_venue_closure,
)
log = get_logger(
name=__name__,
) )
_bar_load_dtype: list[tuple[str, type]] = [ _bar_load_dtype: list[tuple[str, type]] = [
@ -186,7 +179,7 @@ class NonShittyIB(IB):
# override `ib_insync` internal loggers so we can see wtf # override `ib_insync` internal loggers so we can see wtf
# it's doing.. # it's doing..
self._logger = get_logger( self._logger = get_logger(
name=__name__, 'ib_insync.ib',
) )
self._createEvents() self._createEvents()
@ -194,7 +187,7 @@ class NonShittyIB(IB):
self.wrapper = NonShittyWrapper(self) self.wrapper = NonShittyWrapper(self)
self.client = ib_client.Client(self.wrapper) self.client = ib_client.Client(self.wrapper)
self.client._logger = get_logger( self.client._logger = get_logger(
name='ib_insync.client', 'ib_insync.client',
) )
# self.errorEvent += self._onError # self.errorEvent += self._onError
@ -266,16 +259,6 @@ def remove_handler_on_err(
event.disconnect(handler) event.disconnect(handler)
# (originally?) i thot that,
# > "EST in ISO 8601 format is required.."
#
# XXX, but see `ib_async`'s impl,
# - `ib_async.ib.IB.reqHistoricalDataAsync()`
# - `ib_async.util.formatIBDatetime()`
# below is EPOCH.
_iso8601_epoch_in_est: str = "1970-01-01T00:00:00.000000-05:00"
class Client: class Client:
''' '''
IB wrapped for our broker backend API. IB wrapped for our broker backend API.
@ -349,11 +332,9 @@ class Client:
self, self,
fqme: str, fqme: str,
# EST in ISO 8601 format is required.. # EST in ISO 8601 format is required... below is EPOCH
# XXX, see `ib_async.ib.IB.reqHistoricalDataAsync()` start_dt: datetime | str = "1970-01-01T00:00:00.000000-05:00",
# below is EPOCH. end_dt: datetime | str = "",
start_dt: datetime|None = None, # _iso8601_epoch_in_est,
end_dt: datetime|None = None,
# ohlc sample period in seconds # ohlc sample period in seconds
sample_period_s: int = 1, sample_period_s: int = 1,
@ -364,17 +345,9 @@ class Client:
**kwargs, **kwargs,
) -> tuple[ ) -> tuple[BarDataList, np.ndarray, Duration]:
BarDataList,
np.ndarray,
Duration,
]:
''' '''
Retreive the `fqme`'s OHLCV-bars for the time-range "until `end_dt`". Retreive OHLCV bars for a fqme over a range to the present.
Notes:
- IB's api doesn't support a `start_dt` (which is why default
is null) so we only use it for bar-frame duration checking.
''' '''
# See API docs here: # See API docs here:
@ -389,19 +362,13 @@ class Client:
dt_duration: Duration = ( dt_duration: Duration = (
duration duration
or or default_dt_duration
default_dt_duration
) )
# TODO: maybe remove all this? # TODO: maybe remove all this?
global _enters global _enters
if end_dt is None: if not end_dt:
end_dt: str = '' end_dt = ''
else:
est_end_dt = end_dt.in_tz('EST')
if est_end_dt != end_dt:
breakpoint()
_enters += 1 _enters += 1
@ -470,116 +437,58 @@ class Client:
+ query_info + query_info
) )
# TODO: we could maybe raise `NoData` instead if we # TODO: we could maybe raise ``NoData`` instead if we
# rewrite the method in the first case? # rewrite the method in the first case?
# right now there's no way to detect a timeout.. # right now there's no way to detect a timeout..
return [], np.empty(0), dt_duration return [], np.empty(0), dt_duration
log.info(query_info) log.info(query_info)
# ------ GAP-DETECTION ------
# NOTE XXX: ensure minimum duration in bars? # NOTE XXX: ensure minimum duration in bars?
# => recursively call this method until we get at least as # => recursively call this method until we get at least as
# many bars such that they sum in aggregate to the the # many bars such that they sum in aggregate to the the
# desired total time (duration) at most. # desired total time (duration) at most.
# - if you query over a gap and get no data # - if you query over a gap and get no data
# that may short circuit the history # that may short circuit the history
if end_dt: if (
# XXX XXX XXX
# => WHY DID WE EVEN NEED THIS ORIGINALLY!? <=
# XXX XXX XXX
False
and end_dt
):
nparr: np.ndarray = bars_to_np(bars) nparr: np.ndarray = bars_to_np(bars)
times: np.ndarray = nparr['time'] times: np.ndarray = nparr['time']
first: float = times[0] first: float = times[0]
last: float = times[-1] tdiff: float = times[-1] - first
# frame_dur: float = times[-1] - first
details: ContractDetails = (
await self.ib.reqContractDetailsAsync(contract)
)[0]
# convert to makt-native tz
tz: str = details.timeZoneId
end_dt = end_dt.in_tz(tz)
first_dt: DateTime = from_timestamp(first).in_tz(tz)
last_dt: DateTime = from_timestamp(last).in_tz(tz)
tdiff: int = (
last_dt
-
first_dt
).in_seconds() + sample_period_s
_open_now: bool = is_venue_open(
con_deats=details,
)
# XXX, do gap detections.
has_closure_gap: bool = False
if (
last_dt.add(seconds=sample_period_s)
<
end_dt
):
open_time, close_time = sesh_times(details)
# XXX, always calc gap in mkt-venue-local timezone
gap: Interval = end_dt - last_dt
if not (
has_closure_gap := is_venue_closure(
gap=gap,
con_deats=details,
time_step_s=sample_period_s,
)):
log.warning(
f'Invalid non-closure gap for {fqme!r} ?!?\n'
f'is-open-now: {_open_now}\n'
f'\n'
f'{gap}\n'
)
log.warning(
f'Detected NON venue-closure GAP ??\n'
f'{gap}\n'
)
breakpoint()
else:
assert has_closure_gap
log.debug(
f'Detected venue closure gap (weekend),\n'
f'{gap}\n'
)
if ( if (
start_dt is None # len(bars) * sample_period_s) < dt_duration.in_seconds()
and ( tdiff < dt_duration.in_seconds()
tdiff # and False
<
dt_duration.in_seconds()
)
and
not has_closure_gap
): ):
log.error( end_dt: DateTime = from_timestamp(first)
log.warning(
f'Frame result was shorter then {dt_duration}!?\n' f'Frame result was shorter then {dt_duration}!?\n'
'Recursing for more bars:\n'
f'end_dt: {end_dt}\n' f'end_dt: {end_dt}\n'
f'dt_duration: {dt_duration}\n' f'dt_duration: {dt_duration}\n'
# f'\n'
# f'Recursing for more bars:\n'
) )
# XXX, debug! (
breakpoint() r_bars,
# XXX ? TODO? recursively try to re-request? r_arr,
# => i think *NO* right? r_duration,
# ) = await self.bars(
# ( fqme,
# r_bars, start_dt=start_dt,
# r_arr, end_dt=end_dt,
# r_duration, sample_period_s=sample_period_s,
# ) = await self.bars(
# fqme,
# start_dt=start_dt,
# end_dt=end_dt,
# sample_period_s=sample_period_s,
# # TODO: make a table for Duration to # TODO: make a table for Duration to
# # the ib str values in order to use this? # the ib str values in order to use this?
# # duration=duration, # duration=duration,
# ) )
# r_bars.extend(bars) r_bars.extend(bars)
# bars = r_bars bars = r_bars
nparr: np.ndarray = bars_to_np(bars) nparr: np.ndarray = bars_to_np(bars)
@ -874,16 +783,9 @@ class Client:
# crypto$ # crypto$
elif exch == 'PAXOS': # btc.paxos elif exch == 'PAXOS': # btc.paxos
con = Crypto( con = Crypto(
symbol=symbol.upper(), symbol=symbol,
currency='USD', currency=currency,
exchange='PAXOS',
) )
# XXX, on `ib_insync` when first tried this,
# > Error 10299, reqId 141: Expected what to show is
# > AGGTRADES, please use that instead of TRADES.,
# > contract: Crypto(conId=479624278, symbol='BTC',
# > exchange='PAXOS', currency='USD',
# > localSymbol='BTC.USD', tradingClass='BTC')
# stonks # stonks
else: else:
@ -1041,7 +943,6 @@ class Client:
) )
if tkr: if tkr:
break break
except TimeoutError as err: except TimeoutError as err:
timeouterr = err timeouterr = err
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
@ -1050,9 +951,7 @@ class Client:
else: else:
if not warnset: if not warnset:
log.warning( log.warning(
f'Quote req timed out..\n' f'Quote req timed out..maybe venue is closed?\n'
f'Maybe the venue is closed?\n'
f'\n'
f'{asdict(contract)}' f'{asdict(contract)}'
) )
warnset = True warnset = True
@ -1064,11 +963,9 @@ class Client:
) )
break break
else: else:
if ( if timeouterr and raise_on_timeout:
timeouterr import pdbp
and pdbp.set_trace()
raise_on_timeout
):
raise timeouterr raise timeouterr
if not warnset: if not warnset:
@ -1284,7 +1181,7 @@ async def load_aio_clients(
# the API TCP in `ib_insync` connection can be flaky af so instead # the API TCP in `ib_insync` connection can be flaky af so instead
# retry a few times to get the client going.. # retry a few times to get the client going..
connect_retries: int = 3, connect_retries: int = 3,
connect_timeout: float = 30, # in case a remote-host connect_timeout: float = 10,
disconnect_on_exit: bool = True, disconnect_on_exit: bool = True,
) -> dict[str, Client]: ) -> dict[str, Client]:
@ -1353,12 +1250,6 @@ async def load_aio_clients(
for i in range(connect_retries): for i in range(connect_retries):
try: try:
log.info(
'Trying `ib_async` connect\n'
f'{host}: {port}\n'
f'clientId: {client_id}\n'
f'timeout: {connect_timeout}\n'
)
await ib.connectAsync( await ib.connectAsync(
host, host,
port, port,
@ -1465,20 +1356,21 @@ async def load_aio_clients(
async def load_clients_for_trio( async def load_clients_for_trio(
chan: tractor.to_asyncio.LinkedTaskChannel, from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
) -> None: ) -> None:
''' '''
Pure async mngr proxy to ``load_aio_clients()``. Pure async mngr proxy to ``load_aio_clients()``.
This is a bootstrap entrypoint to call from This is a bootstrap entrypoing to call from
a `tractor.to_asyncio.open_channel_from()`. a ``tractor.to_asyncio.open_channel_from()``.
''' '''
async with load_aio_clients( async with load_aio_clients() as accts2clients:
disconnect_on_exit=False,
) as accts2clients: to_trio.send_nowait(accts2clients)
chan.started_nowait(accts2clients)
# TODO: maybe a sync event to wait on instead? # TODO: maybe a sync event to wait on instead?
await asyncio.sleep(float('inf')) await asyncio.sleep(float('inf'))
@ -1491,10 +1383,7 @@ async def open_client_proxies() -> tuple[
async with ( async with (
tractor.trionics.maybe_open_context( tractor.trionics.maybe_open_context(
acm_func=tractor.to_asyncio.open_channel_from, acm_func=tractor.to_asyncio.open_channel_from,
kwargs={ kwargs={'target': load_clients_for_trio},
'target': load_clients_for_trio,
# ^XXX, kwarg to `open_channel_from()`
},
# lock around current actor task access # lock around current actor task access
# TODO: maybe this should be the default in tractor? # TODO: maybe this should be the default in tractor?
@ -1625,22 +1514,23 @@ class MethodProxy:
async def open_aio_client_method_relay( async def open_aio_client_method_relay(
chan: tractor.to_asyncio.LinkedTaskChannel, from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
client: Client, client: Client,
event_consumers: dict[str, trio.Event], event_consumers: dict[str, trio.Event],
) -> None: ) -> None:
# sync with `open_client_proxy()` caller # sync with `open_client_proxy()` caller
chan.started_nowait(client) to_trio.send_nowait(client)
# TODO: separate channel for error handling? # TODO: separate channel for error handling?
client.inline_errors(chan) client.inline_errors(to_trio)
# relay all method requests to ``asyncio``-side client and deliver # relay all method requests to ``asyncio``-side client and deliver
# back results # back results
while not chan._to_trio._closed: # <- TODO, better check like `._web_bs`? while not to_trio._closed:
msg: tuple[str, dict]|dict|None = await chan.get() msg: tuple[str, dict] | dict | None = await from_trio.get()
match msg: match msg:
case None: # termination sentinel case None: # termination sentinel
log.info('asyncio `Client` method-proxy SHUTDOWN!') log.info('asyncio `Client` method-proxy SHUTDOWN!')
@ -1653,7 +1543,7 @@ async def open_aio_client_method_relay(
try: try:
resp = await meth(**kwargs) resp = await meth(**kwargs)
# echo the msg back # echo the msg back
chan.send_nowait({'result': resp}) to_trio.send_nowait({'result': resp})
except ( except (
RequestError, RequestError,
@ -1661,10 +1551,10 @@ async def open_aio_client_method_relay(
# TODO: relay all errors to trio? # TODO: relay all errors to trio?
# BaseException, # BaseException,
) as err: ) as err:
chan.send_nowait({'exception': err}) to_trio.send_nowait({'exception': err})
case {'error': content}: case {'error': content}:
chan.send_nowait({'exception': content}) to_trio.send_nowait({'exception': content})
case _: case _:
raise ValueError(f'Unhandled msg {msg}') raise ValueError(f'Unhandled msg {msg}')
@ -1686,8 +1576,7 @@ async def open_client_proxy(
event_consumers=event_table, event_consumers=event_table,
) as (first, chan), ) as (first, chan),
trionics.collapse_eg(), # loose-ify trio.open_nursery() as relay_n,
trio.open_nursery() as relay_tn,
): ):
assert isinstance(first, Client) assert isinstance(first, Client)
@ -1727,7 +1616,7 @@ async def open_client_proxy(
continue continue
relay_tn.start_soon(relay_events) relay_n.start_soon(relay_events)
yield proxy yield proxy

View File

@ -34,7 +34,6 @@ import trio
from trio_typing import TaskStatus from trio_typing import TaskStatus
import tractor import tractor
from tractor.to_asyncio import LinkedTaskChannel from tractor.to_asyncio import LinkedTaskChannel
from tractor import trionics
from ib_insync.contract import ( from ib_insync.contract import (
Contract, Contract,
) )
@ -50,10 +49,6 @@ from ib_insync.objects import (
) )
from piker import config from piker import config
from piker.log import (
get_logger,
get_console_log,
)
from piker.types import Struct from piker.types import Struct
from piker.accounting import ( from piker.accounting import (
Position, Position,
@ -81,6 +76,7 @@ from piker.clearing._messages import (
BrokerdFill, BrokerdFill,
BrokerdError, BrokerdError,
) )
from ._util import log
from .api import ( from .api import (
_accounts2clients, _accounts2clients,
get_config, get_config,
@ -98,10 +94,6 @@ from .ledger import (
update_ledger_from_api_trades, update_ledger_from_api_trades,
) )
log = get_logger(
name=__name__,
)
def pack_position( def pack_position(
pos: IbPosition, pos: IbPosition,
@ -124,11 +116,7 @@ def pack_position(
symbol=fqme, symbol=fqme,
currency=con.currency, currency=con.currency,
size=float(pos.position), size=float(pos.position),
avg_price=( avg_price=float(pos.avgCost) / float(con.multiplier or 1.0),
float(pos.avgCost)
/
float(con.multiplier or 1.0)
),
), ),
) )
@ -369,10 +357,6 @@ async def update_and_audit_pos_msg(
size=ibpos.position, size=ibpos.position,
avg_price=pikerpos.ppu, avg_price=pikerpos.ppu,
# XXX ensures matching even if multiple venue-names
# in `.bs_fqme`, likely from txn records..
bs_mktid=mkt.bs_mktid,
) )
ibfmtmsg: str = pformat(ibpos._asdict()) ibfmtmsg: str = pformat(ibpos._asdict())
@ -423,7 +407,7 @@ async def update_and_audit_pos_msg(
# TODO: make this a "propaganda" log level? # TODO: make this a "propaganda" log level?
if ibpos.avgCost != msg.avg_price: if ibpos.avgCost != msg.avg_price:
log.debug( log.warning(
f'IB "FIFO" avg price for {msg.symbol} is DIFF:\n' f'IB "FIFO" avg price for {msg.symbol} is DIFF:\n'
f'ib: {ibfmtmsg}\n' f'ib: {ibfmtmsg}\n'
'---------------------------\n' '---------------------------\n'
@ -441,8 +425,7 @@ async def aggr_open_orders(
) -> None: ) -> None:
''' '''
Collect all open orders from client and fill in `order_msgs: Collect all open orders from client and fill in `order_msgs: list`.
list`.
''' '''
trades: list[Trade] = client.ib.openTrades() trades: list[Trade] = client.ib.openTrades()
@ -543,15 +526,9 @@ class IbAcnt(Struct):
@tractor.context @tractor.context
async def open_trade_dialog( async def open_trade_dialog(
ctx: tractor.Context, ctx: tractor.Context,
loglevel: str = 'warning',
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
get_console_log(
level=loglevel,
name=__name__,
)
# task local msg dialog tracking # task local msg dialog tracking
flows = OrderDialogs() flows = OrderDialogs()
accounts_def = config.load_accounts(['ib']) accounts_def = config.load_accounts(['ib'])
@ -569,10 +546,7 @@ async def open_trade_dialog(
), ),
# TODO: do this as part of `open_account()`!? # TODO: do this as part of `open_account()`!?
open_symcache( open_symcache('ib', only_from_memcache=True) as symcache,
'ib',
only_from_memcache=True,
) as symcache,
): ):
# Open a trade ledgers stack for appending trade records over # Open a trade ledgers stack for appending trade records over
# multiple accounts. # multiple accounts.
@ -580,10 +554,8 @@ async def open_trade_dialog(
ledgers: dict[str, TransactionLedger] = {} ledgers: dict[str, TransactionLedger] = {}
tables: dict[str, Account] = {} tables: dict[str, Account] = {}
order_msgs: list[Status] = [] order_msgs: list[Status] = []
conf: dict = get_config() conf = get_config()
accounts_def_inv: bidict[str, str] = bidict( accounts_def_inv: bidict[str, str] = bidict(conf['accounts']).inverse
conf['accounts']
).inverse
with ( with (
ExitStack() as lstack, ExitStack() as lstack,
@ -733,11 +705,7 @@ async def open_trade_dialog(
# client-account and build out position msgs to deliver to # client-account and build out position msgs to deliver to
# EMS. # EMS.
for acctid, acnt in tables.items(): for acctid, acnt in tables.items():
active_pps: dict[str, Position] active_pps, closed_pps = acnt.dump_active()
(
active_pps,
closed_pps,
) = acnt.dump_active()
for pps in [active_pps, closed_pps]: for pps in [active_pps, closed_pps]:
piker_pps: list[Position] = list(pps.values()) piker_pps: list[Position] = list(pps.values())
@ -753,7 +721,6 @@ async def open_trade_dialog(
) )
if ibpos: if ibpos:
bs_mktid: str = str(ibpos.contract.conId) bs_mktid: str = str(ibpos.contract.conId)
msg = await update_and_audit_pos_msg( msg = await update_and_audit_pos_msg(
acctid, acctid,
pikerpos, pikerpos,
@ -771,7 +738,7 @@ async def open_trade_dialog(
f'UNEXPECTED POSITION says IB => {msg.symbol}\n' f'UNEXPECTED POSITION says IB => {msg.symbol}\n'
'Maybe they LIQUIDATED YOU or your ledger is wrong?\n' 'Maybe they LIQUIDATED YOU or your ledger is wrong?\n'
) )
log.debug(logmsg) log.error(logmsg)
await ctx.started(( await ctx.started((
all_positions, all_positions,
@ -780,22 +747,21 @@ async def open_trade_dialog(
async with ( async with (
ctx.open_stream() as ems_stream, ctx.open_stream() as ems_stream,
trionics.collapse_eg(), trio.open_nursery() as n,
trio.open_nursery() as tn,
): ):
# relay existing open orders to ems # relay existing open orders to ems
for msg in order_msgs: for msg in order_msgs:
await ems_stream.send(msg) await ems_stream.send(msg)
for client in set(aioclients.values()): for client in set(aioclients.values()):
trade_event_stream: LinkedTaskChannel = await tn.start( trade_event_stream: LinkedTaskChannel = await n.start(
open_trade_event_stream, open_trade_event_stream,
client, client,
) )
# start order request handler **before** local trades # start order request handler **before** local trades
# event loop # event loop
tn.start_soon( n.start_soon(
handle_order_requests, handle_order_requests,
ems_stream, ems_stream,
accounts_def, accounts_def,
@ -803,7 +769,7 @@ async def open_trade_dialog(
) )
# allocate event relay tasks for each client connection # allocate event relay tasks for each client connection
tn.start_soon( n.start_soon(
deliver_trade_events, deliver_trade_events,
trade_event_stream, trade_event_stream,
@ -1275,47 +1241,32 @@ async def deliver_trade_events(
# never relay errors for non-broker related issues # never relay errors for non-broker related issues
# https://interactivebrokers.github.io/tws-api/message_codes.html # https://interactivebrokers.github.io/tws-api/message_codes.html
code: int = err['error_code'] code: int = err['error_code']
reason: str = err['reason'] if code in {
reqid: str = str(err['reqid']) 200, # uhh
# "Warning:" msg codes,
# https://interactivebrokers.github.io/tws-api/message_codes.html#warning_codes
# - 2109: 'Outside Regular Trading Hours'
if 'Warning:' in reason:
log.warning(
f'Order-API-warning: {code!r}\n'
f'reqid: {reqid!r}\n'
f'\n'
f'{pformat(err)}\n'
# ^TODO? should we just print the `reason`
# not the full `err`-dict?
)
continue
# XXX known special (ignore) cases
elif code in {
200, # uhh.. ni idea
# hist pacing / connectivity # hist pacing / connectivity
162, 162,
165, 165,
# WARNING codes:
# https://interactivebrokers.github.io/tws-api/message_codes.html#warning_codes
# Attribute 'Outside Regular Trading Hours' is
# " 'ignored based on the order type and
# destination. PlaceOrder is now ' 'being
# processed.',
2109,
# XXX: lol this isn't even documented.. # XXX: lol this isn't even documented..
# 'No market data during competing live session' # 'No market data during competing live session'
1669, 1669,
}: }:
log.error(
f'Order-API-error which is non-cancel-causing ?!\n'
f'\n'
f'{pformat(err)}\n'
)
continue continue
reqid: str = str(err['reqid'])
reason: str = err['reason']
if err['reqid'] == -1: if err['reqid'] == -1:
log.error( log.error(f'TWS external order error:\n{pformat(err)}')
f'TWS external order error ??\n'
f'{pformat(err)}\n'
)
flow: dict = dict( flow: dict = dict(
flows.get(reqid) flows.get(reqid)

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # piker: trading gear for hackers
# Copyright (C) 2018-forever Tyler Goodlet (in stewardship for pikers) # Copyright (C) Tyler Goodlet (in stewardship for pikers)
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -13,12 +13,10 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Data feed endpoints pre-wrapped and ready for use with ``tractor``/``trio``.
''' """
Data feed endpoints pre-wrapped and ready for use with `tractor`/`trio`
via "infected-asyncio-mode".
'''
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from contextlib import ( from contextlib import (
@ -28,6 +26,7 @@ from dataclasses import asdict
from datetime import datetime from datetime import datetime
from functools import partial from functools import partial
from pprint import pformat from pprint import pformat
from math import isnan
import time import time
from typing import ( from typing import (
Any, Any,
@ -41,6 +40,7 @@ import numpy as np
from pendulum import ( from pendulum import (
now, now,
from_timestamp, from_timestamp,
# DateTime,
Duration, Duration,
duration as mk_duration, duration as mk_duration,
) )
@ -56,11 +56,11 @@ from piker.brokers._util import (
NoData, NoData,
DataUnavailable, DataUnavailable,
) )
from piker.log import get_logger
from .api import ( from .api import (
# _adhoc_futes_set, # _adhoc_futes_set,
Client, Client,
con2fqme, con2fqme,
log,
load_aio_clients, load_aio_clients,
MethodProxy, MethodProxy,
open_client_proxies, open_client_proxies,
@ -69,18 +69,12 @@ from .api import (
Contract, Contract,
RequestError, RequestError,
) )
from .venues import is_venue_open from ._util import data_reset_hack
from ._util import (
data_reset_hack,
)
from .symbols import get_mkt_info from .symbols import get_mkt_info
if TYPE_CHECKING: if TYPE_CHECKING:
from trio._core._run import Task from trio._core._run import Task
log = get_logger(
name=__name__,
)
# XXX NOTE: See available types table docs: # XXX NOTE: See available types table docs:
# https://interactivebrokers.github.io/tws-api/tick_types.html # https://interactivebrokers.github.io/tws-api/tick_types.html
@ -190,8 +184,7 @@ async def open_history_client(
if ( if (
start_dt start_dt
and and start_dt.timestamp() == 0
start_dt.timestamp() == 0
): ):
await tractor.pause() await tractor.pause()
@ -206,27 +199,22 @@ async def open_history_client(
latency = time.time() - query_start latency = time.time() - query_start
if ( if (
not timedout not timedout
# and # and latency <= max_timeout
# latency <= max_timeout
): ):
count += 1 count += 1
mean += latency / count mean += latency / count
log.debug( print(
f'HISTORY FRAME QUERY LATENCY: {latency}\n' f'HISTORY FRAME QUERY LATENCY: {latency}\n'
f'mean: {mean}' f'mean: {mean}'
) )
# could be trying to retreive bars over weekend # could be trying to retreive bars over weekend
if out is None: if out is None:
log.error( log.error(f"Can't grab bars starting at {end_dt}!?!?")
f"No bars starting at {end_dt!r} !?!?"
)
if ( if (
end_dt end_dt
and and head_dt
head_dt and end_dt <= head_dt
and
end_dt <= head_dt
): ):
raise DataUnavailable( raise DataUnavailable(
f'First timestamp is {head_dt}\n' f'First timestamp is {head_dt}\n'
@ -268,43 +256,12 @@ async def open_history_client(
vlm = bars_array['volume'] vlm = bars_array['volume']
vlm[vlm < 0] = 0 vlm[vlm < 0] = 0
# XXX, if a start-limit was passed ensure we only return bars_array, first_dt, last_dt
# return history that far back!
if (
start_dt
and
first_dt < start_dt
):
trimmed_bars = bars_array[
bars_array['time'] >= start_dt.timestamp()
]
if (
trimmed_first_dt := from_timestamp(trimmed_bars['time'][0])
!=
start_dt
):
# TODO! rm this once we're more confident it never hits!
# breakpoint()
raise RuntimeError(
f'OHLC-bars array start is gt `start_dt` limit !!\n'
f'start_dt: {start_dt}\n'
f'first_dt: {first_dt}\n'
f'trimmed_first_dt: {trimmed_first_dt}\n'
)
# XXX, overwrite with start_dt-limited frame
bars_array = trimmed_bars
return (
bars_array,
first_dt,
last_dt,
)
# TODO: it seems like we can do async queries for ohlc # TODO: it seems like we can do async queries for ohlc
# but getting the order right still isn't working and I'm not # but getting the order right still isn't working and I'm not
# quite sure why.. needs some tinkering and probably # quite sure why.. needs some tinkering and probably
# a lookthrough of the `ib_insync` machinery, for eg. maybe # a lookthrough of the ``ib_insync`` machinery, for eg. maybe
# we have to do the batch queries on the `asyncio` side? # we have to do the batch queries on the `asyncio` side?
yield ( yield (
get_hist, get_hist,
@ -328,9 +285,8 @@ _pacing: str = (
async def wait_on_data_reset( async def wait_on_data_reset(
proxy: MethodProxy, proxy: MethodProxy,
reset_type: str = 'data', reset_type: str = 'data',
timeout: float = 16, timeout: float = 16, # float('inf'),
task_status: TaskStatus[ task_status: TaskStatus[
tuple[ tuple[
@ -339,47 +295,29 @@ async def wait_on_data_reset(
] ]
] = trio.TASK_STATUS_IGNORED, ] = trio.TASK_STATUS_IGNORED,
) -> bool: ) -> bool:
'''
Wait on a (global-ish) "data-farm" event to be emitted
by the IB api server.
Allows syncing to reconnect event-messages emitted on the API # TODO: we might have to put a task lock around this
console, such as: # method..
hist_ev = proxy.status_event(
- 'HMDS data farm connection is OK:ushmds'
- 'Market data farm is connecting:usfuture'
- 'Market data farm connection is OK:usfuture'
Deliver a `(cs, done: Event)` pair to the caller to support it
waiting or cancelling the associated "data-reset-request";
normally a manual data-reset-req is expected to be the cause and
thus trigger such events (such as our click-hack-magic from
`.ib._util`).
'''
# ?TODO, do we need a task-lock around this method?
#
# register for an API "status event" wrapped for `trio`-sync.
hist_ev: trio.Event = proxy.status_event(
'HMDS data farm connection is OK:ushmds' 'HMDS data farm connection is OK:ushmds'
) )
#
# ^TODO: other event-messages we might want to support waiting-for # TODO: other event messages we might want to try and
# but i wasn't able to get reliable.. # wait for but i wasn't able to get any of this
# # reliable..
# reconnect_start = proxy.status_event( # reconnect_start = proxy.status_event(
# 'Market data farm is connecting:usfuture' # 'Market data farm is connecting:usfuture'
# ) # )
# live_ev = proxy.status_event( # live_ev = proxy.status_event(
# 'Market data farm connection is OK:usfuture' # 'Market data farm connection is OK:usfuture'
# ) # )
# try to wait on the reset event(s) to arrive, a timeout # try to wait on the reset event(s) to arrive, a timeout
# will trigger a retry up to 6 times (for now). # will trigger a retry up to 6 times (for now).
client: Client = proxy._aio_ns client: Client = proxy._aio_ns
done = trio.Event() done = trio.Event()
with trio.move_on_after(timeout) as cs: with trio.move_on_after(timeout) as cs:
task_status.started((cs, done)) task_status.started((cs, done))
log.warning( log.warning(
@ -427,13 +365,14 @@ _failed_resets: int = 0
async def get_bars( async def get_bars(
proxy: MethodProxy, proxy: MethodProxy,
fqme: str, fqme: str,
timeframe: int, timeframe: int,
# blank to start which tells ib to look up the latest datum # blank to start which tells ib to look up the latest datum
end_dt: datetime|None = None, end_dt: str = '',
start_dt: datetime|None = None, start_dt: str | None = '',
# TODO: make this more dynamic based on measured frame rx latency? # TODO: make this more dynamic based on measured frame rx latency?
# how long before we trigger a feed reset (seconds) # how long before we trigger a feed reset (seconds)
@ -457,9 +396,8 @@ async def get_bars(
bool, # timed out hint bool, # timed out hint
]: ]:
''' '''
Request-n-retrieve historical data frames from a `trio.Task` Retrieve historical data from a ``trio``-side task using
using a `MethoProxy` to query the `asyncio`-side's a ``MethoProxy``.
`.ib.api.Client` methods.
''' '''
global _data_resetter_task, _failed_resets global _data_resetter_task, _failed_resets
@ -487,9 +425,6 @@ async def get_bars(
dt_duration, dt_duration,
) = await proxy.bars( ) = await proxy.bars(
fqme=fqme, fqme=fqme,
# XXX TODO! LOL we're not using this and IB dun
# support it anyway..
# start_dt=start_dt,
end_dt=end_dt, end_dt=end_dt,
sample_period_s=timeframe, sample_period_s=timeframe,
@ -652,7 +587,7 @@ async def get_bars(
data_cs.cancel() data_cs.cancel()
# spawn new data reset task # spawn new data reset task
data_cs, reset_done = await tn.start( data_cs, reset_done = await nurse.start(
partial( partial(
wait_on_data_reset, wait_on_data_reset,
proxy, proxy,
@ -672,14 +607,11 @@ async def get_bars(
# such that simultaneous symbol queries don't try data resettingn # such that simultaneous symbol queries don't try data resettingn
# too fast.. # too fast..
unset_resetter: bool = False unset_resetter: bool = False
async with ( async with trio.open_nursery() as nurse:
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn
):
# start history request that we allow # start history request that we allow
# to run indefinitely until a result is acquired # to run indefinitely until a result is acquired
tn.start_soon(query) nurse.start_soon(query)
# start history reset loop which waits up to the timeout # start history reset loop which waits up to the timeout
# for a result before triggering a data feed reset. # for a result before triggering a data feed reset.
@ -699,7 +631,7 @@ async def get_bars(
unset_resetter: bool = True unset_resetter: bool = True
# spawn new data reset task # spawn new data reset task
data_cs, reset_done = await tn.start( data_cs, reset_done = await nurse.start(
partial( partial(
wait_on_data_reset, wait_on_data_reset,
proxy, proxy,
@ -721,12 +653,14 @@ async def get_bars(
) )
# per-actor cache of inter-eventloop-chans
_quote_streams: dict[str, trio.abc.ReceiveStream] = {} _quote_streams: dict[str, trio.abc.ReceiveStream] = {}
async def _setup_quote_stream( async def _setup_quote_stream(
chan: tractor.to_asyncio.LinkedTaskChannel,
from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
symbol: str, symbol: str,
opts: tuple[int] = ( opts: tuple[int] = (
'375', # RT trade volume (excludes utrades) '375', # RT trade volume (excludes utrades)
@ -744,13 +678,10 @@ async def _setup_quote_stream(
) -> trio.abc.ReceiveChannel: ) -> trio.abc.ReceiveChannel:
''' '''
Stream L1 quotes via the `Ticker.updateEvent.connect(push)` Stream a ticker using the std L1 api.
callback API by registering a `push` callback which simply
`chan.send_nowait()`s quote msgs back to the calling
parent-`trio.Task`-side.
NOTE, that this task-fn is run on the `asyncio.Task`-side ONLY This task is ``asyncio``-side and must be called from
and is thus run via `tractor.to_asyncio.open_channel_from()`. ``tractor.to_asyncio.open_channel_from()``.
''' '''
global _quote_streams global _quote_streams
@ -758,84 +689,37 @@ async def _setup_quote_stream(
async with load_aio_clients( async with load_aio_clients(
disconnect_on_exit=False, disconnect_on_exit=False,
) as accts2clients: ) as accts2clients:
caccount_name, client = get_preferred_data_client(accts2clients)
contract = contract or (await client.find_contract(symbol))
to_trio.send_nowait(contract) # cuz why not
ticker: Ticker = client.ib.reqMktData(contract, ','.join(opts))
# XXX since this is an `asyncio.Task`, we must use # NOTE: it's batch-wise and slow af but I guess could
# tractor.pause_from_sync() # be good for backchecking? Seems to be every 5s maybe?
(
_account_name,
client,
) = get_preferred_data_client(
accts2clients,
)
contract = (
contract
or
(await client.find_contract(symbol))
)
chan.started_nowait(contract) # cuz why not
ticker: Ticker = client.ib.reqMktData(
contract,
','.join(opts),
)
maybe_exc: BaseException|None = None
handler_tries: int = 0
aio_task: asyncio.Task = asyncio.current_task()
# ?TODO? this API is batch-wise and quite slow-af but,
# - seems to be 5s updates?
# - maybe we could use it for backchecking?
#
# ticker: Ticker = client.ib.reqTickByTickData( # ticker: Ticker = client.ib.reqTickByTickData(
# contract, 'Last', # contract, 'Last',
# ) # )
# define a very naive queue-pushing callback that relays # # define a simple queue push routine that streams quote packets
# quote-packets directly the calling (parent) `trio.Task`. # # to trio over the ``to_trio`` memory channel.
# Ensure on teardown we cancel the feed via their cancel API. # to_trio, from_aio = trio.open_memory_channel(2**8) # type: ignore
#
def teardown(): def teardown():
'''
Disconnect our `push`-er callback and cancel the data-feed
for `contract`.
'''
nonlocal maybe_exc
ticker.updateEvent.disconnect(push) ticker.updateEvent.disconnect(push)
report: str = f'Disconnected mkt-data for {symbol!r} due to ' log.error(f"Disconnected stream for `{symbol}`")
if maybe_exc is not None:
report += (
'error,\n'
f'{maybe_exc!r}\n'
)
log.error(report)
else:
report += (
'cancellation.\n'
)
log.cancel(report)
client.ib.cancelMktData(contract) client.ib.cancelMktData(contract)
# decouple broadcast mem chan # decouple broadcast mem chan
_quote_streams.pop(symbol, None) _quote_streams.pop(symbol, None)
def push( def push(t: Ticker) -> None:
t: Ticker, """
tries_before_raise: int = 6, Push quotes to trio task.
) -> None:
'''
Push quotes verbatim to parent-side `trio.Task`.
''' """
nonlocal maybe_exc, handler_tries # log.debug(t)
# log.debug(f'new IB quote: {t}\n')
try: try:
chan.send_nowait(t) to_trio.send_nowait(t)
# XXX TODO XXX replicate in `tractor` tests
# as per `CancelledError`-handler notes below!
# assert 0
except ( except (
trio.BrokenResourceError, trio.BrokenResourceError,
@ -850,107 +734,35 @@ async def _setup_quote_stream(
# resulting in tracebacks spammed to console.. # resulting in tracebacks spammed to console..
# Manually do the dereg ourselves. # Manually do the dereg ourselves.
teardown() teardown()
# for slow debugging purposes to avoid clobbering prompt
# with log msgs
except trio.WouldBlock: except trio.WouldBlock:
log.exception( # log.warning(
f'Asyncio->Trio `chan.send_nowait()` blocked !?\n' # f'channel is blocking symbol feed for {symbol}?'
f'\n' # f'\n{to_trio.statistics}'
f'{chan._to_trio.statistics()}\n' # )
) pass
# ?TODO, handle re-connection attempts?
except BaseException as _berr:
berr = _berr
if handler_tries >= tries_before_raise:
# breakpoint()
maybe_exc = _berr
# task.set_exception(berr)
aio_task.cancel(msg=berr.args)
raise berr
else:
handler_tries += 1
log.exception(
f'Failed to push ticker quote !?\n'
f'handler_tries={handler_tries!r}\n'
f'ticker: {t!r}\n'
f'\n'
f'{chan._to_trio.statistics()}\n'
f'\n'
f'CAUSE: {berr}\n'
)
# except trio.WouldBlock:
# # for slow debugging purposes to avoid clobbering prompt
# # with log msgs
# pass
ticker.updateEvent.connect(push) ticker.updateEvent.connect(push)
try: try:
await asyncio.sleep(float('inf')) await asyncio.sleep(float('inf'))
# XXX, for debug.. TODO? can we rm again?
#
# tractor.pause_from_sync()
# while True:
# await asyncio.sleep(1.6)
# if ticker.ticks:
# log.debug(
# f'ticker.ticks = \n'
# f'{ticker.ticks}\n'
# )
# else:
# log.warning(
# 'UHH no ticker.ticks ??'
# )
# XXX TODO XXX !?!?
# apparently **without this handler** and the subsequent
# re-raising of `maybe_exc from _taskc` cancelling the
# `aio_task` from the `push()`-callback will cause a very
# strange chain of exc raising that breaks alll sorts of
# downstream callers, tasks and remote-actor tasks!?
#
# -[ ] we need some lowlevel reproducting tests to replicate
# those worst-case scenarios in `tractor` core!!
# -[ ] likely we should factor-out the `tractor.to_asyncio`
# attempts at workarounds in `.translate_aio_errors()`
# for failed `asyncio.Task.set_exception()` to either
# call `aio_task.cancel()` and/or
# `aio_task._fut_waiter.set_exception()` to a re-useable
# toolset in something like a `.to_asyncio._utils`??
#
except asyncio.CancelledError as _taskc:
if maybe_exc is not None:
raise maybe_exc from _taskc
raise _taskc
except BaseException as _berr:
# stash any crash cause for reporting in `teardown()`
maybe_exc = _berr
raise _berr
finally: finally:
# always disconnect our `push()` and cancel the
# ib-"mkt-data-feed".
teardown() teardown()
# return from_aio
@acm @acm
async def open_aio_quote_stream( async def open_aio_quote_stream(
symbol: str, symbol: str,
contract: Contract | None = None, contract: Contract | None = None,
) -> ( ) -> trio.abc.ReceiveStream:
trio.abc.Channel| # iface
tractor.to_asyncio.LinkedTaskChannel # actually
):
'''
Open a real-time `Ticker` quote stream from an `asyncio.Task`
spawned via `tractor.to_asyncio.open_channel_from()`, deliver the
inter-event-loop channel to the `trio.Task` caller and cache it
globally for re-use.
'''
from tractor.trionics import broadcast_receiver from tractor.trionics import broadcast_receiver
global _quote_streams global _quote_streams
@ -966,7 +778,6 @@ async def open_aio_quote_stream(
yield from_aio yield from_aio
return return
from_aio: tractor.to_asyncio.LinkedTaskChannel
async with tractor.to_asyncio.open_channel_from( async with tractor.to_asyncio.open_channel_from(
_setup_quote_stream, _setup_quote_stream,
symbol=symbol, symbol=symbol,
@ -976,10 +787,6 @@ async def open_aio_quote_stream(
assert contract assert contract
# TODO? de-reg on teardown of last consumer task?
# -> why aren't we using `.trionics.maybe_open_context()`
# here again?? (we are in `open_client_proxies()` tho?)
#
# cache feed for later consumers # cache feed for later consumers
_quote_streams[symbol] = from_aio _quote_streams[symbol] = from_aio
@ -994,12 +801,7 @@ def normalize(
calc_price: bool = False calc_price: bool = False
) -> dict: ) -> dict:
'''
Translate `ib_async`'s `Ticker.ticks` values to a `piker`
normalized `dict` form for transmit to downstream `.data` layer
consumers.
'''
# check for special contract types # check for special contract types
con = ticker.contract con = ticker.contract
fqme, calc_price = con2fqme(con) fqme, calc_price = con2fqme(con)
@ -1018,7 +820,7 @@ def normalize(
tbt = ticker.tickByTicks tbt = ticker.tickByTicks
if tbt: if tbt:
log.info(f'tickbyticks:\n {ticker.tickByTicks}') print(f'tickbyticks:\n {ticker.tickByTicks}')
ticker.ticks = new_ticks ticker.ticks = new_ticks
@ -1054,39 +856,27 @@ def normalize(
return data return data
# ?TODO? feels like this task-fn could be factored to reduce some
# indentation levels?
# -[ ] the reconnect while loop on ib-gw "data farm connection.."s
# -[ ] everything embedded under the `async with aclosing(stream):`
# as the "meat" of the quote delivery once the connection is
# stable.
#
async def stream_quotes( async def stream_quotes(
send_chan: trio.abc.SendChannel, send_chan: trio.abc.SendChannel,
symbols: list[str], symbols: list[str],
feed_is_live: trio.Event, feed_is_live: trio.Event,
loglevel: str = None,
# TODO? we need to hook into the `ib_async` logger like
# we can with i3ipc from modden!
# loglevel: str|None = None,
# startup sync # startup sync
task_status: TaskStatus[tuple[dict, dict]] = trio.TASK_STATUS_IGNORED, task_status: TaskStatus[tuple[dict, dict]] = trio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
''' '''
Stream `symbols[0]` quotes back via `send_chan`. Stream symbol quotes.
The `feed_is_live: Event` is set to signal the caller that it can This is a ``trio`` callable routine meant to be invoked
begin processing msgs from the mem-chan. once the brokerd is up.
''' '''
# TODO: support multiple subscriptions # TODO: support multiple subscriptions
sym: str = symbols[0] sym = symbols[0]
log.info( log.info(f'request for real-time quotes: {sym}')
f'request for real-time quotes\n'
f'sym: {sym!r}\n'
)
init_msgs: list[FeedInit] = [] init_msgs: list[FeedInit] = []
@ -1095,25 +885,21 @@ async def stream_quotes(
details: ibis.ContractDetails details: ibis.ContractDetails
async with ( async with (
open_data_client() as proxy, open_data_client() as proxy,
# trio.open_nursery() as tn,
): ):
mkt, details = await get_mkt_info( mkt, details = await get_mkt_info(
sym, sym,
proxy=proxy, # passed to avoid implicit client load proxy=proxy, # passed to avoid implicit client load
) )
# is venue active rn?
venue_is_open: bool = is_venue_open(
con_deats=details,
)
init_msg = FeedInit(mkt_info=mkt) init_msg = FeedInit(mkt_info=mkt)
# NOTE, tell sampler (via config) to skip vlm summing for dst
# assets which provide no vlm data..
if mkt.dst.atype in { if mkt.dst.atype in {
'fiat', 'fiat',
'index', 'index',
'commodity', 'commodity',
}: }:
# tell sampler config that it shouldn't do vlm summing.
init_msg.shm_write_opts['sum_tick_vlm'] = False init_msg.shm_write_opts['sum_tick_vlm'] = False
init_msg.shm_write_opts['has_vlm'] = False init_msg.shm_write_opts['has_vlm'] = False
@ -1121,22 +907,12 @@ async def stream_quotes(
con: Contract = details.contract con: Contract = details.contract
first_ticker: Ticker | None = None first_ticker: Ticker | None = None
first_quote: dict[str, Any] = {} with trio.move_on_after(1):
timeout: float = 1.6
with trio.move_on_after(timeout) as quote_cs:
first_ticker: Ticker = await proxy.get_quote( first_ticker: Ticker = await proxy.get_quote(
contract=con, contract=con,
raise_on_timeout=False, raise_on_timeout=False,
) )
# XXX should never happen with this ep right?
# but if so then, more then likely mkt is closed?
if quote_cs.cancelled_caught:
log.warning(
f'First quote req timed out after {timeout!r}s'
)
if first_ticker: if first_ticker:
first_quote: dict = normalize(first_ticker) first_quote: dict = normalize(first_ticker)
@ -1148,42 +924,42 @@ async def stream_quotes(
f'{pformat(first_quote)}\n' f'{pformat(first_quote)}\n'
) )
# XXX NOTE: whenever we're "outside regular trading hours" # NOTE: it might be outside regular trading hours for
# (only relevant for assets coming from the "legacy markets" # assets with "standard venue operating hours" so we
# space) so we basically (from an API/runtime-operational # only "pretend the feed is live" when the dst asset
# perspective) "pretend the feed is live" even if it's # type is NOT within the NON-NORMAL-venue set: aka not
# actually closed. # commodities, forex or crypto currencies which CAN
# # always return a NaN on a snap quote request during
# IOW, we signal to the effective caller (task) that the live # normal venue hours. In the case of a closed venue
# feed is "already up" but really we're just indicating that # (equitiies, futes, bonds etc.) we at least try to
# the OHLCV history can start being loaded immediately by the # grab the OHLC history.
# `piker.data`/`.tsp` layers. if (
# first_ticker
# XXX, deats: the "pretend we're live" is just done by and
# a `feed_is_live.set()` even though nothing is actually live isnan(first_ticker.last)
# Bp # SO, if the last quote price value is NaN we ONLY
if not venue_is_open: # "pretend to do" `feed_is_live.set()` if it's a known
log.warning( # dst asset venue with a lot of closed operating hours.
f'Venue is closed, unable to establish real-time feed.\n' and mkt.dst.atype not in {
f'mkt: {mkt!r}\n' 'commodity',
f'\n' 'fiat',
f'first_ticker: {first_ticker}\n' 'crypto',
) }
):
task_status.started(( task_status.started((
init_msgs, init_msgs,
first_quote, first_quote,
)) ))
# block and let data history backfill code run. # it's not really live but this will unblock
# XXX obvi given the venue is closed, we never expect feed # the brokerd feed task to tell the ui to update?
# to come up; a taskc should be the only way to feed_is_live.set()
# terminate this task.
await trio.sleep_forever()
#
# ^^XXX^^TODO! INSTEAD impl a `trio.sleep()` for the
# duration until the venue opens!!
# ?TODO, we could instead spawn a task that waits on a feed # block and let data history backfill code run.
await trio.sleep_forever()
return # we never expect feed to come up?
# TODO: we should instead spawn a task that waits on a feed
# to start and let it wait indefinitely..instead of this # to start and let it wait indefinitely..instead of this
# hard coded stuff. # hard coded stuff.
# async def wait_for_first_quote(): # async def wait_for_first_quote():
@ -1205,30 +981,23 @@ async def stream_quotes(
'Rxed init quote:\n' 'Rxed init quote:\n'
f'{pformat(first_quote)}' f'{pformat(first_quote)}'
) )
# signal `.data.feed` layer that mkt quotes are LIVE
feed_is_live.set()
cs: trio.CancelScope | None = None cs: trio.CancelScope | None = None
startup: bool = True startup: bool = True
iter_quotes: trio.abc.Channel
while ( while (
startup startup
or or cs.cancel_called
cs.cancel_called
): ):
with trio.CancelScope() as cs: with trio.CancelScope() as cs:
async with ( async with (
tractor.trionics.collapse_eg(), trio.open_nursery() as nurse,
trio.open_nursery() as tn,
open_aio_quote_stream( open_aio_quote_stream(
symbol=sym, symbol=sym,
contract=con, contract=con,
) as iter_quotes, ) as stream,
): ):
# ?TODO? can we rm this - particularly for `ib_async`?
# ugh, clear ticks since we've consumed them # ugh, clear ticks since we've consumed them
# (ahem, ib_insync is stateful trash) # (ahem, ib_insync is stateful trash)
# first_ticker.ticks = [] first_ticker.ticks = []
# only on first entry at feed boot up # only on first entry at feed boot up
if startup: if startup:
@ -1242,8 +1011,8 @@ async def stream_quotes(
# data feed event. # data feed event.
async def reset_on_feed(): async def reset_on_feed():
# ??TODO? this seems to be surpressed from the # TODO: this seems to be surpressed from the
# traceback in `tractor`? # traceback in ``tractor``?
# assert 0 # assert 0
rt_ev = proxy.status_event( rt_ev = proxy.status_event(
@ -1252,28 +1021,60 @@ async def stream_quotes(
await rt_ev.wait() await rt_ev.wait()
cs.cancel() # cancel called should now be set cs.cancel() # cancel called should now be set
tn.start_soon(reset_on_feed) nurse.start_soon(reset_on_feed)
async with aclosing(stream):
# if syminfo.get('no_vlm', False):
if not init_msg.shm_write_opts['has_vlm']:
# generally speaking these feeds don't
# include vlm data.
atype: str = mkt.dst.atype
log.info(
f'No-vlm {mkt.fqme}@{atype}, skipping quote poll'
)
else:
# wait for real volume on feed (trading might be
# closed)
while True:
ticker = await stream.receive()
# for a real volume contract we rait for
# the first "real" trade to take place
if (
# not calc_price
# and not ticker.rtTime
not ticker.rtTime
):
# spin consuming tickers until we
# get a real market datum
log.debug(f"New unsent ticker: {ticker}")
continue
else:
log.debug("Received first volume tick")
# ugh, clear ticks since we've
# consumed them (ahem, ib_insync is
# truly stateful trash)
ticker.ticks = []
# XXX: this works because we don't use
# ``aclosing()`` above?
break
async with aclosing(iter_quotes):
# tell data-layer spawner-caller that live
# quotes are now active desptie not having
# necessarily received a first vlm/clearing
# tick.
ticker = await iter_quotes.receive()
quote = normalize(ticker) quote = normalize(ticker)
fqme: str = quote['fqme'] log.debug(f"First ticker received {quote}")
await send_chan.send({fqme: quote})
# tell data-layer spawner-caller that live
# quotes are now streaming.
feed_is_live.set()
# last = time.time() # last = time.time()
async for ticker in iter_quotes: async for ticker in stream:
quote = normalize(ticker) quote = normalize(ticker)
fqme: str = quote['fqme'] fqme = quote['fqme']
log.debug(
f'Sending quote\n'
f'{quote}'
)
await send_chan.send({fqme: quote}) await send_chan.send({fqme: quote})
# ugh, clear ticks since we've consumed them # ugh, clear ticks since we've consumed them
# ticker.ticks = [] ticker.ticks = []
# last = time.time() # last = time.time()

View File

@ -44,7 +44,6 @@ from ib_insync import (
CommissionReport, CommissionReport,
) )
from piker.log import get_logger
from piker.types import Struct from piker.types import Struct
from piker.data import ( from piker.data import (
SymbologyCache, SymbologyCache,
@ -58,6 +57,7 @@ from piker.accounting import (
iter_by_dt, iter_by_dt,
) )
from ._flex_reports import parse_flex_dt from ._flex_reports import parse_flex_dt
from ._util import log
if TYPE_CHECKING: if TYPE_CHECKING:
from .api import ( from .api import (
@ -65,9 +65,6 @@ if TYPE_CHECKING:
MethodProxy, MethodProxy,
) )
log = get_logger(
name=__name__,
)
tx_sort: Callable = partial( tx_sort: Callable = partial(
iter_by_dt, iter_by_dt,

View File

@ -42,7 +42,10 @@ from piker.accounting import (
from piker._cacheables import ( from piker._cacheables import (
async_lifo_cache, async_lifo_cache,
) )
from piker.log import get_logger
from ._util import (
log,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .api import ( from .api import (
@ -50,10 +53,6 @@ if TYPE_CHECKING:
Client, Client,
) )
log = get_logger(
name=__name__,
)
_futes_venues = ( _futes_venues = (
'GLOBEX', 'GLOBEX',
'NYMEX', 'NYMEX',
@ -489,6 +488,7 @@ def con2fqme(
@async_lifo_cache() @async_lifo_cache()
async def get_mkt_info( async def get_mkt_info(
fqme: str, fqme: str,
proxy: MethodProxy | None = None, proxy: MethodProxy | None = None,
) -> tuple[MktPair, ibis.ContractDetails]: ) -> tuple[MktPair, ibis.ContractDetails]:
@ -550,7 +550,7 @@ async def get_mkt_info(
size_tick: Decimal = Decimal( size_tick: Decimal = Decimal(
str(details.minSize).rstrip('0') str(details.minSize).rstrip('0')
) )
# ?TODO, there is also the Contract.sizeIncrement, bt wtf is it? # |-> TODO: there is also the Contract.sizeIncrement, bt wtf is it?
# NOTE: this is duplicate from the .broker.norm_trade_records() # NOTE: this is duplicate from the .broker.norm_trade_records()
# routine, we should factor all this parsing somewhere.. # routine, we should factor all this parsing somewhere..

View File

@ -1,312 +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/>.
'''
(Multi-)venue mgmt helpers.
IB generally supports all "legacy" trading venues, those mostly owned
by ICE and friends.
'''
from __future__ import annotations
from datetime import ( # noqa
datetime,
date,
tzinfo as TzInfo,
)
from typing import (
Iterator,
TYPE_CHECKING,
)
import exchange_calendars as xcals
from pendulum import (
now,
Duration,
Interval,
Time,
)
if TYPE_CHECKING:
from ib_insync import (
TradingSession,
ContractDetails,
)
from exchange_calendars.exchange_calendars import (
ExchangeCalendar,
)
from pandas import (
# DatetimeIndex,
TimeDelta,
Timestamp,
)
def has_weekend(
period: Interval,
) -> bool:
'''
Predicate to for a period being within
days 6->0 (sat->sun).
'''
has_weekend: bool = False
for dt in period:
if dt.day_of_week in [0, 6]: # 0=Sunday, 6=Saturday
has_weekend = True
break
return has_weekend
def has_holiday(
con_deats: ContractDetails,
period: Interval,
) -> bool:
'''
Using the `exchange_calendars` lib detect if a time-gap `period`
is contained in a known "cash hours" closure.
'''
tz: str = con_deats.timeZoneId
exch: str = con_deats.contract.primaryExchange
cal: ExchangeCalendar = xcals.get_calendar(exch)
end: datetime = period.end
# _start: datetime = period.start
# ?TODO, can rm ya?
# => not that useful?
# dti: DatetimeIndex = cal.sessions_in_range(
# _start.date(),
# end.date(),
# )
prev_close: Timestamp = cal.previous_close(
end.date()
).tz_convert(tz)
prev_open: Timestamp = cal.previous_open(
end.date()
).tz_convert(tz)
# now do relative from prev_ values ^
# to get the next open which should match
# "contain" the end of the gap.
next_open: Timestamp = cal.next_open(
prev_open,
).tz_convert(tz)
next_open: Timestamp = cal.next_open(
prev_open,
).tz_convert(tz)
_next_close: Timestamp = cal.next_close(
prev_close
).tz_convert(tz)
cash_gap: TimeDelta = next_open - prev_close
is_holiday_gap = (
cash_gap
>
period
)
# XXX, debug
# breakpoint()
return is_holiday_gap
def is_current_time_in_range(
sesh: Interval,
when: datetime|None = None,
) -> bool:
'''
Check if current time is within the datetime range.
Use any/the-same timezone as provided by `start_dt.tzinfo` value
in the range.
'''
when: datetime = when or now()
return when in sesh
def iter_sessions(
con_deats: ContractDetails,
) -> Iterator[Interval]:
'''
Yield `pendulum.Interval`s for all
`ibas.ContractDetails.tradingSessions() -> TradingSession`s.
'''
sesh: TradingSession
for sesh in con_deats.tradingSessions():
yield Interval(*sesh)
def sesh_times(
con_deats: ContractDetails,
) -> tuple[Time, Time]:
'''
Based on the earliest trading session provided by the IB API,
get the (day-agnostic) times for the start/end.
'''
earliest_sesh: Interval = next(iter_sessions(con_deats))
return (
earliest_sesh.start.time(),
earliest_sesh.end.time(),
)
# ^?TODO, use `.diff()` to get point-in-time-agnostic period?
# https://pendulum.eustace.io/docs/#difference
def is_venue_open(
con_deats: ContractDetails,
when: datetime|Duration|None = None,
) -> bool:
'''
Check if market-venue is open during `when`, which defaults to
"now".
'''
sesh: Interval
for sesh in iter_sessions(con_deats):
if is_current_time_in_range(
sesh=sesh,
when=when,
):
return True
return False
def is_venue_closure(
gap: Interval,
con_deats: ContractDetails,
time_step_s: int,
) -> bool:
'''
Check if a provided time-`gap` is just an (expected) trading
venue closure period.
'''
open: Time
close: Time
open, close = sesh_times(con_deats)
# ensure times are in mkt-native timezone
tz: str = con_deats.timeZoneId
start = gap.start.in_tz(tz)
start_t = start.time()
end = gap.end.in_tz(tz)
end_t = end.time()
if (
(
start_t in (
close,
close.subtract(seconds=time_step_s)
)
and
end_t in (
open,
open.add(seconds=time_step_s),
)
)
or
has_weekend(gap)
or
has_holiday(
con_deats=con_deats,
period=gap,
)
):
return True
# breakpoint()
return False
# TODO, put this into `._util` and call it from here!
#
# NOTE, this was generated by @guille from a gpt5 prompt
# and was originally thot to be needed before learning about
# `ib_insync.contract.ContractDetails._parseSessions()` and
# it's downstream meths..
#
# This is still likely useful to keep for now to parse the
# `.tradingHours: str` value manually if we ever decide
# to move off `ib_async` and implement our own `trio`/`anyio`
# based version Bp
#
# >attempt to parse the retarted ib "time stampy thing" they
# >do for "venue hours" with this.. written by
# >gpt5-"thinking",
#
def parse_trading_hours(
spec: str,
tz: TzInfo|None = None
) -> dict[
date,
tuple[datetime, datetime]
]|None:
'''
Parse venue hours like:
'YYYYMMDD:HHMM-YYYYMMDD:HHMM;YYYYMMDD:CLOSED;...'
Returns `dict[date] = (open_dt, close_dt)` or `None` if
closed.
'''
if (
not isinstance(spec, str)
or
not spec
):
raise ValueError('spec must be a non-empty string')
out: dict[
date,
tuple[datetime, datetime]
]|None = {}
for part in (p.strip() for p in spec.split(';') if p.strip()):
if part.endswith(':CLOSED'):
day_s, _ = part.split(':', 1)
d = datetime.strptime(day_s, '%Y%m%d').date()
out[d] = None
continue
try:
start_s, end_s = part.split('-', 1)
start_dt = datetime.strptime(start_s, '%Y%m%d:%H%M')
end_dt = datetime.strptime(end_s, '%Y%m%d:%H%M')
except ValueError as exc:
raise ValueError(f'invalid segment: {part}') from exc
if tz is not None:
start_dt = start_dt.replace(tzinfo=tz)
end_dt = end_dt.replace(tzinfo=tz)
out[start_dt.date()] = (start_dt, end_dt)
return out
# ORIG desired usage,
#
# TODO, for non-drunk tomorrow,
# - call above fn and check that `output[today] is not None`
# trading_hrs: dict = parse_trading_hours(
# details.tradingHours
# )
# liq_hrs: dict = parse_trading_hours(
# details.liquidHours
# )

View File

@ -34,7 +34,6 @@ import urllib.parse
import hashlib import hashlib
import hmac import hmac
import base64 import base64
import tractor
import trio import trio
from piker import config from piker import config
@ -373,7 +372,8 @@ class Client:
# 1658347714, 'status': 'Success'}]} # 1658347714, 'status': 'Success'}]}
if xfers: if xfers:
await tractor.pause() import tractor
await tractor.pp()
trans: dict[str, Transaction] = {} trans: dict[str, Transaction] = {}
for entry in xfers: for entry in xfers:
@ -501,7 +501,6 @@ class Client:
for xkey, data in resp['result'].items(): for xkey, data in resp['result'].items():
# 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:
pair = Pair(xname=xkey, **data) pair = Pair(xname=xkey, **data)
# register the above `Pair` structs for all # register the above `Pair` structs for all

View File

@ -62,12 +62,9 @@ from piker.clearing._messages import (
from piker.brokers import ( from piker.brokers import (
open_cached_client, open_cached_client,
) )
from piker.log import (
get_console_log,
get_logger,
)
from piker.data import open_symcache from piker.data import open_symcache
from .api import ( from .api import (
log,
Client, Client,
BrokerError, BrokerError,
) )
@ -81,8 +78,6 @@ from .ledger import (
verify_balances, verify_balances,
) )
log = get_logger(name=__name__)
MsgUnion = Union[ MsgUnion = Union[
BrokerdCancel, BrokerdCancel,
BrokerdError, BrokerdError,
@ -180,8 +175,9 @@ async def handle_order_requests(
case { case {
'account': 'kraken.spot' as account, 'account': 'kraken.spot' as account,
'action': 'buy'|'sell', 'action': action,
}: } if action in {'buy', 'sell'}:
# validate # validate
order = BrokerdOrder(**msg) order = BrokerdOrder(**msg)
@ -266,12 +262,6 @@ async def handle_order_requests(
} | extra } | extra
log.info(f'Submitting WS order request:\n{pformat(req)}') log.info(f'Submitting WS order request:\n{pformat(req)}')
# NOTE HOWTO, debug order requests
#
# if 'XRP' in pair:
# await tractor.pause()
await ws.send_msg(req) await ws.send_msg(req)
# placehold for sanity checking in relay loop # placehold for sanity checking in relay loop
@ -436,15 +426,9 @@ def trades2pps(
@tractor.context @tractor.context
async def open_trade_dialog( async def open_trade_dialog(
ctx: tractor.Context, ctx: tractor.Context,
loglevel: str = 'warning',
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
get_console_log(
level=loglevel,
name=__name__,
)
async with ( async with (
# TODO: maybe bind these together and deliver # TODO: maybe bind these together and deliver
# a tuple from `.open_cached_client()`? # a tuple from `.open_cached_client()`?
@ -560,7 +544,7 @@ async def open_trade_dialog(
# to be reloaded. # to be reloaded.
balances: dict[str, float] = await client.get_balances() balances: dict[str, float] = await client.get_balances()
await verify_balances( verify_balances(
acnt, acnt,
src_fiat, src_fiat,
balances, balances,
@ -1101,8 +1085,6 @@ async def handle_order_updates(
f'Failed to {action} order {reqid}:\n' f'Failed to {action} order {reqid}:\n'
f'{errmsg}' f'{errmsg}'
) )
# if tractor._state.debug_mode():
# await tractor.pause()
symbol: str = 'N/A' symbol: str = 'N/A'
if chain := apiflows.get(reqid): if chain := apiflows.get(reqid):

View File

@ -21,6 +21,7 @@ Symbology defs and search.
from decimal import Decimal from decimal import Decimal
import tractor import tractor
from rapidfuzz import process as fuzzy
from piker._cacheables import ( from piker._cacheables import (
async_lifo_cache, async_lifo_cache,
@ -40,13 +41,8 @@ from piker.accounting._mktinfo import (
) )
# https://www.kraken.com/features/api#get-tradable-pairs
class Pair(Struct): class Pair(Struct):
'''
A tradable asset pair as schema-defined by,
https://docs.kraken.com/api/docs/rest-api/get-tradable-asset-pairs
'''
xname: str # idiotic bs_mktid equiv i guess? xname: str # idiotic bs_mktid equiv i guess?
altname: str # alternate pair name altname: str # alternate pair name
wsname: str # WebSocket pair name (if available) wsname: str # WebSocket pair name (if available)
@ -57,6 +53,7 @@ class Pair(Struct):
lot: str # volume lot size lot: str # volume lot size
cost_decimals: int cost_decimals: int
costmin: float
pair_decimals: int # scaling decimal places for pair pair_decimals: int # scaling decimal places for pair
lot_decimals: int # scaling decimal places for volume lot_decimals: int # scaling decimal places for volume
@ -82,7 +79,6 @@ class Pair(Struct):
tick_size: float # min price step size tick_size: float # min price step size
status: str status: str
costmin: str|None = None # XXX, only some mktpairs?
short_position_limit: float = 0 short_position_limit: float = 0
long_position_limit: float = float('inf') long_position_limit: float = float('inf')

View File

@ -62,7 +62,7 @@ from piker._cacheables import (
) )
from piker.log import get_logger from piker.log import get_logger
from piker.data.validate import FeedInit from piker.data.validate import FeedInit
from piker.types import Struct # NOTE, this is already a `tractor.msg.Struct` from piker.types import Struct
from piker.data import ( from piker.data import (
def_iohlcv_fields, def_iohlcv_fields,
match_from_pairs, match_from_pairs,
@ -98,18 +98,9 @@ class KucoinMktPair(Struct, frozen=True):
def size_tick(self) -> Decimal: def size_tick(self) -> Decimal:
return Decimal(str(self.quoteMinSize)) return Decimal(str(self.quoteMinSize))
callauctionFirstStageStartTime: None|float
callauctionIsEnabled: bool
callauctionPriceCeiling: float|None
callauctionPriceFloor: float|None
callauctionSecondStageStartTime: float|None
callauctionThirdStageStartTime: float|None
enableTrading: bool enableTrading: bool
feeCategory: int
feeCurrency: str feeCurrency: str
isMarginEnabled: bool isMarginEnabled: bool
makerFeeCoefficient: float
market: str market: str
minFunds: float minFunds: float
name: str name: str
@ -119,10 +110,11 @@ class KucoinMktPair(Struct, frozen=True):
quoteIncrement: float quoteIncrement: float
quoteMaxSize: float quoteMaxSize: float
quoteMinSize: float quoteMinSize: float
st: bool
symbol: str # our bs_mktid, kucoin's internal id symbol: str # our bs_mktid, kucoin's internal id
feeCategory: int
makerFeeCoefficient: float
takerFeeCoefficient: float takerFeeCoefficient: float
tradingStartTime: float|None st: bool
class AccountTrade(Struct, frozen=True): class AccountTrade(Struct, frozen=True):
@ -404,13 +396,7 @@ class Client:
pairs: dict[str, KucoinMktPair] = {} pairs: dict[str, KucoinMktPair] = {}
fqmes2mktids: bidict[str, str] = bidict() fqmes2mktids: bidict[str, str] = bidict()
for item in entries: for item in entries:
try:
pair = pairs[item['name']] = KucoinMktPair(**item) pair = pairs[item['name']] = KucoinMktPair(**item)
except TypeError as te:
raise TypeError(
'`KucoinMktPair` and reponse fields do not match ??\n'
f'{KucoinMktPair.fields_diff(item)}\n'
) from te
fqmes2mktids[ fqmes2mktids[
item['name'].lower().replace('-', '') item['name'].lower().replace('-', '')
] = pair.name ] = pair.name

View File

@ -37,12 +37,6 @@ import tractor
from async_generator import asynccontextmanager from async_generator import asynccontextmanager
import numpy as np import numpy as np
import wrapt import wrapt
# TODO, port to `httpx`/`trio-websocket` whenver i get back to
# writing a proper ws-api streamer for this backend (since the data
# feeds are free now) as per GH feat-req:
# https://github.com/pikers/piker/issues/509
#
import asks import asks
from ..calc import humanize, percent_change from ..calc import humanize, percent_change
@ -50,19 +44,13 @@ from . import open_cached_client
from piker._cacheables import async_lifo_cache from piker._cacheables import async_lifo_cache
from .. import config from .. import config
from ._util import resproc, BrokerError, SymbolNotFound from ._util import resproc, BrokerError, SymbolNotFound
from piker.log import ( from ..log import (
colorize_json, colorize_json,
)
from ._util import (
log,
get_console_log, get_console_log,
) )
from piker.log import (
get_logger,
)
log = get_logger(
name=__name__,
)
_use_practice_account = False _use_practice_account = False
_refresh_token_ep = 'https://{}login.questrade.com/oauth2/' _refresh_token_ep = 'https://{}login.questrade.com/oauth2/'
@ -1211,10 +1199,7 @@ async def stream_quotes(
# feed_type: str = 'stock', # feed_type: str = 'stock',
) -> AsyncGenerator[str, Dict[str, Any]]: ) -> AsyncGenerator[str, Dict[str, Any]]:
# XXX: required to propagate ``tractor`` loglevel to piker logging # XXX: required to propagate ``tractor`` loglevel to piker logging
get_console_log( get_console_log(loglevel)
level=loglevel,
name=__name__,
)
async with open_cached_client('questrade') as client: async with open_cached_client('questrade') as client:
if feed_type == 'stock': if feed_type == 'stock':

View File

@ -30,16 +30,9 @@ import asks
from ._util import ( from ._util import (
resproc, resproc,
BrokerError, BrokerError,
log,
) )
from piker.calc import percent_change from ..calc import percent_change
from piker.log import (
get_logger,
)
log = get_logger(
name=__name__,
)
_service_ep = 'https://api.robinhood.com' _service_ep = 'https://api.robinhood.com'

View File

@ -1,49 +0,0 @@
piker.clearing
______________
trade execution-n-control subsys for both live and paper trading as
well as algo-trading manual override/interaction across any backend
broker and data provider.
avail UIs
*********
order ctl
---------
the `piker.clearing` subsys is exposed mainly though
the `piker chart` GUI as a "chart trader" style UX and
is automatically enabled whenever a chart is opened.
.. ^TODO, more prose here!
the "manual" order control features are exposed via the
`piker.ui.order_mode` API and can pretty much always be
used (at least) in simulated-trading mode, aka "paper"-mode, and
the micro-manual is as follows:
``order_mode`` (
edge triggered activation by any of the following keys,
``mouse-click`` on y-level to submit at that price
):
- ``f``/ ``ctl-f`` to stage buy
- ``d``/ ``ctl-d`` to stage sell
- ``a`` to stage alert
``search_mode`` (
``ctl-l`` or ``ctl-space`` to open,
``ctl-c`` or ``ctl-space`` to close
) :
- begin typing to have symbol search automatically lookup
symbols from all loaded backend (broker) providers
- arrow keys and mouse click to navigate selection
- vi-like ``ctl-[hjkl]`` for navigation
position (pp) mgmt
------------------
you can also configure your position allocation limits from the
sidepane.
.. ^TODO, explain and provide tut once more refined!

View File

@ -25,10 +25,7 @@ from typing import TYPE_CHECKING
import trio import trio
import tractor import tractor
from tractor.trionics import ( from tractor.trionics import broadcast_receiver
broadcast_receiver,
collapse_eg,
)
from ._util import ( from ._util import (
log, # sub-sys logger log, # sub-sys logger
@ -171,6 +168,7 @@ class OrderClient(Struct):
async def relay_orders_from_sync_code( async def relay_orders_from_sync_code(
client: OrderClient, client: OrderClient,
symbol_key: str, symbol_key: str,
to_ems_stream: tractor.MsgStream, to_ems_stream: tractor.MsgStream,
@ -215,7 +213,7 @@ async def relay_orders_from_sync_code(
async def open_ems( async def open_ems(
fqme: str, fqme: str,
mode: str = 'live', mode: str = 'live',
loglevel: str = 'warning', loglevel: str = 'error',
) -> tuple[ ) -> tuple[
OrderClient, # client OrderClient, # client
@ -244,11 +242,6 @@ async def open_ems(
async with maybe_open_emsd( async with maybe_open_emsd(
broker, broker,
# XXX NOTE, LOL so this determines the daemon `emsd` loglevel
# then FYI.. that's kinda wrong no?
# -[ ] shouldn't it be set by `pikerd -l` or no?
# -[ ] would make a lot more sense to have a subsys ctl for
# levels.. like `-l emsd.info` or something?
loglevel=loglevel, loglevel=loglevel,
) as portal: ) as portal:
@ -288,11 +281,8 @@ async def open_ems(
client._ems_stream = trades_stream client._ems_stream = trades_stream
# start sync code order msg delivery task # start sync code order msg delivery task
async with ( async with trio.open_nursery() as n:
collapse_eg(), n.start_soon(
trio.open_nursery() as tn,
):
tn.start_soon(
relay_orders_from_sync_code, relay_orders_from_sync_code,
client, client,
fqme, fqme,
@ -308,4 +298,4 @@ async def open_ems(
) )
# stop the sync-msg-relay task on exit. # stop the sync-msg-relay task on exit.
tn.cancel_scope.cancel() n.cancel_scope.cancel()

View File

@ -42,12 +42,10 @@ from bidict import bidict
import trio import trio
from trio_typing import TaskStatus from trio_typing import TaskStatus
import tractor import tractor
from tractor import trionics
from ._util import ( from ._util import (
log, # sub-sys logger log, # sub-sys logger
get_console_log, get_console_log,
subsys,
) )
from ..accounting._mktinfo import ( from ..accounting._mktinfo import (
unpack_fqme, unpack_fqme,
@ -78,6 +76,7 @@ if TYPE_CHECKING:
# TODO: numba all of this # TODO: numba all of this
def mk_check( def mk_check(
trigger_price: float, trigger_price: float,
known_last: float, known_last: float,
action: str, action: str,
@ -163,7 +162,7 @@ async def clear_dark_triggers(
router: Router, router: Router,
brokerd_orders_stream: tractor.MsgStream, brokerd_orders_stream: tractor.MsgStream,
quote_stream: tractor.MsgStream, quote_stream: tractor.ReceiveMsgStream, # noqa
broker: str, broker: str,
fqme: str, fqme: str,
@ -179,7 +178,6 @@ async def clear_dark_triggers(
''' '''
# XXX: optimize this for speed! # XXX: optimize this for speed!
# TODO: # TODO:
# - port to the new ringbuf stuff in `tractor.ipc`!
# - numba all this! # - numba all this!
# - this stream may eventually contain multiple symbols # - this stream may eventually contain multiple symbols
quote_stream._raise_on_lag = False quote_stream._raise_on_lag = False
@ -352,21 +350,9 @@ async def open_brokerd_dialog(
broker backend, configuration, or client code usage. broker backend, configuration, or client code usage.
''' '''
get_console_log(
level=loglevel,
name='clearing',
)
# enable `.accounting` console since normally used by
# each `brokerd`.
get_console_log(
level=loglevel,
name='piker.accounting',
)
broker: str = brokermod.name broker: str = brokermod.name
def mk_paper_ep( def mk_paper_ep():
loglevel: str,
):
from . import _paper_engine as paper_mod from . import _paper_engine as paper_mod
nonlocal brokermod, exec_mode nonlocal brokermod, exec_mode
@ -401,7 +387,6 @@ async def open_brokerd_dialog(
for ep_name in [ for ep_name in [
'open_trade_dialog', # probably final name? 'open_trade_dialog', # probably final name?
'trades_dialogue', # legacy 'trades_dialogue', # legacy
# ^!TODO, rm this since all backends ported no ?!?
]: ]:
trades_endpoint = getattr( trades_endpoint = getattr(
brokermod, brokermod,
@ -418,21 +403,17 @@ async def open_brokerd_dialog(
if ( if (
trades_endpoint is not None trades_endpoint is not None
or or exec_mode != 'paper'
exec_mode != 'paper'
): ):
# open live brokerd trades endpoint # open live brokerd trades endpoint
open_trades_endpoint = portal.open_context( open_trades_endpoint = portal.open_context(
trades_endpoint, trades_endpoint,
loglevel=loglevel,
) )
@acm @acm
async def maybe_open_paper_ep(): async def maybe_open_paper_ep():
if exec_mode == 'paper': if exec_mode == 'paper':
async with mk_paper_ep( async with mk_paper_ep() as msg:
loglevel=loglevel,
) as msg:
yield msg yield msg
return return
@ -443,9 +424,7 @@ async def open_brokerd_dialog(
# runtime indication that the backend can't support live # runtime indication that the backend can't support live
# order ctrl yet, so boot the paperboi B0 # order ctrl yet, so boot the paperboi B0
if first == 'paper': if first == 'paper':
async with mk_paper_ep( async with mk_paper_ep() as msg:
loglevel=loglevel,
) as msg:
yield msg yield msg
return return
else: else:
@ -521,7 +500,7 @@ class Router(Struct):
''' '''
# setup at actor spawn time # setup at actor spawn time
_tn: trio.Nursery nursery: trio.Nursery
# broker to book map # broker to book map
books: dict[str, DarkBook] = {} books: dict[str, DarkBook] = {}
@ -691,7 +670,7 @@ class Router(Struct):
# dark book clearing loop, also lives with parent # dark book clearing loop, also lives with parent
# daemon to allow dark order clearing while no # daemon to allow dark order clearing while no
# client is connected. # client is connected.
self._tn.start_soon( self.nursery.start_soon(
clear_dark_triggers, clear_dark_triggers,
self, self,
relay.brokerd_stream, relay.brokerd_stream,
@ -714,7 +693,7 @@ class Router(Struct):
# spawn a ``brokerd`` order control dialog stream # spawn a ``brokerd`` order control dialog stream
# that syncs lifetime with the parent `emsd` daemon. # that syncs lifetime with the parent `emsd` daemon.
self._tn.start_soon( self.nursery.start_soon(
translate_and_relay_brokerd_events, translate_and_relay_brokerd_events,
broker, broker,
relay.brokerd_stream, relay.brokerd_stream,
@ -748,7 +727,6 @@ class Router(Struct):
except ( except (
trio.ClosedResourceError, trio.ClosedResourceError,
trio.BrokenResourceError, trio.BrokenResourceError,
tractor.TransportClosed,
): ):
to_remove.add(client_stream) to_remove.add(client_stream)
log.warning( log.warning(
@ -785,20 +763,14 @@ async def _setup_persistent_emsd(
) -> None: ) -> None:
if loglevel: if loglevel:
_log = get_console_log( get_console_log(loglevel)
level=loglevel,
name=subsys,
)
assert _log.name == 'piker.clearing'
global _router global _router
# open a root "service task-nursery" for the `emsd`-actor # open a root "service nursery" for the ``emsd`` actor
async with ( async with trio.open_nursery() as service_nursery:
trionics.collapse_eg(),
trio.open_nursery() as tn _router = Router(nursery=service_nursery)
):
_router = Router(_tn=tn)
# TODO: send back the full set of persistent # TODO: send back the full set of persistent
# orders/execs? # orders/execs?
@ -1052,18 +1024,8 @@ async def translate_and_relay_brokerd_events(
) )
if status == 'closed': if status == 'closed':
log.info( log.info(f'Execution for {oid} is complete!')
f'Execution is complete!\n' status_msg = book._active.pop(oid)
f'oid: {oid!r}\n'
)
status_msg = book._active.pop(oid, None)
if status_msg is None:
log.warning(
f'Order was already cleared from book ??\n'
f'oid: {oid!r}\n'
f'\n'
f'Maybe the order cancelled before submitted ??\n'
)
elif status == 'canceled': elif status == 'canceled':
log.cancel(f'Cancellation for {oid} is complete!') log.cancel(f'Cancellation for {oid} is complete!')
@ -1228,16 +1190,12 @@ async def process_client_order_cmds(
submitting live orders immediately if requested by the client. submitting live orders immediately if requested by the client.
''' '''
# TODO, only allow `msgspec.Struct` form! # cmd: dict
cmd: dict
async for cmd in client_order_stream: async for cmd in client_order_stream:
log.info( log.info(f'Received order cmd:\n{pformat(cmd)}')
f'Received order cmd:\n'
f'{pformat(cmd)}\n'
)
# CAWT DAMN we need struct support! # CAWT DAMN we need struct support!
oid: str = str(cmd['oid']) oid = str(cmd['oid'])
# register this stream as an active order dialog (msg flow) for # register this stream as an active order dialog (msg flow) for
# this order id such that translated message from the brokerd # this order id such that translated message from the brokerd
@ -1343,7 +1301,7 @@ async def process_client_order_cmds(
case { case {
'oid': oid, 'oid': oid,
'symbol': fqme, 'symbol': fqme,
'price': price, 'price': trigger_price,
'size': size, 'size': size,
'action': ('buy' | 'sell') as action, 'action': ('buy' | 'sell') as action,
'exec_mode': ('live' | 'paper'), 'exec_mode': ('live' | 'paper'),
@ -1375,7 +1333,7 @@ async def process_client_order_cmds(
symbol=sym, symbol=sym,
action=action, action=action,
price=price, price=trigger_price,
size=size, size=size,
account=req.account, account=req.account,
) )
@ -1397,11 +1355,7 @@ async def process_client_order_cmds(
# (``translate_and_relay_brokerd_events()`` above) will # (``translate_and_relay_brokerd_events()`` above) will
# 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}:\n{pformat(msg)}')
f'Sending live order to {broker}:\n'
f'{pformat(msg)}'
)
await brokerd_order_stream.send(msg) await brokerd_order_stream.send(msg)
# an immediate response should be ``BrokerdOrderAck`` # an immediate response should be ``BrokerdOrderAck``
@ -1417,7 +1371,7 @@ async def process_client_order_cmds(
case { case {
'oid': oid, 'oid': oid,
'symbol': fqme, 'symbol': fqme,
'price': price, 'price': trigger_price,
'size': size, 'size': size,
'exec_mode': exec_mode, 'exec_mode': exec_mode,
'action': action, 'action': action,
@ -1445,12 +1399,7 @@ async def process_client_order_cmds(
if isnan(last): if isnan(last):
last = flume.rt_shm.array[-1]['close'] last = flume.rt_shm.array[-1]['close']
trigger_price: float = float(price) pred = mk_check(trigger_price, last, action)
pred = mk_check(
trigger_price,
last,
action,
)
# NOTE: for dark orders currently we submit # NOTE: for dark orders currently we submit
# the triggered live order at a price 5 ticks # the triggered live order at a price 5 ticks
@ -1557,7 +1506,7 @@ async def maybe_open_trade_relays(
loglevel: str = 'info', loglevel: str = 'info',
): ):
fqme, relay, feed, client_ready = await _router._tn.start( fqme, relay, feed, client_ready = await _router.nursery.start(
_router.open_trade_relays, _router.open_trade_relays,
fqme, fqme,
exec_mode, exec_mode,
@ -1587,18 +1536,19 @@ async def maybe_open_trade_relays(
@tractor.context @tractor.context
async def _emsd_main( async def _emsd_main(
ctx: tractor.Context, # becomes `ems_ctx` below ctx: tractor.Context,
fqme: str, fqme: str,
exec_mode: str, # ('paper', 'live') exec_mode: str, # ('paper', 'live')
loglevel: str | None = None, loglevel: str | None = None,
) -> tuple[ # `ctx.started()` value! ) -> tuple[
dict[ # positions dict[
tuple[str, str], # brokername, acctid # brokername, acctid
tuple[str, str],
list[BrokerdPosition], list[BrokerdPosition],
], ],
list[str], # accounts list[str],
dict[str, Status], # dialogs dict[str, Status],
]: ]:
''' '''
EMS (sub)actor entrypoint providing the execution management EMS (sub)actor entrypoint providing the execution management
@ -1723,5 +1673,5 @@ async def _emsd_main(
if not client_streams: if not client_streams:
log.warning( log.warning(
f'Order dialog is not being monitored:\n' f'Order dialog is not being monitored:\n'
f'{oid!r} <-> {client_stream.chan.aid.reprol()}\n' f'{oid} ->\n{client_stream._ctx.chan.uid}'
) )

View File

@ -19,7 +19,6 @@ Clearing sub-system message and protocols.
""" """
from __future__ import annotations from __future__ import annotations
from decimal import Decimal
from typing import ( from typing import (
Literal, Literal,
) )
@ -72,15 +71,7 @@ class Order(Struct):
symbol: str # | MktPair symbol: str # | MktPair
account: str # should we set a default as '' ? account: str # should we set a default as '' ?
# https://docs.python.org/3/library/decimal.html#decimal-objects price: float
#
# ?TODO? decimal usage throughout?
# -[ ] possibly leverage the `Encoder(decimal_format='number')`
# bit?
# |_https://jcristharif.com/msgspec/supported-types.html#decimal
# -[ ] should we also use it for .size?
#
price: Decimal
size: float # -ve is "sell", +ve is "buy" size: float # -ve is "sell", +ve is "buy"
brokers: list[str] = [] brokers: list[str] = []
@ -187,7 +178,7 @@ class BrokerdOrder(Struct):
time_ns: int time_ns: int
symbol: str # fqme symbol: str # fqme
price: Decimal price: float
size: float size: float
# TODO: if we instead rely on a +ve/-ve size to determine # TODO: if we instead rely on a +ve/-ve size to determine
@ -301,9 +292,6 @@ class BrokerdError(Struct):
# TODO: yeah, so we REALLY need to completely deprecate # TODO: yeah, so we REALLY need to completely deprecate
# this and use the `.accounting.Position` msg-type instead.. # this and use the `.accounting.Position` msg-type instead..
# -[ ] an alternative might be to add a `Position.summary() ->
# `PositionSummary`-msg that we generate since `Position` has a lot
# of fields by default we likely don't want to send over the wire?
class BrokerdPosition(Struct): class BrokerdPosition(Struct):
''' '''
Position update event from brokerd. Position update event from brokerd.
@ -316,4 +304,3 @@ class BrokerdPosition(Struct):
avg_price: float avg_price: float
currency: str = '' currency: str = ''
name: str = 'position' name: str = 'position'
bs_mktid: str|int|None = None

View File

@ -59,9 +59,9 @@ from piker.data import (
open_symcache, open_symcache,
) )
from piker.types import Struct from piker.types import Struct
from piker.log import ( from ._util import (
log, # sub-sys logger
get_console_log, get_console_log,
get_logger,
) )
from ._messages import ( from ._messages import (
BrokerdCancel, BrokerdCancel,
@ -73,8 +73,6 @@ from ._messages import (
BrokerdError, BrokerdError,
) )
log = get_logger(name=__name__)
class PaperBoi(Struct): class PaperBoi(Struct):
''' '''
@ -299,8 +297,6 @@ class PaperBoi(Struct):
# transmit pp msg to ems # transmit pp msg to ems
pp: Position = self.acnt.pps[bs_mktid] pp: Position = self.acnt.pps[bs_mktid]
# TODO, this will break if `require_only=True` was passed to
# `.update_from_ledger()`
pp_msg = BrokerdPosition( pp_msg = BrokerdPosition(
broker=self.broker, broker=self.broker,
@ -512,7 +508,7 @@ async def handle_order_requests(
reqid = await client.submit_limit( reqid = await client.submit_limit(
oid=order.oid, oid=order.oid,
symbol=f'{order.symbol}.{client.broker}', symbol=f'{order.symbol}.{client.broker}',
price=float(order.price), price=order.price,
action=order.action, action=order.action,
size=order.size, size=order.size,
# XXX: by default 0 tells ``ib_insync`` methods that # XXX: by default 0 tells ``ib_insync`` methods that
@ -552,6 +548,7 @@ _sells: defaultdict[
@tractor.context @tractor.context
async def open_trade_dialog( async def open_trade_dialog(
ctx: tractor.Context, ctx: tractor.Context,
broker: str, broker: str,
fqme: str | None = None, # if empty, we only boot broker mode fqme: str | None = None, # if empty, we only boot broker mode
@ -559,11 +556,8 @@ async def open_trade_dialog(
) -> None: ) -> None:
# enable piker.clearing console log for *this* `brokerd` subactor # enable piker.clearing console log for *this* subactor
get_console_log( get_console_log(loglevel)
level=loglevel,
name=__name__,
)
symcache: SymbologyCache symcache: SymbologyCache
async with open_symcache(get_brokermod(broker)) as symcache: async with open_symcache(get_brokermod(broker)) as symcache:

View File

@ -28,14 +28,11 @@ from ..log import (
from piker.types import Struct from piker.types import Struct
subsys: str = 'piker.clearing' subsys: str = 'piker.clearing'
log = get_logger( log = get_logger(subsys)
name='piker.clearing',
)
# TODO, oof doesn't this ignore the `loglevel` then???
get_console_log = partial( get_console_log = partial(
get_console_log, get_console_log,
name='clearing', name=subsys,
) )

View File

@ -61,8 +61,7 @@ def load_trans_eps(
if ( if (
network network
and and not maddrs
not maddrs
): ):
# load network section and (attempt to) connect all endpoints # load network section and (attempt to) connect all endpoints
# which are reachable B) # which are reachable B)
@ -113,27 +112,31 @@ def load_trans_eps(
default=None, default=None,
help='Multiaddrs to bind or contact', help='Multiaddrs to bind or contact',
) )
# @click.option(
# '--tsdb',
# is_flag=True,
# help='Enable local ``marketstore`` instance'
# )
# @click.option(
# '--es',
# is_flag=True,
# help='Enable local ``elasticsearch`` instance'
# )
def pikerd( def pikerd(
maddr: list[str] | None, maddr: list[str] | None,
loglevel: str, loglevel: str,
tl: bool, tl: bool,
pdb: bool, pdb: bool,
# tsdb: bool,
# es: bool,
): ):
''' '''
Start the "root service actor", `pikerd`, run it until Spawn the piker broker-daemon.
cancellation.
This "root daemon" operates as the top most service-mngr and
subsys-as-subactor supervisor, think of it as the "init proc" of
any of any `piker` application or daemon-process tree.
''' '''
# from tractor.devx import maybe_open_crash_handler from tractor.devx import maybe_open_crash_handler
# with maybe_open_crash_handler(pdb=False): with maybe_open_crash_handler(pdb=pdb):
log = get_console_log( log = get_console_log(loglevel, name='cli')
level=loglevel,
with_tractor_log=tl,
)
if pdb: if pdb:
log.warning(( log.warning((
@ -175,18 +178,39 @@ def pikerd(
async def main(): async def main():
service_mngr: service.Services service_mngr: service.Services
async with ( async with (
service.open_pikerd( service.open_pikerd(
registry_addrs=regaddrs, registry_addrs=regaddrs,
loglevel=loglevel, loglevel=loglevel,
debug_mode=pdb, debug_mode=pdb,
# enable_transports=['uds'],
enable_transports=['tcp'], ) as service_mngr, # normally delivers a ``Services`` handle
) as service_mngr,
# AsyncExitStack() as stack,
): ):
assert service_mngr # TODO: spawn all other sub-actor daemons according to
# ?TODO? spawn all other sub-actor daemons according to
# multiaddress endpoint spec defined by user config # multiaddress endpoint spec defined by user config
assert service_mngr
# if tsdb:
# dname, conf = await stack.enter_async_context(
# service.marketstore.start_ahab_daemon(
# service_mngr,
# loglevel=loglevel,
# )
# )
# log.info(f'TSDB `{dname}` up with conf:\n{conf}')
# if es:
# dname, conf = await stack.enter_async_context(
# service.elastic.start_ahab_daemon(
# service_mngr,
# loglevel=loglevel,
# )
# )
# log.info(f'DB `{dname}` up with conf:\n{conf}')
await trio.sleep_forever() await trio.sleep_forever()
trio.run(main) trio.run(main)
@ -234,14 +258,6 @@ def cli(
regaddr: str, regaddr: str,
) -> None: ) -> None:
'''
The "root" `piker`-cmd CLI endpoint.
NOTE, this def generally relies on and requires a sub-cmd to be
provided by the user, OW only a `--help` msg (listing said
subcmds) will be dumped to console.
'''
if configdir is not None: if configdir is not None:
assert os.path.isdir(configdir), f"`{configdir}` is not a valid path" assert os.path.isdir(configdir), f"`{configdir}` is not a valid path"
config._override_config_dir(configdir) config._override_config_dir(configdir)
@ -300,93 +316,42 @@ def cli(
@click.option('--tl', is_flag=True, help='Enable tractor logging') @click.option('--tl', is_flag=True, help='Enable tractor logging')
@click.argument('ports', nargs=-1, required=False) @click.argument('ports', nargs=-1, required=False)
@click.pass_obj @click.pass_obj
def services( def services(config, tl, ports):
config,
tl: bool,
ports: list[int],
):
'''
List all `piker` "service deamons" to the console in
a `json`-table which maps each actor's UID in the form,
`{service_name}.{subservice_name}.{UUID}` from ..service import (
to its (primary) IPC server address.
(^TODO, should be its multiaddr form once we support it)
Note that by convention actors which operate as "headless"
processes (those without GUIs/graphics, and which generally
parent some noteworthy subsystem) are normally suffixed by
a "d" such as,
- pikerd: the root runtime supervisor
- brokerd: a broker-backend order ctl daemon
- emsd: the internal dark-clearing and order routing daemon
- datad: a data-provider-backend data feed daemon
- samplerd: the real-time data sampling and clock-syncing daemon
"Headed units" are normally just given an obvious app-like name
with subactors indexed by `.` such as,
- chart: the primary modal charting iface, a Qt app
- chart.fsp_0: a financial-sig-proc cascade instance which
delivers graphics to a parent `chart` app.
- polars_boi: some (presumably) `polars` using console app.
'''
from piker.service import (
open_piker_runtime, open_piker_runtime,
_default_registry_port, _default_registry_port,
_default_registry_host, _default_registry_host,
) )
# !TODO, mk this to work with UDS! host = _default_registry_host
host: str = _default_registry_host
if not ports: if not ports:
ports: list[int] = [_default_registry_port] ports = [_default_registry_port]
addr = tractor._addr.wrap_address(
addr=(host, ports[0])
)
async def list_services(): async def list_services():
nonlocal host nonlocal host
async with ( async with (
open_piker_runtime( open_piker_runtime(
name='service_query', name='service_query',
loglevel=( loglevel=config['loglevel'] if tl else None,
config['loglevel']
if tl
else None
),
), ),
tractor.get_registry( tractor.get_registry(
addr=addr, host=host,
port=ports[0]
) as portal ) as portal
): ):
registry = await portal.run_from_ns( registry = await portal.run_from_ns('self', 'get_registry')
'self',
'get_registry',
)
json_d = {} json_d = {}
for key, socket in registry.items(): for key, socket in registry.items():
json_d[key] = f'{socket}' host, port = socket
json_d[key] = f'{host}:{port}'
click.echo(f"{colorize_json(json_d)}") click.echo(f"{colorize_json(json_d)}")
trio.run(list_services) trio.run(list_services)
def _load_clis() -> None: def _load_clis() -> None:
''' # from ..service import elastic # noqa
Dynamically load and register all subsys CLI endpoints (at call
time).
NOTE, obviously this is normally expected to be called at
`import` time and implicitly relies on our use of various
`click`/`typer` decorator APIs.
'''
from ..brokers import cli # noqa from ..brokers import cli # noqa
from ..ui import cli # noqa from ..ui import cli # noqa
from ..watchlists import cli # noqa from ..watchlists import cli # noqa
@ -396,5 +361,5 @@ def _load_clis() -> None:
from ..accounting import cli # noqa from ..accounting import cli # noqa
# load all subsytem cli eps # load downstream cli modules
_load_clis() _load_clis()

View File

@ -41,13 +41,10 @@ from .log import get_logger
log = get_logger('broker-config') log = get_logger('broker-config')
# XXX NOTE: taken from `click` # XXX NOTE: taken from ``click`` since apparently they have some
# |_https://github.com/pallets/click/blob/main/src/click/utils.py#L449 # super weirdness with sigint and sudo..no clue
# # we're probably going to slowly just modify it to our own version over
# (since apparently they have some super weirdness with SIGINT and # time..
# sudo.. no clue we're probably going to slowly just modify it to our
# own version over time..)
#
def get_app_dir( def get_app_dir(
app_name: str, app_name: str,
roaming: bool = True, roaming: bool = True,
@ -107,15 +104,14 @@ def get_app_dir(
# `tractor`) with the testing dir and check for it whenever we # `tractor`) with the testing dir and check for it whenever we
# detect `pytest` is being used (which it isn't under normal # detect `pytest` is being used (which it isn't under normal
# operation). # operation).
# if "pytest" in sys.modules: if "pytest" in sys.modules:
# import tractor import tractor
# actor = tractor.current_actor(err_on_no_runtime=False) actor = tractor.current_actor(err_on_no_runtime=False)
# if actor: # runtime is up if actor: # runtime is up
# rvs = tractor._state._runtime_vars rvs = tractor._state._runtime_vars
# import pdbp; pdbp.set_trace() testdirpath = Path(rvs['piker_vars']['piker_test_dir'])
# testdirpath = Path(rvs['piker_vars']['piker_test_dir']) assert testdirpath.exists(), 'piker test harness might be borked!?'
# assert testdirpath.exists(), 'piker test harness might be borked!?' app_name = str(testdirpath)
# app_name = str(testdirpath)
if platform.system() == 'Windows': if platform.system() == 'Windows':
key = "APPDATA" if roaming else "LOCALAPPDATA" key = "APPDATA" if roaming else "LOCALAPPDATA"
@ -264,7 +260,7 @@ def load(
MutableMapping, MutableMapping,
] = tomllib.loads, ] = tomllib.loads,
touch_if_dne: bool = True, touch_if_dne: bool = False,
**tomlkws, **tomlkws,
@ -273,7 +269,7 @@ def load(
Load config file by name. Load config file by name.
If desired config is not in the top level piker-user config path then If desired config is not in the top level piker-user config path then
pass the `path: Path` explicitly. pass the ``path: Path`` explicitly.
''' '''
# create the $HOME/.config/piker dir if dne # create the $HOME/.config/piker dir if dne
@ -288,8 +284,7 @@ def load(
if ( if (
not path.is_file() not path.is_file()
and and touch_if_dne
touch_if_dne
): ):
# only do a template if no path provided, # only do a template if no path provided,
# just touch an empty file with same name. # just touch an empty file with same name.

View File

@ -25,10 +25,12 @@ from collections import (
defaultdict, defaultdict,
) )
from contextlib import asynccontextmanager as acm from contextlib import asynccontextmanager as acm
from functools import partial
import time import time
from typing import ( from typing import (
Any, Any,
AsyncIterator, AsyncIterator,
Callable,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -42,7 +44,7 @@ from tractor.trionics import (
maybe_open_nursery, maybe_open_nursery,
) )
import trio import trio
from trio_typing import TaskStatus from trio import TaskStatus
from .ticktools import ( from .ticktools import (
frame_ticks, frame_ticks,
@ -53,6 +55,9 @@ from ._util import (
get_console_log, get_console_log,
) )
from ..service import maybe_spawn_daemon from ..service import maybe_spawn_daemon
from piker.log import (
mk_repr,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ._sharedmem import ( from ._sharedmem import (
@ -70,6 +75,7 @@ if TYPE_CHECKING:
_default_delay_s: float = 1.0 _default_delay_s: float = 1.0
# TODO: use new `tractor.singleton_acm` API for this!
class Sampler: class Sampler:
''' '''
Global sampling engine registry. Global sampling engine registry.
@ -79,15 +85,15 @@ class Sampler:
This non-instantiated type is meant to be a singleton within This non-instantiated type is meant to be a singleton within
a `samplerd` actor-service spawned once by the user wishing to a `samplerd` actor-service spawned once by the user wishing to
time-step-sample (real-time) quote feeds, see time-step-sample a (real-time) quote feeds, see
`.service.maybe_open_samplerd()` and the below `.service.maybe_open_samplerd()` and the below
`register_with_sampler()`. `register_with_sampler()`.
''' '''
service_nursery: None | trio.Nursery = None service_nursery: None | trio.Nursery = None
# TODO: we could stick these in a composed type to avoid angering # TODO: we could stick these in a composed type to avoid
# the "i hate module scoped variables crowd" (yawn). # angering the "i hate module scoped variables crowd" (yawn).
ohlcv_shms: dict[float, list[ShmArray]] = {} ohlcv_shms: dict[float, list[ShmArray]] = {}
# holds one-task-per-sample-period tasks which are spawned as-needed by # holds one-task-per-sample-period tasks which are spawned as-needed by
@ -95,13 +101,6 @@ class Sampler:
# history loading. # history loading.
incr_task_cs: trio.CancelScope | None = None incr_task_cs: trio.CancelScope | None = None
bcast_errors: tuple[Exception] = (
trio.BrokenResourceError,
trio.ClosedResourceError,
trio.EndOfChannel,
tractor.TransportClosed,
)
# holds all the ``tractor.Context`` remote subscriptions for # holds all the ``tractor.Context`` remote subscriptions for
# a particular sample period increment event: all subscribers are # a particular sample period increment event: all subscribers are
# notified on a step. # notified on a step.
@ -265,15 +264,14 @@ class Sampler:
subs: set subs: set
last_ts, subs = pair last_ts, subs = pair
# NOTE, for debugging pub-sub issues task = trio.lowlevel.current_task()
# task = trio.lowlevel.current_task() log.debug(
# log.debug( f'SUBS {self.subscribers}\n'
# f'AlL-SUBS@{period_s!r}: {self.subscribers}\n' f'PAIR {pair}\n'
# f'PAIR: {pair}\n' f'TASK: {task}: {id(task)}\n'
# f'TASK: {task}: {id(task)}\n' f'broadcasting {period_s} -> {last_ts}\n'
# f'broadcasting {period_s} -> {last_ts}\n'
# f'consumers: {subs}' # f'consumers: {subs}'
# ) )
borked: set[MsgStream] = set() borked: set[MsgStream] = set()
sent: set[MsgStream] = set() sent: set[MsgStream] = set()
while True: while True:
@ -290,12 +288,12 @@ class Sampler:
await stream.send(msg) await stream.send(msg)
sent.add(stream) sent.add(stream)
except self.bcast_errors as err: except (
trio.BrokenResourceError,
trio.ClosedResourceError
):
log.error( log.error(
f'Connection dropped for IPC ctx due to,\n' f'{stream._ctx.chan.uid} dropped connection'
f'{type(err)!r}\n'
f'\n'
f'{stream._ctx}'
) )
borked.add(stream) borked.add(stream)
else: else:
@ -336,18 +334,10 @@ async def register_with_sampler(
open_index_stream: bool = True, # open a 2way stream for sample step msgs? open_index_stream: bool = True, # open a 2way stream for sample step msgs?
sub_for_broadcasts: bool = True, # sampler side to send step updates? sub_for_broadcasts: bool = True, # sampler side to send step updates?
loglevel: str|None = None,
) -> set[int]: ) -> None:
get_console_log( get_console_log(tractor.current_actor().loglevel)
level=(
loglevel
or
tractor.current_actor().loglevel
),
name=__name__,
)
incr_was_started: bool = False incr_was_started: bool = False
try: try:
@ -372,12 +362,7 @@ async def register_with_sampler(
# insert the base 1s period (for OHLC style sampling) into # insert the base 1s period (for OHLC style sampling) into
# the increment buffer set to update and shift every second. # the increment buffer set to update and shift every second.
if ( if shms_by_period is not None:
shms_by_period is not None
# and
# feed_is_live.is_set()
# ^TODO? pass it in instead?
):
from ._sharedmem import ( from ._sharedmem import (
attach_shm_array, attach_shm_array,
_Token, _Token,
@ -391,16 +376,14 @@ async def register_with_sampler(
readonly=False, readonly=False,
) )
shms_by_period[period] = shm shms_by_period[period] = shm
Sampler.ohlcv_shms.setdefault( Sampler.ohlcv_shms.setdefault(period, []).append(shm)
period,
[],
).append(shm)
assert Sampler.ohlcv_shms assert Sampler.ohlcv_shms
# unblock caller # unblock caller
await ctx.started( await ctx.started(
set(Sampler.ohlcv_shms.keys()) # XXX bc msgpack only allows one array type!
list(Sampler.ohlcv_shms.keys())
) )
if open_index_stream: if open_index_stream:
@ -420,8 +403,7 @@ async def register_with_sampler(
finally: finally:
if ( if (
sub_for_broadcasts sub_for_broadcasts
and and subs
subs
): ):
try: try:
subs.remove(stream) subs.remove(stream)
@ -446,7 +428,6 @@ async def register_with_sampler(
async def spawn_samplerd( async def spawn_samplerd(
loglevel: str | None = None, loglevel: str | None = None,
**extra_tractor_kwargs **extra_tractor_kwargs
@ -456,7 +437,10 @@ async def spawn_samplerd(
update and increment count write and stream broadcasting. update and increment count write and stream broadcasting.
''' '''
from piker.service import Services from piker.service import (
get_service_mngr,
ServiceMngr,
)
dname = 'samplerd' dname = 'samplerd'
log.info(f'Spawning `{dname}`') log.info(f'Spawning `{dname}`')
@ -464,27 +448,33 @@ async def spawn_samplerd(
# singleton lock creation of ``samplerd`` since we only ever want # singleton lock creation of ``samplerd`` since we only ever want
# one daemon per ``pikerd`` proc tree. # one daemon per ``pikerd`` proc tree.
# TODO: make this built-into the service api? # TODO: make this built-into the service api?
async with Services.locks[dname + '_singleton']: mngr: ServiceMngr = get_service_mngr()
already_started: bool = dname in mngr.service_tasks
if dname not in Services.service_tasks: async with mngr._locks[dname + '_singleton']:
ctx: Context = await mngr.start_service(
daemon_name=dname,
ctx_ep=partial(
register_with_sampler,
period_s=1,
sub_for_broadcasts=False,
),
debug_mode=mngr.debug_mode, # set by pikerd flag
portal = await Services.actor_n.start_actor( # proxy-through to tractor
dname,
enable_modules=[ enable_modules=[
'piker.data._sampling', 'piker.data._sampling',
], ],
loglevel=loglevel, loglevel=loglevel,
debug_mode=Services.debug_mode, # set by pikerd flag
**extra_tractor_kwargs **extra_tractor_kwargs
) )
if not already_started:
await Services.start_service_task( assert (
dname, ctx
portal, and
register_with_sampler, ctx.portal
period_s=1, and
sub_for_broadcasts=False, not ctx.cancel_called
loglevel=loglevel,
) )
return True return True
@ -493,6 +483,7 @@ async def spawn_samplerd(
@acm @acm
async def maybe_open_samplerd( async def maybe_open_samplerd(
loglevel: str | None = None, loglevel: str | None = None,
**pikerd_kwargs, **pikerd_kwargs,
@ -521,10 +512,10 @@ async def open_sample_stream(
shms_by_period: dict[float, dict] | None = None, shms_by_period: dict[float, dict] | None = None,
open_index_stream: bool = True, open_index_stream: bool = True,
sub_for_broadcasts: bool = True, sub_for_broadcasts: bool = True,
loglevel: str|None = None,
# cache_key: str|None = None, cache_key: str | None = None,
# allow_new_sampler: bool = True, allow_new_sampler: bool = True,
ensure_is_active: bool = False, ensure_is_active: bool = False,
) -> AsyncIterator[dict[str, float]]: ) -> AsyncIterator[dict[str, float]]:
@ -553,15 +544,11 @@ async def open_sample_stream(
# yield bistream # yield bistream
# else: # else:
ctx: tractor.Context
shm_periods: set[int] # in `int`-seconds
async with ( async with (
# XXX: this should be singleton on a host, # XXX: this should be singleton on a host,
# a lone broker-daemon per provider should be # a lone broker-daemon per provider should be
# created for all practical purposes # created for all practical purposes
maybe_open_samplerd( maybe_open_samplerd() as portal,
loglevel=loglevel,
) as portal,
portal.open_context( portal.open_context(
register_with_sampler, register_with_sampler,
@ -570,12 +557,11 @@ async def open_sample_stream(
'shms_by_period': shms_by_period, 'shms_by_period': shms_by_period,
'open_index_stream': open_index_stream, 'open_index_stream': open_index_stream,
'sub_for_broadcasts': sub_for_broadcasts, 'sub_for_broadcasts': sub_for_broadcasts,
'loglevel': loglevel,
}, },
) as (ctx, shm_periods) ) as (ctx, first)
): ):
if ensure_is_active: if ensure_is_active:
assert len(shm_periods) > 1 assert len(first) > 1
async with ( async with (
ctx.open_stream( ctx.open_stream(
@ -593,7 +579,7 @@ async def open_sample_stream(
async def sample_and_broadcast( async def sample_and_broadcast(
bus: _FeedsBus, bus: _FeedsBus, # noqa
rt_shm: ShmArray, rt_shm: ShmArray,
hist_shm: ShmArray, hist_shm: ShmArray,
quote_stream: trio.abc.ReceiveChannel, quote_stream: trio.abc.ReceiveChannel,
@ -613,17 +599,12 @@ async def sample_and_broadcast(
overruns = Counter() overruns = Counter()
# NOTE, only used for debugging live-data-feed issues, though # multiline nested `dict` formatter (since rn quote-msgs are
# this should be resolved more correctly in the future using the # just that).
# new typed-msgspec feats of `tractor`! pfmt: Callable[[str], str] = mk_repr()
#
# XXX, a multiline nested `dict` formatter (since rn quote-msgs
# are just that).
# pfmt: Callable[[str], str] = mk_repr()
# iterate stream delivered by broker # iterate stream delivered by broker
async for quotes in quote_stream: async for quotes in quote_stream:
# print(quotes)
# XXX WARNING XXX only enable for debugging bc ow can cost # XXX WARNING XXX only enable for debugging bc ow can cost
# ALOT of perf with HF-feedz!!! # ALOT of perf with HF-feedz!!!
@ -633,13 +614,7 @@ async def sample_and_broadcast(
# f'{pfmt(quotes)}' # f'{pfmt(quotes)}'
# ) # )
# TODO, # TODO: `numba` this!
# -[ ] `numba` or `cython`-nize this loop possibly?
# |_alternatively could we do it in rust somehow by upacking
# arrow msgs instead of using `msgspec`?
# -[ ] use `msgspec.Struct` support in new typed-msging from
# `tractor` to ensure only allowed msgs are transmitted?
#
for broker_symbol, quote in quotes.items(): for broker_symbol, quote in quotes.items():
# TODO: in theory you can send the IPC msg *before* writing # TODO: in theory you can send the IPC msg *before* writing
# to the sharedmem array to decrease latency, however, that # to the sharedmem array to decrease latency, however, that
@ -712,20 +687,17 @@ async def sample_and_broadcast(
sub_key: str = broker_symbol.lower() sub_key: str = broker_symbol.lower()
subs: set[Sub] = bus.get_subs(sub_key) subs: set[Sub] = bus.get_subs(sub_key)
# TODO, figure out how to make this useful whilst if not subs:
# incoporating feed "pausing" .. all_bs_fqmes: list[str] = list(
# bus._subscribers.keys()
# if not subs: )
# all_bs_fqmes: list[str] = list( log.warning(
# bus._subscribers.keys() f'No subscribers for {brokername!r} live-quote ??\n'
# ) f'broker_symbol: {broker_symbol}\n\n'
# log.warning(
# f'No subscribers for {brokername!r} live-quote ??\n'
# f'broker_symbol: {broker_symbol}\n\n'
# f'Maybe the backend-sys symbol does not match one of,\n' f'Maybe the backend-sys symbol does not match one of,\n'
# f'{pfmt(all_bs_fqmes)}\n' f'{pfmt(all_bs_fqmes)}\n'
# ) )
# NOTE: by default the broker backend doesn't append # NOTE: by default the broker backend doesn't append
# it's own "name" into the fqme schema (but maybe it # it's own "name" into the fqme schema (but maybe it
@ -765,8 +737,8 @@ async def sample_and_broadcast(
log.warning( log.warning(
f'Feed OVERRUN {sub_key}' f'Feed OVERRUN {sub_key}'
f'@{bus.brokername} -> \n' '@{bus.brokername} -> \n'
f'feed @ {chan.aid.reprol()}\n' f'feed @ {chan.uid}\n'
f'throttle = {throttle} Hz' f'throttle = {throttle} Hz'
) )
@ -796,14 +768,18 @@ async def sample_and_broadcast(
if lags > 10: if lags > 10:
await tractor.pause() await tractor.pause()
except Sampler.bcast_errors as ipc_err: except (
trio.BrokenResourceError,
trio.ClosedResourceError,
trio.EndOfChannel,
):
ctx: Context = ipc._ctx ctx: Context = ipc._ctx
chan: Channel = ctx.chan chan: Channel = ctx.chan
if ctx: if ctx:
log.warning( log.warning(
f'Dropped `brokerd`-feed for {broker_symbol!r} due to,\n' 'Dropped `brokerd`-quotes-feed connection:\n'
f'x>) {ctx.cid}@{chan.uid}' f'{broker_symbol}:'
f'|_{ipc_err!r}\n\n' f'{ctx.cid}@{chan.uid}'
) )
if sub.throttle_rate: if sub.throttle_rate:
assert ipc._closed assert ipc._closed
@ -820,11 +796,12 @@ async def sample_and_broadcast(
async def uniform_rate_send( async def uniform_rate_send(
rate: float, rate: float,
quote_stream: trio.abc.ReceiveChannel, quote_stream: trio.abc.ReceiveChannel,
stream: MsgStream, stream: MsgStream,
task_status: TaskStatus[None] = trio.TASK_STATUS_IGNORED, task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
''' '''
@ -842,16 +819,13 @@ async def uniform_rate_send(
https://gist.github.com/njsmith/7ea44ec07e901cb78ebe1dd8dd846cb9 https://gist.github.com/njsmith/7ea44ec07e901cb78ebe1dd8dd846cb9
''' '''
# ?TODO? dynamically compute the **actual** approx overhead latency per cycle # TODO: compute the approx overhead latency per cycle
# instead of this magic # bidinezz? left_to_sleep = throttle_period = 1/rate - 0.000616
throttle_period: float = 1/rate - 0.000616
left_to_sleep: float = throttle_period
# send cycle state # send cycle state
first_quote: dict|None
first_quote = last_quote = None first_quote = last_quote = None
last_send: float = time.time() last_send = time.time()
diff: float = 0 diff = 0
task_status.started() task_status.started()
ticks_by_type: dict[ ticks_by_type: dict[
@ -862,28 +836,22 @@ async def uniform_rate_send(
clear_types = _tick_groups['clears'] clear_types = _tick_groups['clears']
while True: while True:
# compute the remaining time to sleep for this throttled cycle # compute the remaining time to sleep for this throttled cycle
left_to_sleep: float = throttle_period - diff left_to_sleep = throttle_period - diff
if left_to_sleep > 0: if left_to_sleep > 0:
cs: trio.CancelScope
with trio.move_on_after(left_to_sleep) as cs: with trio.move_on_after(left_to_sleep) as cs:
sym: str
last_quote: dict
try: try:
sym, last_quote = await quote_stream.receive() sym, last_quote = await quote_stream.receive()
except trio.EndOfChannel: except trio.EndOfChannel:
log.exception( log.exception(f"feed for {stream} ended?")
f'Live stream for feed for ended?\n'
f'<=c\n'
f' |_[{stream!r}\n'
)
break break
diff: float = time.time() - last_send diff = time.time() - last_send
if not first_quote: if not first_quote:
first_quote: float = last_quote first_quote = last_quote
# first_quote['tbt'] = ticks_by_type # first_quote['tbt'] = ticks_by_type
if (throttle_period - diff) > 0: if (throttle_period - diff) > 0:
@ -944,9 +912,7 @@ async def uniform_rate_send(
# TODO: now if only we could sync this to the display # TODO: now if only we could sync this to the display
# rate timing exactly lul # rate timing exactly lul
try: try:
await stream.send({ await stream.send({sym: first_quote})
sym: first_quote
})
except tractor.RemoteActorError as rme: except tractor.RemoteActorError as rme:
if rme.type is not tractor._exceptions.StreamOverrun: if rme.type is not tractor._exceptions.StreamOverrun:
raise raise
@ -957,28 +923,20 @@ async def uniform_rate_send(
f'{sym}:{ctx.cid}@{chan.uid}' f'{sym}:{ctx.cid}@{chan.uid}'
) )
# NOTE: any of these can be raised by `tractor`'s IPC except (
# NOTE: any of these can be raised by ``tractor``'s IPC
# transport-layer and we want to be highly resilient # transport-layer and we want to be highly resilient
# to consumers which crash or lose network connection. # to consumers which crash or lose network connection.
# I.e. we **DO NOT** want to crash and propagate up to # I.e. we **DO NOT** want to crash and propagate up to
# ``pikerd`` these kinds of errors! # ``pikerd`` these kinds of errors!
except ( trio.EndOfChannel,
trio.ClosedResourceError,
trio.BrokenResourceError,
ConnectionResetError, ConnectionResetError,
) + Sampler.bcast_errors as ipc_err: ):
match ipc_err:
case trio.EndOfChannel():
log.info(
f'{stream} terminated by peer,\n'
f'{ipc_err!r}'
)
case _:
# if the feed consumer goes down then drop # if the feed consumer goes down then drop
# out of this rate limiter # out of this rate limiter
log.warning( log.warning(f'{stream} closed')
f'{stream} closed due to,\n'
f'{ipc_err!r}'
)
await stream.aclose() await stream.aclose()
return return

View File

@ -520,10 +520,7 @@ def open_shm_array(
# "unlink" created shm on process teardown by # "unlink" created shm on process teardown by
# pushing teardown calls onto actor context stack # pushing teardown calls onto actor context stack
stack = tractor.current_actor( stack = tractor.current_actor().lifetime_stack
err_on_no_runtime=False,
).lifetime_stack
if stack:
stack.callback(shmarr.close) stack.callback(shmarr.close)
stack.callback(shmarr.destroy) stack.callback(shmarr.destroy)
@ -610,10 +607,7 @@ def attach_shm_array(
_known_tokens[key] = token _known_tokens[key] = token
# "close" attached shm on actor teardown # "close" attached shm on actor teardown
if (actor := tractor.current_actor( tractor.current_actor().lifetime_stack.callback(sha.close)
err_on_no_runtime=False,
)):
actor.lifetime_stack.callback(sha.close)
return sha return sha

View File

@ -31,7 +31,6 @@ from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import ( from typing import (
Any, Any,
Callable,
Sequence, Sequence,
Hashable, Hashable,
TYPE_CHECKING, TYPE_CHECKING,
@ -57,7 +56,7 @@ from piker.brokers import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from piker.accounting import ( from ..accounting import (
Asset, Asset,
MktPair, MktPair,
) )
@ -91,18 +90,6 @@ class SymbologyCache(Struct):
# provided by the backend pkg. # provided by the backend pkg.
mktmaps: dict[str, MktPair] = field(default_factory=dict) mktmaps: dict[str, MktPair] = field(default_factory=dict)
def pformat(self) -> str:
return (
f'<{type(self).__name__}(\n'
f' .mod: {self.mod!r}\n'
f' .assets: {len(self.assets)!r}\n'
f' .pairs: {len(self.pairs)!r}\n'
f' .mktmaps: {len(self.mktmaps)!r}\n'
f')>'
)
__repr__ = pformat
def write_config(self) -> None: def write_config(self) -> None:
# put the backend's pair-struct type ref at the top # put the backend's pair-struct type ref at the top
@ -162,36 +149,19 @@ class SymbologyCache(Struct):
'Implement `Client.get_assets()`!' 'Implement `Client.get_assets()`!'
) )
get_mkt_pairs: Callable|None = getattr( if get_mkt_pairs := getattr(client, 'get_mkt_pairs', None):
client,
'get_mkt_pairs',
None,
)
if not get_mkt_pairs:
log.warning(
'No symbology cache `Pair` support for `{provider}`..\n'
'Implement `Client.get_mkt_pairs()`!'
)
return self
pairs: dict[str, Struct] = await get_mkt_pairs() pairs: dict[str, Struct] = await get_mkt_pairs()
if not pairs:
log.warning(
'No pairs from intial {provider!r} sym-cache request?\n\n'
'`Client.get_mkt_pairs()` -> {pairs!r} ?'
)
return self
for bs_fqme, pair in pairs.items(): for bs_fqme, pair in pairs.items():
# NOTE: every backend defined pair should
# declare it's ns path for roundtrip
# serialization lookup.
if not getattr(pair, 'ns_path', None): if not getattr(pair, 'ns_path', None):
# XXX: every backend defined pair must declare
# a `.ns_path: tractor.NamespacePath` to enable
# roundtrip serialization lookup from a local
# cache file.
raise TypeError( raise TypeError(
f'Pair-struct for {self.mod.name} MUST define a ' f'Pair-struct for {self.mod.name} MUST define a '
'`.ns_path: str`!\n\n' '`.ns_path: str`!\n'
f'{pair!r}' f'{pair}'
) )
entry = await self.mod.get_mkt_info(pair.bs_fqme) entry = await self.mod.get_mkt_info(pair.bs_fqme)
@ -225,6 +195,12 @@ class SymbologyCache(Struct):
pair, pair,
) )
else:
log.warning(
'No symbology cache `Pair` support for `{provider}`..\n'
'Implement `Client.get_mkt_pairs()`!'
)
return self return self
@classmethod @classmethod

View File

@ -26,9 +26,7 @@ from ..log import (
) )
subsys: str = 'piker.data' subsys: str = 'piker.data'
log = get_logger( log = get_logger(subsys)
name=subsys,
)
get_console_log = partial( get_console_log = partial(
get_console_log, get_console_log,

View File

@ -27,15 +27,14 @@ from functools import partial
from types import ModuleType from types import ModuleType
from typing import ( from typing import (
Any, Any,
Optional,
Callable, Callable,
AsyncContextManager, AsyncContextManager,
AsyncGenerator, AsyncGenerator,
Iterable, Iterable,
Type,
) )
import json import json
import tractor
import trio import trio
from trio_typing import TaskStatus from trio_typing import TaskStatus
from trio_websocket import ( from trio_websocket import (
@ -68,7 +67,7 @@ class NoBsWs:
''' '''
# apparently we can QoS for all sorts of reasons..so catch em. # apparently we can QoS for all sorts of reasons..so catch em.
recon_errors: tuple[Type[Exception]] = ( recon_errors = (
ConnectionClosed, ConnectionClosed,
DisconnectionTimeout, DisconnectionTimeout,
ConnectionRejected, ConnectionRejected,
@ -106,10 +105,7 @@ class NoBsWs:
def connected(self) -> bool: def connected(self) -> bool:
return self._connected.is_set() return self._connected.is_set()
async def reset( async def reset(self) -> None:
self,
timeout: float,
) -> bool:
''' '''
Reset the underlying ws connection by cancelling Reset the underlying ws connection by cancelling
the bg relay task and waiting for it to signal the bg relay task and waiting for it to signal
@ -118,31 +114,18 @@ class NoBsWs:
''' '''
self._connected = trio.Event() self._connected = trio.Event()
self._cs.cancel() self._cs.cancel()
with trio.move_on_after(timeout) as cs:
await self._connected.wait() await self._connected.wait()
return True
assert cs.cancelled_caught
return False
async def send_msg( async def send_msg(
self, self,
data: Any, data: Any,
timeout: float = 3,
) -> None: ) -> None:
while True: while True:
try: try:
msg: Any = self._dumps(data) msg: Any = self._dumps(data)
return await self._ws.send_message(msg) return await self._ws.send_message(msg)
except self.recon_errors: except self.recon_errors:
with trio.CancelScope(shield=True): await self.reset()
reconnected: bool = await self.reset(
timeout=timeout,
)
if not reconnected:
log.warning(
'Failed to reconnect after {timeout!r}s ??'
)
async def recv_msg(self) -> Any: async def recv_msg(self) -> Any:
msg: Any = await self._rx.receive() msg: Any = await self._rx.receive()
@ -184,7 +167,7 @@ async def _reconnect_forever(
async def proxy_msgs( async def proxy_msgs(
ws: WebSocketConnection, ws: WebSocketConnection,
rent_cs: trio.CancelScope, # parent cancel scope pcs: trio.CancelScope, # parent cancel scope
): ):
''' '''
Receive (under `timeout` deadline) all msgs from from underlying Receive (under `timeout` deadline) all msgs from from underlying
@ -208,10 +191,8 @@ async def _reconnect_forever(
f'{src_mod}\n' f'{src_mod}\n'
f'{url} connection bail with:' f'{url} connection bail with:'
) )
with trio.CancelScope(shield=True):
await trio.sleep(0.5) await trio.sleep(0.5)
pcs.cancel()
rent_cs.cancel()
# go back to reonnect loop in parent task # go back to reonnect loop in parent task
return return
@ -223,7 +204,7 @@ async def _reconnect_forever(
f'{src_mod}\n' f'{src_mod}\n'
'WS feed seems down and slow af.. reconnecting\n' 'WS feed seems down and slow af.. reconnecting\n'
) )
rent_cs.cancel() pcs.cancel()
# go back to reonnect loop in parent task # go back to reonnect loop in parent task
return return
@ -247,12 +228,7 @@ async def _reconnect_forever(
nobsws._connected = trio.Event() nobsws._connected = trio.Event()
task_status.started() task_status.started()
mc_state: trio._channel.MemoryChannelState = snd._state while not snd._closed:
while (
mc_state.open_receive_channels > 0
and
mc_state.open_send_channels > 0
):
log.info( log.info(
f'{src_mod}\n' f'{src_mod}\n'
f'{url} trying (RE)CONNECT' f'{url} trying (RE)CONNECT'
@ -261,11 +237,10 @@ async def _reconnect_forever(
ws: WebSocketConnection ws: WebSocketConnection
try: try:
async with ( async with (
trio.open_nursery() as n,
open_websocket_url(url) as ws, open_websocket_url(url) as ws,
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn,
): ):
cs = nobsws._cs = tn.cancel_scope cs = nobsws._cs = n.cancel_scope
nobsws._ws = ws nobsws._ws = ws
log.info( log.info(
f'{src_mod}\n' f'{src_mod}\n'
@ -273,7 +248,7 @@ async def _reconnect_forever(
) )
# begin relay loop to forward msgs # begin relay loop to forward msgs
tn.start_soon( n.start_soon(
proxy_msgs, proxy_msgs,
ws, ws,
cs, cs,
@ -287,7 +262,7 @@ async def _reconnect_forever(
# TODO: should we return an explicit sub-cs # TODO: should we return an explicit sub-cs
# from this fixture task? # from this fixture task?
await tn.start( await n.start(
open_fixture, open_fixture,
fixture, fixture,
nobsws, nobsws,
@ -297,24 +272,11 @@ async def _reconnect_forever(
# to let tasks run **inside** the ws open block above. # to let tasks run **inside** the ws open block above.
nobsws._connected.set() nobsws._connected.set()
await trio.sleep_forever() await trio.sleep_forever()
except HandshakeError:
except (
HandshakeError,
ConnectionRejected,
):
log.exception('Retrying connection') log.exception('Retrying connection')
await trio.sleep(0.5) # throttle
except BaseException as _berr: # ws & nursery block ends
berr = _berr
log.exception(
'Reconnect-attempt failed ??\n'
)
with trio.CancelScope(shield=True):
await trio.sleep(0.2) # throttle
raise berr
#|_ws & nursery block ends
nobsws._connected = trio.Event() nobsws._connected = trio.Event()
if cs.cancelled_caught: if cs.cancelled_caught:
log.cancel( log.cancel(
@ -362,26 +324,21 @@ async def open_autorecon_ws(
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
(re)requesting subscriptions without requiring streaming setup subscriptions without requiring streaming setup code to rerun.
code to rerun.
''' '''
snd: trio.MemorySendChannel snd: trio.MemorySendChannel
rcv: trio.MemoryReceiveChannel rcv: trio.MemoryReceiveChannel
snd, rcv = trio.open_memory_channel(616) snd, rcv = trio.open_memory_channel(616)
try: async with trio.open_nursery() as n:
async with (
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn
):
nobsws = NoBsWs( nobsws = NoBsWs(
url, url,
rcv, rcv,
msg_recv_timeout=msg_recv_timeout, msg_recv_timeout=msg_recv_timeout,
) )
await tn.start( await n.start(
partial( partial(
_reconnect_forever, _reconnect_forever,
url, url,
@ -394,16 +351,11 @@ async def open_autorecon_ws(
await nobsws._connected.wait() await nobsws._connected.wait()
assert nobsws._cs assert nobsws._cs
assert nobsws.connected() assert nobsws.connected()
try: try:
yield nobsws yield nobsws
finally: finally:
tn.cancel_scope.cancel() n.cancel_scope.cancel()
except NoBsWs.recon_errors as con_err:
log.warning(
f'Entire ws-channel disconnect due to,\n'
f'con_err: {con_err!r}\n'
)
''' '''
@ -416,8 +368,8 @@ of msgs over a `NoBsWs`.
class JSONRPCResult(Struct): class JSONRPCResult(Struct):
id: int id: int
jsonrpc: str = '2.0' jsonrpc: str = '2.0'
result: dict|None = None result: Optional[dict] = None
error: dict|None = None error: Optional[dict] = None
@acm @acm
@ -435,22 +387,17 @@ 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.
# request_type: Optional[type] = None,
# request_hook: Optional[Callable] = None,
# error_hook: Optional[Callable] = None,
) -> Callable[[str, dict], dict]: ) -> Callable[[str, dict], dict]:
'''
Init a json-RPC-over-websocket connection to the provided `url`.
A `json_rpc: Callable[[str, dict], dict` is delivered to the
caller for sending requests and a bg-`trio.Task` handles
processing of response msgs including error reporting/raising in
the parent/caller task.
'''
# NOTE, store all request msgs so we can raise errors on the # NOTE, store all request msgs so we can raise errors on the
# caller side! # caller side!
req_msgs: dict[int, dict] = {} req_msgs: dict[int, dict] = {}
async with ( async with (
trio.open_nursery() as tn, trio.open_nursery() as n,
open_autorecon_ws( open_autorecon_ws(
url=url, url=url,
msg_recv_timeout=msg_recv_timeout, msg_recv_timeout=msg_recv_timeout,
@ -539,10 +486,15 @@ async def open_jsonrpc_session(
'params': _, 'params': _,
}: }:
log.debug(f'Recieved\n{msg}') log.debug(f'Recieved\n{msg}')
# if request_hook:
# await request_hook(request_type(**msg))
case { case {
'error': error 'error': error
}: }:
# if error_hook:
# await error_hook(response_type(**msg))
# retreive orig request msg, set error # retreive orig request msg, set error
# response in original "result" msg, # response in original "result" msg,
# THEN FINALLY set the event to signal caller # THEN FINALLY set the event to signal caller
@ -561,6 +513,6 @@ async def open_jsonrpc_session(
case _: case _:
log.warning(f'Unhandled JSON-RPC msg!?\n{msg}') log.warning(f'Unhandled JSON-RPC msg!?\n{msg}')
tn.start_soon(recv_task) n.start_soon(recv_task)
yield json_rpc yield json_rpc
tn.cancel_scope.cancel() n.cancel_scope.cancel()

View File

@ -39,7 +39,6 @@ from typing import (
AsyncContextManager, AsyncContextManager,
Awaitable, Awaitable,
Sequence, Sequence,
TYPE_CHECKING,
) )
import trio import trio
@ -62,6 +61,7 @@ from ._util import (
log, log,
get_console_log, get_console_log,
) )
from .flows import Flume
from .validate import ( from .validate import (
FeedInit, FeedInit,
validate_backend, validate_backend,
@ -75,11 +75,6 @@ from ._sampling import (
uniform_rate_send, uniform_rate_send,
) )
if TYPE_CHECKING:
from .flows import Flume
from tractor._addr import Address
from tractor.msg.types import Aid
class Sub(Struct, frozen=True): class Sub(Struct, frozen=True):
''' '''
@ -239,6 +234,7 @@ async def allocate_persistent_feed(
brokername: str, brokername: str,
symstr: str, symstr: str,
loglevel: str, loglevel: str,
start_stream: bool = True, start_stream: bool = True,
init_timeout: float = 616, init_timeout: float = 616,
@ -347,25 +343,18 @@ async def allocate_persistent_feed(
izero_rt, izero_rt,
rt_shm, rt_shm,
) = await bus.nursery.start( ) = await bus.nursery.start(
partial(
manage_history, manage_history,
mod=mod, mod,
mkt=mkt, mkt,
some_data_ready=some_data_ready, some_data_ready,
feed_is_live=feed_is_live, feed_is_live,
loglevel=loglevel,
)
) )
# yield back control to starting nursery once we receive either # yield back control to starting nursery once we receive either
# some history or a real-time quote. # some history or a real-time quote.
log.info( log.info(f'loading OHLCV history: {fqme}')
f'loading OHLCV history: {fqme!r}\n'
)
await some_data_ready.wait() await some_data_ready.wait()
# XXX, avoid cycle; it imports this mod.
from .flows import Flume
flume = Flume( flume = Flume(
# TODO: we have to use this for now since currently the # TODO: we have to use this for now since currently the
@ -462,6 +451,7 @@ async def allocate_persistent_feed(
@tractor.context @tractor.context
async def open_feed_bus( async def open_feed_bus(
ctx: tractor.Context, ctx: tractor.Context,
brokername: str, brokername: str,
symbols: list[str], # normally expected to the broker-specific fqme symbols: list[str], # normally expected to the broker-specific fqme
@ -482,16 +472,13 @@ async def open_feed_bus(
''' '''
if loglevel is None: if loglevel is None:
loglevel: str = tractor.current_actor().loglevel loglevel = tractor.current_actor().loglevel
# XXX: required to propagate ``tractor`` loglevel to piker # XXX: required to propagate ``tractor`` loglevel to piker
# logging # logging
get_console_log( get_console_log(
level=(loglevel loglevel
or or tractor.current_actor().loglevel
tractor.current_actor().loglevel
),
name=__name__,
) )
# local state sanity checks # local state sanity checks
@ -506,6 +493,7 @@ async def open_feed_bus(
sub_registered = trio.Event() sub_registered = trio.Event()
flumes: dict[str, Flume] = {} flumes: dict[str, Flume] = {}
for symbol in symbols: for symbol in symbols:
# if no cached feed for this symbol has been created for this # if no cached feed for this symbol has been created for this
@ -552,7 +540,10 @@ async def open_feed_bus(
# subscription since the backend isn't (yet) expected to # subscription since the backend isn't (yet) expected to
# append it's own name to the fqme, so we filter on keys # append it's own name to the fqme, so we filter on keys
# which *do not* include that name (e.g .ib) . # which *do not* include that name (e.g .ib) .
bus._subscribers.setdefault(bs_fqme, set()) bus._subscribers.setdefault(
bs_fqme,
set(),
)
# sync feed subscribers with flume handles # sync feed subscribers with flume handles
await ctx.started( await ctx.started(
@ -689,7 +680,6 @@ class Feed(Struct):
''' '''
mods: dict[str, ModuleType] = {} mods: dict[str, ModuleType] = {}
portals: dict[ModuleType, tractor.Portal] = {} portals: dict[ModuleType, tractor.Portal] = {}
flumes: dict[ flumes: dict[
str, # FQME str, # FQME
Flume, Flume,
@ -736,10 +726,7 @@ class Feed(Struct):
async for msg in stream: async for msg in stream:
await tx.send(msg) await tx.send(msg)
async with ( async with trio.open_nursery() as nurse:
tractor.trionics.collapse_eg(),
trio.open_nursery() as nurse
):
# spawn a relay task for each stream so that they all # spawn a relay task for each stream so that they all
# multiplex to a common channel. # multiplex to a common channel.
for brokername in mods: for brokername in mods:
@ -802,6 +789,7 @@ async def install_brokerd_search(
@acm @acm
async def maybe_open_feed( async def maybe_open_feed(
fqmes: list[str], fqmes: list[str],
loglevel: str | None = None, loglevel: str | None = None,
@ -855,6 +843,7 @@ async def maybe_open_feed(
@acm @acm
async def open_feed( async def open_feed(
fqmes: list[str], fqmes: list[str],
loglevel: str | None = None, loglevel: str | None = None,
@ -887,6 +876,7 @@ async def open_feed(
# one actor per brokerd for now # one actor per brokerd for now
brokerd_ctxs = [] brokerd_ctxs = []
for brokermod, bfqmes in providers.items(): for brokermod, bfqmes in providers.items():
# if no `brokerd` for this backend exists yet we spawn # if no `brokerd` for this backend exists yet we spawn
@ -912,19 +902,19 @@ async def open_feed(
feed.portals[brokermod] = portal feed.portals[brokermod] = portal
# fill out "status info" that the UI can show # fill out "status info" that the UI can show
chan: tractor.Channel = portal.chan host, port = portal.channel.raddr
raddr: Address = chan.raddr if host == '127.0.0.1':
aid: Aid = chan.aid host = 'localhost'
# TAG_feed_status_update
feed.status.update({ feed.status.update({
'actor_id': aid, 'actor_name': portal.channel.uid[0],
'actor_short_id': f'{aid.name}@{aid.pid}', 'host': host,
'ipc': chan.raddr.proto_key, 'port': port,
'ipc_addr': raddr,
'hist_shm': 'NA', 'hist_shm': 'NA',
'rt_shm': 'NA', 'rt_shm': 'NA',
'throttle_hz': tick_throttle, 'throttle_rate': tick_throttle,
}) })
# feed.status.update(init_msg.pop('status', {}))
# (allocate and) connect to any feed bus for this broker # (allocate and) connect to any feed bus for this broker
bus_ctxs.append( bus_ctxs.append(
@ -956,8 +946,6 @@ async def open_feed(
assert len(feed.mods) == len(feed.portals) assert len(feed.mods) == len(feed.portals)
# XXX, avoid cycle; it imports this mod.
from .flows import Flume
async with ( async with (
trionics.gather_contexts(bus_ctxs) as ctxs, trionics.gather_contexts(bus_ctxs) as ctxs,
): ):

View File

@ -36,10 +36,10 @@ from ._sharedmem import (
ShmArray, ShmArray,
_Token, _Token,
) )
from piker.accounting import MktPair
if TYPE_CHECKING: if TYPE_CHECKING:
from piker.data.feed import Feed from ..accounting import MktPair
from .feed import Feed
class Flume(Struct): class Flume(Struct):

View File

@ -113,9 +113,9 @@ def validate_backend(
) )
if ep is None: if ep is None:
log.warning( log.warning(
f'Provider backend {mod.name!r} is missing ' f'Provider backend {mod.name} is missing '
f'{daemon_name!r} support?\n' f'{daemon_name} support :(\n'
f'|_module endpoint-func missing: {name!r}\n' f'The following endpoint is missing: {name}'
) )
inits: list[ inits: list[

View File

@ -24,7 +24,6 @@ from functools import partial
from typing import ( from typing import (
AsyncIterator, AsyncIterator,
Callable, Callable,
TYPE_CHECKING,
) )
import numpy as np import numpy as np
@ -34,12 +33,12 @@ import tractor
from tractor.msg import NamespacePath from tractor.msg import NamespacePath
from piker.types import Struct from piker.types import Struct
from ..log import ( from ..log import get_logger, get_console_log
get_logger,
get_console_log,
)
from .. import data from .. import data
from ..data.flows import Flume from ..data.feed import (
Flume,
Feed,
)
from ..data._sharedmem import ShmArray from ..data._sharedmem import ShmArray
from ..data._sampling import ( from ..data._sampling import (
_default_delay_s, _default_delay_s,
@ -53,9 +52,6 @@ from ._api import (
) )
from ..toolz import Profiler from ..toolz import Profiler
if TYPE_CHECKING:
from ..data.feed import Feed
log = get_logger(__name__) log = get_logger(__name__)
@ -173,10 +169,8 @@ class Cascade(Struct):
if not synced: if not synced:
fsp: Fsp = self.fsp fsp: Fsp = self.fsp
log.warning( log.warning(
f'***DESYNCED fsp***\n' '***DESYNCED FSP***\n'
f'------------------\n' f'{fsp.ns_path}@{src_shm.token}\n'
f'ns-path: {fsp.ns_path!r}\n'
f'shm-token: {src_shm.token}\n'
f'step_diff: {step_diff}\n' f'step_diff: {step_diff}\n'
f'len_diff: {len_diff}\n' f'len_diff: {len_diff}\n'
) )
@ -404,6 +398,7 @@ async def connect_streams(
@tractor.context @tractor.context
async def cascade( async def cascade(
ctx: tractor.Context, ctx: tractor.Context,
# data feed key # data feed key
@ -431,17 +426,7 @@ async def cascade(
) )
if loglevel: if loglevel:
log = get_console_log( get_console_log(loglevel)
loglevel,
name=__name__,
)
# XXX TODO!
# figure out why this writes a dict to,
# `tractor._state._runtime_vars['_root_mailbox']`
# XD .. wtf
# TODO, solve this as reported in,
# https://www.pikers.dev/pikers/piker/issues/70
# await tractor.pause()
src: Flume = Flume.from_msg(src_flume_addr) src: Flume = Flume.from_msg(src_flume_addr)
dst: Flume = Flume.from_msg( dst: Flume = Flume.from_msg(
@ -484,8 +469,7 @@ async def cascade(
# open a data feed stream with requested broker # open a data feed stream with requested broker
feed: Feed feed: Feed
async with data.feed.maybe_open_feed( async with data.feed.maybe_open_feed(
fqmes=[fqme], [fqme],
loglevel=loglevel,
# TODO throttle tick outputs from *this* daemon since # TODO throttle tick outputs from *this* daemon since
# it'll emit tons of ticks due to the throttle only # it'll emit tons of ticks due to the throttle only
@ -514,7 +498,6 @@ async def cascade(
func_name: str = func.__name__ func_name: str = func.__name__
async with ( async with (
tractor.trionics.collapse_eg(), # avoid multi-taskc tb in console
trio.open_nursery() as tn, trio.open_nursery() as tn,
): ):
# TODO: might be better to just make a "restart" method where # TODO: might be better to just make a "restart" method where
@ -583,8 +566,7 @@ async def cascade(
# on every step msg received from the global `samplerd` # on every step msg received from the global `samplerd`
# service. # service.
async with open_sample_stream( async with open_sample_stream(
period_s=float(delay_s), float(delay_s)
loglevel=loglevel,
) as istream: ) as istream:
profiler(f'{func_name}: sample stream up') profiler(f'{func_name}: sample stream up')

View File

@ -18,8 +18,8 @@
Log like a forester! Log like a forester!
""" """
import logging import logging
import json
import reprlib import reprlib
import json
from typing import ( from typing import (
Callable, Callable,
) )
@ -37,84 +37,35 @@ _proj_name: str = 'piker'
def get_logger( def get_logger(
name: str|None = None, name: str = None,
**tractor_log_kwargs,
) -> logging.Logger: ) -> logging.Logger:
''' '''
Return the package log or a sub-logger if a `name=` is provided, Return the package log or a sub-log for `name` if provided.
which defaults to the calling module's pkg-namespace path.
See `tractor.log.get_logger()` for details.
''' '''
pkg_name: str = _proj_name
if (
name
and
pkg_name in name
):
name: str = name.lstrip(f'{_proj_name}.')
return tractor.log.get_logger( return tractor.log.get_logger(
name=name, name=name,
pkg_name=pkg_name, _root_name=_proj_name,
**tractor_log_kwargs,
) )
def get_console_log( def get_console_log(
level: str | None = None, level: str | None = None,
name: str | None = None, name: str | None = None,
pkg_name: str|None = None,
with_tractor_log: bool = False,
# ?TODO, support a "log-spec" style `str|dict[str, str]` which
# dictates both the sublogger-key and a level?
# -> see similar idea in `modden`'s usage.
**tractor_log_kwargs,
) -> logging.Logger: ) -> logging.Logger:
''' '''
Get the package logger and enable a handler which writes to Get the package logger and enable a handler which writes to stderr.
stderr.
Yeah yeah, i know we can use `DictConfig`. Yeah yeah, i know we can use ``DictConfig``. You do it...
You do it.. Bp
''' '''
pkg_name: str = _proj_name
if (
name
and
pkg_name in name
):
name: str = name.lstrip(f'{_proj_name}.')
tll: str|None = None
if (
with_tractor_log is not False
):
tll = level
elif maybe_actor := tractor.current_actor(
err_on_no_runtime=False,
):
tll = maybe_actor.loglevel
if tll:
t_log = tractor.log.get_console_log(
level=tll,
name='tractor', # <- XXX, force root tractor log!
**tractor_log_kwargs,
)
# TODO/ allow only enabling certain tractor sub-logs?
assert t_log.name == 'tractor'
return tractor.log.get_console_log( return tractor.log.get_console_log(
level=level, level,
name=name, name=name,
pkg_name=pkg_name, _root_name=_proj_name,
**tractor_log_kwargs, ) # our root logger
)
def colorize_json( def colorize_json(
@ -139,8 +90,6 @@ def colorize_json(
) )
# TODO, eventually defer to the version in `modden` once
# it becomes a dep!
def mk_repr( def mk_repr(
**repr_kws, **repr_kws,
) -> Callable[[str], str]: ) -> Callable[[str], str]:

View File

@ -30,7 +30,11 @@ Actor runtime primtives and (distributed) service APIs for,
=> TODO: maybe to (re)move elsewhere? => TODO: maybe to (re)move elsewhere?
''' '''
from ._mngr import Services as Services from ._mngr import (
get_service_mngr as get_service_mngr,
open_service_mngr as open_service_mngr,
ServiceMngr as ServiceMngr,
)
from ._registry import ( from ._registry import (
_tractor_kwargs as _tractor_kwargs, _tractor_kwargs as _tractor_kwargs,
_default_reg_addr as _default_reg_addr, _default_reg_addr as _default_reg_addr,

View File

@ -29,16 +29,13 @@ from contextlib import (
) )
import tractor import tractor
import trio
from piker.log import ( from ._util import (
get_console_log, get_console_log,
) )
from ._util import (
subsys,
)
from ._mngr import ( from ._mngr import (
Services, open_service_mngr,
ServiceMngr,
) )
from ._registry import ( # noqa from ._registry import ( # noqa
_tractor_kwargs, _tractor_kwargs,
@ -99,8 +96,7 @@ async def open_piker_runtime(
# setting it as the root actor on localhost. # setting it as the root actor on localhost.
registry_addrs = ( registry_addrs = (
registry_addrs registry_addrs
or or [_default_reg_addr]
[_default_reg_addr]
) )
if ems := tractor_kwargs.pop('enable_modules', None): if ems := tractor_kwargs.pop('enable_modules', None):
@ -110,22 +106,21 @@ async def open_piker_runtime(
async with ( async with (
tractor.open_root_actor( tractor.open_root_actor(
# passed through to `open_root_actor` # passed through to ``open_root_actor``
registry_addrs=registry_addrs, registry_addrs=registry_addrs,
name=name, name=name,
start_method=start_method,
loglevel=loglevel, loglevel=loglevel,
debug_mode=debug_mode, debug_mode=debug_mode,
start_method=start_method,
# XXX NOTE MEMBER DAT der's a perf hit yo!!
# https://greenback.readthedocs.io/en/latest/principle.html#performance
maybe_enable_greenback=True,
# TODO: eventually we should be able to avoid # TODO: eventually we should be able to avoid
# having the root have more then permissions to # having the root have more then permissions to
# spawn other specialized daemons I think? # spawn other specialized daemons I think?
enable_modules=enable_modules, enable_modules=enable_modules,
hide_tb=False,
# TODO: how to configure this?
# keep it on by default if debug mode is set?
# maybe_enable_greenback=debug_mode,
**tractor_kwargs, **tractor_kwargs,
) as actor, ) as actor,
@ -166,6 +161,7 @@ _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]],
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
@ -174,12 +170,13 @@ async def open_pikerd(
**kwargs, **kwargs,
) -> Services: ) -> ServiceMngr:
''' '''
Start a root piker daemon with an indefinite lifetime. Start a root piker daemon actor (aka `pikerd`) with an indefinite
lifetime.
A root actor nursery is created which can be used to create and keep A root actor-nursery is created which can be used to spawn and
alive underling services (see below). supervise underling service sub-actors (see below).
''' '''
# NOTE: for the root daemon we always enable the root # NOTE: for the root daemon we always enable the root
@ -194,6 +191,7 @@ async def open_pikerd(
async with ( async with (
open_piker_runtime( open_piker_runtime(
name=_root_dname, name=_root_dname,
loglevel=loglevel, loglevel=loglevel,
debug_mode=debug_mode, debug_mode=debug_mode,
@ -205,9 +203,6 @@ async def open_pikerd(
root_actor, root_actor,
reg_addrs, reg_addrs,
), ),
tractor.open_nursery() as actor_nursery,
tractor.trionics.collapse_eg(),
trio.open_nursery() as service_tn,
): ):
for addr in reg_addrs: for addr in reg_addrs:
if addr not in root_actor.accept_addrs: if addr not in root_actor.accept_addrs:
@ -216,25 +211,17 @@ async def open_pikerd(
'Maybe you have another daemon already running?' 'Maybe you have another daemon already running?'
) )
# assign globally for future daemon/task creation mngr: ServiceMngr
Services.actor_n = actor_nursery async with open_service_mngr(
Services.service_n = service_tn debug_mode=debug_mode,
Services.debug_mode = debug_mode ) as mngr:
yield mngr
try:
yield Services
finally:
# TODO: is this more clever/efficient?
# if 'samplerd' in Services.service_tasks:
# await Services.cancel_service('samplerd')
service_tn.cancel_scope.cancel()
# TODO: do we even need this? # TODO: do we even need this?
# @acm # @acm
# async def maybe_open_runtime( # async def maybe_open_runtime(
# loglevel: Optional[str] = None, # loglevel: str|None = None,
# **kwargs, # **kwargs,
# ) -> None: # ) -> None:
@ -263,10 +250,7 @@ async def maybe_open_pikerd(
loglevel: str | None = None, loglevel: str | None = None,
**kwargs, **kwargs,
) -> ( ) -> tractor._portal.Portal | ClassVar[ServiceMngr]:
tractor._portal.Portal
|ClassVar[Services]
):
''' '''
If no ``pikerd`` daemon-root-actor can be found start it and If no ``pikerd`` daemon-root-actor can be found start it and
yield up (we should probably figure out returning a portal to self yield up (we should probably figure out returning a portal to self
@ -274,10 +258,7 @@ async def maybe_open_pikerd(
''' '''
if loglevel: if loglevel:
get_console_log( get_console_log(loglevel)
name=subsys,
level=loglevel
)
# subtle, we must have the runtime up here or portal lookup will fail # subtle, we must have the runtime up here or portal lookup will fail
query_name = kwargs.pop( query_name = kwargs.pop(
@ -294,8 +275,7 @@ async def maybe_open_pikerd(
registry_addrs: list[tuple[str, int]] = ( registry_addrs: list[tuple[str, int]] = (
registry_addrs registry_addrs
or or [_default_reg_addr]
[_default_reg_addr]
) )
pikerd_portal: tractor.Portal | None pikerd_portal: tractor.Portal | None

View File

@ -49,15 +49,13 @@ from requests.exceptions import (
ReadTimeout, ReadTimeout,
) )
from piker.log import ( from ._mngr import ServiceMngr
from ._util import (
log, # sub-sys logger
get_console_log, get_console_log,
get_logger,
) )
from ._mngr import Services
from .. import config from .. import config
log = get_logger(name=__name__)
class DockerNotStarted(Exception): class DockerNotStarted(Exception):
'Prolly you dint start da daemon bruh' 'Prolly you dint start da daemon bruh'
@ -338,16 +336,13 @@ class Container:
async def open_ahabd( async def open_ahabd(
ctx: tractor.Context, ctx: tractor.Context,
endpoint: str, # ns-pointer str-msg-type endpoint: str, # ns-pointer str-msg-type
loglevel: str = 'cancel', loglevel: str | None = None,
**ep_kwargs, **ep_kwargs,
) -> None: ) -> None:
log = get_console_log( log = get_console_log(loglevel or 'cancel')
level=loglevel,
name='piker.service',
)
async with open_docker() as client: async with open_docker() as client:
@ -458,7 +453,7 @@ async def open_ahabd(
@acm @acm
async def start_ahab_service( async def start_ahab_service(
services: Services, services: ServiceMngr,
service_name: str, service_name: str,
# endpoint config passed as **kwargs # endpoint config passed as **kwargs
@ -554,7 +549,8 @@ async def start_ahab_service(
log.warning('Failed to cancel root permsed container') log.warning('Failed to cancel root permsed container')
except ( except (
trio.MultiError, # trio.MultiError,
ExceptionGroup,
) as err: ) as err:
for subexc in err.exceptions: for subexc in err.exceptions:
if isinstance(subexc, PermissionError): if isinstance(subexc, PermissionError):

View File

@ -26,33 +26,32 @@ from typing import (
from contextlib import ( from contextlib import (
asynccontextmanager as acm, asynccontextmanager as acm,
) )
from collections import defaultdict
import tractor import tractor
from trio.lowlevel import current_task import trio
from piker.log import ( from ._util import (
get_console_log, log, # sub-sys logger
get_logger,
) )
from ._mngr import ( from ._mngr import (
Services, get_service_mngr,
ServiceMngr,
) )
from ._actor_runtime import maybe_open_pikerd from ._actor_runtime import maybe_open_pikerd
from ._registry import find_service from ._registry import find_service
log = get_logger(name=__name__)
@acm @acm
async def maybe_spawn_daemon( async def maybe_spawn_daemon(
service_name: str, service_name: str,
service_task_target: Callable, service_task_target: Callable,
spawn_args: dict[str, Any], spawn_args: dict[str, Any],
loglevel: str | None = None, loglevel: str | None = None,
singleton: bool = False, singleton: bool = False,
_locks = defaultdict(trio.Lock),
**pikerd_kwargs, **pikerd_kwargs,
) -> tractor.Portal: ) -> tractor.Portal:
@ -68,18 +67,11 @@ async def maybe_spawn_daemon(
clients. clients.
''' '''
log = get_console_log(
level=loglevel,
name=__name__,
)
assert log.name == 'piker.service'
# serialize access to this section to avoid # serialize access to this section to avoid
# 2 or more tasks racing to create a daemon # 2 or more tasks racing to create a daemon
lock = Services.locks[service_name] lock = _locks[service_name]
await lock.acquire() await lock.acquire()
try:
async with find_service( async with find_service(
service_name, service_name,
registry_addrs=[('127.0.0.1', 6116)], registry_addrs=[('127.0.0.1', 6116)],
@ -142,24 +134,69 @@ 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
except BaseException as _err: # --- ---- ---
err = _err # DO NOT PUT A `portal.cancel_actor()` here (as was prior)!
if ( #
lock.locked() # Doing so will cause an "out-of-band" ctxc
and # (`tractor.ContextCancelled`) to be raised inside the
lock.statistics().owner is current_task() # `ServiceMngr.open_context_in_task()`'s call to
): # `ctx.wait_for_result()` AND the internal self-ctxc
log.exception( # "graceful capture" WILL NOT CATCH IT!
f'Releasing stale lock after crash..?' #
f'{err!r}\n' # This can cause certain types of operations to raise
) # that ctxc BEFORE THEY `return`, resulting in
lock.release() # a "false-negative" ctxc being raised when really
raise err # 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!
async def spawn_emsd( async def spawn_emsd(
loglevel: str | None = None, loglevel: str | None = None,
**extra_tractor_kwargs **extra_tractor_kwargs
@ -170,21 +207,22 @@ async def spawn_emsd(
""" """
log.info('Spawning emsd') log.info('Spawning emsd')
portal = await Services.actor_n.start_actor( smngr: ServiceMngr = get_service_mngr()
portal = await smngr.actor_n.start_actor(
'emsd', 'emsd',
enable_modules=[ enable_modules=[
'piker.clearing._ems', 'piker.clearing._ems',
'piker.clearing._client', 'piker.clearing._client',
], ],
loglevel=loglevel, loglevel=loglevel,
debug_mode=Services.debug_mode, # set by pikerd flag debug_mode=smngr.debug_mode, # set by pikerd flag
**extra_tractor_kwargs **extra_tractor_kwargs
) )
# non-blocking setup of clearing service # non-blocking setup of clearing service
from ..clearing._ems import _setup_persistent_emsd from ..clearing._ems import _setup_persistent_emsd
await Services.start_service_task( await smngr.start_service_task(
'emsd', 'emsd',
portal, portal,
@ -197,6 +235,7 @@ async def spawn_emsd(
@acm @acm
async def maybe_open_emsd( async def maybe_open_emsd(
brokername: str, brokername: str,
loglevel: str | None = None, loglevel: str | None = None,

View File

@ -18,25 +18,162 @@
daemon-service management API. daemon-service management API.
""" """
from __future__ import annotations
from contextlib import (
asynccontextmanager as acm,
# contextmanager as cm,
)
from collections import defaultdict from collections import defaultdict
from dataclasses import (
dataclass,
field,
)
import functools
import inspect
from typing import ( from typing import (
Callable, Callable,
Any, Any,
) )
import trio import msgspec
from trio_typing import TaskStatus
import tractor import tractor
import trio
from trio import TaskStatus
from tractor import ( from tractor import (
ActorNursery,
current_actor, current_actor,
ContextCancelled, ContextCancelled,
Context, Context,
Portal, Portal,
) )
from piker.log import get_logger from ._util import (
log, # sub-sys logger
)
log = get_logger(name=__name__)
# TODO: implement a singleton deco-API for wrapping the below
# factory's impl for general actor-singleton use?
#
# @singleton
# async def open_service_mngr(
# **init_kwargs,
# ) -> ServiceMngr:
# '''
# Note this function body is invoke IFF no existing singleton instance already
# exists in this proc's memory.
# '''
# # setup
# yield ServiceMngr(**init_kwargs)
# # teardown
# TODO: singleton factory API instead of a class API
@acm
async def open_service_mngr(
*,
debug_mode: bool = False,
# impl deat which ensures a single global instance
_singleton: list[ServiceMngr|None] = [None],
**init_kwargs,
) -> ServiceMngr:
'''
Open a multi-subactor-as-service-daemon tree supervisor.
The delivered `ServiceMngr` is a singleton instance for each
actor-process and is allocated on first open and never
de-allocated unless explicitly deleted by al call to
`del_service_mngr()`.
'''
# TODO: factor this an allocation into
# a `._mngr.open_service_mngr()` and put in the
# once-n-only-once setup/`.__aenter__()` part!
# -[ ] how to make this only happen on the `mngr == None` case?
# |_ use `.trionics.maybe_open_context()` (for generic
# async-with-style-only-once of the factory impl, though
# what do we do for the allocation case?
# / `.maybe_open_nursery()` (since for this specific case
# it's simpler?) to activate
async with (
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
# impl specific obvi..
init_kwargs.update({
'actor_n': an,
'service_n': tn,
})
mngr: ServiceMngr|None
if (mngr := _singleton[0]) is None:
log.info('Allocating a new service mngr!')
mngr = _singleton[0] = ServiceMngr(**init_kwargs)
# TODO: put into `.__aenter__()` section of
# eventual `@singleton_acm` API wrapper.
#
# assign globally for future daemon/task creation
mngr.actor_n = an
mngr.service_n = tn
else:
assert (
mngr.actor_n
and
mngr.service_tn
)
log.info(
'Using extant service mngr!\n\n'
f'{mngr!r}\n' # it has a nice `.__repr__()` of services state
)
try:
# NOTE: this is a singleton factory impl specific detail
# which should be supported in the condensed
# `@singleton_acm` API?
mngr.debug_mode = debug_mode
yield mngr
finally:
# TODO: is this more clever/efficient?
# if 'samplerd' in mngr.service_tasks:
# await mngr.cancel_service('samplerd')
tn.cancel_scope.cancel()
def get_service_mngr() -> ServiceMngr:
'''
Try to get the singleton service-mngr for this actor presuming it
has already been allocated using,
.. code:: python
async with open_<@singleton_acm(func)>() as mngr`
... this block kept open ...
If not yet allocated raise a `ServiceError`.
'''
# https://stackoverflow.com/a/12627202
# https://docs.python.org/3/library/inspect.html#inspect.Signature
maybe_mngr: ServiceMngr|None = inspect.signature(
open_service_mngr
).parameters['_singleton'].default[0]
if maybe_mngr is None:
raise RuntimeError(
'Someone must allocate a `ServiceMngr` using\n\n'
'`async with open_service_mngr()` beforehand!!\n'
)
return maybe_mngr
# TODO: we need remote wrapping and a general soln: # TODO: we need remote wrapping and a general soln:
@ -46,31 +183,46 @@ log = get_logger(name=__name__)
# to the pikerd actor for starting services remotely! # to the pikerd actor for starting services remotely!
# - prolly rename this to ActorServicesNursery since it spawns # - prolly rename this to ActorServicesNursery since it spawns
# new actors and supervises them to completion? # new actors and supervises them to completion?
class Services: @dataclass
class ServiceMngr:
# class ServiceMngr(msgspec.Struct):
'''
A multi-subactor-as-service manager.
actor_n: tractor._supervise.ActorNursery Spawn, supervise and monitor service/daemon subactors in a SC
process tree.
'''
actor_n: ActorNursery
service_n: trio.Nursery service_n: trio.Nursery
debug_mode: bool # tractor sub-actor debug mode flag debug_mode: bool = False # tractor sub-actor debug mode flag
service_tasks: dict[ service_tasks: dict[
str, str,
tuple[ tuple[
trio.CancelScope, trio.CancelScope,
Context,
Portal, Portal,
trio.Event, trio.Event,
] ]
] = {} ] = field(default_factory=dict)
locks = defaultdict(trio.Lock)
# internal per-service task mutexs
_locks = defaultdict(trio.Lock)
@classmethod
async def start_service_task( async def start_service_task(
self, self,
name: str, name: str,
portal: Portal, portal: Portal,
# TODO: typevar for the return type of the target and then
# use it below for `ctx_res`?
target: Callable, target: Callable,
allow_overruns: bool = False, allow_overruns: bool = False,
**ctx_kwargs, **ctx_kwargs,
) -> (trio.CancelScope, Context): ) -> (trio.CancelScope, Context, Any):
''' '''
Open a context in a service sub-actor, add to a stack Open a context in a service sub-actor, add to a stack
that gets unwound at ``pikerd`` teardown. that gets unwound at ``pikerd`` teardown.
@ -83,6 +235,7 @@ class Services:
task_status: TaskStatus[ task_status: TaskStatus[
tuple[ tuple[
trio.CancelScope, trio.CancelScope,
Context,
trio.Event, trio.Event,
Any, Any,
] ]
@ -90,22 +243,29 @@ class Services:
) -> Any: ) -> Any:
# TODO: use the ctx._scope directly here instead?
# -[ ] actually what semantics do we expect for this
# usage!?
with trio.CancelScope() as cs: with trio.CancelScope() as cs:
try:
async with portal.open_context( async with portal.open_context(
target, target,
allow_overruns=allow_overruns, allow_overruns=allow_overruns,
**ctx_kwargs, **ctx_kwargs,
) as (ctx, first): ) as (ctx, started):
# unblock once the remote context has started # unblock once the remote context has started
complete = trio.Event() complete = trio.Event()
task_status.started((cs, complete, first)) task_status.started((
cs,
ctx,
complete,
started,
))
log.info( log.info(
f'`pikerd` service {name} started with value {first}' f'`pikerd` service {name} started with value {started}'
) )
try:
# wait on any context's return value # wait on any context's return value
# and any final portal result from the # and any final portal result from the
# sub-actor. # sub-actor.
@ -115,39 +275,55 @@ class Services:
# either by error from the target context # either by error from the target context
# function or by being cancelled here by the # function or by being cancelled here by the
# surrounding cancel scope. # surrounding cancel scope.
return (await portal.result(), ctx_res) return (
await portal.wait_for_result(),
ctx_res,
)
except ContextCancelled as ctxe: except ContextCancelled as ctxe:
canceller: tuple[str, str] = ctxe.canceller canceller: tuple[str, str] = ctxe.canceller
our_uid: tuple[str, str] = current_actor().uid our_uid: tuple[str, str] = current_actor().uid
if ( if (
canceller != portal.channel.uid canceller != portal.chan.uid
and and
canceller != our_uid canceller != our_uid
): ):
log.cancel( log.cancel(
f'Actor-service {name} was remotely cancelled?\n' f'Actor-service `{name}` was remotely cancelled by a peer?\n'
f'remote canceller: {canceller}\n'
f'Keeping {our_uid} alive, ignoring sub-actor cancel..\n' # TODO: this would be a good spot to use
# a respawn feature Bo
f'-> Keeping `pikerd` service manager alive despite this inter-peer cancel\n\n'
f'cancellee: {portal.chan.uid}\n'
f'canceller: {canceller}\n'
) )
else: else:
raise raise
finally: finally:
# NOTE: the ctx MUST be cancelled first if we
# don't want the above `ctx.wait_for_result()` to
# raise a self-ctxc. WHY, well since from the ctx's
# perspective the cancel request will have
# arrived out-out-of-band at the `Actor.cancel()`
# level, thus `Context.cancel_called == False`,
# meaning `ctx._is_self_cancelled() == False`.
# with trio.CancelScope(shield=True):
# await ctx.cancel()
await portal.cancel_actor() await portal.cancel_actor()
complete.set() complete.set()
self.service_tasks.pop(name) self.service_tasks.pop(name)
cs, complete, first = await self.service_n.start(open_context_in_task) cs, sub_ctx, complete, started = await self.service_n.start(
open_context_in_task
)
# store the cancel scope and portal for later cancellation or # store the cancel scope and portal for later cancellation or
# retstart if needed. # retstart if needed.
self.service_tasks[name] = (cs, portal, complete) self.service_tasks[name] = (cs, sub_ctx, portal, complete)
return cs, sub_ctx, started
return cs, first
@classmethod
async def cancel_service( async def cancel_service(
self, self,
name: str, name: str,
@ -158,8 +334,80 @@ class Services:
''' '''
log.info(f'Cancelling `pikerd` service {name}') log.info(f'Cancelling `pikerd` service {name}')
cs, portal, complete = self.service_tasks[name] cs, sub_ctx, portal, complete = self.service_tasks[name]
cs.cancel()
# cs.cancel()
await sub_ctx.cancel()
await complete.wait() await complete.wait()
assert name not in self.service_tasks, \
if name in self.service_tasks:
# TODO: custom err?
# raise ServiceError(
raise RuntimeError(
f'Serice task for {name} not terminated?' f'Serice task for {name} not terminated?'
)
# assert name not in self.service_tasks, \
# f'Serice task for {name} not terminated?'
async def start_service(
self,
daemon_name: str,
ctx_ep: Callable, # kwargs must `partial`-ed in!
debug_mode: bool = False,
**tractor_actor_kwargs,
) -> Context:
'''
Start a "service" task in a new sub-actor (daemon) and manage it's lifetime
indefinitely.
Services can be cancelled/shutdown using `.cancel_service()`.
'''
entry: tuple|None = self.service_tasks.get(daemon_name)
if entry:
(cs, sub_ctx, portal, complete) = entry
return sub_ctx
if daemon_name not in self.service_tasks:
portal = await self.actor_n.start_actor(
daemon_name,
debug_mode=( # maybe set globally during allocate
debug_mode
or
self.debug_mode
),
**tractor_actor_kwargs,
)
ctx_kwargs: dict[str, Any] = {}
if isinstance(ctx_ep, functools.partial):
ctx_kwargs: dict[str, Any] = ctx_ep.keywords
ctx_ep: Callable = ctx_ep.func
(cs, sub_ctx, started) = await self.start_service_task(
daemon_name,
portal,
ctx_ep,
**ctx_kwargs,
)
return sub_ctx
# TODO:
# -[ ] factor all the common shit from `.data._sampling`
# and `.brokers._daemon` into here / `ServiceMngr`
# in terms of allocating the `Portal` as part of the
# "service-in-subactor" starting!
# -[ ] move to `tractor.hilevel._service`, import and use here!
# NOTE: purposely leaks the ref to the mod-scope Bo
# import tractor
# from tractor.hilevel import (
# open_service_mngr,
# ServiceMngr,
# )
# mngr: ServiceMngr|None = None
# with tractor.hilevel.open_service_mngr() as mngr:
# Services = proxy(mngr)

View File

@ -27,29 +27,15 @@ from typing import (
) )
import tractor import tractor
from tractor import ( from tractor import Portal
msg,
Actor, from ._util import (
Portal, log, # sub-sys logger
) )
from piker.log import get_logger
log = get_logger(name=__name__)
# TODO? default path-space for UDS registry?
# [ ] needs to be Xplatform tho!
# _default_registry_path: Path = (
# Path(os.environ['XDG_RUNTIME_DIR'])
# /'piker'
# )
_default_registry_host: str = '127.0.0.1' _default_registry_host: str = '127.0.0.1'
_default_registry_port: int = 6116 _default_registry_port: int = 6116
_default_reg_addr: tuple[ _default_reg_addr: tuple[str, int] = (
str,
int, # |str TODO, once we support UDS, see above.
] = (
_default_registry_host, _default_registry_host,
_default_registry_port, _default_registry_port,
) )
@ -89,22 +75,16 @@ async def open_registry(
''' '''
global _tractor_kwargs global _tractor_kwargs
actor: Actor = tractor.current_actor() actor = tractor.current_actor()
aid: msg.Aid = actor.aid uid = actor.uid
uid: tuple[str, str] = aid.uid preset_reg_addrs: list[tuple[str, int]] = Registry.addrs
preset_reg_addrs: list[
tuple[str, int]
] = Registry.addrs
if ( if (
preset_reg_addrs preset_reg_addrs
and and addrs
addrs
): ):
if preset_reg_addrs != addrs: if preset_reg_addrs != addrs:
# if any(addr in preset_reg_addrs for addr in addrs): # if any(addr in preset_reg_addrs for addr in addrs):
diff: set[ diff: set[tuple[str, int]] = set(preset_reg_addrs) - set(addrs)
tuple[str, int]
] = set(preset_reg_addrs) - set(addrs)
if diff: if diff:
log.warning( log.warning(
f'`{uid}` requested only subset of registrars: {addrs}\n' f'`{uid}` requested only subset of registrars: {addrs}\n'
@ -118,39 +98,31 @@ async def open_registry(
) )
was_set: bool = False was_set: bool = False
if ( if (
not tractor.is_root_process() not tractor.is_root_process()
and and not Registry.addrs
not Registry.addrs
): ):
Registry.addrs.extend(actor.reg_addrs) Registry.addrs.extend(actor.reg_addrs)
if ( if (
ensure_exists ensure_exists
and and not Registry.addrs
not Registry.addrs
): ):
raise RuntimeError( raise RuntimeError(
f"`{uid}` registry should already exist but doesn't?" f"`{uid}` registry should already exist but doesn't?"
) )
if not Registry.addrs: if (
not Registry.addrs
):
was_set = True was_set = True
Registry.addrs = ( Registry.addrs = addrs or [_default_reg_addr]
addrs
or
[_default_reg_addr]
)
# NOTE: only spot this seems currently used is inside # NOTE: only spot this seems currently used is inside
# `.ui._exec` which is the (eventual qtloops) bootstrapping # `.ui._exec` which is the (eventual qtloops) bootstrapping
# with guest mode. # with guest mode.
reg_addrs: list[tuple[str, str|int]] = Registry.addrs _tractor_kwargs['registry_addrs'] = Registry.addrs
# !TODO, a struct-API to stringently allow this only in special
# cases?
# -> better would be to have some way to (atomically) rewrite
# and entire `RuntimeVars`?? ideas welcome obvi..
_tractor_kwargs['registry_addrs'] = reg_addrs
try: try:
yield Registry.addrs yield Registry.addrs
@ -174,8 +146,8 @@ async def find_service(
| list[Portal] | list[Portal]
| None | None
): ):
# try:
reg_addrs: list[tuple[str, int|str]] reg_addrs: list[tuple[str, int]]
async with open_registry( async with open_registry(
addrs=( addrs=(
registry_addrs registry_addrs
@ -185,40 +157,26 @@ async def find_service(
or Registry.addrs or Registry.addrs
), ),
) as reg_addrs: ) as reg_addrs:
log.info(f'Scanning for service `{service_name}`')
log.info( maybe_portals: list[Portal] | Portal | None
f'Scanning for service {service_name!r}'
)
# attach to existing daemon by name if possible # attach to existing daemon by name if possible
maybe_portals: list[Portal]|Portal|None
async with tractor.find_actor( async with tractor.find_actor(
service_name, service_name,
registry_addrs=reg_addrs, registry_addrs=reg_addrs,
only_first=first_only, # if set only returns single ref only_first=first_only, # if set only returns single ref
) as maybe_portals: ) as maybe_portals:
if not maybe_portals: if not maybe_portals:
log.info(
f'Could NOT find service {service_name!r} -> {maybe_portals!r}'
)
yield None yield None
return return
log.info(
f'Found service {service_name!r} -> {maybe_portals}'
)
yield maybe_portals yield maybe_portals
# except BaseException as _berr:
# berr = _berr
# log.exception(
# 'tractor.find_actor() failed with,\n'
# )
# raise berr
async def check_for_service( async def check_for_service(
service_name: str, service_name: str,
) -> None | tuple[str, int]: ) -> None | tuple[str, int]:
''' '''
Service daemon "liveness" predicate. Service daemon "liveness" predicate.

View File

@ -14,12 +14,20 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
Sub-sys module commons (if any ?? Bp). Sub-sys module commons.
""" """
from functools import partial
from ..log import (
get_logger,
get_console_log,
)
subsys: str = 'piker.service' subsys: str = 'piker.service'
# ?TODO, if we were going to keep a `get_console_log()` in here to be log = get_logger(subsys)
# invoked at `import`-time, how do we dynamically hand in the
# `level=` value? seems too early in the runtime to be injected get_console_log = partial(
# right? get_console_log,
name=subsys,
)

View File

@ -16,28 +16,24 @@
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager as acm from contextlib import asynccontextmanager as acm
from pprint import pformat
from typing import ( from typing import (
Any, Any,
TYPE_CHECKING, TYPE_CHECKING,
) )
# TODO: oof, needs to be changed to `httpx`!
import asks import asks
if TYPE_CHECKING: if TYPE_CHECKING:
import docker import docker
from ._ahab import DockerContainer from ._ahab import DockerContainer
from . import ( from . import ServiceMngr
Services,
)
from piker.log import ( from ._util import log # sub-sys logger
from ._util import (
get_console_log, get_console_log,
get_logger,
) )
log = get_logger(name=__name__)
# container level config # container level config
_config = { _config = {
@ -73,10 +69,7 @@ def start_elasticsearch(
elastic elastic
''' '''
get_console_log( get_console_log('info', name=__name__)
level='info',
name=__name__,
)
dcntr: DockerContainer = client.containers.run( dcntr: DockerContainer = client.containers.run(
'piker:elastic', 'piker:elastic',
@ -136,7 +129,7 @@ def start_elasticsearch(
@acm @acm
async def start_ahab_daemon( async def start_ahab_daemon(
service_mngr: Services, service_mngr: ServiceMngr,
user_config: dict | None = None, user_config: dict | None = None,
loglevel: str | None = None, loglevel: str | None = None,

View File

@ -52,18 +52,17 @@ import pendulum
# TODO: import this for specific error set expected by mkts client # TODO: import this for specific error set expected by mkts client
# import purerpc # import purerpc
from piker.data.feed import maybe_open_feed from ..data.feed import maybe_open_feed
from . import Services from . import ServiceMngr
from piker.log import ( from ._util import (
log, # sub-sys logger
get_console_log, get_console_log,
get_logger,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
import docker import docker
from ._ahab import DockerContainer from ._ahab import DockerContainer
log = get_logger(name=__name__)
# ahabd-supervisor and container level config # ahabd-supervisor and container level config
@ -234,7 +233,7 @@ def start_marketstore(
@acm @acm
async def start_ahab_daemon( async def start_ahab_daemon(
service_mngr: Services, service_mngr: ServiceMngr,
user_config: dict | None = None, user_config: dict | None = None,
loglevel: str | None = None, loglevel: str | None = None,

View File

@ -43,6 +43,7 @@ from typing import (
import numpy as np import numpy as np
from .. import config from .. import config
from ..service import ( from ..service import (
check_for_service, check_for_service,
@ -151,10 +152,7 @@ class StorageConnectionError(ConnectionError):
''' '''
def get_storagemod( def get_storagemod(name: str) -> ModuleType:
name: str,
) -> ModuleType:
mod: ModuleType = import_module( mod: ModuleType = import_module(
'.' + name, '.' + name,
'piker.storage', 'piker.storage',
@ -169,10 +167,7 @@ def get_storagemod(
async def open_storage_client( async def open_storage_client(
backend: str | None = None, backend: str | None = None,
) -> tuple[ ) -> tuple[ModuleType, StorageClient]:
ModuleType,
StorageClient,
]:
''' '''
Load the ``StorageClient`` for named backend. Load the ``StorageClient`` for named backend.
@ -272,10 +267,7 @@ async def open_tsdb_client(
from ..data.feed import maybe_open_feed from ..data.feed import maybe_open_feed
async with ( async with (
open_storage_client() as ( open_storage_client() as (_, storage),
_,
storage,
),
maybe_open_feed( maybe_open_feed(
[fqme], [fqme],
@ -283,7 +275,7 @@ async def open_tsdb_client(
) as feed, ) as feed,
): ):
profiler(f'opened feed for {fqme!r}') profiler(f'opened feed for {fqme}')
# to_append = feed.hist_shm.array # to_append = feed.hist_shm.array
# to_prepend = None # to_prepend = None

View File

@ -19,10 +19,16 @@ Storage middle-ware CLIs.
""" """
from __future__ import annotations from __future__ import annotations
# from datetime import datetime
# from contextlib import (
# AsyncExitStack,
# )
from pathlib import Path from pathlib import Path
from math import copysign
import time import time
from types import ModuleType from types import ModuleType
from typing import ( from typing import (
Any,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -41,6 +47,7 @@ from piker.data import (
ShmArray, ShmArray,
) )
from piker import tsp from piker import tsp
from piker.data._formatters import BGM
from . import log from . import log
from . import ( from . import (
__tsdbs__, __tsdbs__,
@ -235,12 +242,122 @@ def anal(
trio.run(main) trio.run(main)
async def markup_gaps(
fqme: str,
timeframe: float,
actl: AnnotCtl,
wdts: pl.DataFrame,
gaps: pl.DataFrame,
) -> dict[int, dict]:
'''
Remote annotate time-gaps in a dt-fielded ts (normally OHLC)
with rectangles.
'''
aids: dict[int] = {}
for i in range(gaps.height):
row: pl.DataFrame = gaps[i]
# the gap's RIGHT-most bar's OPEN value
# at that time (sample) step.
iend: int = row['index'][0]
# dt: datetime = row['dt'][0]
# dt_prev: datetime = row['dt_prev'][0]
# dt_end_t: float = dt.timestamp()
# TODO: can we eventually remove this
# once we figure out why the epoch cols
# don't match?
# TODO: FIX HOW/WHY these aren't matching
# and are instead off by 4hours (EST
# vs. UTC?!?!)
# end_t: float = row['time']
# assert (
# dt.timestamp()
# ==
# end_t
# )
# the gap's LEFT-most bar's CLOSE value
# at that time (sample) step.
prev_r: pl.DataFrame = wdts.filter(
pl.col('index') == iend - 1
)
# XXX: probably a gap in the (newly sorted or de-duplicated)
# dt-df, so we might need to re-index first..
if prev_r.is_empty():
await tractor.pause()
istart: int = prev_r['index'][0]
# dt_start_t: float = dt_prev.timestamp()
# start_t: float = prev_r['time']
# assert (
# dt_start_t
# ==
# start_t
# )
# TODO: implement px-col width measure
# and ensure at least as many px-cols
# shown per rect as configured by user.
# gap_w: float = abs((iend - istart))
# if gap_w < 6:
# margin: float = 6
# iend += margin
# istart -= margin
rect_gap: float = BGM*3/8
opn: float = row['open'][0]
ro: tuple[float, float] = (
# dt_end_t,
iend + rect_gap + 1,
opn,
)
cls: float = prev_r['close'][0]
lc: tuple[float, float] = (
# dt_start_t,
istart - rect_gap, # + 1 ,
cls,
)
color: str = 'dad_blue'
diff: float = cls - opn
sgn: float = copysign(1, diff)
color: str = {
-1: 'buy_green',
1: 'sell_red',
}[sgn]
rect_kwargs: dict[str, Any] = dict(
fqme=fqme,
timeframe=timeframe,
start_pos=lc,
end_pos=ro,
color=color,
)
aid: int = await actl.add_rect(**rect_kwargs)
assert aid
aids[aid] = rect_kwargs
# tell chart to redraw all its
# graphics view layers Bo
await actl.redraw(
fqme=fqme,
timeframe=timeframe,
)
return aids
@store.command() @store.command()
def ldshm( def ldshm(
fqme: str, fqme: str,
write_parquet: bool = True, write_parquet: bool = True,
reload_parquet_to_shm: bool = True, reload_parquet_to_shm: bool = True,
pdb: bool = False, # --pdb passed?
) -> None: ) -> None:
''' '''
@ -260,7 +377,7 @@ def ldshm(
open_piker_runtime( open_piker_runtime(
'polars_boi', 'polars_boi',
enable_modules=['piker.data._sharedmem'], enable_modules=['piker.data._sharedmem'],
debug_mode=pdb, debug_mode=True,
), ),
open_storage_client() as ( open_storage_client() as (
mod, mod,
@ -269,8 +386,6 @@ def ldshm(
open_annot_ctl() as actl, open_annot_ctl() as actl,
): ):
shm_df: pl.DataFrame | None = None shm_df: pl.DataFrame | None = None
tf2aids: dict[float, dict] = {}
for ( for (
shmfile, shmfile,
shm, shm,
@ -280,9 +395,6 @@ def ldshm(
times: np.ndarray = shm.array['time'] times: np.ndarray = shm.array['time']
d1: float = float(times[-1] - times[-2]) d1: float = float(times[-1] - times[-2])
d2: float = 0
# XXX, take a median sample rate if sufficient data
if times.size > 2:
d2: float = float(times[-2] - times[-3]) d2: float = float(times[-2] - times[-3])
med: float = np.median(np.diff(times)) med: float = np.median(np.diff(times))
if ( if (
@ -293,6 +405,7 @@ def ldshm(
raise ValueError( raise ValueError(
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))
null_segs: tuple = tsp.get_null_segs( null_segs: tuple = tsp.get_null_segs(
@ -302,8 +415,6 @@ def ldshm(
# TODO: call null-seg fixer somehow? # TODO: call null-seg fixer somehow?
if null_segs: if null_segs:
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,
@ -328,35 +439,9 @@ def ldshm(
wdts, wdts,
deduped, deduped,
diff, diff,
valid_races, ) = tsp.dedupe(
dq_issues,
) = tsp.dedupe_ohlcv_smart(
shm_df, shm_df,
) period=period_s,
# Report duplicate analysis
if diff > 0:
log.info(
f'Removed {diff} duplicate timestamp(s)\n'
)
if valid_races is not None:
identical: int = (
valid_races
.filter(pl.col('identical_bars'))
.height
)
monotonic: int = valid_races.height - identical
log.info(
f'Valid race conditions: {valid_races.height}\n'
f' - Identical bars: {identical}\n'
f' - Volume monotonic: {monotonic}\n'
)
if dq_issues is not None:
log.warning(
f'DATA QUALITY ISSUES from provider: '
f'{dq_issues.height} timestamp(s)\n'
f'{dq_issues}\n'
) )
# detect gaps from in expected (uniform OHLC) sample period # detect gaps from in expected (uniform OHLC) sample period
@ -373,8 +458,7 @@ def ldshm(
# TODO: actually pull the exact duration # TODO: actually pull the exact duration
# expected for each venue operational period? # expected for each venue operational period?
# gap_dt_unit='day', gap_dt_unit='days',
gap_dt_unit='day',
gap_thresh=1, gap_thresh=1,
) )
@ -385,11 +469,8 @@ def ldshm(
if ( if (
not venue_gaps.is_empty() not venue_gaps.is_empty()
or ( or (
not step_gaps.is_empty() period_s < 60
# XXX, i presume i put this bc i was guarding and not step_gaps.is_empty()
# for ib venue gaps?
# and
# period_s < 60
) )
): ):
# write repaired ts to parquet-file? # write repaired ts to parquet-file?
@ -438,35 +519,23 @@ def ldshm(
do_markup_gaps: bool = True do_markup_gaps: bool = True
if do_markup_gaps: if do_markup_gaps:
new_df: pl.DataFrame = tsp.np2pl(new) new_df: pl.DataFrame = tsp.np2pl(new)
aids: dict = await tsp._annotate.markup_gaps( aids: dict = await markup_gaps(
fqme, fqme,
period_s, period_s,
actl, actl,
new_df, new_df,
step_gaps, step_gaps,
) )
# last chance manual overwrites in REPL # last chance manual overwrites in REPL
# await tractor.pause() await tractor.pause()
if not aids: assert aids
log.warning(
f'No gaps were found !?\n'
f'fqme: {fqme!r}\n'
f'timeframe: {period_s!r}\n'
f"WELL THAT'S GOOD NOOZ!\n"
)
tf2aids[period_s] = aids
else: else:
# No significant gaps to handle, but may have had # allow interaction even when no ts problems.
# duplicates removed (valid race conditions are ok)
if diff > 0 and dq_issues is not None:
log.warning(
'Found duplicates with data quality issues '
'but no significant time gaps!\n'
)
await tractor.pause() await tractor.pause()
log.info('Exiting TSP shm anal-izer!') # assert not diff
if shm_df is None: if shm_df is None:
log.error( log.error(

View File

@ -162,12 +162,11 @@ class NativeStorageClient:
def index_files(self): def index_files(self):
for path in self._datadir.iterdir(): for path in self._datadir.iterdir():
if ( if (
path.is_dir() path.name in {'borked', 'expired',}
or or
'.parquet' not in str(path) '.parquet' not in str(path)
# or
# path.name in {'borked', 'expired',}
): ):
# ignore all non-apache files (for now)
continue continue
key: str = path.name.rstrip('.parquet') key: str = path.name.rstrip('.parquet')

File diff suppressed because it is too large Load Diff

View File

@ -54,10 +54,10 @@ from ..log import (
# for "time series processing" # for "time series processing"
subsys: str = 'piker.tsp' subsys: str = 'piker.tsp'
log = get_logger(name=__name__) log = get_logger(subsys)
get_console_log = partial( get_console_log = partial(
get_console_log, get_console_log,
name=subsys, # activate for subsys-pkg "downward" name=subsys,
) )
# NOTE: union type-defs to handle generic `numpy` and `polars` types # NOTE: union type-defs to handle generic `numpy` and `polars` types
@ -275,18 +275,6 @@ def get_null_segs(
# diff of abs index steps between each zeroed row # diff of abs index steps between each zeroed row
absi_zdiff: np.ndarray = np.diff(absi_zeros) absi_zdiff: np.ndarray = np.diff(absi_zeros)
if zero_t.size < 2:
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
# scan for all frame-indices where the # scan for all frame-indices where the
# zeroed-row-abs-index-step-diff is greater then the # zeroed-row-abs-index-step-diff is greater then the
# expected increment of 1. # expected increment of 1.
@ -499,8 +487,7 @@ def iter_null_segs(
start_dt = None start_dt = None
if ( if (
absi_start is not None absi_start is not None
and and start_t != 0
start_t != 0
): ):
fi_start: int = absi_start - absi_first fi_start: int = absi_start - absi_first
start_row: Seq = frame[fi_start] start_row: Seq = frame[fi_start]
@ -514,8 +501,8 @@ def iter_null_segs(
yield ( yield (
absi_start, absi_end, # abs indices absi_start, absi_end, # abs indices
fi_start, fi_end, # relative "frame" indices fi_start, fi_end, # relative "frame" indices
start_t, end_t, # epoch times start_t, end_t,
start_dt, end_dt, # dts start_dt, end_dt,
) )
@ -530,7 +517,7 @@ def with_dts(
''' '''
return df.with_columns([ return df.with_columns([
pl.col(time_col).shift(1).name.suffix('_prev'), pl.col(time_col).shift(1).suffix('_prev'),
pl.col(time_col).diff().alias('s_diff'), pl.col(time_col).diff().alias('s_diff'),
pl.from_epoch(pl.col(time_col)).alias('dt'), pl.from_epoch(pl.col(time_col)).alias('dt'),
]).with_columns([ ]).with_columns([
@ -591,22 +578,11 @@ def detect_time_gaps(
# NOTE: this flag is to indicate that on this (sampling) time # NOTE: this flag is to indicate that on this (sampling) time
# scale we expect to only be filtering against larger venue # scale we expect to only be filtering against larger venue
# closures-scale time gaps. # closures-scale time gaps.
#
# Map to total_ method since `dt_diff` is a duration type,
# not datetime - modern polars requires `total_*` methods
# for duration types (e.g. `total_days()` not `day()`)
# Ensure plural form for polars API (e.g. 'day' -> 'days')
unit_plural: str = (
gap_dt_unit
if gap_dt_unit.endswith('s')
else f'{gap_dt_unit}s'
)
duration_method: str = f'total_{unit_plural}'
return step_gaps.filter( return step_gaps.filter(
# Second by an arbitrary dt-unit step size # Second by an arbitrary dt-unit step size
getattr( getattr(
pl.col('dt_diff').dt, pl.col('dt_diff').dt,
duration_method, gap_dt_unit,
)().abs() > gap_thresh )().abs() > gap_thresh
) )
@ -640,18 +616,6 @@ def detect_price_gaps(
# ]) # ])
... ...
# TODO: probably just use the null_segs impl above?
def detect_vlm_gaps(
df: pl.DataFrame,
col: str = 'volume',
) -> pl.DataFrame:
vnull: pl.DataFrame = df.filter(
pl.col(col) == 0
)
return vnull
def dedupe( def dedupe(
src_df: pl.DataFrame, src_df: pl.DataFrame,
@ -662,6 +626,7 @@ def dedupe(
) -> tuple[ ) -> tuple[
pl.DataFrame, # with dts pl.DataFrame, # with dts
pl.DataFrame, # gaps
pl.DataFrame, # with deduplicated dts (aka gap/repeat removal) pl.DataFrame, # with deduplicated dts (aka gap/repeat removal)
int, # len diff between input and deduped int, # len diff between input and deduped
]: ]:
@ -674,22 +639,19 @@ def dedupe(
''' '''
wdts: pl.DataFrame = with_dts(src_df) wdts: pl.DataFrame = with_dts(src_df)
deduped = wdts
# remove duplicated datetime samples/sections
deduped: pl.DataFrame = wdts.unique(
# subset=['dt'],
subset=['time'],
maintain_order=True,
)
# maybe sort on any time field # maybe sort on any time field
if sort: if sort:
deduped = deduped.sort(by='time') wdts = wdts.sort(by='time')
# TODO: detect out-of-order segments which were corrected! # TODO: detect out-of-order segments which were corrected!
# -[ ] report in log msg # -[ ] report in log msg
# -[ ] possibly return segment sections which were moved? # -[ ] possibly return segment sections which were moved?
# remove duplicated datetime samples/sections
deduped: pl.DataFrame = wdts.unique(
subset=['dt'],
maintain_order=True,
)
diff: int = ( diff: int = (
wdts.height wdts.height
- -

View File

@ -1,306 +0,0 @@
# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of 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/>.
"""
Time-series (remote) annotation APIs.
"""
from __future__ import annotations
from math import copysign
from typing import (
Any,
TYPE_CHECKING,
)
import polars as pl
import tractor
from piker.data._formatters import BGM
from piker.storage import log
from piker.ui._style import get_fonts
if TYPE_CHECKING:
from piker.ui._remote_ctl import AnnotCtl
def humanize_duration(
seconds: float,
) -> str:
'''
Convert duration in seconds to short human-readable form.
Uses smallest appropriate time unit:
- d: days
- h: hours
- m: minutes
- s: seconds
Examples:
- 86400 -> "1d"
- 28800 -> "8h"
- 180 -> "3m"
- 45 -> "45s"
'''
abs_secs: float = abs(seconds)
if abs_secs >= 86400:
days: float = abs_secs / 86400
if days >= 10 or days == int(days):
return f'{int(days)}d'
return f'{days:.1f}d'
elif abs_secs >= 3600:
hours: float = abs_secs / 3600
if hours >= 10 or hours == int(hours):
return f'{int(hours)}h'
return f'{hours:.1f}h'
elif abs_secs >= 60:
mins: float = abs_secs / 60
if mins >= 10 or mins == int(mins):
return f'{int(mins)}m'
return f'{mins:.1f}m'
else:
if abs_secs >= 10 or abs_secs == int(abs_secs):
return f'{int(abs_secs)}s'
return f'{abs_secs:.1f}s'
async def markup_gaps(
fqme: str,
timeframe: float,
actl: AnnotCtl,
wdts: pl.DataFrame,
gaps: pl.DataFrame,
# XXX, switch on to see txt showing a "humanized" label of each
# gap's duration.
show_txt: bool = False,
) -> dict[int, dict]:
'''
Remote annotate time-gaps in a dt-fielded ts (normally OHLC)
with rectangles.
'''
# XXX: force chart redraw FIRST to ensure PlotItem coordinate
# system is properly initialized before we position annotations!
# Without this, annotations may be misaligned on first creation
# due to Qt/pyqtgraph initialization race conditions.
await actl.redraw(
fqme=fqme,
timeframe=timeframe,
)
aids: dict[int] = {}
for i in range(gaps.height):
row: pl.DataFrame = gaps[i]
# the gap's RIGHT-most bar's OPEN value
# at that time (sample) step.
iend: int = row['index'][0]
# dt: datetime = row['dt'][0]
# dt_prev: datetime = row['dt_prev'][0]
# dt_end_t: float = dt.timestamp()
# TODO: can we eventually remove this
# once we figure out why the epoch cols
# don't match?
# TODO: FIX HOW/WHY these aren't matching
# and are instead off by 4hours (EST
# vs. UTC?!?!)
# end_t: float = row['time']
# assert (
# dt.timestamp()
# ==
# end_t
# )
# the gap's LEFT-most bar's CLOSE value
# at that time (sample) step.
prev_r: pl.DataFrame = wdts.filter(
pl.col('index') == iend - 1
)
# XXX: probably a gap in the (newly sorted or de-duplicated)
# dt-df, so we might need to re-index first..
dt: pl.Series = row['dt']
dt_prev: pl.Series = row['dt_prev']
if prev_r.is_empty():
# XXX, filter out any special ignore cases,
# - UNIX-epoch stamped datums
# - first row
if (
dt_prev.dt.epoch()[0] == 0
or
dt.dt.epoch()[0] == 0
):
log.warning('Skipping row with UNIX epoch timestamp ??')
continue
if wdts[0]['index'][0] == iend: # first row
log.warning('Skipping first-row (has no previous obvi) !!')
continue
# XXX, if the previous-row by shm-index is missing,
# meaning there is a missing sample (set), get the prior
# row by df index and attempt to use it?
i_wdts: pl.DataFrame = wdts.with_row_index(name='i')
i_row: int = i_wdts.filter(pl.col('index') == iend)['i'][0]
prev_row_by_i = wdts[i_row]
prev_r: pl.DataFrame = prev_row_by_i
# debug any missing pre-row
if tractor._state.is_debug_mode():
await tractor.pause()
istart: int = prev_r['index'][0]
# TODO: implement px-col width measure
# and ensure at least as many px-cols
# shown per rect as configured by user.
# gap_w: float = abs((iend - istart))
# if gap_w < 6:
# margin: float = 6
# iend += margin
# istart -= margin
opn: float = row['open'][0]
cls: float = prev_r['close'][0]
# get gap duration for humanized label
gap_dur_s: float = row['s_diff'][0]
gap_label: str = humanize_duration(gap_dur_s)
# XXX: get timestamps for server-side index lookup
start_time: float = prev_r['time'][0]
end_time: float = row['time'][0]
# BGM=0.16 is the normal diff from overlap between bars, SO
# just go slightly "in" from that "between them".
from_idx: int = BGM - .06 # = .10
lc: tuple[float, float] = (
istart + 1 - from_idx,
cls,
)
ro: tuple[float, float] = (
iend + from_idx,
opn,
)
diff: float = cls - opn
sgn: float = copysign(1, diff)
up_gap: bool = sgn == -1
down_gap: bool = sgn == 1
flat: bool = sgn == 0
color: str = 'dad_blue'
# TODO? mks more sense to have up/down coloring?
# color: str = {
# -1: 'lilypad_green', # up-gap
# 1: 'wine', # down-gap
# }[sgn]
rect_kwargs: dict[str, Any] = dict(
fqme=fqme,
timeframe=timeframe,
start_pos=lc,
end_pos=ro,
color=color,
start_time=start_time,
end_time=end_time,
)
# 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 = (
'down' if down_gap
else 'up'
)
# TODO! mk this a `msgspec.Struct` which we deserialize
# 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]
arrow_kwargs: dict[str, Any] = dict(
fqme=fqme,
timeframe=timeframe,
x=iend, # fallback if timestamp lookup fails
y=cls,
time=gap_time, # for server-side index lookup
color=color,
alpha=169,
pointing=direction,
# TODO: expose these as params to markup_gaps()?
headLen=10,
headWidth=2.222,
pxMode=True,
)
aid: int = await actl.add_arrow(
**arrow_kwargs
)
# add duration label to RHS of arrow
if up_gap:
anchor = (0, 0)
# ^XXX? i dun get dese dims.. XD
elif down_gap:
anchor = (0, 1) # XXX y, x?
else: # no-gap?
assert flat
anchor = (0, 0) # up from bottom
# use a slightly smaller font for gap label txt.
font, small_font = get_fonts()
font_size: int = small_font.px_size - 1
assert isinstance(font_size, int)
if show_txt:
text_aid: int = await actl.add_text(
fqme=fqme,
timeframe=timeframe,
text=gap_label,
x=iend + 1, # fallback if timestamp lookup fails
y=cls,
time=gap_time, # server-side index lookup
color=color,
anchor=anchor,
font_size=font_size,
)
aids[text_aid] = {'text': gap_label}
# tell chart to redraw all its
# graphics view layers Bo
await actl.redraw(
fqme=fqme,
timeframe=timeframe,
)
return aids

View File

@ -1,206 +0,0 @@
'''
Smart OHLCV deduplication with data quality validation.
Handles concurrent write conflicts by keeping the most complete bar
(highest volume) while detecting data quality anomalies.
'''
import polars as pl
from ._anal import with_dts
def dedupe_ohlcv_smart(
src_df: pl.DataFrame,
time_col: str = 'time',
volume_col: str = 'volume',
sort: bool = True,
) -> tuple[
pl.DataFrame, # with dts
pl.DataFrame, # deduped (keeping higher volume bars)
int, # count of dupes removed
pl.DataFrame|None, # valid race conditions
pl.DataFrame|None, # data quality violations
]:
'''
Smart OHLCV deduplication keeping most complete bars.
For duplicate timestamps, keeps bar with highest volume under
the assumption that higher volume indicates more complete/final
data from backfill vs partial live updates.
Returns
-------
Tuple of:
- wdts: original dataframe with datetime columns added
- deduped: deduplicated frame keeping highest-volume bars
- diff: number of duplicate rows removed
- valid_races: duplicates meeting expected race condition pattern
(volume monotonic, OHLC ranges valid)
- data_quality_issues: duplicates violating expected relationships
indicating provider data problems
'''
wdts: pl.DataFrame = with_dts(src_df)
# Find duplicate timestamps
dupes: pl.DataFrame = wdts.filter(
pl.col(time_col).is_duplicated()
)
if dupes.is_empty():
# No duplicates, return as-is
return (wdts, wdts, 0, None, None)
# Analyze duplicate groups for validation
dupe_analysis: pl.DataFrame = (
dupes
.sort([time_col, 'index'])
.group_by(time_col, maintain_order=True)
.agg([
pl.col('index').alias('indices'),
pl.col('volume').alias('volumes'),
pl.col('high').alias('highs'),
pl.col('low').alias('lows'),
pl.col('open').alias('opens'),
pl.col('close').alias('closes'),
pl.col('dt').first().alias('dt'),
pl.len().alias('count'),
])
)
# Validate OHLCV monotonicity for each duplicate group
def check_ohlcv_validity(row) -> dict[str, bool]:
'''
Check if duplicate bars follow expected race condition pattern.
For a valid live-update backfill race:
- volume should be monotonically increasing
- high should be monotonically non-decreasing
- low should be monotonically non-increasing
- open should be identical (fixed at bar start)
Returns dict of violation flags.
'''
vols: list = row['volumes']
highs: list = row['highs']
lows: list = row['lows']
opens: list = row['opens']
violations: dict[str, bool] = {
'volume_non_monotonic': False,
'high_decreased': False,
'low_increased': False,
'open_mismatch': False,
'identical_bars': False,
}
# Check if all bars are identical (pure duplicate)
if (
len(set(vols)) == 1
and len(set(highs)) == 1
and len(set(lows)) == 1
and len(set(opens)) == 1
):
violations['identical_bars'] = True
return violations
# Check volume monotonicity
for i in range(1, len(vols)):
if vols[i] < vols[i-1]:
violations['volume_non_monotonic'] = True
break
# Check high monotonicity (can only increase or stay same)
for i in range(1, len(highs)):
if highs[i] < highs[i-1]:
violations['high_decreased'] = True
break
# Check low monotonicity (can only decrease or stay same)
for i in range(1, len(lows)):
if lows[i] > lows[i-1]:
violations['low_increased'] = True
break
# Check open consistency (should be fixed)
if len(set(opens)) > 1:
violations['open_mismatch'] = True
return violations
# Apply validation
dupe_analysis = dupe_analysis.with_columns([
pl.struct(['volumes', 'highs', 'lows', 'opens'])
.map_elements(
check_ohlcv_validity,
return_dtype=pl.Struct([
pl.Field('volume_non_monotonic', pl.Boolean),
pl.Field('high_decreased', pl.Boolean),
pl.Field('low_increased', pl.Boolean),
pl.Field('open_mismatch', pl.Boolean),
pl.Field('identical_bars', pl.Boolean),
])
)
.alias('validity')
])
# Unnest validity struct
dupe_analysis = dupe_analysis.unnest('validity')
# Separate valid races from data quality issues
valid_races: pl.DataFrame|None = (
dupe_analysis
.filter(
# Valid if no violations OR just identical bars
~pl.col('volume_non_monotonic')
& ~pl.col('high_decreased')
& ~pl.col('low_increased')
& ~pl.col('open_mismatch')
)
)
if valid_races.is_empty():
valid_races = None
data_quality_issues: pl.DataFrame|None = (
dupe_analysis
.filter(
# Issues if any non-identical violation exists
(
pl.col('volume_non_monotonic')
| pl.col('high_decreased')
| pl.col('low_increased')
| pl.col('open_mismatch')
)
& ~pl.col('identical_bars')
)
)
if data_quality_issues.is_empty():
data_quality_issues = None
# Deduplicate: keep highest volume bar for each timestamp
deduped: pl.DataFrame = (
wdts
.sort([time_col, volume_col])
.unique(
subset=[time_col],
keep='last',
maintain_order=False,
)
)
# Re-sort by time or index
if sort:
deduped = deduped.sort(by=time_col)
diff: int = wdts.height - deduped.height
return (
wdts,
deduped,
diff,
valid_races,
data_quality_issues,
)

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -21,24 +21,20 @@ Main app startup and run.
from functools import partial from functools import partial
from types import ModuleType from types import ModuleType
import tractor
import trio import trio
from piker.ui.qt import ( from piker.ui.qt import (
QEvent, QEvent,
) )
from . import _chart
from . import _event
from . import _search
from ..accounting import unpack_fqme
from ..data._symcache import open_symcache
from ..data.feed import install_brokerd_search
from ..log import (
get_logger,
get_console_log,
)
from ..service import maybe_spawn_brokerd from ..service import maybe_spawn_brokerd
from . import _event
from ._exec import run_qtractor from ._exec import run_qtractor
from ..data.feed import install_brokerd_search
from ..data._symcache import open_symcache
from ..accounting import unpack_fqme
from . import _search
from ._chart import GodWidget
from ..log import get_logger
log = get_logger(__name__) log = get_logger(__name__)
@ -76,8 +72,8 @@ async def load_provider_search(
async def _async_main( async def _async_main(
# implicit required argument provided by `qtractor_run()` # implicit required argument provided by ``qtractor_run()``
main_widget: _chart.GodWidget, main_widget: GodWidget,
syms: list[str], syms: list[str],
brokers: dict[str, ModuleType], brokers: dict[str, ModuleType],
@ -90,16 +86,6 @@ async def _async_main(
Provision the "main" widget with initial symbol data and root nursery. Provision the "main" widget with initial symbol data and root nursery.
""" """
# enable chart's console logging
if loglevel:
get_console_log(
level=loglevel,
name=__name__,
)
# set as singleton
_chart._godw = main_widget
from . import _display from . import _display
from ._pg_overrides import _do_overrides from ._pg_overrides import _do_overrides
_do_overrides() _do_overrides()
@ -130,7 +116,6 @@ async def _async_main(
needed_brokermods[brokername] = brokers[brokername] needed_brokermods[brokername] = brokers[brokername]
async with ( async with (
tractor.trionics.collapse_eg(),
trio.open_nursery() as root_n, 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
@ -214,6 +199,6 @@ def _main(
brokermods, brokermods,
piker_loglevel, piker_loglevel,
), ),
main_widget_type=_chart.GodWidget, main_widget_type=GodWidget,
tractor_kwargs=tractor_kwargs, tractor_kwargs=tractor_kwargs,
) )

View File

@ -29,9 +29,11 @@ from typing import (
) )
import pyqtgraph as pg import pyqtgraph as pg
import trio
from piker.ui.qt import ( from piker.ui.qt import (
QtCore, QtCore,
QtWidgets,
Qt, Qt,
QLineF, QLineF,
QFrame, QFrame,
@ -40,7 +42,6 @@ from piker.ui.qt import (
QVBoxLayout, QVBoxLayout,
QSplitter, QSplitter,
) )
from ._widget import GodWidget
from ._axes import ( from ._axes import (
DynamicDateAxis, DynamicDateAxis,
PriceAxis, PriceAxis,
@ -61,6 +62,10 @@ from ._style import (
_xaxis_at, _xaxis_at,
# _min_points_to_show, # _min_points_to_show,
) )
from ..data.feed import (
Feed,
Flume,
)
from ..accounting import ( from ..accounting import (
MktPair, MktPair,
) )
@ -74,12 +79,286 @@ from . import _pg_overrides as pgo
if TYPE_CHECKING: if TYPE_CHECKING:
from ._display import DisplayState from ._display import DisplayState
from ..data.flows import Flume
from ..data.feed import Feed
log = get_logger(__name__) log = get_logger(__name__)
class GodWidget(QWidget):
'''
"Our lord and savior, the holy child of window-shua, there is no
widget above thee." - 6|6
The highest level composed widget which contains layouts for
organizing charts as well as other sub-widgets used to control or
modify them.
'''
search: SearchWidget
mode_name: str = 'god'
def __init__(
self,
parent=None,
) -> None:
super().__init__(parent)
self.search: SearchWidget | None = None
self.hbox = QHBoxLayout(self)
self.hbox.setContentsMargins(0, 0, 0, 0)
self.hbox.setSpacing(6)
self.hbox.setAlignment(Qt.AlignTop)
self.vbox = QVBoxLayout()
self.vbox.setContentsMargins(0, 0, 0, 0)
self.vbox.setSpacing(2)
self.vbox.setAlignment(Qt.AlignTop)
self.hbox.addLayout(self.vbox)
self._chart_cache: dict[
str,
tuple[LinkedSplits, LinkedSplits],
] = {}
self.hist_linked: LinkedSplits | None = None
self.rt_linked: LinkedSplits | None = None
self._active_cursor: Cursor | None = None
# assigned in the startup func `_async_main()`
self._root_n: trio.Nursery = None
self._widgets: dict[str, QWidget] = {}
self._resizing: bool = False
# TODO: do we need this, when would god get resized
# and the window does not? Never right?!
# self.reg_for_resize(self)
# TODO: strat loader/saver that we don't need yet.
# def init_strategy_ui(self):
# self.toolbar_layout = QHBoxLayout()
# self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
# self.vbox.addLayout(self.toolbar_layout)
# self.strategy_box = StrategyBoxWidget(self)
# self.toolbar_layout.addWidget(self.strategy_box)
@property
def linkedsplits(self) -> LinkedSplits:
return self.rt_linked
def set_chart_symbols(
self,
group_key: tuple[str], # of form <fqme>.<providername>
all_linked: tuple[LinkedSplits, LinkedSplits], # type: ignore
) -> None:
# re-sort org cache symbol list in LIFO order
cache = self._chart_cache
cache.pop(group_key, None)
cache[group_key] = all_linked
def get_chart_symbols(
self,
symbol_key: str,
) -> tuple[LinkedSplits, LinkedSplits]: # type: ignore
return self._chart_cache.get(symbol_key)
async def load_symbols(
self,
fqmes: list[str],
loglevel: str,
reset: bool = False,
) -> trio.Event:
'''
Load a new contract into the charting app.
Expects a ``numpy`` structured array containing all the ohlcv fields.
'''
# NOTE: for now we use the first symbol in the set as the "key"
# for the overlay of feeds on the chart.
group_key: tuple[str] = tuple(fqmes)
all_linked = self.get_chart_symbols(group_key)
order_mode_started = trio.Event()
if not self.vbox.isEmpty():
# XXX: seems to make switching slower?
# qframe = self.hist_linked.chart.qframe
# if qframe.sidepane is self.search:
# qframe.hbox.removeWidget(self.search)
for linked in [self.rt_linked, self.hist_linked]:
# XXX: this is CRITICAL especially with pixel buffer caching
linked.hide()
linked.unfocus()
# XXX: pretty sure we don't need this
# remove any existing plots?
# XXX: ahh we might want to support cache unloading..
# self.vbox.removeWidget(linked)
# switching to a new viewable chart
if all_linked is None or reset:
from ._display import display_symbol_data
# we must load a fresh linked charts set
self.rt_linked = rt_charts = LinkedSplits(self)
self.hist_linked = hist_charts = LinkedSplits(self)
# spawn new task to start up and update new sub-chart instances
self._root_n.start_soon(
display_symbol_data,
self,
fqmes,
loglevel,
order_mode_started,
)
# self.vbox.addWidget(hist_charts)
self.vbox.addWidget(rt_charts)
self.set_chart_symbols(
group_key,
(hist_charts, rt_charts),
)
for linked in [hist_charts, rt_charts]:
linked.show()
linked.focus()
await trio.sleep(0)
else:
# symbol is already loaded and ems ready
order_mode_started.set()
self.hist_linked, self.rt_linked = all_linked
for linked in all_linked:
# TODO:
# - we'll probably want per-instrument/provider state here?
# change the order config form over to the new chart
# chart is already in memory so just focus it
linked.show()
linked.focus()
linked.graphics_cycle()
await trio.sleep(0)
# resume feeds *after* rendering chart view asap
chart = linked.chart
if chart:
chart.resume_all_feeds()
# TODO: we need a check to see if the chart
# last had the xlast in view, if so then shift so it's
# still in view, if the user was viewing history then
# do nothing yah?
self.rt_linked.chart.main_viz.default_view(
do_min_bars=True,
)
# if a history chart instance is already up then
# set the search widget as its sidepane.
hist_chart = self.hist_linked.chart
if hist_chart:
hist_chart.qframe.set_sidepane(self.search)
# NOTE: this is really stupid/hard to follow.
# we have to reposition the active position nav
# **AFTER** applying the search bar as a sidepane
# to the newly switched to symbol.
await trio.sleep(0)
# TODO: probably stick this in some kinda `LooknFeel` API?
for tracker in self.rt_linked.mode.trackers.values():
pp_nav = tracker.nav
if tracker.live_pp.cumsize:
pp_nav.show()
pp_nav.hide_info()
else:
pp_nav.hide()
# set window titlebar info
symbol = self.rt_linked.mkt
if symbol is not None:
self.window.setWindowTitle(
f'{symbol.fqme} '
f'tick:{symbol.size_tick}'
)
return order_mode_started
def focus(self) -> None:
'''
Focus the top level widget which in turn focusses the chart
ala "view mode".
'''
# go back to view-mode focus (aka chart focus)
self.clearFocus()
chart = self.rt_linked.chart
if chart:
chart.setFocus()
def reg_for_resize(
self,
widget: QWidget,
) -> None:
getattr(widget, 'on_resize')
self._widgets[widget.mode_name] = widget
def on_win_resize(self, event: QtCore.QEvent) -> None:
'''
Top level god widget handler from window (the real yaweh) resize
events such that any registered widgets which wish to be
notified are invoked using our pythonic `.on_resize()` method
api.
Where we do UX magic to make things not suck B)
'''
if self._resizing:
return
self._resizing = True
log.info('God widget resize')
for name, widget in self._widgets.items():
widget.on_resize()
self._resizing = False
# on_resize = on_win_resize
def get_cursor(self) -> Cursor:
return self._active_cursor
def iter_linked(self) -> Iterator[LinkedSplits]:
for linked in [self.hist_linked, self.rt_linked]:
yield linked
def resize_all(self) -> None:
'''
Dynamic resize sequence: adjusts all sub-widgets/charts to
sensible default ratios of what space is detected as available
on the display / window.
'''
rt_linked = self.rt_linked
rt_linked.set_split_sizes()
self.rt_linked.resize_sidepanes()
self.hist_linked.resize_sidepanes(from_linked=rt_linked)
self.search.on_resize()
class ChartnPane(QFrame): class ChartnPane(QFrame):
''' '''
One-off ``QFrame`` composite which pairs a chart One-off ``QFrame`` composite which pairs a chart
@ -141,6 +420,7 @@ class LinkedSplits(QWidget):
''' '''
def __init__( def __init__(
self, self,
godwidget: GodWidget, godwidget: GodWidget,
@ -288,8 +568,8 @@ class LinkedSplits(QWidget):
# style? # style?
self.chart.setFrameStyle( self.chart.setFrameStyle(
QFrame.Shape.StyledPanel QFrame.Shape.StyledPanel |
|QFrame.Shadow.Plain QFrame.Shadow.Plain
) )
return self.chart return self.chart
@ -752,7 +1032,7 @@ class ChartPlotWidget(pg.PlotWidget):
) -> None: ) -> None:
''' '''
Increment the data view `datums`` steps toward y-axis thus Increment the data view ``datums``` steps toward y-axis thus
"following" the current time slot/step/bar. "following" the current time slot/step/bar.
''' '''
@ -762,7 +1042,7 @@ class ChartPlotWidget(pg.PlotWidget):
x_shift = viz.index_step() * datums x_shift = viz.index_step() * datums
if datums >= 300: if datums >= 300:
log.warning('FUCKING FIX THE GLOBAL STEP BULLSHIT') print("FUCKING FIX THE GLOBAL STEP BULLSHIT")
# breakpoint() # breakpoint()
return return

View File

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

View File

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

View File

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

View File

@ -21,7 +21,6 @@ this module ties together quote and computational (fsp) streams with
graphics update methods via our custom ``pyqtgraph`` charting api. graphics update methods via our custom ``pyqtgraph`` charting api.
''' '''
from functools import partial
import itertools import itertools
from math import floor from math import floor
import time import time
@ -209,7 +208,6 @@ class DisplayState(Struct):
async def increment_history_view( async def increment_history_view(
# min_istream: tractor.MsgStream, # min_istream: tractor.MsgStream,
ds: DisplayState, ds: DisplayState,
loglevel: str = 'warning',
): ):
hist_chart: ChartPlotWidget = ds.hist_chart hist_chart: ChartPlotWidget = ds.hist_chart
hist_viz: Viz = ds.hist_viz hist_viz: Viz = ds.hist_viz
@ -231,10 +229,7 @@ async def increment_history_view(
hist_viz.reset_graphics() hist_viz.reset_graphics()
# hist_viz.update_graphics(force_redraw=True) # hist_viz.update_graphics(force_redraw=True)
async with open_sample_stream( async with open_sample_stream(1.) as min_istream:
period_s=1.,
loglevel=loglevel,
) as min_istream:
async for msg in min_istream: async for msg in min_istream:
profiler = Profiler( profiler = Profiler(
@ -315,6 +310,7 @@ async def increment_history_view(
async def graphics_update_loop( async def graphics_update_loop(
dss: dict[str, DisplayState], dss: dict[str, DisplayState],
nurse: trio.Nursery, nurse: trio.Nursery,
godwidget: GodWidget, godwidget: GodWidget,
@ -323,7 +319,6 @@ async def graphics_update_loop(
pis: dict[str, list[pgo.PlotItem, pgo.PlotItem]] = {}, pis: dict[str, list[pgo.PlotItem, pgo.PlotItem]] = {},
vlm_charts: dict[str, ChartPlotWidget] = {}, vlm_charts: dict[str, ChartPlotWidget] = {},
loglevel: str = 'warning',
) -> None: ) -> None:
''' '''
@ -467,12 +462,9 @@ async def graphics_update_loop(
# }) # })
nurse.start_soon( nurse.start_soon(
partial(
increment_history_view, increment_history_view,
# min_istream, # min_istream,
ds=ds, ds,
loglevel=loglevel,
),
) )
await trio.sleep(0) await trio.sleep(0)
@ -519,19 +511,14 @@ async def graphics_update_loop(
fast_chart.linked.isHidden() fast_chart.linked.isHidden()
or not rt_pi.isVisible() or not rt_pi.isVisible()
): ):
log.debug( print(f'{fqme} skipping update for HIDDEN CHART')
f'{fqme} skipping update for HIDDEN CHART'
)
fast_chart.pause_all_feeds() fast_chart.pause_all_feeds()
continue continue
ic = fast_chart.view._in_interact ic = fast_chart.view._in_interact
if ic: if ic:
fast_chart.pause_all_feeds() fast_chart.pause_all_feeds()
log.debug( print(f'{fqme} PAUSING DURING INTERACTION')
f'Pausing chart updaates during interaction\n'
f'fqme: {fqme!r}'
)
await ic.wait() await ic.wait()
fast_chart.resume_all_feeds() fast_chart.resume_all_feeds()
@ -1458,10 +1445,7 @@ async def display_symbol_data(
# for pause/resume on mouse interaction # for pause/resume on mouse interaction
rt_chart.feed = feed rt_chart.feed = feed
async with ( async with trio.open_nursery() as ln:
tractor.trionics.collapse_eg(),
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[
str, str,
@ -1604,18 +1588,15 @@ async def display_symbol_data(
# start update loop task # start update loop task
dss: dict[str, DisplayState] = {} dss: dict[str, DisplayState] = {}
ln.start_soon( ln.start_soon(
partial(
graphics_update_loop, graphics_update_loop,
dss=dss, dss,
nurse=ln, ln,
godwidget=godwidget, godwidget,
feed=feed, feed,
# min_istream, # min_istream,
pis=pis, pis,
vlm_charts=vlm_charts, vlm_charts,
loglevel=loglevel,
)
) )
# boot order-mode # boot order-mode

View File

@ -21,7 +21,6 @@ Higher level annotation editors.
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import ( from typing import (
Literal,
Sequence, Sequence,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -55,11 +54,6 @@ from ._style import (
from ._lines import LevelLine from ._lines import LevelLine
from ..log import get_logger from ..log import get_logger
# TODO, rm the cycle here!
from ._widget import (
GodWidget,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ._chart import ( from ._chart import (
GodWidget, GodWidget,
@ -72,18 +66,9 @@ log = get_logger(__name__)
class ArrowEditor(Struct): class ArrowEditor(Struct):
'''
Annotate a chart-view with arrows most often used for indicating,
- order txns/clears,
- positions directions,
- general points-of-interest like nooz events.
'''
godw: GodWidget = None # type: ignore # noqa godw: GodWidget = None # type: ignore # noqa
_arrows: dict[ _arrows: dict[str, list[pg.ArrowItem]] = {}
str,
list[pg.ArrowItem]
] = {}
def add( def add(
self, self,
@ -91,19 +76,8 @@ class ArrowEditor(Struct):
uid: str, uid: str,
x: float, x: float,
y: float, y: float,
color: str|None = None, color: str = 'default',
pointing: Literal[ pointing: str | None = None,
'up',
'down',
None,
] = None,
alpha: int = 255,
zval: float = 1e9,
headLen: float|None = None,
headWidth: float|None = None,
tailLen: float|None = None,
tailWidth: float|None = None,
pxMode: bool = True,
) -> pg.ArrowItem: ) -> pg.ArrowItem:
''' '''
@ -119,83 +93,29 @@ class ArrowEditor(Struct):
# scale arrow sizing to dpi-aware font # scale arrow sizing to dpi-aware font
size = _font.font.pixelSize() * 0.8 size = _font.font.pixelSize() * 0.8
# allow caller override of head dimensions
if headLen is None:
headLen = size
if headWidth is None:
headWidth = size/2
# tail params default to None (no tail)
if tailWidth is None:
tailWidth = 3
color = color or 'default'
color = QColor(hcolor(color))
color.setAlpha(alpha)
pen = fn.mkPen(color, width=1)
brush = fn.mkBrush(color)
arrow = pg.ArrowItem( arrow = pg.ArrowItem(
angle=angle, angle=angle,
baseAngle=0, baseAngle=0,
headLen=headLen, headLen=size,
headWidth=headWidth, headWidth=size/2,
tailLen=tailLen, tailLen=None,
tailWidth=tailWidth, pxMode=True,
pxMode=pxMode,
# coloring
pen=pen,
brush=brush,
)
arrow.setZValue(zval)
arrow.setPos(x, y)
plot.addItem(arrow) # render to view
# register for removal # coloring
arrow._uid = uid pen=pg.mkPen(hcolor('papas_special')),
self._arrows.setdefault( brush=pg.mkBrush(hcolor(color)),
uid, [] )
).append(arrow) arrow.setPos(x, y)
self._arrows.setdefault(uid, []).append(arrow)
# render to view
plot.addItem(arrow)
return arrow return arrow
def remove( def remove(self, arrow) -> bool:
self,
arrow: pg.ArrowItem,
) -> None:
'''
Remove a *single arrow* from all chart views to which it was
added.
'''
uid: str = arrow._uid
arrows: list[pg.ArrowItem] = self._arrows[uid]
log.info(
f'Removing arrow from views\n'
f'uid: {uid!r}\n'
f'{arrow!r}\n'
)
for linked in self.godw.iter_linked(): for linked in self.godw.iter_linked():
if not (chart := linked.chart): linked.chart.plotItem.removeItem(arrow)
continue
chart.plotItem.removeItem(arrow)
try:
arrows.remove(arrow)
except ValueError:
log.warning(
f'Arrow was already removed?\n'
f'uid: {uid!r}\n'
f'{arrow!r}\n'
)
def remove_all(self) -> set[pg.ArrowItem]:
'''
Remove all arrows added by this editor from all
chart-views.
'''
for uid, arrows in self._arrows.items():
for arrow in arrows:
self.remove(arrow)
class LineEditor(Struct): class LineEditor(Struct):
@ -341,9 +261,6 @@ class LineEditor(Struct):
return lines return lines
# compat with ArrowEditor
remove = remove_line
def as_point( def as_point(
pair: Sequence[float, float] | QPointF, pair: Sequence[float, float] | QPointF,
@ -692,6 +609,3 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
): ):
scen.removeItem(self._label_proxy) scen.removeItem(self._label_proxy)
# compat with ArrowEditor
remove = delete

View File

@ -22,10 +22,7 @@ from contextlib import asynccontextmanager as acm
from typing import Callable from typing import Callable
import trio import trio
from tractor.trionics import ( from tractor.trionics import gather_contexts
gather_contexts,
collapse_eg,
)
from piker.ui.qt import ( from piker.ui.qt import (
QtCore, QtCore,
@ -210,10 +207,7 @@ async def open_signal_handler(
async for args in recv: async for args in recv:
await async_handler(*args) await async_handler(*args)
async with ( async with trio.open_nursery() as tn:
collapse_eg(),
trio.open_nursery() as tn
):
tn.start_soon(proxy_to_handler) tn.start_soon(proxy_to_handler)
async with send: async with send:
yield yield
@ -248,7 +242,6 @@ async def open_handlers(
widget: QWidget widget: QWidget
streams: list[trio.abc.ReceiveChannel] streams: list[trio.abc.ReceiveChannel]
async with ( async with (
collapse_eg(),
trio.open_nursery() as tn, trio.open_nursery() as tn,
gather_contexts([ gather_contexts([
open_event_stream( open_event_stream(

View File

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

View File

@ -18,11 +18,10 @@
Feed status and controls widget(s) for embedding in a UI-pane. Feed status and controls widget(s) for embedding in a UI-pane.
""" """
from __future__ import annotations from __future__ import annotations
from typing import ( from textwrap import dedent
Any, from typing import TYPE_CHECKING
TYPE_CHECKING,
)
# from PyQt5.QtCore import Qt # from PyQt5.QtCore import Qt
@ -50,55 +49,35 @@ def mk_feed_label(
a feed control protocol. a feed control protocol.
''' '''
status: dict[str, Any] = feed.status status = feed.status
assert status assert status
# SO tips on ws/nls, msg = dedent("""
# https://stackoverflow.com/a/15721400 actor: **{actor_name}**\n
ws: str = '&nbsp;' |_ @**{host}:{port}**\n
# nl: str = '<br>' # dun work? """)
actor_info_repr: str = (
f')> **{status["actor_short_id"]}**\n'
'\n' # bc md?
)
# fields to select *IN* for display for key, val in status.items():
# (see `.data.feed.open_feed()` status if key in ('host', 'port', 'actor_name'):
# update -> TAG_feed_status_update) continue
for key in [ msg += f'\n|_ {key}: **{{{key}}}**\n'
'ipc',
'hist_shm',
'rt_shm',
'throttle_hz',
]:
# NOTE, the 2nd key is filled via `.format()` updates.
actor_info_repr += (
f'\n' # bc md?
f'{ws}|_{key}: **{{{key}}}**\n'
)
# ^TODO? formatting and content..
# -[ ] showing which fqme is "forward" on the
# chart/fsp/order-mode?
# '|_ flows: **{symbols}**\n'
#
# -[x] why isn't the indent working?
# => markdown, now solved..
feed_label = FormatLabel( feed_label = FormatLabel(
fmt_str=actor_info_repr, fmt_str=msg,
# |_ streams: **{symbols}**\n
font=_font.font, font=_font.font,
font_size=_font_small.px_size, font_size=_font_small.px_size,
font_color='default_lightest', font_color='default_lightest',
) )
# ?TODO, remove this?
# form.vbox.setAlignment(feed_label, Qt.AlignBottom) # form.vbox.setAlignment(feed_label, Qt.AlignBottom)
# form.vbox.setAlignment(Qt.AlignBottom) # form.vbox.setAlignment(Qt.AlignBottom)
# _ = chart.height() - ( _ = chart.height() - (
# form.height() + form.height() +
# form.fill_bar.height() form.fill_bar.height()
# # feed_label.height() # feed_label.height()
# ) )
feed_label.format(**feed.status) feed_label.format(**feed.status)
return feed_label return feed_label

View File

@ -87,11 +87,7 @@ def update_fsp_chart(
# guard against unreadable case # guard against unreadable case
if not last_row: if not last_row:
log.warning( log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}')
f'Read-race on shm array,\n'
f'graphics_name: {graphics_name!r}\n'
f'shm.token: {shm.token}\n'
)
return return
# update graphics # update graphics
@ -183,17 +179,13 @@ async def open_fsp_sidepane(
@acm @acm
async def open_fsp_actor_cluster( async def open_fsp_actor_cluster(
names: list[str] = [ names: list[str] = ['fsp_0', 'fsp_1'],
'fsp_0',
'fsp_1',
],
) -> AsyncGenerator[ ) -> AsyncGenerator[
int, int,
dict[str, tractor.Portal] dict[str, tractor.Portal]
]: ]:
# TODO! change to .experimental!
from tractor._clustering import open_actor_cluster from tractor._clustering import open_actor_cluster
# profiler = Profiler( # profiler = Profiler(
@ -201,7 +193,7 @@ async def open_fsp_actor_cluster(
# disabled=False # disabled=False
# ) # )
async with open_actor_cluster( async with open_actor_cluster(
count=len(names), count=2,
names=names, names=names,
modules=['piker.fsp._engine'], modules=['piker.fsp._engine'],
@ -211,6 +203,7 @@ async def open_fsp_actor_cluster(
async def run_fsp_ui( async def run_fsp_ui(
linkedsplits: LinkedSplits, linkedsplits: LinkedSplits,
flume: Flume, flume: Flume,
started: trio.Event, started: trio.Event,
@ -501,8 +494,7 @@ class FspAdmin:
portal: tractor.Portal = ( portal: tractor.Portal = (
self.cluster.get(worker_name) self.cluster.get(worker_name)
or or self.rr_next_portal()
self.rr_next_portal()
) )
# TODO: this should probably be turned into a # TODO: this should probably be turned into a
@ -608,7 +600,6 @@ async def open_fsp_admin(
kwargs=kwargs, kwargs=kwargs,
) as (cache_hit, cluster_map), ) as (cache_hit, cluster_map),
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn, trio.open_nursery() as tn,
): ):
if cache_hit: if cache_hit:
@ -622,8 +613,6 @@ async def open_fsp_admin(
) )
try: try:
yield admin yield admin
# ??TODO, does this *need* to be inside a finally?
finally: finally:
# terminate all tasks via signals # terminate all tasks via signals
for key, entry in admin._registry.items(): for key, entry in admin._registry.items():
@ -631,10 +620,8 @@ async def open_fsp_admin(
event.set() event.set()
# TODO, passing in `pikerd` related settings here!
# [ ] read in the `tractor` setting for `enable_transports: list`
# from the root `conf.toml`!
async def open_vlm_displays( async def open_vlm_displays(
linked: LinkedSplits, linked: LinkedSplits,
flume: Flume, flume: Flume,
dvlm: bool = True, dvlm: bool = True,
@ -644,12 +631,12 @@ async def open_vlm_displays(
) -> None: ) -> None:
''' '''
Vlm (volume) subchart displays. Volume subchart displays.
Since "volume" is often included directly alongside OHLCV price Since "volume" is often included directly alongside OHLCV price
data, we don't really need a separate FSP-actor + shm array for data, we don't really need a separate FSP-actor + shm array for it
it since it's likely already directly adjacent to OHLC samples since it's likely already directly adjacent to OHLC samples from the
from the data provider. data provider.
Further only if volume data is detected (it sometimes isn't provided Further only if volume data is detected (it sometimes isn't provided
eg. forex, certain commodities markets) will volume dependent FSPs eg. forex, certain commodities markets) will volume dependent FSPs

View File

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

View File

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

View File

@ -285,20 +285,18 @@ class FormatLabel(QLabel):
font_size: int, font_size: int,
font_color: str, font_color: str,
use_md: bool = True,
parent=None, parent=None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
# by default set the format string verbatim and expect user # by default set the format string verbatim and expect user to
# to call ``.format()`` later (presumably they'll notice the # call ``.format()`` later (presumably they'll notice the
# unformatted content if ``fmt_str`` isn't meant to be # unformatted content if ``fmt_str`` isn't meant to be
# unformatted). # unformatted).
self.fmt_str = fmt_str self.fmt_str = fmt_str
# self.setText(fmt_str) # ?TODO, why here? self.setText(fmt_str)
self.setStyleSheet( self.setStyleSheet(
f"""QLabel {{ f"""QLabel {{
@ -308,7 +306,6 @@ class FormatLabel(QLabel):
""" """
) )
self.setFont(_font.font) self.setFont(_font.font)
if use_md:
self.setTextFormat( self.setTextFormat(
Qt.TextFormat.MarkdownText Qt.TextFormat.MarkdownText
) )
@ -319,10 +316,7 @@ class FormatLabel(QLabel):
size_policy.Expanding, size_policy.Expanding,
) )
self.setAlignment( self.setAlignment(
Qt.AlignLeft Qt.AlignVCenter | Qt.AlignLeft
|
Qt.AlignBottom
# Qt.AlignVCenter
) )
self.setText(self.fmt_str) self.setText(self.fmt_str)

View File

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

View File

@ -15,8 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
''' '''
Remote control tasks for sending annotations (and maybe more cmds) to Remote control tasks for sending annotations (and maybe more cmds)
a chart from some other actor. to a chart from some other actor.
''' '''
from __future__ import annotations from __future__ import annotations
@ -27,14 +27,11 @@ from contextlib import (
from functools import partial from functools import partial
from pprint import pformat from pprint import pformat
from typing import ( from typing import (
# Any,
AsyncContextManager, AsyncContextManager,
Literal,
) )
from uuid import uuid4
import pyqtgraph as pg
import tractor import tractor
import trio
from tractor import trionics from tractor import trionics
from tractor import ( from tractor import (
Portal, Portal,
@ -49,16 +46,12 @@ from piker.brokers import SymbolNotFound
from piker.ui.qt import ( from piker.ui.qt import (
QGraphicsItem, QGraphicsItem,
) )
from PyQt6.QtGui import QFont
from ._display import DisplayState from ._display import DisplayState
from ._interaction import ChartView from ._interaction import ChartView
from ._editors import ( from ._editors import SelectRect
SelectRect,
ArrowEditor,
)
from ._chart import ChartPlotWidget from ._chart import ChartPlotWidget
from ._dataviz import Viz from ._dataviz import Viz
from ._style import hcolor
log = get_logger(__name__) log = get_logger(__name__)
@ -89,40 +82,8 @@ _ctxs: IpcCtxTable = {}
# the "annotations server" which actually renders to a Qt canvas). # the "annotations server" which actually renders to a Qt canvas).
# type AnnotsTable = dict[int, QGraphicsItem] # type AnnotsTable = dict[int, QGraphicsItem]
AnnotsTable = dict[int, QGraphicsItem] AnnotsTable = dict[int, QGraphicsItem]
EditorsTable = dict[int, ArrowEditor]
_annots: AnnotsTable = {} _annots: AnnotsTable = {}
_editors: EditorsTable = {}
def rm_annot(
annot: ArrowEditor|SelectRect|pg.TextItem
) -> bool:
global _editors
match annot:
case pg.ArrowItem():
editor = _editors[annot._uid]
editor.remove(annot)
# ^TODO? only remove each arrow or all?
# if editor._arrows:
# editor.remove_all()
# else:
# log.warning(
# f'Annot already removed!\n'
# f'{annot!r}\n'
# )
return True
case SelectRect():
annot.delete()
return True
case pg.TextItem():
scene = annot.scene()
if scene:
scene.removeItem(annot)
return True
return False
async def serve_rc_annots( async def serve_rc_annots(
@ -133,12 +94,6 @@ async def serve_rc_annots(
annots: AnnotsTable, annots: AnnotsTable,
) -> None: ) -> None:
'''
A small viz(ualization) server for remote ctl of chart
annotations.
'''
global _editors
async for msg in annot_req_stream: async for msg in annot_req_stream:
match msg: match msg:
case { case {
@ -148,77 +103,14 @@ async def serve_rc_annots(
'meth': str(meth), 'meth': str(meth),
'kwargs': dict(kwargs), 'kwargs': dict(kwargs),
}: }:
ds: DisplayState = _dss[fqme] ds: DisplayState = _dss[fqme]
try:
chart: ChartPlotWidget = { chart: ChartPlotWidget = {
60: ds.hist_chart, 60: ds.hist_chart,
1: ds.chart, 1: ds.chart,
}[timeframe] }[timeframe]
except KeyError:
msg: str = (
f'No chart for timeframe={timeframe}s, '
f'skipping rect annotation'
)
log.exeception(msg)
await annot_req_stream.send({'error': msg})
continue
cv: ChartView = chart.cv cv: ChartView = chart.cv
# NEW: if timestamps provided, lookup current indices
# from shm to ensure alignment with current buffer
# state
start_time = kwargs.pop('start_time', None)
end_time = kwargs.pop('end_time', None)
if (
start_time is not None
and end_time is not None
):
viz: Viz = chart.get_viz(fqme)
shm = viz.shm
arr = shm.array
# lookup start index
start_matches = arr[arr['time'] == start_time]
if len(start_matches) == 0:
msg: str = (
f'No shm entry for start_time={start_time}, '
f'skipping rect'
)
log.error(msg)
await annot_req_stream.send({'error': msg})
continue
# lookup end index
end_matches = arr[arr['time'] == end_time]
if len(end_matches) == 0:
msg: str = (
f'No shm entry for end_time={end_time}, '
f'skipping rect'
)
log.error(msg)
await annot_req_stream.send({'error': msg})
continue
# get close price from start bar, open from end
# bar
start_idx = float(start_matches[0]['index'])
end_idx = float(end_matches[0]['index'])
start_close = float(start_matches[0]['close'])
end_open = float(end_matches[0]['open'])
# reconstruct start_pos and end_pos with
# looked-up indices
from_idx: float = 0.16 - 0.06 # BGM offset
kwargs['start_pos'] = (
start_idx + 1 - from_idx,
start_close,
)
kwargs['end_pos'] = (
end_idx + from_idx,
end_open,
)
# annot type lookup from cmd # annot type lookup from cmd
rect = SelectRect( rect = SelectRect(
viewbox=cv, viewbox=cv,
@ -237,207 +129,21 @@ async def serve_rc_annots(
# delegate generically to the requested method # delegate generically to the requested method
getattr(rect, meth)(**kwargs) getattr(rect, meth)(**kwargs)
rect.show() rect.show()
# XXX: store absolute coords for repositioning
# during viz redraws (eg backfill updates)
rect._meth = meth
rect._kwargs = kwargs
aid: int = id(rect) aid: int = id(rect)
annots[aid] = rect annots[aid] = rect
aids: set[int] = ctxs[ipc_key][1] aids: set[int] = ctxs[ipc_key][1]
aids.add(aid) aids.add(aid)
await annot_req_stream.send(aid) await annot_req_stream.send(aid)
case {
'cmd': 'ArrowEditor',
'fqme': fqme,
'timeframe': timeframe,
'meth': 'add'|'remove' as meth,
'kwargs': {
'x': float(x),
'y': float(y),
'pointing': pointing,
'color': color,
'aid': str()|None as aid,
'alpha': int(alpha),
'headLen': int()|float()|None as headLen,
'headWidth': int()|float()|None as headWidth,
'tailLen': int()|float()|None as tailLen,
'tailWidth': int()|float()|None as tailWidth,
'pxMode': bool(pxMode),
'time': int()|float()|None as timestamp,
},
# ?TODO? split based on method fn-sigs?
# 'pointing',
}:
ds: DisplayState = _dss[fqme]
try:
chart: ChartPlotWidget = {
60: ds.hist_chart,
1: ds.chart,
}[timeframe]
except KeyError:
log.warning(
f'No chart for timeframe={timeframe}s, '
f'skipping arrow annotation'
)
# return -1 to indicate failure
await annot_req_stream.send(-1)
continue
cv: ChartView = chart.cv
godw = chart.linked.godwidget
# NEW: if timestamp provided, lookup current index
# from shm to ensure alignment with current buffer
# state
if timestamp is not None:
viz: Viz = chart.get_viz(fqme)
shm = viz.shm
arr = shm.array
# find index where time matches timestamp
matches = arr[arr['time'] == timestamp]
if len(matches) == 0:
log.error(
f'No shm entry for timestamp={timestamp}, '
f'skipping arrow annotation'
)
await annot_req_stream.send(-1)
continue
# use the matched row's index as x
x = float(matches[0]['index'])
arrows = ArrowEditor(godw=godw)
# `.add/.remove()` API
if meth != 'add':
# await tractor.pause()
raise ValueError(
f'Invalid arrow-edit request ?\n'
f'{msg!r}\n'
)
aid: str = str(uuid4())
arrow: pg.ArrowItem = arrows.add(
plot=chart.plotItem,
uid=aid,
x=x,
y=y,
pointing=pointing,
color=color,
alpha=alpha,
headLen=headLen,
headWidth=headWidth,
tailLen=tailLen,
tailWidth=tailWidth,
pxMode=pxMode,
)
# XXX: store absolute coords for repositioning
# during viz redraws (eg backfill updates)
arrow._abs_x = x
arrow._abs_y = y
annots[aid] = arrow
_editors[aid] = arrows
aids: set[int] = ctxs[ipc_key][1]
aids.add(aid)
await annot_req_stream.send(aid)
case {
'cmd': 'TextItem',
'fqme': fqme,
'timeframe': timeframe,
'kwargs': {
'text': str(text),
'x': int()|float() as x,
'y': int()|float() as y,
'color': color,
'anchor': list(anchor),
'font_size': int()|None as font_size,
'time': int()|float()|None as timestamp,
},
}:
ds: DisplayState = _dss[fqme]
try:
chart: ChartPlotWidget = {
60: ds.hist_chart,
1: ds.chart,
}[timeframe]
except KeyError:
log.warning(
f'No chart for timeframe={timeframe}s, '
f'skipping text annotation'
)
await annot_req_stream.send(-1)
continue
# NEW: if timestamp provided, lookup current index
# from shm to ensure alignment with current buffer
# state
if timestamp is not None:
viz: Viz = chart.get_viz(fqme)
shm = viz.shm
arr = shm.array
# find index where time matches timestamp
matches = arr[arr['time'] == timestamp]
if len(matches) == 0:
log.error(
f'No shm entry for timestamp={timestamp}, '
f'skipping text annotation'
)
await annot_req_stream.send(-1)
continue
# use the matched row's index as x, +1 for text
# offset
x = float(matches[0]['index']) + 1
# convert named color to hex
color_hex: str = hcolor(color)
# create text item
text_item: pg.TextItem = pg.TextItem(
text=text,
color=color_hex,
anchor=anchor,
# ?TODO, pin to github:main for this?
# legacy, can have scaling ish?
# ensureInBounds=True,
)
# apply font size (default to DpiAwareFont if not
# provided)
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(x, y)
chart.plotItem.addItem(text_item)
# XXX: store absolute coords for repositioning
# during viz redraws (eg backfill updates)
text_item._abs_x = x
text_item._abs_y = y
aid: str = str(uuid4())
annots[aid] = text_item
aids: set[int] = ctxs[ipc_key][1]
aids.add(aid)
await annot_req_stream.send(aid)
case { case {
'cmd': 'remove', 'cmd': 'remove',
'aid': int(aid)|str(aid), 'aid': int(aid),
}: }:
# NOTE: this is normally entered on # NOTE: this is normally entered on
# a client's annotation de-alloc normally # a client's annotation de-alloc normally
# prior to detach or modify. # prior to detach or modify.
annot: QGraphicsItem = annots[aid] annot: QGraphicsItem = annots[aid]
assert rm_annot(annot) annot.delete()
# respond to client indicating annot # respond to client indicating annot
# was indeed deleted. # was indeed deleted.
@ -468,38 +174,6 @@ async def serve_rc_annots(
) )
viz.reset_graphics() viz.reset_graphics()
# XXX: reposition all annotations to ensure they
# stay aligned with viz data after reset (eg during
# backfill when abs-index range changes)
n_repositioned: int = 0
for aid, annot in annots.items():
# arrows and text items use abs x,y coords
if (
hasattr(annot, '_abs_x')
and
hasattr(annot, '_abs_y')
):
annot.setPos(
annot._abs_x,
annot._abs_y,
)
n_repositioned += 1
# rects use method + kwargs
elif (
hasattr(annot, '_meth')
and
hasattr(annot, '_kwargs')
):
getattr(annot, annot._meth)(**annot._kwargs)
n_repositioned += 1
if n_repositioned:
log.info(
f'Repositioned {n_repositioned} annotation(s) '
f'after viz redraw'
)
case _: case _:
log.error( log.error(
'Unknown remote annotation cmd:\n' 'Unknown remote annotation cmd:\n'
@ -513,12 +187,6 @@ async def remote_annotate(
) -> None: ) -> None:
global _dss, _ctxs global _dss, _ctxs
if not _dss:
raise RuntimeError(
'Race condition on chart-init state ??\n'
'Anoter actor is trying to annoate this chart '
'before it has fully spawned.\n'
)
assert _dss assert _dss
_ctxs[ctx.cid] = (ctx, set()) _ctxs[ctx.cid] = (ctx, set())
@ -543,7 +211,7 @@ async def remote_annotate(
assert _ctx is ctx assert _ctx is ctx
for aid in aids: for aid in aids:
annot: QGraphicsItem = _annots[aid] annot: QGraphicsItem = _annots[aid]
assert rm_annot(annot) annot.delete()
class AnnotCtl(Struct): class AnnotCtl(Struct):
@ -588,18 +256,13 @@ class AnnotCtl(Struct):
from_acm: bool = False, from_acm: bool = False,
# NEW: optional timestamps for server-side index lookup ) -> int:
start_time: float|None = None,
end_time: float|None = None,
) -> int|None:
''' '''
Add a `SelectRect` annotation to the target view, return Add a `SelectRect` annotation to the target view, return
the instances `id(obj)` from the remote UI actor. the instances `id(obj)` from the remote UI actor.
''' '''
ipc: MsgStream = self._get_ipc(fqme) ipc: MsgStream = self._get_ipc(fqme)
with trio.fail_after(3):
await ipc.send({ await ipc.send({
'fqme': fqme, 'fqme': fqme,
'cmd': 'SelectRect', 'cmd': 'SelectRect',
@ -611,15 +274,9 @@ class AnnotCtl(Struct):
'end_pos': tuple(end_pos), 'end_pos': tuple(end_pos),
'color': color, 'color': color,
'update_label': False, 'update_label': False,
'start_time': start_time,
'end_time': end_time,
}, },
}) })
aid: int|dict = await ipc.receive() aid: int = await ipc.receive()
match aid:
case {'error': str(msg)}:
log.error(msg)
return None
self._ipcs[aid] = ipc self._ipcs[aid] = ipc
if not from_acm: if not from_acm:
self._annot_stack.push_async_callback( self._annot_stack.push_async_callback(
@ -659,8 +316,6 @@ class AnnotCtl(Struct):
) )
yield aid yield aid
finally: finally:
# async ipc send op
with trio.CancelScope(shield=True):
await self.remove(aid) await self.remove(aid)
async def redraw( async def redraw(
@ -676,130 +331,20 @@ class AnnotCtl(Struct):
'timeframe': timeframe, 'timeframe': timeframe,
}) })
async def add_arrow( # TODO: do we even need this?
self, # async def modify(
fqme: str, # self,
timeframe: float, # aid: int, # annotation id
x: float, # meth: str, # far end graphics object method to invoke
y: float, # params: dict[str, Any], # far end `meth(**kwargs)`
pointing: Literal[ # ) -> bool:
'up', # '''
'down', # Modify an existing (remote) annotation's graphics
], # paramters, thus changing it's appearance / state in real
# TODO: a `Literal['view', 'scene']` for this? # time.
# domain: str = 'view', # or 'scene'
color: str = 'dad_blue',
alpha: int = 116,
headLen: float|None = None,
headWidth: float|None = None,
tailLen: float|None = None,
tailWidth: float|None = None,
pxMode: bool = True,
from_acm: bool = False, # '''
# raise NotImplementedError
# NEW: optional timestamp for server-side index lookup
time: float|None = None,
) -> int|None:
'''
Add a `SelectRect` annotation to the target view, return
the instances `id(obj)` from the remote UI actor.
'''
ipc: MsgStream = self._get_ipc(fqme)
with trio.fail_after(3):
await ipc.send({
'fqme': fqme,
'cmd': 'ArrowEditor',
'timeframe': timeframe,
# 'meth': str(meth),
'meth': 'add',
'kwargs': {
'x': float(x),
'y': float(y),
'color': color,
'pointing': pointing, # up|down
'alpha': alpha,
'aid': None,
'headLen': headLen,
'headWidth': headWidth,
'tailLen': tailLen,
'tailWidth': tailWidth,
'pxMode': pxMode,
'time': time, # for server-side index lookup
},
})
aid: int|dict = await ipc.receive()
match aid:
case {'error': str(msg)}:
log.error(msg)
return None
self._ipcs[aid] = ipc
if not from_acm:
self._annot_stack.push_async_callback(
partial(
self.remove,
aid,
)
)
return aid
async def add_text(
self,
fqme: str,
timeframe: float,
text: str,
x: float,
y: float,
color: str|tuple = 'dad_blue',
anchor: tuple[float, float] = (0, 1),
font_size: int|None = None,
from_acm: bool = False,
# NEW: optional timestamp for server-side index lookup
time: float|None = None,
) -> int|None:
'''
Add a `pg.TextItem` annotation to the target view.
anchor: (x, y) where (0,0) is upper-left, (1,1) is lower-right
font_size: pixel size for font, defaults to `_font.font.pixelSize()`
'''
ipc: MsgStream = self._get_ipc(fqme)
with trio.fail_after(3):
await ipc.send({
'fqme': fqme,
'cmd': 'TextItem',
'timeframe': timeframe,
'kwargs': {
'text': text,
'x': float(x),
'y': float(y),
'color': color,
'anchor': tuple(anchor),
'font_size': font_size,
'time': time, # for server-side index lookup
},
})
aid: int|dict = await ipc.receive()
match aid:
case {'error': str(msg)}:
log.error(msg)
return None
self._ipcs[aid] = ipc
if not from_acm:
self._annot_stack.push_async_callback(
partial(
self.remove,
aid,
)
)
return aid
@acm @acm
@ -826,9 +371,7 @@ async def open_annot_ctl(
# TODO: print the current discoverable actor UID set # TODO: print the current discoverable actor UID set
# here as well? # here as well?
if not maybe_portals: if not maybe_portals:
raise RuntimeError( raise RuntimeError('No chart UI actors found in service domain?')
'No chart actors found in service domain?'
)
for portal in maybe_portals: for portal in maybe_portals:
ctx_mngrs.append( ctx_mngrs.append(

View File

@ -15,8 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
qompleterz: embeddable search and complete using trio, Qt and qompleterz: embeddable search and complete using trio, Qt and rapidfuzz.
rapidfuzz.
""" """
@ -47,7 +46,6 @@ import time
from pprint import pformat from pprint import pformat
from rapidfuzz import process as fuzzy from rapidfuzz import process as fuzzy
import tractor
import trio import trio
from trio_typing import TaskStatus from trio_typing import TaskStatus
@ -55,7 +53,7 @@ from piker.ui.qt import (
size_policy, size_policy,
align_flag, align_flag,
Qt, Qt,
# QtCore, QtCore,
QtWidgets, QtWidgets,
QModelIndex, QModelIndex,
QItemSelectionModel, QItemSelectionModel,
@ -922,10 +920,7 @@ async def fill_results(
# issue multi-provider fan-out search request and place # issue multi-provider fan-out search request and place
# "searching.." statuses on outstanding results providers # "searching.." statuses on outstanding results providers
async with ( async with trio.open_nursery() as n:
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn
):
for provider, (search, pause) in ( for provider, (search, pause) in (
_searcher_cache.copy().items() _searcher_cache.copy().items()
@ -949,7 +944,7 @@ async def fill_results(
status_field='-> searchin..', status_field='-> searchin..',
) )
await tn.start( await n.start(
pack_matches, pack_matches,
view, view,
has_results, has_results,
@ -1009,14 +1004,12 @@ async def handle_keyboard_input(
view.set_font_size(searchbar.dpi_font.px_size) view.set_font_size(searchbar.dpi_font.px_size)
send, recv = trio.open_memory_channel(616) send, recv = trio.open_memory_channel(616)
async with ( async with trio.open_nursery() as n:
tractor.trionics.collapse_eg(), # needed?
trio.open_nursery() as tn
):
# start a background multi-searcher task which receives # start a background multi-searcher task which receives
# patterns relayed from this keyboard input handler and # patterns relayed from this keyboard input handler and
# async updates the completer view's results. # async updates the completer view's results.
tn.start_soon( n.start_soon(
partial( partial(
fill_results, fill_results,
searchw, searchw,

View File

@ -107,22 +107,7 @@ class DpiAwareFont:
@property @property
def px_size(self) -> int: def px_size(self) -> int:
size: int = self._qfont.pixelSize() return self._qfont.pixelSize()
# XXX, when no Qt app has been spawned this will always be
# invalid..
# SO, just return any conf.toml value.
if size == -1:
if (conf_size := self._font_size) is None:
raise ValueError(
f'No valid `{type(_font).__name__}.px_size` set?\n'
f'\n'
f'-> `ui.font_size` is NOT set in `conf.toml`\n'
f'-> no Qt app is active ??\n'
)
return conf_size
return size
def configure_to_dpi(self, screen: QtGui.QScreen | None = None): def configure_to_dpi(self, screen: QtGui.QScreen | None = None):
''' '''
@ -236,20 +221,6 @@ def _config_fonts_to_screen() -> None:
_font_small.configure_to_dpi() _font_small.configure_to_dpi()
def get_fonts() -> tuple[
DpiAwareFont,
DpiAwareFont,
]:
'''
Get the singleton font pair (of instances) from which all other
UI/UX should be "scaled around".
See `DpiAwareFont` for (internal) deats.
'''
return _font, _font_small
# TODO: re-compute font size when main widget switches screens? # TODO: re-compute font size when main widget switches screens?
# https://forum.qt.io/topic/54136/how-do-i-get-the-qscreen-my-widget-is-on-qapplication-desktop-screen-returns-a-qwidget-and-qobject_cast-qscreen-returns-null/3 # https://forum.qt.io/topic/54136/how-do-i-get-the-qscreen-my-widget-is-on-qapplication-desktop-screen-returns-a-qwidget-and-qobject_cast-qscreen-returns-null/3
@ -298,8 +269,6 @@ def hcolor(name: str) -> str:
# default ohlc-bars/curve gray # default ohlc-bars/curve gray
'bracket': '#666666', # like the logo 'bracket': '#666666', # like the logo
'pikers': '#616161', # a trader shade of..
'beast': '#161616', # in the dark alone.
# bluish # bluish
'charcoal': '#36454F', 'charcoal': '#36454F',
@ -337,7 +306,6 @@ def hcolor(name: str) -> str:
'cool_green': '#33b864', 'cool_green': '#33b864',
'dull_green': '#74a662', 'dull_green': '#74a662',
'hedge_green': '#518360', 'hedge_green': '#518360',
'lilypad_green': '#839c84',
# orders and alerts # orders and alerts
'alert_yellow': '#e2d083', 'alert_yellow': '#e2d083',
@ -365,7 +333,6 @@ def hcolor(name: str) -> str:
'sell_red': '#b6003f', 'sell_red': '#b6003f',
# 'sell_red': '#d00048', # 'sell_red': '#d00048',
'sell_red_light': '#f85462', 'sell_red_light': '#f85462',
'wine': '#69212d',
# 'sell_red': '#f85462', # 'sell_red': '#f85462',
# 'sell_red_light': '#ff4d5c', # 'sell_red_light': '#ff4d5c',

View File

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

View File

@ -40,7 +40,7 @@ from piker.ui.qt import (
) )
from ..log import get_logger from ..log import get_logger
from ._style import _font_small, hcolor from ._style import _font_small, hcolor
from ._widget import GodWidget from ._chart import GodWidget
log = get_logger(__name__) log = get_logger(__name__)
@ -255,16 +255,8 @@ class MainWindow(QMainWindow):
current: QWidget, current: QWidget,
) -> None: ) -> None:
'''
Focus handler.
For now updates the "current mode" name. log.info(f'widget focus changed from {last} -> {current}')
'''
log.debug(
f'widget focus changed from,\n'
f'{last} -> {current}'
)
if current is not None: if current is not None:
# cursor left window? # cursor left window?

View File

@ -177,7 +177,7 @@ def chart(
return return
# global opts # global opts
# brokernames: list[str] = config['brokers'] brokernames = config['brokers']
brokermods = config['brokermods'] brokermods = config['brokermods']
assert brokermods assert brokermods
tractorloglevel = config['tractorloglevel'] tractorloglevel = config['tractorloglevel']
@ -216,7 +216,6 @@ def chart(
layers['tcp']['port'], layers['tcp']['port'],
)) ))
# breakpoint()
from tractor.devx import maybe_open_crash_handler from tractor.devx import maybe_open_crash_handler
pdb: bool = config['pdb'] pdb: bool = config['pdb']
with maybe_open_crash_handler(pdb=pdb): with maybe_open_crash_handler(pdb=pdb):

Some files were not shown because too many files have changed in this diff Show More