Compare commits
No commits in common. "gitea_feats" and "basic_buy_bot" have entirely different histories.
gitea_feat
...
basic_buy_
234
README.rst
234
README.rst
|
|
@ -1,161 +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 lock
|
|
||||||
|
rigorous install on ``nixos`` using ``poetry2nix``
|
||||||
|
**************************************************
|
||||||
|
TODO!
|
||||||
|
|
||||||
|
|
||||||
hacky install on nixos
|
hacky install on nixos
|
||||||
**********************
|
**********************
|
||||||
``NixOS`` is our core devs' distro of choice for which we offer
|
`NixOS` is our core devs' distro of choice for which we offer
|
||||||
a stringently defined development shell envoirment that can be loaded with::
|
a stringently defined development shell envoirment that can be loaded with::
|
||||||
|
|
||||||
nix-shell default.nix
|
nix-shell develop.nix
|
||||||
|
|
||||||
|
this will setup the required python environment to run piker, make sure to
|
||||||
|
run::
|
||||||
|
|
||||||
|
pip install -r requirements.txt -e .
|
||||||
|
|
||||||
|
once after loading the shell
|
||||||
|
|
||||||
|
|
||||||
start a chart
|
install wild-west style via `pip`
|
||||||
*************
|
*********************************
|
||||||
run a realtime OHLCV chart stand-alone::
|
``piker`` is currently under heavy pre-alpha development and as such
|
||||||
|
should be cloned from this repo and hacked on directly.
|
||||||
|
|
||||||
piker -l info chart btcusdt.spot.binance xmrusdt.spot.kraken
|
for a development install::
|
||||||
|
|
||||||
this runs a chart UI (with 1m sampled OHLCV) and shows 2 spot markets from 2 diff cexes
|
git clone git@github.com:pikers/piker.git
|
||||||
overlayed on the same graph. Use of `piker` without first starting
|
cd piker
|
||||||
a daemon (`pikerd` - see below) means there is an implicit spawning of the
|
virtualenv env
|
||||||
multi-actor-runtime (implemented as a `tractor` app).
|
source ./env/bin/activate
|
||||||
|
pip install -r requirements.txt -e .
|
||||||
For additional subsystem feats available through our chart UI see the
|
|
||||||
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
|
check out our charts
|
||||||
*************************
|
********************
|
||||||
we call the root actor-process the ``pikerd``. it can be (and is
|
bet you weren't expecting this from the foss::
|
||||||
recommended normally to be) started separately from the ``piker
|
|
||||||
chart`` program::
|
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?
|
||||||
|
|
@ -165,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!
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,13 @@
|
||||||
# ---- CEXY ----
|
# ---- CEXY ----
|
||||||
################
|
################
|
||||||
[binance]
|
[binance]
|
||||||
accounts.paper = 'paper'
|
|
||||||
|
|
||||||
accounts.usdtm = 'futes'
|
accounts.usdtm = 'futes'
|
||||||
futes.use_testnet = false
|
futes.use_testnet = true
|
||||||
futes.api_key = ''
|
futes.api_key = ''
|
||||||
futes.api_secret = ''
|
futes.api_secret = ''
|
||||||
|
|
||||||
accounts.spot = 'spot'
|
accounts.spot = 'spot'
|
||||||
spot.use_testnet = false
|
spot.use_testnet = true
|
||||||
spot.api_key = ''
|
spot.api_key = ''
|
||||||
spot.api_secret = ''
|
spot.api_secret = ''
|
||||||
|
|
||||||
|
|
|
||||||
134
default.nix
134
default.nix
|
|
@ -1,134 +0,0 @@
|
||||||
with (import <nixpkgs> {});
|
|
||||||
let
|
|
||||||
glibStorePath = lib.getLib glib;
|
|
||||||
zlibStorePath = lib.getLib zlib;
|
|
||||||
zstdStorePath = lib.getLib zstd;
|
|
||||||
dbusStorePath = lib.getLib dbus;
|
|
||||||
libGLStorePath = lib.getLib libGL;
|
|
||||||
freetypeStorePath = lib.getLib freetype;
|
|
||||||
qt6baseStorePath = lib.getLib qt6.qtbase;
|
|
||||||
fontconfigStorePath = lib.getLib fontconfig;
|
|
||||||
libxkbcommonStorePath = lib.getLib libxkbcommon;
|
|
||||||
xcbutilcursorStorePath = lib.getLib xcb-util-cursor;
|
|
||||||
|
|
||||||
qtpyStorePath = lib.getLib python312Packages.qtpy;
|
|
||||||
pyqt6StorePath = lib.getLib python312Packages.pyqt6;
|
|
||||||
pyqt6SipStorePath = lib.getLib python312Packages.pyqt6-sip;
|
|
||||||
rapidfuzzStorePath = lib.getLib python312Packages.rapidfuzz;
|
|
||||||
qdarkstyleStorePath = lib.getLib python312Packages.qdarkstyle;
|
|
||||||
|
|
||||||
xorgLibX11StorePath = lib.getLib xorg.libX11;
|
|
||||||
xorgLibxcbStorePath = lib.getLib xorg.libxcb;
|
|
||||||
xorgxcbutilwmStorePath = lib.getLib xorg.xcbutilwm;
|
|
||||||
xorgxcbutilimageStorePath = lib.getLib xorg.xcbutilimage;
|
|
||||||
xorgxcbutilerrorsStorePath = lib.getLib xorg.xcbutilerrors;
|
|
||||||
xorgxcbutilkeysymsStorePath = lib.getLib xorg.xcbutilkeysyms;
|
|
||||||
xorgxcbutilrenderutilStorePath = lib.getLib xorg.xcbutilrenderutil;
|
|
||||||
in
|
|
||||||
stdenv.mkDerivation {
|
|
||||||
name = "piker-qt6-uv";
|
|
||||||
buildInputs = [
|
|
||||||
# System requirements.
|
|
||||||
glib
|
|
||||||
zlib
|
|
||||||
dbus
|
|
||||||
zstd
|
|
||||||
libGL
|
|
||||||
freetype
|
|
||||||
qt6.qtbase
|
|
||||||
libgcc.lib
|
|
||||||
fontconfig
|
|
||||||
libxkbcommon
|
|
||||||
|
|
||||||
# Xorg requirements
|
|
||||||
xcb-util-cursor
|
|
||||||
xorg.libxcb
|
|
||||||
xorg.libX11
|
|
||||||
xorg.xcbutilwm
|
|
||||||
xorg.xcbutilimage
|
|
||||||
xorg.xcbutilerrors
|
|
||||||
xorg.xcbutilkeysyms
|
|
||||||
xorg.xcbutilrenderutil
|
|
||||||
|
|
||||||
# Python requirements.
|
|
||||||
python312Full
|
|
||||||
python312Packages.uv
|
|
||||||
python312Packages.qdarkstyle
|
|
||||||
python312Packages.rapidfuzz
|
|
||||||
python312Packages.pyqt6
|
|
||||||
python312Packages.qtpy
|
|
||||||
];
|
|
||||||
src = null;
|
|
||||||
shellHook = ''
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Set the Qt plugin path
|
|
||||||
# export QT_DEBUG_PLUGINS=1
|
|
||||||
|
|
||||||
QTBASE_PATH="${qt6baseStorePath}/lib"
|
|
||||||
QT_PLUGIN_PATH="$QTBASE_PATH/qt-6/plugins"
|
|
||||||
QT_QPA_PLATFORM_PLUGIN_PATH="$QT_PLUGIN_PATH/platforms"
|
|
||||||
|
|
||||||
LIB_GCC_PATH="${libgcc.lib}/lib"
|
|
||||||
GLIB_PATH="${glibStorePath}/lib"
|
|
||||||
ZSTD_PATH="${zstdStorePath}/lib"
|
|
||||||
ZLIB_PATH="${zlibStorePath}/lib"
|
|
||||||
DBUS_PATH="${dbusStorePath}/lib"
|
|
||||||
LIBGL_PATH="${libGLStorePath}/lib"
|
|
||||||
FREETYPE_PATH="${freetypeStorePath}/lib"
|
|
||||||
FONTCONFIG_PATH="${fontconfigStorePath}/lib"
|
|
||||||
LIB_XKB_COMMON_PATH="${libxkbcommonStorePath}/lib"
|
|
||||||
|
|
||||||
XCB_UTIL_CURSOR_PATH="${xcbutilcursorStorePath}/lib"
|
|
||||||
XORG_LIB_X11_PATH="${xorgLibX11StorePath}/lib"
|
|
||||||
XORG_LIB_XCB_PATH="${xorgLibxcbStorePath}/lib"
|
|
||||||
XORG_XCB_UTIL_IMAGE_PATH="${xorgxcbutilimageStorePath}/lib"
|
|
||||||
XORG_XCB_UTIL_WM_PATH="${xorgxcbutilwmStorePath}/lib"
|
|
||||||
XORG_XCB_UTIL_RENDER_UTIL_PATH="${xorgxcbutilrenderutilStorePath}/lib"
|
|
||||||
XORG_XCB_UTIL_KEYSYMS_PATH="${xorgxcbutilkeysymsStorePath}/lib"
|
|
||||||
XORG_XCB_UTIL_ERRORS_PATH="${xorgxcbutilerrorsStorePath}/lib"
|
|
||||||
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$QTBASE_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$QT_PLUGIN_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$QT_QPA_PLATFORM_PLUGIN_PATH"
|
|
||||||
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$LIB_GCC_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$DBUS_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$GLIB_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$ZLIB_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$ZSTD_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$LIBGL_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$FONTCONFIG_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$FREETYPE_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$LIB_XKB_COMMON_PATH"
|
|
||||||
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XCB_UTIL_CURSOR_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XORG_LIB_X11_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XORG_LIB_XCB_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XORG_XCB_UTIL_IMAGE_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XORG_XCB_UTIL_WM_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XORG_XCB_UTIL_RENDER_UTIL_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XORG_XCB_UTIL_KEYSYMS_PATH"
|
|
||||||
LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$XORG_XCB_UTIL_ERRORS_PATH"
|
|
||||||
|
|
||||||
export LD_LIBRARY_PATH
|
|
||||||
|
|
||||||
RPDFUZZ_PATH="${rapidfuzzStorePath}/lib/python3.12/site-packages"
|
|
||||||
QDRKSTYLE_PATH="${qdarkstyleStorePath}/lib/python3.12/site-packages"
|
|
||||||
QTPY_PATH="${qtpyStorePath}/lib/python3.12/site-packages"
|
|
||||||
PYQT6_PATH="${pyqt6StorePath}/lib/python3.12/site-packages"
|
|
||||||
PYQT6_SIP_PATH="${pyqt6SipStorePath}/lib/python3.12/site-packages"
|
|
||||||
|
|
||||||
PATCH="$PATCH:$RPDFUZZ_PATH"
|
|
||||||
PATCH="$PATCH:$QDRKSTYLE_PATH"
|
|
||||||
PATCH="$PATCH:$QTPY_PATH"
|
|
||||||
PATCH="$PATCH:$PYQT6_PATH"
|
|
||||||
PATCH="$PATCH:$PYQT6_SIP_PATH"
|
|
||||||
|
|
||||||
export PATCH
|
|
||||||
|
|
||||||
# Install deps
|
|
||||||
uv lock
|
|
||||||
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
37
develop.nix
37
develop.nix
|
|
@ -1,34 +1,28 @@
|
||||||
with (import <nixpkgs> {});
|
with (import <nixpkgs> {});
|
||||||
|
with python310Packages;
|
||||||
stdenv.mkDerivation {
|
stdenv.mkDerivation {
|
||||||
name = "poetry-env";
|
name = "pip-env";
|
||||||
buildInputs = [
|
buildInputs = [
|
||||||
# System requirements.
|
# System requirements.
|
||||||
readline
|
readline
|
||||||
|
|
||||||
# TODO: hacky non-poetry install stuff we need to get rid of!!
|
# TODO: hacky non-poetry install stuff we need to get rid of!!
|
||||||
poetry
|
virtualenv
|
||||||
# virtualenv
|
setuptools
|
||||||
# setuptools
|
pip
|
||||||
# pip
|
|
||||||
|
|
||||||
# Python requirements (enough to get a virtualenv going).
|
|
||||||
python311Full
|
|
||||||
|
|
||||||
# obviously, and see below for hacked linking
|
# obviously, and see below for hacked linking
|
||||||
python311Packages.pyqt5
|
pyqt5
|
||||||
python311Packages.pyqt5_sip
|
|
||||||
# python311Packages.qtpy
|
# Python requirements (enough to get a virtualenv going).
|
||||||
|
python310Full
|
||||||
|
|
||||||
# numerics deps
|
# numerics deps
|
||||||
python311Packages.levenshtein
|
python310Packages.python-Levenshtein
|
||||||
python311Packages.fastparquet
|
python310Packages.fastparquet
|
||||||
python311Packages.polars
|
python310Packages.polars
|
||||||
|
|
||||||
];
|
];
|
||||||
# environment.sessionVariables = {
|
|
||||||
# LD_LIBRARY_PATH = "${pkgs.stdenv.cc.cc.lib}/lib";
|
|
||||||
# };
|
|
||||||
src = null;
|
src = null;
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
# Allow the use of wheels.
|
# Allow the use of wheels.
|
||||||
|
|
@ -36,12 +30,13 @@ stdenv.mkDerivation {
|
||||||
|
|
||||||
# Augment the dynamic linker path
|
# Augment the dynamic linker path
|
||||||
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:${R}/lib/R/lib:${readline}/lib
|
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:${R}/lib/R/lib:${readline}/lib
|
||||||
|
|
||||||
export QT_QPA_PLATFORM_PLUGIN_PATH="${qt5.qtbase.bin}/lib/qt-${qt5.qtbase.version}/plugins";
|
export QT_QPA_PLATFORM_PLUGIN_PATH="${qt5.qtbase.bin}/lib/qt-${qt5.qtbase.version}/plugins";
|
||||||
|
|
||||||
if [ ! -d ".venv" ]; then
|
if [ ! -d "venv" ]; then
|
||||||
poetry install --with uis
|
virtualenv venv
|
||||||
fi
|
fi
|
||||||
|
|
||||||
poetry shell
|
source venv/bin/activate
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,8 @@ services:
|
||||||
|
|
||||||
# other image tags available:
|
# other image tags available:
|
||||||
# https://github.com/waytrade/ib-gateway-docker#supported-tags
|
# https://github.com/waytrade/ib-gateway-docker#supported-tags
|
||||||
# image: waytrade/ib-gateway:1012.2i
|
# image: waytrade/ib-gateway:981.3j
|
||||||
image: ghcr.io/gnzsnz/ib-gateway:latest
|
image: waytrade/ib-gateway:1012.2i
|
||||||
|
|
||||||
restart: 'no' # restart on boot whenev there's a crash or user clicsk
|
restart: 'no' # restart on boot whenev there's a crash or user clicsk
|
||||||
network_mode: 'host'
|
network_mode: 'host'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,57 +117,9 @@ SecondFactorDevice=
|
||||||
|
|
||||||
# If you use the IBKR Mobile app for second factor authentication,
|
# If you use the IBKR Mobile app for second factor authentication,
|
||||||
# and you fail to complete the process before the time limit imposed
|
# and you fail to complete the process before the time limit imposed
|
||||||
# by IBKR, this setting tells IBC whether to automatically restart
|
# by IBKR, you can use this setting to tell IBC to exit: arrangements
|
||||||
# the login sequence, giving you another opportunity to complete
|
# can then be made to automatically restart IBC in order to initiate
|
||||||
# second factor authentication.
|
# the login sequence afresh. Otherwise, manual intervention at TWS's
|
||||||
#
|
|
||||||
# Permitted values are 'yes' and 'no'.
|
|
||||||
#
|
|
||||||
# If this setting is not present or has no value, then the value
|
|
||||||
# of the deprecated ExitAfterSecondFactorAuthenticationTimeout is
|
|
||||||
# used instead. If this also has no value, then this setting defaults
|
|
||||||
# to 'no'.
|
|
||||||
#
|
|
||||||
# NB: you must be using IBC v3.14.0 or later to use this setting:
|
|
||||||
# earlier versions ignore it.
|
|
||||||
|
|
||||||
ReloginAfterSecondFactorAuthenticationTimeout=
|
|
||||||
|
|
||||||
|
|
||||||
# This setting is only relevant if
|
|
||||||
# ReloginAfterSecondFactorAuthenticationTimeout is set to 'yes',
|
|
||||||
# or if ExitAfterSecondFactorAuthenticationTimeout is set to 'yes'.
|
|
||||||
#
|
|
||||||
# It controls how long (in seconds) IBC waits for login to complete
|
|
||||||
# after the user acknowledges the second factor authentication
|
|
||||||
# alert at the IBKR Mobile app. If login has not completed after
|
|
||||||
# this time, IBC terminates.
|
|
||||||
# The default value is 60.
|
|
||||||
|
|
||||||
SecondFactorAuthenticationExitInterval=
|
|
||||||
|
|
||||||
|
|
||||||
# This setting specifies the timeout for second factor authentication
|
|
||||||
# imposed by IB. The value is in seconds. You should not change this
|
|
||||||
# setting unless you have reason to believe that IB has changed the
|
|
||||||
# timeout. The default value is 180.
|
|
||||||
|
|
||||||
SecondFactorAuthenticationTimeout=180
|
|
||||||
|
|
||||||
|
|
||||||
# DEPRECATED SETTING
|
|
||||||
# ------------------
|
|
||||||
#
|
|
||||||
# ExitAfterSecondFactorAuthenticationTimeout - THIS SETTING WILL BE
|
|
||||||
# REMOVED IN A FUTURE RELEASE. For IBC version 3.14.0 and later, see
|
|
||||||
# the notes for ReloginAfterSecondFactorAuthenticationTimeout above.
|
|
||||||
#
|
|
||||||
# For IBC versions earlier than 3.14.0: If you use the IBKR Mobile
|
|
||||||
# app for second factor authentication, and you fail to complete the
|
|
||||||
# process before the time limit imposed by IBKR, you can use this
|
|
||||||
# setting to tell IBC to exit: arrangements can then be made to
|
|
||||||
# automatically restart IBC in order to initiate the login sequence
|
|
||||||
# afresh. Otherwise, manual intervention at TWS's
|
|
||||||
# Second Factor Authentication dialog is needed to complete the
|
# Second Factor Authentication dialog is needed to complete the
|
||||||
# login.
|
# login.
|
||||||
#
|
#
|
||||||
|
|
@ -180,18 +132,29 @@ SecondFactorAuthenticationTimeout=180
|
||||||
ExitAfterSecondFactorAuthenticationTimeout=no
|
ExitAfterSecondFactorAuthenticationTimeout=no
|
||||||
|
|
||||||
|
|
||||||
|
# This setting is only relevant if
|
||||||
|
# ExitAfterSecondFactorAuthenticationTimeout is set to 'yes'.
|
||||||
|
#
|
||||||
|
# It controls how long (in seconds) IBC waits for login to complete
|
||||||
|
# after the user acknowledges the second factor authentication
|
||||||
|
# alert at the IBKR Mobile app. If login has not completed after
|
||||||
|
# this time, IBC terminates.
|
||||||
|
# The default value is 40.
|
||||||
|
|
||||||
|
SecondFactorAuthenticationExitInterval=
|
||||||
|
|
||||||
|
|
||||||
# Trading Mode
|
# Trading Mode
|
||||||
# ------------
|
# ------------
|
||||||
#
|
#
|
||||||
# This indicates whether the live account or the paper trading
|
# TWS 955 introduced a new Trading Mode combo box on its login
|
||||||
# account corresponding to the supplied credentials is to be used.
|
# dialog. This indicates whether the live account or the paper
|
||||||
# The allowed values are 'live' (the default) and 'paper'.
|
# trading account corresponding to the supplied credentials is
|
||||||
#
|
# to be used. The allowed values are 'live' (the default) and
|
||||||
# If this is set to 'live', then the credentials for the live
|
# 'paper'. For earlier versions of TWS this setting has no
|
||||||
# account must be supplied. If it is set to 'paper', then either
|
# effect.
|
||||||
# the live or the paper-trading credentials may be supplied.
|
|
||||||
|
|
||||||
TradingMode=paper
|
TradingMode=
|
||||||
|
|
||||||
|
|
||||||
# Paper-trading Account Warning
|
# Paper-trading Account Warning
|
||||||
|
|
@ -225,7 +188,7 @@ AcceptNonBrokerageAccountWarning=yes
|
||||||
#
|
#
|
||||||
# The default value is 60.
|
# The default value is 60.
|
||||||
|
|
||||||
LoginDialogDisplayTimeout=60
|
LoginDialogDisplayTimeout=20
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -254,15 +217,7 @@ LoginDialogDisplayTimeout=60
|
||||||
# but they are acceptable.
|
# but they are acceptable.
|
||||||
#
|
#
|
||||||
# The default is the current working directory when IBC is
|
# The default is the current working directory when IBC is
|
||||||
# started, unless the TWS_SETTINGS_PATH setting in the relevant
|
# started.
|
||||||
# start script is set.
|
|
||||||
#
|
|
||||||
# If both this setting and TWS_SETTINGS_PATH are set, then this
|
|
||||||
# setting takes priority. Note that if they have different values,
|
|
||||||
# auto-restart will not work.
|
|
||||||
#
|
|
||||||
# NB: this setting is now DEPRECATED. You should use the
|
|
||||||
# TWS_SETTINGS_PATH setting in the relevant start script.
|
|
||||||
|
|
||||||
IbDir=/root/Jts
|
IbDir=/root/Jts
|
||||||
|
|
||||||
|
|
@ -331,30 +286,13 @@ ExistingSessionDetectedAction=primary
|
||||||
#
|
#
|
||||||
# If OverrideTwsApiPort is set to an integer, IBC changes the
|
# If OverrideTwsApiPort is set to an integer, IBC changes the
|
||||||
# 'Socket port' in TWS's API configuration to that number shortly
|
# 'Socket port' in TWS's API configuration to that number shortly
|
||||||
# after startup (but note that for the FIX Gateway, this setting is
|
# after startup. Leaving the setting blank will make no change to
|
||||||
# actually stored in jts.ini rather than the Gateway's settings
|
|
||||||
# file). Leaving the setting blank will make no change to
|
|
||||||
# the current setting. This setting is only intended for use in
|
# the current setting. This setting is only intended for use in
|
||||||
# certain specialized situations where the port number needs to
|
# certain specialized situations where the port number needs to
|
||||||
# be set dynamically at run-time, and for the FIX Gateway: most
|
|
||||||
# non-FIX users will never need it, so don't use it unless you know
|
|
||||||
# you need it.
|
|
||||||
|
|
||||||
OverrideTwsApiPort=4000
|
|
||||||
|
|
||||||
|
|
||||||
# Override TWS Master Client ID
|
|
||||||
# -----------------------------
|
|
||||||
#
|
|
||||||
# If OverrideTwsMasterClientID is set to an integer, IBC changes the
|
|
||||||
# 'Master Client ID' value in TWS's API configuration to that
|
|
||||||
# value shortly after startup. Leaving the setting blank will make
|
|
||||||
# no change to the current setting. This setting is only intended
|
|
||||||
# for use in certain specialized situations where the value needs to
|
|
||||||
# be set dynamically at run-time: most users will never need it,
|
# be set dynamically at run-time: most users will never need it,
|
||||||
# so don't use it unless you know you need it.
|
# so don't use it unless you know you need it.
|
||||||
|
|
||||||
OverrideTwsMasterClientID=
|
; OverrideTwsApiPort=4002
|
||||||
|
|
||||||
|
|
||||||
# Read-only Login
|
# Read-only Login
|
||||||
|
|
@ -364,13 +302,11 @@ OverrideTwsMasterClientID=
|
||||||
# account security programme, the user will not be asked to perform
|
# account security programme, the user will not be asked to perform
|
||||||
# the second factor authentication action, and login to TWS will
|
# the second factor authentication action, and login to TWS will
|
||||||
# occur automatically in read-only mode: in this mode, placing or
|
# occur automatically in read-only mode: in this mode, placing or
|
||||||
# managing orders is not allowed.
|
# managing orders is not allowed. If set to 'no', and the user is
|
||||||
#
|
# enrolled in IB's account security programme, the user must perform
|
||||||
# If set to 'no', and the user is enrolled in IB's account security
|
# the relevant second factor authentication action to complete the
|
||||||
# programme, the second factor authentication process is handled
|
# login.
|
||||||
# according to the Second Factor Authentication Settings described
|
|
||||||
# elsewhere in this file.
|
|
||||||
#
|
|
||||||
# If the user is not enrolled in IB's account security programme,
|
# If the user is not enrolled in IB's account security programme,
|
||||||
# this setting is ignored. The default is 'no'.
|
# this setting is ignored. The default is 'no'.
|
||||||
|
|
||||||
|
|
@ -390,44 +326,7 @@ ReadOnlyLogin=no
|
||||||
# set the relevant checkbox (this only needs to be done once) and
|
# set the relevant checkbox (this only needs to be done once) and
|
||||||
# not provide a value for this setting.
|
# not provide a value for this setting.
|
||||||
|
|
||||||
ReadOnlyApi=
|
ReadOnlyApi=no
|
||||||
|
|
||||||
|
|
||||||
# API Precautions
|
|
||||||
# ---------------
|
|
||||||
#
|
|
||||||
# These settings relate to the corresponding 'Precautions' checkboxes in the
|
|
||||||
# API section of the Global Configuration dialog.
|
|
||||||
#
|
|
||||||
# For all of these, the accepted values are:
|
|
||||||
# - 'yes' sets the checkbox
|
|
||||||
# - 'no' clears the checkbox
|
|
||||||
# - if not set, the existing TWS/Gateway configuration is unchanged
|
|
||||||
#
|
|
||||||
# NB: thess settings are really only supplied for the benefit of new TWS
|
|
||||||
# or Gateway instances that are being automatically installed and
|
|
||||||
# started without user intervention, or where user settings are not preserved
|
|
||||||
# between sessions (eg some Docker containers). Where a user is involved, they
|
|
||||||
# should use the Global Configuration to set the relevant checkboxes and not
|
|
||||||
# provide values for these settings.
|
|
||||||
|
|
||||||
BypassOrderPrecautions=
|
|
||||||
|
|
||||||
BypassBondWarning=
|
|
||||||
|
|
||||||
BypassNegativeYieldToWorstConfirmation=
|
|
||||||
|
|
||||||
BypassCalledBondWarning=
|
|
||||||
|
|
||||||
BypassSameActionPairTradeWarning=
|
|
||||||
|
|
||||||
BypassPriceBasedVolatilityRiskWarning=
|
|
||||||
|
|
||||||
BypassUSStocksMarketDataInSharesWarning=
|
|
||||||
|
|
||||||
BypassRedirectOrderWarning=
|
|
||||||
|
|
||||||
BypassNoOverfillProtectionPrecaution=
|
|
||||||
|
|
||||||
|
|
||||||
# Market data size for US stocks - lots or shares
|
# Market data size for US stocks - lots or shares
|
||||||
|
|
@ -482,145 +381,54 @@ AcceptBidAskLastSizeDisplayUpdateNotification=accept
|
||||||
SendMarketDataInLotsForUSstocks=
|
SendMarketDataInLotsForUSstocks=
|
||||||
|
|
||||||
|
|
||||||
# Trusted API Client IPs
|
|
||||||
# ----------------------
|
|
||||||
#
|
|
||||||
# NB: THIS SETTING IS ONLY RELEVANT FOR THE GATEWAY, AND ONLY WHEN FIX=yes.
|
|
||||||
# In all other cases it is ignored.
|
|
||||||
#
|
|
||||||
# This is a list of IP addresses separated by commas. API clients with IP
|
|
||||||
# addresses in this list are able to connect to the API without Gateway
|
|
||||||
# generating the 'Incoming connection' popup.
|
|
||||||
#
|
|
||||||
# Note that 127.0.0.1 is always permitted to connect, so do not include it
|
|
||||||
# in this setting.
|
|
||||||
|
|
||||||
TrustedTwsApiClientIPs=
|
|
||||||
|
|
||||||
|
|
||||||
# Reset Order ID Sequence
|
|
||||||
# -----------------------
|
|
||||||
#
|
|
||||||
# The setting resets the order id sequence for orders submitted via the API, so
|
|
||||||
# that the next invocation of the `NextValidId` API callback will return the
|
|
||||||
# value 1. The reset occurs when TWS starts.
|
|
||||||
#
|
|
||||||
# Note that order ids are reset for all API clients, except those that have
|
|
||||||
# outstanding (ie incomplete) orders: their order id sequence carries on as
|
|
||||||
# before.
|
|
||||||
#
|
|
||||||
# Valid values are 'yes', 'true', 'false' and 'no'. The default is 'no'.
|
|
||||||
|
|
||||||
ResetOrderIdsAtStart=
|
|
||||||
|
|
||||||
|
|
||||||
# This setting specifies IBC's action when TWS displays the dialog asking for
|
|
||||||
# confirmation of a request to reset the API order id sequence.
|
|
||||||
#
|
|
||||||
# Note that the Gateway never displays this dialog, so this setting is ignored
|
|
||||||
# for a Gateway session.
|
|
||||||
#
|
|
||||||
# Valid values consist of two strings separated by a solidus '/'. The first
|
|
||||||
# value specifies the action to take when the order id reset request resulted
|
|
||||||
# from setting ResetOrderIdsAtStart=yes. The second specifies the action to
|
|
||||||
# take when the order id reset request is a result of the user clicking the
|
|
||||||
# 'Reset API order ID sequence' button in the API configuration. Each value
|
|
||||||
# must be one of the following:
|
|
||||||
#
|
|
||||||
# 'confirm'
|
|
||||||
# order ids will be reset
|
|
||||||
#
|
|
||||||
# 'reject'
|
|
||||||
# order ids will not be reset
|
|
||||||
#
|
|
||||||
# 'ignore'
|
|
||||||
# IBC will ignore the dialog. The user must take action.
|
|
||||||
#
|
|
||||||
# The default setting is ignore/ignore
|
|
||||||
|
|
||||||
# Examples:
|
|
||||||
#
|
|
||||||
# 'confirm/reject' - confirm order id reset only if ResetOrderIdsAtStart=yes
|
|
||||||
# and reject any user-initiated requests
|
|
||||||
#
|
|
||||||
# 'ignore/confirm' - user must decide what to do if ResetOrderIdsAtStart=yes
|
|
||||||
# and confirm user-initiated requests
|
|
||||||
#
|
|
||||||
# 'reject/ignore' - reject order id reset if ResetOrderIdsAtStart=yes but
|
|
||||||
# allow user to handle user-initiated requests
|
|
||||||
|
|
||||||
ConfirmOrderIdReset=
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 4. TWS Auto-Logoff and Auto-Restart
|
# 4. TWS Auto-Closedown
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# TWS and Gateway insist on being restarted every day. Two alternative
|
# IMPORTANT NOTE: Starting with TWS 974, this setting no longer
|
||||||
# automatic options are offered:
|
# works properly, because IB have changed the way TWS handles its
|
||||||
|
# autologoff mechanism.
|
||||||
#
|
#
|
||||||
# - Auto-Logoff: at a specified time, TWS shuts down tidily, without
|
# You should now configure the TWS autologoff time to something
|
||||||
# restarting.
|
# convenient for you, and restart IBC each day.
|
||||||
#
|
#
|
||||||
# - Auto-Restart: at a specified time, TWS shuts down and then restarts
|
# Alternatively, discontinue use of IBC and use the auto-relogin
|
||||||
# without the user having to re-autheticate.
|
# mechanism within TWS 974 and later versions (note that the
|
||||||
#
|
# auto-relogin mechanism provided by IB is not available if you
|
||||||
# The normal way to configure the time at which this happens is via the Lock
|
# use IBC).
|
||||||
# and Exit section of the Configuration dialog. Once this time has been
|
|
||||||
# configured in this way, the setting persists until the user changes it again.
|
|
||||||
#
|
|
||||||
# However, there are situations where there is no user available to do this
|
|
||||||
# configuration, or where there is no persistent storage (for example some
|
|
||||||
# Docker images). In such cases, the auto-restart or auto-logoff time can be
|
|
||||||
# set whenever IBC starts with the settings below.
|
|
||||||
#
|
|
||||||
# The value, if specified, must be a time in HH:MM AM/PM format, for example
|
|
||||||
# 08:00 AM or 10:00 PM. Note that there must be a single space between the
|
|
||||||
# two parts of this value; also that midnight is "12:00 AM" and midday is
|
|
||||||
# "12:00 PM".
|
|
||||||
#
|
|
||||||
# If no value is specified for either setting, the currently configured
|
|
||||||
# settings will apply. If a value is supplied for one setting, the other
|
|
||||||
# setting is cleared. If values are supplied for both settings, only the
|
|
||||||
# auto-restart time is set, and the auto-logoff time is cleared.
|
|
||||||
#
|
|
||||||
# Note that for a normal TWS/Gateway installation with persistent storage
|
|
||||||
# (for example on a desktop computer) the value will be persisted as if the
|
|
||||||
# user had set it via the configuration dialog.
|
|
||||||
#
|
|
||||||
# If you choose to auto-restart, you should take note of the considerations
|
|
||||||
# described at the link below. Note that where this information mentions
|
|
||||||
# 'manual authentication', restarting IBC will do the job (IBKR does not
|
|
||||||
# recognise the existence of IBC in its docuemntation).
|
|
||||||
#
|
|
||||||
# https://www.interactivebrokers.com/en/software/tws/twsguide.htm#usersguidebook/configuretws/auto_restart_info.htm
|
|
||||||
#
|
|
||||||
# If you use the "RESTART" command via the IBC command server, and IBC is
|
|
||||||
# running any version of the Gateway (or a version of TWS earlier than 1018),
|
|
||||||
# note that this will set the Auto-Restart time in Gateway/TWS's configuration
|
|
||||||
# dialog to the time at which the restart actually happens (which may be up to
|
|
||||||
# a minute after the RESTART command is issued). To prevent future auto-
|
|
||||||
# restarts at this time, you must make sure you have set AutoLogoffTime or
|
|
||||||
# AutoRestartTime to your desired value before running IBC. NB: this does not
|
|
||||||
# apply to TWS from version 1018 onwards.
|
|
||||||
|
|
||||||
AutoLogoffTime=
|
# Set to yes or no (lower case).
|
||||||
|
#
|
||||||
|
# yes means allow TWS to shut down automatically at its
|
||||||
|
# specified shutdown time, which is set via the TWS
|
||||||
|
# configuration menu.
|
||||||
|
#
|
||||||
|
# no means TWS never shuts down automatically.
|
||||||
|
#
|
||||||
|
# NB: IB recommends that you do not keep TWS running
|
||||||
|
# continuously. If you set this setting to 'no', you may
|
||||||
|
# experience incorrect TWS operation.
|
||||||
|
#
|
||||||
|
# NB: the default for this setting is 'no'. Since this will
|
||||||
|
# only work properly with TWS versions earlier than 974, you
|
||||||
|
# should explicitly set this to 'yes' for version 974 and later.
|
||||||
|
|
||||||
|
IbAutoClosedown=yes
|
||||||
|
|
||||||
AutoRestartTime=
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 5. TWS Tidy Closedown Time
|
# 5. TWS Tidy Closedown Time
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
#
|
#
|
||||||
# Specifies a time at which TWS will close down tidily, with no restart.
|
# NB: starting with TWS 974 this is no longer a useful option
|
||||||
|
# because both TWS and Gateway now have the same auto-logoff
|
||||||
|
# mechanism, and IBC can no longer avoid this.
|
||||||
#
|
#
|
||||||
# There is little reason to use this setting. It is similar to AutoLogoffTime,
|
# Note that giving this setting a value does not change TWS's
|
||||||
# but can include a day-of-the-week, whereas AutoLogoffTime and AutoRestartTime
|
# auto-logoff in any way: any setting will be additional to the
|
||||||
# apply every day. So for example you could use ClosedownAt in conjunction with
|
# TWS auto-logoff.
|
||||||
# AutoRestartTime to shut down TWS on Friday evenings after the markets
|
|
||||||
# close, without it running on Saturday as well.
|
|
||||||
#
|
#
|
||||||
# To tell IBC to tidily close TWS at a specified time every
|
# To tell IBC to tidily close TWS at a specified time every
|
||||||
# day, set this value to <hh:mm>, for example:
|
# day, set this value to <hh:mm>, for example:
|
||||||
|
|
@ -679,7 +487,7 @@ AcceptIncomingConnectionAction=reject
|
||||||
# no means the dialog remains on display and must be
|
# no means the dialog remains on display and must be
|
||||||
# handled by the user.
|
# handled by the user.
|
||||||
|
|
||||||
AllowBlindTrading=no
|
AllowBlindTrading=yes
|
||||||
|
|
||||||
|
|
||||||
# Save Settings on a Schedule
|
# Save Settings on a Schedule
|
||||||
|
|
@ -722,26 +530,6 @@ AllowBlindTrading=no
|
||||||
SaveTwsSettingsAt=
|
SaveTwsSettingsAt=
|
||||||
|
|
||||||
|
|
||||||
# Confirm Crypto Currency Orders Automatically
|
|
||||||
# --------------------------------------------
|
|
||||||
#
|
|
||||||
# When you place an order for a cryptocurrency contract, a dialog is displayed
|
|
||||||
# asking you to confirm that you want to place the order, and notifying you
|
|
||||||
# that you are placing an order to trade cryptocurrency with Paxos, a New York
|
|
||||||
# limited trust company, and not at Interactive Brokers.
|
|
||||||
#
|
|
||||||
# transmit means that the order will be placed automatically, and the
|
|
||||||
# dialog will then be closed
|
|
||||||
#
|
|
||||||
# cancel means that the order will not be placed, and the dialog will
|
|
||||||
# then be closed
|
|
||||||
#
|
|
||||||
# manual means that IBC will take no action and the user must deal
|
|
||||||
# with the dialog
|
|
||||||
|
|
||||||
ConfirmCryptoCurrencyOrders=transmit
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 7. Settings Specific to Indian Versions of TWS
|
# 7. Settings Specific to Indian Versions of TWS
|
||||||
|
|
@ -778,17 +566,13 @@ DismissNSEComplianceNotice=yes
|
||||||
#
|
#
|
||||||
# The port number that IBC listens on for commands
|
# The port number that IBC listens on for commands
|
||||||
# such as "STOP". DO NOT set this to the port number
|
# such as "STOP". DO NOT set this to the port number
|
||||||
# used for TWS API connections.
|
# used for TWS API connections. There is no good reason
|
||||||
#
|
# to change this setting unless the port is used by
|
||||||
# The convention is to use 7462 for this port,
|
# some other application (typically another instance of
|
||||||
# but it must be set to a different value from any other
|
# IBC). The default value is 0, which tells IBC not to
|
||||||
# IBC instance that might run at the same time.
|
# start the command server
|
||||||
#
|
|
||||||
# The default value is 0, which tells IBC not to start
|
|
||||||
# the command server
|
|
||||||
|
|
||||||
#CommandServerPort=7462
|
#CommandServerPort=7462
|
||||||
CommandServerPort=0
|
|
||||||
|
|
||||||
|
|
||||||
# Permitted Command Sources
|
# Permitted Command Sources
|
||||||
|
|
@ -799,19 +583,19 @@ CommandServerPort=0
|
||||||
# IBC. Commands can always be sent from the
|
# IBC. Commands can always be sent from the
|
||||||
# same host as IBC is running on.
|
# same host as IBC is running on.
|
||||||
|
|
||||||
ControlFrom=
|
ControlFrom=127.0.0.1
|
||||||
|
|
||||||
|
|
||||||
# Address for Receiving Commands
|
# Address for Receiving Commands
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
#
|
#
|
||||||
# Specifies the IP address on which the Command Server
|
# Specifies the IP address on which the Command Server
|
||||||
# is to listen. For a multi-homed host, this can be used
|
# is so listen. For a multi-homed host, this can be used
|
||||||
# to specify that connection requests are only to be
|
# to specify that connection requests are only to be
|
||||||
# accepted on the specified address. The default is to
|
# accepted on the specified address. The default is to
|
||||||
# accept connection requests on all local addresses.
|
# accept connection requests on all local addresses.
|
||||||
|
|
||||||
BindAddress=
|
BindAddress=127.0.0.1
|
||||||
|
|
||||||
|
|
||||||
# Command Prompt
|
# Command Prompt
|
||||||
|
|
@ -837,7 +621,7 @@ CommandPrompt=
|
||||||
# information is sent. The default is that such information
|
# information is sent. The default is that such information
|
||||||
# is not sent.
|
# is not sent.
|
||||||
|
|
||||||
SuppressInfoMessages=yes
|
SuppressInfoMessages=no
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -867,10 +651,10 @@ SuppressInfoMessages=yes
|
||||||
# The LogStructureScope setting indicates which windows are
|
# The LogStructureScope setting indicates which windows are
|
||||||
# eligible for structure logging:
|
# eligible for structure logging:
|
||||||
#
|
#
|
||||||
# - (default value) if set to 'known', only windows that
|
# - if set to 'known', only windows that IBC recognizes
|
||||||
# IBC recognizes are eligible - these are windows that
|
# are eligible - these are windows that IBC has some
|
||||||
# IBC has some interest in monitoring, usually to take
|
# interest in monitoring, usually to take some action
|
||||||
# some action on the user's behalf;
|
# on the user's behalf;
|
||||||
#
|
#
|
||||||
# - if set to 'unknown', only windows that IBC does not
|
# - if set to 'unknown', only windows that IBC does not
|
||||||
# recognize are eligible. Most windows displayed by
|
# recognize are eligible. Most windows displayed by
|
||||||
|
|
@ -883,8 +667,9 @@ SuppressInfoMessages=yes
|
||||||
# - if set to 'all', then every window displayed by TWS
|
# - if set to 'all', then every window displayed by TWS
|
||||||
# is eligible.
|
# is eligible.
|
||||||
#
|
#
|
||||||
|
# The default value is 'known'.
|
||||||
|
|
||||||
LogStructureScope=known
|
LogStructureScope=all
|
||||||
|
|
||||||
|
|
||||||
# When to Log Window Structure
|
# When to Log Window Structure
|
||||||
|
|
@ -897,15 +682,13 @@ LogStructureScope=known
|
||||||
# structure of an eligible window the first time it
|
# structure of an eligible window the first time it
|
||||||
# is encountered;
|
# is encountered;
|
||||||
#
|
#
|
||||||
# - if set to 'openclose', the structure is logged every
|
|
||||||
# time an eligible window is opened or closed;
|
|
||||||
#
|
|
||||||
# - if set to 'activate', the structure is logged every
|
# - if set to 'activate', the structure is logged every
|
||||||
# time an eligible window is made active;
|
# time an eligible window is made active;
|
||||||
#
|
#
|
||||||
# - (default value) if set to 'never' or 'no' or 'false',
|
# - if set to 'never' or 'no' or 'false', structure
|
||||||
# structure information is never logged.
|
# information is never logged.
|
||||||
#
|
#
|
||||||
|
# The default value is 'never'.
|
||||||
|
|
||||||
LogStructureWhen=never
|
LogStructureWhen=never
|
||||||
|
|
||||||
|
|
@ -925,3 +708,4 @@ LogStructureWhen=never
|
||||||
#LogComponents=
|
#LogComponents=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
138
flake.lock
138
flake.lock
|
|
@ -1,138 +0,0 @@
|
||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1689068808,
|
|
||||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"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",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"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": {
|
|
||||||
"inputs": {
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
180
flake.nix
180
flake.nix
|
|
@ -1,180 +0,0 @@
|
||||||
# NOTE: to convert to a poetry2nix env like this here are the
|
|
||||||
# steps:
|
|
||||||
# - install poetry in your system nix config
|
|
||||||
# - convert the repo to use poetry using `poetry init`:
|
|
||||||
# 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
|
|
||||||
# -
|
|
||||||
|
|
||||||
# GROKin tips:
|
|
||||||
# - 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 = {
|
|
||||||
self,
|
|
||||||
nixpkgs,
|
|
||||||
flake-utils,
|
|
||||||
poetry2nix,
|
|
||||||
}:
|
|
||||||
# TODO: build cross-OS and use the `${system}` var thingy..
|
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
|
||||||
# use PWD as sources
|
|
||||||
projectDir = ./.;
|
|
||||||
pyproject = ./pyproject.toml;
|
|
||||||
poetrylock = ./poetry.lock;
|
|
||||||
|
|
||||||
# TODO: port to 3.11 and support both versions?
|
|
||||||
python = "python3.10";
|
|
||||||
|
|
||||||
# for more functions and examples.
|
|
||||||
# inherit
|
|
||||||
# (poetry2nix.legacyPackages.${system})
|
|
||||||
# mkPoetryApplication;
|
|
||||||
# pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
|
|
||||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
|
||||||
lib = pkgs.lib;
|
|
||||||
p2npkgs = poetry2nix.legacyPackages.x86_64-linux;
|
|
||||||
|
|
||||||
# define all pkg overrides per dep, see edgecases.md:
|
|
||||||
# https://github.com/nix-community/poetry2nix/blob/master/docs/edgecases.md
|
|
||||||
# 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" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
# auto-generate override entries
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
# override some ahead-of-time compiled extensions
|
|
||||||
# to be built with their wheels.
|
|
||||||
ahot_overrides = p2n-overrides.extend(
|
|
||||||
final: prev: {
|
|
||||||
|
|
||||||
# llvmlite = prev.llvmlite.override {
|
|
||||||
# preferWheel = false;
|
|
||||||
# };
|
|
||||||
|
|
||||||
# TODO: get this workin with p2n and nixpkgs..
|
|
||||||
# pyqt6 = prev.pyqt6.override {
|
|
||||||
# preferWheel = true;
|
|
||||||
# };
|
|
||||||
|
|
||||||
# NOTE: this DOESN'T work atm but after a fix
|
|
||||||
# to poetry2nix, it will and actually this line
|
|
||||||
# won't be needed - thanks @k900:
|
|
||||||
# https://github.com/nix-community/poetry2nix/pull/1257
|
|
||||||
pyqt5 = prev.pyqt5.override {
|
|
||||||
# withWebkit = false;
|
|
||||||
preferWheel = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
# see PR from @k900:
|
|
||||||
# https://github.com/nix-community/poetry2nix/pull/1257
|
|
||||||
# pyqt5-qt5 = prev.pyqt5-qt5.override {
|
|
||||||
# withWebkit = false;
|
|
||||||
# preferWheel = true;
|
|
||||||
# };
|
|
||||||
|
|
||||||
# TODO: patch in an override for polars to build
|
|
||||||
# from src! See the details likely needed from
|
|
||||||
# the cryptography entry:
|
|
||||||
# https://github.com/nix-community/poetry2nix/blob/master/overrides/default.nix#L426-L435
|
|
||||||
polars = prev.polars.override {
|
|
||||||
preferWheel = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
.accounting
|
|
||||||
-----------
|
|
||||||
A subsystem for transaction processing, storage and historical
|
|
||||||
measurement.
|
|
||||||
|
|
||||||
|
|
||||||
.pnl
|
|
||||||
----
|
|
||||||
BEP, the break even price: the price at which liquidating
|
|
||||||
a remaining position results in a zero PnL since the position was
|
|
||||||
"opened" in the destination asset.
|
|
||||||
|
|
||||||
PPU: price-per-unit: the "average cost" (in cumulative mean terms)
|
|
||||||
of the "entry" transactions which "make a position larger"; taking
|
|
||||||
a profit relative to this price means that you will "make more
|
|
||||||
profit then made prior" since the position was opened.
|
|
||||||
|
|
@ -21,21 +21,17 @@ for tendiez.
|
||||||
'''
|
'''
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
|
|
||||||
from .calc import (
|
|
||||||
iter_by_dt,
|
|
||||||
)
|
|
||||||
from ._ledger import (
|
from ._ledger import (
|
||||||
|
iter_by_dt,
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionLedger,
|
TransactionLedger,
|
||||||
open_trade_ledger,
|
open_trade_ledger,
|
||||||
)
|
)
|
||||||
from ._pos import (
|
from ._pos import (
|
||||||
Account,
|
load_pps_from_ledger,
|
||||||
load_account,
|
|
||||||
load_account_from_ledger,
|
|
||||||
open_pps,
|
open_pps,
|
||||||
open_account,
|
|
||||||
Position,
|
Position,
|
||||||
|
PpTable,
|
||||||
)
|
)
|
||||||
from ._mktinfo import (
|
from ._mktinfo import (
|
||||||
Asset,
|
Asset,
|
||||||
|
|
@ -51,25 +47,22 @@ from ._allocate import (
|
||||||
Allocator,
|
Allocator,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Account',
|
|
||||||
'Allocator',
|
'Allocator',
|
||||||
'Asset',
|
'Asset',
|
||||||
'MktPair',
|
'MktPair',
|
||||||
'Position',
|
'Position',
|
||||||
|
'PpTable',
|
||||||
'Symbol',
|
'Symbol',
|
||||||
'Transaction',
|
'Transaction',
|
||||||
'TransactionLedger',
|
'TransactionLedger',
|
||||||
'dec_digits',
|
'dec_digits',
|
||||||
'digits_to_dec',
|
'digits_to_dec',
|
||||||
'iter_by_dt',
|
'iter_by_dt',
|
||||||
'load_account',
|
'load_pps_from_ledger',
|
||||||
'load_account_from_ledger',
|
|
||||||
'mk_allocator',
|
'mk_allocator',
|
||||||
'open_account',
|
|
||||||
'open_pps',
|
'open_pps',
|
||||||
'open_trade_ledger',
|
'open_trade_ledger',
|
||||||
'unpack_fqme',
|
'unpack_fqme',
|
||||||
|
|
@ -89,7 +82,7 @@ def get_likely_pair(
|
||||||
|
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
src_name_start: str = bs_mktid.rindex(src)
|
src_name_start = bs_mktid.rindex(src)
|
||||||
except (
|
except (
|
||||||
ValueError, # substr not found
|
ValueError, # substr not found
|
||||||
):
|
):
|
||||||
|
|
@ -100,8 +93,25 @@ def get_likely_pair(
|
||||||
# log.warning(
|
# log.warning(
|
||||||
# f'No src fiat {src} found in {bs_mktid}?'
|
# f'No src fiat {src} found in {bs_mktid}?'
|
||||||
# )
|
# )
|
||||||
return None
|
return
|
||||||
|
|
||||||
likely_dst: str = bs_mktid[:src_name_start]
|
likely_dst = bs_mktid[:src_name_start]
|
||||||
if likely_dst == dst:
|
if likely_dst == dst:
|
||||||
return bs_mktid
|
return bs_mktid
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
from pprint import pformat
|
||||||
|
|
||||||
|
args = sys.argv
|
||||||
|
assert len(args) > 1, 'Specifiy account(s) from `brokers.toml`'
|
||||||
|
args = args[1:]
|
||||||
|
for acctid in args:
|
||||||
|
broker, name = acctid.split('.')
|
||||||
|
trans, updated_pps = load_pps_from_ledger(broker, name)
|
||||||
|
print(
|
||||||
|
f'Processing transactions into pps for {broker}:{acctid}\n'
|
||||||
|
f'{pformat(trans)}\n\n'
|
||||||
|
f'{pformat(updated_pps)}'
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from bidict import bidict
|
||||||
|
|
||||||
from ._pos import Position
|
from ._pos import Position
|
||||||
from . import MktPair
|
from . import MktPair
|
||||||
from piker.types import Struct
|
from ..data.types import Struct
|
||||||
|
|
||||||
|
|
||||||
_size_units = bidict({
|
_size_units = bidict({
|
||||||
|
|
@ -118,9 +118,9 @@ class Allocator(Struct):
|
||||||
ld: int = mkt.size_tick_digits
|
ld: int = mkt.size_tick_digits
|
||||||
|
|
||||||
size_unit = self.size_unit
|
size_unit = self.size_unit
|
||||||
live_size = live_pp.cumsize
|
live_size = live_pp.size
|
||||||
abs_live_size = abs(live_size)
|
abs_live_size = abs(live_size)
|
||||||
abs_startup_size = abs(startup_pp.cumsize)
|
abs_startup_size = abs(startup_pp.size)
|
||||||
|
|
||||||
u_per_slot, currency_per_slot = self.step_sizes()
|
u_per_slot, currency_per_slot = self.step_sizes()
|
||||||
|
|
||||||
|
|
@ -213,6 +213,8 @@ class Allocator(Struct):
|
||||||
slots_used = self.slots_used(
|
slots_used = self.slots_used(
|
||||||
Position(
|
Position(
|
||||||
mkt=mkt,
|
mkt=mkt,
|
||||||
|
size=order_size,
|
||||||
|
ppu=price,
|
||||||
bs_mktid=mkt.bs_mktid,
|
bs_mktid=mkt.bs_mktid,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -239,7 +241,7 @@ class Allocator(Struct):
|
||||||
Calc and return the number of slots used by this ``Position``.
|
Calc and return the number of slots used by this ``Position``.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
abs_pp_size = abs(pp.cumsize)
|
abs_pp_size = abs(pp.size)
|
||||||
|
|
||||||
if self.size_unit == 'currency':
|
if self.size_unit == 'currency':
|
||||||
# live_currency_size = size or (abs_pp_size * pp.ppu)
|
# live_currency_size = size or (abs_pp_size * pp.ppu)
|
||||||
|
|
|
||||||
|
|
@ -21,77 +21,65 @@ Trade and transaction ledger processing.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from collections import UserDict
|
from collections import UserDict
|
||||||
from contextlib import contextmanager as cm
|
from contextlib import contextmanager as cm
|
||||||
from functools import partial
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pprint import pformat
|
|
||||||
from types import ModuleType
|
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
Generator,
|
Iterator,
|
||||||
Literal,
|
Union,
|
||||||
TYPE_CHECKING,
|
Generator
|
||||||
)
|
)
|
||||||
|
|
||||||
from pendulum import (
|
from pendulum import (
|
||||||
|
datetime,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
from_timestamp,
|
||||||
|
parse,
|
||||||
)
|
)
|
||||||
import tomli_w # for fast ledger writing
|
import tomli_w # for fast ledger writing
|
||||||
|
|
||||||
from piker.types import Struct
|
from .. import config
|
||||||
from piker import config
|
from ..data.types import Struct
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from .calc import (
|
from ._mktinfo import (
|
||||||
iter_by_dt,
|
Symbol, # legacy
|
||||||
|
MktPair,
|
||||||
|
Asset,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..data._symcache import (
|
|
||||||
SymbologyCache,
|
|
||||||
)
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
TxnType = Literal[
|
|
||||||
'clear',
|
|
||||||
'transfer',
|
|
||||||
|
|
||||||
# TODO: see https://github.com/pikers/piker/issues/510
|
|
||||||
# 'split',
|
|
||||||
# 'rename',
|
|
||||||
# 'resize',
|
|
||||||
# 'removal',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Transaction(Struct, frozen=True):
|
class Transaction(Struct, frozen=True):
|
||||||
|
|
||||||
# NOTE: this is a unified acronym also used in our `MktPair`
|
# TODO: unify this with the `MktPair`,
|
||||||
# and can stand for any of a
|
# once we have that as a required field,
|
||||||
# "fully qualified <blank> endpoint":
|
# we don't really need the fqme any more..
|
||||||
# - "market" in the case of financial trades
|
|
||||||
# (btcusdt.spot.binance).
|
|
||||||
# - "merkel (tree)" aka a blockchain system "wallet tranfers"
|
|
||||||
# (btc.blockchain)
|
|
||||||
# - "money" for tradtitional (digital databases)
|
|
||||||
# *bank accounts* (usd.swift, eur.sepa)
|
|
||||||
fqme: str
|
fqme: str
|
||||||
|
|
||||||
tid: str | int # unique transaction id
|
tid: Union[str, int] # unique transaction id
|
||||||
size: float
|
size: float
|
||||||
price: float
|
price: float
|
||||||
cost: float # commisions or other additional costs
|
cost: float # commisions or other additional costs
|
||||||
dt: DateTime
|
dt: datetime
|
||||||
|
|
||||||
# the "event type" in terms of "market events" see above and
|
|
||||||
# https://github.com/pikers/piker/issues/510
|
|
||||||
etype: TxnType = 'clear'
|
|
||||||
|
|
||||||
# TODO: we can drop this right since we
|
# TODO: we can drop this right since we
|
||||||
# can instead expect the backend to provide this
|
# can instead expect the backend to provide this
|
||||||
# via the `MktPair`?
|
# via the `MktPair`?
|
||||||
expiry: DateTime | None = None
|
expiry: datetime | None = None
|
||||||
|
|
||||||
|
# TODO: drop the Symbol type, construct using
|
||||||
|
# t.sys (the transaction system)
|
||||||
|
|
||||||
|
# the underlying "transaction system", normally one of a ``MktPair``
|
||||||
|
# (a description of a tradable double auction) or a ledger-recorded
|
||||||
|
# ("ledger" in any sense as long as you can record transfers) of any
|
||||||
|
# sort) ``Asset``.
|
||||||
|
sym: MktPair | Asset | Symbol | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sys(self) -> Symbol:
|
||||||
|
return self.sym
|
||||||
|
|
||||||
# (optional) key-id defined by the broker-service backend which
|
# (optional) key-id defined by the broker-service backend which
|
||||||
# ensures the instrument-symbol market key for this record is unique
|
# ensures the instrument-symbol market key for this record is unique
|
||||||
|
|
@ -100,16 +88,15 @@ class Transaction(Struct, frozen=True):
|
||||||
# service.
|
# service.
|
||||||
bs_mktid: str | int | None = None
|
bs_mktid: str | int | None = None
|
||||||
|
|
||||||
def to_dict(
|
def to_dict(self) -> dict:
|
||||||
self,
|
dct = super().to_dict()
|
||||||
**kwargs,
|
|
||||||
) -> dict:
|
# TODO: switch to sys!
|
||||||
dct: dict[str, Any] = super().to_dict(**kwargs)
|
dct.pop('sym')
|
||||||
|
|
||||||
# ensure we use a pendulum formatted
|
# ensure we use a pendulum formatted
|
||||||
# ISO style str here!@
|
# ISO style str here!@
|
||||||
dct['dt'] = str(self.dt)
|
dct['dt'] = str(self.dt)
|
||||||
|
|
||||||
return dct
|
return dct
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -121,45 +108,17 @@ class TransactionLedger(UserDict):
|
||||||
outside.
|
outside.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# NOTE: see `open_trade_ledger()` for defaults, this should
|
|
||||||
# never be constructed manually!
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
ledger_dict: dict,
|
ledger_dict: dict,
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
account: str,
|
|
||||||
mod: ModuleType, # broker mod
|
|
||||||
tx_sort: Callable,
|
tx_sort: Callable,
|
||||||
symcache: SymbologyCache,
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.account: str = account
|
self.file_path = file_path
|
||||||
self.file_path: Path = file_path
|
self.tx_sort = tx_sort
|
||||||
self.mod: ModuleType = mod
|
|
||||||
self.tx_sort: Callable = tx_sort
|
|
||||||
|
|
||||||
self._symcache: SymbologyCache = symcache
|
|
||||||
|
|
||||||
# any added txns we keep in that form for meta-data
|
|
||||||
# gathering purposes
|
|
||||||
self._txns: dict[str, Transaction] = {}
|
|
||||||
|
|
||||||
super().__init__(ledger_dict)
|
super().__init__(ledger_dict)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return (
|
|
||||||
f'TransactionLedger: {len(self)}\n'
|
|
||||||
f'{pformat(list(self.data))}'
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def symcache(self) -> SymbologyCache:
|
|
||||||
'''
|
|
||||||
Read-only ref to backend's ``SymbologyCache``.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return self._symcache
|
|
||||||
|
|
||||||
def update_from_t(
|
def update_from_t(
|
||||||
self,
|
self,
|
||||||
t: Transaction,
|
t: Transaction,
|
||||||
|
|
@ -170,14 +129,14 @@ class TransactionLedger(UserDict):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
self.data[t.tid] = t.to_dict()
|
self.data[t.tid] = t.to_dict()
|
||||||
self._txns[t.tid] = t
|
|
||||||
|
|
||||||
def iter_txns(
|
def iter_trans(
|
||||||
self,
|
self,
|
||||||
symcache: SymbologyCache | None = None,
|
mkt_by_fqme: dict[str, MktPair],
|
||||||
|
broker: str = 'paper',
|
||||||
|
|
||||||
) -> Generator[
|
) -> Generator[
|
||||||
Transaction,
|
tuple[str, Transaction],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
]:
|
]:
|
||||||
|
|
@ -186,125 +145,129 @@ class TransactionLedger(UserDict):
|
||||||
form via generator.
|
form via generator.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
symcache = symcache or self._symcache
|
if broker != 'paper':
|
||||||
|
raise NotImplementedError('Per broker support not dun yet!')
|
||||||
|
|
||||||
if self.account == 'paper':
|
# TODO: lookup some standard normalizer
|
||||||
from piker.clearing import _paper_engine
|
# func in the backend?
|
||||||
norm_trade: Callable = partial(
|
# from ..brokers import get_brokermod
|
||||||
_paper_engine.norm_trade,
|
# mod = get_brokermod(broker)
|
||||||
brokermod=self.mod,
|
# trans_dict = mod.norm_trade_records(self.data)
|
||||||
|
|
||||||
|
# NOTE: instead i propose the normalizer is
|
||||||
|
# a one shot routine (that can be lru cached)
|
||||||
|
# and instead call it for each entry incrementally:
|
||||||
|
# normer = mod.norm_trade_record(txdict)
|
||||||
|
|
||||||
|
# TODO: use tx_sort here yah?
|
||||||
|
for txdict in self.tx_sort(self.data.values()):
|
||||||
|
# for tid, txdict in self.data.items():
|
||||||
|
# special field handling for datetimes
|
||||||
|
# to ensure pendulum is used!
|
||||||
|
tid: str = txdict['tid']
|
||||||
|
fqme: str = txdict.get('fqme') or txdict['fqsn']
|
||||||
|
dt: DateTime = parse(txdict['dt'])
|
||||||
|
expiry: str | None = txdict.get('expiry')
|
||||||
|
|
||||||
|
if not (mkt := mkt_by_fqme.get(fqme)):
|
||||||
|
# we can't build a trans if we don't have
|
||||||
|
# the ``.sys: MktPair`` info, so skip.
|
||||||
|
continue
|
||||||
|
|
||||||
|
tx = Transaction(
|
||||||
|
fqme=fqme,
|
||||||
|
tid=txdict['tid'],
|
||||||
|
dt=dt,
|
||||||
|
price=txdict['price'],
|
||||||
|
size=txdict['size'],
|
||||||
|
cost=txdict.get('cost', 0),
|
||||||
|
bs_mktid=txdict['bs_mktid'],
|
||||||
|
|
||||||
|
# TODO: change to .sys!
|
||||||
|
sym=mkt,
|
||||||
|
expiry=parse(expiry) if expiry else None,
|
||||||
)
|
)
|
||||||
|
yield tid, tx
|
||||||
|
|
||||||
else:
|
def to_trans(
|
||||||
norm_trade: Callable = self.mod.norm_trade
|
|
||||||
|
|
||||||
# datetime-sort and pack into txs
|
|
||||||
for tid, txdict in self.tx_sort(self.data.items()):
|
|
||||||
txn: Transaction = norm_trade(
|
|
||||||
tid,
|
|
||||||
txdict,
|
|
||||||
pairs=symcache.pairs,
|
|
||||||
symcache=symcache,
|
|
||||||
)
|
|
||||||
yield txn
|
|
||||||
|
|
||||||
def to_txns(
|
|
||||||
self,
|
self,
|
||||||
symcache: SymbologyCache | None = None,
|
**kwargs,
|
||||||
|
|
||||||
) -> dict[str, Transaction]:
|
) -> dict[str, Transaction]:
|
||||||
'''
|
'''
|
||||||
Return entire output from ``.iter_txns()`` in a ``dict``.
|
Return entire output from ``.iter_trans()`` in a ``dict``.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
txns: dict[str, Transaction] = {}
|
return dict(self.iter_trans(**kwargs))
|
||||||
for t in self.iter_txns(symcache=symcache):
|
|
||||||
|
|
||||||
if not t:
|
def write_config(
|
||||||
log.warning(f'{self.mod.name}:{self.account} TXN is -> {t}')
|
self,
|
||||||
continue
|
|
||||||
|
|
||||||
txns[t.tid] = t
|
) -> None:
|
||||||
|
|
||||||
return txns
|
|
||||||
|
|
||||||
def write_config(self) -> None:
|
|
||||||
'''
|
'''
|
||||||
Render the self.data ledger dict to its TOML file form.
|
Render the self.data ledger dict to it's TOML file form.
|
||||||
|
|
||||||
ALWAYS order datetime sorted!
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
is_paper: bool = self.account == 'paper'
|
cpy = self.data.copy()
|
||||||
|
|
||||||
symcache: SymbologyCache = self._symcache
|
|
||||||
towrite: dict[str, Any] = {}
|
towrite: dict[str, Any] = {}
|
||||||
for tid, txdict in self.tx_sort(self.data.copy()):
|
for tid, trans in cpy.items():
|
||||||
# write blank-str expiry for non-expiring assets
|
|
||||||
|
# drop key for non-expiring assets
|
||||||
|
txdict = towrite[tid] = self.data[tid]
|
||||||
if (
|
if (
|
||||||
'expiry' in txdict
|
'expiry' in txdict
|
||||||
and txdict['expiry'] is None
|
and txdict['expiry'] is None
|
||||||
):
|
):
|
||||||
txdict['expiry'] = ''
|
txdict.pop('expiry')
|
||||||
|
|
||||||
# (maybe) re-write old acro-key
|
# re-write old acro-key
|
||||||
if (
|
fqme = txdict.get('fqsn')
|
||||||
is_paper
|
if fqme:
|
||||||
# if symcache is empty/not supported (yet), don't
|
|
||||||
# bother xD
|
|
||||||
and symcache.mktmaps
|
|
||||||
):
|
|
||||||
fqme: str = txdict.pop('fqsn', None) or txdict['fqme']
|
|
||||||
bs_mktid: str | None = txdict.get('bs_mktid')
|
|
||||||
|
|
||||||
if (
|
|
||||||
|
|
||||||
fqme not in symcache.mktmaps
|
|
||||||
or (
|
|
||||||
# also try to see if this is maybe a paper
|
|
||||||
# engine ledger in which case the bs_mktid
|
|
||||||
# should be the fqme as well!
|
|
||||||
bs_mktid
|
|
||||||
and fqme != bs_mktid
|
|
||||||
)
|
|
||||||
):
|
|
||||||
# always take any (paper) bs_mktid if defined and
|
|
||||||
# in the backend's cache key set.
|
|
||||||
if bs_mktid in symcache.mktmaps:
|
|
||||||
fqme: str = bs_mktid
|
|
||||||
else:
|
|
||||||
best_fqme: str = list(symcache.search(fqme))[0]
|
|
||||||
log.warning(
|
|
||||||
f'Could not find FQME: {fqme} in qualified set?\n'
|
|
||||||
f'Qualifying and expanding {fqme} -> {best_fqme}'
|
|
||||||
)
|
|
||||||
fqme = best_fqme
|
|
||||||
|
|
||||||
if (
|
|
||||||
bs_mktid
|
|
||||||
and bs_mktid != fqme
|
|
||||||
):
|
|
||||||
# in paper account case always make sure both the
|
|
||||||
# fqme and bs_mktid are fully qualified..
|
|
||||||
txdict['bs_mktid'] = fqme
|
|
||||||
|
|
||||||
# in paper ledgers always write the latest
|
|
||||||
# symbology key field: an FQME.
|
|
||||||
txdict['fqme'] = fqme
|
txdict['fqme'] = fqme
|
||||||
|
|
||||||
towrite[tid] = txdict
|
|
||||||
|
|
||||||
with self.file_path.open(mode='wb') as fp:
|
with self.file_path.open(mode='wb') as fp:
|
||||||
tomli_w.dump(towrite, fp)
|
tomli_w.dump(towrite, fp)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_by_dt(
|
||||||
|
records: dict[str, dict[str, Any]] | list[dict],
|
||||||
|
|
||||||
|
# NOTE: parsers are looked up in the insert order
|
||||||
|
# so if you know that the record stats show some field
|
||||||
|
# is more common then others, stick it at the top B)
|
||||||
|
parsers: dict[tuple[str], Callable] = {
|
||||||
|
'dt': None, # parity case
|
||||||
|
'datetime': parse, # datetime-str
|
||||||
|
'time': from_timestamp, # float epoch
|
||||||
|
},
|
||||||
|
key: Callable | None = None,
|
||||||
|
|
||||||
|
) -> Iterator[tuple[str, dict]]:
|
||||||
|
'''
|
||||||
|
Iterate entries of a ``records: dict`` table sorted by entry recorded
|
||||||
|
datetime presumably set at the ``'dt'`` field in each entry.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def dyn_parse_to_dt(txdict: dict[str, Any]) -> DateTime:
|
||||||
|
k, v, parser = next(
|
||||||
|
(k, txdict[k], parsers[k]) for k in parsers if k in txdict
|
||||||
|
)
|
||||||
|
return parser(v) if parser else v
|
||||||
|
|
||||||
|
if isinstance(records, dict):
|
||||||
|
records = records.values()
|
||||||
|
|
||||||
|
for entry in sorted(
|
||||||
|
records,
|
||||||
|
key=key or dyn_parse_to_dt,
|
||||||
|
):
|
||||||
|
yield entry
|
||||||
|
|
||||||
|
|
||||||
def load_ledger(
|
def load_ledger(
|
||||||
brokername: str,
|
brokername: str,
|
||||||
acctid: str,
|
acctid: str,
|
||||||
|
|
||||||
# for testing or manual load from file
|
|
||||||
dirpath: Path | None = None,
|
|
||||||
|
|
||||||
) -> tuple[dict, Path]:
|
) -> tuple[dict, Path]:
|
||||||
'''
|
'''
|
||||||
Load a ledger (TOML) file from user's config directory:
|
Load a ledger (TOML) file from user's config directory:
|
||||||
|
|
@ -319,11 +282,7 @@ def load_ledger(
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
import tomli as tomllib
|
import tomli as tomllib
|
||||||
|
|
||||||
ldir: Path = (
|
ldir: Path = config._config_dir / 'accounting' / 'ledgers'
|
||||||
dirpath
|
|
||||||
or
|
|
||||||
config._config_dir / 'accounting' / 'ledgers'
|
|
||||||
)
|
|
||||||
if not ldir.is_dir():
|
if not ldir.is_dir():
|
||||||
ldir.mkdir()
|
ldir.mkdir()
|
||||||
|
|
||||||
|
|
@ -349,15 +308,8 @@ def open_trade_ledger(
|
||||||
broker: str,
|
broker: str,
|
||||||
account: str,
|
account: str,
|
||||||
|
|
||||||
allow_from_sync_code: bool = False,
|
|
||||||
symcache: SymbologyCache | None = None,
|
|
||||||
|
|
||||||
# default is to sort by detected datetime-ish field
|
# default is to sort by detected datetime-ish field
|
||||||
tx_sort: Callable = iter_by_dt,
|
tx_sort: Callable = iter_by_dt,
|
||||||
rewrite: bool = False,
|
|
||||||
|
|
||||||
# for testing or manual load from file
|
|
||||||
_fp: Path | None = None,
|
|
||||||
|
|
||||||
) -> Generator[TransactionLedger, None, None]:
|
) -> Generator[TransactionLedger, None, None]:
|
||||||
'''
|
'''
|
||||||
|
|
@ -369,52 +321,18 @@ def open_trade_ledger(
|
||||||
name as defined in the user's ``brokers.toml`` config.
|
name as defined in the user's ``brokers.toml`` config.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from ..brokers import get_brokermod
|
ledger_dict, fpath = load_ledger(broker, account)
|
||||||
mod: ModuleType = get_brokermod(broker)
|
|
||||||
|
|
||||||
ledger_dict, fpath = load_ledger(
|
|
||||||
broker,
|
|
||||||
account,
|
|
||||||
dirpath=_fp,
|
|
||||||
)
|
|
||||||
cpy = ledger_dict.copy()
|
cpy = ledger_dict.copy()
|
||||||
|
|
||||||
# XXX NOTE: if not provided presume we are being called from
|
|
||||||
# sync code and need to maybe run `trio` to generate..
|
|
||||||
if symcache is None:
|
|
||||||
|
|
||||||
# XXX: be mega pendantic and ensure the caller knows what
|
|
||||||
# they're doing!
|
|
||||||
if not allow_from_sync_code:
|
|
||||||
raise RuntimeError(
|
|
||||||
'You MUST set `allow_from_sync_code=True` when '
|
|
||||||
'calling `open_trade_ledger()` from sync code! '
|
|
||||||
'If you are calling from async code you MUST '
|
|
||||||
'instead pass a `symcache: SymbologyCache`!'
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..data._symcache import (
|
|
||||||
get_symcache,
|
|
||||||
)
|
|
||||||
symcache: SymbologyCache = get_symcache(broker)
|
|
||||||
|
|
||||||
assert symcache
|
|
||||||
|
|
||||||
ledger = TransactionLedger(
|
ledger = TransactionLedger(
|
||||||
ledger_dict=cpy,
|
ledger_dict=cpy,
|
||||||
file_path=fpath,
|
file_path=fpath,
|
||||||
account=account,
|
tx_sort=tx_sort,
|
||||||
mod=mod,
|
|
||||||
symcache=symcache,
|
|
||||||
tx_sort=getattr(mod, 'tx_sort', tx_sort),
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
yield ledger
|
yield ledger
|
||||||
finally:
|
finally:
|
||||||
if (
|
if ledger.data != ledger_dict:
|
||||||
ledger.data != ledger_dict
|
|
||||||
or rewrite
|
|
||||||
):
|
|
||||||
# TODO: show diff output?
|
# TODO: show diff output?
|
||||||
# https://stackoverflow.com/questions/12956957/print-diff-of-python-dictionaries
|
# https://stackoverflow.com/questions/12956957/print-diff-of-python-dictionaries
|
||||||
log.info(f'Updating ledger for {fpath}:\n')
|
log.info(f'Updating ledger for {fpath}:\n')
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ from typing import (
|
||||||
Literal,
|
Literal,
|
||||||
)
|
)
|
||||||
|
|
||||||
from piker.types import Struct
|
from ..data.types import Struct
|
||||||
|
|
||||||
|
|
||||||
# TODO: make these literals..
|
# TODO: make these literals..
|
||||||
|
|
@ -130,29 +130,8 @@ class Asset(Struct, frozen=True):
|
||||||
# should not be explicitly required in our generic API.
|
# should not be explicitly required in our generic API.
|
||||||
info: dict | None = None
|
info: dict | None = None
|
||||||
|
|
||||||
# `None` is not toml-compat so drop info
|
# TODO?
|
||||||
# if no extra data added..
|
# _to_dict_skip = {'info'}
|
||||||
def to_dict(
|
|
||||||
self,
|
|
||||||
**kwargs,
|
|
||||||
) -> dict:
|
|
||||||
dct = super().to_dict(**kwargs)
|
|
||||||
if (info := dct.pop('info', None)):
|
|
||||||
dct['info'] = info
|
|
||||||
|
|
||||||
assert dct['tx_tick']
|
|
||||||
return dct
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_msg(
|
|
||||||
cls,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
) -> Asset:
|
|
||||||
return cls(
|
|
||||||
tx_tick=Decimal(str(msg.pop('tx_tick'))),
|
|
||||||
info=msg.pop('info', None),
|
|
||||||
**msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|
@ -309,40 +288,12 @@ class MktPair(Struct, frozen=True):
|
||||||
# strike price, call 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
|
|
||||||
# be considered "securities"?
|
|
||||||
_atype: str = ''
|
_atype: str = ''
|
||||||
|
|
||||||
# allow explicit disable of the src part of the market
|
|
||||||
# pair name -> useful for legacy markets like qqq.nasdaq.ib
|
|
||||||
_fqme_without_src: bool = False
|
|
||||||
|
|
||||||
# NOTE: when cast to `str` return fqme
|
# NOTE: when cast to `str` return fqme
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.fqme
|
return self.fqme
|
||||||
|
|
||||||
def to_dict(
|
|
||||||
self,
|
|
||||||
**kwargs,
|
|
||||||
) -> dict:
|
|
||||||
d = super().to_dict(**kwargs)
|
|
||||||
d['src'] = self.src.to_dict(**kwargs)
|
|
||||||
|
|
||||||
if not isinstance(self.dst, str):
|
|
||||||
d['dst'] = self.dst.to_dict(**kwargs)
|
|
||||||
else:
|
|
||||||
d['dst'] = str(self.dst)
|
|
||||||
|
|
||||||
d['price_tick'] = str(self.price_tick)
|
|
||||||
d['size_tick'] = str(self.size_tick)
|
|
||||||
|
|
||||||
if self.contract_info is None:
|
|
||||||
d.pop('contract_info')
|
|
||||||
|
|
||||||
# d.pop('_fqme_without_src')
|
|
||||||
|
|
||||||
return d
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_msg(
|
def from_msg(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -353,32 +304,36 @@ class MktPair(Struct, frozen=True):
|
||||||
Constructor for a received msg-dict normally received over IPC.
|
Constructor for a received msg-dict normally received over IPC.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if not isinstance(
|
dst_asset_msg = msg.pop('dst')
|
||||||
dst_asset_msg := msg.pop('dst'),
|
src_asset_msg = msg.pop('src')
|
||||||
str,
|
|
||||||
):
|
if isinstance(dst_asset_msg, str):
|
||||||
dst: Asset = Asset.from_msg(dst_asset_msg) # .copy()
|
src: str = str(src_asset_msg)
|
||||||
|
assert isinstance(src, str)
|
||||||
|
return cls.from_fqme(
|
||||||
|
dst_asset_msg,
|
||||||
|
src=src,
|
||||||
|
**msg,
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
dst: str = dst_asset_msg
|
# NOTE: we call `.copy()` here to ensure
|
||||||
|
# type casting!
|
||||||
|
dst = Asset(**dst_asset_msg).copy()
|
||||||
|
if not isinstance(src_asset_msg, str):
|
||||||
|
src = Asset(**src_asset_msg).copy()
|
||||||
|
else:
|
||||||
|
src = str(src_asset_msg)
|
||||||
|
|
||||||
src_asset_msg: dict = msg.pop('src')
|
|
||||||
src: Asset = Asset.from_msg(src_asset_msg) # .copy()
|
|
||||||
|
|
||||||
# XXX NOTE: ``msgspec`` can encode `Decimal` but it doesn't
|
|
||||||
# decide to it by default since we aren't spec-cing these
|
|
||||||
# msgs as structs proper to get them to decode implictily
|
|
||||||
# (yet) as per,
|
|
||||||
# - https://github.com/pikers/piker/pull/354
|
|
||||||
# - https://github.com/goodboy/tractor/pull/311
|
|
||||||
# SO we have to ensure we do a struct type
|
|
||||||
# case (which `.copy()` does) to ensure we get the right
|
|
||||||
# type!
|
|
||||||
return cls(
|
return cls(
|
||||||
dst=dst,
|
dst=dst,
|
||||||
src=src,
|
src=src,
|
||||||
price_tick=Decimal(msg.pop('price_tick')),
|
|
||||||
size_tick=Decimal(msg.pop('size_tick')),
|
|
||||||
**msg,
|
**msg,
|
||||||
|
# XXX NOTE: ``msgspec`` can encode `Decimal`
|
||||||
|
# but it doesn't decide to it by default since
|
||||||
|
# we aren't spec-cing these msgs as structs, SO
|
||||||
|
# we have to ensure we do a struct type case (which `.copy()`
|
||||||
|
# does) to ensure we get the right type!
|
||||||
).copy()
|
).copy()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -406,20 +361,7 @@ class MktPair(Struct, frozen=True):
|
||||||
):
|
):
|
||||||
_fqme = f'{fqme}.{broker}'
|
_fqme = f'{fqme}.{broker}'
|
||||||
|
|
||||||
broker, mkt_ep_key, venue, expiry = unpack_fqme(_fqme)
|
broker, mkt_ep_key, venue, suffix = unpack_fqme(_fqme)
|
||||||
|
|
||||||
kven: str = kwargs.pop('venue', venue)
|
|
||||||
if venue:
|
|
||||||
assert venue == kven
|
|
||||||
else:
|
|
||||||
venue = kven
|
|
||||||
|
|
||||||
exp: str = kwargs.pop('expiry', expiry)
|
|
||||||
if expiry:
|
|
||||||
assert exp == expiry
|
|
||||||
else:
|
|
||||||
expiry = exp
|
|
||||||
|
|
||||||
dst: Asset = Asset.guess_from_mkt_ep_key(
|
dst: Asset = Asset.guess_from_mkt_ep_key(
|
||||||
mkt_ep_key,
|
mkt_ep_key,
|
||||||
atype=kwargs.get('_atype'),
|
atype=kwargs.get('_atype'),
|
||||||
|
|
@ -431,15 +373,14 @@ class MktPair(Struct, frozen=True):
|
||||||
# which we expect to be filled in by some
|
# which we expect to be filled in by some
|
||||||
# backend client with access to that data-info.
|
# backend client with access to that data-info.
|
||||||
return cls(
|
return cls(
|
||||||
dst=dst,
|
|
||||||
# XXX: not resolved to ``Asset`` :(
|
# XXX: not resolved to ``Asset`` :(
|
||||||
#src=src,
|
dst=dst,
|
||||||
|
|
||||||
broker=broker,
|
broker=broker,
|
||||||
venue=venue,
|
venue=venue,
|
||||||
# XXX NOTE: we presume this token
|
# XXX NOTE: we presume this token
|
||||||
# if the expiry for now!
|
# if the expiry for now!
|
||||||
expiry=expiry,
|
expiry=suffix,
|
||||||
|
|
||||||
price_tick=price_tick,
|
price_tick=price_tick,
|
||||||
size_tick=size_tick,
|
size_tick=size_tick,
|
||||||
|
|
@ -545,7 +486,7 @@ class MktPair(Struct, frozen=True):
|
||||||
'''
|
'''
|
||||||
key: str = (
|
key: str = (
|
||||||
self.pair(delim_char=delim_char)
|
self.pair(delim_char=delim_char)
|
||||||
if not (without_src or self._fqme_without_src)
|
if not without_src
|
||||||
else str(self.dst)
|
else str(self.dst)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -614,7 +555,7 @@ class MktPair(Struct, frozen=True):
|
||||||
if isinstance(self.dst, Asset):
|
if isinstance(self.dst, Asset):
|
||||||
return str(self.dst.atype)
|
return str(self.dst.atype)
|
||||||
|
|
||||||
return 'UNKNOWN'
|
return 'unknown'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def price_tick_digits(self) -> int:
|
def price_tick_digits(self) -> int:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,698 +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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Calculation routines for balance and position tracking such that
|
|
||||||
you know when you're losing money (if possible) XD
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
from collections.abc import ValuesView
|
|
||||||
from contextlib import contextmanager as cm
|
|
||||||
from math import copysign
|
|
||||||
from typing import (
|
|
||||||
Any,
|
|
||||||
Callable,
|
|
||||||
Iterator,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
from pendulum import (
|
|
||||||
DateTime,
|
|
||||||
from_timestamp,
|
|
||||||
parse,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ._ledger import (
|
|
||||||
Transaction,
|
|
||||||
TransactionLedger,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ppu(
|
|
||||||
clears: Iterator[Transaction],
|
|
||||||
|
|
||||||
# include transaction cost in breakeven price
|
|
||||||
# and presume the worst case of the same cost
|
|
||||||
# to exit this transaction (even though in reality
|
|
||||||
# it will be dynamic based on exit stratetgy).
|
|
||||||
cost_scalar: float = 2,
|
|
||||||
|
|
||||||
# return the ledger of clears as a (now dt sorted) dict with
|
|
||||||
# new position fields inserted alongside each entry.
|
|
||||||
as_ledger: bool = False,
|
|
||||||
|
|
||||||
) -> float | list[(str, dict)]:
|
|
||||||
'''
|
|
||||||
Compute the "price-per-unit" price for the given non-zero sized
|
|
||||||
rolling position.
|
|
||||||
|
|
||||||
The recurrence relation which computes this (exponential) mean
|
|
||||||
per new clear which **increases** the accumulative postiion size
|
|
||||||
is:
|
|
||||||
|
|
||||||
ppu[-1] = (
|
|
||||||
ppu[-2] * accum_size[-2]
|
|
||||||
+
|
|
||||||
ppu[-1] * size
|
|
||||||
) / accum_size[-1]
|
|
||||||
|
|
||||||
where `cost_basis` for the current step is simply the price
|
|
||||||
* size of the most recent clearing transaction.
|
|
||||||
|
|
||||||
-----
|
|
||||||
TODO: get the BEP computed and working similarly!
|
|
||||||
-----
|
|
||||||
the equivalent "break even price" or bep at each new clear
|
|
||||||
event step conversely only changes when an "position exiting
|
|
||||||
clear" which **decreases** the cumulative dst asset size:
|
|
||||||
|
|
||||||
bep[-1] = ppu[-1] - (cum_pnl[-1] / cumsize[-1])
|
|
||||||
|
|
||||||
'''
|
|
||||||
asize_h: list[float] = [] # historical accumulative size
|
|
||||||
ppu_h: list[float] = [] # historical price-per-unit
|
|
||||||
# ledger: dict[str, dict] = {}
|
|
||||||
ledger: list[dict] = []
|
|
||||||
|
|
||||||
t: Transaction
|
|
||||||
for t in clears:
|
|
||||||
clear_size: float = t.size
|
|
||||||
clear_price: str | float = t.price
|
|
||||||
is_clear: bool = not isinstance(clear_price, str)
|
|
||||||
|
|
||||||
last_accum_size = asize_h[-1] if asize_h else 0
|
|
||||||
accum_size: float = last_accum_size + clear_size
|
|
||||||
accum_sign = copysign(1, accum_size)
|
|
||||||
sign_change: bool = False
|
|
||||||
|
|
||||||
# on transfers we normally write some non-valid
|
|
||||||
# price since withdrawal to another account/wallet
|
|
||||||
# has nothing to do with inter-asset-market prices.
|
|
||||||
# TODO: this should be better handled via a `type: 'tx'`
|
|
||||||
# field as per existing issue surrounding all this:
|
|
||||||
# https://github.com/pikers/piker/issues/510
|
|
||||||
if isinstance(clear_price, str):
|
|
||||||
# TODO: we can't necessarily have this commit to
|
|
||||||
# the overall pos size since we also need to
|
|
||||||
# include other positions contributions to this
|
|
||||||
# balance or we might end up with a -ve balance for
|
|
||||||
# the position..
|
|
||||||
continue
|
|
||||||
|
|
||||||
# test if the pp somehow went "passed" a net zero size state
|
|
||||||
# resulting in a change of the "sign" of the size (+ve for
|
|
||||||
# long, -ve for short).
|
|
||||||
sign_change = (
|
|
||||||
copysign(1, last_accum_size) + accum_sign == 0
|
|
||||||
and last_accum_size != 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# since we passed the net-zero-size state the new size
|
|
||||||
# after sum should be the remaining size the new
|
|
||||||
# "direction" (aka, long vs. short) for this clear.
|
|
||||||
if sign_change:
|
|
||||||
clear_size: float = accum_size
|
|
||||||
abs_diff: float = abs(accum_size)
|
|
||||||
asize_h.append(0)
|
|
||||||
ppu_h.append(0)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# old size minus the new size gives us size diff with
|
|
||||||
# +ve -> increase in pp size
|
|
||||||
# -ve -> decrease in pp size
|
|
||||||
abs_diff = abs(accum_size) - abs(last_accum_size)
|
|
||||||
|
|
||||||
# XXX: LIFO breakeven price update. only an increaze in size
|
|
||||||
# of the position contributes the breakeven price,
|
|
||||||
# a decrease does not (i.e. the position is being made
|
|
||||||
# smaller).
|
|
||||||
# abs_clear_size = abs(clear_size)
|
|
||||||
abs_new_size: float | int = abs(accum_size)
|
|
||||||
|
|
||||||
if (
|
|
||||||
abs_diff > 0
|
|
||||||
and is_clear
|
|
||||||
):
|
|
||||||
cost_basis = (
|
|
||||||
# cost basis for this clear
|
|
||||||
clear_price * abs(clear_size)
|
|
||||||
+
|
|
||||||
# transaction cost
|
|
||||||
accum_sign * cost_scalar * t.cost
|
|
||||||
)
|
|
||||||
|
|
||||||
if asize_h:
|
|
||||||
size_last: float = abs(asize_h[-1])
|
|
||||||
cb_last: float = ppu_h[-1] * size_last
|
|
||||||
ppu: float = (cost_basis + cb_last) / abs_new_size
|
|
||||||
|
|
||||||
else:
|
|
||||||
ppu: float = cost_basis / abs_new_size
|
|
||||||
|
|
||||||
else:
|
|
||||||
# TODO: for PPU we should probably handle txs out
|
|
||||||
# (aka withdrawals) similarly by simply not having
|
|
||||||
# them contrib to the running PPU calc and only
|
|
||||||
# when the next entry clear comes in (which will
|
|
||||||
# then have a higher weighting on the PPU).
|
|
||||||
|
|
||||||
# on "exit" clears from a given direction,
|
|
||||||
# only the size changes not the price-per-unit
|
|
||||||
# need to be updated since the ppu remains constant
|
|
||||||
# and gets weighted by the new size.
|
|
||||||
ppu: float = ppu_h[-1] if ppu_h else 0 # set to previous value
|
|
||||||
|
|
||||||
# extend with new rolling metric for this step
|
|
||||||
ppu_h.append(ppu)
|
|
||||||
asize_h.append(accum_size)
|
|
||||||
|
|
||||||
# ledger[t.tid] = {
|
|
||||||
# 'txn': t,
|
|
||||||
# ledger[t.tid] = t.to_dict() | {
|
|
||||||
ledger.append((
|
|
||||||
t.tid,
|
|
||||||
t.to_dict() | {
|
|
||||||
'ppu': ppu,
|
|
||||||
'cumsize': accum_size,
|
|
||||||
'sign_change': sign_change,
|
|
||||||
|
|
||||||
# TODO: cum_pnl, bep
|
|
||||||
}
|
|
||||||
))
|
|
||||||
|
|
||||||
final_ppu = ppu_h[-1] if ppu_h else 0
|
|
||||||
# TODO: once we have etypes in all ledger entries..
|
|
||||||
# handle any split info entered (for now) manually by user
|
|
||||||
# if self.split_ratio is not None:
|
|
||||||
# final_ppu /= self.split_ratio
|
|
||||||
|
|
||||||
if as_ledger:
|
|
||||||
return ledger
|
|
||||||
|
|
||||||
else:
|
|
||||||
return final_ppu
|
|
||||||
|
|
||||||
|
|
||||||
def iter_by_dt(
|
|
||||||
records: (
|
|
||||||
dict[str, dict[str, Any]]
|
|
||||||
| ValuesView[dict] # eg. `Position._events.values()`
|
|
||||||
| list[dict]
|
|
||||||
| list[Transaction] # XXX preferred!
|
|
||||||
),
|
|
||||||
|
|
||||||
# NOTE: parsers are looked up in the insert order
|
|
||||||
# so if you know that the record stats show some field
|
|
||||||
# is more common then others, stick it at the top B)
|
|
||||||
parsers: dict[str, Callable | None] = {
|
|
||||||
'dt': parse, # parity case
|
|
||||||
'datetime': parse, # datetime-str
|
|
||||||
'time': from_timestamp, # float epoch
|
|
||||||
},
|
|
||||||
key: Callable | None = None,
|
|
||||||
|
|
||||||
) -> Iterator[tuple[str, dict]]:
|
|
||||||
'''
|
|
||||||
Iterate entries of a transaction table sorted by entry recorded
|
|
||||||
datetime presumably set at the ``'dt'`` field in each entry.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if isinstance(records, dict):
|
|
||||||
records: list[tuple[str, dict]] = list(records.items())
|
|
||||||
|
|
||||||
def dyn_parse_to_dt(
|
|
||||||
tx: tuple[str, dict[str, Any]] | Transaction,
|
|
||||||
) -> DateTime:
|
|
||||||
|
|
||||||
# handle `.items()` inputs
|
|
||||||
if isinstance(tx, tuple):
|
|
||||||
tx = tx[1]
|
|
||||||
|
|
||||||
# dict or tx object?
|
|
||||||
isdict: bool = isinstance(tx, dict)
|
|
||||||
|
|
||||||
# get best parser for this record..
|
|
||||||
for k in parsers:
|
|
||||||
if (
|
|
||||||
isdict and k in tx
|
|
||||||
or getattr(tx, k, None)
|
|
||||||
):
|
|
||||||
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
|
|
||||||
# the `parsers` table above (when NOT using
|
|
||||||
# `.get()`), otherwise pass through the value and
|
|
||||||
# sort on it directly
|
|
||||||
if (
|
|
||||||
not isinstance(v, DateTime)
|
|
||||||
and (parser := parsers.get(k))
|
|
||||||
):
|
|
||||||
return parser(v)
|
|
||||||
else:
|
|
||||||
return v
|
|
||||||
|
|
||||||
else:
|
|
||||||
# XXX: should never get here..
|
|
||||||
breakpoint()
|
|
||||||
|
|
||||||
entry: tuple[str, dict] | Transaction
|
|
||||||
for entry in sorted(
|
|
||||||
records,
|
|
||||||
key=key or dyn_parse_to_dt,
|
|
||||||
):
|
|
||||||
# NOTE the type sig above; either pairs or txns B)
|
|
||||||
yield entry
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: probably just move this into the test suite or
|
|
||||||
# keep it here for use from as such?
|
|
||||||
# def ensure_state(self) -> None:
|
|
||||||
# '''
|
|
||||||
# Audit either the `.cumsize` and `.ppu` local instance vars against
|
|
||||||
# the clears table calculations and return the calc-ed values if
|
|
||||||
# they differ and log warnings to console.
|
|
||||||
|
|
||||||
# '''
|
|
||||||
# # clears: list[dict] = self._clears
|
|
||||||
|
|
||||||
# # self.first_clear_dt = min(clears, key=lambda e: e['dt'])['dt']
|
|
||||||
# last_clear: dict = clears[-1]
|
|
||||||
# csize: float = self.calc_size()
|
|
||||||
# accum: float = last_clear['accum_size']
|
|
||||||
|
|
||||||
# if not self.expired():
|
|
||||||
# if (
|
|
||||||
# csize != accum
|
|
||||||
# and csize != round(accum * (self.split_ratio or 1))
|
|
||||||
# ):
|
|
||||||
# raise ValueError(f'Size mismatch: {csize}')
|
|
||||||
# else:
|
|
||||||
# assert csize == 0, 'Contract is expired but non-zero size?'
|
|
||||||
|
|
||||||
# if self.cumsize != csize:
|
|
||||||
# log.warning(
|
|
||||||
# 'Position state mismatch:\n'
|
|
||||||
# f'{self.cumsize} => {csize}'
|
|
||||||
# )
|
|
||||||
# self.cumsize = csize
|
|
||||||
|
|
||||||
# cppu: float = self.calc_ppu()
|
|
||||||
# ppu: float = last_clear['ppu']
|
|
||||||
# if (
|
|
||||||
# cppu != ppu
|
|
||||||
# and self.split_ratio is not None
|
|
||||||
|
|
||||||
# # handle any split info entered (for now) manually by user
|
|
||||||
# and cppu != (ppu / self.split_ratio)
|
|
||||||
# ):
|
|
||||||
# raise ValueError(f'PPU mismatch: {cppu}')
|
|
||||||
|
|
||||||
# if self.ppu != cppu:
|
|
||||||
# log.warning(
|
|
||||||
# 'Position state mismatch:\n'
|
|
||||||
# f'{self.ppu} => {cppu}'
|
|
||||||
# )
|
|
||||||
# self.ppu = cppu
|
|
||||||
|
|
||||||
|
|
||||||
@cm
|
|
||||||
def open_ledger_dfs(
|
|
||||||
|
|
||||||
brokername: str,
|
|
||||||
acctname: str,
|
|
||||||
|
|
||||||
ledger: TransactionLedger | None = None,
|
|
||||||
|
|
||||||
**kwargs,
|
|
||||||
|
|
||||||
) -> tuple[
|
|
||||||
dict[str, pl.DataFrame],
|
|
||||||
TransactionLedger,
|
|
||||||
]:
|
|
||||||
'''
|
|
||||||
Open a ledger of trade records (presumably from some broker
|
|
||||||
backend), normalize the records into `Transactions` via the
|
|
||||||
backend's declared endpoint, cast to a `polars.DataFrame` which
|
|
||||||
can update the ledger on exit.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from piker.toolz import open_crash_handler
|
|
||||||
with open_crash_handler():
|
|
||||||
if not ledger:
|
|
||||||
import time
|
|
||||||
from ._ledger import open_trade_ledger
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
with open_trade_ledger(
|
|
||||||
brokername,
|
|
||||||
acctname,
|
|
||||||
rewrite=True,
|
|
||||||
allow_from_sync_code=True,
|
|
||||||
|
|
||||||
# proxied through from caller
|
|
||||||
**kwargs,
|
|
||||||
|
|
||||||
) as ledger:
|
|
||||||
if not ledger:
|
|
||||||
raise ValueError(f'No ledger for {acctname}@{brokername} exists?')
|
|
||||||
|
|
||||||
print(f'LEDGER LOAD TIME: {time.time() - now}')
|
|
||||||
|
|
||||||
yield ledger_to_dfs(ledger), ledger
|
|
||||||
|
|
||||||
|
|
||||||
def ledger_to_dfs(
|
|
||||||
ledger: TransactionLedger,
|
|
||||||
|
|
||||||
) -> dict[str, pl.DataFrame]:
|
|
||||||
|
|
||||||
txns: dict[str, Transaction] = ledger.to_txns()
|
|
||||||
|
|
||||||
# ldf = pl.DataFrame(
|
|
||||||
# list(txn.to_dict() for txn in txns.values()),
|
|
||||||
ldf = pl.from_dicts(
|
|
||||||
list(txn.to_dict() for txn in txns.values()),
|
|
||||||
|
|
||||||
# only for ordering the cols
|
|
||||||
schema=[
|
|
||||||
('fqme', str),
|
|
||||||
('tid', str),
|
|
||||||
('bs_mktid', str),
|
|
||||||
('expiry', str),
|
|
||||||
('etype', str),
|
|
||||||
('dt', str),
|
|
||||||
('size', pl.Float64),
|
|
||||||
('price', pl.Float64),
|
|
||||||
('cost', pl.Float64),
|
|
||||||
],
|
|
||||||
).sort( # chronological order
|
|
||||||
'dt'
|
|
||||||
).with_columns([
|
|
||||||
pl.col('dt').str.to_datetime(),
|
|
||||||
# pl.col('expiry').str.to_datetime(),
|
|
||||||
# pl.col('expiry').dt.date(),
|
|
||||||
])
|
|
||||||
|
|
||||||
# filter out to the columns matching values filter passed
|
|
||||||
# as input.
|
|
||||||
# if filter_by_ids:
|
|
||||||
# for col, vals in filter_by_ids.items():
|
|
||||||
# str_vals = set(map(str, vals))
|
|
||||||
# pred: pl.Expr = pl.col(col).eq(str_vals.pop())
|
|
||||||
# for val in str_vals:
|
|
||||||
# pred |= pl.col(col).eq(val)
|
|
||||||
|
|
||||||
# fdf = df.filter(pred)
|
|
||||||
|
|
||||||
# TODO: originally i had tried just using a plain ol' groupby
|
|
||||||
# + agg here but the issue was re-inserting to the src frame.
|
|
||||||
# however, learning more about `polars` seems like maybe we can
|
|
||||||
# use `.over()`?
|
|
||||||
# https://pola-rs.github.io/polars/py-polars/html/reference/expressions/api/polars.Expr.over.html#polars.Expr.over
|
|
||||||
# => CURRENTLY we break up into a frame per mkt / fqme
|
|
||||||
dfs: dict[str, pl.DataFrame] = ldf.partition_by(
|
|
||||||
'bs_mktid',
|
|
||||||
as_dict=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: not sure if this is even possible but..
|
|
||||||
# - it'd be more ideal to use `ppt = df.groupby('fqme').agg([`
|
|
||||||
# - ppu and bep calcs!
|
|
||||||
for key in dfs:
|
|
||||||
|
|
||||||
# covert to lazy form (since apparently we might need it
|
|
||||||
# eventually ...)
|
|
||||||
df: pl.DataFrame = dfs[key]
|
|
||||||
|
|
||||||
ldf: pl.LazyFrame = df.lazy()
|
|
||||||
|
|
||||||
df = dfs[key] = ldf.with_columns([
|
|
||||||
|
|
||||||
pl.cumsum('size').alias('cumsize'),
|
|
||||||
|
|
||||||
# amount of source asset "sent" (via buy txns in
|
|
||||||
# the market) to acquire the dst asset, PER txn.
|
|
||||||
# when this value is -ve (i.e. a sell operation) then
|
|
||||||
# the amount sent is actually "returned".
|
|
||||||
(
|
|
||||||
(pl.col('price') * pl.col('size'))
|
|
||||||
+
|
|
||||||
(pl.col('cost')) # * pl.col('size').sign())
|
|
||||||
).alias('dst_bot'),
|
|
||||||
|
|
||||||
]).with_columns([
|
|
||||||
|
|
||||||
# rolling balance in src asset units
|
|
||||||
(pl.col('dst_bot').cumsum() * -1).alias('src_balance'),
|
|
||||||
|
|
||||||
# "position operation type" in terms of increasing the
|
|
||||||
# amount in the dst asset (entering) or decreasing the
|
|
||||||
# amount in the dst asset (exiting).
|
|
||||||
pl.when(
|
|
||||||
pl.col('size').sign() == pl.col('cumsize').sign()
|
|
||||||
|
|
||||||
).then(
|
|
||||||
pl.lit('enter') # see above, but is just price * size per txn
|
|
||||||
|
|
||||||
).otherwise(
|
|
||||||
pl.when(pl.col('cumsize') == 0)
|
|
||||||
.then(pl.lit('exit_to_zero'))
|
|
||||||
.otherwise(pl.lit('exit'))
|
|
||||||
).alias('descr'),
|
|
||||||
|
|
||||||
(pl.col('cumsize').sign() == pl.col('size').sign())
|
|
||||||
.alias('is_enter'),
|
|
||||||
|
|
||||||
]).with_columns([
|
|
||||||
|
|
||||||
# pl.lit(0, dtype=pl.Utf8).alias('virt_cost'),
|
|
||||||
pl.lit(0, dtype=pl.Float64).alias('applied_cost'),
|
|
||||||
pl.lit(0, dtype=pl.Float64).alias('pos_ppu'),
|
|
||||||
pl.lit(0, dtype=pl.Float64).alias('per_txn_pnl'),
|
|
||||||
pl.lit(0, dtype=pl.Float64).alias('cum_pos_pnl'),
|
|
||||||
pl.lit(0, dtype=pl.Float64).alias('pos_bep'),
|
|
||||||
pl.lit(0, dtype=pl.Float64).alias('cum_ledger_pnl'),
|
|
||||||
pl.lit(None, dtype=pl.Float64).alias('ledger_bep'),
|
|
||||||
|
|
||||||
# TODO: instead of the iterative loop below i guess we
|
|
||||||
# could try using embedded lists to track which txns
|
|
||||||
# are part of which ppu / bep calcs? Not sure this will
|
|
||||||
# look any better nor be any more performant though xD
|
|
||||||
# pl.lit([[0]], dtype=pl.List(pl.Float64)).alias('list'),
|
|
||||||
|
|
||||||
# choose fields to emit for accounting puposes
|
|
||||||
]).select([
|
|
||||||
pl.exclude([
|
|
||||||
'tid',
|
|
||||||
# 'dt',
|
|
||||||
'expiry',
|
|
||||||
'bs_mktid',
|
|
||||||
'etype',
|
|
||||||
# 'is_enter',
|
|
||||||
]),
|
|
||||||
]).collect()
|
|
||||||
|
|
||||||
# compute recurrence relations for ppu and bep
|
|
||||||
last_ppu: float = 0
|
|
||||||
last_cumsize: float = 0
|
|
||||||
last_ledger_pnl: float = 0
|
|
||||||
last_pos_pnl: float = 0
|
|
||||||
virt_costs: list[float, float] = [0., 0.]
|
|
||||||
|
|
||||||
# imperatively compute the PPU (price per unit) and BEP
|
|
||||||
# (break even price) iteratively over the ledger, oriented
|
|
||||||
# around each position state: a state of split balances in
|
|
||||||
# > 1 asset.
|
|
||||||
for i, row in enumerate(df.iter_rows(named=True)):
|
|
||||||
|
|
||||||
cumsize: float = row['cumsize']
|
|
||||||
is_enter: bool = row['is_enter']
|
|
||||||
price: float = row['price']
|
|
||||||
size: float = row['size']
|
|
||||||
|
|
||||||
# the profit is ALWAYS decreased, aka made a "loss"
|
|
||||||
# by the constant fee charged by the txn provider!
|
|
||||||
# see below in final PnL calculation and row element
|
|
||||||
# set.
|
|
||||||
txn_cost: float = row['cost']
|
|
||||||
pnl: float = 0
|
|
||||||
|
|
||||||
# ALWAYS reset per-position cum PnL
|
|
||||||
if last_cumsize == 0:
|
|
||||||
last_pos_pnl: float = 0
|
|
||||||
|
|
||||||
# a "position size INCREASING" or ENTER transaction
|
|
||||||
# which "makes larger", in src asset unit terms, the
|
|
||||||
# trade's side-size of the destination asset:
|
|
||||||
# - "buying" (more) units of the dst asset
|
|
||||||
# - "selling" (more short) units of the dst asset
|
|
||||||
if is_enter:
|
|
||||||
|
|
||||||
# Naively include transaction cost in breakeven
|
|
||||||
# price and presume the worst case of the
|
|
||||||
# exact-same-cost-to-exit this transaction's worth
|
|
||||||
# of size even though in reality it will be dynamic
|
|
||||||
# based on exit strategy, price, liquidity, etc..
|
|
||||||
virt_cost: float = txn_cost
|
|
||||||
|
|
||||||
# cpu: float = cost / size
|
|
||||||
# cummean of the cost-per-unit used for modelling
|
|
||||||
# a projected future exit cost which we immediately
|
|
||||||
# include in the costs incorporated to BEP on enters
|
|
||||||
last_cum_costs_size, last_cpu = virt_costs
|
|
||||||
cum_costs_size: float = last_cum_costs_size + abs(size)
|
|
||||||
cumcpu = (
|
|
||||||
(last_cpu * last_cum_costs_size)
|
|
||||||
+
|
|
||||||
txn_cost
|
|
||||||
) / cum_costs_size
|
|
||||||
virt_costs = [cum_costs_size, cumcpu]
|
|
||||||
|
|
||||||
txn_cost = txn_cost + virt_cost
|
|
||||||
# df[i, 'virt_cost'] = f'{-virt_cost} FROM {cumcpu}@{cum_costs_size}'
|
|
||||||
|
|
||||||
# a cumulative mean of the price-per-unit acquired
|
|
||||||
# in the destination asset:
|
|
||||||
# https://en.wikipedia.org/wiki/Moving_average#Cumulative_average
|
|
||||||
# You could also think of this measure more
|
|
||||||
# generally as an exponential mean with `alpha
|
|
||||||
# = 1/N` where `N` is the current number of txns
|
|
||||||
# included in the "position" defining set:
|
|
||||||
# https://en.wikipedia.org/wiki/Exponential_smoothing
|
|
||||||
ppu: float = (
|
|
||||||
(
|
|
||||||
(last_ppu * last_cumsize)
|
|
||||||
+
|
|
||||||
(price * size)
|
|
||||||
) /
|
|
||||||
cumsize
|
|
||||||
)
|
|
||||||
|
|
||||||
# a "position size DECREASING" or EXIT transaction
|
|
||||||
# which "makes smaller" the trade's side-size of the
|
|
||||||
# destination asset:
|
|
||||||
# - selling previously bought units of the dst asset
|
|
||||||
# (aka 'closing' a long position).
|
|
||||||
# - buying previously borrowed and sold (short) units
|
|
||||||
# of the dst asset (aka 'covering'/'closing' a short
|
|
||||||
# position).
|
|
||||||
else:
|
|
||||||
# only changes on position size increasing txns
|
|
||||||
ppu: float = last_ppu
|
|
||||||
|
|
||||||
# UNWIND IMPLIED COSTS FROM ENTRIES
|
|
||||||
# => Reverse the virtual/modelled (2x predicted) txn
|
|
||||||
# cost that was included in the least-recently
|
|
||||||
# entered txn that is still part of the current CSi
|
|
||||||
# set.
|
|
||||||
# => we look up the cost-per-unit cumsum and apply
|
|
||||||
# if over the current txn size (by multiplication)
|
|
||||||
# and then reverse that previusly applied cost on
|
|
||||||
# the txn_cost for this record.
|
|
||||||
#
|
|
||||||
# NOTE: current "model" is just to previously assumed 2x
|
|
||||||
# the txn cost for a matching enter-txn's
|
|
||||||
# cost-per-unit; we then immediately reverse this
|
|
||||||
# prediction and apply the real cost received here.
|
|
||||||
last_cum_costs_size, last_cpu = virt_costs
|
|
||||||
prev_virt_cost: float = last_cpu * abs(size)
|
|
||||||
txn_cost: float = txn_cost - prev_virt_cost # +ve thus a "reversal"
|
|
||||||
cum_costs_size: float = last_cum_costs_size - abs(size)
|
|
||||||
virt_costs = [cum_costs_size, last_cpu]
|
|
||||||
|
|
||||||
# df[i, 'virt_cost'] = (
|
|
||||||
# f'{-prev_virt_cost} FROM {last_cpu}@{cum_costs_size}'
|
|
||||||
# )
|
|
||||||
|
|
||||||
# the per-txn profit or loss (PnL) given we are
|
|
||||||
# (partially) "closing"/"exiting" the position via
|
|
||||||
# this txn.
|
|
||||||
pnl: float = (last_ppu - price) * size
|
|
||||||
|
|
||||||
# always subtract txn cost from total txn pnl
|
|
||||||
txn_pnl: float = pnl - txn_cost
|
|
||||||
|
|
||||||
# cumulative PnLs per txn
|
|
||||||
last_ledger_pnl = (
|
|
||||||
last_ledger_pnl + txn_pnl
|
|
||||||
)
|
|
||||||
last_pos_pnl = df[i, 'cum_pos_pnl'] = (
|
|
||||||
last_pos_pnl + txn_pnl
|
|
||||||
)
|
|
||||||
|
|
||||||
if cumsize == 0:
|
|
||||||
last_ppu = ppu = 0
|
|
||||||
|
|
||||||
# compute the BEP: "break even price", a value that
|
|
||||||
# determines at what price the remaining cumsize can be
|
|
||||||
# liquidated such that the net-PnL on the current
|
|
||||||
# position will result in ZERO gain or loss from open
|
|
||||||
# to close including all txn costs B)
|
|
||||||
if (
|
|
||||||
abs(cumsize) > 0 # non-exit-to-zero position txn
|
|
||||||
):
|
|
||||||
cumsize_sign: float = copysign(1, cumsize)
|
|
||||||
ledger_bep: float = (
|
|
||||||
(
|
|
||||||
(ppu * cumsize)
|
|
||||||
-
|
|
||||||
(last_ledger_pnl * cumsize_sign)
|
|
||||||
) / cumsize
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE: when we "enter more" dst asset units (aka
|
|
||||||
# increase position state) AFTER having exited some
|
|
||||||
# units (aka decreasing the pos size some) the bep
|
|
||||||
# needs to be RECOMPUTED based on new ppu such that
|
|
||||||
# liquidation of the cumsize at the bep price
|
|
||||||
# results in a zero-pnl for the existing position
|
|
||||||
# (since the last one).
|
|
||||||
# for position lifetime BEP we never can have
|
|
||||||
# a valid value once the position is "closed"
|
|
||||||
# / full exitted Bo
|
|
||||||
pos_bep: float = (
|
|
||||||
(
|
|
||||||
(ppu * cumsize)
|
|
||||||
-
|
|
||||||
(last_pos_pnl * cumsize_sign)
|
|
||||||
) / cumsize
|
|
||||||
)
|
|
||||||
|
|
||||||
# inject DF row with all values
|
|
||||||
df[i, 'pos_ppu'] = ppu
|
|
||||||
df[i, 'per_txn_pnl'] = txn_pnl
|
|
||||||
df[i, 'applied_cost'] = -txn_cost
|
|
||||||
df[i, 'cum_pos_pnl'] = last_pos_pnl
|
|
||||||
df[i, 'pos_bep'] = pos_bep
|
|
||||||
df[i, 'cum_ledger_pnl'] = last_ledger_pnl
|
|
||||||
df[i, 'ledger_bep'] = ledger_bep
|
|
||||||
|
|
||||||
# keep backrefs to suffice reccurence relation
|
|
||||||
last_ppu: float = ppu
|
|
||||||
last_cumsize: float = cumsize
|
|
||||||
|
|
||||||
# TODO?: pass back the current `Position` object loaded from
|
|
||||||
# the account as well? Would provide incentive to do all
|
|
||||||
# this ledger loading inside a new async open_account().
|
|
||||||
# bs_mktid: str = df[0]['bs_mktid']
|
|
||||||
# pos: Position = acnt.pps[bs_mktid]
|
|
||||||
|
|
||||||
return dfs
|
|
||||||
|
|
@ -19,7 +19,6 @@ CLI front end for trades ledger and position tracking management.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pprint import pformat
|
|
||||||
|
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
@ -38,11 +37,13 @@ from ..calc import humanize
|
||||||
from ..brokers._daemon import broker_init
|
from ..brokers._daemon import broker_init
|
||||||
from ._ledger import (
|
from ._ledger import (
|
||||||
load_ledger,
|
load_ledger,
|
||||||
TransactionLedger,
|
|
||||||
# open_trade_ledger,
|
# open_trade_ledger,
|
||||||
|
# TransactionLedger,
|
||||||
)
|
)
|
||||||
from .calc import (
|
from ._pos import (
|
||||||
open_ledger_dfs,
|
PpTable,
|
||||||
|
load_pps_from_ledger,
|
||||||
|
# load_account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -239,73 +240,54 @@ def sync(
|
||||||
def disect(
|
def disect(
|
||||||
# "fully_qualified_account_name"
|
# "fully_qualified_account_name"
|
||||||
fqan: str,
|
fqan: str,
|
||||||
fqme: str, # for ib
|
bs_mktid: str, # for ib
|
||||||
|
|
||||||
# TODO: in tractor we should really have
|
|
||||||
# a debug_mode ctx for wrapping any kind of code no?
|
|
||||||
pdb: bool = False,
|
pdb: bool = False,
|
||||||
bs_mktid: str = typer.Option(
|
|
||||||
None,
|
|
||||||
"-bid",
|
|
||||||
),
|
|
||||||
loglevel: str = typer.Option(
|
loglevel: str = typer.Option(
|
||||||
'error',
|
'error',
|
||||||
"-l",
|
"-l",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
from piker.log import get_console_log
|
|
||||||
from piker.toolz import open_crash_handler
|
|
||||||
get_console_log(loglevel)
|
|
||||||
|
|
||||||
pair: tuple[str, str]
|
pair: tuple[str, str]
|
||||||
if not (pair := unpack_fqan(fqan)):
|
if not (pair := unpack_fqan(fqan)):
|
||||||
raise ValueError('{fqan} malformed!?')
|
raise ValueError('{fqan} malformed!?')
|
||||||
|
|
||||||
brokername, account = pair
|
brokername, account = pair
|
||||||
|
|
||||||
# ledger dfs groupby-partitioned by fqme
|
# ledger: TransactionLedger
|
||||||
dfs: dict[str, pl.DataFrame]
|
records: dict[str, dict]
|
||||||
# actual ledger instance
|
table: PpTable
|
||||||
ldgr: TransactionLedger
|
records, table = load_pps_from_ledger(
|
||||||
|
|
||||||
pl.Config.set_tbl_cols(-1)
|
|
||||||
pl.Config.set_tbl_rows(-1)
|
|
||||||
with (
|
|
||||||
open_crash_handler(),
|
|
||||||
open_ledger_dfs(
|
|
||||||
brokername,
|
brokername,
|
||||||
account,
|
account,
|
||||||
) as (dfs, ldgr),
|
filter_by_ids={bs_mktid},
|
||||||
):
|
|
||||||
|
|
||||||
# look up specific frame for fqme-selected asset
|
|
||||||
if (df := dfs.get(fqme)) is None:
|
|
||||||
mktids2fqmes: dict[str, list[str]] = {}
|
|
||||||
for bs_mktid in dfs:
|
|
||||||
df: pl.DataFrame = dfs[bs_mktid]
|
|
||||||
fqmes: pl.Series[str] = df['fqme']
|
|
||||||
uniques: list[str] = fqmes.unique()
|
|
||||||
mktids2fqmes[bs_mktid] = set(uniques)
|
|
||||||
if fqme in uniques:
|
|
||||||
break
|
|
||||||
print(
|
|
||||||
f'No specific ledger for fqme={fqme} could be found in\n'
|
|
||||||
f'{pformat(mktids2fqmes)}?\n'
|
|
||||||
f'Maybe the `{brokername}` backend uses something '
|
|
||||||
'else for its `bs_mktid` then the `fqme`?\n'
|
|
||||||
'Scanning for matches in unique fqmes per frame..\n'
|
|
||||||
)
|
)
|
||||||
|
df = pl.DataFrame(
|
||||||
|
list(records.values()),
|
||||||
|
# schema=[
|
||||||
|
# ('tid', str),
|
||||||
|
# ('fqme', str),
|
||||||
|
# ('dt', str),
|
||||||
|
# ('size', pl.Float64),
|
||||||
|
# ('price', pl.Float64),
|
||||||
|
# ('cost', pl.Float64),
|
||||||
|
# ('expiry', str),
|
||||||
|
# ('bs_mktid', str),
|
||||||
|
# ],
|
||||||
|
).select([
|
||||||
|
pl.col('fqme'),
|
||||||
|
pl.col('dt').str.to_datetime(),
|
||||||
|
# pl.col('expiry').dt.datetime(),
|
||||||
|
pl.col('size'),
|
||||||
|
pl.col('price'),
|
||||||
|
])
|
||||||
|
|
||||||
# :pray:
|
|
||||||
assert not df.is_empty()
|
assert not df.is_empty()
|
||||||
|
|
||||||
# muck around in pdbp REPL
|
|
||||||
breakpoint()
|
breakpoint()
|
||||||
|
# tractor.pause_from_sync()
|
||||||
# TODO: we REALLY need a better console REPL for this
|
# with open_trade_ledger(
|
||||||
# kinda thing..
|
# brokername,
|
||||||
# - `xonsh` is an obvious option (and it looks amazin) but
|
# account,
|
||||||
# we need to figure out how to embed it better then just:
|
# ) as ledger:
|
||||||
# from xonsh.main import main
|
# for tid, rec in ledger.items():
|
||||||
# main(argv=[])
|
# bs_mktid: str = rec['bs_mktid']
|
||||||
# which will not actually inject the `df` to globals?
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ __brokers__: list[str] = [
|
||||||
'binance',
|
'binance',
|
||||||
'ib',
|
'ib',
|
||||||
'kraken',
|
'kraken',
|
||||||
'kucoin',
|
'kucoin'
|
||||||
|
|
||||||
# broken but used to work
|
# broken but used to work
|
||||||
# 'questrade',
|
# 'questrade',
|
||||||
|
|
@ -71,7 +71,7 @@ def get_brokermod(brokername: str) -> ModuleType:
|
||||||
Return the imported broker module by name.
|
Return the imported broker module by name.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
module: ModuleType = import_module('.' + brokername, 'piker.brokers')
|
module = import_module('.' + brokername, 'piker.brokers')
|
||||||
# we only allow monkeying because it's for internal keying
|
# we only allow monkeying because it's for internal keying
|
||||||
module.name = module.__name__.split('.')[-1]
|
module.name = module.__name__.split('.')[-1]
|
||||||
return module
|
return module
|
||||||
|
|
@ -106,6 +106,6 @@ async def open_cached_client(
|
||||||
) as (cache_hit, client):
|
) as (cache_hit, client):
|
||||||
|
|
||||||
if cache_hit:
|
if cache_hit:
|
||||||
log.runtime(f'Reusing existing {client}')
|
log.info(f'Reusing existing {client}')
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,9 @@ def broker_init(
|
||||||
subpath: str = f'{modpath}.{submodname}'
|
subpath: str = f'{modpath}.{submodname}'
|
||||||
enabled.append(subpath)
|
enabled.append(subpath)
|
||||||
|
|
||||||
|
# TODO XXX: DO WE NEED THIS?
|
||||||
|
# enabled.append('piker.data.feed')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
brokermod,
|
brokermod,
|
||||||
start_actor_kwargs, # to `ActorNursery.start_actor()`
|
start_actor_kwargs, # to `ActorNursery.start_actor()`
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,10 @@
|
||||||
Handy cross-broker utils.
|
Handy cross-broker utils.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import httpx
|
import asks
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ..log import (
|
from ..log import (
|
||||||
|
|
@ -51,7 +50,6 @@ class SymbolNotFound(BrokerError):
|
||||||
"Symbol not found by broker search"
|
"Symbol not found by broker search"
|
||||||
|
|
||||||
|
|
||||||
# TODO: these should probably be moved to `.tsp/.data`?
|
|
||||||
class NoData(BrokerError):
|
class NoData(BrokerError):
|
||||||
'''
|
'''
|
||||||
Symbol data not permitted or no data
|
Symbol data not permitted or no data
|
||||||
|
|
@ -61,15 +59,14 @@ class NoData(BrokerError):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*args,
|
*args,
|
||||||
info: dict|None = None,
|
frame_size: int = 1000,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(*args)
|
super().__init__(*args)
|
||||||
self.info: dict|None = info
|
|
||||||
|
|
||||||
# when raised, machinery can check if the backend
|
# when raised, machinery can check if the backend
|
||||||
# set a "frame size" for doing datetime calcs.
|
# set a "frame size" for doing datetime calcs.
|
||||||
# self.frame_size: int = 1000
|
self.frame_size: int = 1000
|
||||||
|
|
||||||
|
|
||||||
class DataUnavailable(BrokerError):
|
class DataUnavailable(BrokerError):
|
||||||
|
|
@ -91,18 +88,16 @@ class DataThrottle(BrokerError):
|
||||||
|
|
||||||
|
|
||||||
def resproc(
|
def resproc(
|
||||||
resp: httpx.Response,
|
resp: asks.response_objects.Response,
|
||||||
log: logging.Logger,
|
log: logging.Logger,
|
||||||
return_json: bool = True,
|
return_json: bool = True,
|
||||||
log_resp: bool = False,
|
log_resp: bool = False,
|
||||||
|
|
||||||
) -> httpx.Response:
|
) -> asks.response_objects.Response:
|
||||||
'''
|
"""Process response and return its json content.
|
||||||
Process response and return its json content.
|
|
||||||
|
|
||||||
Raise the appropriate error on non-200 OK responses.
|
Raise the appropriate error on non-200 OK responses.
|
||||||
|
"""
|
||||||
'''
|
|
||||||
if not resp.status_code == 200:
|
if not resp.status_code == 200:
|
||||||
raise BrokerError(resp.body)
|
raise BrokerError(resp.body)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -32,19 +32,12 @@ from .feed import (
|
||||||
)
|
)
|
||||||
from .broker import (
|
from .broker import (
|
||||||
open_trade_dialog,
|
open_trade_dialog,
|
||||||
get_cost,
|
|
||||||
)
|
|
||||||
from .venues import (
|
|
||||||
SpotPair,
|
|
||||||
FutesPair,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'get_client',
|
'get_client',
|
||||||
'get_mkt_info',
|
'get_mkt_info',
|
||||||
'get_cost',
|
|
||||||
'SpotPair',
|
|
||||||
'FutesPair',
|
|
||||||
'open_trade_dialog',
|
'open_trade_dialog',
|
||||||
'open_history_client',
|
'open_history_client',
|
||||||
'open_symbol_search',
|
'open_symbol_search',
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ from __future__ import annotations
|
||||||
from collections import ChainMap
|
from collections import ChainMap
|
||||||
from contextlib import (
|
from contextlib import (
|
||||||
asynccontextmanager as acm,
|
asynccontextmanager as acm,
|
||||||
AsyncExitStack,
|
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
|
|
@ -42,7 +41,8 @@ import trio
|
||||||
from pendulum import (
|
from pendulum import (
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
import httpx
|
import asks
|
||||||
|
from fuzzywuzzy import process as fuzzy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from piker import config
|
from piker import config
|
||||||
|
|
@ -52,13 +52,9 @@ from piker.clearing._messages import (
|
||||||
from piker.accounting import (
|
from piker.accounting import (
|
||||||
Asset,
|
Asset,
|
||||||
digits_to_dec,
|
digits_to_dec,
|
||||||
MktPair,
|
|
||||||
)
|
|
||||||
from piker.types import Struct
|
|
||||||
from piker.data import (
|
|
||||||
def_iohlcv_fields,
|
|
||||||
match_from_pairs,
|
|
||||||
)
|
)
|
||||||
|
from piker.data.types import Struct
|
||||||
|
from piker.data import def_iohlcv_fields
|
||||||
from piker.brokers import (
|
from piker.brokers import (
|
||||||
resproc,
|
resproc,
|
||||||
SymbolNotFound,
|
SymbolNotFound,
|
||||||
|
|
@ -68,6 +64,7 @@ from .venues import (
|
||||||
PAIRTYPES,
|
PAIRTYPES,
|
||||||
Pair,
|
Pair,
|
||||||
MarketType,
|
MarketType,
|
||||||
|
|
||||||
_spot_url,
|
_spot_url,
|
||||||
_futes_url,
|
_futes_url,
|
||||||
_testnet_futes_url,
|
_testnet_futes_url,
|
||||||
|
|
@ -77,18 +74,16 @@ from .venues import (
|
||||||
log = get_logger('piker.brokers.binance')
|
log = get_logger('piker.brokers.binance')
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> dict[str, Any]:
|
def get_config() -> dict:
|
||||||
|
|
||||||
conf: dict
|
conf: dict
|
||||||
path: Path
|
path: Path
|
||||||
conf, path = config.load(
|
conf, path = config.load()
|
||||||
conf_name='brokers',
|
|
||||||
touch_if_dne=True,
|
section = conf.get('binance')
|
||||||
)
|
|
||||||
section: dict = conf.get('binance')
|
|
||||||
if not section:
|
if not section:
|
||||||
log.warning(
|
log.warning(f'No config section found for binance in {path}')
|
||||||
f'No config section found for binance in {path}'
|
|
||||||
)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return section
|
return section
|
||||||
|
|
@ -144,7 +139,7 @@ def binance_timestamp(
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
'''
|
'''
|
||||||
Async ReST API client using `trio` + `httpx` B)
|
Async ReST API client using ``trio`` + ``asks`` B)
|
||||||
|
|
||||||
Supports all of the spot, margin and futures endpoints depending
|
Supports all of the spot, margin and futures endpoints depending
|
||||||
on method.
|
on method.
|
||||||
|
|
@ -153,17 +148,10 @@ class Client:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
venue_sessions: dict[
|
|
||||||
str, # venue key
|
|
||||||
tuple[httpx.AsyncClient, str] # session, eps path
|
|
||||||
],
|
|
||||||
conf: dict[str, Any],
|
|
||||||
# TODO: change this to `Client.[mkt_]venue: MarketType`?
|
# TODO: change this to `Client.[mkt_]venue: MarketType`?
|
||||||
mkt_mode: MarketType = 'spot',
|
mkt_mode: MarketType = 'spot',
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.conf = conf
|
|
||||||
|
|
||||||
# build out pair info tables for each market type
|
# build out pair info tables for each market type
|
||||||
# and wrap in a chain-map view for search / query.
|
# and wrap in a chain-map view for search / query.
|
||||||
self._spot_pairs: dict[str, Pair] = {} # spot info table
|
self._spot_pairs: dict[str, Pair] = {} # spot info table
|
||||||
|
|
@ -190,13 +178,44 @@ class Client:
|
||||||
# market symbols for use by search. See `.exch_info()`.
|
# market symbols for use by search. See `.exch_info()`.
|
||||||
self._pairs: ChainMap[str, Pair] = ChainMap()
|
self._pairs: ChainMap[str, Pair] = ChainMap()
|
||||||
|
|
||||||
|
# spot EPs sesh
|
||||||
|
self._sesh = asks.Session(connections=4)
|
||||||
|
self._sesh.base_location: str = _spot_url
|
||||||
|
# spot testnet
|
||||||
|
self._test_sesh: asks.Session = asks.Session(connections=4)
|
||||||
|
self._test_sesh.base_location: str = _testnet_spot_url
|
||||||
|
|
||||||
|
# margin and extended spot endpoints session.
|
||||||
|
self._sapi_sesh = asks.Session(connections=4)
|
||||||
|
self._sapi_sesh.base_location: str = _spot_url
|
||||||
|
|
||||||
|
# futes EPs sesh
|
||||||
|
self._fapi_sesh = asks.Session(connections=4)
|
||||||
|
self._fapi_sesh.base_location: str = _futes_url
|
||||||
|
# futes testnet
|
||||||
|
self._test_fapi_sesh: asks.Session = asks.Session(connections=4)
|
||||||
|
self._test_fapi_sesh.base_location: str = _testnet_futes_url
|
||||||
|
|
||||||
# global client "venue selection" mode.
|
# global client "venue selection" mode.
|
||||||
# set this when you want to switch venues and not have to
|
# set this when you want to switch venues and not have to
|
||||||
# specify the venue for the next request.
|
# specify the venue for the next request.
|
||||||
self.mkt_mode: MarketType = mkt_mode
|
self.mkt_mode: MarketType = mkt_mode
|
||||||
|
|
||||||
# per-mkt-venue API client table
|
# per 8
|
||||||
self.venue_sesh = venue_sessions
|
self.venue_sesh: dict[
|
||||||
|
str, # venue key
|
||||||
|
tuple[asks.Session, str] # session, eps path
|
||||||
|
] = {
|
||||||
|
'spot': (self._sesh, '/api/v3/'),
|
||||||
|
'spot_testnet': (self._test_sesh, '/fapi/v1/'),
|
||||||
|
|
||||||
|
'margin': (self._sapi_sesh, '/sapi/v1/'),
|
||||||
|
|
||||||
|
'usdtm_futes': (self._fapi_sesh, '/fapi/v1/'),
|
||||||
|
'usdtm_futes_testnet': (self._test_fapi_sesh, '/fapi/v1/'),
|
||||||
|
|
||||||
|
# 'futes_coin': self._dapi, # TODO
|
||||||
|
}
|
||||||
|
|
||||||
# lookup for going from `.mkt_mode: str` to the config
|
# lookup for going from `.mkt_mode: str` to the config
|
||||||
# subsection `key: str`
|
# subsection `key: str`
|
||||||
|
|
@ -211,6 +230,40 @@ class Client:
|
||||||
'futes': ['usdtm_futes'],
|
'futes': ['usdtm_futes'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# for creating API keys see,
|
||||||
|
# https://www.binance.com/en/support/faq/how-to-create-api-keys-on-binance-360002502072
|
||||||
|
self.conf: dict = get_config()
|
||||||
|
|
||||||
|
for key, subconf in self.conf.items():
|
||||||
|
if api_key := subconf.get('api_key', ''):
|
||||||
|
venue_keys: list[str] = self.confkey2venuekeys[key]
|
||||||
|
|
||||||
|
venue_key: str
|
||||||
|
sesh: asks.Session
|
||||||
|
for venue_key in venue_keys:
|
||||||
|
sesh, _ = self.venue_sesh[venue_key]
|
||||||
|
|
||||||
|
api_key_header: dict = {
|
||||||
|
# taken from official:
|
||||||
|
# https://github.com/binance/binance-futures-connector-python/blob/main/binance/api.py#L47
|
||||||
|
"Content-Type": "application/json;charset=utf-8",
|
||||||
|
|
||||||
|
# TODO: prolly should just always query and copy
|
||||||
|
# in the real latest ver?
|
||||||
|
"User-Agent": "binance-connector/6.1.6smbz6",
|
||||||
|
"X-MBX-APIKEY": api_key,
|
||||||
|
}
|
||||||
|
sesh.headers.update(api_key_header)
|
||||||
|
|
||||||
|
# if `.use_tesnet = true` in the config then
|
||||||
|
# also add headers for the testnet session which
|
||||||
|
# will be used for all order control
|
||||||
|
if subconf.get('use_testnet', False):
|
||||||
|
testnet_sesh, _ = self.venue_sesh[
|
||||||
|
venue_key + '_testnet'
|
||||||
|
]
|
||||||
|
testnet_sesh.headers.update(api_key_header)
|
||||||
|
|
||||||
def _mk_sig(
|
def _mk_sig(
|
||||||
self,
|
self,
|
||||||
data: dict,
|
data: dict,
|
||||||
|
|
@ -229,6 +282,7 @@ class Client:
|
||||||
'to define the creds for auth-ed endpoints!?'
|
'to define the creds for auth-ed endpoints!?'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# XXX: Info on security and authentification
|
# XXX: Info on security and authentification
|
||||||
# https://binance-docs.github.io/apidocs/#endpoint-security-type
|
# https://binance-docs.github.io/apidocs/#endpoint-security-type
|
||||||
if not (api_secret := subconf.get('api_secret')):
|
if not (api_secret := subconf.get('api_secret')):
|
||||||
|
|
@ -257,7 +311,7 @@ class Client:
|
||||||
params: dict,
|
params: dict,
|
||||||
|
|
||||||
method: str = 'get',
|
method: str = 'get',
|
||||||
venue: str|None = None, # if None use `.mkt_mode` state
|
venue: str | None = None, # if None use `.mkt_mode` state
|
||||||
signed: bool = False,
|
signed: bool = False,
|
||||||
allow_testnet: bool = False,
|
allow_testnet: bool = False,
|
||||||
|
|
||||||
|
|
@ -268,9 +322,8 @@ class Client:
|
||||||
- /fapi/v3/ USD-M FUTURES, or
|
- /fapi/v3/ USD-M FUTURES, or
|
||||||
- /api/v3/ SPOT/MARGIN
|
- /api/v3/ SPOT/MARGIN
|
||||||
|
|
||||||
account/market endpoint request depending on either passed in
|
account/market endpoint request depending on either passed in `venue: str`
|
||||||
`venue: str` or the current setting `.mkt_mode: str` setting,
|
or the current setting `.mkt_mode: str` setting, default `'spot'`.
|
||||||
default `'spot'`.
|
|
||||||
|
|
||||||
|
|
||||||
Docs per venue API:
|
Docs per venue API:
|
||||||
|
|
@ -299,6 +352,9 @@ class Client:
|
||||||
venue=venue_key,
|
venue=venue_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sesh: asks.Session
|
||||||
|
path: str
|
||||||
|
|
||||||
# Check if we're configured to route order requests to the
|
# Check if we're configured to route order requests to the
|
||||||
# venue equivalent's testnet.
|
# venue equivalent's testnet.
|
||||||
use_testnet: bool = False
|
use_testnet: bool = False
|
||||||
|
|
@ -323,12 +379,11 @@ class Client:
|
||||||
# ctl machinery B)
|
# ctl machinery B)
|
||||||
venue_key += '_testnet'
|
venue_key += '_testnet'
|
||||||
|
|
||||||
client: httpx.AsyncClient
|
sesh, path = self.venue_sesh[venue_key]
|
||||||
path: str
|
|
||||||
client, path = self.venue_sesh[venue_key]
|
meth: Callable = getattr(sesh, method)
|
||||||
meth: Callable = getattr(client, method)
|
|
||||||
resp = await meth(
|
resp = await meth(
|
||||||
url=path + endpoint,
|
path=path + endpoint,
|
||||||
params=params,
|
params=params,
|
||||||
timeout=float('inf'),
|
timeout=float('inf'),
|
||||||
)
|
)
|
||||||
|
|
@ -341,6 +396,7 @@ class Client:
|
||||||
) -> None:
|
) -> None:
|
||||||
# lookup internal mkt-specific pair table to update
|
# lookup internal mkt-specific pair table to update
|
||||||
pair_table: dict[str, Pair] = self._venue2pairs[venue]
|
pair_table: dict[str, Pair] = self._venue2pairs[venue]
|
||||||
|
asset_table: dict[str, Asset] = self._venue2assets[venue]
|
||||||
|
|
||||||
# make API request(s)
|
# make API request(s)
|
||||||
resp = await self._api(
|
resp = await self._api(
|
||||||
|
|
@ -352,7 +408,6 @@ class Client:
|
||||||
venue=venue,
|
venue=venue,
|
||||||
allow_testnet=False, # XXX: never use testnet for symbol lookups
|
allow_testnet=False, # XXX: never use testnet for symbol lookups
|
||||||
)
|
)
|
||||||
|
|
||||||
mkt_pairs = resp['symbols']
|
mkt_pairs = resp['symbols']
|
||||||
if not mkt_pairs:
|
if not mkt_pairs:
|
||||||
raise SymbolNotFound(f'No market pairs found!?:\n{resp}')
|
raise SymbolNotFound(f'No market pairs found!?:\n{resp}')
|
||||||
|
|
@ -370,60 +425,28 @@ class Client:
|
||||||
item['filters'] = filters
|
item['filters'] = filters
|
||||||
|
|
||||||
pair_type: Type = PAIRTYPES[venue]
|
pair_type: Type = PAIRTYPES[venue]
|
||||||
try:
|
|
||||||
pair: Pair = pair_type(**item)
|
pair: Pair = pair_type(**item)
|
||||||
except Exception as e:
|
|
||||||
e.add_note(
|
|
||||||
"\nDon't panic, prolly stupid binance changed their symbology schema again..\n"
|
|
||||||
'Check out their API docs here:\n\n'
|
|
||||||
'https://binance-docs.github.io/apidocs/spot/en/#exchange-information'
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
pair_table[pair.symbol.upper()] = pair
|
pair_table[pair.symbol.upper()] = pair
|
||||||
|
|
||||||
# update an additional top-level-cross-venue-table
|
# update an additional top-level-cross-venue-table
|
||||||
# `._pairs: ChainMap` for search B0
|
# `._pairs: ChainMap` for search B0
|
||||||
pairs_view_subtable[pair.bs_fqme] = pair
|
pairs_view_subtable[pair.bs_fqme] = pair
|
||||||
|
|
||||||
# XXX WOW: TURNS OUT THIS ISN'T TRUE !?
|
|
||||||
# > (populate `Asset` table for spot mkts only since it
|
|
||||||
# > should be a superset of any other venues such as
|
|
||||||
# > futes or margin)
|
|
||||||
if venue == 'spot':
|
if venue == 'spot':
|
||||||
dst_sectype: str = 'crypto_currency'
|
if (name := pair.quoteAsset) not in asset_table:
|
||||||
|
asset_table[name] = Asset(
|
||||||
elif venue in {'usdtm_futes'}:
|
|
||||||
dst_sectype: str = 'future'
|
|
||||||
if pair.contractType == 'PERPETUAL':
|
|
||||||
dst_sectype: str = 'perpetual_future'
|
|
||||||
|
|
||||||
spot_asset_table: dict[str, Asset] = self._venue2assets['spot']
|
|
||||||
ven_asset_table: dict[str, Asset] = self._venue2assets[venue]
|
|
||||||
|
|
||||||
if (
|
|
||||||
(name := pair.quoteAsset) not in spot_asset_table
|
|
||||||
):
|
|
||||||
spot_asset_table[pair.bs_src_asset] = Asset(
|
|
||||||
name=name,
|
name=name,
|
||||||
atype='crypto_currency',
|
atype='crypto_currency',
|
||||||
tx_tick=digits_to_dec(pair.quoteAssetPrecision),
|
tx_tick=digits_to_dec(pair.quoteAssetPrecision),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (name := pair.baseAsset) not in asset_table:
|
||||||
(name := pair.baseAsset) not in ven_asset_table
|
asset_table[name] = Asset(
|
||||||
):
|
|
||||||
if venue != 'spot':
|
|
||||||
assert dst_sectype != 'crypto_currency'
|
|
||||||
|
|
||||||
ven_asset_table[pair.bs_dst_asset] = Asset(
|
|
||||||
name=name,
|
name=name,
|
||||||
atype=dst_sectype,
|
atype='crypto_currency',
|
||||||
tx_tick=digits_to_dec(pair.baseAssetPrecision),
|
tx_tick=digits_to_dec(pair.baseAssetPrecision),
|
||||||
)
|
)
|
||||||
|
|
||||||
# log.warning(
|
|
||||||
# f'Assets not YET found in spot set: `{pformat(dne)}`!?'
|
|
||||||
# )
|
|
||||||
# NOTE: make merged view of all market-type pairs but
|
# NOTE: make merged view of all market-type pairs but
|
||||||
# use market specific `Pair.bs_fqme` for keys!
|
# use market specific `Pair.bs_fqme` for keys!
|
||||||
# this allows searching for market pairs with different
|
# this allows searching for market pairs with different
|
||||||
|
|
@ -435,29 +458,16 @@ class Client:
|
||||||
if venue == 'spot':
|
if venue == 'spot':
|
||||||
return
|
return
|
||||||
|
|
||||||
# TODO: maybe use this assets response for non-spot venues?
|
assets: list[dict] = resp.get('assets', ())
|
||||||
# -> issue is we do the exch_info queries conc, so we can't
|
for entry in assets:
|
||||||
# guarantee order for inter-table lookups..
|
name: str = entry['asset']
|
||||||
# if venue ep delivers an explicit set of assets copy just
|
asset_table[name] = self._venue2assets['spot'].get(name)
|
||||||
# ensure they are also already listed in the spot equivs.
|
|
||||||
# assets: list[dict] = resp.get('assets', ())
|
|
||||||
# for entry in assets:
|
|
||||||
# name: str = entry['asset']
|
|
||||||
# spot_asset_table: dict[str, Asset] = self._venue2assets['spot']
|
|
||||||
# if name not in spot_asset_table:
|
|
||||||
# log.warning(
|
|
||||||
# f'COULDNT FIND ASSET {name}\n{entry}\n'
|
|
||||||
# f'ADDING AS FUTES ONLY!?'
|
|
||||||
# )
|
|
||||||
# asset_table: dict[str, Asset] = self._venue2assets[venue]
|
|
||||||
# asset_table[name] = spot_asset_table.get(name)
|
|
||||||
|
|
||||||
async def exch_info(
|
async def exch_info(
|
||||||
self,
|
self,
|
||||||
sym: str | None = None,
|
sym: str | None = None,
|
||||||
|
|
||||||
venue: MarketType | None = None,
|
venue: MarketType | None = None,
|
||||||
expiry: str | None = None,
|
|
||||||
|
|
||||||
) -> dict[str, Pair] | Pair:
|
) -> dict[str, Pair] | Pair:
|
||||||
'''
|
'''
|
||||||
|
|
@ -473,20 +483,9 @@ class Client:
|
||||||
|
|
||||||
'''
|
'''
|
||||||
pair_table: dict[str, Pair] = self._venue2pairs[
|
pair_table: dict[str, Pair] = self._venue2pairs[
|
||||||
venue
|
venue or self.mkt_mode
|
||||||
or
|
|
||||||
self.mkt_mode
|
|
||||||
]
|
]
|
||||||
if (
|
if cached_pair := pair_table.get(sym):
|
||||||
expiry
|
|
||||||
and 'perp' not in expiry.lower()
|
|
||||||
):
|
|
||||||
sym: str = f'{sym}_{expiry}'
|
|
||||||
|
|
||||||
if (
|
|
||||||
sym
|
|
||||||
and (cached_pair := pair_table.get(sym))
|
|
||||||
):
|
|
||||||
return cached_pair
|
return cached_pair
|
||||||
|
|
||||||
venues: list[str] = ['spot', 'usdtm_futes']
|
venues: list[str] = ['spot', 'usdtm_futes']
|
||||||
|
|
@ -494,52 +493,14 @@ class Client:
|
||||||
venues: list[str] = [venue]
|
venues: list[str] = [venue]
|
||||||
|
|
||||||
# batch per-venue download of all exchange infos
|
# batch per-venue download of all exchange infos
|
||||||
async with trio.open_nursery() as tn:
|
async with trio.open_nursery() as rn:
|
||||||
for ven in venues:
|
for ven in venues:
|
||||||
tn.start_soon(
|
rn.start_soon(
|
||||||
self._cache_pairs,
|
self._cache_pairs,
|
||||||
ven,
|
ven,
|
||||||
)
|
)
|
||||||
|
|
||||||
if sym:
|
return pair_table[sym] if sym else self._pairs
|
||||||
return pair_table[sym]
|
|
||||||
else:
|
|
||||||
return self._pairs
|
|
||||||
|
|
||||||
async def get_assets(
|
|
||||||
self,
|
|
||||||
venue: str | None = None,
|
|
||||||
|
|
||||||
) -> dict[str, Asset]:
|
|
||||||
if (
|
|
||||||
venue
|
|
||||||
and venue != 'spot'
|
|
||||||
):
|
|
||||||
venues = [venue]
|
|
||||||
else:
|
|
||||||
venues = ['usdtm_futes']
|
|
||||||
|
|
||||||
ass_table: dict[str, Asset] = self._venue2assets['spot']
|
|
||||||
|
|
||||||
# merge in futes contracts with a sectype suffix
|
|
||||||
for venue in venues:
|
|
||||||
ass_table |= self._venue2assets[venue]
|
|
||||||
|
|
||||||
return ass_table
|
|
||||||
|
|
||||||
|
|
||||||
async def get_mkt_pairs(self) -> dict[str, Pair]:
|
|
||||||
'''
|
|
||||||
Flatten the multi-venue (chain) map of market pairs
|
|
||||||
to a fqme indexed table for data layer caching.
|
|
||||||
|
|
||||||
'''
|
|
||||||
flat: dict[str, Pair] = {}
|
|
||||||
for venmap in self._pairs.maps:
|
|
||||||
for bs_fqme, pair in venmap.items():
|
|
||||||
flat[pair.bs_fqme] = pair
|
|
||||||
|
|
||||||
return flat
|
|
||||||
|
|
||||||
# TODO: unused except by `brokers.core.search_symbols()`?
|
# TODO: unused except by `brokers.core.search_symbols()`?
|
||||||
async def search_symbols(
|
async def search_symbols(
|
||||||
|
|
@ -549,32 +510,20 @@ class Client:
|
||||||
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
||||||
fq_pairs: dict[str, Pair] = await self.exch_info()
|
fq_pairs: dict = await self.exch_info()
|
||||||
|
|
||||||
# TODO: cache this list like we were in
|
matches = fuzzy.extractBests(
|
||||||
# `open_symbol_search()`?
|
pattern,
|
||||||
# keys: list[str] = list(fq_pairs)
|
fq_pairs,
|
||||||
|
|
||||||
return match_from_pairs(
|
|
||||||
pairs=fq_pairs,
|
|
||||||
query=pattern.upper(),
|
|
||||||
score_cutoff=50,
|
score_cutoff=50,
|
||||||
)
|
)
|
||||||
|
# repack in dict form
|
||||||
def pair2venuekey(
|
return {item[0]['symbol']: item[0]
|
||||||
self,
|
for item in matches}
|
||||||
pair: Pair,
|
|
||||||
) -> str:
|
|
||||||
return {
|
|
||||||
'USDTM': 'usdtm_futes',
|
|
||||||
'SPOT': 'spot',
|
|
||||||
# 'COINM': 'coin_futes',
|
|
||||||
# ^-TODO-^ bc someone might want it..?
|
|
||||||
}[pair.venue]
|
|
||||||
|
|
||||||
async def bars(
|
async def bars(
|
||||||
self,
|
self,
|
||||||
mkt: MktPair,
|
symbol: str,
|
||||||
|
|
||||||
start_dt: datetime | None = None,
|
start_dt: datetime | None = None,
|
||||||
end_dt: datetime | None = None,
|
end_dt: datetime | None = None,
|
||||||
|
|
@ -604,20 +553,16 @@ class Client:
|
||||||
start_time = binance_timestamp(start_dt)
|
start_time = binance_timestamp(start_dt)
|
||||||
end_time = binance_timestamp(end_dt)
|
end_time = binance_timestamp(end_dt)
|
||||||
|
|
||||||
bs_pair: Pair = self._pairs[mkt.bs_fqme.upper()]
|
|
||||||
|
|
||||||
# https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data
|
# https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-data
|
||||||
bars = await self._api(
|
bars = await self._api(
|
||||||
'klines',
|
'klines',
|
||||||
params={
|
params={
|
||||||
# NOTE: always query using their native symbology!
|
'symbol': symbol.upper(),
|
||||||
'symbol': mkt.bs_mktid.upper(),
|
|
||||||
'interval': '1m',
|
'interval': '1m',
|
||||||
'startTime': start_time,
|
'startTime': start_time,
|
||||||
'endTime': end_time,
|
'endTime': end_time,
|
||||||
'limit': limit
|
'limit': limit
|
||||||
},
|
},
|
||||||
venue=self.pair2venuekey(bs_pair),
|
|
||||||
allow_testnet=False,
|
allow_testnet=False,
|
||||||
)
|
)
|
||||||
new_bars: list[tuple] = []
|
new_bars: list[tuple] = []
|
||||||
|
|
@ -934,148 +879,17 @@ class Client:
|
||||||
await self.close_listen_key(key)
|
await self.close_listen_key(key)
|
||||||
|
|
||||||
|
|
||||||
_venue_urls: dict[str, str] = {
|
|
||||||
'spot': (
|
|
||||||
_spot_url,
|
|
||||||
'/api/v3/',
|
|
||||||
),
|
|
||||||
'spot_testnet': (
|
|
||||||
_testnet_spot_url,
|
|
||||||
'/fapi/v1/'
|
|
||||||
),
|
|
||||||
# margin and extended spot endpoints session.
|
|
||||||
# TODO: did this ever get implemented fully?
|
|
||||||
# 'margin': (
|
|
||||||
# _spot_url,
|
|
||||||
# '/sapi/v1/'
|
|
||||||
# ),
|
|
||||||
|
|
||||||
'usdtm_futes': (
|
|
||||||
_futes_url,
|
|
||||||
'/fapi/v1/',
|
|
||||||
),
|
|
||||||
|
|
||||||
'usdtm_futes_testnet': (
|
|
||||||
_testnet_futes_url,
|
|
||||||
'/fapi/v1/',
|
|
||||||
),
|
|
||||||
|
|
||||||
# TODO: for anyone who actually needs it ;P
|
|
||||||
# 'coin_futes': ()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def init_api_keys(
|
|
||||||
client: Client,
|
|
||||||
conf: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Set up per-venue API keys each http client according to the user's
|
|
||||||
`brokers.conf`.
|
|
||||||
|
|
||||||
For ex, to use spot-testnet and live usdt futures APIs:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[binance]
|
|
||||||
# spot test net
|
|
||||||
spot.use_testnet = true
|
|
||||||
spot.api_key = '<spot_api_key_from_binance_account>'
|
|
||||||
spot.api_secret = '<spot_api_key_password>'
|
|
||||||
|
|
||||||
# futes live
|
|
||||||
futes.use_testnet = false
|
|
||||||
accounts.usdtm = 'futes'
|
|
||||||
futes.api_key = '<futes_api_key_from_binance>'
|
|
||||||
futes.api_secret = '<futes_api_key_password>''
|
|
||||||
|
|
||||||
# if uncommented will use the built-in paper engine and not
|
|
||||||
# connect to `binance` API servers for order ctl.
|
|
||||||
# accounts.paper = 'paper'
|
|
||||||
```
|
|
||||||
|
|
||||||
'''
|
|
||||||
for key, subconf in conf.items():
|
|
||||||
if api_key := subconf.get('api_key', ''):
|
|
||||||
venue_keys: list[str] = client.confkey2venuekeys[key]
|
|
||||||
|
|
||||||
venue_key: str
|
|
||||||
client: httpx.AsyncClient
|
|
||||||
for venue_key in venue_keys:
|
|
||||||
client, _ = client.venue_sesh[venue_key]
|
|
||||||
|
|
||||||
api_key_header: dict = {
|
|
||||||
# taken from official:
|
|
||||||
# https://github.com/binance/binance-futures-connector-python/blob/main/binance/api.py#L47
|
|
||||||
"Content-Type": "application/json;charset=utf-8",
|
|
||||||
|
|
||||||
# TODO: prolly should just always query and copy
|
|
||||||
# in the real latest ver?
|
|
||||||
"User-Agent": "binance-connector/6.1.6smbz6",
|
|
||||||
"X-MBX-APIKEY": api_key,
|
|
||||||
}
|
|
||||||
client.headers.update(api_key_header)
|
|
||||||
|
|
||||||
# if `.use_tesnet = true` in the config then
|
|
||||||
# also add headers for the testnet session which
|
|
||||||
# will be used for all order control
|
|
||||||
if subconf.get('use_testnet', False):
|
|
||||||
testnet_sesh, _ = client.venue_sesh[
|
|
||||||
venue_key + '_testnet'
|
|
||||||
]
|
|
||||||
testnet_sesh.headers.update(api_key_header)
|
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def get_client(
|
async def get_client() -> Client:
|
||||||
mkt_mode: MarketType = 'spot',
|
|
||||||
) -> Client:
|
|
||||||
'''
|
|
||||||
Construct an single `piker` client which composes multiple underlying venue
|
|
||||||
specific API clients both for live and test networks.
|
|
||||||
|
|
||||||
'''
|
client = Client()
|
||||||
venue_sessions: dict[
|
await client.exch_info()
|
||||||
str, # venue key
|
|
||||||
tuple[httpx.AsyncClient, str] # session, eps path
|
|
||||||
] = {}
|
|
||||||
async with AsyncExitStack() as client_stack:
|
|
||||||
for name, (base_url, path) in _venue_urls.items():
|
|
||||||
api: httpx.AsyncClient = await client_stack.enter_async_context(
|
|
||||||
httpx.AsyncClient(
|
|
||||||
base_url=base_url,
|
|
||||||
# headers={},
|
|
||||||
|
|
||||||
# TODO: is there a way to numerate this?
|
|
||||||
# https://www.python-httpx.org/advanced/clients/#why-use-a-client
|
|
||||||
# connections=4
|
|
||||||
)
|
|
||||||
)
|
|
||||||
venue_sessions[name] = (
|
|
||||||
api,
|
|
||||||
path,
|
|
||||||
)
|
|
||||||
|
|
||||||
conf: dict[str, Any] = get_config()
|
|
||||||
# for creating API keys see,
|
|
||||||
# https://www.binance.com/en/support/faq/how-to-create-api-keys-on-binance-360002502072
|
|
||||||
client = Client(
|
|
||||||
venue_sessions=venue_sessions,
|
|
||||||
conf=conf,
|
|
||||||
mkt_mode=mkt_mode,
|
|
||||||
)
|
|
||||||
init_api_keys(
|
|
||||||
client=client,
|
|
||||||
conf=conf,
|
|
||||||
)
|
|
||||||
fq_pairs: dict[str, Pair] = await client.exch_info()
|
|
||||||
assert fq_pairs
|
|
||||||
log.info(
|
log.info(
|
||||||
f'Loaded multi-venue `Client` in mkt_mode={client.mkt_mode!r}\n\n'
|
f'{client} in {client.mkt_mode} mode: caching exchange infos..\n'
|
||||||
f'Symbology Summary:\n'
|
'Cached multi-market pairs:\n'
|
||||||
f'------ - ------\n'
|
|
||||||
f'spot: {len(client._spot_pairs)}\n'
|
f'spot: {len(client._spot_pairs)}\n'
|
||||||
f'usdtm_futes: {len(client._ufutes_pairs)}\n'
|
f'usdtm_futes: {len(client._ufutes_pairs)}\n'
|
||||||
'------ - ------\n'
|
f'Total: {len(client._pairs)}\n'
|
||||||
f'total: {len(client._pairs)}\n'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import trio
|
||||||
|
|
||||||
from piker.accounting import (
|
from piker.accounting import (
|
||||||
Asset,
|
Asset,
|
||||||
|
# MktPair,
|
||||||
)
|
)
|
||||||
from piker.brokers._util import (
|
from piker.brokers._util import (
|
||||||
get_logger,
|
get_logger,
|
||||||
|
|
@ -48,9 +49,7 @@ from piker.brokers import (
|
||||||
open_cached_client,
|
open_cached_client,
|
||||||
BrokerError,
|
BrokerError,
|
||||||
)
|
)
|
||||||
from piker.clearing import (
|
from piker.clearing import OrderDialogs
|
||||||
OrderDialogs,
|
|
||||||
)
|
|
||||||
from piker.clearing._messages import (
|
from piker.clearing._messages import (
|
||||||
BrokerdOrder,
|
BrokerdOrder,
|
||||||
BrokerdOrderAck,
|
BrokerdOrderAck,
|
||||||
|
|
@ -72,33 +71,6 @@ from .api import Client
|
||||||
log = get_logger('piker.brokers.binance')
|
log = get_logger('piker.brokers.binance')
|
||||||
|
|
||||||
|
|
||||||
# Fee schedule template, mostly for paper engine fees modelling.
|
|
||||||
# https://www.binance.com/en/support/faq/what-are-market-makers-and-takers-360007720071
|
|
||||||
def get_cost(
|
|
||||||
price: float,
|
|
||||||
size: float,
|
|
||||||
is_taker: bool = False,
|
|
||||||
|
|
||||||
) -> float:
|
|
||||||
|
|
||||||
# https://www.binance.com/en/fee/trading
|
|
||||||
cb: float = price * size
|
|
||||||
match is_taker:
|
|
||||||
case True:
|
|
||||||
return cb * 0.001000
|
|
||||||
|
|
||||||
case False if cb < 1e6:
|
|
||||||
return cb * 0.001000
|
|
||||||
|
|
||||||
case False if 1e6 >= cb < 5e6:
|
|
||||||
return cb * 0.000900
|
|
||||||
|
|
||||||
# NOTE: there's more but are you really going
|
|
||||||
# to have a cb bigger then this per trade?
|
|
||||||
case False if cb >= 5e6:
|
|
||||||
return cb * 0.000800
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_order_requests(
|
async def handle_order_requests(
|
||||||
ems_order_stream: tractor.MsgStream,
|
ems_order_stream: tractor.MsgStream,
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
@ -260,24 +232,16 @@ async def open_trade_dialog(
|
||||||
account_name: str = 'usdtm'
|
account_name: str = 'usdtm'
|
||||||
use_testnet: bool = False
|
use_testnet: bool = False
|
||||||
|
|
||||||
# TODO: if/when we add .accounting support we need to
|
|
||||||
# do a open_symcache() call.. though maybe we can hide
|
|
||||||
# this in a new async version of open_account()?
|
|
||||||
async with open_cached_client('binance') as client:
|
async with open_cached_client('binance') as client:
|
||||||
subconf: dict|None = client.conf.get(venue_name)
|
subconf: dict = client.conf[venue_name]
|
||||||
|
use_testnet = subconf.get('use_testnet', False)
|
||||||
|
|
||||||
# XXX: if no futes.api_key or spot.api_key has been set we
|
# XXX: if no futes.api_key or spot.api_key has been set we
|
||||||
# always fall back to the paper engine!
|
# always fall back to the paper engine!
|
||||||
if (
|
if not subconf.get('api_key'):
|
||||||
not subconf
|
|
||||||
or
|
|
||||||
not subconf.get('api_key')
|
|
||||||
):
|
|
||||||
await ctx.started('paper')
|
await ctx.started('paper')
|
||||||
return
|
return
|
||||||
|
|
||||||
use_testnet: bool = subconf.get('use_testnet', False)
|
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
open_cached_client('binance') as client,
|
open_cached_client('binance') as client,
|
||||||
):
|
):
|
||||||
|
|
@ -357,7 +321,7 @@ async def open_trade_dialog(
|
||||||
|
|
||||||
if balance > 0:
|
if balance > 0:
|
||||||
balances[spot_asset] = (balance, last_update_t)
|
balances[spot_asset] = (balance, last_update_t)
|
||||||
# await tractor.pause()
|
# await tractor.breakpoint()
|
||||||
|
|
||||||
# @position response:
|
# @position response:
|
||||||
# {'positions': [{'entryPrice': '0.0',
|
# {'positions': [{'entryPrice': '0.0',
|
||||||
|
|
@ -436,7 +400,7 @@ async def open_trade_dialog(
|
||||||
# and comparison with binance's own position calcs.
|
# and comparison with binance's own position calcs.
|
||||||
# - load pps and accounts using accounting apis, write
|
# - load pps and accounts using accounting apis, write
|
||||||
# the ledger and account files
|
# the ledger and account files
|
||||||
# - table: Account
|
# - table: PpTable
|
||||||
# - ledger: TransactionLedger
|
# - ledger: TransactionLedger
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,8 @@ from contextlib import (
|
||||||
aclosing,
|
aclosing,
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import (
|
from functools import partial
|
||||||
partial,
|
|
||||||
)
|
|
||||||
import itertools
|
import itertools
|
||||||
from pprint import pformat
|
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
AsyncGenerator,
|
AsyncGenerator,
|
||||||
|
|
@ -42,12 +39,12 @@ from trio_typing import TaskStatus
|
||||||
from pendulum import (
|
from pendulum import (
|
||||||
from_timestamp,
|
from_timestamp,
|
||||||
)
|
)
|
||||||
|
from fuzzywuzzy import process as fuzzy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
from piker.brokers import (
|
from piker.brokers import (
|
||||||
open_cached_client,
|
open_cached_client,
|
||||||
NoData,
|
|
||||||
)
|
)
|
||||||
from piker._cacheables import (
|
from piker._cacheables import (
|
||||||
async_lifo_cache,
|
async_lifo_cache,
|
||||||
|
|
@ -57,8 +54,9 @@ from piker.accounting import (
|
||||||
DerivTypes,
|
DerivTypes,
|
||||||
MktPair,
|
MktPair,
|
||||||
unpack_fqme,
|
unpack_fqme,
|
||||||
|
digits_to_dec,
|
||||||
)
|
)
|
||||||
from piker.types import Struct
|
from piker.data.types import Struct
|
||||||
from piker.data.validate import FeedInit
|
from piker.data.validate import FeedInit
|
||||||
from piker.data._web_bs import (
|
from piker.data._web_bs import (
|
||||||
open_autorecon_ws,
|
open_autorecon_ws,
|
||||||
|
|
@ -110,7 +108,6 @@ class AggTrade(Struct, frozen=True):
|
||||||
|
|
||||||
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!
|
||||||
|
|
@ -221,8 +218,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,
|
||||||
|
|
@ -255,30 +250,24 @@ async def open_history_client(
|
||||||
else:
|
else:
|
||||||
client.mkt_mode = 'spot'
|
client.mkt_mode = 'spot'
|
||||||
|
|
||||||
array: np.ndarray = await client.bars(
|
# NOTE: always query using their native symbology!
|
||||||
mkt=mkt,
|
mktid: str = mkt.bs_mktid
|
||||||
|
array = await client.bars(
|
||||||
|
mktid,
|
||||||
start_dt=start_dt,
|
start_dt=start_dt,
|
||||||
end_dt=end_dt,
|
end_dt=end_dt,
|
||||||
)
|
)
|
||||||
if array.size == 0:
|
|
||||||
raise NoData(
|
|
||||||
f'No frame for {start_dt} -> {end_dt}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
times = array['time']
|
times = array['time']
|
||||||
if not times.any():
|
if (
|
||||||
raise ValueError(
|
end_dt is None
|
||||||
'Bad frame with null-times?\n\n'
|
):
|
||||||
f'{times}'
|
inow = round(time.time())
|
||||||
)
|
|
||||||
|
|
||||||
if end_dt is None:
|
|
||||||
inow: int = round(time.time())
|
|
||||||
if (inow - times[-1]) > 60:
|
if (inow - times[-1]) > 60:
|
||||||
await tractor.pause()
|
await tractor.breakpoint()
|
||||||
|
|
||||||
start_dt = from_timestamp(times[0])
|
start_dt = from_timestamp(times[0])
|
||||||
end_dt = from_timestamp(times[-1])
|
end_dt = from_timestamp(times[-1])
|
||||||
|
|
||||||
return array, start_dt, end_dt
|
return array, start_dt, end_dt
|
||||||
|
|
||||||
yield get_ohlc, {'erlangs': 3, 'rate': 3}
|
yield get_ohlc, {'erlangs': 3, 'rate': 3}
|
||||||
|
|
@ -288,113 +277,69 @@ async def open_history_client(
|
||||||
async def get_mkt_info(
|
async def get_mkt_info(
|
||||||
fqme: str,
|
fqme: str,
|
||||||
|
|
||||||
) -> tuple[MktPair, Pair] | None:
|
) -> tuple[MktPair, Pair]:
|
||||||
|
|
||||||
# uppercase since kraken bs_mktid is always upper
|
# uppercase since kraken bs_mktid is always upper
|
||||||
if 'binance' not in fqme.lower():
|
if 'binance' not in fqme:
|
||||||
fqme += '.binance'
|
fqme += '.binance'
|
||||||
|
|
||||||
mkt_mode: str = ''
|
bs_fqme, _, broker = fqme.rpartition('.')
|
||||||
broker, mkt_ep, venue, expiry = unpack_fqme(fqme)
|
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()
|
|
||||||
|
|
||||||
# XXX TODO: we should change the usdtm_futes name to just
|
|
||||||
# usdm_futes (dropping the tether part) since it turns out that
|
|
||||||
# there are indeed USD-tokens OTHER THEN tether being used as
|
|
||||||
# the margin assets.. it's going to require a wholesale
|
|
||||||
# (variable/key) rename as well as file name adjustments to any
|
|
||||||
# existing tsdb set..
|
|
||||||
if 'usd' in venue_lower:
|
|
||||||
mkt_mode: str = 'usdtm_futes'
|
|
||||||
|
|
||||||
# NO IDEA what these contracts (some kinda DEX-ish futes?) are
|
|
||||||
# but we're masking them for now..
|
|
||||||
elif (
|
|
||||||
'defi' in venue_lower
|
|
||||||
|
|
||||||
# TODO: handle coinm futes which have a margin asset that
|
|
||||||
# is some crypto token!
|
|
||||||
# https://binance-docs.github.io/apidocs/delivery/en/#exchange-information
|
|
||||||
or 'btc' in venue_lower
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
else:
|
|
||||||
# NOTE: see the `FutesPair.bs_fqme: str` implementation
|
# NOTE: see the `FutesPair.bs_fqme: str` implementation
|
||||||
# to understand the reverse market info lookup below.
|
# to understand the reverse market info lookup below.
|
||||||
mkt_mode = venue_lower or 'spot'
|
mkt_mode = venue = venue.lower() or 'spot'
|
||||||
|
_atype: str = ''
|
||||||
if (
|
if (
|
||||||
venue
|
venue
|
||||||
and 'spot' not in venue_lower
|
and 'spot' not in venue.lower()
|
||||||
|
|
||||||
# XXX: catch all in case user doesn't know which
|
# XXX: catch all in case user doesn't know which
|
||||||
# venue they want (usdtm vs. coinm) and we can choose
|
# venue they want (usdtm vs. coinm) and we can choose
|
||||||
# a default (via config?) once we support coin-m APIs.
|
# a default (via config?) once we support coin-m APIs.
|
||||||
or 'perp' in venue_lower
|
or 'perp' in bs_fqme.lower()
|
||||||
):
|
):
|
||||||
if not mkt_mode:
|
mkt_mode: str = f'{venue.lower()}_futes'
|
||||||
mkt_mode: str = f'{venue_lower}_futes'
|
if 'perp' in expiry:
|
||||||
|
_atype = 'perpetual_future'
|
||||||
|
|
||||||
|
else:
|
||||||
|
_atype = 'future'
|
||||||
|
|
||||||
async with open_cached_client(
|
async with open_cached_client(
|
||||||
'binance',
|
'binance',
|
||||||
) as client:
|
) as client:
|
||||||
|
|
||||||
assets: dict[str, Asset] = await client.get_assets()
|
# switch mode depending on input pattern parsing
|
||||||
pair_str: str = mkt_ep.upper()
|
|
||||||
|
|
||||||
# switch venue-mode depending on input pattern parsing
|
|
||||||
# since we want to use a particular endpoint (set) for
|
|
||||||
# pair info lookup!
|
|
||||||
client.mkt_mode = mkt_mode
|
client.mkt_mode = mkt_mode
|
||||||
|
|
||||||
pair: Pair = await client.exch_info(
|
pair_str: str = mkt_ep.upper()
|
||||||
pair_str,
|
pair: Pair = await client.exch_info(pair_str)
|
||||||
venue=mkt_mode, # explicit
|
|
||||||
expiry=expiry,
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'futes' in mkt_mode:
|
if 'futes' in mkt_mode:
|
||||||
assert isinstance(pair, FutesPair)
|
assert isinstance(pair, FutesPair)
|
||||||
|
|
||||||
dst: Asset | None = assets.get(pair.bs_dst_asset)
|
|
||||||
if (
|
|
||||||
not dst
|
|
||||||
# TODO: a known asset DNE list?
|
|
||||||
# and pair.baseAsset == 'DEFI'
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
f'UNKNOWN {venue} asset {pair.baseAsset} from,\n'
|
|
||||||
f'{pformat(pair.to_dict())}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# XXX UNKNOWN missing "asset", though no idea why?
|
|
||||||
# maybe it's only avail in the margin venue(s): /dapi/ ?
|
|
||||||
return None
|
|
||||||
|
|
||||||
mkt = MktPair(
|
mkt = MktPair(
|
||||||
dst=dst,
|
dst=Asset(
|
||||||
src=assets[pair.bs_src_asset],
|
name=pair.baseAsset,
|
||||||
|
atype='crypto',
|
||||||
|
tx_tick=digits_to_dec(pair.baseAssetPrecision),
|
||||||
|
),
|
||||||
|
src=Asset(
|
||||||
|
name=pair.quoteAsset,
|
||||||
|
atype='crypto',
|
||||||
|
tx_tick=digits_to_dec(pair.quoteAssetPrecision),
|
||||||
|
),
|
||||||
price_tick=pair.price_tick,
|
price_tick=pair.price_tick,
|
||||||
size_tick=pair.size_tick,
|
size_tick=pair.size_tick,
|
||||||
bs_mktid=pair.symbol,
|
bs_mktid=pair.symbol,
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
venue=venue,
|
venue=venue,
|
||||||
broker='binance',
|
broker='binance',
|
||||||
|
_atype=_atype,
|
||||||
# NOTE: sectype is always taken from dst, see
|
|
||||||
# `MktPair.type_key` and `Client._cache_pairs()`
|
|
||||||
# _atype=sectype,
|
|
||||||
)
|
)
|
||||||
return mkt, pair
|
both = mkt, pair
|
||||||
|
return both
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
|
|
@ -465,8 +410,6 @@ async def stream_quotes(
|
||||||
):
|
):
|
||||||
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
|
||||||
|
|
@ -515,6 +458,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:
|
||||||
|
|
@ -528,11 +472,10 @@ async def open_symbol_search(
|
||||||
ctx: tractor.Context,
|
ctx: tractor.Context,
|
||||||
) -> Client:
|
) -> Client:
|
||||||
|
|
||||||
# NOTE: symbology tables are loaded as part of client
|
|
||||||
# startup in ``.api.get_client()`` and in this case
|
|
||||||
# are stored as `Client._pairs`.
|
|
||||||
async with open_cached_client('binance') as client:
|
async with open_cached_client('binance') as client:
|
||||||
|
|
||||||
|
# load all symbols locally for fast search
|
||||||
|
fqpairs_cache = await client.exch_info()
|
||||||
# TODO: maybe we should deliver the cache
|
# TODO: maybe we should deliver the cache
|
||||||
# so that client's can always do a local-lookup-first
|
# so that client's can always do a local-lookup-first
|
||||||
# style try and then update async as (new) match results
|
# style try and then update async as (new) match results
|
||||||
|
|
@ -543,15 +486,14 @@ async def open_symbol_search(
|
||||||
|
|
||||||
pattern: str
|
pattern: str
|
||||||
async for pattern in stream:
|
async for pattern in stream:
|
||||||
# NOTE: pattern fuzzy-matching is done within
|
matches = fuzzy.extractBests(
|
||||||
# the methd impl.
|
|
||||||
pairs: dict[str, Pair] = await client.search_symbols(
|
|
||||||
pattern,
|
pattern,
|
||||||
|
fqpairs_cache,
|
||||||
|
score_cutoff=50,
|
||||||
)
|
)
|
||||||
|
|
||||||
# repack in fqme-keyed table
|
# repack in dict form
|
||||||
byfqme: dict[str, Pair] = {}
|
await stream.send({
|
||||||
for pair in pairs.values():
|
item[0].bs_fqme: item[0]
|
||||||
byfqme[pair.bs_fqme] = pair
|
for item in matches
|
||||||
|
})
|
||||||
await stream.send(byfqme)
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
# Copyright (C)
|
||||||
|
# Guillermo Rodriguez (aka ze jefe)
|
||||||
|
# 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
|
||||||
|
|
@ -26,7 +29,7 @@ from decimal import Decimal
|
||||||
|
|
||||||
from msgspec import field
|
from msgspec import field
|
||||||
|
|
||||||
from piker.types import Struct
|
from piker.data.types import Struct
|
||||||
|
|
||||||
|
|
||||||
# API endpoint paths by venue / sub-API
|
# API endpoint paths by venue / sub-API
|
||||||
|
|
@ -62,7 +65,7 @@ MarketType = Literal[
|
||||||
'spot',
|
'spot',
|
||||||
# 'margin',
|
# 'margin',
|
||||||
'usdtm_futes',
|
'usdtm_futes',
|
||||||
# 'coinm_futes',
|
# 'coin_futes',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,7 +87,6 @@ def get_api_eps(venue: MarketType) -> tuple[str, str]:
|
||||||
|
|
||||||
|
|
||||||
class Pair(Struct, frozen=True, kw_only=True):
|
class Pair(Struct, frozen=True, kw_only=True):
|
||||||
|
|
||||||
symbol: str
|
symbol: str
|
||||||
status: str
|
status: str
|
||||||
orderTypes: list[str]
|
orderTypes: list[str]
|
||||||
|
|
@ -118,10 +120,6 @@ class Pair(Struct, frozen=True, kw_only=True):
|
||||||
def bs_fqme(self) -> str:
|
def bs_fqme(self) -> str:
|
||||||
return self.symbol
|
return self.symbol
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_mktid(self) -> str:
|
|
||||||
return f'{self.symbol}.{self.venue}'
|
|
||||||
|
|
||||||
|
|
||||||
class SpotPair(Pair, frozen=True):
|
class SpotPair(Pair, frozen=True):
|
||||||
|
|
||||||
|
|
@ -137,31 +135,15 @@ class SpotPair(Pair, frozen=True):
|
||||||
quoteOrderQtyMarketAllowed: bool
|
quoteOrderQtyMarketAllowed: bool
|
||||||
isSpotTradingAllowed: bool
|
isSpotTradingAllowed: bool
|
||||||
isMarginTradingAllowed: bool
|
isMarginTradingAllowed: bool
|
||||||
otoAllowed: bool
|
|
||||||
|
|
||||||
defaultSelfTradePreventionMode: str
|
defaultSelfTradePreventionMode: str
|
||||||
allowedSelfTradePreventionModes: list[str]
|
allowedSelfTradePreventionModes: list[str]
|
||||||
permissions: list[str]
|
permissions: list[str]
|
||||||
permissionSets: list[list[str]]
|
|
||||||
|
|
||||||
# NOTE: see `.data._symcache.SymbologyCache.load()` for why
|
|
||||||
ns_path: str = 'piker.brokers.binance:SpotPair'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def venue(self) -> str:
|
|
||||||
return 'SPOT'
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bs_fqme(self) -> str:
|
def bs_fqme(self) -> str:
|
||||||
return f'{self.symbol}.SPOT'
|
return f'{self.symbol}.SPOT'
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_src_asset(self) -> str:
|
|
||||||
return f'{self.quoteAsset}'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_dst_asset(self) -> str:
|
|
||||||
return f'{self.baseAsset}'
|
|
||||||
|
|
||||||
|
|
||||||
class FutesPair(Pair):
|
class FutesPair(Pair):
|
||||||
|
|
@ -181,14 +163,12 @@ class FutesPair(Pair):
|
||||||
quoteAsset: str # 'USDT',
|
quoteAsset: str # 'USDT',
|
||||||
quotePrecision: int # 8,
|
quotePrecision: int # 8,
|
||||||
requiredMarginPercent: float # '5.0000',
|
requiredMarginPercent: float # '5.0000',
|
||||||
|
settlePlan: int # 0,
|
||||||
timeInForce: list[str] # ['GTC', 'IOC', 'FOK', 'GTX'],
|
timeInForce: list[str] # ['GTC', 'IOC', 'FOK', 'GTX'],
|
||||||
triggerProtect: float # '0.0500',
|
triggerProtect: float # '0.0500',
|
||||||
underlyingSubType: list[str] # ['PoW'],
|
underlyingSubType: list[str] # ['PoW'],
|
||||||
underlyingType: str # 'COIN'
|
underlyingType: str # 'COIN'
|
||||||
|
|
||||||
# NOTE: see `.data._symcache.SymbologyCache.load()` for why
|
|
||||||
ns_path: str = 'piker.brokers.binance:FutesPair'
|
|
||||||
|
|
||||||
# NOTE: for compat with spot pairs and `MktPair.src: Asset`
|
# NOTE: for compat with spot pairs and `MktPair.src: Asset`
|
||||||
# processing..
|
# processing..
|
||||||
@property
|
@property
|
||||||
|
|
@ -196,101 +176,32 @@ class FutesPair(Pair):
|
||||||
return self.quotePrecision
|
return self.quotePrecision
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def expiry(self) -> str:
|
def bs_fqme(self) -> str:
|
||||||
symbol: str = self.symbol
|
|
||||||
contype: str = self.contractType
|
|
||||||
match contype:
|
|
||||||
case (
|
|
||||||
'CURRENT_QUARTER'
|
|
||||||
| 'CURRENT_QUARTER DELIVERING'
|
|
||||||
| 'NEXT_QUARTER' # su madre binance..
|
|
||||||
):
|
|
||||||
pair, _, expiry = symbol.partition('_')
|
|
||||||
assert pair == self.pair # sanity
|
|
||||||
return f'{expiry}'
|
|
||||||
|
|
||||||
case 'PERPETUAL':
|
|
||||||
return 'PERP'
|
|
||||||
|
|
||||||
case '':
|
|
||||||
subtype: list[str] = self.underlyingSubType
|
|
||||||
if not subtype:
|
|
||||||
if self.status == 'PENDING_TRADING':
|
|
||||||
return 'PENDING'
|
|
||||||
|
|
||||||
match subtype:
|
|
||||||
case ['DEFI']:
|
|
||||||
return 'PERP'
|
|
||||||
|
|
||||||
# wow, just wow you binance guys suck..
|
|
||||||
if self.status == 'PENDING_TRADING':
|
|
||||||
return 'PENDING'
|
|
||||||
|
|
||||||
# XXX: yeah no clue then..
|
|
||||||
raise ValueError(
|
|
||||||
f'Bad .expiry token match: {contype} for {symbol}'
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def venue(self) -> str:
|
|
||||||
symbol: str = self.symbol
|
symbol: str = self.symbol
|
||||||
ctype: str = self.contractType
|
ctype: str = self.contractType
|
||||||
margin: str = self.marginAsset
|
margin: str = self.marginAsset
|
||||||
|
|
||||||
match ctype:
|
match ctype:
|
||||||
case 'PERPETUAL':
|
case 'PERPETUAL':
|
||||||
return f'{margin}M'
|
return f'{symbol}.{margin}M.PERP'
|
||||||
|
|
||||||
case (
|
case 'CURRENT_QUARTER':
|
||||||
'CURRENT_QUARTER'
|
pair, _, expiry = symbol.partition('_')
|
||||||
| 'CURRENT_QUARTER DELIVERING'
|
return f'{pair}.{margin}M.{expiry}'
|
||||||
| 'NEXT_QUARTER' # su madre binance..
|
|
||||||
):
|
|
||||||
_, _, expiry = symbol.partition('_')
|
|
||||||
return f'{margin}M'
|
|
||||||
|
|
||||||
case '':
|
case '':
|
||||||
subtype: list[str] = self.underlyingSubType
|
subtype: list[str] = self.underlyingSubType
|
||||||
if not subtype:
|
if not subtype:
|
||||||
if self.status == 'PENDING_TRADING':
|
if self.status == 'PENDING_TRADING':
|
||||||
return f'{margin}M'
|
return f'{symbol}.{margin}M.PENDING'
|
||||||
|
|
||||||
match subtype:
|
match subtype[0]:
|
||||||
case (
|
case 'DEFI':
|
||||||
['DEFI']
|
return f'{symbol}.{subtype}.PERP'
|
||||||
| ['USDC']
|
|
||||||
):
|
|
||||||
return f'{subtype[0]}'
|
|
||||||
|
|
||||||
# XXX: yeah no clue then..
|
# XXX: yeah no clue then..
|
||||||
raise ValueError(
|
return f'{symbol}.WTF.PWNED.BBQ'
|
||||||
f'Bad .venue token match: {ctype}'
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_fqme(self) -> str:
|
|
||||||
symbol: str = self.symbol
|
|
||||||
ctype: str = self.contractType
|
|
||||||
venue: str = self.venue
|
|
||||||
pair: str = self.pair
|
|
||||||
|
|
||||||
match ctype:
|
|
||||||
case (
|
|
||||||
'CURRENT_QUARTER'
|
|
||||||
| 'NEXT_QUARTER' # su madre binance..
|
|
||||||
):
|
|
||||||
pair, _, expiry = symbol.partition('_')
|
|
||||||
assert pair == self.pair
|
|
||||||
|
|
||||||
return f'{pair}.{venue}.{self.expiry}'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_src_asset(self) -> str:
|
|
||||||
return f'{self.quoteAsset}'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_dst_asset(self) -> str:
|
|
||||||
return f'{self.baseAsset}.{self.venue}'
|
|
||||||
|
|
||||||
|
|
||||||
PAIRTYPES: dict[MarketType, Pair] = {
|
PAIRTYPES: dict[MarketType, Pair] = {
|
||||||
|
|
|
||||||
|
|
@ -454,18 +454,8 @@ def mkt_info(
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument('pattern', required=True)
|
@click.argument('pattern', required=True)
|
||||||
# TODO: move this to top level click/typer context for all subs
|
|
||||||
@click.option(
|
|
||||||
'--pdb',
|
|
||||||
is_flag=True,
|
|
||||||
help='Enable tractor debug mode',
|
|
||||||
)
|
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def search(
|
def search(config, pattern):
|
||||||
config: dict,
|
|
||||||
pattern: str,
|
|
||||||
pdb: bool,
|
|
||||||
):
|
|
||||||
'''
|
'''
|
||||||
Search for symbols from broker backend(s).
|
Search for symbols from broker backend(s).
|
||||||
|
|
||||||
|
|
@ -478,12 +468,9 @@ def search(
|
||||||
|
|
||||||
async with maybe_open_pikerd(
|
async with maybe_open_pikerd(
|
||||||
loglevel=config['loglevel'],
|
loglevel=config['loglevel'],
|
||||||
debug_mode=pdb,
|
|
||||||
):
|
):
|
||||||
return await func()
|
return await func()
|
||||||
|
|
||||||
from piker.toolz import open_crash_handler
|
|
||||||
with open_crash_handler():
|
|
||||||
quotes = trio.run(
|
quotes = trio.run(
|
||||||
main,
|
main,
|
||||||
partial(
|
partial(
|
||||||
|
|
@ -506,11 +493,9 @@ def search(
|
||||||
@click.option('--delete', '-d', flag_value=True, help='Delete section')
|
@click.option('--delete', '-d', flag_value=True, help='Delete section')
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def brokercfg(config, section, value, delete):
|
def brokercfg(config, section, value, delete):
|
||||||
'''
|
"""If invoked with no arguments, open an editor to edit broker configs file
|
||||||
If invoked with no arguments, open an editor to edit broker
|
or get / update an individual section.
|
||||||
configs file or get / update an individual section.
|
"""
|
||||||
|
|
||||||
'''
|
|
||||||
from .. import config
|
from .. import config
|
||||||
|
|
||||||
if section:
|
if section:
|
||||||
|
|
|
||||||
|
|
@ -95,15 +95,15 @@ async def option_chain(
|
||||||
return await client.option_chains(contracts)
|
return await client.option_chains(contracts)
|
||||||
|
|
||||||
|
|
||||||
# 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:
|
||||||
# # return await client.get_all_contracts([symbol])
|
# return await client.get_all_contracts([symbol])
|
||||||
# return await client.get_all_contracts([symbol])
|
return await client.get_all_contracts([symbol])
|
||||||
|
|
||||||
|
|
||||||
async def bars(
|
async def bars(
|
||||||
|
|
@ -117,6 +117,21 @@ async def bars(
|
||||||
return await client.bars(symbol, **kwargs)
|
return await client.bars(symbol, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def mkt_info(
|
||||||
|
brokermod: ModuleType,
|
||||||
|
fqme: str,
|
||||||
|
**kwargs,
|
||||||
|
|
||||||
|
) -> MktPair:
|
||||||
|
'''
|
||||||
|
Return MktPair info from broker including src and dst assets.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return await brokermod.get_mkt_info(
|
||||||
|
fqme.replace(brokermod.name, '')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def search_w_brokerd(name: str, pattern: str) -> dict:
|
async def search_w_brokerd(name: str, pattern: str) -> dict:
|
||||||
|
|
||||||
async with open_cached_client(name) as client:
|
async with open_cached_client(name) as client:
|
||||||
|
|
@ -145,11 +160,7 @@ async def symbol_search(
|
||||||
|
|
||||||
async with maybe_spawn_brokerd(
|
async with maybe_spawn_brokerd(
|
||||||
mod.name,
|
mod.name,
|
||||||
infect_asyncio=getattr(
|
infect_asyncio=getattr(mod, '_infect_asyncio', False),
|
||||||
mod,
|
|
||||||
'_infect_asyncio',
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
) as portal:
|
) as portal:
|
||||||
|
|
||||||
results.append((
|
results.append((
|
||||||
|
|
@ -167,20 +178,3 @@ async def symbol_search(
|
||||||
n.start_soon(search_backend, mod.name)
|
n.start_soon(search_backend, mod.name)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def mkt_info(
|
|
||||||
brokermod: ModuleType,
|
|
||||||
fqme: str,
|
|
||||||
**kwargs,
|
|
||||||
|
|
||||||
) -> MktPair:
|
|
||||||
'''
|
|
||||||
Return MktPair info from broker including src and dst assets.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async with open_cached_client(brokermod.name) as client:
|
|
||||||
assert client
|
|
||||||
return await brokermod.get_mkt_info(
|
|
||||||
fqme.replace(brokermod.name, '')
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ from typing import (
|
||||||
import pendulum
|
import pendulum
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
from rapidfuzz import process as fuzzy
|
from fuzzywuzzy import process as fuzzy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from tractor.trionics import (
|
from tractor.trionics import (
|
||||||
broadcast_receiver,
|
broadcast_receiver,
|
||||||
|
|
@ -52,11 +52,8 @@ from cryptofeed.defines import (
|
||||||
)
|
)
|
||||||
from cryptofeed.symbols import Symbol
|
from cryptofeed.symbols import Symbol
|
||||||
|
|
||||||
from piker.data import (
|
from piker.data.types import Struct
|
||||||
def_iohlcv_fields,
|
from piker.data import def_iohlcv_fields
|
||||||
match_from_pairs,
|
|
||||||
Struct,
|
|
||||||
)
|
|
||||||
from piker.data._web_bs import (
|
from piker.data._web_bs import (
|
||||||
open_jsonrpc_session
|
open_jsonrpc_session
|
||||||
)
|
)
|
||||||
|
|
@ -82,7 +79,7 @@ _testnet_ws_url = 'wss://test.deribit.com/ws/api/v2'
|
||||||
class JSONRPCResult(Struct):
|
class JSONRPCResult(Struct):
|
||||||
jsonrpc: str = '2.0'
|
jsonrpc: str = '2.0'
|
||||||
id: int
|
id: int
|
||||||
result: Optional[list[dict]] = None
|
result: Optional[dict] = None
|
||||||
error: Optional[dict] = None
|
error: Optional[dict] = None
|
||||||
usIn: int
|
usIn: int
|
||||||
usOut: int
|
usOut: int
|
||||||
|
|
@ -292,29 +289,24 @@ class Client:
|
||||||
currency: str = 'btc', # BTC, ETH, SOL, USDC
|
currency: str = 'btc', # BTC, ETH, SOL, USDC
|
||||||
kind: str = 'option',
|
kind: str = 'option',
|
||||||
expired: bool = False
|
expired: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get symbol info for the exchange.
|
||||||
|
|
||||||
) -> dict[str, dict]:
|
"""
|
||||||
'''
|
|
||||||
Get symbol infos.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if self._pairs:
|
if self._pairs:
|
||||||
return self._pairs
|
return self._pairs
|
||||||
|
|
||||||
# will retrieve all symbols by default
|
# will retrieve all symbols by default
|
||||||
params: dict[str, str] = {
|
params = {
|
||||||
'currency': currency.upper(),
|
'currency': currency.upper(),
|
||||||
'kind': kind,
|
'kind': kind,
|
||||||
'expired': str(expired).lower()
|
'expired': str(expired).lower()
|
||||||
}
|
}
|
||||||
|
|
||||||
resp: JSONRPCResult = await self.json_rpc(
|
resp = await self.json_rpc('public/get_instruments', params)
|
||||||
'public/get_instruments',
|
results = resp.result
|
||||||
params,
|
|
||||||
)
|
instruments = {
|
||||||
# convert to symbol-keyed table
|
|
||||||
results: list[dict] | None = resp.result
|
|
||||||
instruments: dict[str, dict] = {
|
|
||||||
item['instrument_name'].lower(): item
|
item['instrument_name'].lower(): item
|
||||||
for item in results
|
for item in results
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +319,6 @@ class Client:
|
||||||
async def cache_symbols(
|
async def cache_symbols(
|
||||||
self,
|
self,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
||||||
if not self._pairs:
|
if not self._pairs:
|
||||||
self._pairs = await self.symbol_info()
|
self._pairs = await self.symbol_info()
|
||||||
|
|
||||||
|
|
@ -338,23 +329,17 @@ class Client:
|
||||||
pattern: str,
|
pattern: str,
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
'''
|
data = await self.symbol_info()
|
||||||
Fuzzy search symbology set for pairs matching `pattern`.
|
|
||||||
|
|
||||||
'''
|
matches = fuzzy.extractBests(
|
||||||
pairs: dict[str, Any] = await self.symbol_info()
|
pattern,
|
||||||
matches: dict[str, Pair] = match_from_pairs(
|
data,
|
||||||
pairs=pairs,
|
|
||||||
query=pattern.upper(),
|
|
||||||
score_cutoff=35,
|
score_cutoff=35,
|
||||||
limit=limit
|
limit=limit
|
||||||
)
|
)
|
||||||
|
# repack in dict form
|
||||||
# repack in name-keyed table
|
return {item[0]['instrument_name'].lower(): item[0]
|
||||||
return {
|
for item in matches}
|
||||||
pair['instrument_name'].lower(): pair
|
|
||||||
for pair in matches.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
async def bars(
|
async def bars(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import time
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
import pendulum
|
import pendulum
|
||||||
from rapidfuzz import process as fuzzy
|
from fuzzywuzzy import process as fuzzy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,33 +30,23 @@ from .api import (
|
||||||
)
|
)
|
||||||
from .feed import (
|
from .feed import (
|
||||||
open_history_client,
|
open_history_client,
|
||||||
|
open_symbol_search,
|
||||||
stream_quotes,
|
stream_quotes,
|
||||||
)
|
)
|
||||||
from .broker import (
|
from .broker import (
|
||||||
open_trade_dialog,
|
open_trade_dialog,
|
||||||
)
|
)
|
||||||
from .ledger import (
|
from .ledger import (
|
||||||
norm_trade,
|
|
||||||
norm_trade_records,
|
norm_trade_records,
|
||||||
tx_sort,
|
|
||||||
)
|
|
||||||
from .symbols import (
|
|
||||||
get_mkt_info,
|
|
||||||
open_symbol_search,
|
|
||||||
_search_conf,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'get_client',
|
'get_client',
|
||||||
'get_mkt_info',
|
|
||||||
'norm_trade',
|
|
||||||
'norm_trade_records',
|
'norm_trade_records',
|
||||||
'open_trade_dialog',
|
'open_trade_dialog',
|
||||||
'open_history_client',
|
'open_history_client',
|
||||||
'open_symbol_search',
|
'open_symbol_search',
|
||||||
'stream_quotes',
|
'stream_quotes',
|
||||||
'_search_conf',
|
|
||||||
'tx_sort',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
_brokerd_mods: list[str] = [
|
_brokerd_mods: list[str] = [
|
||||||
|
|
@ -66,7 +56,6 @@ _brokerd_mods: list[str] = [
|
||||||
|
|
||||||
_datad_mods: list[str] = [
|
_datad_mods: list[str] = [
|
||||||
'feed',
|
'feed',
|
||||||
'symbols',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,8 +75,3 @@ _spawn_kwargs = {
|
||||||
# know if ``brokerd`` should be spawned with
|
# know if ``brokerd`` should be spawned with
|
||||||
# ``tractor``'s aio mode.
|
# ``tractor``'s aio mode.
|
||||||
_infect_asyncio: bool = True
|
_infect_asyncio: bool = True
|
||||||
|
|
||||||
# XXX NOTE: for now we disable symcache with this backend since
|
|
||||||
# there is no clearly simple nor practical way to download "all
|
|
||||||
# symbology info" for all supported venues..
|
|
||||||
_no_symcache: bool = True
|
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,7 @@ def load_flex_trades(
|
||||||
for acctid in trades_by_account:
|
for acctid in trades_by_account:
|
||||||
trades_by_id = trades_by_account[acctid]
|
trades_by_id = trades_by_account[acctid]
|
||||||
|
|
||||||
with open_trade_ledger(
|
with open_trade_ledger('ib', acctid) as ledger_dict:
|
||||||
'ib',
|
|
||||||
acctid,
|
|
||||||
allow_from_sync_code=True,
|
|
||||||
) as ledger_dict:
|
|
||||||
tid_delta = set(trades_by_id) - set(ledger_dict)
|
tid_delta = set(trades_by_id) - set(ledger_dict)
|
||||||
log.info(
|
log.info(
|
||||||
'New trades detected\n'
|
'New trades detected\n'
|
||||||
|
|
|
||||||
|
|
@ -88,25 +88,16 @@ async def data_reset_hack(
|
||||||
api_port: str = str(ib_client.client.port)
|
api_port: str = str(ib_client.client.port)
|
||||||
vnc_host: str
|
vnc_host: str
|
||||||
vnc_port: int
|
vnc_port: int
|
||||||
vnc_sockaddr: tuple[str] | None = client.conf.get('vnc_addrs')
|
vnc_host, vnc_port = client.conf['vnc_addrs'].get(
|
||||||
|
|
||||||
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(
|
|
||||||
no_setup_msg
|
|
||||||
+
|
|
||||||
'REQUIRES A `vnc_addrs: array` ENTRY'
|
|
||||||
)
|
|
||||||
|
|
||||||
vnc_host, vnc_port = vnc_sockaddr.get(
|
|
||||||
api_port,
|
api_port,
|
||||||
('localhost', 3003)
|
('localhost', 3003)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
no_setup_msg:str = (
|
||||||
|
f'No data reset hack test setup for {vnc_host}!\n'
|
||||||
|
'See setup @\n'
|
||||||
|
'https://github.com/pikers/piker/tree/master/piker/brokers/ib'
|
||||||
|
)
|
||||||
global _reset_tech
|
global _reset_tech
|
||||||
|
|
||||||
match _reset_tech:
|
match _reset_tech:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -21,40 +21,33 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import (
|
from contextlib import (
|
||||||
asynccontextmanager as acm,
|
asynccontextmanager as acm,
|
||||||
|
nullcontext,
|
||||||
)
|
)
|
||||||
|
from decimal import Decimal
|
||||||
from dataclasses import asdict
|
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 math import isnan
|
from math import isnan
|
||||||
import time
|
import time
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
TYPE_CHECKING,
|
Awaitable,
|
||||||
)
|
)
|
||||||
|
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
|
from fuzzywuzzy import process as fuzzy
|
||||||
import ib_insync as ibis
|
import ib_insync as ibis
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pendulum import (
|
import pendulum
|
||||||
now,
|
|
||||||
from_timestamp,
|
|
||||||
# DateTime,
|
|
||||||
Duration,
|
|
||||||
duration as mk_duration,
|
|
||||||
)
|
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
|
|
||||||
from piker.accounting import (
|
from .._util import (
|
||||||
MktPair,
|
|
||||||
)
|
|
||||||
from piker.data.validate import FeedInit
|
|
||||||
from piker.brokers._util import (
|
|
||||||
NoData,
|
NoData,
|
||||||
DataUnavailable,
|
DataUnavailable,
|
||||||
|
SymbolNotFound,
|
||||||
)
|
)
|
||||||
from .api import (
|
from .api import (
|
||||||
# _adhoc_futes_set,
|
# _adhoc_futes_set,
|
||||||
|
|
@ -70,10 +63,14 @@ from .api import (
|
||||||
RequestError,
|
RequestError,
|
||||||
)
|
)
|
||||||
from ._util import data_reset_hack
|
from ._util import data_reset_hack
|
||||||
from .symbols import get_mkt_info
|
from piker._cacheables import (
|
||||||
|
async_lifo_cache,
|
||||||
if TYPE_CHECKING:
|
)
|
||||||
from trio._core._run import Task
|
from piker.accounting import (
|
||||||
|
Asset,
|
||||||
|
MktPair,
|
||||||
|
)
|
||||||
|
from piker.data.validate import FeedInit
|
||||||
|
|
||||||
|
|
||||||
# XXX NOTE: See available types table docs:
|
# XXX NOTE: See available types table docs:
|
||||||
|
|
@ -143,21 +140,32 @@ async def open_history_client(
|
||||||
# memory.
|
# memory.
|
||||||
|
|
||||||
# IB's internal symbology does not expect the "source asset" in
|
# IB's internal symbology does not expect the "source asset" in
|
||||||
# the "symbol name", what we call the "market pair name". This is
|
# the "symbol name", what we call the "market name". This is
|
||||||
# common in most legacy market brokers since it's presumed that
|
# common in most legacy market brokers since it's presumed that
|
||||||
# given a certain stock exchange, listed assets are traded
|
# given a certain stock exchange, listed assets are traded
|
||||||
# "from" a particular source fiat, normally something like USD
|
# "from" a particular source fiat, normally something like USD.
|
||||||
# on the given venue-provider, eg. nasdaq, nyse, etc.
|
if (
|
||||||
|
mkt.src
|
||||||
|
and mkt.src.atype == 'fiat'
|
||||||
|
):
|
||||||
fqme_kwargs: dict[str, Any] = {}
|
fqme_kwargs: dict[str, Any] = {}
|
||||||
if mkt.dst.atype != 'fiat':
|
|
||||||
|
if mkt.dst.atype == 'forex':
|
||||||
|
|
||||||
|
# XXX: for now we do need the src token kept in since
|
||||||
fqme_kwargs = {
|
fqme_kwargs = {
|
||||||
'without_src': True, # default is True
|
'without_src': False, # default is True
|
||||||
'delim_char': '', # bc they would normally use a frickin `.` smh
|
'delim_char': '', # bc they would normally use a frickin `.` smh
|
||||||
}
|
}
|
||||||
|
|
||||||
fqme: str = mkt.get_bs_fqme(**(fqme_kwargs))
|
fqme: str = mkt.get_bs_fqme(**(fqme_kwargs))
|
||||||
|
|
||||||
|
else:
|
||||||
|
fqme = mkt.bs_fqme
|
||||||
|
|
||||||
async with open_data_client() as proxy:
|
async with open_data_client() as proxy:
|
||||||
|
|
||||||
|
|
||||||
max_timeout: float = 2.
|
max_timeout: float = 2.
|
||||||
mean: float = 0
|
mean: float = 0
|
||||||
count: int = 0
|
count: int = 0
|
||||||
|
|
@ -165,13 +173,12 @@ async def open_history_client(
|
||||||
head_dt: None | datetime = None
|
head_dt: None | datetime = None
|
||||||
if (
|
if (
|
||||||
# fx cons seem to not provide this endpoint?
|
# fx cons seem to not provide this endpoint?
|
||||||
# TODO: guard against all contract types which don't
|
|
||||||
# support it?
|
|
||||||
'idealpro' not in fqme
|
'idealpro' not in fqme
|
||||||
):
|
):
|
||||||
head_dt: datetime | None = await proxy.maybe_get_head_time(
|
try:
|
||||||
fqme=fqme
|
head_dt = await proxy.get_head_time(fqme=fqme)
|
||||||
)
|
except RequestError:
|
||||||
|
head_dt = None
|
||||||
|
|
||||||
async def get_hist(
|
async def get_hist(
|
||||||
timeframe: float,
|
timeframe: float,
|
||||||
|
|
@ -179,15 +186,8 @@ async def open_history_client(
|
||||||
start_dt: datetime | None = None,
|
start_dt: datetime | None = None,
|
||||||
|
|
||||||
) -> tuple[np.ndarray, str]:
|
) -> tuple[np.ndarray, str]:
|
||||||
|
|
||||||
nonlocal max_timeout, mean, count
|
nonlocal max_timeout, mean, count
|
||||||
|
|
||||||
if (
|
|
||||||
start_dt
|
|
||||||
and start_dt.timestamp() == 0
|
|
||||||
):
|
|
||||||
await tractor.pause()
|
|
||||||
|
|
||||||
query_start = time.time()
|
query_start = time.time()
|
||||||
out, timedout = await get_bars(
|
out, timedout = await get_bars(
|
||||||
proxy,
|
proxy,
|
||||||
|
|
@ -208,48 +208,24 @@ async def open_history_client(
|
||||||
f'mean: {mean}'
|
f'mean: {mean}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
out is None
|
||||||
|
):
|
||||||
# could be trying to retreive bars over weekend
|
# could be trying to retreive bars over weekend
|
||||||
if out is None:
|
|
||||||
log.error(f"Can't grab bars starting at {end_dt}!?!?")
|
log.error(f"Can't grab bars starting at {end_dt}!?!?")
|
||||||
|
raise NoData(
|
||||||
|
f'{end_dt}',
|
||||||
|
# frame_size=2000,
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
end_dt
|
end_dt
|
||||||
and head_dt
|
and head_dt
|
||||||
and end_dt <= head_dt
|
and end_dt <= head_dt
|
||||||
):
|
):
|
||||||
raise DataUnavailable(
|
raise DataUnavailable(f'First timestamp is {head_dt}')
|
||||||
f'First timestamp is {head_dt}\n'
|
|
||||||
f'But {end_dt} was requested..'
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
bars, bars_array, first_dt, last_dt = out
|
||||||
raise NoData(
|
|
||||||
info={
|
|
||||||
'fqme': fqme,
|
|
||||||
'head_dt': head_dt,
|
|
||||||
'start_dt': start_dt,
|
|
||||||
'end_dt': end_dt,
|
|
||||||
'timedout': timedout,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# also see return type for `get_bars()`
|
|
||||||
bars: ibis.objects.BarDataList
|
|
||||||
bars_array: np.ndarray
|
|
||||||
first_dt: datetime
|
|
||||||
last_dt: datetime
|
|
||||||
(
|
|
||||||
bars,
|
|
||||||
bars_array,
|
|
||||||
first_dt,
|
|
||||||
last_dt,
|
|
||||||
) = out
|
|
||||||
|
|
||||||
# TODO: audit the sampling period here as well?
|
|
||||||
# timestep should always be at least as large as the
|
|
||||||
# period step.
|
|
||||||
# tdiff: np.ndarray = np.diff(bars_array['time'])
|
|
||||||
# if (tdiff < timeframe).any():
|
|
||||||
# await tractor.pause()
|
|
||||||
|
|
||||||
# volume cleaning since there's -ve entries,
|
# volume cleaning since there's -ve entries,
|
||||||
# wood luv to know what crookery that is..
|
# wood luv to know what crookery that is..
|
||||||
|
|
@ -263,18 +239,7 @@ async def open_history_client(
|
||||||
# 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, {'erlangs': 1, 'rate': 3}
|
||||||
get_hist,
|
|
||||||
{
|
|
||||||
'erlangs': 1, # max conc reqs
|
|
||||||
'rate': 3, # max req rate
|
|
||||||
'frame_types': { # expected frame sizes
|
|
||||||
1: mk_duration(seconds=2e3),
|
|
||||||
60: mk_duration(days=2),
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_pacing: str = (
|
_pacing: str = (
|
||||||
|
|
@ -360,7 +325,7 @@ async def wait_on_data_reset(
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
_data_resetter_task: Task | None = None
|
_data_resetter_task: trio.Task | None = None
|
||||||
_failed_resets: int = 0
|
_failed_resets: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -386,15 +351,7 @@ async def get_bars(
|
||||||
|
|
||||||
task_status: TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
) -> tuple[
|
) -> (dict, np.ndarray):
|
||||||
tuple[ # result tuple
|
|
||||||
ibis.objects.BarDataList,
|
|
||||||
np.ndarray,
|
|
||||||
datetime,
|
|
||||||
datetime,
|
|
||||||
] | None,
|
|
||||||
bool, # timed out hint
|
|
||||||
]:
|
|
||||||
'''
|
'''
|
||||||
Retrieve historical data from a ``trio``-side task using
|
Retrieve historical data from a ``trio``-side task using
|
||||||
a ``MethoProxy``.
|
a ``MethoProxy``.
|
||||||
|
|
@ -419,11 +376,7 @@ async def get_bars(
|
||||||
|
|
||||||
while _failed_resets < max_failed_resets:
|
while _failed_resets < max_failed_resets:
|
||||||
try:
|
try:
|
||||||
(
|
out = await proxy.bars(
|
||||||
bars,
|
|
||||||
bars_array,
|
|
||||||
dt_duration,
|
|
||||||
) = await proxy.bars(
|
|
||||||
fqme=fqme,
|
fqme=fqme,
|
||||||
end_dt=end_dt,
|
end_dt=end_dt,
|
||||||
sample_period_s=timeframe,
|
sample_period_s=timeframe,
|
||||||
|
|
@ -434,58 +387,44 @@ async def get_bars(
|
||||||
# current impl) to detect a cancel case.
|
# current impl) to detect a cancel case.
|
||||||
# timeout=timeout,
|
# timeout=timeout,
|
||||||
)
|
)
|
||||||
# usually either a request during a venue closure
|
if out is None:
|
||||||
# or into a large (weekend) closure gap.
|
raise NoData(f'{end_dt}')
|
||||||
if not bars:
|
|
||||||
# no data returned?
|
bars, bars_array, dt_duration = out
|
||||||
log.warning(
|
|
||||||
'History frame is blank?\n'
|
|
||||||
f'start_dt: {start_dt}\n'
|
|
||||||
f'end_dt: {end_dt}\n'
|
|
||||||
f'duration: {dt_duration}\n'
|
|
||||||
)
|
|
||||||
# NOTE: REQUIRED to pass back value..
|
|
||||||
result = None
|
|
||||||
return None
|
|
||||||
|
|
||||||
# not enough bars signal, likely due to venue
|
# not enough bars signal, likely due to venue
|
||||||
# operational gaps.
|
# operational gaps.
|
||||||
if end_dt:
|
too_little: bool = False
|
||||||
dur_s: float = len(bars) * timeframe
|
if (
|
||||||
bars_dur = Duration(seconds=dur_s)
|
end_dt
|
||||||
dt_dur_s: float = dt_duration.in_seconds()
|
and (
|
||||||
if dur_s < dt_dur_s:
|
not bars
|
||||||
log.warning(
|
or (too_little :=
|
||||||
'History frame is shorter then expected?\n'
|
start_dt
|
||||||
f'start_dt: {start_dt}\n'
|
and (len(bars) * timeframe)
|
||||||
f'end_dt: {end_dt}\n'
|
< dt_duration.in_seconds()
|
||||||
f'duration: {dt_dur_s}\n'
|
|
||||||
f'frame duration seconds: {dur_s}\n'
|
|
||||||
f'dur diff: {dt_duration - bars_dur}\n'
|
|
||||||
)
|
)
|
||||||
# NOTE: we used to try to get a minimal
|
)
|
||||||
# set of bars by recursing but this ran
|
):
|
||||||
# into possible infinite query loops
|
if (
|
||||||
# when logic in the `Client.bars()` dt
|
end_dt
|
||||||
# diffing went bad. So instead for now
|
or too_little
|
||||||
# we just return the
|
):
|
||||||
# shorter-then-expected history with
|
log.warning(
|
||||||
# a warning.
|
f'History is blank for {dt_duration} from {end_dt}'
|
||||||
# TODO: in the future it prolly makes
|
)
|
||||||
# the most send to do venue operating
|
end_dt -= dt_duration
|
||||||
# hours lookup and
|
continue
|
||||||
# timestamp-in-operating-range set
|
|
||||||
# checking to know for sure if we can
|
|
||||||
# safely and quickly ignore non-uniform history
|
|
||||||
# frame timestamp gaps..
|
|
||||||
# end_dt -= dt_duration
|
|
||||||
# continue
|
|
||||||
# await tractor.pause()
|
|
||||||
|
|
||||||
first_dt = from_timestamp(
|
raise NoData(f'{end_dt}')
|
||||||
|
|
||||||
|
if bars_array is None:
|
||||||
|
raise SymbolNotFound(fqme)
|
||||||
|
|
||||||
|
first_dt = pendulum.from_timestamp(
|
||||||
bars[0].date.timestamp())
|
bars[0].date.timestamp())
|
||||||
|
|
||||||
last_dt = from_timestamp(
|
last_dt = pendulum.from_timestamp(
|
||||||
bars[-1].date.timestamp())
|
bars[-1].date.timestamp())
|
||||||
|
|
||||||
time = bars_array['time']
|
time = bars_array['time']
|
||||||
|
|
@ -498,22 +437,15 @@ async def get_bars(
|
||||||
if data_cs:
|
if data_cs:
|
||||||
data_cs.cancel()
|
data_cs.cancel()
|
||||||
|
|
||||||
# NOTE: setting this is critical!
|
result = (bars, bars_array, first_dt, last_dt)
|
||||||
result = (
|
|
||||||
bars, # ib native
|
|
||||||
bars_array, # numpy
|
|
||||||
first_dt,
|
|
||||||
last_dt,
|
|
||||||
)
|
|
||||||
|
|
||||||
# signal data reset loop parent task
|
# signal data reset loop parent task
|
||||||
result_ready.set()
|
result_ready.set()
|
||||||
|
|
||||||
# NOTE: this isn't getting collected anywhere!
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except RequestError as err:
|
except RequestError as err:
|
||||||
msg: str = err.message
|
msg = err.message
|
||||||
|
|
||||||
if 'No market data permissions for' in msg:
|
if 'No market data permissions for' in msg:
|
||||||
# TODO: signalling for no permissions searches
|
# TODO: signalling for no permissions searches
|
||||||
|
|
@ -535,7 +467,7 @@ async def get_bars(
|
||||||
if end_dt is not None:
|
if end_dt is not None:
|
||||||
end_dt = end_dt.subtract(days=1)
|
end_dt = end_dt.subtract(days=1)
|
||||||
elif end_dt is None:
|
elif end_dt is None:
|
||||||
end_dt = now().subtract(days=1)
|
end_dt = pendulum.now().subtract(days=1)
|
||||||
|
|
||||||
log.warning(
|
log.warning(
|
||||||
f'NO DATA found ending @ {end_dt}\n'
|
f'NO DATA found ending @ {end_dt}\n'
|
||||||
|
|
@ -551,29 +483,21 @@ async def get_bars(
|
||||||
nodatas_count += 1
|
nodatas_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif (
|
elif 'API historical data query cancelled' in err.message:
|
||||||
'API historical data query cancelled'
|
|
||||||
in
|
|
||||||
err.message
|
|
||||||
):
|
|
||||||
log.warning(
|
log.warning(
|
||||||
'Query cancelled by IB (:eyeroll:):\n'
|
'Query cancelled by IB (:eyeroll:):\n'
|
||||||
f'{err.message}'
|
f'{err.message}'
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif (
|
elif (
|
||||||
'Trading TWS session is connected from a different IP'
|
'Trading TWS session is connected from a different IP'
|
||||||
in
|
in err.message
|
||||||
err.message
|
|
||||||
):
|
):
|
||||||
log.warning("ignoring ip address warning")
|
log.warning("ignoring ip address warning")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# XXX: more or less same as above timeout case
|
# XXX: more or less same as above timeout case
|
||||||
elif (
|
elif _pacing in msg:
|
||||||
_pacing in msg
|
|
||||||
):
|
|
||||||
log.warning(
|
log.warning(
|
||||||
'History throttle rate reached!\n'
|
'History throttle rate reached!\n'
|
||||||
'Resetting farms with `ctrl-alt-f` hack\n'
|
'Resetting farms with `ctrl-alt-f` hack\n'
|
||||||
|
|
@ -625,10 +549,9 @@ async def get_bars(
|
||||||
# don't double invoke the reset hack if another
|
# don't double invoke the reset hack if another
|
||||||
# requester task already has it covered.
|
# requester task already has it covered.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
else:
|
else:
|
||||||
_data_resetter_task = trio.lowlevel.current_task()
|
_data_resetter_task = trio.lowlevel.current_task()
|
||||||
unset_resetter: bool = True
|
unset_resetter = True
|
||||||
|
|
||||||
# spawn new data reset task
|
# spawn new data reset task
|
||||||
data_cs, reset_done = await nurse.start(
|
data_cs, reset_done = await nurse.start(
|
||||||
|
|
@ -641,16 +564,31 @@ async def get_bars(
|
||||||
# sync wait on reset to complete
|
# sync wait on reset to complete
|
||||||
await reset_done.wait()
|
await reset_done.wait()
|
||||||
|
|
||||||
_data_resetter_task = (
|
_data_resetter_task = None if unset_resetter else _data_resetter_task
|
||||||
None
|
|
||||||
if unset_resetter
|
|
||||||
else _data_resetter_task
|
|
||||||
)
|
|
||||||
assert result
|
assert result
|
||||||
return (
|
return result, data_cs is not None
|
||||||
result,
|
|
||||||
data_cs is not None,
|
|
||||||
)
|
# re-mapping to piker asset type names
|
||||||
|
# https://github.com/erdewit/ib_insync/blob/master/ib_insync/contract.py#L113
|
||||||
|
_asset_type_map = {
|
||||||
|
'STK': 'stock',
|
||||||
|
'OPT': 'option',
|
||||||
|
'FUT': 'future',
|
||||||
|
'CONTFUT': 'continuous_future',
|
||||||
|
'CASH': 'forex',
|
||||||
|
'IND': 'index',
|
||||||
|
'CFD': 'cfd',
|
||||||
|
'BOND': 'bond',
|
||||||
|
'CMDTY': 'commodity',
|
||||||
|
'FOP': 'futures_option',
|
||||||
|
'FUND': 'mutual_fund',
|
||||||
|
'WAR': 'warrant',
|
||||||
|
'IOPT': 'warran',
|
||||||
|
'BAG': 'bag',
|
||||||
|
'CRYPTO': 'crypto', # bc it's diff then fiat?
|
||||||
|
# 'NEWS': 'news',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_quote_streams: dict[str, trio.abc.ReceiveStream] = {}
|
_quote_streams: dict[str, trio.abc.ReceiveStream] = {}
|
||||||
|
|
@ -671,8 +609,8 @@ async def _setup_quote_stream(
|
||||||
# making them mostly useless and explains why the scanner
|
# making them mostly useless and explains why the scanner
|
||||||
# is always slow XD
|
# is always slow XD
|
||||||
# '293', # Trade count for day
|
# '293', # Trade count for day
|
||||||
# '294', # Trade rate / minute
|
'294', # Trade rate / minute
|
||||||
# '295', # Vlm rate / minute
|
'295', # Vlm rate / minute
|
||||||
),
|
),
|
||||||
contract: Contract | None = None,
|
contract: Contract | None = None,
|
||||||
|
|
||||||
|
|
@ -856,6 +794,96 @@ def normalize(
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@async_lifo_cache()
|
||||||
|
async def get_mkt_info(
|
||||||
|
fqme: str,
|
||||||
|
|
||||||
|
proxy: MethodProxy | None = None,
|
||||||
|
|
||||||
|
) -> tuple[MktPair, ibis.ContractDetails]:
|
||||||
|
|
||||||
|
# XXX: we don't need to split off any fqme broker part?
|
||||||
|
# bs_fqme, _, broker = fqme.partition('.')
|
||||||
|
|
||||||
|
proxy: MethodProxy
|
||||||
|
if proxy is not None:
|
||||||
|
client_ctx = nullcontext(proxy)
|
||||||
|
else:
|
||||||
|
client_ctx = open_data_client
|
||||||
|
|
||||||
|
async with client_ctx as proxy:
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
con, # Contract
|
||||||
|
details, # ContractDetails
|
||||||
|
) = await proxy.get_sym_details(symbol=fqme)
|
||||||
|
except ConnectionError:
|
||||||
|
log.exception(f'Proxy is ded {proxy._aio_ns}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
# TODO: more consistent field translation
|
||||||
|
atype = _asset_type_map[con.secType]
|
||||||
|
|
||||||
|
if atype == 'commodity':
|
||||||
|
venue: str = 'cmdty'
|
||||||
|
else:
|
||||||
|
venue = con.primaryExchange or con.exchange
|
||||||
|
|
||||||
|
price_tick: Decimal = Decimal(str(details.minTick))
|
||||||
|
|
||||||
|
if atype == 'stock':
|
||||||
|
# XXX: GRRRR they don't support fractional share sizes for
|
||||||
|
# stocks from the API?!
|
||||||
|
# if con.secType == 'STK':
|
||||||
|
size_tick = Decimal('1')
|
||||||
|
else:
|
||||||
|
size_tick: Decimal = Decimal(str(details.minSize).rstrip('0'))
|
||||||
|
# |-> TODO: there is also the Contract.sizeIncrement, bt wtf is it?
|
||||||
|
|
||||||
|
# NOTE: this is duplicate from the .broker.norm_trade_records()
|
||||||
|
# routine, we should factor all this parsing somewhere..
|
||||||
|
expiry_str = str(con.lastTradeDateOrContractMonth)
|
||||||
|
# if expiry:
|
||||||
|
# expiry_str: str = str(pendulum.parse(
|
||||||
|
# str(expiry).strip(' ')
|
||||||
|
# ))
|
||||||
|
|
||||||
|
# TODO: currently we can't pass the fiat src asset because
|
||||||
|
# then we'll get a `MNQUSD` request for history data..
|
||||||
|
# we need to figure out how we're going to handle this (later?)
|
||||||
|
# but likely we want all backends to eventually handle
|
||||||
|
# ``dst/src.venue.`` style !?
|
||||||
|
src: str | Asset = ''
|
||||||
|
if atype == 'forex':
|
||||||
|
src = Asset(
|
||||||
|
name=str(con.currency),
|
||||||
|
atype='fiat',
|
||||||
|
tx_tick=Decimal('0.01'), # right?
|
||||||
|
)
|
||||||
|
|
||||||
|
mkt = MktPair(
|
||||||
|
dst=Asset(
|
||||||
|
name=con.symbol.lower(),
|
||||||
|
atype=atype,
|
||||||
|
tx_tick=size_tick,
|
||||||
|
),
|
||||||
|
src=src,
|
||||||
|
|
||||||
|
price_tick=price_tick,
|
||||||
|
size_tick=size_tick,
|
||||||
|
|
||||||
|
bs_mktid=str(con.conId),
|
||||||
|
venue=str(venue),
|
||||||
|
expiry=expiry_str,
|
||||||
|
broker='ib',
|
||||||
|
|
||||||
|
# TODO: options contract info as str?
|
||||||
|
# contract_info=<optionsdetails>
|
||||||
|
)
|
||||||
|
|
||||||
|
return mkt, details
|
||||||
|
|
||||||
|
|
||||||
async def stream_quotes(
|
async def stream_quotes(
|
||||||
|
|
||||||
send_chan: trio.abc.SendChannel,
|
send_chan: trio.abc.SendChannel,
|
||||||
|
|
@ -883,10 +911,7 @@ async def stream_quotes(
|
||||||
proxy: MethodProxy
|
proxy: MethodProxy
|
||||||
mkt: MktPair
|
mkt: MktPair
|
||||||
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
|
||||||
|
|
@ -895,7 +920,7 @@ async def stream_quotes(
|
||||||
init_msg = FeedInit(mkt_info=mkt)
|
init_msg = FeedInit(mkt_info=mkt)
|
||||||
|
|
||||||
if mkt.dst.atype in {
|
if mkt.dst.atype in {
|
||||||
'fiat',
|
'forex',
|
||||||
'index',
|
'index',
|
||||||
'commodity',
|
'commodity',
|
||||||
}:
|
}:
|
||||||
|
|
@ -906,50 +931,27 @@ async def stream_quotes(
|
||||||
init_msgs.append(init_msg)
|
init_msgs.append(init_msg)
|
||||||
|
|
||||||
con: Contract = details.contract
|
con: Contract = details.contract
|
||||||
first_ticker: Ticker | None = None
|
first_ticker: Ticker = await proxy.get_quote(contract=con)
|
||||||
with trio.move_on_after(1):
|
|
||||||
first_ticker: Ticker = await proxy.get_quote(
|
|
||||||
contract=con,
|
|
||||||
raise_on_timeout=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if first_ticker:
|
|
||||||
first_quote: dict = normalize(first_ticker)
|
first_quote: dict = normalize(first_ticker)
|
||||||
|
|
||||||
# TODO: we need a stack-oriented log levels filters for
|
log.warning(f'FIRST QUOTE: {first_quote}')
|
||||||
# this!
|
|
||||||
# log.info(message, filter={'stack': 'live_feed'}) ?
|
|
||||||
log.runtime(
|
|
||||||
'Rxed init quote:\n\n'
|
|
||||||
f'{pformat(first_quote)}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE: it might be outside regular trading hours for
|
# TODO: we should instead spawn a task that waits on a feed to start
|
||||||
# assets with "standard venue operating hours" so we
|
# and let it wait indefinitely..instead of this hard coded stuff.
|
||||||
# only "pretend the feed is live" when the dst asset
|
with trio.move_on_after(1):
|
||||||
# type is NOT within the NON-NORMAL-venue set: aka not
|
first_ticker = await proxy.get_quote(contract=con)
|
||||||
# commodities, forex or crypto currencies which CAN
|
|
||||||
# always return a NaN on a snap quote request during
|
# it might be outside regular trading hours so see if we can at
|
||||||
# normal venue hours. In the case of a closed venue
|
# least grab history.
|
||||||
# (equitiies, futes, bonds etc.) we at least try to
|
|
||||||
# grab the OHLC history.
|
|
||||||
if (
|
if (
|
||||||
first_ticker
|
isnan(first_ticker.last) # last quote price value is nan
|
||||||
and
|
|
||||||
isnan(first_ticker.last)
|
|
||||||
# SO, if the last quote price value is NaN we ONLY
|
|
||||||
# "pretend to do" `feed_is_live.set()` if it's a known
|
|
||||||
# dst asset venue with a lot of closed operating hours.
|
|
||||||
and mkt.dst.atype not in {
|
and mkt.dst.atype not in {
|
||||||
'commodity',
|
'commodity',
|
||||||
'fiat',
|
'forex',
|
||||||
'crypto',
|
'crypto',
|
||||||
}
|
}
|
||||||
):
|
):
|
||||||
task_status.started((
|
task_status.started((init_msgs, first_quote))
|
||||||
init_msgs,
|
|
||||||
first_quote,
|
|
||||||
))
|
|
||||||
|
|
||||||
# it's not really live but this will unblock
|
# it's not really live but this will unblock
|
||||||
# the brokerd feed task to tell the ui to update?
|
# the brokerd feed task to tell the ui to update?
|
||||||
|
|
@ -959,28 +961,6 @@ async def stream_quotes(
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
return # we never expect feed to come up?
|
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
|
|
||||||
# hard coded stuff.
|
|
||||||
# async def wait_for_first_quote():
|
|
||||||
# with trio.CancelScope() as cs:
|
|
||||||
|
|
||||||
# XXX: MUST acquire a ticker + first quote before starting
|
|
||||||
# the live quotes loop!
|
|
||||||
# with trio.move_on_after(1):
|
|
||||||
first_ticker = await proxy.get_quote(
|
|
||||||
contract=con,
|
|
||||||
raise_on_timeout=True,
|
|
||||||
)
|
|
||||||
first_quote: dict = normalize(first_ticker)
|
|
||||||
|
|
||||||
# TODO: we need a stack-oriented log levels filters for
|
|
||||||
# this!
|
|
||||||
# log.info(message, filter={'stack': 'live_feed'}) ?
|
|
||||||
log.runtime(
|
|
||||||
'Rxed init quote:\n'
|
|
||||||
f'{pformat(first_quote)}'
|
|
||||||
)
|
|
||||||
cs: trio.CancelScope | None = None
|
cs: trio.CancelScope | None = None
|
||||||
startup: bool = True
|
startup: bool = True
|
||||||
while (
|
while (
|
||||||
|
|
@ -1001,11 +981,8 @@ async def stream_quotes(
|
||||||
|
|
||||||
# only on first entry at feed boot up
|
# only on first entry at feed boot up
|
||||||
if startup:
|
if startup:
|
||||||
startup: bool = False
|
startup = False
|
||||||
task_status.started((
|
task_status.started((init_msgs, first_quote))
|
||||||
init_msgs,
|
|
||||||
first_quote,
|
|
||||||
))
|
|
||||||
|
|
||||||
# start a stream restarter task which monitors the
|
# start a stream restarter task which monitors the
|
||||||
# data feed event.
|
# data feed event.
|
||||||
|
|
@ -1029,7 +1006,7 @@ async def stream_quotes(
|
||||||
|
|
||||||
# generally speaking these feeds don't
|
# generally speaking these feeds don't
|
||||||
# include vlm data.
|
# include vlm data.
|
||||||
atype: str = mkt.dst.atype
|
atype = mkt.dst.atype
|
||||||
log.info(
|
log.info(
|
||||||
f'No-vlm {mkt.fqme}@{atype}, skipping quote poll'
|
f'No-vlm {mkt.fqme}@{atype}, skipping quote poll'
|
||||||
)
|
)
|
||||||
|
|
@ -1065,8 +1042,7 @@ async def stream_quotes(
|
||||||
quote = normalize(ticker)
|
quote = normalize(ticker)
|
||||||
log.debug(f"First ticker received {quote}")
|
log.debug(f"First ticker received {quote}")
|
||||||
|
|
||||||
# tell data-layer spawner-caller that live
|
# tell caller quotes are now coming in live
|
||||||
# quotes are now streaming.
|
|
||||||
feed_is_live.set()
|
feed_is_live.set()
|
||||||
|
|
||||||
# last = time.time()
|
# last = time.time()
|
||||||
|
|
@ -1078,3 +1054,141 @@ async def stream_quotes(
|
||||||
# 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()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def open_symbol_search(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# TODO: load user defined symbol set locally for fast search?
|
||||||
|
await ctx.started({})
|
||||||
|
|
||||||
|
async with (
|
||||||
|
open_client_proxies() as (proxies, _),
|
||||||
|
open_data_client() as data_proxy,
|
||||||
|
):
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
# select a non-history client for symbol search to lighten
|
||||||
|
# the load in the main data node.
|
||||||
|
proxy = data_proxy
|
||||||
|
for name, proxy in proxies.items():
|
||||||
|
if proxy is data_proxy:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
ib_client = proxy._aio_ns.ib
|
||||||
|
log.info(f'Using {ib_client} for symbol search')
|
||||||
|
|
||||||
|
last = time.time()
|
||||||
|
async for pattern in stream:
|
||||||
|
log.info(f'received {pattern}')
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# this causes tractor hang...
|
||||||
|
# assert 0
|
||||||
|
|
||||||
|
assert pattern, 'IB can not accept blank search pattern'
|
||||||
|
|
||||||
|
# throttle search requests to no faster then 1Hz
|
||||||
|
diff = now - last
|
||||||
|
if diff < 1.0:
|
||||||
|
log.debug('throttle sleeping')
|
||||||
|
await trio.sleep(diff)
|
||||||
|
try:
|
||||||
|
pattern = stream.receive_nowait()
|
||||||
|
except trio.WouldBlock:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if (
|
||||||
|
not pattern
|
||||||
|
or pattern.isspace()
|
||||||
|
|
||||||
|
# XXX: not sure if this is a bad assumption but it
|
||||||
|
# seems to make search snappier?
|
||||||
|
or len(pattern) < 1
|
||||||
|
):
|
||||||
|
log.warning('empty pattern received, skipping..')
|
||||||
|
|
||||||
|
# TODO: *BUG* if nothing is returned here the client
|
||||||
|
# side will cache a null set result and not showing
|
||||||
|
# anything to the use on re-searches when this query
|
||||||
|
# timed out. We probably need a special "timeout" msg
|
||||||
|
# or something...
|
||||||
|
|
||||||
|
# XXX: this unblocks the far end search task which may
|
||||||
|
# hold up a multi-search nursery block
|
||||||
|
await stream.send({})
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.info(f'searching for {pattern}')
|
||||||
|
|
||||||
|
last = time.time()
|
||||||
|
|
||||||
|
# async batch search using api stocks endpoint and module
|
||||||
|
# defined adhoc symbol set.
|
||||||
|
stock_results = []
|
||||||
|
|
||||||
|
async def stash_results(target: Awaitable[list]):
|
||||||
|
try:
|
||||||
|
results = await target
|
||||||
|
except tractor.trionics.Lagged:
|
||||||
|
print("IB SYM-SEARCH OVERRUN?!?")
|
||||||
|
return
|
||||||
|
|
||||||
|
stock_results.extend(results)
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
with trio.move_on_after(3) as cs:
|
||||||
|
async with trio.open_nursery() as sn:
|
||||||
|
sn.start_soon(
|
||||||
|
stash_results,
|
||||||
|
proxy.search_symbols(
|
||||||
|
pattern=pattern,
|
||||||
|
upto=5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# trigger async request
|
||||||
|
await trio.sleep(0)
|
||||||
|
|
||||||
|
if cs.cancelled_caught:
|
||||||
|
log.warning(
|
||||||
|
f'Search timeout? {proxy._aio_ns.ib.client}'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# # match against our ad-hoc set immediately
|
||||||
|
# adhoc_matches = fuzzy.extractBests(
|
||||||
|
# pattern,
|
||||||
|
# list(_adhoc_futes_set),
|
||||||
|
# score_cutoff=90,
|
||||||
|
# )
|
||||||
|
# log.info(f'fuzzy matched adhocs: {adhoc_matches}')
|
||||||
|
# adhoc_match_results = {}
|
||||||
|
# if adhoc_matches:
|
||||||
|
# # TODO: do we need to pull contract details?
|
||||||
|
# adhoc_match_results = {i[0]: {} for i in
|
||||||
|
# adhoc_matches}
|
||||||
|
|
||||||
|
log.debug(f'fuzzy matching stocks {stock_results}')
|
||||||
|
stock_matches = fuzzy.extractBests(
|
||||||
|
pattern,
|
||||||
|
stock_results,
|
||||||
|
score_cutoff=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# matches = adhoc_match_results | {
|
||||||
|
matches = {
|
||||||
|
item[0]: {} for item in stock_matches
|
||||||
|
}
|
||||||
|
# TODO: we used to deliver contract details
|
||||||
|
# {item[2]: item[0] for item in stock_matches}
|
||||||
|
|
||||||
|
log.debug(f"sending matches: {matches.keys()}")
|
||||||
|
await stream.send(matches)
|
||||||
|
|
|
||||||
|
|
@ -18,328 +18,135 @@
|
||||||
Trade transaction accounting and normalization.
|
Trade transaction accounting and normalization.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
|
||||||
from bisect import insort
|
from bisect import insort
|
||||||
from dataclasses import asdict
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from functools import partial
|
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
from pendulum import (
|
import pendulum
|
||||||
DateTime,
|
|
||||||
parse,
|
|
||||||
from_timestamp,
|
|
||||||
)
|
|
||||||
from ib_insync import (
|
|
||||||
Contract,
|
|
||||||
Commodity,
|
|
||||||
Fill,
|
|
||||||
Execution,
|
|
||||||
CommissionReport,
|
|
||||||
)
|
|
||||||
|
|
||||||
from piker.types import Struct
|
|
||||||
from piker.data import (
|
|
||||||
SymbologyCache,
|
|
||||||
)
|
|
||||||
from piker.accounting import (
|
from piker.accounting import (
|
||||||
Asset,
|
|
||||||
dec_digits,
|
dec_digits,
|
||||||
digits_to_dec,
|
digits_to_dec,
|
||||||
Transaction,
|
Transaction,
|
||||||
MktPair,
|
MktPair,
|
||||||
iter_by_dt,
|
|
||||||
)
|
)
|
||||||
from ._flex_reports import parse_flex_dt
|
from ._flex_reports import parse_flex_dt
|
||||||
from ._util import log
|
from ._util import log
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .api import (
|
|
||||||
Client,
|
|
||||||
MethodProxy,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def norm_trade_records(
|
||||||
|
ledger: dict[str, Any],
|
||||||
|
|
||||||
tx_sort: Callable = partial(
|
) -> dict[str, Transaction]:
|
||||||
iter_by_dt,
|
'''
|
||||||
parsers={
|
Normalize a flex report or API retrieved executions
|
||||||
'dateTime': parse_flex_dt,
|
ledger into our standard record format.
|
||||||
'datetime': parse,
|
|
||||||
|
|
||||||
# XXX: for some some fucking 2022 and
|
'''
|
||||||
# back options records.. f@#$ me..
|
records: list[Transaction] = []
|
||||||
'date': parse,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
for tid, record in ledger.items():
|
||||||
|
conid = record.get('conId') or record['conid']
|
||||||
|
comms = record.get('commission')
|
||||||
|
if comms is None:
|
||||||
|
comms = -1*record['ibCommission']
|
||||||
|
|
||||||
def norm_trade(
|
price = record.get('price') or record['tradePrice']
|
||||||
tid: str,
|
|
||||||
record: dict[str, Any],
|
|
||||||
|
|
||||||
# this is the dict that was returned from
|
|
||||||
# `Client.get_mkt_pairs()` and when running offline ledger
|
|
||||||
# processing from `.accounting`, this will be the table loaded
|
|
||||||
# into `SymbologyCache.pairs`.
|
|
||||||
pairs: dict[str, Struct],
|
|
||||||
symcache: SymbologyCache | None = None,
|
|
||||||
|
|
||||||
) -> Transaction | None:
|
|
||||||
|
|
||||||
conid: int = str(record.get('conId') or record['conid'])
|
|
||||||
bs_mktid: str = str(conid)
|
|
||||||
|
|
||||||
# NOTE: sometimes weird records (like BTTX?)
|
|
||||||
# have no field for this?
|
|
||||||
comms: float = -1 * (
|
|
||||||
record.get('commission')
|
|
||||||
or record.get('ibCommission')
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
if not comms:
|
|
||||||
log.warning(
|
|
||||||
'No commissions found for record?\n'
|
|
||||||
f'{pformat(record)}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
price: float = (
|
|
||||||
record.get('price')
|
|
||||||
or record.get('tradePrice')
|
|
||||||
)
|
|
||||||
if price is None:
|
|
||||||
log.warning(
|
|
||||||
'No `price` field found in record?\n'
|
|
||||||
'Skipping normalization..\n'
|
|
||||||
f'{pformat(record)}\n'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# the api doesn't do the -/+ on the quantity for you but flex
|
# the api doesn't do the -/+ on the quantity for you but flex
|
||||||
# records do.. are you fucking serious ib...!?
|
# records do.. are you fucking serious ib...!?
|
||||||
size: float|int = (
|
size = record.get('quantity') or record['shares'] * {
|
||||||
record.get('quantity')
|
|
||||||
or record['shares']
|
|
||||||
) * {
|
|
||||||
'BOT': 1,
|
'BOT': 1,
|
||||||
'SLD': -1,
|
'SLD': -1,
|
||||||
}[record['side']]
|
}[record['side']]
|
||||||
|
|
||||||
symbol: str = record['symbol']
|
exch = record['exchange']
|
||||||
exch: str = (
|
lexch = record.get('listingExchange')
|
||||||
record.get('listingExchange')
|
|
||||||
or record.get('primaryExchange')
|
|
||||||
or record['exchange']
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE: remove null values since `tomlkit` can't serialize
|
# NOTE: remove null values since `tomlkit` can't serialize
|
||||||
# them to file.
|
# them to file.
|
||||||
if dnc := record.pop('deltaNeutralContract', None):
|
dnc = record.pop('deltaNeutralContract', False)
|
||||||
|
if dnc is not None:
|
||||||
record['deltaNeutralContract'] = dnc
|
record['deltaNeutralContract'] = dnc
|
||||||
|
|
||||||
|
suffix = lexch or exch
|
||||||
|
symbol = record['symbol']
|
||||||
|
|
||||||
# likely an opts contract record from a flex report..
|
# likely an opts contract record from a flex report..
|
||||||
# TODO: no idea how to parse ^ the strike part from flex..
|
# TODO: no idea how to parse ^ the strike part from flex..
|
||||||
# (00010000 any, or 00007500 tsla, ..)
|
# (00010000 any, or 00007500 tsla, ..)
|
||||||
# we probably must do the contract lookup for this?
|
# we probably must do the contract lookup for this?
|
||||||
if (
|
if ' ' in symbol or '--' in exch:
|
||||||
' ' in symbol
|
|
||||||
or '--' in exch
|
|
||||||
):
|
|
||||||
underlying, _, tail = symbol.partition(' ')
|
underlying, _, tail = symbol.partition(' ')
|
||||||
exch: str = 'opt'
|
suffix = exch = 'opt'
|
||||||
expiry: str = tail[:6]
|
expiry = tail[:6]
|
||||||
# otype = tail[6]
|
# otype = tail[6]
|
||||||
# strike = tail[7:]
|
# strike = tail[7:]
|
||||||
|
|
||||||
log.warning(
|
print(f'skipping opts contract {symbol}')
|
||||||
f'Skipping option contract -> NO SUPPORT YET!\n'
|
continue
|
||||||
f'{symbol}\n'
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# timestamping is way different in API records
|
# timestamping is way different in API records
|
||||||
dtstr: str = record.get('datetime')
|
dtstr = record.get('datetime')
|
||||||
date: str = record.get('date')
|
date = record.get('date')
|
||||||
flex_dtstr: str = record.get('dateTime')
|
flex_dtstr = record.get('dateTime')
|
||||||
|
|
||||||
if dtstr or date:
|
if dtstr or date:
|
||||||
dt: DateTime = parse(dtstr or date)
|
dt = pendulum.parse(dtstr or date)
|
||||||
|
|
||||||
elif flex_dtstr:
|
elif flex_dtstr:
|
||||||
# probably a flex record with a wonky non-std timestamp..
|
# probably a flex record with a wonky non-std timestamp..
|
||||||
dt: DateTime = parse_flex_dt(record['dateTime'])
|
dt = parse_flex_dt(record['dateTime'])
|
||||||
|
|
||||||
# special handling of symbol extraction from
|
# special handling of symbol extraction from
|
||||||
# flex records using some ad-hoc schema parsing.
|
# flex records using some ad-hoc schema parsing.
|
||||||
asset_type: str = (
|
asset_type: str = record.get(
|
||||||
record.get('assetCategory')
|
'assetCategory'
|
||||||
or record.get('secType')
|
) or record.get('secType', 'STK')
|
||||||
or 'STK'
|
|
||||||
)
|
|
||||||
|
|
||||||
if (expiry := (
|
# TODO: XXX: WOA this is kinda hacky.. probably
|
||||||
|
# should figure out the correct future pair key more
|
||||||
|
# explicitly and consistently?
|
||||||
|
if asset_type == 'FUT':
|
||||||
|
# (flex) ledger entries don't have any simple 3-char key?
|
||||||
|
symbol = record['symbol'][:3]
|
||||||
|
asset_type: str = 'future'
|
||||||
|
|
||||||
|
elif asset_type == 'STK':
|
||||||
|
asset_type: str = 'stock'
|
||||||
|
|
||||||
|
# try to build out piker fqme from record.
|
||||||
|
expiry = (
|
||||||
record.get('lastTradeDateOrContractMonth')
|
record.get('lastTradeDateOrContractMonth')
|
||||||
or record.get('expiry')
|
or record.get('expiry')
|
||||||
)
|
)
|
||||||
):
|
|
||||||
expiry: str = str(expiry).strip(' ')
|
|
||||||
# NOTE: we directly use the (simple and usually short)
|
|
||||||
# date-string expiry token when packing the `MktPair`
|
|
||||||
# since we want the fqme to contain *that* token.
|
|
||||||
# It might make sense later to instead parse and then
|
|
||||||
# render different output str format(s) for this same
|
|
||||||
# purpose depending on asset-type-market down the road.
|
|
||||||
# Eg. for derivs we use the short token only for fqme
|
|
||||||
# but use the isoformat('T') for transactions and
|
|
||||||
# account file position entries?
|
|
||||||
# dt_str: str = pendulum.parse(expiry).isoformat('T')
|
|
||||||
|
|
||||||
# XXX: pretty much all legacy market assets have a fiat
|
if expiry:
|
||||||
# currency (denomination) determined by their venue.
|
expiry = str(expiry).strip(' ')
|
||||||
currency: str = record['currency']
|
suffix = f'{exch}.{expiry}'
|
||||||
src = Asset(
|
expiry = pendulum.parse(expiry)
|
||||||
name=currency.lower(),
|
|
||||||
atype='fiat',
|
|
||||||
tx_tick=Decimal('0.01'),
|
|
||||||
)
|
|
||||||
|
|
||||||
match asset_type:
|
|
||||||
case 'FUT':
|
|
||||||
# XXX (flex) ledger entries don't necessarily have any
|
|
||||||
# simple 3-char key.. sometimes the .symbol is some
|
|
||||||
# weird internal key that we probably don't want in the
|
|
||||||
# .fqme => we should probably just wrap `Contract` to
|
|
||||||
# this like we do other crypto$ backends XD
|
|
||||||
|
|
||||||
# NOTE: at least older FLEX records should have
|
|
||||||
# this field.. no idea about API entries..
|
|
||||||
local_symbol: str | None = record.get('localSymbol')
|
|
||||||
underlying_key: str = record.get('underlyingSymbol')
|
|
||||||
descr: str | None = record.get('description')
|
|
||||||
|
|
||||||
if (
|
|
||||||
not (
|
|
||||||
local_symbol
|
|
||||||
and symbol in local_symbol
|
|
||||||
)
|
|
||||||
and (
|
|
||||||
descr
|
|
||||||
and symbol not in descr
|
|
||||||
)
|
|
||||||
):
|
|
||||||
con_key, exp_str = descr.split(' ')
|
|
||||||
symbol: str = underlying_key or con_key
|
|
||||||
|
|
||||||
dst = Asset(
|
|
||||||
name=symbol.lower(),
|
|
||||||
atype='future',
|
|
||||||
tx_tick=Decimal('1'),
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'STK':
|
|
||||||
dst = Asset(
|
|
||||||
name=symbol.lower(),
|
|
||||||
atype='stock',
|
|
||||||
tx_tick=Decimal('1'),
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'CASH':
|
|
||||||
if currency not in symbol:
|
|
||||||
# likely a dict-casted `Forex` contract which
|
|
||||||
# has .symbol as the dst and .currency as the
|
|
||||||
# src.
|
|
||||||
name: str = symbol.lower()
|
|
||||||
else:
|
|
||||||
# likely a flex-report record which puts
|
|
||||||
# EUR.USD as the symbol field and just USD in
|
|
||||||
# the currency field.
|
|
||||||
name: str = symbol.lower().replace(f'.{src.name}', '')
|
|
||||||
|
|
||||||
dst = Asset(
|
|
||||||
name=name,
|
|
||||||
atype='fiat',
|
|
||||||
tx_tick=Decimal('0.01'),
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'OPT':
|
|
||||||
dst = Asset(
|
|
||||||
name=symbol.lower(),
|
|
||||||
atype='option',
|
|
||||||
tx_tick=Decimal('1'),
|
|
||||||
|
|
||||||
# TODO: we should probably always cast to the
|
|
||||||
# `Contract` instance then dict-serialize that for
|
|
||||||
# the `.info` field!
|
|
||||||
# info=asdict(Option()),
|
|
||||||
)
|
|
||||||
|
|
||||||
case 'CMDTY':
|
|
||||||
from .symbols import _adhoc_symbol_map
|
|
||||||
con_kwargs, _ = _adhoc_symbol_map[symbol.upper()]
|
|
||||||
dst = Asset(
|
|
||||||
name=symbol.lower(),
|
|
||||||
atype='commodity',
|
|
||||||
tx_tick=Decimal('1'),
|
|
||||||
info=asdict(Commodity(**con_kwargs)),
|
|
||||||
)
|
|
||||||
|
|
||||||
# try to build out piker fqme from record.
|
|
||||||
# src: str = record['currency']
|
# src: str = record['currency']
|
||||||
price_tick: Decimal = digits_to_dec(dec_digits(price))
|
price_tick: Decimal = digits_to_dec(dec_digits(price))
|
||||||
|
|
||||||
# NOTE: can't serlialize `tomlkit.String` so cast to native
|
pair = MktPair.from_fqme(
|
||||||
atype: str = str(dst.atype)
|
fqme=f'{symbol}.{suffix}.ib',
|
||||||
|
bs_mktid=str(conid),
|
||||||
# if not (mkt := symcache.mktmaps.get(bs_mktid)):
|
_atype=str(asset_type), # XXX: can't serlialize `tomlkit.String`
|
||||||
mkt = MktPair(
|
|
||||||
bs_mktid=bs_mktid,
|
|
||||||
dst=dst,
|
|
||||||
|
|
||||||
price_tick=price_tick,
|
price_tick=price_tick,
|
||||||
# NOTE: for "legacy" assets, volume is normally discreet, not
|
# NOTE: for "legacy" assets, volume is normally discreet, not
|
||||||
# a float, but we keep a digit in case the suitz decide
|
# a float, but we keep a digit in case the suitz decide
|
||||||
# to get crazy and change it; we'll be kinda ready
|
# to get crazy and change it; we'll be kinda ready
|
||||||
# schema-wise..
|
# schema-wise..
|
||||||
size_tick=Decimal('1'),
|
size_tick='1',
|
||||||
|
|
||||||
src=src, # XXX: normally always a fiat
|
|
||||||
|
|
||||||
_atype=atype,
|
|
||||||
|
|
||||||
venue=exch,
|
|
||||||
expiry=expiry,
|
|
||||||
broker='ib',
|
|
||||||
|
|
||||||
_fqme_without_src=(atype != 'fiat'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fqme: str = mkt.fqme
|
fqme = pair.fqme
|
||||||
|
|
||||||
# XXX: if passed in, we fill out the symcache ad-hoc in order
|
|
||||||
# to make downstream accounting work..
|
|
||||||
if symcache is not None:
|
|
||||||
orig_mkt: MktPair | None = symcache.mktmaps.get(bs_mktid)
|
|
||||||
if (
|
|
||||||
orig_mkt
|
|
||||||
and orig_mkt.fqme != mkt.fqme
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
# print(
|
|
||||||
f'Contracts with common `conId`: {bs_mktid} mismatch..\n'
|
|
||||||
f'{orig_mkt.fqme} -> {mkt.fqme}\n'
|
|
||||||
# 'with DIFF:\n'
|
|
||||||
# f'{mkt - orig_mkt}'
|
|
||||||
)
|
|
||||||
|
|
||||||
symcache.mktmaps[bs_mktid] = mkt
|
|
||||||
symcache.mktmaps[fqme] = mkt
|
|
||||||
symcache.assets[src.name] = src
|
|
||||||
symcache.assets[dst.name] = dst
|
|
||||||
|
|
||||||
# NOTE: for flex records the normal fields for defining an fqme
|
# NOTE: for flex records the normal fields for defining an fqme
|
||||||
# sometimes won't be available so we rely on two approaches for
|
# sometimes won't be available so we rely on two approaches for
|
||||||
|
|
@ -351,8 +158,11 @@ def norm_trade(
|
||||||
# should already have entries if the pps are still open, in
|
# should already have entries if the pps are still open, in
|
||||||
# which case, we can pull the fqme from that table (see
|
# which case, we can pull the fqme from that table (see
|
||||||
# `trades_dialogue()` above).
|
# `trades_dialogue()` above).
|
||||||
return Transaction(
|
insort(
|
||||||
|
records,
|
||||||
|
Transaction(
|
||||||
fqme=fqme,
|
fqme=fqme,
|
||||||
|
sym=pair,
|
||||||
tid=tid,
|
tid=tid,
|
||||||
size=size,
|
size=size,
|
||||||
price=price,
|
price=price,
|
||||||
|
|
@ -360,40 +170,7 @@ def norm_trade(
|
||||||
dt=dt,
|
dt=dt,
|
||||||
expiry=expiry,
|
expiry=expiry,
|
||||||
bs_mktid=str(conid),
|
bs_mktid=str(conid),
|
||||||
)
|
),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def norm_trade_records(
|
|
||||||
ledger: dict[str, Any],
|
|
||||||
symcache: SymbologyCache | None = None,
|
|
||||||
|
|
||||||
) -> dict[str, Transaction]:
|
|
||||||
'''
|
|
||||||
Normalize (xml) flex-report or (recent) API trade records into
|
|
||||||
our ledger format with parsing for `MktPair` and `Asset`
|
|
||||||
extraction to fill in the `Transaction.sys: MktPair` field.
|
|
||||||
|
|
||||||
'''
|
|
||||||
records: list[Transaction] = []
|
|
||||||
for tid, record in ledger.items():
|
|
||||||
|
|
||||||
txn = norm_trade(
|
|
||||||
tid,
|
|
||||||
record,
|
|
||||||
|
|
||||||
# NOTE: currently no symcache support
|
|
||||||
pairs={},
|
|
||||||
symcache=symcache,
|
|
||||||
)
|
|
||||||
|
|
||||||
if txn is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# inject txns sorted by datetime
|
|
||||||
insort(
|
|
||||||
records,
|
|
||||||
txn,
|
|
||||||
key=lambda t: t.dt
|
key=lambda t: t.dt
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -402,49 +179,50 @@ def norm_trade_records(
|
||||||
|
|
||||||
def api_trades_to_ledger_entries(
|
def api_trades_to_ledger_entries(
|
||||||
accounts: bidict[str, str],
|
accounts: bidict[str, str],
|
||||||
fills: list[Fill],
|
|
||||||
|
|
||||||
) -> dict[str, dict]:
|
# TODO: maybe we should just be passing through the
|
||||||
|
# ``ib_insync.order.Trade`` instance directly here
|
||||||
|
# instead of pre-casting to dicts?
|
||||||
|
trade_entries: list[dict],
|
||||||
|
|
||||||
|
) -> dict:
|
||||||
'''
|
'''
|
||||||
Convert API execution objects entry objects into
|
Convert API execution objects entry objects into ``dict`` form,
|
||||||
flattened-``dict`` form, pretty much straight up without
|
pretty much straight up without modification except add
|
||||||
modification except add a `pydatetime` field from the parsed
|
a `pydatetime` field from the parsed timestamp.
|
||||||
timestamp so that on write
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
trades_by_account: dict[str, dict] = {}
|
trades_by_account = {}
|
||||||
for fill in fills:
|
for t in trade_entries:
|
||||||
|
# NOTE: example of schema we pull from the API client.
|
||||||
|
# {
|
||||||
|
# 'commissionReport': CommissionReport(...
|
||||||
|
# 'contract': {...
|
||||||
|
# 'execution': Execution(...
|
||||||
|
# 'time': 1654801166.0
|
||||||
|
# }
|
||||||
|
|
||||||
# NOTE: for the schema, see the defn for `Fill` which is
|
# flatten all sub-dicts and values into one top level entry.
|
||||||
# a `NamedTuple` subtype
|
entry = {}
|
||||||
fdict: dict = fill._asdict()
|
for section, val in t.items():
|
||||||
|
match section:
|
||||||
# flatten all (sub-)objects and convert to dicts.
|
|
||||||
# with values packed into one top level entry.
|
|
||||||
val: CommissionReport | Execution | Contract
|
|
||||||
txn_dict: dict[str, Any] = {}
|
|
||||||
for attr_name, val in fdict.items():
|
|
||||||
match attr_name:
|
|
||||||
# value is a `@dataclass` subtype
|
|
||||||
case 'contract' | 'execution' | 'commissionReport':
|
case 'contract' | 'execution' | 'commissionReport':
|
||||||
txn_dict.update(asdict(val))
|
# sub-dict cases
|
||||||
|
entry.update(val)
|
||||||
|
|
||||||
case 'time':
|
case 'time':
|
||||||
# ib has wack ns timestamps, or is that us?
|
# ib has wack ns timestamps, or is that us?
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# TODO: we can remove this case right since there's
|
|
||||||
# only 4 fields on a `Fill`?
|
|
||||||
case _:
|
case _:
|
||||||
txn_dict[attr_name] = val
|
entry[section] = val
|
||||||
|
|
||||||
tid = str(txn_dict['execId'])
|
tid = str(entry['execId'])
|
||||||
dt = from_timestamp(txn_dict['time'])
|
dt = pendulum.from_timestamp(entry['time'])
|
||||||
txn_dict['datetime'] = str(dt)
|
# TODO: why isn't this showing seconds in the str?
|
||||||
acctid = accounts[txn_dict['acctNumber']]
|
entry['pydatetime'] = dt
|
||||||
|
entry['datetime'] = str(dt)
|
||||||
# NOTE: only inserted (then later popped) for sorting below!
|
acctid = accounts[entry['acctNumber']]
|
||||||
txn_dict['pydatetime'] = dt
|
|
||||||
|
|
||||||
if not tid:
|
if not tid:
|
||||||
# this is likely some kind of internal adjustment
|
# this is likely some kind of internal adjustment
|
||||||
|
|
@ -455,18 +233,13 @@ def api_trades_to_ledger_entries(
|
||||||
# the user from the accounts window in TWS where they can
|
# the user from the accounts window in TWS where they can
|
||||||
# manually set the avg price and size:
|
# manually set the avg price and size:
|
||||||
# https://api.ibkr.com/lib/cstools/faq/web1/index.html#/tag/DTWS_ADJ_AVG_COST
|
# https://api.ibkr.com/lib/cstools/faq/web1/index.html#/tag/DTWS_ADJ_AVG_COST
|
||||||
log.warning(
|
log.warning(f'Skipping ID-less ledger entry:\n{pformat(entry)}')
|
||||||
'Skipping ID-less ledger txn_dict:\n'
|
|
||||||
f'{pformat(txn_dict)}'
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
trades_by_account.setdefault(
|
trades_by_account.setdefault(
|
||||||
acctid, {}
|
acctid, {}
|
||||||
)[tid] = txn_dict
|
)[tid] = entry
|
||||||
|
|
||||||
# TODO: maybe we should just bisect.insort() into a list of
|
|
||||||
# tuples and then return a dict of that?
|
|
||||||
# sort entries in output by python based datetime
|
# sort entries in output by python based datetime
|
||||||
for acctid in trades_by_account:
|
for acctid in trades_by_account:
|
||||||
trades_by_account[acctid] = dict(sorted(
|
trades_by_account[acctid] = dict(sorted(
|
||||||
|
|
@ -475,55 +248,3 @@ def api_trades_to_ledger_entries(
|
||||||
))
|
))
|
||||||
|
|
||||||
return trades_by_account
|
return trades_by_account
|
||||||
|
|
||||||
|
|
||||||
async def update_ledger_from_api_trades(
|
|
||||||
fills: list[Fill],
|
|
||||||
client: Client | MethodProxy,
|
|
||||||
accounts_def_inv: bidict[str, str],
|
|
||||||
|
|
||||||
# NOTE: provided for ad-hoc insertions "as transactions are
|
|
||||||
# processed" -> see `norm_trade()` signature requirements.
|
|
||||||
symcache: SymbologyCache | None = None,
|
|
||||||
|
|
||||||
) -> tuple[
|
|
||||||
dict[str, Transaction],
|
|
||||||
dict[str, dict],
|
|
||||||
]:
|
|
||||||
# XXX; ERRGGG..
|
|
||||||
# pack in the "primary/listing exchange" value from a
|
|
||||||
# contract lookup since it seems this isn't available by
|
|
||||||
# default from the `.fills()` method endpoint...
|
|
||||||
fill: Fill
|
|
||||||
for fill in fills:
|
|
||||||
con: Contract = fill.contract
|
|
||||||
conid: str = con.conId
|
|
||||||
pexch: str | None = con.primaryExchange
|
|
||||||
|
|
||||||
if not pexch:
|
|
||||||
cons = await client.get_con(conid=conid)
|
|
||||||
if cons:
|
|
||||||
con = cons[0]
|
|
||||||
pexch = con.primaryExchange or con.exchange
|
|
||||||
else:
|
|
||||||
# for futes it seems like the primary is always empty?
|
|
||||||
pexch: str = con.exchange
|
|
||||||
|
|
||||||
# pack in the ``Contract.secType``
|
|
||||||
# entry['asset_type'] = condict['secType']
|
|
||||||
|
|
||||||
entries: dict[str, dict] = api_trades_to_ledger_entries(
|
|
||||||
accounts_def_inv,
|
|
||||||
fills,
|
|
||||||
)
|
|
||||||
# normalize recent session's trades to the `Transaction` type
|
|
||||||
trans_by_acct: dict[str, dict[str, Transaction]] = {}
|
|
||||||
|
|
||||||
for acctid, trades_by_id in entries.items():
|
|
||||||
# normalize to transaction form
|
|
||||||
trans_by_acct[acctid] = norm_trade_records(
|
|
||||||
trades_by_id,
|
|
||||||
symcache=symcache,
|
|
||||||
)
|
|
||||||
|
|
||||||
return trans_by_acct, entries
|
|
||||||
|
|
|
||||||
|
|
@ -1,615 +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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Symbology search and normalization.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
from contextlib import (
|
|
||||||
nullcontext,
|
|
||||||
)
|
|
||||||
from decimal import Decimal
|
|
||||||
import time
|
|
||||||
from typing import (
|
|
||||||
Awaitable,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
|
|
||||||
from rapidfuzz import process as fuzzy
|
|
||||||
import ib_insync as ibis
|
|
||||||
import tractor
|
|
||||||
import trio
|
|
||||||
|
|
||||||
from piker.accounting import (
|
|
||||||
Asset,
|
|
||||||
MktPair,
|
|
||||||
unpack_fqme,
|
|
||||||
)
|
|
||||||
from piker._cacheables import (
|
|
||||||
async_lifo_cache,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ._util import (
|
|
||||||
log,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .api import (
|
|
||||||
MethodProxy,
|
|
||||||
Client,
|
|
||||||
)
|
|
||||||
|
|
||||||
_futes_venues = (
|
|
||||||
'GLOBEX',
|
|
||||||
'NYMEX',
|
|
||||||
'CME',
|
|
||||||
'CMECRYPTO',
|
|
||||||
'COMEX',
|
|
||||||
# 'CMDTY', # special name case..
|
|
||||||
'CBOT', # (treasury) yield futures
|
|
||||||
)
|
|
||||||
|
|
||||||
_adhoc_cmdty_set = {
|
|
||||||
# metals
|
|
||||||
# https://misc.interactivebrokers.com/cstools/contract_info/v3.10/index.php?action=Conid%20Info&wlId=IB&conid=69067924
|
|
||||||
'xauusd.cmdty', # london gold spot ^
|
|
||||||
'xagusd.cmdty', # silver spot
|
|
||||||
}
|
|
||||||
|
|
||||||
# NOTE: if you aren't seeing one of these symbol's futues contracts
|
|
||||||
# show up, it's likely the `.<venue>` part is wrong!
|
|
||||||
_adhoc_futes_set = {
|
|
||||||
|
|
||||||
# equities
|
|
||||||
'nq.cme',
|
|
||||||
'mnq.cme', # micro
|
|
||||||
|
|
||||||
'es.cme',
|
|
||||||
'mes.cme', # micro
|
|
||||||
|
|
||||||
# cypto$
|
|
||||||
'brr.cme',
|
|
||||||
'mbt.cme', # micro
|
|
||||||
'ethusdrr.cme',
|
|
||||||
|
|
||||||
# agriculture
|
|
||||||
'he.comex', # lean hogs
|
|
||||||
'le.comex', # live cattle (geezers)
|
|
||||||
'gf.comex', # feeder cattle (younguns)
|
|
||||||
|
|
||||||
# raw
|
|
||||||
'lb.comex', # random len lumber
|
|
||||||
|
|
||||||
'gc.comex',
|
|
||||||
'mgc.comex', # micro
|
|
||||||
|
|
||||||
# oil & gas
|
|
||||||
'cl.nymex',
|
|
||||||
|
|
||||||
'ni.comex', # silver futes
|
|
||||||
'qi.comex', # mini-silver futes
|
|
||||||
|
|
||||||
# treasury yields
|
|
||||||
# etfs by duration:
|
|
||||||
# SHY -> IEI -> IEF -> TLT
|
|
||||||
'zt.cbot', # 2y
|
|
||||||
'z3n.cbot', # 3y
|
|
||||||
'zf.cbot', # 5y
|
|
||||||
'zn.cbot', # 10y
|
|
||||||
'zb.cbot', # 30y
|
|
||||||
|
|
||||||
# (micros of above)
|
|
||||||
'2yy.cbot',
|
|
||||||
'5yy.cbot',
|
|
||||||
'10y.cbot',
|
|
||||||
'30y.cbot',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# taken from list here:
|
|
||||||
# https://www.interactivebrokers.com/en/trading/products-spot-currencies.php
|
|
||||||
_adhoc_fiat_set = set((
|
|
||||||
'USD, AED, AUD, CAD,'
|
|
||||||
'CHF, CNH, CZK, DKK,'
|
|
||||||
'EUR, GBP, HKD, HUF,'
|
|
||||||
'ILS, JPY, MXN, NOK,'
|
|
||||||
'NZD, PLN, RUB, SAR,'
|
|
||||||
'SEK, SGD, TRY, ZAR'
|
|
||||||
).split(' ,')
|
|
||||||
)
|
|
||||||
|
|
||||||
# manually discovered tick discrepancies,
|
|
||||||
# onl god knows how or why they'd cuck these up..
|
|
||||||
_adhoc_mkt_infos: dict[int | str, dict] = {
|
|
||||||
'vtgn.nasdaq': {'price_tick': Decimal('0.01')},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# map of symbols to contract ids
|
|
||||||
_adhoc_symbol_map = {
|
|
||||||
# https://misc.interactivebrokers.com/cstools/contract_info/v3.10/index.php?action=Conid%20Info&wlId=IB&conid=69067924
|
|
||||||
|
|
||||||
# NOTE: some cmdtys/metals don't have trade data like gold/usd:
|
|
||||||
# https://groups.io/g/twsapi/message/44174
|
|
||||||
'XAUUSD': ({'conId': 69067924}, {'whatToShow': 'MIDPOINT'}),
|
|
||||||
}
|
|
||||||
for qsn in _adhoc_futes_set:
|
|
||||||
sym, venue = qsn.split('.')
|
|
||||||
assert venue.upper() in _futes_venues, f'{venue}'
|
|
||||||
_adhoc_symbol_map[sym.upper()] = (
|
|
||||||
{'exchange': venue},
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# exchanges we don't support at the moment due to not knowing
|
|
||||||
# how to do symbol-contract lookup correctly likely due
|
|
||||||
# to not having the data feeds subscribed.
|
|
||||||
_exch_skip_list = {
|
|
||||||
|
|
||||||
'ASX', # aussie stocks
|
|
||||||
'MEXI', # mexican stocks
|
|
||||||
|
|
||||||
# no idea
|
|
||||||
'NSE',
|
|
||||||
'VALUE',
|
|
||||||
'FUNDSERV',
|
|
||||||
'SWB2',
|
|
||||||
'PSE',
|
|
||||||
'PHLX',
|
|
||||||
}
|
|
||||||
|
|
||||||
# optional search config the backend can register for
|
|
||||||
# it's symbol search handling (in this case we avoid
|
|
||||||
# accepting patterns before the kb has settled more then
|
|
||||||
# a quarter second).
|
|
||||||
_search_conf = {
|
|
||||||
'pause_period': 6 / 16,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
|
||||||
async def open_symbol_search(ctx: tractor.Context) -> None:
|
|
||||||
'''
|
|
||||||
Symbology search brokerd-endpoint.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from .api import open_client_proxies
|
|
||||||
from .feed import open_data_client
|
|
||||||
|
|
||||||
# TODO: load user defined symbol set locally for fast search?
|
|
||||||
await ctx.started({})
|
|
||||||
|
|
||||||
async with (
|
|
||||||
open_client_proxies() as (proxies, _),
|
|
||||||
open_data_client() as data_proxy,
|
|
||||||
):
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
|
|
||||||
# select a non-history client for symbol search to lighten
|
|
||||||
# the load in the main data node.
|
|
||||||
proxy = data_proxy
|
|
||||||
for name, proxy in proxies.items():
|
|
||||||
if proxy is data_proxy:
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
|
|
||||||
ib_client = proxy._aio_ns.ib
|
|
||||||
log.info(
|
|
||||||
f'Using API client for symbol-search\n'
|
|
||||||
f'{ib_client}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
last = time.time()
|
|
||||||
async for pattern in stream:
|
|
||||||
log.info(f'received {pattern}')
|
|
||||||
now: float = time.time()
|
|
||||||
|
|
||||||
# this causes tractor hang...
|
|
||||||
# assert 0
|
|
||||||
|
|
||||||
assert pattern, 'IB can not accept blank search pattern'
|
|
||||||
|
|
||||||
# throttle search requests to no faster then 1Hz
|
|
||||||
diff = now - last
|
|
||||||
if diff < 1.0:
|
|
||||||
log.debug('throttle sleeping')
|
|
||||||
await trio.sleep(diff)
|
|
||||||
try:
|
|
||||||
pattern = stream.receive_nowait()
|
|
||||||
except trio.WouldBlock:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if (
|
|
||||||
not pattern
|
|
||||||
or pattern.isspace()
|
|
||||||
|
|
||||||
# XXX: not sure if this is a bad assumption but it
|
|
||||||
# seems to make search snappier?
|
|
||||||
or len(pattern) < 1
|
|
||||||
):
|
|
||||||
log.warning('empty pattern received, skipping..')
|
|
||||||
|
|
||||||
# TODO: *BUG* if nothing is returned here the client
|
|
||||||
# side will cache a null set result and not showing
|
|
||||||
# anything to the use on re-searches when this query
|
|
||||||
# timed out. We probably need a special "timeout" msg
|
|
||||||
# or something...
|
|
||||||
|
|
||||||
# XXX: this unblocks the far end search task which may
|
|
||||||
# hold up a multi-search nursery block
|
|
||||||
await stream.send({})
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.info(f'searching for {pattern}')
|
|
||||||
|
|
||||||
last = time.time()
|
|
||||||
|
|
||||||
# async batch search using api stocks endpoint and module
|
|
||||||
# defined adhoc symbol set.
|
|
||||||
stock_results = []
|
|
||||||
|
|
||||||
async def extend_results(
|
|
||||||
target: Awaitable[list]
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
results = await target
|
|
||||||
except tractor.trionics.Lagged:
|
|
||||||
print("IB SYM-SEARCH OVERRUN?!?")
|
|
||||||
return
|
|
||||||
|
|
||||||
stock_results.extend(results)
|
|
||||||
|
|
||||||
for _ in range(10):
|
|
||||||
with trio.move_on_after(3) as cs:
|
|
||||||
async with trio.open_nursery() as sn:
|
|
||||||
sn.start_soon(
|
|
||||||
extend_results,
|
|
||||||
proxy.search_symbols(
|
|
||||||
pattern=pattern,
|
|
||||||
upto=5,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# trigger async request
|
|
||||||
await trio.sleep(0)
|
|
||||||
|
|
||||||
if cs.cancelled_caught:
|
|
||||||
log.warning(
|
|
||||||
f'Search timeout? {proxy._aio_ns.ib.client}'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
elif stock_results:
|
|
||||||
break
|
|
||||||
# else:
|
|
||||||
# await tractor.pause()
|
|
||||||
|
|
||||||
# # match against our ad-hoc set immediately
|
|
||||||
# adhoc_matches = fuzzy.extract(
|
|
||||||
# pattern,
|
|
||||||
# list(_adhoc_futes_set),
|
|
||||||
# score_cutoff=90,
|
|
||||||
# )
|
|
||||||
# log.info(f'fuzzy matched adhocs: {adhoc_matches}')
|
|
||||||
# adhoc_match_results = {}
|
|
||||||
# if adhoc_matches:
|
|
||||||
# # TODO: do we need to pull contract details?
|
|
||||||
# adhoc_match_results = {i[0]: {} for i in
|
|
||||||
# adhoc_matches}
|
|
||||||
|
|
||||||
log.debug(f'fuzzy matching stocks {stock_results}')
|
|
||||||
stock_matches = fuzzy.extract(
|
|
||||||
pattern,
|
|
||||||
stock_results,
|
|
||||||
score_cutoff=50,
|
|
||||||
)
|
|
||||||
|
|
||||||
# matches = adhoc_match_results | {
|
|
||||||
matches = {
|
|
||||||
item[0]: {} for item in stock_matches
|
|
||||||
}
|
|
||||||
# TODO: we used to deliver contract details
|
|
||||||
# {item[2]: item[0] for item in stock_matches}
|
|
||||||
|
|
||||||
log.debug(f"sending matches: {matches.keys()}")
|
|
||||||
await stream.send(matches)
|
|
||||||
|
|
||||||
|
|
||||||
# re-mapping to piker asset type names
|
|
||||||
# https://github.com/erdewit/ib_insync/blob/master/ib_insync/contract.py#L113
|
|
||||||
_asset_type_map = {
|
|
||||||
'STK': 'stock',
|
|
||||||
'OPT': 'option',
|
|
||||||
'FUT': 'future',
|
|
||||||
'CONTFUT': 'continuous_future',
|
|
||||||
'CASH': 'fiat',
|
|
||||||
'IND': 'index',
|
|
||||||
'CFD': 'cfd',
|
|
||||||
'BOND': 'bond',
|
|
||||||
'CMDTY': 'commodity',
|
|
||||||
'FOP': 'futures_option',
|
|
||||||
'FUND': 'mutual_fund',
|
|
||||||
'WAR': 'warrant',
|
|
||||||
'IOPT': 'warran',
|
|
||||||
'BAG': 'bag',
|
|
||||||
'CRYPTO': 'crypto', # bc it's diff then fiat?
|
|
||||||
# 'NEWS': 'news',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_patt2fqme(
|
|
||||||
# client: Client,
|
|
||||||
pattern: str,
|
|
||||||
|
|
||||||
) -> tuple[str, str, str, str]:
|
|
||||||
|
|
||||||
# TODO: we can't use this currently because
|
|
||||||
# ``wrapper.starTicker()`` currently cashes ticker instances
|
|
||||||
# which means getting a singel quote will potentially look up
|
|
||||||
# a quote for a ticker that it already streaming and thus run
|
|
||||||
# into state clobbering (eg. list: Ticker.ticks). It probably
|
|
||||||
# makes sense to try this once we get the pub-sub working on
|
|
||||||
# individual symbols...
|
|
||||||
|
|
||||||
# XXX UPDATE: we can probably do the tick/trades scraping
|
|
||||||
# inside our eventkit handler instead to bypass this entirely?
|
|
||||||
|
|
||||||
currency = ''
|
|
||||||
|
|
||||||
# fqme parsing stage
|
|
||||||
# ------------------
|
|
||||||
if '.ib' in pattern:
|
|
||||||
_, symbol, venue, expiry = unpack_fqme(pattern)
|
|
||||||
|
|
||||||
else:
|
|
||||||
symbol = pattern
|
|
||||||
expiry = ''
|
|
||||||
|
|
||||||
# # another hack for forex pairs lul.
|
|
||||||
# if (
|
|
||||||
# '.idealpro' in symbol
|
|
||||||
# # or '/' in symbol
|
|
||||||
# ):
|
|
||||||
# exch: str = 'IDEALPRO'
|
|
||||||
# symbol = symbol.removesuffix('.idealpro')
|
|
||||||
# if '/' in symbol:
|
|
||||||
# symbol, currency = symbol.split('/')
|
|
||||||
|
|
||||||
# else:
|
|
||||||
# TODO: yes, a cache..
|
|
||||||
# try:
|
|
||||||
# # give the cache a go
|
|
||||||
# return client._contracts[symbol]
|
|
||||||
# except KeyError:
|
|
||||||
# log.debug(f'Looking up contract for {symbol}')
|
|
||||||
expiry: str = ''
|
|
||||||
if symbol.count('.') > 1:
|
|
||||||
symbol, _, expiry = symbol.rpartition('.')
|
|
||||||
|
|
||||||
# use heuristics to figure out contract "type"
|
|
||||||
symbol, venue = symbol.upper().rsplit('.', maxsplit=1)
|
|
||||||
|
|
||||||
return symbol, currency, venue, expiry
|
|
||||||
|
|
||||||
|
|
||||||
def con2fqme(
|
|
||||||
con: ibis.Contract,
|
|
||||||
_cache: dict[int, (str, bool)] = {}
|
|
||||||
|
|
||||||
) -> tuple[str, bool]:
|
|
||||||
'''
|
|
||||||
Convert contracts to fqme-style strings to be used both in
|
|
||||||
symbol-search matching and as feed tokens passed to the front
|
|
||||||
end data deed layer.
|
|
||||||
|
|
||||||
Previously seen contracts are cached by id.
|
|
||||||
|
|
||||||
'''
|
|
||||||
# should be real volume for this contract by default
|
|
||||||
calc_price: bool = False
|
|
||||||
if con.conId:
|
|
||||||
try:
|
|
||||||
# TODO: LOL so apparently IB just changes the contract
|
|
||||||
# ID (int) on a whim.. so we probably need to use an
|
|
||||||
# FQME style key after all...
|
|
||||||
return _cache[con.conId]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
suffix: str = con.primaryExchange or con.exchange
|
|
||||||
symbol: str = con.symbol
|
|
||||||
expiry: str = con.lastTradeDateOrContractMonth or ''
|
|
||||||
|
|
||||||
match con:
|
|
||||||
case ibis.Option():
|
|
||||||
# TODO: option symbol parsing and sane display:
|
|
||||||
symbol = con.localSymbol.replace(' ', '')
|
|
||||||
|
|
||||||
case (
|
|
||||||
ibis.Commodity()
|
|
||||||
# search API endpoint returns std con box..
|
|
||||||
| ibis.Contract(secType='CMDTY')
|
|
||||||
):
|
|
||||||
# commodities and forex don't have an exchange name and
|
|
||||||
# no real volume so we have to calculate the price
|
|
||||||
suffix = con.secType
|
|
||||||
|
|
||||||
# no real volume on this tract
|
|
||||||
calc_price = True
|
|
||||||
|
|
||||||
case ibis.Forex() | ibis.Contract(secType='CASH'):
|
|
||||||
dst, src = con.localSymbol.split('.')
|
|
||||||
symbol = ''.join([dst, src])
|
|
||||||
suffix = con.exchange or 'idealpro'
|
|
||||||
|
|
||||||
# no real volume on forex feeds..
|
|
||||||
calc_price = True
|
|
||||||
|
|
||||||
if not suffix:
|
|
||||||
entry = _adhoc_symbol_map.get(
|
|
||||||
con.symbol or con.localSymbol
|
|
||||||
)
|
|
||||||
if entry:
|
|
||||||
meta, kwargs = entry
|
|
||||||
cid = meta.get('conId')
|
|
||||||
if cid:
|
|
||||||
assert con.conId == meta['conId']
|
|
||||||
suffix = meta['exchange']
|
|
||||||
|
|
||||||
# append a `.<suffix>` to the returned symbol
|
|
||||||
# key for derivatives that normally is the expiry
|
|
||||||
# date key.
|
|
||||||
if expiry:
|
|
||||||
suffix += f'.{expiry}'
|
|
||||||
|
|
||||||
fqme_key = symbol.lower()
|
|
||||||
if suffix:
|
|
||||||
fqme_key = '.'.join((fqme_key, suffix)).lower()
|
|
||||||
|
|
||||||
_cache[con.conId] = fqme_key, calc_price
|
|
||||||
return fqme_key, calc_price
|
|
||||||
|
|
||||||
|
|
||||||
@async_lifo_cache()
|
|
||||||
async def get_mkt_info(
|
|
||||||
fqme: str,
|
|
||||||
|
|
||||||
proxy: MethodProxy | None = None,
|
|
||||||
|
|
||||||
) -> tuple[MktPair, ibis.ContractDetails]:
|
|
||||||
|
|
||||||
if '.ib' not in fqme:
|
|
||||||
fqme += '.ib'
|
|
||||||
broker, pair, venue, expiry = unpack_fqme(fqme)
|
|
||||||
|
|
||||||
proxy: MethodProxy
|
|
||||||
if proxy is not None:
|
|
||||||
client_ctx = nullcontext(proxy)
|
|
||||||
else:
|
|
||||||
from .feed import (
|
|
||||||
open_data_client,
|
|
||||||
)
|
|
||||||
client_ctx = open_data_client
|
|
||||||
|
|
||||||
async with client_ctx as proxy:
|
|
||||||
try:
|
|
||||||
(
|
|
||||||
con, # Contract
|
|
||||||
details, # ContractDetails
|
|
||||||
) = await proxy.get_sym_details(fqme=fqme)
|
|
||||||
except ConnectionError:
|
|
||||||
log.exception(f'Proxy is ded {proxy._aio_ns}')
|
|
||||||
raise
|
|
||||||
|
|
||||||
# TODO: more consistent field translation
|
|
||||||
atype = _asset_type_map[con.secType]
|
|
||||||
|
|
||||||
if atype == 'commodity':
|
|
||||||
venue: str = 'cmdty'
|
|
||||||
else:
|
|
||||||
venue = con.primaryExchange or con.exchange
|
|
||||||
|
|
||||||
price_tick: Decimal = Decimal(str(details.minTick))
|
|
||||||
ib_min_tick_gt_2: Decimal = Decimal('0.01')
|
|
||||||
if (
|
|
||||||
price_tick < ib_min_tick_gt_2
|
|
||||||
):
|
|
||||||
# TODO: we need to add some kinda dynamic rounding sys
|
|
||||||
# to our MktPair i guess?
|
|
||||||
# not sure where the logic should sit, but likely inside
|
|
||||||
# the `.clearing._ems` i suppose...
|
|
||||||
log.warning(
|
|
||||||
'IB seems to disallow a min price tick < 0.01 '
|
|
||||||
'when the price is > 2.0..?\n'
|
|
||||||
f'Decreasing min tick precision for {fqme} to 0.01'
|
|
||||||
)
|
|
||||||
# price_tick = ib_min_tick
|
|
||||||
# await tractor.pause()
|
|
||||||
|
|
||||||
if atype == 'stock':
|
|
||||||
# XXX: GRRRR they don't support fractional share sizes for
|
|
||||||
# stocks from the API?!
|
|
||||||
# if con.secType == 'STK':
|
|
||||||
size_tick = Decimal('1')
|
|
||||||
else:
|
|
||||||
size_tick: Decimal = Decimal(
|
|
||||||
str(details.minSize).rstrip('0')
|
|
||||||
)
|
|
||||||
# |-> TODO: there is also the Contract.sizeIncrement, bt wtf is it?
|
|
||||||
|
|
||||||
# NOTE: this is duplicate from the .broker.norm_trade_records()
|
|
||||||
# routine, we should factor all this parsing somewhere..
|
|
||||||
expiry_str = str(con.lastTradeDateOrContractMonth)
|
|
||||||
# if expiry:
|
|
||||||
# expiry_str: str = str(pendulum.parse(
|
|
||||||
# str(expiry).strip(' ')
|
|
||||||
# ))
|
|
||||||
|
|
||||||
# TODO: currently we can't pass the fiat src asset because
|
|
||||||
# then we'll get a `MNQUSD` request for history data..
|
|
||||||
# we need to figure out how we're going to handle this (later?)
|
|
||||||
# but likely we want all backends to eventually handle
|
|
||||||
# ``dst/src.venue.`` style !?
|
|
||||||
src = Asset(
|
|
||||||
name=str(con.currency).lower(),
|
|
||||||
atype='fiat',
|
|
||||||
tx_tick=Decimal('0.01'), # right?
|
|
||||||
)
|
|
||||||
dst = Asset(
|
|
||||||
name=con.symbol.lower(),
|
|
||||||
atype=atype,
|
|
||||||
tx_tick=size_tick,
|
|
||||||
)
|
|
||||||
|
|
||||||
mkt = MktPair(
|
|
||||||
src=src,
|
|
||||||
dst=dst,
|
|
||||||
|
|
||||||
price_tick=price_tick,
|
|
||||||
size_tick=size_tick,
|
|
||||||
|
|
||||||
bs_mktid=str(con.conId),
|
|
||||||
venue=str(venue),
|
|
||||||
expiry=expiry_str,
|
|
||||||
broker='ib',
|
|
||||||
|
|
||||||
# TODO: options contract info as str?
|
|
||||||
# contract_info=<optionsdetails>
|
|
||||||
_fqme_without_src=(atype != 'fiat'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# just.. wow.
|
|
||||||
if entry := _adhoc_mkt_infos.get(mkt.bs_fqme):
|
|
||||||
log.warning(f'Frickin {mkt.fqme} has an adhoc {entry}..')
|
|
||||||
new = mkt.to_dict()
|
|
||||||
new['price_tick'] = entry['price_tick']
|
|
||||||
new['src'] = src
|
|
||||||
new['dst'] = dst
|
|
||||||
mkt = MktPair(**new)
|
|
||||||
|
|
||||||
# if possible register the bs_mktid to the just-built
|
|
||||||
# mkt so that it can be retreived by order mode tasks later.
|
|
||||||
# TODO NOTE: this is going to be problematic if/when we split
|
|
||||||
# out the datatd vs. brokerd actors since the mktmap lookup
|
|
||||||
# table will now be inaccessible..
|
|
||||||
if proxy is not None:
|
|
||||||
client: Client = proxy._aio_ns
|
|
||||||
client._contracts[mkt.bs_fqme] = con
|
|
||||||
client._cons2mkts[con] = mkt
|
|
||||||
|
|
||||||
return mkt, details
|
|
||||||
|
|
@ -19,36 +19,23 @@ Kraken backend.
|
||||||
|
|
||||||
Sub-modules within break into the core functionalities:
|
Sub-modules within break into the core functionalities:
|
||||||
|
|
||||||
- .api: for the core API machinery which generally
|
- ``broker.py`` part for orders / trading endpoints
|
||||||
a ``asks``/``trio-websocket`` implemented ``Client``.
|
- ``feed.py`` for real-time data feed endpoints
|
||||||
- .broker: part for orders / trading endpoints.
|
- ``api.py`` for the core API machinery which is ``trio``-ized
|
||||||
- .feed: for real-time and historical data query endpoints.
|
wrapping around ``ib_insync``.
|
||||||
- .ledger: for transaction processing as it pertains to accounting.
|
|
||||||
- .symbols: for market (name) search and symbology meta-defs.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from .symbols import (
|
|
||||||
Pair, # for symcache
|
|
||||||
open_symbol_search,
|
|
||||||
# required by `.accounting`, `.data`
|
|
||||||
get_mkt_info,
|
|
||||||
)
|
|
||||||
# required by `.brokers`
|
|
||||||
from .api import (
|
from .api import (
|
||||||
get_client,
|
get_client,
|
||||||
)
|
)
|
||||||
from .feed import (
|
from .feed import (
|
||||||
# required by `.data`
|
get_mkt_info,
|
||||||
stream_quotes,
|
|
||||||
open_history_client,
|
open_history_client,
|
||||||
|
open_symbol_search,
|
||||||
|
stream_quotes,
|
||||||
)
|
)
|
||||||
from .broker import (
|
from .broker import (
|
||||||
# required by `.clearing`
|
|
||||||
open_trade_dialog,
|
open_trade_dialog,
|
||||||
)
|
|
||||||
from .ledger import (
|
|
||||||
# required by `.accounting`
|
|
||||||
norm_trade,
|
|
||||||
norm_trade_records,
|
norm_trade_records,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -56,20 +43,17 @@ from .ledger import (
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'get_client',
|
'get_client',
|
||||||
'get_mkt_info',
|
'get_mkt_info',
|
||||||
'Pair',
|
|
||||||
'open_trade_dialog',
|
'open_trade_dialog',
|
||||||
'open_history_client',
|
'open_history_client',
|
||||||
'open_symbol_search',
|
'open_symbol_search',
|
||||||
'stream_quotes',
|
'stream_quotes',
|
||||||
'norm_trade_records',
|
'norm_trade_records',
|
||||||
'norm_trade',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# tractor RPC enable arg
|
# tractor RPC enable arg
|
||||||
__enable_modules__: list[str] = [
|
__enable_modules__: list[str] = [
|
||||||
'api',
|
'api',
|
||||||
'broker',
|
|
||||||
'feed',
|
'feed',
|
||||||
'symbols',
|
'broker',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,12 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Core (web) API client
|
Kraken web API wrapping.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from contextlib import asynccontextmanager as acm
|
from contextlib import asynccontextmanager as acm
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
import itertools
|
import itertools
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
|
|
@ -27,8 +28,10 @@ from typing import (
|
||||||
)
|
)
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
from bidict import bidict
|
||||||
import pendulum
|
import pendulum
|
||||||
|
import asks
|
||||||
|
from fuzzywuzzy import process as fuzzy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
@ -37,14 +40,11 @@ import base64
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from piker import config
|
from piker import config
|
||||||
from piker.data import (
|
from piker.data.types import Struct
|
||||||
def_iohlcv_fields,
|
from piker.data import def_iohlcv_fields
|
||||||
match_from_pairs,
|
|
||||||
)
|
|
||||||
from piker.accounting._mktinfo import (
|
from piker.accounting._mktinfo import (
|
||||||
Asset,
|
Asset,
|
||||||
digits_to_dec,
|
digits_to_dec,
|
||||||
dec_digits,
|
|
||||||
)
|
)
|
||||||
from piker.brokers._util import (
|
from piker.brokers._util import (
|
||||||
resproc,
|
resproc,
|
||||||
|
|
@ -54,17 +54,11 @@ from piker.brokers._util import (
|
||||||
)
|
)
|
||||||
from piker.accounting import Transaction
|
from piker.accounting import Transaction
|
||||||
from piker.log import get_logger
|
from piker.log import get_logger
|
||||||
from .symbols import Pair
|
|
||||||
|
|
||||||
log = get_logger('piker.brokers.kraken')
|
log = get_logger('piker.brokers.kraken')
|
||||||
|
|
||||||
# <uri>/<version>/
|
# <uri>/<version>/
|
||||||
_url = 'https://api.kraken.com/0'
|
_url = 'https://api.kraken.com/0'
|
||||||
|
|
||||||
_headers: dict[str, str] = {
|
|
||||||
'User-Agent': 'krakenex/2.1.0 (+https://github.com/veox/python3-krakenex)'
|
|
||||||
}
|
|
||||||
|
|
||||||
# TODO: this is the only backend providing this right?
|
# TODO: this is the only backend providing this right?
|
||||||
# in which case we should drop it from the defaults and
|
# in which case we should drop it from the defaults and
|
||||||
# instead make a custom fields descr in this module!
|
# instead make a custom fields descr in this module!
|
||||||
|
|
@ -75,18 +69,12 @@ _symbol_info_translation: dict[str, str] = {
|
||||||
|
|
||||||
|
|
||||||
def get_config() -> dict[str, Any]:
|
def get_config() -> dict[str, Any]:
|
||||||
'''
|
|
||||||
Load our section from `piker/brokers.toml`.
|
|
||||||
|
|
||||||
'''
|
conf, path = config.load()
|
||||||
conf, path = config.load(
|
section = conf.get('kraken')
|
||||||
conf_name='brokers',
|
|
||||||
touch_if_dne=True,
|
if section is None:
|
||||||
)
|
log.warning(f'No config section found for kraken in {path}')
|
||||||
if (section := conf.get('kraken')) is None:
|
|
||||||
log.warning(
|
|
||||||
f'No config section found for kraken in {path}'
|
|
||||||
)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return section
|
return section
|
||||||
|
|
@ -117,51 +105,96 @@ class InvalidKey(ValueError):
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
# https://www.kraken.com/features/api#get-tradable-pairs
|
||||||
|
class Pair(Struct):
|
||||||
|
altname: str # alternate pair name
|
||||||
|
wsname: str # WebSocket pair name (if available)
|
||||||
|
aclass_base: str # asset class of base component
|
||||||
|
base: str # asset id of base component
|
||||||
|
aclass_quote: str # asset class of quote component
|
||||||
|
quote: str # asset id of quote component
|
||||||
|
lot: str # volume lot size
|
||||||
|
|
||||||
|
cost_decimals: int
|
||||||
|
costmin: float
|
||||||
|
pair_decimals: int # scaling decimal places for pair
|
||||||
|
lot_decimals: int # scaling decimal places for volume
|
||||||
|
|
||||||
|
# amount to multiply lot volume by to get currency volume
|
||||||
|
lot_multiplier: float
|
||||||
|
|
||||||
|
# array of leverage amounts available when buying
|
||||||
|
leverage_buy: list[int]
|
||||||
|
# array of leverage amounts available when selling
|
||||||
|
leverage_sell: list[int]
|
||||||
|
|
||||||
|
# fee schedule array in [volume, percent fee] tuples
|
||||||
|
fees: list[tuple[int, float]]
|
||||||
|
|
||||||
|
# maker fee schedule array in [volume, percent fee] tuples (if on
|
||||||
|
# maker/taker)
|
||||||
|
fees_maker: list[tuple[int, float]]
|
||||||
|
|
||||||
|
fee_volume_currency: str # volume discount currency
|
||||||
|
margin_call: str # margin call level
|
||||||
|
margin_stop: str # stop-out/liquidation margin level
|
||||||
|
ordermin: float # minimum order volume for pair
|
||||||
|
tick_size: float # min price step size
|
||||||
|
status: str
|
||||||
|
|
||||||
|
short_position_limit: float = 0
|
||||||
|
long_position_limit: float = float('inf')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def price_tick(self) -> Decimal:
|
||||||
|
return digits_to_dec(self.pair_decimals)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_tick(self) -> Decimal:
|
||||||
|
return digits_to_dec(self.lot_decimals)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bs_fqme(self) -> str:
|
||||||
|
return f'{self.symbol}.SPOT'
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
|
|
||||||
# assets and mkt pairs are key-ed by kraken's ReST response
|
# symbol mapping from all names to the altname
|
||||||
# symbol-bs_mktids (we call them "X-keys" like fricking
|
_ntable: dict[str, str] = {}
|
||||||
# "XXMRZEUR"). these keys used directly since ledger endpoints
|
|
||||||
# return transaction sets keyed with the same set!
|
|
||||||
_Assets: dict[str, Asset] = {}
|
|
||||||
_AssetPairs: dict[str, Pair] = {}
|
|
||||||
|
|
||||||
# offer lookup tables for all .altname and .wsname
|
# 2-way map of symbol names to their "alt names" ffs XD
|
||||||
# to the equivalent .xname so that various symbol-schemas
|
_altnames: bidict[str, str] = bidict()
|
||||||
# can be mapped to `Pair`s in the tables above.
|
|
||||||
_altnames: dict[str, str] = {}
|
|
||||||
_wsnames: dict[str, str] = {}
|
|
||||||
|
|
||||||
# key-ed by `Pair.bs_fqme: str`, and thus used for search
|
|
||||||
# allowing for lookup using piker's own FQME symbology sys.
|
|
||||||
_pairs: dict[str, Pair] = {}
|
_pairs: dict[str, Pair] = {}
|
||||||
_assets: dict[str, Asset] = {}
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: dict[str, str],
|
config: dict[str, str],
|
||||||
httpx_client: httpx.AsyncClient,
|
|
||||||
|
|
||||||
name: str = '',
|
name: str = '',
|
||||||
api_key: str = '',
|
api_key: str = '',
|
||||||
secret: str = ''
|
secret: str = ''
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self._sesh = asks.Session(connections=4)
|
||||||
self._sesh: httpx.AsyncClient = httpx_client
|
self._sesh.base_location = _url
|
||||||
|
self._sesh.headers.update({
|
||||||
|
'User-Agent':
|
||||||
|
'krakenex/2.1.0 (+https://github.com/veox/python3-krakenex)'
|
||||||
|
})
|
||||||
self._name = name
|
self._name = name
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._secret = secret
|
self._secret = secret
|
||||||
|
|
||||||
self.conf: dict[str, str] = config
|
self.conf: dict[str, str] = config
|
||||||
|
self.assets: dict[str, Asset] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pairs(self) -> dict[str, Pair]:
|
def pairs(self) -> dict[str, Pair]:
|
||||||
|
|
||||||
if self._pairs is None:
|
if self._pairs is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Client didn't run `.get_mkt_pairs()` on startup?!"
|
"Make sure to run `cache_symbols()` on startup!"
|
||||||
)
|
)
|
||||||
|
# retreive and cache all symbols
|
||||||
|
|
||||||
return self._pairs
|
return self._pairs
|
||||||
|
|
||||||
|
|
@ -170,9 +203,10 @@ class Client:
|
||||||
method: str,
|
method: str,
|
||||||
data: dict,
|
data: dict,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
resp: httpx.Response = await self._sesh.post(
|
resp = await self._sesh.post(
|
||||||
url=f'/public/{method}',
|
path=f'/public/{method}',
|
||||||
json=data,
|
json=data,
|
||||||
|
timeout=float('inf')
|
||||||
)
|
)
|
||||||
return resproc(resp, log)
|
return resproc(resp, log)
|
||||||
|
|
||||||
|
|
@ -183,18 +217,18 @@ class Client:
|
||||||
uri_path: str
|
uri_path: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
headers = {
|
headers = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type':
|
||||||
'API-Key': self._api_key,
|
'application/x-www-form-urlencoded',
|
||||||
'API-Sign': get_kraken_signature(
|
'API-Key':
|
||||||
uri_path,
|
self._api_key,
|
||||||
data,
|
'API-Sign':
|
||||||
self._secret,
|
get_kraken_signature(uri_path, data, self._secret)
|
||||||
),
|
|
||||||
}
|
}
|
||||||
resp: httpx.Response = await self._sesh.post(
|
resp = await self._sesh.post(
|
||||||
url=f'/private/{method}',
|
path=f'/private/{method}',
|
||||||
data=data,
|
data=data,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
timeout=float('inf')
|
||||||
)
|
)
|
||||||
return resproc(resp, log)
|
return resproc(resp, log)
|
||||||
|
|
||||||
|
|
@ -220,29 +254,17 @@ class Client:
|
||||||
'Balance',
|
'Balance',
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
by_bsmktid: dict[str, dict] = resp['result']
|
by_bsmktid = resp['result']
|
||||||
|
|
||||||
balances: dict = {}
|
# TODO: we need to pull out the "asset" decimals
|
||||||
for xname, bal in by_bsmktid.items():
|
# data and return a `decimal.Decimal` instead here!
|
||||||
asset: Asset = self._Assets[xname]
|
# using the underlying Asset
|
||||||
|
return {
|
||||||
|
self._altnames[sym].lower(): float(bal)
|
||||||
|
for sym, bal in by_bsmktid.items()
|
||||||
|
}
|
||||||
|
|
||||||
# TODO: which KEY should we use? it's used to index
|
async def get_assets(self) -> dict[str, Asset]:
|
||||||
# the `Account.pps: dict` ..
|
|
||||||
key: str = asset.name.lower()
|
|
||||||
# TODO: should we just return a `Decimal` here
|
|
||||||
# or is the rounded version ok?
|
|
||||||
balances[key] = round(
|
|
||||||
float(bal),
|
|
||||||
ndigits=dec_digits(asset.tx_tick)
|
|
||||||
)
|
|
||||||
|
|
||||||
return balances
|
|
||||||
|
|
||||||
async def get_assets(
|
|
||||||
self,
|
|
||||||
reload: bool = False,
|
|
||||||
|
|
||||||
) -> dict[str, Asset]:
|
|
||||||
'''
|
'''
|
||||||
Load and cache all asset infos and pack into
|
Load and cache all asset infos and pack into
|
||||||
our native ``Asset`` struct.
|
our native ``Asset`` struct.
|
||||||
|
|
@ -260,37 +282,21 @@ class Client:
|
||||||
}
|
}
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if (
|
|
||||||
not self._assets
|
|
||||||
or reload
|
|
||||||
):
|
|
||||||
resp = await self._public('Assets', {})
|
resp = await self._public('Assets', {})
|
||||||
assets: dict[str, dict] = resp['result']
|
assets = resp['result']
|
||||||
|
|
||||||
for bs_mktid, info in assets.items():
|
for bs_mktid, info in assets.items():
|
||||||
|
altname = self._altnames[bs_mktid] = info['altname']
|
||||||
altname: str = info['altname']
|
|
||||||
aclass: str = info['aclass']
|
aclass: str = info['aclass']
|
||||||
asset = Asset(
|
|
||||||
name=altname,
|
self.assets[bs_mktid] = Asset(
|
||||||
|
name=altname.lower(),
|
||||||
atype=f'crypto_{aclass}',
|
atype=f'crypto_{aclass}',
|
||||||
tx_tick=digits_to_dec(info['decimals']),
|
tx_tick=digits_to_dec(info['decimals']),
|
||||||
info=info,
|
info=info,
|
||||||
)
|
)
|
||||||
# NOTE: yes we keep 2 sets since kraken insists on
|
|
||||||
# keeping 3 frickin sets bc apparently they have
|
|
||||||
# no sane data engineers whol all like different
|
|
||||||
# keys for their fricking symbology sets..
|
|
||||||
self._Assets[bs_mktid] = asset
|
|
||||||
self._assets[altname.lower()] = asset
|
|
||||||
self._assets[altname] = asset
|
|
||||||
|
|
||||||
# we return the "most native" set merged with our preferred
|
return self.assets
|
||||||
# naming (which i guess is the "altname" one) since that's
|
|
||||||
# what the symcache loader will be storing, and we need the
|
|
||||||
# keys that are easiest to match against in any trade
|
|
||||||
# records.
|
|
||||||
return self._Assets | self._assets
|
|
||||||
|
|
||||||
async def get_trades(
|
async def get_trades(
|
||||||
self,
|
self,
|
||||||
|
|
@ -371,25 +377,23 @@ class Client:
|
||||||
# 'amount': '0.00300726', 'fee': '0.00001000', 'time':
|
# 'amount': '0.00300726', 'fee': '0.00001000', 'time':
|
||||||
# 1658347714, 'status': 'Success'}]}
|
# 1658347714, 'status': 'Success'}]}
|
||||||
|
|
||||||
if xfers:
|
|
||||||
import tractor
|
|
||||||
await tractor.pp()
|
|
||||||
|
|
||||||
trans: dict[str, Transaction] = {}
|
trans: dict[str, Transaction] = {}
|
||||||
for entry in xfers:
|
for entry in xfers:
|
||||||
|
|
||||||
# look up the normalized name and asset info
|
# look up the normalized name and asset info
|
||||||
asset_key: str = entry['asset']
|
asset_key = entry['asset']
|
||||||
asset: Asset = self._Assets[asset_key]
|
asset = self.assets[asset_key]
|
||||||
asset_key: str = asset.name.lower()
|
asset_key = self._altnames[asset_key].lower()
|
||||||
|
|
||||||
# XXX: this is in the asset units (likely) so it isn't
|
# XXX: this is in the asset units (likely) so it isn't
|
||||||
# quite the same as a commisions cost necessarily..)
|
# quite the same as a commisions cost necessarily..)
|
||||||
# TODO: also round this based on `Pair` cost precision info?
|
|
||||||
cost = float(entry['fee'])
|
cost = float(entry['fee'])
|
||||||
# fqme: str = asset_key + '.kraken'
|
|
||||||
|
fqme = asset_key + '.kraken'
|
||||||
|
|
||||||
tx = Transaction(
|
tx = Transaction(
|
||||||
fqme=asset_key, # this must map to an entry in .assets!
|
fqme=fqme,
|
||||||
|
sym=asset,
|
||||||
tid=entry['txid'],
|
tid=entry['txid'],
|
||||||
dt=pendulum.from_timestamp(entry['time']),
|
dt=pendulum.from_timestamp(entry['time']),
|
||||||
bs_mktid=f'{asset_key}{src_asset}',
|
bs_mktid=f'{asset_key}{src_asset}',
|
||||||
|
|
@ -404,11 +408,6 @@ class Client:
|
||||||
|
|
||||||
# XXX: see note above
|
# XXX: see note above
|
||||||
cost=cost,
|
cost=cost,
|
||||||
|
|
||||||
# not a trade but a withdrawal or deposit on the
|
|
||||||
# asset (chain) system.
|
|
||||||
etype='transfer',
|
|
||||||
|
|
||||||
)
|
)
|
||||||
trans[tx.tid] = tx
|
trans[tx.tid] = tx
|
||||||
|
|
||||||
|
|
@ -459,7 +458,7 @@ class Client:
|
||||||
# txid is a transaction id given by kraken
|
# txid is a transaction id given by kraken
|
||||||
return await self.endpoint('CancelOrder', {"txid": reqid})
|
return await self.endpoint('CancelOrder', {"txid": reqid})
|
||||||
|
|
||||||
async def asset_pairs(
|
async def pair_info(
|
||||||
self,
|
self,
|
||||||
pair_patt: str | None = None,
|
pair_patt: str | None = None,
|
||||||
|
|
||||||
|
|
@ -471,76 +470,64 @@ class Client:
|
||||||
https://docs.kraken.com/rest/#tag/Market-Data/operation/getTradableAssetPairs
|
https://docs.kraken.com/rest/#tag/Market-Data/operation/getTradableAssetPairs
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if not self._AssetPairs:
|
|
||||||
# get all pairs by default, or filter
|
# get all pairs by default, or filter
|
||||||
# to whatever pattern is provided as input.
|
# to whatever pattern is provided as input.
|
||||||
req_pairs: dict[str, str] | None = None
|
pairs: dict[str, str] | None = None
|
||||||
if pair_patt is not None:
|
if pair_patt is not None:
|
||||||
req_pairs = {'pair': pair_patt}
|
pairs = {'pair': pair_patt}
|
||||||
|
|
||||||
resp = await self._public(
|
resp = await self._public(
|
||||||
'AssetPairs',
|
'AssetPairs',
|
||||||
req_pairs,
|
pairs,
|
||||||
)
|
)
|
||||||
err = resp['error']
|
err = resp['error']
|
||||||
if err:
|
if err:
|
||||||
raise SymbolNotFound(pair_patt)
|
raise SymbolNotFound(pair_patt)
|
||||||
|
|
||||||
# NOTE: we try to key pairs by our custom defined
|
pairs: dict[str, Pair] = {
|
||||||
# `.bs_fqme` field since we want to offer search over
|
|
||||||
# this pattern set, callers should fill out lookup
|
|
||||||
# tables for kraken's bs_mktid keys to map to these
|
|
||||||
# keys!
|
|
||||||
# XXX: FURTHER kraken's data eng team decided to offer
|
|
||||||
# 3 frickin market-pair-symbol key sets depending on
|
|
||||||
# which frickin API is being used.
|
|
||||||
# Example for the trading pair 'LTC<EUR'
|
|
||||||
# - the "X-key" from rest eps 'XLTCZEUR'
|
|
||||||
# - the "websocket key" from ws msgs is 'LTC/EUR'
|
|
||||||
# - the "altname key" also delivered in pair info is 'LTCEUR'
|
|
||||||
for xkey, data in resp['result'].items():
|
|
||||||
|
|
||||||
# NOTE: always cache in pairs tables for faster lookup
|
key: Pair(**data)
|
||||||
pair = Pair(xname=xkey, **data)
|
for key, data in resp['result'].items()
|
||||||
|
}
|
||||||
# register the above `Pair` structs for all
|
# always cache so we can possibly do faster lookup
|
||||||
# key-sets/monikers: a set of 4 (frickin) tables
|
self._pairs.update(pairs)
|
||||||
# acting as a combined surjection of all possible
|
|
||||||
# (and stupid) kraken names to their `Pair` obj.
|
|
||||||
self._AssetPairs[xkey] = pair
|
|
||||||
self._pairs[pair.bs_fqme] = pair
|
|
||||||
self._altnames[pair.altname] = pair
|
|
||||||
self._wsnames[pair.wsname] = pair
|
|
||||||
|
|
||||||
if pair_patt is not None:
|
if pair_patt is not None:
|
||||||
return next(iter(self._pairs.items()))[1]
|
return next(iter(pairs.items()))[1]
|
||||||
|
|
||||||
return self._AssetPairs
|
return pairs
|
||||||
|
|
||||||
async def get_mkt_pairs(
|
async def cache_symbols(self) -> dict:
|
||||||
self,
|
|
||||||
reload: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
'''
|
'''
|
||||||
Load all market pair info build and cache it for downstream
|
Load all market pair info build and cache it for downstream use.
|
||||||
use.
|
|
||||||
|
|
||||||
Multiple pair info lookup tables (like ``._altnames:
|
A ``._ntable: dict[str, str]`` is available for mapping the
|
||||||
dict[str, str]``) are created for looking up the
|
websocket pair name-keys and their http endpoint API (smh)
|
||||||
piker-native `Pair`-struct from any input of the three
|
equivalents to the "alternative name" which is generally the one
|
||||||
(yes, it's that idiotic..) available symbol/pair-key-sets
|
we actually want to use XD
|
||||||
that kraken frickin offers depending on the API including
|
|
||||||
the .altname, .wsname and the weird ass default set they
|
|
||||||
return in ReST responses .xname..
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if (
|
if not self._pairs:
|
||||||
not self._pairs
|
pairs = await self.pair_info()
|
||||||
or reload
|
assert self._pairs == pairs
|
||||||
):
|
|
||||||
await self.asset_pairs()
|
|
||||||
|
|
||||||
return self._AssetPairs
|
# table of all ws and rest keys to their alt-name values.
|
||||||
|
ntable: dict[str, str] = {}
|
||||||
|
|
||||||
|
for rest_key in list(pairs.keys()):
|
||||||
|
|
||||||
|
pair: Pair = pairs[rest_key]
|
||||||
|
altname = pair.altname
|
||||||
|
wsname = pair.wsname
|
||||||
|
ntable[altname] = ntable[rest_key] = ntable[wsname] = altname
|
||||||
|
|
||||||
|
# register the pair under all monikers, a giant flat
|
||||||
|
# surjection of all possible names to each info obj.
|
||||||
|
self._pairs[altname] = self._pairs[wsname] = pair
|
||||||
|
|
||||||
|
self._ntable.update(ntable)
|
||||||
|
|
||||||
|
return self._pairs
|
||||||
|
|
||||||
async def search_symbols(
|
async def search_symbols(
|
||||||
self,
|
self,
|
||||||
|
|
@ -556,20 +543,16 @@ class Client:
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if not len(self._pairs):
|
if not len(self._pairs):
|
||||||
await self.get_mkt_pairs()
|
await self.cache_symbols()
|
||||||
assert self._pairs, '`Client.get_mkt_pairs()` was never called!?'
|
assert self._pairs, '`Client.cache_symbols()` was never called!?'
|
||||||
|
|
||||||
matches: dict[str, Pair] = match_from_pairs(
|
matches = fuzzy.extractBests(
|
||||||
pairs=self._pairs,
|
pattern,
|
||||||
query=pattern.upper(),
|
self._pairs,
|
||||||
score_cutoff=50,
|
score_cutoff=50,
|
||||||
)
|
)
|
||||||
|
# repack in dict form
|
||||||
# repack in .altname-keyed output table
|
return {item[0].altname: item[0] for item in matches}
|
||||||
return {
|
|
||||||
pair.altname: pair
|
|
||||||
for pair in matches.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
async def bars(
|
async def bars(
|
||||||
self,
|
self,
|
||||||
|
|
@ -649,10 +632,10 @@ class Client:
|
||||||
raise BrokerError(errmsg)
|
raise BrokerError(errmsg)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def to_bs_fqme(
|
def normalize_symbol(
|
||||||
cls,
|
cls,
|
||||||
pair_str: str
|
ticker: str
|
||||||
) -> str:
|
) -> tuple[str, Pair]:
|
||||||
'''
|
'''
|
||||||
Normalize symbol names to to a 3x3 pair from the global
|
Normalize symbol names to to a 3x3 pair from the global
|
||||||
definition map which we build out from the data retreived from
|
definition map which we build out from the data retreived from
|
||||||
|
|
@ -660,7 +643,7 @@ class Client:
|
||||||
|
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
return cls._altnames[pair_str.upper()].bs_fqme
|
return cls._ntable[ticker]
|
||||||
except KeyError as ke:
|
except KeyError as ke:
|
||||||
raise SymbolNotFound(f'kraken has no {ke.args[0]}')
|
raise SymbolNotFound(f'kraken has no {ke.args[0]}')
|
||||||
|
|
||||||
|
|
@ -668,36 +651,21 @@ class Client:
|
||||||
@acm
|
@acm
|
||||||
async def get_client() -> Client:
|
async def get_client() -> Client:
|
||||||
|
|
||||||
conf: dict[str, Any] = get_config()
|
conf = get_config()
|
||||||
async with httpx.AsyncClient(
|
|
||||||
base_url=_url,
|
|
||||||
headers=_headers,
|
|
||||||
|
|
||||||
# TODO: is there a way to numerate this?
|
|
||||||
# https://www.python-httpx.org/advanced/clients/#why-use-a-client
|
|
||||||
# connections=4
|
|
||||||
) as trio_client:
|
|
||||||
if conf:
|
if conf:
|
||||||
client = Client(
|
client = Client(
|
||||||
conf,
|
conf,
|
||||||
httpx_client=trio_client,
|
|
||||||
|
|
||||||
# TODO: don't break these up and just do internal
|
|
||||||
# conf lookups instead..
|
|
||||||
name=conf['key_descr'],
|
name=conf['key_descr'],
|
||||||
api_key=conf['api_key'],
|
api_key=conf['api_key'],
|
||||||
secret=conf['secret']
|
secret=conf['secret']
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
client = Client(
|
client = Client({})
|
||||||
conf={},
|
|
||||||
httpx_client=trio_client,
|
|
||||||
)
|
|
||||||
|
|
||||||
# at startup, load all symbols, and asset info in
|
# at startup, load all symbols, and asset info in
|
||||||
# batch requests.
|
# batch requests.
|
||||||
async with trio.open_nursery() as nurse:
|
async with trio.open_nursery() as nurse:
|
||||||
nurse.start_soon(client.get_assets)
|
nurse.start_soon(client.get_assets)
|
||||||
await client.get_mkt_pairs()
|
await client.cache_symbols()
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from contextlib import (
|
||||||
)
|
)
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from itertools import count
|
from itertools import count
|
||||||
|
import math
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
import time
|
import time
|
||||||
from typing import (
|
from typing import (
|
||||||
|
|
@ -34,16 +35,21 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
|
import pendulum
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
from piker.accounting import (
|
from piker.accounting import (
|
||||||
Position,
|
Position,
|
||||||
Account,
|
PpTable,
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionLedger,
|
TransactionLedger,
|
||||||
open_trade_ledger,
|
open_trade_ledger,
|
||||||
open_account,
|
open_pps,
|
||||||
|
get_likely_pair,
|
||||||
|
)
|
||||||
|
from piker.accounting._mktinfo import (
|
||||||
|
MktPair,
|
||||||
)
|
)
|
||||||
from piker.clearing import(
|
from piker.clearing import(
|
||||||
OrderDialogs,
|
OrderDialogs,
|
||||||
|
|
@ -59,24 +65,18 @@ from piker.clearing._messages import (
|
||||||
BrokerdPosition,
|
BrokerdPosition,
|
||||||
BrokerdStatus,
|
BrokerdStatus,
|
||||||
)
|
)
|
||||||
from piker.brokers import (
|
|
||||||
open_cached_client,
|
|
||||||
)
|
|
||||||
from piker.data import open_symcache
|
|
||||||
from .api import (
|
from .api import (
|
||||||
log,
|
log,
|
||||||
Client,
|
Client,
|
||||||
BrokerError,
|
BrokerError,
|
||||||
|
get_client,
|
||||||
)
|
)
|
||||||
from .feed import (
|
from .feed import (
|
||||||
|
get_mkt_info,
|
||||||
open_autorecon_ws,
|
open_autorecon_ws,
|
||||||
NoBsWs,
|
NoBsWs,
|
||||||
stream_messages,
|
stream_messages,
|
||||||
)
|
)
|
||||||
from .ledger import (
|
|
||||||
norm_trade_records,
|
|
||||||
verify_balances,
|
|
||||||
)
|
|
||||||
|
|
||||||
MsgUnion = Union[
|
MsgUnion = Union[
|
||||||
BrokerdCancel,
|
BrokerdCancel,
|
||||||
|
|
@ -371,8 +371,7 @@ async def subscribe(
|
||||||
|
|
||||||
|
|
||||||
def trades2pps(
|
def trades2pps(
|
||||||
acnt: Account,
|
table: PpTable,
|
||||||
ledger: TransactionLedger,
|
|
||||||
acctid: str,
|
acctid: str,
|
||||||
new_trans: dict[str, Transaction] = {},
|
new_trans: dict[str, Transaction] = {},
|
||||||
|
|
||||||
|
|
@ -380,14 +379,13 @@ def trades2pps(
|
||||||
|
|
||||||
) -> list[BrokerdPosition]:
|
) -> list[BrokerdPosition]:
|
||||||
if new_trans:
|
if new_trans:
|
||||||
updated = acnt.update_from_ledger(
|
updated = table.update_from_trans(
|
||||||
new_trans,
|
new_trans,
|
||||||
symcache=ledger.symcache,
|
|
||||||
)
|
)
|
||||||
log.info(f'Updated pps:\n{pformat(updated)}')
|
log.info(f'Updated pps:\n{pformat(updated)}')
|
||||||
|
|
||||||
pp_entries, closed_pp_objs = acnt.dump_active()
|
pp_entries, closed_pp_objs = table.dump_active()
|
||||||
pp_objs: dict[Union[str, int], Position] = acnt.pps
|
pp_objs: dict[Union[str, int], Position] = table.pps
|
||||||
|
|
||||||
pps: dict[int, Position]
|
pps: dict[int, Position]
|
||||||
position_msgs: list[dict] = []
|
position_msgs: list[dict] = []
|
||||||
|
|
@ -401,13 +399,13 @@ def trades2pps(
|
||||||
# backend suffix prefixed but when
|
# backend suffix prefixed but when
|
||||||
# reading accounts from ledgers we
|
# reading accounts from ledgers we
|
||||||
# don't need it and/or it's prefixed
|
# don't need it and/or it's prefixed
|
||||||
# in the section acnt.. we should
|
# in the section table.. we should
|
||||||
# just strip this from the message
|
# just strip this from the message
|
||||||
# right since `.broker` is already
|
# right since `.broker` is already
|
||||||
# included?
|
# included?
|
||||||
account='kraken.' + acctid,
|
account='kraken.' + acctid,
|
||||||
symbol=p.mkt.fqme,
|
symbol=p.mkt.fqme,
|
||||||
size=p.cumsize,
|
size=p.size,
|
||||||
avg_price=p.ppu,
|
avg_price=p.ppu,
|
||||||
currency='',
|
currency='',
|
||||||
)
|
)
|
||||||
|
|
@ -418,7 +416,7 @@ def trades2pps(
|
||||||
# as little as possible. we need to either do
|
# as little as possible. we need to either do
|
||||||
# these writes in another actor, or try out `trio`'s
|
# these writes in another actor, or try out `trio`'s
|
||||||
# async file IO api?
|
# async file IO api?
|
||||||
acnt.write_config()
|
table.write_config()
|
||||||
|
|
||||||
return position_msgs
|
return position_msgs
|
||||||
|
|
||||||
|
|
@ -429,12 +427,7 @@ async def open_trade_dialog(
|
||||||
|
|
||||||
) -> AsyncIterator[dict[str, Any]]:
|
) -> AsyncIterator[dict[str, Any]]:
|
||||||
|
|
||||||
async with (
|
async with get_client() as client:
|
||||||
# TODO: maybe bind these together and deliver
|
|
||||||
# a tuple from `.open_cached_client()`?
|
|
||||||
open_cached_client('kraken') as client,
|
|
||||||
open_symcache('kraken') as symcache,
|
|
||||||
):
|
|
||||||
# make ems flip to paper mode when no creds setup in
|
# make ems flip to paper mode when no creds setup in
|
||||||
# `brokers.toml` B0
|
# `brokers.toml` B0
|
||||||
if not client._api_key:
|
if not client._api_key:
|
||||||
|
|
@ -464,8 +457,8 @@ async def open_trade_dialog(
|
||||||
# - delete the *ABSOLUTE LAST* entry from account's corresponding
|
# - delete the *ABSOLUTE LAST* entry from account's corresponding
|
||||||
# trade ledgers file (NOTE this MUST be the last record
|
# trade ledgers file (NOTE this MUST be the last record
|
||||||
# delivered from the api ledger),
|
# delivered from the api ledger),
|
||||||
# - open you ``account.kraken.spot.toml`` and find that
|
# - open you ``pps.toml`` and find that same tid and delete it
|
||||||
# same tid and delete it from the pos's clears table,
|
# from the pp's clears table,
|
||||||
# - set this flag to `True`
|
# - set this flag to `True`
|
||||||
#
|
#
|
||||||
# You should see an update come in after the order mode
|
# You should see an update come in after the order mode
|
||||||
|
|
@ -476,85 +469,172 @@ async def open_trade_dialog(
|
||||||
# update things correctly.
|
# update things correctly.
|
||||||
simulate_pp_update: bool = False
|
simulate_pp_update: bool = False
|
||||||
|
|
||||||
acnt: Account
|
table: PpTable
|
||||||
ledger: TransactionLedger
|
ledger: TransactionLedger
|
||||||
with (
|
with (
|
||||||
open_account(
|
open_pps(
|
||||||
'kraken',
|
'kraken',
|
||||||
acctid,
|
acctid,
|
||||||
write_on_exit=True,
|
write_on_exit=True,
|
||||||
) as acnt,
|
) as table,
|
||||||
|
|
||||||
open_trade_ledger(
|
open_trade_ledger(
|
||||||
'kraken',
|
'kraken',
|
||||||
acctid,
|
acctid,
|
||||||
symcache=symcache,
|
|
||||||
) as ledger,
|
) as ledger,
|
||||||
):
|
):
|
||||||
# TODO: loading ledger entries should all be done
|
# transaction-ify the ledger entries
|
||||||
# within a newly implemented `async with open_account()
|
ledger_trans = await norm_trade_records(ledger)
|
||||||
# as acnt` where `Account.ledger: TransactionLedger`
|
|
||||||
# can be used to explicitily update and write the
|
|
||||||
# offline TOML files!
|
|
||||||
# ------ - ------
|
|
||||||
# MOL the init sequence is:
|
|
||||||
# - get `Account` (with presumed pre-loaded ledger done
|
|
||||||
# beind the scenes as part of ctx enter).
|
|
||||||
# - pull new trades from API, update the ledger with
|
|
||||||
# normalized to `Transaction` entries of those
|
|
||||||
# records, presumably (and implicitly) update the
|
|
||||||
# acnt state including expiries, positions,
|
|
||||||
# transfers..), and finally of course existing
|
|
||||||
# per-asset balances.
|
|
||||||
# - validate all pos and balances ensuring there's
|
|
||||||
# no seemingly noticeable discrepancies?
|
|
||||||
|
|
||||||
# LOAD and transaction-ify the EXISTING LEDGER
|
if not table.pps:
|
||||||
ledger_trans: dict[str, Transaction] = await norm_trade_records(
|
# NOTE: we can't use this since it first needs
|
||||||
ledger,
|
# broker: str input support!
|
||||||
client,
|
# table.update_from_trans(ledger.to_trans())
|
||||||
api_name_set='xname',
|
table.update_from_trans(ledger_trans)
|
||||||
)
|
table.write_config()
|
||||||
|
|
||||||
if not acnt.pps:
|
|
||||||
acnt.update_from_ledger(
|
|
||||||
ledger_trans,
|
|
||||||
symcache=ledger.symcache,
|
|
||||||
)
|
|
||||||
acnt.write_config()
|
|
||||||
|
|
||||||
# TODO: eventually probably only load
|
# TODO: eventually probably only load
|
||||||
# as far back as it seems is not deliverd in the
|
# as far back as it seems is not deliverd in the
|
||||||
# most recent 50 trades and assume that by ordering we
|
# most recent 50 trades and assume that by ordering we
|
||||||
# already have those records in the ledger?
|
# already have those records in the ledger.
|
||||||
tids2trades: dict[str, dict] = await client.get_trades()
|
tids2trades = await client.get_trades()
|
||||||
ledger.update(tids2trades)
|
ledger.update(tids2trades)
|
||||||
if tids2trades:
|
if tids2trades:
|
||||||
ledger.write_config()
|
ledger.write_config()
|
||||||
|
|
||||||
api_trans: dict[str, Transaction] = await norm_trade_records(
|
api_trans = await norm_trade_records(tids2trades)
|
||||||
tids2trades,
|
|
||||||
client,
|
|
||||||
api_name_set='xname',
|
|
||||||
)
|
|
||||||
|
|
||||||
# retrieve kraken reported balances
|
# retrieve kraken reported balances
|
||||||
# and do diff with ledger to determine
|
# and do diff with ledger to determine
|
||||||
# what amount of trades-transactions need
|
# what amount of trades-transactions need
|
||||||
# to be reloaded.
|
# to be reloaded.
|
||||||
balances: dict[str, float] = await client.get_balances()
|
balances = await client.get_balances()
|
||||||
|
|
||||||
verify_balances(
|
for dst, size in balances.items():
|
||||||
acnt,
|
|
||||||
|
# we don't care about tracking positions
|
||||||
|
# in the user's source fiat currency.
|
||||||
|
if (
|
||||||
|
dst == src_fiat
|
||||||
|
or not any(
|
||||||
|
dst in bs_mktid for bs_mktid in table.pps
|
||||||
|
)
|
||||||
|
):
|
||||||
|
log.warning(
|
||||||
|
f'Skipping balance `{dst}`:{size} for position calcs!'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
def has_pp(
|
||||||
|
dst: str,
|
||||||
|
size: float,
|
||||||
|
|
||||||
|
) -> Position | None:
|
||||||
|
|
||||||
|
src2dst: dict[str, str] = {}
|
||||||
|
|
||||||
|
for bs_mktid in table.pps:
|
||||||
|
likely_pair = get_likely_pair(
|
||||||
src_fiat,
|
src_fiat,
|
||||||
balances,
|
dst,
|
||||||
client,
|
bs_mktid,
|
||||||
ledger,
|
)
|
||||||
ledger_trans,
|
if likely_pair:
|
||||||
api_trans,
|
src2dst[src_fiat] = dst
|
||||||
|
|
||||||
|
for src, dst in src2dst.items():
|
||||||
|
pair = f'{dst}{src_fiat}'
|
||||||
|
pp = table.pps.get(pair)
|
||||||
|
if (
|
||||||
|
pp
|
||||||
|
and math.isclose(pp.size, size)
|
||||||
|
):
|
||||||
|
return pp
|
||||||
|
|
||||||
|
elif (
|
||||||
|
size == 0
|
||||||
|
and pp.size
|
||||||
|
):
|
||||||
|
log.warning(
|
||||||
|
f'`kraken` account says you have a ZERO '
|
||||||
|
f'balance for {bs_mktid}:{pair}\n'
|
||||||
|
f'but piker seems to think `{pp.size}`\n'
|
||||||
|
'This is likely a discrepancy in piker '
|
||||||
|
'accounting if the above number is'
|
||||||
|
"large,' though it's likely to due lack"
|
||||||
|
"f tracking xfers fees.."
|
||||||
|
)
|
||||||
|
return pp
|
||||||
|
|
||||||
|
return None # signal no entry
|
||||||
|
|
||||||
|
pos = has_pp(dst, size)
|
||||||
|
if not pos:
|
||||||
|
|
||||||
|
# we have a balance for which there is no pp
|
||||||
|
# entry? so we have to likely update from the
|
||||||
|
# ledger.
|
||||||
|
updated = table.update_from_trans(ledger_trans)
|
||||||
|
log.info(f'Updated pps from ledger:\n{pformat(updated)}')
|
||||||
|
pos = has_pp(dst, size)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not pos
|
||||||
|
and not simulate_pp_update
|
||||||
|
):
|
||||||
|
# try reloading from API
|
||||||
|
table.update_from_trans(api_trans)
|
||||||
|
pos = has_pp(dst, size)
|
||||||
|
if not pos:
|
||||||
|
|
||||||
|
# get transfers to make sense of abs balances.
|
||||||
|
# NOTE: we do this after ledger and API
|
||||||
|
# loading since we might not have an entry
|
||||||
|
# in the ``pps.toml`` for the necessary pair
|
||||||
|
# yet and thus this likely pair grabber will
|
||||||
|
# likely fail.
|
||||||
|
for bs_mktid in table.pps:
|
||||||
|
likely_pair = get_likely_pair(
|
||||||
|
src_fiat,
|
||||||
|
dst,
|
||||||
|
bs_mktid,
|
||||||
|
)
|
||||||
|
if likely_pair:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
'Could not find a position pair in '
|
||||||
|
'ledger for likely widthdrawal '
|
||||||
|
f'candidate: {dst}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# XXX NOTE: only for simulate-testing a "new fill" since
|
if likely_pair:
|
||||||
|
# this was likely pp that had a withdrawal
|
||||||
|
# from the dst asset out of the account.
|
||||||
|
|
||||||
|
xfer_trans = await client.get_xfers(
|
||||||
|
dst,
|
||||||
|
# TODO: not all src assets are
|
||||||
|
# 3 chars long...
|
||||||
|
src_asset=likely_pair[3:],
|
||||||
|
)
|
||||||
|
if xfer_trans:
|
||||||
|
updated = table.update_from_trans(
|
||||||
|
xfer_trans,
|
||||||
|
cost_scalar=1,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
f'Updated {dst} from transfers:\n'
|
||||||
|
f'{pformat(updated)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_pp(dst, size):
|
||||||
|
raise ValueError(
|
||||||
|
'Could not reproduce balance:\n'
|
||||||
|
f'dst: {dst}, {size}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# only for simulate-testing a "new fill" since
|
||||||
# otherwise we have to actually conduct a live clear.
|
# otherwise we have to actually conduct a live clear.
|
||||||
if simulate_pp_update:
|
if simulate_pp_update:
|
||||||
tid = list(tids2trades)[0]
|
tid = list(tids2trades)[0]
|
||||||
|
|
@ -563,27 +643,25 @@ async def open_trade_dialog(
|
||||||
reqids2txids[0] = last_trade_dict['ordertxid']
|
reqids2txids[0] = last_trade_dict['ordertxid']
|
||||||
|
|
||||||
ppmsgs: list[BrokerdPosition] = trades2pps(
|
ppmsgs: list[BrokerdPosition] = trades2pps(
|
||||||
acnt,
|
table,
|
||||||
ledger,
|
|
||||||
acctid,
|
acctid,
|
||||||
)
|
)
|
||||||
# sync with EMS delivering pps and accounts
|
|
||||||
await ctx.started((ppmsgs, [acc_name]))
|
await ctx.started((ppmsgs, [acc_name]))
|
||||||
|
|
||||||
# TODO: ideally this blocks the this task
|
# TODO: ideally this blocks the this task
|
||||||
# as little as possible. we need to either do
|
# as little as possible. we need to either do
|
||||||
# these writes in another actor, or try out `trio`'s
|
# these writes in another actor, or try out `trio`'s
|
||||||
# async file IO api?
|
# async file IO api?
|
||||||
acnt.write_config()
|
table.write_config()
|
||||||
|
|
||||||
# Get websocket token for authenticated data stream
|
# Get websocket token for authenticated data stream
|
||||||
# Assert that a token was actually received.
|
# Assert that a token was actually received.
|
||||||
resp = await client.endpoint('GetWebSocketsToken', {})
|
resp = await client.endpoint('GetWebSocketsToken', {})
|
||||||
if err := resp.get('error'):
|
err = resp.get('error')
|
||||||
|
if err:
|
||||||
raise BrokerError(err)
|
raise BrokerError(err)
|
||||||
|
|
||||||
# resp token for ws init
|
token = resp['result']['token']
|
||||||
token: str = resp['result']['token']
|
|
||||||
|
|
||||||
ws: NoBsWs
|
ws: NoBsWs
|
||||||
async with (
|
async with (
|
||||||
|
|
@ -612,35 +690,32 @@ async def open_trade_dialog(
|
||||||
|
|
||||||
# enter relay loop
|
# enter relay loop
|
||||||
await handle_order_updates(
|
await handle_order_updates(
|
||||||
client=client,
|
ws,
|
||||||
ws=ws,
|
stream,
|
||||||
ws_stream=stream,
|
ems_stream,
|
||||||
ems_stream=ems_stream,
|
apiflows,
|
||||||
apiflows=apiflows,
|
ids,
|
||||||
ids=ids,
|
reqids2txids,
|
||||||
reqids2txids=reqids2txids,
|
table,
|
||||||
acnt=acnt,
|
api_trans,
|
||||||
ledger=ledger,
|
acctid,
|
||||||
acctid=acctid,
|
acc_name,
|
||||||
acc_name=acc_name,
|
token,
|
||||||
token=token,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def handle_order_updates(
|
async def handle_order_updates(
|
||||||
client: Client, # only for pairs table needed in ledger proc
|
|
||||||
ws: NoBsWs,
|
ws: NoBsWs,
|
||||||
ws_stream: AsyncIterator,
|
ws_stream: AsyncIterator,
|
||||||
ems_stream: tractor.MsgStream,
|
ems_stream: tractor.MsgStream,
|
||||||
apiflows: OrderDialogs,
|
apiflows: OrderDialogs,
|
||||||
ids: bidict[str, int],
|
ids: bidict[str, int],
|
||||||
reqids2txids: bidict[int, str],
|
reqids2txids: bidict[int, str],
|
||||||
acnt: Account,
|
table: PpTable,
|
||||||
|
|
||||||
# transaction records which will be updated
|
# transaction records which will be updated
|
||||||
# on new trade clearing events (aka order "fills")
|
# on new trade clearing events (aka order "fills")
|
||||||
ledger: TransactionLedger,
|
ledger_trans: dict[str, Transaction],
|
||||||
# ledger_trans: dict[str, Transaction],
|
|
||||||
acctid: str,
|
acctid: str,
|
||||||
acc_name: str,
|
acc_name: str,
|
||||||
token: str,
|
token: str,
|
||||||
|
|
@ -658,7 +733,7 @@ async def handle_order_updates(
|
||||||
|
|
||||||
# TODO: turns out you get the fill events from the
|
# TODO: turns out you get the fill events from the
|
||||||
# `openOrders` before you get this, so it might be better
|
# `openOrders` before you get this, so it might be better
|
||||||
# to do all fill/status/pos updates in that sub and just use
|
# to do all fill/status/pp updates in that sub and just use
|
||||||
# this one for ledger syncs?
|
# this one for ledger syncs?
|
||||||
|
|
||||||
# For eg. we could take the "last 50 trades" and do a diff
|
# For eg. we could take the "last 50 trades" and do a diff
|
||||||
|
|
@ -700,8 +775,7 @@ async def handle_order_updates(
|
||||||
# if tid not in ledger_trans
|
# if tid not in ledger_trans
|
||||||
}
|
}
|
||||||
for tid, trade in trades.items():
|
for tid, trade in trades.items():
|
||||||
# assert tid not in ledger_trans
|
assert tid not in ledger_trans
|
||||||
assert tid not in ledger
|
|
||||||
txid = trade['ordertxid']
|
txid = trade['ordertxid']
|
||||||
reqid = trade.get('userref')
|
reqid = trade.get('userref')
|
||||||
|
|
||||||
|
|
@ -744,22 +818,12 @@ async def handle_order_updates(
|
||||||
)
|
)
|
||||||
await ems_stream.send(status_msg)
|
await ems_stream.send(status_msg)
|
||||||
|
|
||||||
new_trans = await norm_trade_records(
|
new_trans = await norm_trade_records(trades)
|
||||||
trades,
|
ppmsgs = trades2pps(
|
||||||
client,
|
table,
|
||||||
api_name_set='wsname',
|
acctid,
|
||||||
|
new_trans,
|
||||||
)
|
)
|
||||||
ppmsgs: list[BrokerdPosition] = trades2pps(
|
|
||||||
acnt=acnt,
|
|
||||||
ledger=ledger,
|
|
||||||
acctid=acctid,
|
|
||||||
new_trans=new_trans,
|
|
||||||
)
|
|
||||||
# ppmsgs = trades2pps(
|
|
||||||
# acnt,
|
|
||||||
# acctid,
|
|
||||||
# new_trans,
|
|
||||||
# )
|
|
||||||
for pp_msg in ppmsgs:
|
for pp_msg in ppmsgs:
|
||||||
await ems_stream.send(pp_msg)
|
await ems_stream.send(pp_msg)
|
||||||
|
|
||||||
|
|
@ -1119,3 +1183,36 @@ async def handle_order_updates(
|
||||||
})
|
})
|
||||||
case _:
|
case _:
|
||||||
log.warning(f'Unhandled trades update msg: {msg}')
|
log.warning(f'Unhandled trades update msg: {msg}')
|
||||||
|
|
||||||
|
|
||||||
|
async def norm_trade_records(
|
||||||
|
ledger: dict[str, Any],
|
||||||
|
|
||||||
|
) -> dict[str, Transaction]:
|
||||||
|
|
||||||
|
records: dict[str, Transaction] = {}
|
||||||
|
|
||||||
|
for tid, record in ledger.items():
|
||||||
|
|
||||||
|
size = float(record.get('vol')) * {
|
||||||
|
'buy': 1,
|
||||||
|
'sell': -1,
|
||||||
|
}[record['type']]
|
||||||
|
|
||||||
|
# we normalize to kraken's `altname` always..
|
||||||
|
bs_mktid: str = Client.normalize_symbol(record['pair'])
|
||||||
|
fqme = f'{bs_mktid.lower()}.kraken'
|
||||||
|
mkt: MktPair = (await get_mkt_info(fqme))[0]
|
||||||
|
|
||||||
|
records[tid] = Transaction(
|
||||||
|
fqme=fqme,
|
||||||
|
sym=mkt,
|
||||||
|
tid=tid,
|
||||||
|
size=size,
|
||||||
|
price=float(record['price']),
|
||||||
|
cost=float(record['fee']),
|
||||||
|
dt=pendulum.from_timestamp(float(record['time'])),
|
||||||
|
bs_mktid=bs_mktid,
|
||||||
|
)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
|
||||||
|
|
@ -30,29 +30,38 @@ from typing import (
|
||||||
)
|
)
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from fuzzywuzzy import process as fuzzy
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pendulum
|
import pendulum
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from piker.accounting._mktinfo import (
|
from piker.accounting._mktinfo import (
|
||||||
|
Asset,
|
||||||
MktPair,
|
MktPair,
|
||||||
|
unpack_fqme,
|
||||||
)
|
)
|
||||||
from piker.brokers import (
|
from piker.brokers import (
|
||||||
open_cached_client,
|
open_cached_client,
|
||||||
|
SymbolNotFound,
|
||||||
|
)
|
||||||
|
from piker._cacheables import (
|
||||||
|
async_lifo_cache,
|
||||||
)
|
)
|
||||||
from piker.brokers._util import (
|
from piker.brokers._util import (
|
||||||
BrokerError,
|
BrokerError,
|
||||||
DataThrottle,
|
DataThrottle,
|
||||||
DataUnavailable,
|
DataUnavailable,
|
||||||
)
|
)
|
||||||
from piker.types import Struct
|
from piker.data.types import Struct
|
||||||
from piker.data.validate import FeedInit
|
from piker.data.validate import FeedInit
|
||||||
from piker.data._web_bs import open_autorecon_ws, NoBsWs
|
from piker.data._web_bs import open_autorecon_ws, NoBsWs
|
||||||
from .api import (
|
from .api import (
|
||||||
log,
|
log,
|
||||||
|
Client,
|
||||||
|
Pair,
|
||||||
)
|
)
|
||||||
from .symbols import get_mkt_info
|
|
||||||
|
|
||||||
|
|
||||||
class OHLC(Struct, frozen=True):
|
class OHLC(Struct, frozen=True):
|
||||||
|
|
@ -258,6 +267,62 @@ async def open_history_client(
|
||||||
yield get_ohlc, {'erlangs': 1, 'rate': 1}
|
yield get_ohlc, {'erlangs': 1, 'rate': 1}
|
||||||
|
|
||||||
|
|
||||||
|
@async_lifo_cache()
|
||||||
|
async def get_mkt_info(
|
||||||
|
fqme: str,
|
||||||
|
|
||||||
|
) -> tuple[MktPair, Pair]:
|
||||||
|
'''
|
||||||
|
Query for and return a `MktPair` and backend-native `Pair` (or
|
||||||
|
wtv else) info.
|
||||||
|
|
||||||
|
If more then one fqme is provided return a ``dict`` of native
|
||||||
|
key-strs to `MktPair`s.
|
||||||
|
|
||||||
|
'''
|
||||||
|
venue: str = 'spot'
|
||||||
|
expiry: str = ''
|
||||||
|
if '.kraken' in fqme:
|
||||||
|
broker, pair, venue, expiry = unpack_fqme(fqme)
|
||||||
|
venue: str = venue or 'spot'
|
||||||
|
|
||||||
|
if venue != 'spot':
|
||||||
|
raise SymbolNotFound(
|
||||||
|
'kraken only supports spot markets right now!\n'
|
||||||
|
f'{fqme}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
async with open_cached_client('kraken') as client:
|
||||||
|
|
||||||
|
# uppercase since kraken bs_mktid is always upper
|
||||||
|
bs_fqme, _, broker = fqme.partition('.')
|
||||||
|
pair_str: str = bs_fqme.upper()
|
||||||
|
bs_mktid: str = Client.normalize_symbol(pair_str)
|
||||||
|
pair: Pair = await client.pair_info(pair_str)
|
||||||
|
|
||||||
|
assets = client.assets
|
||||||
|
dst_asset: Asset = assets[pair.base]
|
||||||
|
src_asset: Asset = assets[pair.quote]
|
||||||
|
|
||||||
|
mkt = MktPair(
|
||||||
|
dst=dst_asset,
|
||||||
|
src=src_asset,
|
||||||
|
|
||||||
|
price_tick=pair.price_tick,
|
||||||
|
size_tick=pair.size_tick,
|
||||||
|
bs_mktid=bs_mktid,
|
||||||
|
|
||||||
|
expiry=expiry,
|
||||||
|
venue=venue or 'spot',
|
||||||
|
|
||||||
|
# TODO: futes
|
||||||
|
# _atype=_atype,
|
||||||
|
|
||||||
|
broker='kraken',
|
||||||
|
)
|
||||||
|
return mkt, pair
|
||||||
|
|
||||||
|
|
||||||
async def stream_quotes(
|
async def stream_quotes(
|
||||||
|
|
||||||
send_chan: trio.abc.SendChannel,
|
send_chan: trio.abc.SendChannel,
|
||||||
|
|
@ -413,3 +478,30 @@ async def stream_quotes(
|
||||||
log.warning(f'Unknown WSS message: {typ}, {quote}')
|
log.warning(f'Unknown WSS message: {typ}, {quote}')
|
||||||
|
|
||||||
await send_chan.send({topic: quote})
|
await send_chan.send({topic: quote})
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def open_symbol_search(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> Client:
|
||||||
|
async with open_cached_client('kraken') as client:
|
||||||
|
|
||||||
|
# load all symbols locally for fast search
|
||||||
|
cache = await client.cache_symbols()
|
||||||
|
await ctx.started(cache)
|
||||||
|
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
async for pattern in stream:
|
||||||
|
|
||||||
|
matches = fuzzy.extractBests(
|
||||||
|
pattern,
|
||||||
|
cache,
|
||||||
|
score_cutoff=50,
|
||||||
|
)
|
||||||
|
# repack in dict form
|
||||||
|
await stream.send({
|
||||||
|
pair[0].altname: pair[0]
|
||||||
|
for pair in matches
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,269 +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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Trade transaction accounting and normalization.
|
|
||||||
|
|
||||||
'''
|
|
||||||
import math
|
|
||||||
from pprint import pformat
|
|
||||||
from typing import (
|
|
||||||
Any,
|
|
||||||
)
|
|
||||||
|
|
||||||
import pendulum
|
|
||||||
|
|
||||||
from piker.accounting import (
|
|
||||||
Transaction,
|
|
||||||
Position,
|
|
||||||
Account,
|
|
||||||
get_likely_pair,
|
|
||||||
TransactionLedger,
|
|
||||||
# MktPair,
|
|
||||||
)
|
|
||||||
from piker.types import Struct
|
|
||||||
from piker.data import (
|
|
||||||
SymbologyCache,
|
|
||||||
)
|
|
||||||
from .api import (
|
|
||||||
log,
|
|
||||||
Client,
|
|
||||||
Pair,
|
|
||||||
)
|
|
||||||
# from .feed import get_mkt_info
|
|
||||||
|
|
||||||
|
|
||||||
def norm_trade(
|
|
||||||
tid: str,
|
|
||||||
record: dict[str, Any],
|
|
||||||
|
|
||||||
# this is the dict that was returned from
|
|
||||||
# `Client.get_mkt_pairs()` and when running offline ledger
|
|
||||||
# processing from `.accounting`, this will be the table loaded
|
|
||||||
# into `SymbologyCache.pairs`.
|
|
||||||
pairs: dict[str, Struct],
|
|
||||||
symcache: SymbologyCache | None = None,
|
|
||||||
|
|
||||||
) -> Transaction:
|
|
||||||
|
|
||||||
size: float = float(record.get('vol')) * {
|
|
||||||
'buy': 1,
|
|
||||||
'sell': -1,
|
|
||||||
}[record['type']]
|
|
||||||
|
|
||||||
# NOTE: this value may be either the websocket OR the rest schema
|
|
||||||
# so we need to detect the key format and then choose the
|
|
||||||
# correct symbol lookup table to evetually get a ``Pair``..
|
|
||||||
# See internals of `Client.asset_pairs()` for deats!
|
|
||||||
src_pair_key: str = record['pair']
|
|
||||||
|
|
||||||
# XXX: kraken's data engineering is soo bad they require THREE
|
|
||||||
# different pair schemas (more or less seemingly tied to
|
|
||||||
# transport-APIs)..LITERALLY they return different market id
|
|
||||||
# pairs in the ledger endpoints vs. the websocket event subs..
|
|
||||||
# lookup pair using appropriately provided tabled depending
|
|
||||||
# on API-key-schema..
|
|
||||||
pair: Pair = pairs[src_pair_key]
|
|
||||||
fqme: str = pair.bs_fqme.lower() + '.kraken'
|
|
||||||
|
|
||||||
return Transaction(
|
|
||||||
fqme=fqme,
|
|
||||||
tid=tid,
|
|
||||||
size=size,
|
|
||||||
price=float(record['price']),
|
|
||||||
cost=float(record['fee']),
|
|
||||||
dt=pendulum.from_timestamp(float(record['time'])),
|
|
||||||
bs_mktid=pair.bs_mktid,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def norm_trade_records(
|
|
||||||
ledger: dict[str, Any],
|
|
||||||
client: Client,
|
|
||||||
api_name_set: str = 'xname',
|
|
||||||
|
|
||||||
) -> dict[str, Transaction]:
|
|
||||||
'''
|
|
||||||
Loop through an input ``dict`` of trade records
|
|
||||||
and convert them to ``Transactions``.
|
|
||||||
|
|
||||||
'''
|
|
||||||
records: dict[str, Transaction] = {}
|
|
||||||
for tid, record in ledger.items():
|
|
||||||
|
|
||||||
# manual_fqme: str = f'{bs_mktid.lower()}.kraken'
|
|
||||||
# mkt: MktPair = (await get_mkt_info(manual_fqme))[0]
|
|
||||||
# fqme: str = mkt.fqme
|
|
||||||
# assert fqme == manual_fqme
|
|
||||||
pairs: dict[str, Pair] = {
|
|
||||||
'xname': client._AssetPairs,
|
|
||||||
'wsname': client._wsnames,
|
|
||||||
'altname': client._altnames,
|
|
||||||
}[api_name_set]
|
|
||||||
|
|
||||||
records[tid] = norm_trade(
|
|
||||||
tid,
|
|
||||||
record,
|
|
||||||
pairs=pairs,
|
|
||||||
)
|
|
||||||
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
def has_pp(
|
|
||||||
acnt: Account,
|
|
||||||
src_fiat: str,
|
|
||||||
dst: str,
|
|
||||||
size: float,
|
|
||||||
|
|
||||||
) -> Position | None:
|
|
||||||
|
|
||||||
src2dst: dict[str, str] = {}
|
|
||||||
for bs_mktid in acnt.pps:
|
|
||||||
likely_pair = get_likely_pair(
|
|
||||||
src_fiat,
|
|
||||||
dst,
|
|
||||||
bs_mktid,
|
|
||||||
)
|
|
||||||
if likely_pair:
|
|
||||||
src2dst[src_fiat] = dst
|
|
||||||
|
|
||||||
for src, dst in src2dst.items():
|
|
||||||
pair: str = f'{dst}{src_fiat}'
|
|
||||||
pos: Position = acnt.pps.get(pair)
|
|
||||||
if (
|
|
||||||
pos
|
|
||||||
and math.isclose(pos.size, size)
|
|
||||||
):
|
|
||||||
return pos
|
|
||||||
|
|
||||||
elif (
|
|
||||||
size == 0
|
|
||||||
and pos.size
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
f'`kraken` account says you have a ZERO '
|
|
||||||
f'balance for {bs_mktid}:{pair}\n'
|
|
||||||
f'but piker seems to think `{pos.size}`\n'
|
|
||||||
'This is likely a discrepancy in piker '
|
|
||||||
'accounting if the above number is'
|
|
||||||
"large,' though it's likely to due lack"
|
|
||||||
"f tracking xfers fees.."
|
|
||||||
)
|
|
||||||
return pos
|
|
||||||
|
|
||||||
return None # indicate no entry found
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: factor most of this "account updating from txns" into the
|
|
||||||
# the `Account` impl so has to provide for hiding the mostly
|
|
||||||
# cross-provider updates from txn sets
|
|
||||||
async def verify_balances(
|
|
||||||
acnt: Account,
|
|
||||||
src_fiat: str,
|
|
||||||
balances: dict[str, float],
|
|
||||||
client: Client,
|
|
||||||
ledger: TransactionLedger,
|
|
||||||
ledger_trans: dict[str, Transaction], # from toml
|
|
||||||
api_trans: dict[str, Transaction], # from API
|
|
||||||
|
|
||||||
simulate_pp_update: bool = False,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
for dst, size in balances.items():
|
|
||||||
|
|
||||||
# we don't care about tracking positions
|
|
||||||
# in the user's source fiat currency.
|
|
||||||
if (
|
|
||||||
dst == src_fiat
|
|
||||||
or not any(
|
|
||||||
dst in bs_mktid for bs_mktid in acnt.pps
|
|
||||||
)
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
f'Skipping balance `{dst}`:{size} for position calcs!'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# we have a balance for which there is no pos entry
|
|
||||||
# - we have to likely update from the ledger?
|
|
||||||
if not has_pp(acnt, src_fiat, dst, size):
|
|
||||||
updated = acnt.update_from_ledger(
|
|
||||||
ledger_trans,
|
|
||||||
symcache=ledger.symcache,
|
|
||||||
)
|
|
||||||
log.info(f'Updated pps from ledger:\n{pformat(updated)}')
|
|
||||||
|
|
||||||
# FIRST try reloading from API records
|
|
||||||
if (
|
|
||||||
not has_pp(acnt, src_fiat, dst, size)
|
|
||||||
and not simulate_pp_update
|
|
||||||
):
|
|
||||||
acnt.update_from_ledger(
|
|
||||||
api_trans,
|
|
||||||
symcache=ledger.symcache,
|
|
||||||
)
|
|
||||||
|
|
||||||
# get transfers to make sense of abs
|
|
||||||
# balances.
|
|
||||||
# NOTE: we do this after ledger and API
|
|
||||||
# loading since we might not have an
|
|
||||||
# entry in the
|
|
||||||
# ``account.kraken.spot.toml`` for the
|
|
||||||
# necessary pair yet and thus this
|
|
||||||
# likely pair grabber will likely fail.
|
|
||||||
if not has_pp(acnt, src_fiat, dst, size):
|
|
||||||
for bs_mktid in acnt.pps:
|
|
||||||
likely_pair: str | None = get_likely_pair(
|
|
||||||
src_fiat,
|
|
||||||
dst,
|
|
||||||
bs_mktid,
|
|
||||||
)
|
|
||||||
if likely_pair:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
'Could not find a position pair in '
|
|
||||||
'ledger for likely widthdrawal '
|
|
||||||
f'candidate: {dst}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# this was likely pos that had a withdrawal
|
|
||||||
# from the dst asset out of the account.
|
|
||||||
if likely_pair:
|
|
||||||
xfer_trans = await client.get_xfers(
|
|
||||||
dst,
|
|
||||||
|
|
||||||
# TODO: not all src assets are
|
|
||||||
# 3 chars long...
|
|
||||||
src_asset=likely_pair[3:],
|
|
||||||
)
|
|
||||||
if xfer_trans:
|
|
||||||
updated = acnt.update_from_ledger(
|
|
||||||
xfer_trans,
|
|
||||||
cost_scalar=1,
|
|
||||||
symcache=ledger.symcache,
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
f'Updated {dst} from transfers:\n'
|
|
||||||
f'{pformat(updated)}'
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_pp(acnt, src_fiat, dst, size):
|
|
||||||
raise ValueError(
|
|
||||||
'Could not reproduce balance:\n'
|
|
||||||
f'dst: {dst}, {size}\n'
|
|
||||||
)
|
|
||||||
|
|
@ -1,206 +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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Symbology defs and search.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import tractor
|
|
||||||
from rapidfuzz import process as fuzzy
|
|
||||||
|
|
||||||
from piker._cacheables import (
|
|
||||||
async_lifo_cache,
|
|
||||||
)
|
|
||||||
from piker.accounting._mktinfo import (
|
|
||||||
digits_to_dec,
|
|
||||||
)
|
|
||||||
from piker.brokers import (
|
|
||||||
open_cached_client,
|
|
||||||
SymbolNotFound,
|
|
||||||
)
|
|
||||||
from piker.types import Struct
|
|
||||||
from piker.accounting._mktinfo import (
|
|
||||||
Asset,
|
|
||||||
MktPair,
|
|
||||||
unpack_fqme,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# https://www.kraken.com/features/api#get-tradable-pairs
|
|
||||||
class Pair(Struct):
|
|
||||||
xname: str # idiotic bs_mktid equiv i guess?
|
|
||||||
altname: str # alternate pair name
|
|
||||||
wsname: str # WebSocket pair name (if available)
|
|
||||||
aclass_base: str # asset class of base component
|
|
||||||
base: str # asset id of base component
|
|
||||||
aclass_quote: str # asset class of quote component
|
|
||||||
quote: str # asset id of quote component
|
|
||||||
lot: str # volume lot size
|
|
||||||
|
|
||||||
cost_decimals: int
|
|
||||||
costmin: float
|
|
||||||
pair_decimals: int # scaling decimal places for pair
|
|
||||||
lot_decimals: int # scaling decimal places for volume
|
|
||||||
|
|
||||||
# amount to multiply lot volume by to get currency volume
|
|
||||||
lot_multiplier: float
|
|
||||||
|
|
||||||
# array of leverage amounts available when buying
|
|
||||||
leverage_buy: list[int]
|
|
||||||
# array of leverage amounts available when selling
|
|
||||||
leverage_sell: list[int]
|
|
||||||
|
|
||||||
# fee schedule array in [volume, percent fee] tuples
|
|
||||||
fees: list[tuple[int, float]]
|
|
||||||
|
|
||||||
# maker fee schedule array in [volume, percent fee] tuples (if on
|
|
||||||
# maker/taker)
|
|
||||||
fees_maker: list[tuple[int, float]]
|
|
||||||
|
|
||||||
fee_volume_currency: str # volume discount currency
|
|
||||||
margin_call: str # margin call level
|
|
||||||
margin_stop: str # stop-out/liquidation margin level
|
|
||||||
ordermin: float # minimum order volume for pair
|
|
||||||
tick_size: float # min price step size
|
|
||||||
status: str
|
|
||||||
|
|
||||||
short_position_limit: float = 0
|
|
||||||
long_position_limit: float = float('inf')
|
|
||||||
|
|
||||||
# TODO: should we make this a literal NamespacePath ref?
|
|
||||||
ns_path: str = 'piker.brokers.kraken:Pair'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_mktid(self) -> str:
|
|
||||||
'''
|
|
||||||
Kraken seems to index it's market symbol sets in
|
|
||||||
transaction ledgers using the key returned from rest
|
|
||||||
queries.. so use that since apparently they can't
|
|
||||||
make up their minds on a better key set XD
|
|
||||||
|
|
||||||
'''
|
|
||||||
return self.xname
|
|
||||||
|
|
||||||
@property
|
|
||||||
def price_tick(self) -> Decimal:
|
|
||||||
return digits_to_dec(self.pair_decimals)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size_tick(self) -> Decimal:
|
|
||||||
return digits_to_dec(self.lot_decimals)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_dst_asset(self) -> str:
|
|
||||||
dst, _ = self.wsname.split('/')
|
|
||||||
return dst
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_src_asset(self) -> str:
|
|
||||||
_, src = self.wsname.split('/')
|
|
||||||
return src
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bs_fqme(self) -> str:
|
|
||||||
'''
|
|
||||||
Basically the `.altname` but with special '.' handling and
|
|
||||||
`.SPOT` suffix appending (for future multi-venue support).
|
|
||||||
|
|
||||||
'''
|
|
||||||
dst, src = self.wsname.split('/')
|
|
||||||
# XXX: omg for stupid shite like ETH2.S/ETH..
|
|
||||||
dst = dst.replace('.', '-')
|
|
||||||
return f'{dst}{src}.SPOT'
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
|
||||||
async def open_symbol_search(ctx: tractor.Context) -> None:
|
|
||||||
async with open_cached_client('kraken') as client:
|
|
||||||
|
|
||||||
# load all symbols locally for fast search
|
|
||||||
cache = await client.get_mkt_pairs()
|
|
||||||
await ctx.started(cache)
|
|
||||||
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
async for pattern in stream:
|
|
||||||
await stream.send(
|
|
||||||
await client.search_symbols(pattern)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@async_lifo_cache()
|
|
||||||
async def get_mkt_info(
|
|
||||||
fqme: str,
|
|
||||||
|
|
||||||
) -> tuple[MktPair, Pair]:
|
|
||||||
'''
|
|
||||||
Query for and return a `MktPair` and backend-native `Pair` (or
|
|
||||||
wtv else) info.
|
|
||||||
|
|
||||||
If more then one fqme is provided return a ``dict`` of native
|
|
||||||
key-strs to `MktPair`s.
|
|
||||||
|
|
||||||
'''
|
|
||||||
venue: str = 'spot'
|
|
||||||
expiry: str = ''
|
|
||||||
if '.kraken' not in fqme:
|
|
||||||
fqme += '.kraken'
|
|
||||||
|
|
||||||
broker, pair, venue, expiry = unpack_fqme(fqme)
|
|
||||||
venue: str = venue or 'spot'
|
|
||||||
|
|
||||||
if venue.lower() != 'spot':
|
|
||||||
raise SymbolNotFound(
|
|
||||||
'kraken only supports spot markets right now!\n'
|
|
||||||
f'{fqme}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
async with open_cached_client('kraken') as client:
|
|
||||||
|
|
||||||
# uppercase since kraken bs_mktid is always upper
|
|
||||||
# bs_fqme, _, broker = fqme.partition('.')
|
|
||||||
# pair_str: str = bs_fqme.upper()
|
|
||||||
pair_str: str = f'{pair}.{venue}'
|
|
||||||
|
|
||||||
pair: Pair | None = client._pairs.get(pair_str.upper())
|
|
||||||
if not pair:
|
|
||||||
bs_fqme: str = client.to_bs_fqme(pair_str)
|
|
||||||
pair: Pair = client._pairs[bs_fqme]
|
|
||||||
|
|
||||||
if not (assets := client._assets):
|
|
||||||
assets: dict[str, Asset] = await client.get_assets()
|
|
||||||
|
|
||||||
dst_asset: Asset = assets[pair.bs_dst_asset]
|
|
||||||
src_asset: Asset = assets[pair.bs_src_asset]
|
|
||||||
|
|
||||||
mkt = MktPair(
|
|
||||||
dst=dst_asset,
|
|
||||||
src=src_asset,
|
|
||||||
|
|
||||||
price_tick=pair.price_tick,
|
|
||||||
size_tick=pair.size_tick,
|
|
||||||
bs_mktid=pair.bs_mktid,
|
|
||||||
|
|
||||||
expiry=expiry,
|
|
||||||
venue=venue or 'spot',
|
|
||||||
|
|
||||||
# TODO: futes
|
|
||||||
# _atype=_atype,
|
|
||||||
|
|
||||||
broker='kraken',
|
|
||||||
)
|
|
||||||
return mkt, pair
|
|
||||||
|
|
@ -16,9 +16,10 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Kucoin cex API backend.
|
Kucoin broker backend
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
from contextlib import (
|
from contextlib import (
|
||||||
asynccontextmanager as acm,
|
asynccontextmanager as acm,
|
||||||
aclosing,
|
aclosing,
|
||||||
|
|
@ -40,8 +41,9 @@ from typing import (
|
||||||
import wsproto
|
import wsproto
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fuzzywuzzy import process as fuzzy
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
import httpx
|
import asks
|
||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pendulum
|
import pendulum
|
||||||
|
|
@ -62,11 +64,8 @@ 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.data.types import Struct
|
||||||
from piker.data import (
|
from piker.data import def_iohlcv_fields
|
||||||
def_iohlcv_fields,
|
|
||||||
match_from_pairs,
|
|
||||||
)
|
|
||||||
from piker.data._web_bs import (
|
from piker.data._web_bs import (
|
||||||
open_autorecon_ws,
|
open_autorecon_ws,
|
||||||
NoBsWs,
|
NoBsWs,
|
||||||
|
|
@ -75,8 +74,6 @@ from ._util import DataUnavailable
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
_no_symcache: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class KucoinMktPair(Struct, frozen=True):
|
class KucoinMktPair(Struct, frozen=True):
|
||||||
'''
|
'''
|
||||||
|
|
@ -89,27 +86,18 @@ class KucoinMktPair(Struct, frozen=True):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def price_tick(self) -> Decimal:
|
def price_tick(self) -> Decimal:
|
||||||
return Decimal(str(self.quoteIncrement))
|
return Decimal(str(self.baseIncrement))
|
||||||
|
|
||||||
baseMaxSize: float
|
baseMaxSize: float
|
||||||
baseMinSize: float
|
baseMinSize: float
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size_tick(self) -> Decimal:
|
def size_tick(self) -> Decimal:
|
||||||
return Decimal(str(self.quoteMinSize))
|
return Decimal(str(self.baseMinSize))
|
||||||
|
|
||||||
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 +107,7 @@ 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
|
||||||
takerFeeCoefficient: float
|
|
||||||
tradingStartTime: float|None
|
|
||||||
|
|
||||||
|
|
||||||
class AccountTrade(Struct, frozen=True):
|
class AccountTrade(Struct, frozen=True):
|
||||||
|
|
@ -222,13 +207,8 @@ def get_config() -> BrokerConfig | None:
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
|
def __init__(self) -> None:
|
||||||
def __init__(
|
self._config: BrokerConfig | None = get_config()
|
||||||
self,
|
|
||||||
httpx_client: httpx.AsyncClient,
|
|
||||||
) -> None:
|
|
||||||
self._http: httpx.AsyncClient = httpx_client
|
|
||||||
self._config: BrokerConfig|None = get_config()
|
|
||||||
self._pairs: dict[str, KucoinMktPair] = {}
|
self._pairs: dict[str, KucoinMktPair] = {}
|
||||||
self._fqmes2mktids: bidict[str, str] = bidict()
|
self._fqmes2mktids: bidict[str, str] = bidict()
|
||||||
self._bars: list[list[float]] = []
|
self._bars: list[list[float]] = []
|
||||||
|
|
@ -242,24 +222,18 @@ class Client:
|
||||||
|
|
||||||
) -> dict[str, str | bytes]:
|
) -> dict[str, str | bytes]:
|
||||||
'''
|
'''
|
||||||
Generate authenticated request headers:
|
Generate authenticated request headers
|
||||||
|
|
||||||
https://docs.kucoin.com/#authentication
|
https://docs.kucoin.com/#authentication
|
||||||
https://www.kucoin.com/docs/basic-info/connection-method/authentication/creating-a-request
|
|
||||||
https://www.kucoin.com/docs/basic-info/connection-method/authentication/signing-a-message
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
if not self._config:
|
if not self._config:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'No config found when trying to send authenticated request'
|
'No config found when trying to send authenticated request')
|
||||||
)
|
|
||||||
|
|
||||||
str_to_sign = (
|
str_to_sign = (
|
||||||
str(int(time.time() * 1000))
|
str(int(time.time() * 1000))
|
||||||
+
|
+ action + f'/api/{api}/{endpoint.lstrip("/")}'
|
||||||
action
|
|
||||||
+
|
|
||||||
f'/api/{api}/{endpoint.lstrip("/")}'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
signature = base64.b64encode(
|
signature = base64.b64encode(
|
||||||
|
|
@ -270,7 +244,6 @@ class Client:
|
||||||
).digest()
|
).digest()
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: can we cache this between calls?
|
|
||||||
passphrase = base64.b64encode(
|
passphrase = base64.b64encode(
|
||||||
hmac.new(
|
hmac.new(
|
||||||
self._config.key_secret.encode('utf-8'),
|
self._config.key_secret.encode('utf-8'),
|
||||||
|
|
@ -292,10 +265,8 @@ class Client:
|
||||||
self,
|
self,
|
||||||
action: Literal['POST', 'GET'],
|
action: Literal['POST', 'GET'],
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
|
|
||||||
api: str = 'v2',
|
api: str = 'v2',
|
||||||
headers: dict = {},
|
headers: dict = {},
|
||||||
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
'''
|
'''
|
||||||
Generic request wrapper for Kucoin API
|
Generic request wrapper for Kucoin API
|
||||||
|
|
@ -308,19 +279,14 @@ class Client:
|
||||||
api,
|
api,
|
||||||
)
|
)
|
||||||
|
|
||||||
req_meth: Callable = getattr(
|
api_url = f'https://api.kucoin.com/api/{api}/{endpoint}'
|
||||||
self._http,
|
|
||||||
action.lower(),
|
res = await asks.request(action, api_url, headers=headers)
|
||||||
)
|
|
||||||
res = await req_meth(
|
json = res.json()
|
||||||
url=f'/{api}/{endpoint}',
|
if 'data' in json:
|
||||||
headers=headers,
|
return json['data']
|
||||||
)
|
|
||||||
json: dict = res.json()
|
|
||||||
if (data := json.get('data')) is not None:
|
|
||||||
return data
|
|
||||||
else:
|
else:
|
||||||
api_url: str = self._http.base_url
|
|
||||||
log.error(
|
log.error(
|
||||||
f'Error making request to {api_url} ->\n'
|
f'Error making request to {api_url} ->\n'
|
||||||
f'{pformat(res)}'
|
f'{pformat(res)}'
|
||||||
|
|
@ -340,7 +306,7 @@ class Client:
|
||||||
'''
|
'''
|
||||||
token_type = 'private' if private else 'public'
|
token_type = 'private' if private else 'public'
|
||||||
try:
|
try:
|
||||||
data: dict[str, Any]|None = await self._request(
|
data: dict[str, Any] | None = await self._request(
|
||||||
'POST',
|
'POST',
|
||||||
endpoint=f'bullet-{token_type}',
|
endpoint=f'bullet-{token_type}',
|
||||||
api='v1'
|
api='v1'
|
||||||
|
|
@ -378,8 +344,8 @@ class Client:
|
||||||
currencies: dict[str, Currency] = {}
|
currencies: dict[str, Currency] = {}
|
||||||
entries: list[dict] = await self._request(
|
entries: list[dict] = await self._request(
|
||||||
'GET',
|
'GET',
|
||||||
endpoint='currencies',
|
|
||||||
api='v1',
|
api='v1',
|
||||||
|
endpoint='currencies',
|
||||||
)
|
)
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
curr = Currency(**entry).copy()
|
curr = Currency(**entry).copy()
|
||||||
|
|
@ -395,29 +361,20 @@ class Client:
|
||||||
dict[str, KucoinMktPair],
|
dict[str, KucoinMktPair],
|
||||||
bidict[str, KucoinMktPair],
|
bidict[str, KucoinMktPair],
|
||||||
]:
|
]:
|
||||||
entries = await self._request(
|
entries = await self._request('GET', 'symbols')
|
||||||
'GET',
|
|
||||||
endpoint='symbols',
|
|
||||||
)
|
|
||||||
log.info(f' {len(entries)} Kucoin market pairs fetched')
|
log.info(f' {len(entries)} Kucoin market pairs fetched')
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
return pairs, fqmes2mktids
|
return pairs, fqmes2mktids
|
||||||
|
|
||||||
async def get_mkt_pairs(
|
async def cache_pairs(
|
||||||
self,
|
self,
|
||||||
update: bool = False,
|
update: bool = False,
|
||||||
|
|
||||||
|
|
@ -445,27 +402,16 @@ class Client:
|
||||||
|
|
||||||
) -> dict[str, KucoinMktPair]:
|
) -> dict[str, KucoinMktPair]:
|
||||||
'''
|
'''
|
||||||
Use fuzzy search engine to match against pairs, deliver
|
Use fuzzy search to match against all market names.
|
||||||
matching ones.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if not len(self._pairs):
|
data = await self.cache_pairs()
|
||||||
await self.get_mkt_pairs()
|
|
||||||
assert self._pairs, '`Client.get_mkt_pairs()` was never called!?'
|
|
||||||
|
|
||||||
matches: dict[str, KucoinMktPair] = match_from_pairs(
|
matches = fuzzy.extractBests(
|
||||||
pairs=self._pairs,
|
pattern, data, score_cutoff=35, limit=limit
|
||||||
# query=pattern.upper(),
|
|
||||||
query=pattern.upper(),
|
|
||||||
score_cutoff=35,
|
|
||||||
limit=limit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# repack in dict form
|
# repack in dict form
|
||||||
return {
|
return {item[0].name: item[0] for item in matches}
|
||||||
pair.name: pair
|
|
||||||
for pair in matches.values()
|
|
||||||
}
|
|
||||||
|
|
||||||
async def last_trades(self, sym: str) -> list[AccountTrade]:
|
async def last_trades(self, sym: str) -> list[AccountTrade]:
|
||||||
trades = await self._request(
|
trades = await self._request(
|
||||||
|
|
@ -605,18 +551,10 @@ def fqme_to_kucoin_sym(
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def get_client() -> AsyncGenerator[Client, None]:
|
async def get_client() -> AsyncGenerator[Client, None]:
|
||||||
'''
|
client = Client()
|
||||||
Load an API `Client` preconfigured from user settings
|
|
||||||
|
|
||||||
'''
|
async with trio.open_nursery() as n:
|
||||||
async with (
|
n.start_soon(client.cache_pairs)
|
||||||
httpx.AsyncClient(
|
|
||||||
base_url='https://api.kucoin.com/api',
|
|
||||||
) as trio_client,
|
|
||||||
):
|
|
||||||
client = Client(httpx_client=trio_client)
|
|
||||||
async with trio.open_nursery() as tn:
|
|
||||||
tn.start_soon(client.get_mkt_pairs)
|
|
||||||
await client.get_currencies()
|
await client.get_currencies()
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
@ -628,7 +566,7 @@ async def open_symbol_search(
|
||||||
) -> None:
|
) -> None:
|
||||||
async with open_cached_client('kucoin') as client:
|
async with open_cached_client('kucoin') as client:
|
||||||
# load all symbols locally for fast search
|
# load all symbols locally for fast search
|
||||||
await client.get_mkt_pairs()
|
await client.cache_pairs()
|
||||||
await ctx.started()
|
await ctx.started()
|
||||||
|
|
||||||
async with ctx.open_stream() as stream:
|
async with ctx.open_stream() as stream:
|
||||||
|
|
@ -655,7 +593,7 @@ async def open_ping_task(
|
||||||
await trio.sleep((ping_interval - 1000) / 1000)
|
await trio.sleep((ping_interval - 1000) / 1000)
|
||||||
await ws.send_msg({'id': connect_id, 'type': 'ping'})
|
await ws.send_msg({'id': connect_id, 'type': 'ping'})
|
||||||
|
|
||||||
log.warning('Starting ping task for kucoin ws connection')
|
log.info('Starting ping task for kucoin ws connection')
|
||||||
n.start_soon(ping_server)
|
n.start_soon(ping_server)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
@ -667,21 +605,16 @@ async def open_ping_task(
|
||||||
async def get_mkt_info(
|
async def get_mkt_info(
|
||||||
fqme: str,
|
fqme: str,
|
||||||
|
|
||||||
) -> tuple[
|
) -> tuple[MktPair, KucoinMktPair]:
|
||||||
MktPair,
|
|
||||||
KucoinMktPair,
|
|
||||||
]:
|
|
||||||
'''
|
'''
|
||||||
Query for and return both a `piker.accounting.MktPair` and
|
Query for and return a `MktPair` and `KucoinMktPair`.
|
||||||
`KucoinMktPair` from provided `fqme: str`
|
|
||||||
(fully-qualified-market-endpoint).
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async with open_cached_client('kucoin') as client:
|
async with open_cached_client('kucoin') as client:
|
||||||
# split off any fqme broker part
|
# split off any fqme broker part
|
||||||
bs_fqme, _, broker = fqme.partition('.')
|
bs_fqme, _, broker = fqme.partition('.')
|
||||||
|
|
||||||
pairs: dict[str, KucoinMktPair] = await client.get_mkt_pairs()
|
pairs: dict[str, KucoinMktPair] = await client.cache_pairs()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# likely search result key which is already in native mkt symbol form
|
# likely search result key which is already in native mkt symbol form
|
||||||
|
|
@ -749,8 +682,6 @@ async def stream_quotes(
|
||||||
|
|
||||||
log.info(f'Starting up quote stream(s) for {symbols}')
|
log.info(f'Starting up quote stream(s) for {symbols}')
|
||||||
for sym_str in symbols:
|
for sym_str in symbols:
|
||||||
mkt: MktPair
|
|
||||||
pair: KucoinMktPair
|
|
||||||
mkt, pair = await get_mkt_info(sym_str)
|
mkt, pair = await get_mkt_info(sym_str)
|
||||||
init_msgs.append(
|
init_msgs.append(
|
||||||
FeedInit(mkt_info=mkt)
|
FeedInit(mkt_info=mkt)
|
||||||
|
|
@ -758,11 +689,7 @@ async def stream_quotes(
|
||||||
|
|
||||||
ws: NoBsWs
|
ws: NoBsWs
|
||||||
token, ping_interval = await client._get_ws_token()
|
token, ping_interval = await client._get_ws_token()
|
||||||
log.info('API reported ping_interval: {ping_interval}\n')
|
connect_id = str(uuid4())
|
||||||
|
|
||||||
connect_id: str = str(uuid4())
|
|
||||||
typ: str
|
|
||||||
quote: dict
|
|
||||||
async with (
|
async with (
|
||||||
open_autorecon_ws(
|
open_autorecon_ws(
|
||||||
(
|
(
|
||||||
|
|
@ -776,37 +703,20 @@ async def stream_quotes(
|
||||||
),
|
),
|
||||||
) as ws,
|
) as ws,
|
||||||
open_ping_task(ws, ping_interval, connect_id),
|
open_ping_task(ws, ping_interval, connect_id),
|
||||||
aclosing(
|
aclosing(stream_messages(ws, sym_str)) as msg_gen,
|
||||||
iter_normed_quotes(
|
|
||||||
ws, sym_str
|
|
||||||
)
|
|
||||||
) as iter_quotes,
|
|
||||||
):
|
):
|
||||||
typ, quote = await anext(iter_quotes)
|
typ, quote = await anext(msg_gen)
|
||||||
|
|
||||||
|
while typ != 'trade':
|
||||||
# take care to not unblock here until we get a real
|
# take care to not unblock here until we get a real
|
||||||
# trade quote?
|
# trade quote
|
||||||
# ^TODO, remove this right?
|
typ, quote = await anext(msg_gen)
|
||||||
# -[ ] what often blocks chart boot/new-feed switching
|
|
||||||
# since we'ere waiting for a live quote instead of just
|
|
||||||
# loading history afap..
|
|
||||||
# |_ XXX, not sure if we require a bit of rework to core
|
|
||||||
# feed init logic or if backends justg gotta be
|
|
||||||
# changed up.. feel like there was some causality
|
|
||||||
# dilema prolly only seen with IB too..
|
|
||||||
# while typ != 'trade':
|
|
||||||
# typ, quote = await anext(iter_quotes)
|
|
||||||
|
|
||||||
task_status.started((init_msgs, quote))
|
task_status.started((init_msgs, quote))
|
||||||
feed_is_live.set()
|
feed_is_live.set()
|
||||||
|
|
||||||
# XXX NOTE, DO NOT include the `.<backend>` suffix!
|
async for typ, msg in msg_gen:
|
||||||
# OW the sampling loop will not broadcast correctly..
|
await send_chan.send({sym_str: msg})
|
||||||
# since `bus._subscribers.setdefault(bs_fqme, set())`
|
|
||||||
# is used inside `.data.open_feed_bus()` !!!
|
|
||||||
topic: str = mkt.bs_fqme
|
|
||||||
async for typ, quote in iter_quotes:
|
|
||||||
await send_chan.send({topic: quote})
|
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
|
|
@ -861,7 +771,7 @@ async def subscribe(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def iter_normed_quotes(
|
async def stream_messages(
|
||||||
ws: NoBsWs,
|
ws: NoBsWs,
|
||||||
sym: str,
|
sym: str,
|
||||||
|
|
||||||
|
|
@ -892,9 +802,6 @@ async def iter_normed_quotes(
|
||||||
|
|
||||||
yield 'trade', {
|
yield 'trade', {
|
||||||
'symbol': sym,
|
'symbol': sym,
|
||||||
# TODO, is 'last' even used elsewhere/a-good
|
|
||||||
# semantic? can't we just read the ticks with our
|
|
||||||
# .data.ticktools.frame_ticks()`/
|
|
||||||
'last': trade_data.price,
|
'last': trade_data.price,
|
||||||
'brokerd_ts': last_trade_ts,
|
'brokerd_ts': last_trade_ts,
|
||||||
'ticks': [
|
'ticks': [
|
||||||
|
|
@ -987,7 +894,7 @@ async def open_history_client(
|
||||||
if end_dt is None:
|
if end_dt is None:
|
||||||
inow = round(time.time())
|
inow = round(time.time())
|
||||||
|
|
||||||
log.debug(
|
print(
|
||||||
f'difference in time between load and processing'
|
f'difference in time between load and processing'
|
||||||
f'{inow - times[-1]}'
|
f'{inow - times[-1]}'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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!
|
|
||||||
|
|
@ -27,28 +27,13 @@ from ._ems import (
|
||||||
open_brokerd_dialog,
|
open_brokerd_dialog,
|
||||||
)
|
)
|
||||||
from ._util import OrderDialogs
|
from ._util import OrderDialogs
|
||||||
from ._messages import(
|
|
||||||
Order,
|
|
||||||
Status,
|
|
||||||
Cancel,
|
|
||||||
|
|
||||||
# TODO: deprecate these and replace end-2-end with
|
|
||||||
# client-side-dialog set above B)
|
|
||||||
# https://github.com/pikers/piker/issues/514
|
|
||||||
BrokerdPosition
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'FeeModel',
|
|
||||||
'open_ems',
|
'open_ems',
|
||||||
'OrderClient',
|
'OrderClient',
|
||||||
'open_brokerd_dialog',
|
'open_brokerd_dialog',
|
||||||
'OrderDialogs',
|
'OrderDialogs',
|
||||||
'Order',
|
|
||||||
'Status',
|
|
||||||
'Cancel',
|
|
||||||
'BrokerdPosition'
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ from tractor.trionics import broadcast_receiver
|
||||||
from ._util import (
|
from ._util import (
|
||||||
log, # sub-sys logger
|
log, # sub-sys logger
|
||||||
)
|
)
|
||||||
from piker.types import Struct
|
from ..data.types import Struct
|
||||||
from ..service import maybe_open_emsd
|
from ..service import maybe_open_emsd
|
||||||
from ._messages import (
|
from ._messages import (
|
||||||
Order,
|
Order,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from contextlib import asynccontextmanager as acm
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from math import isnan
|
from math import isnan
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from time import time_ns
|
import time
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import (
|
from typing import (
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
|
|
@ -51,13 +51,12 @@ from ..accounting._mktinfo import (
|
||||||
unpack_fqme,
|
unpack_fqme,
|
||||||
dec_digits,
|
dec_digits,
|
||||||
)
|
)
|
||||||
from piker.types import Struct
|
|
||||||
from ..ui._notify import notify_from_ems_status_msg
|
from ..ui._notify import notify_from_ems_status_msg
|
||||||
from ..data import iterticks
|
from ..data import iterticks
|
||||||
|
from ..data.types import Struct
|
||||||
from ._messages import (
|
from ._messages import (
|
||||||
Order,
|
Order,
|
||||||
Status,
|
Status,
|
||||||
Error,
|
|
||||||
BrokerdCancel,
|
BrokerdCancel,
|
||||||
BrokerdOrder,
|
BrokerdOrder,
|
||||||
# BrokerdOrderAck,
|
# BrokerdOrderAck,
|
||||||
|
|
@ -256,7 +255,7 @@ async def clear_dark_triggers(
|
||||||
action=action,
|
action=action,
|
||||||
oid=oid,
|
oid=oid,
|
||||||
account=account,
|
account=account,
|
||||||
time_ns=time_ns(),
|
time_ns=time.time_ns(),
|
||||||
symbol=bfqme,
|
symbol=bfqme,
|
||||||
price=submit_price,
|
price=submit_price,
|
||||||
size=size,
|
size=size,
|
||||||
|
|
@ -269,7 +268,7 @@ async def clear_dark_triggers(
|
||||||
# fallthrough logic
|
# fallthrough logic
|
||||||
status = Status(
|
status = Status(
|
||||||
oid=oid, # ems dialog id
|
oid=oid, # ems dialog id
|
||||||
time_ns=time_ns(),
|
time_ns=time.time_ns(),
|
||||||
resp=resp,
|
resp=resp,
|
||||||
req=cmd,
|
req=cmd,
|
||||||
brokerd_msg=brokerd_msg,
|
brokerd_msg=brokerd_msg,
|
||||||
|
|
@ -827,8 +826,8 @@ async def translate_and_relay_brokerd_events(
|
||||||
# keep pps per account up to date locally in ``emsd`` mem
|
# keep pps per account up to date locally in ``emsd`` mem
|
||||||
# sym, broker = pos_msg.symbol, pos_msg.broker
|
# sym, broker = pos_msg.symbol, pos_msg.broker
|
||||||
|
|
||||||
# NOTE: translate to a FQME!
|
|
||||||
relay.positions.setdefault(
|
relay.positions.setdefault(
|
||||||
|
# NOTE: translate to a FQSN!
|
||||||
(broker, pos_msg.account),
|
(broker, pos_msg.account),
|
||||||
{}
|
{}
|
||||||
)[pos_msg.symbol] = pos_msg
|
)[pos_msg.symbol] = pos_msg
|
||||||
|
|
@ -884,7 +883,7 @@ async def translate_and_relay_brokerd_events(
|
||||||
BrokerdCancel(
|
BrokerdCancel(
|
||||||
oid=oid,
|
oid=oid,
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
time_ns=time_ns(),
|
time_ns=time.time_ns(),
|
||||||
account=status_msg.req.account,
|
account=status_msg.req.account,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -899,6 +898,15 @@ async def translate_and_relay_brokerd_events(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# BrokerdError
|
# BrokerdError
|
||||||
|
case {
|
||||||
|
'name': 'error',
|
||||||
|
'oid': oid, # ems order-dialog id
|
||||||
|
'reqid': reqid, # brokerd generated order-request id
|
||||||
|
}:
|
||||||
|
status_msg = book._active.get(oid)
|
||||||
|
msg = BrokerdError(**brokerd_msg)
|
||||||
|
log.error(fmsg) # XXX make one when it's blank?
|
||||||
|
|
||||||
# TODO: figure out how this will interact with EMS clients
|
# TODO: figure out how this will interact with EMS clients
|
||||||
# for ex. on an error do we react with a dark orders
|
# for ex. on an error do we react with a dark orders
|
||||||
# management response, like cancelling all dark orders?
|
# management response, like cancelling all dark orders?
|
||||||
|
|
@ -906,69 +914,23 @@ async def translate_and_relay_brokerd_events(
|
||||||
# some unexpected failure - something we need to think more
|
# some unexpected failure - something we need to think more
|
||||||
# about. In most default situations, with composed orders
|
# about. In most default situations, with composed orders
|
||||||
# (ex. brackets), most brokers seem to use a oca policy.
|
# (ex. brackets), most brokers seem to use a oca policy.
|
||||||
case {
|
|
||||||
'name': 'error',
|
|
||||||
'oid': oid, # ems order-dialog id
|
|
||||||
'reqid': reqid, # brokerd generated order-request id
|
|
||||||
}:
|
|
||||||
if (
|
|
||||||
not oid
|
|
||||||
# try to lookup any order dialog by
|
|
||||||
# brokerd-side id..
|
|
||||||
and not (
|
|
||||||
oid := book._ems2brokerd_ids.inverse.get(reqid)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
f'Rxed unusable error-msg:\n'
|
|
||||||
f'{brokerd_msg}'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
msg = BrokerdError(**brokerd_msg)
|
# only relay to client side if we have an active
|
||||||
|
# ongoing dialog
|
||||||
# NOTE: retreive the last client-side response
|
if status_msg:
|
||||||
# OR create an error when we have no last msg /dialog
|
|
||||||
# on record
|
|
||||||
status_msg: Status
|
|
||||||
if not (status_msg := book._active.get(oid)):
|
|
||||||
status_msg = Error(
|
|
||||||
time_ns=time_ns(),
|
|
||||||
oid=oid,
|
|
||||||
reqid=reqid,
|
|
||||||
brokerd_msg=msg,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# only modify last status if we have an active
|
|
||||||
# ongoing dialog..
|
|
||||||
status_msg.resp = 'error'
|
status_msg.resp = 'error'
|
||||||
status_msg.brokerd_msg = msg
|
status_msg.brokerd_msg = msg
|
||||||
|
|
||||||
book._active[oid] = status_msg
|
book._active[oid] = status_msg
|
||||||
|
|
||||||
log.error(
|
|
||||||
'Translating brokerd error to status:\n'
|
|
||||||
f'{fmsg}'
|
|
||||||
f'{status_msg.to_dict()}'
|
|
||||||
)
|
|
||||||
if req := status_msg.req:
|
|
||||||
fqme: str = req.symbol
|
|
||||||
else:
|
|
||||||
bdmsg: Struct = status_msg.brokerd_msg
|
|
||||||
fqme: str = (
|
|
||||||
bdmsg.symbol # might be None
|
|
||||||
or
|
|
||||||
bdmsg.broker_details['flow']
|
|
||||||
# NOTE: what happens in empty case in the
|
|
||||||
# broadcast below? it's a problem?
|
|
||||||
.get('symbol', '')
|
|
||||||
)
|
|
||||||
|
|
||||||
await router.client_broadcast(
|
await router.client_broadcast(
|
||||||
fqme,
|
status_msg.req.symbol,
|
||||||
status_msg,
|
status_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
log.error(f'Error for unknown order flow:\n{msg}')
|
||||||
|
continue
|
||||||
|
|
||||||
# BrokerdStatus
|
# BrokerdStatus
|
||||||
case {
|
case {
|
||||||
'name': 'status',
|
'name': 'status',
|
||||||
|
|
@ -1108,7 +1070,7 @@ async def translate_and_relay_brokerd_events(
|
||||||
status_msg.req = order
|
status_msg.req = order
|
||||||
|
|
||||||
assert status_msg.src # source tag?
|
assert status_msg.src # source tag?
|
||||||
oid: str = str(status_msg.reqid)
|
oid = str(status_msg.reqid)
|
||||||
|
|
||||||
# attempt to avoid collisions
|
# attempt to avoid collisions
|
||||||
status_msg.reqid = oid
|
status_msg.reqid = oid
|
||||||
|
|
@ -1125,28 +1087,38 @@ async def translate_and_relay_brokerd_events(
|
||||||
status_msg,
|
status_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# don't fall through
|
||||||
|
continue
|
||||||
|
|
||||||
|
# brokerd error
|
||||||
|
case {
|
||||||
|
'name': 'status',
|
||||||
|
'status': 'error',
|
||||||
|
}:
|
||||||
|
log.error(f'Broker error:\n{fmsg}')
|
||||||
|
# XXX: we presume the brokerd cancels its own order
|
||||||
|
continue
|
||||||
|
|
||||||
# TOO FAST ``BrokerdStatus`` that arrives
|
# TOO FAST ``BrokerdStatus`` that arrives
|
||||||
# before the ``BrokerdAck``.
|
# before the ``BrokerdAck``.
|
||||||
# NOTE XXX: sometimes there is a race with the backend (like
|
case {
|
||||||
# `ib` where the pending status will be relayed *before*
|
# XXX: sometimes there is a race with the backend (like
|
||||||
# the ack msg, in which case we just ignore the faster
|
# `ib` where the pending stauts will be related before
|
||||||
|
# the ack, in which case we just ignore the faster
|
||||||
# pending msg and wait for our expected ack to arrive
|
# pending msg and wait for our expected ack to arrive
|
||||||
# later (i.e. the first block below should enter).
|
# later (i.e. the first block below should enter).
|
||||||
case {
|
|
||||||
'name': 'status',
|
'name': 'status',
|
||||||
'status': status,
|
'status': status,
|
||||||
'reqid': reqid,
|
'reqid': reqid,
|
||||||
}:
|
}:
|
||||||
msg = (
|
oid = book._ems2brokerd_ids.inverse.get(reqid)
|
||||||
f'Unhandled broker status for dialog {reqid}:\n'
|
msg = f'Unhandled broker status for dialog {reqid}:\n'
|
||||||
f'{pformat(brokerd_msg)}'
|
if oid:
|
||||||
)
|
status_msg = book._active.get(oid)
|
||||||
if (
|
# status msg may not have been set yet or popped?
|
||||||
oid := book._ems2brokerd_ids.inverse.get(reqid)
|
|
||||||
):
|
|
||||||
# NOTE: have seen a key error here on kraken
|
# NOTE: have seen a key error here on kraken
|
||||||
# clearable limits..
|
# clearable limits..
|
||||||
if status_msg := book._active.get(oid):
|
if status_msg:
|
||||||
msg += (
|
msg += (
|
||||||
f'last status msg: {pformat(status_msg)}\n\n'
|
f'last status msg: {pformat(status_msg)}\n\n'
|
||||||
f'this msg:{fmsg}\n'
|
f'this msg:{fmsg}\n'
|
||||||
|
|
@ -1242,7 +1214,7 @@ async def process_client_order_cmds(
|
||||||
BrokerdCancel(
|
BrokerdCancel(
|
||||||
oid=oid,
|
oid=oid,
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
time_ns=time_ns(),
|
time_ns=time.time_ns(),
|
||||||
account=order.account,
|
account=order.account,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1317,7 +1289,7 @@ async def process_client_order_cmds(
|
||||||
|
|
||||||
msg = BrokerdOrder(
|
msg = BrokerdOrder(
|
||||||
oid=oid, # no ib support for oids...
|
oid=oid, # no ib support for oids...
|
||||||
time_ns=time_ns(),
|
time_ns=time.time_ns(),
|
||||||
|
|
||||||
# if this is None, creates a new order
|
# if this is None, creates a new order
|
||||||
# otherwise will modify any existing one
|
# otherwise will modify any existing one
|
||||||
|
|
@ -1335,7 +1307,7 @@ async def process_client_order_cmds(
|
||||||
oid=oid,
|
oid=oid,
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
resp='pending',
|
resp='pending',
|
||||||
time_ns=time_ns(),
|
time_ns=time.time_ns(),
|
||||||
brokerd_msg=msg,
|
brokerd_msg=msg,
|
||||||
req=req,
|
req=req,
|
||||||
)
|
)
|
||||||
|
|
@ -1452,7 +1424,7 @@ async def process_client_order_cmds(
|
||||||
status = Status(
|
status = Status(
|
||||||
resp=resp,
|
resp=resp,
|
||||||
oid=oid,
|
oid=oid,
|
||||||
time_ns=time_ns(),
|
time_ns=time.time_ns(),
|
||||||
req=req,
|
req=req,
|
||||||
src='dark',
|
src='dark',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,39 @@
|
||||||
Clearing sub-system message and protocols.
|
Clearing sub-system message and protocols.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
# from collections import (
|
||||||
|
# ChainMap,
|
||||||
|
# deque,
|
||||||
|
# )
|
||||||
from typing import (
|
from typing import (
|
||||||
Literal,
|
Literal,
|
||||||
)
|
)
|
||||||
|
|
||||||
from msgspec import field
|
from msgspec import field
|
||||||
|
|
||||||
from piker.types import Struct
|
from ..data.types import Struct
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: a composite for tracking msg flow on 2-legged
|
||||||
|
# dialogs.
|
||||||
|
# class Dialog(ChainMap):
|
||||||
|
# '''
|
||||||
|
# Msg collection abstraction to easily track the state changes of
|
||||||
|
# a msg flow in one high level, query-able and immutable construct.
|
||||||
|
|
||||||
|
# The main use case is to query data from a (long-running)
|
||||||
|
# msg-transaction-sequence
|
||||||
|
|
||||||
|
|
||||||
|
# '''
|
||||||
|
# def update(
|
||||||
|
# self,
|
||||||
|
# msg,
|
||||||
|
# ) -> None:
|
||||||
|
# self.maps.insert(0, msg.to_dict())
|
||||||
|
|
||||||
|
# def flatten(self) -> dict:
|
||||||
|
# return dict(self)
|
||||||
|
|
||||||
|
|
||||||
# TODO: ``msgspec`` stuff worth paying attention to:
|
# TODO: ``msgspec`` stuff worth paying attention to:
|
||||||
|
|
@ -138,18 +163,6 @@ class Status(Struct):
|
||||||
brokerd_msg: dict = {}
|
brokerd_msg: dict = {}
|
||||||
|
|
||||||
|
|
||||||
class Error(Status):
|
|
||||||
resp: str = 'error'
|
|
||||||
|
|
||||||
# TODO: allow re-wrapping from existing (last) status?
|
|
||||||
@classmethod
|
|
||||||
def from_status(
|
|
||||||
cls,
|
|
||||||
msg: Status,
|
|
||||||
) -> Error:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------
|
# ---------------
|
||||||
# emsd -> brokerd
|
# emsd -> brokerd
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
@ -213,7 +226,6 @@ class BrokerdOrderAck(Struct):
|
||||||
|
|
||||||
# emsd id originally sent in matching request msg
|
# emsd id originally sent in matching request msg
|
||||||
oid: str
|
oid: str
|
||||||
# TODO: do we need this?
|
|
||||||
account: str = ''
|
account: str = ''
|
||||||
name: str = 'ack'
|
name: str = 'ack'
|
||||||
|
|
||||||
|
|
@ -226,14 +238,13 @@ class BrokerdStatus(Struct):
|
||||||
'open',
|
'open',
|
||||||
'canceled',
|
'canceled',
|
||||||
'pending',
|
'pending',
|
||||||
# 'error', # NOTE: use `BrokerdError`
|
'error',
|
||||||
'closed',
|
'closed',
|
||||||
]
|
]
|
||||||
name: str = 'status'
|
|
||||||
|
|
||||||
oid: str = ''
|
|
||||||
# TODO: do we need this?
|
# TODO: do we need this?
|
||||||
account: str | None = None,
|
account: str | None = None,
|
||||||
|
name: str = 'status'
|
||||||
filled: float = 0.0
|
filled: float = 0.0
|
||||||
reason: str = ''
|
reason: str = ''
|
||||||
remaining: float = 0.0
|
remaining: float = 0.0
|
||||||
|
|
@ -276,22 +287,20 @@ class BrokerdError(Struct):
|
||||||
This is still a TODO thing since we're not sure how to employ it yet.
|
This is still a TODO thing since we're not sure how to employ it yet.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
oid: str
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
# TODO: drop this right?
|
# TODO: drop this right?
|
||||||
symbol: str | None = None
|
symbol: str | None = None
|
||||||
|
|
||||||
oid: str | None = None
|
|
||||||
# if no brokerd order request was actually submitted (eg. we errored
|
# if no brokerd order request was actually submitted (eg. we errored
|
||||||
# at the ``pikerd`` layer) then there will be ``reqid`` allocated.
|
# at the ``pikerd`` layer) then there will be ``reqid`` allocated.
|
||||||
reqid: str | None = None
|
reqid: int | str | None = None
|
||||||
|
|
||||||
name: str = 'error'
|
name: str = 'error'
|
||||||
broker_details: dict = {}
|
broker_details: dict = {}
|
||||||
|
|
||||||
|
|
||||||
# TODO: yeah, so we REALLY need to completely deprecate
|
|
||||||
# this and use the `.accounting.Position` msg-type instead..
|
|
||||||
class BrokerdPosition(Struct):
|
class BrokerdPosition(Struct):
|
||||||
'''
|
'''
|
||||||
Position update event from brokerd.
|
Position update event from brokerd.
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,10 @@ from contextlib import asynccontextmanager as acm
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
import itertools
|
import itertools
|
||||||
from pprint import pformat
|
|
||||||
import time
|
import time
|
||||||
from typing import (
|
from typing import (
|
||||||
Callable,
|
Callable,
|
||||||
)
|
)
|
||||||
from types import ModuleType
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
|
|
@ -39,26 +37,22 @@ import pendulum
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
from piker.brokers import get_brokermod
|
from ..brokers import get_brokermod
|
||||||
from piker.service import find_service
|
from .. import data
|
||||||
from piker.accounting import (
|
from ..data.types import Struct
|
||||||
Account,
|
from ..accounting._mktinfo import (
|
||||||
MktPair,
|
MktPair,
|
||||||
|
)
|
||||||
|
from ..accounting import (
|
||||||
Position,
|
Position,
|
||||||
|
PpTable,
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionLedger,
|
TransactionLedger,
|
||||||
open_account,
|
|
||||||
open_trade_ledger,
|
open_trade_ledger,
|
||||||
unpack_fqme,
|
open_pps,
|
||||||
)
|
)
|
||||||
from piker.data import (
|
from ..data import iterticks
|
||||||
Feed,
|
from ..accounting import unpack_fqme
|
||||||
SymbologyCache,
|
|
||||||
iterticks,
|
|
||||||
open_feed,
|
|
||||||
open_symcache,
|
|
||||||
)
|
|
||||||
from piker.types import Struct
|
|
||||||
from ._util import (
|
from ._util import (
|
||||||
log, # sub-sys logger
|
log, # sub-sys logger
|
||||||
get_console_log,
|
get_console_log,
|
||||||
|
|
@ -83,10 +77,11 @@ class PaperBoi(Struct):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
broker: str
|
broker: str
|
||||||
|
|
||||||
ems_trades_stream: tractor.MsgStream
|
ems_trades_stream: tractor.MsgStream
|
||||||
acnt: Account
|
|
||||||
|
ppt: PpTable
|
||||||
ledger: TransactionLedger
|
ledger: TransactionLedger
|
||||||
fees: Callable
|
|
||||||
|
|
||||||
# map of paper "live" orders which be used
|
# map of paper "live" orders which be used
|
||||||
# to simulate fills based on paper engine settings
|
# to simulate fills based on paper engine settings
|
||||||
|
|
@ -268,42 +263,29 @@ class PaperBoi(Struct):
|
||||||
# we don't actually have any unique backend symbol ourselves
|
# we don't actually have any unique backend symbol ourselves
|
||||||
# other then this thing, our fqme address.
|
# other then this thing, our fqme address.
|
||||||
bs_mktid: str = fqme
|
bs_mktid: str = fqme
|
||||||
if fees := self.fees:
|
|
||||||
cost: float = fees(price, size)
|
|
||||||
else:
|
|
||||||
cost: float = 0
|
|
||||||
|
|
||||||
t = Transaction(
|
t = Transaction(
|
||||||
fqme=fqme,
|
fqme=fqme,
|
||||||
|
sym=self._mkts[fqme],
|
||||||
tid=oid,
|
tid=oid,
|
||||||
size=size,
|
size=size,
|
||||||
price=price,
|
price=price,
|
||||||
cost=cost,
|
cost=0, # TODO: cost model
|
||||||
dt=pendulum.from_timestamp(fill_time_s),
|
dt=pendulum.from_timestamp(fill_time_s),
|
||||||
bs_mktid=bs_mktid,
|
bs_mktid=bs_mktid,
|
||||||
)
|
)
|
||||||
|
|
||||||
# update in-mem ledger and pos table
|
# update in-mem ledger and pos table
|
||||||
self.ledger.update_from_t(t)
|
self.ledger.update_from_t(t)
|
||||||
self.acnt.update_from_ledger(
|
self.ppt.update_from_trans({oid: t})
|
||||||
{oid: t},
|
|
||||||
symcache=self.ledger._symcache,
|
|
||||||
|
|
||||||
# XXX when a backend has no symcache support yet we can
|
|
||||||
# simply pass in the gmi() retreived table created
|
|
||||||
# during init :o
|
|
||||||
_mktmap_table=self._mkts,
|
|
||||||
)
|
|
||||||
|
|
||||||
# transmit pp msg to ems
|
# transmit pp msg to ems
|
||||||
pp: Position = self.acnt.pps[bs_mktid]
|
pp = self.ppt.pps[bs_mktid]
|
||||||
|
|
||||||
pp_msg = BrokerdPosition(
|
pp_msg = BrokerdPosition(
|
||||||
broker=self.broker,
|
broker=self.broker,
|
||||||
account='paper',
|
account='paper',
|
||||||
symbol=fqme,
|
symbol=fqme,
|
||||||
|
|
||||||
size=pp.cumsize,
|
size=pp.size,
|
||||||
avg_price=pp.ppu,
|
avg_price=pp.ppu,
|
||||||
|
|
||||||
# TODO: we need to look up the asset currency from
|
# TODO: we need to look up the asset currency from
|
||||||
|
|
@ -314,7 +296,7 @@ class PaperBoi(Struct):
|
||||||
# write all updates to filesys immediately
|
# write all updates to filesys immediately
|
||||||
# (adds latency but that works for simulation anyway)
|
# (adds latency but that works for simulation anyway)
|
||||||
self.ledger.write_config()
|
self.ledger.write_config()
|
||||||
self.acnt.write_config()
|
self.ppt.write_config()
|
||||||
|
|
||||||
await self.ems_trades_stream.send(pp_msg)
|
await self.ems_trades_stream.send(pp_msg)
|
||||||
|
|
||||||
|
|
@ -343,7 +325,6 @@ async def simulate_fills(
|
||||||
# this stream may eventually contain multiple symbols
|
# this stream may eventually contain multiple symbols
|
||||||
async for quotes in quote_stream:
|
async for quotes in quote_stream:
|
||||||
for sym, quote in quotes.items():
|
for sym, quote in quotes.items():
|
||||||
# print(sym)
|
|
||||||
for tick in iterticks(
|
for tick in iterticks(
|
||||||
quote,
|
quote,
|
||||||
# dark order price filter(s)
|
# dark order price filter(s)
|
||||||
|
|
@ -559,76 +540,53 @@ async def open_trade_dialog(
|
||||||
# enable piker.clearing console log for *this* subactor
|
# enable piker.clearing console log for *this* subactor
|
||||||
get_console_log(loglevel)
|
get_console_log(loglevel)
|
||||||
|
|
||||||
symcache: SymbologyCache
|
ppt: PpTable
|
||||||
async with open_symcache(get_brokermod(broker)) as symcache:
|
|
||||||
|
|
||||||
acnt: Account
|
|
||||||
ledger: TransactionLedger
|
ledger: TransactionLedger
|
||||||
with (
|
with (
|
||||||
|
open_pps(
|
||||||
# TODO: probably do the symcache and ledger loading
|
|
||||||
# implicitly behind this? Deliver an account, and ledger
|
|
||||||
# pair or make the ledger an attr of the account?
|
|
||||||
open_account(
|
|
||||||
broker,
|
broker,
|
||||||
'paper',
|
'paper',
|
||||||
write_on_exit=True,
|
write_on_exit=True,
|
||||||
) as acnt,
|
) as ppt,
|
||||||
|
|
||||||
open_trade_ledger(
|
open_trade_ledger(
|
||||||
broker,
|
broker,
|
||||||
'paper',
|
'paper',
|
||||||
symcache=symcache,
|
|
||||||
) as ledger
|
) as ledger
|
||||||
):
|
):
|
||||||
# NOTE: WE MUST retreive market(pair) info from each
|
# NOTE: retreive market(pair) info from the backend broker
|
||||||
# backend broker since ledger entries (in their
|
# since ledger entries (in their backend native format) often
|
||||||
# provider-native format) often don't contain necessary
|
# don't contain necessary market info per trade record entry..
|
||||||
# market info per trade record entry..
|
# - if no fqme was passed in, we presume we're running in
|
||||||
# FURTHER, if no fqme was passed in, we presume we're
|
# "ledger-sync-only mode" and thus we load mkt info for
|
||||||
# running in "ledger-sync-only mode" and thus we load
|
# each symbol found in the ledger to a ppt table manually.
|
||||||
# mkt info for each symbol found in the ledger to
|
|
||||||
# an acnt table manually.
|
|
||||||
|
|
||||||
# TODO: how to process ledger info from backends?
|
# TODO: how to process ledger info from backends?
|
||||||
# - should we be rolling our own actor-cached version of these
|
# - should we be rolling our own actor-cached version of these
|
||||||
# client API refs or using portal IPC to send requests to the
|
# client API refs or using portal IPC to send requests to the
|
||||||
# existing brokerd daemon?
|
# existing brokerd daemon?
|
||||||
# - alternatively we can possibly expect and use
|
# - alternatively we can possibly expect and use
|
||||||
# a `.broker.ledger.norm_trade()` ep?
|
# a `.broker.norm_trade_records()` ep?
|
||||||
brokermod: ModuleType = get_brokermod(broker)
|
brokermod = get_brokermod(broker)
|
||||||
gmi: Callable = getattr(brokermod, 'get_mkt_info', None)
|
gmi = getattr(brokermod, 'get_mkt_info', None)
|
||||||
|
|
||||||
# update all transactions with mkt info before
|
# update all transactions with mkt info before
|
||||||
# loading any pps
|
# loading any pps
|
||||||
mkt_by_fqme: dict[str, MktPair] = {}
|
mkt_by_fqme: dict[str, MktPair] = {}
|
||||||
if (
|
if fqme:
|
||||||
fqme
|
|
||||||
and fqme not in symcache.mktmaps
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
f'Symcache for {broker} has no `{fqme}` entry?\n'
|
|
||||||
'Manually requesting mkt map data via `.get_mkt_info()`..'
|
|
||||||
)
|
|
||||||
|
|
||||||
bs_fqme, _, broker = fqme.rpartition('.')
|
bs_fqme, _, broker = fqme.rpartition('.')
|
||||||
mkt, pair = await gmi(bs_fqme)
|
mkt, _ = await brokermod.get_mkt_info(bs_fqme)
|
||||||
mkt_by_fqme[mkt.fqme] = mkt
|
mkt_by_fqme[mkt.fqme] = mkt
|
||||||
|
|
||||||
# for each sym in the ledger load its `MktPair` info
|
# for each sym in the ledger load it's `MktPair` info
|
||||||
for tid, txdict in ledger.data.items():
|
for tid, txdict in ledger.data.items():
|
||||||
l_fqme: str = txdict.get('fqme') or txdict['fqsn']
|
l_fqme: str = txdict.get('fqme') or txdict['fqsn']
|
||||||
|
|
||||||
if (
|
if (
|
||||||
gmi
|
gmi
|
||||||
and l_fqme not in symcache.mktmaps
|
|
||||||
and l_fqme not in mkt_by_fqme
|
and l_fqme not in mkt_by_fqme
|
||||||
):
|
):
|
||||||
log.warning(
|
mkt, pair = await brokermod.get_mkt_info(
|
||||||
f'Symcache for {broker} has no `{l_fqme}` entry?\n'
|
|
||||||
'Manually requesting mkt map data via `.get_mkt_info()`..'
|
|
||||||
)
|
|
||||||
mkt, pair = await gmi(
|
|
||||||
l_fqme.rstrip(f'.{broker}'),
|
l_fqme.rstrip(f'.{broker}'),
|
||||||
)
|
)
|
||||||
mkt_by_fqme[l_fqme] = mkt
|
mkt_by_fqme[l_fqme] = mkt
|
||||||
|
|
@ -645,26 +603,17 @@ async def open_trade_dialog(
|
||||||
|
|
||||||
# update pos table from ledger history and provide a ``MktPair``
|
# update pos table from ledger history and provide a ``MktPair``
|
||||||
# lookup for internal position accounting calcs.
|
# lookup for internal position accounting calcs.
|
||||||
acnt.update_from_ledger(
|
ppt.update_from_trans(ledger.to_trans(mkt_by_fqme=mkt_by_fqme))
|
||||||
ledger,
|
|
||||||
|
|
||||||
# NOTE: if the symcache fails on fqme lookup
|
|
||||||
# (either sycache not yet supported or not filled
|
|
||||||
# in) use manually constructed table from calling
|
|
||||||
# the `.get_mkt_info()` provider EP above.
|
|
||||||
_mktmap_table=mkt_by_fqme,
|
|
||||||
)
|
|
||||||
|
|
||||||
pp_msgs: list[BrokerdPosition] = []
|
pp_msgs: list[BrokerdPosition] = []
|
||||||
pos: Position
|
pos: Position
|
||||||
token: str # f'{symbol}.{self.broker}'
|
token: str # f'{symbol}.{self.broker}'
|
||||||
for token, pos in acnt.pps.items():
|
for token, pos in ppt.pps.items():
|
||||||
|
|
||||||
pp_msgs.append(BrokerdPosition(
|
pp_msgs.append(BrokerdPosition(
|
||||||
broker=broker,
|
broker=broker,
|
||||||
account='paper',
|
account='paper',
|
||||||
symbol=pos.mkt.fqme,
|
symbol=pos.mkt.fqme,
|
||||||
size=pos.cumsize,
|
size=pos.size,
|
||||||
avg_price=pos.ppu,
|
avg_price=pos.ppu,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
@ -675,7 +624,7 @@ async def open_trade_dialog(
|
||||||
|
|
||||||
# write new positions state in case ledger was
|
# write new positions state in case ledger was
|
||||||
# newer then that tracked in pps.toml
|
# newer then that tracked in pps.toml
|
||||||
acnt.write_config()
|
ppt.write_config()
|
||||||
|
|
||||||
# exit early since no fqme was passed,
|
# exit early since no fqme was passed,
|
||||||
# normally this case is just to load
|
# normally this case is just to load
|
||||||
|
|
@ -688,28 +637,15 @@ async def open_trade_dialog(
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
return
|
return
|
||||||
|
|
||||||
feed: Feed
|
|
||||||
async with (
|
async with (
|
||||||
open_feed(
|
data.open_feed(
|
||||||
[fqme],
|
[fqme],
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
) as feed,
|
) as feed,
|
||||||
):
|
):
|
||||||
# sanity check all the mkt infos
|
# sanity check all the mkt infos
|
||||||
for fqme, flume in feed.flumes.items():
|
for fqme, flume in feed.flumes.items():
|
||||||
mkt: MktPair = symcache.mktmaps.get(fqme) or mkt_by_fqme[fqme]
|
assert mkt_by_fqme[fqme] == flume.mkt
|
||||||
if mkt != flume.mkt:
|
|
||||||
diff: tuple = mkt - flume.mkt
|
|
||||||
log.warning(
|
|
||||||
'MktPair sig mismatch?\n'
|
|
||||||
f'{pformat(diff)}'
|
|
||||||
)
|
|
||||||
|
|
||||||
get_cost: Callable = getattr(
|
|
||||||
brokermod,
|
|
||||||
'get_cost',
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
ctx.open_stream() as ems_stream,
|
ctx.open_stream() as ems_stream,
|
||||||
|
|
@ -718,9 +654,8 @@ async def open_trade_dialog(
|
||||||
client = PaperBoi(
|
client = PaperBoi(
|
||||||
broker=broker,
|
broker=broker,
|
||||||
ems_trades_stream=ems_stream,
|
ems_trades_stream=ems_stream,
|
||||||
acnt=acnt,
|
ppt=ppt,
|
||||||
ledger=ledger,
|
ledger=ledger,
|
||||||
fees=get_cost,
|
|
||||||
|
|
||||||
_buys=_buys,
|
_buys=_buys,
|
||||||
_sells=_sells,
|
_sells=_sells,
|
||||||
|
|
@ -761,7 +696,7 @@ async def open_paperboi(
|
||||||
service_name = f'paperboi.{broker}'
|
service_name = f'paperboi.{broker}'
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
find_service(service_name) as portal,
|
tractor.find_actor(service_name) as portal,
|
||||||
tractor.open_nursery() as an,
|
tractor.open_nursery() as an,
|
||||||
):
|
):
|
||||||
# NOTE: only spawn if no paperboi already is up since we likely
|
# NOTE: only spawn if no paperboi already is up since we likely
|
||||||
|
|
@ -784,59 +719,7 @@ async def open_paperboi(
|
||||||
) as (ctx, first):
|
) as (ctx, first):
|
||||||
yield ctx, first
|
yield ctx, first
|
||||||
|
|
||||||
# ALWAYS tear down connection AND any newly spawned
|
# tear down connection and any spawned actor on exit
|
||||||
# paperboi actor on exit!
|
|
||||||
await ctx.cancel()
|
await ctx.cancel()
|
||||||
|
|
||||||
if we_spawned:
|
if we_spawned:
|
||||||
await portal.cancel_actor()
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
def norm_trade(
|
|
||||||
tid: str,
|
|
||||||
txdict: dict,
|
|
||||||
pairs: dict[str, Struct],
|
|
||||||
symcache: SymbologyCache | None = None,
|
|
||||||
|
|
||||||
brokermod: ModuleType | None = None,
|
|
||||||
|
|
||||||
) -> Transaction:
|
|
||||||
from pendulum import (
|
|
||||||
DateTime,
|
|
||||||
parse,
|
|
||||||
)
|
|
||||||
|
|
||||||
# special field handling for datetimes
|
|
||||||
# to ensure pendulum is used!
|
|
||||||
dt: DateTime = parse(txdict['dt'])
|
|
||||||
expiry: str | None = txdict.get('expiry')
|
|
||||||
fqme: str = txdict.get('fqme') or txdict.pop('fqsn')
|
|
||||||
|
|
||||||
price: float = txdict['price']
|
|
||||||
size: float = txdict['size']
|
|
||||||
cost: float = txdict.get('cost', 0)
|
|
||||||
if (
|
|
||||||
brokermod
|
|
||||||
and (get_cost := getattr(
|
|
||||||
brokermod,
|
|
||||||
'get_cost',
|
|
||||||
False,
|
|
||||||
))
|
|
||||||
):
|
|
||||||
cost = get_cost(
|
|
||||||
price,
|
|
||||||
size,
|
|
||||||
is_taker=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Transaction(
|
|
||||||
fqme=fqme,
|
|
||||||
tid=txdict['tid'],
|
|
||||||
dt=dt,
|
|
||||||
price=price,
|
|
||||||
size=size,
|
|
||||||
cost=cost,
|
|
||||||
bs_mktid=txdict['bs_mktid'],
|
|
||||||
expiry=parse(expiry) if expiry else None,
|
|
||||||
etype='clear',
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ from ..log import (
|
||||||
get_logger,
|
get_logger,
|
||||||
get_console_log,
|
get_console_log,
|
||||||
)
|
)
|
||||||
from piker.types import Struct
|
from piker.data.types import Struct
|
||||||
subsys: str = 'piker.clearing'
|
subsys: str = 'piker.clearing'
|
||||||
|
|
||||||
log = get_logger(subsys)
|
log = get_logger(subsys)
|
||||||
|
|
@ -36,6 +36,9 @@ get_console_log = partial(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: use this in other backends like kraken which currently has
|
||||||
|
# a less formalized version more or less:
|
||||||
|
# `apiflows[reqid].maps.append(status_msg.to_dict())`
|
||||||
class OrderDialogs(Struct):
|
class OrderDialogs(Struct):
|
||||||
'''
|
'''
|
||||||
Order control dialog (and thus transaction) tracking via
|
Order control dialog (and thus transaction) tracking via
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,30 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet
|
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers)
|
||||||
# (in stewardship for pikers, everywhere.)
|
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# modify it under the terms of the GNU Affero General Public
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
# License as published by the Free Software Foundation, either
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# version 3 of the License, or (at your option) any later version.
|
# (at your option) any later version.
|
||||||
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# Affero General Public License for more details.
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
# You should have received a copy of the GNU Affero General Public
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# License along with this program. If not, see
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
# <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
CLI commons.
|
CLI commons.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import os
|
import os
|
||||||
# from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
from tractor._multiaddr import parse_maddr
|
|
||||||
|
|
||||||
from ..log import (
|
from ..log import (
|
||||||
get_console_log,
|
get_console_log,
|
||||||
|
|
@ -45,97 +42,35 @@ from .. import config
|
||||||
log = get_logger('piker.cli')
|
log = get_logger('piker.cli')
|
||||||
|
|
||||||
|
|
||||||
def load_trans_eps(
|
|
||||||
network: dict | None = None,
|
|
||||||
maddrs: list[tuple] | None = None,
|
|
||||||
|
|
||||||
) -> dict[str, dict[str, dict]]:
|
|
||||||
|
|
||||||
# transport-oriented endpoint multi-addresses
|
|
||||||
eps: dict[
|
|
||||||
str, # service name, eg. `pikerd`, `emsd`..
|
|
||||||
|
|
||||||
# libp2p style multi-addresses parsed into prot layers
|
|
||||||
list[dict[str, str | int]]
|
|
||||||
] = {}
|
|
||||||
|
|
||||||
if (
|
|
||||||
network
|
|
||||||
and not maddrs
|
|
||||||
):
|
|
||||||
# load network section and (attempt to) connect all endpoints
|
|
||||||
# which are reachable B)
|
|
||||||
for key, maddrs in network.items():
|
|
||||||
match key:
|
|
||||||
|
|
||||||
# TODO: resolve table across multiple discov
|
|
||||||
# prots Bo
|
|
||||||
case 'resolv':
|
|
||||||
pass
|
|
||||||
|
|
||||||
case 'pikerd':
|
|
||||||
dname: str = key
|
|
||||||
for maddr in maddrs:
|
|
||||||
layers: dict = parse_maddr(maddr)
|
|
||||||
eps.setdefault(
|
|
||||||
dname,
|
|
||||||
[],
|
|
||||||
).append(layers)
|
|
||||||
|
|
||||||
elif maddrs:
|
|
||||||
# presume user is manually specifying the root actor ep.
|
|
||||||
eps['pikerd'] = [parse_maddr(maddr)]
|
|
||||||
|
|
||||||
return eps
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
|
@click.option('--loglevel', '-l', default='warning', help='Logging level')
|
||||||
|
@click.option('--tl', is_flag=True, help='Enable tractor logging')
|
||||||
|
@click.option('--pdb', is_flag=True, help='Enable tractor debug mode')
|
||||||
|
@click.option('--host', '-h', default=None, help='Host addr to bind')
|
||||||
|
@click.option('--port', '-p', default=None, help='Port number to bind')
|
||||||
@click.option(
|
@click.option(
|
||||||
'--loglevel',
|
'--tsdb',
|
||||||
'-l',
|
|
||||||
default='warning',
|
|
||||||
help='Logging level',
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
'--tl',
|
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
help='Enable tractor-runtime logs',
|
help='Enable local ``marketstore`` instance'
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--pdb',
|
'--es',
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
help='Enable tractor debug mode',
|
help='Enable local ``elasticsearch`` instance'
|
||||||
)
|
)
|
||||||
@click.option(
|
|
||||||
'--maddr',
|
|
||||||
'-m',
|
|
||||||
default=None,
|
|
||||||
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,
|
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
tl: bool,
|
tl: bool,
|
||||||
pdb: bool,
|
pdb: bool,
|
||||||
# tsdb: bool,
|
tsdb: bool,
|
||||||
# es: bool,
|
es: bool,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
Spawn the piker broker-daemon.
|
Spawn the piker broker-daemon.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from tractor.devx import maybe_open_crash_handler
|
|
||||||
with maybe_open_crash_handler(pdb=pdb):
|
|
||||||
log = get_console_log(loglevel, name='cli')
|
log = get_console_log(loglevel, name='cli')
|
||||||
|
|
||||||
if pdb:
|
if pdb:
|
||||||
|
|
@ -147,32 +82,12 @@ def pikerd(
|
||||||
"\n"
|
"\n"
|
||||||
))
|
))
|
||||||
|
|
||||||
# service-actor registry endpoint socket-address set
|
reg_addr: None | tuple[str, int] = None
|
||||||
regaddrs: list[tuple[str, int]] = []
|
if host or port:
|
||||||
|
reg_addr = (
|
||||||
conf, _ = config.load(
|
host or _default_registry_host,
|
||||||
conf_name='conf',
|
int(port) or _default_registry_port,
|
||||||
)
|
)
|
||||||
network: dict = conf.get('network')
|
|
||||||
if (
|
|
||||||
network is None
|
|
||||||
and not maddr
|
|
||||||
):
|
|
||||||
regaddrs = [(
|
|
||||||
_default_registry_host,
|
|
||||||
_default_registry_port,
|
|
||||||
)]
|
|
||||||
|
|
||||||
else:
|
|
||||||
eps: dict = load_trans_eps(
|
|
||||||
network,
|
|
||||||
maddr,
|
|
||||||
)
|
|
||||||
for layers in eps['pikerd']:
|
|
||||||
regaddrs.append((
|
|
||||||
layers['ipv4']['addr'],
|
|
||||||
layers['tcp']['port'],
|
|
||||||
))
|
|
||||||
|
|
||||||
from .. import service
|
from .. import service
|
||||||
|
|
||||||
|
|
@ -181,35 +96,31 @@ def pikerd(
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
service.open_pikerd(
|
service.open_pikerd(
|
||||||
registry_addrs=regaddrs,
|
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
debug_mode=pdb,
|
debug_mode=pdb,
|
||||||
|
registry_addr=reg_addr,
|
||||||
|
|
||||||
) as service_mngr, # normally delivers a ``Services`` handle
|
) as service_mngr, # normally delivers a ``Services`` handle
|
||||||
|
|
||||||
# AsyncExitStack() as stack,
|
AsyncExitStack() as stack,
|
||||||
):
|
):
|
||||||
# TODO: spawn all other sub-actor daemons according to
|
if tsdb:
|
||||||
# multiaddress endpoint spec defined by user config
|
dname, conf = await stack.enter_async_context(
|
||||||
assert service_mngr
|
service.marketstore.start_ahab_daemon(
|
||||||
|
service_mngr,
|
||||||
|
loglevel=loglevel,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log.info(f'TSDB `{dname}` up with conf:\n{conf}')
|
||||||
|
|
||||||
# if tsdb:
|
if es:
|
||||||
# dname, conf = await stack.enter_async_context(
|
dname, conf = await stack.enter_async_context(
|
||||||
# service.marketstore.start_ahab_daemon(
|
service.elastic.start_ahab_daemon(
|
||||||
# service_mngr,
|
service_mngr,
|
||||||
# loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
# )
|
)
|
||||||
# )
|
)
|
||||||
# log.info(f'TSDB `{dname}` up with conf:\n{conf}')
|
log.info(f'DB `{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()
|
||||||
|
|
||||||
|
|
@ -226,24 +137,8 @@ def pikerd(
|
||||||
@click.option('--loglevel', '-l', default='warning', help='Logging level')
|
@click.option('--loglevel', '-l', default='warning', help='Logging level')
|
||||||
@click.option('--tl', is_flag=True, help='Enable tractor logging')
|
@click.option('--tl', is_flag=True, help='Enable tractor logging')
|
||||||
@click.option('--configdir', '-c', help='Configuration directory')
|
@click.option('--configdir', '-c', help='Configuration directory')
|
||||||
@click.option(
|
@click.option('--host', '-h', default=None, help='Host addr to bind')
|
||||||
'--pdb',
|
@click.option('--port', '-p', default=None, help='Port number to bind')
|
||||||
is_flag=True,
|
|
||||||
help='Enable runtime debug mode ',
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
'--maddr',
|
|
||||||
'-m',
|
|
||||||
default=None,
|
|
||||||
multiple=True,
|
|
||||||
help='Multiaddr to bind',
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
'--regaddr',
|
|
||||||
'-r',
|
|
||||||
default=None,
|
|
||||||
help='Registrar addr to contact',
|
|
||||||
)
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(
|
def cli(
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
|
|
@ -251,11 +146,8 @@ def cli(
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
tl: bool,
|
tl: bool,
|
||||||
configdir: str,
|
configdir: str,
|
||||||
pdb: bool,
|
host: str,
|
||||||
|
port: int,
|
||||||
# TODO: make these list[str] with multiple -m maddr0 -m maddr1
|
|
||||||
maddr: list[str],
|
|
||||||
regaddr: str,
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if configdir is not None:
|
if configdir is not None:
|
||||||
|
|
@ -276,20 +168,12 @@ def cli(
|
||||||
}
|
}
|
||||||
assert brokermods
|
assert brokermods
|
||||||
|
|
||||||
# TODO: load endpoints from `conf::[network].pikerd`
|
reg_addr: None | tuple[str, int] = None
|
||||||
# - pikerd vs. regd, separate registry daemon?
|
if host or port:
|
||||||
# - expose datad vs. brokerd?
|
reg_addr = (
|
||||||
# - bind emsd with certain perms on public iface?
|
host or _default_registry_host,
|
||||||
regaddrs: list[tuple[str, int]] = regaddr or [(
|
int(port) or _default_registry_port,
|
||||||
_default_registry_host,
|
)
|
||||||
_default_registry_port,
|
|
||||||
)]
|
|
||||||
|
|
||||||
# TODO: factor [network] section parsing out from pikerd
|
|
||||||
# above and call it here as well.
|
|
||||||
# if maddr:
|
|
||||||
# for addr in maddr:
|
|
||||||
# layers: dict = parse_maddr(addr)
|
|
||||||
|
|
||||||
ctx.obj.update({
|
ctx.obj.update({
|
||||||
'brokers': brokers,
|
'brokers': brokers,
|
||||||
|
|
@ -299,12 +183,7 @@ def cli(
|
||||||
'log': get_console_log(loglevel),
|
'log': get_console_log(loglevel),
|
||||||
'confdir': config._config_dir,
|
'confdir': config._config_dir,
|
||||||
'wl_path': config._watchlists_data_path,
|
'wl_path': config._watchlists_data_path,
|
||||||
'registry_addrs': regaddrs,
|
'registry_addr': reg_addr,
|
||||||
'pdb': pdb, # debug mode flag
|
|
||||||
|
|
||||||
# TODO: endpoint parsing, pinging and binding
|
|
||||||
# on no existing server.
|
|
||||||
# 'maddrs': maddr,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# allow enabling same loglevel in ``tractor`` machinery
|
# allow enabling same loglevel in ``tractor`` machinery
|
||||||
|
|
@ -351,7 +230,8 @@ def services(config, tl, ports):
|
||||||
|
|
||||||
|
|
||||||
def _load_clis() -> None:
|
def _load_clis() -> None:
|
||||||
# from ..service import elastic # noqa
|
from ..service import marketstore # noqa
|
||||||
|
from ..service import elastic # noqa
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -104,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"
|
||||||
|
|
@ -135,19 +134,14 @@ def get_app_dir(
|
||||||
|
|
||||||
_click_config_dir: Path = Path(get_app_dir('piker'))
|
_click_config_dir: Path = Path(get_app_dir('piker'))
|
||||||
_config_dir: Path = _click_config_dir
|
_config_dir: Path = _click_config_dir
|
||||||
|
_parent_user: str = os.environ.get('SUDO_USER')
|
||||||
|
|
||||||
# NOTE: when using `sudo` we attempt to determine the non-root user
|
if _parent_user:
|
||||||
# and still use their normal config dir.
|
|
||||||
if (
|
|
||||||
(_parent_user := os.environ.get('SUDO_USER'))
|
|
||||||
and
|
|
||||||
_parent_user != 'root'
|
|
||||||
):
|
|
||||||
non_root_user_dir = Path(
|
non_root_user_dir = Path(
|
||||||
os.path.expanduser(f'~{_parent_user}')
|
os.path.expanduser(f'~{_parent_user}')
|
||||||
)
|
)
|
||||||
root: str = 'root'
|
root: str = 'root'
|
||||||
_ccds: str = str(_click_config_dir) # click config dir as string
|
_ccds: str = str(_click_config_dir) # click config dir string
|
||||||
i_tail: int = int(_ccds.rfind(root) + len(root))
|
i_tail: int = int(_ccds.rfind(root) + len(root))
|
||||||
_config_dir = (
|
_config_dir = (
|
||||||
non_root_user_dir
|
non_root_user_dir
|
||||||
|
|
@ -252,8 +246,7 @@ def repodir() -> Path:
|
||||||
|
|
||||||
|
|
||||||
def load(
|
def load(
|
||||||
# NOTE: always appended with .toml suffix
|
conf_name: str = 'brokers', # appended with .toml suffix
|
||||||
conf_name: str = 'conf',
|
|
||||||
path: Path | None = None,
|
path: Path | None = None,
|
||||||
|
|
||||||
decode: Callable[
|
decode: Callable[
|
||||||
|
|
@ -364,9 +357,7 @@ def load_accounts(
|
||||||
|
|
||||||
) -> bidict[str, str | None]:
|
) -> bidict[str, str | None]:
|
||||||
|
|
||||||
conf, path = load(
|
conf, path = load()
|
||||||
conf_name='brokers',
|
|
||||||
)
|
|
||||||
accounts = bidict()
|
accounts = bidict()
|
||||||
for provider_name, section in conf.items():
|
for provider_name, section in conf.items():
|
||||||
accounts_section = section.get('accounts')
|
accounts_section = section.get('accounts')
|
||||||
|
|
@ -387,3 +378,51 @@ def load_accounts(
|
||||||
accounts['paper'] = None
|
accounts['paper'] = None
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
# XXX: Recursive getting & setting
|
||||||
|
|
||||||
|
def get_value(_dict, _section):
|
||||||
|
subs = _section.split('.')
|
||||||
|
if len(subs) > 1:
|
||||||
|
return get_value(
|
||||||
|
_dict[subs[0]],
|
||||||
|
'.'.join(subs[1:]),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return _dict[_section]
|
||||||
|
|
||||||
|
|
||||||
|
def set_value(_dict, _section, val):
|
||||||
|
subs = _section.split('.')
|
||||||
|
if len(subs) > 1:
|
||||||
|
if subs[0] not in _dict:
|
||||||
|
_dict[subs[0]] = {}
|
||||||
|
|
||||||
|
return set_value(
|
||||||
|
_dict[subs[0]],
|
||||||
|
'.'.join(subs[1:]),
|
||||||
|
val
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
_dict[_section] = val
|
||||||
|
|
||||||
|
|
||||||
|
def del_value(_dict, _section):
|
||||||
|
subs = _section.split('.')
|
||||||
|
if len(subs) > 1:
|
||||||
|
if subs[0] not in _dict:
|
||||||
|
return
|
||||||
|
|
||||||
|
return del_value(
|
||||||
|
_dict[subs[0]],
|
||||||
|
'.'.join(subs[1:])
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if _section not in _dict:
|
||||||
|
return
|
||||||
|
|
||||||
|
del _dict[_section]
|
||||||
|
|
|
||||||
|
|
@ -39,33 +39,18 @@ from .feed import (
|
||||||
open_feed,
|
open_feed,
|
||||||
)
|
)
|
||||||
from .flows import Flume
|
from .flows import Flume
|
||||||
from ._symcache import (
|
|
||||||
SymbologyCache,
|
|
||||||
open_symcache,
|
|
||||||
get_symcache,
|
|
||||||
match_from_pairs,
|
|
||||||
)
|
|
||||||
from ._sampling import open_sample_stream
|
|
||||||
from ..types import Struct
|
|
||||||
|
|
||||||
|
|
||||||
__all__: list[str] = [
|
__all__ = [
|
||||||
'Flume',
|
'Flume',
|
||||||
'Feed',
|
'Feed',
|
||||||
'open_feed',
|
'open_feed',
|
||||||
'ShmArray',
|
'ShmArray',
|
||||||
'iterticks',
|
'iterticks',
|
||||||
'maybe_open_shm_array',
|
'maybe_open_shm_array',
|
||||||
'match_from_pairs',
|
|
||||||
'attach_shm_array',
|
'attach_shm_array',
|
||||||
'open_shm_array',
|
'open_shm_array',
|
||||||
'get_shm_token',
|
'get_shm_token',
|
||||||
'def_iohlcv_fields',
|
'def_iohlcv_fields',
|
||||||
'def_ohlcv_fields',
|
'def_ohlcv_fields',
|
||||||
'open_symcache',
|
|
||||||
'open_sample_stream',
|
|
||||||
'get_symcache',
|
|
||||||
'Struct',
|
|
||||||
'SymbologyCache',
|
|
||||||
'types',
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||||
|
|
||||||
# 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,10 +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/>.
|
||||||
'''
|
"""
|
||||||
Pre-(path)-graphics formatted x/y nd/1d rendering subsystem.
|
Pre-(path)-graphics formatted x/y nd/1d rendering subsystem.
|
||||||
|
|
||||||
'''
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import (
|
from typing import (
|
||||||
Optional,
|
Optional,
|
||||||
|
|
@ -39,12 +39,7 @@ if TYPE_CHECKING:
|
||||||
from ._dataviz import (
|
from ._dataviz import (
|
||||||
Viz,
|
Viz,
|
||||||
)
|
)
|
||||||
from piker.toolz import Profiler
|
from .._profile import Profiler
|
||||||
|
|
||||||
# default gap between bars: "bar gap multiplier"
|
|
||||||
# - 0.5 is no overlap between OC arms,
|
|
||||||
# - 1.0 is full overlap on each neighbor sample
|
|
||||||
BGM: float = 0.16
|
|
||||||
|
|
||||||
|
|
||||||
class IncrementalFormatter(msgspec.Struct):
|
class IncrementalFormatter(msgspec.Struct):
|
||||||
|
|
@ -518,7 +513,6 @@ class IncrementalFormatter(msgspec.Struct):
|
||||||
|
|
||||||
|
|
||||||
class OHLCBarsFmtr(IncrementalFormatter):
|
class OHLCBarsFmtr(IncrementalFormatter):
|
||||||
|
|
||||||
x_offset: np.ndarray = np.array([
|
x_offset: np.ndarray = np.array([
|
||||||
-0.5,
|
-0.5,
|
||||||
0,
|
0,
|
||||||
|
|
@ -610,9 +604,8 @@ class OHLCBarsFmtr(IncrementalFormatter):
|
||||||
vr: tuple[int, int],
|
vr: tuple[int, int],
|
||||||
|
|
||||||
start: int = 0, # XXX: do we need this?
|
start: int = 0, # XXX: do we need this?
|
||||||
|
|
||||||
# 0.5 is no overlap between arms, 1.0 is full overlap
|
# 0.5 is no overlap between arms, 1.0 is full overlap
|
||||||
gap: float = BGM,
|
w: float = 0.16,
|
||||||
|
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
np.ndarray,
|
np.ndarray,
|
||||||
|
|
@ -629,7 +622,7 @@ class OHLCBarsFmtr(IncrementalFormatter):
|
||||||
array[:-1],
|
array[:-1],
|
||||||
start,
|
start,
|
||||||
bar_w=self.index_step_size,
|
bar_w=self.index_step_size,
|
||||||
bar_gap=gap * self.index_step_size,
|
bar_gap=w * self.index_step_size,
|
||||||
|
|
||||||
# XXX: don't ask, due to a ``numba`` bug..
|
# XXX: don't ask, due to a ``numba`` bug..
|
||||||
use_time_index=(self.index_field == 'time'),
|
use_time_index=(self.index_field == 'time'),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@
|
||||||
Super fast ``QPainterPath`` generation related operator routines.
|
Super fast ``QPainterPath`` generation related operator routines.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from math import (
|
||||||
|
ceil,
|
||||||
|
floor,
|
||||||
|
)
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.lib import recfunctions as rfn
|
from numpy.lib import recfunctions as rfn
|
||||||
from numba import (
|
from numba import (
|
||||||
|
|
@ -30,6 +35,11 @@ from numba import (
|
||||||
# TODO: for ``numba`` typing..
|
# TODO: for ``numba`` typing..
|
||||||
# from ._source import numba_ohlc_dtype
|
# from ._source import numba_ohlc_dtype
|
||||||
from ._m4 import ds_m4
|
from ._m4 import ds_m4
|
||||||
|
from .._profile import (
|
||||||
|
Profiler,
|
||||||
|
pg_profile_enabled,
|
||||||
|
ms_slower_then,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def xy_downsample(
|
def xy_downsample(
|
||||||
|
|
@ -125,7 +135,7 @@ def path_arrays_from_ohlc(
|
||||||
half_w: float = bar_w/2
|
half_w: float = bar_w/2
|
||||||
|
|
||||||
# TODO: report bug for assert @
|
# TODO: report bug for assert @
|
||||||
# ../piker/env/lib/python3.8/site-packages/numba/core/typing/builtins.py:991
|
# /home/goodboy/repos/piker/env/lib/python3.8/site-packages/numba/core/typing/builtins.py:991
|
||||||
for i, q in enumerate(data[start:], start):
|
for i, q in enumerate(data[start:], start):
|
||||||
|
|
||||||
open = q['open']
|
open = q['open']
|
||||||
|
|
@ -227,20 +237,20 @@ def trace_hl(
|
||||||
|
|
||||||
for i in range(hl.size):
|
for i in range(hl.size):
|
||||||
row = hl[i]
|
row = hl[i]
|
||||||
lo, hi = row['low'], row['high']
|
l, h = row['low'], row['high']
|
||||||
|
|
||||||
up_diff = hi - last_l
|
up_diff = h - last_l
|
||||||
down_diff = last_h - lo
|
down_diff = last_h - l
|
||||||
|
|
||||||
if up_diff > down_diff:
|
if up_diff > down_diff:
|
||||||
out[2*i + 1] = hi
|
out[2*i + 1] = h
|
||||||
out[2*i] = last_l
|
out[2*i] = last_l
|
||||||
else:
|
else:
|
||||||
out[2*i + 1] = lo
|
out[2*i + 1] = l
|
||||||
out[2*i] = last_h
|
out[2*i] = last_h
|
||||||
|
|
||||||
last_l = lo
|
last_l = l
|
||||||
last_h = hi
|
last_h = h
|
||||||
|
|
||||||
x[2*i] = int(i) - margin
|
x[2*i] = int(i) - margin
|
||||||
x[2*i + 1] = int(i) + margin
|
x[2*i + 1] = int(i) + margin
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,6 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
from tractor import (
|
|
||||||
Context,
|
|
||||||
MsgStream,
|
|
||||||
Channel,
|
|
||||||
)
|
|
||||||
from tractor.trionics import (
|
from tractor.trionics import (
|
||||||
maybe_open_nursery,
|
maybe_open_nursery,
|
||||||
)
|
)
|
||||||
|
|
@ -58,10 +53,7 @@ if TYPE_CHECKING:
|
||||||
from ._sharedmem import (
|
from ._sharedmem import (
|
||||||
ShmArray,
|
ShmArray,
|
||||||
)
|
)
|
||||||
from .feed import (
|
from .feed import _FeedsBus
|
||||||
_FeedsBus,
|
|
||||||
Sub,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# highest frequency sample step is 1 second by default, though in
|
# highest frequency sample step is 1 second by default, though in
|
||||||
|
|
@ -102,7 +94,7 @@ class Sampler:
|
||||||
float,
|
float,
|
||||||
list[
|
list[
|
||||||
float,
|
float,
|
||||||
set[MsgStream]
|
set[tractor.MsgStream]
|
||||||
],
|
],
|
||||||
] = defaultdict(
|
] = defaultdict(
|
||||||
lambda: [
|
lambda: [
|
||||||
|
|
@ -266,8 +258,8 @@ class Sampler:
|
||||||
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[tractor.MsgStream] = set()
|
||||||
sent: set[MsgStream] = set()
|
sent: set[tractor.MsgStream] = set()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
for stream in (subs - sent):
|
for stream in (subs - sent):
|
||||||
|
|
@ -322,7 +314,7 @@ class Sampler:
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
async def register_with_sampler(
|
async def register_with_sampler(
|
||||||
ctx: Context,
|
ctx: tractor.Context,
|
||||||
period_s: float,
|
period_s: float,
|
||||||
shms_by_period: dict[float, dict] | None = None,
|
shms_by_period: dict[float, dict] | None = None,
|
||||||
|
|
||||||
|
|
@ -657,7 +649,12 @@ async def sample_and_broadcast(
|
||||||
# eventually block this producer end of the feed and
|
# eventually block this producer end of the feed and
|
||||||
# thus other consumers still attached.
|
# thus other consumers still attached.
|
||||||
sub_key: str = broker_symbol.lower()
|
sub_key: str = broker_symbol.lower()
|
||||||
subs: set[Sub] = bus.get_subs(sub_key)
|
subs: list[
|
||||||
|
tuple[
|
||||||
|
tractor.MsgStream | trio.MemorySendChannel,
|
||||||
|
float | None, # tick throttle in Hz
|
||||||
|
]
|
||||||
|
] = bus.get_subs(sub_key)
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -666,40 +663,34 @@ async def sample_and_broadcast(
|
||||||
fqme: str = f'{broker_symbol}.{brokername}'
|
fqme: str = f'{broker_symbol}.{brokername}'
|
||||||
lags: int = 0
|
lags: int = 0
|
||||||
|
|
||||||
# XXX TODO XXX: speed up this loop in an AOT compiled
|
# TODO: speed up this loop in an AOT compiled lang (like
|
||||||
# lang (like rust or nim or zig)!
|
# rust or nim or zig) and/or instead of doing a fan out to
|
||||||
# AND/OR instead of doing a fan out to TCP sockets
|
# TCP sockets here, we add a shm-style tick queue which
|
||||||
# here, we add a shm-style tick queue which readers can
|
# readers can pull from instead of placing the burden of
|
||||||
# pull from instead of placing the burden of broadcast
|
# broadcast on solely on this `brokerd` actor. see issues:
|
||||||
# on solely on this `brokerd` actor. see issues:
|
|
||||||
# - https://github.com/pikers/piker/issues/98
|
# - https://github.com/pikers/piker/issues/98
|
||||||
# - https://github.com/pikers/piker/issues/107
|
# - https://github.com/pikers/piker/issues/107
|
||||||
|
|
||||||
# for (stream, tick_throttle) in subs.copy():
|
for (stream, tick_throttle) in subs.copy():
|
||||||
for sub in subs.copy():
|
|
||||||
ipc: MsgStream = sub.ipc
|
|
||||||
throttle: float = sub.throttle_rate
|
|
||||||
try:
|
try:
|
||||||
with trio.move_on_after(0.2) as cs:
|
with trio.move_on_after(0.2) as cs:
|
||||||
if throttle:
|
if tick_throttle:
|
||||||
send_chan: trio.abc.SendChannel = sub.send_chan
|
|
||||||
|
|
||||||
# this is a send mem chan that likely
|
# this is a send mem chan that likely
|
||||||
# pushes to the ``uniform_rate_send()`` below.
|
# pushes to the ``uniform_rate_send()`` below.
|
||||||
try:
|
try:
|
||||||
send_chan.send_nowait(
|
stream.send_nowait(
|
||||||
(fqme, quote)
|
(fqme, quote)
|
||||||
)
|
)
|
||||||
except trio.WouldBlock:
|
except trio.WouldBlock:
|
||||||
overruns[sub_key] += 1
|
overruns[sub_key] += 1
|
||||||
ctx: Context = ipc._ctx
|
ctx = stream._ctx
|
||||||
chan: Channel = ctx.chan
|
chan = ctx.chan
|
||||||
|
|
||||||
log.warning(
|
log.warning(
|
||||||
f'Feed OVERRUN {sub_key}'
|
f'Feed OVERRUN {sub_key}'
|
||||||
'@{bus.brokername} -> \n'
|
'@{bus.brokername} -> \n'
|
||||||
f'feed @ {chan.uid}\n'
|
f'feed @ {chan.uid}\n'
|
||||||
f'throttle = {throttle} Hz'
|
f'throttle = {tick_throttle} Hz'
|
||||||
)
|
)
|
||||||
|
|
||||||
if overruns[sub_key] > 6:
|
if overruns[sub_key] > 6:
|
||||||
|
|
@ -716,33 +707,33 @@ async def sample_and_broadcast(
|
||||||
f'{sub_key}:'
|
f'{sub_key}:'
|
||||||
f'{ctx.cid}@{chan.uid}'
|
f'{ctx.cid}@{chan.uid}'
|
||||||
)
|
)
|
||||||
await ipc.aclose()
|
await stream.aclose()
|
||||||
raise trio.BrokenResourceError
|
raise trio.BrokenResourceError
|
||||||
else:
|
else:
|
||||||
await ipc.send(
|
await stream.send(
|
||||||
{fqme: quote}
|
{fqme: quote}
|
||||||
)
|
)
|
||||||
|
|
||||||
if cs.cancelled_caught:
|
if cs.cancelled_caught:
|
||||||
lags += 1
|
lags += 1
|
||||||
if lags > 10:
|
if lags > 10:
|
||||||
await tractor.pause()
|
await tractor.breakpoint()
|
||||||
|
|
||||||
except (
|
except (
|
||||||
trio.BrokenResourceError,
|
trio.BrokenResourceError,
|
||||||
trio.ClosedResourceError,
|
trio.ClosedResourceError,
|
||||||
trio.EndOfChannel,
|
trio.EndOfChannel,
|
||||||
):
|
):
|
||||||
ctx: Context = ipc._ctx
|
ctx = stream._ctx
|
||||||
chan: Channel = ctx.chan
|
chan = ctx.chan
|
||||||
if ctx:
|
if ctx:
|
||||||
log.warning(
|
log.warning(
|
||||||
'Dropped `brokerd`-quotes-feed connection:\n'
|
'Dropped `brokerd`-quotes-feed connection:\n'
|
||||||
f'{broker_symbol}:'
|
f'{broker_symbol}:'
|
||||||
f'{ctx.cid}@{chan.uid}'
|
f'{ctx.cid}@{chan.uid}'
|
||||||
)
|
)
|
||||||
if sub.throttle_rate:
|
if tick_throttle:
|
||||||
assert ipc._closed
|
assert stream._closed
|
||||||
|
|
||||||
# XXX: do we need to deregister here
|
# XXX: do we need to deregister here
|
||||||
# if it's done in the fee bus code?
|
# if it's done in the fee bus code?
|
||||||
|
|
@ -751,7 +742,7 @@ async def sample_and_broadcast(
|
||||||
# since there seems to be some kinda race..
|
# since there seems to be some kinda race..
|
||||||
bus.remove_subs(
|
bus.remove_subs(
|
||||||
sub_key,
|
sub_key,
|
||||||
{sub},
|
{(stream, tick_throttle)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -759,7 +750,7 @@ async def uniform_rate_send(
|
||||||
|
|
||||||
rate: float,
|
rate: float,
|
||||||
quote_stream: trio.abc.ReceiveChannel,
|
quote_stream: trio.abc.ReceiveChannel,
|
||||||
stream: MsgStream,
|
stream: tractor.MsgStream,
|
||||||
|
|
||||||
task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import tractor
|
||||||
|
|
||||||
from ._util import log
|
from ._util import log
|
||||||
from ._source import def_iohlcv_fields
|
from ._source import def_iohlcv_fields
|
||||||
from piker.types import Struct
|
from .types import Struct
|
||||||
|
|
||||||
|
|
||||||
def cuckoff_mantracker():
|
def cuckoff_mantracker():
|
||||||
|
|
|
||||||
|
|
@ -1,510 +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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Mega-simple symbology cache via TOML files.
|
|
||||||
|
|
||||||
Allow backend data providers and/or brokers to stash their
|
|
||||||
symbology sets (aka the meta data we normalize into our
|
|
||||||
`.accounting.MktPair` type) to the filesystem for faster lookup and
|
|
||||||
offline usage.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
from contextlib import (
|
|
||||||
asynccontextmanager as acm,
|
|
||||||
)
|
|
||||||
from pathlib import Path
|
|
||||||
from pprint import pformat
|
|
||||||
from typing import (
|
|
||||||
Any,
|
|
||||||
Sequence,
|
|
||||||
Hashable,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
from types import ModuleType
|
|
||||||
|
|
||||||
from rapidfuzz import process as fuzzy
|
|
||||||
import tomli_w # for fast symbol cache writing
|
|
||||||
import tractor
|
|
||||||
import trio
|
|
||||||
try:
|
|
||||||
import tomllib
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
import tomli as tomllib
|
|
||||||
from msgspec import field
|
|
||||||
|
|
||||||
from piker.log import get_logger
|
|
||||||
from piker import config
|
|
||||||
from piker.types import Struct
|
|
||||||
from piker.brokers import (
|
|
||||||
open_cached_client,
|
|
||||||
get_brokermod,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..accounting import (
|
|
||||||
Asset,
|
|
||||||
MktPair,
|
|
||||||
)
|
|
||||||
|
|
||||||
log = get_logger('data.cache')
|
|
||||||
|
|
||||||
|
|
||||||
class SymbologyCache(Struct):
|
|
||||||
'''
|
|
||||||
Asset meta-data cache which holds lookup tables for 3 sets of
|
|
||||||
market-symbology related struct-types required by the
|
|
||||||
`.accounting` and `.data` subsystems.
|
|
||||||
|
|
||||||
'''
|
|
||||||
mod: ModuleType
|
|
||||||
fp: Path
|
|
||||||
|
|
||||||
# all asset-money-systems descriptions as minimally defined by
|
|
||||||
# in `.accounting.Asset`
|
|
||||||
assets: dict[str, Asset] = field(default_factory=dict)
|
|
||||||
|
|
||||||
# backend-system pairs loaded in provider (schema) specific
|
|
||||||
# structs.
|
|
||||||
pairs: dict[str, Struct] = field(default_factory=dict)
|
|
||||||
# serialized namespace path to the backend's pair-info-`Struct`
|
|
||||||
# defn B)
|
|
||||||
pair_ns_path: tractor.msg.NamespacePath | None = None
|
|
||||||
|
|
||||||
# TODO: piker-normalized `.accounting.MktPair` table?
|
|
||||||
# loaded from the `.pairs` and a normalizer
|
|
||||||
# provided by the backend pkg.
|
|
||||||
mktmaps: dict[str, MktPair] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def write_config(self) -> None:
|
|
||||||
|
|
||||||
# put the backend's pair-struct type ref at the top
|
|
||||||
# of file if possible.
|
|
||||||
cachedict: dict[str, Any] = {
|
|
||||||
'pair_ns_path': str(self.pair_ns_path) or '',
|
|
||||||
}
|
|
||||||
|
|
||||||
# serialize all tables as dicts for TOML.
|
|
||||||
for key, table in {
|
|
||||||
'assets': self.assets,
|
|
||||||
'pairs': self.pairs,
|
|
||||||
'mktmaps': self.mktmaps,
|
|
||||||
}.items():
|
|
||||||
if not table:
|
|
||||||
log.warning(
|
|
||||||
f'Asset cache table for `{key}` is empty?'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
dct = cachedict[key] = {}
|
|
||||||
for key, struct in table.items():
|
|
||||||
dct[key] = struct.to_dict(include_non_members=False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self.fp.open(mode='wb') as fp:
|
|
||||||
tomli_w.dump(cachedict, fp)
|
|
||||||
except TypeError:
|
|
||||||
self.fp.unlink()
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def load(self) -> None:
|
|
||||||
'''
|
|
||||||
Explicitly load the "symbology set" for this provider by using
|
|
||||||
2 required `Client` methods:
|
|
||||||
|
|
||||||
- `.get_assets()`: returning a table of `Asset`s
|
|
||||||
- `.get_mkt_pairs()`: returning a table of pair-`Struct`
|
|
||||||
types, custom defined by the particular backend.
|
|
||||||
|
|
||||||
AND, the required `.get_mkt_info()` module-level endpoint
|
|
||||||
which maps `fqme: str` -> `MktPair`s.
|
|
||||||
|
|
||||||
These tables are then used to fill out the `.assets`, `.pairs` and
|
|
||||||
`.mktmaps` tables on this cache instance, respectively.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async with open_cached_client(self.mod.name) as client:
|
|
||||||
|
|
||||||
if get_assets := getattr(client, 'get_assets', None):
|
|
||||||
assets: dict[str, Asset] = await get_assets()
|
|
||||||
for bs_mktid, asset in assets.items():
|
|
||||||
self.assets[bs_mktid] = asset
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
'No symbology cache `Asset` support for `{provider}`..\n'
|
|
||||||
'Implement `Client.get_assets()`!'
|
|
||||||
)
|
|
||||||
|
|
||||||
if get_mkt_pairs := getattr(client, 'get_mkt_pairs', None):
|
|
||||||
|
|
||||||
pairs: dict[str, Struct] = await get_mkt_pairs()
|
|
||||||
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):
|
|
||||||
raise TypeError(
|
|
||||||
f'Pair-struct for {self.mod.name} MUST define a '
|
|
||||||
'`.ns_path: str`!\n'
|
|
||||||
f'{pair}'
|
|
||||||
)
|
|
||||||
|
|
||||||
entry = await self.mod.get_mkt_info(pair.bs_fqme)
|
|
||||||
if not entry:
|
|
||||||
continue
|
|
||||||
|
|
||||||
mkt: MktPair
|
|
||||||
pair: Struct
|
|
||||||
mkt, _pair = entry
|
|
||||||
assert _pair is pair, (
|
|
||||||
f'`{self.mod.name}` backend probably has a '
|
|
||||||
'keying-symmetry problem between the pair-`Struct` '
|
|
||||||
'returned from `Client.get_mkt_pairs()`and the '
|
|
||||||
'module level endpoint: `.get_mkt_info()`\n\n'
|
|
||||||
"Here's the struct diff:\n"
|
|
||||||
f'{_pair - pair}'
|
|
||||||
)
|
|
||||||
# NOTE XXX: this means backends MUST implement
|
|
||||||
# a `Struct.bs_mktid: str` field to provide
|
|
||||||
# a native-keyed map to their own symbol
|
|
||||||
# set(s).
|
|
||||||
self.pairs[pair.bs_mktid] = pair
|
|
||||||
|
|
||||||
# NOTE: `MktPair`s are keyed here using piker's
|
|
||||||
# internal FQME schema so that search,
|
|
||||||
# accounting and feed init can be accomplished
|
|
||||||
# a sane, uniform, normalized basis.
|
|
||||||
self.mktmaps[mkt.fqme] = mkt
|
|
||||||
|
|
||||||
self.pair_ns_path: str = tractor.msg.NamespacePath.from_ref(
|
|
||||||
pair,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
'No symbology cache `Pair` support for `{provider}`..\n'
|
|
||||||
'Implement `Client.get_mkt_pairs()`!'
|
|
||||||
)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(
|
|
||||||
cls: type,
|
|
||||||
data: dict,
|
|
||||||
**kwargs,
|
|
||||||
) -> SymbologyCache:
|
|
||||||
|
|
||||||
# normal init inputs
|
|
||||||
cache = cls(**kwargs)
|
|
||||||
|
|
||||||
# XXX WARNING: this may break if backend namespacing
|
|
||||||
# changes (eg. `Pair` class def is moved to another
|
|
||||||
# module) in which case you can manually update the
|
|
||||||
# `pair_ns_path` in the symcache file and try again.
|
|
||||||
# TODO: probably a verbose error about this?
|
|
||||||
Pair: type = tractor.msg.NamespacePath(
|
|
||||||
str(data['pair_ns_path'])
|
|
||||||
).load_ref()
|
|
||||||
|
|
||||||
pairtable = data.pop('pairs')
|
|
||||||
for key, pairtable in pairtable.items():
|
|
||||||
|
|
||||||
# allow each serialized pair-dict-table to declare its
|
|
||||||
# specific struct type's path in cases where a backend
|
|
||||||
# supports multiples (normally with different
|
|
||||||
# schemas..) and we are storing them in a flat `.pairs`
|
|
||||||
# table.
|
|
||||||
ThisPair = Pair
|
|
||||||
if this_pair_type := pairtable.get('ns_path'):
|
|
||||||
ThisPair: type = tractor.msg.NamespacePath(
|
|
||||||
str(this_pair_type)
|
|
||||||
).load_ref()
|
|
||||||
|
|
||||||
pair: Struct = ThisPair(**pairtable)
|
|
||||||
cache.pairs[key] = pair
|
|
||||||
|
|
||||||
from ..accounting import (
|
|
||||||
Asset,
|
|
||||||
MktPair,
|
|
||||||
)
|
|
||||||
|
|
||||||
# load `dict` -> `Asset`
|
|
||||||
assettable = data.pop('assets')
|
|
||||||
for name, asdict in assettable.items():
|
|
||||||
cache.assets[name] = Asset.from_msg(asdict)
|
|
||||||
|
|
||||||
# load `dict` -> `MktPair`
|
|
||||||
dne: list[str] = []
|
|
||||||
mkttable = data.pop('mktmaps')
|
|
||||||
for fqme, mktdict in mkttable.items():
|
|
||||||
|
|
||||||
mkt = MktPair.from_msg(mktdict)
|
|
||||||
assert mkt.fqme == fqme
|
|
||||||
|
|
||||||
# sanity check asset refs from those (presumably)
|
|
||||||
# loaded asset set above.
|
|
||||||
src: Asset = cache.assets[mkt.src.name]
|
|
||||||
assert src == mkt.src
|
|
||||||
dst: Asset
|
|
||||||
if not (dst := cache.assets.get(mkt.dst.name)):
|
|
||||||
dne.append(mkt.dst.name)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
assert dst.name == mkt.dst.name
|
|
||||||
|
|
||||||
cache.mktmaps[fqme] = mkt
|
|
||||||
|
|
||||||
log.warning(
|
|
||||||
f'These `MktPair.dst: Asset`s DNE says `{cache.mod.name}`?\n'
|
|
||||||
f'{pformat(dne)}'
|
|
||||||
)
|
|
||||||
return cache
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def from_scratch(
|
|
||||||
mod: ModuleType,
|
|
||||||
fp: Path,
|
|
||||||
**kwargs,
|
|
||||||
|
|
||||||
) -> SymbologyCache:
|
|
||||||
'''
|
|
||||||
Generate (a) new symcache (contents) entirely from scratch
|
|
||||||
including all (TOML) serialized data and file.
|
|
||||||
|
|
||||||
'''
|
|
||||||
log.info(f'GENERATING symbology cache for `{mod.name}`')
|
|
||||||
cache = SymbologyCache(
|
|
||||||
mod=mod,
|
|
||||||
fp=fp,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
await cache.load()
|
|
||||||
cache.write_config()
|
|
||||||
return cache
|
|
||||||
|
|
||||||
def search(
|
|
||||||
self,
|
|
||||||
pattern: str,
|
|
||||||
table: str = 'mktmaps'
|
|
||||||
|
|
||||||
) -> dict[str, Struct]:
|
|
||||||
'''
|
|
||||||
(Fuzzy) search this cache's `.mktmaps` table, which is
|
|
||||||
keyed by FQMEs, for `pattern: str` and return the best
|
|
||||||
matches in a `dict` including the `MktPair` values.
|
|
||||||
|
|
||||||
'''
|
|
||||||
matches = fuzzy.extract(
|
|
||||||
pattern,
|
|
||||||
getattr(self, table),
|
|
||||||
score_cutoff=50,
|
|
||||||
)
|
|
||||||
|
|
||||||
# repack in dict[fqme, MktPair] form
|
|
||||||
return {
|
|
||||||
item[0].fqme: item[0]
|
|
||||||
for item in matches
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# actor-process-local in-mem-cache of symcaches (by backend).
|
|
||||||
_caches: dict[str, SymbologyCache] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def mk_cachefile(
|
|
||||||
provider: str,
|
|
||||||
) -> Path:
|
|
||||||
cachedir: Path = config.get_conf_dir() / '_cache'
|
|
||||||
if not cachedir.is_dir():
|
|
||||||
log.info(f'Creating `nativedb` director: {cachedir}')
|
|
||||||
cachedir.mkdir()
|
|
||||||
|
|
||||||
cachefile: Path = cachedir / f'{str(provider)}.symcache.toml'
|
|
||||||
cachefile.touch()
|
|
||||||
return cachefile
|
|
||||||
|
|
||||||
|
|
||||||
@acm
|
|
||||||
async def open_symcache(
|
|
||||||
mod_or_name: ModuleType | str,
|
|
||||||
|
|
||||||
reload: bool = False,
|
|
||||||
only_from_memcache: bool = False, # no API req
|
|
||||||
_no_symcache: bool = False, # no backend support
|
|
||||||
|
|
||||||
) -> SymbologyCache:
|
|
||||||
|
|
||||||
if isinstance(mod_or_name, str):
|
|
||||||
mod = get_brokermod(mod_or_name)
|
|
||||||
else:
|
|
||||||
mod: ModuleType = mod_or_name
|
|
||||||
|
|
||||||
provider: str = mod.name
|
|
||||||
cachefile: Path = mk_cachefile(provider)
|
|
||||||
|
|
||||||
# NOTE: certain backends might not support a symbology cache
|
|
||||||
# (easily) and thus we allow for an empty instance to be loaded
|
|
||||||
# and manually filled in at the whim of the caller presuming
|
|
||||||
# the backend pkg-module is annotated appropriately.
|
|
||||||
if (
|
|
||||||
getattr(mod, '_no_symcache', False)
|
|
||||||
or _no_symcache
|
|
||||||
):
|
|
||||||
yield SymbologyCache(
|
|
||||||
mod=mod,
|
|
||||||
fp=cachefile,
|
|
||||||
)
|
|
||||||
# don't do nuttin
|
|
||||||
return
|
|
||||||
|
|
||||||
# actor-level cache-cache XD
|
|
||||||
global _caches
|
|
||||||
if not reload:
|
|
||||||
try:
|
|
||||||
yield _caches[provider]
|
|
||||||
except KeyError:
|
|
||||||
msg: str = (
|
|
||||||
f'No asset info cache exists yet for `{provider}`'
|
|
||||||
)
|
|
||||||
if only_from_memcache:
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
else:
|
|
||||||
log.warning(msg)
|
|
||||||
|
|
||||||
# if no cache exists or an explicit reload is requested, load
|
|
||||||
# the provider API and call appropriate endpoints to populate
|
|
||||||
# the mkt and asset tables.
|
|
||||||
if (
|
|
||||||
reload
|
|
||||||
or not cachefile.is_file()
|
|
||||||
):
|
|
||||||
cache = await SymbologyCache.from_scratch(
|
|
||||||
mod=mod,
|
|
||||||
fp=cachefile,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
log.info(
|
|
||||||
f'Loading EXISTING `{mod.name}` symbology cache:\n'
|
|
||||||
f'> {cachefile}'
|
|
||||||
)
|
|
||||||
import time
|
|
||||||
now = time.time()
|
|
||||||
with cachefile.open('rb') as existing_fp:
|
|
||||||
data: dict[str, dict] = tomllib.load(existing_fp)
|
|
||||||
log.runtime(f'SYMCACHE TOML LOAD TIME: {time.time() - now}')
|
|
||||||
|
|
||||||
# if there's an empty file for some reason we need
|
|
||||||
# to do a full reload as well!
|
|
||||||
if not data:
|
|
||||||
cache = await SymbologyCache.from_scratch(
|
|
||||||
mod=mod,
|
|
||||||
fp=cachefile,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cache = SymbologyCache.from_dict(
|
|
||||||
data,
|
|
||||||
mod=mod,
|
|
||||||
fp=cachefile,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: use a real profiling sys..
|
|
||||||
# https://github.com/pikers/piker/issues/337
|
|
||||||
log.info(f'SYMCACHE LOAD TIME: {time.time() - now}')
|
|
||||||
|
|
||||||
yield cache
|
|
||||||
|
|
||||||
# TODO: write only when changes detected? but that should
|
|
||||||
# never happen right except on reload?
|
|
||||||
# cache.write_config()
|
|
||||||
|
|
||||||
|
|
||||||
def get_symcache(
|
|
||||||
provider: str,
|
|
||||||
force_reload: bool = False,
|
|
||||||
|
|
||||||
) -> SymbologyCache:
|
|
||||||
'''
|
|
||||||
Get any available symbology/assets cache from sync code by
|
|
||||||
(maybe) manually running `trio` to do the work.
|
|
||||||
|
|
||||||
'''
|
|
||||||
# spawn tractor runtime and generate cache
|
|
||||||
# if not existing.
|
|
||||||
async def sched_gen_symcache():
|
|
||||||
async with (
|
|
||||||
# only for runtime's debug mode
|
|
||||||
tractor.open_nursery(debug_mode=True),
|
|
||||||
|
|
||||||
open_symcache(
|
|
||||||
get_brokermod(provider),
|
|
||||||
reload=force_reload,
|
|
||||||
) as symcache,
|
|
||||||
):
|
|
||||||
return symcache
|
|
||||||
|
|
||||||
try:
|
|
||||||
symcache: SymbologyCache = trio.run(sched_gen_symcache)
|
|
||||||
assert symcache
|
|
||||||
except BaseException:
|
|
||||||
import pdbp
|
|
||||||
pdbp.xpm()
|
|
||||||
|
|
||||||
return symcache
|
|
||||||
|
|
||||||
|
|
||||||
def match_from_pairs(
|
|
||||||
pairs: dict[str, Struct],
|
|
||||||
query: str,
|
|
||||||
score_cutoff: int = 50,
|
|
||||||
**extract_kwargs,
|
|
||||||
|
|
||||||
) -> dict[str, Struct]:
|
|
||||||
'''
|
|
||||||
Fuzzy search over a "pairs table" maintained by most backends
|
|
||||||
as part of their symbology-info caching internals.
|
|
||||||
|
|
||||||
Scan the native symbol key set and return best ranked
|
|
||||||
matches back in a new `dict`.
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
# TODO: somehow cache this list (per call) like we were in
|
|
||||||
# `open_symbol_search()`?
|
|
||||||
keys: list[str] = list(pairs)
|
|
||||||
matches: list[tuple[
|
|
||||||
Sequence[Hashable], # matching input key
|
|
||||||
Any, # scores
|
|
||||||
Any,
|
|
||||||
]] = fuzzy.extract(
|
|
||||||
# NOTE: most backends provide keys uppercased
|
|
||||||
query=query,
|
|
||||||
choices=keys,
|
|
||||||
score_cutoff=score_cutoff,
|
|
||||||
**extract_kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
# pop and repack pairs in output dict
|
|
||||||
matched_pairs: dict[str, Struct] = {}
|
|
||||||
for item in matches:
|
|
||||||
pair_key: str = item[0]
|
|
||||||
matched_pairs[pair_key] = pairs[pair_key]
|
|
||||||
|
|
||||||
return matched_pairs
|
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Financial time series processing utilities usually
|
||||||
|
pertaining to OHLCV style sampled data.
|
||||||
|
|
||||||
|
Routines are generally implemented in either ``numpy`` or ``polars`` B)
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Literal
|
||||||
|
from math import (
|
||||||
|
ceil,
|
||||||
|
floor,
|
||||||
|
)
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
from ._sharedmem import ShmArray
|
||||||
|
from .._profile import (
|
||||||
|
Profiler,
|
||||||
|
pg_profile_enabled,
|
||||||
|
ms_slower_then,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def slice_from_time(
|
||||||
|
arr: np.ndarray,
|
||||||
|
start_t: float,
|
||||||
|
stop_t: float,
|
||||||
|
step: float, # sampler period step-diff
|
||||||
|
|
||||||
|
) -> slice:
|
||||||
|
'''
|
||||||
|
Calculate array indices mapped from a time range and return them in
|
||||||
|
a slice.
|
||||||
|
|
||||||
|
Given an input array with an epoch `'time'` series entry, calculate
|
||||||
|
the indices which span the time range and return in a slice. Presume
|
||||||
|
each `'time'` step increment is uniform and when the time stamp
|
||||||
|
series contains gaps (the uniform presumption is untrue) use
|
||||||
|
``np.searchsorted()`` binary search to look up the appropriate
|
||||||
|
index.
|
||||||
|
|
||||||
|
'''
|
||||||
|
profiler = Profiler(
|
||||||
|
msg='slice_from_time()',
|
||||||
|
disabled=not pg_profile_enabled(),
|
||||||
|
ms_threshold=ms_slower_then,
|
||||||
|
)
|
||||||
|
|
||||||
|
times = arr['time']
|
||||||
|
t_first = floor(times[0])
|
||||||
|
t_last = ceil(times[-1])
|
||||||
|
|
||||||
|
# the greatest index we can return which slices to the
|
||||||
|
# end of the input array.
|
||||||
|
read_i_max = arr.shape[0]
|
||||||
|
|
||||||
|
# compute (presumed) uniform-time-step index offsets
|
||||||
|
i_start_t = floor(start_t)
|
||||||
|
read_i_start = floor(((i_start_t - t_first) // step)) - 1
|
||||||
|
|
||||||
|
i_stop_t = ceil(stop_t)
|
||||||
|
|
||||||
|
# XXX: edge case -> always set stop index to last in array whenever
|
||||||
|
# the input stop time is detected to be greater then the equiv time
|
||||||
|
# stamp at that last entry.
|
||||||
|
if i_stop_t >= t_last:
|
||||||
|
read_i_stop = read_i_max
|
||||||
|
else:
|
||||||
|
read_i_stop = ceil((i_stop_t - t_first) // step) + 1
|
||||||
|
|
||||||
|
# always clip outputs to array support
|
||||||
|
# for read start:
|
||||||
|
# - never allow a start < the 0 index
|
||||||
|
# - never allow an end index > the read array len
|
||||||
|
read_i_start = min(
|
||||||
|
max(0, read_i_start),
|
||||||
|
read_i_max - 1,
|
||||||
|
)
|
||||||
|
read_i_stop = max(
|
||||||
|
0,
|
||||||
|
min(read_i_stop, read_i_max),
|
||||||
|
)
|
||||||
|
|
||||||
|
# check for larger-then-latest calculated index for given start
|
||||||
|
# time, in which case we do a binary search for the correct index.
|
||||||
|
# NOTE: this is usually the result of a time series with time gaps
|
||||||
|
# where it is expected that each index step maps to a uniform step
|
||||||
|
# in the time stamp series.
|
||||||
|
t_iv_start = times[read_i_start]
|
||||||
|
if (
|
||||||
|
t_iv_start > i_start_t
|
||||||
|
):
|
||||||
|
# do a binary search for the best index mapping to ``start_t``
|
||||||
|
# given we measured an overshoot using the uniform-time-step
|
||||||
|
# calculation from above.
|
||||||
|
|
||||||
|
# TODO: once we start caching these per source-array,
|
||||||
|
# we can just overwrite ``read_i_start`` directly.
|
||||||
|
new_read_i_start = np.searchsorted(
|
||||||
|
times,
|
||||||
|
i_start_t,
|
||||||
|
side='left',
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: minimize binary search work as much as possible:
|
||||||
|
# - cache these remap values which compensate for gaps in the
|
||||||
|
# uniform time step basis where we calc a later start
|
||||||
|
# index for the given input ``start_t``.
|
||||||
|
# - can we shorten the input search sequence by heuristic?
|
||||||
|
# up_to_arith_start = index[:read_i_start]
|
||||||
|
|
||||||
|
if (
|
||||||
|
new_read_i_start <= read_i_start
|
||||||
|
):
|
||||||
|
# t_diff = t_iv_start - start_t
|
||||||
|
# print(
|
||||||
|
# f"WE'RE CUTTING OUT TIME - STEP:{step}\n"
|
||||||
|
# f'start_t:{start_t} -> 0index start_t:{t_iv_start}\n'
|
||||||
|
# f'diff: {t_diff}\n'
|
||||||
|
# f'REMAPPED START i: {read_i_start} -> {new_read_i_start}\n'
|
||||||
|
# )
|
||||||
|
read_i_start = new_read_i_start
|
||||||
|
|
||||||
|
t_iv_stop = times[read_i_stop - 1]
|
||||||
|
if (
|
||||||
|
t_iv_stop > i_stop_t
|
||||||
|
):
|
||||||
|
# t_diff = stop_t - t_iv_stop
|
||||||
|
# print(
|
||||||
|
# f"WE'RE CUTTING OUT TIME - STEP:{step}\n"
|
||||||
|
# f'calced iv stop:{t_iv_stop} -> stop_t:{stop_t}\n'
|
||||||
|
# f'diff: {t_diff}\n'
|
||||||
|
# # f'SHOULD REMAP STOP: {read_i_start} -> {new_read_i_start}\n'
|
||||||
|
# )
|
||||||
|
new_read_i_stop = np.searchsorted(
|
||||||
|
times[read_i_start:],
|
||||||
|
# times,
|
||||||
|
i_stop_t,
|
||||||
|
side='right',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
new_read_i_stop <= read_i_stop
|
||||||
|
):
|
||||||
|
read_i_stop = read_i_start + new_read_i_stop + 1
|
||||||
|
|
||||||
|
# sanity checks for range size
|
||||||
|
# samples = (i_stop_t - i_start_t) // step
|
||||||
|
# index_diff = read_i_stop - read_i_start + 1
|
||||||
|
# if index_diff > (samples + 3):
|
||||||
|
# breakpoint()
|
||||||
|
|
||||||
|
# read-relative indexes: gives a slice where `shm.array[read_slc]`
|
||||||
|
# will be the data spanning the input time range `start_t` ->
|
||||||
|
# `stop_t`
|
||||||
|
read_slc = slice(
|
||||||
|
int(read_i_start),
|
||||||
|
int(read_i_stop),
|
||||||
|
)
|
||||||
|
|
||||||
|
profiler(
|
||||||
|
'slicing complete'
|
||||||
|
# f'{start_t} -> {abs_slc.start} | {read_slc.start}\n'
|
||||||
|
# f'{stop_t} -> {abs_slc.stop} | {read_slc.stop}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: if caller needs absolute buffer indices they can
|
||||||
|
# slice the buffer abs index like so:
|
||||||
|
# index = arr['index']
|
||||||
|
# abs_indx = index[read_slc]
|
||||||
|
# abs_slc = slice(
|
||||||
|
# int(abs_indx[0]),
|
||||||
|
# int(abs_indx[-1]),
|
||||||
|
# )
|
||||||
|
|
||||||
|
return read_slc
|
||||||
|
|
||||||
|
|
||||||
|
def detect_null_time_gap(
|
||||||
|
shm: ShmArray,
|
||||||
|
imargin: int = 1,
|
||||||
|
|
||||||
|
) -> tuple[float, float] | None:
|
||||||
|
'''
|
||||||
|
Detect if there are any zero-epoch stamped rows in
|
||||||
|
the presumed 'time' field-column.
|
||||||
|
|
||||||
|
Filter to the gap and return a surrounding index range.
|
||||||
|
|
||||||
|
NOTE: for now presumes only ONE gap XD
|
||||||
|
|
||||||
|
'''
|
||||||
|
zero_pred: np.ndarray = shm.array['time'] == 0
|
||||||
|
zero_t: np.ndarray = shm.array[zero_pred]
|
||||||
|
if zero_t.size:
|
||||||
|
istart, iend = zero_t['index'][[0, -1]]
|
||||||
|
start, end = shm._array['time'][
|
||||||
|
[istart - imargin, iend + imargin]
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
istart - imargin,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
iend + imargin,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
t_unit: Literal[
|
||||||
|
'days',
|
||||||
|
'hours',
|
||||||
|
'minutes',
|
||||||
|
'seconds',
|
||||||
|
'miliseconds',
|
||||||
|
'microseconds',
|
||||||
|
'nanoseconds',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def with_dts(
|
||||||
|
df: pl.DataFrame,
|
||||||
|
time_col: str = 'time',
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
'''
|
||||||
|
Insert datetime (casted) columns to a (presumably) OHLC sampled
|
||||||
|
time series with an epoch-time column keyed by ``time_col``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return df.with_columns([
|
||||||
|
pl.col(time_col).shift(1).suffix('_prev'),
|
||||||
|
pl.col(time_col).diff().alias('s_diff'),
|
||||||
|
pl.from_epoch(pl.col(time_col)).alias('dt'),
|
||||||
|
]).with_columns([
|
||||||
|
pl.from_epoch(pl.col(f'{time_col}_prev')).alias('dt_prev'),
|
||||||
|
pl.col('dt').diff().alias('dt_diff'),
|
||||||
|
]) #.with_columns(
|
||||||
|
# pl.col('dt').diff().dt.days().alias('days_dt_diff'),
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
def detect_time_gaps(
|
||||||
|
df: pl.DataFrame,
|
||||||
|
|
||||||
|
time_col: str = 'time',
|
||||||
|
# epoch sampling step diff
|
||||||
|
expect_period: float = 60,
|
||||||
|
|
||||||
|
# datetime diff unit and gap value
|
||||||
|
# crypto mkts
|
||||||
|
# gap_dt_unit: t_unit = 'minutes',
|
||||||
|
# gap_thresh: int = 1,
|
||||||
|
|
||||||
|
# legacy stock mkts
|
||||||
|
gap_dt_unit: t_unit = 'days',
|
||||||
|
gap_thresh: int = 2,
|
||||||
|
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
'''
|
||||||
|
Filter to OHLC datums which contain sample step gaps.
|
||||||
|
|
||||||
|
For eg. legacy markets which have venue close gaps and/or
|
||||||
|
actual missing data segments.
|
||||||
|
|
||||||
|
'''
|
||||||
|
dt_gap_col: str = f'{gap_dt_unit}_diff'
|
||||||
|
return with_dts(
|
||||||
|
df
|
||||||
|
).filter(
|
||||||
|
pl.col('s_diff').abs() > expect_period
|
||||||
|
).with_columns(
|
||||||
|
getattr(
|
||||||
|
pl.col('dt_diff').dt,
|
||||||
|
gap_dt_unit, # NOTE: must be valid ``Expr.dt.<name>``
|
||||||
|
)().alias(dt_gap_col)
|
||||||
|
).filter(
|
||||||
|
pl.col(dt_gap_col).abs() > gap_thresh
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_price_gaps(
|
||||||
|
df: pl.DataFrame,
|
||||||
|
gt_multiplier: float = 2.,
|
||||||
|
price_fields: list[str] = ['high', 'low'],
|
||||||
|
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
'''
|
||||||
|
Detect gaps in clearing price over an OHLC series.
|
||||||
|
|
||||||
|
2 types of gaps generally exist; up gaps and down gaps:
|
||||||
|
|
||||||
|
- UP gap: when any next sample's lo price is strictly greater
|
||||||
|
then the current sample's hi price.
|
||||||
|
|
||||||
|
- DOWN gap: when any next sample's hi price is strictly
|
||||||
|
less then the current samples lo price.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# return df.filter(
|
||||||
|
# pl.col('high') - ) > expect_period,
|
||||||
|
# ).select([
|
||||||
|
# pl.dt.datetime(pl.col(time_col).shift(1)).suffix('_previous'),
|
||||||
|
# pl.all(),
|
||||||
|
# ]).select([
|
||||||
|
# pl.all(),
|
||||||
|
# (pl.col(time_col) - pl.col(f'{time_col}_previous')).alias('diff'),
|
||||||
|
# ])
|
||||||
|
...
|
||||||
|
|
@ -50,8 +50,8 @@ from trio_websocket._impl import (
|
||||||
ConnectionTimeout,
|
ConnectionTimeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from piker.types import Struct
|
|
||||||
from ._util import log
|
from ._util import log
|
||||||
|
from .types import Struct
|
||||||
|
|
||||||
|
|
||||||
class NoBsWs:
|
class NoBsWs:
|
||||||
|
|
@ -234,13 +234,10 @@ async def _reconnect_forever(
|
||||||
f'{url} trying (RE)CONNECT'
|
f'{url} trying (RE)CONNECT'
|
||||||
)
|
)
|
||||||
|
|
||||||
ws: WebSocketConnection
|
async with trio.open_nursery() as n:
|
||||||
try:
|
|
||||||
async with (
|
|
||||||
trio.open_nursery() as n,
|
|
||||||
open_websocket_url(url) as ws,
|
|
||||||
):
|
|
||||||
cs = nobsws._cs = n.cancel_scope
|
cs = nobsws._cs = n.cancel_scope
|
||||||
|
ws: WebSocketConnection
|
||||||
|
async with open_websocket_url(url) as ws:
|
||||||
nobsws._ws = ws
|
nobsws._ws = ws
|
||||||
log.info(
|
log.info(
|
||||||
f'{src_mod}\n'
|
f'{src_mod}\n'
|
||||||
|
|
@ -272,11 +269,9 @@ 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:
|
|
||||||
log.exception('Retrying connection')
|
|
||||||
|
|
||||||
# ws & nursery block ends
|
|
||||||
|
|
||||||
|
# ws open block end
|
||||||
|
# nursery block end
|
||||||
nobsws._connected = trio.Event()
|
nobsws._connected = trio.Event()
|
||||||
if cs.cancelled_caught:
|
if cs.cancelled_caught:
|
||||||
log.cancel(
|
log.cancel(
|
||||||
|
|
@ -289,8 +284,7 @@ async def _reconnect_forever(
|
||||||
and not nobsws._connected.is_set()
|
and not nobsws._connected.is_set()
|
||||||
)
|
)
|
||||||
|
|
||||||
# -> from here, move to next reconnect attempt iteration
|
# -> from here, move to next reconnect attempt
|
||||||
# in the while loop above Bp
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
log.exception(
|
log.exception(
|
||||||
|
|
@ -359,8 +353,8 @@ async def open_autorecon_ws(
|
||||||
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
JSONRPC response-request style machinery for transparent multiplexing
|
JSONRPC response-request style machinery for transparent multiplexing of msgs
|
||||||
of msgs over a `NoBsWs`.
|
over a NoBsWs.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
@ -377,82 +371,43 @@ async def open_jsonrpc_session(
|
||||||
url: str,
|
url: str,
|
||||||
start_id: int = 0,
|
start_id: int = 0,
|
||||||
response_type: type = JSONRPCResult,
|
response_type: type = JSONRPCResult,
|
||||||
msg_recv_timeout: float = float('inf'),
|
request_type: Optional[type] = None,
|
||||||
# ^NOTE, since only `deribit` is using this jsonrpc stuff atm
|
request_hook: Optional[Callable] = None,
|
||||||
# and options mkts are generally "slow moving"..
|
error_hook: Optional[Callable] = None,
|
||||||
#
|
|
||||||
# FURTHER if we break the underlying ws connection then since we
|
|
||||||
# don't pass a `fixture` to the task that manages `NoBsWs`, i.e.
|
|
||||||
# `_reconnect_forever()`, the jsonrpc "transport pipe" get's
|
|
||||||
# broken and never restored with wtv init sequence is required to
|
|
||||||
# re-establish a working req-resp session.
|
|
||||||
|
|
||||||
) -> 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
|
|
||||||
# caller side!
|
|
||||||
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) as ws
|
||||||
url=url,
|
|
||||||
msg_recv_timeout=msg_recv_timeout,
|
|
||||||
) as ws
|
|
||||||
):
|
):
|
||||||
rpc_id: Iterable[int] = count(start_id)
|
rpc_id: Iterable = count(start_id)
|
||||||
rpc_results: dict[int, dict] = {}
|
rpc_results: dict[int, dict] = {}
|
||||||
|
|
||||||
async def json_rpc(
|
async def json_rpc(method: str, params: dict) -> dict:
|
||||||
method: str,
|
|
||||||
params: dict,
|
|
||||||
) -> dict:
|
|
||||||
'''
|
'''
|
||||||
perform a json rpc call and wait for the result, raise exception in
|
perform a json rpc call and wait for the result, raise exception in
|
||||||
case of error field present on response
|
case of error field present on response
|
||||||
'''
|
'''
|
||||||
nonlocal req_msgs
|
|
||||||
|
|
||||||
req_id: int = next(rpc_id)
|
|
||||||
msg = {
|
msg = {
|
||||||
'jsonrpc': '2.0',
|
'jsonrpc': '2.0',
|
||||||
'id': req_id,
|
'id': next(rpc_id),
|
||||||
'method': method,
|
'method': method,
|
||||||
'params': params
|
'params': params
|
||||||
}
|
}
|
||||||
_id = msg['id']
|
_id = msg['id']
|
||||||
|
|
||||||
result = rpc_results[_id] = {
|
rpc_results[_id] = {
|
||||||
'result': None,
|
'result': None,
|
||||||
'error': None,
|
'event': trio.Event()
|
||||||
'event': trio.Event(), # signal caller resp arrived
|
|
||||||
}
|
}
|
||||||
req_msgs[_id] = msg
|
|
||||||
|
|
||||||
await ws.send_msg(msg)
|
await ws.send_msg(msg)
|
||||||
|
|
||||||
# wait for reponse before unblocking requester code
|
|
||||||
await rpc_results[_id]['event'].wait()
|
await rpc_results[_id]['event'].wait()
|
||||||
|
|
||||||
if (maybe_result := result['result']):
|
ret = rpc_results[_id]['result']
|
||||||
ret = maybe_result
|
|
||||||
del rpc_results[_id]
|
|
||||||
|
|
||||||
else:
|
del rpc_results[_id]
|
||||||
err = result['error']
|
|
||||||
raise Exception(
|
|
||||||
f'JSONRPC request failed\n'
|
|
||||||
f'req: {msg}\n'
|
|
||||||
f'resp: {err}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
if ret.error is not None:
|
if ret.error is not None:
|
||||||
raise Exception(json.dumps(ret.error, indent=4))
|
raise Exception(json.dumps(ret.error, indent=4))
|
||||||
|
|
@ -467,7 +422,6 @@ async def open_jsonrpc_session(
|
||||||
the server side.
|
the server side.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
nonlocal req_msgs
|
|
||||||
async for msg in ws:
|
async for msg in ws:
|
||||||
match msg:
|
match msg:
|
||||||
case {
|
case {
|
||||||
|
|
@ -491,28 +445,19 @@ 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
|
||||||
}:
|
}:
|
||||||
# retreive orig request msg, set error
|
log.warning(f'Recieved\n{error}')
|
||||||
# response in original "result" msg,
|
if error_hook:
|
||||||
# THEN FINALLY set the event to signal caller
|
await error_hook(response_type(**msg))
|
||||||
# to raise the error in the parent task.
|
|
||||||
req_id: int = error['id']
|
|
||||||
req_msg: dict = req_msgs[req_id]
|
|
||||||
result: dict = rpc_results[req_id]
|
|
||||||
result['error'] = error
|
|
||||||
result['event'].set()
|
|
||||||
log.error(
|
|
||||||
f'JSONRPC request failed\n'
|
|
||||||
f'req: {req_msg}\n'
|
|
||||||
f'resp: {error}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ module.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from collections import (
|
from collections import (
|
||||||
defaultdict,
|
defaultdict,
|
||||||
abc,
|
|
||||||
)
|
)
|
||||||
from contextlib import asynccontextmanager as acm
|
from contextlib import asynccontextmanager as acm
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
@ -37,6 +36,7 @@ from types import ModuleType
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
AsyncContextManager,
|
AsyncContextManager,
|
||||||
|
Optional,
|
||||||
Awaitable,
|
Awaitable,
|
||||||
Sequence,
|
Sequence,
|
||||||
)
|
)
|
||||||
|
|
@ -45,62 +45,41 @@ import trio
|
||||||
from trio.abc import ReceiveChannel
|
from trio.abc import ReceiveChannel
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
import tractor
|
import tractor
|
||||||
from tractor import trionics
|
from tractor.trionics import (
|
||||||
|
maybe_open_context,
|
||||||
|
gather_contexts,
|
||||||
|
)
|
||||||
|
|
||||||
from piker.accounting import (
|
from ..brokers import get_brokermod
|
||||||
MktPair,
|
from ..calc import humanize
|
||||||
unpack_fqme,
|
|
||||||
)
|
|
||||||
from piker.types import Struct
|
|
||||||
from piker.brokers import get_brokermod
|
|
||||||
from piker.service import (
|
|
||||||
maybe_spawn_brokerd,
|
|
||||||
)
|
|
||||||
from piker.calc import humanize
|
|
||||||
from ._util import (
|
from ._util import (
|
||||||
log,
|
log,
|
||||||
get_console_log,
|
get_console_log,
|
||||||
)
|
)
|
||||||
|
from ..service import (
|
||||||
|
maybe_spawn_brokerd,
|
||||||
|
)
|
||||||
from .flows import Flume
|
from .flows import Flume
|
||||||
from .validate import (
|
from .validate import (
|
||||||
FeedInit,
|
FeedInit,
|
||||||
validate_backend,
|
validate_backend,
|
||||||
)
|
)
|
||||||
from ..tsp import (
|
from .history import (
|
||||||
manage_history,
|
manage_history,
|
||||||
)
|
)
|
||||||
from .ingest import get_ingestormod
|
from .ingest import get_ingestormod
|
||||||
|
from .types import Struct
|
||||||
|
from ..accounting import (
|
||||||
|
MktPair,
|
||||||
|
unpack_fqme,
|
||||||
|
)
|
||||||
|
from ..ui import _search
|
||||||
from ._sampling import (
|
from ._sampling import (
|
||||||
sample_and_broadcast,
|
sample_and_broadcast,
|
||||||
uniform_rate_send,
|
uniform_rate_send,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Sub(Struct, frozen=True):
|
|
||||||
'''
|
|
||||||
A live feed subscription entry.
|
|
||||||
|
|
||||||
Contains meta-data on the remote-actor type (in functionality
|
|
||||||
terms) as well as refs to IPC streams and sampler runtime
|
|
||||||
params.
|
|
||||||
|
|
||||||
'''
|
|
||||||
ipc: tractor.MsgStream
|
|
||||||
send_chan: trio.abc.SendChannel | None = None
|
|
||||||
|
|
||||||
# tick throttle rate in Hz; determines how live
|
|
||||||
# quotes/ticks should be downsampled before relay
|
|
||||||
# to the receiving remote consumer (process).
|
|
||||||
throttle_rate: float | None = None
|
|
||||||
_throttle_cs: trio.CancelScope | None = None
|
|
||||||
|
|
||||||
# TODO: actually stash comms info for the far end to allow
|
|
||||||
# `.tsp`, `.fsp` and `.data._sampling` sub-systems to re-render
|
|
||||||
# the data view as needed via msging with the `._remote_ctl`
|
|
||||||
# ipc ctx.
|
|
||||||
rc_ui: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class _FeedsBus(Struct):
|
class _FeedsBus(Struct):
|
||||||
'''
|
'''
|
||||||
Data feeds broadcaster and persistence management.
|
Data feeds broadcaster and persistence management.
|
||||||
|
|
@ -125,7 +104,13 @@ class _FeedsBus(Struct):
|
||||||
|
|
||||||
_subscribers: defaultdict[
|
_subscribers: defaultdict[
|
||||||
str,
|
str,
|
||||||
set[Sub]
|
set[
|
||||||
|
tuple[
|
||||||
|
tractor.MsgStream | trio.MemorySendChannel,
|
||||||
|
# tractor.Context,
|
||||||
|
float | None, # tick throttle in Hz
|
||||||
|
]
|
||||||
|
]
|
||||||
] = defaultdict(set)
|
] = defaultdict(set)
|
||||||
|
|
||||||
async def start_task(
|
async def start_task(
|
||||||
|
|
@ -140,8 +125,6 @@ class _FeedsBus(Struct):
|
||||||
trio.CancelScope] = trio.TASK_STATUS_IGNORED,
|
trio.CancelScope] = trio.TASK_STATUS_IGNORED,
|
||||||
) -> None:
|
) -> None:
|
||||||
with trio.CancelScope() as cs:
|
with trio.CancelScope() as cs:
|
||||||
# TODO: shouldn't this be a direct await to avoid
|
|
||||||
# cancellation contagion to the bus nursery!?!?!
|
|
||||||
await self.nursery.start(
|
await self.nursery.start(
|
||||||
target,
|
target,
|
||||||
*args,
|
*args,
|
||||||
|
|
@ -159,28 +142,31 @@ class _FeedsBus(Struct):
|
||||||
def get_subs(
|
def get_subs(
|
||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
|
) -> set[
|
||||||
) -> set[Sub]:
|
tuple[
|
||||||
|
tractor.MsgStream | trio.MemorySendChannel,
|
||||||
|
float | None, # tick throttle in Hz
|
||||||
|
]
|
||||||
|
]:
|
||||||
'''
|
'''
|
||||||
Get the ``set`` of consumer subscription entries for the given key.
|
Get the ``set`` of consumer subscription entries for the given key.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
return self._subscribers[key]
|
return self._subscribers[key]
|
||||||
|
|
||||||
def subs_items(self) -> abc.ItemsView[str, set[Sub]]:
|
|
||||||
return self._subscribers.items()
|
|
||||||
|
|
||||||
def add_subs(
|
def add_subs(
|
||||||
self,
|
self,
|
||||||
key: str,
|
key: str,
|
||||||
subs: set[Sub],
|
subs: set[tuple[
|
||||||
|
tractor.MsgStream | trio.MemorySendChannel,
|
||||||
) -> set[Sub]:
|
float | None, # tick throttle in Hz
|
||||||
|
]],
|
||||||
|
) -> set[tuple]:
|
||||||
'''
|
'''
|
||||||
Add a ``set`` of consumer subscription entries for the given key.
|
Add a ``set`` of consumer subscription entries for the given key.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
_subs: set[Sub] = self._subscribers.setdefault(key, set())
|
_subs: set[tuple] = self._subscribers[key]
|
||||||
_subs.update(subs)
|
_subs.update(subs)
|
||||||
return _subs
|
return _subs
|
||||||
|
|
||||||
|
|
@ -345,6 +331,7 @@ async def allocate_persistent_feed(
|
||||||
) = await bus.nursery.start(
|
) = await bus.nursery.start(
|
||||||
manage_history,
|
manage_history,
|
||||||
mod,
|
mod,
|
||||||
|
bus,
|
||||||
mkt,
|
mkt,
|
||||||
some_data_ready,
|
some_data_ready,
|
||||||
feed_is_live,
|
feed_is_live,
|
||||||
|
|
@ -421,12 +408,6 @@ async def allocate_persistent_feed(
|
||||||
rt_shm.array['time'][1] = ts + 1
|
rt_shm.array['time'][1] = ts + 1
|
||||||
|
|
||||||
elif hist_shm.array.size == 0:
|
elif hist_shm.array.size == 0:
|
||||||
for i in range(100):
|
|
||||||
await trio.sleep(0.1)
|
|
||||||
if hist_shm.array.size > 0:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
await tractor.pause()
|
|
||||||
raise RuntimeError(f'History (1m) Shm for {fqme} is empty!?')
|
raise RuntimeError(f'History (1m) Shm for {fqme} is empty!?')
|
||||||
|
|
||||||
# wait the spawning parent task to register its subscriber
|
# wait the spawning parent task to register its subscriber
|
||||||
|
|
@ -457,9 +438,8 @@ async def open_feed_bus(
|
||||||
symbols: list[str], # normally expected to the broker-specific fqme
|
symbols: list[str], # normally expected to the broker-specific fqme
|
||||||
|
|
||||||
loglevel: str = 'error',
|
loglevel: str = 'error',
|
||||||
tick_throttle: float | None = None,
|
tick_throttle: Optional[float] = None,
|
||||||
start_stream: bool = True,
|
start_stream: bool = True,
|
||||||
allow_remote_ctl_ui: bool = False,
|
|
||||||
|
|
||||||
) -> dict[
|
) -> dict[
|
||||||
str, # fqme
|
str, # fqme
|
||||||
|
|
@ -474,12 +454,8 @@ async def open_feed_bus(
|
||||||
if loglevel is None:
|
if loglevel is None:
|
||||||
loglevel = 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(loglevel or tractor.current_actor().loglevel)
|
||||||
get_console_log(
|
|
||||||
loglevel
|
|
||||||
or tractor.current_actor().loglevel
|
|
||||||
)
|
|
||||||
|
|
||||||
# local state sanity checks
|
# local state sanity checks
|
||||||
# TODO: check for any stale shm entries for this symbol
|
# TODO: check for any stale shm entries for this symbol
|
||||||
|
|
@ -489,7 +465,7 @@ async def open_feed_bus(
|
||||||
assert 'brokerd' in servicename
|
assert 'brokerd' in servicename
|
||||||
assert brokername in servicename
|
assert brokername in servicename
|
||||||
|
|
||||||
bus: _FeedsBus = get_feed_bus(brokername)
|
bus = get_feed_bus(brokername)
|
||||||
sub_registered = trio.Event()
|
sub_registered = trio.Event()
|
||||||
|
|
||||||
flumes: dict[str, Flume] = {}
|
flumes: dict[str, Flume] = {}
|
||||||
|
|
@ -536,10 +512,10 @@ async def open_feed_bus(
|
||||||
# pack for ``.started()`` sync msg
|
# pack for ``.started()`` sync msg
|
||||||
flumes[fqme] = flume
|
flumes[fqme] = flume
|
||||||
|
|
||||||
# we use the broker-specific fqme (bs_fqme) for the sampler
|
# we use the broker-specific fqme (bs_fqme) for the
|
||||||
# subscription since the backend isn't (yet) expected to
|
# sampler 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
|
||||||
# which *do not* include that name (e.g .ib) .
|
# *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
|
||||||
|
|
@ -578,60 +554,49 @@ async def open_feed_bus(
|
||||||
# that the ``sample_and_broadcast()`` task (spawned inside
|
# that the ``sample_and_broadcast()`` task (spawned inside
|
||||||
# ``allocate_persistent_feed()``) will push real-time quote
|
# ``allocate_persistent_feed()``) will push real-time quote
|
||||||
# (ticks) to this new consumer.
|
# (ticks) to this new consumer.
|
||||||
cs: trio.CancelScope | None = None
|
|
||||||
send: trio.MemorySendChannel | None = None
|
|
||||||
if tick_throttle:
|
if tick_throttle:
|
||||||
flume.throttle_rate = tick_throttle
|
flume.throttle_rate = tick_throttle
|
||||||
|
|
||||||
# open a bg task which receives quotes over a mem
|
# open a bg task which receives quotes over a mem chan
|
||||||
# chan and only pushes them to the target
|
# and only pushes them to the target actor-consumer at
|
||||||
# actor-consumer at a max ``tick_throttle``
|
# a max ``tick_throttle`` instantaneous rate.
|
||||||
# (instantaneous) rate.
|
|
||||||
send, recv = trio.open_memory_channel(2**10)
|
send, recv = trio.open_memory_channel(2**10)
|
||||||
|
|
||||||
# NOTE: the ``.send`` channel here is a swapped-in
|
cs = await bus.start_task(
|
||||||
# trio mem chan which gets `.send()`-ed by the normal
|
|
||||||
# sampler task but instead of being sent directly
|
|
||||||
# over the IPC msg stream it's the throttle task
|
|
||||||
# does the work of incrementally forwarding to the
|
|
||||||
# IPC stream at the throttle rate.
|
|
||||||
cs: trio.CancelScope = await bus.start_task(
|
|
||||||
uniform_rate_send,
|
uniform_rate_send,
|
||||||
tick_throttle,
|
tick_throttle,
|
||||||
recv,
|
recv,
|
||||||
stream,
|
stream,
|
||||||
)
|
)
|
||||||
|
# NOTE: so the ``send`` channel here is actually a swapped
|
||||||
|
# in trio mem chan which gets pushed by the normal sampler
|
||||||
|
# task but instead of being sent directly over the IPC msg
|
||||||
|
# stream it's the throttle task does the work of
|
||||||
|
# incrementally forwarding to the IPC stream at the throttle
|
||||||
|
# rate.
|
||||||
|
send._ctx = ctx # mock internal ``tractor.MsgStream`` ref
|
||||||
|
sub = (send, tick_throttle)
|
||||||
|
|
||||||
sub = Sub(
|
else:
|
||||||
ipc=stream,
|
sub = (stream, tick_throttle)
|
||||||
send_chan=send,
|
|
||||||
throttle_rate=tick_throttle,
|
|
||||||
_throttle_cs=cs,
|
|
||||||
rc_ui=allow_remote_ctl_ui,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: add an api for this on the bus?
|
# TODO: add an api for this on the bus?
|
||||||
# maybe use the current task-id to key the sub list that's
|
# maybe use the current task-id to key the sub list that's
|
||||||
# added / removed? Or maybe we can add a general
|
# added / removed? Or maybe we can add a general
|
||||||
# pause-resume by sub-key api?
|
# pause-resume by sub-key api?
|
||||||
bs_fqme = fqme.removesuffix(f'.{brokername}')
|
bs_fqme = fqme.removesuffix(f'.{brokername}')
|
||||||
local_subs.setdefault(
|
local_subs.setdefault(bs_fqme, set()).add(sub)
|
||||||
bs_fqme,
|
bus.add_subs(bs_fqme, {sub})
|
||||||
set()
|
|
||||||
).add(sub)
|
|
||||||
bus.add_subs(
|
|
||||||
bs_fqme,
|
|
||||||
{sub}
|
|
||||||
)
|
|
||||||
|
|
||||||
# sync caller with all subs registered state
|
# sync caller with all subs registered state
|
||||||
sub_registered.set()
|
sub_registered.set()
|
||||||
|
|
||||||
uid: tuple[str, str] = ctx.chan.uid
|
uid = ctx.chan.uid
|
||||||
try:
|
try:
|
||||||
# ctrl protocol for start/stop of live quote streams
|
# ctrl protocol for start/stop of quote streams based on UI
|
||||||
# based on UI state (eg. don't need a stream when
|
# state (eg. don't need a stream when a symbol isn't being
|
||||||
# a symbol isn't being displayed).
|
# displayed).
|
||||||
async for msg in stream:
|
async for msg in stream:
|
||||||
|
|
||||||
if msg == 'pause':
|
if msg == 'pause':
|
||||||
|
|
@ -769,7 +734,6 @@ async def install_brokerd_search(
|
||||||
except trio.EndOfChannel:
|
except trio.EndOfChannel:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
from piker.ui import _search
|
|
||||||
async with _search.register_symbol_search(
|
async with _search.register_symbol_search(
|
||||||
|
|
||||||
provider_name=brokermod.name,
|
provider_name=brokermod.name,
|
||||||
|
|
@ -788,7 +752,7 @@ async def install_brokerd_search(
|
||||||
async def maybe_open_feed(
|
async def maybe_open_feed(
|
||||||
|
|
||||||
fqmes: list[str],
|
fqmes: list[str],
|
||||||
loglevel: str | None = None,
|
loglevel: Optional[str] = None,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
|
|
@ -804,7 +768,7 @@ async def maybe_open_feed(
|
||||||
'''
|
'''
|
||||||
fqme = fqmes[0]
|
fqme = fqmes[0]
|
||||||
|
|
||||||
async with trionics.maybe_open_context(
|
async with maybe_open_context(
|
||||||
acm_func=open_feed,
|
acm_func=open_feed,
|
||||||
kwargs={
|
kwargs={
|
||||||
'fqmes': fqmes,
|
'fqmes': fqmes,
|
||||||
|
|
@ -824,7 +788,7 @@ async def maybe_open_feed(
|
||||||
# add a new broadcast subscription for the quote stream
|
# add a new broadcast subscription for the quote stream
|
||||||
# if this feed is likely already in use
|
# if this feed is likely already in use
|
||||||
|
|
||||||
async with trionics.gather_contexts(
|
async with gather_contexts(
|
||||||
mngrs=[stream.subscribe() for stream in feed.streams.values()]
|
mngrs=[stream.subscribe() for stream in feed.streams.values()]
|
||||||
) as bstreams:
|
) as bstreams:
|
||||||
for bstream, flume in zip(bstreams, feed.flumes.values()):
|
for bstream, flume in zip(bstreams, feed.flumes.values()):
|
||||||
|
|
@ -848,8 +812,6 @@ async def open_feed(
|
||||||
start_stream: bool = True,
|
start_stream: bool = True,
|
||||||
tick_throttle: float | None = None, # Hz
|
tick_throttle: float | None = None, # Hz
|
||||||
|
|
||||||
allow_remote_ctl_ui: bool = False,
|
|
||||||
|
|
||||||
) -> Feed:
|
) -> Feed:
|
||||||
'''
|
'''
|
||||||
Open a "data feed" which provides streamed real-time quotes.
|
Open a "data feed" which provides streamed real-time quotes.
|
||||||
|
|
@ -886,7 +848,7 @@ async def open_feed(
|
||||||
)
|
)
|
||||||
|
|
||||||
portals: tuple[tractor.Portal]
|
portals: tuple[tractor.Portal]
|
||||||
async with trionics.gather_contexts(
|
async with gather_contexts(
|
||||||
brokerd_ctxs,
|
brokerd_ctxs,
|
||||||
) as portals:
|
) as portals:
|
||||||
|
|
||||||
|
|
@ -932,19 +894,13 @@ async def open_feed(
|
||||||
# of these stream open sequences sequentially per
|
# of these stream open sequences sequentially per
|
||||||
# backend? .. need some thot!
|
# backend? .. need some thot!
|
||||||
allow_overruns=True,
|
allow_overruns=True,
|
||||||
|
|
||||||
# NOTE: UI actors (like charts) can allow
|
|
||||||
# remote control of certain graphics rendering
|
|
||||||
# capabilities via the
|
|
||||||
# `.ui._remote_ctl.remote_annotate()` msg loop.
|
|
||||||
allow_remote_ctl_ui=allow_remote_ctl_ui,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(feed.mods) == len(feed.portals)
|
assert len(feed.mods) == len(feed.portals)
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
trionics.gather_contexts(bus_ctxs) as ctxs,
|
gather_contexts(bus_ctxs) as ctxs,
|
||||||
):
|
):
|
||||||
stream_ctxs: list[tractor.MsgStream] = []
|
stream_ctxs: list[tractor.MsgStream] = []
|
||||||
for (
|
for (
|
||||||
|
|
@ -986,7 +942,7 @@ async def open_feed(
|
||||||
brokermod: ModuleType
|
brokermod: ModuleType
|
||||||
fqmes: list[str]
|
fqmes: list[str]
|
||||||
async with (
|
async with (
|
||||||
trionics.gather_contexts(stream_ctxs) as streams,
|
gather_contexts(stream_ctxs) as streams,
|
||||||
):
|
):
|
||||||
for (
|
for (
|
||||||
stream,
|
stream,
|
||||||
|
|
@ -1002,12 +958,6 @@ async def open_feed(
|
||||||
if brokermod.name == flume.mkt.broker:
|
if brokermod.name == flume.mkt.broker:
|
||||||
flume.stream = stream
|
flume.stream = stream
|
||||||
|
|
||||||
assert (
|
assert len(feed.mods) == len(feed.portals) == len(feed.streams)
|
||||||
len(feed.mods)
|
|
||||||
==
|
|
||||||
len(feed.portals)
|
|
||||||
==
|
|
||||||
len(feed.streams)
|
|
||||||
)
|
|
||||||
|
|
||||||
yield feed
|
yield feed
|
||||||
|
|
|
||||||
|
|
@ -30,27 +30,53 @@ import tractor
|
||||||
import pendulum
|
import pendulum
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from piker.types import Struct
|
from ..accounting import MktPair
|
||||||
|
from ._util import log
|
||||||
|
from .types import Struct
|
||||||
from ._sharedmem import (
|
from ._sharedmem import (
|
||||||
attach_shm_array,
|
attach_shm_array,
|
||||||
ShmArray,
|
ShmArray,
|
||||||
_Token,
|
_Token,
|
||||||
)
|
)
|
||||||
|
# from .._profile import (
|
||||||
|
# Profiler,
|
||||||
|
# pg_profile_enabled,
|
||||||
|
# )
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..accounting import MktPair
|
# from pyqtgraph import PlotItem
|
||||||
from .feed import Feed
|
from .feed import Feed
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: ideas for further abstractions as per
|
||||||
|
# https://github.com/pikers/piker/issues/216 and
|
||||||
|
# https://github.com/pikers/piker/issues/270:
|
||||||
|
# - a ``Cascade`` would be the minimal "connection" of 2 ``Flumes``
|
||||||
|
# as per circuit parlance:
|
||||||
|
# https://en.wikipedia.org/wiki/Two-port_network#Cascade_connection
|
||||||
|
# - could cover the combination of our `FspAdmin` and the
|
||||||
|
# backend `.fsp._engine` related machinery to "connect" one flume
|
||||||
|
# to another?
|
||||||
|
# - a (financial signal) ``Flow`` would be the a "collection" of such
|
||||||
|
# minmial cascades. Some engineering based jargon concepts:
|
||||||
|
# - https://en.wikipedia.org/wiki/Signal_chain
|
||||||
|
# - https://en.wikipedia.org/wiki/Daisy_chain_(electrical_engineering)
|
||||||
|
# - https://en.wikipedia.org/wiki/Audio_signal_flow
|
||||||
|
# - https://en.wikipedia.org/wiki/Digital_signal_processing#Implementation
|
||||||
|
# - https://en.wikipedia.org/wiki/Dataflow_programming
|
||||||
|
# - https://en.wikipedia.org/wiki/Signal_programming
|
||||||
|
# - https://en.wikipedia.org/wiki/Incremental_computing
|
||||||
|
|
||||||
|
|
||||||
class Flume(Struct):
|
class Flume(Struct):
|
||||||
'''
|
'''
|
||||||
Composite reference type which points to all the addressing
|
Composite reference type which points to all the addressing handles
|
||||||
handles and other meta-data necessary for the read, measure and
|
and other meta-data necessary for the read, measure and management
|
||||||
management of a set of real-time updated data flows.
|
of a set of real-time updated data flows.
|
||||||
|
|
||||||
Can be thought of as a "flow descriptor" or "flow frame" which
|
Can be thought of as a "flow descriptor" or "flow frame" which
|
||||||
describes the high level properties of a set of data flows that
|
describes the high level properties of a set of data flows that can
|
||||||
can be used seamlessly across process-memory boundaries.
|
be used seamlessly across process-memory boundaries.
|
||||||
|
|
||||||
Each instance's sub-components normally includes:
|
Each instance's sub-components normally includes:
|
||||||
- a msg oriented quote stream provided via an IPC transport
|
- a msg oriented quote stream provided via an IPC transport
|
||||||
|
|
@ -73,7 +99,6 @@ class Flume(Struct):
|
||||||
# private shm refs loaded dynamically from tokens
|
# private shm refs loaded dynamically from tokens
|
||||||
_hist_shm: ShmArray | None = None
|
_hist_shm: ShmArray | None = None
|
||||||
_rt_shm: ShmArray | None = None
|
_rt_shm: ShmArray | None = None
|
||||||
_readonly: bool = True
|
|
||||||
|
|
||||||
stream: tractor.MsgStream | None = None
|
stream: tractor.MsgStream | None = None
|
||||||
izero_hist: int = 0
|
izero_hist: int = 0
|
||||||
|
|
@ -90,7 +115,7 @@ class Flume(Struct):
|
||||||
if self._rt_shm is None:
|
if self._rt_shm is None:
|
||||||
self._rt_shm = attach_shm_array(
|
self._rt_shm = attach_shm_array(
|
||||||
token=self._rt_shm_token,
|
token=self._rt_shm_token,
|
||||||
readonly=self._readonly,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._rt_shm
|
return self._rt_shm
|
||||||
|
|
@ -103,10 +128,12 @@ class Flume(Struct):
|
||||||
'No shm token has been set for the history buffer?'
|
'No shm token has been set for the history buffer?'
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._hist_shm is None:
|
if (
|
||||||
|
self._hist_shm is None
|
||||||
|
):
|
||||||
self._hist_shm = attach_shm_array(
|
self._hist_shm = attach_shm_array(
|
||||||
token=self._hist_shm_token,
|
token=self._hist_shm_token,
|
||||||
readonly=self._readonly,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._hist_shm
|
return self._hist_shm
|
||||||
|
|
@ -125,10 +152,10 @@ class Flume(Struct):
|
||||||
period and ratio between them.
|
period and ratio between them.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
times: np.ndarray = self.hist_shm.array['time']
|
times = self.hist_shm.array['time']
|
||||||
end: float | int = pendulum.from_timestamp(times[-1])
|
end = pendulum.from_timestamp(times[-1])
|
||||||
start: float | int = pendulum.from_timestamp(times[times != times[-1]][-1])
|
start = pendulum.from_timestamp(times[times != times[-1]][-1])
|
||||||
hist_step_size_s: float = (end - start).seconds
|
hist_step_size_s = (end - start).seconds
|
||||||
|
|
||||||
times = self.rt_shm.array['time']
|
times = self.rt_shm.array['time']
|
||||||
end = pendulum.from_timestamp(times[-1])
|
end = pendulum.from_timestamp(times[-1])
|
||||||
|
|
@ -148,25 +175,17 @@ class Flume(Struct):
|
||||||
msg = self.to_dict()
|
msg = self.to_dict()
|
||||||
msg['mkt'] = self.mkt.to_dict()
|
msg['mkt'] = self.mkt.to_dict()
|
||||||
|
|
||||||
# NOTE: pop all un-msg-serializable fields:
|
# can't serialize the stream or feed objects, it's expected
|
||||||
# - `tractor.MsgStream`
|
# you'll have a ref to it since this msg should be rxed on
|
||||||
# - `Feed`
|
# a stream on whatever far end IPC..
|
||||||
# - `Shmarray`
|
|
||||||
# it's expected the `.from_msg()` on the other side
|
|
||||||
# will get instead some kind of msg-compat version
|
|
||||||
# that it can load.
|
|
||||||
msg.pop('stream')
|
msg.pop('stream')
|
||||||
msg.pop('feed')
|
msg.pop('feed')
|
||||||
msg.pop('_rt_shm')
|
|
||||||
msg.pop('_hist_shm')
|
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_msg(
|
def from_msg(
|
||||||
cls,
|
cls,
|
||||||
msg: dict,
|
msg: dict,
|
||||||
readonly: bool = True,
|
|
||||||
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
'''
|
'''
|
||||||
|
|
@ -175,13 +194,8 @@ class Flume(Struct):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
mkt_msg = msg.pop('mkt')
|
mkt_msg = msg.pop('mkt')
|
||||||
from ..accounting import MktPair # cycle otherwise..
|
|
||||||
mkt = MktPair.from_msg(mkt_msg)
|
mkt = MktPair.from_msg(mkt_msg)
|
||||||
msg |= {'_readonly': readonly}
|
return cls(mkt=mkt, **msg)
|
||||||
return cls(
|
|
||||||
mkt=mkt,
|
|
||||||
**msg,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_index(
|
def get_index(
|
||||||
self,
|
self,
|
||||||
|
|
@ -219,3 +233,5 @@ class Flume(Struct):
|
||||||
np.all(np.isin(vlm, -1))
|
np.all(np.isin(vlm, -1))
|
||||||
or np.all(np.isnan(vlm))
|
or np.all(np.isnan(vlm))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,967 @@
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Historical data business logic for load, backfill and tsdb storage.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
# from collections import (
|
||||||
|
# Counter,
|
||||||
|
# )
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import partial
|
||||||
|
# import time
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import (
|
||||||
|
Callable,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
import trio
|
||||||
|
from trio_typing import TaskStatus
|
||||||
|
import tractor
|
||||||
|
from pendulum import (
|
||||||
|
Duration,
|
||||||
|
from_timestamp,
|
||||||
|
)
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..accounting import (
|
||||||
|
MktPair,
|
||||||
|
)
|
||||||
|
from ._util import (
|
||||||
|
log,
|
||||||
|
)
|
||||||
|
from ._sharedmem import (
|
||||||
|
maybe_open_shm_array,
|
||||||
|
ShmArray,
|
||||||
|
)
|
||||||
|
from ._source import def_iohlcv_fields
|
||||||
|
from ._sampling import (
|
||||||
|
open_sample_stream,
|
||||||
|
)
|
||||||
|
from ..brokers._util import (
|
||||||
|
DataUnavailable,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bidict import bidict
|
||||||
|
from ..service.marketstore import StorageClient
|
||||||
|
from .feed import _FeedsBus
|
||||||
|
|
||||||
|
|
||||||
|
# `ShmArray` buffer sizing configuration:
|
||||||
|
_mins_in_day = int(60 * 24)
|
||||||
|
# how much is probably dependent on lifestyle
|
||||||
|
# but we reco a buncha times (but only on a
|
||||||
|
# run-every-other-day kinda week).
|
||||||
|
_secs_in_day = int(60 * _mins_in_day)
|
||||||
|
_days_in_week: int = 7
|
||||||
|
|
||||||
|
_days_worth: int = 3
|
||||||
|
_default_hist_size: int = 6 * 365 * _mins_in_day
|
||||||
|
_hist_buffer_start = int(
|
||||||
|
_default_hist_size - round(7 * _mins_in_day)
|
||||||
|
)
|
||||||
|
|
||||||
|
_default_rt_size: int = _days_worth * _secs_in_day
|
||||||
|
# NOTE: start the append index in rt buffer such that 1 day's worth
|
||||||
|
# can be appenened before overrun.
|
||||||
|
_rt_buffer_start = int((_days_worth - 1) * _secs_in_day)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_history(
|
||||||
|
array: np.ndarray,
|
||||||
|
append_until_dt: datetime | None = None,
|
||||||
|
prepend_until_dt: datetime | None = None,
|
||||||
|
|
||||||
|
) -> np.ndarray:
|
||||||
|
|
||||||
|
# no diffing with tsdb dt index possible..
|
||||||
|
if (
|
||||||
|
prepend_until_dt is None
|
||||||
|
and append_until_dt is None
|
||||||
|
):
|
||||||
|
return array
|
||||||
|
|
||||||
|
times = array['time']
|
||||||
|
|
||||||
|
if append_until_dt:
|
||||||
|
return array[times < append_until_dt.timestamp()]
|
||||||
|
else:
|
||||||
|
return array[times >= prepend_until_dt.timestamp()]
|
||||||
|
|
||||||
|
|
||||||
|
async def shm_push_in_between(
|
||||||
|
shm: ShmArray,
|
||||||
|
to_push: np.ndarray,
|
||||||
|
prepend_index: int,
|
||||||
|
|
||||||
|
update_start_on_prepend: bool = False,
|
||||||
|
|
||||||
|
) -> int:
|
||||||
|
shm.push(
|
||||||
|
to_push,
|
||||||
|
prepend=True,
|
||||||
|
|
||||||
|
# XXX: only update the ._first index if no tsdb
|
||||||
|
# segment was previously prepended by the
|
||||||
|
# parent task.
|
||||||
|
update_first=update_start_on_prepend,
|
||||||
|
|
||||||
|
# XXX: only prepend from a manually calculated shm
|
||||||
|
# index if there was already a tsdb history
|
||||||
|
# segment prepended (since then the
|
||||||
|
# ._first.value is going to be wayyy in the
|
||||||
|
# past!)
|
||||||
|
start=(
|
||||||
|
prepend_index
|
||||||
|
if not update_start_on_prepend
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# XXX: extremely important, there can be no checkpoints
|
||||||
|
# in the block above to avoid entering new ``frames``
|
||||||
|
# values while we're pipelining the current ones to
|
||||||
|
# memory...
|
||||||
|
array = shm.array
|
||||||
|
zeros = array[array['low'] == 0]
|
||||||
|
if (
|
||||||
|
0 < zeros.size < 1000
|
||||||
|
):
|
||||||
|
tractor.breakpoint()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def start_backfill(
|
||||||
|
get_hist,
|
||||||
|
mod: ModuleType,
|
||||||
|
mkt: MktPair,
|
||||||
|
shm: ShmArray,
|
||||||
|
timeframe: float,
|
||||||
|
|
||||||
|
backfill_from_shm_index: int,
|
||||||
|
backfill_from_dt: datetime,
|
||||||
|
|
||||||
|
sampler_stream: tractor.MsgStream,
|
||||||
|
|
||||||
|
backfill_until_dt: datetime | None = None,
|
||||||
|
storage: StorageClient | None = None,
|
||||||
|
|
||||||
|
write_tsdb: bool = True,
|
||||||
|
|
||||||
|
task_status: TaskStatus[tuple] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
|
) -> int:
|
||||||
|
|
||||||
|
# let caller unblock and deliver latest history frame
|
||||||
|
# and use to signal that backfilling the shm gap until
|
||||||
|
# the tsdb end is complete!
|
||||||
|
bf_done = trio.Event()
|
||||||
|
task_status.started(bf_done)
|
||||||
|
|
||||||
|
# based on the sample step size, maybe load a certain amount history
|
||||||
|
update_start_on_prepend: bool = False
|
||||||
|
if backfill_until_dt is None:
|
||||||
|
|
||||||
|
# TODO: drop this right and just expose the backfill
|
||||||
|
# limits inside a [storage] section in conf.toml?
|
||||||
|
# when no tsdb "last datum" is provided, we just load
|
||||||
|
# some near-term history.
|
||||||
|
# periods = {
|
||||||
|
# 1: {'days': 1},
|
||||||
|
# 60: {'days': 14},
|
||||||
|
# }
|
||||||
|
|
||||||
|
# do a decently sized backfill and load it into storage.
|
||||||
|
periods = {
|
||||||
|
1: {'days': 6},
|
||||||
|
60: {'years': 6},
|
||||||
|
}
|
||||||
|
period_duration: int = periods[timeframe]
|
||||||
|
|
||||||
|
update_start_on_prepend = True
|
||||||
|
|
||||||
|
# NOTE: manually set the "latest" datetime which we intend to
|
||||||
|
# backfill history "until" so as to adhere to the history
|
||||||
|
# settings above when the tsdb is detected as being empty.
|
||||||
|
backfill_until_dt = backfill_from_dt.subtract(**period_duration)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: can we drop this? without conc i don't think this
|
||||||
|
# is necessary any more?
|
||||||
|
# configure async query throttling
|
||||||
|
# rate = config.get('rate', 1)
|
||||||
|
# XXX: legacy from ``trimeter`` code but unsupported now.
|
||||||
|
# erlangs = config.get('erlangs', 1)
|
||||||
|
# avoid duplicate history frames with a set of datetime frame
|
||||||
|
# starts and associated counts of how many duplicates we see
|
||||||
|
# per time stamp.
|
||||||
|
# starts: Counter[datetime] = Counter()
|
||||||
|
|
||||||
|
# conduct "backward history gap filling" where we push to
|
||||||
|
# the shm buffer until we have history back until the
|
||||||
|
# latest entry loaded from the tsdb's table B)
|
||||||
|
last_start_dt: datetime = backfill_from_dt
|
||||||
|
next_prepend_index: int = backfill_from_shm_index
|
||||||
|
|
||||||
|
while last_start_dt > backfill_until_dt:
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
f'Requesting {timeframe}s frame ending in {last_start_dt}'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
array,
|
||||||
|
next_start_dt,
|
||||||
|
next_end_dt,
|
||||||
|
) = await get_hist(
|
||||||
|
timeframe,
|
||||||
|
end_dt=last_start_dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# broker says there never was or is no more history to pull
|
||||||
|
except DataUnavailable:
|
||||||
|
log.warning(
|
||||||
|
f'NO-MORE-DATA: backend {mod.name} halted history!?'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ugh, what's a better way?
|
||||||
|
# TODO: fwiw, we probably want a way to signal a throttle
|
||||||
|
# condition (eg. with ib) so that we can halt the
|
||||||
|
# request loop until the condition is resolved?
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: drop this? see todo above..
|
||||||
|
# if (
|
||||||
|
# next_start_dt in starts
|
||||||
|
# and starts[next_start_dt] <= 6
|
||||||
|
# ):
|
||||||
|
# start_dt = min(starts)
|
||||||
|
# log.warning(
|
||||||
|
# f"{mkt.fqme}: skipping duplicate frame @ {next_start_dt}"
|
||||||
|
# )
|
||||||
|
# starts[start_dt] += 1
|
||||||
|
# await tractor.breakpoint()
|
||||||
|
# continue
|
||||||
|
|
||||||
|
# elif starts[next_start_dt] > 6:
|
||||||
|
# log.warning(
|
||||||
|
# f'NO-MORE-DATA: backend {mod.name} before {next_start_dt}?'
|
||||||
|
# )
|
||||||
|
# return
|
||||||
|
|
||||||
|
# # only update new start point if not-yet-seen
|
||||||
|
# starts[next_start_dt] += 1
|
||||||
|
|
||||||
|
assert array['time'][0] == next_start_dt.timestamp()
|
||||||
|
|
||||||
|
diff = last_start_dt - next_start_dt
|
||||||
|
frame_time_diff_s = diff.seconds
|
||||||
|
|
||||||
|
# frame's worth of sample-period-steps, in seconds
|
||||||
|
frame_size_s = len(array) * timeframe
|
||||||
|
expected_frame_size_s = frame_size_s + timeframe
|
||||||
|
if frame_time_diff_s > expected_frame_size_s:
|
||||||
|
|
||||||
|
# XXX: query result includes a start point prior to our
|
||||||
|
# expected "frame size" and thus is likely some kind of
|
||||||
|
# history gap (eg. market closed period, outage, etc.)
|
||||||
|
# so just report it to console for now.
|
||||||
|
log.warning(
|
||||||
|
f'History frame ending @ {last_start_dt} appears to have a gap:\n'
|
||||||
|
f'{diff} ~= {frame_time_diff_s} seconds'
|
||||||
|
)
|
||||||
|
|
||||||
|
to_push = diff_history(
|
||||||
|
array,
|
||||||
|
prepend_until_dt=backfill_until_dt,
|
||||||
|
)
|
||||||
|
ln = len(to_push)
|
||||||
|
if ln:
|
||||||
|
log.info(f'{ln} bars for {next_start_dt} -> {last_start_dt}')
|
||||||
|
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
'0 BARS TO PUSH after diff!?\n'
|
||||||
|
f'{next_start_dt} -> {last_start_dt}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# bail gracefully on shm allocation overrun/full
|
||||||
|
# condition
|
||||||
|
try:
|
||||||
|
await shm_push_in_between(
|
||||||
|
shm,
|
||||||
|
to_push,
|
||||||
|
prepend_index=next_prepend_index,
|
||||||
|
update_start_on_prepend=update_start_on_prepend,
|
||||||
|
)
|
||||||
|
await sampler_stream.send({
|
||||||
|
'broadcast_all': {
|
||||||
|
'backfilling': (mkt.fqme, timeframe),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# decrement next prepend point
|
||||||
|
next_prepend_index = next_prepend_index - ln
|
||||||
|
last_start_dt = next_start_dt
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
_ve = ve
|
||||||
|
log.error(
|
||||||
|
f'Shm prepend OVERRUN on: {next_start_dt} -> {last_start_dt}?'
|
||||||
|
)
|
||||||
|
|
||||||
|
if next_prepend_index < ln:
|
||||||
|
log.warning(
|
||||||
|
f'Shm buffer can only hold {next_prepend_index} more rows..\n'
|
||||||
|
f'Appending those from recent {ln}-sized frame, no more!'
|
||||||
|
)
|
||||||
|
|
||||||
|
to_push = to_push[-next_prepend_index + 1:]
|
||||||
|
await shm_push_in_between(
|
||||||
|
shm,
|
||||||
|
to_push,
|
||||||
|
prepend_index=next_prepend_index,
|
||||||
|
update_start_on_prepend=update_start_on_prepend,
|
||||||
|
)
|
||||||
|
await sampler_stream.send({
|
||||||
|
'broadcast_all': {
|
||||||
|
'backfilling': (mkt.fqme, timeframe),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
# can't push the entire frame? so
|
||||||
|
# push only the amount that can fit..
|
||||||
|
break
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f'Shm pushed {ln} frame:\n'
|
||||||
|
f'{next_start_dt} -> {last_start_dt}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# FINALLY, maybe write immediately to the tsdb backend for
|
||||||
|
# long-term storage.
|
||||||
|
if (
|
||||||
|
storage is not None
|
||||||
|
and write_tsdb
|
||||||
|
):
|
||||||
|
log.info(
|
||||||
|
f'Writing {ln} frame to storage:\n'
|
||||||
|
f'{next_start_dt} -> {last_start_dt}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if mkt.dst.atype not in {'crypto', 'crypto_currency'}:
|
||||||
|
# for now, our table key schema is not including
|
||||||
|
# the dst[/src] source asset token.
|
||||||
|
col_sym_key: str = mkt.get_fqme(
|
||||||
|
delim_char='',
|
||||||
|
without_src=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
col_sym_key: str = mkt.get_fqme(delim_char='')
|
||||||
|
|
||||||
|
# TODO: implement parquet append!?
|
||||||
|
await storage.write_ohlcv(
|
||||||
|
col_sym_key,
|
||||||
|
shm.array,
|
||||||
|
timeframe,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# finally filled gap
|
||||||
|
log.info(
|
||||||
|
f'Finished filling gap to tsdb start @ {backfill_until_dt}!'
|
||||||
|
)
|
||||||
|
# conduct tsdb timestamp gap detection and backfill any
|
||||||
|
# seemingly missing sequence segments..
|
||||||
|
# TODO: ideally these never exist but somehow it seems
|
||||||
|
# sometimes we're writing zero-ed segments on certain
|
||||||
|
# (teardown) cases?
|
||||||
|
from ._timeseries import detect_null_time_gap
|
||||||
|
|
||||||
|
gap_indices: tuple | None = detect_null_time_gap(shm)
|
||||||
|
while gap_indices:
|
||||||
|
(
|
||||||
|
istart,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
iend,
|
||||||
|
) = gap_indices
|
||||||
|
|
||||||
|
start_dt = from_timestamp(start)
|
||||||
|
end_dt = from_timestamp(end)
|
||||||
|
(
|
||||||
|
array,
|
||||||
|
next_start_dt,
|
||||||
|
next_end_dt,
|
||||||
|
) = await get_hist(
|
||||||
|
timeframe,
|
||||||
|
start_dt=start_dt,
|
||||||
|
end_dt=end_dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# XXX TODO: pretty sure if i plot tsla, btcusdt.binance
|
||||||
|
# and mnq.cme.ib this causes a Qt crash XXDDD
|
||||||
|
|
||||||
|
# make sure we don't overrun the buffer start
|
||||||
|
len_to_push: int = min(iend, array.size)
|
||||||
|
to_push: np.ndarray = array[-len_to_push:]
|
||||||
|
await shm_push_in_between(
|
||||||
|
shm,
|
||||||
|
to_push,
|
||||||
|
prepend_index=iend,
|
||||||
|
update_start_on_prepend=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: UI side needs IPC event to update..
|
||||||
|
# - make sure the UI actually always handles
|
||||||
|
# this update!
|
||||||
|
# - remember that in the display side, only refersh this
|
||||||
|
# if the respective history is actually "in view".
|
||||||
|
# loop
|
||||||
|
await sampler_stream.send({
|
||||||
|
'broadcast_all': {
|
||||||
|
'backfilling': (mkt.fqme, timeframe),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
gap_indices: tuple | None = detect_null_time_gap(shm)
|
||||||
|
|
||||||
|
# XXX: extremely important, there can be no checkpoints
|
||||||
|
# in the block above to avoid entering new ``frames``
|
||||||
|
# values while we're pipelining the current ones to
|
||||||
|
# memory...
|
||||||
|
# await sampler_stream.send('broadcast_all')
|
||||||
|
|
||||||
|
# short-circuit (for now)
|
||||||
|
bf_done.set()
|
||||||
|
|
||||||
|
|
||||||
|
async def back_load_from_tsdb(
|
||||||
|
storemod: ModuleType,
|
||||||
|
storage: StorageClient,
|
||||||
|
|
||||||
|
fqme: str,
|
||||||
|
|
||||||
|
tsdb_history: np.ndarray,
|
||||||
|
|
||||||
|
last_tsdb_dt: datetime,
|
||||||
|
latest_start_dt: datetime,
|
||||||
|
latest_end_dt: datetime,
|
||||||
|
|
||||||
|
bf_done: trio.Event,
|
||||||
|
|
||||||
|
timeframe: int,
|
||||||
|
shm: ShmArray,
|
||||||
|
):
|
||||||
|
assert len(tsdb_history)
|
||||||
|
|
||||||
|
# sync to backend history task's query/load completion
|
||||||
|
# if bf_done:
|
||||||
|
# await bf_done.wait()
|
||||||
|
|
||||||
|
# TODO: eventually it'd be nice to not require a shm array/buffer
|
||||||
|
# to accomplish this.. maybe we can do some kind of tsdb direct to
|
||||||
|
# graphics format eventually in a child-actor?
|
||||||
|
if storemod.name == 'nativedb':
|
||||||
|
return
|
||||||
|
|
||||||
|
await tractor.breakpoint()
|
||||||
|
assert shm._first.value == 0
|
||||||
|
|
||||||
|
array = shm.array
|
||||||
|
|
||||||
|
# if timeframe == 1:
|
||||||
|
# times = shm.array['time']
|
||||||
|
# assert (times[1] - times[0]) == 1
|
||||||
|
|
||||||
|
if len(array):
|
||||||
|
shm_last_dt = from_timestamp(
|
||||||
|
shm.array[0]['time']
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shm_last_dt = None
|
||||||
|
|
||||||
|
if last_tsdb_dt:
|
||||||
|
assert shm_last_dt >= last_tsdb_dt
|
||||||
|
|
||||||
|
# do diff against start index of last frame of history and only
|
||||||
|
# fill in an amount of datums from tsdb allows for most recent
|
||||||
|
# to be loaded into mem *before* tsdb data.
|
||||||
|
if (
|
||||||
|
last_tsdb_dt
|
||||||
|
and latest_start_dt
|
||||||
|
):
|
||||||
|
backfilled_size_s = (
|
||||||
|
latest_start_dt - last_tsdb_dt
|
||||||
|
).seconds
|
||||||
|
# if the shm buffer len is not large enough to contain
|
||||||
|
# all missing data between the most recent backend-queried frame
|
||||||
|
# and the most recent dt-index in the db we warn that we only
|
||||||
|
# want to load a portion of the next tsdb query to fill that
|
||||||
|
# space.
|
||||||
|
log.info(
|
||||||
|
f'{backfilled_size_s} seconds worth of {timeframe}s loaded'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load TSDB history into shm buffer (for display) if there is
|
||||||
|
# remaining buffer space.
|
||||||
|
|
||||||
|
time_key: str = 'time'
|
||||||
|
if getattr(storemod, 'ohlc_key_map', False):
|
||||||
|
keymap: bidict = storemod.ohlc_key_map
|
||||||
|
time_key: str = keymap.inverse['time']
|
||||||
|
|
||||||
|
# if (
|
||||||
|
# not len(tsdb_history)
|
||||||
|
# ):
|
||||||
|
# return
|
||||||
|
|
||||||
|
tsdb_last_frame_start: datetime = last_tsdb_dt
|
||||||
|
# load as much from storage into shm possible (depends on
|
||||||
|
# user's shm size settings).
|
||||||
|
while shm._first.value > 0:
|
||||||
|
|
||||||
|
tsdb_history = await storage.read_ohlcv(
|
||||||
|
fqme,
|
||||||
|
timeframe=timeframe,
|
||||||
|
end=tsdb_last_frame_start,
|
||||||
|
)
|
||||||
|
|
||||||
|
# # empty query
|
||||||
|
# if not len(tsdb_history):
|
||||||
|
# break
|
||||||
|
|
||||||
|
next_start = tsdb_history[time_key][0]
|
||||||
|
if next_start >= tsdb_last_frame_start:
|
||||||
|
# no earlier data detected
|
||||||
|
break
|
||||||
|
|
||||||
|
else:
|
||||||
|
tsdb_last_frame_start = next_start
|
||||||
|
|
||||||
|
# TODO: see if there's faster multi-field reads:
|
||||||
|
# https://numpy.org/doc/stable/user/basics.rec.html#accessing-multiple-fields
|
||||||
|
# re-index with a `time` and index field
|
||||||
|
prepend_start = shm._first.value
|
||||||
|
|
||||||
|
to_push = tsdb_history[-prepend_start:]
|
||||||
|
shm.push(
|
||||||
|
to_push,
|
||||||
|
|
||||||
|
# insert the history pre a "days worth" of samples
|
||||||
|
# to leave some real-time buffer space at the end.
|
||||||
|
prepend=True,
|
||||||
|
# update_first=False,
|
||||||
|
# start=prepend_start,
|
||||||
|
field_map=storemod.ohlc_key_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f'Loaded {to_push.shape} datums from storage')
|
||||||
|
tsdb_last_frame_start = tsdb_history[time_key][0]
|
||||||
|
|
||||||
|
# manually trigger step update to update charts/fsps
|
||||||
|
# which need an incremental update.
|
||||||
|
# NOTE: the way this works is super duper
|
||||||
|
# un-intuitive right now:
|
||||||
|
# - the broadcaster fires a msg to the fsp subsystem.
|
||||||
|
# - fsp subsys then checks for a sample step diff and
|
||||||
|
# possibly recomputes prepended history.
|
||||||
|
# - the fsp then sends back to the parent actor
|
||||||
|
# (usually a chart showing graphics for said fsp)
|
||||||
|
# which tells the chart to conduct a manual full
|
||||||
|
# graphics loop cycle.
|
||||||
|
# await sampler_stream.send('broadcast_all')
|
||||||
|
|
||||||
|
|
||||||
|
async def tsdb_backfill(
|
||||||
|
mod: ModuleType,
|
||||||
|
storemod: ModuleType,
|
||||||
|
tn: trio.Nursery,
|
||||||
|
|
||||||
|
storage: StorageClient,
|
||||||
|
mkt: MktPair,
|
||||||
|
shm: ShmArray,
|
||||||
|
timeframe: float,
|
||||||
|
|
||||||
|
sampler_stream: tractor.MsgStream,
|
||||||
|
|
||||||
|
task_status: TaskStatus[
|
||||||
|
tuple[ShmArray, ShmArray]
|
||||||
|
] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
get_hist: Callable[
|
||||||
|
[int, datetime, datetime],
|
||||||
|
tuple[np.ndarray, str]
|
||||||
|
]
|
||||||
|
config: dict[str, int]
|
||||||
|
async with mod.open_history_client(
|
||||||
|
mkt,
|
||||||
|
) as (get_hist, config):
|
||||||
|
log.info(f'{mod} history client returned backfill config: {config}')
|
||||||
|
|
||||||
|
# get latest query's worth of history all the way
|
||||||
|
# back to what is recorded in the tsdb
|
||||||
|
try:
|
||||||
|
array, mr_start_dt, mr_end_dt = await get_hist(
|
||||||
|
timeframe,
|
||||||
|
end_dt=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# XXX: timeframe not supported for backend (since
|
||||||
|
# above exception type), terminate immediately since
|
||||||
|
# there's no backfilling possible.
|
||||||
|
except DataUnavailable:
|
||||||
|
task_status.started()
|
||||||
|
return
|
||||||
|
|
||||||
|
times: np.ndarray = array['time']
|
||||||
|
|
||||||
|
# sample period step size in seconds
|
||||||
|
step_size_s = (
|
||||||
|
from_timestamp(times[-1])
|
||||||
|
- from_timestamp(times[-2])
|
||||||
|
).seconds
|
||||||
|
|
||||||
|
if step_size_s not in (1, 60):
|
||||||
|
log.error(f'Last 2 sample period is off!? -> {step_size_s}')
|
||||||
|
step_size_s = (
|
||||||
|
from_timestamp(times[-2])
|
||||||
|
- from_timestamp(times[-3])
|
||||||
|
).seconds
|
||||||
|
|
||||||
|
# NOTE: on the first history, most recent history
|
||||||
|
# frame we PREPEND from the current shm ._last index
|
||||||
|
# and thus a gap between the earliest datum loaded here
|
||||||
|
# and the latest loaded from the tsdb may exist!
|
||||||
|
log.info(f'Pushing {array.size} to shm!')
|
||||||
|
shm.push(
|
||||||
|
array,
|
||||||
|
prepend=True, # append on first frame
|
||||||
|
)
|
||||||
|
backfill_gap_from_shm_index: int = shm._first.value + 1
|
||||||
|
|
||||||
|
# tell parent task to continue
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
|
# loads a (large) frame of data from the tsdb depending
|
||||||
|
# on the db's query size limit; our "nativedb" (using
|
||||||
|
# parquet) generally can load the entire history into mem
|
||||||
|
# but if not then below the remaining history can be lazy
|
||||||
|
# loaded?
|
||||||
|
fqme: str = mkt.fqme
|
||||||
|
tsdb_entry: tuple | None = await storage.load(
|
||||||
|
fqme,
|
||||||
|
timeframe=timeframe,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_tsdb_dt: datetime | None = None
|
||||||
|
if tsdb_entry:
|
||||||
|
(
|
||||||
|
tsdb_history,
|
||||||
|
first_tsdb_dt,
|
||||||
|
last_tsdb_dt,
|
||||||
|
) = tsdb_entry
|
||||||
|
|
||||||
|
# calc the index from which the tsdb data should be
|
||||||
|
# prepended, presuming there is a gap between the
|
||||||
|
# latest frame (loaded/read above) and the latest
|
||||||
|
# sample loaded from the tsdb.
|
||||||
|
backfill_diff: Duration = mr_start_dt - last_tsdb_dt
|
||||||
|
offset_s: float = backfill_diff.in_seconds()
|
||||||
|
offset_samples: int = round(offset_s / timeframe)
|
||||||
|
|
||||||
|
# TODO: see if there's faster multi-field reads:
|
||||||
|
# https://numpy.org/doc/stable/user/basics.rec.html#accessing-multiple-fields
|
||||||
|
# re-index with a `time` and index field
|
||||||
|
prepend_start = shm._first.value - offset_samples + 1
|
||||||
|
|
||||||
|
# tsdb history is so far in the past we can't fit it in
|
||||||
|
# shm buffer space so simply don't load it!
|
||||||
|
if prepend_start > 0:
|
||||||
|
to_push = tsdb_history[-prepend_start:]
|
||||||
|
shm.push(
|
||||||
|
to_push,
|
||||||
|
|
||||||
|
# insert the history pre a "days worth" of samples
|
||||||
|
# to leave some real-time buffer space at the end.
|
||||||
|
prepend=True,
|
||||||
|
# update_first=False,
|
||||||
|
start=prepend_start,
|
||||||
|
field_map=storemod.ohlc_key_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f'Loaded {to_push.shape} datums from storage')
|
||||||
|
|
||||||
|
# TODO: maybe start history anal and load missing "history
|
||||||
|
# gaps" via backend..
|
||||||
|
|
||||||
|
if timeframe not in (1, 60):
|
||||||
|
raise ValueError(
|
||||||
|
'`piker` only needs to support 1m and 1s sampling '
|
||||||
|
'but ur api is trying to deliver a longer '
|
||||||
|
f'timeframe of {timeframe} seconds..\n'
|
||||||
|
'So yuh.. dun do dat brudder.'
|
||||||
|
)
|
||||||
|
# if there is a gap to backfill from the first
|
||||||
|
# history frame until the last datum loaded from the tsdb
|
||||||
|
# continue that now in the background
|
||||||
|
bf_done = await tn.start(
|
||||||
|
partial(
|
||||||
|
start_backfill,
|
||||||
|
get_hist,
|
||||||
|
mod,
|
||||||
|
mkt,
|
||||||
|
shm,
|
||||||
|
timeframe,
|
||||||
|
|
||||||
|
backfill_from_shm_index=backfill_gap_from_shm_index,
|
||||||
|
backfill_from_dt=mr_start_dt,
|
||||||
|
|
||||||
|
sampler_stream=sampler_stream,
|
||||||
|
|
||||||
|
backfill_until_dt=last_tsdb_dt,
|
||||||
|
storage=storage,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# if len(hist_shm.array) < 2:
|
||||||
|
# TODO: there's an edge case here to solve where if the last
|
||||||
|
# frame before market close (at least on ib) was pushed and
|
||||||
|
# there was only "1 new" row pushed from the first backfill
|
||||||
|
# query-iteration, then the sample step sizing calcs will
|
||||||
|
# break upstream from here since you can't diff on at least
|
||||||
|
# 2 steps... probably should also add logic to compute from
|
||||||
|
# the tsdb series and stash that somewhere as meta data on
|
||||||
|
# the shm buffer?.. no se.
|
||||||
|
|
||||||
|
# backload any further data from tsdb (concurrently per
|
||||||
|
# timeframe) if not all data was able to be loaded (in memory)
|
||||||
|
# from the ``StorageClient.load()`` call above.
|
||||||
|
try:
|
||||||
|
await trio.sleep_forever()
|
||||||
|
finally:
|
||||||
|
return
|
||||||
|
|
||||||
|
# IF we need to continue backloading incrementall from the
|
||||||
|
# tsdb client..
|
||||||
|
tn.start_soon(
|
||||||
|
back_load_from_tsdb,
|
||||||
|
|
||||||
|
storemod,
|
||||||
|
storage,
|
||||||
|
fqme,
|
||||||
|
|
||||||
|
tsdb_history,
|
||||||
|
last_tsdb_dt,
|
||||||
|
mr_start_dt,
|
||||||
|
mr_end_dt,
|
||||||
|
bf_done,
|
||||||
|
|
||||||
|
timeframe,
|
||||||
|
shm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def manage_history(
|
||||||
|
mod: ModuleType,
|
||||||
|
bus: _FeedsBus,
|
||||||
|
mkt: MktPair,
|
||||||
|
some_data_ready: trio.Event,
|
||||||
|
feed_is_live: trio.Event,
|
||||||
|
timeframe: float = 60, # in seconds
|
||||||
|
|
||||||
|
task_status: TaskStatus[
|
||||||
|
tuple[ShmArray, ShmArray]
|
||||||
|
] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Load and manage historical data including the loading of any
|
||||||
|
available series from any connected tsdb as well as conduct
|
||||||
|
real-time update of both that existing db and the allocated
|
||||||
|
shared memory buffer.
|
||||||
|
|
||||||
|
Init sequence:
|
||||||
|
- allocate shm (numpy array) buffers for 60s & 1s sample rates
|
||||||
|
- configure "zero index" for each buffer: the index where
|
||||||
|
history will prepended *to* and new live data will be
|
||||||
|
appened *from*.
|
||||||
|
- open a ``.storage.StorageClient`` and load any existing tsdb
|
||||||
|
history as well as (async) start a backfill task which loads
|
||||||
|
missing (newer) history from the data provider backend:
|
||||||
|
- tsdb history is loaded first and pushed to shm ASAP.
|
||||||
|
- the backfill task loads the most recent history before
|
||||||
|
unblocking its parent task, so that the `ShmArray._last` is
|
||||||
|
up to date to allow the OHLC sampler to begin writing new
|
||||||
|
samples as the correct buffer index once the provider feed
|
||||||
|
engages.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# TODO: is there a way to make each shm file key
|
||||||
|
# actor-tree-discovery-addr unique so we avoid collisions
|
||||||
|
# when doing tests which also allocate shms for certain instruments
|
||||||
|
# that may be in use on the system by some other running daemons?
|
||||||
|
# from tractor._state import _runtime_vars
|
||||||
|
# port = _runtime_vars['_root_mailbox'][1]
|
||||||
|
|
||||||
|
uid = tractor.current_actor().uid
|
||||||
|
name, uuid = uid
|
||||||
|
service = name.rstrip(f'.{mod.name}')
|
||||||
|
|
||||||
|
fqme: str = mkt.get_fqme(delim_char='')
|
||||||
|
|
||||||
|
# (maybe) allocate shm array for this broker/symbol which will
|
||||||
|
# be used for fast near-term history capture and processing.
|
||||||
|
hist_shm, opened = maybe_open_shm_array(
|
||||||
|
size=_default_hist_size,
|
||||||
|
append_start_index=_hist_buffer_start,
|
||||||
|
|
||||||
|
key=f'piker.{service}[{uuid[:16]}].{fqme}.hist',
|
||||||
|
|
||||||
|
# use any broker defined ohlc dtype:
|
||||||
|
dtype=getattr(mod, '_ohlc_dtype', def_iohlcv_fields),
|
||||||
|
|
||||||
|
# we expect the sub-actor to write
|
||||||
|
readonly=False,
|
||||||
|
)
|
||||||
|
hist_zero_index = hist_shm.index - 1
|
||||||
|
|
||||||
|
# TODO: history validation
|
||||||
|
if not opened:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Persistent shm for sym was already open?!"
|
||||||
|
)
|
||||||
|
|
||||||
|
rt_shm, opened = maybe_open_shm_array(
|
||||||
|
size=_default_rt_size,
|
||||||
|
append_start_index=_rt_buffer_start,
|
||||||
|
key=f'piker.{service}[{uuid[:16]}].{fqme}.rt',
|
||||||
|
|
||||||
|
# use any broker defined ohlc dtype:
|
||||||
|
dtype=getattr(mod, '_ohlc_dtype', def_iohlcv_fields),
|
||||||
|
|
||||||
|
# we expect the sub-actor to write
|
||||||
|
readonly=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# (for now) set the rt (hft) shm array with space to prepend
|
||||||
|
# only a few days worth of 1s history.
|
||||||
|
days = 2
|
||||||
|
start_index = days*_secs_in_day
|
||||||
|
rt_shm._first.value = start_index
|
||||||
|
rt_shm._last.value = start_index
|
||||||
|
rt_zero_index = rt_shm.index - 1
|
||||||
|
|
||||||
|
if not opened:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Persistent shm for sym was already open?!"
|
||||||
|
)
|
||||||
|
|
||||||
|
open_history_client = getattr(
|
||||||
|
mod,
|
||||||
|
'open_history_client',
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert open_history_client
|
||||||
|
|
||||||
|
# TODO: maybe it should be a subpkg of `.data`?
|
||||||
|
from piker import storage
|
||||||
|
|
||||||
|
async with (
|
||||||
|
storage.open_storage_client() as (storemod, client),
|
||||||
|
trio.open_nursery() as tn,
|
||||||
|
):
|
||||||
|
log.info(
|
||||||
|
f'Connecting to storage backend `{storemod.name}`:\n'
|
||||||
|
f'location: {client.address}\n'
|
||||||
|
f'db cardinality: {client.cardinality}\n'
|
||||||
|
# TODO: show backend config, eg:
|
||||||
|
# - network settings
|
||||||
|
# - storage size with compression
|
||||||
|
# - number of loaded time series?
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: this call ONLY UNBLOCKS once the latest-most frame
|
||||||
|
# (i.e. history just before the live feed latest datum) of
|
||||||
|
# history has been loaded and written to the shm buffer:
|
||||||
|
# - the backfiller task can write in reverse chronological
|
||||||
|
# to the shm and tsdb
|
||||||
|
# - the tsdb data can be loaded immediately and the
|
||||||
|
# backfiller can do a single append from it's end datum and
|
||||||
|
# then prepends backward to that from the current time
|
||||||
|
# step.
|
||||||
|
tf2mem: dict = {
|
||||||
|
1: rt_shm,
|
||||||
|
60: hist_shm,
|
||||||
|
}
|
||||||
|
async with open_sample_stream(
|
||||||
|
period_s=1.,
|
||||||
|
shms_by_period={
|
||||||
|
1.: rt_shm.token,
|
||||||
|
60.: hist_shm.token,
|
||||||
|
},
|
||||||
|
|
||||||
|
# NOTE: we want to only open a stream for doing
|
||||||
|
# broadcasts on backfill operations, not receive the
|
||||||
|
# sample index-stream (since there's no code in this
|
||||||
|
# data feed layer that needs to consume it).
|
||||||
|
open_index_stream=True,
|
||||||
|
sub_for_broadcasts=False,
|
||||||
|
|
||||||
|
) as sample_stream:
|
||||||
|
# register 1s and 1m buffers with the global incrementer task
|
||||||
|
log.info(f'Connected to sampler stream: {sample_stream}')
|
||||||
|
|
||||||
|
for timeframe in [60, 1]:
|
||||||
|
await tn.start(
|
||||||
|
tsdb_backfill,
|
||||||
|
mod,
|
||||||
|
storemod,
|
||||||
|
tn,
|
||||||
|
# bus,
|
||||||
|
client,
|
||||||
|
mkt,
|
||||||
|
tf2mem[timeframe],
|
||||||
|
timeframe,
|
||||||
|
|
||||||
|
sample_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
# indicate to caller that feed can be delivered to
|
||||||
|
# remote requesting client since we've loaded history
|
||||||
|
# data that can be used.
|
||||||
|
some_data_ready.set()
|
||||||
|
|
||||||
|
# wait for a live feed before starting the sampler.
|
||||||
|
await feed_is_live.wait()
|
||||||
|
|
||||||
|
# yield back after client connect with filled shm
|
||||||
|
task_status.started((
|
||||||
|
hist_zero_index,
|
||||||
|
hist_shm,
|
||||||
|
rt_zero_index,
|
||||||
|
rt_shm,
|
||||||
|
))
|
||||||
|
|
||||||
|
# history retreival loop depending on user interaction
|
||||||
|
# and thus a small RPC-prot for remotely controllinlg
|
||||||
|
# what data is loaded for viewing.
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) (in stewardship for pikers)
|
||||||
|
# - Tyler Goodlet
|
||||||
|
# - Guillermo Rodriguez
|
||||||
|
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Extensions to built-in or (heavily used but 3rd party) friend-lib
|
||||||
|
types.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from pprint import pformat
|
||||||
|
|
||||||
|
from msgspec import (
|
||||||
|
msgpack,
|
||||||
|
Struct,
|
||||||
|
structs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Struct(
|
||||||
|
Struct,
|
||||||
|
|
||||||
|
# https://jcristharif.com/msgspec/structs.html#tagged-unions
|
||||||
|
# tag='pikerstruct',
|
||||||
|
# tag=True,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
A "human friendlier" (aka repl buddy) struct subtype.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
'''
|
||||||
|
Like it sounds.. direct delegation to:
|
||||||
|
https://jcristharif.com/msgspec/api.html#msgspec.structs.asdict
|
||||||
|
|
||||||
|
TODO: probably just drop this method since it's now a built-int method?
|
||||||
|
|
||||||
|
'''
|
||||||
|
return structs.asdict(self)
|
||||||
|
|
||||||
|
def pformat(self) -> str:
|
||||||
|
return f'Struct({pformat(self.to_dict())})'
|
||||||
|
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
|
@ -18,7 +18,6 @@ Data feed synchronization protocols, init msgs, and general
|
||||||
data-provider-backend-agnostic schema definitions.
|
data-provider-backend-agnostic schema definitions.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
@ -29,8 +28,8 @@ from typing import (
|
||||||
|
|
||||||
from msgspec import field
|
from msgspec import field
|
||||||
|
|
||||||
from piker.types import Struct
|
from .types import Struct
|
||||||
from piker.accounting import (
|
from ..accounting import (
|
||||||
Asset,
|
Asset,
|
||||||
MktPair,
|
MktPair,
|
||||||
)
|
)
|
||||||
|
|
@ -82,8 +81,8 @@ _eps: dict[str, list[str]] = {
|
||||||
# live order control and trading
|
# live order control and trading
|
||||||
'brokerd': [
|
'brokerd': [
|
||||||
'trades_dialogue',
|
'trades_dialogue',
|
||||||
'open_trade_dialog', # live order ctl
|
# TODO: ledger normalizer helper?
|
||||||
'norm_trade', # ledger normalizer for txns
|
# norm_trades(records: dict[str, Any]) -> TransactionLedger)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,40 +22,17 @@ from typing import AsyncIterator
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ._api import (
|
from ._engine import cascade
|
||||||
maybe_mk_fsp_shm,
|
|
||||||
Fsp,
|
|
||||||
)
|
|
||||||
from ._engine import (
|
|
||||||
cascade,
|
|
||||||
Cascade,
|
|
||||||
)
|
|
||||||
from ._volume import (
|
|
||||||
dolla_vlm,
|
|
||||||
flow_rates,
|
|
||||||
tina_vwap,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__: list[str] = [
|
__all__ = ['cascade']
|
||||||
'cascade',
|
|
||||||
'Cascade',
|
|
||||||
'maybe_mk_fsp_shm',
|
|
||||||
'Fsp',
|
|
||||||
'dolla_vlm',
|
|
||||||
'flow_rates',
|
|
||||||
'tina_vwap',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def latency(
|
async def latency(
|
||||||
source: 'TickStream[Dict[str, float]]', # noqa
|
source: 'TickStream[Dict[str, float]]', # noqa
|
||||||
ohlcv: np.ndarray
|
ohlcv: np.ndarray
|
||||||
|
|
||||||
) -> AsyncIterator[np.ndarray]:
|
) -> AsyncIterator[np.ndarray]:
|
||||||
'''
|
"""Latency measurements, broker to piker.
|
||||||
Latency measurements, broker to piker.
|
"""
|
||||||
|
|
||||||
'''
|
|
||||||
# TODO: do we want to offer yielding this async
|
# TODO: do we want to offer yielding this async
|
||||||
# before the rt data connection comes up?
|
# before the rt data connection comes up?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@
|
||||||
core task logic for processing chains
|
core task logic for processing chains
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from dataclasses import dataclass
|
||||||
from contextlib import asynccontextmanager as acm
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import (
|
from typing import (
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
Callable,
|
Callable,
|
||||||
|
Optional,
|
||||||
|
Union,
|
||||||
)
|
)
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -32,9 +33,9 @@ from trio_typing import TaskStatus
|
||||||
import tractor
|
import tractor
|
||||||
from tractor.msg import NamespacePath
|
from tractor.msg import NamespacePath
|
||||||
|
|
||||||
from piker.types import Struct
|
|
||||||
from ..log import get_logger, get_console_log
|
from ..log import get_logger, get_console_log
|
||||||
from .. import data
|
from .. import data
|
||||||
|
from ..data import attach_shm_array
|
||||||
from ..data.feed import (
|
from ..data.feed import (
|
||||||
Flume,
|
Flume,
|
||||||
Feed,
|
Feed,
|
||||||
|
|
@ -50,11 +51,17 @@ from ._api import (
|
||||||
_load_builtins,
|
_load_builtins,
|
||||||
_Token,
|
_Token,
|
||||||
)
|
)
|
||||||
from ..toolz import Profiler
|
from .._profile import Profiler
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskTracker:
|
||||||
|
complete: trio.Event
|
||||||
|
cs: trio.CancelScope
|
||||||
|
|
||||||
|
|
||||||
async def filter_quotes_by_sym(
|
async def filter_quotes_by_sym(
|
||||||
|
|
||||||
sym: str,
|
sym: str,
|
||||||
|
|
@ -75,168 +82,30 @@ async def filter_quotes_by_sym(
|
||||||
if quote:
|
if quote:
|
||||||
yield quote
|
yield quote
|
||||||
|
|
||||||
# TODO: unifying the abstractions in this FSP subsys/layer:
|
|
||||||
# -[ ] move the `.data.flows.Flume` type into this
|
|
||||||
# module/subsys/pkg?
|
|
||||||
# -[ ] ideas for further abstractions as per
|
|
||||||
# - https://github.com/pikers/piker/issues/216,
|
|
||||||
# - https://github.com/pikers/piker/issues/270:
|
|
||||||
# - a (financial signal) ``Flow`` would be the a "collection" of such
|
|
||||||
# minmial cascades. Some engineering based jargon concepts:
|
|
||||||
# - https://en.wikipedia.org/wiki/Signal_chain
|
|
||||||
# - https://en.wikipedia.org/wiki/Daisy_chain_(electrical_engineering)
|
|
||||||
# - https://en.wikipedia.org/wiki/Audio_signal_flow
|
|
||||||
# - https://en.wikipedia.org/wiki/Digital_signal_processing#Implementation
|
|
||||||
# - https://en.wikipedia.org/wiki/Dataflow_programming
|
|
||||||
# - https://en.wikipedia.org/wiki/Signal_programming
|
|
||||||
# - https://en.wikipedia.org/wiki/Incremental_computing
|
|
||||||
# - https://en.wikipedia.org/wiki/Signal-flow_graph
|
|
||||||
# - https://en.wikipedia.org/wiki/Signal-flow_graph#Basic_components
|
|
||||||
|
|
||||||
# -[ ] we probably want to eval THE BELOW design and unify with the
|
async def fsp_compute(
|
||||||
# proto `TaskManager` in the `tractor` dev branch as well as with
|
|
||||||
# our below idea for `Cascade`:
|
|
||||||
# - https://github.com/goodboy/tractor/pull/363
|
|
||||||
class Cascade(Struct):
|
|
||||||
'''
|
|
||||||
As per sig-proc engineering parlance, this is a chaining of
|
|
||||||
`Flume`s, which are themselves collections of "Streams"
|
|
||||||
implemented currently via `ShmArray`s.
|
|
||||||
|
|
||||||
A `Cascade` is be the minimal "connection" of 2 `Flumes`
|
|
||||||
as per circuit parlance:
|
|
||||||
https://en.wikipedia.org/wiki/Two-port_network#Cascade_connection
|
|
||||||
|
|
||||||
TODO:
|
|
||||||
-[ ] could cover the combination of our `FspAdmin` and the
|
|
||||||
backend `.fsp._engine` related machinery to "connect" one flume
|
|
||||||
to another?
|
|
||||||
|
|
||||||
'''
|
|
||||||
# TODO: make these `Flume`s
|
|
||||||
src: Flume
|
|
||||||
dst: Flume
|
|
||||||
tn: trio.Nursery
|
|
||||||
fsp: Fsp # UI-side middleware ctl API
|
|
||||||
|
|
||||||
# filled during cascade/.bind_func() (fsp_compute) init phases
|
|
||||||
bind_func: Callable | None = None
|
|
||||||
complete: trio.Event | None = None
|
|
||||||
cs: trio.CancelScope | None = None
|
|
||||||
client_stream: tractor.MsgStream | None = None
|
|
||||||
|
|
||||||
async def resync(self) -> int:
|
|
||||||
# TODO: adopt an incremental update engine/approach
|
|
||||||
# where possible here eventually!
|
|
||||||
log.info(f're-syncing fsp {self.fsp.name} to source')
|
|
||||||
self.cs.cancel()
|
|
||||||
await self.complete.wait()
|
|
||||||
index: int = await self.tn.start(self.bind_func)
|
|
||||||
|
|
||||||
# always trigger UI refresh after history update,
|
|
||||||
# see ``piker.ui._fsp.FspAdmin.open_chain()`` and
|
|
||||||
# ``piker.ui._display.trigger_update()``.
|
|
||||||
dst_shm: ShmArray = self.dst.rt_shm
|
|
||||||
await self.client_stream.send({
|
|
||||||
'fsp_update': {
|
|
||||||
'key': dst_shm.token,
|
|
||||||
'first': dst_shm._first.value,
|
|
||||||
'last': dst_shm._last.value,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return index
|
|
||||||
|
|
||||||
def is_synced(self) -> tuple[bool, int, int]:
|
|
||||||
'''
|
|
||||||
Predicate to dertmine if a destination FSP
|
|
||||||
output array is aligned to its source array.
|
|
||||||
|
|
||||||
'''
|
|
||||||
src_shm: ShmArray = self.src.rt_shm
|
|
||||||
dst_shm: ShmArray = self.dst.rt_shm
|
|
||||||
step_diff = src_shm.index - dst_shm.index
|
|
||||||
len_diff = abs(len(src_shm.array) - len(dst_shm.array))
|
|
||||||
synced: bool = not (
|
|
||||||
# the source is likely backfilling and we must
|
|
||||||
# sync history calculations
|
|
||||||
len_diff > 2
|
|
||||||
|
|
||||||
# we aren't step synced to the source and may be
|
|
||||||
# leading/lagging by a step
|
|
||||||
or step_diff > 1
|
|
||||||
or step_diff < 0
|
|
||||||
)
|
|
||||||
if not synced:
|
|
||||||
fsp: Fsp = self.fsp
|
|
||||||
log.warning(
|
|
||||||
'***DESYNCED FSP***\n'
|
|
||||||
f'{fsp.ns_path}@{src_shm.token}\n'
|
|
||||||
f'step_diff: {step_diff}\n'
|
|
||||||
f'len_diff: {len_diff}\n'
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
synced,
|
|
||||||
step_diff,
|
|
||||||
len_diff,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def poll_and_sync_to_step(self) -> int:
|
|
||||||
synced, step_diff, _ = self.is_synced()
|
|
||||||
while not synced:
|
|
||||||
await self.resync()
|
|
||||||
synced, step_diff, _ = self.is_synced()
|
|
||||||
|
|
||||||
return step_diff
|
|
||||||
|
|
||||||
@acm
|
|
||||||
async def open_edge(
|
|
||||||
self,
|
|
||||||
bind_func: Callable,
|
|
||||||
) -> int:
|
|
||||||
self.bind_func = bind_func
|
|
||||||
index = await self.tn.start(bind_func)
|
|
||||||
yield index
|
|
||||||
# TODO: what do we want on teardown/error?
|
|
||||||
# -[ ] dynamic reconnection after update?
|
|
||||||
|
|
||||||
|
|
||||||
async def connect_streams(
|
|
||||||
casc: Cascade,
|
|
||||||
mkt: MktPair,
|
mkt: MktPair,
|
||||||
|
flume: Flume,
|
||||||
quote_stream: trio.abc.ReceiveChannel,
|
quote_stream: trio.abc.ReceiveChannel,
|
||||||
src: Flume,
|
|
||||||
dst: Flume,
|
|
||||||
|
|
||||||
edge_func: Callable,
|
src: ShmArray,
|
||||||
|
dst: ShmArray,
|
||||||
|
|
||||||
|
func: Callable,
|
||||||
|
|
||||||
# attach_stream: bool = False,
|
# attach_stream: bool = False,
|
||||||
task_status: TaskStatus[None] = trio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus[None] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
|
||||||
Stream and per-sample compute and write the cascade of
|
|
||||||
2 `Flumes`/streams given some operating `func`.
|
|
||||||
|
|
||||||
https://en.wikipedia.org/wiki/Signal-flow_graph#Basic_components
|
|
||||||
|
|
||||||
Not literally, but something like:
|
|
||||||
|
|
||||||
edge_func(Flume_in) -> Flume_out
|
|
||||||
|
|
||||||
'''
|
|
||||||
profiler = Profiler(
|
profiler = Profiler(
|
||||||
delayed=False,
|
delayed=False,
|
||||||
disabled=True
|
disabled=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: just pull it from src.mkt.fqme no?
|
fqme = mkt.fqme
|
||||||
# fqme: str = mkt.fqme
|
out_stream = func(
|
||||||
fqme: str = src.mkt.fqme
|
|
||||||
|
|
||||||
# TODO: dynamic introspection of what the underlying (vertex)
|
|
||||||
# function actually requires from input node (flumes) then
|
|
||||||
# deliver those inputs as part of a graph "compilation" step?
|
|
||||||
out_stream = edge_func(
|
|
||||||
|
|
||||||
# TODO: do we even need this if we do the feed api right?
|
# TODO: do we even need this if we do the feed api right?
|
||||||
# shouldn't a local stream do this before we get a handle
|
# shouldn't a local stream do this before we get a handle
|
||||||
|
|
@ -244,21 +113,20 @@ async def connect_streams(
|
||||||
# async itertools style?
|
# async itertools style?
|
||||||
filter_quotes_by_sym(fqme, quote_stream),
|
filter_quotes_by_sym(fqme, quote_stream),
|
||||||
|
|
||||||
# XXX: currently the ``ohlcv`` arg, but we should allow
|
# XXX: currently the ``ohlcv`` arg
|
||||||
# (dynamic) requests for src flume (node) streams?
|
flume.rt_shm,
|
||||||
src.rt_shm,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# HISTORY COMPUTE PHASE
|
# HISTORY COMPUTE PHASE
|
||||||
# conduct a single iteration of fsp with historical bars input
|
# conduct a single iteration of fsp with historical bars input
|
||||||
# and get historical output.
|
# and get historical output.
|
||||||
history_output: (
|
history_output: Union[
|
||||||
dict[str, np.ndarray] # multi-output case
|
dict[str, np.ndarray], # multi-output case
|
||||||
| np.ndarray, # single output case
|
np.ndarray, # single output case
|
||||||
)
|
]
|
||||||
history_output = await anext(out_stream)
|
history_output = await anext(out_stream)
|
||||||
|
|
||||||
func_name = edge_func.__name__
|
func_name = func.__name__
|
||||||
profiler(f'{func_name} generated history')
|
profiler(f'{func_name} generated history')
|
||||||
|
|
||||||
# build struct array with an 'index' field to push as history
|
# build struct array with an 'index' field to push as history
|
||||||
|
|
@ -266,12 +134,10 @@ async def connect_streams(
|
||||||
# TODO: push using a[['f0', 'f1', .., 'fn']] = .. syntax no?
|
# TODO: push using a[['f0', 'f1', .., 'fn']] = .. syntax no?
|
||||||
# if the output array is multi-field then push
|
# if the output array is multi-field then push
|
||||||
# each respective field.
|
# each respective field.
|
||||||
dst_shm: ShmArray = dst.rt_shm
|
fields = getattr(dst.array.dtype, 'fields', None).copy()
|
||||||
fields = getattr(dst_shm.array.dtype, 'fields', None).copy()
|
|
||||||
fields.pop('index')
|
fields.pop('index')
|
||||||
history_by_field: np.ndarray | None = None
|
history_by_field: Optional[np.ndarray] = None
|
||||||
src_shm: ShmArray = src.rt_shm
|
src_time = src.array['time']
|
||||||
src_time = src_shm.array['time']
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
fields and
|
fields and
|
||||||
|
|
@ -290,7 +156,7 @@ async def connect_streams(
|
||||||
if history_by_field is None:
|
if history_by_field is None:
|
||||||
|
|
||||||
if output is None:
|
if output is None:
|
||||||
length = len(src_shm.array)
|
length = len(src.array)
|
||||||
else:
|
else:
|
||||||
length = len(output)
|
length = len(output)
|
||||||
|
|
||||||
|
|
@ -299,7 +165,7 @@ async def connect_streams(
|
||||||
# will be pushed to shm.
|
# will be pushed to shm.
|
||||||
history_by_field = np.zeros(
|
history_by_field = np.zeros(
|
||||||
length,
|
length,
|
||||||
dtype=dst_shm.array.dtype
|
dtype=dst.array.dtype
|
||||||
)
|
)
|
||||||
|
|
||||||
if output is None:
|
if output is None:
|
||||||
|
|
@ -316,13 +182,13 @@ async def connect_streams(
|
||||||
)
|
)
|
||||||
history_by_field = np.zeros(
|
history_by_field = np.zeros(
|
||||||
len(history_output),
|
len(history_output),
|
||||||
dtype=dst_shm.array.dtype
|
dtype=dst.array.dtype
|
||||||
)
|
)
|
||||||
history_by_field[func_name] = history_output
|
history_by_field[func_name] = history_output
|
||||||
|
|
||||||
history_by_field['time'] = src_time[-len(history_by_field):]
|
history_by_field['time'] = src_time[-len(history_by_field):]
|
||||||
|
|
||||||
history_output['time'] = src_shm.array['time']
|
history_output['time'] = src.array['time']
|
||||||
|
|
||||||
# TODO: XXX:
|
# TODO: XXX:
|
||||||
# THERE'S A BIG BUG HERE WITH THE `index` field since we're
|
# THERE'S A BIG BUG HERE WITH THE `index` field since we're
|
||||||
|
|
@ -335,11 +201,11 @@ async def connect_streams(
|
||||||
# is `index` aware such that historical data can be indexed
|
# is `index` aware such that historical data can be indexed
|
||||||
# relative to the true first datum? Not sure if this is sane
|
# relative to the true first datum? Not sure if this is sane
|
||||||
# for incremental compuations.
|
# for incremental compuations.
|
||||||
first = dst_shm._first.value = src_shm._first.value
|
first = dst._first.value = src._first.value
|
||||||
|
|
||||||
# TODO: can we use this `start` flag instead of the manual
|
# TODO: can we use this `start` flag instead of the manual
|
||||||
# setting above?
|
# setting above?
|
||||||
index = dst_shm.push(
|
index = dst.push(
|
||||||
history_by_field,
|
history_by_field,
|
||||||
start=first,
|
start=first,
|
||||||
)
|
)
|
||||||
|
|
@ -350,9 +216,12 @@ async def connect_streams(
|
||||||
# setup a respawn handle
|
# setup a respawn handle
|
||||||
with trio.CancelScope() as cs:
|
with trio.CancelScope() as cs:
|
||||||
|
|
||||||
casc.cs = cs
|
# TODO: might be better to just make a "restart" method where
|
||||||
casc.complete = trio.Event()
|
# the target task is spawned implicitly and then the event is
|
||||||
task_status.started(index)
|
# set via some higher level api? At that poing we might as well
|
||||||
|
# be writing a one-cancels-one nursery though right?
|
||||||
|
tracker = TaskTracker(trio.Event(), cs)
|
||||||
|
task_status.started((tracker, index))
|
||||||
|
|
||||||
profiler(f'{func_name} yield last index')
|
profiler(f'{func_name} yield last index')
|
||||||
|
|
||||||
|
|
@ -366,12 +235,12 @@ async def connect_streams(
|
||||||
log.debug(f"{func_name}: {processed}")
|
log.debug(f"{func_name}: {processed}")
|
||||||
key, output = processed
|
key, output = processed
|
||||||
# dst.array[-1][key] = output
|
# dst.array[-1][key] = output
|
||||||
dst_shm.array[[key, 'time']][-1] = (
|
dst.array[[key, 'time']][-1] = (
|
||||||
output,
|
output,
|
||||||
# TODO: what about pushing ``time.time_ns()``
|
# TODO: what about pushing ``time.time_ns()``
|
||||||
# in which case we'll need to round at the graphics
|
# in which case we'll need to round at the graphics
|
||||||
# processing / sampling layer?
|
# processing / sampling layer?
|
||||||
src_shm.array[-1]['time']
|
src.array[-1]['time']
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: for now we aren't streaming this to the consumer
|
# NOTE: for now we aren't streaming this to the consumer
|
||||||
|
|
@ -383,7 +252,7 @@ async def connect_streams(
|
||||||
# N-consumers who subscribe for the real-time output,
|
# N-consumers who subscribe for the real-time output,
|
||||||
# which we'll likely want to implement using local-mem
|
# which we'll likely want to implement using local-mem
|
||||||
# chans for the fan out?
|
# chans for the fan out?
|
||||||
# index = src_shm.index
|
# index = src.index
|
||||||
# if attach_stream:
|
# if attach_stream:
|
||||||
# await client_stream.send(index)
|
# await client_stream.send(index)
|
||||||
|
|
||||||
|
|
@ -393,7 +262,7 @@ async def connect_streams(
|
||||||
# log.info(f'FSP quote too fast: {hz}')
|
# log.info(f'FSP quote too fast: {hz}')
|
||||||
# last = time.time()
|
# last = time.time()
|
||||||
finally:
|
finally:
|
||||||
casc.complete.set()
|
tracker.complete.set()
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
|
|
@ -404,15 +273,15 @@ async def cascade(
|
||||||
# data feed key
|
# data feed key
|
||||||
fqme: str,
|
fqme: str,
|
||||||
|
|
||||||
# flume pair cascaded using an "edge function"
|
src_shm_token: dict,
|
||||||
src_flume_addr: dict,
|
dst_shm_token: tuple[str, np.dtype],
|
||||||
dst_flume_addr: dict,
|
|
||||||
ns_path: NamespacePath,
|
ns_path: NamespacePath,
|
||||||
|
|
||||||
shm_registry: dict[str, _Token],
|
shm_registry: dict[str, _Token],
|
||||||
|
|
||||||
zero_on_step: bool = False,
|
zero_on_step: bool = False,
|
||||||
loglevel: str | None = None,
|
loglevel: Optional[str] = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -428,14 +297,8 @@ async def cascade(
|
||||||
if loglevel:
|
if loglevel:
|
||||||
get_console_log(loglevel)
|
get_console_log(loglevel)
|
||||||
|
|
||||||
src: Flume = Flume.from_msg(src_flume_addr)
|
src = attach_shm_array(token=src_shm_token)
|
||||||
dst: Flume = Flume.from_msg(
|
dst = attach_shm_array(readonly=False, token=dst_shm_token)
|
||||||
dst_flume_addr,
|
|
||||||
readonly=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# src: ShmArray = attach_shm_array(token=src_shm_token)
|
|
||||||
# dst: ShmArray = attach_shm_array(readonly=False, token=dst_shm_token)
|
|
||||||
|
|
||||||
reg = _load_builtins()
|
reg = _load_builtins()
|
||||||
lines = '\n'.join([f'{key.rpartition(":")[2]} => {key}' for key in reg])
|
lines = '\n'.join([f'{key.rpartition(":")[2]} => {key}' for key in reg])
|
||||||
|
|
@ -443,11 +306,11 @@ async def cascade(
|
||||||
f'Registered FSP set:\n{lines}'
|
f'Registered FSP set:\n{lines}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE XXX: update actorlocal flows table which registers
|
# update actorlocal flows table which registers
|
||||||
# readonly "instances" of this fsp for symbol/source so that
|
# readonly "instances" of this fsp for symbol/source
|
||||||
# consumer fsps can look it up by source + fsp.
|
# so that consumer fsps can look it up by source + fsp.
|
||||||
# TODO: ugh i hate this wind/unwind to list over the wire but
|
# TODO: ugh i hate this wind/unwind to list over the wire
|
||||||
# not sure how else to do it.
|
# but not sure how else to do it.
|
||||||
for (token, fsp_name, dst_token) in shm_registry:
|
for (token, fsp_name, dst_token) in shm_registry:
|
||||||
Fsp._flow_registry[(
|
Fsp._flow_registry[(
|
||||||
_Token.from_msg(token),
|
_Token.from_msg(token),
|
||||||
|
|
@ -457,15 +320,12 @@ async def cascade(
|
||||||
fsp: Fsp = reg.get(
|
fsp: Fsp = reg.get(
|
||||||
NamespacePath(ns_path)
|
NamespacePath(ns_path)
|
||||||
)
|
)
|
||||||
func: Callable = fsp.func
|
func = fsp.func
|
||||||
|
|
||||||
if not func:
|
if not func:
|
||||||
# TODO: assume it's a func target path
|
# TODO: assume it's a func target path
|
||||||
raise ValueError(f'Unknown fsp target: {ns_path}')
|
raise ValueError(f'Unknown fsp target: {ns_path}')
|
||||||
|
|
||||||
_fqme: str = src.mkt.fqme
|
|
||||||
assert _fqme == fqme
|
|
||||||
|
|
||||||
# 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(
|
||||||
|
|
@ -479,68 +339,40 @@ async def cascade(
|
||||||
|
|
||||||
) as feed:
|
) as feed:
|
||||||
|
|
||||||
flume: Flume = feed.flumes[fqme]
|
flume = feed.flumes[fqme]
|
||||||
# XXX: can't do this since flume.feed will be set XD
|
mkt = flume.mkt
|
||||||
# assert flume == src
|
assert src.token == flume.rt_shm.token
|
||||||
assert flume.mkt == src.mkt
|
|
||||||
mkt: MktPair = flume.mkt
|
|
||||||
|
|
||||||
# NOTE: FOR NOW, sanity checks around the feed as being
|
|
||||||
# always the src flume (until we get to fancier/lengthier
|
|
||||||
# chains/graphs.
|
|
||||||
assert src.rt_shm.token == flume.rt_shm.token
|
|
||||||
|
|
||||||
# XXX: won't work bc the _hist_shm_token value will be
|
|
||||||
# list[list] after IPC..
|
|
||||||
# assert flume.to_msg() == src_flume_addr
|
|
||||||
|
|
||||||
profiler(f'{func}: feed up')
|
profiler(f'{func}: feed up')
|
||||||
|
|
||||||
func_name: str = func.__name__
|
func_name = func.__name__
|
||||||
async with (
|
async with (
|
||||||
trio.open_nursery() as tn,
|
trio.open_nursery() as n,
|
||||||
):
|
):
|
||||||
# TODO: might be better to just make a "restart" method where
|
|
||||||
# the target task is spawned implicitly and then the event is
|
|
||||||
# set via some higher level api? At that poing we might as well
|
|
||||||
# be writing a one-cancels-one nursery though right?
|
|
||||||
casc = Cascade(
|
|
||||||
src,
|
|
||||||
dst,
|
|
||||||
tn,
|
|
||||||
fsp,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: this seems like it should be wrapped somewhere?
|
|
||||||
fsp_target = partial(
|
fsp_target = partial(
|
||||||
connect_streams,
|
|
||||||
casc=casc,
|
fsp_compute,
|
||||||
mkt=mkt,
|
mkt=mkt,
|
||||||
|
flume=flume,
|
||||||
quote_stream=flume.stream,
|
quote_stream=flume.stream,
|
||||||
|
|
||||||
# flumes and shm passthrough
|
# shm
|
||||||
src=src,
|
src=src,
|
||||||
dst=dst,
|
dst=dst,
|
||||||
|
|
||||||
# chain function which takes src flume input(s)
|
# target
|
||||||
# and renders dst flume output(s)
|
func=func
|
||||||
edge_func=func
|
|
||||||
)
|
)
|
||||||
async with casc.open_edge(
|
|
||||||
bind_func=fsp_target,
|
tracker, index = await n.start(fsp_target)
|
||||||
) as index:
|
|
||||||
# casc.bind_func = fsp_target
|
|
||||||
# index = await tn.start(fsp_target)
|
|
||||||
dst_shm: ShmArray = dst.rt_shm
|
|
||||||
src_shm: ShmArray = src.rt_shm
|
|
||||||
|
|
||||||
if zero_on_step:
|
if zero_on_step:
|
||||||
last = dst.rt_shm.array[-1:]
|
last = dst.array[-1:]
|
||||||
zeroed = np.zeros(last.shape, dtype=last.dtype)
|
zeroed = np.zeros(last.shape, dtype=last.dtype)
|
||||||
|
|
||||||
profiler(f'{func_name}: fsp up')
|
profiler(f'{func_name}: fsp up')
|
||||||
|
|
||||||
# sync to client-side actor
|
# sync client
|
||||||
await ctx.started(index)
|
await ctx.started(index)
|
||||||
|
|
||||||
# XXX: rt stream with client which we MUST
|
# XXX: rt stream with client which we MUST
|
||||||
|
|
@ -548,26 +380,85 @@ async def cascade(
|
||||||
# incremental "updates" as history prepends take
|
# incremental "updates" as history prepends take
|
||||||
# place.
|
# place.
|
||||||
async with ctx.open_stream() as client_stream:
|
async with ctx.open_stream() as client_stream:
|
||||||
casc.client_stream: tractor.MsgStream = client_stream
|
|
||||||
|
|
||||||
s, step, ld = casc.is_synced()
|
# TODO: these likely should all become
|
||||||
|
# methods of this ``TaskLifetime`` or wtv
|
||||||
|
# abstraction..
|
||||||
|
async def resync(
|
||||||
|
tracker: TaskTracker,
|
||||||
|
|
||||||
|
) -> tuple[TaskTracker, int]:
|
||||||
|
# TODO: adopt an incremental update engine/approach
|
||||||
|
# where possible here eventually!
|
||||||
|
log.info(f're-syncing fsp {func_name} to source')
|
||||||
|
tracker.cs.cancel()
|
||||||
|
await tracker.complete.wait()
|
||||||
|
tracker, index = await n.start(fsp_target)
|
||||||
|
|
||||||
|
# always trigger UI refresh after history update,
|
||||||
|
# see ``piker.ui._fsp.FspAdmin.open_chain()`` and
|
||||||
|
# ``piker.ui._display.trigger_update()``.
|
||||||
|
await client_stream.send({
|
||||||
|
'fsp_update': {
|
||||||
|
'key': dst_shm_token,
|
||||||
|
'first': dst._first.value,
|
||||||
|
'last': dst._last.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tracker, index
|
||||||
|
|
||||||
|
def is_synced(
|
||||||
|
src: ShmArray,
|
||||||
|
dst: ShmArray
|
||||||
|
) -> tuple[bool, int, int]:
|
||||||
|
'''
|
||||||
|
Predicate to dertmine if a destination FSP
|
||||||
|
output array is aligned to its source array.
|
||||||
|
|
||||||
|
'''
|
||||||
|
step_diff = src.index - dst.index
|
||||||
|
len_diff = abs(len(src.array) - len(dst.array))
|
||||||
|
return not (
|
||||||
|
# the source is likely backfilling and we must
|
||||||
|
# sync history calculations
|
||||||
|
len_diff > 2
|
||||||
|
|
||||||
|
# we aren't step synced to the source and may be
|
||||||
|
# leading/lagging by a step
|
||||||
|
or step_diff > 1
|
||||||
|
or step_diff < 0
|
||||||
|
), step_diff, len_diff
|
||||||
|
|
||||||
|
async def poll_and_sync_to_step(
|
||||||
|
tracker: TaskTracker,
|
||||||
|
src: ShmArray,
|
||||||
|
dst: ShmArray,
|
||||||
|
|
||||||
|
) -> tuple[TaskTracker, int]:
|
||||||
|
|
||||||
|
synced, step_diff, _ = is_synced(src, dst)
|
||||||
|
while not synced:
|
||||||
|
tracker, index = await resync(tracker)
|
||||||
|
synced, step_diff, _ = is_synced(src, dst)
|
||||||
|
|
||||||
|
return tracker, step_diff
|
||||||
|
|
||||||
|
s, step, ld = is_synced(src, dst)
|
||||||
|
|
||||||
# detect sample period step for subscription to increment
|
# detect sample period step for subscription to increment
|
||||||
# signal
|
# signal
|
||||||
times = src.rt_shm.array['time']
|
times = src.array['time']
|
||||||
if len(times) > 1:
|
if len(times) > 1:
|
||||||
last_ts = times[-1]
|
last_ts = times[-1]
|
||||||
delay_s: float = float(last_ts - times[times != last_ts][-1])
|
delay_s = float(last_ts - times[times != last_ts][-1])
|
||||||
else:
|
else:
|
||||||
# our default "HFT" sample rate.
|
# our default "HFT" sample rate.
|
||||||
delay_s: float = _default_delay_s
|
delay_s = _default_delay_s
|
||||||
|
|
||||||
# sub and increment the underlying shared memory buffer
|
# sub and increment the underlying shared memory buffer
|
||||||
# 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(float(delay_s)) as istream:
|
||||||
float(delay_s)
|
|
||||||
) as istream:
|
|
||||||
|
|
||||||
profiler(f'{func_name}: sample stream up')
|
profiler(f'{func_name}: sample stream up')
|
||||||
profiler.finish()
|
profiler.finish()
|
||||||
|
|
@ -578,9 +469,13 @@ async def cascade(
|
||||||
# respawn the compute task if the source
|
# respawn the compute task if the source
|
||||||
# array has been updated such that we compute
|
# array has been updated such that we compute
|
||||||
# new history from the (prepended) source.
|
# new history from the (prepended) source.
|
||||||
synced, step_diff, _ = casc.is_synced()
|
synced, step_diff, _ = is_synced(src, dst)
|
||||||
if not synced:
|
if not synced:
|
||||||
step_diff: int = await casc.poll_and_sync_to_step()
|
tracker, step_diff = await poll_and_sync_to_step(
|
||||||
|
tracker,
|
||||||
|
src,
|
||||||
|
dst,
|
||||||
|
)
|
||||||
|
|
||||||
# skip adding a last bar since we should already
|
# skip adding a last bar since we should already
|
||||||
# be step alinged
|
# be step alinged
|
||||||
|
|
@ -588,7 +483,7 @@ async def cascade(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# read out last shm row, copy and write new row
|
# read out last shm row, copy and write new row
|
||||||
array = dst_shm.array
|
array = dst.array
|
||||||
|
|
||||||
# some metrics like vlm should be reset
|
# some metrics like vlm should be reset
|
||||||
# to zero every step.
|
# to zero every step.
|
||||||
|
|
@ -597,14 +492,14 @@ async def cascade(
|
||||||
else:
|
else:
|
||||||
last = array[-1:].copy()
|
last = array[-1:].copy()
|
||||||
|
|
||||||
dst.rt_shm.push(last)
|
dst.push(last)
|
||||||
|
|
||||||
# sync with source buffer's time step
|
# sync with source buffer's time step
|
||||||
src_l2 = src_shm.array[-2:]
|
src_l2 = src.array[-2:]
|
||||||
src_li, src_lt = src_l2[-1][['index', 'time']]
|
src_li, src_lt = src_l2[-1][['index', 'time']]
|
||||||
src_2li, src_2lt = src_l2[-2][['index', 'time']]
|
src_2li, src_2lt = src_l2[-2][['index', 'time']]
|
||||||
dst_shm._array['time'][src_li] = src_lt
|
dst._array['time'][src_li] = src_lt
|
||||||
dst_shm._array['time'][src_2li] = src_2lt
|
dst._array['time'][src_2li] = src_2lt
|
||||||
|
|
||||||
# last2 = dst.array[-2:]
|
# last2 = dst.array[-2:]
|
||||||
# if (
|
# if (
|
||||||
|
|
|
||||||
|
|
@ -14,45 +14,49 @@
|
||||||
# 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/>.
|
||||||
|
|
||||||
'''
|
"""
|
||||||
Actor runtime primtives and (distributed) service APIs for,
|
Actor-runtime service orchestration machinery.
|
||||||
|
|
||||||
- daemon-service mgmt: `_daemon` (i.e. low-level spawn and supervise machinery
|
"""
|
||||||
for sub-actors like `brokerd`, `emsd`, datad`, etc.)
|
from __future__ import annotations
|
||||||
|
|
||||||
- service-actor supervision (via `trio` tasks) API: `._mngr`
|
from ._mngr import Services
|
||||||
|
from ._registry import ( # noqa
|
||||||
- discovery interface (via light wrapping around `tractor`'s built-in
|
_tractor_kwargs,
|
||||||
prot): `._registry`
|
_default_reg_addr,
|
||||||
|
_default_registry_host,
|
||||||
- `docker` cntr SC supervision for use with `trio`: `_ahab`
|
_default_registry_port,
|
||||||
- wrappers for marketstore and elasticsearch dbs
|
open_registry,
|
||||||
=> TODO: maybe to (re)move elsewhere?
|
find_service,
|
||||||
|
check_for_service,
|
||||||
'''
|
|
||||||
from ._mngr import Services as Services
|
|
||||||
from ._registry import (
|
|
||||||
_tractor_kwargs as _tractor_kwargs,
|
|
||||||
_default_reg_addr as _default_reg_addr,
|
|
||||||
_default_registry_host as _default_registry_host,
|
|
||||||
_default_registry_port as _default_registry_port,
|
|
||||||
|
|
||||||
open_registry as open_registry,
|
|
||||||
find_service as find_service,
|
|
||||||
check_for_service as check_for_service,
|
|
||||||
)
|
)
|
||||||
from ._daemon import (
|
from ._daemon import ( # noqa
|
||||||
maybe_spawn_daemon as maybe_spawn_daemon,
|
maybe_spawn_daemon,
|
||||||
spawn_emsd as spawn_emsd,
|
spawn_emsd,
|
||||||
maybe_open_emsd as maybe_open_emsd,
|
maybe_open_emsd,
|
||||||
)
|
)
|
||||||
from ._actor_runtime import (
|
from ._actor_runtime import (
|
||||||
open_piker_runtime as open_piker_runtime,
|
open_piker_runtime,
|
||||||
maybe_open_pikerd as maybe_open_pikerd,
|
maybe_open_pikerd,
|
||||||
open_pikerd as open_pikerd,
|
open_pikerd,
|
||||||
get_runtime_vars as get_runtime_vars,
|
get_tractor_runtime_kwargs,
|
||||||
)
|
)
|
||||||
from ..brokers._daemon import (
|
from ..brokers._daemon import (
|
||||||
spawn_brokerd as spawn_brokerd,
|
spawn_brokerd,
|
||||||
maybe_spawn_brokerd as maybe_spawn_brokerd,
|
maybe_spawn_brokerd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'check_for_service',
|
||||||
|
'Services',
|
||||||
|
'maybe_spawn_daemon',
|
||||||
|
'spawn_brokerd',
|
||||||
|
'maybe_spawn_brokerd',
|
||||||
|
'spawn_emsd',
|
||||||
|
'maybe_open_emsd',
|
||||||
|
'open_piker_runtime',
|
||||||
|
'maybe_open_pikerd',
|
||||||
|
'open_pikerd',
|
||||||
|
'get_tractor_runtime_kwargs',
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ from ._registry import ( # noqa
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_vars() -> dict[str, Any]:
|
def get_tractor_runtime_kwargs() -> dict[str, Any]:
|
||||||
'''
|
'''
|
||||||
Deliver ``tractor`` related runtime variables in a `dict`.
|
Deliver ``tractor`` related runtime variables in a `dict`.
|
||||||
|
|
||||||
|
|
@ -56,8 +56,6 @@ def get_runtime_vars() -> dict[str, Any]:
|
||||||
@acm
|
@acm
|
||||||
async def open_piker_runtime(
|
async def open_piker_runtime(
|
||||||
name: str,
|
name: str,
|
||||||
registry_addrs: list[tuple[str, int]] = [],
|
|
||||||
|
|
||||||
enable_modules: list[str] = [],
|
enable_modules: list[str] = [],
|
||||||
loglevel: Optional[str] = None,
|
loglevel: Optional[str] = None,
|
||||||
|
|
||||||
|
|
@ -65,6 +63,8 @@ async def open_piker_runtime(
|
||||||
# for data daemons when running in production.
|
# for data daemons when running in production.
|
||||||
debug_mode: bool = False,
|
debug_mode: bool = False,
|
||||||
|
|
||||||
|
registry_addr: None | tuple[str, int] = None,
|
||||||
|
|
||||||
# TODO: once we have `rsyscall` support we will read a config
|
# TODO: once we have `rsyscall` support we will read a config
|
||||||
# and spawn the service tree distributed per that.
|
# and spawn the service tree distributed per that.
|
||||||
start_method: str = 'trio',
|
start_method: str = 'trio',
|
||||||
|
|
@ -74,7 +74,7 @@ async def open_piker_runtime(
|
||||||
|
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
tractor.Actor,
|
tractor.Actor,
|
||||||
list[tuple[str, int]],
|
tuple[str, int],
|
||||||
]:
|
]:
|
||||||
'''
|
'''
|
||||||
Start a piker actor who's runtime will automatically sync with
|
Start a piker actor who's runtime will automatically sync with
|
||||||
|
|
@ -84,31 +84,21 @@ async def open_piker_runtime(
|
||||||
a root actor.
|
a root actor.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# check for existing runtime, boot it
|
|
||||||
# if not already running.
|
|
||||||
try:
|
try:
|
||||||
actor = tractor.current_actor()
|
# check for existing runtime
|
||||||
|
actor = tractor.current_actor().uid
|
||||||
|
|
||||||
except tractor._exceptions.NoRuntime:
|
except tractor._exceptions.NoRuntime:
|
||||||
tractor._state._runtime_vars[
|
tractor._state._runtime_vars[
|
||||||
'piker_vars'
|
'piker_vars'] = tractor_runtime_overrides
|
||||||
] = tractor_runtime_overrides
|
|
||||||
|
|
||||||
# NOTE: if no registrar list passed used the default of just
|
registry_addr = registry_addr or _default_reg_addr
|
||||||
# setting it as the root actor on localhost.
|
|
||||||
registry_addrs = (
|
|
||||||
registry_addrs
|
|
||||||
or [_default_reg_addr]
|
|
||||||
)
|
|
||||||
|
|
||||||
if ems := tractor_kwargs.pop('enable_modules', None):
|
|
||||||
# import pdbp; pdbp.set_trace()
|
|
||||||
enable_modules.extend(ems)
|
|
||||||
|
|
||||||
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,
|
arbiter_addr=registry_addr,
|
||||||
name=name,
|
name=name,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
debug_mode=debug_mode,
|
debug_mode=debug_mode,
|
||||||
|
|
@ -120,30 +110,24 @@ async def open_piker_runtime(
|
||||||
enable_modules=enable_modules,
|
enable_modules=enable_modules,
|
||||||
|
|
||||||
**tractor_kwargs,
|
**tractor_kwargs,
|
||||||
) as actor,
|
) as _,
|
||||||
|
|
||||||
open_registry(
|
open_registry(registry_addr, ensure_exists=False) as addr,
|
||||||
registry_addrs,
|
|
||||||
ensure_exists=False,
|
|
||||||
) as addrs,
|
|
||||||
):
|
):
|
||||||
assert actor is tractor.current_actor()
|
|
||||||
yield (
|
yield (
|
||||||
actor,
|
tractor.current_actor(),
|
||||||
addrs,
|
addr,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
async with open_registry(
|
async with open_registry(registry_addr) as addr:
|
||||||
registry_addrs
|
|
||||||
) as addrs:
|
|
||||||
yield (
|
yield (
|
||||||
actor,
|
actor,
|
||||||
addrs,
|
addr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_root_dname: str = 'pikerd'
|
_root_dname = 'pikerd'
|
||||||
_root_modules: list[str] = [
|
_root_modules = [
|
||||||
__name__,
|
__name__,
|
||||||
'piker.service._daemon',
|
'piker.service._daemon',
|
||||||
'piker.brokers._daemon',
|
'piker.brokers._daemon',
|
||||||
|
|
@ -157,13 +141,13 @@ _root_modules: list[str] = [
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_pikerd(
|
async def open_pikerd(
|
||||||
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
|
||||||
# for data daemons when running in production.
|
# for data daemons when running in production.
|
||||||
debug_mode: bool = False,
|
debug_mode: bool = False,
|
||||||
|
registry_addr: None | tuple[str, int] = None,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
|
|
@ -175,37 +159,27 @@ async def open_pikerd(
|
||||||
alive underling services (see below).
|
alive underling services (see below).
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# NOTE: for the root daemon we always enable the root
|
|
||||||
# mod set and we `list.extend()` it into wtv the
|
|
||||||
# caller requested.
|
|
||||||
# TODO: make this mod set more strict?
|
|
||||||
# -[ ] eventually we should be able to avoid
|
|
||||||
# having the root have more then permissions to spawn other
|
|
||||||
# specialized daemons I think?
|
|
||||||
ems: list[str] = kwargs.setdefault('enable_modules', [])
|
|
||||||
ems.extend(_root_modules)
|
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
open_piker_runtime(
|
open_piker_runtime(
|
||||||
|
|
||||||
name=_root_dname,
|
name=_root_dname,
|
||||||
|
# TODO: eventually we should be able to avoid
|
||||||
|
# having the root have more then permissions to
|
||||||
|
# spawn other specialized daemons I think?
|
||||||
|
enable_modules=_root_modules,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
debug_mode=debug_mode,
|
debug_mode=debug_mode,
|
||||||
registry_addrs=registry_addrs,
|
registry_addr=registry_addr,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) as (
|
) as (root_actor, reg_addr),
|
||||||
root_actor,
|
|
||||||
reg_addrs,
|
|
||||||
),
|
|
||||||
tractor.open_nursery() as actor_nursery,
|
tractor.open_nursery() as actor_nursery,
|
||||||
trio.open_nursery() as service_nursery,
|
trio.open_nursery() as service_nursery,
|
||||||
):
|
):
|
||||||
for addr in reg_addrs:
|
if root_actor.accept_addr != reg_addr:
|
||||||
if addr not in root_actor.accept_addrs:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'`pikerd` failed to bind on {addr}!\n'
|
f'`pikerd` failed to bind on {reg_addr}!\n'
|
||||||
'Maybe you have another daemon already running?'
|
'Maybe you have another daemon already running?'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -251,9 +225,9 @@ async def open_pikerd(
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def maybe_open_pikerd(
|
async def maybe_open_pikerd(
|
||||||
registry_addrs: list[tuple[str, int]] | None = None,
|
loglevel: Optional[str] = None,
|
||||||
|
registry_addr: None | tuple = None,
|
||||||
|
|
||||||
loglevel: str | None = None,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> tractor._portal.Portal | ClassVar[Services]:
|
) -> tractor._portal.Portal | ClassVar[Services]:
|
||||||
|
|
@ -279,51 +253,32 @@ async def maybe_open_pikerd(
|
||||||
# async with open_portal(chan) as arb_portal:
|
# async with open_portal(chan) as arb_portal:
|
||||||
# yield arb_portal
|
# yield arb_portal
|
||||||
|
|
||||||
registry_addrs: list[tuple[str, int]] = (
|
|
||||||
registry_addrs
|
|
||||||
or [_default_reg_addr]
|
|
||||||
)
|
|
||||||
|
|
||||||
pikerd_portal: tractor.Portal | None
|
|
||||||
async with (
|
async with (
|
||||||
open_piker_runtime(
|
open_piker_runtime(
|
||||||
name=query_name,
|
name=query_name,
|
||||||
registry_addrs=registry_addrs,
|
registry_addr=registry_addr,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) as (actor, addrs),
|
) as _,
|
||||||
):
|
|
||||||
if _root_dname in actor.uid:
|
|
||||||
yield None
|
|
||||||
return
|
|
||||||
|
|
||||||
# NOTE: IFF running in disti mode, try to attach to any
|
tractor.find_actor(
|
||||||
# existing (host-local) `pikerd`.
|
|
||||||
else:
|
|
||||||
async with tractor.find_actor(
|
|
||||||
_root_dname,
|
_root_dname,
|
||||||
registry_addrs=registry_addrs,
|
arbiter_sockaddr=registry_addr,
|
||||||
only_first=True,
|
) as portal
|
||||||
# raise_on_none=True,
|
):
|
||||||
) as pikerd_portal:
|
# connect to any existing daemon presuming
|
||||||
|
# its registry socket was selected.
|
||||||
# connect to any existing remote daemon presuming its
|
if (
|
||||||
# registry socket was selected.
|
portal is not None
|
||||||
if pikerd_portal is not None:
|
):
|
||||||
|
yield portal
|
||||||
# sanity check that we are actually connecting to
|
|
||||||
# a remote process and not ourselves.
|
|
||||||
assert actor.uid != pikerd_portal.channel.uid
|
|
||||||
assert registry_addrs
|
|
||||||
|
|
||||||
yield pikerd_portal
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# presume pikerd role since no daemon could be found at
|
# presume pikerd role since no daemon could be found at
|
||||||
# configured address
|
# configured address
|
||||||
async with open_pikerd(
|
async with open_pikerd(
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
registry_addrs=registry_addrs,
|
registry_addr=registry_addr,
|
||||||
|
|
||||||
# passthrough to ``tractor`` init
|
# passthrough to ``tractor`` init
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
|
||||||
|
|
@ -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/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Supervisor for ``docker`` with included async and SC wrapping to
|
Supervisor for ``docker`` with included async and SC wrapping
|
||||||
ensure a cancellable container lifetime system.
|
to ensure a cancellable container lifetime system.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -70,10 +70,7 @@ async def maybe_spawn_daemon(
|
||||||
lock = Services.locks[service_name]
|
lock = Services.locks[service_name]
|
||||||
await lock.acquire()
|
await lock.acquire()
|
||||||
|
|
||||||
async with find_service(
|
async with find_service(service_name) as portal:
|
||||||
service_name,
|
|
||||||
registry_addrs=[('127.0.0.1', 6116)],
|
|
||||||
) as portal:
|
|
||||||
if portal is not None:
|
if portal is not None:
|
||||||
lock.release()
|
lock.release()
|
||||||
yield portal
|
yield portal
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,6 @@ from typing import (
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
import tractor
|
import tractor
|
||||||
from tractor import (
|
|
||||||
current_actor,
|
|
||||||
ContextCancelled,
|
|
||||||
Context,
|
|
||||||
Portal,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ._util import (
|
from ._util import (
|
||||||
log, # sub-sys logger
|
log, # sub-sys logger
|
||||||
|
|
@ -44,8 +38,6 @@ from ._util import (
|
||||||
# library.
|
# library.
|
||||||
# - wrap a "remote api" wherein you can get a method proxy
|
# - wrap a "remote api" wherein you can get a method proxy
|
||||||
# to the pikerd actor for starting services remotely!
|
# to the pikerd actor for starting services remotely!
|
||||||
# - prolly rename this to ActorServicesNursery since it spawns
|
|
||||||
# new actors and supervises them to completion?
|
|
||||||
class Services:
|
class Services:
|
||||||
|
|
||||||
actor_n: tractor._supervise.ActorNursery
|
actor_n: tractor._supervise.ActorNursery
|
||||||
|
|
@ -55,7 +47,7 @@ class Services:
|
||||||
str,
|
str,
|
||||||
tuple[
|
tuple[
|
||||||
trio.CancelScope,
|
trio.CancelScope,
|
||||||
Portal,
|
tractor.Portal,
|
||||||
trio.Event,
|
trio.Event,
|
||||||
]
|
]
|
||||||
] = {}
|
] = {}
|
||||||
|
|
@ -65,12 +57,12 @@ class Services:
|
||||||
async def start_service_task(
|
async def start_service_task(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
portal: Portal,
|
portal: tractor.Portal,
|
||||||
target: Callable,
|
target: Callable,
|
||||||
allow_overruns: bool = False,
|
allow_overruns: bool = False,
|
||||||
**ctx_kwargs,
|
**ctx_kwargs,
|
||||||
|
|
||||||
) -> (trio.CancelScope, Context):
|
) -> (trio.CancelScope, tractor.Context):
|
||||||
'''
|
'''
|
||||||
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.
|
||||||
|
|
@ -109,30 +101,13 @@ class Services:
|
||||||
# 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.
|
||||||
ctx_res: Any = await ctx.result()
|
ctx_res = await ctx.result()
|
||||||
|
|
||||||
# NOTE: blocks indefinitely until cancelled
|
# NOTE: blocks indefinitely until cancelled
|
||||||
# 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.result(), ctx_res)
|
||||||
except ContextCancelled as ctxe:
|
|
||||||
canceller: tuple[str, str] = ctxe.canceller
|
|
||||||
our_uid: tuple[str, str] = current_actor().uid
|
|
||||||
if (
|
|
||||||
canceller != portal.channel.uid
|
|
||||||
and
|
|
||||||
canceller != our_uid
|
|
||||||
):
|
|
||||||
log.cancel(
|
|
||||||
f'Actor-service {name} was remotely cancelled?\n'
|
|
||||||
f'remote canceller: {canceller}\n'
|
|
||||||
f'Keeping {our_uid} alive, ignoring sub-actor cancel..\n'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await portal.cancel_actor()
|
await portal.cancel_actor()
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
import tractor
|
import tractor
|
||||||
from tractor import Portal
|
|
||||||
|
|
||||||
from ._util import (
|
from ._util import (
|
||||||
log, # sub-sys logger
|
log, # sub-sys logger
|
||||||
|
|
@ -47,9 +46,7 @@ _registry: Registry | None = None
|
||||||
|
|
||||||
|
|
||||||
class Registry:
|
class Registry:
|
||||||
# TODO: should this be a set or should we complain
|
addr: None | tuple[str, int] = None
|
||||||
# on duplicates?
|
|
||||||
addrs: list[tuple[str, int]] = []
|
|
||||||
|
|
||||||
# TODO: table of uids to sockaddrs
|
# TODO: table of uids to sockaddrs
|
||||||
peers: dict[
|
peers: dict[
|
||||||
|
|
@ -63,115 +60,69 @@ _tractor_kwargs: dict[str, Any] = {}
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_registry(
|
async def open_registry(
|
||||||
addrs: list[tuple[str, int]],
|
addr: None | tuple[str, int] = None,
|
||||||
ensure_exists: bool = True,
|
ensure_exists: bool = True,
|
||||||
|
|
||||||
) -> list[tuple[str, int]]:
|
) -> tuple[str, int]:
|
||||||
'''
|
|
||||||
Open the service-actor-discovery registry by returning a set of
|
|
||||||
tranport socket-addrs to registrar actors which may be
|
|
||||||
contacted and queried for similar addresses for other
|
|
||||||
non-registrar actors.
|
|
||||||
|
|
||||||
'''
|
|
||||||
global _tractor_kwargs
|
global _tractor_kwargs
|
||||||
actor = tractor.current_actor()
|
actor = tractor.current_actor()
|
||||||
uid = actor.uid
|
uid = actor.uid
|
||||||
preset_reg_addrs: list[tuple[str, int]] = Registry.addrs
|
|
||||||
if (
|
if (
|
||||||
preset_reg_addrs
|
Registry.addr is not None
|
||||||
and addrs
|
and addr
|
||||||
):
|
):
|
||||||
if preset_reg_addrs != addrs:
|
|
||||||
# if any(addr in preset_reg_addrs for addr in addrs):
|
|
||||||
diff: set[tuple[str, int]] = set(preset_reg_addrs) - set(addrs)
|
|
||||||
if diff:
|
|
||||||
log.warning(
|
|
||||||
f'`{uid}` requested only subset of registrars: {addrs}\n'
|
|
||||||
f'However there are more @{diff}'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'`{uid}` has non-matching registrar addresses?\n'
|
f'`{uid}` registry addr already bound @ {_registry.sockaddr}'
|
||||||
f'request: {addrs}\n'
|
|
||||||
f'already set: {preset_reg_addrs}'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
was_set: bool = False
|
was_set: bool = False
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not tractor.is_root_process()
|
not tractor.is_root_process()
|
||||||
and not Registry.addrs
|
and Registry.addr is None
|
||||||
):
|
):
|
||||||
Registry.addrs.extend(actor.reg_addrs)
|
Registry.addr = actor._arb_addr
|
||||||
|
|
||||||
if (
|
if (
|
||||||
ensure_exists
|
ensure_exists
|
||||||
and not Registry.addrs
|
and Registry.addr is None
|
||||||
):
|
):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"`{uid}` registry should already exist but doesn't?"
|
f"`{uid}` registry should already exist bug doesn't?"
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not Registry.addrs
|
Registry.addr is None
|
||||||
):
|
):
|
||||||
was_set = True
|
was_set = True
|
||||||
Registry.addrs = addrs or [_default_reg_addr]
|
Registry.addr = addr or _default_reg_addr
|
||||||
|
|
||||||
# NOTE: only spot this seems currently used is inside
|
_tractor_kwargs['arbiter_addr'] = Registry.addr
|
||||||
# `.ui._exec` which is the (eventual qtloops) bootstrapping
|
|
||||||
# with guest mode.
|
|
||||||
_tractor_kwargs['registry_addrs'] = Registry.addrs
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield Registry.addrs
|
yield Registry.addr
|
||||||
finally:
|
finally:
|
||||||
# XXX: always clear the global addr if we set it so that the
|
# XXX: always clear the global addr if we set it so that the
|
||||||
# next (set of) calls will apply whatever new one is passed
|
# next (set of) calls will apply whatever new one is passed
|
||||||
# in.
|
# in.
|
||||||
if was_set:
|
if was_set:
|
||||||
Registry.addrs = None
|
Registry.addr = None
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def find_service(
|
async def find_service(
|
||||||
service_name: str,
|
service_name: str,
|
||||||
registry_addrs: list[tuple[str, int]] | None = None,
|
) -> tractor.Portal | None:
|
||||||
|
|
||||||
first_only: bool = True,
|
async with open_registry() as reg_addr:
|
||||||
|
|
||||||
) -> (
|
|
||||||
Portal
|
|
||||||
| list[Portal]
|
|
||||||
| None
|
|
||||||
):
|
|
||||||
|
|
||||||
reg_addrs: list[tuple[str, int]]
|
|
||||||
async with open_registry(
|
|
||||||
addrs=(
|
|
||||||
registry_addrs
|
|
||||||
# NOTE: if no addr set is passed assume the registry has
|
|
||||||
# already been opened and use the previously applied
|
|
||||||
# startup set.
|
|
||||||
or Registry.addrs
|
|
||||||
),
|
|
||||||
) as reg_addrs:
|
|
||||||
log.info(f'Scanning for service `{service_name}`')
|
log.info(f'Scanning for service `{service_name}`')
|
||||||
|
|
||||||
maybe_portals: list[Portal] | Portal | None
|
|
||||||
|
|
||||||
# attach to existing daemon by name if possible
|
# attach to existing daemon by name if possible
|
||||||
async with tractor.find_actor(
|
async with tractor.find_actor(
|
||||||
service_name,
|
service_name,
|
||||||
registry_addrs=reg_addrs,
|
arbiter_sockaddr=reg_addr,
|
||||||
only_first=first_only, # if set only returns single ref
|
) as maybe_portal:
|
||||||
) as maybe_portals:
|
yield maybe_portal
|
||||||
if not maybe_portals:
|
|
||||||
yield None
|
|
||||||
return
|
|
||||||
|
|
||||||
yield maybe_portals
|
|
||||||
|
|
||||||
|
|
||||||
async def check_for_service(
|
async def check_for_service(
|
||||||
|
|
@ -182,11 +133,9 @@ async def check_for_service(
|
||||||
Service daemon "liveness" predicate.
|
Service daemon "liveness" predicate.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async with (
|
async with open_registry(ensure_exists=False) as reg_addr:
|
||||||
open_registry(ensure_exists=False) as reg_addr,
|
async with tractor.query_actor(
|
||||||
tractor.query_actor(
|
|
||||||
service_name,
|
service_name,
|
||||||
arbiter_sockaddr=reg_addr,
|
arbiter_sockaddr=reg_addr,
|
||||||
) as sockaddr,
|
) as sockaddr:
|
||||||
):
|
|
||||||
return sockaddr
|
return sockaddr
|
||||||
|
|
|
||||||
|
|
@ -139,13 +139,6 @@ class StorageClient(
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class TimeseriesNotFound(Exception):
|
|
||||||
'''
|
|
||||||
No timeseries entry can be found for this backend.
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
class StorageConnectionError(ConnectionError):
|
class StorageConnectionError(ConnectionError):
|
||||||
'''
|
'''
|
||||||
Can't connect to the desired tsdb subsys/service.
|
Can't connect to the desired tsdb subsys/service.
|
||||||
|
|
@ -176,13 +169,10 @@ async def open_storage_client(
|
||||||
tsdb_host: str = 'localhost'
|
tsdb_host: str = 'localhost'
|
||||||
|
|
||||||
# load root config and any tsdb user defined settings
|
# load root config and any tsdb user defined settings
|
||||||
conf, path = config.load(
|
conf, path = config.load('conf', touch_if_dne=True)
|
||||||
conf_name='conf',
|
|
||||||
touch_if_dne=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: maybe not under a "network" section.. since
|
# TODO: maybe not under a "network" section.. since
|
||||||
# no more chitty `marketstore`..
|
# no more chitty mkts..
|
||||||
tsdbconf: dict = {}
|
tsdbconf: dict = {}
|
||||||
service_section = conf.get('service')
|
service_section = conf.get('service')
|
||||||
if (
|
if (
|
||||||
|
|
@ -193,11 +183,8 @@ async def open_storage_client(
|
||||||
|
|
||||||
# lookup backend tsdb module by name and load any user service
|
# lookup backend tsdb module by name and load any user service
|
||||||
# settings for connecting to the tsdb service.
|
# settings for connecting to the tsdb service.
|
||||||
backend: str = tsdbconf.pop(
|
backend: str = tsdbconf.pop('backend')
|
||||||
'name',
|
tsdb_host: str = tsdbconf['host']
|
||||||
def_backend,
|
|
||||||
)
|
|
||||||
tsdb_host: str = tsdbconf.get('maddrs', [])
|
|
||||||
|
|
||||||
if backend is None:
|
if backend is None:
|
||||||
backend: str = def_backend
|
backend: str = def_backend
|
||||||
|
|
@ -259,7 +246,7 @@ async def open_tsdb_client(
|
||||||
# * the original data feed arch blurb:
|
# * the original data feed arch blurb:
|
||||||
# - https://github.com/pikers/piker/issues/98
|
# - https://github.com/pikers/piker/issues/98
|
||||||
#
|
#
|
||||||
from ..toolz import Profiler
|
from .._profile import Profiler
|
||||||
profiler = Profiler(
|
profiler = Profiler(
|
||||||
disabled=True, # not pg_profile_enabled(),
|
disabled=True, # not pg_profile_enabled(),
|
||||||
delayed=False,
|
delayed=False,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers)
|
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -19,18 +19,10 @@ 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 typing import Generator
|
||||||
from typing import (
|
# from typing import TYPE_CHECKING
|
||||||
Any,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -43,21 +35,24 @@ import typer
|
||||||
|
|
||||||
from piker.service import open_piker_runtime
|
from piker.service import open_piker_runtime
|
||||||
from piker.cli import cli
|
from piker.cli import cli
|
||||||
|
from piker.config import get_conf_dir
|
||||||
from piker.data import (
|
from piker.data import (
|
||||||
|
maybe_open_shm_array,
|
||||||
|
def_iohlcv_fields,
|
||||||
ShmArray,
|
ShmArray,
|
||||||
)
|
)
|
||||||
from piker import tsp
|
from piker.data.history import (
|
||||||
from piker.data._formatters import BGM
|
_default_hist_size,
|
||||||
from . import log
|
_default_rt_size,
|
||||||
|
)
|
||||||
|
from . import (
|
||||||
|
log,
|
||||||
|
)
|
||||||
from . import (
|
from . import (
|
||||||
__tsdbs__,
|
__tsdbs__,
|
||||||
open_storage_client,
|
open_storage_client,
|
||||||
StorageClient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from piker.ui._remote_ctl import AnnotCtl
|
|
||||||
|
|
||||||
|
|
||||||
store = typer.Typer()
|
store = typer.Typer()
|
||||||
|
|
||||||
|
|
@ -82,6 +77,7 @@ def ls(
|
||||||
async with (
|
async with (
|
||||||
open_piker_runtime(
|
open_piker_runtime(
|
||||||
'tsdb_storage',
|
'tsdb_storage',
|
||||||
|
enable_modules=['piker.service._ahab'],
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
for i, backend in enumerate(backends):
|
for i, backend in enumerate(backends):
|
||||||
|
|
@ -103,18 +99,6 @@ def ls(
|
||||||
trio.run(query_all)
|
trio.run(query_all)
|
||||||
|
|
||||||
|
|
||||||
# TODO: like ls but takes in a pattern and matches
|
|
||||||
# @store.command()
|
|
||||||
# def search(
|
|
||||||
# patt: str,
|
|
||||||
# backends: list[str] = typer.Argument(
|
|
||||||
# default=None,
|
|
||||||
# help='Storage backends to query, default is all.'
|
|
||||||
# ),
|
|
||||||
# ):
|
|
||||||
# ...
|
|
||||||
|
|
||||||
|
|
||||||
@store.command()
|
@store.command()
|
||||||
def delete(
|
def delete(
|
||||||
symbols: list[str],
|
symbols: list[str],
|
||||||
|
|
@ -137,6 +121,7 @@ def delete(
|
||||||
async with (
|
async with (
|
||||||
open_piker_runtime(
|
open_piker_runtime(
|
||||||
'tsdb_storage',
|
'tsdb_storage',
|
||||||
|
enable_modules=['piker.service._ahab']
|
||||||
),
|
),
|
||||||
open_storage_client(backend) as (_, client),
|
open_storage_client(backend) as (_, client),
|
||||||
trio.open_nursery() as n,
|
trio.open_nursery() as n,
|
||||||
|
|
@ -157,33 +142,20 @@ def delete(
|
||||||
def anal(
|
def anal(
|
||||||
fqme: str,
|
fqme: str,
|
||||||
period: int = 60,
|
period: int = 60,
|
||||||
pdb: bool = False,
|
|
||||||
|
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
'''
|
|
||||||
Anal-ysis is when you take the data do stuff to it.
|
|
||||||
|
|
||||||
NOTE: This ONLY loads the offline timeseries data (by default
|
|
||||||
from a parquet file) NOT the in-shm version you might be seeing
|
|
||||||
in a chart.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async def main():
|
async def main():
|
||||||
async with (
|
async with (
|
||||||
open_piker_runtime(
|
open_piker_runtime(
|
||||||
# are you a bear or boi?
|
|
||||||
'tsdb_polars_anal',
|
'tsdb_polars_anal',
|
||||||
debug_mode=pdb,
|
# enable_modules=['piker.service._ahab']
|
||||||
),
|
|
||||||
open_storage_client() as (
|
|
||||||
mod,
|
|
||||||
client,
|
|
||||||
),
|
),
|
||||||
|
open_storage_client() as (mod, client),
|
||||||
):
|
):
|
||||||
syms: list[str] = await client.list_keys()
|
syms: list[str] = await client.list_keys()
|
||||||
log.info(f'{len(syms)} FOUND for {mod.name}')
|
print(f'{len(syms)} FOUND for {mod.name}')
|
||||||
|
|
||||||
history: ShmArray # np buffer format
|
|
||||||
(
|
(
|
||||||
history,
|
history,
|
||||||
first_dt,
|
first_dt,
|
||||||
|
|
@ -194,357 +166,166 @@ def anal(
|
||||||
)
|
)
|
||||||
assert first_dt < last_dt
|
assert first_dt < last_dt
|
||||||
|
|
||||||
null_segs: tuple = tsp.get_null_segs(
|
src_df = await client.as_df(fqme, period)
|
||||||
frame=history,
|
from piker.data import _timeseries as tsmod
|
||||||
period=period,
|
df = tsmod.with_dts(src_df)
|
||||||
)
|
gaps: pl.DataFrame = tsmod.detect_time_gaps(df)
|
||||||
# TODO: do tsp queries to backcend to fill i missing
|
|
||||||
# history and then prolly write it to tsdb!
|
|
||||||
|
|
||||||
shm_df: pl.DataFrame = await client.as_df(
|
if gaps:
|
||||||
fqme,
|
print(f'Gaps found:\n{gaps}')
|
||||||
period,
|
|
||||||
)
|
|
||||||
|
|
||||||
df: pl.DataFrame # with dts
|
|
||||||
deduped: pl.DataFrame # deduplicated dts
|
|
||||||
(
|
|
||||||
df,
|
|
||||||
deduped,
|
|
||||||
diff,
|
|
||||||
) = tsp.dedupe(
|
|
||||||
shm_df,
|
|
||||||
period=period,
|
|
||||||
)
|
|
||||||
|
|
||||||
write_edits: bool = True
|
|
||||||
if (
|
|
||||||
write_edits
|
|
||||||
and (
|
|
||||||
diff
|
|
||||||
or null_segs
|
|
||||||
)
|
|
||||||
):
|
|
||||||
await tractor.pause()
|
|
||||||
await client.write_ohlcv(
|
|
||||||
fqme,
|
|
||||||
ohlcv=deduped,
|
|
||||||
timeframe=period,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# TODO: something better with tab completion..
|
# TODO: something better with tab completion..
|
||||||
# is there something more minimal but nearly as
|
# is there something more minimal but nearly as
|
||||||
# functional as ipython?
|
# functional as ipython?
|
||||||
await tractor.pause()
|
await tractor.breakpoint()
|
||||||
assert not null_segs
|
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
async def markup_gaps(
|
def iter_dfs_from_shms(fqme: str) -> Generator[
|
||||||
fqme: str,
|
tuple[Path, ShmArray, pl.DataFrame],
|
||||||
timeframe: float,
|
None,
|
||||||
actl: AnnotCtl,
|
None,
|
||||||
wdts: pl.DataFrame,
|
]:
|
||||||
gaps: pl.DataFrame,
|
# shm buffer size table based on known sample rates
|
||||||
|
sizes: dict[str, int] = {
|
||||||
|
'hist': _default_hist_size,
|
||||||
|
'rt': _default_rt_size,
|
||||||
|
}
|
||||||
|
|
||||||
) -> dict[int, dict]:
|
# load all detected shm buffer files which have the
|
||||||
'''
|
# passed FQME pattern in the file name.
|
||||||
Remote annotate time-gaps in a dt-fielded ts (normally OHLC)
|
shmfiles: list[Path] = []
|
||||||
with rectangles.
|
shmdir = Path('/dev/shm/')
|
||||||
|
|
||||||
'''
|
for shmfile in shmdir.glob(f'*{fqme}*'):
|
||||||
aids: dict[int] = {}
|
filename: str = shmfile.name
|
||||||
for i in range(gaps.height):
|
|
||||||
|
|
||||||
row: pl.DataFrame = gaps[i]
|
# skip index files
|
||||||
|
if (
|
||||||
|
'_first' in filename
|
||||||
|
or '_last' in filename
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
# the gap's RIGHT-most bar's OPEN value
|
assert shmfile.is_file()
|
||||||
# at that time (sample) step.
|
log.debug(f'Found matching shm buffer file: {filename}')
|
||||||
iend: int = row['index'][0]
|
shmfiles.append(shmfile)
|
||||||
# dt: datetime = row['dt'][0]
|
|
||||||
# dt_prev: datetime = row['dt_prev'][0]
|
|
||||||
# dt_end_t: float = dt.timestamp()
|
|
||||||
|
|
||||||
|
for shmfile in shmfiles:
|
||||||
|
|
||||||
# TODO: can we eventually remove this
|
# lookup array buffer size based on file suffix
|
||||||
# once we figure out why the epoch cols
|
# being either .rt or .hist
|
||||||
# don't match?
|
size: int = sizes[shmfile.name.rsplit('.')[-1]]
|
||||||
# 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
|
# attach to any shm buffer, load array into polars df,
|
||||||
# at that time (sample) step.
|
# write to local parquet file.
|
||||||
prev_r: pl.DataFrame = wdts.filter(
|
shm, opened = maybe_open_shm_array(
|
||||||
pl.col('index') == iend - 1
|
key=shmfile.name,
|
||||||
|
size=size,
|
||||||
|
dtype=def_iohlcv_fields,
|
||||||
|
readonly=True,
|
||||||
)
|
)
|
||||||
# XXX: probably a gap in the (newly sorted or de-duplicated)
|
assert not opened
|
||||||
# dt-df, so we might need to re-index first..
|
ohlcv = shm.array
|
||||||
if prev_r.is_empty():
|
|
||||||
await tractor.pause()
|
|
||||||
|
|
||||||
istart: int = prev_r['index'][0]
|
start = time.time()
|
||||||
# dt_start_t: float = dt_prev.timestamp()
|
|
||||||
|
|
||||||
# start_t: float = prev_r['time']
|
# XXX: thanks to this SO answer for this conversion tip:
|
||||||
# assert (
|
# https://stackoverflow.com/a/72054819
|
||||||
# dt_start_t
|
df = pl.DataFrame({
|
||||||
# ==
|
field_name: ohlcv[field_name]
|
||||||
# start_t
|
for field_name in ohlcv.dtype.fields
|
||||||
# )
|
})
|
||||||
|
delay: float = round(
|
||||||
# TODO: implement px-col width measure
|
time.time() - start,
|
||||||
# and ensure at least as many px-cols
|
ndigits=6,
|
||||||
# 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]
|
log.info(
|
||||||
lc: tuple[float, float] = (
|
f'numpy -> polars conversion took {delay} secs\n'
|
||||||
# dt_start_t,
|
f'polars df: {df}'
|
||||||
istart - rect_gap, # + 1 ,
|
|
||||||
cls,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
color: str = 'dad_blue'
|
yield (
|
||||||
diff: float = cls - opn
|
shmfile,
|
||||||
sgn: float = copysign(1, diff)
|
shm,
|
||||||
color: str = {
|
df,
|
||||||
-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,
|
|
||||||
reload_parquet_to_shm: bool = True,
|
write_parquet: bool = False,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
Linux ONLY: load any fqme file name matching shm buffer from
|
Linux ONLY: load any fqme file name matching shm buffer from
|
||||||
/dev/shm/ into an OHLCV numpy array and polars DataFrame,
|
/dev/shm/ into an OHLCV numpy array and polars DataFrame,
|
||||||
optionally write to offline storage via `.parquet` file.
|
optionally write to .parquet file.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async def main():
|
async def main():
|
||||||
from piker.ui._remote_ctl import (
|
|
||||||
open_annot_ctl,
|
|
||||||
)
|
|
||||||
actl: AnnotCtl
|
|
||||||
mod: ModuleType
|
|
||||||
client: StorageClient
|
|
||||||
async with (
|
async with (
|
||||||
open_piker_runtime(
|
open_piker_runtime(
|
||||||
'polars_boi',
|
'polars_boi',
|
||||||
enable_modules=['piker.data._sharedmem'],
|
enable_modules=['piker.data._sharedmem'],
|
||||||
debug_mode=True,
|
|
||||||
),
|
),
|
||||||
open_storage_client() as (
|
|
||||||
mod,
|
|
||||||
client,
|
|
||||||
),
|
|
||||||
open_annot_ctl() as actl,
|
|
||||||
):
|
):
|
||||||
shm_df: pl.DataFrame | None = None
|
|
||||||
tf2aids: dict[float, dict] = {}
|
|
||||||
|
|
||||||
for (
|
df: pl.DataFrame | None = None
|
||||||
shmfile,
|
for shmfile, shm, df in iter_dfs_from_shms(fqme):
|
||||||
shm,
|
|
||||||
# parquet_path,
|
|
||||||
shm_df,
|
|
||||||
) in tsp.iter_dfs_from_shms(fqme):
|
|
||||||
|
|
||||||
|
# compute ohlc properties for naming
|
||||||
times: np.ndarray = shm.array['time']
|
times: np.ndarray = shm.array['time']
|
||||||
d1: float = float(times[-1] - times[-2])
|
secs: float = times[-1] - times[-2]
|
||||||
d2: float = float(times[-2] - times[-3])
|
if secs < 1.:
|
||||||
med: float = np.median(np.diff(times))
|
breakpoint()
|
||||||
if (
|
|
||||||
d1 < 1.
|
|
||||||
and d2 < 1.
|
|
||||||
and med < 1.
|
|
||||||
):
|
|
||||||
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))
|
# TODO: maybe only optionally enter this depending
|
||||||
|
# on some CLI flags and/or gap detection?
|
||||||
|
await tractor.breakpoint()
|
||||||
|
|
||||||
null_segs: tuple = tsp.get_null_segs(
|
# write to parquet file?
|
||||||
frame=shm.array,
|
|
||||||
period=period_s,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: call null-seg fixer somehow?
|
|
||||||
if null_segs:
|
|
||||||
await tractor.pause()
|
|
||||||
# async with (
|
|
||||||
# trio.open_nursery() as tn,
|
|
||||||
# mod.open_history_client(
|
|
||||||
# mkt,
|
|
||||||
# ) as (get_hist, config),
|
|
||||||
# ):
|
|
||||||
# nulls_detected: trio.Event = await tn.start(partial(
|
|
||||||
# tsp.maybe_fill_null_segments,
|
|
||||||
|
|
||||||
# shm=shm,
|
|
||||||
# timeframe=timeframe,
|
|
||||||
# get_hist=get_hist,
|
|
||||||
# sampler_stream=sampler_stream,
|
|
||||||
# mkt=mkt,
|
|
||||||
# ))
|
|
||||||
|
|
||||||
# over-write back to shm?
|
|
||||||
wdts: pl.DataFrame # with dts
|
|
||||||
deduped: pl.DataFrame # deduplicated dts
|
|
||||||
(
|
|
||||||
wdts,
|
|
||||||
deduped,
|
|
||||||
diff,
|
|
||||||
) = tsp.dedupe(
|
|
||||||
shm_df,
|
|
||||||
period=period_s,
|
|
||||||
)
|
|
||||||
|
|
||||||
# detect gaps from in expected (uniform OHLC) sample period
|
|
||||||
step_gaps: pl.DataFrame = tsp.detect_time_gaps(
|
|
||||||
deduped,
|
|
||||||
expect_period=period_s,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: by default we always want to mark these up
|
|
||||||
# with rects showing up/down gaps Bo
|
|
||||||
venue_gaps: pl.DataFrame = tsp.detect_time_gaps(
|
|
||||||
deduped,
|
|
||||||
expect_period=period_s,
|
|
||||||
|
|
||||||
# TODO: actually pull the exact duration
|
|
||||||
# expected for each venue operational period?
|
|
||||||
gap_dt_unit='days',
|
|
||||||
gap_thresh=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: find the disjoint set of step gaps from
|
|
||||||
# venue (closure) set!
|
|
||||||
# -[ ] do a set diff by checking for the unique
|
|
||||||
# gap set only in the step_gaps?
|
|
||||||
if (
|
|
||||||
not venue_gaps.is_empty()
|
|
||||||
or (
|
|
||||||
period_s < 60
|
|
||||||
and not step_gaps.is_empty()
|
|
||||||
)
|
|
||||||
):
|
|
||||||
# write repaired ts to parquet-file?
|
|
||||||
if write_parquet:
|
if write_parquet:
|
||||||
start: float = time.time()
|
timeframe: str = f'{secs}s'
|
||||||
path: Path = await client.write_ohlcv(
|
|
||||||
fqme,
|
|
||||||
ohlcv=deduped,
|
|
||||||
timeframe=period_s,
|
|
||||||
)
|
|
||||||
write_delay: float = round(
|
|
||||||
time.time() - start,
|
|
||||||
ndigits=6,
|
|
||||||
)
|
|
||||||
|
|
||||||
# read back from fs
|
datadir: Path = get_conf_dir() / 'nativedb'
|
||||||
start: float = time.time()
|
if not datadir.is_dir():
|
||||||
read_df: pl.DataFrame = pl.read_parquet(path)
|
datadir.mkdir()
|
||||||
read_delay: float = round(
|
|
||||||
|
path: Path = datadir / f'{fqme}.{timeframe}.parquet'
|
||||||
|
|
||||||
|
# write to fs
|
||||||
|
start = time.time()
|
||||||
|
df.write_parquet(path)
|
||||||
|
delay: float = round(
|
||||||
time.time() - start,
|
time.time() - start,
|
||||||
ndigits=6,
|
ndigits=6,
|
||||||
)
|
)
|
||||||
log.info(
|
log.info(
|
||||||
f'parquet write took {write_delay} secs\n'
|
f'parquet write took {delay} secs\n'
|
||||||
f'file path: {path}'
|
f'file path: {path}'
|
||||||
f'parquet read took {read_delay} secs\n'
|
)
|
||||||
|
|
||||||
|
# read back from fs
|
||||||
|
start = time.time()
|
||||||
|
read_df: pl.DataFrame = pl.read_parquet(path)
|
||||||
|
delay: float = round(
|
||||||
|
time.time() - start,
|
||||||
|
ndigits=6,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f'parquet read took {delay} secs\n'
|
||||||
f'polars df: {read_df}'
|
f'polars df: {read_df}'
|
||||||
)
|
)
|
||||||
|
|
||||||
if reload_parquet_to_shm:
|
if df is None:
|
||||||
new = tsp.pl2np(
|
log.error(f'No matching shm buffers for {fqme} ?')
|
||||||
deduped,
|
|
||||||
dtype=shm.array.dtype,
|
|
||||||
)
|
|
||||||
# since normally readonly
|
|
||||||
shm._array.setflags(
|
|
||||||
write=int(1),
|
|
||||||
)
|
|
||||||
shm.push(
|
|
||||||
new,
|
|
||||||
prepend=True,
|
|
||||||
start=new['index'][-1],
|
|
||||||
update_first=False, # don't update ._first
|
|
||||||
)
|
|
||||||
|
|
||||||
do_markup_gaps: bool = True
|
|
||||||
if do_markup_gaps:
|
|
||||||
new_df: pl.DataFrame = tsp.np2pl(new)
|
|
||||||
aids: dict = await markup_gaps(
|
|
||||||
fqme,
|
|
||||||
period_s,
|
|
||||||
actl,
|
|
||||||
new_df,
|
|
||||||
step_gaps,
|
|
||||||
)
|
|
||||||
# last chance manual overwrites in REPL
|
|
||||||
# await tractor.pause()
|
|
||||||
assert aids
|
|
||||||
tf2aids[period_s] = aids
|
|
||||||
|
|
||||||
else:
|
|
||||||
# allow interaction even when no ts problems.
|
|
||||||
assert not diff
|
|
||||||
|
|
||||||
await tractor.pause()
|
|
||||||
log.info('Exiting TSP shm anal-izer!')
|
|
||||||
|
|
||||||
if shm_df is None:
|
|
||||||
log.error(
|
|
||||||
f'No matching shm buffers for {fqme} ?'
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ from anyio_marketstore import ( # noqa
|
||||||
Params,
|
Params,
|
||||||
)
|
)
|
||||||
from piker.log import get_logger
|
from piker.log import get_logger
|
||||||
|
# from .._profile import Profiler
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
@ -204,7 +205,7 @@ class MktsStorageClient:
|
||||||
# break
|
# break
|
||||||
# except purerpc.grpclib.exceptions.UnknownError as err:
|
# except purerpc.grpclib.exceptions.UnknownError as err:
|
||||||
# if 'snappy' in err.args:
|
# if 'snappy' in err.args:
|
||||||
# await tractor.pause()
|
# await tractor.breakpoint()
|
||||||
|
|
||||||
# # indicate there is no history for this timeframe
|
# # indicate there is no history for this timeframe
|
||||||
# log.exception(
|
# log.exception(
|
||||||
|
|
@ -232,7 +233,7 @@ class MktsStorageClient:
|
||||||
'YOUR DATABASE LIKELY CONTAINS BAD DATA FROM AN OLD BUG '
|
'YOUR DATABASE LIKELY CONTAINS BAD DATA FROM AN OLD BUG '
|
||||||
f'WIPING HISTORY FOR {ts}s'
|
f'WIPING HISTORY FOR {ts}s'
|
||||||
)
|
)
|
||||||
await tractor.pause()
|
await tractor.breakpoint()
|
||||||
# await self.delete_ts(fqme, timeframe)
|
# await self.delete_ts(fqme, timeframe)
|
||||||
|
|
||||||
# try reading again..
|
# try reading again..
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@
|
||||||
call a poor man's tsdb).
|
call a poor man's tsdb).
|
||||||
|
|
||||||
AKA a `piker`-native file-system native "time series database"
|
AKA a `piker`-native file-system native "time series database"
|
||||||
without needing an extra process and no standard TSDB features,
|
without needing an extra process and no standard TSDB features, YET!
|
||||||
YET!
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# TODO: like there's soo much..
|
# TODO: like there's soo much..
|
||||||
|
|
@ -56,6 +55,8 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
# from bidict import bidict
|
||||||
|
# import tractor
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from pendulum import (
|
from pendulum import (
|
||||||
|
|
@ -63,18 +64,46 @@ from pendulum import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from piker import config
|
from piker import config
|
||||||
from piker import tsp
|
from piker.data import def_iohlcv_fields
|
||||||
from piker.data import (
|
from piker.data import ShmArray
|
||||||
def_iohlcv_fields,
|
|
||||||
ShmArray,
|
|
||||||
)
|
|
||||||
from piker.log import get_logger
|
from piker.log import get_logger
|
||||||
from . import TimeseriesNotFound
|
# from .._profile import Profiler
|
||||||
|
|
||||||
|
|
||||||
log = get_logger('storage.nativedb')
|
log = get_logger('storage.nativedb')
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: thanks to this SO answer for the below conversion routines
|
||||||
|
# to go from numpy struct-arrays to polars dataframes and back:
|
||||||
|
# https://stackoverflow.com/a/72054819
|
||||||
|
def np2pl(array: np.ndarray) -> pl.DataFrame:
|
||||||
|
return pl.DataFrame({
|
||||||
|
field_name: array[field_name]
|
||||||
|
for field_name in array.dtype.fields
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def pl2np(
|
||||||
|
df: pl.DataFrame,
|
||||||
|
dtype: np.dtype,
|
||||||
|
|
||||||
|
) -> np.ndarray:
|
||||||
|
|
||||||
|
# Create numpy struct array of the correct size and dtype
|
||||||
|
# and loop through df columns to fill in array fields.
|
||||||
|
array = np.empty(
|
||||||
|
df.height,
|
||||||
|
dtype,
|
||||||
|
)
|
||||||
|
for field, col in zip(
|
||||||
|
dtype.fields,
|
||||||
|
df.columns,
|
||||||
|
):
|
||||||
|
array[field] = df.get_column(col).to_numpy()
|
||||||
|
|
||||||
|
return array
|
||||||
|
|
||||||
|
|
||||||
def detect_period(shm: ShmArray) -> float:
|
def detect_period(shm: ShmArray) -> float:
|
||||||
'''
|
'''
|
||||||
Attempt to detect the series time step sampling period
|
Attempt to detect the series time step sampling period
|
||||||
|
|
@ -95,19 +124,16 @@ def detect_period(shm: ShmArray) -> float:
|
||||||
|
|
||||||
def mk_ohlcv_shm_keyed_filepath(
|
def mk_ohlcv_shm_keyed_filepath(
|
||||||
fqme: str,
|
fqme: str,
|
||||||
period: float | int, # ow known as the "timeframe"
|
period: float, # ow known as the "timeframe"
|
||||||
datadir: Path,
|
datadir: Path,
|
||||||
|
|
||||||
) -> Path:
|
) -> str:
|
||||||
|
|
||||||
if period < 1.:
|
if period < 1.:
|
||||||
raise ValueError('Sample period should be >= 1.!?')
|
raise ValueError('Sample period should be >= 1.!?')
|
||||||
|
|
||||||
path: Path = (
|
period_s: str = f'{period}s'
|
||||||
datadir
|
path: Path = datadir / f'{fqme}.ohlcv{period_s}.parquet'
|
||||||
/
|
|
||||||
f'{fqme}.ohlcv{int(period)}s.parquet'
|
|
||||||
)
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -161,13 +187,7 @@ class NativeStorageClient:
|
||||||
|
|
||||||
def index_files(self):
|
def index_files(self):
|
||||||
for path in self._datadir.iterdir():
|
for path in self._datadir.iterdir():
|
||||||
if (
|
if 'borked' in path.name:
|
||||||
path.is_dir()
|
|
||||||
or
|
|
||||||
'.parquet' not in str(path)
|
|
||||||
# or
|
|
||||||
# path.name in {'borked', 'expired',}
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
key: str = path.name.rstrip('.parquet')
|
key: str = path.name.rstrip('.parquet')
|
||||||
|
|
@ -209,21 +229,8 @@ class NativeStorageClient:
|
||||||
fqme,
|
fqme,
|
||||||
timeframe,
|
timeframe,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as fnfe:
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
bs_fqme, _, *_ = fqme.rpartition('.')
|
|
||||||
|
|
||||||
possible_matches: list[str] = []
|
|
||||||
for tskey in self._index:
|
|
||||||
if bs_fqme in tskey:
|
|
||||||
possible_matches.append(tskey)
|
|
||||||
|
|
||||||
match_str: str = '\n'.join(sorted(possible_matches))
|
|
||||||
raise TimeseriesNotFound(
|
|
||||||
f'No entry for `{fqme}`?\n'
|
|
||||||
f'Maybe you need a more specific fqme-key like:\n\n'
|
|
||||||
f'{match_str}'
|
|
||||||
) from fnfe
|
|
||||||
|
|
||||||
times = array['time']
|
times = array['time']
|
||||||
return (
|
return (
|
||||||
|
|
@ -236,7 +243,6 @@ class NativeStorageClient:
|
||||||
self,
|
self,
|
||||||
fqme: str,
|
fqme: str,
|
||||||
period: float,
|
period: float,
|
||||||
|
|
||||||
) -> Path:
|
) -> Path:
|
||||||
return mk_ohlcv_shm_keyed_filepath(
|
return mk_ohlcv_shm_keyed_filepath(
|
||||||
fqme=fqme,
|
fqme=fqme,
|
||||||
|
|
@ -244,23 +250,6 @@ class NativeStorageClient:
|
||||||
datadir=self._datadir,
|
datadir=self._datadir,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _cache_df(
|
|
||||||
self,
|
|
||||||
fqme: str,
|
|
||||||
df: pl.DataFrame,
|
|
||||||
timeframe: float,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
# cache df for later usage since we (currently) need to
|
|
||||||
# convert to np.ndarrays to push to our `ShmArray` rt
|
|
||||||
# buffers subsys but later we may operate entirely on
|
|
||||||
# pyarrow arrays/buffers so keeping the dfs around for
|
|
||||||
# a variety of purposes is handy.
|
|
||||||
self._dfs.setdefault(
|
|
||||||
timeframe,
|
|
||||||
{},
|
|
||||||
)[fqme] = df
|
|
||||||
|
|
||||||
async def read_ohlcv(
|
async def read_ohlcv(
|
||||||
self,
|
self,
|
||||||
fqme: str,
|
fqme: str,
|
||||||
|
|
@ -269,20 +258,13 @@ class NativeStorageClient:
|
||||||
# limit: int = int(200e3),
|
# limit: int = int(200e3),
|
||||||
|
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
path: Path = self.mk_path(
|
path: Path = self.mk_path(fqme, period=int(timeframe))
|
||||||
fqme,
|
|
||||||
period=int(timeframe),
|
|
||||||
)
|
|
||||||
df: pl.DataFrame = pl.read_parquet(path)
|
df: pl.DataFrame = pl.read_parquet(path)
|
||||||
|
self._dfs.setdefault(timeframe, {})[fqme] = df
|
||||||
|
|
||||||
self._cache_df(
|
|
||||||
fqme=fqme,
|
|
||||||
df=df,
|
|
||||||
timeframe=timeframe,
|
|
||||||
)
|
|
||||||
# TODO: filter by end and limit inputs
|
# TODO: filter by end and limit inputs
|
||||||
# times: pl.Series = df['time']
|
# times: pl.Series = df['time']
|
||||||
array: np.ndarray = tsp.pl2np(
|
array: np.ndarray = pl2np(
|
||||||
df,
|
df,
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
dtype=np.dtype(def_iohlcv_fields),
|
||||||
)
|
)
|
||||||
|
|
@ -292,15 +274,11 @@ class NativeStorageClient:
|
||||||
self,
|
self,
|
||||||
fqme: str,
|
fqme: str,
|
||||||
period: int = 60,
|
period: int = 60,
|
||||||
load_from_offline: bool = True,
|
|
||||||
|
|
||||||
) -> pl.DataFrame:
|
) -> pl.DataFrame:
|
||||||
try:
|
try:
|
||||||
return self._dfs[period][fqme]
|
return self._dfs[period][fqme]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
if not load_from_offline:
|
|
||||||
raise
|
|
||||||
|
|
||||||
await self.read_ohlcv(fqme, period)
|
await self.read_ohlcv(fqme, period)
|
||||||
return self._dfs[period][fqme]
|
return self._dfs[period][fqme]
|
||||||
|
|
||||||
|
|
@ -322,22 +300,14 @@ class NativeStorageClient:
|
||||||
datadir=self._datadir,
|
datadir=self._datadir,
|
||||||
)
|
)
|
||||||
if isinstance(ohlcv, np.ndarray):
|
if isinstance(ohlcv, np.ndarray):
|
||||||
df: pl.DataFrame = tsp.np2pl(ohlcv)
|
df: pl.DataFrame = np2pl(ohlcv)
|
||||||
else:
|
else:
|
||||||
df = ohlcv
|
df = ohlcv
|
||||||
|
|
||||||
self._cache_df(
|
|
||||||
fqme=fqme,
|
|
||||||
df=df,
|
|
||||||
timeframe=timeframe,
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: in terms of managing the ultra long term data
|
# TODO: in terms of managing the ultra long term data
|
||||||
# -[ ] use a proper profiler to measure all this IO and
|
# - use a proper profiler to measure all this IO and
|
||||||
# roundtripping!
|
# roundtripping!
|
||||||
# -[ ] implement parquet append!? see issue:
|
# - try out ``fastparquet``'s append writing:
|
||||||
# https://github.com/pikers/piker/issues/536
|
|
||||||
# -[ ] try out ``fastparquet``'s append writing:
|
|
||||||
# https://fastparquet.readthedocs.io/en/latest/api.html#fastparquet.write
|
# https://fastparquet.readthedocs.io/en/latest/api.html#fastparquet.write
|
||||||
start = time.time()
|
start = time.time()
|
||||||
df.write_parquet(path)
|
df.write_parquet(path)
|
||||||
|
|
@ -345,16 +315,17 @@ class NativeStorageClient:
|
||||||
time.time() - start,
|
time.time() - start,
|
||||||
ndigits=6,
|
ndigits=6,
|
||||||
)
|
)
|
||||||
log.info(
|
print(
|
||||||
f'parquet write took {delay} secs\n'
|
f'parquet write took {delay} secs\n'
|
||||||
f'file path: {path}'
|
f'file path: {path}'
|
||||||
)
|
)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
async def write_ohlcv(
|
async def write_ohlcv(
|
||||||
self,
|
self,
|
||||||
fqme: str,
|
fqme: str,
|
||||||
ohlcv: np.ndarray | pl.DataFrame,
|
ohlcv: np.ndarray,
|
||||||
timeframe: int,
|
timeframe: int,
|
||||||
|
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
|
@ -406,8 +377,6 @@ class NativeStorageClient:
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
|
|
||||||
# TODO: does this need to be async on average?
|
|
||||||
# I guess for any IPC connected backend yes?
|
|
||||||
@acm
|
@acm
|
||||||
async def get_client(
|
async def get_client(
|
||||||
|
|
||||||
|
|
@ -425,7 +394,7 @@ async def get_client(
|
||||||
'''
|
'''
|
||||||
datadir: Path = config.get_conf_dir() / 'nativedb'
|
datadir: Path = config.get_conf_dir() / 'nativedb'
|
||||||
if not datadir.is_dir():
|
if not datadir.is_dir():
|
||||||
log.info(f'Creating `nativedb` dir: {datadir}')
|
log.info(f'Creating `nativedb` director: {datadir}')
|
||||||
datadir.mkdir()
|
datadir.mkdir()
|
||||||
|
|
||||||
client = NativeStorageClient(datadir)
|
client = NativeStorageClient(datadir)
|
||||||
|
|
|
||||||
|
|
@ -1,29 +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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Toolz for debug, profile and trace of the distributed runtime :surfer:
|
|
||||||
|
|
||||||
'''
|
|
||||||
from tractor.devx import (
|
|
||||||
open_crash_handler as open_crash_handler,
|
|
||||||
)
|
|
||||||
from .profile import (
|
|
||||||
Profiler as Profiler,
|
|
||||||
pg_profile_enabled as pg_profile_enabled,
|
|
||||||
ms_slower_then as ms_slower_then,
|
|
||||||
timeit as timeit,
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship of piker0)
|
||||||
|
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
sugarz for trio/tractor conc peeps.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from typing import AsyncContextManager
|
||||||
|
from typing import TypeVar
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
|
||||||
|
import trio
|
||||||
|
|
||||||
|
|
||||||
|
# A regular invariant generic type
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
async def _enter_and_sleep(
|
||||||
|
|
||||||
|
mngr: AsyncContextManager[T],
|
||||||
|
to_yield: dict[int, T],
|
||||||
|
all_entered: trio.Event,
|
||||||
|
# task_status: TaskStatus[T] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
|
) -> T:
|
||||||
|
'''Open the async context manager deliver it's value
|
||||||
|
to this task's spawner and sleep until cancelled.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with mngr as value:
|
||||||
|
to_yield[id(mngr)] = value
|
||||||
|
|
||||||
|
if all(to_yield.values()):
|
||||||
|
all_entered.set()
|
||||||
|
|
||||||
|
# sleep until cancelled
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def async_enter_all(
|
||||||
|
|
||||||
|
*mngrs: list[AsyncContextManager[T]],
|
||||||
|
|
||||||
|
) -> tuple[T]:
|
||||||
|
|
||||||
|
to_yield = {}.fromkeys(id(mngr) for mngr in mngrs)
|
||||||
|
|
||||||
|
all_entered = trio.Event()
|
||||||
|
|
||||||
|
async with trio.open_nursery() as n:
|
||||||
|
for mngr in mngrs:
|
||||||
|
n.start_soon(
|
||||||
|
_enter_and_sleep,
|
||||||
|
mngr,
|
||||||
|
to_yield,
|
||||||
|
all_entered,
|
||||||
|
)
|
||||||
|
|
||||||
|
# deliver control once all managers have started up
|
||||||
|
await all_entered.wait()
|
||||||
|
yield tuple(to_yield.values())
|
||||||
|
|
||||||
|
# tear down all sleeper tasks thus triggering individual
|
||||||
|
# mngr ``__aexit__()``s.
|
||||||
|
n.cancel_scope.cancel()
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,746 +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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Financial time series processing utilities usually
|
|
||||||
pertaining to OHLCV style sampled data.
|
|
||||||
|
|
||||||
Routines are generally implemented in either ``numpy`` or
|
|
||||||
``polars`` B)
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
from functools import partial
|
|
||||||
from math import (
|
|
||||||
ceil,
|
|
||||||
floor,
|
|
||||||
)
|
|
||||||
import time
|
|
||||||
from typing import (
|
|
||||||
Literal,
|
|
||||||
# AsyncGenerator,
|
|
||||||
Generator,
|
|
||||||
)
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import polars as pl
|
|
||||||
from pendulum import (
|
|
||||||
DateTime,
|
|
||||||
from_timestamp,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..toolz.profile import (
|
|
||||||
Profiler,
|
|
||||||
pg_profile_enabled,
|
|
||||||
ms_slower_then,
|
|
||||||
)
|
|
||||||
from ..log import (
|
|
||||||
get_logger,
|
|
||||||
get_console_log,
|
|
||||||
)
|
|
||||||
# for "time series processing"
|
|
||||||
subsys: str = 'piker.tsp'
|
|
||||||
|
|
||||||
log = get_logger(subsys)
|
|
||||||
get_console_log = partial(
|
|
||||||
get_console_log,
|
|
||||||
name=subsys,
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE: union type-defs to handle generic `numpy` and `polars` types
|
|
||||||
# side-by-side Bo
|
|
||||||
# |_ TODO: schema spec typing?
|
|
||||||
# -[ ] nptyping!
|
|
||||||
# -[ ] wtv we can with polars?
|
|
||||||
Frame = pl.DataFrame | np.ndarray
|
|
||||||
Seq = pl.Series | np.ndarray
|
|
||||||
|
|
||||||
|
|
||||||
def slice_from_time(
|
|
||||||
arr: np.ndarray,
|
|
||||||
start_t: float,
|
|
||||||
stop_t: float,
|
|
||||||
step: float, # sampler period step-diff
|
|
||||||
|
|
||||||
) -> slice:
|
|
||||||
'''
|
|
||||||
Calculate array indices mapped from a time range and return them in
|
|
||||||
a slice.
|
|
||||||
|
|
||||||
Given an input array with an epoch `'time'` series entry, calculate
|
|
||||||
the indices which span the time range and return in a slice. Presume
|
|
||||||
each `'time'` step increment is uniform and when the time stamp
|
|
||||||
series contains gaps (the uniform presumption is untrue) use
|
|
||||||
``np.searchsorted()`` binary search to look up the appropriate
|
|
||||||
index.
|
|
||||||
|
|
||||||
'''
|
|
||||||
profiler = Profiler(
|
|
||||||
msg='slice_from_time()',
|
|
||||||
disabled=not pg_profile_enabled(),
|
|
||||||
ms_threshold=ms_slower_then,
|
|
||||||
)
|
|
||||||
|
|
||||||
times = arr['time']
|
|
||||||
t_first = floor(times[0])
|
|
||||||
t_last = ceil(times[-1])
|
|
||||||
|
|
||||||
# the greatest index we can return which slices to the
|
|
||||||
# end of the input array.
|
|
||||||
read_i_max = arr.shape[0]
|
|
||||||
|
|
||||||
# compute (presumed) uniform-time-step index offsets
|
|
||||||
i_start_t = floor(start_t)
|
|
||||||
read_i_start = floor(((i_start_t - t_first) // step)) - 1
|
|
||||||
|
|
||||||
i_stop_t = ceil(stop_t)
|
|
||||||
|
|
||||||
# XXX: edge case -> always set stop index to last in array whenever
|
|
||||||
# the input stop time is detected to be greater then the equiv time
|
|
||||||
# stamp at that last entry.
|
|
||||||
if i_stop_t >= t_last:
|
|
||||||
read_i_stop = read_i_max
|
|
||||||
else:
|
|
||||||
read_i_stop = ceil((i_stop_t - t_first) // step) + 1
|
|
||||||
|
|
||||||
# always clip outputs to array support
|
|
||||||
# for read start:
|
|
||||||
# - never allow a start < the 0 index
|
|
||||||
# - never allow an end index > the read array len
|
|
||||||
read_i_start = min(
|
|
||||||
max(0, read_i_start),
|
|
||||||
read_i_max - 1,
|
|
||||||
)
|
|
||||||
read_i_stop = max(
|
|
||||||
0,
|
|
||||||
min(read_i_stop, read_i_max),
|
|
||||||
)
|
|
||||||
|
|
||||||
# check for larger-then-latest calculated index for given start
|
|
||||||
# time, in which case we do a binary search for the correct index.
|
|
||||||
# NOTE: this is usually the result of a time series with time gaps
|
|
||||||
# where it is expected that each index step maps to a uniform step
|
|
||||||
# in the time stamp series.
|
|
||||||
t_iv_start = times[read_i_start]
|
|
||||||
if (
|
|
||||||
t_iv_start > i_start_t
|
|
||||||
):
|
|
||||||
# do a binary search for the best index mapping to ``start_t``
|
|
||||||
# given we measured an overshoot using the uniform-time-step
|
|
||||||
# calculation from above.
|
|
||||||
|
|
||||||
# TODO: once we start caching these per source-array,
|
|
||||||
# we can just overwrite ``read_i_start`` directly.
|
|
||||||
new_read_i_start = np.searchsorted(
|
|
||||||
times,
|
|
||||||
i_start_t,
|
|
||||||
side='left',
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: minimize binary search work as much as possible:
|
|
||||||
# - cache these remap values which compensate for gaps in the
|
|
||||||
# uniform time step basis where we calc a later start
|
|
||||||
# index for the given input ``start_t``.
|
|
||||||
# - can we shorten the input search sequence by heuristic?
|
|
||||||
# up_to_arith_start = index[:read_i_start]
|
|
||||||
|
|
||||||
if (
|
|
||||||
new_read_i_start <= read_i_start
|
|
||||||
):
|
|
||||||
# t_diff = t_iv_start - start_t
|
|
||||||
# print(
|
|
||||||
# f"WE'RE CUTTING OUT TIME - STEP:{step}\n"
|
|
||||||
# f'start_t:{start_t} -> 0index start_t:{t_iv_start}\n'
|
|
||||||
# f'diff: {t_diff}\n'
|
|
||||||
# f'REMAPPED START i: {read_i_start} -> {new_read_i_start}\n'
|
|
||||||
# )
|
|
||||||
read_i_start = new_read_i_start
|
|
||||||
|
|
||||||
t_iv_stop = times[read_i_stop - 1]
|
|
||||||
if (
|
|
||||||
t_iv_stop > i_stop_t
|
|
||||||
):
|
|
||||||
# t_diff = stop_t - t_iv_stop
|
|
||||||
# print(
|
|
||||||
# f"WE'RE CUTTING OUT TIME - STEP:{step}\n"
|
|
||||||
# f'calced iv stop:{t_iv_stop} -> stop_t:{stop_t}\n'
|
|
||||||
# f'diff: {t_diff}\n'
|
|
||||||
# # f'SHOULD REMAP STOP: {read_i_start} -> {new_read_i_start}\n'
|
|
||||||
# )
|
|
||||||
new_read_i_stop = np.searchsorted(
|
|
||||||
times[read_i_start:],
|
|
||||||
# times,
|
|
||||||
i_stop_t,
|
|
||||||
side='right',
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
new_read_i_stop <= read_i_stop
|
|
||||||
):
|
|
||||||
read_i_stop = read_i_start + new_read_i_stop + 1
|
|
||||||
|
|
||||||
# sanity checks for range size
|
|
||||||
# samples = (i_stop_t - i_start_t) // step
|
|
||||||
# index_diff = read_i_stop - read_i_start + 1
|
|
||||||
# if index_diff > (samples + 3):
|
|
||||||
# breakpoint()
|
|
||||||
|
|
||||||
# read-relative indexes: gives a slice where `shm.array[read_slc]`
|
|
||||||
# will be the data spanning the input time range `start_t` ->
|
|
||||||
# `stop_t`
|
|
||||||
read_slc = slice(
|
|
||||||
int(read_i_start),
|
|
||||||
int(read_i_stop),
|
|
||||||
)
|
|
||||||
|
|
||||||
profiler(
|
|
||||||
'slicing complete'
|
|
||||||
# f'{start_t} -> {abs_slc.start} | {read_slc.start}\n'
|
|
||||||
# f'{stop_t} -> {abs_slc.stop} | {read_slc.stop}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE: if caller needs absolute buffer indices they can
|
|
||||||
# slice the buffer abs index like so:
|
|
||||||
# index = arr['index']
|
|
||||||
# abs_indx = index[read_slc]
|
|
||||||
# abs_slc = slice(
|
|
||||||
# int(abs_indx[0]),
|
|
||||||
# int(abs_indx[-1]),
|
|
||||||
# )
|
|
||||||
|
|
||||||
return read_slc
|
|
||||||
|
|
||||||
|
|
||||||
def get_null_segs(
|
|
||||||
frame: Frame,
|
|
||||||
period: float, # sampling step in seconds
|
|
||||||
imargin: int = 1,
|
|
||||||
col: str = 'time',
|
|
||||||
|
|
||||||
) -> tuple[
|
|
||||||
# Seq, # TODO: can we make it an array-type instead?
|
|
||||||
list[
|
|
||||||
list[int, int],
|
|
||||||
],
|
|
||||||
Seq,
|
|
||||||
Frame
|
|
||||||
] | None:
|
|
||||||
'''
|
|
||||||
Detect if there are any zero(-epoch stamped) valued
|
|
||||||
rows in for the provided `col: str` column; by default
|
|
||||||
presume the 'time' field/column.
|
|
||||||
|
|
||||||
Filter to all such zero (time) segments and return
|
|
||||||
the corresponding frame zeroed segment's,
|
|
||||||
|
|
||||||
- gap absolute (in buffer terms) indices-endpoints as
|
|
||||||
`absi_zsegs`
|
|
||||||
- abs indices of all rows with zeroed `col` values as `absi_zeros`
|
|
||||||
- the corresponding frame's row-entries (view) which are
|
|
||||||
zeroed for the `col` as `zero_t`
|
|
||||||
|
|
||||||
'''
|
|
||||||
times: Seq = frame['time']
|
|
||||||
zero_pred: Seq = (times == 0)
|
|
||||||
|
|
||||||
if isinstance(frame, np.ndarray):
|
|
||||||
tis_zeros: int = zero_pred.any()
|
|
||||||
else:
|
|
||||||
tis_zeros: int = zero_pred.any()
|
|
||||||
|
|
||||||
if not tis_zeros:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# TODO: use ndarray for this?!
|
|
||||||
absi_zsegs: list[list[int, int]] = []
|
|
||||||
|
|
||||||
if isinstance(frame, np.ndarray):
|
|
||||||
# view of ONLY the zero segments as one continuous chunk
|
|
||||||
zero_t: np.ndarray = frame[zero_pred]
|
|
||||||
# abs indices of said zeroed rows
|
|
||||||
absi_zeros = zero_t['index']
|
|
||||||
# diff of abs index steps between each zeroed row
|
|
||||||
absi_zdiff: np.ndarray = np.diff(absi_zeros)
|
|
||||||
|
|
||||||
# scan for all frame-indices where the
|
|
||||||
# zeroed-row-abs-index-step-diff is greater then the
|
|
||||||
# expected increment of 1.
|
|
||||||
# data 1st zero seg data zeros
|
|
||||||
# ---- ------------ ---- ----- ------ ----
|
|
||||||
# ||||..000000000000..||||..00000..||||||..0000
|
|
||||||
# ---- ------------ ---- ----- ------ ----
|
|
||||||
# ^zero_t[0] ^zero_t[-1]
|
|
||||||
# ^fi_zgaps[0] ^fi_zgaps[1]
|
|
||||||
# ^absi_zsegs[0][0] ^---^ => absi_zsegs[1]: tuple
|
|
||||||
# absi_zsegs[0][1]^
|
|
||||||
#
|
|
||||||
# NOTE: the first entry in `fi_zgaps` is where
|
|
||||||
# the first (absolute) index step diff is > 1.
|
|
||||||
# and it is a frame-relative index into `zero_t`.
|
|
||||||
fi_zgaps = np.argwhere(
|
|
||||||
absi_zdiff > 1
|
|
||||||
# NOTE: +1 here is ensure we index to the "start" of each
|
|
||||||
# segment (if we didn't the below loop needs to be
|
|
||||||
# re-written to expect `fi_end_rows`!
|
|
||||||
) + 1
|
|
||||||
# the rows from the contiguous zeroed segments which have
|
|
||||||
# abs-index steps >1 compared to the previous zero row
|
|
||||||
# (indicating an end of zeroed segment).
|
|
||||||
fi_zseg_start_rows = zero_t[fi_zgaps]
|
|
||||||
|
|
||||||
# TODO: equiv for pl.DataFrame case!
|
|
||||||
else:
|
|
||||||
izeros: pl.Series = zero_pred.arg_true()
|
|
||||||
zero_t: pl.DataFrame = frame[izeros]
|
|
||||||
|
|
||||||
absi_zeros = zero_t['index']
|
|
||||||
absi_zdiff: pl.Series = absi_zeros.diff()
|
|
||||||
fi_zgaps = (absi_zdiff > 1).arg_true()
|
|
||||||
|
|
||||||
# XXX: our goal (in this func) is to select out slice index
|
|
||||||
# pairs (zseg0_start, zseg_end) in abs index units for each
|
|
||||||
# null-segment portion detected throughout entire input frame.
|
|
||||||
|
|
||||||
# only up to one null-segment in entire frame?
|
|
||||||
num_gaps: int = fi_zgaps.size + 1
|
|
||||||
if num_gaps < 1:
|
|
||||||
if absi_zeros.size > 1:
|
|
||||||
absi_zsegs = [[
|
|
||||||
# TODO: maybe mk these max()/min() limits func
|
|
||||||
# consts instead of called more then once?
|
|
||||||
max(
|
|
||||||
absi_zeros[0] - 1,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
# NOTE: need the + 1 to guarantee we index "up to"
|
|
||||||
# the next non-null row-datum.
|
|
||||||
min(
|
|
||||||
absi_zeros[-1] + 1,
|
|
||||||
frame['index'][-1],
|
|
||||||
),
|
|
||||||
]]
|
|
||||||
else:
|
|
||||||
# XXX EDGE CASE: only one null-datum found so
|
|
||||||
# mark the start abs index as None to trigger
|
|
||||||
# a full frame-len query to the respective backend?
|
|
||||||
absi_zsegs = [[
|
|
||||||
# see `get_hist()` in backend, should ALWAYS be
|
|
||||||
# able to handle a `start_dt=None`!
|
|
||||||
# None,
|
|
||||||
None,
|
|
||||||
absi_zeros[0] + 1,
|
|
||||||
]]
|
|
||||||
|
|
||||||
# XXX NOTE XXX: if >= 2 zeroed segments are found, there should
|
|
||||||
# ALWAYS be more then one zero-segment-abs-index-step-diff row
|
|
||||||
# in `absi_zdiff`, so loop through all such
|
|
||||||
# abs-index-step-diffs >1 (i.e. the entries of `absi_zdiff`)
|
|
||||||
# and add them as the "end index" entries for each segment.
|
|
||||||
# Then, iif NOT iterating the first such segment end, look back
|
|
||||||
# for the prior segments zero-segment start indext by relative
|
|
||||||
# indexing the `zero_t` frame by -1 and grabbing the abs index
|
|
||||||
# of what should be the prior zero-segment abs start index.
|
|
||||||
else:
|
|
||||||
# NOTE: since `absi_zdiff` will never have a row
|
|
||||||
# corresponding to the first zero-segment's row, we add it
|
|
||||||
# manually here.
|
|
||||||
absi_zsegs.append([
|
|
||||||
max(
|
|
||||||
absi_zeros[0] - 1,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
])
|
|
||||||
|
|
||||||
# TODO: can we do it with vec ops?
|
|
||||||
for i, (
|
|
||||||
fi, # frame index of zero-seg start
|
|
||||||
zseg_start_row, # full row for ^
|
|
||||||
) in enumerate(zip(
|
|
||||||
fi_zgaps,
|
|
||||||
fi_zseg_start_rows,
|
|
||||||
)):
|
|
||||||
assert (zseg_start_row == zero_t[fi]).all()
|
|
||||||
iabs: int = zseg_start_row['index'][0]
|
|
||||||
absi_zsegs.append([
|
|
||||||
iabs - 1,
|
|
||||||
None, # backfilled on next iter
|
|
||||||
])
|
|
||||||
|
|
||||||
# final iter case, backfill FINAL end iabs!
|
|
||||||
if (i + 1) == fi_zgaps.size:
|
|
||||||
absi_zsegs[-1][1] = absi_zeros[-1] + 1
|
|
||||||
|
|
||||||
# NOTE: only after the first segment (due to `.diff()`
|
|
||||||
# usage above) can we do a lookback to the prior
|
|
||||||
# segment's end row and determine it's abs index to
|
|
||||||
# retroactively insert to the prior
|
|
||||||
# `absi_zsegs[i-1][1]` entry Bo
|
|
||||||
last_end: int = absi_zsegs[i][1]
|
|
||||||
if last_end is None:
|
|
||||||
prev_zseg_row = zero_t[fi - 1]
|
|
||||||
absi_post_zseg = prev_zseg_row['index'][0] + 1
|
|
||||||
# XXX: MUST BACKFILL previous end iabs!
|
|
||||||
absi_zsegs[i][1] = absi_post_zseg
|
|
||||||
|
|
||||||
else:
|
|
||||||
if 0 < num_gaps < 2:
|
|
||||||
absi_zsegs[-1][1] = min(
|
|
||||||
absi_zeros[-1] + 1,
|
|
||||||
frame['index'][-1],
|
|
||||||
)
|
|
||||||
|
|
||||||
iabs_first: int = frame['index'][0]
|
|
||||||
for start, end in absi_zsegs:
|
|
||||||
|
|
||||||
ts_start: float = times[start - iabs_first]
|
|
||||||
ts_end: float = times[end - iabs_first]
|
|
||||||
if (
|
|
||||||
(ts_start == 0 and not start == 0)
|
|
||||||
or
|
|
||||||
ts_end == 0
|
|
||||||
):
|
|
||||||
import pdbp
|
|
||||||
pdbp.set_trace()
|
|
||||||
|
|
||||||
assert end
|
|
||||||
assert start < end
|
|
||||||
|
|
||||||
log.warning(
|
|
||||||
f'Frame has {len(absi_zsegs)} NULL GAPS!?\n'
|
|
||||||
f'period: {period}\n'
|
|
||||||
f'total null samples: {len(zero_t)}\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
absi_zsegs, # [start, end] abs slice indices of seg
|
|
||||||
absi_zeros, # all abs indices within all null-segs
|
|
||||||
zero_t, # sliced-view of all null-segment rows-datums
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def iter_null_segs(
|
|
||||||
timeframe: float,
|
|
||||||
frame: Frame | None = None,
|
|
||||||
null_segs: tuple | None = None,
|
|
||||||
|
|
||||||
) -> Generator[
|
|
||||||
tuple[
|
|
||||||
int, int,
|
|
||||||
int, int,
|
|
||||||
float, float,
|
|
||||||
float, float,
|
|
||||||
|
|
||||||
# Seq, # TODO: can we make it an array-type instead?
|
|
||||||
# list[
|
|
||||||
# list[int, int],
|
|
||||||
# ],
|
|
||||||
# Seq,
|
|
||||||
# Frame
|
|
||||||
],
|
|
||||||
None,
|
|
||||||
]:
|
|
||||||
if not (
|
|
||||||
null_segs := get_null_segs(
|
|
||||||
frame,
|
|
||||||
period=timeframe,
|
|
||||||
)
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
absi_pairs_zsegs: list[list[float, float]]
|
|
||||||
izeros: Seq
|
|
||||||
zero_t: Frame
|
|
||||||
(
|
|
||||||
absi_pairs_zsegs,
|
|
||||||
izeros,
|
|
||||||
zero_t,
|
|
||||||
) = null_segs
|
|
||||||
|
|
||||||
absi_first: int = frame[0]['index']
|
|
||||||
for (
|
|
||||||
absi_start,
|
|
||||||
absi_end,
|
|
||||||
) in absi_pairs_zsegs:
|
|
||||||
|
|
||||||
fi_end: int = absi_end - absi_first
|
|
||||||
end_row: Seq = frame[fi_end]
|
|
||||||
end_t: float = end_row['time']
|
|
||||||
end_dt: DateTime = from_timestamp(end_t)
|
|
||||||
|
|
||||||
fi_start = None
|
|
||||||
start_row = None
|
|
||||||
start_t = None
|
|
||||||
start_dt = None
|
|
||||||
if (
|
|
||||||
absi_start is not None
|
|
||||||
and start_t != 0
|
|
||||||
):
|
|
||||||
fi_start: int = absi_start - absi_first
|
|
||||||
start_row: Seq = frame[fi_start]
|
|
||||||
start_t: float = start_row['time']
|
|
||||||
start_dt: DateTime = from_timestamp(start_t)
|
|
||||||
|
|
||||||
if absi_start < 0:
|
|
||||||
import pdbp
|
|
||||||
pdbp.set_trace()
|
|
||||||
|
|
||||||
yield (
|
|
||||||
absi_start, absi_end, # abs indices
|
|
||||||
fi_start, fi_end, # relative "frame" indices
|
|
||||||
start_t, end_t,
|
|
||||||
start_dt, end_dt,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def with_dts(
|
|
||||||
df: pl.DataFrame,
|
|
||||||
time_col: str = 'time',
|
|
||||||
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
'''
|
|
||||||
Insert datetime (casted) columns to a (presumably) OHLC sampled
|
|
||||||
time series with an epoch-time column keyed by `time_col: str`.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return df.with_columns([
|
|
||||||
pl.col(time_col).shift(1).suffix('_prev'),
|
|
||||||
pl.col(time_col).diff().alias('s_diff'),
|
|
||||||
pl.from_epoch(pl.col(time_col)).alias('dt'),
|
|
||||||
]).with_columns([
|
|
||||||
pl.from_epoch(
|
|
||||||
column=pl.col(f'{time_col}_prev'),
|
|
||||||
).alias('dt_prev'),
|
|
||||||
pl.col('dt').diff().alias('dt_diff'),
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
t_unit: Literal = Literal[
|
|
||||||
'days',
|
|
||||||
'hours',
|
|
||||||
'minutes',
|
|
||||||
'seconds',
|
|
||||||
'miliseconds',
|
|
||||||
'microseconds',
|
|
||||||
'nanoseconds',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def detect_time_gaps(
|
|
||||||
w_dts: pl.DataFrame,
|
|
||||||
|
|
||||||
time_col: str = 'time',
|
|
||||||
# epoch sampling step diff
|
|
||||||
expect_period: float = 60,
|
|
||||||
|
|
||||||
# NOTE: legacy stock mkts have venue operating hours
|
|
||||||
# and thus gaps normally no more then 1-2 days at
|
|
||||||
# a time.
|
|
||||||
gap_thresh: float = 1.,
|
|
||||||
|
|
||||||
# TODO: allow passing in a frame of operating hours?
|
|
||||||
# -[ ] durations/ranges for faster legit gap checks?
|
|
||||||
# XXX -> must be valid ``polars.Expr.dt.<name>``
|
|
||||||
# like 'days' which a sane default for venue closures
|
|
||||||
# though will detect weekend gaps which are normal :o
|
|
||||||
gap_dt_unit: t_unit | None = None,
|
|
||||||
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
'''
|
|
||||||
Filter to OHLC datums which contain sample step gaps.
|
|
||||||
|
|
||||||
For eg. legacy markets which have venue close gaps and/or
|
|
||||||
actual missing data segments.
|
|
||||||
|
|
||||||
'''
|
|
||||||
# first select by any sample-period (in seconds unit) step size
|
|
||||||
# greater then expected.
|
|
||||||
step_gaps: pl.DataFrame = w_dts.filter(
|
|
||||||
pl.col('s_diff').abs() > expect_period
|
|
||||||
)
|
|
||||||
|
|
||||||
if gap_dt_unit is None:
|
|
||||||
return step_gaps
|
|
||||||
|
|
||||||
# NOTE: this flag is to indicate that on this (sampling) time
|
|
||||||
# scale we expect to only be filtering against larger venue
|
|
||||||
# closures-scale time gaps.
|
|
||||||
return step_gaps.filter(
|
|
||||||
# Second by an arbitrary dt-unit step size
|
|
||||||
getattr(
|
|
||||||
pl.col('dt_diff').dt,
|
|
||||||
gap_dt_unit,
|
|
||||||
)().abs() > gap_thresh
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_price_gaps(
|
|
||||||
df: pl.DataFrame,
|
|
||||||
gt_multiplier: float = 2.,
|
|
||||||
price_fields: list[str] = ['high', 'low'],
|
|
||||||
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
'''
|
|
||||||
Detect gaps in clearing price over an OHLC series.
|
|
||||||
|
|
||||||
2 types of gaps generally exist; up gaps and down gaps:
|
|
||||||
|
|
||||||
- UP gap: when any next sample's lo price is strictly greater
|
|
||||||
then the current sample's hi price.
|
|
||||||
|
|
||||||
- DOWN gap: when any next sample's hi price is strictly
|
|
||||||
less then the current samples lo price.
|
|
||||||
|
|
||||||
'''
|
|
||||||
# return df.filter(
|
|
||||||
# pl.col('high') - ) > expect_period,
|
|
||||||
# ).select([
|
|
||||||
# pl.dt.datetime(pl.col(time_col).shift(1)).suffix('_previous'),
|
|
||||||
# pl.all(),
|
|
||||||
# ]).select([
|
|
||||||
# pl.all(),
|
|
||||||
# (pl.col(time_col) - pl.col(f'{time_col}_previous')).alias('diff'),
|
|
||||||
# ])
|
|
||||||
...
|
|
||||||
|
|
||||||
# TODO: probably just use the null_segs impl above?
|
|
||||||
def detect_vlm_gaps(
|
|
||||||
df: pl.DataFrame,
|
|
||||||
col: str = 'volume',
|
|
||||||
|
|
||||||
) -> pl.DataFrame:
|
|
||||||
|
|
||||||
vnull: pl.DataFrame = w_dts.filter(
|
|
||||||
pl.col(col) == 0
|
|
||||||
)
|
|
||||||
return vnull
|
|
||||||
|
|
||||||
|
|
||||||
def dedupe(
|
|
||||||
src_df: pl.DataFrame,
|
|
||||||
|
|
||||||
time_gaps: pl.DataFrame | None = None,
|
|
||||||
sort: bool = True,
|
|
||||||
period: float = 60,
|
|
||||||
|
|
||||||
) -> tuple[
|
|
||||||
pl.DataFrame, # with dts
|
|
||||||
pl.DataFrame, # with deduplicated dts (aka gap/repeat removal)
|
|
||||||
int, # len diff between input and deduped
|
|
||||||
]:
|
|
||||||
'''
|
|
||||||
Check for time series gaps and if found
|
|
||||||
de-duplicate any datetime entries, check for
|
|
||||||
a frame height diff and return the newly
|
|
||||||
dt-deduplicated frame.
|
|
||||||
|
|
||||||
'''
|
|
||||||
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
|
|
||||||
if sort:
|
|
||||||
deduped = deduped.sort(by='time')
|
|
||||||
# TODO: detect out-of-order segments which were corrected!
|
|
||||||
# -[ ] report in log msg
|
|
||||||
# -[ ] possibly return segment sections which were moved?
|
|
||||||
|
|
||||||
diff: int = (
|
|
||||||
wdts.height
|
|
||||||
-
|
|
||||||
deduped.height
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
wdts,
|
|
||||||
deduped,
|
|
||||||
diff,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def sort_diff(
|
|
||||||
src_df: pl.DataFrame,
|
|
||||||
col: str = 'time',
|
|
||||||
|
|
||||||
) -> tuple[
|
|
||||||
pl.DataFrame, # with dts
|
|
||||||
pl.DataFrame, # sorted
|
|
||||||
list[int], # indices of segments that are out-of-order
|
|
||||||
]:
|
|
||||||
ser: pl.Series = src_df[col]
|
|
||||||
sortd: pl.DataFrame = ser.sort()
|
|
||||||
diff: pl.Series = ser.diff()
|
|
||||||
|
|
||||||
sortd_diff: pl.Series = sortd.diff()
|
|
||||||
i_step_diff = (diff != sortd_diff).arg_true()
|
|
||||||
frame_reorders: int = i_step_diff.len()
|
|
||||||
if frame_reorders:
|
|
||||||
log.warn(
|
|
||||||
f'Resorted frame on col: {col}\n'
|
|
||||||
f'{frame_reorders}'
|
|
||||||
|
|
||||||
)
|
|
||||||
# import pdbp; pdbp.set_trace()
|
|
||||||
|
|
||||||
# NOTE: thanks to this SO answer for the below conversion routines
|
|
||||||
# to go from numpy struct-arrays to polars dataframes and back:
|
|
||||||
# https://stackoverflow.com/a/72054819
|
|
||||||
def np2pl(array: np.ndarray) -> pl.DataFrame:
|
|
||||||
start: float = time.time()
|
|
||||||
|
|
||||||
# XXX: thanks to this SO answer for this conversion tip:
|
|
||||||
# https://stackoverflow.com/a/72054819
|
|
||||||
df = pl.DataFrame({
|
|
||||||
field_name: array[field_name]
|
|
||||||
for field_name in array.dtype.fields
|
|
||||||
})
|
|
||||||
delay: float = round(
|
|
||||||
time.time() - start,
|
|
||||||
ndigits=6,
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
f'numpy -> polars conversion took {delay} secs\n'
|
|
||||||
f'polars df: {df}'
|
|
||||||
)
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def pl2np(
|
|
||||||
df: pl.DataFrame,
|
|
||||||
dtype: np.dtype,
|
|
||||||
|
|
||||||
) -> np.ndarray:
|
|
||||||
|
|
||||||
# Create numpy struct array of the correct size and dtype
|
|
||||||
# and loop through df columns to fill in array fields.
|
|
||||||
array = np.empty(
|
|
||||||
df.height,
|
|
||||||
dtype,
|
|
||||||
)
|
|
||||||
for field, col in zip(
|
|
||||||
dtype.fields,
|
|
||||||
df.columns,
|
|
||||||
):
|
|
||||||
array[field] = df.get_column(col).to_numpy()
|
|
||||||
|
|
||||||
return array
|
|
||||||
250
piker/types.py
250
piker/types.py
|
|
@ -1,250 +0,0 @@
|
||||||
# piker: trading gear for hackers
|
|
||||||
# Copyright (C) (in stewardship for pikers)
|
|
||||||
# - Tyler Goodlet
|
|
||||||
# - Guillermo Rodriguez
|
|
||||||
|
|
||||||
# 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/>.
|
|
||||||
|
|
||||||
'''
|
|
||||||
Extensions to built-in or (heavily used but 3rd party) friend-lib
|
|
||||||
types.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from __future__ import annotations
|
|
||||||
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
|
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
# 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/>.
|
||||||
|
|
||||||
'''
|
"""
|
||||||
UI components built using `Qt` with major versions swapped in via
|
Stuff for your eyes, aka super hawt Qt UI components.
|
||||||
the import indirection in the `.qt` sub-mod.
|
|
||||||
|
|
||||||
'''
|
Currently we only support PyQt5 due to this issue in Pyside2:
|
||||||
|
https://bugreports.qt.io/projects/PYSIDE/issues/PYSIDE-1313
|
||||||
|
"""
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,8 @@ Anchor funtions for UI placement of annotions.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Callable, TYPE_CHECKING
|
from typing import Callable, TYPE_CHECKING
|
||||||
|
|
||||||
from piker.ui.qt import (
|
from PyQt5.QtCore import QPointF
|
||||||
QPointF,
|
from PyQt5.QtWidgets import QGraphicsPathItem
|
||||||
QGraphicsPathItem,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ._chart import ChartPlotWidget
|
from ._chart import ChartPlotWidget
|
||||||
|
|
|
||||||
|
|
@ -20,22 +20,12 @@ Annotations for ur faces.
|
||||||
"""
|
"""
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from pyqtgraph import (
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
Point,
|
from PyQt5.QtCore import QPointF, QRectF
|
||||||
functions as fn,
|
from PyQt5.QtWidgets import QGraphicsPathItem
|
||||||
Color,
|
from pyqtgraph import Point, functions as fn, Color
|
||||||
)
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from piker.ui.qt import (
|
|
||||||
QtCore,
|
|
||||||
QtGui,
|
|
||||||
QtWidgets,
|
|
||||||
QPointF,
|
|
||||||
QRectF,
|
|
||||||
QGraphicsPathItem,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mk_marker_path(
|
def mk_marker_path(
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,13 @@ Main app startup and run.
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
|
||||||
|
from PyQt5.QtCore import QEvent
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from piker.ui.qt import (
|
|
||||||
QEvent,
|
|
||||||
)
|
|
||||||
from ..service import maybe_spawn_brokerd
|
from ..service import maybe_spawn_brokerd
|
||||||
from . import _event
|
from . import _event
|
||||||
from ._exec import run_qtractor
|
from ._exec import run_qtractor
|
||||||
from ..data.feed import install_brokerd_search
|
from ..data.feed import install_brokerd_search
|
||||||
from ..data._symcache import open_symcache
|
|
||||||
from ..accounting import unpack_fqme
|
from ..accounting import unpack_fqme
|
||||||
from . import _search
|
from . import _search
|
||||||
from ._chart import GodWidget
|
from ._chart import GodWidget
|
||||||
|
|
@ -59,13 +56,7 @@ async def load_provider_search(
|
||||||
portal,
|
portal,
|
||||||
brokermod,
|
brokermod,
|
||||||
),
|
),
|
||||||
open_symcache(brokermod) as symcache,
|
|
||||||
):
|
):
|
||||||
if not symcache.mktmaps:
|
|
||||||
log.warning(
|
|
||||||
f'BACKEND DOES NOT (yet) support symcaching: `{brokermod.name}`'
|
|
||||||
)
|
|
||||||
|
|
||||||
# keep search engine stream up until cancelled
|
# keep search engine stream up until cancelled
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
@ -108,8 +99,6 @@ async def _async_main(
|
||||||
sbar = godwidget.window.status_bar
|
sbar = godwidget.window.status_bar
|
||||||
starting_done = sbar.open_status('starting ze sexy chartz')
|
starting_done = sbar.open_status('starting ze sexy chartz')
|
||||||
|
|
||||||
# NOTE: by default we load all "builtin" backends for search
|
|
||||||
# and that includes loading their symcaches if possible B)
|
|
||||||
needed_brokermods: dict[str, ModuleType] = {}
|
needed_brokermods: dict[str, ModuleType] = {}
|
||||||
for fqme in syms:
|
for fqme in syms:
|
||||||
brokername, *_ = unpack_fqme(fqme)
|
brokername, *_ = unpack_fqme(fqme)
|
||||||
|
|
|
||||||
|
|
@ -23,24 +23,16 @@ from functools import lru_cache
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from math import floor
|
from math import floor
|
||||||
|
|
||||||
import polars as pl
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
|
from PyQt5.QtCore import QPointF
|
||||||
|
|
||||||
from piker.ui.qt import (
|
|
||||||
QtCore,
|
|
||||||
QtGui,
|
|
||||||
QtWidgets,
|
|
||||||
QPointF,
|
|
||||||
txt_flag,
|
|
||||||
align_flag,
|
|
||||||
px_cache_mode,
|
|
||||||
)
|
|
||||||
from . import _pg_overrides as pgo
|
from . import _pg_overrides as pgo
|
||||||
from ..accounting._mktinfo import float_digits
|
from ..accounting._mktinfo import float_digits
|
||||||
from ._label import Label
|
from ._label import Label
|
||||||
from ._style import DpiAwareFont, hcolor, _font
|
from ._style import DpiAwareFont, hcolor, _font
|
||||||
from ._interaction import ChartView
|
from ._interaction import ChartView
|
||||||
from ._dataviz import Viz
|
|
||||||
|
|
||||||
_axis_pen = pg.mkPen(hcolor('bracket'))
|
_axis_pen = pg.mkPen(hcolor('bracket'))
|
||||||
|
|
||||||
|
|
@ -295,7 +287,9 @@ class DynamicDateAxis(Axis):
|
||||||
# time formats mapped by seconds between bars
|
# time formats mapped by seconds between bars
|
||||||
tick_tpl = {
|
tick_tpl = {
|
||||||
60 * 60 * 24: '%Y-%b-%d',
|
60 * 60 * 24: '%Y-%b-%d',
|
||||||
60: '%Y-%b-%d(%H:%M)',
|
60: '%H:%M',
|
||||||
|
30: '%H:%M:%S',
|
||||||
|
5: '%H:%M:%S',
|
||||||
1: '%H:%M:%S',
|
1: '%H:%M:%S',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,10 +305,10 @@ class DynamicDateAxis(Axis):
|
||||||
# XX: ARGGGGG AG:LKSKDJF:LKJSDFD
|
# XX: ARGGGGG AG:LKSKDJF:LKJSDFD
|
||||||
chart = self.pi.chart_widget
|
chart = self.pi.chart_widget
|
||||||
|
|
||||||
viz: Viz = chart._vizs[chart.name]
|
viz = chart._vizs[chart.name]
|
||||||
shm = viz.shm
|
shm = viz.shm
|
||||||
array = shm.array
|
array = shm.array
|
||||||
ifield: str = viz.index_field
|
ifield = viz.index_field
|
||||||
index = array[ifield]
|
index = array[ifield]
|
||||||
i_0, i_l = index[0], index[-1]
|
i_0, i_l = index[0], index[-1]
|
||||||
|
|
||||||
|
|
@ -335,7 +329,7 @@ class DynamicDateAxis(Axis):
|
||||||
arr_len = index.shape[0]
|
arr_len = index.shape[0]
|
||||||
first = shm._first.value
|
first = shm._first.value
|
||||||
times = array['time']
|
times = array['time']
|
||||||
epochs: list[int] = times[
|
epochs = times[
|
||||||
list(
|
list(
|
||||||
map(
|
map(
|
||||||
int,
|
int,
|
||||||
|
|
@ -347,30 +341,23 @@ class DynamicDateAxis(Axis):
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
epochs: list[int] = list(map(int, indexes))
|
epochs = list(map(int, indexes))
|
||||||
|
|
||||||
# TODO: **don't** have this hard coded shift to EST
|
# TODO: **don't** have this hard coded shift to EST
|
||||||
delay: float = viz.time_step()
|
# delay = times[-1] - times[-2]
|
||||||
if delay > 1:
|
dts = np.array(
|
||||||
# NOTE: use less granular dt-str when using 1M+ OHLC
|
|
||||||
fmtstr: str = self.tick_tpl[delay]
|
|
||||||
else:
|
|
||||||
fmtstr: str = '%Y-%m-%d(%H:%M:%S)'
|
|
||||||
|
|
||||||
# https://pola-rs.github.io/polars/py-polars/html/reference/expressions/api/polars.from_epoch.html#polars-from-epoch
|
|
||||||
pl_dts: pl.Series = pl.from_epoch(
|
|
||||||
epochs,
|
epochs,
|
||||||
time_unit='s',
|
dtype='datetime64[s]',
|
||||||
# NOTE: kinda weird we can pass it to `.from_epoch()` no?
|
|
||||||
).dt.replace_time_zone(
|
|
||||||
time_zone='UTC'
|
|
||||||
).dt.convert_time_zone(
|
|
||||||
# TODO: pull this from either:
|
|
||||||
# -[ ] the mkt venue tz by default
|
|
||||||
# -[ ] the user's config under `sys.mkt_timezone: str`
|
|
||||||
'EST'
|
|
||||||
)
|
)
|
||||||
return pl_dts.dt.to_string(fmtstr).to_list()
|
|
||||||
|
# see units listing:
|
||||||
|
# https://numpy.org/devdocs/reference/arrays.datetime.html#datetime-units
|
||||||
|
return list(np.datetime_as_string(dts))
|
||||||
|
|
||||||
|
# TODO: per timeframe formatting?
|
||||||
|
# - we probably need this based on zoom now right?
|
||||||
|
# prec = self.np_dt_precision[delay]
|
||||||
|
# return dts.strftime(self.tick_tpl[delay])
|
||||||
|
|
||||||
def tickStrings(
|
def tickStrings(
|
||||||
self,
|
self,
|
||||||
|
|
@ -421,15 +408,11 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setParentItem(parent)
|
self.setParentItem(parent)
|
||||||
|
|
||||||
self.setFlag(
|
self.setFlag(self.ItemIgnoresTransformations)
|
||||||
self.GraphicsItemFlag.ItemIgnoresTransformations
|
|
||||||
)
|
|
||||||
self.setZValue(100)
|
self.setZValue(100)
|
||||||
|
|
||||||
# XXX: pretty sure this is faster
|
# XXX: pretty sure this is faster
|
||||||
self.setCacheMode(
|
self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
|
||||||
px_cache_mode.DeviceCoordinateCache
|
|
||||||
)
|
|
||||||
|
|
||||||
self._parent = parent
|
self._parent = parent
|
||||||
|
|
||||||
|
|
@ -566,14 +549,21 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
|
|
||||||
return (self.rect.width(), self.rect.height())
|
return (self.rect.width(), self.rect.height())
|
||||||
|
|
||||||
|
# _common_text_flags = (
|
||||||
|
# QtCore.Qt.TextDontClip |
|
||||||
|
# QtCore.Qt.AlignCenter |
|
||||||
|
# QtCore.Qt.AlignTop |
|
||||||
|
# QtCore.Qt.AlignHCenter |
|
||||||
|
# QtCore.Qt.AlignVCenter
|
||||||
|
# )
|
||||||
|
|
||||||
|
|
||||||
class XAxisLabel(AxisLabel):
|
class XAxisLabel(AxisLabel):
|
||||||
_x_margin = 8
|
_x_margin = 8
|
||||||
|
|
||||||
text_flags = (
|
text_flags = (
|
||||||
align_flag.AlignCenter
|
QtCore.Qt.TextDontClip
|
||||||
| txt_flag.TextDontClip
|
| QtCore.Qt.AlignCenter
|
||||||
)
|
)
|
||||||
|
|
||||||
def size_hint(self) -> tuple[float, float]:
|
def size_hint(self) -> tuple[float, float]:
|
||||||
|
|
@ -630,10 +620,10 @@ class YAxisLabel(AxisLabel):
|
||||||
_y_margin: int = 4
|
_y_margin: int = 4
|
||||||
|
|
||||||
text_flags = (
|
text_flags = (
|
||||||
align_flag.AlignLeft
|
QtCore.Qt.AlignLeft
|
||||||
| align_flag.AlignVCenter
|
# QtCore.Qt.AlignHCenter
|
||||||
# | align_flag.AlignHCenter
|
| QtCore.Qt.AlignVCenter
|
||||||
| txt_flag.TextDontClip
|
| QtCore.Qt.TextDontClip
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
|
||||||
|
|
@ -28,20 +28,22 @@ from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
import pyqtgraph as pg
|
from PyQt5 import QtCore, QtWidgets
|
||||||
import trio
|
from PyQt5.QtCore import (
|
||||||
|
|
||||||
from piker.ui.qt import (
|
|
||||||
QtCore,
|
|
||||||
QtWidgets,
|
|
||||||
Qt,
|
Qt,
|
||||||
QLineF,
|
QLineF,
|
||||||
|
# QPointF,
|
||||||
|
)
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QWidget,
|
QWidget,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
)
|
)
|
||||||
|
import pyqtgraph as pg
|
||||||
|
import trio
|
||||||
|
|
||||||
from ._axes import (
|
from ._axes import (
|
||||||
DynamicDateAxis,
|
DynamicDateAxis,
|
||||||
PriceAxis,
|
PriceAxis,
|
||||||
|
|
@ -280,7 +282,7 @@ class GodWidget(QWidget):
|
||||||
# TODO: probably stick this in some kinda `LooknFeel` API?
|
# TODO: probably stick this in some kinda `LooknFeel` API?
|
||||||
for tracker in self.rt_linked.mode.trackers.values():
|
for tracker in self.rt_linked.mode.trackers.values():
|
||||||
pp_nav = tracker.nav
|
pp_nav = tracker.nav
|
||||||
if tracker.live_pp.cumsize:
|
if tracker.live_pp.size:
|
||||||
pp_nav.show()
|
pp_nav.show()
|
||||||
pp_nav.hide_info()
|
pp_nav.hide_info()
|
||||||
else:
|
else:
|
||||||
|
|
@ -404,7 +406,6 @@ class ChartnPane(QFrame):
|
||||||
)
|
)
|
||||||
self._sidepane = sidepane
|
self._sidepane = sidepane
|
||||||
|
|
||||||
@property
|
|
||||||
def sidepane(self) -> FieldsForm | SearchWidget:
|
def sidepane(self) -> FieldsForm | SearchWidget:
|
||||||
return self._sidepane
|
return self._sidepane
|
||||||
|
|
||||||
|
|
@ -494,7 +495,7 @@ class LinkedSplits(QWidget):
|
||||||
Set the proportion of space allocated for linked subcharts.
|
Set the proportion of space allocated for linked subcharts.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
ln: int = len(self.subplots) or 1
|
ln = len(self.subplots) or 1
|
||||||
|
|
||||||
# proportion allocated to consumer subcharts
|
# proportion allocated to consumer subcharts
|
||||||
if not prop:
|
if not prop:
|
||||||
|
|
@ -568,8 +569,8 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
# style?
|
# style?
|
||||||
self.chart.setFrameStyle(
|
self.chart.setFrameStyle(
|
||||||
QFrame.Shape.StyledPanel |
|
QFrame.StyledPanel |
|
||||||
QFrame.Shadow.Plain
|
QFrame.Plain
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.chart
|
return self.chart
|
||||||
|
|
@ -687,8 +688,8 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
cpw.plotItem.vb.linked = self
|
cpw.plotItem.vb.linked = self
|
||||||
cpw.setFrameStyle(
|
cpw.setFrameStyle(
|
||||||
QFrame.Shape.StyledPanel
|
QtWidgets.QFrame.StyledPanel
|
||||||
# | QFrame.Shadow.Plain
|
# | QtWidgets.QFrame.Plain
|
||||||
)
|
)
|
||||||
|
|
||||||
# don't show the little "autoscale" A label.
|
# don't show the little "autoscale" A label.
|
||||||
|
|
@ -924,7 +925,6 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self.useOpenGL(use_open_gl)
|
self.useOpenGL(use_open_gl)
|
||||||
self.name = name
|
self.name = name
|
||||||
self.data_key = data_key or name
|
self.data_key = data_key or name
|
||||||
self.qframe: ChartnPane | None = None
|
|
||||||
|
|
||||||
# scene-local placeholder for book graphics
|
# scene-local placeholder for book graphics
|
||||||
# sizing to avoid overlap with data contents
|
# sizing to avoid overlap with data contents
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,9 @@ from typing import (
|
||||||
import inspect
|
import inspect
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
from PyQt5 import QtCore, QtWidgets
|
||||||
|
from PyQt5.QtCore import QPointF, QRectF
|
||||||
|
|
||||||
from piker.ui.qt import (
|
|
||||||
QPointF,
|
|
||||||
QRectF,
|
|
||||||
QtCore,
|
|
||||||
QtWidgets,
|
|
||||||
px_cache_mode,
|
|
||||||
)
|
|
||||||
from ._style import (
|
from ._style import (
|
||||||
_xaxis_at,
|
_xaxis_at,
|
||||||
hcolor,
|
hcolor,
|
||||||
|
|
@ -109,9 +104,7 @@ class LineDot(pg.CurvePoint):
|
||||||
dot.setParentItem(self)
|
dot.setParentItem(self)
|
||||||
|
|
||||||
# keep a static size
|
# keep a static size
|
||||||
self.setFlag(
|
self.setFlag(self.ItemIgnoresTransformations)
|
||||||
self.GraphicsItemFlag.ItemIgnoresTransformations
|
|
||||||
)
|
|
||||||
|
|
||||||
def event(
|
def event(
|
||||||
self,
|
self,
|
||||||
|
|
@ -214,10 +207,9 @@ class ContentsLabel(pg.LabelItem):
|
||||||
# this being "html" is the dumbest shit :eyeroll:
|
# this being "html" is the dumbest shit :eyeroll:
|
||||||
|
|
||||||
self.setText(
|
self.setText(
|
||||||
"<b>i_arr</b>:{index}<br/>"
|
"<b>i</b>:{index}<br/>"
|
||||||
# NB: these fields must be indexed in the correct order via
|
# NB: these fields must be indexed in the correct order via
|
||||||
# the slice syntax below.
|
# the slice syntax below.
|
||||||
"<b>i_shm</b>:{}<br/>"
|
|
||||||
"<b>epoch</b>:{}<br/>"
|
"<b>epoch</b>:{}<br/>"
|
||||||
"<b>O</b>:{}<br/>"
|
"<b>O</b>:{}<br/>"
|
||||||
"<b>H</b>:{}<br/>"
|
"<b>H</b>:{}<br/>"
|
||||||
|
|
@ -227,7 +219,6 @@ class ContentsLabel(pg.LabelItem):
|
||||||
# "<b>wap</b>:{}".format(
|
# "<b>wap</b>:{}".format(
|
||||||
*array[ix][
|
*array[ix][
|
||||||
[
|
[
|
||||||
'index',
|
|
||||||
'time',
|
'time',
|
||||||
'open',
|
'open',
|
||||||
'high',
|
'high',
|
||||||
|
|
@ -279,15 +270,10 @@ class ContentsLabels:
|
||||||
x_in: int,
|
x_in: int,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
for (
|
for chart, name, label, update in self._labels:
|
||||||
chart,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
update,
|
|
||||||
)in self._labels:
|
|
||||||
|
|
||||||
viz = chart.get_viz(name)
|
viz = chart.get_viz(name)
|
||||||
array: np.ndarray = viz.shm._array
|
array = viz.shm.array
|
||||||
index = array[viz.index_field]
|
index = array[viz.index_field]
|
||||||
start = index[0]
|
start = index[0]
|
||||||
stop = index[-1]
|
stop = index[-1]
|
||||||
|
|
@ -298,7 +284,7 @@ class ContentsLabels:
|
||||||
):
|
):
|
||||||
# out of range
|
# out of range
|
||||||
print('WTF out of range?')
|
print('WTF out of range?')
|
||||||
# continue
|
continue
|
||||||
|
|
||||||
# call provided update func with data point
|
# call provided update func with data point
|
||||||
try:
|
try:
|
||||||
|
|
@ -306,7 +292,6 @@ class ContentsLabels:
|
||||||
ix = np.searchsorted(index, x_in)
|
ix = np.searchsorted(index, x_in)
|
||||||
if ix > len(array):
|
if ix > len(array):
|
||||||
breakpoint()
|
breakpoint()
|
||||||
|
|
||||||
update(ix, array)
|
update(ix, array)
|
||||||
|
|
||||||
except IndexError:
|
except IndexError:
|
||||||
|
|
@ -431,10 +416,10 @@ class Cursor(pg.GraphicsObject):
|
||||||
# vertical and horizonal lines and a y-axis label
|
# vertical and horizonal lines and a y-axis label
|
||||||
|
|
||||||
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
|
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
|
||||||
vl.setCacheMode(px_cache_mode.DeviceCoordinateCache)
|
vl.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
|
||||||
|
|
||||||
hl = plot.addLine(y=0, pen=self.lines_pen, movable=False)
|
hl = plot.addLine(y=0, pen=self.lines_pen, movable=False)
|
||||||
hl.setCacheMode(px_cache_mode.DeviceCoordinateCache)
|
hl.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
|
||||||
hl.hide()
|
hl.hide()
|
||||||
|
|
||||||
yl = YAxisLabel(
|
yl = YAxisLabel(
|
||||||
|
|
@ -518,10 +503,7 @@ class Cursor(pg.GraphicsObject):
|
||||||
plot=chart
|
plot=chart
|
||||||
)
|
)
|
||||||
chart.addItem(cursor)
|
chart.addItem(cursor)
|
||||||
self.graphics[chart].setdefault(
|
self.graphics[chart].setdefault('cursors', []).append(cursor)
|
||||||
'cursors',
|
|
||||||
[],
|
|
||||||
).append(cursor)
|
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
def mouseAction(
|
def mouseAction(
|
||||||
|
|
|
||||||
|
|
@ -19,47 +19,42 @@ Fast, smooth, sexy curves.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from contextlib import contextmanager as cm
|
from contextlib import contextmanager as cm
|
||||||
from enum import EnumType
|
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
from PyQt5 import QtWidgets
|
||||||
from piker.ui.qt import (
|
from PyQt5.QtWidgets import QGraphicsItem
|
||||||
QtWidgets,
|
from PyQt5.QtCore import (
|
||||||
QGraphicsItem,
|
|
||||||
Qt,
|
Qt,
|
||||||
QLineF,
|
QLineF,
|
||||||
QRectF,
|
QRectF,
|
||||||
|
)
|
||||||
|
from PyQt5.QtGui import (
|
||||||
QPainter,
|
QPainter,
|
||||||
QPainterPath,
|
QPainterPath,
|
||||||
px_cache_mode,
|
|
||||||
)
|
)
|
||||||
|
from .._profile import pg_profile_enabled, ms_slower_then
|
||||||
from ._style import hcolor
|
from ._style import hcolor
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ..toolz.profile import (
|
from .._profile import Profiler
|
||||||
Profiler,
|
|
||||||
pg_profile_enabled,
|
|
||||||
ms_slower_then,
|
|
||||||
)
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
pen_style: EnumType = Qt.PenStyle
|
|
||||||
|
|
||||||
_line_styles: dict[str, int] = {
|
_line_styles: dict[str, int] = {
|
||||||
'solid': pen_style.SolidLine,
|
'solid': Qt.PenStyle.SolidLine,
|
||||||
'dash': pen_style.DashLine,
|
'dash': Qt.PenStyle.DashLine,
|
||||||
'dot': pen_style.DotLine,
|
'dot': Qt.PenStyle.DotLine,
|
||||||
'dashdot': pen_style.DashDotLine,
|
'dashdot': Qt.PenStyle.DashDotLine,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FlowGraphic(pg.GraphicsObject):
|
class FlowGraphic(pg.GraphicsObject):
|
||||||
'''
|
'''
|
||||||
Base class with minimal interface for `QPainterPath`
|
Base class with minimal interface for `QPainterPath` implemented,
|
||||||
implemented, real-time updated "data flow" graphics.
|
real-time updated "data flow" graphics.
|
||||||
|
|
||||||
See subtypes below.
|
See subtypes below.
|
||||||
|
|
||||||
|
|
@ -71,12 +66,12 @@ class FlowGraphic(pg.GraphicsObject):
|
||||||
# XXX-NOTE-XXX: graphics caching B)
|
# XXX-NOTE-XXX: graphics caching B)
|
||||||
# see explanation for different caching modes:
|
# see explanation for different caching modes:
|
||||||
# https://stackoverflow.com/a/39410081
|
# https://stackoverflow.com/a/39410081
|
||||||
cache_mode: int = px_cache_mode.DeviceCoordinateCache
|
cache_mode: int = QGraphicsItem.DeviceCoordinateCache
|
||||||
# XXX: WARNING item caching seems to only be useful
|
# XXX: WARNING item caching seems to only be useful
|
||||||
# if we don't re-generate the entire QPainterPath every time
|
# if we don't re-generate the entire QPainterPath every time
|
||||||
# don't ever use this - it's a colossal nightmare of artefacts
|
# don't ever use this - it's a colossal nightmare of artefacts
|
||||||
# and is disastrous for performance.
|
# and is disastrous for performance.
|
||||||
# cache_mode.ItemCoordinateCache
|
# QGraphicsItem.ItemCoordinateCache
|
||||||
# TODO: still questions todo with coord-cacheing that we should
|
# TODO: still questions todo with coord-cacheing that we should
|
||||||
# probably talk to a core dev about:
|
# probably talk to a core dev about:
|
||||||
# - if this makes trasform interactions slower (such as zooming)
|
# - if this makes trasform interactions slower (such as zooming)
|
||||||
|
|
@ -169,16 +164,15 @@ class FlowGraphic(pg.GraphicsObject):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# XXX: due to a variety of weird jitter bugs and "smearing"
|
# XXX: due to a variety of weird jitter bugs and "smearing"
|
||||||
# artifacts when click-drag panning and viewing history time
|
# artifacts when click-drag panning and viewing history time series,
|
||||||
# series, we offer this ctx-mngr interface to allow temporarily
|
# we offer this ctx-mngr interface to allow temporarily disabling
|
||||||
# disabling Qt's graphics caching mode; this is now currently
|
# Qt's graphics caching mode; this is now currently used from
|
||||||
# used from ``ChartView.start/signal_ic()`` methods which also
|
# ``ChartView.start/signal_ic()`` methods which also disable the
|
||||||
# disable the rt-display loop when the user is moving around
|
# rt-display loop when the user is moving around a view.
|
||||||
# a view.
|
|
||||||
@cm
|
@cm
|
||||||
def reset_cache(self) -> None:
|
def reset_cache(self) -> None:
|
||||||
try:
|
try:
|
||||||
none = px_cache_mode.NoCache
|
none = QGraphicsItem.NoCache
|
||||||
log.debug(
|
log.debug(
|
||||||
f'{self._name} -> CACHE DISABLE: {none}'
|
f'{self._name} -> CACHE DISABLE: {none}'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,9 @@ from msgspec import (
|
||||||
field,
|
field,
|
||||||
)
|
)
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy import (
|
|
||||||
ndarray,
|
|
||||||
)
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
from PyQt5.QtCore import QLineF
|
||||||
|
|
||||||
from piker.ui.qt import QLineF
|
|
||||||
from ..data._sharedmem import (
|
from ..data._sharedmem import (
|
||||||
ShmArray,
|
ShmArray,
|
||||||
)
|
)
|
||||||
|
|
@ -52,7 +49,7 @@ from ..data._formatters import (
|
||||||
OHLCBarsAsCurveFmtr, # OHLC converted to line
|
OHLCBarsAsCurveFmtr, # OHLC converted to line
|
||||||
StepCurveFmtr, # "step" curve (like for vlm)
|
StepCurveFmtr, # "step" curve (like for vlm)
|
||||||
)
|
)
|
||||||
from ..tsp import (
|
from ..data._timeseries import (
|
||||||
slice_from_time,
|
slice_from_time,
|
||||||
)
|
)
|
||||||
from ._ohlc import (
|
from ._ohlc import (
|
||||||
|
|
@ -65,7 +62,7 @@ from ._curve import (
|
||||||
)
|
)
|
||||||
from ._render import Renderer
|
from ._render import Renderer
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ..toolz.profile import (
|
from .._profile import (
|
||||||
Profiler,
|
Profiler,
|
||||||
pg_profile_enabled,
|
pg_profile_enabled,
|
||||||
ms_slower_then,
|
ms_slower_then,
|
||||||
|
|
@ -85,11 +82,10 @@ def render_baritems(
|
||||||
viz: Viz,
|
viz: Viz,
|
||||||
graphics: BarItems,
|
graphics: BarItems,
|
||||||
read: tuple[
|
read: tuple[
|
||||||
int, int, ndarray,
|
int, int, np.ndarray,
|
||||||
int, int, ndarray,
|
int, int, np.ndarray,
|
||||||
],
|
],
|
||||||
profiler: Profiler,
|
profiler: Profiler,
|
||||||
force_redraw: bool = False,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -220,11 +216,9 @@ def render_baritems(
|
||||||
viz._in_ds = should_line
|
viz._in_ds = should_line
|
||||||
|
|
||||||
should_redraw = (
|
should_redraw = (
|
||||||
force_redraw
|
changed_to_line
|
||||||
or changed_to_line
|
|
||||||
or not should_line
|
or not should_line
|
||||||
)
|
)
|
||||||
# print(f'should_redraw: {should_redraw}')
|
|
||||||
return (
|
return (
|
||||||
graphics,
|
graphics,
|
||||||
r,
|
r,
|
||||||
|
|
@ -256,7 +250,7 @@ class ViewState(Struct):
|
||||||
] | None = None
|
] | None = None
|
||||||
|
|
||||||
# last in view ``ShmArray.array[read_slc]`` data
|
# last in view ``ShmArray.array[read_slc]`` data
|
||||||
in_view: ndarray | None = None
|
in_view: np.ndarray | None = None
|
||||||
|
|
||||||
|
|
||||||
class Viz(Struct):
|
class Viz(Struct):
|
||||||
|
|
@ -319,7 +313,6 @@ class Viz(Struct):
|
||||||
_last_uppx: float = 0
|
_last_uppx: float = 0
|
||||||
_in_ds: bool = False
|
_in_ds: bool = False
|
||||||
_index_step: float | None = None
|
_index_step: float | None = None
|
||||||
_time_step: float | None = None
|
|
||||||
|
|
||||||
# map from uppx -> (downsampled data, incremental graphics)
|
# map from uppx -> (downsampled data, incremental graphics)
|
||||||
_src_r: Renderer | None = None
|
_src_r: Renderer | None = None
|
||||||
|
|
@ -366,8 +359,7 @@ class Viz(Struct):
|
||||||
|
|
||||||
def index_step(
|
def index_step(
|
||||||
self,
|
self,
|
||||||
index_field: str | None = None,
|
reset: bool = False,
|
||||||
|
|
||||||
) -> float:
|
) -> float:
|
||||||
'''
|
'''
|
||||||
Return the size between sample steps in the units of the
|
Return the size between sample steps in the units of the
|
||||||
|
|
@ -375,17 +367,12 @@ class Viz(Struct):
|
||||||
epoch time in seconds.
|
epoch time in seconds.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# attempt to detect the best step size by scanning a sample
|
# attempt to dectect the best step size by scanning a sample of
|
||||||
# of the source data.
|
# the source data.
|
||||||
if (
|
if self._index_step is None:
|
||||||
self._index_step is None
|
|
||||||
or index_field is not None
|
index: np.ndarray = self.shm.array[self.index_field]
|
||||||
):
|
isample: np.ndarray = index[-16:]
|
||||||
index: ndarray = self.shm.array[
|
|
||||||
index_field
|
|
||||||
or self.index_field
|
|
||||||
]
|
|
||||||
isample: ndarray = index[-16:]
|
|
||||||
|
|
||||||
mxdiff: None | float = None
|
mxdiff: None | float = None
|
||||||
for step in np.diff(isample):
|
for step in np.diff(isample):
|
||||||
|
|
@ -399,15 +386,7 @@ class Viz(Struct):
|
||||||
)
|
)
|
||||||
mxdiff = step
|
mxdiff = step
|
||||||
|
|
||||||
step: float = max(mxdiff, 1)
|
self._index_step = max(mxdiff, 1)
|
||||||
|
|
||||||
# only SET the internal index step if an explicit
|
|
||||||
# field name is NOT passed, since in such cases this
|
|
||||||
# is likely just being called from `.time_step()`.
|
|
||||||
if index_field is not None:
|
|
||||||
return step
|
|
||||||
|
|
||||||
self._index_step = step
|
|
||||||
if (
|
if (
|
||||||
mxdiff < 1
|
mxdiff < 1
|
||||||
or 1 < mxdiff < 60
|
or 1 < mxdiff < 60
|
||||||
|
|
@ -418,17 +397,6 @@ class Viz(Struct):
|
||||||
|
|
||||||
return self._index_step
|
return self._index_step
|
||||||
|
|
||||||
def time_step(self) -> float:
|
|
||||||
'''
|
|
||||||
Attempt to determine the per-sample time-step period by
|
|
||||||
forcing an epoch-index and calling `.index_step()`.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if self._time_step is None:
|
|
||||||
self._time_step: float = self.index_step(index_field='time')
|
|
||||||
|
|
||||||
return self._time_step
|
|
||||||
|
|
||||||
def maxmin(
|
def maxmin(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
|
|
@ -436,9 +404,6 @@ class Viz(Struct):
|
||||||
i_read_range: tuple[int, int] | None = None,
|
i_read_range: tuple[int, int] | None = None,
|
||||||
use_caching: bool = True,
|
use_caching: bool = True,
|
||||||
|
|
||||||
# XXX: internal debug
|
|
||||||
_do_print: bool = False
|
|
||||||
|
|
||||||
) -> tuple[float, float] | None:
|
) -> tuple[float, float] | None:
|
||||||
'''
|
'''
|
||||||
Compute the cached max and min y-range values for a given
|
Compute the cached max and min y-range values for a given
|
||||||
|
|
@ -458,14 +423,15 @@ class Viz(Struct):
|
||||||
if shm is None:
|
if shm is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
arr: ndarray = shm.array
|
do_print: bool = False
|
||||||
|
arr = shm.array
|
||||||
|
|
||||||
if i_read_range is not None:
|
if i_read_range is not None:
|
||||||
read_slc = slice(*i_read_range)
|
read_slc = slice(*i_read_range)
|
||||||
index: float | int = arr[read_slc][self.index_field]
|
index = arr[read_slc][self.index_field]
|
||||||
if not index.size:
|
if not index.size:
|
||||||
return None
|
return None
|
||||||
ixrng: tuple[int, int] = (index[0], index[-1])
|
ixrng = (index[0], index[-1])
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if x_range is None:
|
if x_range is None:
|
||||||
|
|
@ -483,24 +449,15 @@ class Viz(Struct):
|
||||||
|
|
||||||
# TODO: hash the slice instead maybe?
|
# TODO: hash the slice instead maybe?
|
||||||
# https://stackoverflow.com/a/29980872
|
# https://stackoverflow.com/a/29980872
|
||||||
ixrng = lbar, rbar = (
|
ixrng = lbar, rbar = round(x_range[0]), round(x_range[1])
|
||||||
round(x_range[0]),
|
|
||||||
round(x_range[1]),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
use_caching
|
use_caching
|
||||||
and self._mxmn_cache_enabled
|
and self._mxmn_cache_enabled
|
||||||
):
|
):
|
||||||
# TODO: is there a way to ONLY clear ranges containing
|
|
||||||
# a certain sub-range?
|
|
||||||
# -[ ] currently we have a problem where a previously
|
|
||||||
# cached mxmn will persist even if the viz is "hard
|
|
||||||
# re-rendered" (usually bc underlying data was
|
|
||||||
# corrected)
|
|
||||||
cached_result = self._mxmns.get(ixrng)
|
cached_result = self._mxmns.get(ixrng)
|
||||||
if cached_result:
|
if cached_result:
|
||||||
if _do_print:
|
if do_print:
|
||||||
print(
|
print(
|
||||||
f'{self.name} CACHED maxmin\n'
|
f'{self.name} CACHED maxmin\n'
|
||||||
f'{ixrng} -> {cached_result}'
|
f'{ixrng} -> {cached_result}'
|
||||||
|
|
@ -530,7 +487,7 @@ class Viz(Struct):
|
||||||
(rbar - ifirst) + 1
|
(rbar - ifirst) + 1
|
||||||
)
|
)
|
||||||
|
|
||||||
slice_view: ndarray = arr[read_slc]
|
slice_view = arr[read_slc]
|
||||||
|
|
||||||
if not slice_view.size:
|
if not slice_view.size:
|
||||||
log.warning(
|
log.warning(
|
||||||
|
|
@ -541,7 +498,7 @@ class Viz(Struct):
|
||||||
|
|
||||||
elif self.ds_yrange:
|
elif self.ds_yrange:
|
||||||
mxmn = self.ds_yrange
|
mxmn = self.ds_yrange
|
||||||
if _do_print:
|
if do_print:
|
||||||
print(
|
print(
|
||||||
f'{self.name} M4 maxmin:\n'
|
f'{self.name} M4 maxmin:\n'
|
||||||
f'{ixrng} -> {mxmn}'
|
f'{ixrng} -> {mxmn}'
|
||||||
|
|
@ -558,7 +515,7 @@ class Viz(Struct):
|
||||||
|
|
||||||
mxmn = ylow, yhigh
|
mxmn = ylow, yhigh
|
||||||
if (
|
if (
|
||||||
_do_print
|
do_print
|
||||||
):
|
):
|
||||||
s = 3
|
s = 3
|
||||||
print(
|
print(
|
||||||
|
|
@ -572,23 +529,14 @@ class Viz(Struct):
|
||||||
|
|
||||||
# cache result for input range
|
# cache result for input range
|
||||||
ylow, yhi = mxmn
|
ylow, yhi = mxmn
|
||||||
diff: float = yhi - ylow
|
|
||||||
|
|
||||||
# order-of-magnitude check
|
|
||||||
# TODO: really we should be checking the hi or low
|
|
||||||
# against the previous sample to catch stuff like,
|
|
||||||
# - rando stock (reverse-)split
|
|
||||||
# - null-segments written by some prior
|
|
||||||
# crash-during-backfil
|
|
||||||
if diff > 0:
|
|
||||||
omg: float = abs(logf(diff, 10))
|
|
||||||
else:
|
|
||||||
omg: float = 0
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
prolly_anomaly: bool = (
|
prolly_anomaly: bool = (
|
||||||
# diff == 0
|
(
|
||||||
(ylow and omg > 10)
|
abs(logf(ylow, 10)) > 16
|
||||||
|
if ylow
|
||||||
|
else False
|
||||||
|
)
|
||||||
or (
|
or (
|
||||||
isnan(ylow) or isnan(yhi)
|
isnan(ylow) or isnan(yhi)
|
||||||
)
|
)
|
||||||
|
|
@ -615,8 +563,7 @@ class Viz(Struct):
|
||||||
|
|
||||||
def view_range(self) -> tuple[int, int]:
|
def view_range(self) -> tuple[int, int]:
|
||||||
'''
|
'''
|
||||||
Return the start and stop x-indexes for the managed
|
Return the start and stop x-indexes for the managed ``ViewBox``.
|
||||||
``ViewBox``.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
vr = self.plot.viewRect()
|
vr = self.plot.viewRect()
|
||||||
|
|
@ -629,7 +576,7 @@ class Viz(Struct):
|
||||||
self,
|
self,
|
||||||
view_range: None | tuple[float, float] = None,
|
view_range: None | tuple[float, float] = None,
|
||||||
index_field: str | None = None,
|
index_field: str | None = None,
|
||||||
array: ndarray | None = None,
|
array: np.ndarray | None = None,
|
||||||
|
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
int, int, int, int, int, int
|
int, int, int, int, int, int
|
||||||
|
|
@ -700,8 +647,8 @@ class Viz(Struct):
|
||||||
profiler: None | Profiler = None,
|
profiler: None | Profiler = None,
|
||||||
|
|
||||||
) -> tuple[
|
) -> tuple[
|
||||||
int, int, ndarray,
|
int, int, np.ndarray,
|
||||||
int, int, ndarray,
|
int, int, np.ndarray,
|
||||||
]:
|
]:
|
||||||
'''
|
'''
|
||||||
Read the underlying shm array buffer and
|
Read the underlying shm array buffer and
|
||||||
|
|
@ -871,10 +818,6 @@ class Viz(Struct):
|
||||||
graphics,
|
graphics,
|
||||||
read,
|
read,
|
||||||
profiler,
|
profiler,
|
||||||
|
|
||||||
# NOTE: only set when caller says to
|
|
||||||
force_redraw=should_redraw,
|
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1037,39 +980,6 @@ class Viz(Struct):
|
||||||
graphics,
|
graphics,
|
||||||
)
|
)
|
||||||
|
|
||||||
def reset_graphics(
|
|
||||||
self,
|
|
||||||
|
|
||||||
# TODO: allow only resetting within some x-domain range?
|
|
||||||
# ixrng: tuple[int, int] | None = None,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Hard reset all graphics (rendering) layers for this
|
|
||||||
data viz including clearing the mxmn auto-y-range
|
|
||||||
cache.
|
|
||||||
|
|
||||||
Normally called when the underlying data set is modified
|
|
||||||
(probably by some `.tsp` correcting/editing routine) and
|
|
||||||
the (now cached) graphics need to be fully re-rendered from
|
|
||||||
source.
|
|
||||||
|
|
||||||
'''
|
|
||||||
log.warning(
|
|
||||||
f'Forcing hard Viz graphihcs RESET:\n'
|
|
||||||
f'.name: {self.name}\n'
|
|
||||||
f'.index_field: {self.index_field}\n'
|
|
||||||
f'.index_step(): {self.index_step()}\n'
|
|
||||||
f'.time_step(): {self.time_step()}\n'
|
|
||||||
)
|
|
||||||
# XXX: always clear the mxn y-range cache
|
|
||||||
# to avoid old data (anomalies) from being
|
|
||||||
# retained in auto-yrange output.
|
|
||||||
self._mxmn_cache_enabled = False
|
|
||||||
self._mxmns.clear()
|
|
||||||
self.update_graphics(force_redraw=True)
|
|
||||||
self._mxmn_cache_enabled = True
|
|
||||||
|
|
||||||
def draw_last(
|
def draw_last(
|
||||||
self,
|
self,
|
||||||
array_key: str | None = None,
|
array_key: str | None = None,
|
||||||
|
|
@ -1162,7 +1072,7 @@ class Viz(Struct):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
shm: ShmArray = self.shm
|
shm: ShmArray = self.shm
|
||||||
array: ndarray = shm.array
|
array: np.ndarray = shm.array
|
||||||
view: ChartView = self.plot.vb
|
view: ChartView = self.plot.vb
|
||||||
(
|
(
|
||||||
vl,
|
vl,
|
||||||
|
|
|
||||||
|
|
@ -36,28 +36,25 @@ import pyqtgraph as pg
|
||||||
from msgspec import field
|
from msgspec import field
|
||||||
|
|
||||||
# from .. import brokers
|
# from .. import brokers
|
||||||
from piker.accounting import (
|
from ..accounting import (
|
||||||
MktPair,
|
MktPair,
|
||||||
)
|
)
|
||||||
from piker.types import Struct
|
from ..data import (
|
||||||
from piker.data import (
|
|
||||||
open_feed,
|
open_feed,
|
||||||
Feed,
|
Feed,
|
||||||
Flume,
|
Flume,
|
||||||
open_sample_stream,
|
|
||||||
ShmArray,
|
|
||||||
)
|
)
|
||||||
from piker.data.ticktools import (
|
from ..data.ticktools import (
|
||||||
_tick_groups,
|
_tick_groups,
|
||||||
_auction_ticks,
|
_auction_ticks,
|
||||||
)
|
)
|
||||||
from piker.toolz import (
|
from ..data.types import Struct
|
||||||
pg_profile_enabled,
|
from ..data._sharedmem import (
|
||||||
ms_slower_then,
|
ShmArray,
|
||||||
Profiler,
|
)
|
||||||
|
from ..data._sampling import (
|
||||||
|
open_sample_stream,
|
||||||
)
|
)
|
||||||
from piker.log import get_logger
|
|
||||||
from piker import config
|
|
||||||
# from ..data._source import tf_in_1s
|
# from ..data._source import tf_in_1s
|
||||||
from ._axes import YAxisLabel
|
from ._axes import YAxisLabel
|
||||||
from ._chart import (
|
from ._chart import (
|
||||||
|
|
@ -82,6 +79,12 @@ from .order_mode import (
|
||||||
open_order_mode,
|
open_order_mode,
|
||||||
OrderMode,
|
OrderMode,
|
||||||
)
|
)
|
||||||
|
from .._profile import (
|
||||||
|
pg_profile_enabled,
|
||||||
|
ms_slower_then,
|
||||||
|
)
|
||||||
|
from ..log import get_logger
|
||||||
|
from .._profile import Profiler
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ._interaction import ChartView
|
from ._interaction import ChartView
|
||||||
|
|
@ -211,9 +214,9 @@ async def increment_history_view(
|
||||||
):
|
):
|
||||||
hist_chart: ChartPlotWidget = ds.hist_chart
|
hist_chart: ChartPlotWidget = ds.hist_chart
|
||||||
hist_viz: Viz = ds.hist_viz
|
hist_viz: Viz = ds.hist_viz
|
||||||
# viz: Viz = ds.viz
|
viz: Viz = ds.viz
|
||||||
assert 'hist' in hist_viz.shm.token['shm_name']
|
assert 'hist' in hist_viz.shm.token['shm_name']
|
||||||
# name: str = hist_viz.name
|
name: str = hist_viz.name
|
||||||
|
|
||||||
# TODO: seems this is more reliable at keeping the slow
|
# TODO: seems this is more reliable at keeping the slow
|
||||||
# chart incremented in view more correctly?
|
# chart incremented in view more correctly?
|
||||||
|
|
@ -226,8 +229,7 @@ async def increment_history_view(
|
||||||
# draw everything from scratch on first entry!
|
# draw everything from scratch on first entry!
|
||||||
for curve_name, hist_viz in hist_chart._vizs.items():
|
for curve_name, hist_viz in hist_chart._vizs.items():
|
||||||
log.info(f'Forcing hard redraw -> {curve_name}')
|
log.info(f'Forcing hard redraw -> {curve_name}')
|
||||||
hist_viz.reset_graphics()
|
hist_viz.update_graphics(force_redraw=True)
|
||||||
# hist_viz.update_graphics(force_redraw=True)
|
|
||||||
|
|
||||||
async with open_sample_stream(1.) as min_istream:
|
async with open_sample_stream(1.) as min_istream:
|
||||||
async for msg in min_istream:
|
async for msg in min_istream:
|
||||||
|
|
@ -250,27 +252,17 @@ async def increment_history_view(
|
||||||
# - samplerd could emit the actual update range via
|
# - samplerd could emit the actual update range via
|
||||||
# tuple and then we only enter the below block if that
|
# tuple and then we only enter the below block if that
|
||||||
# range is detected as in-view?
|
# range is detected as in-view?
|
||||||
# match msg:
|
if (
|
||||||
# case {
|
(bf_wut := msg.get('backfilling', False))
|
||||||
# 'backfilling': (viz_name, timeframe),
|
):
|
||||||
# } if (
|
viz_name, timeframe = bf_wut
|
||||||
# viz_name == name
|
if viz_name == name:
|
||||||
# ):
|
log.info(f'Forcing hard redraw -> {name}@{timeframe}')
|
||||||
# log.warning(
|
match timeframe:
|
||||||
# f'Forcing HARD REDRAW:\n'
|
case 60:
|
||||||
# f'name: {name}\n'
|
hist_viz.update_graphics(force_redraw=True)
|
||||||
# f'timeframe: {timeframe}\n'
|
case 1:
|
||||||
# )
|
viz.update_graphics(force_redraw=True)
|
||||||
# # TODO: only allow this when the data is IN VIEW!
|
|
||||||
# # also, we probably can do this more efficiently
|
|
||||||
# # / smarter by only redrawing the portion of the
|
|
||||||
# # path necessary?
|
|
||||||
# {
|
|
||||||
# 60: hist_viz,
|
|
||||||
# 1: viz,
|
|
||||||
# }[timeframe].update_graphics(
|
|
||||||
# force_redraw=True
|
|
||||||
# )
|
|
||||||
|
|
||||||
# check if slow chart needs an x-domain shift and/or
|
# check if slow chart needs an x-domain shift and/or
|
||||||
# y-range resize.
|
# y-range resize.
|
||||||
|
|
@ -311,7 +303,6 @@ async def increment_history_view(
|
||||||
|
|
||||||
async def graphics_update_loop(
|
async def graphics_update_loop(
|
||||||
|
|
||||||
dss: dict[str, DisplayState],
|
|
||||||
nurse: trio.Nursery,
|
nurse: trio.Nursery,
|
||||||
godwidget: GodWidget,
|
godwidget: GodWidget,
|
||||||
feed: Feed,
|
feed: Feed,
|
||||||
|
|
@ -353,6 +344,8 @@ async def graphics_update_loop(
|
||||||
'i_last_slow_t': 0, # multiview-global slow (1m) step index
|
'i_last_slow_t': 0, # multiview-global slow (1m) step index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dss: dict[str, DisplayState] = {}
|
||||||
|
|
||||||
for fqme, flume in feed.flumes.items():
|
for fqme, flume in feed.flumes.items():
|
||||||
ohlcv = flume.rt_shm
|
ohlcv = flume.rt_shm
|
||||||
hist_ohlcv = flume.hist_shm
|
hist_ohlcv = flume.hist_shm
|
||||||
|
|
@ -469,20 +462,12 @@ async def graphics_update_loop(
|
||||||
await trio.sleep(0)
|
await trio.sleep(0)
|
||||||
|
|
||||||
if ds.hist_vars['i_last'] < ds.hist_vars['i_last_append']:
|
if ds.hist_vars['i_last'] < ds.hist_vars['i_last_append']:
|
||||||
await tractor.pause()
|
await tractor.breakpoint()
|
||||||
|
|
||||||
# try:
|
|
||||||
|
|
||||||
# XXX TODO: we need to do _dss UPDATE here so that when
|
|
||||||
# a feed-view is switched you can still remote annotate the
|
|
||||||
# prior view..
|
|
||||||
from . import _remote_ctl
|
|
||||||
_remote_ctl._dss.update(dss)
|
|
||||||
|
|
||||||
# main real-time quotes update loop
|
# main real-time quotes update loop
|
||||||
stream: tractor.MsgStream
|
stream: tractor.MsgStream
|
||||||
async with feed.open_multi_stream() as stream:
|
async with feed.open_multi_stream() as stream:
|
||||||
# assert stream
|
assert stream
|
||||||
async for quotes in stream:
|
async for quotes in stream:
|
||||||
quote_period = time.time() - last_quote_s
|
quote_period = time.time() - last_quote_s
|
||||||
quote_rate = round(
|
quote_rate = round(
|
||||||
|
|
@ -498,7 +483,7 @@ async def graphics_update_loop(
|
||||||
pass
|
pass
|
||||||
# log.warning(f'High quote rate {mkt.fqme}: {quote_rate}')
|
# log.warning(f'High quote rate {mkt.fqme}: {quote_rate}')
|
||||||
|
|
||||||
last_quote_s: float = time.time()
|
last_quote_s = time.time()
|
||||||
|
|
||||||
for fqme, quote in quotes.items():
|
for fqme, quote in quotes.items():
|
||||||
ds = dss[fqme]
|
ds = dss[fqme]
|
||||||
|
|
@ -528,12 +513,6 @@ async def graphics_update_loop(
|
||||||
quote,
|
quote,
|
||||||
)
|
)
|
||||||
|
|
||||||
# finally:
|
|
||||||
# # XXX: cancel any remote annotation control ctxs
|
|
||||||
# _remote_ctl._dss = None
|
|
||||||
# for cid, (ctx, aids) in _remote_ctl._ctxs.items():
|
|
||||||
# await ctx.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
def graphics_update_cycle(
|
def graphics_update_cycle(
|
||||||
ds: DisplayState,
|
ds: DisplayState,
|
||||||
|
|
@ -1232,8 +1211,6 @@ async def link_views_with_region(
|
||||||
# region.sigRegionChangeFinished.connect(update_pi_from_region)
|
# region.sigRegionChangeFinished.connect(update_pi_from_region)
|
||||||
|
|
||||||
|
|
||||||
# NOTE: default is set to 60 FPS until the runtime delivers the
|
|
||||||
# discoverd hw value below.
|
|
||||||
_quote_throttle_rate: int = 60 - 6
|
_quote_throttle_rate: int = 60 - 6
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1252,7 +1229,7 @@ async def display_symbol_data(
|
||||||
fast from a cached watch-list.
|
fast from a cached watch-list.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# sbar = godwidget.window.status_bar
|
sbar = godwidget.window.status_bar
|
||||||
# historical data fetch
|
# historical data fetch
|
||||||
# brokermod = brokers.get_brokermod(provider)
|
# brokermod = brokers.get_brokermod(provider)
|
||||||
|
|
||||||
|
|
@ -1262,11 +1239,11 @@ async def display_symbol_data(
|
||||||
# group_key=loading_sym_key,
|
# group_key=loading_sym_key,
|
||||||
# )
|
# )
|
||||||
|
|
||||||
# for fqme in fqmes:
|
for fqme in fqmes:
|
||||||
# loading_sym_key = sbar.open_status(
|
loading_sym_key = sbar.open_status(
|
||||||
# f'loading {fqme} ->',
|
f'loading {fqme} ->',
|
||||||
# group_key=True
|
group_key=True
|
||||||
# )
|
)
|
||||||
|
|
||||||
# (TODO: make this not so shit XD)
|
# (TODO: make this not so shit XD)
|
||||||
# close group status once a symbol feed fully loads to view.
|
# close group status once a symbol feed fully loads to view.
|
||||||
|
|
@ -1275,54 +1252,26 @@ async def display_symbol_data(
|
||||||
# TODO: ctl over update loop's maximum frequency.
|
# TODO: ctl over update loop's maximum frequency.
|
||||||
# - load this from a config.toml!
|
# - load this from a config.toml!
|
||||||
# - allow dyanmic configuration from chart UI?
|
# - allow dyanmic configuration from chart UI?
|
||||||
(
|
|
||||||
conf,
|
|
||||||
path,
|
|
||||||
) = config.load()
|
|
||||||
ui_conf: dict = conf['ui']
|
|
||||||
|
|
||||||
global _quote_throttle_rate
|
global _quote_throttle_rate
|
||||||
from ._window import main_window
|
from ._window import main_window
|
||||||
|
display_rate = main_window().current_screen().refreshRate()
|
||||||
display_rate: int = floor(
|
_quote_throttle_rate = floor(display_rate) - 6
|
||||||
main_window().current_screen().refreshRate()
|
|
||||||
) - 6
|
|
||||||
|
|
||||||
mx_redraw_rate: int = ui_conf.get(
|
|
||||||
'max_redraw_rate',
|
|
||||||
_quote_throttle_rate,
|
|
||||||
)
|
|
||||||
|
|
||||||
if mx_redraw_rate < display_rate:
|
|
||||||
log.info(
|
|
||||||
'Down-throttling redraw rate to config setting\n'
|
|
||||||
f'display FPS: {display_rate}\n'
|
|
||||||
'max_redraw_rate: {max_redraw_rate}\n'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_quote_throttle_rate = display_rate
|
|
||||||
|
|
||||||
# TODO: we should be able to increase this if we use some
|
# TODO: we should be able to increase this if we use some
|
||||||
# `mypyc` speedups elsewhere? 22ish seems to be the sweet
|
# `mypyc` speedups elsewhere? 22ish seems to be the sweet
|
||||||
# spot for single-feed chart.
|
# spot for single-feed chart.
|
||||||
num_of_feeds = len(fqmes)
|
num_of_feeds = len(fqmes)
|
||||||
# if num_of_feeds > 1:
|
mx: int = 22
|
||||||
|
if num_of_feeds > 1:
|
||||||
# there will be more ctx switches with more than 1 feed so we
|
# there will be more ctx switches with more than 1 feed so we
|
||||||
# max throttle down a bit more.
|
# max throttle down a bit more.
|
||||||
mx_per_feed: int = (
|
mx = 16
|
||||||
ui_conf.get(
|
|
||||||
'per_feed_redraw_rate',
|
|
||||||
mx_redraw_rate,
|
|
||||||
)
|
|
||||||
or 16
|
|
||||||
)
|
|
||||||
|
|
||||||
# limit to at least display's FPS
|
# limit to at least display's FPS
|
||||||
# avoiding needless Qt-in-guest-mode context switches
|
# avoiding needless Qt-in-guest-mode context switches
|
||||||
cycles_per_feed = min(
|
cycles_per_feed = min(
|
||||||
round(_quote_throttle_rate/num_of_feeds),
|
round(_quote_throttle_rate/num_of_feeds),
|
||||||
mx_per_feed,
|
mx,
|
||||||
)
|
)
|
||||||
|
|
||||||
feed: Feed
|
feed: Feed
|
||||||
|
|
@ -1467,7 +1416,7 @@ async def display_symbol_data(
|
||||||
start_fsp_displays,
|
start_fsp_displays,
|
||||||
rt_linked,
|
rt_linked,
|
||||||
flume,
|
flume,
|
||||||
# loading_sym_key,
|
loading_sym_key,
|
||||||
loglevel,
|
loglevel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1586,10 +1535,8 @@ async def display_symbol_data(
|
||||||
)
|
)
|
||||||
|
|
||||||
# start update loop task
|
# start update loop task
|
||||||
dss: dict[str, DisplayState] = {}
|
|
||||||
ln.start_soon(
|
ln.start_soon(
|
||||||
graphics_update_loop,
|
graphics_update_loop,
|
||||||
dss,
|
|
||||||
ln,
|
ln,
|
||||||
godwidget,
|
godwidget,
|
||||||
feed,
|
feed,
|
||||||
|
|
@ -1603,31 +1550,15 @@ async def display_symbol_data(
|
||||||
order_ctl_fqme: str = fqmes[0]
|
order_ctl_fqme: str = fqmes[0]
|
||||||
mode: OrderMode
|
mode: OrderMode
|
||||||
async with (
|
async with (
|
||||||
|
|
||||||
open_order_mode(
|
open_order_mode(
|
||||||
feed,
|
feed,
|
||||||
godwidget,
|
godwidget,
|
||||||
order_ctl_fqme,
|
order_ctl_fqme,
|
||||||
order_mode_started,
|
order_mode_started,
|
||||||
loglevel=loglevel
|
loglevel=loglevel
|
||||||
) as mode,
|
) as mode
|
||||||
|
|
||||||
# TODO: maybe have these startup sooner before
|
|
||||||
# order mode fully boots? but we gotta,
|
|
||||||
# -[ ] decouple the order mode bindings until
|
|
||||||
# the mode has fully booted..
|
|
||||||
# -[ ] maybe do an Event to sync?
|
|
||||||
|
|
||||||
# start input handling for ``ChartView`` input
|
|
||||||
# (i.e. kb + mouse handling loops)
|
|
||||||
rt_chart.view.open_async_input_handler(
|
|
||||||
dss=dss,
|
|
||||||
),
|
|
||||||
hist_chart.view.open_async_input_handler(
|
|
||||||
dss=dss,
|
|
||||||
),
|
|
||||||
|
|
||||||
):
|
):
|
||||||
|
|
||||||
rt_linked.mode = mode
|
rt_linked.mode = mode
|
||||||
|
|
||||||
rt_viz = rt_chart.get_viz(order_ctl_fqme)
|
rt_viz = rt_chart.get_viz(order_ctl_fqme)
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ 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 (
|
||||||
Sequence,
|
TYPE_CHECKING
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
|
@ -32,34 +31,24 @@ from pyqtgraph import (
|
||||||
QtCore,
|
QtCore,
|
||||||
QtWidgets,
|
QtWidgets,
|
||||||
)
|
)
|
||||||
|
from PyQt5.QtGui import (
|
||||||
|
QColor,
|
||||||
|
)
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QLabel,
|
||||||
|
)
|
||||||
|
|
||||||
from pyqtgraph import functions as fn
|
from pyqtgraph import functions as fn
|
||||||
|
from PyQt5.QtCore import QPointF
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from piker.types import Struct
|
from ._style import hcolor, _font
|
||||||
from piker.ui.qt import (
|
|
||||||
Qt,
|
|
||||||
QPointF,
|
|
||||||
QRectF,
|
|
||||||
QGraphicsProxyWidget,
|
|
||||||
QGraphicsScene,
|
|
||||||
QLabel,
|
|
||||||
QColor,
|
|
||||||
QTransform,
|
|
||||||
)
|
|
||||||
from ._style import (
|
|
||||||
hcolor,
|
|
||||||
_font,
|
|
||||||
)
|
|
||||||
from ._lines import LevelLine
|
from ._lines import LevelLine
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
|
from ..data.types import Struct
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ._chart import (
|
from ._chart import GodWidget
|
||||||
GodWidget,
|
|
||||||
ChartPlotWidget,
|
|
||||||
)
|
|
||||||
from ._interaction import ChartView
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
@ -76,7 +65,7 @@ class ArrowEditor(Struct):
|
||||||
uid: str,
|
uid: str,
|
||||||
x: float,
|
x: float,
|
||||||
y: float,
|
y: float,
|
||||||
color: str = 'default',
|
color='default',
|
||||||
pointing: str | None = None,
|
pointing: str | None = None,
|
||||||
|
|
||||||
) -> pg.ArrowItem:
|
) -> pg.ArrowItem:
|
||||||
|
|
@ -262,75 +251,43 @@ class LineEditor(Struct):
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def as_point(
|
|
||||||
pair: Sequence[float, float] | QPointF,
|
|
||||||
) -> list[QPointF, QPointF]:
|
|
||||||
'''
|
|
||||||
Case any input tuple of floats to a a list of `QPoint` objects
|
|
||||||
for use in Qt geometry routines.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if isinstance(pair, QPointF):
|
|
||||||
return pair
|
|
||||||
|
|
||||||
return QPointF(pair[0], pair[1])
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: maybe implement better, something something RectItemProxy??
|
|
||||||
# -[ ] dig into details of how proxy's work?
|
|
||||||
# https://doc.qt.io/qt-5/qgraphicsscene.html#addWidget
|
|
||||||
# -[ ] consider using `.addRect()` maybe?
|
|
||||||
|
|
||||||
class SelectRect(QtWidgets.QGraphicsRectItem):
|
class SelectRect(QtWidgets.QGraphicsRectItem):
|
||||||
'''
|
|
||||||
A data-view "selection rectangle": the most fundamental
|
|
||||||
geometry for annotating data views.
|
|
||||||
|
|
||||||
- https://doc.qt.io/qt-5/qgraphicsrectitem.html
|
|
||||||
- https://doc.qt.io/qt-6/qgraphicsrectitem.html
|
|
||||||
|
|
||||||
'''
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
viewbox: ViewBox,
|
viewbox: ViewBox,
|
||||||
color: str | None = None,
|
color: str = 'dad_blue',
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(0, 0, 1, 1)
|
super().__init__(0, 0, 1, 1)
|
||||||
|
|
||||||
# self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1)
|
# self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1)
|
||||||
self.vb: ViewBox = viewbox
|
self.vb = viewbox
|
||||||
|
self._chart: 'ChartPlotWidget' = None # noqa
|
||||||
|
|
||||||
self._chart: ChartPlotWidget | None = None # noqa
|
# override selection box color
|
||||||
|
|
||||||
# TODO: maybe allow this to be dynamic via a method?
|
|
||||||
#l override selection box color
|
|
||||||
color: str = color or 'dad_blue'
|
|
||||||
color = QColor(hcolor(color))
|
color = QColor(hcolor(color))
|
||||||
|
|
||||||
self.setPen(fn.mkPen(color, width=1))
|
self.setPen(fn.mkPen(color, width=1))
|
||||||
color.setAlpha(66)
|
color.setAlpha(66)
|
||||||
self.setBrush(fn.mkBrush(color))
|
self.setBrush(fn.mkBrush(color))
|
||||||
self.setZValue(1e9)
|
self.setZValue(1e9)
|
||||||
|
self.hide()
|
||||||
|
self._label = None
|
||||||
|
|
||||||
label = self._label = QLabel()
|
label = self._label = QLabel()
|
||||||
label.setTextFormat(
|
label.setTextFormat(0) # markdown
|
||||||
Qt.TextFormat.MarkdownText
|
|
||||||
)
|
|
||||||
label.setFont(_font.font)
|
label.setFont(_font.font)
|
||||||
label.setMargin(0)
|
label.setMargin(0)
|
||||||
label.setAlignment(
|
label.setAlignment(
|
||||||
QtCore.Qt.AlignLeft
|
QtCore.Qt.AlignLeft
|
||||||
# | QtCore.Qt.AlignVCenter
|
# | QtCore.Qt.AlignVCenter
|
||||||
)
|
)
|
||||||
label.hide() # always right after init
|
|
||||||
|
|
||||||
# proxy is created after containing scene is initialized
|
# proxy is created after containing scene is initialized
|
||||||
self._label_proxy: QGraphicsProxyWidget | None = None
|
self._label_proxy = None
|
||||||
self._abs_top_right: Point | None = None
|
self._abs_top_right = None
|
||||||
|
|
||||||
# TODO: "swing %" might be handy here (data's max/min
|
# TODO: "swing %" might be handy here (data's max/min # % change)
|
||||||
# # % change)?
|
self._contents = [
|
||||||
self._contents: list[str] = [
|
|
||||||
'change: {pchng:.2f} %',
|
'change: {pchng:.2f} %',
|
||||||
'range: {rng:.2f}',
|
'range: {rng:.2f}',
|
||||||
'bars: {nbars}',
|
'bars: {nbars}',
|
||||||
|
|
@ -340,31 +297,12 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
|
||||||
'sigma: {std:.2f}',
|
'sigma: {std:.2f}',
|
||||||
]
|
]
|
||||||
|
|
||||||
self.add_to_view(viewbox)
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
def add_to_view(
|
|
||||||
self,
|
|
||||||
view: ChartView,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Self-defined view hookup impl which will
|
|
||||||
also re-assign the internal ref.
|
|
||||||
|
|
||||||
'''
|
|
||||||
view.addItem(
|
|
||||||
self,
|
|
||||||
ignoreBounds=True,
|
|
||||||
)
|
|
||||||
if self.vb is not view:
|
|
||||||
self.vb = view
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def chart(self) -> ChartPlotWidget: # noqa
|
def chart(self) -> 'ChartPlotWidget': # noqa
|
||||||
return self._chart
|
return self._chart
|
||||||
|
|
||||||
@chart.setter
|
@chart.setter
|
||||||
def chart(self, chart: ChartPlotWidget) -> None: # noqa
|
def chart(self, chart: 'ChartPlotWidget') -> None: # noqa
|
||||||
self._chart = chart
|
self._chart = chart
|
||||||
chart.sigRangeChanged.connect(self.update_on_resize)
|
chart.sigRangeChanged.connect(self.update_on_resize)
|
||||||
palette = self._label.palette()
|
palette = self._label.palette()
|
||||||
|
|
@ -377,155 +315,57 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_on_resize(self, vr, r):
|
def update_on_resize(self, vr, r):
|
||||||
'''
|
"""Re-position measure label on view range change.
|
||||||
Re-position measure label on view range change.
|
|
||||||
|
|
||||||
'''
|
"""
|
||||||
if self._abs_top_right:
|
if self._abs_top_right:
|
||||||
self._label_proxy.setPos(
|
self._label_proxy.setPos(
|
||||||
self.vb.mapFromView(self._abs_top_right)
|
self.vb.mapFromView(self._abs_top_right)
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_scen_pos(
|
def mouse_drag_released(
|
||||||
self,
|
self,
|
||||||
scen_p1: QPointF,
|
p1: QPointF,
|
||||||
scen_p2: QPointF,
|
p2: QPointF
|
||||||
|
|
||||||
update_label: bool = True,
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
"""Called on final button release for mouse drag with start and
|
||||||
Set position from scene coords of selection rect (normally
|
end positions.
|
||||||
from mouse position) and accompanying label, move label to
|
|
||||||
match.
|
|
||||||
|
|
||||||
'''
|
"""
|
||||||
# NOTE XXX: apparently just setting it doesn't work!?
|
self.set_pos(p1, p2)
|
||||||
# i have no idea why but it's pretty weird we have to do
|
|
||||||
# this transform thing which was basically pulled verbatim
|
|
||||||
# from the `pg.ViewBox.updateScaleBox()` method.
|
|
||||||
view_rect: QRectF = self.vb.childGroup.mapRectFromScene(
|
|
||||||
QRectF(
|
|
||||||
scen_p1,
|
|
||||||
scen_p2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.setPos(view_rect.topLeft())
|
|
||||||
# XXX: does not work..!?!?
|
|
||||||
# https://doc.qt.io/qt-5/qgraphicsrectitem.html#setRect
|
|
||||||
# self.setRect(view_rect)
|
|
||||||
|
|
||||||
tr = QTransform.fromScale(
|
def set_pos(
|
||||||
view_rect.width(),
|
|
||||||
view_rect.height(),
|
|
||||||
)
|
|
||||||
self.setTransform(tr)
|
|
||||||
|
|
||||||
# XXX: never got this working, was always offset
|
|
||||||
# / transformed completely wrong (and off to the far right
|
|
||||||
# from the cursor?)
|
|
||||||
# self.set_view_pos(
|
|
||||||
# view_rect=view_rect,
|
|
||||||
# # self.vwqpToView(p1),
|
|
||||||
# # self.vb.mapToView(p2),
|
|
||||||
# # start_pos=self.vb.mapToScene(p1),
|
|
||||||
# # end_pos=self.vb.mapToScene(p2),
|
|
||||||
# )
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
if update_label:
|
|
||||||
self.init_label(view_rect)
|
|
||||||
|
|
||||||
def set_view_pos(
|
|
||||||
self,
|
self,
|
||||||
|
p1: QPointF,
|
||||||
start_pos: QPointF | Sequence[float, float] | None = None,
|
p2: QPointF
|
||||||
end_pos: QPointF | Sequence[float, float] | None = None,
|
|
||||||
view_rect: QRectF | None = None,
|
|
||||||
|
|
||||||
update_label: bool = True,
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
"""Set position of selection rect and accompanying label, move
|
||||||
Set position from `ViewBox` coords (i.e. from the actual
|
label to match.
|
||||||
data domain) of rect (and any accompanying label which is
|
|
||||||
moved to match).
|
|
||||||
|
|
||||||
'''
|
"""
|
||||||
if self._chart is None:
|
if self._label_proxy is None:
|
||||||
raise RuntimeError(
|
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html
|
||||||
'You MUST assign a `SelectRect.chart: ChartPlotWidget`!'
|
self._label_proxy = self.vb.scene().addWidget(self._label)
|
||||||
)
|
|
||||||
|
|
||||||
if view_rect is None:
|
start_pos = self.vb.mapToView(p1)
|
||||||
# ensure point casting
|
end_pos = self.vb.mapToView(p2)
|
||||||
start_pos: QPointF = as_point(start_pos)
|
|
||||||
end_pos: QPointF = as_point(end_pos)
|
|
||||||
|
|
||||||
# map to view coords and update area
|
# map to view coords and update area
|
||||||
view_rect = QtCore.QRectF(
|
r = QtCore.QRectF(start_pos, end_pos)
|
||||||
start_pos,
|
|
||||||
end_pos,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.setPos(view_rect.topLeft())
|
# old way; don't need right?
|
||||||
|
# lr = QtCore.QRectF(p1, p2)
|
||||||
|
# r = self.vb.childGroup.mapRectFromParent(lr)
|
||||||
|
|
||||||
# NOTE: SERIOUSLY NO IDEA WHY THIS WORKS...
|
self.setPos(r.topLeft())
|
||||||
# but it does and all the other commented stuff above
|
self.resetTransform()
|
||||||
# dint, dawg..
|
self.setRect(r)
|
||||||
|
|
||||||
# self.resetTransform()
|
|
||||||
# self.setRect(view_rect)
|
|
||||||
|
|
||||||
tr = QTransform.fromScale(
|
|
||||||
view_rect.width(),
|
|
||||||
view_rect.height(),
|
|
||||||
)
|
|
||||||
self.setTransform(tr)
|
|
||||||
|
|
||||||
if update_label:
|
|
||||||
self.init_label(view_rect)
|
|
||||||
|
|
||||||
print(
|
|
||||||
'SelectRect modify:\n'
|
|
||||||
f'QRectF: {view_rect}\n'
|
|
||||||
f'start_pos: {start_pos}\n'
|
|
||||||
f'end_pos: {end_pos}\n'
|
|
||||||
)
|
|
||||||
self.show()
|
self.show()
|
||||||
|
|
||||||
def init_label(
|
y1, y2 = start_pos.y(), end_pos.y()
|
||||||
self,
|
x1, x2 = start_pos.x(), end_pos.x()
|
||||||
view_rect: QRectF,
|
|
||||||
) -> QLabel:
|
|
||||||
|
|
||||||
# should be init-ed in `.__init__()`
|
# TODO: heh, could probably use a max-min streamin algo here too
|
||||||
label: QLabel = self._label
|
|
||||||
cv: ChartView = self.vb
|
|
||||||
|
|
||||||
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html
|
|
||||||
if self._label_proxy is None:
|
|
||||||
scen: QGraphicsScene = cv.scene()
|
|
||||||
# NOTE: specifically this is passing a widget
|
|
||||||
# pointer to the scene's `.addWidget()` as per,
|
|
||||||
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html#embedding-a-widget-with-qgraphicsproxywidget
|
|
||||||
self._label_proxy: QGraphicsProxyWidget = scen.addWidget(label)
|
|
||||||
|
|
||||||
# get label startup coords
|
|
||||||
tl: QPointF = view_rect.topLeft()
|
|
||||||
br: QPointF = view_rect.bottomRight()
|
|
||||||
|
|
||||||
x1, y1 = tl.x(), tl.y()
|
|
||||||
x2, y2 = br.x(), br.y()
|
|
||||||
|
|
||||||
# TODO: to remove, previous label corner point unpacking
|
|
||||||
# x1, y1 = start_pos.x(), start_pos.y()
|
|
||||||
# x2, y2 = end_pos.x(), end_pos.y()
|
|
||||||
# y1, y2 = start_pos.y(), end_pos.y()
|
|
||||||
# x1, x2 = start_pos.x(), end_pos.x()
|
|
||||||
|
|
||||||
# TODO: heh, could probably use a max-min streamin algo
|
|
||||||
# here too?
|
|
||||||
_, xmn = min(y1, y2), min(x1, x2)
|
_, xmn = min(y1, y2), min(x1, x2)
|
||||||
ymx, xmx = max(y1, y2), max(x1, x2)
|
ymx, xmx = max(y1, y2), max(x1, x2)
|
||||||
|
|
||||||
|
|
@ -535,35 +375,26 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
|
||||||
ixmn, ixmx = round(xmn), round(xmx)
|
ixmn, ixmx = round(xmn), round(xmx)
|
||||||
nbars = ixmx - ixmn + 1
|
nbars = ixmx - ixmn + 1
|
||||||
|
|
||||||
chart: ChartPlotWidget = self._chart
|
chart = self._chart
|
||||||
data: np.ndarray = chart.get_viz(
|
data = chart.get_viz(chart.name).shm.array[ixmn:ixmx]
|
||||||
chart.name
|
|
||||||
).shm.array[ixmn:ixmx]
|
|
||||||
|
|
||||||
if len(data):
|
if len(data):
|
||||||
std: float = data['close'].std()
|
std = data['close'].std()
|
||||||
dmx: float = data['high'].max()
|
dmx = data['high'].max()
|
||||||
dmn: float = data['low'].min()
|
dmn = data['low'].min()
|
||||||
else:
|
else:
|
||||||
dmn = dmx = std = np.nan
|
dmn = dmx = std = np.nan
|
||||||
|
|
||||||
# update label info
|
# update label info
|
||||||
label.setText('\n'.join(self._contents).format(
|
self._label.setText('\n'.join(self._contents).format(
|
||||||
pchng=pchng,
|
pchng=pchng, rng=rng, nbars=nbars,
|
||||||
rng=rng,
|
std=std, dmx=dmx, dmn=dmn,
|
||||||
nbars=nbars,
|
|
||||||
std=std,
|
|
||||||
dmx=dmx,
|
|
||||||
dmn=dmn,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
# print(f'x2, y2: {(x2, y2)}')
|
# print(f'x2, y2: {(x2, y2)}')
|
||||||
# print(f'xmn, ymn: {(xmn, ymx)}')
|
# print(f'xmn, ymn: {(xmn, ymx)}')
|
||||||
|
|
||||||
label_anchor = Point(
|
label_anchor = Point(xmx + 2, ymx)
|
||||||
xmx + 2,
|
|
||||||
ymx,
|
|
||||||
)
|
|
||||||
|
|
||||||
# XXX: in the drag bottom-right -> top-left case we don't
|
# XXX: in the drag bottom-right -> top-left case we don't
|
||||||
# want the label to overlay the box.
|
# want the label to overlay the box.
|
||||||
|
|
@ -572,40 +403,13 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
|
||||||
# # label_anchor = Point(x2, y2 + self._label.height())
|
# # label_anchor = Point(x2, y2 + self._label.height())
|
||||||
# label_anchor = Point(xmn, ymn)
|
# label_anchor = Point(xmn, ymn)
|
||||||
|
|
||||||
self._abs_top_right: Point = label_anchor
|
self._abs_top_right = label_anchor
|
||||||
self._label_proxy.setPos(
|
self._label_proxy.setPos(self.vb.mapFromView(label_anchor))
|
||||||
cv.mapFromView(label_anchor)
|
# self._label.show()
|
||||||
)
|
|
||||||
label.show()
|
|
||||||
|
|
||||||
def hide(self):
|
def clear(self):
|
||||||
'''
|
"""Clear the selection box from view.
|
||||||
Clear the selection box from its graphics scene but
|
|
||||||
don't delete it permanently.
|
|
||||||
|
|
||||||
'''
|
"""
|
||||||
super().hide()
|
|
||||||
self._label.hide()
|
self._label.hide()
|
||||||
|
self.hide()
|
||||||
# TODO: ensure noone else using dis.
|
|
||||||
clear = hide
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
'''
|
|
||||||
De-allocate this rect from its rendering graphics scene.
|
|
||||||
|
|
||||||
Like a permanent hide.
|
|
||||||
|
|
||||||
'''
|
|
||||||
scen: QGraphicsScene = self.scene()
|
|
||||||
if scen is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
scen.removeItem(self)
|
|
||||||
if (
|
|
||||||
self._label
|
|
||||||
and
|
|
||||||
self._label_proxy
|
|
||||||
|
|
||||||
):
|
|
||||||
scen.removeItem(self._label_proxy)
|
|
||||||
|
|
|
||||||
|
|
@ -23,29 +23,28 @@ from typing import Callable
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
from tractor.trionics import gather_contexts
|
from tractor.trionics import gather_contexts
|
||||||
|
from PyQt5 import QtCore
|
||||||
from piker.ui.qt import (
|
from PyQt5.QtCore import QEvent, pyqtBoundSignal
|
||||||
QtCore,
|
from PyQt5.QtWidgets import QWidget
|
||||||
QWidget,
|
from PyQt5.QtWidgets import (
|
||||||
QEvent,
|
QGraphicsSceneMouseEvent as gs_mouse,
|
||||||
keys,
|
|
||||||
gs_keys,
|
|
||||||
pyqtBoundSignal,
|
|
||||||
)
|
)
|
||||||
from piker.types import Struct
|
|
||||||
|
from ..data.types import Struct
|
||||||
|
|
||||||
|
|
||||||
MOUSE_EVENTS = {
|
MOUSE_EVENTS = {
|
||||||
gs_keys.GraphicsSceneMousePress,
|
gs_mouse.GraphicsSceneMousePress,
|
||||||
gs_keys.GraphicsSceneMouseRelease,
|
gs_mouse.GraphicsSceneMouseRelease,
|
||||||
keys.MouseButtonPress,
|
QEvent.MouseButtonPress,
|
||||||
keys.MouseButtonRelease,
|
QEvent.MouseButtonRelease,
|
||||||
# QtGui.QMouseEvent,
|
# QtGui.QMouseEvent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# TODO: maybe consider some constrained ints down the road?
|
# TODO: maybe consider some constrained ints down the road?
|
||||||
# https://pydantic-docs.helpmanual.io/usage/types/#constrained-types
|
# https://pydantic-docs.helpmanual.io/usage/types/#constrained-types
|
||||||
|
|
||||||
class KeyboardMsg(Struct):
|
class KeyboardMsg(Struct):
|
||||||
'''Unpacked Qt keyboard event data.
|
'''Unpacked Qt keyboard event data.
|
||||||
|
|
||||||
|
|
@ -115,10 +114,7 @@ class EventRelay(QtCore.QObject):
|
||||||
# something to do with Qt internals and calling the
|
# something to do with Qt internals and calling the
|
||||||
# parent handler?
|
# parent handler?
|
||||||
|
|
||||||
if etype in {
|
if etype in {QEvent.KeyPress, QEvent.KeyRelease}:
|
||||||
QEvent.Type.KeyPress,
|
|
||||||
QEvent.Type.KeyRelease,
|
|
||||||
}:
|
|
||||||
|
|
||||||
msg = KeyboardMsg(
|
msg = KeyboardMsg(
|
||||||
event=ev,
|
event=ev,
|
||||||
|
|
@ -164,9 +160,7 @@ class EventRelay(QtCore.QObject):
|
||||||
async def open_event_stream(
|
async def open_event_stream(
|
||||||
|
|
||||||
source_widget: QWidget,
|
source_widget: QWidget,
|
||||||
event_types: set[QEvent] = {
|
event_types: set[QEvent] = {QEvent.KeyPress},
|
||||||
QEvent.Type.KeyPress,
|
|
||||||
},
|
|
||||||
filter_auto_repeats: bool = True,
|
filter_auto_repeats: bool = True,
|
||||||
|
|
||||||
) -> trio.abc.ReceiveChannel:
|
) -> trio.abc.ReceiveChannel:
|
||||||
|
|
@ -207,8 +201,8 @@ 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 trio.open_nursery() as tn:
|
async with trio.open_nursery() as n:
|
||||||
tn.start_soon(proxy_to_handler)
|
n.start_soon(proxy_to_handler)
|
||||||
async with send:
|
async with send:
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
@ -218,48 +212,18 @@ async def open_handlers(
|
||||||
|
|
||||||
source_widgets: list[QWidget],
|
source_widgets: list[QWidget],
|
||||||
event_types: set[QEvent],
|
event_types: set[QEvent],
|
||||||
|
async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None],
|
||||||
# NOTE: if you want to bind in additional kwargs to the handler
|
**kwargs,
|
||||||
# pass in a `partial()` instead!
|
|
||||||
async_handler: Callable[
|
|
||||||
[QWidget, trio.abc.ReceiveChannel], # required handler args
|
|
||||||
None
|
|
||||||
],
|
|
||||||
|
|
||||||
# XXX: these are ONLY inputs available to the
|
|
||||||
# `open_event_stream()` event-relay to mem-chan factor above!
|
|
||||||
**open_ev_stream_kwargs,
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
|
||||||
Connect and schedule an async handler function to receive an
|
|
||||||
arbitrary `QWidget`'s events with kb/mouse msgs repacked into
|
|
||||||
structs (see above) and shuttled over a mem-chan to the input
|
|
||||||
`async_handler` to allow interaction-IO processing from
|
|
||||||
a `trio` func-as-task.
|
|
||||||
|
|
||||||
'''
|
|
||||||
widget: QWidget
|
|
||||||
streams: list[trio.abc.ReceiveChannel]
|
|
||||||
async with (
|
async with (
|
||||||
trio.open_nursery() as tn,
|
trio.open_nursery() as n,
|
||||||
gather_contexts([
|
gather_contexts([
|
||||||
open_event_stream(
|
open_event_stream(widget, event_types, **kwargs)
|
||||||
widget,
|
|
||||||
event_types,
|
|
||||||
**open_ev_stream_kwargs,
|
|
||||||
)
|
|
||||||
for widget in source_widgets
|
for widget in source_widgets
|
||||||
]) as streams,
|
]) as streams,
|
||||||
):
|
):
|
||||||
for widget, event_recv_stream in zip(
|
for widget, event_recv_stream in zip(source_widgets, streams):
|
||||||
source_widgets,
|
n.start_soon(async_handler, widget, event_recv_stream)
|
||||||
streams,
|
|
||||||
):
|
|
||||||
tn.start_soon(
|
|
||||||
async_handler,
|
|
||||||
widget,
|
|
||||||
event_recv_stream,
|
|
||||||
)
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
|
||||||
|
|
@ -30,35 +30,34 @@ from typing import (
|
||||||
import platform
|
import platform
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
# Qt specific
|
||||||
|
import PyQt5 # noqa
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QMainWindow,
|
||||||
|
QApplication,
|
||||||
|
)
|
||||||
|
from PyQt5 import QtCore
|
||||||
|
from PyQt5.QtCore import (
|
||||||
|
pyqtRemoveInputHook,
|
||||||
|
Qt,
|
||||||
|
QCoreApplication,
|
||||||
|
)
|
||||||
import qdarkstyle
|
import qdarkstyle
|
||||||
from qdarkstyle import DarkPalette
|
from qdarkstyle import DarkPalette
|
||||||
# import qdarkgraystyle # TODO: play with it
|
# import qdarkgraystyle # TODO: play with it
|
||||||
import trio
|
import trio
|
||||||
from outcome import Error
|
from outcome import Error
|
||||||
|
|
||||||
# Qt version-agnostic
|
|
||||||
from .qt import (
|
|
||||||
QWidget,
|
|
||||||
QMainWindow,
|
|
||||||
QApplication,
|
|
||||||
QtCore,
|
|
||||||
pyqtRemoveInputHook,
|
|
||||||
Qt,
|
|
||||||
QCoreApplication,
|
|
||||||
)
|
|
||||||
from ..service import (
|
from ..service import (
|
||||||
maybe_open_pikerd,
|
maybe_open_pikerd,
|
||||||
get_runtime_vars,
|
get_tractor_runtime_kwargs,
|
||||||
)
|
)
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._pg_overrides import _do_overrides
|
from ._pg_overrides import _do_overrides
|
||||||
from . import _style
|
from . import _style
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ._chart import GodWidget
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
# pyqtgraph global config
|
# pyqtgraph global config
|
||||||
|
|
@ -147,7 +146,7 @@ def run_qtractor(
|
||||||
|
|
||||||
# load dark theme
|
# load dark theme
|
||||||
stylesheet = qdarkstyle.load_stylesheet(
|
stylesheet = qdarkstyle.load_stylesheet(
|
||||||
qt_api='pyqt6',
|
qt_api='pyqt5',
|
||||||
palette=DarkPalette,
|
palette=DarkPalette,
|
||||||
)
|
)
|
||||||
app.setStyleSheet(stylesheet)
|
app.setStyleSheet(stylesheet)
|
||||||
|
|
@ -174,9 +173,7 @@ def run_qtractor(
|
||||||
instance.window = window
|
instance.window = window
|
||||||
|
|
||||||
# override tractor's defaults
|
# override tractor's defaults
|
||||||
tractor_kwargs.update(
|
tractor_kwargs.update(get_tractor_runtime_kwargs())
|
||||||
get_runtime_vars()
|
|
||||||
)
|
|
||||||
|
|
||||||
# define tractor entrypoint
|
# define tractor entrypoint
|
||||||
async def main():
|
async def main():
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ Text entry "forms" widgets (mostly for configuration and UI user input).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from math import floor
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
|
|
@ -28,15 +29,9 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
|
from PyQt5 import QtGui
|
||||||
from piker.ui.qt import (
|
from PyQt5.QtCore import QSize, QModelIndex, Qt, QEvent
|
||||||
keys,
|
from PyQt5.QtWidgets import (
|
||||||
size_policy,
|
|
||||||
QtGui,
|
|
||||||
QSize,
|
|
||||||
QModelIndex,
|
|
||||||
Qt,
|
|
||||||
QEvent,
|
|
||||||
QWidget,
|
QWidget,
|
||||||
QLabel,
|
QLabel,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
|
@ -45,6 +40,7 @@ from piker.ui.qt import (
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
QProgressBar,
|
QProgressBar,
|
||||||
|
QSizePolicy,
|
||||||
QStyledItemDelegate,
|
QStyledItemDelegate,
|
||||||
QStyleOptionViewItem,
|
QStyleOptionViewItem,
|
||||||
)
|
)
|
||||||
|
|
@ -76,14 +72,14 @@ class Edit(QLineEdit):
|
||||||
|
|
||||||
if width_in_chars:
|
if width_in_chars:
|
||||||
self._chars = int(width_in_chars)
|
self._chars = int(width_in_chars)
|
||||||
x_size_policy = size_policy.Fixed
|
x_size_policy = QSizePolicy.Fixed
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# chart count which will be used to calculate
|
# chart count which will be used to calculate
|
||||||
# width of input field.
|
# width of input field.
|
||||||
self._chars: int = 6
|
self._chars: int = 6
|
||||||
# fit to surroundingn frame width
|
# fit to surroundingn frame width
|
||||||
x_size_policy = size_policy.Expanding
|
x_size_policy = QSizePolicy.Expanding
|
||||||
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
|
|
@ -91,7 +87,7 @@ class Edit(QLineEdit):
|
||||||
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
|
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
|
||||||
self.setSizePolicy(
|
self.setSizePolicy(
|
||||||
x_size_policy,
|
x_size_policy,
|
||||||
size_policy.Fixed,
|
QSizePolicy.Fixed,
|
||||||
)
|
)
|
||||||
self.setFont(font.font)
|
self.setFont(font.font)
|
||||||
|
|
||||||
|
|
@ -185,13 +181,11 @@ class Selection(QComboBox):
|
||||||
|
|
||||||
self._items: dict[str, int] = {}
|
self._items: dict[str, int] = {}
|
||||||
super().__init__(parent=parent)
|
super().__init__(parent=parent)
|
||||||
self.setSizeAdjustPolicy(
|
self.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||||
QComboBox.SizeAdjustPolicy.AdjustToContents,
|
|
||||||
)
|
|
||||||
# make line edit expand to surrounding frame
|
# make line edit expand to surrounding frame
|
||||||
self.setSizePolicy(
|
self.setSizePolicy(
|
||||||
size_policy.Expanding,
|
QSizePolicy.Expanding,
|
||||||
size_policy.Fixed,
|
QSizePolicy.Fixed,
|
||||||
)
|
)
|
||||||
view = self.view()
|
view = self.view()
|
||||||
view.setUniformItemSizes(True)
|
view.setUniformItemSizes(True)
|
||||||
|
|
@ -315,8 +309,8 @@ class FieldsForm(QWidget):
|
||||||
|
|
||||||
# size it as we specify
|
# size it as we specify
|
||||||
self.setSizePolicy(
|
self.setSizePolicy(
|
||||||
size_policy.Expanding,
|
QSizePolicy.Expanding,
|
||||||
size_policy.Expanding,
|
QSizePolicy.Expanding,
|
||||||
)
|
)
|
||||||
|
|
||||||
# XXX: not sure why we have to create this here exactly
|
# XXX: not sure why we have to create this here exactly
|
||||||
|
|
@ -423,8 +417,8 @@ class FieldsForm(QWidget):
|
||||||
select.set_items(values)
|
select.set_items(values)
|
||||||
|
|
||||||
self.setSizePolicy(
|
self.setSizePolicy(
|
||||||
size_policy.Fixed,
|
QSizePolicy.Fixed,
|
||||||
size_policy.Fixed,
|
QSizePolicy.Fixed,
|
||||||
)
|
)
|
||||||
select.show()
|
select.show()
|
||||||
self.form.addRow(label, select)
|
self.form.addRow(label, select)
|
||||||
|
|
@ -444,10 +438,7 @@ async def handle_field_input(
|
||||||
|
|
||||||
async for kbmsg in recv_chan:
|
async for kbmsg in recv_chan:
|
||||||
|
|
||||||
if kbmsg.etype in {
|
if kbmsg.etype in {QEvent.KeyPress, QEvent.KeyRelease}:
|
||||||
keys.KeyPress,
|
|
||||||
keys.KeyRelease,
|
|
||||||
}:
|
|
||||||
event, etype, key, mods, txt = kbmsg.to_tuple()
|
event, etype, key, mods, txt = kbmsg.to_tuple()
|
||||||
print(f'key: {kbmsg.key}, mods: {kbmsg.mods}, txt: {kbmsg.txt}')
|
print(f'key: {kbmsg.key}, mods: {kbmsg.mods}, txt: {kbmsg.txt}')
|
||||||
|
|
||||||
|
|
@ -713,8 +704,7 @@ def mk_fill_status_bar(
|
||||||
)
|
)
|
||||||
|
|
||||||
bottom_label = form.add_field_label(
|
bottom_label = form.add_field_label(
|
||||||
# 'x: {step_size}',
|
'x: {step_size}',
|
||||||
'{unit_prefix}: {step_size}',
|
|
||||||
font_size=bar_label_font_size,
|
font_size=bar_label_font_size,
|
||||||
font_color='gunmetal',
|
font_color='gunmetal',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,27 +36,16 @@ from tractor.trionics import maybe_open_context
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
|
|
||||||
from piker.accounting import MktPair
|
from piker.data.types import Struct
|
||||||
from piker.fsp import (
|
from ._axes import PriceAxis
|
||||||
cascade,
|
from ..calc import humanize
|
||||||
maybe_mk_fsp_shm,
|
from ..data._sharedmem import (
|
||||||
Fsp,
|
|
||||||
dolla_vlm,
|
|
||||||
flow_rates,
|
|
||||||
)
|
|
||||||
from piker.data import (
|
|
||||||
Flume,
|
|
||||||
ShmArray,
|
ShmArray,
|
||||||
)
|
|
||||||
from piker.data._sharedmem import (
|
|
||||||
_Token,
|
_Token,
|
||||||
try_read,
|
try_read,
|
||||||
)
|
)
|
||||||
from piker.log import get_logger
|
from ..data.feed import Flume
|
||||||
from piker.toolz import Profiler
|
from ..accounting import MktPair
|
||||||
from piker.types import Struct
|
|
||||||
from ._axes import PriceAxis
|
|
||||||
from ..calc import humanize
|
|
||||||
from ._chart import (
|
from ._chart import (
|
||||||
ChartPlotWidget,
|
ChartPlotWidget,
|
||||||
LinkedSplits,
|
LinkedSplits,
|
||||||
|
|
@ -66,6 +55,18 @@ from ._forms import (
|
||||||
mk_form,
|
mk_form,
|
||||||
open_form_input_handling,
|
open_form_input_handling,
|
||||||
)
|
)
|
||||||
|
from ..fsp._api import (
|
||||||
|
maybe_mk_fsp_shm,
|
||||||
|
Fsp,
|
||||||
|
)
|
||||||
|
from ..fsp import cascade
|
||||||
|
from ..fsp._volume import (
|
||||||
|
# tina_vwap,
|
||||||
|
dolla_vlm,
|
||||||
|
flow_rates,
|
||||||
|
)
|
||||||
|
from ..log import get_logger
|
||||||
|
from .._profile import Profiler
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -181,10 +182,7 @@ async def open_fsp_sidepane(
|
||||||
async def open_fsp_actor_cluster(
|
async def open_fsp_actor_cluster(
|
||||||
names: list[str] = ['fsp_0', 'fsp_1'],
|
names: list[str] = ['fsp_0', 'fsp_1'],
|
||||||
|
|
||||||
) -> AsyncGenerator[
|
) -> AsyncGenerator[int, dict[str, tractor.Portal]]:
|
||||||
int,
|
|
||||||
dict[str, tractor.Portal]
|
|
||||||
]:
|
|
||||||
|
|
||||||
from tractor._clustering import open_actor_cluster
|
from tractor._clustering import open_actor_cluster
|
||||||
|
|
||||||
|
|
@ -285,7 +283,6 @@ async def run_fsp_ui(
|
||||||
name,
|
name,
|
||||||
array_key=array_key,
|
array_key=array_key,
|
||||||
)
|
)
|
||||||
assert chart.qframe
|
|
||||||
|
|
||||||
chart.linked.focus()
|
chart.linked.focus()
|
||||||
|
|
||||||
|
|
@ -393,7 +390,7 @@ class FspAdmin:
|
||||||
complete: trio.Event,
|
complete: trio.Event,
|
||||||
started: trio.Event,
|
started: trio.Event,
|
||||||
fqme: str,
|
fqme: str,
|
||||||
dst_flume: Flume,
|
dst_fsp_flume: Flume,
|
||||||
conf: dict,
|
conf: dict,
|
||||||
target: Fsp,
|
target: Fsp,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
@ -411,14 +408,16 @@ class FspAdmin:
|
||||||
# chaining entrypoint
|
# chaining entrypoint
|
||||||
cascade,
|
cascade,
|
||||||
|
|
||||||
# TODO: can't we just drop this and expect
|
|
||||||
# far end to read the src flume's .mkt.fqme?
|
|
||||||
# data feed key
|
# data feed key
|
||||||
fqme=fqme,
|
fqme=fqme,
|
||||||
|
|
||||||
src_flume_addr=self.flume.to_msg(),
|
# TODO: pass `Flume.to_msg()`s here?
|
||||||
dst_flume_addr=dst_flume.to_msg(),
|
# mems
|
||||||
ns_path=ns_path, # edge-bind-func
|
src_shm_token=self.flume.rt_shm.token,
|
||||||
|
dst_shm_token=dst_fsp_flume.rt_shm.token,
|
||||||
|
|
||||||
|
# target
|
||||||
|
ns_path=ns_path,
|
||||||
|
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
zero_on_step=conf.get('zero_on_step', False),
|
zero_on_step=conf.get('zero_on_step', False),
|
||||||
|
|
@ -432,14 +431,14 @@ class FspAdmin:
|
||||||
ctx.open_stream() as stream,
|
ctx.open_stream() as stream,
|
||||||
):
|
):
|
||||||
|
|
||||||
dst_flume.stream: tractor.MsgStream = stream
|
dst_fsp_flume.stream: tractor.MsgStream = stream
|
||||||
|
|
||||||
# register output data
|
# register output data
|
||||||
self._registry[
|
self._registry[
|
||||||
(fqme, ns_path)
|
(fqme, ns_path)
|
||||||
] = (
|
] = (
|
||||||
stream,
|
stream,
|
||||||
dst_flume.rt_shm,
|
dst_fsp_flume.rt_shm,
|
||||||
complete
|
complete
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -516,7 +515,7 @@ class FspAdmin:
|
||||||
broker='piker',
|
broker='piker',
|
||||||
_atype='fsp',
|
_atype='fsp',
|
||||||
)
|
)
|
||||||
dst_flume = Flume(
|
dst_fsp_flume = Flume(
|
||||||
mkt=mkt,
|
mkt=mkt,
|
||||||
_rt_shm_token=dst_shm.token,
|
_rt_shm_token=dst_shm.token,
|
||||||
first_quote={},
|
first_quote={},
|
||||||
|
|
@ -544,13 +543,13 @@ class FspAdmin:
|
||||||
complete,
|
complete,
|
||||||
started,
|
started,
|
||||||
fqme,
|
fqme,
|
||||||
dst_flume,
|
dst_fsp_flume,
|
||||||
conf,
|
conf,
|
||||||
target,
|
target,
|
||||||
loglevel,
|
loglevel,
|
||||||
)
|
)
|
||||||
|
|
||||||
return dst_flume, started
|
return dst_fsp_flume, started
|
||||||
|
|
||||||
async def open_fsp_chart(
|
async def open_fsp_chart(
|
||||||
self,
|
self,
|
||||||
|
|
@ -560,7 +559,7 @@ class FspAdmin:
|
||||||
conf: dict, # yeah probably dumb..
|
conf: dict, # yeah probably dumb..
|
||||||
loglevel: str = 'error',
|
loglevel: str = 'error',
|
||||||
|
|
||||||
) -> trio.Event:
|
) -> (trio.Event, ChartPlotWidget):
|
||||||
|
|
||||||
flume, started = await self.start_engine_task(
|
flume, started = await self.start_engine_task(
|
||||||
target,
|
target,
|
||||||
|
|
@ -927,7 +926,7 @@ async def start_fsp_displays(
|
||||||
|
|
||||||
linked: LinkedSplits,
|
linked: LinkedSplits,
|
||||||
flume: Flume,
|
flume: Flume,
|
||||||
# group_status_key: str,
|
group_status_key: str,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -974,23 +973,21 @@ async def start_fsp_displays(
|
||||||
flume,
|
flume,
|
||||||
) as admin,
|
) as admin,
|
||||||
):
|
):
|
||||||
statuses: list[trio.Event] = []
|
statuses = []
|
||||||
for target, conf in fsp_conf.items():
|
for target, conf in fsp_conf.items():
|
||||||
started: trio.Event = await admin.open_fsp_chart(
|
started = await admin.open_fsp_chart(
|
||||||
target,
|
target,
|
||||||
conf,
|
conf,
|
||||||
)
|
)
|
||||||
# done = linked.window().status_bar.open_status(
|
done = linked.window().status_bar.open_status(
|
||||||
# f'loading fsp, {target}..',
|
f'loading fsp, {target}..',
|
||||||
# group_key=group_status_key,
|
group_key=group_status_key,
|
||||||
# )
|
)
|
||||||
# statuses.append((started, done))
|
statuses.append((started, done))
|
||||||
statuses.append(started)
|
|
||||||
|
|
||||||
# for fsp_loaded, status_cb in statuses:
|
for fsp_loaded, status_cb in statuses:
|
||||||
for fsp_loaded in statuses:
|
|
||||||
await fsp_loaded.wait()
|
await fsp_loaded.wait()
|
||||||
profiler(f'attached to fsp portal: {target}')
|
profiler(f'attached to fsp portal: {target}')
|
||||||
# status_cb()
|
status_cb()
|
||||||
|
|
||||||
# blocks on nursery until all fsp actors complete
|
# blocks on nursery until all fsp actors complete
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,15 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
`QIcon` hackery.
|
``QIcon`` hackery.
|
||||||
|
|
||||||
Mostly dynamically loading pixmaps for use with `QGraphicsScene`.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from piker.ui.qt import (
|
from PyQt5.QtWidgets import QStyle
|
||||||
QSize,
|
from PyQt5.QtGui import (
|
||||||
QStyle,
|
QIcon, QPixmap, QColor
|
||||||
QIcon,
|
|
||||||
QPixmap,
|
|
||||||
QColor,
|
|
||||||
)
|
)
|
||||||
|
from PyQt5.QtCore import QSize
|
||||||
|
|
||||||
from ._style import hcolor
|
from ._style import hcolor
|
||||||
|
|
||||||
# https://www.pythonguis.com/faq/built-in-qicons-pyqt/
|
# https://www.pythonguis.com/faq/built-in-qicons-pyqt/
|
||||||
|
|
@ -47,8 +44,7 @@ def mk_icons(
|
||||||
size: QSize,
|
size: QSize,
|
||||||
|
|
||||||
) -> dict[str, QIcon]:
|
) -> dict[str, QIcon]:
|
||||||
'''
|
'''This helper is indempotent.
|
||||||
This helper is indempotent.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
global _icons, _icon_names
|
global _icons, _icon_names
|
||||||
|
|
@ -60,11 +56,7 @@ def mk_icons(
|
||||||
# load account selection using current style
|
# load account selection using current style
|
||||||
for name, icon_name in _icon_names.items():
|
for name, icon_name in _icon_names.items():
|
||||||
|
|
||||||
stdpixmap = getattr(
|
stdpixmap = getattr(QStyle, icon_name)
|
||||||
# https://www.pythonguis.com/faq/built-in-qicons-pyqt/
|
|
||||||
QStyle.StandardPixmap, # pyqt/pyside6
|
|
||||||
icon_name,
|
|
||||||
)
|
|
||||||
stdicon = style.standardIcon(stdpixmap)
|
stdicon = style.standardIcon(stdpixmap)
|
||||||
pixmap = stdicon.pixmap(size)
|
pixmap = stdicon.pixmap(size)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ from contextlib import (
|
||||||
asynccontextmanager,
|
asynccontextmanager,
|
||||||
ExitStack,
|
ExitStack,
|
||||||
)
|
)
|
||||||
from functools import partial
|
|
||||||
import time
|
import time
|
||||||
from typing import (
|
from typing import (
|
||||||
Callable,
|
Callable,
|
||||||
|
|
@ -31,70 +30,46 @@ from typing import (
|
||||||
)
|
)
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
# NOTE XXX: pg is super annoying and re-implements it's own mouse
|
# from pyqtgraph.GraphicsScene import mouseEvents
|
||||||
# event subsystem.. we should really look into re-working/writing
|
from PyQt5.QtWidgets import QGraphicsSceneMouseEvent as gs_mouse
|
||||||
# this down the road.. Bo
|
from PyQt5.QtCore import Qt, QEvent
|
||||||
from pyqtgraph.GraphicsScene import mouseEvents as mevs
|
from pyqtgraph import ViewBox, Point, QtCore
|
||||||
# from pyqtgraph.GraphicsScene.mouseEvents import MouseDragEvent
|
from pyqtgraph import functions as fn
|
||||||
from pyqtgraph import (
|
|
||||||
ViewBox,
|
|
||||||
Point,
|
|
||||||
QtCore,
|
|
||||||
functions as fn,
|
|
||||||
)
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from piker.ui.qt import (
|
|
||||||
QWheelEvent,
|
|
||||||
QGraphicsSceneMouseEvent as gs_mouse,
|
|
||||||
Qt,
|
|
||||||
QEvent,
|
|
||||||
)
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ..toolz import (
|
from .._profile import Profiler
|
||||||
Profiler,
|
from .._profile import pg_profile_enabled, ms_slower_then
|
||||||
pg_profile_enabled,
|
|
||||||
ms_slower_then,
|
|
||||||
)
|
|
||||||
from .view_mode import overlay_viewlists
|
from .view_mode import overlay_viewlists
|
||||||
# from ._style import _min_points_to_show
|
# from ._style import _min_points_to_show
|
||||||
from ._editors import SelectRect
|
from ._editors import SelectRect
|
||||||
from . import _event
|
from . import _event
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# from ._search import (
|
from ._chart import ChartPlotWidget
|
||||||
# SearchWidget,
|
|
||||||
# )
|
|
||||||
from ._chart import (
|
|
||||||
ChartnPane,
|
|
||||||
ChartPlotWidget,
|
|
||||||
GodWidget,
|
|
||||||
)
|
|
||||||
from ._dataviz import Viz
|
from ._dataviz import Viz
|
||||||
from .order_mode import OrderMode
|
|
||||||
from ._display import DisplayState
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
NUMBER_LINE = {
|
NUMBER_LINE = {
|
||||||
Qt.Key.Key_1,
|
Qt.Key_1,
|
||||||
Qt.Key.Key_2,
|
Qt.Key_2,
|
||||||
Qt.Key.Key_3,
|
Qt.Key_3,
|
||||||
Qt.Key.Key_4,
|
Qt.Key_4,
|
||||||
Qt.Key.Key_5,
|
Qt.Key_5,
|
||||||
Qt.Key.Key_6,
|
Qt.Key_6,
|
||||||
Qt.Key.Key_7,
|
Qt.Key_7,
|
||||||
Qt.Key.Key_8,
|
Qt.Key_8,
|
||||||
Qt.Key.Key_9,
|
Qt.Key_9,
|
||||||
Qt.Key.Key_0,
|
Qt.Key_0,
|
||||||
}
|
}
|
||||||
|
|
||||||
ORDER_MODE = {
|
ORDER_MODE = {
|
||||||
Qt.Key.Key_A,
|
Qt.Key_A,
|
||||||
Qt.Key.Key_F,
|
Qt.Key_F,
|
||||||
Qt.Key.Key_D,
|
Qt.Key_D,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,12 +77,10 @@ async def handle_viewmode_kb_inputs(
|
||||||
|
|
||||||
view: ChartView,
|
view: ChartView,
|
||||||
recv_chan: trio.abc.ReceiveChannel,
|
recv_chan: trio.abc.ReceiveChannel,
|
||||||
dss: dict[str, DisplayState],
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
order_mode: OrderMode = view.order_mode
|
order_mode = view.order_mode
|
||||||
godw: GodWidget = order_mode.godw # noqa
|
|
||||||
|
|
||||||
# track edge triggered keys
|
# track edge triggered keys
|
||||||
# (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods)
|
# (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods)
|
||||||
|
|
@ -171,55 +144,29 @@ async def handle_viewmode_kb_inputs(
|
||||||
if mods == Qt.ControlModifier:
|
if mods == Qt.ControlModifier:
|
||||||
ctrl = True
|
ctrl = True
|
||||||
|
|
||||||
# UI REPL-shell, with ctrl-p (for "pause")
|
# UI REPL-shell
|
||||||
if (
|
if (
|
||||||
ctrl
|
ctrl and key in {
|
||||||
and key in {
|
Qt.Key_U,
|
||||||
Qt.Key_P,
|
|
||||||
}
|
}
|
||||||
):
|
):
|
||||||
|
import tractor
|
||||||
|
god = order_mode.godw # noqa
|
||||||
feed = order_mode.feed # noqa
|
feed = order_mode.feed # noqa
|
||||||
chart = order_mode.chart # noqa
|
chart = order_mode.chart # noqa
|
||||||
viz = chart.main_viz # noqa
|
viz = chart.main_viz # noqa
|
||||||
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.breakpoint()
|
||||||
await tractor.pause()
|
|
||||||
view.interact_graphics_cycle()
|
view.interact_graphics_cycle()
|
||||||
|
|
||||||
# FORCE graphics reset-and-render of all currently
|
# SEARCH MODE #
|
||||||
# shown data `Viz`s for the current chart app.
|
|
||||||
if (
|
|
||||||
ctrl
|
|
||||||
and key in {
|
|
||||||
Qt.Key_R,
|
|
||||||
}
|
|
||||||
):
|
|
||||||
fqme: str
|
|
||||||
ds: DisplayState
|
|
||||||
for fqme, ds in dss.items():
|
|
||||||
|
|
||||||
viz: Viz
|
|
||||||
for tf, viz in {
|
|
||||||
60: ds.hist_viz,
|
|
||||||
1: ds.viz,
|
|
||||||
}.items():
|
|
||||||
# TODO: only allow this when the data is IN VIEW!
|
|
||||||
# also, we probably can do this more efficiently
|
|
||||||
# / smarter by only redrawing the portion of the
|
|
||||||
# path necessary?
|
|
||||||
viz.reset_graphics()
|
|
||||||
|
|
||||||
# ------ - ------
|
|
||||||
# SEARCH MODE
|
|
||||||
# ------ - ------
|
|
||||||
# ctlr-<space>/<l> for "lookup", "search" -> open search tree
|
# ctlr-<space>/<l> for "lookup", "search" -> open search tree
|
||||||
if (
|
if (
|
||||||
ctrl
|
ctrl and key in {
|
||||||
and key in {
|
|
||||||
Qt.Key_L,
|
Qt.Key_L,
|
||||||
# Qt.Key_Space,
|
Qt.Key_Space,
|
||||||
}
|
}
|
||||||
):
|
):
|
||||||
godw = view._chart.linked.godwidget
|
godw = view._chart.linked.godwidget
|
||||||
|
|
@ -227,56 +174,20 @@ async def handle_viewmode_kb_inputs(
|
||||||
godw.search.focus()
|
godw.search.focus()
|
||||||
|
|
||||||
# esc and ctrl-c
|
# esc and ctrl-c
|
||||||
if (
|
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
|
||||||
key == Qt.Key_Escape
|
|
||||||
or (
|
|
||||||
ctrl
|
|
||||||
and key == Qt.Key_C
|
|
||||||
)
|
|
||||||
):
|
|
||||||
# ctrl-c as cancel
|
# ctrl-c as cancel
|
||||||
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
||||||
view.select_box.clear()
|
view.select_box.clear()
|
||||||
view.linked.focus()
|
view.linked.focus()
|
||||||
|
|
||||||
# cancel order or clear graphics
|
# cancel order or clear graphics
|
||||||
if (
|
if key == Qt.Key_C or key == Qt.Key_Delete:
|
||||||
key == Qt.Key_C
|
|
||||||
or key == Qt.Key_Delete
|
|
||||||
):
|
|
||||||
|
|
||||||
order_mode.cancel_orders_under_cursor()
|
order_mode.cancel_orders_under_cursor()
|
||||||
|
|
||||||
# View modes
|
# View modes
|
||||||
if (
|
if key == Qt.Key_R:
|
||||||
ctrl
|
|
||||||
and (
|
|
||||||
key == Qt.Key_Equal
|
|
||||||
or key == Qt.Key_I
|
|
||||||
)
|
|
||||||
):
|
|
||||||
view.wheelEvent(
|
|
||||||
ev=None,
|
|
||||||
axis=None,
|
|
||||||
delta=view.def_delta,
|
|
||||||
)
|
|
||||||
elif (
|
|
||||||
ctrl
|
|
||||||
and (
|
|
||||||
key == Qt.Key_Minus
|
|
||||||
or key == Qt.Key_O
|
|
||||||
)
|
|
||||||
):
|
|
||||||
view.wheelEvent(
|
|
||||||
ev=None,
|
|
||||||
axis=None,
|
|
||||||
delta=-view.def_delta,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif (
|
|
||||||
not ctrl
|
|
||||||
and key == Qt.Key_R
|
|
||||||
):
|
|
||||||
# NOTE: seems that if we don't yield a Qt render
|
# NOTE: seems that if we don't yield a Qt render
|
||||||
# cycle then the m4 downsampled curves will show here
|
# cycle then the m4 downsampled curves will show here
|
||||||
# without another reset..
|
# without another reset..
|
||||||
|
|
@ -321,47 +232,15 @@ async def handle_viewmode_kb_inputs(
|
||||||
|
|
||||||
# Toggle position config pane
|
# Toggle position config pane
|
||||||
if (
|
if (
|
||||||
ctrl
|
ctrl and key in {
|
||||||
and key in {
|
Qt.Key_P,
|
||||||
Qt.Key_Space,
|
|
||||||
}
|
}
|
||||||
):
|
):
|
||||||
# searchw: SearchWidget = godw.search
|
pp_pane = order_mode.current_pp.pane
|
||||||
# pp_pane = order_mode.current_pp.pane
|
if pp_pane.isHidden():
|
||||||
qframes: list[ChartnPane] = []
|
pp_pane.show()
|
||||||
|
|
||||||
for linked in (
|
|
||||||
godw.rt_linked,
|
|
||||||
godw.hist_linked,
|
|
||||||
):
|
|
||||||
for chartw in (
|
|
||||||
[linked.chart]
|
|
||||||
+
|
|
||||||
list(linked.subplots.values())
|
|
||||||
):
|
|
||||||
qframes.append(
|
|
||||||
chartw.qframe
|
|
||||||
)
|
|
||||||
|
|
||||||
# NOTE: place priority on FIRST hiding all
|
|
||||||
# panes before showing them.
|
|
||||||
# TODO: make this more "fancy"?
|
|
||||||
# - maybe look at majority of hidden states and then
|
|
||||||
# flip based on that?
|
|
||||||
# - move these loops into the chart APIs?
|
|
||||||
# - store the UX-state for a given feed/symbol and
|
|
||||||
# apply when opening a new one (eg. if panes were
|
|
||||||
# hidden then also hide them on newly loaded mkt
|
|
||||||
# feeds).
|
|
||||||
if not any(
|
|
||||||
qf.sidepane.isHidden() for qf in qframes
|
|
||||||
):
|
|
||||||
for qf in qframes:
|
|
||||||
qf.sidepane.hide()
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
for qf in qframes:
|
pp_pane.hide()
|
||||||
qf.sidepane.show()
|
|
||||||
|
|
||||||
# ORDER MODE
|
# ORDER MODE
|
||||||
# ----------
|
# ----------
|
||||||
|
|
@ -379,7 +258,7 @@ async def handle_viewmode_kb_inputs(
|
||||||
# show the pp size label only if there is
|
# show the pp size label only if there is
|
||||||
# a non-zero pos existing
|
# a non-zero pos existing
|
||||||
tracker = order_mode.current_pp
|
tracker = order_mode.current_pp
|
||||||
if tracker.live_pp.cumsize:
|
if tracker.live_pp.size:
|
||||||
tracker.nav.show()
|
tracker.nav.show()
|
||||||
|
|
||||||
# TODO: show pp config mini-params in status bar widget
|
# TODO: show pp config mini-params in status bar widget
|
||||||
|
|
@ -459,7 +338,6 @@ async def handle_viewmode_mouse(
|
||||||
|
|
||||||
view: ChartView,
|
view: ChartView,
|
||||||
recv_chan: trio.abc.ReceiveChannel,
|
recv_chan: trio.abc.ReceiveChannel,
|
||||||
dss: dict[str, DisplayState],
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
|
@ -497,9 +375,6 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
mode_name: str = 'view'
|
mode_name: str = 'view'
|
||||||
def_delta: float = 616 * 6
|
|
||||||
def_scale_factor: float = 1.016 ** (def_delta * -1 / 20)
|
|
||||||
# annots: dict[int, GraphicsObject] = {}
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -520,7 +395,6 @@ class ChartView(ViewBox):
|
||||||
# defaultPadding=0.,
|
# defaultPadding=0.,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
# for "known y-range style"
|
# for "known y-range style"
|
||||||
self._static_yrange = static_yrange
|
self._static_yrange = static_yrange
|
||||||
|
|
||||||
|
|
@ -535,11 +409,7 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
# add our selection box annotator
|
# add our selection box annotator
|
||||||
self.select_box = SelectRect(self)
|
self.select_box = SelectRect(self)
|
||||||
# self.select_box.add_to_view(self)
|
self.addItem(self.select_box, ignoreBounds=True)
|
||||||
# self.addItem(
|
|
||||||
# self.select_box,
|
|
||||||
# ignoreBounds=True,
|
|
||||||
# )
|
|
||||||
|
|
||||||
self.mode = None
|
self.mode = None
|
||||||
self.order_mode: bool = False
|
self.order_mode: bool = False
|
||||||
|
|
@ -596,7 +466,6 @@ class ChartView(ViewBox):
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def open_async_input_handler(
|
async def open_async_input_handler(
|
||||||
self,
|
self,
|
||||||
**handler_kwargs,
|
|
||||||
|
|
||||||
) -> ChartView:
|
) -> ChartView:
|
||||||
|
|
||||||
|
|
@ -607,20 +476,14 @@ class ChartView(ViewBox):
|
||||||
QEvent.KeyPress,
|
QEvent.KeyPress,
|
||||||
QEvent.KeyRelease,
|
QEvent.KeyRelease,
|
||||||
},
|
},
|
||||||
async_handler=partial(
|
async_handler=handle_viewmode_kb_inputs,
|
||||||
handle_viewmode_kb_inputs,
|
|
||||||
**handler_kwargs,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_event.open_handlers(
|
_event.open_handlers(
|
||||||
[self],
|
[self],
|
||||||
event_types={
|
event_types={
|
||||||
gs_mouse.GraphicsSceneMousePress,
|
gs_mouse.GraphicsSceneMousePress,
|
||||||
},
|
},
|
||||||
async_handler=partial(
|
async_handler=handle_viewmode_mouse,
|
||||||
handle_viewmode_mouse,
|
|
||||||
**handler_kwargs,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
yield self
|
yield self
|
||||||
|
|
@ -636,9 +499,8 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
def wheelEvent(
|
def wheelEvent(
|
||||||
self,
|
self,
|
||||||
ev: QWheelEvent | None = None,
|
ev,
|
||||||
axis: int | None = None,
|
axis=None,
|
||||||
delta: float | None = None,
|
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
Override "center-point" location for scrolling.
|
Override "center-point" location for scrolling.
|
||||||
|
|
@ -649,12 +511,6 @@ class ChartView(ViewBox):
|
||||||
TODO: PR a method into ``pyqtgraph`` to make this configurable
|
TODO: PR a method into ``pyqtgraph`` to make this configurable
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# NOTE: certain operations are only avail when this handler is
|
|
||||||
# actually called on events.
|
|
||||||
if ev is None:
|
|
||||||
assert delta
|
|
||||||
assert axis is None
|
|
||||||
|
|
||||||
linked = self.linked
|
linked = self.linked
|
||||||
if (
|
if (
|
||||||
not linked
|
not linked
|
||||||
|
|
@ -665,7 +521,7 @@ class ChartView(ViewBox):
|
||||||
mask = [False, False]
|
mask = [False, False]
|
||||||
mask[axis] = self.state['mouseEnabled'][axis]
|
mask[axis] = self.state['mouseEnabled'][axis]
|
||||||
else:
|
else:
|
||||||
mask: list[bool] = self.state['mouseEnabled'][:]
|
mask = self.state['mouseEnabled'][:]
|
||||||
|
|
||||||
chart = self.linked.chart
|
chart = self.linked.chart
|
||||||
|
|
||||||
|
|
@ -686,15 +542,8 @@ class ChartView(ViewBox):
|
||||||
# return
|
# return
|
||||||
|
|
||||||
# actual scaling factor
|
# actual scaling factor
|
||||||
delta: float = ev.delta() if ev else delta
|
s = 1.016 ** (ev.delta() * -1 / 20) # self.state['wheelScaleFactor'])
|
||||||
scale_factor: float = 1.016 ** (delta * -1 / 20)
|
s = [(None if m is False else s) for m in mask]
|
||||||
|
|
||||||
# NOTE: if elem is False -> None meaning "do not scale that
|
|
||||||
# axis".
|
|
||||||
scales: list[float | bool] = [
|
|
||||||
(None if m is False else scale_factor)
|
|
||||||
for m in mask
|
|
||||||
]
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
# zoom happened on axis
|
# zoom happened on axis
|
||||||
|
|
@ -717,7 +566,7 @@ class ChartView(ViewBox):
|
||||||
).map(ev.pos())
|
).map(ev.pos())
|
||||||
)
|
)
|
||||||
# scale_y = 1.3 ** (center.y() * -1 / 20)
|
# scale_y = 1.3 ** (center.y() * -1 / 20)
|
||||||
self.scaleBy(scales, center)
|
self.scaleBy(s, center)
|
||||||
|
|
||||||
# zoom in view-box area
|
# zoom in view-box area
|
||||||
else:
|
else:
|
||||||
|
|
@ -732,7 +581,7 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
# NOTE: scroll "around" the right most datum-element in view
|
# NOTE: scroll "around" the right most datum-element in view
|
||||||
# gives the feeling of staying "pinned" in place.
|
# gives the feeling of staying "pinned" in place.
|
||||||
self.scaleBy(scales, focal)
|
self.scaleBy(s, focal)
|
||||||
|
|
||||||
# XXX: the order of the next 2 lines i'm pretty sure
|
# XXX: the order of the next 2 lines i'm pretty sure
|
||||||
# matters, we want the resize to trigger before the graphics
|
# matters, we want the resize to trigger before the graphics
|
||||||
|
|
@ -752,23 +601,21 @@ class ChartView(ViewBox):
|
||||||
self.interact_graphics_cycle()
|
self.interact_graphics_cycle()
|
||||||
self.interact_graphics_cycle()
|
self.interact_graphics_cycle()
|
||||||
|
|
||||||
if ev:
|
|
||||||
ev.accept()
|
ev.accept()
|
||||||
|
|
||||||
def mouseDragEvent(
|
def mouseDragEvent(
|
||||||
self,
|
self,
|
||||||
ev: mevs.MouseDragEvent,
|
ev,
|
||||||
axis: int | None = None,
|
axis: int | None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
pos: Point = ev.pos()
|
pos = ev.pos()
|
||||||
lastPos: Point = ev.lastPos()
|
lastPos = ev.lastPos()
|
||||||
dif: Point = (pos - lastPos) * -1
|
dif = pos - lastPos
|
||||||
# dif: Point = pos - lastPos
|
dif = dif * -1
|
||||||
# dif: Point = dif * -1
|
|
||||||
|
|
||||||
# NOTE: if axis is specified, event will only affect that axis.
|
# NOTE: if axis is specified, event will only affect that axis.
|
||||||
btn = ev.button()
|
button = ev.button()
|
||||||
|
|
||||||
# Ignore axes if mouse is disabled
|
# Ignore axes if mouse is disabled
|
||||||
mouseEnabled = np.array(
|
mouseEnabled = np.array(
|
||||||
|
|
@ -780,7 +627,7 @@ class ChartView(ViewBox):
|
||||||
mask[1-axis] = 0.0
|
mask[1-axis] = 0.0
|
||||||
|
|
||||||
# Scale or translate based on mouse button
|
# Scale or translate based on mouse button
|
||||||
if btn & (
|
if button & (
|
||||||
QtCore.Qt.LeftButton | QtCore.Qt.MidButton
|
QtCore.Qt.LeftButton | QtCore.Qt.MidButton
|
||||||
):
|
):
|
||||||
# zoom y-axis ONLY when click-n-drag on it
|
# zoom y-axis ONLY when click-n-drag on it
|
||||||
|
|
@ -803,55 +650,34 @@ class ChartView(ViewBox):
|
||||||
# XXX: WHY
|
# XXX: WHY
|
||||||
ev.accept()
|
ev.accept()
|
||||||
|
|
||||||
down_pos: Point = ev.buttonDownPos(
|
down_pos = ev.buttonDownPos()
|
||||||
btn=btn,
|
|
||||||
)
|
|
||||||
scen_pos: Point = ev.scenePos()
|
|
||||||
scen_down_pos: Point = ev.buttonDownScenePos(
|
|
||||||
btn=btn,
|
|
||||||
)
|
|
||||||
|
|
||||||
# This is the final position in the drag
|
# This is the final position in the drag
|
||||||
if ev.isFinish():
|
if ev.isFinish():
|
||||||
|
|
||||||
# import pdbp; pdbp.set_trace()
|
self.select_box.mouse_drag_released(down_pos, pos)
|
||||||
|
|
||||||
# NOTE: think of this as a `.mouse_drag_release()`
|
|
||||||
# (bc HINT that's what i called the shit ass
|
|
||||||
# method that wrapped this call [yes, as a single
|
|
||||||
# fucking call] originally.. you bish, guille)
|
|
||||||
# Bo.. oraleeee
|
|
||||||
self.select_box.set_scen_pos(
|
|
||||||
# down_pos,
|
|
||||||
# pos,
|
|
||||||
scen_down_pos,
|
|
||||||
scen_pos,
|
|
||||||
)
|
|
||||||
|
|
||||||
# this is the zoom transform cmd
|
|
||||||
ax = QtCore.QRectF(down_pos, pos)
|
ax = QtCore.QRectF(down_pos, pos)
|
||||||
ax = self.childGroup.mapRectFromParent(ax)
|
ax = self.childGroup.mapRectFromParent(ax)
|
||||||
# self.showAxRect(ax)
|
|
||||||
|
# this is the zoom transform cmd
|
||||||
|
self.showAxRect(ax)
|
||||||
|
|
||||||
# axis history tracking
|
# axis history tracking
|
||||||
self.axHistoryPointer += 1
|
self.axHistoryPointer += 1
|
||||||
self.axHistory = self.axHistory[
|
self.axHistory = self.axHistory[
|
||||||
:self.axHistoryPointer] + [ax]
|
:self.axHistoryPointer] + [ax]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.select_box.set_scen_pos(
|
print('drag finish?')
|
||||||
# down_pos,
|
self.select_box.set_pos(down_pos, pos)
|
||||||
# pos,
|
|
||||||
scen_down_pos,
|
|
||||||
scen_pos,
|
|
||||||
)
|
|
||||||
|
|
||||||
# update shape of scale box
|
# update shape of scale box
|
||||||
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
||||||
# breakpoint()
|
self.updateScaleBox(
|
||||||
# self.updateScaleBox(
|
down_pos,
|
||||||
# down_pos,
|
ev.pos(),
|
||||||
# ev.pos(),
|
)
|
||||||
# )
|
|
||||||
|
|
||||||
# PANNING MODE
|
# PANNING MODE
|
||||||
else:
|
else:
|
||||||
|
|
@ -890,7 +716,7 @@ class ChartView(ViewBox):
|
||||||
# ev.accept()
|
# ev.accept()
|
||||||
|
|
||||||
# WEIRD "RIGHT-CLICK CENTER ZOOM" MODE
|
# WEIRD "RIGHT-CLICK CENTER ZOOM" MODE
|
||||||
elif btn & QtCore.Qt.RightButton:
|
elif button & QtCore.Qt.RightButton:
|
||||||
|
|
||||||
if self.state['aspectLocked'] is not False:
|
if self.state['aspectLocked'] is not False:
|
||||||
mask[0] = 0
|
mask[0] = 0
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue