commit
e51ba404fc
|
@ -43,16 +43,21 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build DB container
|
||||
run: docker build -t piker:elastic dockering/elastic
|
||||
# elastic only
|
||||
# - name: Build DB container
|
||||
# run: docker build -t piker:elastic dockering/elastic
|
||||
|
||||
- name: Setup python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
# elastic only
|
||||
# - name: Install dependencies
|
||||
# run: pip install -U .[es] -r requirements-test.txt -r requirements.txt --upgrade-strategy eager
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -U .[es] -r requirements-test.txt -r requirements.txt --upgrade-strategy eager
|
||||
run: pip install -U . -r requirements-test.txt -r requirements.txt --upgrade-strategy eager
|
||||
|
||||
- name: Test suite
|
||||
run: pytest tests -rs
|
||||
|
|
|
@ -1,19 +1,32 @@
|
|||
[questrade]
|
||||
refresh_token = ""
|
||||
access_token = ""
|
||||
api_server = "https://api06.iq.questrade.com/"
|
||||
refresh_token = ''
|
||||
access_token = ''
|
||||
api_server = 'https://api06.iq.questrade.com/'
|
||||
expires_in = 1800
|
||||
token_type = "Bearer"
|
||||
token_type = 'Bearer'
|
||||
expires_at = 1616095326.355846
|
||||
|
||||
|
||||
[deribit]
|
||||
key_id = ''
|
||||
key_secret = ''
|
||||
|
||||
|
||||
[kraken]
|
||||
key_descr = "api_0"
|
||||
api_key = ""
|
||||
secret = ""
|
||||
key_descr = ''
|
||||
api_key = ''
|
||||
secret = ''
|
||||
|
||||
|
||||
[kucoin]
|
||||
key_id = ''
|
||||
key_secret = ''
|
||||
key_passphrase = ''
|
||||
|
||||
|
||||
[ib]
|
||||
hosts = [
|
||||
"127.0.0.1",
|
||||
'127.0.0.1',
|
||||
]
|
||||
# XXX: the order in which ports will be scanned
|
||||
# (by the `brokerd` daemon-actor)
|
||||
|
@ -30,8 +43,8 @@ ports = [
|
|||
# is not supported so you have to manually download
|
||||
# and XML report and put it in a location that can be
|
||||
# accessed by the ``brokerd.ib`` backend code for parsing.
|
||||
flex_token = '666666666666666666666666'
|
||||
flex_trades_query_id = '666666' # live account
|
||||
flex_token = ''
|
||||
flex_trades_query_id = '' # live account
|
||||
|
||||
# when clients are being scanned this determines
|
||||
# which clients are preferred to be used for data
|
||||
|
@ -47,11 +60,6 @@ prefer_data_account = [
|
|||
# the order in which accounts will be selectable
|
||||
# in the order mode UI (if found via clients during
|
||||
# API-app scanning)when a new symbol is loaded.
|
||||
paper = "XX0000000"
|
||||
margin = "X0000000"
|
||||
ira = "X0000000"
|
||||
|
||||
|
||||
[deribit]
|
||||
key_id = 'XXXXXXXX'
|
||||
key_secret = 'Xx_XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx'
|
||||
paper = 'XX0000000'
|
||||
margin = 'X0000000'
|
||||
ira = 'X0000000'
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
[network]
|
||||
tsdb.backend = 'marketstore'
|
||||
tsdb.host = 'localhost'
|
||||
tsdb.grpc_port = 5995
|
|
@ -2,8 +2,21 @@
|
|||
# https://github.com/waytrade/ib-gateway-docker/blob/master/docker-compose.yml
|
||||
version: "3.5"
|
||||
|
||||
|
||||
services:
|
||||
|
||||
ib_gw_paper:
|
||||
|
||||
# apparently java is a mega cukc:
|
||||
# https://stackoverflow.com/a/56895801
|
||||
# https://bugs.openjdk.org/browse/JDK-8150460
|
||||
ulimits:
|
||||
# nproc: 65535
|
||||
nproc: 6000
|
||||
nofile:
|
||||
soft: 2000
|
||||
hard: 3000
|
||||
|
||||
# other image tags available:
|
||||
# https://github.com/waytrade/ib-gateway-docker#supported-tags
|
||||
# image: waytrade/ib-gateway:981.3j
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
# 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
|
||||
|
@ -14,14 +14,20 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
'''
|
||||
Cacheing apis and toolz.
|
||||
|
||||
"""
|
||||
'''
|
||||
|
||||
from collections import OrderedDict
|
||||
from contextlib import (
|
||||
asynccontextmanager,
|
||||
asynccontextmanager as acm,
|
||||
)
|
||||
from typing import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
ParamSpec,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from tractor.trionics import maybe_open_context
|
||||
|
@ -32,19 +38,54 @@ from .log import get_logger
|
|||
|
||||
log = get_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
P = ParamSpec("P")
|
||||
|
||||
def async_lifo_cache(maxsize=128):
|
||||
"""Async ``cache`` with a LIFO policy.
|
||||
|
||||
# TODO: move this to `tractor.trionics`..
|
||||
# - egs. to replicate for tests: https://github.com/aio-libs/async-lru#usage
|
||||
# - their suite as well:
|
||||
# https://github.com/aio-libs/async-lru/tree/master/tests
|
||||
# - asked trio_util about it too:
|
||||
# https://github.com/groove-x/trio-util/issues/21
|
||||
def async_lifo_cache(
|
||||
maxsize=128,
|
||||
|
||||
# NOTE: typing style was learned from:
|
||||
# https://stackoverflow.com/a/71132186
|
||||
) -> Callable[
|
||||
Callable[P, Awaitable[T]],
|
||||
Callable[
|
||||
Callable[P, Awaitable[T]],
|
||||
Callable[P, Awaitable[T]],
|
||||
],
|
||||
]:
|
||||
'''
|
||||
Async ``cache`` with a LIFO policy.
|
||||
|
||||
Implemented my own since no one else seems to have
|
||||
a standard. I'll wait for the smarter people to come
|
||||
up with one, but until then...
|
||||
"""
|
||||
|
||||
NOTE: when decorating, due to this simple/naive implementation, you
|
||||
MUST call the decorator like,
|
||||
|
||||
.. code:: python
|
||||
|
||||
@async_lifo_cache()
|
||||
async def cache_target():
|
||||
|
||||
'''
|
||||
cache = OrderedDict()
|
||||
|
||||
def decorator(fn):
|
||||
def decorator(
|
||||
fn: Callable[P, Awaitable[T]],
|
||||
) -> Callable[P, Awaitable[T]]:
|
||||
|
||||
async def wrapper(*args):
|
||||
async def decorated(
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> T:
|
||||
key = args
|
||||
try:
|
||||
return cache[key]
|
||||
|
@ -53,16 +94,20 @@ def async_lifo_cache(maxsize=128):
|
|||
# discard last added new entry
|
||||
cache.popitem()
|
||||
|
||||
# do it
|
||||
cache[key] = await fn(*args)
|
||||
# call underlying
|
||||
cache[key] = await fn(
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
return cache[key]
|
||||
|
||||
return wrapper
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
# TODO: move this to `.brokers.utils`..
|
||||
@acm
|
||||
async def open_cached_client(
|
||||
brokername: str,
|
||||
) -> 'Client': # noqa
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
# 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/>.
|
||||
|
||||
'''
|
||||
"Accounting for degens": count dem numberz that tracks how much you got
|
||||
for tendiez.
|
||||
|
||||
'''
|
||||
from ..log import get_logger
|
||||
|
||||
from ._ledger import (
|
||||
iter_by_dt,
|
||||
Transaction,
|
||||
TransactionLedger,
|
||||
open_trade_ledger,
|
||||
)
|
||||
from ._pos import (
|
||||
load_pps_from_ledger,
|
||||
open_pps,
|
||||
Position,
|
||||
PpTable,
|
||||
)
|
||||
from ._mktinfo import (
|
||||
Asset,
|
||||
dec_digits,
|
||||
digits_to_dec,
|
||||
MktPair,
|
||||
Symbol,
|
||||
unpack_fqme,
|
||||
)
|
||||
from ._allocate import (
|
||||
mk_allocator,
|
||||
Allocator,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'Allocator',
|
||||
'Asset',
|
||||
'MktPair',
|
||||
'Position',
|
||||
'PpTable',
|
||||
'Symbol',
|
||||
'Transaction',
|
||||
'TransactionLedger',
|
||||
'dec_digits',
|
||||
'digits_to_dec',
|
||||
'iter_by_dt',
|
||||
'load_pps_from_ledger',
|
||||
'mk_allocator',
|
||||
'open_pps',
|
||||
'open_trade_ledger',
|
||||
'unpack_fqme',
|
||||
]
|
||||
|
||||
|
||||
def get_likely_pair(
|
||||
src: str,
|
||||
dst: str,
|
||||
bs_mktid: str,
|
||||
|
||||
) -> str | None:
|
||||
'''
|
||||
Attempt to get the likely trading pair matching a given destination
|
||||
asset `dst: str`.
|
||||
|
||||
'''
|
||||
try:
|
||||
src_name_start = bs_mktid.rindex(src)
|
||||
except (
|
||||
ValueError, # substr not found
|
||||
):
|
||||
# TODO: handle nested positions..(i.e.
|
||||
# positions where the src fiat was used to
|
||||
# buy some other dst which was furhter used
|
||||
# to buy another dst..)
|
||||
# log.warning(
|
||||
# f'No src fiat {src} found in {bs_mktid}?'
|
||||
# )
|
||||
return
|
||||
|
||||
likely_dst = bs_mktid[:src_name_start]
|
||||
if likely_dst == dst:
|
||||
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)}'
|
||||
)
|
|
@ -23,9 +23,9 @@ from typing import Optional
|
|||
|
||||
from bidict import bidict
|
||||
|
||||
from ..data._source import Symbol
|
||||
from ._pos import Position
|
||||
from . import MktPair
|
||||
from ..data.types import Struct
|
||||
from ..pp import Position
|
||||
|
||||
|
||||
_size_units = bidict({
|
||||
|
@ -42,7 +42,7 @@ SizeUnit = Enum(
|
|||
|
||||
class Allocator(Struct):
|
||||
|
||||
symbol: Symbol
|
||||
mkt: MktPair
|
||||
|
||||
# TODO: if we ever want ot support non-uniform entry-slot-proportion
|
||||
# "sizes"
|
||||
|
@ -114,8 +114,8 @@ class Allocator(Struct):
|
|||
depending on position / order entry config.
|
||||
|
||||
'''
|
||||
sym = self.symbol
|
||||
ld = sym.lot_size_digits
|
||||
mkt: MktPair = self.mkt
|
||||
ld: int = mkt.size_tick_digits
|
||||
|
||||
size_unit = self.size_unit
|
||||
live_size = live_pp.size
|
||||
|
@ -125,13 +125,13 @@ class Allocator(Struct):
|
|||
u_per_slot, currency_per_slot = self.step_sizes()
|
||||
|
||||
if size_unit == 'units':
|
||||
slot_size = u_per_slot
|
||||
l_sub_pp = self.units_limit - abs_live_size
|
||||
slot_size: float = u_per_slot
|
||||
l_sub_pp: float = self.units_limit - abs_live_size
|
||||
|
||||
elif size_unit == 'currency':
|
||||
live_cost_basis = abs_live_size * live_pp.ppu
|
||||
slot_size = currency_per_slot / price
|
||||
l_sub_pp = (self.currency_limit - live_cost_basis) / price
|
||||
live_cost_basis: float = abs_live_size * live_pp.ppu
|
||||
slot_size: float = currency_per_slot / price
|
||||
l_sub_pp: float = (self.currency_limit - live_cost_basis) / price
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
|
@ -141,8 +141,14 @@ class Allocator(Struct):
|
|||
# an entry (adding-to or starting a pp)
|
||||
if (
|
||||
live_size == 0
|
||||
or (action == 'buy' and live_size > 0)
|
||||
or action == 'sell' and live_size < 0
|
||||
or (
|
||||
action == 'buy'
|
||||
and live_size > 0
|
||||
)
|
||||
or (
|
||||
action == 'sell'
|
||||
and live_size < 0
|
||||
)
|
||||
):
|
||||
order_size = min(
|
||||
slot_size,
|
||||
|
@ -178,7 +184,7 @@ class Allocator(Struct):
|
|||
order_size = max(slotted_pp, slot_size)
|
||||
|
||||
if (
|
||||
abs_live_size < slot_size or
|
||||
abs_live_size < slot_size
|
||||
|
||||
# NOTE: front/back "loading" heurstic:
|
||||
# if the remaining pp is in between 0-1.5x a slot's
|
||||
|
@ -187,14 +193,17 @@ class Allocator(Struct):
|
|||
# **without** going past a net-zero pp. if the pp is
|
||||
# > 1.5x a slot size, then front load: exit a slot's and
|
||||
# expect net-zero to be acquired on the final exit.
|
||||
slot_size < pp_size < round((1.5*slot_size), ndigits=ld) or
|
||||
or slot_size < pp_size < round((1.5*slot_size), ndigits=ld)
|
||||
or (
|
||||
|
||||
# underlying requires discrete (int) units (eg. stocks)
|
||||
# and thus our slot size (based on our limit) would
|
||||
# exit a fractional unit's worth so, presuming we aren't
|
||||
# supporting a fractional-units-style broker, we need
|
||||
# exit the final unit.
|
||||
ld == 0 and abs_live_size == 1
|
||||
# underlying requires discrete (int) units (eg. stocks)
|
||||
# and thus our slot size (based on our limit) would
|
||||
# exit a fractional unit's worth so, presuming we aren't
|
||||
# supporting a fractional-units-style broker, we need
|
||||
# exit the final unit.
|
||||
ld == 0
|
||||
and abs_live_size == 1
|
||||
)
|
||||
):
|
||||
order_size = abs_live_size
|
||||
|
||||
|
@ -203,13 +212,14 @@ class Allocator(Struct):
|
|||
# compute a fractional slots size to display
|
||||
slots_used = self.slots_used(
|
||||
Position(
|
||||
symbol=sym,
|
||||
mkt=mkt,
|
||||
size=order_size,
|
||||
ppu=price,
|
||||
bsuid=sym,
|
||||
bs_mktid=mkt.bs_mktid,
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: render an actual ``Executable`` type here?
|
||||
return {
|
||||
'size': abs(round(order_size, ndigits=ld)),
|
||||
'size_digits': ld,
|
||||
|
@ -249,7 +259,7 @@ class Allocator(Struct):
|
|||
|
||||
def mk_allocator(
|
||||
|
||||
symbol: Symbol,
|
||||
mkt: MktPair,
|
||||
startup_pp: Position,
|
||||
|
||||
# default allocation settings
|
||||
|
@ -276,6 +286,6 @@ def mk_allocator(
|
|||
defaults.update(user_def)
|
||||
|
||||
return Allocator(
|
||||
symbol=symbol,
|
||||
mkt=mkt,
|
||||
**defaults,
|
||||
)
|
|
@ -0,0 +1,295 @@
|
|||
# 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 and transaction ledger processing.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from collections import UserDict
|
||||
from contextlib import contextmanager as cm
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Iterator,
|
||||
Union,
|
||||
Generator
|
||||
)
|
||||
|
||||
from pendulum import (
|
||||
datetime,
|
||||
DateTime,
|
||||
from_timestamp,
|
||||
parse,
|
||||
)
|
||||
import tomli_w # for fast ledger writing
|
||||
|
||||
from .. import config
|
||||
from ..data.types import Struct
|
||||
from ..log import get_logger
|
||||
from ._mktinfo import (
|
||||
Symbol, # legacy
|
||||
MktPair,
|
||||
Asset,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class Transaction(Struct, frozen=True):
|
||||
|
||||
# TODO: unify this with the `MktPair`,
|
||||
# once we have that as a required field,
|
||||
# we don't really need the fqme any more..
|
||||
fqme: str
|
||||
|
||||
tid: Union[str, int] # unique transaction id
|
||||
size: float
|
||||
price: float
|
||||
cost: float # commisions or other additional costs
|
||||
dt: datetime
|
||||
|
||||
# TODO: we can drop this right since we
|
||||
# can instead expect the backend to provide this
|
||||
# via the `MktPair`?
|
||||
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
|
||||
# ensures the instrument-symbol market key for this record is unique
|
||||
# in the "their backend/system" sense; i.e. this uid for the market
|
||||
# as defined (internally) in some namespace defined by the broker
|
||||
# service.
|
||||
bs_mktid: str | int | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
dct = super().to_dict()
|
||||
|
||||
# TODO: switch to sys!
|
||||
dct.pop('sym')
|
||||
|
||||
# ensure we use a pendulum formatted
|
||||
# ISO style str here!@
|
||||
dct['dt'] = str(self.dt)
|
||||
return dct
|
||||
|
||||
|
||||
class TransactionLedger(UserDict):
|
||||
'''
|
||||
Very simple ``dict`` wrapper + ``pathlib.Path`` handle to
|
||||
a TOML formatted transaction file for enabling file writes
|
||||
dynamically whilst still looking exactly like a ``dict`` from the
|
||||
outside.
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
self,
|
||||
ledger_dict: dict,
|
||||
file_path: Path,
|
||||
tx_sort: Callable,
|
||||
|
||||
) -> None:
|
||||
self.file_path = file_path
|
||||
self.tx_sort = tx_sort
|
||||
super().__init__(ledger_dict)
|
||||
|
||||
def update_from_t(
|
||||
self,
|
||||
t: Transaction,
|
||||
) -> None:
|
||||
self.data[t.tid] = t.to_dict()
|
||||
|
||||
def iter_trans(
|
||||
self,
|
||||
mkt_by_fqme: dict[str, MktPair],
|
||||
broker: str = 'paper',
|
||||
|
||||
) -> Generator[
|
||||
tuple[str, Transaction],
|
||||
None,
|
||||
None,
|
||||
]:
|
||||
'''
|
||||
Deliver trades records in ``(key: str, t: Transaction)``
|
||||
form via generator.
|
||||
|
||||
'''
|
||||
if broker != 'paper':
|
||||
raise NotImplementedError('Per broker support not dun yet!')
|
||||
|
||||
# TODO: lookup some standard normalizer
|
||||
# func in the backend?
|
||||
# from ..brokers import get_brokermod
|
||||
# mod = get_brokermod(broker)
|
||||
# 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 tid, txdict in self.data.items():
|
||||
# special field handling for datetimes
|
||||
# to ensure pendulum is used!
|
||||
fqme = txdict.get('fqme') or txdict['fqsn']
|
||||
dt = parse(txdict['dt'])
|
||||
expiry = txdict.get('expiry')
|
||||
|
||||
mkt = mkt_by_fqme.get(fqme)
|
||||
if not mkt:
|
||||
# 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
|
||||
|
||||
def to_trans(
|
||||
self,
|
||||
**kwargs,
|
||||
|
||||
) -> dict[str, Transaction]:
|
||||
'''
|
||||
Return entire output from ``.iter_trans()`` in a ``dict``.
|
||||
|
||||
'''
|
||||
return dict(self.iter_trans(**kwargs))
|
||||
|
||||
def write_config(
|
||||
self,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Render the self.data ledger dict to it's TOML file form.
|
||||
|
||||
'''
|
||||
cpy = self.data.copy()
|
||||
towrite: dict[str, Any] = {}
|
||||
for tid, trans in cpy.items():
|
||||
|
||||
# drop key for non-expiring assets
|
||||
txdict = towrite[tid] = self.data[tid]
|
||||
if (
|
||||
'expiry' in txdict
|
||||
and txdict['expiry'] is None
|
||||
):
|
||||
txdict.pop('expiry')
|
||||
|
||||
# re-write old acro-key
|
||||
fqme = txdict.get('fqsn')
|
||||
if fqme:
|
||||
txdict['fqme'] = fqme
|
||||
|
||||
with self.file_path.open(mode='wb') as fp:
|
||||
tomli_w.dump(towrite, fp)
|
||||
|
||||
|
||||
def iter_by_dt(
|
||||
records: dict[str, Any],
|
||||
|
||||
# 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(
|
||||
pair: tuple[str, dict],
|
||||
) -> DateTime:
|
||||
_, txdict = pair
|
||||
k, v, parser = next(
|
||||
(k, txdict[k], parsers[k]) for k in parsers if k in txdict
|
||||
)
|
||||
|
||||
return parser(v) if parser else v
|
||||
|
||||
for tid, data in sorted(
|
||||
records.items(),
|
||||
key=key or dyn_parse_to_dt,
|
||||
):
|
||||
yield tid, data
|
||||
|
||||
|
||||
@cm
|
||||
def open_trade_ledger(
|
||||
broker: str,
|
||||
account: str,
|
||||
|
||||
# default is to sort by detected datetime-ish field
|
||||
tx_sort: Callable = iter_by_dt,
|
||||
|
||||
) -> Generator[dict, None, None]:
|
||||
'''
|
||||
Indempotently create and read in a trade log file from the
|
||||
``<configuration_dir>/ledgers/`` directory.
|
||||
|
||||
Files are named per broker account of the form
|
||||
``<brokername>_<accountname>.toml``. The ``accountname`` here is the
|
||||
name as defined in the user's ``brokers.toml`` config.
|
||||
|
||||
'''
|
||||
ledger_dict, fpath = config.load_ledger(broker, account)
|
||||
cpy = ledger_dict.copy()
|
||||
ledger = TransactionLedger(
|
||||
ledger_dict=cpy,
|
||||
file_path=fpath,
|
||||
tx_sort=tx_sort,
|
||||
)
|
||||
try:
|
||||
yield ledger
|
||||
finally:
|
||||
if ledger.data != ledger_dict:
|
||||
|
||||
# TODO: show diff output?
|
||||
# https://stackoverflow.com/questions/12956957/print-diff-of-python-dictionaries
|
||||
log.info(f'Updating ledger for {fpath}:\n')
|
||||
ledger.write_config()
|
|
@ -0,0 +1,668 @@
|
|||
# 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/>.
|
||||
|
||||
'''
|
||||
Market (pair) meta-info layer: sane addressing semantics and meta-data
|
||||
for cross-provider marketplaces.
|
||||
|
||||
We intoduce the concept of,
|
||||
|
||||
- a FQMA: fully qualified market address,
|
||||
- a sane schema for FQMAs including derivatives,
|
||||
- a msg-serializeable description of markets for
|
||||
easy sharing with other pikers B)
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from decimal import (
|
||||
Decimal,
|
||||
ROUND_HALF_EVEN,
|
||||
)
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
)
|
||||
|
||||
from ..data.types import Struct
|
||||
|
||||
|
||||
_underlyings: list[str] = [
|
||||
'stock',
|
||||
'bond',
|
||||
'crypto',
|
||||
'fiat',
|
||||
'commodity',
|
||||
]
|
||||
|
||||
|
||||
_derivs: list[str] = [
|
||||
'swap',
|
||||
'future',
|
||||
'continuous_future',
|
||||
'option',
|
||||
'futures_option',
|
||||
|
||||
# if we can't figure it out, presume the worst XD
|
||||
'unknown',
|
||||
]
|
||||
|
||||
# NOTE: a tag for other subsystems to try
|
||||
# and do default settings for certain things:
|
||||
# - allocator does unit vs. dolla size limiting.
|
||||
AssetTypeName: Literal[
|
||||
_underlyings
|
||||
+
|
||||
_derivs
|
||||
]
|
||||
|
||||
# egs. stock, futer, option, bond etc.
|
||||
|
||||
|
||||
def dec_digits(
|
||||
value: float | str | Decimal,
|
||||
|
||||
) -> int:
|
||||
'''
|
||||
Return the number of precision digits read from a decimal or float
|
||||
value.
|
||||
|
||||
'''
|
||||
if value == 0:
|
||||
return 0
|
||||
|
||||
return int(
|
||||
-Decimal(str(value)).as_tuple().exponent
|
||||
)
|
||||
|
||||
|
||||
float_digits = dec_digits
|
||||
|
||||
|
||||
def digits_to_dec(
|
||||
ndigits: int,
|
||||
) -> Decimal:
|
||||
'''
|
||||
Return the minimum float value for an input integer value.
|
||||
|
||||
eg. 3 -> 0.001
|
||||
|
||||
'''
|
||||
if ndigits == 0:
|
||||
return Decimal('0')
|
||||
|
||||
return Decimal('0.' + '0'*(ndigits-1) + '1')
|
||||
|
||||
|
||||
class Asset(Struct, frozen=True):
|
||||
'''
|
||||
Container type describing any transactable asset and its
|
||||
contract-like and/or underlying technology meta-info.
|
||||
|
||||
'''
|
||||
name: str
|
||||
atype: str # AssetTypeName
|
||||
|
||||
# minimum transaction size / precision.
|
||||
# eg. for buttcoin this is a "satoshi".
|
||||
tx_tick: Decimal
|
||||
|
||||
# NOTE: additional info optionally packed in by the backend, but
|
||||
# should not be explicitly required in our generic API.
|
||||
info: dict = {} # make it frozen?
|
||||
|
||||
# TODO?
|
||||
# _to_dict_skip = {'info'}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def quantize(
|
||||
self,
|
||||
size: float,
|
||||
|
||||
) -> Decimal:
|
||||
'''
|
||||
Truncate input ``size: float`` using ``Decimal``
|
||||
quantized form of the digit precision defined
|
||||
by ``self.lot_tick_size``.
|
||||
|
||||
'''
|
||||
digits = float_digits(self.tx_tick)
|
||||
return Decimal(size).quantize(
|
||||
Decimal(f'1.{"0".ljust(digits, "0")}'),
|
||||
rounding=ROUND_HALF_EVEN
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def guess_from_mkt_ep_key(
|
||||
cls,
|
||||
mkt_ep_key: str,
|
||||
atype: str | None = None,
|
||||
|
||||
) -> Asset:
|
||||
'''
|
||||
A hacky guess method for presuming a (target) asset's properties
|
||||
based on either the actualy market endpoint key, or config settings
|
||||
from the user.
|
||||
|
||||
'''
|
||||
atype = atype or 'unknown'
|
||||
|
||||
# attempt to strip off any source asset
|
||||
# via presumed syntax of:
|
||||
# - <dst>/<src>
|
||||
# - <dst>.<src>
|
||||
# - etc.
|
||||
for char in ['/', '.']:
|
||||
dst, _, src = mkt_ep_key.partition(char)
|
||||
if src:
|
||||
if not atype:
|
||||
atype = 'fiat'
|
||||
break
|
||||
|
||||
return Asset(
|
||||
name=dst,
|
||||
atype=atype,
|
||||
tx_tick=Decimal('0.01'),
|
||||
)
|
||||
|
||||
|
||||
def maybe_cons_tokens(
|
||||
tokens: list[Any],
|
||||
delim_char: str = '.',
|
||||
) -> str:
|
||||
'''
|
||||
Construct `str` output from a maybe-concatenation of input
|
||||
sequence of elements in ``tokens``.
|
||||
|
||||
'''
|
||||
return delim_char.join(filter(bool, tokens)).lower()
|
||||
|
||||
|
||||
class MktPair(Struct, frozen=True):
|
||||
'''
|
||||
Market description for a pair of assets which are tradeable:
|
||||
a market which enables transactions of the form,
|
||||
buy: source asset -> destination asset
|
||||
sell: destination asset -> source asset
|
||||
|
||||
The main intention of this type is for a **simple** cross-asset
|
||||
venue/broker normalized descrption type from which all
|
||||
market-auctions can be mapped from FQME identifiers.
|
||||
|
||||
TODO: our eventual target fqme format/schema is:
|
||||
<dst>/<src>.<expiry>.<con_info_1>.<con_info_2>. -> .<venue>.<broker>
|
||||
^ -- optional tokens ------------------------------- ^
|
||||
|
||||
'''
|
||||
dst: str | Asset
|
||||
# "destination asset" (name) used to buy *to*
|
||||
# (or used to sell *from*)
|
||||
|
||||
price_tick: Decimal # minimum price increment
|
||||
size_tick: Decimal # minimum size (aka vlm) increment
|
||||
# the tick size is the number describing the smallest step in value
|
||||
# available in this market between the source and destination
|
||||
# assets.
|
||||
# https://en.wikipedia.org/wiki/Tick_size
|
||||
# https://en.wikipedia.org/wiki/Commodity_tick
|
||||
# https://en.wikipedia.org/wiki/Percentage_in_point
|
||||
|
||||
# unique "broker id" since every market endpoint provider
|
||||
# has their own nomenclature and schema for market maps.
|
||||
bs_mktid: str
|
||||
broker: str # the middle man giving access
|
||||
|
||||
# NOTE: to start this field is optional but should eventually be
|
||||
# required; the reason is for backward compat since more positioning
|
||||
# calculations were not originally stored with a src asset..
|
||||
|
||||
src: str | Asset = ''
|
||||
# "source asset" (name) used to buy *from*
|
||||
# (or used to sell *to*).
|
||||
|
||||
venue: str = '' # market venue provider name
|
||||
expiry: str = '' # for derivs, expiry datetime parseable str
|
||||
|
||||
# destination asset's financial type/classification name
|
||||
# NOTE: this is required for the order size allocator system,
|
||||
# since we use different default settings based on the type
|
||||
# of the destination asset, eg. futes use a units limits vs.
|
||||
# equities a $limit.
|
||||
# dst_type: AssetTypeName | None = None
|
||||
|
||||
# source asset's financial type/classification name
|
||||
# TODO: is a src type required for trading?
|
||||
# there's no reason to need any more then the one-way alloc-limiter
|
||||
# config right?
|
||||
# src_type: AssetTypeName
|
||||
|
||||
# for derivs, info describing contract, egs.
|
||||
# strike price, call or put, swap type, exercise model, etc.
|
||||
contract_info: list[str] | None = None
|
||||
|
||||
_atype: str = ''
|
||||
|
||||
# NOTE: when cast to `str` return fqme
|
||||
def __str__(self) -> str:
|
||||
return self.fqme
|
||||
|
||||
@classmethod
|
||||
def from_msg(
|
||||
cls,
|
||||
msg: dict[str, Any],
|
||||
|
||||
) -> MktPair:
|
||||
'''
|
||||
Constructor for a received msg-dict normally received over IPC.
|
||||
|
||||
'''
|
||||
dst_asset_msg = msg.pop('dst')
|
||||
src_asset_msg = msg.pop('src')
|
||||
|
||||
if isinstance(dst_asset_msg, str):
|
||||
src: str = str(src_asset_msg)
|
||||
assert isinstance(src, str)
|
||||
return cls.from_fqme(
|
||||
dst_asset_msg,
|
||||
src=src,
|
||||
**msg,
|
||||
)
|
||||
|
||||
else:
|
||||
# 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)
|
||||
|
||||
return cls(
|
||||
dst=dst,
|
||||
src=src,
|
||||
**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()
|
||||
|
||||
@property
|
||||
def resolved(self) -> bool:
|
||||
return isinstance(self.dst, Asset)
|
||||
|
||||
@classmethod
|
||||
def from_fqme(
|
||||
cls,
|
||||
fqme: str,
|
||||
|
||||
price_tick: float | str,
|
||||
size_tick: float | str,
|
||||
bs_mktid: str,
|
||||
|
||||
broker: str | None = None,
|
||||
**kwargs,
|
||||
|
||||
) -> MktPair:
|
||||
|
||||
_fqme: str = fqme
|
||||
if (
|
||||
broker
|
||||
and broker not in fqme
|
||||
):
|
||||
_fqme = f'{fqme}.{broker}'
|
||||
|
||||
broker, mkt_ep_key, venue, suffix = unpack_fqme(_fqme)
|
||||
dst: Asset = Asset.guess_from_mkt_ep_key(
|
||||
mkt_ep_key,
|
||||
atype=kwargs.get('_atype'),
|
||||
)
|
||||
|
||||
# XXX: loading from a fqme string will
|
||||
# leave this pair as "un resolved" meaning
|
||||
# we don't yet have `.dst` set as an `Asset`
|
||||
# which we expect to be filled in by some
|
||||
# backend client with access to that data-info.
|
||||
return cls(
|
||||
# XXX: not resolved to ``Asset`` :(
|
||||
dst=dst,
|
||||
|
||||
broker=broker,
|
||||
venue=venue,
|
||||
# XXX NOTE: we presume this token
|
||||
# if the expiry for now!
|
||||
expiry=suffix,
|
||||
|
||||
price_tick=price_tick,
|
||||
size_tick=size_tick,
|
||||
bs_mktid=bs_mktid,
|
||||
|
||||
**kwargs,
|
||||
|
||||
).copy()
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
'''
|
||||
The "endpoint key" for this market.
|
||||
|
||||
'''
|
||||
return self.pair
|
||||
|
||||
def pair(
|
||||
self,
|
||||
delim_char: str | None = None,
|
||||
) -> str:
|
||||
'''
|
||||
The "endpoint asset pair key" for this market.
|
||||
Eg. mnq/usd or btc/usdt or xmr/btc
|
||||
|
||||
In most other tina platforms this is referred to as the
|
||||
"symbol".
|
||||
|
||||
'''
|
||||
return maybe_cons_tokens(
|
||||
[str(self.dst),
|
||||
str(self.src)],
|
||||
# TODO: make the default '/'
|
||||
delim_char=delim_char or '',
|
||||
)
|
||||
|
||||
@property
|
||||
def suffix(self) -> str:
|
||||
'''
|
||||
The "contract suffix" for this market.
|
||||
|
||||
Eg. mnq/usd.20230616.cme.ib
|
||||
^ ----- ^
|
||||
or tsla/usd.20230324.200c.cboe.ib
|
||||
^ ---------- ^
|
||||
|
||||
In most other tina platforms they only show you these details in
|
||||
some kinda "meta data" format, we have FQMEs so we do this up
|
||||
front and explicit.
|
||||
|
||||
'''
|
||||
field_strs = [self.expiry]
|
||||
con_info = self.contract_info
|
||||
if con_info is not None:
|
||||
field_strs.extend(con_info)
|
||||
|
||||
return maybe_cons_tokens(field_strs)
|
||||
|
||||
def get_fqme(
|
||||
self,
|
||||
|
||||
# NOTE: allow dropping the source asset from the
|
||||
# market endpoint's pair key. Eg. to change
|
||||
# mnq/usd.<> -> mnq.<> which is useful when
|
||||
# searching (legacy) stock exchanges.
|
||||
without_src: bool = False,
|
||||
delim_char: str | None = None,
|
||||
|
||||
) -> str:
|
||||
'''
|
||||
Return the fully qualified market endpoint-address for the
|
||||
pair of transacting assets.
|
||||
|
||||
fqme = "fully qualified market endpoint"
|
||||
|
||||
And yes, you pronounce it colloquially as read..
|
||||
|
||||
Basically the idea here is for all client code (consumers of piker's
|
||||
APIs which query the data/broker-provider agnostic layer(s)) should be
|
||||
able to tell which backend / venue / derivative each data feed/flow is
|
||||
from by an explicit string-key of the current form:
|
||||
|
||||
<market-instrument-name>
|
||||
.<venue>
|
||||
.<expiry>
|
||||
.<derivative-suffix-info>
|
||||
.<brokerbackendname>
|
||||
|
||||
eg. for an explicit daq mini futes contract: mnq.cme.20230317.ib
|
||||
|
||||
TODO: I have thoughts that we should actually change this to be
|
||||
more like an "attr lookup" (like how the web should have done
|
||||
urls, but marketting peeps ruined it etc. etc.)
|
||||
|
||||
<broker>.<venue>.<instrumentname>.<suffixwithmetadata>
|
||||
|
||||
TODO:
|
||||
See community discussion on naming and nomenclature, order
|
||||
of addressing hierarchy, general schema, internal representation:
|
||||
|
||||
https://github.com/pikers/piker/issues/467
|
||||
|
||||
'''
|
||||
key: str = (
|
||||
self.pair(delim_char=delim_char)
|
||||
if not without_src
|
||||
else str(self.dst)
|
||||
)
|
||||
|
||||
return maybe_cons_tokens([
|
||||
key, # final "pair name" (eg. qqq[/usd], btcusdt)
|
||||
self.venue,
|
||||
self.suffix, # includes expiry and other con info
|
||||
self.broker,
|
||||
])
|
||||
|
||||
# NOTE: the main idea behind an fqme is to map a "market address"
|
||||
# to some endpoint from a transaction provider (eg. a broker) such
|
||||
# that we build a table of `fqme: str -> bs_mktid: Any` where any "piker
|
||||
# market address" maps 1-to-1 to some broker trading endpoint.
|
||||
# @cached_property
|
||||
fqme = property(get_fqme)
|
||||
|
||||
def get_bs_fqme(
|
||||
self,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
'''
|
||||
FQME sin broker part XD
|
||||
|
||||
'''
|
||||
sin_broker, *_ = self.get_fqme(**kwargs).rpartition('.')
|
||||
return sin_broker
|
||||
|
||||
bs_fqme = property(get_bs_fqme)
|
||||
|
||||
@property
|
||||
def fqsn(self) -> str:
|
||||
return self.fqme
|
||||
|
||||
def quantize(
|
||||
self,
|
||||
size: float,
|
||||
|
||||
quantity_type: Literal['price', 'size'] = 'size',
|
||||
|
||||
) -> Decimal:
|
||||
'''
|
||||
Truncate input ``size: float`` using ``Decimal``
|
||||
and ``.size_tick``'s # of digits.
|
||||
|
||||
'''
|
||||
match quantity_type:
|
||||
case 'price':
|
||||
digits = float_digits(self.price_tick)
|
||||
case 'size':
|
||||
digits = float_digits(self.size_tick)
|
||||
|
||||
return Decimal(size).quantize(
|
||||
Decimal(f'1.{"0".ljust(digits, "0")}'),
|
||||
rounding=ROUND_HALF_EVEN
|
||||
)
|
||||
|
||||
# TODO: BACKWARD COMPAT, TO REMOVE?
|
||||
@property
|
||||
def type_key(self) -> str:
|
||||
if isinstance(self.dst, Asset):
|
||||
return str(self.dst.atype)
|
||||
|
||||
return self._atype
|
||||
|
||||
@property
|
||||
def price_tick_digits(self) -> int:
|
||||
return float_digits(self.price_tick)
|
||||
|
||||
@property
|
||||
def size_tick_digits(self) -> int:
|
||||
return float_digits(self.size_tick)
|
||||
|
||||
|
||||
def unpack_fqme(
|
||||
fqme: str,
|
||||
|
||||
broker: str | None = None
|
||||
|
||||
) -> tuple[str, ...]:
|
||||
'''
|
||||
Unpack a fully-qualified-symbol-name to ``tuple``.
|
||||
|
||||
'''
|
||||
venue = ''
|
||||
suffix = ''
|
||||
|
||||
# TODO: probably reverse the order of all this XD
|
||||
tokens = fqme.split('.')
|
||||
|
||||
match tokens:
|
||||
case [mkt_ep, broker]:
|
||||
# probably crypto
|
||||
return (
|
||||
broker,
|
||||
mkt_ep,
|
||||
'',
|
||||
'',
|
||||
)
|
||||
|
||||
# TODO: swap venue and suffix/deriv-info here?
|
||||
case [mkt_ep, venue, suffix, broker]:
|
||||
pass
|
||||
|
||||
# handle `bs_mktid` + `broker` input case
|
||||
case [
|
||||
mkt_ep, venue, suffix
|
||||
] if (
|
||||
broker
|
||||
and suffix != broker
|
||||
):
|
||||
pass
|
||||
|
||||
case [mkt_ep, venue, broker]:
|
||||
suffix = ''
|
||||
|
||||
case _:
|
||||
raise ValueError(f'Invalid fqme: {fqme}')
|
||||
|
||||
return (
|
||||
broker,
|
||||
mkt_ep,
|
||||
venue,
|
||||
# '.'.join([mkt_ep, venue]),
|
||||
suffix,
|
||||
)
|
||||
|
||||
|
||||
class Symbol(Struct):
|
||||
'''
|
||||
I guess this is some kinda container thing for dealing with
|
||||
all the different meta-data formats from brokers?
|
||||
|
||||
'''
|
||||
key: str
|
||||
|
||||
broker: str = ''
|
||||
venue: str = ''
|
||||
|
||||
# precision descriptors for price and vlm
|
||||
tick_size: Decimal = Decimal('0.01')
|
||||
lot_tick_size: Decimal = Decimal('0.0')
|
||||
|
||||
suffix: str = ''
|
||||
broker_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def from_fqme(
|
||||
cls,
|
||||
fqsn: str,
|
||||
info: dict[str, Any],
|
||||
|
||||
) -> Symbol:
|
||||
broker, mktep, venue, suffix = unpack_fqme(fqsn)
|
||||
tick_size = info.get('price_tick_size', 0.01)
|
||||
lot_size = info.get('lot_tick_size', 0.0)
|
||||
|
||||
return Symbol(
|
||||
broker=broker,
|
||||
key=mktep,
|
||||
tick_size=tick_size,
|
||||
lot_tick_size=lot_size,
|
||||
venue=venue,
|
||||
suffix=suffix,
|
||||
broker_info={broker: info},
|
||||
)
|
||||
|
||||
@property
|
||||
def type_key(self) -> str:
|
||||
return list(self.broker_info.values())[0]['asset_type']
|
||||
|
||||
@property
|
||||
def tick_size_digits(self) -> int:
|
||||
return float_digits(self.tick_size)
|
||||
|
||||
@property
|
||||
def lot_size_digits(self) -> int:
|
||||
return float_digits(self.lot_tick_size)
|
||||
|
||||
@property
|
||||
def price_tick(self) -> Decimal:
|
||||
return Decimal(str(self.tick_size))
|
||||
|
||||
@property
|
||||
def size_tick(self) -> Decimal:
|
||||
return Decimal(str(self.lot_tick_size))
|
||||
|
||||
@property
|
||||
def broker(self) -> str:
|
||||
return list(self.broker_info.keys())[0]
|
||||
|
||||
@property
|
||||
def fqme(self) -> str:
|
||||
return maybe_cons_tokens([
|
||||
self.key, # final "pair name" (eg. qqq[/usd], btcusdt)
|
||||
self.venue,
|
||||
self.suffix, # includes expiry and other con info
|
||||
self.broker,
|
||||
])
|
||||
|
||||
def quantize(
|
||||
self,
|
||||
size: float,
|
||||
) -> Decimal:
|
||||
digits = float_digits(self.lot_tick_size)
|
||||
return Decimal(size).quantize(
|
||||
Decimal(f'1.{"0".ljust(digits, "0")}'),
|
||||
rounding=ROUND_HALF_EVEN
|
||||
)
|
||||
|
||||
# NOTE: when cast to `str` return fqme
|
||||
def __str__(self) -> str:
|
||||
return self.fqme
|
|
@ -12,158 +12,104 @@
|
|||
# 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/>.
|
||||
|
||||
'''
|
||||
Personal/Private position parsing, calculating, summarizing in a way
|
||||
that doesn't try to cuk most humans who prefer to not lose their moneys..
|
||||
|
||||
(looking at you `ib` and dirt-bird friends)
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from contextlib import contextmanager as cm
|
||||
from pprint import pformat
|
||||
import os
|
||||
from os import path
|
||||
from decimal import Decimal
|
||||
from math import copysign
|
||||
import re
|
||||
import time
|
||||
from pprint import pformat
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Iterator,
|
||||
Optional,
|
||||
Union,
|
||||
Generator
|
||||
)
|
||||
|
||||
import pendulum
|
||||
from pendulum import datetime, now
|
||||
import tomli
|
||||
import toml
|
||||
import tomlkit
|
||||
|
||||
from . import config
|
||||
from .brokers import get_brokermod
|
||||
from .clearing._messages import BrokerdPosition, Status
|
||||
from .data._source import Symbol, unpack_fqsn
|
||||
from .log import get_logger
|
||||
from .data.types import Struct
|
||||
from ._ledger import (
|
||||
Transaction,
|
||||
iter_by_dt,
|
||||
open_trade_ledger,
|
||||
)
|
||||
from ._mktinfo import (
|
||||
MktPair,
|
||||
Asset,
|
||||
unpack_fqme,
|
||||
)
|
||||
from .. import config
|
||||
from ..brokers import get_brokermod
|
||||
from ..clearing._messages import (
|
||||
BrokerdPosition,
|
||||
Status,
|
||||
)
|
||||
from ..data.types import Struct
|
||||
from ..log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
@cm
|
||||
def open_trade_ledger(
|
||||
broker: str,
|
||||
account: str,
|
||||
|
||||
) -> Generator[dict, None, None]:
|
||||
'''
|
||||
Indempotently create and read in a trade log file from the
|
||||
``<configuration_dir>/ledgers/`` directory.
|
||||
|
||||
Files are named per broker account of the form
|
||||
``<brokername>_<accountname>.toml``. The ``accountname`` here is the
|
||||
name as defined in the user's ``brokers.toml`` config.
|
||||
|
||||
'''
|
||||
ldir = path.join(config._config_dir, 'ledgers')
|
||||
if not path.isdir(ldir):
|
||||
os.makedirs(ldir)
|
||||
|
||||
fname = f'trades_{broker}_{account}.toml'
|
||||
tradesfile = path.join(ldir, fname)
|
||||
|
||||
if not path.isfile(tradesfile):
|
||||
log.info(
|
||||
f'Creating new local trades ledger: {tradesfile}'
|
||||
)
|
||||
with open(tradesfile, 'w') as cf:
|
||||
pass # touch
|
||||
with open(tradesfile, 'rb') as cf:
|
||||
start = time.time()
|
||||
ledger = tomli.load(cf)
|
||||
log.info(f'Ledger load took {time.time() - start}s')
|
||||
cpy = ledger.copy()
|
||||
|
||||
try:
|
||||
yield cpy
|
||||
finally:
|
||||
if cpy != ledger:
|
||||
|
||||
# TODO: show diff output?
|
||||
# https://stackoverflow.com/questions/12956957/print-diff-of-python-dictionaries
|
||||
log.info(f'Updating ledger for {tradesfile}:\n')
|
||||
ledger.update(cpy)
|
||||
|
||||
# we write on close the mutated ledger data
|
||||
with open(tradesfile, 'w') as cf:
|
||||
toml.dump(ledger, cf)
|
||||
|
||||
|
||||
class Transaction(Struct, frozen=True):
|
||||
# TODO: should this be ``.to`` (see below)?
|
||||
fqsn: str
|
||||
|
||||
sym: Symbol
|
||||
tid: Union[str, int] # unique transaction id
|
||||
size: float
|
||||
price: float
|
||||
cost: float # commisions or other additional costs
|
||||
dt: datetime
|
||||
expiry: datetime | None = None
|
||||
|
||||
# optional key normally derived from the broker
|
||||
# backend which ensures the instrument-symbol this record
|
||||
# is for is truly unique.
|
||||
bsuid: Union[str, int] | None = None
|
||||
|
||||
# optional fqsn for the source "asset"/money symbol?
|
||||
# from: Optional[str] = None
|
||||
|
||||
|
||||
def iter_by_dt(
|
||||
clears: dict[str, Any],
|
||||
) -> Iterator[tuple[str, dict]]:
|
||||
'''
|
||||
Iterate entries of a ``clears: dict`` table sorted by entry recorded
|
||||
datetime presumably set at the ``'dt'`` field in each entry.
|
||||
|
||||
'''
|
||||
for tid, data in sorted(
|
||||
list(clears.items()),
|
||||
key=lambda item: item[1]['dt'],
|
||||
):
|
||||
yield tid, data
|
||||
|
||||
|
||||
class Position(Struct):
|
||||
'''
|
||||
Basic pp (personal/piker position) model with attached clearing
|
||||
transaction history.
|
||||
An asset "position" model with attached clearing transaction history.
|
||||
|
||||
A financial "position" in `piker` terms is a summary of accounting
|
||||
metrics computed from a transaction ledger; generally it describes
|
||||
some acumulative "size" and "average price" from the summarized
|
||||
underlying transaction set.
|
||||
|
||||
In piker we focus on the `.ppu` (price per unit) and the `.bep`
|
||||
(break even price) including all transaction entries and exits since
|
||||
the last "net-zero" size of the destination asset's holding.
|
||||
|
||||
This interface serves as an object API for computing and tracking
|
||||
positions as well as supports serialization for storage in the local
|
||||
file system (in TOML) and to interchange as a msg over IPC.
|
||||
|
||||
'''
|
||||
symbol: Symbol
|
||||
mkt: MktPair
|
||||
|
||||
# can be +ve or -ve for long/short
|
||||
size: float
|
||||
|
||||
# "breakeven price" above or below which pnl moves above and below
|
||||
# zero for the entirety of the current "trade state".
|
||||
# "price-per-unit price" above or below which pnl moves above and
|
||||
# below zero for the entirety of the current "trade state". The ppu
|
||||
# is only modified on "increases of" the absolute size of a position
|
||||
# in one of a long/short "direction" (i.e. abs(.size_i) > 0 after
|
||||
# the next transaction given .size was > 0 before that tx, and vice
|
||||
# versa for -ve sized positions).
|
||||
ppu: float
|
||||
|
||||
# unique backend symbol id
|
||||
bsuid: str
|
||||
# TODO: break-even-price support!
|
||||
# bep: float
|
||||
|
||||
split_ratio: Optional[int] = None
|
||||
# unique "backend system market id"
|
||||
bs_mktid: str
|
||||
|
||||
split_ratio: int | None = None
|
||||
|
||||
# ordered record of known constituent trade messages
|
||||
clears: dict[
|
||||
Union[str, int, Status], # trade id
|
||||
dict[str, Any], # transaction history summaries
|
||||
] = {}
|
||||
first_clear_dt: Optional[datetime] = None
|
||||
first_clear_dt: datetime | None = None
|
||||
|
||||
expiry: Optional[datetime] = None
|
||||
expiry: datetime | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return pformat(self.to_dict())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
|
@ -192,37 +138,40 @@ class Position(Struct):
|
|||
# listing venue here even when the backend isn't providing
|
||||
# it via the trades ledger..
|
||||
# drop symbol obj in serialized form
|
||||
s = d.pop('symbol')
|
||||
fqsn = s.front_fqsn()
|
||||
mkt: MktPair = d.pop('mkt')
|
||||
assert isinstance(mkt, MktPair)
|
||||
|
||||
broker, key, suffix = unpack_fqsn(fqsn)
|
||||
sym_info = s.broker_info[broker]
|
||||
fqme = mkt.fqme
|
||||
broker, mktep, venue, suffix = unpack_fqme(fqme)
|
||||
|
||||
d['asset_type'] = sym_info['asset_type']
|
||||
d['price_tick_size'] = (
|
||||
sym_info.get('price_tick_size')
|
||||
or
|
||||
s.tick_size
|
||||
)
|
||||
d['lot_tick_size'] = (
|
||||
sym_info.get('lot_tick_size')
|
||||
or
|
||||
s.lot_tick_size
|
||||
)
|
||||
# an asset resolved mkt where we have ``Asset`` info about
|
||||
# each tradeable asset in the market.
|
||||
if mkt.resolved:
|
||||
dst: Asset = mkt.dst
|
||||
d['asset_type'] = dst.atype
|
||||
|
||||
d['price_tick'] = mkt.price_tick
|
||||
d['size_tick'] = mkt.size_tick
|
||||
|
||||
if self.expiry is None:
|
||||
d.pop('expiry', None)
|
||||
elif expiry:
|
||||
d['expiry'] = str(expiry)
|
||||
|
||||
toml_clears_list = []
|
||||
clears_table: tomlkit.Array = tomlkit.array()
|
||||
clears_table.multiline(
|
||||
multiline=True,
|
||||
indent='',
|
||||
)
|
||||
|
||||
# reverse sort so latest clears are at top of section?
|
||||
for tid, data in iter_by_dt(clears):
|
||||
inline_table = toml.TomlDecoder().get_empty_inline_table()
|
||||
|
||||
inline_table = tomlkit.inline_table()
|
||||
|
||||
# serialize datetime to parsable `str`
|
||||
inline_table['dt'] = str(data['dt'])
|
||||
dtstr = inline_table['dt'] = data['dt'].isoformat('T')
|
||||
assert 'Datetime' not in dtstr
|
||||
|
||||
# insert optional clear fields in column order
|
||||
for k in ['ppu', 'accum_size']:
|
||||
|
@ -235,11 +184,11 @@ class Position(Struct):
|
|||
inline_table[k] = data[k]
|
||||
|
||||
inline_table['tid'] = tid
|
||||
toml_clears_list.append(inline_table)
|
||||
clears_table.append(inline_table)
|
||||
|
||||
d['clears'] = toml_clears_list
|
||||
d['clears'] = clears_table
|
||||
|
||||
return fqsn, d
|
||||
return fqme, d
|
||||
|
||||
def ensure_state(self) -> None:
|
||||
'''
|
||||
|
@ -249,7 +198,9 @@ class Position(Struct):
|
|||
|
||||
'''
|
||||
clears = list(self.clears.values())
|
||||
self.first_clear_dt = min(list(entry['dt'] for entry in clears))
|
||||
self.first_clear_dt = min(
|
||||
list(entry['dt'] for entry in clears)
|
||||
)
|
||||
last_clear = clears[-1]
|
||||
|
||||
csize = self.calc_size()
|
||||
|
@ -294,22 +245,19 @@ class Position(Struct):
|
|||
) -> None:
|
||||
|
||||
# XXX: better place to do this?
|
||||
symbol = self.symbol
|
||||
mkt = self.mkt
|
||||
size_tick_digits = mkt.size_tick_digits
|
||||
price_tick_digits = mkt.price_tick_digits
|
||||
|
||||
lot_size_digits = symbol.lot_size_digits
|
||||
ppu, size = (
|
||||
round(
|
||||
msg['avg_price'],
|
||||
ndigits=symbol.tick_size_digits
|
||||
),
|
||||
round(
|
||||
msg['size'],
|
||||
ndigits=lot_size_digits
|
||||
),
|
||||
self.ppu = round(
|
||||
# TODO: change this to ppu?
|
||||
msg['avg_price'],
|
||||
ndigits=price_tick_digits,
|
||||
)
|
||||
self.size = round(
|
||||
msg['size'],
|
||||
ndigits=size_tick_digits,
|
||||
)
|
||||
|
||||
self.ppu = ppu
|
||||
self.size = size
|
||||
|
||||
@property
|
||||
def dsize(self) -> float:
|
||||
|
@ -337,10 +285,16 @@ class Position(Struct):
|
|||
datetime-stamped order.
|
||||
|
||||
'''
|
||||
return iter_by_dt(self.clears)
|
||||
# sort on the already existing datetime that should have
|
||||
# been generated for the entry's table
|
||||
return iter_by_dt(
|
||||
self.clears,
|
||||
key=lambda entry: entry[1]['dt']
|
||||
)
|
||||
|
||||
def calc_ppu(
|
||||
self,
|
||||
|
||||
# include transaction cost in breakeven price
|
||||
# and presume the worst case of the same cost
|
||||
# to exit this transaction (even though in reality
|
||||
|
@ -471,20 +425,28 @@ class Position(Struct):
|
|||
asset using the clears/trade event table; zero if expired.
|
||||
|
||||
'''
|
||||
size: float = 0
|
||||
size: float = 0.
|
||||
|
||||
# time-expired pps (normally derivatives) are "closed"
|
||||
# and have a zero size.
|
||||
if self.expired():
|
||||
return 0
|
||||
return 0.
|
||||
|
||||
for tid, entry in self.clears.items():
|
||||
size += entry['size']
|
||||
# XXX: do we need it every step?
|
||||
# no right since rounding is an LT?
|
||||
# size = self.mkt.quantize(
|
||||
# size + entry['size'],
|
||||
# quantity_type='size',
|
||||
# )
|
||||
|
||||
if self.split_ratio is not None:
|
||||
size = round(size * self.split_ratio)
|
||||
|
||||
return float(self.symbol.quantize_size(size))
|
||||
return float(
|
||||
self.mkt.quantize(size),
|
||||
)
|
||||
|
||||
def minimize_clears(
|
||||
self,
|
||||
|
@ -506,7 +468,9 @@ class Position(Struct):
|
|||
# scan for the last "net zero" position by iterating
|
||||
# transactions until the next net-zero size, rinse, repeat.
|
||||
for tid, clear in self.clears.items():
|
||||
size += clear['size']
|
||||
size = float(
|
||||
self.mkt.quantize(size + clear['size'])
|
||||
)
|
||||
clears_since_zero.append((tid, clear))
|
||||
|
||||
if size == 0:
|
||||
|
@ -543,8 +507,8 @@ class Position(Struct):
|
|||
|
||||
return clear
|
||||
|
||||
def sugest_split(self) -> float:
|
||||
...
|
||||
# def sugest_split(self) -> float:
|
||||
# ...
|
||||
|
||||
|
||||
class PpTable(Struct):
|
||||
|
@ -552,7 +516,8 @@ class PpTable(Struct):
|
|||
brokername: str
|
||||
acctid: str
|
||||
pps: dict[str, Position]
|
||||
conf: Optional[dict] = {}
|
||||
conf_path: Path
|
||||
conf: dict | None = {}
|
||||
|
||||
def update_from_trans(
|
||||
self,
|
||||
|
@ -564,24 +529,38 @@ class PpTable(Struct):
|
|||
pps = self.pps
|
||||
updated: dict[str, Position] = {}
|
||||
|
||||
# lifo update all pps from records
|
||||
for tid, t in trans.items():
|
||||
|
||||
pp = pps.setdefault(
|
||||
t.bsuid,
|
||||
# lifo update all pps from records, ensuring
|
||||
# we compute the PPU and size sorted in time!
|
||||
for t in sorted(
|
||||
trans.values(),
|
||||
key=lambda t: t.dt,
|
||||
reverse=True,
|
||||
):
|
||||
fqme = t.fqme
|
||||
bs_mktid = t.bs_mktid
|
||||
|
||||
# template the mkt-info presuming a legacy market ticks
|
||||
# if no info exists in the transactions..
|
||||
mkt: MktPair = t.sys
|
||||
pp = pps.get(bs_mktid)
|
||||
if not pp:
|
||||
# if no existing pp, allocate fresh one.
|
||||
Position(
|
||||
Symbol.from_fqsn(
|
||||
t.fqsn,
|
||||
info={},
|
||||
) if not t.sym else t.sym,
|
||||
pp = pps[bs_mktid] = Position(
|
||||
mkt=mkt,
|
||||
size=0.0,
|
||||
ppu=0.0,
|
||||
bsuid=t.bsuid,
|
||||
bs_mktid=bs_mktid,
|
||||
expiry=t.expiry,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# NOTE: if for some reason a "less resolved" mkt pair
|
||||
# info has been set (based on the `.fqme` being
|
||||
# a shorter string), instead use the one from the
|
||||
# transaction since it likely has (more) full
|
||||
# information from the provider.
|
||||
if len(pp.mkt.fqme) < len(fqme):
|
||||
pp.mkt = mkt
|
||||
|
||||
clears = pp.clears
|
||||
if clears:
|
||||
first_clear_dt = pp.first_clear_dt
|
||||
|
@ -590,7 +569,10 @@ class PpTable(Struct):
|
|||
# included in the current pps state.
|
||||
if (
|
||||
t.tid in clears
|
||||
or first_clear_dt and t.dt < first_clear_dt
|
||||
or (
|
||||
first_clear_dt
|
||||
and t.dt < first_clear_dt
|
||||
)
|
||||
):
|
||||
# NOTE: likely you'll see repeats of the same
|
||||
# ``Transaction`` passed in here if/when you are restarting
|
||||
|
@ -601,12 +583,14 @@ class PpTable(Struct):
|
|||
|
||||
# update clearing table
|
||||
pp.add_clear(t)
|
||||
updated[t.bsuid] = pp
|
||||
updated[t.bs_mktid] = pp
|
||||
|
||||
# minimize clears tables and update sizing.
|
||||
for bsuid, pp in updated.items():
|
||||
for bs_mktid, pp in updated.items():
|
||||
pp.ensure_state()
|
||||
|
||||
# deliver only the position entries that were actually updated
|
||||
# (modified the state) from the input transaction set.
|
||||
return updated
|
||||
|
||||
def dump_active(
|
||||
|
@ -630,14 +614,8 @@ class PpTable(Struct):
|
|||
open_pp_objs: dict[str, Position] = {}
|
||||
|
||||
pp_objs = self.pps
|
||||
for bsuid in list(pp_objs):
|
||||
pp = pp_objs[bsuid]
|
||||
|
||||
# XXX: debug hook for size mismatches
|
||||
# qqqbsuid = 320227571
|
||||
# if bsuid == qqqbsuid:
|
||||
# breakpoint()
|
||||
|
||||
for bs_mktid in list(pp_objs):
|
||||
pp = pp_objs[bs_mktid]
|
||||
pp.ensure_state()
|
||||
|
||||
if (
|
||||
|
@ -656,37 +634,42 @@ class PpTable(Struct):
|
|||
# ignored; the closed positions won't be written to the
|
||||
# ``pps.toml`` since ``pp_active_entries`` above is what's
|
||||
# written.
|
||||
closed_pp_objs[bsuid] = pp
|
||||
closed_pp_objs[bs_mktid] = pp
|
||||
|
||||
else:
|
||||
open_pp_objs[bsuid] = pp
|
||||
open_pp_objs[bs_mktid] = pp
|
||||
|
||||
return open_pp_objs, closed_pp_objs
|
||||
|
||||
def to_toml(
|
||||
self,
|
||||
active: dict[str, Position] | None = None,
|
||||
|
||||
) -> dict[str, Any]:
|
||||
|
||||
active, closed = self.dump_active()
|
||||
if active is None:
|
||||
active, _ = self.dump_active()
|
||||
|
||||
# ONLY dict-serialize all active positions; those that are closed
|
||||
# we don't store in the ``pps.toml``.
|
||||
# ONLY dict-serialize all active positions; those that are
|
||||
# closed we don't store in the ``pps.toml``.
|
||||
to_toml_dict = {}
|
||||
|
||||
for bsuid, pos in active.items():
|
||||
|
||||
# keep the minimal amount of clears that make up this
|
||||
pos: Position
|
||||
for bs_mktid, pos in active.items():
|
||||
# NOTE: we only store the minimal amount of clears that make up this
|
||||
# position since the last net-zero state.
|
||||
pos.minimize_clears()
|
||||
pos.ensure_state()
|
||||
|
||||
# serialize to pre-toml form
|
||||
fqsn, asdict = pos.to_pretoml()
|
||||
log.info(f'Updating active pp: {fqsn}')
|
||||
fqme, asdict = pos.to_pretoml()
|
||||
|
||||
assert 'Datetime' not in asdict['clears'][0]['dt']
|
||||
log.info(f'Updating active pp: {fqme}')
|
||||
|
||||
# XXX: ugh, it's cuz we push the section under
|
||||
# the broker name.. maybe we need to rethink this?
|
||||
brokerless_key = fqsn.removeprefix(f'{self.brokername}.')
|
||||
brokerless_key = fqme.removeprefix(f'{self.brokername}.')
|
||||
to_toml_dict[brokerless_key] = asdict
|
||||
|
||||
return to_toml_dict
|
||||
|
@ -699,33 +682,55 @@ class PpTable(Struct):
|
|||
# TODO: show diff output?
|
||||
# https://stackoverflow.com/questions/12956957/print-diff-of-python-dictionaries
|
||||
# active, closed_pp_objs = table.dump_active()
|
||||
pp_entries = self.to_toml()
|
||||
|
||||
active, closed = self.dump_active()
|
||||
pp_entries = self.to_toml(active=active)
|
||||
if pp_entries:
|
||||
log.info(f'Updating ``pps.toml`` for {path}:\n')
|
||||
log.info(f'Current positions:\n{pp_entries}')
|
||||
self.conf[self.brokername][self.acctid] = pp_entries
|
||||
log.info(
|
||||
f'Updating positions in ``{self.conf_path}``:\n'
|
||||
f'n{pformat(pp_entries)}'
|
||||
)
|
||||
|
||||
elif (
|
||||
self.brokername in self.conf and
|
||||
self.acctid in self.conf[self.brokername]
|
||||
):
|
||||
del self.conf[self.brokername][self.acctid]
|
||||
if len(self.conf[self.brokername]) == 0:
|
||||
del self.conf[self.brokername]
|
||||
if self.brokername in self.conf:
|
||||
log.warning(
|
||||
f'Rewriting {self.conf_path} keys to drop <broker.acct>!'
|
||||
)
|
||||
# legacy key schema including <brokername.account>, so
|
||||
# rewrite all entries to drop those tables since we now
|
||||
# put that in the filename!
|
||||
accounts = self.conf.pop(self.brokername)
|
||||
assert len(accounts) == 1
|
||||
entries = accounts.pop(self.acctid)
|
||||
self.conf.update(entries)
|
||||
|
||||
# TODO: why tf haven't they already done this for inline
|
||||
# tables smh..
|
||||
enc = PpsEncoder(preserve=True)
|
||||
# table_bs_type = type(toml.TomlDecoder().get_empty_inline_table())
|
||||
enc.dump_funcs[
|
||||
toml.decoder.InlineTableDict
|
||||
] = enc.dump_inline_table
|
||||
self.conf.update(pp_entries)
|
||||
|
||||
# drop any entries that are computed as net-zero
|
||||
# we don't care about storing in the pps file.
|
||||
if closed:
|
||||
bs_mktid: str
|
||||
for bs_mktid, pos in closed.items():
|
||||
fqme: str = pos.mkt.fqme
|
||||
if fqme in self.conf:
|
||||
self.conf.pop(fqme)
|
||||
else:
|
||||
# TODO: we reallly need a diff set of
|
||||
# loglevels/colors per subsys.
|
||||
log.warning(
|
||||
f'Recent position for {fqme} was closed!'
|
||||
)
|
||||
|
||||
# if there are no active position entries according
|
||||
# to the toml dump output above, then clear the config
|
||||
# file of all entries.
|
||||
elif self.conf:
|
||||
for entry in list(self.conf):
|
||||
del self.conf[entry]
|
||||
|
||||
config.write(
|
||||
self.conf,
|
||||
'pps',
|
||||
encoder=enc,
|
||||
fail_empty=False
|
||||
config=self.conf,
|
||||
path=self.conf_path,
|
||||
fail_empty=False,
|
||||
)
|
||||
|
||||
|
||||
|
@ -735,7 +740,7 @@ def load_pps_from_ledger(
|
|||
acctname: str,
|
||||
|
||||
# post normalization filter on ledger entries to be processed
|
||||
filter_by: Optional[list[dict]] = None,
|
||||
filter_by: list[dict] | None = None,
|
||||
|
||||
) -> tuple[
|
||||
dict[str, Transaction],
|
||||
|
@ -745,7 +750,7 @@ def load_pps_from_ledger(
|
|||
Open a ledger file by broker name and account and read in and
|
||||
process any trade records into our normalized ``Transaction`` form
|
||||
and then update the equivalent ``Pptable`` and deliver the two
|
||||
bsuid-mapped dict-sets of the transactions and pps.
|
||||
bs_mktid-mapped dict-sets of the transactions and pps.
|
||||
|
||||
'''
|
||||
with (
|
||||
|
@ -761,9 +766,9 @@ def load_pps_from_ledger(
|
|||
|
||||
if filter_by:
|
||||
records = {}
|
||||
bsuids = set(filter_by)
|
||||
bs_mktids = set(filter_by)
|
||||
for tid, r in src_records.items():
|
||||
if r.bsuid in bsuids:
|
||||
if r.bs_mktid in bs_mktids:
|
||||
records[tid] = r
|
||||
else:
|
||||
records = src_records
|
||||
|
@ -773,151 +778,33 @@ def load_pps_from_ledger(
|
|||
return records, updated
|
||||
|
||||
|
||||
# TODO: instead see if we can hack tomli and tomli-w to do the same:
|
||||
# - https://github.com/hukkin/tomli
|
||||
# - https://github.com/hukkin/tomli-w
|
||||
class PpsEncoder(toml.TomlEncoder):
|
||||
'''
|
||||
Special "styled" encoder that makes a ``pps.toml`` redable and
|
||||
compact by putting `.clears` tables inline and everything else
|
||||
flat-ish.
|
||||
|
||||
'''
|
||||
separator = ','
|
||||
|
||||
def dump_list(self, v):
|
||||
'''
|
||||
Dump an inline list with a newline after every element and
|
||||
with consideration for denoted inline table types.
|
||||
|
||||
'''
|
||||
retval = "[\n"
|
||||
for u in v:
|
||||
if isinstance(u, toml.decoder.InlineTableDict):
|
||||
out = self.dump_inline_table(u)
|
||||
else:
|
||||
out = str(self.dump_value(u))
|
||||
|
||||
retval += " " + out + "," + "\n"
|
||||
retval += "]"
|
||||
return retval
|
||||
|
||||
def dump_inline_table(self, section):
|
||||
"""Preserve inline table in its compact syntax instead of expanding
|
||||
into subsection.
|
||||
https://github.com/toml-lang/toml#user-content-inline-table
|
||||
"""
|
||||
val_list = []
|
||||
for k, v in section.items():
|
||||
# if isinstance(v, toml.decoder.InlineTableDict):
|
||||
if isinstance(v, dict):
|
||||
val = self.dump_inline_table(v)
|
||||
else:
|
||||
val = str(self.dump_value(v))
|
||||
|
||||
val_list.append(k + " = " + val)
|
||||
|
||||
retval = "{ " + ", ".join(val_list) + " }"
|
||||
return retval
|
||||
|
||||
def dump_sections(self, o, sup):
|
||||
retstr = ""
|
||||
if sup != "" and sup[-1] != ".":
|
||||
sup += '.'
|
||||
retdict = self._dict()
|
||||
arraystr = ""
|
||||
for section in o:
|
||||
qsection = str(section)
|
||||
value = o[section]
|
||||
|
||||
if not re.match(r'^[A-Za-z0-9_-]+$', section):
|
||||
qsection = toml.encoder._dump_str(section)
|
||||
|
||||
# arrayoftables = False
|
||||
if (
|
||||
self.preserve
|
||||
and isinstance(value, toml.decoder.InlineTableDict)
|
||||
):
|
||||
retstr += (
|
||||
qsection
|
||||
+
|
||||
" = "
|
||||
+
|
||||
self.dump_inline_table(o[section])
|
||||
+
|
||||
'\n' # only on the final terminating left brace
|
||||
)
|
||||
|
||||
# XXX: this code i'm pretty sure is just blatantly bad
|
||||
# and/or wrong..
|
||||
# if isinstance(o[section], list):
|
||||
# for a in o[section]:
|
||||
# if isinstance(a, dict):
|
||||
# arrayoftables = True
|
||||
# if arrayoftables:
|
||||
# for a in o[section]:
|
||||
# arraytabstr = "\n"
|
||||
# arraystr += "[[" + sup + qsection + "]]\n"
|
||||
# s, d = self.dump_sections(a, sup + qsection)
|
||||
# if s:
|
||||
# if s[0] == "[":
|
||||
# arraytabstr += s
|
||||
# else:
|
||||
# arraystr += s
|
||||
# while d:
|
||||
# newd = self._dict()
|
||||
# for dsec in d:
|
||||
# s1, d1 = self.dump_sections(d[dsec], sup +
|
||||
# qsection + "." +
|
||||
# dsec)
|
||||
# if s1:
|
||||
# arraytabstr += ("[" + sup + qsection +
|
||||
# "." + dsec + "]\n")
|
||||
# arraytabstr += s1
|
||||
# for s1 in d1:
|
||||
# newd[dsec + "." + s1] = d1[s1]
|
||||
# d = newd
|
||||
# arraystr += arraytabstr
|
||||
|
||||
elif isinstance(value, dict):
|
||||
retdict[qsection] = o[section]
|
||||
|
||||
elif o[section] is not None:
|
||||
retstr += (
|
||||
qsection
|
||||
+
|
||||
" = "
|
||||
+
|
||||
str(self.dump_value(o[section]))
|
||||
)
|
||||
|
||||
# if not isinstance(value, dict):
|
||||
if not isinstance(value, toml.decoder.InlineTableDict):
|
||||
# inline tables should not contain newlines:
|
||||
# https://toml.io/en/v1.0.0#inline-table
|
||||
retstr += '\n'
|
||||
|
||||
else:
|
||||
raise ValueError(value)
|
||||
|
||||
retstr += arraystr
|
||||
return (retstr, retdict)
|
||||
|
||||
|
||||
@cm
|
||||
def open_pps(
|
||||
brokername: str,
|
||||
acctid: str,
|
||||
write_on_exit: bool = False,
|
||||
|
||||
) -> Generator[PpTable, None, None]:
|
||||
'''
|
||||
Read out broker-specific position entries from
|
||||
incremental update file: ``pps.toml``.
|
||||
|
||||
'''
|
||||
conf, path = config.load('pps')
|
||||
brokersection = conf.setdefault(brokername, {})
|
||||
pps = brokersection.setdefault(acctid, {})
|
||||
conf: dict
|
||||
conf_path: Path
|
||||
conf, conf_path = config.load_account(brokername, acctid)
|
||||
|
||||
if brokername in conf:
|
||||
log.warning(
|
||||
f'Rewriting {conf_path} keys to drop <broker.acct>!'
|
||||
)
|
||||
# legacy key schema including <brokername.account>, so
|
||||
# rewrite all entries to drop those tables since we now
|
||||
# put that in the filename!
|
||||
accounts = conf.pop(brokername)
|
||||
for acctid in accounts.copy():
|
||||
entries = accounts.pop(acctid)
|
||||
conf.update(entries)
|
||||
|
||||
# TODO: ideally we can pass in an existing
|
||||
# pps state to this right? such that we
|
||||
|
@ -934,61 +821,72 @@ def open_pps(
|
|||
brokername,
|
||||
acctid,
|
||||
pp_objs,
|
||||
conf_path,
|
||||
conf=conf,
|
||||
)
|
||||
|
||||
# unmarshal/load ``pps.toml`` config entries into object form
|
||||
# and update `PpTable` obj entries.
|
||||
for fqsn, entry in pps.items():
|
||||
bsuid = entry['bsuid']
|
||||
symbol = Symbol.from_fqsn(
|
||||
fqsn,
|
||||
for fqme, entry in conf.items():
|
||||
|
||||
# NOTE & TODO: right now we fill in the defaults from
|
||||
# `.data._source.Symbol` but eventually these should always
|
||||
# either be already written to the pos table or provided at
|
||||
# write time to ensure always having these values somewhere
|
||||
# and thus allowing us to get our pos sizing precision
|
||||
# correct!
|
||||
info={
|
||||
'asset_type': entry.get('asset_type', '<unknown>'),
|
||||
'price_tick_size': entry.get('price_tick_size', 0.01),
|
||||
'lot_tick_size': entry.get('lot_tick_size', 0.0),
|
||||
}
|
||||
# atype = entry.get('asset_type', '<unknown>')
|
||||
|
||||
# unique broker market id
|
||||
bs_mktid = str(
|
||||
entry.get('bsuid')
|
||||
or entry.get('bs_mktid')
|
||||
)
|
||||
price_tick = Decimal(str(
|
||||
entry.get('price_tick_size')
|
||||
or entry.get('price_tick')
|
||||
or '0.01'
|
||||
))
|
||||
size_tick = Decimal(str(
|
||||
entry.get('lot_tick_size')
|
||||
or entry.get('size_tick')
|
||||
or '0.0'
|
||||
))
|
||||
|
||||
# load the pair using the fqme which
|
||||
# will make the pair "unresolved" until
|
||||
# the backend broker actually loads
|
||||
# the market and position info.
|
||||
mkt = MktPair.from_fqme(
|
||||
fqme,
|
||||
price_tick=price_tick,
|
||||
size_tick=size_tick,
|
||||
bs_mktid=bs_mktid
|
||||
)
|
||||
|
||||
# TODO: RE: general "events" instead of just "clears":
|
||||
# - make this an `events` field and support more event types
|
||||
# such as 'split', 'name_change', 'mkt_info', etc..
|
||||
# - should be make a ``Struct`` for clear/event entries? convert
|
||||
# "clear events table" from the toml config (list of a dicts)
|
||||
# and load it into object form for use in position processing of
|
||||
# new clear events.
|
||||
|
||||
# convert clears sub-tables (only in this form
|
||||
# for toml re-presentation) back into a master table.
|
||||
clears_list = entry['clears']
|
||||
|
||||
# index clears entries in "object" form by tid in a top
|
||||
# level dict instead of a list (as is presented in our
|
||||
# ``pps.toml``).
|
||||
clears = pp_objs.setdefault(bsuid, {})
|
||||
|
||||
# TODO: should be make a ``Struct`` for clear/event entries?
|
||||
# convert "clear events table" from the toml config (list of
|
||||
# a dicts) and load it into object form for use in position
|
||||
# processing of new clear events.
|
||||
toml_clears_list: list[dict[str, Any]] = entry['clears']
|
||||
trans: list[Transaction] = []
|
||||
for clears_table in toml_clears_list:
|
||||
|
||||
for clears_table in clears_list:
|
||||
tid = clears_table.pop('tid')
|
||||
tid = clears_table.get('tid')
|
||||
dtstr = clears_table['dt']
|
||||
dt = pendulum.parse(dtstr)
|
||||
clears_table['dt'] = dt
|
||||
|
||||
trans.append(Transaction(
|
||||
fqsn=bsuid,
|
||||
sym=symbol,
|
||||
bsuid=bsuid,
|
||||
fqme=bs_mktid,
|
||||
sym=mkt,
|
||||
bs_mktid=bs_mktid,
|
||||
tid=tid,
|
||||
size=clears_table['size'],
|
||||
price=clears_table['price'],
|
||||
cost=clears_table['cost'],
|
||||
dt=dt,
|
||||
))
|
||||
clears[tid] = clears_table
|
||||
|
||||
size = entry['size']
|
||||
|
||||
|
@ -1004,13 +902,13 @@ def open_pps(
|
|||
if expiry:
|
||||
expiry = pendulum.parse(expiry)
|
||||
|
||||
pp = pp_objs[bsuid] = Position(
|
||||
symbol,
|
||||
pp = pp_objs[bs_mktid] = Position(
|
||||
mkt,
|
||||
size=size,
|
||||
ppu=ppu,
|
||||
split_ratio=split_ratio,
|
||||
expiry=expiry,
|
||||
bsuid=entry['bsuid'],
|
||||
bs_mktid=bs_mktid,
|
||||
)
|
||||
|
||||
# XXX: super critical, we need to be sure to include
|
||||
|
@ -1029,19 +927,3 @@ def open_pps(
|
|||
finally:
|
||||
if write_on_exit:
|
||||
table.write_config()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
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)}'
|
||||
)
|
|
@ -0,0 +1,234 @@
|
|||
# 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/>.
|
||||
|
||||
'''
|
||||
CLI front end for trades ledger and position tracking management.
|
||||
|
||||
'''
|
||||
from typing import (
|
||||
Any,
|
||||
)
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
import tractor
|
||||
import trio
|
||||
import typer
|
||||
|
||||
from ..log import get_logger
|
||||
from ..service import (
|
||||
open_piker_runtime,
|
||||
)
|
||||
from ..clearing._messages import BrokerdPosition
|
||||
from ..calc import humanize
|
||||
|
||||
|
||||
ledger = typer.Typer()
|
||||
|
||||
|
||||
def broker_init(
|
||||
brokername: str,
|
||||
loglevel: str | None = None,
|
||||
|
||||
**start_actor_kwargs,
|
||||
|
||||
) -> dict:
|
||||
'''
|
||||
Given an input broker name, load all named arguments
|
||||
which can be passed to a daemon + context spawn for
|
||||
the relevant `brokerd` service endpoint.
|
||||
|
||||
'''
|
||||
from ..brokers import get_brokermod
|
||||
brokermod = get_brokermod(brokername)
|
||||
modpath = brokermod.__name__
|
||||
|
||||
start_actor_kwargs['name'] = f'brokerd.{brokername}'
|
||||
start_actor_kwargs.update(
|
||||
getattr(
|
||||
brokermod,
|
||||
'_spawn_kwargs',
|
||||
{},
|
||||
)
|
||||
)
|
||||
|
||||
# lookup actor-enabled modules declared by the backend offering the
|
||||
# `brokerd` endpoint(s).
|
||||
enabled = start_actor_kwargs['enable_modules'] = [modpath]
|
||||
for submodname in getattr(
|
||||
brokermod,
|
||||
'__enable_modules__',
|
||||
[],
|
||||
):
|
||||
subpath = f'{modpath}.{submodname}'
|
||||
enabled.append(subpath)
|
||||
|
||||
# TODO XXX: DO WE NEED THIS?
|
||||
# enabled.append('piker.data.feed')
|
||||
|
||||
# non-blocking setup of brokerd service nursery
|
||||
from ..brokers._daemon import _setup_persistent_brokerd
|
||||
|
||||
return (
|
||||
start_actor_kwargs, # to `ActorNursery.start_actor()`
|
||||
_setup_persistent_brokerd, # service task ep
|
||||
getattr( # trades endpoint
|
||||
brokermod,
|
||||
'trades_dialogue',
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ledger.command()
|
||||
def sync(
|
||||
fully_qualified_account_name: str,
|
||||
pdb: bool = False,
|
||||
|
||||
loglevel: str = typer.Option(
|
||||
'error',
|
||||
"-l",
|
||||
),
|
||||
):
|
||||
log = get_logger(loglevel)
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
brokername, account = fully_qualified_account_name.split('.')
|
||||
except ValueError:
|
||||
md = Markdown(
|
||||
f'=> `{fully_qualified_account_name}` <=\n\n'
|
||||
'is not a valid '
|
||||
'__fully qualified account name?__\n\n'
|
||||
'Your account name needs to be of the form '
|
||||
'`<brokername>.<account_name>`\n'
|
||||
)
|
||||
console.print(md)
|
||||
return
|
||||
|
||||
start_kwargs, _, trades_ep = broker_init(
|
||||
brokername,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
|
||||
async def main():
|
||||
|
||||
async with (
|
||||
open_piker_runtime(
|
||||
name='ledger_cli',
|
||||
loglevel=loglevel,
|
||||
debug_mode=pdb,
|
||||
|
||||
) as (actor, sockaddr),
|
||||
|
||||
tractor.open_nursery() as an,
|
||||
):
|
||||
log.info(
|
||||
f'Piker runtime up as {actor.uid}@{sockaddr}'
|
||||
)
|
||||
|
||||
portal = await an.start_actor(
|
||||
loglevel=loglevel,
|
||||
debug_mode=pdb,
|
||||
**start_kwargs,
|
||||
)
|
||||
|
||||
if (
|
||||
brokername == 'paper'
|
||||
or trades_ep is None
|
||||
):
|
||||
from ..clearing import _paper_engine as paper
|
||||
open_trades_endpoint = paper.open_paperboi(
|
||||
fqme=None, # tell paper to not start clearing loop
|
||||
broker=brokername,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
else:
|
||||
# open live brokerd trades endpoint
|
||||
open_trades_endpoint = portal.open_context(
|
||||
trades_ep,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
|
||||
positions: dict[str, Any]
|
||||
accounts: list[str]
|
||||
async with (
|
||||
open_trades_endpoint as (
|
||||
brokerd_ctx,
|
||||
(positions, accounts),
|
||||
),
|
||||
):
|
||||
assert len(accounts) == 1
|
||||
summary: str = (
|
||||
'[dim underline]Piker Position Summary[/] '
|
||||
f'[dim blue underline]{brokername}[/]'
|
||||
'[dim].[/]'
|
||||
f'[blue underline]{account}[/]'
|
||||
f'[dim underline] -> total pps: [/]'
|
||||
f'[green]{len(positions)}[/]\n'
|
||||
)
|
||||
for ppdict in positions:
|
||||
ppmsg = BrokerdPosition(**ppdict)
|
||||
size = ppmsg.size
|
||||
if size:
|
||||
ppu: float = round(
|
||||
ppmsg.avg_price,
|
||||
ndigits=2,
|
||||
)
|
||||
cost_basis: str = humanize(size * ppu)
|
||||
h_size: str = humanize(size)
|
||||
|
||||
if size < 0:
|
||||
pcolor = 'red'
|
||||
else:
|
||||
pcolor = 'green'
|
||||
|
||||
# sematic-highlight of fqme
|
||||
fqme = ppmsg.symbol
|
||||
tokens = fqme.split('.')
|
||||
styled_fqme = f'[blue underline]{tokens[0]}[/]'
|
||||
for tok in tokens[1:]:
|
||||
styled_fqme += '[dim].[/]'
|
||||
styled_fqme += f'[dim blue underline]{tok}[/]'
|
||||
|
||||
# TODO: instead display in a ``rich.Table``?
|
||||
summary += (
|
||||
styled_fqme +
|
||||
'[dim]: [/]'
|
||||
f'[{pcolor}]{h_size}[/]'
|
||||
'[dim blue]u @[/]'
|
||||
f'[{pcolor}]{ppu}[/]'
|
||||
'[dim blue] = [/]'
|
||||
f'[{pcolor}]$ {cost_basis}\n[/]'
|
||||
)
|
||||
|
||||
console.print(summary)
|
||||
|
||||
# exit via ctx cancellation.
|
||||
await brokerd_ctx.cancel(timeout=1)
|
||||
# TODO: once ported to newer tractor branch we should
|
||||
# be able to do a loop like this:
|
||||
# while brokerd_ctx.cancel_called_remote is None:
|
||||
# await trio.sleep(0.01)
|
||||
# await brokerd_ctx.cancel()
|
||||
|
||||
await portal.cancel_actor()
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ledger() # this is called from ``>> ledger <accountname>``
|
|
@ -25,6 +25,7 @@ __brokers__ = [
|
|||
'ib',
|
||||
'kraken',
|
||||
'kucoin'
|
||||
|
||||
# broken but used to work
|
||||
# 'questrade',
|
||||
# 'robinhood',
|
||||
|
|
|
@ -0,0 +1,169 @@
|
|||
# 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/>.
|
||||
|
||||
'''
|
||||
Broker-daemon-actor "endpoint-hooks": the service task entry points for
|
||||
``brokerd``.
|
||||
|
||||
'''
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
)
|
||||
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from . import _util
|
||||
from . import get_brokermod
|
||||
|
||||
# `brokerd` enabled modules
|
||||
# TODO: move this def to the `.data` subpkg..
|
||||
# NOTE: keeping this list as small as possible is part of our caps-sec
|
||||
# model and should be treated with utmost care!
|
||||
_data_mods = [
|
||||
'piker.brokers.core',
|
||||
'piker.brokers.data',
|
||||
'piker.brokers._daemon',
|
||||
'piker.data',
|
||||
'piker.data.feed',
|
||||
'piker.data._sampling'
|
||||
]
|
||||
|
||||
|
||||
# TODO: we should rename the daemon to datad prolly once we split up
|
||||
# broker vs. data tasks into separate actors?
|
||||
@tractor.context
|
||||
async def _setup_persistent_brokerd(
|
||||
ctx: tractor.Context,
|
||||
brokername: str,
|
||||
loglevel: str | None = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Allocate a actor-wide service nursery in ``brokerd``
|
||||
such that feeds can be run in the background persistently by
|
||||
the broker backend as needed.
|
||||
|
||||
'''
|
||||
log = _util.get_console_log(
|
||||
loglevel or tractor.current_actor().loglevel,
|
||||
name=f'{_util.subsys}.{brokername}',
|
||||
)
|
||||
# set global for this actor to this new process-wide instance B)
|
||||
_util.log = log
|
||||
|
||||
from piker.data.feed import (
|
||||
_bus,
|
||||
get_feed_bus,
|
||||
)
|
||||
global _bus
|
||||
assert not _bus
|
||||
|
||||
async with trio.open_nursery() as service_nursery:
|
||||
# assign a nursery to the feeds bus for spawning
|
||||
# background tasks from clients
|
||||
get_feed_bus(brokername, service_nursery)
|
||||
|
||||
# unblock caller
|
||||
await ctx.started()
|
||||
|
||||
# we pin this task to keep the feeds manager active until the
|
||||
# parent actor decides to tear it down
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
||||
async def spawn_brokerd(
|
||||
|
||||
brokername: str,
|
||||
loglevel: str | None = None,
|
||||
|
||||
**tractor_kwargs,
|
||||
|
||||
) -> bool:
|
||||
|
||||
from piker.service import Services
|
||||
from piker.service._util import log # use service mngr log
|
||||
|
||||
log.info(f'Spawning {brokername} broker daemon')
|
||||
|
||||
brokermod = get_brokermod(brokername)
|
||||
dname = f'brokerd.{brokername}'
|
||||
|
||||
extra_tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
|
||||
tractor_kwargs.update(extra_tractor_kwargs)
|
||||
|
||||
# ask `pikerd` to spawn a new sub-actor and manage it under its
|
||||
# actor nursery
|
||||
modpath = brokermod.__name__
|
||||
broker_enable = [modpath]
|
||||
for submodname in getattr(
|
||||
brokermod,
|
||||
'__enable_modules__',
|
||||
[],
|
||||
):
|
||||
subpath = f'{modpath}.{submodname}'
|
||||
broker_enable.append(subpath)
|
||||
|
||||
portal = await Services.actor_n.start_actor(
|
||||
dname,
|
||||
enable_modules=_data_mods + broker_enable,
|
||||
loglevel=loglevel,
|
||||
debug_mode=Services.debug_mode,
|
||||
**tractor_kwargs
|
||||
)
|
||||
|
||||
# non-blocking setup of brokerd service nursery
|
||||
await Services.start_service_task(
|
||||
dname,
|
||||
portal,
|
||||
|
||||
# signature of target root-task endpoint
|
||||
_setup_persistent_brokerd,
|
||||
brokername=brokername,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@acm
|
||||
async def maybe_spawn_brokerd(
|
||||
|
||||
brokername: str,
|
||||
loglevel: str | None = None,
|
||||
|
||||
**pikerd_kwargs,
|
||||
|
||||
) -> tractor.Portal:
|
||||
'''
|
||||
Helper to spawn a brokerd service *from* a client
|
||||
who wishes to use the sub-actor-daemon.
|
||||
|
||||
'''
|
||||
from piker.service import maybe_spawn_daemon
|
||||
|
||||
async with maybe_spawn_daemon(
|
||||
|
||||
f'brokerd.{brokername}',
|
||||
service_task_target=spawn_brokerd,
|
||||
spawn_args={
|
||||
'brokername': brokername,
|
||||
},
|
||||
loglevel=loglevel,
|
||||
|
||||
**pikerd_kwargs,
|
||||
|
||||
) as portal:
|
||||
yield portal
|
|
@ -15,13 +15,29 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Handy utils.
|
||||
Handy cross-broker utils.
|
||||
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
import json
|
||||
import asks
|
||||
import logging
|
||||
|
||||
from ..log import colorize_json
|
||||
from ..log import (
|
||||
get_logger,
|
||||
get_console_log,
|
||||
colorize_json,
|
||||
)
|
||||
subsys: str = 'piker.brokers'
|
||||
|
||||
# NOTE: level should be reset by any actor that is spawned
|
||||
log = get_logger(subsys)
|
||||
|
||||
get_console_log = partial(
|
||||
get_console_log,
|
||||
name=subsys,
|
||||
)
|
||||
|
||||
|
||||
class BrokerError(Exception):
|
||||
|
@ -69,7 +85,6 @@ class DataThrottle(BrokerError):
|
|||
# TODO: add in throttle metrics/feedback
|
||||
|
||||
|
||||
|
||||
def resproc(
|
||||
resp: asks.response_objects.Response,
|
||||
log: logging.Logger,
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Guillermo Rodriguez (in stewardship for piker0)
|
||||
# Copyright (C)
|
||||
# Guillermo Rodriguez
|
||||
# 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
|
||||
|
@ -18,15 +21,19 @@
|
|||
Binance backend
|
||||
|
||||
"""
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
aclosing,
|
||||
)
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import itertools
|
||||
from typing import (
|
||||
Any, Union, Optional,
|
||||
AsyncGenerator, Callable,
|
||||
)
|
||||
import time
|
||||
|
||||
from trio_util import trio_async_generator
|
||||
import trio
|
||||
from trio_typing import TaskStatus
|
||||
import pendulum
|
||||
|
@ -34,26 +41,30 @@ import asks
|
|||
from fuzzywuzzy import process as fuzzy
|
||||
import numpy as np
|
||||
import tractor
|
||||
import wsproto
|
||||
|
||||
from .._cacheables import async_lifo_cache
|
||||
from ..accounting._mktinfo import (
|
||||
Asset,
|
||||
MktPair,
|
||||
digits_to_dec,
|
||||
)
|
||||
from .._cacheables import open_cached_client
|
||||
from ._util import (
|
||||
resproc,
|
||||
SymbolNotFound,
|
||||
DataUnavailable,
|
||||
)
|
||||
from ..log import (
|
||||
get_logger,
|
||||
from ._util import (
|
||||
log,
|
||||
get_console_log,
|
||||
)
|
||||
from ..data.types import Struct
|
||||
from ..data.validate import FeedInit
|
||||
from ..data._web_bs import (
|
||||
open_autorecon_ws,
|
||||
NoBsWs,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
_url = 'https://api.binance.com'
|
||||
|
||||
|
@ -88,6 +99,9 @@ _show_wap_in_history = False
|
|||
|
||||
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#exchange-information
|
||||
|
||||
# TODO: make this frozen again by pre-processing the
|
||||
# filters list to a dict at init time?
|
||||
class Pair(Struct, frozen=True):
|
||||
symbol: str
|
||||
status: str
|
||||
|
@ -114,9 +128,24 @@ class Pair(Struct, frozen=True):
|
|||
defaultSelfTradePreventionMode: str
|
||||
allowedSelfTradePreventionModes: list[str]
|
||||
|
||||
filters: list[dict[str, Union[str, int, float]]]
|
||||
filters: dict[
|
||||
str,
|
||||
Union[str, int, float]
|
||||
]
|
||||
permissions: list[str]
|
||||
|
||||
@property
|
||||
def price_tick(self) -> Decimal:
|
||||
# XXX: lul, after manually inspecting the response format we
|
||||
# just directly pick out the info we need
|
||||
step_size: str = self.filters['PRICE_FILTER']['tickSize'].rstrip('0')
|
||||
return Decimal(step_size)
|
||||
|
||||
@property
|
||||
def size_tick(self) -> Decimal:
|
||||
step_size: str = self.filters['LOT_SIZE']['stepSize'].rstrip('0')
|
||||
return Decimal(step_size)
|
||||
|
||||
|
||||
class OHLC(Struct):
|
||||
'''
|
||||
|
@ -147,6 +176,18 @@ class OHLC(Struct):
|
|||
bar_wap: float = 0.0
|
||||
|
||||
|
||||
class L1(Struct):
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#individual-symbol-book-ticker-streams
|
||||
|
||||
update_id: int
|
||||
sym: str
|
||||
|
||||
bid: float
|
||||
bsize: float
|
||||
ask: float
|
||||
asize: float
|
||||
|
||||
|
||||
# convert datetime obj timestamp to unixtime in milliseconds
|
||||
def binance_timestamp(
|
||||
when: datetime
|
||||
|
@ -159,7 +200,7 @@ class Client:
|
|||
def __init__(self) -> None:
|
||||
self._sesh = asks.Session(connections=4)
|
||||
self._sesh.base_location = _url
|
||||
self._pairs: dict[str, Any] = {}
|
||||
self._pairs: dict[str, Pair] = {}
|
||||
|
||||
async def _api(
|
||||
self,
|
||||
|
@ -173,48 +214,58 @@ class Client:
|
|||
)
|
||||
return resproc(resp, log)
|
||||
|
||||
async def symbol_info(
|
||||
async def exch_info(
|
||||
|
||||
self,
|
||||
sym: Optional[str] = None,
|
||||
sym: str | None = None,
|
||||
|
||||
) -> dict[str, Any]:
|
||||
'''Get symbol info for the exchange.
|
||||
) -> dict[str, Pair] | Pair:
|
||||
'''
|
||||
Fresh exchange-pairs info query for symbol ``sym: str``:
|
||||
https://binance-docs.github.io/apidocs/spot/en/#exchange-information
|
||||
|
||||
'''
|
||||
# TODO: we can load from our self._pairs cache
|
||||
# on repeat calls...
|
||||
cached_pair = self._pairs.get(sym)
|
||||
if cached_pair:
|
||||
return cached_pair
|
||||
|
||||
# will retrieve all symbols by default
|
||||
# retrieve all symbols by default
|
||||
params = {}
|
||||
|
||||
if sym is not None:
|
||||
sym = sym.lower()
|
||||
params = {'symbol': sym}
|
||||
|
||||
resp = await self._api(
|
||||
'exchangeInfo',
|
||||
params=params,
|
||||
)
|
||||
|
||||
resp = await self._api('exchangeInfo', params=params)
|
||||
entries = resp['symbols']
|
||||
if not entries:
|
||||
raise SymbolNotFound(f'{sym} not found')
|
||||
raise SymbolNotFound(f'{sym} not found:\n{resp}')
|
||||
|
||||
syms = {item['symbol']: item for item in entries}
|
||||
# pre-process .filters field into a table
|
||||
pairs = {}
|
||||
for item in entries:
|
||||
symbol = item['symbol']
|
||||
filters = {}
|
||||
filters_ls: list = item.pop('filters')
|
||||
for entry in filters_ls:
|
||||
ftype = entry['filterType']
|
||||
filters[ftype] = entry
|
||||
|
||||
pairs[symbol] = Pair(
|
||||
filters=filters,
|
||||
**item,
|
||||
)
|
||||
|
||||
# pairs = {
|
||||
# item['symbol']: Pair(**item) for item in entries
|
||||
# }
|
||||
self._pairs.update(pairs)
|
||||
|
||||
if sym is not None:
|
||||
return syms[sym]
|
||||
return pairs[sym]
|
||||
else:
|
||||
return syms
|
||||
return self._pairs
|
||||
|
||||
async def cache_symbols(
|
||||
self,
|
||||
) -> dict:
|
||||
if not self._pairs:
|
||||
self._pairs = await self.symbol_info()
|
||||
|
||||
return self._pairs
|
||||
symbol_info = exch_info
|
||||
|
||||
async def search_symbols(
|
||||
self,
|
||||
|
@ -224,7 +275,7 @@ class Client:
|
|||
if self._pairs is not None:
|
||||
data = self._pairs
|
||||
else:
|
||||
data = await self.symbol_info()
|
||||
data = await self.exch_info()
|
||||
|
||||
matches = fuzzy.extractBests(
|
||||
pattern,
|
||||
|
@ -299,7 +350,8 @@ class Client:
|
|||
@acm
|
||||
async def get_client() -> Client:
|
||||
client = Client()
|
||||
await client.cache_symbols()
|
||||
log.info('Caching exchange infos..')
|
||||
await client.exch_info()
|
||||
yield client
|
||||
|
||||
|
||||
|
@ -318,67 +370,93 @@ class AggTrade(Struct):
|
|||
M: bool # Ignore
|
||||
|
||||
|
||||
@trio_async_generator
|
||||
async def stream_messages(
|
||||
ws: NoBsWs,
|
||||
) -> AsyncGenerator[NoBsWs, dict]:
|
||||
|
||||
timeouts = 0
|
||||
while True:
|
||||
# TODO: match syntax here!
|
||||
msg: dict[str, Any]
|
||||
async for msg in ws:
|
||||
match msg:
|
||||
# for l1 streams binance doesn't add an event type field so
|
||||
# identify those messages by matching keys
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#individual-symbol-book-ticker-streams
|
||||
case {
|
||||
# NOTE: this is never an old value it seems, so
|
||||
# they are always sending real L1 spread updates.
|
||||
'u': upid, # update id
|
||||
's': sym,
|
||||
'b': bid,
|
||||
'B': bsize,
|
||||
'a': ask,
|
||||
'A': asize,
|
||||
}:
|
||||
# TODO: it would be super nice to have a `L1` piker type
|
||||
# which "renders" incremental tick updates from a packed
|
||||
# msg-struct:
|
||||
# - backend msgs after packed into the type such that we
|
||||
# can reduce IPC usage but without each backend having
|
||||
# to do that incremental update logic manually B)
|
||||
# - would it maybe be more efficient to use this instead?
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#diff-depth-stream
|
||||
l1 = L1(
|
||||
update_id=upid,
|
||||
sym=sym,
|
||||
bid=bid,
|
||||
bsize=bsize,
|
||||
ask=ask,
|
||||
asize=asize,
|
||||
)
|
||||
l1.typecast()
|
||||
|
||||
with trio.move_on_after(3) as cs:
|
||||
msg = await ws.recv_msg()
|
||||
# repack into piker's tick-quote format
|
||||
yield 'l1', {
|
||||
'symbol': l1.sym,
|
||||
'ticks': [
|
||||
{
|
||||
'type': 'bid',
|
||||
'price': l1.bid,
|
||||
'size': l1.bsize,
|
||||
},
|
||||
{
|
||||
'type': 'bsize',
|
||||
'price': l1.bid,
|
||||
'size': l1.bsize,
|
||||
},
|
||||
{
|
||||
'type': 'ask',
|
||||
'price': l1.ask,
|
||||
'size': l1.asize,
|
||||
},
|
||||
{
|
||||
'type': 'asize',
|
||||
'price': l1.ask,
|
||||
'size': l1.asize,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if cs.cancelled_caught:
|
||||
|
||||
timeouts += 1
|
||||
if timeouts > 2:
|
||||
log.error("binance feed seems down and slow af? rebooting...")
|
||||
await ws._connect()
|
||||
|
||||
continue
|
||||
|
||||
# for l1 streams binance doesn't add an event type field so
|
||||
# identify those messages by matching keys
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#individual-symbol-book-ticker-streams
|
||||
|
||||
if msg.get('u'):
|
||||
sym = msg['s']
|
||||
bid = float(msg['b'])
|
||||
bsize = float(msg['B'])
|
||||
ask = float(msg['a'])
|
||||
asize = float(msg['A'])
|
||||
|
||||
yield 'l1', {
|
||||
'symbol': sym,
|
||||
'ticks': [
|
||||
{'type': 'bid', 'price': bid, 'size': bsize},
|
||||
{'type': 'bsize', 'price': bid, 'size': bsize},
|
||||
{'type': 'ask', 'price': ask, 'size': asize},
|
||||
{'type': 'asize', 'price': ask, 'size': asize}
|
||||
]
|
||||
}
|
||||
|
||||
elif msg.get('e') == 'aggTrade':
|
||||
|
||||
# NOTE: this is purely for a definition, ``msgspec.Struct``
|
||||
# does not runtime-validate until you decode/encode.
|
||||
# see: https://jcristharif.com/msgspec/structs.html#type-validation
|
||||
msg = AggTrade(**msg)
|
||||
|
||||
# TODO: type out and require this quote format
|
||||
# from all backends!
|
||||
yield 'trade', {
|
||||
'symbol': msg.s,
|
||||
'last': msg.p,
|
||||
'brokerd_ts': time.time(),
|
||||
'ticks': [{
|
||||
'type': 'trade',
|
||||
'price': float(msg.p),
|
||||
'size': float(msg.q),
|
||||
'broker_ts': msg.T,
|
||||
}],
|
||||
}
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#aggregate-trade-streams
|
||||
case {
|
||||
'e': 'aggTrade',
|
||||
}:
|
||||
# NOTE: this is purely for a definition,
|
||||
# ``msgspec.Struct`` does not runtime-validate until you
|
||||
# decode/encode, see:
|
||||
# https://jcristharif.com/msgspec/structs.html#type-validation
|
||||
msg = AggTrade(**msg)
|
||||
msg.typecast()
|
||||
yield 'trade', {
|
||||
'symbol': msg.s,
|
||||
'last': msg.p,
|
||||
'brokerd_ts': time.time(),
|
||||
'ticks': [{
|
||||
'type': 'trade',
|
||||
'price': msg.p,
|
||||
'size': msg.q,
|
||||
'broker_ts': msg.T,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def make_sub(pairs: list[str], sub_name: str, uid: int) -> dict[str, str]:
|
||||
|
@ -398,10 +476,12 @@ def make_sub(pairs: list[str], sub_name: str, uid: int) -> dict[str, str]:
|
|||
|
||||
@acm
|
||||
async def open_history_client(
|
||||
symbol: str,
|
||||
mkt: MktPair,
|
||||
|
||||
) -> tuple[Callable, int]:
|
||||
|
||||
symbol: str = mkt.bs_fqme
|
||||
|
||||
# TODO implement history getter for the new storage layer.
|
||||
async with open_cached_client('binance') as client:
|
||||
|
||||
|
@ -439,6 +519,35 @@ async def open_history_client(
|
|||
yield get_ohlc, {'erlangs': 3, 'rate': 3}
|
||||
|
||||
|
||||
@async_lifo_cache()
|
||||
async def get_mkt_info(
|
||||
fqme: str,
|
||||
|
||||
) -> tuple[MktPair, Pair]:
|
||||
|
||||
async with open_cached_client('binance') as client:
|
||||
|
||||
pair: Pair = await client.exch_info(fqme.upper())
|
||||
mkt = MktPair(
|
||||
dst=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,
|
||||
size_tick=pair.size_tick,
|
||||
bs_mktid=pair.symbol,
|
||||
broker='binance',
|
||||
)
|
||||
both = mkt, pair
|
||||
return both
|
||||
|
||||
|
||||
async def stream_quotes(
|
||||
|
||||
send_chan: trio.abc.SendChannel,
|
||||
|
@ -453,67 +562,43 @@ async def stream_quotes(
|
|||
# XXX: required to propagate ``tractor`` loglevel to piker logging
|
||||
get_console_log(loglevel or tractor.current_actor().loglevel)
|
||||
|
||||
sym_infos = {}
|
||||
uid = 0
|
||||
|
||||
async with (
|
||||
open_cached_client('binance') as client,
|
||||
send_chan as send_chan,
|
||||
):
|
||||
|
||||
# keep client cached for real-time section
|
||||
cache = await client.cache_symbols()
|
||||
|
||||
init_msgs: list[FeedInit] = []
|
||||
for sym in symbols:
|
||||
d = cache[sym.upper()]
|
||||
syminfo = Pair(**d) # validation
|
||||
mkt, pair = await get_mkt_info(sym)
|
||||
|
||||
si = sym_infos[sym] = syminfo.to_dict()
|
||||
filters = {}
|
||||
for entry in syminfo.filters:
|
||||
ftype = entry['filterType']
|
||||
filters[ftype] = entry
|
||||
|
||||
# XXX: after manually inspecting the response format we
|
||||
# just directly pick out the info we need
|
||||
si['price_tick_size'] = float(
|
||||
filters['PRICE_FILTER']['tickSize']
|
||||
# build out init msgs according to latest spec
|
||||
init_msgs.append(
|
||||
FeedInit(mkt_info=mkt)
|
||||
)
|
||||
si['lot_tick_size'] = float(
|
||||
filters['LOT_SIZE']['stepSize']
|
||||
)
|
||||
si['asset_type'] = 'crypto'
|
||||
|
||||
symbol = symbols[0]
|
||||
|
||||
init_msgs = {
|
||||
# pass back token, and bool, signalling if we're the writer
|
||||
# and that history has been written
|
||||
symbol: {
|
||||
'symbol_info': sym_infos[sym],
|
||||
'shm_write_opts': {'sum_tick_vml': False},
|
||||
'fqsn': sym,
|
||||
},
|
||||
}
|
||||
iter_subids = itertools.count()
|
||||
|
||||
@acm
|
||||
async def subscribe(ws: wsproto.WSConnection):
|
||||
async def subscribe(ws: NoBsWs):
|
||||
# setup subs
|
||||
|
||||
subid: int = next(iter_subids)
|
||||
|
||||
# trade data (aka L1)
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#symbol-order-book-ticker
|
||||
l1_sub = make_sub(symbols, 'bookTicker', uid)
|
||||
l1_sub = make_sub(symbols, 'bookTicker', subid)
|
||||
await ws.send_msg(l1_sub)
|
||||
|
||||
# aggregate (each order clear by taker **not** by maker)
|
||||
# trades data:
|
||||
# https://binance-docs.github.io/apidocs/spot/en/#aggregate-trade-streams
|
||||
agg_trades_sub = make_sub(symbols, 'aggTrade', uid)
|
||||
agg_trades_sub = make_sub(symbols, 'aggTrade', subid)
|
||||
await ws.send_msg(agg_trades_sub)
|
||||
|
||||
# ack from ws server
|
||||
# might get ack from ws server, or maybe some
|
||||
# other msg still in transit..
|
||||
res = await ws.recv_msg()
|
||||
assert res['id'] == uid
|
||||
subid: str | None = res.get('id')
|
||||
if subid:
|
||||
assert res['id'] == subid
|
||||
|
||||
yield
|
||||
|
||||
|
@ -527,7 +612,7 @@ async def stream_quotes(
|
|||
await ws.send_msg({
|
||||
"method": "UNSUBSCRIBE",
|
||||
"params": subs,
|
||||
"id": uid,
|
||||
"id": subid,
|
||||
})
|
||||
|
||||
# XXX: do we need to ack the unsub?
|
||||
|
@ -543,7 +628,7 @@ async def stream_quotes(
|
|||
) as ws,
|
||||
|
||||
# avoid stream-gen closure from breaking trio..
|
||||
stream_messages(ws) as msg_gen,
|
||||
aclosing(stream_messages(ws)) as msg_gen,
|
||||
):
|
||||
typ, quote = await anext(msg_gen)
|
||||
|
||||
|
@ -579,13 +664,13 @@ async def open_symbol_search(
|
|||
async with open_cached_client('binance') as client:
|
||||
|
||||
# load all symbols locally for fast search
|
||||
cache = await client.cache_symbols()
|
||||
cache = await client.exch_info()
|
||||
await ctx.started()
|
||||
|
||||
async with ctx.open_stream() as stream:
|
||||
|
||||
async for pattern in stream:
|
||||
# results = await client.symbol_info(sym=pattern.upper())
|
||||
# results = await client.exch_info(sym=pattern.upper())
|
||||
|
||||
matches = fuzzy.extractBests(
|
||||
pattern,
|
||||
|
@ -593,7 +678,7 @@ async def open_symbol_search(
|
|||
score_cutoff=50,
|
||||
)
|
||||
# repack in dict form
|
||||
await stream.send(
|
||||
{item[0]['symbol']: item[0]
|
||||
for item in matches}
|
||||
)
|
||||
await stream.send({
|
||||
item[0].symbol: item[0]
|
||||
for item in matches
|
||||
})
|
||||
|
|
|
@ -28,7 +28,13 @@ import tractor
|
|||
|
||||
from ..cli import cli
|
||||
from .. import watchlists as wl
|
||||
from ..log import get_console_log, colorize_json, get_logger
|
||||
from ..log import (
|
||||
colorize_json,
|
||||
)
|
||||
from ._util import (
|
||||
log,
|
||||
get_console_log,
|
||||
)
|
||||
from ..service import (
|
||||
maybe_spawn_brokerd,
|
||||
maybe_open_pikerd,
|
||||
|
@ -38,9 +44,7 @@ from ..brokers import (
|
|||
get_brokermod,
|
||||
data,
|
||||
)
|
||||
|
||||
log = get_logger('cli')
|
||||
DEFAULT_BROKER = 'questrade'
|
||||
DEFAULT_BROKER = 'binance'
|
||||
|
||||
_config_dir = click.get_app_dir('piker')
|
||||
_watchlists_data_path = os.path.join(_config_dir, 'watchlists.json')
|
||||
|
|
|
@ -26,15 +26,12 @@ from typing import List, Dict, Any, Optional
|
|||
|
||||
import trio
|
||||
|
||||
from ..log import get_logger
|
||||
from ._util import log
|
||||
from . import get_brokermod
|
||||
from ..service import maybe_spawn_brokerd
|
||||
from .._cacheables import open_cached_client
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
async def api(brokername: str, methname: str, **kwargs) -> dict:
|
||||
"""Make (proxy through) a broker API call by name and return its result.
|
||||
"""
|
||||
|
|
|
@ -41,13 +41,13 @@ import tractor
|
|||
from tractor.experimental import msgpub
|
||||
from async_generator import asynccontextmanager
|
||||
|
||||
from ..log import get_logger, get_console_log
|
||||
from ._util import (
|
||||
log,
|
||||
get_console_log,
|
||||
)
|
||||
from . import get_brokermod
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
async def wait_for_network(
|
||||
net_func: Callable,
|
||||
sleep: int = 1
|
||||
|
|
|
@ -62,9 +62,10 @@ log = get_logger(__name__)
|
|||
|
||||
@acm
|
||||
async def open_history_client(
|
||||
instrument: str,
|
||||
mkt: MktPair,
|
||||
) -> tuple[Callable, int]:
|
||||
|
||||
fnstrument: str = mkt.bs_fqme
|
||||
# TODO implement history getter for the new storage layer.
|
||||
async with open_cached_client('deribit') as client:
|
||||
|
||||
|
|
|
@ -127,7 +127,7 @@ your ``pps.toml`` file will have position entries like,
|
|||
[ib.algopaper."mnq.globex.20221216"]
|
||||
size = -1.0
|
||||
ppu = 12423.630576923071
|
||||
bsuid = 515416577
|
||||
bs_mktid = 515416577
|
||||
expiry = "2022-12-16T00:00:00+00:00"
|
||||
clears = [
|
||||
{ dt = "2022-08-31T18:54:46+00:00", ppu = 12423.630576923071, accum_size = -19.0, price = 12372.75, size = 1.0, cost = 0.57, tid = "0000e1a7.630f5e5a.01.01" },
|
||||
|
|
|
@ -35,7 +35,6 @@ from .feed import (
|
|||
)
|
||||
from .broker import (
|
||||
trades_dialogue,
|
||||
norm_trade_records,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
@ -46,14 +45,23 @@ __all__ = [
|
|||
'stream_quotes',
|
||||
]
|
||||
|
||||
|
||||
# tractor RPC enable arg
|
||||
__enable_modules__: list[str] = [
|
||||
_brokerd_mods: list[str] = [
|
||||
'api',
|
||||
'feed',
|
||||
'broker',
|
||||
]
|
||||
|
||||
_datad_mods: list[str] = [
|
||||
'feed',
|
||||
]
|
||||
|
||||
|
||||
# tractor RPC enable arg
|
||||
__enable_modules__: list[str] = (
|
||||
_brokerd_mods
|
||||
+
|
||||
_datad_mods
|
||||
)
|
||||
|
||||
# passed to ``tractor.ActorNursery.start_actor()``
|
||||
_spawn_kwargs = {
|
||||
'infect_asyncio': True,
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
"FLEX" report processing utils.
|
||||
|
||||
"""
|
||||
from bidict import bidict
|
||||
import pendulum
|
||||
from pprint import pformat
|
||||
from typing import Any
|
||||
|
||||
from .api import (
|
||||
get_config,
|
||||
log,
|
||||
)
|
||||
from piker.accounting import (
|
||||
open_trade_ledger,
|
||||
)
|
||||
|
||||
|
||||
def parse_flex_dt(
|
||||
record: str,
|
||||
) -> pendulum.datetime:
|
||||
'''
|
||||
Parse stupid flex record datetime stamps for the `dateTime` field..
|
||||
|
||||
'''
|
||||
date, ts = record.split(';')
|
||||
dt = pendulum.parse(date)
|
||||
ts = f'{ts[:2]}:{ts[2:4]}:{ts[4:]}'
|
||||
tsdt = pendulum.parse(ts)
|
||||
return dt.set(hour=tsdt.hour, minute=tsdt.minute, second=tsdt.second)
|
||||
|
||||
|
||||
def flex_records_to_ledger_entries(
|
||||
accounts: bidict,
|
||||
trade_entries: list[object],
|
||||
|
||||
) -> dict:
|
||||
'''
|
||||
Convert flex report entry objects into ``dict`` form, pretty much
|
||||
straight up without modification except add a `pydatetime` field
|
||||
from the parsed timestamp.
|
||||
|
||||
'''
|
||||
trades_by_account = {}
|
||||
for t in trade_entries:
|
||||
entry = t.__dict__
|
||||
|
||||
# XXX: LOL apparently ``toml`` has a bug
|
||||
# where a section key error will show up in the write
|
||||
# if you leave a table key as an `int`? So i guess
|
||||
# cast to strs for all keys..
|
||||
|
||||
# oddly for some so-called "BookTrade" entries
|
||||
# this field seems to be blank, no cuckin clue.
|
||||
# trade['ibExecID']
|
||||
tid = str(entry.get('ibExecID') or entry['tradeID'])
|
||||
# date = str(entry['tradeDate'])
|
||||
|
||||
# XXX: is it going to cause problems if a account name
|
||||
# get's lost? The user should be able to find it based
|
||||
# on the actual exec history right?
|
||||
acctid = accounts[str(entry['accountId'])]
|
||||
|
||||
# probably a flex record with a wonky non-std timestamp..
|
||||
dt = entry['pydatetime'] = parse_flex_dt(entry['dateTime'])
|
||||
entry['datetime'] = str(dt)
|
||||
|
||||
if not tid:
|
||||
# this is likely some kind of internal adjustment
|
||||
# transaction, likely one of the following:
|
||||
# - an expiry event that will show a "book trade" indicating
|
||||
# some adjustment to cash balances: zeroing or itm settle.
|
||||
# - a manual cash balance position adjustment likely done by
|
||||
# the user from the accounts window in TWS where they can
|
||||
# manually set the avg price and size:
|
||||
# https://api.ibkr.com/lib/cstools/faq/web1/index.html#/tag/DTWS_ADJ_AVG_COST
|
||||
log.warning(f'Skipping ID-less ledger entry:\n{pformat(entry)}')
|
||||
continue
|
||||
|
||||
trades_by_account.setdefault(
|
||||
acctid, {}
|
||||
)[tid] = entry
|
||||
|
||||
for acctid in trades_by_account:
|
||||
trades_by_account[acctid] = dict(sorted(
|
||||
trades_by_account[acctid].items(),
|
||||
key=lambda entry: entry[1]['pydatetime'],
|
||||
))
|
||||
|
||||
return trades_by_account
|
||||
|
||||
|
||||
def load_flex_trades(
|
||||
path: str | None = None,
|
||||
|
||||
) -> dict[str, Any]:
|
||||
|
||||
from ib_insync import flexreport, util
|
||||
|
||||
conf = get_config()
|
||||
|
||||
if not path:
|
||||
# load ``brokers.toml`` and try to get the flex
|
||||
# token and query id that must be previously defined
|
||||
# by the user.
|
||||
token = conf.get('flex_token')
|
||||
if not token:
|
||||
raise ValueError(
|
||||
'You must specify a ``flex_token`` field in your'
|
||||
'`brokers.toml` in order load your trade log, see our'
|
||||
'intructions for how to set this up here:\n'
|
||||
'PUT LINK HERE!'
|
||||
)
|
||||
|
||||
qid = conf['flex_trades_query_id']
|
||||
|
||||
# TODO: hack this into our logging
|
||||
# system like we do with the API client..
|
||||
util.logToConsole()
|
||||
|
||||
# TODO: rewrite the query part of this with async..httpx?
|
||||
report = flexreport.FlexReport(
|
||||
token=token,
|
||||
queryId=qid,
|
||||
)
|
||||
|
||||
else:
|
||||
# XXX: another project we could potentially look at,
|
||||
# https://pypi.org/project/ibflex/
|
||||
report = flexreport.FlexReport(path=path)
|
||||
|
||||
trade_entries = report.extract('Trade')
|
||||
ln = len(trade_entries)
|
||||
log.info(f'Loaded {ln} trades from flex query')
|
||||
|
||||
trades_by_account = flex_records_to_ledger_entries(
|
||||
conf['accounts'].inverse, # reverse map to user account names
|
||||
trade_entries,
|
||||
)
|
||||
|
||||
ledger_dict: dict | None = None
|
||||
|
||||
for acctid in trades_by_account:
|
||||
trades_by_id = trades_by_account[acctid]
|
||||
|
||||
with open_trade_ledger('ib', acctid) as ledger_dict:
|
||||
tid_delta = set(trades_by_id) - set(ledger_dict)
|
||||
log.info(
|
||||
'New trades detected\n'
|
||||
f'{pformat(tid_delta)}'
|
||||
)
|
||||
if tid_delta:
|
||||
sorted_delta = dict(sorted(
|
||||
{tid: trades_by_id[tid] for tid in tid_delta}.items(),
|
||||
key=lambda entry: entry[1].pop('pydatetime'),
|
||||
))
|
||||
ledger_dict.update(sorted_delta)
|
||||
|
||||
return ledger_dict
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
import os
|
||||
|
||||
args = sys.argv
|
||||
if len(args) > 1:
|
||||
args = args[1:]
|
||||
for arg in args:
|
||||
path = os.path.abspath(arg)
|
||||
load_flex_trades(path=path)
|
||||
else:
|
||||
# expect brokers.toml to have an entry and
|
||||
# pull from the web service.
|
||||
load_flex_trades()
|
|
@ -19,14 +19,23 @@
|
|||
runnable script-programs.
|
||||
|
||||
'''
|
||||
from typing import Literal
|
||||
from __future__ import annotations
|
||||
from functools import partial
|
||||
from typing import (
|
||||
Literal,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
import subprocess
|
||||
|
||||
import tractor
|
||||
|
||||
from piker.log import get_logger
|
||||
from .._util import log
|
||||
|
||||
log = get_logger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from .api import (
|
||||
MethodProxy,
|
||||
ib_Client
|
||||
)
|
||||
|
||||
|
||||
_reset_tech: Literal[
|
||||
|
@ -41,7 +50,8 @@ _reset_tech: Literal[
|
|||
|
||||
|
||||
async def data_reset_hack(
|
||||
reset_type: str = 'data',
|
||||
vnc_host: str,
|
||||
reset_type: Literal['data', 'connection'],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
|
@ -71,18 +81,40 @@ async def data_reset_hack(
|
|||
that need to be wrangle.
|
||||
|
||||
'''
|
||||
|
||||
no_setup_msg:str = (
|
||||
'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
|
||||
|
||||
match _reset_tech:
|
||||
case 'vnc':
|
||||
try:
|
||||
await tractor.to_asyncio.run_task(vnc_click_hack)
|
||||
await tractor.to_asyncio.run_task(
|
||||
partial(
|
||||
vnc_click_hack,
|
||||
host=vnc_host,
|
||||
)
|
||||
)
|
||||
except OSError:
|
||||
_reset_tech = 'i3ipc_xdotool'
|
||||
if vnc_host != 'localhost':
|
||||
log.warning(no_setup_msg)
|
||||
return False
|
||||
|
||||
try:
|
||||
import i3ipc
|
||||
except ModuleNotFoundError:
|
||||
log.warning(no_setup_msg)
|
||||
return False
|
||||
|
||||
try:
|
||||
i3ipc_xdotool_manual_click_hack()
|
||||
_reset_tech = 'i3ipc_xdotool'
|
||||
return True
|
||||
except OSError:
|
||||
log.exception(no_setup_msg)
|
||||
return False
|
||||
|
||||
case 'i3ipc_xdotool':
|
||||
|
@ -96,19 +128,32 @@ async def data_reset_hack(
|
|||
|
||||
|
||||
async def vnc_click_hack(
|
||||
host: str = 'localhost',
|
||||
reset_type: str = 'data'
|
||||
) -> None:
|
||||
'''
|
||||
Reset the data or netowork connection for the VNC attached
|
||||
Reset the data or network connection for the VNC attached
|
||||
ib gateway using magic combos.
|
||||
|
||||
'''
|
||||
key = {'data': 'f', 'connection': 'r'}[reset_type]
|
||||
try:
|
||||
import asyncvnc
|
||||
except ModuleNotFoundError:
|
||||
log.warning(
|
||||
"In order to leverage `piker`'s built-in data reset hacks, install "
|
||||
"the `asyncvnc` project: https://github.com/barneygale/asyncvnc"
|
||||
)
|
||||
return
|
||||
|
||||
import asyncvnc
|
||||
# two different hot keys which trigger diff types of reset
|
||||
# requests B)
|
||||
key = {
|
||||
'data': 'f',
|
||||
'connection': 'r'
|
||||
}[reset_type]
|
||||
|
||||
async with asyncvnc.connect(
|
||||
'localhost',
|
||||
host,
|
||||
port=3003,
|
||||
# password='ibcansmbz',
|
||||
) as client:
|
||||
|
@ -124,9 +169,11 @@ async def vnc_click_hack(
|
|||
|
||||
|
||||
def i3ipc_xdotool_manual_click_hack() -> None:
|
||||
import i3ipc
|
||||
|
||||
i3 = i3ipc.Connection()
|
||||
|
||||
# TODO: might be worth offering some kinda api for grabbing
|
||||
# the window id from the pid?
|
||||
# https://stackoverflow.com/a/2250879
|
||||
t = i3.get_tree()
|
||||
|
||||
orig_win_id = t.find_focused().window
|
||||
|
|
|
@ -14,21 +14,27 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
``ib`` core API client machinery; mostly sane wrapping around
|
||||
``ib_insync``.
|
||||
'''
|
||||
Core API client machinery; mostly sane/useful wrapping around `ib_insync`..
|
||||
|
||||
"""
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
contextmanager as cm,
|
||||
)
|
||||
from contextlib import AsyncExitStack
|
||||
from dataclasses import asdict, astuple
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from functools import (
|
||||
partial,
|
||||
# lru_cache,
|
||||
)
|
||||
import itertools
|
||||
from math import isnan
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
|
@ -44,6 +50,7 @@ import trio
|
|||
import tractor
|
||||
from tractor import to_asyncio
|
||||
import pendulum
|
||||
from eventkit import Event
|
||||
import ib_insync as ibis
|
||||
from ib_insync.contract import (
|
||||
Contract,
|
||||
|
@ -67,13 +74,13 @@ from ib_insync.client import Client as ib_Client
|
|||
import numpy as np
|
||||
|
||||
from piker import config
|
||||
from piker.log import get_logger
|
||||
from piker.brokers._util import (
|
||||
log,
|
||||
get_logger,
|
||||
)
|
||||
from piker.data._source import base_ohlc_dtype
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
_time_units = {
|
||||
's': ' sec',
|
||||
'm': ' mins',
|
||||
|
@ -130,11 +137,13 @@ class NonShittyWrapper(Wrapper):
|
|||
|
||||
|
||||
class NonShittyIB(ibis.IB):
|
||||
"""The beginning of overriding quite a few decisions in this lib.
|
||||
'''
|
||||
The beginning of overriding quite a few decisions in this lib.
|
||||
|
||||
- Don't use datetimes
|
||||
- Don't use named tuples
|
||||
"""
|
||||
|
||||
'''
|
||||
def __init__(self):
|
||||
|
||||
# override `ib_insync` internal loggers so we can see wtf
|
||||
|
@ -172,6 +181,8 @@ _adhoc_cmdty_set = {
|
|||
'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
|
||||
|
@ -183,6 +194,7 @@ _adhoc_futes_set = {
|
|||
|
||||
# cypto$
|
||||
'brr.cme',
|
||||
'mbt.cme', # micro
|
||||
'ethusdrr.cme',
|
||||
|
||||
# agriculture
|
||||
|
@ -197,7 +209,7 @@ _adhoc_futes_set = {
|
|||
'mgc.comex', # micro
|
||||
|
||||
# oil & gas
|
||||
'cl.comex',
|
||||
'cl.nymex',
|
||||
|
||||
'ni.comex', # silver futes
|
||||
'qi.comex', # mini-silver futes
|
||||
|
@ -311,6 +323,22 @@ _samplings: dict[int, tuple[str, str]] = {
|
|||
}
|
||||
|
||||
|
||||
@cm
|
||||
def remove_handler_on_err(
|
||||
event: Event,
|
||||
handler: Callable,
|
||||
) -> None:
|
||||
try:
|
||||
yield
|
||||
except trio.BrokenResourceError:
|
||||
# XXX: eventkit's ``Event.emit()`` for whatever redic
|
||||
# reason will catch and ignore regular exceptions
|
||||
# resulting in tracebacks spammed to console..
|
||||
# Manually do the dereg ourselves.
|
||||
log.exception(f'Disconnected from {event} updates')
|
||||
event.disconnect(handler)
|
||||
|
||||
|
||||
class Client:
|
||||
'''
|
||||
IB wrapped for our broker backend API.
|
||||
|
@ -330,7 +358,7 @@ class Client:
|
|||
self.ib.RaiseRequestErrors = True
|
||||
|
||||
# contract cache
|
||||
self._feeds: dict[str, trio.abc.SendChannel] = {}
|
||||
self._cons: dict[str, Contract] = {}
|
||||
|
||||
# NOTE: the ib.client here is "throttled" to 45 rps by default
|
||||
|
||||
|
@ -359,7 +387,7 @@ class Client:
|
|||
|
||||
async def bars(
|
||||
self,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
|
||||
# EST in ISO 8601 format is required... below is EPOCH
|
||||
start_dt: Union[datetime, str] = "1970-01-01T00:00:00.000000-05:00",
|
||||
|
@ -376,7 +404,7 @@ class Client:
|
|||
|
||||
) -> tuple[BarDataList, np.ndarray, pendulum.Duration]:
|
||||
'''
|
||||
Retreive OHLCV bars for a fqsn over a range to the present.
|
||||
Retreive OHLCV bars for a fqme over a range to the present.
|
||||
|
||||
'''
|
||||
# See API docs here:
|
||||
|
@ -386,8 +414,7 @@ class Client:
|
|||
bar_size, duration, dt_duration = _samplings[sample_period_s]
|
||||
|
||||
global _enters
|
||||
# log.info(f'REQUESTING BARS {_enters} @ end={end_dt}')
|
||||
print(
|
||||
log.info(
|
||||
f"REQUESTING {duration}'s worth {bar_size} BARS\n"
|
||||
f'{_enters} @ end={end_dt}"'
|
||||
)
|
||||
|
@ -397,7 +424,7 @@ class Client:
|
|||
|
||||
_enters += 1
|
||||
|
||||
contract = (await self.find_contracts(fqsn))[0]
|
||||
contract = (await self.find_contracts(fqme))[0]
|
||||
bars_kwargs.update(getattr(contract, 'bars_kwargs', {}))
|
||||
|
||||
bars = await self.ib.reqHistoricalDataAsync(
|
||||
|
@ -473,7 +500,7 @@ class Client:
|
|||
# nested dataclass we probably don't need and that won't
|
||||
# IPC serialize..
|
||||
d.secIdList = ''
|
||||
key, calc_price = con2fqsn(d.contract)
|
||||
key, calc_price = con2fqme(d.contract)
|
||||
details[key] = d
|
||||
|
||||
return details
|
||||
|
@ -614,15 +641,22 @@ class Client:
|
|||
|
||||
return con
|
||||
|
||||
# TODO: make this work with our `MethodProxy`..
|
||||
# @lru_cache(maxsize=None)
|
||||
async def get_con(
|
||||
self,
|
||||
conid: int,
|
||||
) -> Contract:
|
||||
return await self.ib.qualifyContractsAsync(
|
||||
ibis.Contract(conId=conid)
|
||||
)
|
||||
try:
|
||||
return self._cons[conid]
|
||||
except KeyError:
|
||||
con: Contract = await self.ib.qualifyContractsAsync(
|
||||
ibis.Contract(conId=conid)
|
||||
)
|
||||
self._cons[conid] = con
|
||||
return con
|
||||
|
||||
def parse_patt2fqsn(
|
||||
def parse_patt2fqme(
|
||||
self,
|
||||
pattern: str,
|
||||
|
||||
|
@ -641,11 +675,11 @@ class Client:
|
|||
|
||||
currency = ''
|
||||
|
||||
# fqsn parsing stage
|
||||
# fqme parsing stage
|
||||
# ------------------
|
||||
if '.ib' in pattern:
|
||||
from ..data._source import unpack_fqsn
|
||||
_, symbol, expiry = unpack_fqsn(pattern)
|
||||
from piker.accounting import unpack_fqme
|
||||
_, symbol, venue, expiry = unpack_fqme(pattern)
|
||||
|
||||
else:
|
||||
symbol = pattern
|
||||
|
@ -687,7 +721,7 @@ class Client:
|
|||
) -> Contract:
|
||||
|
||||
if pattern is not None:
|
||||
symbol, currency, exch, expiry = self.parse_patt2fqsn(
|
||||
symbol, currency, exch, expiry = self.parse_patt2fqme(
|
||||
pattern,
|
||||
)
|
||||
sectype = ''
|
||||
|
@ -722,7 +756,7 @@ class Client:
|
|||
)
|
||||
|
||||
elif (
|
||||
exch in ('IDEALPRO')
|
||||
exch in {'IDEALPRO'}
|
||||
or sectype == 'CASH'
|
||||
):
|
||||
# if '/' in symbol:
|
||||
|
@ -806,14 +840,14 @@ class Client:
|
|||
|
||||
async def get_head_time(
|
||||
self,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
|
||||
) -> datetime:
|
||||
'''
|
||||
Return the first datetime stamp for ``contract``.
|
||||
|
||||
'''
|
||||
contract = (await self.find_contracts(fqsn))[0]
|
||||
contract = (await self.find_contracts(fqme))[0]
|
||||
return await self.ib.reqHeadTimeStampAsync(
|
||||
contract,
|
||||
whatToShow='TRADES',
|
||||
|
@ -825,29 +859,34 @@ class Client:
|
|||
self,
|
||||
symbol: str,
|
||||
|
||||
) -> tuple[Contract, Ticker, ContractDetails]:
|
||||
) -> tuple[
|
||||
Contract,
|
||||
ContractDetails,
|
||||
]:
|
||||
'''
|
||||
Get summary (meta) data for a given symbol str including
|
||||
``Contract`` and its details and a (first snapshot of the)
|
||||
``Ticker``.
|
||||
|
||||
'''
|
||||
contract = (await self.find_contracts(symbol))[0]
|
||||
details_fute = self.ib.reqContractDetailsAsync(contract)
|
||||
details = (await details_fute)[0]
|
||||
return contract, details
|
||||
|
||||
async def get_quote(
|
||||
self,
|
||||
contract: Contract,
|
||||
|
||||
) -> Ticker:
|
||||
'''
|
||||
Return a single (snap) quote for symbol.
|
||||
|
||||
'''
|
||||
ticker: Ticker = self.ib.reqMktData(
|
||||
contract,
|
||||
snapshot=True,
|
||||
)
|
||||
details_fute = self.ib.reqContractDetailsAsync(contract)
|
||||
details = (await details_fute)[0]
|
||||
|
||||
return contract, ticker, details
|
||||
|
||||
async def get_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
|
||||
) -> tuple[Contract, Ticker, ContractDetails]:
|
||||
'''
|
||||
Return a single quote for symbol.
|
||||
|
||||
'''
|
||||
contract, ticker, details = await self.get_sym_details(symbol)
|
||||
|
||||
ready = ticker.updateEvent
|
||||
|
||||
# ensure a last price gets filled in before we deliver quote
|
||||
|
@ -864,21 +903,22 @@ class Client:
|
|||
else:
|
||||
if not warnset:
|
||||
log.warning(
|
||||
f'Quote for {symbol} timed out: market is closed?'
|
||||
f'Quote for {contract} timed out: market is closed?'
|
||||
)
|
||||
warnset = True
|
||||
|
||||
else:
|
||||
log.info(f'Got first quote for {symbol}')
|
||||
log.info(f'Got first quote for {contract}')
|
||||
break
|
||||
else:
|
||||
if not warnset:
|
||||
log.warning(
|
||||
f'Symbol {symbol} is not returning a quote '
|
||||
'it may be outside trading hours?')
|
||||
f'Contract {contract} is not returning a quote '
|
||||
'it may be outside trading hours?'
|
||||
)
|
||||
warnset = True
|
||||
|
||||
return contract, ticker, details
|
||||
return ticker
|
||||
|
||||
# async to be consistent for the client proxy, and cuz why not.
|
||||
def submit_limit(
|
||||
|
@ -1008,6 +1048,21 @@ class Client:
|
|||
|
||||
self.ib.errorEvent.connect(push_err)
|
||||
|
||||
api_err = self.ib.client.apiError
|
||||
|
||||
def report_api_err(msg: str) -> None:
|
||||
with remove_handler_on_err(
|
||||
api_err,
|
||||
report_api_err,
|
||||
):
|
||||
to_trio.send_nowait((
|
||||
'error',
|
||||
msg,
|
||||
))
|
||||
api_err.clear() # drop msg history
|
||||
|
||||
api_err.connect(report_api_err)
|
||||
|
||||
def positions(
|
||||
self,
|
||||
account: str = '',
|
||||
|
@ -1019,13 +1074,13 @@ class Client:
|
|||
return self.ib.positions(account=account)
|
||||
|
||||
|
||||
def con2fqsn(
|
||||
def con2fqme(
|
||||
con: Contract,
|
||||
_cache: dict[int, (str, bool)] = {}
|
||||
|
||||
) -> tuple[str, bool]:
|
||||
'''
|
||||
Convert contracts to fqsn-style strings to be used both in symbol-search
|
||||
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.
|
||||
|
@ -1085,12 +1140,12 @@ def con2fqsn(
|
|||
if expiry:
|
||||
suffix += f'.{expiry}'
|
||||
|
||||
fqsn_key = symbol.lower()
|
||||
fqme_key = symbol.lower()
|
||||
if suffix:
|
||||
fqsn_key = '.'.join((fqsn_key, suffix)).lower()
|
||||
fqme_key = '.'.join((fqme_key, suffix)).lower()
|
||||
|
||||
_cache[con.conId] = fqsn_key, calc_price
|
||||
return fqsn_key, calc_price
|
||||
_cache[con.conId] = fqme_key, calc_price
|
||||
return fqme_key, calc_price
|
||||
|
||||
|
||||
# per-actor API ep caching
|
||||
|
@ -1137,7 +1192,7 @@ async def load_aio_clients(
|
|||
# the API TCP in `ib_insync` connection can be flaky af so instead
|
||||
# retry a few times to get the client going..
|
||||
connect_retries: int = 3,
|
||||
connect_timeout: float = 0.5,
|
||||
connect_timeout: float = 1,
|
||||
disconnect_on_exit: bool = True,
|
||||
|
||||
) -> dict[str, Client]:
|
||||
|
@ -1191,9 +1246,14 @@ async def load_aio_clients(
|
|||
for host, port in combos:
|
||||
|
||||
sockaddr = (host, port)
|
||||
|
||||
maybe_client = _client_cache.get(sockaddr)
|
||||
if (
|
||||
sockaddr in _client_cache
|
||||
or sockaddr in _scan_ignore
|
||||
sockaddr in _scan_ignore
|
||||
or (
|
||||
maybe_client
|
||||
and maybe_client.ib.isConnected()
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
|
@ -1204,9 +1264,9 @@ async def load_aio_clients(
|
|||
await ib.connectAsync(
|
||||
host,
|
||||
port,
|
||||
clientId=client_id,
|
||||
clientId=client_id + i,
|
||||
|
||||
# this timeout is sensative on windows and will
|
||||
# this timeout is sensitive on windows and will
|
||||
# fail without a good "timeout error" so be
|
||||
# careful.
|
||||
timeout=connect_timeout,
|
||||
|
@ -1230,15 +1290,10 @@ async def load_aio_clients(
|
|||
OSError,
|
||||
) as ce:
|
||||
_err = ce
|
||||
|
||||
if i > 8:
|
||||
# cache logic to avoid rescanning if we already have all
|
||||
# clients loaded.
|
||||
_scan_ignore.add(sockaddr)
|
||||
raise
|
||||
|
||||
log.warning(
|
||||
f'Failed to connect on {port} for {i} time, retrying...')
|
||||
f'Failed to connect on {port} for {i} time with,\n'
|
||||
f'{ib.client.apiError.value()}\n'
|
||||
'retrying with a new client id..')
|
||||
|
||||
# Pre-collect all accounts available for this
|
||||
# connection and map account names to this client
|
||||
|
@ -1299,19 +1354,13 @@ async def load_clients_for_trio(
|
|||
a ``tractor.to_asyncio.open_channel_from()``.
|
||||
|
||||
'''
|
||||
global _accounts2clients
|
||||
async with load_aio_clients() as accts2clients:
|
||||
|
||||
if _accounts2clients:
|
||||
to_trio.send_nowait(_accounts2clients)
|
||||
to_trio.send_nowait(accts2clients)
|
||||
|
||||
# TODO: maybe a sync event to wait on instead?
|
||||
await asyncio.sleep(float('inf'))
|
||||
|
||||
else:
|
||||
async with load_aio_clients() as accts2clients:
|
||||
to_trio.send_nowait(accts2clients)
|
||||
|
||||
# TODO: maybe a sync event to wait on instead?
|
||||
await asyncio.sleep(float('inf'))
|
||||
|
||||
|
||||
@acm
|
||||
async def open_client_proxies() -> tuple[
|
||||
|
@ -1400,6 +1449,14 @@ class MethodProxy:
|
|||
while not chan.closed():
|
||||
# send through method + ``kwargs: dict`` as pair
|
||||
msg = await chan.receive()
|
||||
|
||||
# TODO: implement reconnect functionality like
|
||||
# in our `.data._web_bs.NoBsWs`
|
||||
# try:
|
||||
# msg = await chan.receive()
|
||||
# except ConnectionError:
|
||||
# self.reset()
|
||||
|
||||
# print(f'NEXT MSG: {msg}')
|
||||
|
||||
# TODO: py3.10 ``match:`` syntax B)
|
||||
|
@ -1451,6 +1508,7 @@ async def open_aio_client_method_relay(
|
|||
|
||||
) -> None:
|
||||
|
||||
# sync with `open_client_proxy()` caller
|
||||
to_trio.send_nowait(client)
|
||||
|
||||
# TODO: separate channel for error handling?
|
||||
|
@ -1460,25 +1518,34 @@ async def open_aio_client_method_relay(
|
|||
# back results
|
||||
while not to_trio._closed:
|
||||
msg = await from_trio.get()
|
||||
if msg is None:
|
||||
print('asyncio PROXY-RELAY SHUTDOWN')
|
||||
break
|
||||
|
||||
meth_name, kwargs = msg
|
||||
meth = getattr(client, meth_name)
|
||||
match msg:
|
||||
case None: # termination sentinel
|
||||
print('asyncio PROXY-RELAY SHUTDOWN')
|
||||
break
|
||||
|
||||
try:
|
||||
resp = await meth(**kwargs)
|
||||
# echo the msg back
|
||||
to_trio.send_nowait({'result': resp})
|
||||
case (meth_name, kwargs):
|
||||
meth_name, kwargs = msg
|
||||
meth = getattr(client, meth_name)
|
||||
|
||||
except (
|
||||
RequestError,
|
||||
try:
|
||||
resp = await meth(**kwargs)
|
||||
# echo the msg back
|
||||
to_trio.send_nowait({'result': resp})
|
||||
|
||||
# TODO: relay all errors to trio?
|
||||
# BaseException,
|
||||
) as err:
|
||||
to_trio.send_nowait({'exception': err})
|
||||
except (
|
||||
RequestError,
|
||||
|
||||
# TODO: relay all errors to trio?
|
||||
# BaseException,
|
||||
) as err:
|
||||
to_trio.send_nowait({'exception': err})
|
||||
|
||||
case {'error': content}:
|
||||
to_trio.send_nowait({'exception': content})
|
||||
|
||||
case _:
|
||||
raise ValueError(f'Unhandled msg {msg}')
|
||||
|
||||
|
||||
@acm
|
||||
|
@ -1509,7 +1576,8 @@ async def open_client_proxy(
|
|||
|
||||
# mock all remote methods on ib ``Client``.
|
||||
for name, method in inspect.getmembers(
|
||||
Client, predicate=inspect.isfunction
|
||||
Client,
|
||||
predicate=inspect.isfunction,
|
||||
):
|
||||
if '_' == name[0]:
|
||||
continue
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
Order and trades endpoints for use with ``piker``'s EMS.
|
||||
|
||||
|
@ -21,6 +22,7 @@ from __future__ import annotations
|
|||
from bisect import insort
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import asdict
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from pprint import pformat
|
||||
import time
|
||||
|
@ -37,6 +39,7 @@ from trio_typing import TaskStatus
|
|||
import tractor
|
||||
from ib_insync.contract import (
|
||||
Contract,
|
||||
Option,
|
||||
)
|
||||
from ib_insync.order import (
|
||||
Trade,
|
||||
|
@ -51,14 +54,17 @@ from ib_insync.objects import Position as IbPosition
|
|||
import pendulum
|
||||
|
||||
from piker import config
|
||||
from piker.pp import (
|
||||
from piker.accounting import (
|
||||
dec_digits,
|
||||
digits_to_dec,
|
||||
Position,
|
||||
Transaction,
|
||||
open_trade_ledger,
|
||||
iter_by_dt,
|
||||
open_pps,
|
||||
PpTable,
|
||||
)
|
||||
from piker.log import get_console_log
|
||||
from .._util import get_console_log
|
||||
from piker.clearing._messages import (
|
||||
Order,
|
||||
Status,
|
||||
|
@ -70,36 +76,39 @@ from piker.clearing._messages import (
|
|||
BrokerdFill,
|
||||
BrokerdError,
|
||||
)
|
||||
from piker.data._source import (
|
||||
Symbol,
|
||||
float_digits,
|
||||
from piker.accounting import (
|
||||
MktPair,
|
||||
)
|
||||
from .api import (
|
||||
_accounts2clients,
|
||||
con2fqsn,
|
||||
con2fqme,
|
||||
log,
|
||||
get_config,
|
||||
open_client_proxies,
|
||||
Client,
|
||||
MethodProxy,
|
||||
)
|
||||
from ._flex_reports import parse_flex_dt
|
||||
|
||||
|
||||
def pack_position(
|
||||
pos: IbPosition
|
||||
|
||||
) -> dict[str, Any]:
|
||||
) -> tuple[
|
||||
str,
|
||||
dict[str, Any]
|
||||
]:
|
||||
|
||||
con = pos.contract
|
||||
fqsn, calc_price = con2fqsn(con)
|
||||
fqme, calc_price = con2fqme(con)
|
||||
|
||||
# TODO: options contracts into a sane format..
|
||||
return (
|
||||
con.conId,
|
||||
str(con.conId),
|
||||
BrokerdPosition(
|
||||
broker='ib',
|
||||
account=pos.account,
|
||||
symbol=fqsn,
|
||||
symbol=fqme,
|
||||
currency=con.currency,
|
||||
size=float(pos.position),
|
||||
avg_price=float(pos.avgCost) / float(con.multiplier or 1.0),
|
||||
|
@ -281,18 +290,21 @@ async def recv_trade_updates(
|
|||
async def update_ledger_from_api_trades(
|
||||
trade_entries: list[dict[str, Any]],
|
||||
client: Union[Client, MethodProxy],
|
||||
accounts_def_inv: bidict[str, str],
|
||||
|
||||
) -> 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...
|
||||
for entry in trade_entries:
|
||||
condict = entry['contract']
|
||||
# print(
|
||||
# f"{condict['symbol']}: GETTING CONTRACT INFO!\n"
|
||||
# )
|
||||
conid = condict['conId']
|
||||
pexch = condict['primaryExchange']
|
||||
|
||||
|
@ -310,9 +322,8 @@ async def update_ledger_from_api_trades(
|
|||
# pack in the ``Contract.secType``
|
||||
entry['asset_type'] = condict['secType']
|
||||
|
||||
conf = get_config()
|
||||
entries = api_trades_to_ledger_entries(
|
||||
conf['accounts'].inverse,
|
||||
accounts_def_inv,
|
||||
trade_entries,
|
||||
)
|
||||
# normalize recent session's trades to the `Transaction` type
|
||||
|
@ -334,15 +345,17 @@ async def update_and_audit_msgs(
|
|||
) -> list[BrokerdPosition]:
|
||||
|
||||
msgs: list[BrokerdPosition] = []
|
||||
p: Position
|
||||
for p in pps:
|
||||
bsuid = p.bsuid
|
||||
bs_mktid = p.bs_mktid
|
||||
|
||||
# retreive equivalent ib reported position message
|
||||
# for comparison/audit versus the piker equivalent
|
||||
# breakeven pp calcs.
|
||||
ibppmsg = cids2pps.get((acctid, bsuid))
|
||||
ibppmsg = cids2pps.get((acctid, bs_mktid))
|
||||
|
||||
if ibppmsg:
|
||||
symbol = ibppmsg.symbol
|
||||
msg = BrokerdPosition(
|
||||
broker='ib',
|
||||
|
||||
|
@ -353,13 +366,16 @@ async def update_and_audit_msgs(
|
|||
# table..
|
||||
account=ibppmsg.account,
|
||||
# XXX: the `.ib` is stripped..?
|
||||
symbol=ibppmsg.symbol,
|
||||
symbol=symbol,
|
||||
currency=ibppmsg.currency,
|
||||
size=p.size,
|
||||
avg_price=p.ppu,
|
||||
)
|
||||
msgs.append(msg)
|
||||
|
||||
ibfmtmsg = pformat(ibppmsg.to_dict())
|
||||
pikerfmtmsg = pformat(msg.to_dict())
|
||||
|
||||
if validate:
|
||||
ibsize = ibppmsg.size
|
||||
pikersize = msg.size
|
||||
|
@ -379,26 +395,24 @@ async def update_and_audit_msgs(
|
|||
|
||||
# raise ValueError(
|
||||
log.error(
|
||||
f'POSITION MISMATCH ib <-> piker ledger:\n'
|
||||
f'ib: {ibppmsg}\n'
|
||||
f'piker: {msg}\n'
|
||||
f'reverse_split_ratio: {reverse_split_ratio}\n'
|
||||
f'split_ratio: {split_ratio}\n\n'
|
||||
'FIGURE OUT WHY TF YOUR LEDGER IS OFF!?!?\n\n'
|
||||
f'Pos mismatch in ib vs. the piker ledger!\n'
|
||||
f'IB:\n{ibfmtmsg}\n\n'
|
||||
f'PIKER:\n{pikerfmtmsg}\n\n'
|
||||
'If you are expecting a (reverse) split in this '
|
||||
'instrument you should probably put the following '
|
||||
f'in the `pps.toml` section:\n{entry}'
|
||||
'instrument you should probably put the following'
|
||||
'in the `pps.toml` section:\n'
|
||||
f'{entry}\n'
|
||||
# f'reverse_split_ratio: {reverse_split_ratio}\n'
|
||||
# f'split_ratio: {split_ratio}\n\n'
|
||||
)
|
||||
msg.size = ibsize
|
||||
|
||||
if ibppmsg.avg_price != msg.avg_price:
|
||||
|
||||
# TODO: make this a "propoganda" log level?
|
||||
# TODO: make this a "propaganda" log level?
|
||||
log.warning(
|
||||
'The mega-cucks at IB want you to believe with their '
|
||||
f'"FIFO" positioning for {msg.symbol}:\n'
|
||||
f'"ib" mega-cucker avg price: {ibppmsg.avg_price}\n'
|
||||
f'piker, LIFO breakeven PnL price: {msg.avg_price}'
|
||||
f'IB "FIFO" avg price for {msg.symbol} is DIFF:\n'
|
||||
f'ib: {ibppmsg.avg_price}\n'
|
||||
f'piker: {msg.avg_price}'
|
||||
)
|
||||
|
||||
else:
|
||||
|
@ -414,7 +428,7 @@ async def update_and_audit_msgs(
|
|||
# right since `.broker` is already included?
|
||||
account=f'ib.{acctid}',
|
||||
# XXX: the `.ib` is stripped..?
|
||||
symbol=p.symbol.front_fqsn(),
|
||||
symbol=p.mkt.fqme,
|
||||
# currency=ibppmsg.currency,
|
||||
size=p.size,
|
||||
avg_price=p.ppu,
|
||||
|
@ -422,16 +436,89 @@ async def update_and_audit_msgs(
|
|||
if validate and p.size:
|
||||
# raise ValueError(
|
||||
log.error(
|
||||
f'UNEXPECTED POSITION says ib:\n'
|
||||
f'piker: {msg}\n'
|
||||
'YOU SHOULD FIGURE OUT WHY TF YOUR LEDGER IS OFF!?\n'
|
||||
'THEY LIQUIDATED YOU OR YOUR MISSING LEDGER RECORDS!?'
|
||||
f'UNEXPECTED POSITION says IB => {msg.symbol}\n'
|
||||
'Maybe they LIQUIDATED YOU or are missing ledger entries?\n'
|
||||
)
|
||||
msgs.append(msg)
|
||||
|
||||
return msgs
|
||||
|
||||
|
||||
async def aggr_open_orders(
|
||||
order_msgs: list[Status],
|
||||
client: Client,
|
||||
proxy: MethodProxy,
|
||||
accounts_def: bidict[str, str],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Collect all open orders from client and fill in `order_msgs: list`.
|
||||
|
||||
'''
|
||||
trades: list[Trade] = client.ib.openTrades()
|
||||
for trade in trades:
|
||||
order = trade.order
|
||||
quant = trade.order.totalQuantity
|
||||
action = order.action.lower()
|
||||
size = {
|
||||
'sell': -1,
|
||||
'buy': 1,
|
||||
}[action] * quant
|
||||
con = trade.contract
|
||||
|
||||
# TODO: in the case of the SMART venue (aka ib's
|
||||
# router-clearing sys) we probably should handle
|
||||
# showing such orders overtop of the fqme for the
|
||||
# primary exchange, how to map this easily is going
|
||||
# to be a bit tricky though?
|
||||
deats = await proxy.con_deats(contracts=[con])
|
||||
fqme = list(deats)[0]
|
||||
|
||||
reqid = order.orderId
|
||||
|
||||
# TODO: maybe embed a ``BrokerdOrder`` instead
|
||||
# since then we can directly load it on the client
|
||||
# side in the order mode loop?
|
||||
msg = Status(
|
||||
time_ns=time.time_ns(),
|
||||
resp='open',
|
||||
oid=str(reqid),
|
||||
reqid=reqid,
|
||||
|
||||
# embedded order info
|
||||
req=Order(
|
||||
action=action,
|
||||
exec_mode='live',
|
||||
oid=str(reqid),
|
||||
symbol=fqme,
|
||||
account=accounts_def.inverse[order.account],
|
||||
price=order.lmtPrice,
|
||||
size=size,
|
||||
),
|
||||
src='ib',
|
||||
)
|
||||
order_msgs.append(msg)
|
||||
|
||||
return order_msgs
|
||||
|
||||
|
||||
# proxy wrapper for starting trade event stream
|
||||
async def open_trade_event_stream(
|
||||
client: Client,
|
||||
task_status: TaskStatus[
|
||||
trio.abc.ReceiveChannel
|
||||
] = trio.TASK_STATUS_IGNORED,
|
||||
):
|
||||
# each api client has a unique event stream
|
||||
async with tractor.to_asyncio.open_channel_from(
|
||||
recv_trade_updates,
|
||||
client=client,
|
||||
) as (first, trade_event_stream):
|
||||
|
||||
task_status.started(trade_event_stream)
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
||||
@tractor.context
|
||||
async def trades_dialogue(
|
||||
|
||||
|
@ -465,7 +552,10 @@ async def trades_dialogue(
|
|||
# we might also want to delegate a specific actor for
|
||||
# ledger writing / reading for speed?
|
||||
async with (
|
||||
open_client_proxies() as (proxies, aioclients),
|
||||
open_client_proxies() as (
|
||||
proxies,
|
||||
aioclients,
|
||||
),
|
||||
):
|
||||
# Open a trade ledgers stack for appending trade records over
|
||||
# multiple accounts.
|
||||
|
@ -473,6 +563,9 @@ async def trades_dialogue(
|
|||
ledgers: dict[str, dict] = {}
|
||||
tables: dict[str, PpTable] = {}
|
||||
order_msgs: list[Status] = []
|
||||
conf = get_config()
|
||||
accounts_def_inv: bidict[str, str] = bidict(conf['accounts']).inverse
|
||||
|
||||
with (
|
||||
ExitStack() as lstack,
|
||||
):
|
||||
|
@ -489,148 +582,15 @@ async def trades_dialogue(
|
|||
open_trade_ledger(
|
||||
'ib',
|
||||
acctid,
|
||||
)
|
||||
)
|
||||
table = tables[acctid] = lstack.enter_context(
|
||||
open_pps(
|
||||
'ib',
|
||||
acctid,
|
||||
write_on_exit=True,
|
||||
)
|
||||
)
|
||||
|
||||
for account, proxy in proxies.items():
|
||||
client = aioclients[account]
|
||||
trades: list[Trade] = client.ib.openTrades()
|
||||
for trade in trades:
|
||||
order = trade.order
|
||||
quant = trade.order.totalQuantity
|
||||
action = order.action.lower()
|
||||
size = {
|
||||
'sell': -1,
|
||||
'buy': 1,
|
||||
}[action] * quant
|
||||
con = trade.contract
|
||||
|
||||
# TODO: in the case of the SMART venue (aka ib's
|
||||
# router-clearing sys) we probably should handle
|
||||
# showing such orders overtop of the fqsn for the
|
||||
# primary exchange, how to map this easily is going
|
||||
# to be a bit tricky though?
|
||||
deats = await proxy.con_deats(contracts=[con])
|
||||
fqsn = list(deats)[0]
|
||||
|
||||
reqid = order.orderId
|
||||
|
||||
# TODO: maybe embed a ``BrokerdOrder`` instead
|
||||
# since then we can directly load it on the client
|
||||
# side in the order mode loop?
|
||||
msg = Status(
|
||||
time_ns=time.time_ns(),
|
||||
resp='open',
|
||||
oid=str(reqid),
|
||||
reqid=reqid,
|
||||
|
||||
# embedded order info
|
||||
req=Order(
|
||||
action=action,
|
||||
exec_mode='live',
|
||||
oid=str(reqid),
|
||||
symbol=fqsn,
|
||||
account=accounts_def.inverse[order.account],
|
||||
price=order.lmtPrice,
|
||||
size=size,
|
||||
tx_sort=partial(
|
||||
iter_by_dt,
|
||||
parsers={
|
||||
'dateTime': parse_flex_dt,
|
||||
'datetime': pendulum.parse,
|
||||
},
|
||||
),
|
||||
src='ib',
|
||||
)
|
||||
order_msgs.append(msg)
|
||||
|
||||
# process pp value reported from ib's system. we only use these
|
||||
# to cross-check sizing since average pricing on their end uses
|
||||
# the so called (bs) "FIFO" style which more or less results in
|
||||
# a price that's not useful for traders who want to not lose
|
||||
# money.. xb
|
||||
for pos in client.positions():
|
||||
|
||||
# collect all ib-pp reported positions so that we can be
|
||||
# sure know which positions to update from the ledger if
|
||||
# any are missing from the ``pps.toml``
|
||||
bsuid, msg = pack_position(pos)
|
||||
|
||||
acctid = msg.account = accounts_def.inverse[msg.account]
|
||||
acctid = acctid.strip('ib.')
|
||||
cids2pps[(acctid, bsuid)] = msg
|
||||
assert msg.account in accounts, (
|
||||
f'Position for unknown account: {msg.account}')
|
||||
|
||||
ledger = ledgers[acctid]
|
||||
table = tables[acctid]
|
||||
|
||||
pp = table.pps.get(bsuid)
|
||||
if (
|
||||
not pp
|
||||
or pp.size != msg.size
|
||||
):
|
||||
trans = norm_trade_records(ledger)
|
||||
table.update_from_trans(trans)
|
||||
|
||||
# update trades ledgers for all accounts from connected
|
||||
# api clients which report trades for **this session**.
|
||||
trades = await proxy.trades()
|
||||
(
|
||||
trans_by_acct,
|
||||
api_to_ledger_entries,
|
||||
) = await update_ledger_from_api_trades(
|
||||
trades,
|
||||
proxy,
|
||||
)
|
||||
|
||||
# if new trades are detected from the API, prepare
|
||||
# them for the ledger file and update the pptable.
|
||||
if api_to_ledger_entries:
|
||||
trade_entries = api_to_ledger_entries.get(acctid)
|
||||
|
||||
if trade_entries:
|
||||
# write ledger with all new trades **AFTER**
|
||||
# we've updated the `pps.toml` from the
|
||||
# original ledger state! (i.e. this is
|
||||
# currently done on exit)
|
||||
ledger.update(trade_entries)
|
||||
|
||||
trans = trans_by_acct.get(acctid)
|
||||
if trans:
|
||||
table.update_from_trans(trans)
|
||||
|
||||
# XXX: not sure exactly why it wouldn't be in
|
||||
# the updated output (maybe this is a bug?) but
|
||||
# if you create a pos from TWS and then load it
|
||||
# from the api trades it seems we get a key
|
||||
# error from ``update[bsuid]`` ?
|
||||
pp = table.pps.get(bsuid)
|
||||
if not pp:
|
||||
log.error(
|
||||
f'The contract id for {msg} may have '
|
||||
f'changed to {bsuid}\nYou may need to '
|
||||
'adjust your ledger for this, skipping '
|
||||
'for now.'
|
||||
)
|
||||
continue
|
||||
|
||||
# XXX: not sure exactly why it wouldn't be in
|
||||
# the updated output (maybe this is a bug?) but
|
||||
# if you create a pos from TWS and then load it
|
||||
# from the api trades it seems we get a key
|
||||
# error from ``update[bsuid]`` ?
|
||||
pp = table.pps[bsuid]
|
||||
pairinfo = pp.symbol
|
||||
if msg.size != pp.size:
|
||||
log.error(
|
||||
f'Pos size mismatch {pairinfo.front_fqsn()}:\n'
|
||||
f'ib: {msg.size}\n'
|
||||
f'piker: {pp.size}\n'
|
||||
)
|
||||
|
||||
active_pps, closed_pps = table.dump_active()
|
||||
)
|
||||
|
||||
# load all positions from `pps.toml`, cross check with
|
||||
# ib's positions data, and relay re-formatted pps as
|
||||
|
@ -641,6 +601,105 @@ async def trades_dialogue(
|
|||
# - no new trades yet but we want to reload and audit any
|
||||
# positions reported by ib's sys that may not yet be in
|
||||
# piker's ``pps.toml`` state-file.
|
||||
tables[acctid] = lstack.enter_context(
|
||||
open_pps(
|
||||
'ib',
|
||||
acctid,
|
||||
write_on_exit=True,
|
||||
)
|
||||
)
|
||||
|
||||
for account, proxy in proxies.items():
|
||||
client = aioclients[account]
|
||||
|
||||
# order_msgs is filled in by this helper
|
||||
await aggr_open_orders(
|
||||
order_msgs,
|
||||
client,
|
||||
proxy,
|
||||
accounts_def,
|
||||
)
|
||||
acctid: str = account.strip('ib.')
|
||||
ledger: dict = ledgers[acctid]
|
||||
table: PpTable = tables[acctid]
|
||||
|
||||
# update trades ledgers for all accounts from connected
|
||||
# api clients which report trades for **this session**.
|
||||
api_trades = await proxy.trades()
|
||||
if api_trades:
|
||||
|
||||
trans_by_acct: dict[str, Transaction]
|
||||
api_to_ledger_entries: dict[str, dict]
|
||||
(
|
||||
trans_by_acct,
|
||||
api_to_ledger_entries,
|
||||
) = await update_ledger_from_api_trades(
|
||||
api_trades,
|
||||
proxy,
|
||||
accounts_def_inv,
|
||||
)
|
||||
|
||||
# if new api_trades are detected from the API, prepare
|
||||
# them for the ledger file and update the pptable.
|
||||
if api_to_ledger_entries:
|
||||
trade_entries = api_to_ledger_entries.get(acctid)
|
||||
|
||||
# TODO: fix this `tractor` BUG!
|
||||
# https://github.com/goodboy/tractor/issues/354
|
||||
# await tractor.breakpoint()
|
||||
|
||||
if trade_entries:
|
||||
# write ledger with all new api_trades
|
||||
# **AFTER** we've updated the `pps.toml`
|
||||
# from the original ledger state! (i.e. this
|
||||
# is currently done on exit)
|
||||
for tid, entry in trade_entries.items():
|
||||
ledger.setdefault(tid, {}).update(entry)
|
||||
|
||||
trans = trans_by_acct.get(acctid)
|
||||
if trans:
|
||||
table.update_from_trans(trans)
|
||||
|
||||
# update position table with latest ledger from all
|
||||
# gathered transactions: ledger file + api records.
|
||||
trans: dict[str, Transaction] = norm_trade_records(ledger)
|
||||
table.update_from_trans(trans)
|
||||
|
||||
# process pp value reported from ib's system. we only
|
||||
# use these to cross-check sizing since average pricing
|
||||
# on their end uses the so called (bs) "FIFO" style
|
||||
# which more or less results in a price that's not
|
||||
# useful for traders who want to not lose money.. xb
|
||||
# -> collect all ib-pp reported positions so that we can be
|
||||
# sure know which positions to update from the ledger if
|
||||
# any are missing from the ``pps.toml``
|
||||
|
||||
pos: IbPosition # named tuple subtype
|
||||
for pos in client.positions():
|
||||
|
||||
# NOTE XXX: we skip options for now since we don't
|
||||
# yet support the symbology nor the live feeds.
|
||||
if isinstance(pos.contract, Option):
|
||||
log.warning(
|
||||
f'Option contracts not supported for now:\n'
|
||||
f'{pos._asdict()}'
|
||||
)
|
||||
continue
|
||||
|
||||
bs_mktid, msg = pack_position(pos)
|
||||
acctid = msg.account = accounts_def.inverse[msg.account]
|
||||
acctid = acctid.strip('ib.')
|
||||
cids2pps[(acctid, bs_mktid)] = msg
|
||||
|
||||
assert msg.account in accounts, (
|
||||
f'Position for unknown account: {msg.account}')
|
||||
|
||||
# iterate all (newly) updated pps tables for every
|
||||
# client-account and build out position msgs to deliver to
|
||||
# EMS.
|
||||
for acctid, table in tables.items():
|
||||
active_pps, closed_pps = table.dump_active()
|
||||
|
||||
for pps in [active_pps, closed_pps]:
|
||||
msgs = await update_and_audit_msgs(
|
||||
acctid,
|
||||
|
@ -661,22 +720,6 @@ async def trades_dialogue(
|
|||
tuple(name for name in accounts_def if name in accounts),
|
||||
))
|
||||
|
||||
# proxy wrapper for starting trade event stream
|
||||
async def open_trade_event_stream(
|
||||
client: Client,
|
||||
task_status: TaskStatus[
|
||||
trio.abc.ReceiveChannel
|
||||
] = trio.TASK_STATUS_IGNORED,
|
||||
):
|
||||
# each api client has a unique event stream
|
||||
async with tractor.to_asyncio.open_channel_from(
|
||||
recv_trade_updates,
|
||||
client=client,
|
||||
) as (first, trade_event_stream):
|
||||
|
||||
task_status.started(trade_event_stream)
|
||||
await trio.sleep_forever()
|
||||
|
||||
async with (
|
||||
ctx.open_stream() as ems_stream,
|
||||
trio.open_nursery() as n,
|
||||
|
@ -723,44 +766,50 @@ async def trades_dialogue(
|
|||
async def emit_pp_update(
|
||||
ems_stream: tractor.MsgStream,
|
||||
trade_entry: dict,
|
||||
accounts_def: bidict,
|
||||
accounts_def: bidict[str, str],
|
||||
proxies: dict,
|
||||
cids2pps: dict,
|
||||
|
||||
ledgers,
|
||||
tables,
|
||||
ledgers: dict[str, dict[str, Any]],
|
||||
tables: dict[str, PpTable],
|
||||
|
||||
) -> None:
|
||||
|
||||
# compute and relay incrementally updated piker pp
|
||||
acctid = accounts_def.inverse[trade_entry['execution']['acctNumber']]
|
||||
proxy = proxies[acctid]
|
||||
|
||||
acctid = acctid.strip('ib.')
|
||||
accounts_def_inv: bidict[str, str] = accounts_def.inverse
|
||||
fq_acctid = accounts_def_inv[trade_entry['execution']['acctNumber']]
|
||||
proxy = proxies[fq_acctid]
|
||||
(
|
||||
records_by_acct,
|
||||
api_to_ledger_entries,
|
||||
) = await update_ledger_from_api_trades(
|
||||
[trade_entry],
|
||||
proxy,
|
||||
accounts_def_inv,
|
||||
)
|
||||
trans = records_by_acct[acctid]
|
||||
trans = records_by_acct[fq_acctid]
|
||||
r = list(trans.values())[0]
|
||||
|
||||
acctid = fq_acctid.strip('ib.')
|
||||
table = tables[acctid]
|
||||
table.update_from_trans(trans)
|
||||
active, closed = table.dump_active()
|
||||
|
||||
# NOTE: update ledger with all new trades
|
||||
for acctid, trades_by_id in api_to_ledger_entries.items():
|
||||
for fq_acctid, trades_by_id in api_to_ledger_entries.items():
|
||||
acctid = fq_acctid.strip('ib.')
|
||||
ledger = ledgers[acctid]
|
||||
ledger.update(trades_by_id)
|
||||
|
||||
for tid, tdict in trades_by_id.items():
|
||||
# NOTE: don't override flex/previous entries with new API
|
||||
# ones, just update with new fields!
|
||||
ledger.setdefault(tid, {}).update(tdict)
|
||||
|
||||
# generate pp msgs and cross check with ib's positions data, relay
|
||||
# re-formatted pps as msgs to the ems.
|
||||
for pos in filter(
|
||||
bool,
|
||||
[active.get(r.bsuid), closed.get(r.bsuid)]
|
||||
[active.get(r.bs_mktid), closed.get(r.bs_mktid)]
|
||||
):
|
||||
msgs = await update_and_audit_msgs(
|
||||
acctid,
|
||||
|
@ -859,8 +908,8 @@ async def deliver_trade_events(
|
|||
# https://github.com/erdewit/ib_insync/issues/363
|
||||
# acctid = accounts_def.inverse[trade.order.account]
|
||||
|
||||
# # double check there is no error when
|
||||
# # cancelling.. gawwwd
|
||||
# double check there is no error when
|
||||
# cancelling.. gawwwd
|
||||
# if ib_status_key == 'cancelled':
|
||||
# last_log = trade.log[-1]
|
||||
# if (
|
||||
|
@ -1000,6 +1049,7 @@ async def deliver_trade_events(
|
|||
accounts_def,
|
||||
proxies,
|
||||
cids2pps,
|
||||
|
||||
ledgers,
|
||||
tables,
|
||||
)
|
||||
|
@ -1034,6 +1084,7 @@ async def deliver_trade_events(
|
|||
accounts_def,
|
||||
proxies,
|
||||
cids2pps,
|
||||
|
||||
ledgers,
|
||||
tables,
|
||||
)
|
||||
|
@ -1095,7 +1146,7 @@ async def deliver_trade_events(
|
|||
def norm_trade_records(
|
||||
ledger: dict[str, Any],
|
||||
|
||||
) -> list[Transaction]:
|
||||
) -> dict[str, Transaction]:
|
||||
'''
|
||||
Normalize a flex report or API retrieved executions
|
||||
ledger into our standard record format.
|
||||
|
@ -1110,7 +1161,6 @@ def norm_trade_records(
|
|||
comms = -1*record['ibCommission']
|
||||
|
||||
price = record.get('price') or record['tradePrice']
|
||||
price_tick_digits = float_digits(price)
|
||||
|
||||
# the api doesn't do the -/+ on the quantity for you but flex
|
||||
# records do.. are you fucking serious ib...!?
|
||||
|
@ -1122,6 +1172,12 @@ def norm_trade_records(
|
|||
exch = record['exchange']
|
||||
lexch = record.get('listingExchange')
|
||||
|
||||
# NOTE: remove null values since `tomlkit` can't serialize
|
||||
# them to file.
|
||||
dnc = record.pop('deltaNeutralContract', False)
|
||||
if dnc is not None:
|
||||
record['deltaNeutralContract'] = dnc
|
||||
|
||||
suffix = lexch or exch
|
||||
symbol = record['symbol']
|
||||
|
||||
|
@ -1153,7 +1209,9 @@ def norm_trade_records(
|
|||
|
||||
# special handling of symbol extraction from
|
||||
# flex records using some ad-hoc schema parsing.
|
||||
asset_type: str = record.get('assetCategory') or record['secType']
|
||||
asset_type: str = record.get(
|
||||
'assetCategory'
|
||||
) or record.get('secType', 'STK')
|
||||
|
||||
# TODO: XXX: WOA this is kinda hacky.. probably
|
||||
# should figure out the correct future pair key more
|
||||
|
@ -1161,58 +1219,54 @@ def norm_trade_records(
|
|||
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')
|
||||
or record.get('expiry')
|
||||
)
|
||||
|
||||
# try to build out piker fqsn from record.
|
||||
expiry = record.get(
|
||||
'lastTradeDateOrContractMonth') or record.get('expiry')
|
||||
if expiry:
|
||||
expiry = str(expiry).strip(' ')
|
||||
suffix = f'{exch}.{expiry}'
|
||||
expiry = pendulum.parse(expiry)
|
||||
|
||||
src: str = record['currency']
|
||||
# src: str = record['currency']
|
||||
price_tick: Decimal = digits_to_dec(dec_digits(price))
|
||||
|
||||
pair = Symbol.from_fqsn(
|
||||
fqsn=f'{symbol}.{suffix}.ib',
|
||||
info={
|
||||
'tick_size_digits': price_tick_digits,
|
||||
pair = MktPair.from_fqme(
|
||||
fqme=f'{symbol}.{suffix}.ib',
|
||||
bs_mktid=str(conid),
|
||||
_atype=str(asset_type), # XXX: can't serlialize `tomlkit.String`
|
||||
|
||||
# NOTE: for "legacy" assets, volume is normally discreet, not
|
||||
# a float, but we keep a digit in case the suitz decide
|
||||
# to get crazy and change it; we'll be kinda ready
|
||||
# schema-wise..
|
||||
'lot_size_digits': 1,
|
||||
|
||||
# TODO: remove when we switching from
|
||||
# ``Symbol`` -> ``MktPair``
|
||||
'asset_type': asset_type,
|
||||
|
||||
# TODO: figure out a target fin-type name
|
||||
# set and normalize to that here!
|
||||
'dst_type': asset_type.lower(),
|
||||
|
||||
# starting to use new key naming as in ``MktPair``
|
||||
# type have drafted...
|
||||
'src': src,
|
||||
'src_type': 'fiat',
|
||||
},
|
||||
price_tick=price_tick,
|
||||
# NOTE: for "legacy" assets, volume is normally discreet, not
|
||||
# a float, but we keep a digit in case the suitz decide
|
||||
# to get crazy and change it; we'll be kinda ready
|
||||
# schema-wise..
|
||||
size_tick='1',
|
||||
)
|
||||
fqsn = pair.front_fqsn().rstrip('.ib')
|
||||
|
||||
# NOTE: for flex records the normal fields for defining an fqsn
|
||||
fqme = pair.fqme
|
||||
|
||||
# NOTE: for flex records the normal fields for defining an fqme
|
||||
# sometimes won't be available so we rely on two approaches for
|
||||
# the "reverse lookup" of piker style fqsn keys:
|
||||
# the "reverse lookup" of piker style fqme keys:
|
||||
# - when dealing with API trade records received from
|
||||
# `IB.trades()` we do a contract lookup at he time of processing
|
||||
# - when dealing with flex records, it is assumed the record
|
||||
# is at least a day old and thus the TWS position reporting system
|
||||
# should already have entries if the pps are still open, in
|
||||
# which case, we can pull the fqsn from that table (see
|
||||
# which case, we can pull the fqme from that table (see
|
||||
# `trades_dialogue()` above).
|
||||
insort(
|
||||
records,
|
||||
Transaction(
|
||||
fqsn=fqsn,
|
||||
fqme=fqme,
|
||||
sym=pair,
|
||||
tid=tid,
|
||||
size=size,
|
||||
|
@ -1220,7 +1274,7 @@ def norm_trade_records(
|
|||
cost=comms,
|
||||
dt=dt,
|
||||
expiry=expiry,
|
||||
bsuid=conid,
|
||||
bs_mktid=str(conid),
|
||||
),
|
||||
key=lambda t: t.dt
|
||||
)
|
||||
|
@ -1228,18 +1282,8 @@ def norm_trade_records(
|
|||
return {r.tid: r for r in records}
|
||||
|
||||
|
||||
def parse_flex_dt(
|
||||
record: str,
|
||||
) -> pendulum.datetime:
|
||||
date, ts = record.split(';')
|
||||
dt = pendulum.parse(date)
|
||||
ts = f'{ts[:2]}:{ts[2:4]}:{ts[4:]}'
|
||||
tsdt = pendulum.parse(ts)
|
||||
return dt.set(hour=tsdt.hour, minute=tsdt.minute, second=tsdt.second)
|
||||
|
||||
|
||||
def api_trades_to_ledger_entries(
|
||||
accounts: bidict,
|
||||
accounts: bidict[str, str],
|
||||
|
||||
# TODO: maybe we should just be passing through the
|
||||
# ``ib_insync.order.Trade`` instance directly here
|
||||
|
@ -1309,148 +1353,3 @@ def api_trades_to_ledger_entries(
|
|||
))
|
||||
|
||||
return trades_by_account
|
||||
|
||||
|
||||
def flex_records_to_ledger_entries(
|
||||
accounts: bidict,
|
||||
trade_entries: list[object],
|
||||
|
||||
) -> dict:
|
||||
'''
|
||||
Convert flex report entry objects into ``dict`` form, pretty much
|
||||
straight up without modification except add a `pydatetime` field
|
||||
from the parsed timestamp.
|
||||
|
||||
'''
|
||||
trades_by_account = {}
|
||||
for t in trade_entries:
|
||||
entry = t.__dict__
|
||||
|
||||
# XXX: LOL apparently ``toml`` has a bug
|
||||
# where a section key error will show up in the write
|
||||
# if you leave a table key as an `int`? So i guess
|
||||
# cast to strs for all keys..
|
||||
|
||||
# oddly for some so-called "BookTrade" entries
|
||||
# this field seems to be blank, no cuckin clue.
|
||||
# trade['ibExecID']
|
||||
tid = str(entry.get('ibExecID') or entry['tradeID'])
|
||||
# date = str(entry['tradeDate'])
|
||||
|
||||
# XXX: is it going to cause problems if a account name
|
||||
# get's lost? The user should be able to find it based
|
||||
# on the actual exec history right?
|
||||
acctid = accounts[str(entry['accountId'])]
|
||||
|
||||
# probably a flex record with a wonky non-std timestamp..
|
||||
dt = entry['pydatetime'] = parse_flex_dt(entry['dateTime'])
|
||||
entry['datetime'] = str(dt)
|
||||
|
||||
if not tid:
|
||||
# this is likely some kind of internal adjustment
|
||||
# transaction, likely one of the following:
|
||||
# - an expiry event that will show a "book trade" indicating
|
||||
# some adjustment to cash balances: zeroing or itm settle.
|
||||
# - a manual cash balance position adjustment likely done by
|
||||
# the user from the accounts window in TWS where they can
|
||||
# manually set the avg price and size:
|
||||
# https://api.ibkr.com/lib/cstools/faq/web1/index.html#/tag/DTWS_ADJ_AVG_COST
|
||||
log.warning(f'Skipping ID-less ledger entry:\n{pformat(entry)}')
|
||||
continue
|
||||
|
||||
trades_by_account.setdefault(
|
||||
acctid, {}
|
||||
)[tid] = entry
|
||||
|
||||
for acctid in trades_by_account:
|
||||
trades_by_account[acctid] = dict(sorted(
|
||||
trades_by_account[acctid].items(),
|
||||
key=lambda entry: entry[1]['pydatetime'],
|
||||
))
|
||||
|
||||
return trades_by_account
|
||||
|
||||
|
||||
def load_flex_trades(
|
||||
path: Optional[str] = None,
|
||||
|
||||
) -> dict[str, Any]:
|
||||
|
||||
from ib_insync import flexreport, util
|
||||
|
||||
conf = get_config()
|
||||
|
||||
if not path:
|
||||
# load ``brokers.toml`` and try to get the flex
|
||||
# token and query id that must be previously defined
|
||||
# by the user.
|
||||
token = conf.get('flex_token')
|
||||
if not token:
|
||||
raise ValueError(
|
||||
'You must specify a ``flex_token`` field in your'
|
||||
'`brokers.toml` in order load your trade log, see our'
|
||||
'intructions for how to set this up here:\n'
|
||||
'PUT LINK HERE!'
|
||||
)
|
||||
|
||||
qid = conf['flex_trades_query_id']
|
||||
|
||||
# TODO: hack this into our logging
|
||||
# system like we do with the API client..
|
||||
util.logToConsole()
|
||||
|
||||
# TODO: rewrite the query part of this with async..httpx?
|
||||
report = flexreport.FlexReport(
|
||||
token=token,
|
||||
queryId=qid,
|
||||
)
|
||||
|
||||
else:
|
||||
# XXX: another project we could potentially look at,
|
||||
# https://pypi.org/project/ibflex/
|
||||
report = flexreport.FlexReport(path=path)
|
||||
|
||||
trade_entries = report.extract('Trade')
|
||||
ln = len(trade_entries)
|
||||
log.info(f'Loaded {ln} trades from flex query')
|
||||
|
||||
trades_by_account = flex_records_to_ledger_entries(
|
||||
conf['accounts'].inverse, # reverse map to user account names
|
||||
trade_entries,
|
||||
)
|
||||
|
||||
ledger_dict: Optional[dict] = None
|
||||
|
||||
for acctid in trades_by_account:
|
||||
trades_by_id = trades_by_account[acctid]
|
||||
|
||||
with open_trade_ledger('ib', acctid) as ledger_dict:
|
||||
tid_delta = set(trades_by_id) - set(ledger_dict)
|
||||
log.info(
|
||||
'New trades detected\n'
|
||||
f'{pformat(tid_delta)}'
|
||||
)
|
||||
if tid_delta:
|
||||
sorted_delta = dict(sorted(
|
||||
{tid: trades_by_id[tid] for tid in tid_delta}.items(),
|
||||
key=lambda entry: entry[1].pop('pydatetime'),
|
||||
))
|
||||
ledger_dict.update(sorted_delta)
|
||||
|
||||
return ledger_dict
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
import os
|
||||
|
||||
args = sys.argv
|
||||
if len(args) > 1:
|
||||
args = args[1:]
|
||||
for arg in args:
|
||||
path = os.path.abspath(arg)
|
||||
load_flex_trades(path=path)
|
||||
else:
|
||||
# expect brokers.toml to have an entry and
|
||||
# pull from the web service.
|
||||
load_flex_trades()
|
||||
|
|
|
@ -19,7 +19,11 @@ Data feed endpoints pre-wrapped and ready for use with ``tractor``/``trio``.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
nullcontext,
|
||||
)
|
||||
from decimal import Decimal
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
|
@ -46,7 +50,7 @@ from .._util import (
|
|||
)
|
||||
from .api import (
|
||||
# _adhoc_futes_set,
|
||||
con2fqsn,
|
||||
con2fqme,
|
||||
log,
|
||||
load_aio_clients,
|
||||
ibis,
|
||||
|
@ -54,10 +58,18 @@ from .api import (
|
|||
open_client_proxies,
|
||||
get_preferred_data_client,
|
||||
Ticker,
|
||||
RequestError,
|
||||
Contract,
|
||||
RequestError,
|
||||
)
|
||||
from ._util import data_reset_hack
|
||||
from piker._cacheables import (
|
||||
async_lifo_cache,
|
||||
)
|
||||
from piker.accounting import (
|
||||
Asset,
|
||||
MktPair,
|
||||
)
|
||||
from piker.data.validate import FeedInit
|
||||
|
||||
|
||||
# https://interactivebrokers.github.io/tws-api/tick_types.html
|
||||
|
@ -108,7 +120,7 @@ async def open_data_client() -> MethodProxy:
|
|||
|
||||
@acm
|
||||
async def open_history_client(
|
||||
fqsn: str,
|
||||
mkt: MktPair,
|
||||
|
||||
) -> tuple[Callable, int]:
|
||||
'''
|
||||
|
@ -116,7 +128,7 @@ async def open_history_client(
|
|||
that takes in ``pendulum.datetime`` and returns ``numpy`` arrays.
|
||||
|
||||
'''
|
||||
# TODO:
|
||||
# TODO: mostly meta-data processing to drive shm and tsdb storage..
|
||||
# - add logic to handle tradable hours and only grab
|
||||
# valid bars in the range?
|
||||
# - we want to avoid overrunning the underlying shm array buffer and
|
||||
|
@ -125,8 +137,33 @@ async def open_history_client(
|
|||
# the shm size will be driven by user config and available sys
|
||||
# memory.
|
||||
|
||||
# IB's internal symbology does not expect the "source asset" in
|
||||
# the "symbol name", what we call the "market name". This is
|
||||
# common in most legacy market brokers since it's presumed that
|
||||
# given a certain stock exchange, listed assets are traded
|
||||
# "from" a particular source fiat, normally something like USD.
|
||||
if (
|
||||
mkt.src
|
||||
and mkt.src.atype == 'fiat'
|
||||
):
|
||||
fqme_kwargs: dict[str, Any] = {}
|
||||
|
||||
if mkt.dst.atype == 'forex':
|
||||
|
||||
# XXX: for now we do need the src token kept in since
|
||||
fqme_kwargs = {
|
||||
'without_src': False, # default is True
|
||||
'delim_char': '', # bc they would normally use a frickin `.` smh
|
||||
}
|
||||
|
||||
fqme: str = mkt.get_bs_fqme(**(fqme_kwargs))
|
||||
|
||||
else:
|
||||
fqme = mkt.bs_fqme
|
||||
|
||||
async with open_data_client() as proxy:
|
||||
|
||||
|
||||
max_timeout: float = 2.
|
||||
mean: float = 0
|
||||
count: int = 0
|
||||
|
@ -134,10 +171,10 @@ async def open_history_client(
|
|||
head_dt: None | datetime = None
|
||||
if (
|
||||
# fx cons seem to not provide this endpoint?
|
||||
'idealpro' not in fqsn
|
||||
'idealpro' not in fqme
|
||||
):
|
||||
try:
|
||||
head_dt = await proxy.get_head_time(fqsn=fqsn)
|
||||
head_dt = await proxy.get_head_time(fqme=fqme)
|
||||
except RequestError:
|
||||
head_dt = None
|
||||
|
||||
|
@ -152,7 +189,7 @@ async def open_history_client(
|
|||
query_start = time.time()
|
||||
out, timedout = await get_bars(
|
||||
proxy,
|
||||
fqsn,
|
||||
fqme,
|
||||
timeframe,
|
||||
end_dt=end_dt,
|
||||
)
|
||||
|
@ -211,7 +248,7 @@ _pacing: str = (
|
|||
async def wait_on_data_reset(
|
||||
proxy: MethodProxy,
|
||||
reset_type: str = 'data',
|
||||
timeout: float = 16,
|
||||
timeout: float = 16, # float('inf'),
|
||||
|
||||
task_status: TaskStatus[
|
||||
tuple[
|
||||
|
@ -227,7 +264,7 @@ async def wait_on_data_reset(
|
|||
'HMDS data farm connection is OK:ushmds'
|
||||
)
|
||||
|
||||
# XXX: other event messages we might want to try and
|
||||
# TODO: other event messages we might want to try and
|
||||
# wait for but i wasn't able to get any of this
|
||||
# reliable..
|
||||
# reconnect_start = proxy.status_event(
|
||||
|
@ -238,14 +275,21 @@ async def wait_on_data_reset(
|
|||
# )
|
||||
# try to wait on the reset event(s) to arrive, a timeout
|
||||
# will trigger a retry up to 6 times (for now).
|
||||
client = proxy._aio_ns.ib.client
|
||||
|
||||
done = trio.Event()
|
||||
with trio.move_on_after(timeout) as cs:
|
||||
|
||||
task_status.started((cs, done))
|
||||
|
||||
log.warning('Sending DATA RESET request')
|
||||
res = await data_reset_hack(reset_type=reset_type)
|
||||
log.warning(
|
||||
'Sending DATA RESET request:\n'
|
||||
f'{client}'
|
||||
)
|
||||
res = await data_reset_hack(
|
||||
vnc_host=client.host,
|
||||
reset_type=reset_type,
|
||||
)
|
||||
|
||||
if not res:
|
||||
log.warning(
|
||||
|
@ -279,12 +323,12 @@ async def wait_on_data_reset(
|
|||
|
||||
|
||||
_data_resetter_task: trio.Task | None = None
|
||||
|
||||
_failed_resets: int = 0
|
||||
|
||||
async def get_bars(
|
||||
|
||||
proxy: MethodProxy,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
timeframe: int,
|
||||
|
||||
# blank to start which tells ib to look up the latest datum
|
||||
|
@ -298,6 +342,7 @@ async def get_bars(
|
|||
# history queries for instrument, presuming that most don't
|
||||
# not trade for a week XD
|
||||
max_nodatas: int = 6,
|
||||
max_failed_resets: int = 6,
|
||||
|
||||
task_status: TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
|
@ -307,7 +352,7 @@ async def get_bars(
|
|||
a ``MethoProxy``.
|
||||
|
||||
'''
|
||||
global _data_resetter_task
|
||||
global _data_resetter_task, _failed_resets
|
||||
nodatas_count: int = 0
|
||||
|
||||
data_cs: trio.CancelScope | None = None
|
||||
|
@ -320,11 +365,14 @@ async def get_bars(
|
|||
result_ready = trio.Event()
|
||||
|
||||
async def query():
|
||||
|
||||
global _failed_resets
|
||||
nonlocal result, data_cs, end_dt, nodatas_count
|
||||
while True:
|
||||
|
||||
while _failed_resets < max_failed_resets:
|
||||
try:
|
||||
out = await proxy.bars(
|
||||
fqsn=fqsn,
|
||||
fqme=fqme,
|
||||
end_dt=end_dt,
|
||||
sample_period_s=timeframe,
|
||||
|
||||
|
@ -339,7 +387,10 @@ async def get_bars(
|
|||
|
||||
bars, bars_array, dt_duration = out
|
||||
|
||||
if not bars:
|
||||
if (
|
||||
not bars
|
||||
and end_dt
|
||||
):
|
||||
log.warning(
|
||||
f'History is blank for {dt_duration} from {end_dt}'
|
||||
)
|
||||
|
@ -347,7 +398,7 @@ async def get_bars(
|
|||
continue
|
||||
|
||||
if bars_array is None:
|
||||
raise SymbolNotFound(fqsn)
|
||||
raise SymbolNotFound(fqme)
|
||||
|
||||
first_dt = pendulum.from_timestamp(
|
||||
bars[0].date.timestamp())
|
||||
|
@ -378,52 +429,51 @@ async def get_bars(
|
|||
if 'No market data permissions for' in msg:
|
||||
# TODO: signalling for no permissions searches
|
||||
raise NoData(
|
||||
f'Symbol: {fqsn}',
|
||||
f'Symbol: {fqme}',
|
||||
)
|
||||
|
||||
elif err.code == 162:
|
||||
if (
|
||||
'HMDS query returned no data' in msg
|
||||
):
|
||||
# XXX: this is now done in the storage mgmt
|
||||
# layer and we shouldn't implicitly decrement
|
||||
# the frame dt index since the upper layer may
|
||||
# be doing so concurrently and we don't want to
|
||||
# be delivering frames that weren't asked for.
|
||||
# try to decrement start point and look further back
|
||||
# end_dt = end_dt.subtract(seconds=2000)
|
||||
logmsg = "SUBTRACTING DAY from DT index"
|
||||
if end_dt is not None:
|
||||
end_dt = end_dt.subtract(days=1)
|
||||
elif end_dt is None:
|
||||
end_dt = pendulum.now().subtract(days=1)
|
||||
elif (
|
||||
'HMDS query returned no data' in msg
|
||||
):
|
||||
# XXX: this is now done in the storage mgmt
|
||||
# layer and we shouldn't implicitly decrement
|
||||
# the frame dt index since the upper layer may
|
||||
# be doing so concurrently and we don't want to
|
||||
# be delivering frames that weren't asked for.
|
||||
# try to decrement start point and look further back
|
||||
# end_dt = end_dt.subtract(seconds=2000)
|
||||
logmsg = "SUBTRACTING DAY from DT index"
|
||||
if end_dt is not None:
|
||||
end_dt = end_dt.subtract(days=1)
|
||||
elif end_dt is None:
|
||||
end_dt = pendulum.now().subtract(days=1)
|
||||
|
||||
log.warning(
|
||||
f'NO DATA found ending @ {end_dt}\n'
|
||||
+ logmsg
|
||||
log.warning(
|
||||
f'NO DATA found ending @ {end_dt}\n'
|
||||
+ logmsg
|
||||
)
|
||||
|
||||
if nodatas_count >= max_nodatas:
|
||||
raise DataUnavailable(
|
||||
f'Presuming {fqme} has no further history '
|
||||
f'after {max_nodatas} tries..'
|
||||
)
|
||||
|
||||
if nodatas_count >= max_nodatas:
|
||||
raise DataUnavailable(
|
||||
f'Presuming {fqsn} has no further history '
|
||||
f'after {max_nodatas} tries..'
|
||||
)
|
||||
nodatas_count += 1
|
||||
continue
|
||||
|
||||
nodatas_count += 1
|
||||
continue
|
||||
|
||||
elif 'API historical data query cancelled' in err.message:
|
||||
log.warning(
|
||||
'Query cancelled by IB (:eyeroll:):\n'
|
||||
f'{err.message}'
|
||||
)
|
||||
continue
|
||||
elif (
|
||||
'Trading TWS session is connected from a different IP'
|
||||
in err.message
|
||||
):
|
||||
log.warning("ignoring ip address warning")
|
||||
continue
|
||||
elif 'API historical data query cancelled' in err.message:
|
||||
log.warning(
|
||||
'Query cancelled by IB (:eyeroll:):\n'
|
||||
f'{err.message}'
|
||||
)
|
||||
continue
|
||||
elif (
|
||||
'Trading TWS session is connected from a different IP'
|
||||
in err.message
|
||||
):
|
||||
log.warning("ignoring ip address warning")
|
||||
continue
|
||||
|
||||
# XXX: more or less same as above timeout case
|
||||
elif _pacing in msg:
|
||||
|
@ -432,8 +482,11 @@ async def get_bars(
|
|||
'Resetting farms with `ctrl-alt-f` hack\n'
|
||||
)
|
||||
|
||||
client = proxy._aio_ns.ib.client
|
||||
|
||||
# cancel any existing reset task
|
||||
if data_cs:
|
||||
log.cancel(f'Cancelling existing reset for {client}')
|
||||
data_cs.cancel()
|
||||
|
||||
# spawn new data reset task
|
||||
|
@ -441,10 +494,13 @@ async def get_bars(
|
|||
partial(
|
||||
wait_on_data_reset,
|
||||
proxy,
|
||||
timeout=float('inf'),
|
||||
reset_type='connection'
|
||||
)
|
||||
)
|
||||
if reset_done:
|
||||
_failed_resets = 0
|
||||
else:
|
||||
_failed_resets += 1
|
||||
continue
|
||||
|
||||
else:
|
||||
|
@ -481,7 +537,7 @@ async def get_bars(
|
|||
partial(
|
||||
wait_on_data_reset,
|
||||
proxy,
|
||||
timeout=float('inf'),
|
||||
reset_type='data',
|
||||
)
|
||||
)
|
||||
# sync wait on reset to complete
|
||||
|
@ -491,7 +547,9 @@ async def get_bars(
|
|||
return result, data_cs is not None
|
||||
|
||||
|
||||
asset_type_map = {
|
||||
# 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',
|
||||
|
@ -532,7 +590,7 @@ async def _setup_quote_stream(
|
|||
'294', # Trade rate / minute
|
||||
'295', # Vlm rate / minute
|
||||
),
|
||||
contract: Optional[Contract] = None,
|
||||
contract: Contract | None = None,
|
||||
|
||||
) -> trio.abc.ReceiveChannel:
|
||||
'''
|
||||
|
@ -618,7 +676,7 @@ async def _setup_quote_stream(
|
|||
async def open_aio_quote_stream(
|
||||
|
||||
symbol: str,
|
||||
contract: Optional[Contract] = None,
|
||||
contract: Contract | None = None,
|
||||
|
||||
) -> trio.abc.ReceiveStream:
|
||||
|
||||
|
@ -661,7 +719,7 @@ def normalize(
|
|||
|
||||
# check for special contract types
|
||||
con = ticker.contract
|
||||
fqsn, calc_price = con2fqsn(con)
|
||||
fqme, calc_price = con2fqme(con)
|
||||
|
||||
# convert named tuples to dicts so we send usable keys
|
||||
new_ticks = []
|
||||
|
@ -691,9 +749,9 @@ def normalize(
|
|||
# serialize for transport
|
||||
data = asdict(ticker)
|
||||
|
||||
# generate fqsn with possible specialized suffix
|
||||
# generate fqme with possible specialized suffix
|
||||
# for derivatives, note the lowercase.
|
||||
data['symbol'] = data['fqsn'] = fqsn
|
||||
data['symbol'] = data['fqme'] = fqme
|
||||
|
||||
# convert named tuples to dicts for transport
|
||||
tbts = data.get('tickByTicks')
|
||||
|
@ -713,6 +771,98 @@ def normalize(
|
|||
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
|
||||
get_details: bool = False
|
||||
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
|
||||
init_info: dict = {}
|
||||
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(
|
||||
|
||||
send_chan: trio.abc.SendChannel,
|
||||
|
@ -735,80 +885,49 @@ async def stream_quotes(
|
|||
sym = symbols[0]
|
||||
log.info(f'request for real-time quotes: {sym}')
|
||||
|
||||
init_msgs: list[FeedInit] = []
|
||||
|
||||
proxy: MethodProxy
|
||||
mkt: MktPair
|
||||
details: ibis.ContractDetails
|
||||
async with open_data_client() as proxy:
|
||||
mkt, details = await get_mkt_info(
|
||||
sym,
|
||||
proxy=proxy, # passed to avoid implicit client load
|
||||
)
|
||||
|
||||
con, first_ticker, details = await proxy.get_sym_details(symbol=sym)
|
||||
first_quote = normalize(first_ticker)
|
||||
# print(f'first quote: {first_quote}')
|
||||
init_msg = FeedInit(mkt_info=mkt)
|
||||
|
||||
def mk_init_msgs() -> dict[str, dict]:
|
||||
'''
|
||||
Collect a bunch of meta-data useful for feed startup and
|
||||
pack in a `dict`-msg.
|
||||
if mkt.dst.atype in {
|
||||
'forex',
|
||||
'index',
|
||||
'commodity',
|
||||
}:
|
||||
# tell sampler config that it shouldn't do vlm summing.
|
||||
init_msg.shm_write_opts['sum_tick_vlm'] = False
|
||||
init_msg.shm_write_opts['has_vlm'] = False
|
||||
|
||||
'''
|
||||
# pass back some symbol info like min_tick, trading_hours, etc.
|
||||
syminfo = asdict(details)
|
||||
syminfo.update(syminfo['contract'])
|
||||
init_msgs.append(init_msg)
|
||||
|
||||
# nested dataclass we probably don't need and that won't IPC
|
||||
# serialize
|
||||
syminfo.pop('secIdList')
|
||||
|
||||
# TODO: more consistent field translation
|
||||
atype = syminfo['asset_type'] = asset_type_map[syminfo['secType']]
|
||||
|
||||
if atype in {
|
||||
'forex',
|
||||
'index',
|
||||
'commodity',
|
||||
}:
|
||||
syminfo['no_vlm'] = True
|
||||
|
||||
# for stocks it seems TWS reports too small a tick size
|
||||
# such that you can't submit orders with that granularity?
|
||||
min_tick = 0.01 if atype == 'stock' else 0
|
||||
|
||||
syminfo['price_tick_size'] = max(syminfo['minTick'], min_tick)
|
||||
|
||||
# for "legacy" assets, volume is normally discreet, not
|
||||
# a float
|
||||
syminfo['lot_tick_size'] = 0.0
|
||||
|
||||
ibclient = proxy._aio_ns.ib.client
|
||||
host, port = ibclient.host, ibclient.port
|
||||
|
||||
# TODO: for loop through all symbols passed in
|
||||
init_msgs = {
|
||||
# pass back token, and bool, signalling if we're the writer
|
||||
# and that history has been written
|
||||
sym: {
|
||||
'symbol_info': syminfo,
|
||||
'fqsn': first_quote['fqsn'],
|
||||
},
|
||||
'status': {
|
||||
'data_ep': f'{host}:{port}',
|
||||
},
|
||||
|
||||
}
|
||||
return init_msgs, syminfo
|
||||
|
||||
init_msgs, syminfo = mk_init_msgs()
|
||||
con: Contract = details.contract
|
||||
first_ticker: Ticker = await proxy.get_quote(contract=con)
|
||||
first_quote: dict = normalize(first_ticker)
|
||||
log.runtime(f'FIRST QUOTE: {first_quote}')
|
||||
|
||||
# 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.
|
||||
with trio.move_on_after(1):
|
||||
contract, first_ticker, details = await proxy.get_quote(symbol=sym)
|
||||
first_ticker = await proxy.get_quote(contract=con)
|
||||
|
||||
# it might be outside regular trading hours so see if we can at
|
||||
# least grab history.
|
||||
if (
|
||||
isnan(first_ticker.last)
|
||||
and type(first_ticker.contract) not in (
|
||||
ibis.Commodity,
|
||||
ibis.Forex,
|
||||
ibis.Crypto,
|
||||
)
|
||||
isnan(first_ticker.last) # last quote price value is nan
|
||||
and mkt.dst.atype not in {
|
||||
'commodity',
|
||||
'forex',
|
||||
'crypto',
|
||||
}
|
||||
):
|
||||
task_status.started((init_msgs, first_quote))
|
||||
|
||||
|
@ -820,7 +939,7 @@ async def stream_quotes(
|
|||
await trio.sleep_forever()
|
||||
return # we never expect feed to come up?
|
||||
|
||||
cs: Optional[trio.CancelScope] = None
|
||||
cs: trio.CancelScope | None = None
|
||||
startup: bool = True
|
||||
while (
|
||||
startup
|
||||
|
@ -860,13 +979,14 @@ async def stream_quotes(
|
|||
nurse.start_soon(reset_on_feed)
|
||||
|
||||
async with aclosing(stream):
|
||||
if syminfo.get('no_vlm', False):
|
||||
# if syminfo.get('no_vlm', False):
|
||||
if not init_msg.shm_write_opts['has_vlm']:
|
||||
|
||||
# generally speaking these feeds don't
|
||||
# include vlm data.
|
||||
atype = syminfo['asset_type']
|
||||
atype = mkt.dst.atype
|
||||
log.info(
|
||||
f'No-vlm {sym}@{atype}, skipping quote poll'
|
||||
f'No-vlm {mkt.fqme}@{atype}, skipping quote poll'
|
||||
)
|
||||
|
||||
else:
|
||||
|
@ -906,9 +1026,9 @@ async def stream_quotes(
|
|||
# last = time.time()
|
||||
async for ticker in stream:
|
||||
quote = normalize(ticker)
|
||||
fqsn = quote['fqsn']
|
||||
# print(f'sending {fqsn}:\n{quote}')
|
||||
await send_chan.send({fqsn: quote})
|
||||
fqme = quote['fqme']
|
||||
# print(f'sending {fqme}:\n{quote}')
|
||||
await send_chan.send({fqme: quote})
|
||||
|
||||
# ugh, clear ticks since we've consumed them
|
||||
ticker.ticks = []
|
||||
|
|
|
@ -58,7 +58,7 @@ your ``pps.toml`` file will have position entries like,
|
|||
[kraken.spot."xmreur.kraken"]
|
||||
size = 4.80907954
|
||||
ppu = 103.97000000
|
||||
bsuid = "XXMRZEUR"
|
||||
bs_mktid = "XXMRZEUR"
|
||||
clears = [
|
||||
{ tid = "TFJBKK-SMBZS-VJ4UWS", cost = 0.8, price = 103.97, size = 4.80907954, dt = "2022-05-20T02:26:33.413397+00:00" },
|
||||
]
|
||||
|
|
|
@ -34,6 +34,7 @@ from .api import (
|
|||
get_client,
|
||||
)
|
||||
from .feed import (
|
||||
get_mkt_info,
|
||||
open_history_client,
|
||||
open_symbol_search,
|
||||
stream_quotes,
|
||||
|
|
|
@ -20,10 +20,10 @@ Kraken web API wrapping.
|
|||
'''
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import itertools
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
import time
|
||||
|
@ -41,14 +41,17 @@ import trio
|
|||
|
||||
from piker import config
|
||||
from piker.data.types import Struct
|
||||
from piker.data._source import Symbol
|
||||
from piker.accounting._mktinfo import (
|
||||
Asset,
|
||||
digits_to_dec,
|
||||
)
|
||||
from piker.brokers._util import (
|
||||
resproc,
|
||||
SymbolNotFound,
|
||||
BrokerError,
|
||||
DataThrottle,
|
||||
)
|
||||
from piker.pp import Transaction
|
||||
from piker.accounting import Transaction
|
||||
from . import log
|
||||
|
||||
# <uri>/<version>/
|
||||
|
@ -155,12 +158,23 @@ class Pair(Struct):
|
|||
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)
|
||||
|
||||
|
||||
class Client:
|
||||
|
||||
# global symbol normalization table
|
||||
# symbol mapping from all names to the altname
|
||||
_ntable: dict[str, str] = {}
|
||||
_atable: bidict[str, str] = bidict()
|
||||
|
||||
# 2-way map of symbol names to their "alt names" ffs XD
|
||||
_altnames: bidict[str, str] = bidict()
|
||||
|
||||
_pairs: dict[str, Pair] = {}
|
||||
|
||||
def __init__(
|
||||
|
@ -176,11 +190,13 @@ class Client:
|
|||
'User-Agent':
|
||||
'krakenex/2.1.0 (+https://github.com/veox/python3-krakenex)'
|
||||
})
|
||||
self.conf: dict[str, str] = config
|
||||
self._name = name
|
||||
self._api_key = api_key
|
||||
self._secret = secret
|
||||
|
||||
self.conf: dict[str, str] = config
|
||||
self.assets: dict[str, Asset] = {}
|
||||
|
||||
@property
|
||||
def pairs(self) -> dict[str, Pair]:
|
||||
if self._pairs is None:
|
||||
|
@ -247,20 +263,49 @@ class Client:
|
|||
'Balance',
|
||||
{},
|
||||
)
|
||||
by_bsuid = resp['result']
|
||||
by_bsmktid = resp['result']
|
||||
|
||||
# TODO: we need to pull out the "asset" decimals
|
||||
# data and return a `decimal.Decimal` instead here!
|
||||
# using the underlying Asset
|
||||
return {
|
||||
self._atable[sym].lower(): float(bal)
|
||||
for sym, bal in by_bsuid.items()
|
||||
self._altnames[sym].lower(): float(bal)
|
||||
for sym, bal in by_bsmktid.items()
|
||||
}
|
||||
|
||||
async def get_assets(self) -> dict[str, dict]:
|
||||
resp = await self._public('Assets', {})
|
||||
return resp['result']
|
||||
async def get_assets(self) -> dict[str, Asset]:
|
||||
'''
|
||||
Load and cache all asset infos and pack into
|
||||
our native ``Asset`` struct.
|
||||
|
||||
async def cache_assets(self) -> None:
|
||||
assets = self.assets = await self.get_assets()
|
||||
for bsuid, info in assets.items():
|
||||
self._atable[bsuid] = info['altname']
|
||||
https://docs.kraken.com/rest/#tag/Market-Data/operation/getAssetInfo
|
||||
|
||||
return msg:
|
||||
"asset1": {
|
||||
"aclass": "string",
|
||||
"altname": "string",
|
||||
"decimals": 0,
|
||||
"display_decimals": 0,
|
||||
"collateral_value": 0,
|
||||
"status": "string"
|
||||
}
|
||||
|
||||
'''
|
||||
resp = await self._public('Assets', {})
|
||||
assets = resp['result']
|
||||
|
||||
for bs_mktid, info in assets.items():
|
||||
altname = self._altnames[bs_mktid] = info['altname']
|
||||
aclass: str = info['aclass']
|
||||
|
||||
self.assets[bs_mktid] = Asset(
|
||||
name=altname.lower(),
|
||||
atype=f'crypto_{aclass}',
|
||||
tx_tick=digits_to_dec(info['decimals']),
|
||||
info=info,
|
||||
)
|
||||
|
||||
return self.assets
|
||||
|
||||
async def get_trades(
|
||||
self,
|
||||
|
@ -323,10 +368,15 @@ class Client:
|
|||
Currently only withdrawals are supported.
|
||||
|
||||
'''
|
||||
xfers: list[dict] = (await self.endpoint(
|
||||
resp = await self.endpoint(
|
||||
'WithdrawStatus',
|
||||
{'asset': asset},
|
||||
))['result']
|
||||
)
|
||||
try:
|
||||
xfers: list[dict] = resp['result']
|
||||
except KeyError:
|
||||
log.exception(f'Kraken suxxx: {resp}')
|
||||
return []
|
||||
|
||||
# eg. resp schema:
|
||||
# 'result': [{'method': 'Bitcoin', 'aclass': 'currency', 'asset':
|
||||
|
@ -341,28 +391,21 @@ class Client:
|
|||
|
||||
# look up the normalized name and asset info
|
||||
asset_key = entry['asset']
|
||||
asset_info = self.assets[asset_key]
|
||||
asset = self._atable[asset_key].lower()
|
||||
asset = self.assets[asset_key]
|
||||
asset_key = self._altnames[asset_key].lower()
|
||||
|
||||
# XXX: this is in the asset units (likely) so it isn't
|
||||
# quite the same as a commisions cost necessarily..)
|
||||
cost = float(entry['fee'])
|
||||
|
||||
fqsn = asset + '.kraken'
|
||||
pairinfo = Symbol.from_fqsn(
|
||||
fqsn,
|
||||
info={
|
||||
'asset_type': 'crypto',
|
||||
'lot_tick_size': asset_info['decimals'],
|
||||
},
|
||||
)
|
||||
fqme = asset_key + '.kraken'
|
||||
|
||||
tran = Transaction(
|
||||
fqsn=fqsn,
|
||||
sym=pairinfo,
|
||||
tx = Transaction(
|
||||
fqme=fqme,
|
||||
sym=asset,
|
||||
tid=entry['txid'],
|
||||
dt=pendulum.from_timestamp(entry['time']),
|
||||
bsuid=f'{asset}{src_asset}',
|
||||
bs_mktid=f'{asset_key}{src_asset}',
|
||||
size=-1*(
|
||||
float(entry['amount'])
|
||||
+
|
||||
|
@ -375,7 +418,7 @@ class Client:
|
|||
# XXX: see note above
|
||||
cost=cost,
|
||||
)
|
||||
trans[tran.tid] = tran
|
||||
trans[tx.tid] = tx
|
||||
|
||||
return trans
|
||||
|
||||
|
@ -424,30 +467,44 @@ class Client:
|
|||
# txid is a transaction id given by kraken
|
||||
return await self.endpoint('CancelOrder', {"txid": reqid})
|
||||
|
||||
async def symbol_info(
|
||||
async def pair_info(
|
||||
self,
|
||||
pair: Optional[str] = None,
|
||||
pair_patt: str | None = None,
|
||||
|
||||
) -> dict[str, Pair] | Pair:
|
||||
'''
|
||||
Query for a tradeable asset pair (info), or all if no input
|
||||
pattern is provided.
|
||||
|
||||
if pair is not None:
|
||||
pairs = {'pair': pair}
|
||||
else:
|
||||
pairs = None # get all pairs
|
||||
https://docs.kraken.com/rest/#tag/Market-Data/operation/getTradableAssetPairs
|
||||
|
||||
resp = await self._public('AssetPairs', pairs)
|
||||
'''
|
||||
# get all pairs by default, or filter
|
||||
# to whatever pattern is provided as input.
|
||||
pairs: dict[str, str] | None = None
|
||||
if pair_patt is not None:
|
||||
pairs = {'pair': pair_patt}
|
||||
|
||||
resp = await self._public(
|
||||
'AssetPairs',
|
||||
pairs,
|
||||
)
|
||||
err = resp['error']
|
||||
if err:
|
||||
symbolname = pairs['pair'] if pair else None
|
||||
raise SymbolNotFound(f'{symbolname}.kraken')
|
||||
raise SymbolNotFound(pair_patt)
|
||||
|
||||
pairs = resp['result']
|
||||
pairs: dict[str, Pair] = {
|
||||
|
||||
if pair is not None:
|
||||
_, data = next(iter(pairs.items()))
|
||||
return Pair(**data)
|
||||
else:
|
||||
return {key: Pair(**data) for key, data in pairs.items()}
|
||||
key: Pair(**data)
|
||||
for key, data in resp['result'].items()
|
||||
}
|
||||
# always cache so we can possibly do faster lookup
|
||||
self._pairs.update(pairs)
|
||||
|
||||
if pair_patt is not None:
|
||||
return next(iter(pairs.items()))[1]
|
||||
|
||||
return pairs
|
||||
|
||||
async def cache_symbols(self) -> dict:
|
||||
'''
|
||||
|
@ -460,17 +517,18 @@ class Client:
|
|||
|
||||
'''
|
||||
if not self._pairs:
|
||||
self._pairs.update(await self.symbol_info())
|
||||
pairs = await self.pair_info()
|
||||
assert self._pairs == pairs
|
||||
|
||||
# table of all ws and rest keys to their alt-name values.
|
||||
ntable: dict[str, str] = {}
|
||||
|
||||
for rest_key in list(self._pairs.keys()):
|
||||
for rest_key in list(pairs.keys()):
|
||||
|
||||
pair: Pair = self._pairs[rest_key]
|
||||
pair: Pair = pairs[rest_key]
|
||||
altname = pair.altname
|
||||
wsname = pair.wsname
|
||||
ntable[rest_key] = ntable[wsname] = altname
|
||||
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.
|
||||
|
@ -483,7 +541,6 @@ class Client:
|
|||
async def search_symbols(
|
||||
self,
|
||||
pattern: str,
|
||||
limit: int = None,
|
||||
|
||||
) -> dict[str, Any]:
|
||||
'''
|
||||
|
@ -594,8 +651,7 @@ class Client:
|
|||
the 'AssetPairs' endpoint, see methods above.
|
||||
|
||||
'''
|
||||
ticker = cls._ntable[ticker]
|
||||
return ticker.lower(), cls._pairs[ticker]
|
||||
return cls._ntable[ticker].lower()
|
||||
|
||||
|
||||
@acm
|
||||
|
@ -615,7 +671,7 @@ async def get_client() -> Client:
|
|||
# at startup, load all symbols, and asset info in
|
||||
# batch requests.
|
||||
async with trio.open_nursery() as nurse:
|
||||
nurse.start_soon(client.cache_assets)
|
||||
nurse.start_soon(client.get_assets)
|
||||
await client.cache_symbols()
|
||||
|
||||
yield client
|
||||
|
|
|
@ -21,7 +21,7 @@ Order api and machinery
|
|||
from collections import ChainMap, defaultdict
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
contextmanager as cm,
|
||||
aclosing,
|
||||
)
|
||||
from functools import partial
|
||||
from itertools import count
|
||||
|
@ -35,20 +35,23 @@ from typing import (
|
|||
Union,
|
||||
)
|
||||
|
||||
from async_generator import aclosing
|
||||
from bidict import bidict
|
||||
import pendulum
|
||||
import trio
|
||||
import tractor
|
||||
|
||||
from piker.pp import (
|
||||
from piker.accounting import (
|
||||
Position,
|
||||
PpTable,
|
||||
Transaction,
|
||||
TransactionLedger,
|
||||
open_trade_ledger,
|
||||
open_pps,
|
||||
get_likely_pair,
|
||||
)
|
||||
from piker.accounting._mktinfo import (
|
||||
MktPair,
|
||||
)
|
||||
from piker.data._source import Symbol
|
||||
from piker.clearing._messages import (
|
||||
Order,
|
||||
Status,
|
||||
|
@ -67,7 +70,7 @@ from .api import (
|
|||
get_client,
|
||||
)
|
||||
from .feed import (
|
||||
get_console_log,
|
||||
get_mkt_info,
|
||||
open_autorecon_ws,
|
||||
NoBsWs,
|
||||
stream_messages,
|
||||
|
@ -367,6 +370,8 @@ def trades2pps(
|
|||
acctid: str,
|
||||
new_trans: dict[str, Transaction] = {},
|
||||
|
||||
write_storage: bool = True,
|
||||
|
||||
) -> tuple[
|
||||
list[BrokerdPosition],
|
||||
list[Transaction],
|
||||
|
@ -397,13 +402,20 @@ def trades2pps(
|
|||
# right since `.broker` is already
|
||||
# included?
|
||||
account='kraken.' + acctid,
|
||||
symbol=p.symbol.front_fqsn(),
|
||||
symbol=p.mkt.fqme,
|
||||
size=p.size,
|
||||
avg_price=p.ppu,
|
||||
currency='',
|
||||
)
|
||||
position_msgs.append(msg)
|
||||
|
||||
if write_storage:
|
||||
# TODO: ideally this blocks the this task
|
||||
# as little as possible. we need to either do
|
||||
# these writes in another actor, or try out `trio`'s
|
||||
# async file IO api?
|
||||
table.write_config()
|
||||
|
||||
return position_msgs
|
||||
|
||||
|
||||
|
@ -414,14 +426,11 @@ async def trades_dialogue(
|
|||
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
|
||||
# XXX: required to propagate ``tractor`` loglevel to ``piker`` logging
|
||||
get_console_log(loglevel or tractor.current_actor().loglevel)
|
||||
|
||||
async with get_client() as client:
|
||||
|
||||
if not client._api_key:
|
||||
raise RuntimeError(
|
||||
'Missing Kraken API key in `brokers.toml`!?!?')
|
||||
await ctx.started('paper')
|
||||
return
|
||||
|
||||
# TODO: make ems flip to paper mode via
|
||||
# some returned signal if the user only wants to use
|
||||
|
@ -467,40 +476,55 @@ async def trades_dialogue(
|
|||
# update things correctly.
|
||||
simulate_pp_update: bool = False
|
||||
|
||||
table: PpTable
|
||||
ledger: TransactionLedger
|
||||
with (
|
||||
open_pps(
|
||||
'kraken',
|
||||
acctid
|
||||
acctid,
|
||||
write_on_exit=True,
|
||||
) as table,
|
||||
|
||||
open_trade_ledger(
|
||||
'kraken',
|
||||
acctid
|
||||
) as ledger_dict,
|
||||
acctid,
|
||||
) as ledger,
|
||||
):
|
||||
# transaction-ify the ledger entries
|
||||
ledger_trans = norm_trade_records(ledger_dict)
|
||||
ledger_trans = await norm_trade_records(ledger)
|
||||
|
||||
if not table.pps:
|
||||
# NOTE: we can't use this since it first needs
|
||||
# broker: str input support!
|
||||
# table.update_from_trans(ledger.to_trans())
|
||||
table.update_from_trans(ledger_trans)
|
||||
table.write_config()
|
||||
|
||||
# TODO: eventually probably only load
|
||||
# as far back as it seems is not deliverd in the
|
||||
# most recent 50 trades and assume that by ordering we
|
||||
# already have those records in the ledger.
|
||||
tids2trades = await client.get_trades()
|
||||
ledger_dict.update(tids2trades)
|
||||
api_trans = norm_trade_records(tids2trades)
|
||||
ledger.update(tids2trades)
|
||||
if tids2trades:
|
||||
ledger.write_config()
|
||||
|
||||
api_trans = await norm_trade_records(tids2trades)
|
||||
|
||||
# retrieve kraken reported balances
|
||||
# and do diff with ledger to determine
|
||||
# what amount of trades-transactions need
|
||||
# to be reloaded.
|
||||
balances = await client.get_balances()
|
||||
|
||||
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 bsuid for bsuid in table.pps
|
||||
dst in bs_mktid for bs_mktid in table.pps
|
||||
)
|
||||
):
|
||||
log.warning(
|
||||
|
@ -508,45 +532,20 @@ async def trades_dialogue(
|
|||
)
|
||||
continue
|
||||
|
||||
def get_likely_pair(
|
||||
dst: str,
|
||||
bsuid: str,
|
||||
src_fiat: str = src_fiat
|
||||
|
||||
) -> str:
|
||||
'''
|
||||
Attempt to get the likely trading pair masting
|
||||
a given destination asset `dst: str`.
|
||||
|
||||
'''
|
||||
try:
|
||||
src_name_start = bsuid.rindex(src_fiat)
|
||||
except (
|
||||
ValueError, # substr not found
|
||||
):
|
||||
# TODO: handle nested positions..(i.e.
|
||||
# positions where the src fiat was used to
|
||||
# buy some other dst which was furhter used
|
||||
# to buy another dst..)
|
||||
log.warning(
|
||||
f'No src fiat {src_fiat} found in {bsuid}?'
|
||||
)
|
||||
return
|
||||
|
||||
likely_dst = bsuid[:src_name_start]
|
||||
if likely_dst == dst:
|
||||
return bsuid
|
||||
|
||||
def has_pp(
|
||||
dst: str,
|
||||
size: float,
|
||||
|
||||
) -> Position | bool:
|
||||
) -> Position | None:
|
||||
|
||||
src2dst: dict[str, str] = {}
|
||||
|
||||
for bsuid in table.pps:
|
||||
likely_pair = get_likely_pair(dst, bsuid)
|
||||
for bs_mktid in table.pps:
|
||||
likely_pair = get_likely_pair(
|
||||
src_fiat,
|
||||
dst,
|
||||
bs_mktid,
|
||||
)
|
||||
if likely_pair:
|
||||
src2dst[src_fiat] = dst
|
||||
|
||||
|
@ -565,7 +564,7 @@ async def trades_dialogue(
|
|||
):
|
||||
log.warning(
|
||||
f'`kraken` account says you have a ZERO '
|
||||
f'balance for {bsuid}:{pair}\n'
|
||||
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'
|
||||
|
@ -574,7 +573,7 @@ async def trades_dialogue(
|
|||
)
|
||||
return pp
|
||||
|
||||
return False
|
||||
return None # signal no entry
|
||||
|
||||
pos = has_pp(dst, size)
|
||||
if not pos:
|
||||
|
@ -601,8 +600,12 @@ async def trades_dialogue(
|
|||
# in the ``pps.toml`` for the necessary pair
|
||||
# yet and thus this likely pair grabber will
|
||||
# likely fail.
|
||||
for bsuid in table.pps:
|
||||
likely_pair = get_likely_pair(dst, bsuid)
|
||||
for bs_mktid in table.pps:
|
||||
likely_pair = get_likely_pair(
|
||||
src_fiat,
|
||||
dst,
|
||||
bs_mktid,
|
||||
)
|
||||
if likely_pair:
|
||||
break
|
||||
else:
|
||||
|
@ -652,6 +655,12 @@ async def trades_dialogue(
|
|||
)
|
||||
await ctx.started((ppmsgs, [acc_name]))
|
||||
|
||||
# TODO: ideally this blocks the this task
|
||||
# as little as possible. we need to either do
|
||||
# these writes in another actor, or try out `trio`'s
|
||||
# async file IO api?
|
||||
table.write_config()
|
||||
|
||||
# Get websocket token for authenticated data stream
|
||||
# Assert that a token was actually received.
|
||||
resp = await client.endpoint('GetWebSocketsToken', {})
|
||||
|
@ -674,8 +683,6 @@ async def trades_dialogue(
|
|||
aclosing(stream_messages(ws)) as stream,
|
||||
trio.open_nursery() as nurse,
|
||||
):
|
||||
stream = stream_messages(ws)
|
||||
|
||||
# task for processing inbound requests from ems
|
||||
nurse.start_soon(
|
||||
handle_order_requests,
|
||||
|
@ -724,8 +731,8 @@ async def handle_order_updates(
|
|||
'''
|
||||
Main msg handling loop for all things order management.
|
||||
|
||||
This code is broken out to make the context explicit and state variables
|
||||
defined in the signature clear to the reader.
|
||||
This code is broken out to make the context explicit and state
|
||||
variables defined in the signature clear to the reader.
|
||||
|
||||
'''
|
||||
async for msg in ws_stream:
|
||||
|
@ -818,7 +825,7 @@ async def handle_order_updates(
|
|||
)
|
||||
await ems_stream.send(status_msg)
|
||||
|
||||
new_trans = norm_trade_records(trades)
|
||||
new_trans = await norm_trade_records(trades)
|
||||
ppmsgs = trades2pps(
|
||||
table,
|
||||
acctid,
|
||||
|
@ -827,8 +834,6 @@ async def handle_order_updates(
|
|||
for pp_msg in ppmsgs:
|
||||
await ems_stream.send(pp_msg)
|
||||
|
||||
ledger_trans.update(new_trans)
|
||||
|
||||
# process and relay order state change events
|
||||
# https://docs.kraken.com/websockets/#message-openOrders
|
||||
case [
|
||||
|
@ -890,7 +895,7 @@ async def handle_order_updates(
|
|||
ids.inverse.get(reqid) is None
|
||||
):
|
||||
# parse out existing live order
|
||||
fqsn = pair.replace('/', '').lower()
|
||||
fqme = pair.replace('/', '').lower()
|
||||
price = float(price)
|
||||
size = float(vol)
|
||||
|
||||
|
@ -917,7 +922,7 @@ async def handle_order_updates(
|
|||
action=action,
|
||||
exec_mode='live',
|
||||
oid=oid,
|
||||
symbol=fqsn,
|
||||
symbol=fqme,
|
||||
account=acc_name,
|
||||
price=price,
|
||||
size=size,
|
||||
|
@ -1182,7 +1187,7 @@ async def handle_order_updates(
|
|||
log.warning(f'Unhandled trades update msg: {msg}')
|
||||
|
||||
|
||||
def norm_trade_records(
|
||||
async def norm_trade_records(
|
||||
ledger: dict[str, Any],
|
||||
|
||||
) -> dict[str, Transaction]:
|
||||
|
@ -1197,30 +1202,19 @@ def norm_trade_records(
|
|||
}[record['type']]
|
||||
|
||||
# we normalize to kraken's `altname` always..
|
||||
bsuid, pair_info = Client.normalize_symbol(record['pair'])
|
||||
fqsn = f'{bsuid}.kraken'
|
||||
|
||||
mktpair = Symbol.from_fqsn(
|
||||
fqsn,
|
||||
info={
|
||||
'lot_size_digits': pair_info.lot_decimals,
|
||||
'tick_size_digits': pair_info.pair_decimals,
|
||||
'asset_type': 'crypto',
|
||||
},
|
||||
)
|
||||
bs_mktid = Client.normalize_symbol(record['pair'])
|
||||
fqme = f'{bs_mktid}.kraken'
|
||||
mkt: MktPair = (await get_mkt_info(fqme))[0]
|
||||
|
||||
records[tid] = Transaction(
|
||||
fqsn=fqsn,
|
||||
sym=mktpair,
|
||||
fqme=fqme,
|
||||
sym=mkt,
|
||||
tid=tid,
|
||||
size=size,
|
||||
price=float(record['price']),
|
||||
cost=float(record['fee']),
|
||||
dt=pendulum.from_timestamp(float(record['time'])),
|
||||
bsuid=bsuid,
|
||||
|
||||
# XXX: there are no derivs on kraken right?
|
||||
# expiry=expiry,
|
||||
bs_mktid=bs_mktid,
|
||||
)
|
||||
|
||||
return records
|
||||
|
|
|
@ -18,12 +18,16 @@
|
|||
Real-time and historical data feed endpoints.
|
||||
|
||||
'''
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
aclosing,
|
||||
)
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
AsyncGenerator,
|
||||
Callable,
|
||||
Optional,
|
||||
)
|
||||
import time
|
||||
|
||||
|
@ -31,18 +35,24 @@ from fuzzywuzzy import process as fuzzy
|
|||
import numpy as np
|
||||
import pendulum
|
||||
from trio_typing import TaskStatus
|
||||
from trio_util import trio_async_generator
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from piker._cacheables import open_cached_client
|
||||
from piker.accounting._mktinfo import (
|
||||
Asset,
|
||||
MktPair,
|
||||
)
|
||||
from piker._cacheables import (
|
||||
open_cached_client,
|
||||
async_lifo_cache,
|
||||
)
|
||||
from piker.brokers._util import (
|
||||
BrokerError,
|
||||
DataThrottle,
|
||||
DataUnavailable,
|
||||
)
|
||||
from piker.log import get_console_log
|
||||
from piker.data.types import Struct
|
||||
from piker.data.validate import FeedInit
|
||||
from piker.data._web_bs import open_autorecon_ws, NoBsWs
|
||||
from . import log
|
||||
from .api import (
|
||||
|
@ -85,26 +95,9 @@ async def stream_messages(
|
|||
though a single async generator.
|
||||
|
||||
'''
|
||||
too_slow_count = last_hb = 0
|
||||
|
||||
while True:
|
||||
|
||||
with trio.move_on_after(5) as cs:
|
||||
msg = await ws.recv_msg()
|
||||
|
||||
# trigger reconnection if heartbeat is laggy
|
||||
if cs.cancelled_caught:
|
||||
|
||||
too_slow_count += 1
|
||||
|
||||
if too_slow_count > 20:
|
||||
log.warning(
|
||||
"Heartbeat is too slow, resetting ws connection")
|
||||
|
||||
await ws._connect()
|
||||
too_slow_count = 0
|
||||
continue
|
||||
last_hb: float = 0
|
||||
|
||||
async for msg in ws:
|
||||
match msg:
|
||||
case {'event': 'heartbeat'}:
|
||||
now = time.time()
|
||||
|
@ -122,7 +115,6 @@ async def stream_messages(
|
|||
yield msg
|
||||
|
||||
|
||||
@trio_async_generator
|
||||
async def process_data_feed_msgs(
|
||||
ws: NoBsWs,
|
||||
):
|
||||
|
@ -130,63 +122,75 @@ async def process_data_feed_msgs(
|
|||
Parse and pack data feed messages.
|
||||
|
||||
'''
|
||||
async for msg in stream_messages(ws):
|
||||
match msg:
|
||||
case {
|
||||
'errorMessage': errmsg
|
||||
}:
|
||||
raise BrokerError(errmsg)
|
||||
async with aclosing(stream_messages(ws)) as ws_stream:
|
||||
async for msg in ws_stream:
|
||||
match msg:
|
||||
case {
|
||||
'errorMessage': errmsg
|
||||
}:
|
||||
raise BrokerError(errmsg)
|
||||
|
||||
case {
|
||||
'event': 'subscriptionStatus',
|
||||
} as sub:
|
||||
log.info(
|
||||
'WS subscription is active:\n'
|
||||
f'{sub}'
|
||||
)
|
||||
continue
|
||||
|
||||
case [
|
||||
chan_id,
|
||||
*payload_array,
|
||||
chan_name,
|
||||
pair
|
||||
]:
|
||||
if 'ohlc' in chan_name:
|
||||
ohlc = OHLC(
|
||||
chan_id,
|
||||
chan_name,
|
||||
pair,
|
||||
*payload_array[0]
|
||||
case {
|
||||
'event': 'subscriptionStatus',
|
||||
} as sub:
|
||||
log.info(
|
||||
'WS subscription is active:\n'
|
||||
f'{sub}'
|
||||
)
|
||||
ohlc.typecast()
|
||||
yield 'ohlc', ohlc
|
||||
continue
|
||||
|
||||
elif 'spread' in chan_name:
|
||||
case [
|
||||
chan_id,
|
||||
*payload_array,
|
||||
chan_name,
|
||||
pair
|
||||
]:
|
||||
if 'ohlc' in chan_name:
|
||||
ohlc = OHLC(
|
||||
chan_id,
|
||||
chan_name,
|
||||
pair,
|
||||
*payload_array[0]
|
||||
)
|
||||
ohlc.typecast()
|
||||
yield 'ohlc', ohlc
|
||||
|
||||
bid, ask, ts, bsize, asize = map(
|
||||
float, payload_array[0])
|
||||
elif 'spread' in chan_name:
|
||||
|
||||
# TODO: really makes you think IB has a horrible API...
|
||||
quote = {
|
||||
'symbol': pair.replace('/', ''),
|
||||
'ticks': [
|
||||
{'type': 'bid', 'price': bid, 'size': bsize},
|
||||
{'type': 'bsize', 'price': bid, 'size': bsize},
|
||||
bid, ask, ts, bsize, asize = map(
|
||||
float, payload_array[0])
|
||||
|
||||
{'type': 'ask', 'price': ask, 'size': asize},
|
||||
{'type': 'asize', 'price': ask, 'size': asize},
|
||||
],
|
||||
}
|
||||
yield 'l1', quote
|
||||
# TODO: really makes you think IB has a horrible API...
|
||||
quote = {
|
||||
'symbol': pair.replace('/', ''),
|
||||
'ticks': [
|
||||
{'type': 'bid', 'price': bid, 'size': bsize},
|
||||
{'type': 'bsize', 'price': bid, 'size': bsize},
|
||||
|
||||
# elif 'book' in msg[-2]:
|
||||
# chan_id, *payload_array, chan_name, pair = msg
|
||||
# print(msg)
|
||||
{'type': 'ask', 'price': ask, 'size': asize},
|
||||
{'type': 'asize', 'price': ask, 'size': asize},
|
||||
],
|
||||
}
|
||||
yield 'l1', quote
|
||||
|
||||
case _:
|
||||
print(f'UNHANDLED MSG: {msg}')
|
||||
# yield msg
|
||||
# elif 'book' in msg[-2]:
|
||||
# chan_id, *payload_array, chan_name, pair = msg
|
||||
# print(msg)
|
||||
|
||||
case {
|
||||
'connectionID': conid,
|
||||
'event': 'systemStatus',
|
||||
'status': 'online',
|
||||
'version': ver,
|
||||
}:
|
||||
log.info(
|
||||
f'Established {ver} ws connection with id: {conid}'
|
||||
)
|
||||
continue
|
||||
|
||||
case _:
|
||||
print(f'UNHANDLED MSG: {msg}')
|
||||
# yield msg
|
||||
|
||||
|
||||
def normalize(
|
||||
|
@ -211,9 +215,11 @@ def normalize(
|
|||
|
||||
@acm
|
||||
async def open_history_client(
|
||||
symbol: str,
|
||||
mkt: MktPair,
|
||||
|
||||
) -> tuple[Callable, int]:
|
||||
) -> AsyncGenerator[Callable, None]:
|
||||
|
||||
symbol: str = mkt.bs_fqme
|
||||
|
||||
# TODO implement history getter for the new storage layer.
|
||||
async with open_cached_client('kraken') as client:
|
||||
|
@ -263,6 +269,44 @@ async def open_history_client(
|
|||
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.
|
||||
|
||||
'''
|
||||
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,
|
||||
|
||||
broker='kraken',
|
||||
)
|
||||
return mkt, pair
|
||||
|
||||
|
||||
async def stream_quotes(
|
||||
|
||||
send_chan: trio.abc.SendChannel,
|
||||
|
@ -283,45 +327,20 @@ async def stream_quotes(
|
|||
``pairs`` must be formatted <crypto_symbol>/<fiat_symbol>.
|
||||
|
||||
'''
|
||||
# XXX: required to propagate ``tractor`` loglevel to piker logging
|
||||
get_console_log(loglevel or tractor.current_actor().loglevel)
|
||||
|
||||
ws_pairs = {}
|
||||
sym_infos = {}
|
||||
ws_pairs: list[str] = []
|
||||
init_msgs: list[FeedInit] = []
|
||||
|
||||
async with open_cached_client('kraken') as client, send_chan as send_chan:
|
||||
async with (
|
||||
send_chan as send_chan,
|
||||
):
|
||||
for sym_str in symbols:
|
||||
mkt, pair = await get_mkt_info(sym_str)
|
||||
init_msgs.append(
|
||||
FeedInit(mkt_info=mkt)
|
||||
)
|
||||
|
||||
# keep client cached for real-time section
|
||||
for sym in symbols:
|
||||
|
||||
# transform to upper since piker style is always lower
|
||||
sym = sym.upper()
|
||||
si: Pair = await client.symbol_info(sym)
|
||||
# try:
|
||||
# si = Pair(**sym_info) # validation
|
||||
# except TypeError:
|
||||
# fields_diff = set(sym_info) - set(Pair.__struct_fields__)
|
||||
# raise TypeError(
|
||||
# f'Missing msg fields {fields_diff}'
|
||||
# )
|
||||
syminfo = si.to_dict()
|
||||
syminfo['price_tick_size'] = 1. / 10**si.pair_decimals
|
||||
syminfo['lot_tick_size'] = 1. / 10**si.lot_decimals
|
||||
syminfo['asset_type'] = 'crypto'
|
||||
sym_infos[sym] = syminfo
|
||||
ws_pairs[sym] = si.wsname
|
||||
|
||||
symbol = symbols[0].lower()
|
||||
|
||||
init_msgs = {
|
||||
# pass back token, and bool, signalling if we're the writer
|
||||
# and that history has been written
|
||||
symbol: {
|
||||
'symbol_info': sym_infos[sym],
|
||||
'shm_write_opts': {'sum_tick_vml': False},
|
||||
'fqsn': sym,
|
||||
},
|
||||
}
|
||||
ws_pairs.append(pair.wsname)
|
||||
|
||||
@acm
|
||||
async def subscribe(ws: NoBsWs):
|
||||
|
@ -332,7 +351,7 @@ async def stream_quotes(
|
|||
# https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188
|
||||
ohlc_sub = {
|
||||
'event': 'subscribe',
|
||||
'pair': list(ws_pairs.values()),
|
||||
'pair': ws_pairs,
|
||||
'subscription': {
|
||||
'name': 'ohlc',
|
||||
'interval': 1,
|
||||
|
@ -348,7 +367,7 @@ async def stream_quotes(
|
|||
# trade data (aka L1)
|
||||
l1_sub = {
|
||||
'event': 'subscribe',
|
||||
'pair': list(ws_pairs.values()),
|
||||
'pair': ws_pairs,
|
||||
'subscription': {
|
||||
'name': 'spread',
|
||||
# 'depth': 10}
|
||||
|
@ -363,7 +382,7 @@ async def stream_quotes(
|
|||
# unsub from all pairs on teardown
|
||||
if ws.connected():
|
||||
await ws.send_msg({
|
||||
'pair': list(ws_pairs.values()),
|
||||
'pair': ws_pairs,
|
||||
'event': 'unsubscribe',
|
||||
'subscription': ['ohlc', 'spread'],
|
||||
})
|
||||
|
@ -378,21 +397,20 @@ async def stream_quotes(
|
|||
open_autorecon_ws(
|
||||
'wss://ws.kraken.com/',
|
||||
fixture=subscribe,
|
||||
reset_after=20,
|
||||
) as ws,
|
||||
|
||||
# avoid stream-gen closure from breaking trio..
|
||||
# NOTE: not sure this actually works XD particularly
|
||||
# if we call `ws._connect()` manally in the streaming
|
||||
# async gen..
|
||||
process_data_feed_msgs(ws) as msg_gen,
|
||||
aclosing(process_data_feed_msgs(ws)) as msg_gen,
|
||||
):
|
||||
# pull a first quote and deliver
|
||||
typ, ohlc_last = await anext(msg_gen)
|
||||
topic, quote = normalize(ohlc_last)
|
||||
|
||||
task_status.started((init_msgs, quote))
|
||||
|
||||
# lol, only "closes" when they're margin squeezing clients ;P
|
||||
feed_is_live.set()
|
||||
|
||||
# keep start of last interval for volume tracking
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
# Copyright (C) Jared Goldman (in stewardship for pikers)
|
||||
# Copyright (C) (in stewardship for pikers)
|
||||
# - Jared Goldman
|
||||
# - Tyler Goodlet
|
||||
|
||||
# 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
|
||||
|
@ -18,34 +20,54 @@ Kucoin broker backend
|
|||
|
||||
'''
|
||||
|
||||
from typing import Any, Callable, Literal, AsyncGenerator
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from contextlib import (
|
||||
asynccontextmanager as acm,
|
||||
aclosing,
|
||||
)
|
||||
from datetime import datetime
|
||||
import time
|
||||
from decimal import Decimal
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from functools import partial
|
||||
from pprint import pformat
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Literal,
|
||||
AsyncGenerator,
|
||||
)
|
||||
import wsproto
|
||||
from uuid import uuid4
|
||||
|
||||
from fuzzywuzzy import process as fuzzy
|
||||
from trio_typing import TaskStatus
|
||||
import asks
|
||||
from bidict import bidict
|
||||
import numpy as np
|
||||
import pendulum
|
||||
import tractor
|
||||
import trio
|
||||
from trio_util import trio_async_generator
|
||||
from trio_typing import TaskStatus
|
||||
from fuzzywuzzy import process as fuzzy
|
||||
import pendulum
|
||||
import numpy as np
|
||||
|
||||
from piker._cacheables import open_cached_client
|
||||
from piker.accounting._mktinfo import (
|
||||
Asset,
|
||||
digits_to_dec,
|
||||
MktPair,
|
||||
)
|
||||
from piker.data.validate import FeedInit
|
||||
from piker import config
|
||||
from piker._cacheables import (
|
||||
open_cached_client,
|
||||
async_lifo_cache,
|
||||
)
|
||||
from piker.log import get_logger
|
||||
from ._util import DataUnavailable
|
||||
from piker.pp import config
|
||||
from ..data.types import Struct
|
||||
from ..data._web_bs import (
|
||||
from piker.data.types import Struct
|
||||
from piker.data._web_bs import (
|
||||
open_autorecon_ws,
|
||||
NoBsWs,
|
||||
)
|
||||
from ._util import DataUnavailable
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
@ -67,11 +89,20 @@ class KucoinMktPair(Struct, frozen=True):
|
|||
https://docs.kucoin.com/#get-symbols-list
|
||||
|
||||
'''
|
||||
|
||||
baseCurrency: str
|
||||
baseIncrement: float
|
||||
|
||||
@property
|
||||
def price_tick(self) -> Decimal:
|
||||
return Decimal(str(self.baseIncrement))
|
||||
|
||||
baseMaxSize: float
|
||||
baseMinSize: float
|
||||
|
||||
@property
|
||||
def size_tick(self) -> Decimal:
|
||||
return Decimal(str(self.baseMinSize))
|
||||
|
||||
enableTrading: bool
|
||||
feeCurrency: str
|
||||
isMarginEnabled: bool
|
||||
|
@ -84,7 +115,7 @@ class KucoinMktPair(Struct, frozen=True):
|
|||
quoteIncrement: float
|
||||
quoteMaxSize: float
|
||||
quoteMinSize: float
|
||||
symbol: str
|
||||
symbol: str # our bs_mktid, kucoin's internal id
|
||||
|
||||
|
||||
class AccountTrade(Struct, frozen=True):
|
||||
|
@ -93,7 +124,6 @@ class AccountTrade(Struct, frozen=True):
|
|||
https://docs.kucoin.com/#get-account-ledgers
|
||||
|
||||
'''
|
||||
|
||||
id: str
|
||||
currency: str
|
||||
amount: float
|
||||
|
@ -111,7 +141,6 @@ class AccountResponse(Struct, frozen=True):
|
|||
https://docs.kucoin.com/#get-account-ledgers
|
||||
|
||||
'''
|
||||
|
||||
currentPage: int
|
||||
pageSize: int
|
||||
totalNum: int
|
||||
|
@ -125,7 +154,6 @@ class KucoinTrade(Struct, frozen=True):
|
|||
https://docs.kucoin.com/#symbol-ticker
|
||||
|
||||
'''
|
||||
|
||||
bestAsk: float
|
||||
bestAskSize: float
|
||||
bestBid: float
|
||||
|
@ -148,16 +176,24 @@ class KucoinL2(Struct, frozen=True):
|
|||
timestamp: float
|
||||
|
||||
|
||||
class KucoinMsg(Struct, frozen=True):
|
||||
class Currency(Struct, frozen=True):
|
||||
'''
|
||||
Generic outer-wrapper for any Kucoin ws msg
|
||||
Currency (asset) info:
|
||||
https://docs.kucoin.com/#get-currencies
|
||||
|
||||
'''
|
||||
|
||||
type: str
|
||||
topic: str
|
||||
subject: str
|
||||
data: list[KucoinTrade | KucoinL2]
|
||||
currency: str
|
||||
name: str
|
||||
fullName: str
|
||||
precision: int
|
||||
confirms: int
|
||||
contractAddress: str
|
||||
withdrawalMinSize: str
|
||||
withdrawalMinFee: str
|
||||
isWithdrawEnabled: bool
|
||||
isDepositEnabled: bool
|
||||
isMarginEnabled: bool
|
||||
isDebitEnabled: bool
|
||||
|
||||
|
||||
class BrokerConfig(Struct, frozen=True):
|
||||
|
@ -180,15 +216,18 @@ def get_config() -> BrokerConfig | None:
|
|||
|
||||
class Client:
|
||||
def __init__(self) -> None:
|
||||
self._pairs: dict[str, KucoinMktPair] = {}
|
||||
self._bars: list[list[float]] = []
|
||||
self._config: BrokerConfig | None = get_config()
|
||||
self._pairs: dict[str, KucoinMktPair] = {}
|
||||
self._fqmes2mktids: bidict[str, str] = bidict()
|
||||
self._bars: list[list[float]] = []
|
||||
self._currencies: dict[str, Currency] = {}
|
||||
|
||||
def _gen_auth_req_headers(
|
||||
self,
|
||||
action: Literal['POST', 'GET'],
|
||||
endpoint: str,
|
||||
api_v: str = 'v2',
|
||||
api: str = 'v2',
|
||||
|
||||
) -> dict[str, str | bytes]:
|
||||
'''
|
||||
Generate authenticated request headers
|
||||
|
@ -202,7 +241,7 @@ class Client:
|
|||
|
||||
str_to_sign = (
|
||||
str(int(time.time() * 1000))
|
||||
+ action + f'/api/{api_v}{endpoint}'
|
||||
+ action + f'/api/{api}/{endpoint.lstrip("/")}'
|
||||
)
|
||||
|
||||
signature = base64.b64encode(
|
||||
|
@ -234,7 +273,7 @@ class Client:
|
|||
self,
|
||||
action: Literal['POST', 'GET'],
|
||||
endpoint: str,
|
||||
api_v: str = 'v2',
|
||||
api: str = 'v2',
|
||||
headers: dict = {},
|
||||
) -> Any:
|
||||
'''
|
||||
|
@ -243,19 +282,24 @@ class Client:
|
|||
'''
|
||||
if self._config:
|
||||
headers = self._gen_auth_req_headers(
|
||||
action, endpoint, api_v)
|
||||
action,
|
||||
endpoint,
|
||||
api,
|
||||
)
|
||||
|
||||
api_url = f'https://api.kucoin.com/api/{api_v}{endpoint}'
|
||||
api_url = f'https://api.kucoin.com/api/{api}/{endpoint}'
|
||||
|
||||
res = await asks.request(action, api_url, headers=headers)
|
||||
|
||||
if 'data' in res.json():
|
||||
return res.json()['data']
|
||||
json = res.json()
|
||||
if 'data' in json:
|
||||
return json['data']
|
||||
else:
|
||||
log.error(
|
||||
f'Error making request to {api_url} -> {res.json()["msg"]}'
|
||||
f'Error making request to {api_url} ->\n'
|
||||
f'{pformat(res)}'
|
||||
)
|
||||
return res.json()['msg']
|
||||
return json['msg']
|
||||
|
||||
async def _get_ws_token(
|
||||
self,
|
||||
|
@ -271,7 +315,9 @@ class Client:
|
|||
token_type = 'private' if private else 'public'
|
||||
try:
|
||||
data: dict[str, Any] | None = await self._request(
|
||||
'POST', f'/bullet-{token_type}', 'v1'
|
||||
'POST',
|
||||
endpoint=f'bullet-{token_type}',
|
||||
api='v1'
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
|
@ -288,27 +334,72 @@ class Client:
|
|||
f'{data.json()["msg"]}'
|
||||
)
|
||||
|
||||
async def get_currencies(
|
||||
self,
|
||||
update: bool = False,
|
||||
) -> dict[str, Currency]:
|
||||
'''
|
||||
Retrieve all "currency" info:
|
||||
https://docs.kucoin.com/#get-currencies
|
||||
|
||||
We use this for creating piker-interal ``Asset``s.
|
||||
|
||||
'''
|
||||
if (
|
||||
not self._currencies
|
||||
or update
|
||||
):
|
||||
currencies: dict[str, Currency] = {}
|
||||
entries: list[dict] = await self._request(
|
||||
'GET',
|
||||
api='v1',
|
||||
endpoint='currencies',
|
||||
)
|
||||
for entry in entries:
|
||||
curr = Currency(**entry).copy()
|
||||
currencies[curr.name] = curr
|
||||
|
||||
self._currencies.update(currencies)
|
||||
|
||||
return self._currencies
|
||||
|
||||
async def _get_pairs(
|
||||
self,
|
||||
) -> dict[str, KucoinMktPair]:
|
||||
entries = await self._request('GET', '/symbols')
|
||||
syms = {
|
||||
kucoin_sym_to_fqsn(item['name']): KucoinMktPair(**item)
|
||||
for item in entries
|
||||
}
|
||||
) -> tuple[
|
||||
dict[str, KucoinMktPair],
|
||||
bidict[str, KucoinMktPair],
|
||||
]:
|
||||
entries = await self._request('GET', 'symbols')
|
||||
log.info(f' {len(entries)} Kucoin market pairs fetched')
|
||||
|
||||
log.info(f' {len(syms)} Kucoin market pairs fetched')
|
||||
return syms
|
||||
pairs: dict[str, KucoinMktPair] = {}
|
||||
fqmes2mktids: bidict[str, str] = bidict()
|
||||
for item in entries:
|
||||
pair = pairs[item['name']] = KucoinMktPair(**item)
|
||||
fqmes2mktids[
|
||||
item['name'].lower().replace('-', '')
|
||||
] = pair.name
|
||||
|
||||
return pairs, fqmes2mktids
|
||||
|
||||
async def cache_pairs(
|
||||
self,
|
||||
update: bool = False,
|
||||
|
||||
) -> dict[str, KucoinMktPair]:
|
||||
'''
|
||||
Get cached pairs and convert keyed symbols into fqsns if ya want
|
||||
Get request all market pairs and store in a local cache.
|
||||
|
||||
Also create a table of piker style fqme -> kucoin symbols.
|
||||
|
||||
'''
|
||||
if not self._pairs:
|
||||
self._pairs = await self._get_pairs()
|
||||
if (
|
||||
not self._pairs
|
||||
or update
|
||||
):
|
||||
pairs, fqmes = await self._get_pairs()
|
||||
self._pairs.update(pairs)
|
||||
self._fqmes2mktids.update(fqmes)
|
||||
|
||||
return self._pairs
|
||||
|
||||
|
@ -316,7 +407,12 @@ class Client:
|
|||
self,
|
||||
pattern: str,
|
||||
limit: int = 30,
|
||||
|
||||
) -> dict[str, KucoinMktPair]:
|
||||
'''
|
||||
Use fuzzy search to match against all market names.
|
||||
|
||||
'''
|
||||
data = await self.cache_pairs()
|
||||
|
||||
matches = fuzzy.extractBests(
|
||||
|
@ -327,19 +423,23 @@ class Client:
|
|||
|
||||
async def last_trades(self, sym: str) -> list[AccountTrade]:
|
||||
trades = await self._request(
|
||||
'GET', f'/accounts/ledgers?currency={sym}', 'v1'
|
||||
'GET',
|
||||
endpoint=f'accounts/ledgers?currency={sym}',
|
||||
api='v1'
|
||||
)
|
||||
trades = AccountResponse(**trades)
|
||||
return trades.items
|
||||
|
||||
async def _get_bars(
|
||||
self,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
|
||||
start_dt: datetime | None = None,
|
||||
end_dt: datetime | None = None,
|
||||
limit: int = 1000,
|
||||
as_np: bool = True,
|
||||
type: str = '1min',
|
||||
|
||||
) -> np.ndarray:
|
||||
'''
|
||||
Get OHLC data and convert to numpy array for perffff:
|
||||
|
@ -381,10 +481,10 @@ class Client:
|
|||
start_dt = int(start_dt.timestamp())
|
||||
end_dt = int(end_dt.timestamp())
|
||||
|
||||
kucoin_sym = fqsn_to_kucoin_sym(fqsn, self._pairs)
|
||||
kucoin_sym = self._fqmes2mktids[fqme]
|
||||
|
||||
url = (
|
||||
f'/market/candles?type={type}'
|
||||
f'market/candles?type={type}'
|
||||
f'&symbol={kucoin_sym}'
|
||||
f'&startAt={start_dt}'
|
||||
f'&endAt={end_dt}'
|
||||
|
@ -394,7 +494,7 @@ class Client:
|
|||
data: list[list[str]] | dict = await self._request(
|
||||
'GET',
|
||||
url,
|
||||
api_v='v1',
|
||||
api='v1',
|
||||
)
|
||||
|
||||
if not isinstance(data, list):
|
||||
|
@ -439,19 +539,22 @@ class Client:
|
|||
return array
|
||||
|
||||
|
||||
def fqsn_to_kucoin_sym(fqsn: str, pairs: dict[str, KucoinMktPair]) -> str:
|
||||
pair_data = pairs[fqsn]
|
||||
def fqme_to_kucoin_sym(
|
||||
fqme: str,
|
||||
pairs: dict[str, KucoinMktPair],
|
||||
|
||||
) -> str:
|
||||
pair_data = pairs[fqme]
|
||||
return pair_data.baseCurrency + '-' + pair_data.quoteCurrency
|
||||
|
||||
|
||||
def kucoin_sym_to_fqsn(sym: str) -> str:
|
||||
return sym.lower().replace('-', '')
|
||||
|
||||
|
||||
@acm
|
||||
async def get_client() -> AsyncGenerator[Client, None]:
|
||||
client = Client()
|
||||
await client.cache_pairs()
|
||||
|
||||
async with trio.open_nursery() as n:
|
||||
n.start_soon(client.cache_pairs)
|
||||
await client.get_currencies()
|
||||
|
||||
yield client
|
||||
|
||||
|
@ -497,195 +600,268 @@ async def open_ping_task(
|
|||
n.cancel_scope.cancel()
|
||||
|
||||
|
||||
@async_lifo_cache()
|
||||
async def get_mkt_info(
|
||||
fqme: str,
|
||||
|
||||
) -> tuple[MktPair, KucoinMktPair]:
|
||||
'''
|
||||
Query for and return a `MktPair` and `KucoinMktPair`.
|
||||
|
||||
'''
|
||||
async with open_cached_client('kucoin') as client:
|
||||
# split off any fqme broker part
|
||||
bs_fqme, _, broker = fqme.partition('.')
|
||||
|
||||
pairs: dict[str, KucoinMktPair] = await client.cache_pairs()
|
||||
|
||||
try:
|
||||
# likely search result key which is already in native mkt symbol form
|
||||
pair: KucoinMktPair = pairs[bs_fqme]
|
||||
bs_mktid: str = bs_fqme
|
||||
|
||||
except KeyError:
|
||||
|
||||
# likely a piker-style fqme from API request or CLI
|
||||
bs_mktid: str = client._fqmes2mktids[bs_fqme]
|
||||
pair: KucoinMktPair = pairs[bs_mktid]
|
||||
|
||||
# symbology sanity
|
||||
assert bs_mktid == pair.symbol
|
||||
|
||||
assets: dict[str, Currency] = client._currencies
|
||||
|
||||
# TODO: maybe just do this processing in
|
||||
# a .get_assets() method (see kraken)?
|
||||
src: Currency = assets[pair.quoteCurrency]
|
||||
src_asset = Asset(
|
||||
name=src.name,
|
||||
atype='crypto_currency',
|
||||
tx_tick=digits_to_dec(src.precision),
|
||||
info=src.to_dict(),
|
||||
)
|
||||
dst: Currency = assets[pair.baseCurrency]
|
||||
dst_asset = Asset(
|
||||
name=dst.name,
|
||||
atype='crypto_currency',
|
||||
tx_tick=digits_to_dec(dst.precision),
|
||||
info=dst.to_dict(),
|
||||
)
|
||||
mkt = MktPair(
|
||||
dst=dst_asset,
|
||||
src=src_asset,
|
||||
|
||||
price_tick=pair.price_tick,
|
||||
size_tick=pair.size_tick,
|
||||
bs_mktid=bs_mktid,
|
||||
|
||||
broker='kucoin',
|
||||
)
|
||||
return mkt, pair
|
||||
|
||||
|
||||
async def stream_quotes(
|
||||
send_chan: trio.abc.SendChannel,
|
||||
symbols: list[str],
|
||||
feed_is_live: trio.Event,
|
||||
loglevel: str = '',
|
||||
# startup sync
|
||||
task_status: TaskStatus[tuple[dict, dict]
|
||||
] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
task_status: TaskStatus[
|
||||
tuple[dict, dict]
|
||||
] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Required piker api to stream real-time data.
|
||||
Where the rubber hits the road baby
|
||||
|
||||
'''
|
||||
init_msgs: list[FeedInit] = []
|
||||
|
||||
async with open_cached_client('kucoin') as client:
|
||||
|
||||
log.info(f'Starting up quote stream(s) for {symbols}')
|
||||
for sym_str in symbols:
|
||||
mkt, pair = await get_mkt_info(sym_str)
|
||||
init_msgs.append(
|
||||
FeedInit(mkt_info=mkt)
|
||||
)
|
||||
|
||||
ws: NoBsWs
|
||||
token, ping_interval = await client._get_ws_token()
|
||||
connect_id = str(uuid4())
|
||||
pairs = await client.cache_pairs()
|
||||
ws_url = (
|
||||
f'wss://ws-api-spot.kucoin.com/?'
|
||||
f'token={token}&[connectId={connect_id}]'
|
||||
)
|
||||
|
||||
# open ping task
|
||||
async with (
|
||||
open_autorecon_ws(ws_url) as ws,
|
||||
open_autorecon_ws(
|
||||
(
|
||||
f'wss://ws-api-spot.kucoin.com/?'
|
||||
f'token={token}&[connectId={connect_id}]'
|
||||
),
|
||||
fixture=partial(
|
||||
subscribe,
|
||||
connect_id=connect_id,
|
||||
bs_mktid=pair.symbol,
|
||||
),
|
||||
) as ws,
|
||||
open_ping_task(ws, ping_interval, connect_id),
|
||||
aclosing(stream_messages(ws, sym_str)) as msg_gen,
|
||||
):
|
||||
log.info('Starting up quote stream')
|
||||
# loop through symbols and sub to feedz
|
||||
for sym in symbols:
|
||||
pair: KucoinMktPair = pairs[sym]
|
||||
kucoin_sym = pair.symbol
|
||||
typ, quote = await anext(msg_gen)
|
||||
|
||||
init_msgs = {
|
||||
# pass back token, and bool, signalling if we're the writer
|
||||
# and that history has been written
|
||||
sym: {
|
||||
'symbol_info': {
|
||||
'asset_type': 'crypto',
|
||||
'price_tick_size': float(pair.baseIncrement),
|
||||
'lot_tick_size': float(pair.baseMinSize),
|
||||
},
|
||||
'shm_write_opts': {'sum_tick_vml': False},
|
||||
'fqsn': sym,
|
||||
}
|
||||
}
|
||||
while typ != 'trade':
|
||||
# take care to not unblock here until we get a real
|
||||
# trade quote
|
||||
typ, quote = await anext(msg_gen)
|
||||
|
||||
async with (
|
||||
subscribe(ws, connect_id, kucoin_sym),
|
||||
stream_messages(ws, sym) as msg_gen,
|
||||
):
|
||||
typ, quote = await anext(msg_gen)
|
||||
while typ != 'trade':
|
||||
# take care to not unblock here until we get a real
|
||||
# trade quote
|
||||
typ, quote = await anext(msg_gen)
|
||||
task_status.started((init_msgs, quote))
|
||||
feed_is_live.set()
|
||||
|
||||
task_status.started((init_msgs, quote))
|
||||
feed_is_live.set()
|
||||
|
||||
async for typ, msg in msg_gen:
|
||||
await send_chan.send({sym: msg})
|
||||
async for typ, msg in msg_gen:
|
||||
await send_chan.send({sym_str: msg})
|
||||
|
||||
|
||||
@acm
|
||||
async def subscribe(ws: wsproto.WSConnection, connect_id, sym) -> AsyncGenerator[None, None]:
|
||||
# level 2 sub
|
||||
await ws.send_msg(
|
||||
{
|
||||
'id': connect_id,
|
||||
'type': 'subscribe',
|
||||
'topic': f'/spotMarket/level2Depth5:{sym}',
|
||||
'privateChannel': False,
|
||||
'response': True,
|
||||
}
|
||||
)
|
||||
async def subscribe(
|
||||
ws: NoBsWs,
|
||||
connect_id,
|
||||
bs_mktid,
|
||||
|
||||
# watch trades
|
||||
await ws.send_msg(
|
||||
{
|
||||
'id': connect_id,
|
||||
'type': 'subscribe',
|
||||
'topic': f'/market/ticker:{sym}',
|
||||
'privateChannel': False,
|
||||
'response': True,
|
||||
}
|
||||
)
|
||||
# subs are filled in with `bs_mktid` from avbove
|
||||
topics: list[str] = [
|
||||
'/market/ticker:{bs_mktid}', # clearing events
|
||||
'/spotMarket/level2Depth5:{bs_mktid}', # level 2
|
||||
],
|
||||
|
||||
yield
|
||||
) -> AsyncGenerator[None, None]:
|
||||
|
||||
# unsub
|
||||
if ws.connected():
|
||||
log.info(f'Unsubscribing to {sym} feed')
|
||||
eps: list[str] = []
|
||||
for topic in topics:
|
||||
ep: str = topic.format(bs_mktid=bs_mktid)
|
||||
eps.append(ep)
|
||||
await ws.send_msg(
|
||||
{
|
||||
'id': connect_id,
|
||||
'type': 'unsubscribe',
|
||||
'topic': f'/market/ticker:{sym}',
|
||||
'type': 'subscribe',
|
||||
'topic': ep,
|
||||
'privateChannel': False,
|
||||
'response': True,
|
||||
}
|
||||
)
|
||||
|
||||
welcome_msg = await ws.recv_msg()
|
||||
log.info(f'WS welcome: {welcome_msg}')
|
||||
|
||||
for _ in topics:
|
||||
ack_msg = await ws.recv_msg()
|
||||
log.info(f'Sub ACK: {ack_msg}')
|
||||
|
||||
yield
|
||||
|
||||
# unsub
|
||||
if ws.connected():
|
||||
log.info(f'Unsubscribing to {bs_mktid} feed')
|
||||
for ep in eps:
|
||||
await ws.send_msg(
|
||||
{
|
||||
'id': connect_id,
|
||||
'type': 'unsubscribe',
|
||||
'topic': ep,
|
||||
'privateChannel': False,
|
||||
'response': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@trio_async_generator
|
||||
async def stream_messages(
|
||||
ws: NoBsWs, sym: str
|
||||
ws: NoBsWs,
|
||||
sym: str,
|
||||
|
||||
) -> AsyncGenerator[tuple[str, dict], None]:
|
||||
timeouts = 0
|
||||
last_trade_ts = 0
|
||||
'''
|
||||
Core (live) feed msg handler: relay market events
|
||||
to the piker-ized tick-stream format.
|
||||
|
||||
while True:
|
||||
with trio.move_on_after(3) as cs:
|
||||
msg = await ws.recv_msg()
|
||||
if cs.cancelled_caught:
|
||||
timeouts += 1
|
||||
if timeouts > 2:
|
||||
log.error(
|
||||
'kucoin feed is sh**ing the bed... rebooting...')
|
||||
await ws._connect()
|
||||
'''
|
||||
last_trade_ts: float = 0
|
||||
|
||||
continue
|
||||
if msg.get('subject'):
|
||||
msg = KucoinMsg(**msg)
|
||||
match msg.subject:
|
||||
case 'trade.ticker':
|
||||
trade_data = KucoinTrade(**msg.data)
|
||||
dict_msg: dict[str, Any]
|
||||
async for dict_msg in ws:
|
||||
match dict_msg:
|
||||
case {
|
||||
'subject': 'trade.ticker',
|
||||
'data': trade_data_dict,
|
||||
}:
|
||||
trade_data = KucoinTrade(**trade_data_dict)
|
||||
|
||||
# XXX: Filter for duplicate messages as ws feed will
|
||||
# send duplicate market state
|
||||
# https://docs.kucoin.com/#level2-5-best-ask-bid-orders
|
||||
if trade_data.time == last_trade_ts:
|
||||
continue
|
||||
# XXX: Filter out duplicate messages as ws feed will
|
||||
# send duplicate market state
|
||||
# https://docs.kucoin.com/#level2-5-best-ask-bid-orders
|
||||
if trade_data.time == last_trade_ts:
|
||||
continue
|
||||
|
||||
last_trade_ts = trade_data.time
|
||||
last_trade_ts = trade_data.time
|
||||
|
||||
yield 'trade', {
|
||||
'symbol': sym,
|
||||
'last': trade_data.price,
|
||||
'brokerd_ts': last_trade_ts,
|
||||
'ticks': [
|
||||
{
|
||||
'type': 'trade',
|
||||
'price': float(trade_data.price),
|
||||
'size': float(trade_data.size),
|
||||
'broker_ts': last_trade_ts,
|
||||
}
|
||||
],
|
||||
}
|
||||
yield 'trade', {
|
||||
'symbol': sym,
|
||||
'last': trade_data.price,
|
||||
'brokerd_ts': last_trade_ts,
|
||||
'ticks': [
|
||||
{
|
||||
'type': 'trade',
|
||||
'price': float(trade_data.price),
|
||||
'size': float(trade_data.size),
|
||||
'broker_ts': last_trade_ts,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
case 'level2':
|
||||
l2_data = KucoinL2(**msg.data)
|
||||
first_ask = l2_data.asks[0]
|
||||
first_bid = l2_data.bids[0]
|
||||
yield 'l1', {
|
||||
'symbol': sym,
|
||||
'ticks': [
|
||||
{
|
||||
'type': 'bid',
|
||||
'price': float(first_bid[0]),
|
||||
'size': float(first_bid[1]),
|
||||
},
|
||||
{
|
||||
'type': 'bsize',
|
||||
'price': float(first_bid[0]),
|
||||
'size': float(first_bid[1]),
|
||||
},
|
||||
{
|
||||
'type': 'ask',
|
||||
'price': float(first_ask[0]),
|
||||
'size': float(first_ask[1]),
|
||||
},
|
||||
{
|
||||
'type': 'asize',
|
||||
'price': float(first_ask[0]),
|
||||
'size': float(first_ask[1]),
|
||||
},
|
||||
],
|
||||
}
|
||||
case {
|
||||
'subject': 'level2',
|
||||
'data': trade_data_dict,
|
||||
}:
|
||||
l2_data = KucoinL2(**trade_data_dict)
|
||||
first_ask = l2_data.asks[0]
|
||||
first_bid = l2_data.bids[0]
|
||||
yield 'l1', {
|
||||
'symbol': sym,
|
||||
'ticks': [
|
||||
{
|
||||
'type': 'bid',
|
||||
'price': float(first_bid[0]),
|
||||
'size': float(first_bid[1]),
|
||||
},
|
||||
{
|
||||
'type': 'bsize',
|
||||
'price': float(first_bid[0]),
|
||||
'size': float(first_bid[1]),
|
||||
},
|
||||
{
|
||||
'type': 'ask',
|
||||
'price': float(first_ask[0]),
|
||||
'size': float(first_ask[1]),
|
||||
},
|
||||
{
|
||||
'type': 'asize',
|
||||
'price': float(first_ask[0]),
|
||||
'size': float(first_ask[1]),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
case _:
|
||||
log.warn(f'Unhandled message: {msg}')
|
||||
case {'type': 'pong'}:
|
||||
# resp to ping task req
|
||||
continue
|
||||
|
||||
case _:
|
||||
log.warn(f'Unhandled message: {dict_msg}')
|
||||
|
||||
|
||||
@acm
|
||||
async def open_history_client(
|
||||
symbol: str,
|
||||
mkt: MktPair,
|
||||
|
||||
) -> AsyncGenerator[Callable, None]:
|
||||
|
||||
symbol: str = mkt.bs_fqme
|
||||
|
||||
async with open_cached_client('kucoin') as client:
|
||||
log.info('Attempting to open kucoin history client')
|
||||
|
||||
|
@ -709,6 +885,11 @@ async def open_history_client(
|
|||
|
||||
times = array['time']
|
||||
|
||||
if not len(times):
|
||||
raise DataUnavailable(
|
||||
f'No more history before {start_dt}?'
|
||||
)
|
||||
|
||||
if end_dt is None:
|
||||
inow = round(time.time())
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# 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
|
||||
|
@ -43,10 +43,13 @@ from ..calc import humanize, percent_change
|
|||
from .._cacheables import open_cached_client, async_lifo_cache
|
||||
from .. import config
|
||||
from ._util import resproc, BrokerError, SymbolNotFound
|
||||
from ..log import get_logger, colorize_json, get_console_log
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
from ..log import (
|
||||
colorize_json,
|
||||
)
|
||||
from ._util import (
|
||||
log,
|
||||
get_console_log,
|
||||
)
|
||||
|
||||
_use_practice_account = False
|
||||
_refresh_token_ep = 'https://{}login.questrade.com/oauth2/'
|
||||
|
|
|
@ -27,12 +27,13 @@ from typing import List
|
|||
from async_generator import asynccontextmanager
|
||||
import asks
|
||||
|
||||
from ..log import get_logger
|
||||
from ._util import resproc, BrokerError
|
||||
from ._util import (
|
||||
resproc,
|
||||
BrokerError,
|
||||
log,
|
||||
)
|
||||
from ..calc import percent_change
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_service_ep = 'https://api.robinhood.com'
|
||||
|
||||
|
||||
|
@ -65,8 +66,10 @@ class Client:
|
|||
self.api = _API(self._sess)
|
||||
|
||||
def _zip_in_order(self, symbols: [str], quotes: List[dict]):
|
||||
return {quote.get('symbol', sym) if quote else sym: quote
|
||||
for sym, quote in zip(symbols, results_dict)}
|
||||
return {
|
||||
quote.get('symbol', sym) if quote else sym: quote
|
||||
for sym, quote in zip(symbols, quotes)
|
||||
}
|
||||
|
||||
async def quote(self, symbols: [str]):
|
||||
"""Retrieve quotes for a list of ``symbols``.
|
||||
|
|
|
@ -18,9 +18,17 @@
|
|||
Market machinery for order executions, book, management.
|
||||
|
||||
"""
|
||||
from ._client import open_ems
|
||||
from ..log import get_logger
|
||||
from ._client import (
|
||||
open_ems,
|
||||
OrderClient,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'open_ems',
|
||||
'OrderClient',
|
||||
|
||||
]
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
# 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
|
||||
|
@ -27,68 +27,104 @@ import trio
|
|||
import tractor
|
||||
from tractor.trionics import broadcast_receiver
|
||||
|
||||
from ..log import get_logger
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
)
|
||||
from ..data.types import Struct
|
||||
from ..service import maybe_open_emsd
|
||||
from ._messages import (
|
||||
Order,
|
||||
Cancel,
|
||||
BrokerdPosition,
|
||||
)
|
||||
from ..brokers import get_brokermod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._messages import (
|
||||
BrokerdPosition,
|
||||
Status,
|
||||
)
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
class OrderClient(Struct):
|
||||
'''
|
||||
EMS-client-side order book ctl and tracking.
|
||||
|
||||
|
||||
class OrderBook(Struct):
|
||||
'''EMS-client-side order book ctl and tracking.
|
||||
|
||||
A style similar to "model-view" is used here where this api is
|
||||
provided as a supervised control for an EMS actor which does all the
|
||||
hard/fast work of talking to brokers/exchanges to conduct
|
||||
executions.
|
||||
|
||||
Currently, this is mostly for keeping local state to match the EMS
|
||||
and use received events to trigger graphics updates.
|
||||
(A)sync API for submitting orders and alerts to the `emsd` service;
|
||||
this is the main control for execution management from client code.
|
||||
|
||||
'''
|
||||
# IPC stream to `emsd` actor
|
||||
_ems_stream: tractor.MsgStream
|
||||
|
||||
# mem channels used to relay order requests to the EMS daemon
|
||||
_to_ems: trio.abc.SendChannel
|
||||
_from_order_book: trio.abc.ReceiveChannel
|
||||
_to_relay_task: trio.abc.SendChannel
|
||||
_from_sync_order_client: trio.abc.ReceiveChannel
|
||||
|
||||
# history table
|
||||
_sent_orders: dict[str, Order] = {}
|
||||
|
||||
def send(
|
||||
def send_nowait(
|
||||
self,
|
||||
msg: Order | dict,
|
||||
|
||||
) -> dict:
|
||||
) -> dict | Order:
|
||||
'''
|
||||
Sync version of ``.send()``.
|
||||
|
||||
'''
|
||||
self._sent_orders[msg.oid] = msg
|
||||
self._to_ems.send_nowait(msg)
|
||||
self._to_relay_task.send_nowait(msg)
|
||||
return msg
|
||||
|
||||
def send_update(
|
||||
async def send(
|
||||
self,
|
||||
msg: Order | dict,
|
||||
|
||||
) -> dict | Order:
|
||||
'''
|
||||
Send a new order msg async to the `emsd` service.
|
||||
|
||||
'''
|
||||
self._sent_orders[msg.oid] = msg
|
||||
await self._ems_stream.send(msg)
|
||||
return msg
|
||||
|
||||
def update_nowait(
|
||||
self,
|
||||
uuid: str,
|
||||
**data: dict,
|
||||
|
||||
) -> dict:
|
||||
'''
|
||||
Sync version of ``.update()``.
|
||||
|
||||
'''
|
||||
cmd = self._sent_orders[uuid]
|
||||
msg = cmd.copy(update=data)
|
||||
self._sent_orders[uuid] = msg
|
||||
self._to_ems.send_nowait(msg)
|
||||
return cmd
|
||||
self._to_relay_task.send_nowait(msg)
|
||||
return msg
|
||||
|
||||
def cancel(self, uuid: str) -> bool:
|
||||
"""Cancel an order (or alert) in the EMS.
|
||||
async def update(
|
||||
self,
|
||||
uuid: str,
|
||||
**data: dict,
|
||||
) -> dict:
|
||||
'''
|
||||
Update an existing order dialog with a msg updated from
|
||||
``update`` kwargs.
|
||||
|
||||
"""
|
||||
'''
|
||||
cmd = self._sent_orders[uuid]
|
||||
msg = cmd.copy(update=data)
|
||||
self._sent_orders[uuid] = msg
|
||||
await self._ems_stream.send(msg)
|
||||
return msg
|
||||
|
||||
def _mk_cancel_msg(
|
||||
self,
|
||||
uuid: str,
|
||||
) -> Cancel:
|
||||
cmd = self._sent_orders.get(uuid)
|
||||
if not cmd:
|
||||
log.error(
|
||||
|
@ -96,77 +132,75 @@ class OrderBook(Struct):
|
|||
f'Maybe there is a stale entry or line?\n'
|
||||
f'You should report this as a bug!'
|
||||
)
|
||||
msg = Cancel(
|
||||
fqme = str(cmd.symbol)
|
||||
return Cancel(
|
||||
oid=uuid,
|
||||
symbol=cmd.symbol,
|
||||
)
|
||||
self._to_ems.send_nowait(msg)
|
||||
|
||||
|
||||
_orders: OrderBook = None
|
||||
|
||||
|
||||
def get_orders(
|
||||
emsd_uid: tuple[str, str] = None
|
||||
) -> OrderBook:
|
||||
""""
|
||||
OrderBook singleton factory per actor.
|
||||
|
||||
"""
|
||||
if emsd_uid is not None:
|
||||
# TODO: read in target emsd's active book on startup
|
||||
pass
|
||||
|
||||
global _orders
|
||||
|
||||
if _orders is None:
|
||||
size = 100
|
||||
tx, rx = trio.open_memory_channel(size)
|
||||
brx = broadcast_receiver(rx, size)
|
||||
|
||||
# setup local ui event streaming channels for request/resp
|
||||
# streamging with EMS daemon
|
||||
_orders = OrderBook(
|
||||
_to_ems=tx,
|
||||
_from_order_book=brx,
|
||||
symbol=fqme,
|
||||
)
|
||||
|
||||
return _orders
|
||||
def cancel_nowait(
|
||||
self,
|
||||
uuid: str,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Sync version of ``.cancel()``.
|
||||
|
||||
'''
|
||||
self._to_relay_task.send_nowait(
|
||||
self._mk_cancel_msg(uuid)
|
||||
)
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
uuid: str,
|
||||
|
||||
) -> bool:
|
||||
'''
|
||||
Cancel an already existintg order (or alert) dialog.
|
||||
|
||||
'''
|
||||
await self._ems_stream.send(
|
||||
self._mk_cancel_msg(uuid)
|
||||
)
|
||||
|
||||
|
||||
# TODO: we can get rid of this relay loop once we move
|
||||
# order_mode inputs to async code!
|
||||
async def relay_order_cmds_from_sync_code(
|
||||
|
||||
async def relay_orders_from_sync_code(
|
||||
|
||||
client: OrderClient,
|
||||
symbol_key: str,
|
||||
to_ems_stream: tractor.MsgStream,
|
||||
|
||||
) -> None:
|
||||
"""
|
||||
Order streaming task: deliver orders transmitted from UI
|
||||
to downstream consumers.
|
||||
'''
|
||||
Order submission relay task: deliver orders sent from synchronous (UI)
|
||||
code to the EMS via ``OrderClient._from_sync_order_client``.
|
||||
|
||||
This is run in the UI actor (usually the one running Qt but could be
|
||||
any other client service code). This process simply delivers order
|
||||
messages to the above ``_to_ems`` send channel (from sync code using
|
||||
messages to the above ``_to_relay_task`` send channel (from sync code using
|
||||
``.send_nowait()``), these values are pulled from the channel here
|
||||
and relayed to any consumer(s) that called this function using
|
||||
a ``tractor`` portal.
|
||||
|
||||
This effectively makes order messages look like they're being
|
||||
"pushed" from the parent to the EMS where local sync code is likely
|
||||
doing the pushing from some UI.
|
||||
doing the pushing from some non-async UI handler.
|
||||
|
||||
"""
|
||||
book = get_orders()
|
||||
async with book._from_order_book.subscribe() as orders_stream:
|
||||
async for cmd in orders_stream:
|
||||
'''
|
||||
async with (
|
||||
client._from_sync_order_client.subscribe() as sync_order_cmds
|
||||
):
|
||||
async for cmd in sync_order_cmds:
|
||||
sym = cmd.symbol
|
||||
msg = pformat(cmd)
|
||||
msg = pformat(cmd.to_dict())
|
||||
|
||||
if sym == symbol_key:
|
||||
log.info(f'Send order cmd:\n{msg}')
|
||||
# send msg over IPC / wire
|
||||
await to_ems_stream.send(cmd)
|
||||
|
||||
else:
|
||||
log.warning(
|
||||
f'Ignoring unmatched order cmd for {sym} != {symbol_key}:'
|
||||
|
@ -176,62 +210,39 @@ async def relay_order_cmds_from_sync_code(
|
|||
|
||||
@acm
|
||||
async def open_ems(
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
mode: str = 'live',
|
||||
loglevel: str = 'error',
|
||||
|
||||
) -> tuple[
|
||||
OrderBook,
|
||||
OrderClient,
|
||||
tractor.MsgStream,
|
||||
dict[
|
||||
# brokername, acctid
|
||||
tuple[str, str],
|
||||
list[BrokerdPosition],
|
||||
dict[str, BrokerdPosition],
|
||||
],
|
||||
list[str],
|
||||
dict[str, Status],
|
||||
]:
|
||||
'''
|
||||
Spawn an EMS daemon and begin sending orders and receiving
|
||||
alerts.
|
||||
(Maybe) spawn an EMS-daemon (emsd), deliver an `OrderClient` for
|
||||
requesting orders/alerts and a `trades_stream` which delivers all
|
||||
response-msgs.
|
||||
|
||||
This EMS tries to reduce most broker's terrible order entry apis to
|
||||
a very simple protocol built on a few easy to grok and/or
|
||||
"rantsy" premises:
|
||||
|
||||
- most users will prefer "dark mode" where orders are not submitted
|
||||
to a broker until and execution condition is triggered
|
||||
(aka client-side "hidden orders")
|
||||
|
||||
- Brokers over-complicate their apis and generally speaking hire
|
||||
poor designers to create them. We're better off using creating a super
|
||||
minimal, schema-simple, request-event-stream protocol to unify all the
|
||||
existing piles of shit (and shocker, it'll probably just end up
|
||||
looking like a decent crypto exchange's api)
|
||||
|
||||
- all order types can be implemented with client-side limit orders
|
||||
|
||||
- we aren't reinventing a wheel in this case since none of these
|
||||
brokers are exposing FIX protocol; it is they doing the re-invention.
|
||||
|
||||
|
||||
TODO: make some fancy diagrams using mermaid.io
|
||||
|
||||
the possible set of responses from the stream is currently:
|
||||
- 'dark_submitted', 'broker_submitted'
|
||||
- 'dark_cancelled', 'broker_cancelled'
|
||||
- 'dark_executed', 'broker_executed'
|
||||
- 'broker_filled'
|
||||
This is a "client side" entrypoint which may spawn the `emsd` service
|
||||
if it can't be discovered and generally speaking is the lowest level
|
||||
broker control client-API.
|
||||
|
||||
'''
|
||||
# wait for service to connect back to us signalling
|
||||
# ready for order commands
|
||||
book = get_orders()
|
||||
# TODO: prolly hand in the `MktPair` instance directly here as well!
|
||||
from piker.accounting import unpack_fqme
|
||||
broker, mktep, venue, suffix = unpack_fqme(fqme)
|
||||
|
||||
from ..data._source import unpack_fqsn
|
||||
broker, symbol, suffix = unpack_fqsn(fqsn)
|
||||
|
||||
async with maybe_open_emsd(broker) as portal:
|
||||
async with maybe_open_emsd(
|
||||
broker,
|
||||
loglevel=loglevel,
|
||||
) as portal:
|
||||
|
||||
mod = get_brokermod(broker)
|
||||
if (
|
||||
|
@ -244,9 +255,8 @@ async def open_ems(
|
|||
async with (
|
||||
# connect to emsd
|
||||
portal.open_context(
|
||||
|
||||
_emsd_main,
|
||||
fqsn=fqsn,
|
||||
fqme=fqme,
|
||||
exec_mode=mode,
|
||||
loglevel=loglevel,
|
||||
|
||||
|
@ -262,18 +272,36 @@ async def open_ems(
|
|||
# open 2-way trade command stream
|
||||
ctx.open_stream() as trades_stream,
|
||||
):
|
||||
size: int = 100 # what should this be?
|
||||
tx, rx = trio.open_memory_channel(size)
|
||||
brx = broadcast_receiver(rx, size)
|
||||
|
||||
# setup local ui event streaming channels for request/resp
|
||||
# streamging with EMS daemon
|
||||
client = OrderClient(
|
||||
_ems_stream=trades_stream,
|
||||
_to_relay_task=tx,
|
||||
_from_sync_order_client=brx,
|
||||
)
|
||||
|
||||
client._ems_stream = trades_stream
|
||||
|
||||
# start sync code order msg delivery task
|
||||
async with trio.open_nursery() as n:
|
||||
n.start_soon(
|
||||
relay_order_cmds_from_sync_code,
|
||||
fqsn,
|
||||
relay_orders_from_sync_code,
|
||||
client,
|
||||
fqme,
|
||||
trades_stream
|
||||
)
|
||||
|
||||
yield (
|
||||
book,
|
||||
client,
|
||||
trades_stream,
|
||||
positions,
|
||||
accounts,
|
||||
dialogs,
|
||||
)
|
||||
|
||||
# stop the sync-msg-relay task on exit.
|
||||
n.cancel_scope.cancel()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
# 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
|
||||
|
@ -41,11 +41,13 @@ import trio
|
|||
from trio_typing import TaskStatus
|
||||
import tractor
|
||||
|
||||
from ..log import get_logger
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
get_console_log,
|
||||
)
|
||||
from ..data._normalize import iterticks
|
||||
from ..data._source import (
|
||||
unpack_fqsn,
|
||||
mk_fqsn,
|
||||
from ..accounting._mktinfo import (
|
||||
unpack_fqme,
|
||||
float_digits,
|
||||
)
|
||||
from ..data.feed import (
|
||||
|
@ -69,9 +71,6 @@ from ._messages import (
|
|||
)
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: numba all of this
|
||||
def mk_check(
|
||||
|
||||
|
@ -157,7 +156,7 @@ async def clear_dark_triggers(
|
|||
brokerd_orders_stream: tractor.MsgStream,
|
||||
quote_stream: tractor.ReceiveMsgStream, # noqa
|
||||
broker: str,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
|
||||
book: DarkBook,
|
||||
|
||||
|
@ -232,7 +231,7 @@ async def clear_dark_triggers(
|
|||
account=account,
|
||||
size=size,
|
||||
):
|
||||
bfqsn: str = symbol.replace(f'.{broker}', '')
|
||||
bfqme: str = symbol.replace(f'.{broker}', '')
|
||||
submit_price = price + abs_diff_away
|
||||
resp = 'triggered' # hidden on client-side
|
||||
|
||||
|
@ -245,7 +244,7 @@ async def clear_dark_triggers(
|
|||
oid=oid,
|
||||
account=account,
|
||||
time_ns=time.time_ns(),
|
||||
symbol=bfqsn,
|
||||
symbol=bfqme,
|
||||
price=submit_price,
|
||||
size=size,
|
||||
)
|
||||
|
@ -288,14 +287,14 @@ async def clear_dark_triggers(
|
|||
|
||||
# send response to client-side
|
||||
await router.client_broadcast(
|
||||
fqsn,
|
||||
fqme,
|
||||
status,
|
||||
)
|
||||
|
||||
else: # condition scan loop complete
|
||||
log.debug(f'execs are {execs}')
|
||||
if execs:
|
||||
book.triggers[fqsn] = execs
|
||||
book.triggers[fqme] = execs
|
||||
|
||||
# print(f'execs scan took: {time.time() - start}')
|
||||
|
||||
|
@ -316,9 +315,6 @@ class TradesRelay(Struct):
|
|||
# allowed account names
|
||||
accounts: tuple[str]
|
||||
|
||||
# count of connected ems clients for this ``brokerd``
|
||||
consumers: int = 0
|
||||
|
||||
|
||||
class Router(Struct):
|
||||
'''
|
||||
|
@ -334,9 +330,12 @@ class Router(Struct):
|
|||
# broker to book map
|
||||
books: dict[str, DarkBook] = {}
|
||||
|
||||
# NOTE: disable for since stupid "dunst"
|
||||
notify_on_order_loads: bool = False
|
||||
|
||||
# sets of clients mapped from subscription keys
|
||||
subscribers: defaultdict[
|
||||
str, # sub key, default fqsn
|
||||
str, # sub key, default fqme
|
||||
set[tractor.MsgStream], # unique client streams
|
||||
] = defaultdict(set)
|
||||
|
||||
|
@ -387,7 +386,7 @@ class Router(Struct):
|
|||
brokermod: ModuleType,
|
||||
portal: tractor.Portal,
|
||||
exec_mode: str,
|
||||
symbol: str,
|
||||
fqme: str,
|
||||
loglevel: str,
|
||||
|
||||
) -> None:
|
||||
|
@ -408,11 +407,12 @@ class Router(Struct):
|
|||
yield relay
|
||||
return
|
||||
|
||||
trades_endpoint = getattr(brokermod, 'trades_dialogue', None)
|
||||
if (
|
||||
trades_endpoint is None
|
||||
or exec_mode == 'paper'
|
||||
):
|
||||
def mk_paper_ep():
|
||||
nonlocal brokermod, exec_mode
|
||||
|
||||
# for logging purposes
|
||||
brokermod = paper
|
||||
|
||||
# for paper mode we need to mock this trades response feed
|
||||
# so we load bidir stream to a new sub-actor running
|
||||
# a paper-simulator clearing engine.
|
||||
|
@ -424,26 +424,53 @@ class Router(Struct):
|
|||
# load the paper trading engine as a subactor of this emsd
|
||||
# actor to simulate the real IPC load it'll have when also
|
||||
# pulling data from feeds
|
||||
open_trades_endpoint = paper.open_paperboi(
|
||||
fqsn='.'.join([symbol, broker]),
|
||||
return paper.open_paperboi(
|
||||
fqme=fqme,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
|
||||
else:
|
||||
trades_endpoint = getattr(brokermod, 'trades_dialogue', None)
|
||||
if (
|
||||
trades_endpoint is not None
|
||||
or exec_mode != 'paper'
|
||||
):
|
||||
# open live brokerd trades endpoint
|
||||
open_trades_endpoint = portal.open_context(
|
||||
trades_endpoint,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
|
||||
# open trades-dialog endpoint with backend broker
|
||||
else:
|
||||
exec_mode: str = 'paper'
|
||||
|
||||
@acm
|
||||
async def maybe_open_paper_ep():
|
||||
if exec_mode == 'paper':
|
||||
async with mk_paper_ep() as msg:
|
||||
yield msg
|
||||
return
|
||||
|
||||
# open trades-dialog endpoint with backend broker
|
||||
async with open_trades_endpoint as msg:
|
||||
ctx, first = msg
|
||||
|
||||
# runtime indication that the backend can't support live
|
||||
# order ctrl yet, so boot the paperboi B0
|
||||
if first == 'paper':
|
||||
async with mk_paper_ep() as msg:
|
||||
yield msg
|
||||
return
|
||||
else:
|
||||
# working live ep case B)
|
||||
yield msg
|
||||
return
|
||||
|
||||
positions: list[BrokerdPosition]
|
||||
accounts: tuple[str]
|
||||
|
||||
async with (
|
||||
open_trades_endpoint as (
|
||||
maybe_open_paper_ep() as (
|
||||
brokerd_ctx,
|
||||
(positions, accounts,),
|
||||
(positions, accounts),
|
||||
),
|
||||
brokerd_ctx.open_stream() as brokerd_trades_stream,
|
||||
):
|
||||
|
@ -466,30 +493,31 @@ class Router(Struct):
|
|||
# client set.
|
||||
|
||||
# locally cache and track positions per account with
|
||||
# a table of (brokername, acctid) -> `BrokerdPosition`
|
||||
# msgs.
|
||||
pps = {}
|
||||
for msg in positions:
|
||||
log.info(f'loading pp: {msg}')
|
||||
|
||||
account = msg['account']
|
||||
|
||||
# TODO: better value error for this which
|
||||
# dumps the account and message and states the
|
||||
# mismatch..
|
||||
assert account in accounts
|
||||
|
||||
pps.setdefault(
|
||||
(broker, account),
|
||||
[],
|
||||
).append(msg)
|
||||
|
||||
# a nested table of msgs:
|
||||
# tuple(brokername, acctid) ->
|
||||
# (fqme: str ->
|
||||
# `BrokerdPosition`)
|
||||
relay = TradesRelay(
|
||||
brokerd_stream=brokerd_trades_stream,
|
||||
positions=pps,
|
||||
positions={},
|
||||
accounts=accounts,
|
||||
consumers=1,
|
||||
)
|
||||
for msg in positions:
|
||||
|
||||
msg = BrokerdPosition(**msg)
|
||||
log.info(
|
||||
f'loading pp for {brokermod.__name__}:\n'
|
||||
f'{pformat(msg.to_dict())}',
|
||||
)
|
||||
|
||||
# TODO: state any mismatch here?
|
||||
account = msg.account
|
||||
assert account in accounts
|
||||
|
||||
relay.positions.setdefault(
|
||||
(broker, account),
|
||||
{},
|
||||
)[msg.symbol] = msg
|
||||
|
||||
self.relays[broker] = relay
|
||||
|
||||
|
@ -507,7 +535,7 @@ class Router(Struct):
|
|||
|
||||
async def open_trade_relays(
|
||||
self,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
exec_mode: str,
|
||||
loglevel: str,
|
||||
|
||||
|
@ -517,35 +545,33 @@ class Router(Struct):
|
|||
|
||||
) -> tuple[TradesRelay, Feed]:
|
||||
'''
|
||||
Open and yield ``brokerd`` trades dialogue context-stream if
|
||||
none already exists.
|
||||
Maybe open a live feed to the target fqme, start `brokerd` order
|
||||
msg relay and dark clearing tasks to run in the background
|
||||
indefinitely.
|
||||
|
||||
'''
|
||||
from ..data._source import unpack_fqsn
|
||||
broker, symbol, suffix = unpack_fqsn(fqsn)
|
||||
|
||||
async with (
|
||||
maybe_open_feed(
|
||||
[fqsn],
|
||||
[fqme],
|
||||
loglevel=loglevel,
|
||||
) as feed,
|
||||
):
|
||||
brokername, _, _ = unpack_fqsn(fqsn)
|
||||
brokername, _, _, _ = unpack_fqme(fqme)
|
||||
brokermod = feed.mods[brokername]
|
||||
broker = brokermod.name
|
||||
portal = feed.portals[brokermod]
|
||||
|
||||
# XXX: this should be initial price quote from target provider
|
||||
flume = feed.flumes[fqsn]
|
||||
flume = feed.flumes[fqme]
|
||||
first_quote: dict = flume.first_quote
|
||||
book: DarkBook = self.get_dark_book(broker)
|
||||
book.lasts[fqsn]: float = first_quote['last']
|
||||
book.lasts[fqme]: float = float(first_quote['last'])
|
||||
|
||||
async with self.maybe_open_brokerd_dialog(
|
||||
brokermod=brokermod,
|
||||
portal=portal,
|
||||
exec_mode=exec_mode,
|
||||
symbol=symbol,
|
||||
fqme=fqme,
|
||||
loglevel=loglevel,
|
||||
) as relay:
|
||||
|
||||
|
@ -558,7 +584,7 @@ class Router(Struct):
|
|||
relay.brokerd_stream,
|
||||
flume.stream,
|
||||
broker,
|
||||
fqsn, # form: <name>.<venue>.<suffix>.<broker>
|
||||
fqme, # form: <name>.<venue>.<suffix>.<broker>
|
||||
book
|
||||
)
|
||||
|
||||
|
@ -619,6 +645,7 @@ class Router(Struct):
|
|||
|
||||
if (
|
||||
not sent_some
|
||||
and self.notify_on_order_loads
|
||||
and notify_on_headless
|
||||
):
|
||||
log.info(
|
||||
|
@ -638,11 +665,14 @@ _router: Router = None
|
|||
|
||||
@tractor.context
|
||||
async def _setup_persistent_emsd(
|
||||
|
||||
ctx: tractor.Context,
|
||||
loglevel: str | None = None,
|
||||
|
||||
) -> None:
|
||||
|
||||
if loglevel:
|
||||
get_console_log(loglevel)
|
||||
|
||||
global _router
|
||||
|
||||
# open a root "service nursery" for the ``emsd`` actor
|
||||
|
@ -692,16 +722,15 @@ async def translate_and_relay_brokerd_events(
|
|||
async for brokerd_msg in brokerd_trades_stream:
|
||||
fmsg = pformat(brokerd_msg)
|
||||
log.info(
|
||||
f'Received broker trade event:\n'
|
||||
f'Rx brokerd trade msg:\n'
|
||||
f'{fmsg}'
|
||||
)
|
||||
status_msg: Optional[Status] = None
|
||||
status_msg: Status | None = None
|
||||
|
||||
match brokerd_msg:
|
||||
# BrokerdPosition
|
||||
case {
|
||||
'name': 'position',
|
||||
'symbol': sym,
|
||||
'broker': broker,
|
||||
}:
|
||||
pos_msg = BrokerdPosition(**brokerd_msg)
|
||||
|
@ -712,9 +741,9 @@ async def translate_and_relay_brokerd_events(
|
|||
|
||||
relay.positions.setdefault(
|
||||
# NOTE: translate to a FQSN!
|
||||
(broker, sym),
|
||||
[]
|
||||
).append(pos_msg)
|
||||
(broker, pos_msg.account),
|
||||
{}
|
||||
)[pos_msg.symbol] = pos_msg
|
||||
|
||||
# fan-out-relay position msgs immediately by
|
||||
# broadcasting updates on all client streams
|
||||
|
@ -781,12 +810,11 @@ async def translate_and_relay_brokerd_events(
|
|||
# no msg to client necessary
|
||||
continue
|
||||
|
||||
# BrokerdOrderError
|
||||
# BrokerdError
|
||||
case {
|
||||
'name': 'error',
|
||||
'oid': oid, # ems order-dialog id
|
||||
'reqid': reqid, # brokerd generated order-request id
|
||||
'symbol': sym,
|
||||
}:
|
||||
status_msg = book._active.get(oid)
|
||||
msg = BrokerdError(**brokerd_msg)
|
||||
|
@ -947,9 +975,9 @@ async def translate_and_relay_brokerd_events(
|
|||
# may end up with collisions?
|
||||
status_msg = Status(**brokerd_msg)
|
||||
|
||||
# NOTE: be sure to pack an fqsn for the client side!
|
||||
# NOTE: be sure to pack an fqme for the client side!
|
||||
order = Order(**status_msg.req)
|
||||
order.symbol = mk_fqsn(broker, order.symbol)
|
||||
order.symbol = f'{order.symbol}.{broker}'
|
||||
|
||||
assert order.price and order.size
|
||||
status_msg.req = order
|
||||
|
@ -1024,7 +1052,7 @@ async def process_client_order_cmds(
|
|||
client_order_stream: tractor.MsgStream,
|
||||
brokerd_order_stream: tractor.MsgStream,
|
||||
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
flume: Flume,
|
||||
dark_book: DarkBook,
|
||||
router: Router,
|
||||
|
@ -1051,11 +1079,11 @@ async def process_client_order_cmds(
|
|||
# backend can be routed and relayed to subscribed clients.
|
||||
subs = router.dialogs[oid]
|
||||
|
||||
# add all subscribed clients for this fqsn (should eventually be
|
||||
# add all subscribed clients for this fqme (should eventually be
|
||||
# a more generalize subscription system) to received order msg
|
||||
# updates (and thus show stuff in the UI).
|
||||
subs.add(client_order_stream)
|
||||
subs.update(router.subscribers[fqsn])
|
||||
subs.update(router.subscribers[fqme])
|
||||
|
||||
reqid = dark_book._ems2brokerd_ids.inverse.get(oid)
|
||||
|
||||
|
@ -1113,7 +1141,7 @@ async def process_client_order_cmds(
|
|||
and status.resp == 'dark_open'
|
||||
):
|
||||
# remove from dark book clearing
|
||||
entry = dark_book.triggers[fqsn].pop(oid, None)
|
||||
entry = dark_book.triggers[fqme].pop(oid, None)
|
||||
if entry:
|
||||
(
|
||||
pred,
|
||||
|
@ -1129,7 +1157,7 @@ async def process_client_order_cmds(
|
|||
status.req = cmd
|
||||
|
||||
await router.client_broadcast(
|
||||
fqsn,
|
||||
fqme,
|
||||
status,
|
||||
)
|
||||
|
||||
|
@ -1139,7 +1167,7 @@ async def process_client_order_cmds(
|
|||
dark_book._active.pop(oid)
|
||||
|
||||
else:
|
||||
log.exception(f'No dark order for {fqsn}?')
|
||||
log.exception(f'No dark order for {fqme}?')
|
||||
|
||||
# TODO: eventually we should be receiving
|
||||
# this struct on the wire unpacked in a scoped protocol
|
||||
|
@ -1148,7 +1176,7 @@ async def process_client_order_cmds(
|
|||
# LIVE order REQUEST
|
||||
case {
|
||||
'oid': oid,
|
||||
'symbol': fqsn,
|
||||
'symbol': fqme,
|
||||
'price': trigger_price,
|
||||
'size': size,
|
||||
'action': ('buy' | 'sell') as action,
|
||||
|
@ -1161,7 +1189,7 @@ async def process_client_order_cmds(
|
|||
# remove the broker part before creating a message
|
||||
# to send to the specific broker since they probably
|
||||
# aren't expectig their own name, but should they?
|
||||
sym = fqsn.replace(f'.{broker}', '')
|
||||
sym = fqme.replace(f'.{broker}', '')
|
||||
|
||||
if status is not None:
|
||||
# if we already had a broker order id then
|
||||
|
@ -1218,7 +1246,7 @@ async def process_client_order_cmds(
|
|||
# DARK-order / alert REQUEST
|
||||
case {
|
||||
'oid': oid,
|
||||
'symbol': fqsn,
|
||||
'symbol': fqme,
|
||||
'price': trigger_price,
|
||||
'size': size,
|
||||
'exec_mode': exec_mode,
|
||||
|
@ -1240,7 +1268,7 @@ async def process_client_order_cmds(
|
|||
# price received from the feed, instead of being
|
||||
# like every other shitty tina platform that makes
|
||||
# the user choose the predicate operator.
|
||||
last = dark_book.lasts[fqsn]
|
||||
last = dark_book.lasts[fqme]
|
||||
|
||||
# sometimes the real-time feed hasn't come up
|
||||
# so just pull from the latest history.
|
||||
|
@ -1249,8 +1277,13 @@ async def process_client_order_cmds(
|
|||
|
||||
pred = mk_check(trigger_price, last, action)
|
||||
|
||||
# NOTE: for dark orders currently we submit
|
||||
# the triggered live order at a price 5 ticks
|
||||
# above/below the L1 prices.
|
||||
# TODO: make this configurable from our top level
|
||||
# config, prolly in a .clearing` section?
|
||||
spread_slap: float = 5
|
||||
min_tick = flume.symbol.tick_size
|
||||
min_tick = float(flume.mkt.size_tick)
|
||||
min_tick_digits = float_digits(min_tick)
|
||||
|
||||
if action == 'buy':
|
||||
|
@ -1282,7 +1315,7 @@ async def process_client_order_cmds(
|
|||
# NOTE: this may result in an override of an existing
|
||||
# dark book entry if the order id already exists
|
||||
dark_book.triggers.setdefault(
|
||||
fqsn, {}
|
||||
fqme, {}
|
||||
)[oid] = (
|
||||
pred,
|
||||
tickfilter,
|
||||
|
@ -1307,7 +1340,7 @@ async def process_client_order_cmds(
|
|||
|
||||
# broadcast status to all subscribed clients
|
||||
await router.client_broadcast(
|
||||
fqsn,
|
||||
fqme,
|
||||
status,
|
||||
)
|
||||
|
||||
|
@ -1318,35 +1351,36 @@ async def process_client_order_cmds(
|
|||
@acm
|
||||
async def maybe_open_trade_relays(
|
||||
router: Router,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
exec_mode: str, # ('paper', 'live')
|
||||
loglevel: str = 'info',
|
||||
|
||||
) -> tuple:
|
||||
|
||||
def cache_on_fqsn_unless_paper(
|
||||
def cache_on_fqme_unless_paper(
|
||||
router: Router,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
exec_mode: str, # ('paper', 'live')
|
||||
loglevel: str = 'info',
|
||||
) -> Hashable:
|
||||
if exec_mode == 'paper':
|
||||
return f'paper_{fqsn}'
|
||||
return f'paper_{fqme}'
|
||||
else:
|
||||
return fqsn
|
||||
return fqme
|
||||
|
||||
# XXX: closure to enable below use of
|
||||
# ``tractor.trionics.maybe_open_context()``
|
||||
@acm
|
||||
async def cached_mngr(
|
||||
router: Router,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
exec_mode: str, # ('paper', 'live')
|
||||
loglevel: str = 'info',
|
||||
):
|
||||
|
||||
relay, feed, client_ready = await _router.nursery.start(
|
||||
_router.open_trade_relays,
|
||||
fqsn,
|
||||
fqme,
|
||||
exec_mode,
|
||||
loglevel,
|
||||
)
|
||||
|
@ -1356,24 +1390,28 @@ async def maybe_open_trade_relays(
|
|||
acm_func=cached_mngr,
|
||||
kwargs={
|
||||
'router': _router,
|
||||
'fqsn': fqsn,
|
||||
'fqme': fqme,
|
||||
'exec_mode': exec_mode,
|
||||
'loglevel': loglevel,
|
||||
},
|
||||
key=cache_on_fqsn_unless_paper,
|
||||
key=cache_on_fqme_unless_paper,
|
||||
) as (
|
||||
cache_hit,
|
||||
(relay, feed, client_ready)
|
||||
):
|
||||
if cache_hit:
|
||||
log.info(f'Reusing existing trades relay for {fqme}:\n'
|
||||
f'{relay}\n')
|
||||
|
||||
yield relay, feed, client_ready
|
||||
|
||||
|
||||
@tractor.context
|
||||
async def _emsd_main(
|
||||
ctx: tractor.Context,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
exec_mode: str, # ('paper', 'live')
|
||||
loglevel: str = 'info',
|
||||
loglevel: str | None = None,
|
||||
|
||||
) -> tuple[
|
||||
dict[
|
||||
|
@ -1428,7 +1466,7 @@ async def _emsd_main(
|
|||
global _router
|
||||
assert _router
|
||||
|
||||
broker, symbol, suffix = unpack_fqsn(fqsn)
|
||||
broker, _, _, _ = unpack_fqme(fqme)
|
||||
|
||||
# TODO: would be nice if in tractor we can require either a ctx arg,
|
||||
# or a named arg with ctx in it and a type annotation of
|
||||
|
@ -1445,7 +1483,7 @@ async def _emsd_main(
|
|||
# few duplicate streams as necessary per ems actor.
|
||||
async with maybe_open_trade_relays(
|
||||
_router,
|
||||
fqsn,
|
||||
fqme,
|
||||
exec_mode,
|
||||
loglevel,
|
||||
) as (relay, feed, client_ready):
|
||||
|
@ -1468,28 +1506,28 @@ async def _emsd_main(
|
|||
# register the client side before starting the
|
||||
# brokerd-side relay task to ensure the client is
|
||||
# delivered all exisiting open orders on startup.
|
||||
# TODO: instead of by fqsn we need a subscription
|
||||
# TODO: instead of by fqme we need a subscription
|
||||
# system/schema here to limit what each new client is
|
||||
# allowed to see in terms of broadcasted order flow
|
||||
# updates per dialog.
|
||||
_router.subscribers[fqsn].add(client_stream)
|
||||
_router.subscribers[fqme].add(client_stream)
|
||||
client_ready.set()
|
||||
|
||||
# start inbound (from attached client) order request processing
|
||||
# main entrypoint, run here until cancelled.
|
||||
try:
|
||||
flume = feed.flumes[fqsn]
|
||||
flume = feed.flumes[fqme]
|
||||
await process_client_order_cmds(
|
||||
client_stream,
|
||||
brokerd_stream,
|
||||
fqsn,
|
||||
fqme,
|
||||
flume,
|
||||
dark_book,
|
||||
_router,
|
||||
)
|
||||
finally:
|
||||
# try to remove client from subscription registry
|
||||
_router.subscribers[fqsn].remove(client_stream)
|
||||
_router.subscribers[fqme].remove(client_stream)
|
||||
|
||||
for oid, client_streams in _router.dialogs.items():
|
||||
client_streams.discard(client_stream)
|
||||
|
|
|
@ -29,7 +29,6 @@ from typing import (
|
|||
|
||||
from msgspec import field
|
||||
|
||||
from ..data._source import Symbol
|
||||
from ..data.types import Struct
|
||||
|
||||
|
||||
|
@ -94,7 +93,8 @@ class Order(Struct):
|
|||
|
||||
# internal ``emdsd`` unique "order id"
|
||||
oid: str # uuid4
|
||||
symbol: str | Symbol
|
||||
# TODO: figure out how to optionally typecast this to `MktPair`?
|
||||
symbol: str # | MktPair
|
||||
account: str # should we set a default as '' ?
|
||||
|
||||
price: float
|
||||
|
@ -191,7 +191,7 @@ class BrokerdOrder(Struct):
|
|||
account: str
|
||||
time_ns: int
|
||||
|
||||
symbol: str # fqsn
|
||||
symbol: str # fqme
|
||||
price: float
|
||||
size: float
|
||||
|
||||
|
@ -300,10 +300,10 @@ class BrokerdError(Struct):
|
|||
|
||||
|
||||
class BrokerdPosition(Struct):
|
||||
'''Position update event from brokerd.
|
||||
'''
|
||||
Position update event from brokerd.
|
||||
|
||||
'''
|
||||
|
||||
broker: str
|
||||
account: str
|
||||
symbol: str
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
# 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
|
||||
|
@ -14,19 +14,20 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
Fake trading for forward testing.
|
||||
'''
|
||||
Fake trading: a full forward testing simulation engine.
|
||||
|
||||
"""
|
||||
We can real-time emulate any mkt conditions you want bruddr B)
|
||||
Just slide us the model que quieres..
|
||||
|
||||
'''
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from datetime import datetime
|
||||
from operator import itemgetter
|
||||
import itertools
|
||||
import time
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
Callable,
|
||||
)
|
||||
import uuid
|
||||
|
@ -36,18 +37,25 @@ import pendulum
|
|||
import trio
|
||||
import tractor
|
||||
|
||||
from ..brokers import get_brokermod
|
||||
from .. import data
|
||||
from ..data.types import Struct
|
||||
from ..data._source import Symbol
|
||||
from ..pp import (
|
||||
from ..accounting._mktinfo import (
|
||||
MktPair,
|
||||
)
|
||||
from ..accounting import (
|
||||
Position,
|
||||
PpTable,
|
||||
Transaction,
|
||||
TransactionLedger,
|
||||
open_trade_ledger,
|
||||
open_pps,
|
||||
)
|
||||
from ..data._normalize import iterticks
|
||||
from ..data._source import unpack_fqsn
|
||||
from ..log import get_logger
|
||||
from ..accounting import unpack_fqme
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
)
|
||||
from ._messages import (
|
||||
BrokerdCancel,
|
||||
BrokerdOrder,
|
||||
|
@ -58,10 +66,6 @@ from ._messages import (
|
|||
BrokerdError,
|
||||
)
|
||||
|
||||
from ..config import load
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class PaperBoi(Struct):
|
||||
'''
|
||||
|
@ -75,14 +79,15 @@ class PaperBoi(Struct):
|
|||
|
||||
ems_trades_stream: tractor.MsgStream
|
||||
|
||||
ppt: PpTable
|
||||
ledger: TransactionLedger
|
||||
|
||||
# map of paper "live" orders which be used
|
||||
# to simulate fills based on paper engine settings
|
||||
_buys: defaultdict[str, bidict]
|
||||
_sells: defaultdict[str, bidict]
|
||||
_reqids: bidict
|
||||
_positions: dict[str, Position]
|
||||
_trade_ledger: dict[str, Any]
|
||||
_syms: dict[str, Symbol] = {}
|
||||
_mkts: dict[str, MktPair] = {}
|
||||
|
||||
# init edge case L1 spread
|
||||
last_ask: tuple[float, float] = (float('inf'), 0) # price, size
|
||||
|
@ -95,7 +100,7 @@ class PaperBoi(Struct):
|
|||
price: float,
|
||||
action: str,
|
||||
size: float,
|
||||
reqid: Optional[str],
|
||||
reqid: str | None,
|
||||
|
||||
) -> int:
|
||||
'''
|
||||
|
@ -121,7 +126,10 @@ class PaperBoi(Struct):
|
|||
# in the broker trades event processing loop
|
||||
await trio.sleep(0.05)
|
||||
|
||||
if action == 'sell':
|
||||
if (
|
||||
action == 'sell'
|
||||
and size > 0
|
||||
):
|
||||
size = -size
|
||||
|
||||
msg = BrokerdStatus(
|
||||
|
@ -197,7 +205,7 @@ class PaperBoi(Struct):
|
|||
async def fake_fill(
|
||||
self,
|
||||
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
price: float,
|
||||
size: float,
|
||||
action: str, # one of {'buy', 'sell'}
|
||||
|
@ -250,43 +258,46 @@ class PaperBoi(Struct):
|
|||
)
|
||||
await self.ems_trades_stream.send(msg)
|
||||
|
||||
# lookup any existing position
|
||||
key = fqsn.rstrip(f'.{self.broker}')
|
||||
# NOTE: for paper we set the "bs_mktid" as just the fqme since
|
||||
# we don't actually have any unique backend symbol ourselves
|
||||
# other then this thing, our fqme address.
|
||||
bs_mktid: str = fqme
|
||||
t = Transaction(
|
||||
fqsn=fqsn,
|
||||
sym=self._syms[fqsn],
|
||||
fqme=fqme,
|
||||
sym=self._mkts[fqme],
|
||||
tid=oid,
|
||||
size=size,
|
||||
price=price,
|
||||
cost=0, # TODO: cost model
|
||||
dt=pendulum.from_timestamp(fill_time_s),
|
||||
bsuid=key,
|
||||
bs_mktid=bs_mktid,
|
||||
)
|
||||
|
||||
with (
|
||||
open_trade_ledger(self.broker, 'paper') as ledger,
|
||||
open_pps(self.broker, 'paper', write_on_exit=True) as table
|
||||
):
|
||||
tx = t.to_dict()
|
||||
tx.pop('sym')
|
||||
ledger.update({oid: tx})
|
||||
# Write to pps toml right now
|
||||
table.update_from_trans({oid: t})
|
||||
# update in-mem ledger and pos table
|
||||
self.ledger.update_from_t(t)
|
||||
self.ppt.update_from_trans({oid: t})
|
||||
|
||||
pp = table.pps[key]
|
||||
pp_msg = BrokerdPosition(
|
||||
broker=self.broker,
|
||||
account='paper',
|
||||
symbol=fqsn,
|
||||
# TODO: we need to look up the asset currency from
|
||||
# broker info. i guess for crypto this can be
|
||||
# inferred from the pair?
|
||||
currency=key,
|
||||
size=pp.size,
|
||||
avg_price=pp.ppu,
|
||||
)
|
||||
# transmit pp msg to ems
|
||||
pp = self.ppt.pps[bs_mktid]
|
||||
pp_msg = BrokerdPosition(
|
||||
broker=self.broker,
|
||||
account='paper',
|
||||
symbol=fqme,
|
||||
|
||||
await self.ems_trades_stream.send(pp_msg)
|
||||
size=pp.size,
|
||||
avg_price=pp.ppu,
|
||||
|
||||
# TODO: we need to look up the asset currency from
|
||||
# broker info. i guess for crypto this can be
|
||||
# inferred from the pair?
|
||||
# currency=bs_mktid,
|
||||
)
|
||||
# write all updates to filesys immediately
|
||||
# (adds latency but that works for simulation anyway)
|
||||
self.ledger.write_config()
|
||||
self.ppt.write_config()
|
||||
|
||||
await self.ems_trades_stream.send(pp_msg)
|
||||
|
||||
|
||||
async def simulate_fills(
|
||||
|
@ -421,7 +432,7 @@ async def simulate_fills(
|
|||
|
||||
# clearing price would have filled entirely
|
||||
await client.fake_fill(
|
||||
fqsn=sym,
|
||||
fqme=sym,
|
||||
# todo slippage to determine fill price
|
||||
price=tick_price,
|
||||
size=size,
|
||||
|
@ -469,6 +480,7 @@ async def handle_order_requests(
|
|||
BrokerdOrderAck(
|
||||
oid=order.oid,
|
||||
reqid=reqid,
|
||||
account='paper'
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -512,7 +524,6 @@ _sells: defaultdict[
|
|||
tuple[float, float, str, str], # order info
|
||||
]
|
||||
] = defaultdict(bidict)
|
||||
_positions: dict[str, Position] = {}
|
||||
|
||||
|
||||
@tractor.context
|
||||
|
@ -520,33 +531,86 @@ async def trades_dialogue(
|
|||
|
||||
ctx: tractor.Context,
|
||||
broker: str,
|
||||
fqsn: str,
|
||||
loglevel: str = None,
|
||||
fqme: str | None = None, # if empty, we only boot broker mode
|
||||
loglevel: str = 'warning',
|
||||
|
||||
) -> None:
|
||||
|
||||
tractor.log.get_console_log(loglevel)
|
||||
|
||||
async with (
|
||||
data.open_feed(
|
||||
[fqsn],
|
||||
loglevel=loglevel,
|
||||
) as feed,
|
||||
ppt: PpTable
|
||||
ledger: TransactionLedger
|
||||
with (
|
||||
open_pps(
|
||||
broker,
|
||||
'paper',
|
||||
write_on_exit=True,
|
||||
) as ppt,
|
||||
|
||||
open_trade_ledger(
|
||||
broker,
|
||||
'paper',
|
||||
) as ledger
|
||||
):
|
||||
# NOTE: retreive market(pair) info from the backend broker
|
||||
# since ledger entries (in their backend native format) often
|
||||
# don't contain necessary market info per trade record entry..
|
||||
# - if no fqme was passed in, we presume we're running in
|
||||
# "ledger-sync-only mode" and thus we load mkt info for
|
||||
# each symbol found in the ledger to a ppt table manually.
|
||||
|
||||
with open_pps(broker, 'paper') as table:
|
||||
# save pps in local state
|
||||
_positions.update(table.pps)
|
||||
# TODO: how to process ledger info from backends?
|
||||
# - should we be rolling our own actor-cached version of these
|
||||
# client API refs or using portal IPC to send requests to the
|
||||
# existing brokerd daemon?
|
||||
# - alternatively we can possibly expect and use
|
||||
# a `.broker.norm_trade_records()` ep?
|
||||
brokermod = get_brokermod(broker)
|
||||
gmi = getattr(brokermod, 'get_mkt_info', None)
|
||||
|
||||
# update all transactions with mkt info before
|
||||
# loading any pps
|
||||
mkt_by_fqme: dict[str, MktPair] = {}
|
||||
if fqme:
|
||||
bs_fqme, _, broker = fqme.rpartition('.')
|
||||
mkt, _ = await brokermod.get_mkt_info(bs_fqme)
|
||||
mkt_by_fqme[fqme] = mkt
|
||||
|
||||
# for each sym in the ledger load it's `MktPair` info
|
||||
for tid, txdict in ledger.data.items():
|
||||
l_fqme: str = txdict.get('fqme') or txdict['fqsn']
|
||||
|
||||
if (
|
||||
gmi
|
||||
and l_fqme not in mkt_by_fqme
|
||||
):
|
||||
mkt, pair = await brokermod.get_mkt_info(
|
||||
l_fqme.rstrip(f'.{broker}'),
|
||||
)
|
||||
mkt_by_fqme[l_fqme] = mkt
|
||||
|
||||
# if an ``fqme: str`` input was provided we only
|
||||
# need a ``MktPair`` for that one market, since we're
|
||||
# running in real simulated-clearing mode, not just ledger
|
||||
# syncing.
|
||||
if (
|
||||
fqme is not None
|
||||
and fqme in mkt_by_fqme
|
||||
):
|
||||
break
|
||||
|
||||
# update pos table from ledger history and provide a ``MktPair``
|
||||
# lookup for internal position accounting calcs.
|
||||
ppt.update_from_trans(ledger.to_trans(mkt_by_fqme=mkt_by_fqme))
|
||||
|
||||
pp_msgs: list[BrokerdPosition] = []
|
||||
pos: Position
|
||||
token: str # f'{symbol}.{self.broker}'
|
||||
for token, pos in _positions.items():
|
||||
for token, pos in ppt.pps.items():
|
||||
pp_msgs.append(BrokerdPosition(
|
||||
broker=broker,
|
||||
account='paper',
|
||||
symbol=pos.symbol.front_fqsn(),
|
||||
symbol=pos.mkt.fqme,
|
||||
size=pos.size,
|
||||
avg_price=pos.ppu,
|
||||
))
|
||||
|
@ -556,42 +620,64 @@ async def trades_dialogue(
|
|||
['paper'],
|
||||
))
|
||||
|
||||
# write new positions state in case ledger was
|
||||
# newer then that tracked in pps.toml
|
||||
ppt.write_config()
|
||||
|
||||
# exit early since no fqme was passed,
|
||||
# normally this case is just to load
|
||||
# positions "offline".
|
||||
if fqme is None:
|
||||
log.warning(
|
||||
'Paper engine only running in position delivery mode!\n'
|
||||
'NO SIMULATED CLEARING LOOP IS ACTIVE!'
|
||||
)
|
||||
await trio.sleep_forever()
|
||||
return
|
||||
|
||||
async with (
|
||||
ctx.open_stream() as ems_stream,
|
||||
trio.open_nursery() as n,
|
||||
data.open_feed(
|
||||
[fqme],
|
||||
loglevel=loglevel,
|
||||
) as feed,
|
||||
):
|
||||
client = PaperBoi(
|
||||
broker,
|
||||
ems_stream,
|
||||
_buys=_buys,
|
||||
_sells=_sells,
|
||||
# sanity check all the mkt infos
|
||||
for fqme, flume in feed.flumes.items():
|
||||
assert mkt_by_fqme[fqme] == flume.mkt
|
||||
|
||||
_reqids=_reqids,
|
||||
async with (
|
||||
ctx.open_stream() as ems_stream,
|
||||
trio.open_nursery() as n,
|
||||
):
|
||||
client = PaperBoi(
|
||||
broker=broker,
|
||||
ems_trades_stream=ems_stream,
|
||||
ppt=ppt,
|
||||
ledger=ledger,
|
||||
|
||||
_positions=_positions,
|
||||
_buys=_buys,
|
||||
_sells=_sells,
|
||||
_reqids=_reqids,
|
||||
|
||||
# TODO: load postions from ledger file
|
||||
_trade_ledger={},
|
||||
_syms={
|
||||
fqsn: flume.symbol
|
||||
for fqsn, flume in feed.flumes.items()
|
||||
}
|
||||
)
|
||||
_mkts=mkt_by_fqme,
|
||||
|
||||
n.start_soon(
|
||||
handle_order_requests,
|
||||
client,
|
||||
ems_stream,
|
||||
)
|
||||
)
|
||||
|
||||
# paper engine simulator clearing task
|
||||
await simulate_fills(feed.streams[broker], client)
|
||||
n.start_soon(
|
||||
handle_order_requests,
|
||||
client,
|
||||
ems_stream,
|
||||
)
|
||||
|
||||
# paper engine simulator clearing task
|
||||
await simulate_fills(feed.streams[broker], client)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@acm
|
||||
async def open_paperboi(
|
||||
fqsn: str,
|
||||
loglevel: str,
|
||||
fqme: str | None = None,
|
||||
broker: str | None = None,
|
||||
loglevel: str | None = None,
|
||||
|
||||
) -> Callable:
|
||||
'''
|
||||
|
@ -599,28 +685,39 @@ async def open_paperboi(
|
|||
its context.
|
||||
|
||||
'''
|
||||
broker, symbol, expiry = unpack_fqsn(fqsn)
|
||||
if not fqme:
|
||||
assert broker, 'One of `broker` or `fqme` is required siss..!'
|
||||
else:
|
||||
broker, _, _, _ = unpack_fqme(fqme)
|
||||
|
||||
we_spawned: bool = False
|
||||
service_name = f'paperboi.{broker}'
|
||||
|
||||
async with (
|
||||
tractor.find_actor(service_name) as portal,
|
||||
tractor.open_nursery() as tn,
|
||||
):
|
||||
# only spawn if no paperboi already is up
|
||||
# (we likely don't need more then one proc for basic
|
||||
# simulated order clearing)
|
||||
# NOTE: only spawn if no paperboi already is up since we likely
|
||||
# don't need more then one actor for simulated order clearing
|
||||
# per broker-backend.
|
||||
if portal is None:
|
||||
log.info('Starting new paper-engine actor')
|
||||
portal = await tn.start_actor(
|
||||
service_name,
|
||||
enable_modules=[__name__]
|
||||
)
|
||||
we_spawned = True
|
||||
|
||||
async with portal.open_context(
|
||||
trades_dialogue,
|
||||
broker=broker,
|
||||
fqsn=fqsn,
|
||||
fqme=fqme,
|
||||
loglevel=loglevel,
|
||||
|
||||
) as (ctx, first):
|
||||
yield ctx, first
|
||||
|
||||
# tear down connection and any spawned actor on exit
|
||||
await ctx.cancel()
|
||||
if we_spawned:
|
||||
await portal.cancel_actor()
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# 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/>.
|
||||
"""
|
||||
Sub-sys module commons.
|
||||
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
get_console_log,
|
||||
)
|
||||
subsys: str = 'piker.clearing'
|
||||
|
||||
log = get_logger(subsys)
|
||||
|
||||
get_console_log = partial(
|
||||
get_console_log,
|
||||
name=subsys,
|
||||
)
|
|
@ -19,6 +19,7 @@ CLI commons.
|
|||
|
||||
'''
|
||||
import os
|
||||
from contextlib import AsyncExitStack
|
||||
|
||||
import click
|
||||
import trio
|
||||
|
@ -69,8 +70,8 @@ def pikerd(
|
|||
Spawn the piker broker-daemon.
|
||||
|
||||
'''
|
||||
from .. import service
|
||||
|
||||
from ..service import open_pikerd
|
||||
log = get_console_log(loglevel)
|
||||
|
||||
if pdb:
|
||||
|
@ -90,17 +91,36 @@ def pikerd(
|
|||
)
|
||||
|
||||
async def main():
|
||||
service_mngr: service.Services
|
||||
|
||||
async with (
|
||||
open_pikerd(
|
||||
tsdb=tsdb,
|
||||
es=es,
|
||||
service.open_pikerd(
|
||||
loglevel=loglevel,
|
||||
debug_mode=pdb,
|
||||
registry_addr=reg_addr,
|
||||
|
||||
), # normally delivers a ``Services`` handle
|
||||
) as service_mngr, # normally delivers a ``Services`` handle
|
||||
trio.open_nursery() as n,
|
||||
|
||||
AsyncExitStack() as stack,
|
||||
):
|
||||
if tsdb:
|
||||
dname, conf = await stack.enter_async_context(
|
||||
service.marketstore.start_ahab_daemon(
|
||||
service_mngr,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
)
|
||||
log.info(f'TSDB `{dname}` up with conf:\n{conf}')
|
||||
|
||||
if es:
|
||||
dname, conf = await stack.enter_async_context(
|
||||
service.elastic.start_ahab_daemon(
|
||||
service_mngr,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
)
|
||||
log.info(f'DB `{dname}` up with conf:\n{conf}')
|
||||
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
|
277
piker/config.py
277
piker/config.py
|
@ -21,14 +21,21 @@ Platform configuration (files) mgmt.
|
|||
import platform
|
||||
import sys
|
||||
import os
|
||||
from os import path
|
||||
from os.path import dirname
|
||||
import shutil
|
||||
from typing import Optional
|
||||
import time
|
||||
from typing import (
|
||||
Callable,
|
||||
MutableMapping,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from bidict import bidict
|
||||
import toml
|
||||
import tomlkit
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib
|
||||
|
||||
|
||||
from .log import get_logger
|
||||
|
||||
|
@ -126,30 +133,33 @@ def get_app_dir(
|
|||
)
|
||||
|
||||
|
||||
_config_dir = _click_config_dir = get_app_dir('piker')
|
||||
_parent_user = os.environ.get('SUDO_USER')
|
||||
_click_config_dir: Path = Path(get_app_dir('piker'))
|
||||
_config_dir: Path = _click_config_dir
|
||||
_parent_user: str = os.environ.get('SUDO_USER')
|
||||
|
||||
if _parent_user:
|
||||
non_root_user_dir = os.path.expanduser(
|
||||
f'~{_parent_user}'
|
||||
non_root_user_dir = Path(
|
||||
os.path.expanduser(f'~{_parent_user}')
|
||||
)
|
||||
root = 'root'
|
||||
root: str = 'root'
|
||||
_ccds: str = str(_click_config_dir) # click config dir string
|
||||
i_tail: int = int(_ccds.rfind(root) + len(root))
|
||||
_config_dir = (
|
||||
non_root_user_dir +
|
||||
_click_config_dir[
|
||||
_click_config_dir.rfind(root) + len(root):
|
||||
]
|
||||
non_root_user_dir
|
||||
/
|
||||
Path(_ccds[i_tail+1:]) # +1 to capture trailing '/'
|
||||
)
|
||||
|
||||
|
||||
_conf_names: set[str] = {
|
||||
'brokers',
|
||||
'pps',
|
||||
'trades',
|
||||
'watchlists',
|
||||
'paper_trades'
|
||||
'conf', # god config
|
||||
'brokers', # sec backend deatz
|
||||
'watchlists', # (user defined) market lists
|
||||
}
|
||||
|
||||
_watchlists_data_path = os.path.join(_config_dir, 'watchlists.json')
|
||||
# TODO: probably drop all this super legacy, questrade specific,
|
||||
# config stuff XD ?
|
||||
_watchlists_data_path: Path = _config_dir / Path('watchlists.json')
|
||||
_context_defaults = dict(
|
||||
default_map={
|
||||
# Questrade specific quote poll rates
|
||||
|
@ -180,7 +190,7 @@ def _conf_fn_w_ext(
|
|||
def get_conf_path(
|
||||
conf_name: str = 'brokers',
|
||||
|
||||
) -> str:
|
||||
) -> Path:
|
||||
'''
|
||||
Return the top-level default config path normally under
|
||||
``~/.config/piker`` on linux for a given ``conf_name``, the config
|
||||
|
@ -188,7 +198,6 @@ def get_conf_path(
|
|||
|
||||
Contains files such as:
|
||||
- brokers.toml
|
||||
- pp.toml
|
||||
- watchlists.toml
|
||||
|
||||
# maybe coming soon ;)
|
||||
|
@ -196,72 +205,187 @@ def get_conf_path(
|
|||
- strats.toml
|
||||
|
||||
'''
|
||||
assert conf_name in _conf_names
|
||||
if 'account.' not in conf_name:
|
||||
assert str(conf_name) in _conf_names
|
||||
|
||||
fn = _conf_fn_w_ext(conf_name)
|
||||
return os.path.join(
|
||||
_config_dir,
|
||||
fn,
|
||||
)
|
||||
return _config_dir / Path(fn)
|
||||
|
||||
|
||||
def repodir():
|
||||
def repodir() -> Path:
|
||||
'''
|
||||
Return the abspath to the repo directory.
|
||||
Return the abspath as ``Path`` to the git repo's root dir.
|
||||
|
||||
'''
|
||||
dirpath = path.abspath(
|
||||
# we're 3 levels down in **this** module file
|
||||
dirname(dirname(os.path.realpath(__file__)))
|
||||
)
|
||||
return dirpath
|
||||
repodir: Path = Path(__file__).absolute().parent.parent
|
||||
confdir: Path = repodir / 'config'
|
||||
|
||||
if not confdir.is_dir():
|
||||
# prolly inside stupid GH actions CI..
|
||||
repodir: Path = Path(os.environ.get('GITHUB_WORKSPACE'))
|
||||
confdir: Path = repodir / 'config'
|
||||
|
||||
assert confdir.is_dir(), f'{confdir} DNE, {repodir} is likely incorrect!'
|
||||
return repodir
|
||||
|
||||
|
||||
def load(
|
||||
conf_name: str = 'brokers',
|
||||
path: str = None,
|
||||
conf_name: str = 'brokers', # appended with .toml suffix
|
||||
path: Path | None = None,
|
||||
|
||||
decode: Callable[
|
||||
[str | bytes,],
|
||||
MutableMapping,
|
||||
] = tomllib.loads,
|
||||
|
||||
touch_if_dne: bool = False,
|
||||
|
||||
**tomlkws,
|
||||
|
||||
) -> (dict, str):
|
||||
) -> tuple[dict, Path]:
|
||||
'''
|
||||
Load config file by name.
|
||||
|
||||
If desired config is not in the top level piker-user config path then
|
||||
pass the ``path: Path`` explicitly.
|
||||
|
||||
'''
|
||||
path = path or get_conf_path(conf_name)
|
||||
|
||||
if not os.path.isdir(_config_dir):
|
||||
Path(_config_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not os.path.isfile(path):
|
||||
fn = _conf_fn_w_ext(conf_name)
|
||||
|
||||
template = os.path.join(
|
||||
repodir(),
|
||||
'config',
|
||||
fn
|
||||
# create the $HOME/.config/piker dir if dne
|
||||
if not _config_dir.is_dir():
|
||||
_config_dir.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
path_provided: bool = path is not None
|
||||
path: Path = path or get_conf_path(conf_name)
|
||||
|
||||
if (
|
||||
not path.is_file()
|
||||
and touch_if_dne
|
||||
):
|
||||
# only do a template if no path provided,
|
||||
# just touch an empty file with same name.
|
||||
if path_provided:
|
||||
with path.open(mode='x'):
|
||||
pass
|
||||
|
||||
# try to copy in a template config to the user's dir if one
|
||||
# exists.
|
||||
else:
|
||||
fn: str = _conf_fn_w_ext(conf_name)
|
||||
template: Path = repodir() / 'config' / fn
|
||||
if template.is_file():
|
||||
shutil.copyfile(template, path)
|
||||
|
||||
elif fn and template:
|
||||
assert template.is_file(), f'{template} is not a file!?'
|
||||
|
||||
assert path.is_file(), f'Config file {path} not created!?'
|
||||
|
||||
with path.open(mode='r') as fp:
|
||||
config: dict = decode(
|
||||
fp.read(),
|
||||
**tomlkws,
|
||||
)
|
||||
# try to copy in a template config to the user's directory
|
||||
# if one exists.
|
||||
if os.path.isfile(template):
|
||||
shutil.copyfile(template, path)
|
||||
else:
|
||||
# create an empty file
|
||||
with open(path, 'x'):
|
||||
pass
|
||||
else:
|
||||
with open(path, 'r'):
|
||||
pass # touch it
|
||||
|
||||
config = toml.load(path, **tomlkws)
|
||||
log.debug(f"Read config file {path}")
|
||||
return config, path
|
||||
|
||||
|
||||
def load_account(
|
||||
brokername: str,
|
||||
acctid: str,
|
||||
|
||||
) -> tuple[dict, Path]:
|
||||
'''
|
||||
Load a accounting (with positions) file from
|
||||
$PIKER_CONFIG_DIR/accounting/account.<brokername>.<acctid>.toml.
|
||||
|
||||
Where normally $PIKER_CONFIG_DIR = ~/.config/piker/
|
||||
and we implicitly create a accounting subdir which should
|
||||
normally be linked to a git repo managed by the user B)
|
||||
|
||||
'''
|
||||
legacy_fn: str = f'pps.{brokername}.{acctid}.toml'
|
||||
fn: str = f'account.{brokername}.{acctid}.toml'
|
||||
|
||||
dirpath: Path = _config_dir / 'accounting'
|
||||
if not dirpath.is_dir():
|
||||
dirpath.mkdir()
|
||||
|
||||
config, path = load(
|
||||
path=dirpath / fn,
|
||||
decode=tomlkit.parse,
|
||||
touch_if_dne=True,
|
||||
)
|
||||
|
||||
if not config:
|
||||
legacypath = dirpath / legacy_fn
|
||||
log.warning(
|
||||
f'Your account file is using the legacy `pps.` prefix..\n'
|
||||
f'Rewriting contents to new name -> {path}\n'
|
||||
'Please delete the old file!\n'
|
||||
f'|-> {legacypath}\n'
|
||||
)
|
||||
if legacypath.is_file():
|
||||
legacy_config, _ = load(
|
||||
path=legacypath,
|
||||
|
||||
# TODO: move to tomlkit:
|
||||
# - needs to be fixed to support bidict?
|
||||
# https://github.com/sdispater/tomlkit/issues/289
|
||||
# - we need to use or fork's fix to do multiline array
|
||||
# indenting.
|
||||
decode=tomlkit.parse,
|
||||
)
|
||||
config.update(legacy_config)
|
||||
|
||||
# XXX: override the presumably previously non-existant
|
||||
# file with legacy's contents.
|
||||
write(
|
||||
config,
|
||||
path=path,
|
||||
fail_empty=False,
|
||||
)
|
||||
|
||||
return config, path
|
||||
|
||||
|
||||
def load_ledger(
|
||||
brokername: str,
|
||||
acctid: str,
|
||||
|
||||
) -> tuple[dict, Path]:
|
||||
|
||||
ldir: Path = _config_dir / 'accounting' / 'ledgers'
|
||||
if not ldir.is_dir():
|
||||
ldir.mkdir()
|
||||
|
||||
fname = f'trades_{brokername}_{acctid}.toml'
|
||||
fpath: Path = ldir / fname
|
||||
|
||||
if not fpath.is_file():
|
||||
log.info(
|
||||
f'Creating new local trades ledger: {fpath}'
|
||||
)
|
||||
fpath.touch()
|
||||
|
||||
with fpath.open(mode='rb') as cf:
|
||||
start = time.time()
|
||||
ledger_dict = tomllib.load(cf)
|
||||
log.debug(f'Ledger load took {time.time() - start}s')
|
||||
|
||||
return ledger_dict, fpath
|
||||
|
||||
|
||||
def write(
|
||||
config: dict, # toml config as dict
|
||||
name: str = 'brokers',
|
||||
path: str = None,
|
||||
|
||||
name: str | None = None,
|
||||
path: Path | None = None,
|
||||
fail_empty: bool = True,
|
||||
|
||||
**toml_kwargs,
|
||||
|
||||
) -> None:
|
||||
|
@ -271,32 +395,37 @@ def write(
|
|||
Create a ``brokers.ini`` file if one does not exist.
|
||||
|
||||
'''
|
||||
path = path or get_conf_path(name)
|
||||
dirname = os.path.dirname(path)
|
||||
if not os.path.isdir(dirname):
|
||||
log.debug(f"Creating config dir {_config_dir}")
|
||||
os.makedirs(dirname)
|
||||
if name:
|
||||
path: Path = path or get_conf_path(name)
|
||||
dirname: Path = path.parent
|
||||
if not dirname.is_dir():
|
||||
log.debug(f"Creating config dir {_config_dir}")
|
||||
dirname.mkdir()
|
||||
|
||||
if not config and fail_empty:
|
||||
if (
|
||||
not config
|
||||
and fail_empty
|
||||
):
|
||||
raise ValueError(
|
||||
"Watch out you're trying to write a blank config!")
|
||||
"Watch out you're trying to write a blank config!"
|
||||
)
|
||||
|
||||
log.debug(
|
||||
f"Writing config `{name}` file to:\n"
|
||||
f"{path}"
|
||||
)
|
||||
with open(path, 'w') as cf:
|
||||
return toml.dump(
|
||||
with path.open(mode='w') as fp:
|
||||
return tomlkit.dump( # preserve style on write B)
|
||||
config,
|
||||
cf,
|
||||
fp,
|
||||
**toml_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def load_accounts(
|
||||
providers: Optional[list[str]] = None
|
||||
providers: list[str] | None = None
|
||||
|
||||
) -> bidict[str, Optional[str]]:
|
||||
) -> bidict[str, str | None]:
|
||||
|
||||
conf, path = load()
|
||||
accounts = bidict()
|
||||
|
|
|
@ -25,7 +25,7 @@ sharing live streams over a network.
|
|||
import tractor
|
||||
import trio
|
||||
|
||||
from ..log import (
|
||||
from ._util import (
|
||||
get_console_log,
|
||||
)
|
||||
from ._normalize import iterticks
|
||||
|
@ -50,39 +50,3 @@ __all__ = [
|
|||
'open_shm_array',
|
||||
'get_shm_token',
|
||||
]
|
||||
|
||||
|
||||
@tractor.context
|
||||
async def _setup_persistent_brokerd(
|
||||
ctx: tractor.Context,
|
||||
brokername: str,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Allocate a actor-wide service nursery in ``brokerd``
|
||||
such that feeds can be run in the background persistently by
|
||||
the broker backend as needed.
|
||||
|
||||
'''
|
||||
get_console_log(tractor.current_actor().loglevel)
|
||||
|
||||
from .feed import (
|
||||
_bus,
|
||||
get_feed_bus,
|
||||
)
|
||||
global _bus
|
||||
assert not _bus
|
||||
|
||||
async with trio.open_nursery() as service_nursery:
|
||||
# assign a nursery to the feeds bus for spawning
|
||||
# background tasks from clients
|
||||
get_feed_bus(brokername, service_nursery)
|
||||
|
||||
# unblock caller
|
||||
await ctx.started()
|
||||
|
||||
# we pin this task to keep the feeds manager active until the
|
||||
# parent actor decides to tear it down
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
||||
|
|
|
@ -42,10 +42,7 @@ from numba import (
|
|||
# float64, optional, int64,
|
||||
)
|
||||
|
||||
from ..log import get_logger
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
from ._util import log
|
||||
|
||||
|
||||
def ds_m4(
|
||||
|
|
|
@ -38,8 +38,8 @@ from tractor.trionics import (
|
|||
import trio
|
||||
from trio_typing import TaskStatus
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
from ._util import (
|
||||
log,
|
||||
get_console_log,
|
||||
)
|
||||
from ..service import maybe_spawn_daemon
|
||||
|
@ -50,8 +50,6 @@ if TYPE_CHECKING:
|
|||
)
|
||||
from .feed import _FeedsBus
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# highest frequency sample step is 1 second by default, though in
|
||||
# the future we may want to support shorter periods or a dynamic style
|
||||
|
@ -353,7 +351,9 @@ async def register_with_sampler(
|
|||
|
||||
if open_index_stream:
|
||||
try:
|
||||
async with ctx.open_stream() as stream:
|
||||
async with ctx.open_stream(
|
||||
allow_overruns=True,
|
||||
) as stream:
|
||||
if sub_for_broadcasts:
|
||||
subs.add(stream)
|
||||
|
||||
|
@ -362,7 +362,10 @@ async def register_with_sampler(
|
|||
if msg == 'broadcast_all':
|
||||
await Sampler.broadcast_all()
|
||||
finally:
|
||||
if sub_for_broadcasts:
|
||||
if (
|
||||
sub_for_broadcasts
|
||||
and subs
|
||||
):
|
||||
subs.remove(stream)
|
||||
else:
|
||||
# if no shms are passed in we just wait until cancelled
|
||||
|
@ -429,7 +432,7 @@ async def spawn_samplerd(
|
|||
async def maybe_open_samplerd(
|
||||
|
||||
loglevel: str | None = None,
|
||||
**kwargs,
|
||||
**pikerd_kwargs,
|
||||
|
||||
) -> tractor.Portal: # noqa
|
||||
'''
|
||||
|
@ -442,9 +445,9 @@ async def maybe_open_samplerd(
|
|||
async with maybe_spawn_daemon(
|
||||
dname,
|
||||
service_task_target=spawn_samplerd,
|
||||
spawn_args={'loglevel': loglevel},
|
||||
spawn_args={},
|
||||
loglevel=loglevel,
|
||||
**kwargs,
|
||||
**pikerd_kwargs,
|
||||
|
||||
) as portal:
|
||||
yield portal
|
||||
|
@ -615,10 +618,10 @@ async def sample_and_broadcast(
|
|||
] = bus.get_subs(sub_key)
|
||||
|
||||
# NOTE: by default the broker backend doesn't append
|
||||
# it's own "name" into the fqsn schema (but maybe it
|
||||
# it's own "name" into the fqme schema (but maybe it
|
||||
# should?) so we have to manually generate the correct
|
||||
# key here.
|
||||
fqsn = f'{broker_symbol}.{brokername}'
|
||||
fqme = f'{broker_symbol}.{brokername}'
|
||||
lags: int = 0
|
||||
|
||||
# TODO: speed up this loop in an AOT compiled lang (like
|
||||
|
@ -637,7 +640,7 @@ async def sample_and_broadcast(
|
|||
# pushes to the ``uniform_rate_send()`` below.
|
||||
try:
|
||||
stream.send_nowait(
|
||||
(fqsn, quote)
|
||||
(fqme, quote)
|
||||
)
|
||||
except trio.WouldBlock:
|
||||
overruns[sub_key] += 1
|
||||
|
@ -669,7 +672,7 @@ async def sample_and_broadcast(
|
|||
raise trio.BrokenResourceError
|
||||
else:
|
||||
await stream.send(
|
||||
{fqsn: quote}
|
||||
{fqme: quote}
|
||||
)
|
||||
|
||||
if cs.cancelled_caught:
|
||||
|
@ -782,9 +785,6 @@ async def uniform_rate_send(
|
|||
https://gist.github.com/njsmith/7ea44ec07e901cb78ebe1dd8dd846cb9
|
||||
|
||||
'''
|
||||
# try not to error-out on overruns of the subscribed client
|
||||
stream._ctx._backpressure = True
|
||||
|
||||
# TODO: compute the approx overhead latency per cycle
|
||||
left_to_sleep = throttle_period = 1/rate - 0.000616
|
||||
|
||||
|
|
|
@ -32,14 +32,11 @@ import numpy as np
|
|||
from numpy.lib import recfunctions as rfn
|
||||
import tractor
|
||||
|
||||
from ..log import get_logger
|
||||
from ._util import log
|
||||
from ._source import base_iohlc_dtype
|
||||
from .types import Struct
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# how much is probably dependent on lifestyle
|
||||
_secs_in_day = int(60 * 60 * 24)
|
||||
# we try for a buncha times, but only on a run-every-other-day kinda week.
|
||||
|
@ -649,7 +646,7 @@ def maybe_open_shm_array(
|
|||
token = _known_tokens[key]
|
||||
return attach_shm_array(token=token, **kwargs), False
|
||||
except KeyError:
|
||||
log.warning(f"Could not find {key} in shms cache")
|
||||
log.debug(f"Could not find {key} in shms cache")
|
||||
if dtype:
|
||||
token = _make_token(
|
||||
key,
|
||||
|
@ -659,7 +656,7 @@ def maybe_open_shm_array(
|
|||
try:
|
||||
return attach_shm_array(token=token, **kwargs), False
|
||||
except FileNotFoundError:
|
||||
log.warning(f"Could not attach to shm with token {token}")
|
||||
log.debug(f"Could not attach to shm with token {token}")
|
||||
|
||||
# This actor does not know about memory
|
||||
# associated with the provided "key".
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship for piker0)
|
||||
# Copyright (C) 2018-present 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
|
||||
|
@ -18,18 +18,10 @@
|
|||
numpy data source coversion helpers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from decimal import (
|
||||
Decimal,
|
||||
ROUND_HALF_EVEN,
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
from bidict import bidict
|
||||
import numpy as np
|
||||
|
||||
from .types import Struct
|
||||
# from numba import from_dtype
|
||||
|
||||
|
||||
ohlc_fields = [
|
||||
('time', float),
|
||||
|
@ -50,6 +42,7 @@ base_ohlc_dtype = np.dtype(ohlc_fields)
|
|||
|
||||
# TODO: for now need to construct this manually for readonly arrays, see
|
||||
# https://github.com/numba/numba/issues/4511
|
||||
# from numba import from_dtype
|
||||
# numba_ohlc_dtype = from_dtype(base_ohlc_dtype)
|
||||
|
||||
# map time frame "keys" to seconds values
|
||||
|
@ -64,32 +57,6 @@ tf_in_1s = bidict({
|
|||
})
|
||||
|
||||
|
||||
def mk_fqsn(
|
||||
provider: str,
|
||||
symbol: str,
|
||||
|
||||
) -> str:
|
||||
'''
|
||||
Generate a "fully qualified symbol name" which is
|
||||
a reverse-hierarchical cross broker/provider symbol
|
||||
|
||||
'''
|
||||
return '.'.join([symbol, provider]).lower()
|
||||
|
||||
|
||||
def float_digits(
|
||||
value: float,
|
||||
) -> int:
|
||||
'''
|
||||
Return the number of precision digits read from a float value.
|
||||
|
||||
'''
|
||||
if value == 0:
|
||||
return 0
|
||||
|
||||
return int(-Decimal(str(value)).as_tuple().exponent)
|
||||
|
||||
|
||||
def ohlc_zeros(length: int) -> np.ndarray:
|
||||
"""Construct an OHLC field formatted structarray.
|
||||
|
||||
|
@ -100,220 +67,6 @@ def ohlc_zeros(length: int) -> np.ndarray:
|
|||
return np.zeros(length, dtype=base_ohlc_dtype)
|
||||
|
||||
|
||||
def unpack_fqsn(fqsn: str) -> tuple[str, str, str]:
|
||||
'''
|
||||
Unpack a fully-qualified-symbol-name to ``tuple``.
|
||||
|
||||
'''
|
||||
venue = ''
|
||||
suffix = ''
|
||||
|
||||
# TODO: probably reverse the order of all this XD
|
||||
tokens = fqsn.split('.')
|
||||
if len(tokens) < 3:
|
||||
# probably crypto
|
||||
symbol, broker = tokens
|
||||
return (
|
||||
broker,
|
||||
symbol,
|
||||
'',
|
||||
)
|
||||
|
||||
elif len(tokens) > 3:
|
||||
symbol, venue, suffix, broker = tokens
|
||||
else:
|
||||
symbol, venue, broker = tokens
|
||||
suffix = ''
|
||||
|
||||
# head, _, broker = fqsn.rpartition('.')
|
||||
# symbol, _, suffix = head.rpartition('.')
|
||||
return (
|
||||
broker,
|
||||
'.'.join([symbol, venue]),
|
||||
suffix,
|
||||
)
|
||||
|
||||
|
||||
class MktPair(Struct, frozen=True):
|
||||
|
||||
src: str # source asset name being used to buy
|
||||
src_type: str # source asset's financial type/classification name
|
||||
# ^ specifies a "class" of financial instrument
|
||||
# egs. stock, futer, option, bond etc.
|
||||
|
||||
dst: str # destination asset name being bought
|
||||
dst_type: str # destination asset's financial type/classification name
|
||||
|
||||
price_tick: float # minimum price increment value increment
|
||||
price_tick_digits: int # required decimal digits for above
|
||||
|
||||
size_tick: float # minimum size (aka vlm) increment value increment
|
||||
size_tick_digits: int # required decimal digits for above
|
||||
|
||||
venue: str | None = None # market venue provider name
|
||||
expiry: str | None = None # for derivs, expiry datetime parseable str
|
||||
|
||||
# for derivs, info describing contract, egs.
|
||||
# strike price, call or put, swap type, exercise model, etc.
|
||||
contract_info: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_msg(
|
||||
self,
|
||||
msg: dict[str, Any],
|
||||
|
||||
) -> MktPair:
|
||||
'''
|
||||
Constructor for a received msg-dict normally received over IPC.
|
||||
|
||||
'''
|
||||
...
|
||||
|
||||
# fqa, fqma, .. etc. see issue:
|
||||
# https://github.com/pikers/piker/issues/467
|
||||
@property
|
||||
def fqsn(self) -> str:
|
||||
'''
|
||||
Return the fully qualified market (endpoint) name for the
|
||||
pair of transacting assets.
|
||||
|
||||
'''
|
||||
...
|
||||
|
||||
|
||||
# TODO: rework the below `Symbol` (which was originally inspired and
|
||||
# derived from stuff in quantdom) into a simpler, ipc msg ready, market
|
||||
# endpoint meta-data container type as per the drafted interace above.
|
||||
class Symbol(Struct):
|
||||
'''
|
||||
I guess this is some kinda container thing for dealing with
|
||||
all the different meta-data formats from brokers?
|
||||
|
||||
'''
|
||||
key: str
|
||||
tick_size: float = 0.01
|
||||
lot_tick_size: float = 0.0 # "volume" precision as min step value
|
||||
tick_size_digits: int = 2
|
||||
lot_size_digits: int = 0
|
||||
suffix: str = ''
|
||||
broker_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def from_broker_info(
|
||||
cls,
|
||||
broker: str,
|
||||
symbol: str,
|
||||
info: dict[str, Any],
|
||||
suffix: str = '',
|
||||
|
||||
) -> Symbol:
|
||||
|
||||
tick_size = info.get('price_tick_size', 0.01)
|
||||
lot_size = info.get('lot_tick_size', 0.0)
|
||||
|
||||
return Symbol(
|
||||
key=symbol,
|
||||
tick_size=tick_size,
|
||||
lot_tick_size=lot_size,
|
||||
tick_size_digits=float_digits(tick_size),
|
||||
lot_size_digits=float_digits(lot_size),
|
||||
suffix=suffix,
|
||||
broker_info={broker: info},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_fqsn(
|
||||
cls,
|
||||
fqsn: str,
|
||||
info: dict[str, Any],
|
||||
|
||||
) -> Symbol:
|
||||
broker, key, suffix = unpack_fqsn(fqsn)
|
||||
return cls.from_broker_info(
|
||||
broker,
|
||||
key,
|
||||
info=info,
|
||||
suffix=suffix,
|
||||
)
|
||||
|
||||
@property
|
||||
def type_key(self) -> str:
|
||||
return list(self.broker_info.values())[0]['asset_type']
|
||||
|
||||
@property
|
||||
def brokers(self) -> list[str]:
|
||||
return list(self.broker_info.keys())
|
||||
|
||||
def nearest_tick(self, value: float) -> float:
|
||||
'''
|
||||
Return the nearest tick value based on mininum increment.
|
||||
|
||||
'''
|
||||
mult = 1 / self.tick_size
|
||||
return round(value * mult) / mult
|
||||
|
||||
def front_feed(self) -> tuple[str, str]:
|
||||
'''
|
||||
Return the "current" feed key for this symbol.
|
||||
|
||||
(i.e. the broker + symbol key in a tuple).
|
||||
|
||||
'''
|
||||
return (
|
||||
list(self.broker_info.keys())[0],
|
||||
self.key,
|
||||
)
|
||||
|
||||
def tokens(self) -> tuple[str]:
|
||||
broker, key = self.front_feed()
|
||||
if self.suffix:
|
||||
return (key, self.suffix, broker)
|
||||
else:
|
||||
return (key, broker)
|
||||
|
||||
@property
|
||||
def fqsn(self) -> str:
|
||||
return '.'.join(self.tokens()).lower()
|
||||
|
||||
def front_fqsn(self) -> str:
|
||||
'''
|
||||
fqsn = "fully qualified symbol name"
|
||||
|
||||
Basically the idea here is for all client-ish code (aka programs/actors
|
||||
that ask the provider agnostic layers in the stack for data) should be
|
||||
able to tell which backend / venue / derivative each data feed/flow is
|
||||
from by an explicit string key of the current form:
|
||||
|
||||
<instrumentname>.<venue>.<suffixwithmetadata>.<brokerbackendname>
|
||||
|
||||
TODO: I have thoughts that we should actually change this to be
|
||||
more like an "attr lookup" (like how the web should have done
|
||||
urls, but marketting peeps ruined it etc. etc.):
|
||||
|
||||
<broker>.<venue>.<instrumentname>.<suffixwithmetadata>
|
||||
|
||||
'''
|
||||
tokens = self.tokens()
|
||||
fqsn = '.'.join(map(str.lower, tokens))
|
||||
return fqsn
|
||||
|
||||
def quantize_size(
|
||||
self,
|
||||
size: float,
|
||||
|
||||
) -> Decimal:
|
||||
'''
|
||||
Truncate input ``size: float`` using ``Decimal``
|
||||
and ``.lot_size_digits``.
|
||||
|
||||
'''
|
||||
digits = self.lot_size_digits
|
||||
return Decimal(size).quantize(
|
||||
Decimal(f'1.{"0".ljust(digits, "0")}'),
|
||||
rounding=ROUND_HALF_EVEN
|
||||
)
|
||||
|
||||
|
||||
def _nan_to_closest_num(array: np.ndarray):
|
||||
"""Return interpolated values instead of NaN.
|
||||
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
'''
|
||||
Data layer module commons.
|
||||
|
||||
'''
|
||||
from functools import partial
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
get_console_log,
|
||||
)
|
||||
subsys: str = 'piker.data'
|
||||
|
||||
log = get_logger(subsys)
|
||||
|
||||
get_console_log = partial(
|
||||
get_console_log,
|
||||
name=subsys,
|
||||
)
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
# 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
|
||||
|
@ -18,23 +18,29 @@
|
|||
ToOlS fOr CoPInG wITh "tHE wEB" protocols.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from contextlib import (
|
||||
asynccontextmanager,
|
||||
AsyncExitStack,
|
||||
asynccontextmanager as acm,
|
||||
)
|
||||
from itertools import count
|
||||
from functools import partial
|
||||
from types import ModuleType
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
Callable,
|
||||
AsyncContextManager,
|
||||
AsyncGenerator,
|
||||
Iterable,
|
||||
)
|
||||
import json
|
||||
|
||||
import trio
|
||||
import trio_websocket
|
||||
from trio_typing import TaskStatus
|
||||
from trio_websocket import (
|
||||
WebSocketConnection,
|
||||
open_websocket_url,
|
||||
)
|
||||
from wsproto.utilities import LocalProtocolError
|
||||
from trio_websocket._impl import (
|
||||
ConnectionClosed,
|
||||
|
@ -44,20 +50,23 @@ from trio_websocket._impl import (
|
|||
ConnectionTimeout,
|
||||
)
|
||||
|
||||
from ..log import get_logger
|
||||
|
||||
from ._util import log
|
||||
from .types import Struct
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class NoBsWs:
|
||||
'''
|
||||
Make ``trio_websocket`` sockets stay up no matter the bs.
|
||||
|
||||
You can provide a ``fixture`` async-context-manager which will be
|
||||
enter/exitted around each reconnect operation.
|
||||
A shim interface that allows client code to stream from some
|
||||
``WebSocketConnection`` but where any connectivy bs is handled
|
||||
automatcially and entirely in the background.
|
||||
|
||||
NOTE: this type should never be created directly but instead is
|
||||
provided via the ``open_autorecon_ws()`` factor below.
|
||||
|
||||
'''
|
||||
# apparently we can QoS for all sorts of reasons..so catch em.
|
||||
recon_errors = (
|
||||
ConnectionClosed,
|
||||
DisconnectionTimeout,
|
||||
|
@ -70,68 +79,42 @@ class NoBsWs:
|
|||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
stack: AsyncExitStack,
|
||||
fixture: Optional[Callable] = None,
|
||||
rxchan: trio.MemoryReceiveChannel,
|
||||
msg_recv_timeout: float,
|
||||
|
||||
serializer: ModuleType = json
|
||||
):
|
||||
self.url = url
|
||||
self.fixture = fixture
|
||||
self._stack = stack
|
||||
self._ws: 'WebSocketConnection' = None # noqa
|
||||
self._rx = rxchan
|
||||
self._timeout = msg_recv_timeout
|
||||
|
||||
# TODO: is there some method we can call
|
||||
# on the underlying `._ws` to get this?
|
||||
self._connected: bool = False
|
||||
# signaling between caller and relay task which determines when
|
||||
# socket is connected (and subscribed).
|
||||
self._connected: trio.Event = trio.Event()
|
||||
|
||||
async def _connect(
|
||||
self,
|
||||
tries: int = 1000,
|
||||
) -> None:
|
||||
# dynamically reset by the bg relay task
|
||||
self._ws: WebSocketConnection | None = None
|
||||
self._cs: trio.CancelScope | None = None
|
||||
|
||||
self._connected = False
|
||||
while True:
|
||||
try:
|
||||
await self._stack.aclose()
|
||||
except self.recon_errors:
|
||||
await trio.sleep(0.5)
|
||||
else:
|
||||
break
|
||||
|
||||
last_err = None
|
||||
for i in range(tries):
|
||||
try:
|
||||
self._ws = await self._stack.enter_async_context(
|
||||
trio_websocket.open_websocket_url(self.url)
|
||||
)
|
||||
|
||||
if self.fixture is not None:
|
||||
# rerun user code fixture
|
||||
ret = await self._stack.enter_async_context(
|
||||
self.fixture(self)
|
||||
)
|
||||
|
||||
assert ret is None
|
||||
|
||||
log.info(f'Connection success: {self.url}')
|
||||
|
||||
self._connected = True
|
||||
return self._ws
|
||||
|
||||
except self.recon_errors as err:
|
||||
last_err = err
|
||||
log.error(
|
||||
f'{self} connection bail with '
|
||||
f'{type(err)}...retry attempt {i}'
|
||||
)
|
||||
await trio.sleep(0.5)
|
||||
self._connected = False
|
||||
continue
|
||||
else:
|
||||
log.exception('ws connection fail...')
|
||||
raise last_err
|
||||
# interchange codec methods
|
||||
# TODO: obviously the method API here may be different
|
||||
# for another interchange format..
|
||||
self._dumps: Callable = serializer.dumps
|
||||
self._loads: Callable = serializer.loads
|
||||
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
return self._connected.is_set()
|
||||
|
||||
async def reset(self) -> None:
|
||||
'''
|
||||
Reset the underlying ws connection by cancelling
|
||||
the bg relay task and waiting for it to signal
|
||||
a new connection.
|
||||
|
||||
'''
|
||||
self._connected = trio.Event()
|
||||
self._cs.cancel()
|
||||
await self._connected.wait()
|
||||
|
||||
async def send_msg(
|
||||
self,
|
||||
|
@ -139,18 +122,15 @@ class NoBsWs:
|
|||
) -> None:
|
||||
while True:
|
||||
try:
|
||||
return await self._ws.send_message(json.dumps(data))
|
||||
msg: Any = self._dumps(data)
|
||||
return await self._ws.send_message(msg)
|
||||
except self.recon_errors:
|
||||
await self._connect()
|
||||
await self.reset()
|
||||
|
||||
async def recv_msg(
|
||||
self,
|
||||
) -> Any:
|
||||
while True:
|
||||
try:
|
||||
return json.loads(await self._ws.get_message())
|
||||
except self.recon_errors:
|
||||
await self._connect()
|
||||
async def recv_msg(self) -> Any:
|
||||
msg: Any = await self._rx.receive()
|
||||
data = self._loads(msg)
|
||||
return data
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
@ -158,27 +138,218 @@ class NoBsWs:
|
|||
async def __anext__(self):
|
||||
return await self.recv_msg()
|
||||
|
||||
def set_recv_timeout(
|
||||
self,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
self._timeout = timeout
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
async def _reconnect_forever(
|
||||
url: str,
|
||||
snd: trio.MemorySendChannel,
|
||||
nobsws: NoBsWs,
|
||||
reset_after: int, # msg recv timeout before reset attempt
|
||||
|
||||
fixture: AsyncContextManager | None = None,
|
||||
task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
) -> None:
|
||||
|
||||
# TODO: can we just report "where" in the call stack
|
||||
# the client code is using the ws stream?
|
||||
# Maybe we can just drop this since it's already in the log msg
|
||||
# orefix?
|
||||
if fixture is not None:
|
||||
src_mod: str = fixture.__module__
|
||||
else:
|
||||
src_mod: str = 'unknown'
|
||||
|
||||
async def proxy_msgs(
|
||||
ws: WebSocketConnection,
|
||||
pcs: trio.CancelScope, # parent cancel scope
|
||||
):
|
||||
'''
|
||||
Receive (under `timeout` deadline) all msgs from from underlying
|
||||
websocket and relay them to (calling) parent task via ``trio``
|
||||
mem chan.
|
||||
|
||||
'''
|
||||
# after so many msg recv timeouts, reset the connection
|
||||
timeouts: int = 0
|
||||
|
||||
while True:
|
||||
with trio.move_on_after(
|
||||
# can be dynamically changed by user code
|
||||
nobsws._timeout,
|
||||
) as cs:
|
||||
try:
|
||||
msg: Any = await ws.get_message()
|
||||
await snd.send(msg)
|
||||
except nobsws.recon_errors:
|
||||
log.exception(
|
||||
f'{src_mod}\n'
|
||||
f'{url} connection bail with:'
|
||||
)
|
||||
await trio.sleep(0.5)
|
||||
pcs.cancel()
|
||||
|
||||
# go back to reonnect loop in parent task
|
||||
return
|
||||
|
||||
if cs.cancelled_caught:
|
||||
timeouts += 1
|
||||
if timeouts > reset_after:
|
||||
log.error(
|
||||
f'{src_mod}\n'
|
||||
'WS feed seems down and slow af.. reconnecting\n'
|
||||
)
|
||||
pcs.cancel()
|
||||
|
||||
# go back to reonnect loop in parent task
|
||||
return
|
||||
|
||||
async def open_fixture(
|
||||
fixture: AsyncContextManager,
|
||||
nobsws: NoBsWs,
|
||||
task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
|
||||
):
|
||||
'''
|
||||
Open user provided `@acm` and sleep until any connection
|
||||
reset occurs.
|
||||
|
||||
'''
|
||||
async with fixture(nobsws) as ret:
|
||||
assert ret is None
|
||||
task_status.started()
|
||||
await trio.sleep_forever()
|
||||
|
||||
# last_err = None
|
||||
nobsws._connected = trio.Event()
|
||||
task_status.started()
|
||||
|
||||
while not snd._closed:
|
||||
log.info(
|
||||
f'{src_mod}\n'
|
||||
f'{url} trying (RE)CONNECT'
|
||||
)
|
||||
|
||||
async with trio.open_nursery() as n:
|
||||
cs = nobsws._cs = n.cancel_scope
|
||||
ws: WebSocketConnection
|
||||
async with open_websocket_url(url) as ws:
|
||||
nobsws._ws = ws
|
||||
log.info(
|
||||
f'{src_mod}\n'
|
||||
f'Connection success: {url}'
|
||||
)
|
||||
|
||||
# begin relay loop to forward msgs
|
||||
n.start_soon(
|
||||
proxy_msgs,
|
||||
ws,
|
||||
cs,
|
||||
)
|
||||
|
||||
if fixture is not None:
|
||||
log.info(
|
||||
f'{src_mod}\n'
|
||||
f'Entering fixture: {fixture}'
|
||||
)
|
||||
|
||||
# TODO: should we return an explicit sub-cs
|
||||
# from this fixture task?
|
||||
await n.start(
|
||||
open_fixture,
|
||||
fixture,
|
||||
nobsws,
|
||||
)
|
||||
|
||||
# indicate to wrapper / opener that we are up and block
|
||||
# to let tasks run **inside** the ws open block above.
|
||||
nobsws._connected.set()
|
||||
await trio.sleep_forever()
|
||||
|
||||
# ws open block end
|
||||
# nursery block end
|
||||
nobsws._connected = trio.Event()
|
||||
if cs.cancelled_caught:
|
||||
log.cancel(
|
||||
f'{url} connection cancelled!'
|
||||
)
|
||||
# if wrapper cancelled us, we expect it to also
|
||||
# have re-assigned a new event
|
||||
assert (
|
||||
nobsws._connected
|
||||
and not nobsws._connected.is_set()
|
||||
)
|
||||
|
||||
# -> from here, move to next reconnect attempt
|
||||
|
||||
else:
|
||||
log.exception(
|
||||
f'{src_mod}\n'
|
||||
'ws connection closed by client...'
|
||||
)
|
||||
|
||||
|
||||
@acm
|
||||
async def open_autorecon_ws(
|
||||
url: str,
|
||||
|
||||
# TODO: proper type cannot smh
|
||||
fixture: Optional[Callable] = None,
|
||||
fixture: AsyncContextManager | None = None,
|
||||
|
||||
) -> AsyncGenerator[tuple[...], NoBsWs]:
|
||||
"""Apparently we can QoS for all sorts of reasons..so catch em.
|
||||
# time in sec between msgs received before
|
||||
# we presume connection might need a reset.
|
||||
msg_recv_timeout: float = 16,
|
||||
|
||||
"""
|
||||
async with AsyncExitStack() as stack:
|
||||
ws = NoBsWs(url, stack, fixture=fixture)
|
||||
await ws._connect()
|
||||
# count of the number of above timeouts before connection reset
|
||||
reset_after: int = 3,
|
||||
|
||||
) -> AsyncGenerator[tuple[...], NoBsWs]:
|
||||
'''
|
||||
An auto-reconnect websocket (wrapper API) around
|
||||
``trio_websocket.open_websocket_url()`` providing automatic
|
||||
re-connection on network errors, msg latency and thus roaming.
|
||||
|
||||
Here we implement a re-connect websocket interface where a bg
|
||||
nursery runs ``WebSocketConnection.receive_message()``s in a loop
|
||||
and restarts the full http(s) handshake on catches of certain
|
||||
connetivity errors, or some user defined recv timeout.
|
||||
|
||||
You can provide a ``fixture`` async-context-manager which will be
|
||||
entered/exitted around each connection reset; eg. for (re)requesting
|
||||
subscriptions without requiring streaming setup code to rerun.
|
||||
|
||||
'''
|
||||
snd: trio.MemorySendChannel
|
||||
rcv: trio.MemoryReceiveChannel
|
||||
snd, rcv = trio.open_memory_channel(616)
|
||||
|
||||
async with trio.open_nursery() as n:
|
||||
nobsws = NoBsWs(
|
||||
url,
|
||||
rcv,
|
||||
msg_recv_timeout=msg_recv_timeout,
|
||||
)
|
||||
await n.start(
|
||||
partial(
|
||||
_reconnect_forever,
|
||||
url,
|
||||
snd,
|
||||
nobsws,
|
||||
fixture=fixture,
|
||||
reset_after=reset_after,
|
||||
)
|
||||
)
|
||||
await nobsws._connected.wait()
|
||||
assert nobsws._cs
|
||||
assert nobsws.connected()
|
||||
|
||||
try:
|
||||
yield ws
|
||||
|
||||
yield nobsws
|
||||
finally:
|
||||
await stack.aclose()
|
||||
n.cancel_scope.cancel()
|
||||
|
||||
|
||||
'''
|
||||
|
@ -195,7 +366,7 @@ class JSONRPCResult(Struct):
|
|||
error: Optional[dict] = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@acm
|
||||
async def open_jsonrpc_session(
|
||||
url: str,
|
||||
start_id: int = 0,
|
||||
|
|
|
@ -32,14 +32,11 @@ from ..service.marketstore import (
|
|||
)
|
||||
from ..cli import cli
|
||||
from .. import watchlists as wl
|
||||
from ..log import (
|
||||
get_logger,
|
||||
from ._util import (
|
||||
log,
|
||||
)
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
'--url',
|
||||
|
@ -187,10 +184,10 @@ def storage(
|
|||
symbol = symbols[0]
|
||||
async with open_tsdb_client(symbol) as storage:
|
||||
if delete:
|
||||
for fqsn in symbols:
|
||||
for fqme in symbols:
|
||||
syms = await storage.client.list_symbols()
|
||||
|
||||
resp60s = await storage.delete_ts(fqsn, 60)
|
||||
resp60s = await storage.delete_ts(fqme, 60)
|
||||
|
||||
msgish = resp60s.ListFields()[0][1]
|
||||
if 'error' in str(msgish):
|
||||
|
@ -202,15 +199,15 @@ def storage(
|
|||
# well, if we ever can make this work we
|
||||
# probably want to dogsplain the real reason
|
||||
# for the delete errurz..llululu
|
||||
if fqsn not in syms:
|
||||
log.error(f'Pair {fqsn} dne in DB')
|
||||
if fqme not in syms:
|
||||
log.error(f'Pair {fqme} dne in DB')
|
||||
|
||||
log.error(f'Deletion error: {fqsn}\n{msgish}')
|
||||
log.error(f'Deletion error: {fqme}\n{msgish}')
|
||||
|
||||
resp1s = await storage.delete_ts(fqsn, 1)
|
||||
resp1s = await storage.delete_ts(fqme, 1)
|
||||
msgish = resp1s.ListFields()[0][1]
|
||||
if 'error' in str(msgish):
|
||||
log.error(f'Deletion error: {fqsn}\n{msgish}')
|
||||
log.error(f'Deletion error: {fqme}\n{msgish}')
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
|
1002
piker/data/feed.py
1002
piker/data/feed.py
File diff suppressed because it is too large
Load Diff
|
@ -15,7 +15,7 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
abstractions for organizing, managing and generally operating-on
|
||||
Public abstractions for organizing, managing and generally operating-on
|
||||
real-time data processing data-structures.
|
||||
|
||||
"Streams, flumes, cascades and flows.."
|
||||
|
@ -30,10 +30,9 @@ import tractor
|
|||
import pendulum
|
||||
import numpy as np
|
||||
|
||||
from ..accounting import MktPair
|
||||
from ._util import log
|
||||
from .types import Struct
|
||||
from ._source import (
|
||||
Symbol,
|
||||
)
|
||||
from ._sharedmem import (
|
||||
attach_shm_array,
|
||||
ShmArray,
|
||||
|
@ -89,7 +88,7 @@ class Flume(Struct):
|
|||
queuing properties.
|
||||
|
||||
'''
|
||||
symbol: Symbol
|
||||
mkt: MktPair
|
||||
first_quote: dict
|
||||
_rt_shm_token: _Token
|
||||
|
||||
|
@ -172,8 +171,9 @@ class Flume(Struct):
|
|||
|
||||
# TODO: get native msgspec decoding for these workinn
|
||||
def to_msg(self) -> dict:
|
||||
|
||||
msg = self.to_dict()
|
||||
msg['symbol'] = msg['symbol'].to_dict()
|
||||
msg['mkt'] = self.mkt.to_dict()
|
||||
|
||||
# can't serialize the stream or feed objects, it's expected
|
||||
# you'll have a ref to it since this msg should be rxed on
|
||||
|
@ -183,12 +183,19 @@ class Flume(Struct):
|
|||
return msg
|
||||
|
||||
@classmethod
|
||||
def from_msg(cls, msg: dict) -> dict:
|
||||
symbol = Symbol(**msg.pop('symbol'))
|
||||
return cls(
|
||||
symbol=symbol,
|
||||
**msg,
|
||||
)
|
||||
def from_msg(
|
||||
cls,
|
||||
msg: dict,
|
||||
|
||||
) -> dict:
|
||||
'''
|
||||
Load from an IPC msg presumably in either `dict` or
|
||||
`msgspec.Struct` form.
|
||||
|
||||
'''
|
||||
mkt_msg = msg.pop('mkt')
|
||||
mkt = MktPair.from_msg(mkt_msg)
|
||||
return cls(mkt=mkt, **msg)
|
||||
|
||||
def get_index(
|
||||
self,
|
||||
|
@ -208,3 +215,23 @@ class Flume(Struct):
|
|||
)
|
||||
imx = times.shape[0] - 1
|
||||
return min(first, imx)
|
||||
|
||||
# only set by external msg or creator, never
|
||||
# manually!
|
||||
_has_vlm: bool = True
|
||||
|
||||
def has_vlm(self) -> bool:
|
||||
|
||||
if not self._has_vlm:
|
||||
return False
|
||||
|
||||
# make sure that the instrument supports volume history
|
||||
# (sometimes this is not the case for some commodities and
|
||||
# derivatives)
|
||||
vlm: np.ndarray = self.rt_shm.array['volume']
|
||||
return not bool(
|
||||
np.all(np.isin(vlm, -1))
|
||||
or np.all(np.isnan(vlm))
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,810 @@
|
|||
# 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,
|
||||
Optional,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
import trio
|
||||
from trio_typing import TaskStatus
|
||||
import tractor
|
||||
import pendulum
|
||||
import numpy as np
|
||||
|
||||
from .. import config
|
||||
from ..accounting import (
|
||||
MktPair,
|
||||
)
|
||||
from ._util import (
|
||||
log,
|
||||
)
|
||||
from ..service import (
|
||||
check_for_service,
|
||||
)
|
||||
from ._sharedmem import (
|
||||
maybe_open_shm_array,
|
||||
ShmArray,
|
||||
_secs_in_day,
|
||||
)
|
||||
from ._source import base_iohlc_dtype
|
||||
from ._sampling import (
|
||||
open_sample_stream,
|
||||
)
|
||||
from ..brokers._util import (
|
||||
DataUnavailable,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..service.marketstore import Storage
|
||||
from .feed import _FeedsBus
|
||||
|
||||
|
||||
def diff_history(
|
||||
array: np.ndarray,
|
||||
timeframe: int,
|
||||
start_dt: datetime,
|
||||
end_dt: datetime,
|
||||
last_tsdb_dt: datetime | None = None
|
||||
|
||||
) -> np.ndarray:
|
||||
|
||||
# no diffing with tsdb dt index possible..
|
||||
if last_tsdb_dt is None:
|
||||
return array
|
||||
|
||||
time = array['time']
|
||||
return array[time > last_tsdb_dt.timestamp()]
|
||||
|
||||
|
||||
async def start_backfill(
|
||||
mod: ModuleType,
|
||||
mkt: MktPair,
|
||||
shm: ShmArray,
|
||||
timeframe: float,
|
||||
sampler_stream: tractor.MsgStream,
|
||||
feed_is_live: trio.Event,
|
||||
|
||||
last_tsdb_dt: Optional[datetime] = None,
|
||||
storage: Optional[Storage] = None,
|
||||
write_tsdb: bool = True,
|
||||
tsdb_is_up: bool = False,
|
||||
|
||||
task_status: TaskStatus[tuple] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
) -> int:
|
||||
|
||||
hist: Callable[
|
||||
[int, datetime, datetime],
|
||||
tuple[np.ndarray, str]
|
||||
]
|
||||
config: dict[str, int]
|
||||
|
||||
async with mod.open_history_client(
|
||||
mkt,
|
||||
) as (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
|
||||
array, start_dt, end_dt = await hist(
|
||||
timeframe,
|
||||
end_dt=None,
|
||||
)
|
||||
times = array['time']
|
||||
|
||||
# sample period step size in seconds
|
||||
step_size_s = (
|
||||
pendulum.from_timestamp(times[-1])
|
||||
- pendulum.from_timestamp(times[-2])
|
||||
).seconds
|
||||
|
||||
# if the market is open (aka we have a live feed) but the
|
||||
# history sample step index seems off we report the surrounding
|
||||
# data and drop into a bp. this case shouldn't really ever
|
||||
# happen if we're doing history retrieval correctly.
|
||||
if (
|
||||
step_size_s == 60
|
||||
and feed_is_live.is_set()
|
||||
):
|
||||
inow = round(time.time())
|
||||
diff = inow - times[-1]
|
||||
if abs(diff) > 60:
|
||||
surr = array[-6:]
|
||||
diff_in_mins = round(diff/60., ndigits=2)
|
||||
log.warning(
|
||||
f'STEP ERROR `{mkt.fqme}` for period {step_size_s}s:\n'
|
||||
f'Off by `{diff}` seconds (or `{diff_in_mins}` mins)\n'
|
||||
'Surrounding 6 time stamps:\n'
|
||||
f'{list(surr["time"])}\n'
|
||||
'Here is surrounding 6 samples:\n'
|
||||
f'{surr}\nn'
|
||||
)
|
||||
|
||||
# uncomment this for a hacker who wants to investigate
|
||||
# this case manually..
|
||||
# await tractor.breakpoint()
|
||||
|
||||
# frame's worth of sample-period-steps, in seconds
|
||||
frame_size_s = len(array) * step_size_s
|
||||
|
||||
to_push = diff_history(
|
||||
array,
|
||||
timeframe,
|
||||
start_dt,
|
||||
end_dt,
|
||||
last_tsdb_dt=last_tsdb_dt,
|
||||
)
|
||||
|
||||
log.info(f'Pushing {to_push.size} to shm!')
|
||||
shm.push(to_push, prepend=True)
|
||||
|
||||
# TODO: *** THIS IS A BUG ***
|
||||
# we need to only broadcast to subscribers for this fqme..
|
||||
# otherwise all fsps get reset on every chart..
|
||||
await sampler_stream.send('broadcast_all')
|
||||
|
||||
# signal that backfilling to tsdb's end datum is complete
|
||||
bf_done = trio.Event()
|
||||
|
||||
# let caller unblock and deliver latest history frame
|
||||
task_status.started((
|
||||
start_dt,
|
||||
end_dt,
|
||||
bf_done,
|
||||
))
|
||||
|
||||
# based on the sample step size, maybe load a certain amount history
|
||||
if last_tsdb_dt is None:
|
||||
if step_size_s 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 {step_size_s} seconds..\n'
|
||||
'So yuh.. dun do dat brudder.'
|
||||
)
|
||||
|
||||
# when no tsdb "last datum" is provided, we just load
|
||||
# some near-term history.
|
||||
periods = {
|
||||
1: {'days': 1},
|
||||
60: {'days': 14},
|
||||
}
|
||||
|
||||
if tsdb_is_up:
|
||||
# do a decently sized backfill and load it into storage.
|
||||
periods = {
|
||||
1: {'days': 6},
|
||||
60: {'years': 6},
|
||||
}
|
||||
|
||||
period_duration = periods[step_size_s]
|
||||
|
||||
# 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.
|
||||
last_tsdb_dt = start_dt.subtract(**period_duration)
|
||||
|
||||
# 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()
|
||||
|
||||
# inline sequential loop where we simply pass the
|
||||
# last retrieved start dt to the next request as
|
||||
# it's end dt.
|
||||
while end_dt > last_tsdb_dt:
|
||||
log.debug(
|
||||
f'Requesting {step_size_s}s frame ending in {start_dt}'
|
||||
)
|
||||
|
||||
try:
|
||||
array, next_start_dt, end_dt = await hist(
|
||||
timeframe,
|
||||
end_dt=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
|
||||
|
||||
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
|
||||
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
|
||||
start_dt = next_start_dt
|
||||
starts[start_dt] += 1
|
||||
|
||||
assert array['time'][0] == start_dt.timestamp()
|
||||
|
||||
diff = end_dt - start_dt
|
||||
frame_time_diff_s = diff.seconds
|
||||
expected_frame_size_s = frame_size_s + step_size_s
|
||||
|
||||
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 @ {end_dt} appears to have a gap:\n'
|
||||
f'{diff} ~= {frame_time_diff_s} seconds'
|
||||
)
|
||||
|
||||
to_push = diff_history(
|
||||
array,
|
||||
timeframe,
|
||||
start_dt,
|
||||
end_dt,
|
||||
last_tsdb_dt=last_tsdb_dt,
|
||||
)
|
||||
ln = len(to_push)
|
||||
if ln:
|
||||
log.info(f'{ln} bars for {start_dt} -> {end_dt}')
|
||||
|
||||
else:
|
||||
log.warning(
|
||||
f'{ln} BARS TO PUSH after diff?!: {start_dt} -> {end_dt}'
|
||||
)
|
||||
|
||||
# bail gracefully on shm allocation overrun/full condition
|
||||
try:
|
||||
shm.push(to_push, prepend=True)
|
||||
except ValueError:
|
||||
log.info(
|
||||
f'Shm buffer overrun on: {start_dt} -> {end_dt}?'
|
||||
)
|
||||
# can't push the entire frame? so
|
||||
# push only the amount that can fit..
|
||||
break
|
||||
|
||||
log.info(
|
||||
f'Shm pushed {ln} frame:\n'
|
||||
f'{start_dt} -> {end_dt}'
|
||||
)
|
||||
|
||||
if (
|
||||
storage is not None
|
||||
and write_tsdb
|
||||
):
|
||||
log.info(
|
||||
f'Writing {ln} frame to storage:\n'
|
||||
f'{start_dt} -> {end_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='')
|
||||
|
||||
await storage.write_ohlcv(
|
||||
col_sym_key,
|
||||
to_push,
|
||||
timeframe,
|
||||
)
|
||||
|
||||
# TODO: can we only trigger this if the respective
|
||||
# history in "in view"?!?
|
||||
|
||||
# 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 basic_backfill(
|
||||
bus: _FeedsBus,
|
||||
mod: ModuleType,
|
||||
mkt: MktPair,
|
||||
shms: dict[int, ShmArray],
|
||||
sampler_stream: tractor.MsgStream,
|
||||
feed_is_live: trio.Event,
|
||||
|
||||
) -> None:
|
||||
|
||||
# do a legacy incremental backfill from the provider.
|
||||
log.info('No TSDB (marketstored) found, doing basic backfill..')
|
||||
|
||||
# start history backfill task ``backfill_bars()`` is
|
||||
# a required backend func this must block until shm is
|
||||
# filled with first set of ohlc bars
|
||||
for timeframe, shm in shms.items():
|
||||
try:
|
||||
await bus.nursery.start(
|
||||
partial(
|
||||
start_backfill,
|
||||
mod,
|
||||
mkt,
|
||||
shm,
|
||||
timeframe,
|
||||
sampler_stream,
|
||||
feed_is_live,
|
||||
)
|
||||
)
|
||||
except DataUnavailable:
|
||||
# XXX: timeframe not supported for backend
|
||||
continue
|
||||
|
||||
|
||||
async def tsdb_backfill(
|
||||
mod: ModuleType,
|
||||
marketstore: ModuleType,
|
||||
bus: _FeedsBus,
|
||||
storage: Storage,
|
||||
mkt: MktPair,
|
||||
shms: dict[int, ShmArray],
|
||||
sampler_stream: tractor.MsgStream,
|
||||
feed_is_live: trio.Event,
|
||||
|
||||
task_status: TaskStatus[
|
||||
tuple[ShmArray, ShmArray]
|
||||
] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
) -> None:
|
||||
|
||||
# TODO: this should be used verbatim for the pure
|
||||
# shm backfiller approach below.
|
||||
dts_per_tf: dict[int, datetime] = {}
|
||||
fqme: str = mkt.fqme
|
||||
|
||||
# start history anal and load missing new data via backend.
|
||||
for timeframe, shm in shms.items():
|
||||
# loads a (large) frame of data from the tsdb depending
|
||||
# on the db's query size limit.
|
||||
tsdb_history, first_tsdb_dt, last_tsdb_dt = await storage.load(
|
||||
fqme,
|
||||
timeframe=timeframe,
|
||||
)
|
||||
|
||||
try:
|
||||
(
|
||||
latest_start_dt,
|
||||
latest_end_dt,
|
||||
bf_done,
|
||||
) = await bus.nursery.start(
|
||||
partial(
|
||||
start_backfill,
|
||||
mod,
|
||||
mkt,
|
||||
shm,
|
||||
timeframe,
|
||||
sampler_stream,
|
||||
feed_is_live,
|
||||
|
||||
last_tsdb_dt=last_tsdb_dt,
|
||||
tsdb_is_up=True,
|
||||
storage=storage,
|
||||
)
|
||||
)
|
||||
except DataUnavailable:
|
||||
# XXX: timeframe not supported for backend
|
||||
dts_per_tf[timeframe] = (
|
||||
tsdb_history,
|
||||
last_tsdb_dt,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
continue
|
||||
|
||||
# tsdb_history = series.get(timeframe)
|
||||
dts_per_tf[timeframe] = (
|
||||
tsdb_history,
|
||||
last_tsdb_dt,
|
||||
latest_start_dt,
|
||||
latest_end_dt,
|
||||
bf_done,
|
||||
)
|
||||
|
||||
# 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.
|
||||
|
||||
# unblock the feed bus management task
|
||||
# assert len(shms[1].array)
|
||||
task_status.started()
|
||||
|
||||
async def back_load_from_tsdb(
|
||||
timeframe: int,
|
||||
shm: ShmArray,
|
||||
):
|
||||
(
|
||||
tsdb_history,
|
||||
last_tsdb_dt,
|
||||
latest_start_dt,
|
||||
latest_end_dt,
|
||||
bf_done,
|
||||
) = dts_per_tf[timeframe]
|
||||
|
||||
# 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?
|
||||
|
||||
# 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
|
||||
array = shm.array
|
||||
if len(array):
|
||||
shm_last_dt = pendulum.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.
|
||||
|
||||
if (
|
||||
len(tsdb_history)
|
||||
):
|
||||
# load the first (smaller) bit of history originally loaded
|
||||
# above from ``Storage.load()``.
|
||||
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=marketstore.ohlc_key_map,
|
||||
)
|
||||
|
||||
tsdb_last_frame_start = tsdb_history['Epoch'][0]
|
||||
|
||||
if timeframe == 1:
|
||||
times = shm.array['time']
|
||||
assert (times[1] - times[0]) == 1
|
||||
|
||||
# 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['Epoch'][0]
|
||||
if next_start >= tsdb_last_frame_start:
|
||||
# no earlier data detected
|
||||
break
|
||||
else:
|
||||
tsdb_last_frame_start = next_start
|
||||
|
||||
prepend_start = shm._first.value
|
||||
to_push = tsdb_history[-prepend_start:]
|
||||
|
||||
# insert the history pre a "days worth" of samples
|
||||
# to leave some real-time buffer space at the end.
|
||||
shm.push(
|
||||
to_push,
|
||||
prepend=True,
|
||||
field_map=marketstore.ohlc_key_map,
|
||||
)
|
||||
log.info(f'Loaded {to_push.shape} datums from storage')
|
||||
|
||||
# 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')
|
||||
|
||||
# TODO: write new data to tsdb to be ready to for next read.
|
||||
|
||||
# backload from db (concurrently per timeframe) once backfilling of
|
||||
# recent dat a loaded from the backend provider (see
|
||||
# ``bf_done.wait()`` call).
|
||||
async with trio.open_nursery() as nurse:
|
||||
for timeframe, shm in shms.items():
|
||||
nurse.start_soon(
|
||||
back_load_from_tsdb,
|
||||
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 `marketstore` as well as conducting real-time
|
||||
update of both that existing db and the allocated shared memory
|
||||
buffer.
|
||||
|
||||
'''
|
||||
|
||||
# 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(
|
||||
key=f'piker.{service}[{uuid[:16]}].{fqme}.hist',
|
||||
|
||||
# use any broker defined ohlc dtype:
|
||||
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
|
||||
|
||||
# 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(
|
||||
key=f'piker.{service}[{uuid[:16]}].{fqme}.rt',
|
||||
|
||||
# use any broker defined ohlc dtype:
|
||||
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
|
||||
|
||||
# we expect the sub-actor to write
|
||||
readonly=False,
|
||||
size=3*_secs_in_day,
|
||||
)
|
||||
|
||||
# (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?!"
|
||||
)
|
||||
|
||||
# register 1s and 1m buffers with the global incrementer task
|
||||
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:
|
||||
|
||||
open_history_client = getattr(
|
||||
mod,
|
||||
'open_history_client',
|
||||
None,
|
||||
)
|
||||
assert open_history_client
|
||||
|
||||
tsdb_is_up: bool = False
|
||||
try_remote_tsdb: bool = False
|
||||
|
||||
conf, path = config.load('conf', touch_if_dne=True)
|
||||
net = conf.get('network')
|
||||
if net:
|
||||
tsdbconf = net.get('tsdb')
|
||||
|
||||
# lookup backend tsdb module by name and load any user service
|
||||
# settings for connecting to the tsdb service.
|
||||
tsdb_backend: str = tsdbconf.pop('backend')
|
||||
tsdb_host: str = tsdbconf['host']
|
||||
|
||||
# TODO: import and load storagemod by name
|
||||
# mod = get_storagemod(tsdb_backend)
|
||||
from ..service import marketstore
|
||||
if tsdb_host == 'localhost':
|
||||
log.info('Scanning for existing `{tsbd_backend}`')
|
||||
tsdb_is_up: bool = await check_for_service(f'{tsdb_backend}d')
|
||||
|
||||
else:
|
||||
try_remote_tsdb: bool = True
|
||||
|
||||
if (
|
||||
tsdb_is_up
|
||||
or try_remote_tsdb
|
||||
and (
|
||||
opened
|
||||
and open_history_client
|
||||
)
|
||||
):
|
||||
log.info('Found existing `marketstored`')
|
||||
|
||||
async with (
|
||||
marketstore.open_storage_client(
|
||||
**tsdbconf
|
||||
) as storage,
|
||||
):
|
||||
# TODO: drop returning the output that we pass in?
|
||||
await bus.nursery.start(
|
||||
tsdb_backfill,
|
||||
mod,
|
||||
marketstore,
|
||||
bus,
|
||||
storage,
|
||||
mkt,
|
||||
{
|
||||
1: rt_shm,
|
||||
60: hist_shm,
|
||||
},
|
||||
sample_stream,
|
||||
feed_is_live,
|
||||
)
|
||||
|
||||
# yield back after client connect with filled shm
|
||||
task_status.started((
|
||||
hist_zero_index,
|
||||
hist_shm,
|
||||
rt_zero_index,
|
||||
rt_shm,
|
||||
))
|
||||
|
||||
# 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()
|
||||
|
||||
# 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()
|
||||
|
||||
# load less history if no tsdb can be found
|
||||
elif (
|
||||
not tsdb_is_up
|
||||
and opened
|
||||
):
|
||||
await basic_backfill(
|
||||
bus,
|
||||
mod,
|
||||
mkt,
|
||||
{
|
||||
1: rt_shm,
|
||||
60: hist_shm,
|
||||
},
|
||||
sample_stream,
|
||||
feed_is_live,
|
||||
)
|
||||
task_status.started((
|
||||
hist_zero_index,
|
||||
hist_shm,
|
||||
rt_zero_index,
|
||||
rt_shm,
|
||||
))
|
||||
some_data_ready.set()
|
||||
await trio.sleep_forever()
|
|
@ -23,7 +23,7 @@ Api layer likely in here...
|
|||
from types import ModuleType
|
||||
from importlib import import_module
|
||||
|
||||
from ..log import get_logger
|
||||
from ._util import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@ Built-in (extension) types.
|
|||
|
||||
"""
|
||||
import sys
|
||||
from typing import Optional
|
||||
from pprint import pformat
|
||||
|
||||
import msgspec
|
||||
|
@ -59,7 +58,7 @@ class Struct(
|
|||
|
||||
def copy(
|
||||
self,
|
||||
update: Optional[dict] = None,
|
||||
update: dict | None = None,
|
||||
|
||||
) -> msgspec.Struct:
|
||||
'''
|
||||
|
@ -80,9 +79,11 @@ class Struct(
|
|||
msgspec.msgpack.Encoder().encode(self)
|
||||
)
|
||||
|
||||
# NOTE XXX: this won't work on frozen types!
|
||||
# use ``.copy()`` above in such cases.
|
||||
def typecast(
|
||||
self,
|
||||
# fields: Optional[list[str]] = None,
|
||||
# fields: list[str] | None = None,
|
||||
) -> None:
|
||||
for fname, ftype in self.__annotations__.items():
|
||||
setattr(self, fname, ftype(getattr(self, fname)))
|
||||
|
|
|
@ -0,0 +1,264 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
'''
|
||||
Data feed synchronization protocols, init msgs, and general
|
||||
data-provider-backend-agnostic schema definitions.
|
||||
|
||||
'''
|
||||
from decimal import Decimal
|
||||
from pprint import pformat
|
||||
from types import ModuleType
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
)
|
||||
|
||||
from msgspec import field
|
||||
|
||||
from .types import Struct
|
||||
from ..accounting import (
|
||||
Asset,
|
||||
MktPair,
|
||||
)
|
||||
from ._util import log
|
||||
|
||||
|
||||
class FeedInitializationError(ValueError):
|
||||
'''
|
||||
Live data feed setup failed due to API / msg incompatiblity!
|
||||
|
||||
'''
|
||||
|
||||
|
||||
class FeedInit(Struct, frozen=True):
|
||||
'''
|
||||
A stringent data provider startup msg schema validator.
|
||||
|
||||
The fields defined here are matched with those absolutely required
|
||||
from each backend broker/data provider.
|
||||
|
||||
'''
|
||||
mkt_info: MktPair
|
||||
|
||||
# NOTE: only field we use rn in ``.data.feed``
|
||||
# TODO: maybe make a SamplerConfig(Struct)?
|
||||
shm_write_opts: dict[str, Any] = field(
|
||||
default_factory=lambda: {
|
||||
'has_vlm': True,
|
||||
'sum_tick_vlm': True,
|
||||
})
|
||||
|
||||
# XXX: we group backend endpoints into 3
|
||||
# groups to determine "degrees" of functionality.
|
||||
_eps: dict[str, list[str]] = {
|
||||
|
||||
# basic API `Client` layer
|
||||
'middleware': [
|
||||
'get_client',
|
||||
],
|
||||
|
||||
# (live) data streaming / loading / search
|
||||
'datad': [
|
||||
'get_mkt_info',
|
||||
'open_history_client',
|
||||
'open_symbol_search',
|
||||
'stream_quotes',
|
||||
],
|
||||
|
||||
# live order control and trading
|
||||
'brokerd': [
|
||||
'trades_dialogue',
|
||||
# TODO: ledger normalizer helper?
|
||||
# norm_trades(records: dict[str, Any]) -> TransactionLedger)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def validate_backend(
|
||||
mod: ModuleType,
|
||||
syms: list[str],
|
||||
init_msgs: list[FeedInit] | dict[str, dict[str, Any]],
|
||||
|
||||
# TODO: do a module method scan and report mismatches.
|
||||
check_eps: bool = False,
|
||||
|
||||
api_log_msg_level: str = 'critical'
|
||||
|
||||
) -> FeedInit:
|
||||
'''
|
||||
Fail on malformed live quotes feed config/init or warn on changes
|
||||
that haven't been implemented by this backend yet.
|
||||
|
||||
'''
|
||||
for daemon_name, eps in _eps.items():
|
||||
for name in eps:
|
||||
ep: Callable = getattr(
|
||||
mod,
|
||||
name,
|
||||
None,
|
||||
)
|
||||
if ep is None:
|
||||
log.warning(
|
||||
f'Provider backend {mod.name} is missing '
|
||||
f'{daemon_name} support :(\n'
|
||||
f'The following endpoint is missing: {name}'
|
||||
)
|
||||
|
||||
inits: list[
|
||||
FeedInit | dict[str, Any]
|
||||
] = init_msgs
|
||||
|
||||
# convert to list if from old dict-style
|
||||
if isinstance(init_msgs, dict):
|
||||
inits = list(init_msgs.values())
|
||||
|
||||
init: FeedInit | dict[str, Any]
|
||||
for i, init in enumerate(inits):
|
||||
|
||||
# XXX: eventually this WILL NOT necessarily be true.
|
||||
if i > 0:
|
||||
assert not len(init_msgs) == 1
|
||||
if isinstance(init_msgs, dict):
|
||||
keys: set = set(init_msgs.keys()) - set(syms)
|
||||
raise FeedInitializationError(
|
||||
'TOO MANY INIT MSGS!\n'
|
||||
f'Unexpected keys: {keys}\n'
|
||||
'ALL MSGS:\n'
|
||||
f'{pformat(init_msgs)}\n'
|
||||
)
|
||||
else:
|
||||
raise FeedInitializationError(
|
||||
'TOO MANY INIT MSGS!\n'
|
||||
f'{pformat(init_msgs)}\n'
|
||||
)
|
||||
|
||||
# TODO: once all backends are updated we can remove this branching.
|
||||
rx_msg: bool = False
|
||||
warn_msg: str = ''
|
||||
if not isinstance(init, FeedInit):
|
||||
warn_msg += (
|
||||
'\n'
|
||||
'--------------------------\n'
|
||||
':::DEPRECATED API STYLE:::\n'
|
||||
'--------------------------\n'
|
||||
f'`{mod.name}.stream_quotes()` should deliver '
|
||||
'`.started(FeedInit)`\n'
|
||||
f'|-> CURRENTLY it is using DEPRECATED `.started(dict)` style!\n'
|
||||
f'|-> SEE `FeedInit` in `piker.data.validate`\n'
|
||||
'--------------------------------------------\n'
|
||||
)
|
||||
else:
|
||||
rx_msg = True
|
||||
|
||||
# verify feed init state / schema
|
||||
bs_fqme: str # backend specific fqme
|
||||
mkt: MktPair
|
||||
|
||||
match init:
|
||||
|
||||
# backend is using old dict msg delivery
|
||||
case {
|
||||
'symbol_info': dict(symbol_info),
|
||||
'fqsn': bs_fqme,
|
||||
} | {
|
||||
'mkt_info': dict(symbol_info),
|
||||
'fqsn': bs_fqme,
|
||||
}:
|
||||
symbol_info: dict
|
||||
warn_msg += (
|
||||
'It may also be still using the legacy `Symbol` style API\n'
|
||||
'IT SHOULD BE PORTED TO THE NEW '
|
||||
'`.accounting._mktinfo.MktPair`\n'
|
||||
'STATTTTT!!!\n'
|
||||
)
|
||||
|
||||
# XXX use default legacy (aka discrete precision) mkt
|
||||
# price/size_ticks if none delivered.
|
||||
price_tick = symbol_info.get(
|
||||
'price_tick_size',
|
||||
Decimal('0.01'),
|
||||
)
|
||||
size_tick = symbol_info.get(
|
||||
'lot_tick_size',
|
||||
Decimal('1'),
|
||||
)
|
||||
bs_mktid = init.get('bs_mktid') or bs_fqme
|
||||
mkt = MktPair.from_fqme(
|
||||
fqme=f'{bs_fqme}.{mod.name}',
|
||||
|
||||
price_tick=price_tick,
|
||||
size_tick=size_tick,
|
||||
|
||||
bs_mktid=str(bs_mktid),
|
||||
_atype=symbol_info['asset_type']
|
||||
)
|
||||
|
||||
# backend is using new `MktPair` but not entirely
|
||||
case {
|
||||
'mkt_info': MktPair(
|
||||
dst=Asset(),
|
||||
) as mkt,
|
||||
'fqsn': bs_fqme,
|
||||
}:
|
||||
warn_msg += (
|
||||
f'{mod.name} in API compat transition?\n'
|
||||
"It's half dict, half man..\n"
|
||||
'-------------------------------------\n'
|
||||
)
|
||||
|
||||
case FeedInit(
|
||||
mkt_info=MktPair(dst=Asset()) as mkt,
|
||||
shm_write_opts=dict(shm_opts),
|
||||
) as init:
|
||||
name: str = mod.name
|
||||
log.info(
|
||||
f"{name}'s `MktPair` info:\n"
|
||||
f'{pformat(mkt.to_dict())}\n'
|
||||
f'shm conf: {pformat(shm_opts)}\n'
|
||||
)
|
||||
|
||||
case _:
|
||||
raise FeedInitializationError(init)
|
||||
|
||||
# build a msg if we received a dict for input.
|
||||
if not rx_msg:
|
||||
assert bs_fqme in mkt.fqme
|
||||
init = FeedInit(
|
||||
mkt_info=mkt,
|
||||
shm_write_opts=init.get('shm_write_opts'),
|
||||
)
|
||||
|
||||
# `MktPair` value audits
|
||||
mkt = init.mkt_info
|
||||
assert mkt.type_key
|
||||
|
||||
# backend is using new `MktPair` but not embedded `Asset` types
|
||||
# for the .src/.dst..
|
||||
if not isinstance(mkt.src, Asset):
|
||||
warn_msg += (
|
||||
f'ALSO, {mod.name.upper()} should try to deliver\n'
|
||||
'the new `MktPair.src: Asset` field!\n'
|
||||
'-----------------------------------------------\n'
|
||||
)
|
||||
|
||||
# complain about any non-idealities
|
||||
if warn_msg:
|
||||
# TODO: would be nice to register an API_COMPAT or something in
|
||||
# maybe cyan for this in general throughput piker no?
|
||||
logmeth = getattr(log, api_log_msg_level)
|
||||
logmeth(warn_msg)
|
||||
|
||||
return init.copy()
|
|
@ -174,15 +174,6 @@ def fsp(
|
|||
return Fsp(wrapped, outputs=(wrapped.__name__,))
|
||||
|
||||
|
||||
def mk_fsp_shm_key(
|
||||
sym: str,
|
||||
target: Fsp
|
||||
|
||||
) -> str:
|
||||
uid = tractor.current_actor().uid
|
||||
return f'{sym}.fsp.{target.name}.{".".join(uid)}'
|
||||
|
||||
|
||||
def maybe_mk_fsp_shm(
|
||||
sym: str,
|
||||
target: Fsp,
|
||||
|
@ -206,7 +197,10 @@ def maybe_mk_fsp_shm(
|
|||
[(field_name, float) for field_name in target.outputs]
|
||||
)
|
||||
|
||||
key = mk_fsp_shm_key(sym, target)
|
||||
# (attempt to) uniquely key the fsp shm buffers
|
||||
actor_name, uuid = tractor.current_actor().uid
|
||||
uuid_snip: str = uuid[:16]
|
||||
key: str = f'piker.{actor_name}[{uuid_snip}].{sym}.{target.name}'
|
||||
|
||||
shm, opened = maybe_open_shm_array(
|
||||
key,
|
||||
|
|
|
@ -45,7 +45,7 @@ from ..data._sampling import (
|
|||
_default_delay_s,
|
||||
open_sample_stream,
|
||||
)
|
||||
from ..data._source import Symbol
|
||||
from ..accounting import MktPair
|
||||
from ._api import (
|
||||
Fsp,
|
||||
_load_builtins,
|
||||
|
@ -85,7 +85,7 @@ async def filter_quotes_by_sym(
|
|||
|
||||
async def fsp_compute(
|
||||
|
||||
symbol: Symbol,
|
||||
mkt: MktPair,
|
||||
flume: Flume,
|
||||
quote_stream: trio.abc.ReceiveChannel,
|
||||
|
||||
|
@ -104,14 +104,14 @@ async def fsp_compute(
|
|||
disabled=True
|
||||
)
|
||||
|
||||
fqsn = symbol.front_fqsn()
|
||||
fqme = mkt.fqme
|
||||
out_stream = func(
|
||||
|
||||
# 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
|
||||
# to the async iterable? it's that or we do some kinda
|
||||
# async itertools style?
|
||||
filter_quotes_by_sym(fqsn, quote_stream),
|
||||
filter_quotes_by_sym(fqme, quote_stream),
|
||||
|
||||
# XXX: currently the ``ohlcv`` arg
|
||||
flume.rt_shm,
|
||||
|
@ -271,7 +271,7 @@ async def cascade(
|
|||
ctx: tractor.Context,
|
||||
|
||||
# data feed key
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
|
||||
src_shm_token: dict,
|
||||
dst_shm_token: tuple[str, np.dtype],
|
||||
|
@ -329,7 +329,7 @@ async def cascade(
|
|||
# open a data feed stream with requested broker
|
||||
feed: Feed
|
||||
async with data.feed.maybe_open_feed(
|
||||
[fqsn],
|
||||
[fqme],
|
||||
|
||||
# TODO throttle tick outputs from *this* daemon since
|
||||
# it'll emit tons of ticks due to the throttle only
|
||||
|
@ -339,8 +339,8 @@ async def cascade(
|
|||
|
||||
) as feed:
|
||||
|
||||
flume = feed.flumes[fqsn]
|
||||
symbol = flume.symbol
|
||||
flume = feed.flumes[fqme]
|
||||
mkt = flume.mkt
|
||||
assert src.token == flume.rt_shm.token
|
||||
profiler(f'{func}: feed up')
|
||||
|
||||
|
@ -352,7 +352,7 @@ async def cascade(
|
|||
fsp_target = partial(
|
||||
|
||||
fsp_compute,
|
||||
symbol=symbol,
|
||||
mkt=mkt,
|
||||
flume=flume,
|
||||
quote_stream=flume.stream,
|
||||
|
||||
|
|
|
@ -32,8 +32,6 @@ from ._registry import ( # noqa
|
|||
)
|
||||
from ._daemon import ( # noqa
|
||||
maybe_spawn_daemon,
|
||||
spawn_brokerd,
|
||||
maybe_spawn_brokerd,
|
||||
spawn_emsd,
|
||||
maybe_open_emsd,
|
||||
)
|
||||
|
@ -43,6 +41,10 @@ from ._actor_runtime import (
|
|||
open_pikerd,
|
||||
get_tractor_runtime_kwargs,
|
||||
)
|
||||
from ..brokers._daemon import (
|
||||
spawn_brokerd,
|
||||
maybe_spawn_brokerd,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
@ -19,8 +19,6 @@
|
|||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from pprint import pformat
|
||||
from functools import partial
|
||||
import os
|
||||
from typing import (
|
||||
Optional,
|
||||
|
@ -34,8 +32,7 @@ from contextlib import (
|
|||
import tractor
|
||||
import trio
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
from ._util import (
|
||||
get_console_log,
|
||||
)
|
||||
from ._mngr import (
|
||||
|
@ -47,8 +44,6 @@ from ._registry import ( # noqa
|
|||
open_registry,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def get_tractor_runtime_kwargs() -> dict[str, Any]:
|
||||
'''
|
||||
|
@ -135,8 +130,11 @@ _root_dname = 'pikerd'
|
|||
_root_modules = [
|
||||
__name__,
|
||||
'piker.service._daemon',
|
||||
'piker.brokers._daemon',
|
||||
|
||||
'piker.clearing._ems',
|
||||
'piker.clearing._client',
|
||||
|
||||
'piker.data._sampling',
|
||||
]
|
||||
|
||||
|
@ -151,11 +149,6 @@ async def open_pikerd(
|
|||
debug_mode: bool = False,
|
||||
registry_addr: None | tuple[str, int] = None,
|
||||
|
||||
# db init flags
|
||||
tsdb: bool = False,
|
||||
es: bool = False,
|
||||
drop_root_perms_for_ahab: bool = True,
|
||||
|
||||
**kwargs,
|
||||
|
||||
) -> Services:
|
||||
|
@ -185,57 +178,16 @@ async def open_pikerd(
|
|||
trio.open_nursery() as service_nursery,
|
||||
):
|
||||
if root_actor.accept_addr != reg_addr:
|
||||
raise RuntimeError(f'Daemon failed to bind on {reg_addr}!?')
|
||||
raise RuntimeError(
|
||||
f'`pikerd` failed to bind on {reg_addr}!\n'
|
||||
'Maybe you have another daemon already running?'
|
||||
)
|
||||
|
||||
# assign globally for future daemon/task creation
|
||||
Services.actor_n = actor_nursery
|
||||
Services.service_n = service_nursery
|
||||
Services.debug_mode = debug_mode
|
||||
|
||||
if tsdb:
|
||||
from ._ahab import start_ahab
|
||||
from .marketstore import start_marketstore
|
||||
|
||||
log.info('Spawning `marketstore` supervisor')
|
||||
ctn_ready, config, (cid, pid) = await service_nursery.start(
|
||||
partial(
|
||||
start_ahab,
|
||||
'marketstored',
|
||||
start_marketstore,
|
||||
loglevel=loglevel,
|
||||
drop_root_perms=drop_root_perms_for_ahab,
|
||||
)
|
||||
|
||||
)
|
||||
log.info(
|
||||
f'`marketstored` up!\n'
|
||||
f'pid: {pid}\n'
|
||||
f'container id: {cid[:12]}\n'
|
||||
f'config: {pformat(config)}'
|
||||
)
|
||||
|
||||
if es:
|
||||
from ._ahab import start_ahab
|
||||
from .elastic import start_elasticsearch
|
||||
|
||||
log.info('Spawning `elasticsearch` supervisor')
|
||||
ctn_ready, config, (cid, pid) = await service_nursery.start(
|
||||
partial(
|
||||
start_ahab,
|
||||
'elasticsearch',
|
||||
start_elasticsearch,
|
||||
loglevel=loglevel,
|
||||
drop_root_perms=drop_root_perms_for_ahab,
|
||||
)
|
||||
)
|
||||
|
||||
log.info(
|
||||
f'`elasticsearch` up!\n'
|
||||
f'pid: {pid}\n'
|
||||
f'container id: {cid[:12]}\n'
|
||||
f'config: {pformat(config)}'
|
||||
)
|
||||
|
||||
try:
|
||||
yield Services
|
||||
|
||||
|
@ -275,9 +227,6 @@ async def open_pikerd(
|
|||
async def maybe_open_pikerd(
|
||||
loglevel: Optional[str] = None,
|
||||
registry_addr: None | tuple = None,
|
||||
tsdb: bool = False,
|
||||
es: bool = False,
|
||||
drop_root_perms_for_ahab: bool = True,
|
||||
|
||||
**kwargs,
|
||||
|
||||
|
@ -331,11 +280,6 @@ async def maybe_open_pikerd(
|
|||
loglevel=loglevel,
|
||||
registry_addr=registry_addr,
|
||||
|
||||
# ahabd (docker super) specific controls
|
||||
tsdb=tsdb,
|
||||
es=es,
|
||||
drop_root_perms_for_ahab=drop_root_perms_for_ahab,
|
||||
|
||||
# passthrough to ``tractor`` init
|
||||
**kwargs,
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ Supervisor for ``docker`` with included async and SC wrapping
|
|||
to ensure a cancellable container lifetime system.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from collections import ChainMap
|
||||
from functools import partial
|
||||
import os
|
||||
|
@ -48,14 +49,13 @@ from requests.exceptions import (
|
|||
ReadTimeout,
|
||||
)
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
from ._mngr import Services
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
get_console_log,
|
||||
)
|
||||
from .. import config
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class DockerNotStarted(Exception):
|
||||
'Prolly you dint start da daemon bruh'
|
||||
|
@ -189,7 +189,11 @@ class Container:
|
|||
and entry not in seen_so_far
|
||||
):
|
||||
seen_so_far.add(entry)
|
||||
getattr(log, level.lower(), log.error)(f'{msg}')
|
||||
getattr(
|
||||
log,
|
||||
level.lower(),
|
||||
log.error
|
||||
)(f'{msg}')
|
||||
|
||||
if level == 'fatal':
|
||||
raise ApplicationLogError(msg)
|
||||
|
@ -265,8 +269,10 @@ class Container:
|
|||
start = time.time()
|
||||
for _ in range(6):
|
||||
|
||||
with trio.move_on_after(0.5) as cs:
|
||||
log.cancel('polling for CNTR logs...')
|
||||
with trio.move_on_after(1) as cs:
|
||||
log.cancel(
|
||||
'polling for CNTR logs for {stop_predicate}..'
|
||||
)
|
||||
|
||||
try:
|
||||
await self.process_logs_until(
|
||||
|
@ -330,16 +336,13 @@ class Container:
|
|||
async def open_ahabd(
|
||||
ctx: tractor.Context,
|
||||
endpoint: str, # ns-pointer str-msg-type
|
||||
loglevel: str | None = 'cancel',
|
||||
loglevel: str | None = None,
|
||||
|
||||
**kwargs,
|
||||
**ep_kwargs,
|
||||
|
||||
) -> None:
|
||||
|
||||
log = get_console_log(
|
||||
loglevel,
|
||||
name=__name__,
|
||||
)
|
||||
log = get_console_log(loglevel or 'cancel')
|
||||
|
||||
async with open_docker() as client:
|
||||
|
||||
|
@ -352,7 +355,7 @@ async def open_ahabd(
|
|||
cntr_config,
|
||||
start_pred,
|
||||
stop_pred,
|
||||
) = ep_func(client)
|
||||
) = ep_func(client, **ep_kwargs)
|
||||
cntr = Container(dcntr)
|
||||
|
||||
conf: ChainMap[str, Any] = ChainMap(
|
||||
|
@ -448,10 +451,17 @@ async def open_ahabd(
|
|||
)
|
||||
|
||||
|
||||
async def start_ahab(
|
||||
@acm
|
||||
async def start_ahab_service(
|
||||
services: Services,
|
||||
service_name: str,
|
||||
|
||||
# endpoint config passed as **kwargs
|
||||
endpoint: Callable[docker.DockerClient, DockerContainer],
|
||||
ep_kwargs: dict,
|
||||
loglevel: str | None = 'cancel',
|
||||
|
||||
# supervisor config
|
||||
drop_root_perms: bool = True,
|
||||
|
||||
task_status: TaskStatus[
|
||||
|
@ -472,6 +482,9 @@ async def start_ahab(
|
|||
is started.
|
||||
|
||||
'''
|
||||
# global log
|
||||
log = get_console_log(loglevel or 'cancel')
|
||||
|
||||
cn_ready = trio.Event()
|
||||
try:
|
||||
async with tractor.open_nursery() as an:
|
||||
|
@ -500,21 +513,28 @@ async def start_ahab(
|
|||
)[2] # named user's uid
|
||||
)
|
||||
|
||||
async with portal.open_context(
|
||||
open_ahabd,
|
||||
cs, first = await services.start_service_task(
|
||||
name=service_name,
|
||||
portal=portal,
|
||||
|
||||
# rest: endpoint inputs
|
||||
target=open_ahabd,
|
||||
endpoint=str(NamespacePath.from_ref(endpoint)),
|
||||
loglevel='cancel',
|
||||
) as (ctx, first):
|
||||
**ep_kwargs,
|
||||
)
|
||||
|
||||
cid, pid, cntr_config = first
|
||||
cid, pid, cntr_config = first
|
||||
|
||||
task_status.started((
|
||||
try:
|
||||
yield (
|
||||
cn_ready,
|
||||
cntr_config,
|
||||
(cid, pid),
|
||||
))
|
||||
|
||||
await trio.sleep_forever()
|
||||
)
|
||||
finally:
|
||||
log.info(f'Cancelling ahab service `{service_name}`')
|
||||
await services.cancel_service(service_name)
|
||||
|
||||
# since we demoted root perms in this parent
|
||||
# we'll get a perms error on proc cleanup in
|
||||
|
|
|
@ -20,7 +20,6 @@ Daemon-actor spawning "endpoint-hooks".
|
|||
"""
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Optional,
|
||||
Callable,
|
||||
Any,
|
||||
)
|
||||
|
@ -30,41 +29,28 @@ from contextlib import (
|
|||
|
||||
import tractor
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
get_console_log,
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
)
|
||||
from ..brokers import get_brokermod
|
||||
from ._mngr import (
|
||||
Services,
|
||||
)
|
||||
from ._actor_runtime import maybe_open_pikerd
|
||||
from ._registry import find_service
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# `brokerd` enabled modules
|
||||
# NOTE: keeping this list as small as possible is part of our caps-sec
|
||||
# model and should be treated with utmost care!
|
||||
_data_mods = [
|
||||
'piker.brokers.core',
|
||||
'piker.brokers.data',
|
||||
'piker.data',
|
||||
'piker.data.feed',
|
||||
'piker.data._sampling'
|
||||
]
|
||||
|
||||
|
||||
@acm
|
||||
async def maybe_spawn_daemon(
|
||||
|
||||
service_name: str,
|
||||
service_task_target: Callable,
|
||||
spawn_args: dict[str, Any],
|
||||
loglevel: Optional[str] = None,
|
||||
|
||||
spawn_args: dict[str, Any],
|
||||
|
||||
loglevel: str | None = None,
|
||||
singleton: bool = False,
|
||||
**kwargs,
|
||||
|
||||
**pikerd_kwargs,
|
||||
|
||||
) -> tractor.Portal:
|
||||
'''
|
||||
|
@ -79,9 +65,6 @@ async def maybe_spawn_daemon(
|
|||
clients.
|
||||
|
||||
'''
|
||||
if loglevel:
|
||||
get_console_log(loglevel)
|
||||
|
||||
# serialize access to this section to avoid
|
||||
# 2 or more tasks racing to create a daemon
|
||||
lock = Services.locks[service_name]
|
||||
|
@ -93,18 +76,17 @@ async def maybe_spawn_daemon(
|
|||
yield portal
|
||||
return
|
||||
|
||||
log.warning(f"Couldn't find any existing {service_name}")
|
||||
|
||||
# TODO: really shouldn't the actor spawning be part of the service
|
||||
# starting method `Services.start_service()` ?
|
||||
log.warning(
|
||||
f"Couldn't find any existing {service_name}\n"
|
||||
'Attempting to spawn new daemon-service..'
|
||||
)
|
||||
|
||||
# ask root ``pikerd`` daemon to spawn the daemon we need if
|
||||
# pikerd is not live we now become the root of the
|
||||
# process tree
|
||||
async with maybe_open_pikerd(
|
||||
|
||||
loglevel=loglevel,
|
||||
**kwargs,
|
||||
**pikerd_kwargs,
|
||||
|
||||
) as pikerd_portal:
|
||||
|
||||
|
@ -117,108 +99,42 @@ async def maybe_spawn_daemon(
|
|||
# service task for that actor.
|
||||
started: bool
|
||||
if pikerd_portal is None:
|
||||
started = await service_task_target(**spawn_args)
|
||||
started = await service_task_target(
|
||||
loglevel=loglevel,
|
||||
**spawn_args,
|
||||
)
|
||||
|
||||
else:
|
||||
# tell the remote `pikerd` to start the target,
|
||||
# the target can't return a non-serializable value
|
||||
# since it is expected that service startingn is
|
||||
# non-blocking and the target task will persist running
|
||||
# on `pikerd` after the client requesting it's start
|
||||
# disconnects.
|
||||
# request a remote `pikerd` (service manager) to start the
|
||||
# target daemon-task, the target can't return
|
||||
# a non-serializable value since it is expected that service
|
||||
# starting is non-blocking and the target task will persist
|
||||
# running "under" or "within" the `pikerd` actor tree after
|
||||
# the questing client disconnects. in other words this
|
||||
# spawns a persistent daemon actor that continues to live
|
||||
# for the lifespan of whatever the service manager inside
|
||||
# `pikerd` says it should.
|
||||
started = await pikerd_portal.run(
|
||||
service_task_target,
|
||||
loglevel=loglevel,
|
||||
**spawn_args,
|
||||
)
|
||||
|
||||
if started:
|
||||
log.info(f'Service {service_name} started!')
|
||||
|
||||
# block until we can discover (by IPC connection) to the newly
|
||||
# spawned daemon-actor and then deliver the portal to the
|
||||
# caller.
|
||||
async with tractor.wait_for_actor(service_name) as portal:
|
||||
lock.release()
|
||||
yield portal
|
||||
await portal.cancel_actor()
|
||||
|
||||
|
||||
async def spawn_brokerd(
|
||||
|
||||
brokername: str,
|
||||
loglevel: Optional[str] = None,
|
||||
**tractor_kwargs,
|
||||
|
||||
) -> bool:
|
||||
|
||||
log.info(f'Spawning {brokername} broker daemon')
|
||||
|
||||
brokermod = get_brokermod(brokername)
|
||||
dname = f'brokerd.{brokername}'
|
||||
|
||||
extra_tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
|
||||
tractor_kwargs.update(extra_tractor_kwargs)
|
||||
|
||||
# ask `pikerd` to spawn a new sub-actor and manage it under its
|
||||
# actor nursery
|
||||
modpath = brokermod.__name__
|
||||
broker_enable = [modpath]
|
||||
for submodname in getattr(
|
||||
brokermod,
|
||||
'__enable_modules__',
|
||||
[],
|
||||
):
|
||||
subpath = f'{modpath}.{submodname}'
|
||||
broker_enable.append(subpath)
|
||||
|
||||
portal = await Services.actor_n.start_actor(
|
||||
dname,
|
||||
enable_modules=_data_mods + broker_enable,
|
||||
loglevel=loglevel,
|
||||
debug_mode=Services.debug_mode,
|
||||
**tractor_kwargs
|
||||
)
|
||||
|
||||
# non-blocking setup of brokerd service nursery
|
||||
from ..data import _setup_persistent_brokerd
|
||||
|
||||
await Services.start_service_task(
|
||||
dname,
|
||||
portal,
|
||||
_setup_persistent_brokerd,
|
||||
brokername=brokername,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@acm
|
||||
async def maybe_spawn_brokerd(
|
||||
|
||||
brokername: str,
|
||||
loglevel: Optional[str] = None,
|
||||
**kwargs,
|
||||
|
||||
) -> tractor.Portal:
|
||||
'''
|
||||
Helper to spawn a brokerd service *from* a client
|
||||
who wishes to use the sub-actor-daemon.
|
||||
|
||||
'''
|
||||
async with maybe_spawn_daemon(
|
||||
|
||||
f'brokerd.{brokername}',
|
||||
service_task_target=spawn_brokerd,
|
||||
spawn_args={
|
||||
'brokername': brokername,
|
||||
'loglevel': loglevel,
|
||||
},
|
||||
loglevel=loglevel,
|
||||
**kwargs,
|
||||
|
||||
) as portal:
|
||||
yield portal
|
||||
|
||||
|
||||
async def spawn_emsd(
|
||||
|
||||
loglevel: Optional[str] = None,
|
||||
loglevel: str | None = None,
|
||||
**extra_tractor_kwargs
|
||||
|
||||
) -> bool:
|
||||
|
@ -245,7 +161,10 @@ async def spawn_emsd(
|
|||
await Services.start_service_task(
|
||||
'emsd',
|
||||
portal,
|
||||
|
||||
# signature of target root-task endpoint
|
||||
_setup_persistent_emsd,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
return True
|
||||
|
||||
|
@ -254,18 +173,18 @@ async def spawn_emsd(
|
|||
async def maybe_open_emsd(
|
||||
|
||||
brokername: str,
|
||||
loglevel: Optional[str] = None,
|
||||
**kwargs,
|
||||
loglevel: str | None = None,
|
||||
|
||||
) -> tractor._portal.Portal: # noqa
|
||||
**pikerd_kwargs,
|
||||
|
||||
) -> tractor.Portal: # noqa
|
||||
|
||||
async with maybe_spawn_daemon(
|
||||
|
||||
'emsd',
|
||||
service_task_target=spawn_emsd,
|
||||
spawn_args={'loglevel': loglevel},
|
||||
spawn_args={},
|
||||
loglevel=loglevel,
|
||||
**kwargs,
|
||||
**pikerd_kwargs,
|
||||
|
||||
) as portal:
|
||||
yield portal
|
||||
|
|
|
@ -28,15 +28,16 @@ import trio
|
|||
from trio_typing import TaskStatus
|
||||
import tractor
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# TODO: factor this into a ``tractor.highlevel`` extension
|
||||
# pack for the library.
|
||||
# TODO: we need remote wrapping and a general soln:
|
||||
# - factor this into a ``tractor.highlevel`` extension # pack for the
|
||||
# library.
|
||||
# - wrap a "remote api" wherein you can get a method proxy
|
||||
# to the pikerd actor for starting services remotely!
|
||||
class Services:
|
||||
|
||||
actor_n: tractor._supervise.ActorNursery
|
||||
|
@ -58,7 +59,8 @@ class Services:
|
|||
name: str,
|
||||
portal: tractor.Portal,
|
||||
target: Callable,
|
||||
**kwargs,
|
||||
allow_overruns: bool = False,
|
||||
**ctx_kwargs,
|
||||
|
||||
) -> (trio.CancelScope, tractor.Context):
|
||||
'''
|
||||
|
@ -81,9 +83,11 @@ class Services:
|
|||
) -> Any:
|
||||
|
||||
with trio.CancelScope() as cs:
|
||||
|
||||
async with portal.open_context(
|
||||
target,
|
||||
**kwargs,
|
||||
allow_overruns=allow_overruns,
|
||||
**ctx_kwargs,
|
||||
|
||||
) as (ctx, first):
|
||||
|
||||
|
|
|
@ -28,13 +28,10 @@ from typing import (
|
|||
|
||||
import tractor
|
||||
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_default_registry_host: str = '127.0.0.1'
|
||||
_default_registry_port: int = 6116
|
||||
_default_reg_addr: tuple[str, int] = (
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
# 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/>.
|
||||
"""
|
||||
Sub-sys module commons.
|
||||
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from ..log import (
|
||||
get_logger,
|
||||
get_console_log,
|
||||
)
|
||||
subsys: str = 'piker.service'
|
||||
|
||||
log = get_logger(subsys)
|
||||
|
||||
get_console_log = partial(
|
||||
get_console_log,
|
||||
name=subsys,
|
||||
)
|
|
@ -15,26 +15,23 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from typing import (
|
||||
Any,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
import asks
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import docker
|
||||
from ._ahab import DockerContainer
|
||||
|
||||
from piker.log import (
|
||||
get_logger,
|
||||
get_console_log
|
||||
from ._util import log # sub-sys logger
|
||||
from ._util import (
|
||||
get_console_log,
|
||||
)
|
||||
|
||||
import asks
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# container level config
|
||||
_config = {
|
||||
|
@ -92,7 +89,7 @@ def start_elasticsearch(
|
|||
'http://localhost:19200/_cat/health',
|
||||
params={'format': 'json'}
|
||||
)).json()
|
||||
kog.info(
|
||||
log.info(
|
||||
'ElasticSearch cntr health:\n'
|
||||
f'{health}'
|
||||
)
|
||||
|
@ -126,3 +123,47 @@ def start_elasticsearch(
|
|||
health_query,
|
||||
chk_for_closed_msg,
|
||||
)
|
||||
|
||||
|
||||
@acm
|
||||
async def start_ahab_daemon(
|
||||
service_mngr: Services,
|
||||
user_config: dict | None = None,
|
||||
loglevel: str | None = None,
|
||||
|
||||
) -> tuple[str, dict]:
|
||||
'''
|
||||
Task entrypoint to start the estasticsearch docker container using
|
||||
the service manager.
|
||||
|
||||
'''
|
||||
from ._ahab import start_ahab_service
|
||||
|
||||
# dict-merge any user settings
|
||||
conf: dict = _config.copy()
|
||||
if user_config:
|
||||
conf = conf | user_config
|
||||
|
||||
dname: str = 'esd'
|
||||
log.info(f'Spawning `{dname}` supervisor')
|
||||
async with start_ahab_service(
|
||||
service_mngr,
|
||||
dname,
|
||||
|
||||
# NOTE: docker-py client is passed at runtime
|
||||
start_elasticsearch,
|
||||
ep_kwargs={'user_config': conf},
|
||||
loglevel=loglevel,
|
||||
|
||||
) as (
|
||||
ctn_ready,
|
||||
config,
|
||||
(cid, pid),
|
||||
):
|
||||
log.info(
|
||||
f'`{dname}` up!\n'
|
||||
f'pid: {pid}\n'
|
||||
f'container id: {cid[:12]}\n'
|
||||
f'config: {pformat(config)}'
|
||||
)
|
||||
yield dname, conf
|
||||
|
|
|
@ -26,9 +26,9 @@
|
|||
from __future__ import annotations
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
Union,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
@ -54,12 +54,14 @@ if TYPE_CHECKING:
|
|||
import docker
|
||||
from ._ahab import DockerContainer
|
||||
|
||||
from ._util import (
|
||||
log, # sub-sys logger
|
||||
get_console_log,
|
||||
)
|
||||
from . import Services
|
||||
from ..data.feed import maybe_open_feed
|
||||
from ..log import get_logger, get_console_log
|
||||
from .._profile import Profiler
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
from .. import config
|
||||
|
||||
|
||||
# ahabd-supervisor and container level config
|
||||
|
@ -70,7 +72,7 @@ _config = {
|
|||
'startup_timeout': 2,
|
||||
}
|
||||
|
||||
_yaml_config = '''
|
||||
_yaml_config_str: str = '''
|
||||
# piker's ``marketstore`` config.
|
||||
|
||||
# mount this config using:
|
||||
|
@ -89,6 +91,12 @@ stale_threshold: 5
|
|||
enable_add: true
|
||||
enable_remove: false
|
||||
|
||||
# SUPER DUPER CRITICAL to address a super weird issue:
|
||||
# https://github.com/pikers/piker/issues/443
|
||||
# seems like "variable compression" is possibly borked
|
||||
# or snappy compression somehow breaks easily?
|
||||
disable_variable_compression: true
|
||||
|
||||
triggers:
|
||||
- module: ondiskagg.so
|
||||
on: "*/1Sec/OHLCV"
|
||||
|
@ -106,18 +114,18 @@ triggers:
|
|||
# config:
|
||||
# filter: "nasdaq"
|
||||
|
||||
'''.format(**_config)
|
||||
'''
|
||||
|
||||
|
||||
def start_marketstore(
|
||||
client: docker.DockerClient,
|
||||
|
||||
user_config: dict,
|
||||
**kwargs,
|
||||
|
||||
) -> tuple[DockerContainer, dict[str, Any]]:
|
||||
'''
|
||||
Start and supervise a marketstore instance with its config bind-mounted
|
||||
in from the piker config directory on the system.
|
||||
Start and supervise a marketstore instance with its config
|
||||
bind-mounted in from the piker config directory on the system.
|
||||
|
||||
The equivalent cli cmd to this code is:
|
||||
|
||||
|
@ -141,14 +149,16 @@ def start_marketstore(
|
|||
os.mkdir(mktsdir)
|
||||
|
||||
yml_file = os.path.join(mktsdir, 'mkts.yml')
|
||||
yaml_config = _yaml_config_str.format(**user_config)
|
||||
|
||||
if not os.path.isfile(yml_file):
|
||||
log.warning(
|
||||
f'No `marketstore` config exists?: {yml_file}\n'
|
||||
'Generating new file from template:\n'
|
||||
f'{_yaml_config}\n'
|
||||
f'{yaml_config}\n'
|
||||
)
|
||||
with open(yml_file, 'w') as yf:
|
||||
yf.write(_yaml_config)
|
||||
yf.write(yaml_config)
|
||||
|
||||
# create a mount from user's local piker config dir into container
|
||||
config_dir_mnt = docker.types.Mount(
|
||||
|
@ -171,6 +181,9 @@ def start_marketstore(
|
|||
type='bind',
|
||||
)
|
||||
|
||||
grpc_listen_port = int(user_config['grpc_listen_port'])
|
||||
ws_listen_port = int(user_config['ws_listen_port'])
|
||||
|
||||
dcntr: DockerContainer = client.containers.run(
|
||||
'alpacamarkets/marketstore:latest',
|
||||
# do we need this for cmds?
|
||||
|
@ -178,8 +191,8 @@ def start_marketstore(
|
|||
|
||||
# '-p 5993:5993',
|
||||
ports={
|
||||
'5993/tcp': 5993, # jsonrpc / ws?
|
||||
'5995/tcp': 5995, # grpc
|
||||
f'{ws_listen_port}/tcp': ws_listen_port,
|
||||
f'{grpc_listen_port}/tcp': grpc_listen_port,
|
||||
},
|
||||
mounts=[
|
||||
config_dir_mnt,
|
||||
|
@ -199,7 +212,13 @@ def start_marketstore(
|
|||
return "launching tcp listener for all services..." in msg
|
||||
|
||||
async def stop_matcher(msg: str):
|
||||
return "exiting..." in msg
|
||||
return (
|
||||
# not sure when this happens, some kinda stop condition
|
||||
"exiting..." in msg
|
||||
|
||||
# after we send SIGINT..
|
||||
or "initiating graceful shutdown due to 'interrupt' request" in msg
|
||||
)
|
||||
|
||||
return (
|
||||
dcntr,
|
||||
|
@ -211,6 +230,49 @@ def start_marketstore(
|
|||
)
|
||||
|
||||
|
||||
@acm
|
||||
async def start_ahab_daemon(
|
||||
service_mngr: Services,
|
||||
user_config: dict | None = None,
|
||||
loglevel: str | None = None,
|
||||
|
||||
) -> tuple[str, dict]:
|
||||
'''
|
||||
Task entrypoint to start the marketstore docker container using the
|
||||
service manager.
|
||||
|
||||
'''
|
||||
from ._ahab import start_ahab_service
|
||||
|
||||
# dict-merge any user settings
|
||||
conf: dict = _config.copy()
|
||||
if user_config:
|
||||
conf: dict = conf | user_config
|
||||
|
||||
dname: str = 'marketstored'
|
||||
log.info(f'Spawning `{dname}` supervisor')
|
||||
async with start_ahab_service(
|
||||
service_mngr,
|
||||
dname,
|
||||
|
||||
# NOTE: docker-py client is passed at runtime
|
||||
start_marketstore,
|
||||
ep_kwargs={'user_config': conf},
|
||||
loglevel=loglevel,
|
||||
) as (
|
||||
_,
|
||||
config,
|
||||
(cid, pid),
|
||||
):
|
||||
log.info(
|
||||
f'`{dname}` up!\n'
|
||||
f'pid: {pid}\n'
|
||||
f'container id: {cid[:12]}\n'
|
||||
f'config: {pformat(config)}'
|
||||
)
|
||||
yield dname, conf
|
||||
|
||||
|
||||
_tick_tbk_ids: tuple[str, str] = ('1Sec', 'TICK')
|
||||
_tick_tbk: str = '{}/' + '/'.join(_tick_tbk_ids)
|
||||
|
||||
|
@ -286,7 +348,7 @@ def mk_tbk(keys: tuple[str, str, str]) -> str:
|
|||
|
||||
def quote_to_marketstore_structarray(
|
||||
quote: dict[str, Any],
|
||||
last_fill: Optional[float]
|
||||
last_fill: float | None,
|
||||
|
||||
) -> np.array:
|
||||
'''
|
||||
|
@ -327,8 +389,8 @@ def quote_to_marketstore_structarray(
|
|||
|
||||
@acm
|
||||
async def get_client(
|
||||
host: str = 'localhost',
|
||||
port: int = _config['grpc_listen_port'],
|
||||
host: str | None,
|
||||
port: int | None,
|
||||
|
||||
) -> MarketstoreClient:
|
||||
'''
|
||||
|
@ -337,8 +399,8 @@ async def get_client(
|
|||
|
||||
'''
|
||||
async with open_marketstore_client(
|
||||
host,
|
||||
port
|
||||
host or 'localhost',
|
||||
port or _config['grpc_listen_port'],
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
@ -402,18 +464,18 @@ class Storage:
|
|||
|
||||
async def load(
|
||||
self,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
timeframe: int,
|
||||
|
||||
) -> tuple[
|
||||
np.ndarray, # timeframe sampled array-series
|
||||
Optional[datetime], # first dt
|
||||
Optional[datetime], # last dt
|
||||
datetime | None, # first dt
|
||||
datetime | None, # last dt
|
||||
]:
|
||||
|
||||
first_tsdb_dt, last_tsdb_dt = None, None
|
||||
hist = await self.read_ohlcv(
|
||||
fqsn,
|
||||
fqme,
|
||||
# on first load we don't need to pull the max
|
||||
# history per request size worth.
|
||||
limit=3000,
|
||||
|
@ -436,9 +498,9 @@ class Storage:
|
|||
|
||||
async def read_ohlcv(
|
||||
self,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
timeframe: int | str,
|
||||
end: Optional[int] = None,
|
||||
end: int | None = None,
|
||||
limit: int = int(800e3),
|
||||
|
||||
) -> np.ndarray:
|
||||
|
@ -446,14 +508,14 @@ class Storage:
|
|||
client = self.client
|
||||
syms = await client.list_symbols()
|
||||
|
||||
if fqsn not in syms:
|
||||
if fqme not in syms:
|
||||
return {}
|
||||
|
||||
# use the provided timeframe or 1s by default
|
||||
tfstr = tf_in_1s.get(timeframe, tf_in_1s[1])
|
||||
|
||||
params = Params(
|
||||
symbols=fqsn,
|
||||
symbols=fqme,
|
||||
timeframe=tfstr,
|
||||
attrgroup='OHLCV',
|
||||
end=end,
|
||||
|
@ -464,20 +526,26 @@ class Storage:
|
|||
limit=limit,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.query(params)
|
||||
except purerpc.grpclib.exceptions.UnknownError as err:
|
||||
# indicate there is no history for this timeframe
|
||||
log.exception(
|
||||
f'Unknown mkts QUERY error: {params}\n'
|
||||
f'{err.args}'
|
||||
)
|
||||
for i in range(3):
|
||||
try:
|
||||
result = await client.query(params)
|
||||
break
|
||||
except purerpc.grpclib.exceptions.UnknownError as err:
|
||||
if 'snappy' in err.args:
|
||||
await tractor.breakpoint()
|
||||
|
||||
# indicate there is no history for this timeframe
|
||||
log.exception(
|
||||
f'Unknown mkts QUERY error: {params}\n'
|
||||
f'{err.args}'
|
||||
)
|
||||
else:
|
||||
return {}
|
||||
|
||||
# TODO: it turns out column access on recarrays is actually slower:
|
||||
# https://jakevdp.github.io/PythonDataScienceHandbook/02.09-structured-data-numpy.html#RecordArrays:-Structured-Arrays-with-a-Twist
|
||||
# it might make sense to make these structured arrays?
|
||||
data_set = result.by_symbols()[fqsn]
|
||||
data_set = result.by_symbols()[fqme]
|
||||
array = data_set.array
|
||||
|
||||
# XXX: ensure sample rate is as expected
|
||||
|
@ -492,11 +560,11 @@ class Storage:
|
|||
'YOUR DATABASE LIKELY CONTAINS BAD DATA FROM AN OLD BUG'
|
||||
f'WIPING HISTORY FOR {ts}s'
|
||||
)
|
||||
await self.delete_ts(fqsn, timeframe)
|
||||
await self.delete_ts(fqme, timeframe)
|
||||
|
||||
# try reading again..
|
||||
return await self.read_ohlcv(
|
||||
fqsn,
|
||||
fqme,
|
||||
timeframe,
|
||||
end,
|
||||
limit,
|
||||
|
@ -507,7 +575,7 @@ class Storage:
|
|||
async def delete_ts(
|
||||
self,
|
||||
key: str,
|
||||
timeframe: Optional[Union[int, str]] = None,
|
||||
timeframe: Union[int, str | None] = None,
|
||||
fmt: str = 'OHLCV',
|
||||
|
||||
) -> bool:
|
||||
|
@ -515,6 +583,7 @@ class Storage:
|
|||
client = self.client
|
||||
syms = await client.list_symbols()
|
||||
if key not in syms:
|
||||
await tractor.breakpoint()
|
||||
raise KeyError(f'`{key}` table key not found in\n{syms}?')
|
||||
|
||||
tbk = mk_tbk((
|
||||
|
@ -526,7 +595,7 @@ class Storage:
|
|||
|
||||
async def write_ohlcv(
|
||||
self,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
ohlcv: np.ndarray,
|
||||
timeframe: int,
|
||||
append_and_duplicate: bool = True,
|
||||
|
@ -559,7 +628,7 @@ class Storage:
|
|||
# write to db
|
||||
resp = await self.client.write(
|
||||
to_push,
|
||||
tbk=f'{fqsn}/{tfkey}/OHLCV',
|
||||
tbk=f'{fqme}/{tfkey}/OHLCV',
|
||||
|
||||
# NOTE: will will append duplicates
|
||||
# for the same timestamp-index.
|
||||
|
@ -582,7 +651,7 @@ class Storage:
|
|||
# write to db
|
||||
resp = await self.client.write(
|
||||
to_push,
|
||||
tbk=f'{fqsn}/{tfkey}/OHLCV',
|
||||
tbk=f'{fqme}/{tfkey}/OHLCV',
|
||||
|
||||
# NOTE: will will append duplicates
|
||||
# for the same timestamp-index.
|
||||
|
@ -614,8 +683,8 @@ class Storage:
|
|||
|
||||
@acm
|
||||
async def open_storage_client(
|
||||
fqsn: str,
|
||||
period: Optional[Union[int, str]] = None, # in seconds
|
||||
host: str,
|
||||
grpc_port: int,
|
||||
|
||||
) -> tuple[Storage, dict[str, np.ndarray]]:
|
||||
'''
|
||||
|
@ -624,7 +693,10 @@ async def open_storage_client(
|
|||
'''
|
||||
async with (
|
||||
# eventually a storage backend endpoint
|
||||
get_client() as client,
|
||||
get_client(
|
||||
host=host,
|
||||
port=grpc_port,
|
||||
) as client,
|
||||
):
|
||||
# slap on our wrapper api
|
||||
yield Storage(client)
|
||||
|
@ -632,7 +704,7 @@ async def open_storage_client(
|
|||
|
||||
@acm
|
||||
async def open_tsdb_client(
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
) -> Storage:
|
||||
|
||||
# TODO: real-time dedicated task for ensuring
|
||||
|
@ -666,25 +738,34 @@ async def open_tsdb_client(
|
|||
delayed=False,
|
||||
)
|
||||
|
||||
# load any user service settings for connecting to
|
||||
rootconf, path = config.load(
|
||||
'conf',
|
||||
touch_if_dne=True,
|
||||
)
|
||||
tsdbconf = rootconf['network'].get('tsdb')
|
||||
# backend = tsdbconf.pop('backend')
|
||||
async with (
|
||||
open_storage_client(fqsn) as storage,
|
||||
open_storage_client(
|
||||
**tsdbconf,
|
||||
) as storage,
|
||||
|
||||
maybe_open_feed(
|
||||
[fqsn],
|
||||
[fqme],
|
||||
start_stream=False,
|
||||
|
||||
) as feed,
|
||||
):
|
||||
profiler(f'opened feed for {fqsn}')
|
||||
profiler(f'opened feed for {fqme}')
|
||||
|
||||
# to_append = feed.hist_shm.array
|
||||
# to_prepend = None
|
||||
|
||||
if fqsn:
|
||||
flume = feed.flumes[fqsn]
|
||||
symbol = flume.symbol
|
||||
if fqme:
|
||||
flume = feed.flumes[fqme]
|
||||
symbol = flume.mkt
|
||||
if symbol:
|
||||
fqsn = symbol.fqsn
|
||||
fqme = symbol.fqme
|
||||
|
||||
# diff db history with shm and only write the missing portions
|
||||
# ohlcv = flume.hist_shm.array
|
||||
|
@ -692,7 +773,7 @@ async def open_tsdb_client(
|
|||
# TODO: use pg profiler
|
||||
# for secs in (1, 60):
|
||||
# tsdb_array = await storage.read_ohlcv(
|
||||
# fqsn,
|
||||
# fqme,
|
||||
# timeframe=timeframe,
|
||||
# )
|
||||
# # hist diffing:
|
||||
|
@ -703,7 +784,7 @@ async def open_tsdb_client(
|
|||
|
||||
# profiler('Finished db arrays diffs')
|
||||
|
||||
syms = await storage.client.list_symbols()
|
||||
_ = await storage.client.list_symbols()
|
||||
# log.info(f'Existing tsdb symbol set:\n{pformat(syms)}')
|
||||
# profiler(f'listed symbols {syms}')
|
||||
yield storage
|
||||
|
@ -715,7 +796,7 @@ async def open_tsdb_client(
|
|||
# log.info(
|
||||
# f'Writing datums {array.size} -> to tsdb from shm\n'
|
||||
# )
|
||||
# await storage.write_ohlcv(fqsn, array)
|
||||
# await storage.write_ohlcv(fqme, array)
|
||||
|
||||
# profiler('Finished db writes')
|
||||
|
||||
|
@ -882,3 +963,5 @@ async def stream_quotes(
|
|||
|
||||
if quotes:
|
||||
yield quotes
|
||||
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ from ..service import maybe_spawn_brokerd
|
|||
from . import _event
|
||||
from ._exec import run_qtractor
|
||||
from ..data.feed import install_brokerd_search
|
||||
from ..data._source import unpack_fqsn
|
||||
from ..accounting import unpack_fqme
|
||||
from . import _search
|
||||
from ._chart import GodWidget
|
||||
from ..log import get_logger
|
||||
|
@ -100,8 +100,8 @@ async def _async_main(
|
|||
starting_done = sbar.open_status('starting ze sexy chartz')
|
||||
|
||||
needed_brokermods: dict[str, ModuleType] = {}
|
||||
for fqsn in syms:
|
||||
brokername, *_ = unpack_fqsn(fqsn)
|
||||
for fqme in syms:
|
||||
brokername, *_ = unpack_fqme(fqme)
|
||||
needed_brokermods[brokername] = brokers[brokername]
|
||||
|
||||
async with (
|
||||
|
@ -120,7 +120,7 @@ async def _async_main(
|
|||
|
||||
# this internally starts a ``display_symbol_data()`` task above
|
||||
order_mode_ready = await godwidget.load_symbols(
|
||||
fqsns=syms,
|
||||
fqmes=syms,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets
|
|||
from PyQt5.QtCore import QPointF
|
||||
|
||||
from . import _pg_overrides as pgo
|
||||
from ..data._source import float_digits
|
||||
from ..accounting._mktinfo import float_digits
|
||||
from ._label import Label
|
||||
from ._style import DpiAwareFont, hcolor, _font
|
||||
from ._interaction import ChartView
|
||||
|
|
|
@ -68,7 +68,9 @@ from ..data.feed import (
|
|||
Feed,
|
||||
Flume,
|
||||
)
|
||||
from ..data._source import Symbol
|
||||
from ..accounting import (
|
||||
MktPair,
|
||||
)
|
||||
from ..log import get_logger
|
||||
from ._interaction import ChartView
|
||||
from ._forms import FieldsForm
|
||||
|
@ -152,7 +154,7 @@ class GodWidget(QWidget):
|
|||
|
||||
def set_chart_symbols(
|
||||
self,
|
||||
group_key: tuple[str], # of form <fqsn>.<providername>
|
||||
group_key: tuple[str], # of form <fqme>.<providername>
|
||||
all_linked: tuple[LinkedSplits, LinkedSplits], # type: ignore
|
||||
|
||||
) -> None:
|
||||
|
@ -170,7 +172,7 @@ class GodWidget(QWidget):
|
|||
|
||||
async def load_symbols(
|
||||
self,
|
||||
fqsns: list[str],
|
||||
fqmes: list[str],
|
||||
loglevel: str,
|
||||
reset: bool = False,
|
||||
|
||||
|
@ -183,7 +185,7 @@ class GodWidget(QWidget):
|
|||
'''
|
||||
# NOTE: for now we use the first symbol in the set as the "key"
|
||||
# for the overlay of feeds on the chart.
|
||||
group_key: tuple[str] = tuple(fqsns)
|
||||
group_key: tuple[str] = tuple(fqmes)
|
||||
|
||||
all_linked = self.get_chart_symbols(group_key)
|
||||
order_mode_started = trio.Event()
|
||||
|
@ -217,7 +219,7 @@ class GodWidget(QWidget):
|
|||
self._root_n.start_soon(
|
||||
display_symbol_data,
|
||||
self,
|
||||
fqsns,
|
||||
fqmes,
|
||||
loglevel,
|
||||
order_mode_started,
|
||||
)
|
||||
|
@ -287,11 +289,11 @@ class GodWidget(QWidget):
|
|||
pp_nav.hide()
|
||||
|
||||
# set window titlebar info
|
||||
symbol = self.rt_linked.symbol
|
||||
symbol = self.rt_linked.mkt
|
||||
if symbol is not None:
|
||||
self.window.setWindowTitle(
|
||||
f'{symbol.front_fqsn()} '
|
||||
f'tick:{symbol.tick_size}'
|
||||
f'{symbol.fqme} '
|
||||
f'tick:{symbol.size_tick}'
|
||||
)
|
||||
|
||||
return order_mode_started
|
||||
|
@ -452,7 +454,7 @@ class LinkedSplits(QWidget):
|
|||
# update the UI for a given "chart instance".
|
||||
self.display_state: DisplayState | None = None
|
||||
|
||||
self._symbol: Symbol = None
|
||||
self._mkt: MktPair = None
|
||||
|
||||
def on_splitter_adjust(
|
||||
self,
|
||||
|
@ -474,9 +476,15 @@ class LinkedSplits(QWidget):
|
|||
**kwargs,
|
||||
)
|
||||
|
||||
def set_mkt_info(
|
||||
self,
|
||||
mkt: MktPair,
|
||||
) -> None:
|
||||
self._mkt = mkt
|
||||
|
||||
@property
|
||||
def symbol(self) -> Symbol:
|
||||
return self._symbol
|
||||
def mkt(self) -> MktPair:
|
||||
return self._mkt
|
||||
|
||||
def set_split_sizes(
|
||||
self,
|
||||
|
@ -521,7 +529,7 @@ class LinkedSplits(QWidget):
|
|||
def plot_ohlc_main(
|
||||
self,
|
||||
|
||||
symbol: Symbol,
|
||||
mkt: MktPair,
|
||||
shm: ShmArray,
|
||||
flume: Flume,
|
||||
sidepane: FieldsForm,
|
||||
|
@ -540,7 +548,7 @@ class LinkedSplits(QWidget):
|
|||
# add crosshairs
|
||||
self.cursor = Cursor(
|
||||
linkedsplits=self,
|
||||
digits=symbol.tick_size_digits,
|
||||
digits=mkt.price_tick_digits,
|
||||
)
|
||||
|
||||
# NOTE: atm the first (and only) OHLC price chart for the symbol
|
||||
|
@ -548,7 +556,7 @@ class LinkedSplits(QWidget):
|
|||
# be no distinction since we will have multiple symbols per
|
||||
# view as part of "aggregate feeds".
|
||||
self.chart = self.add_plot(
|
||||
name=symbol.fqsn,
|
||||
name=mkt.fqme,
|
||||
shm=shm,
|
||||
flume=flume,
|
||||
style=style,
|
||||
|
@ -1030,7 +1038,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
'''
|
||||
view = vb or self.view
|
||||
viz = self.main_viz
|
||||
l, r = viz.view_range()
|
||||
left, right = viz.view_range()
|
||||
x_shift = viz.index_step() * datums
|
||||
|
||||
if datums >= 300:
|
||||
|
@ -1040,8 +1048,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
# should trigger broadcast on all overlays right?
|
||||
view.setXRange(
|
||||
min=l + x_shift,
|
||||
max=r + x_shift,
|
||||
min=left + x_shift,
|
||||
max=right + x_shift,
|
||||
|
||||
# TODO: holy shit, wtf dude... why tf would this not be 0 by
|
||||
# default... speechless.
|
||||
|
@ -1222,12 +1230,12 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
# TODO: UGH! just make this not here! we should
|
||||
# be making the sticky from code which has access
|
||||
# to the ``Symbol`` instance..
|
||||
# to the ``MktPair`` instance..
|
||||
|
||||
# if the sticky is for our symbol
|
||||
# use the tick size precision for display
|
||||
name = name or pi.name
|
||||
sym = self.linked.symbol
|
||||
sym = self.linked.mkt
|
||||
digits = None
|
||||
if name == sym.key:
|
||||
digits = sym.tick_size_digits
|
||||
|
|
|
@ -228,7 +228,7 @@ class ContentsLabel(pg.LabelItem):
|
|||
'bar_wap',
|
||||
]
|
||||
],
|
||||
name=name,
|
||||
# name=name,
|
||||
index=ix,
|
||||
)
|
||||
)
|
||||
|
@ -363,7 +363,8 @@ class Cursor(pg.GraphicsObject):
|
|||
|
||||
# value used for rounding y-axis discreet tick steps
|
||||
# computing once, up front, here cuz why not
|
||||
self._y_incr_mult = 1 / self.linked._symbol.tick_size
|
||||
mkt = self.linked.mkt
|
||||
self._y_tick_mult = 1/float(mkt.price_tick)
|
||||
|
||||
# line width in view coordinates
|
||||
self._lw = self.pixelWidth() * self.lines_pen.width()
|
||||
|
@ -571,9 +572,15 @@ class Cursor(pg.GraphicsObject):
|
|||
line_offset = self._lw / 2
|
||||
|
||||
# round y value to nearest tick step
|
||||
m = self._y_incr_mult
|
||||
m = self._y_tick_mult
|
||||
iy = round(y * m) / m
|
||||
vl_y = iy - line_offset
|
||||
# print(
|
||||
# f'tick: {self._y_tick}\n'
|
||||
# f'y: {y}\n'
|
||||
# f'iy: {iy}\n'
|
||||
# f'vl_y: {vl_y}\n'
|
||||
# )
|
||||
|
||||
# update y-range items
|
||||
if iy != last_iy:
|
||||
|
|
|
@ -23,6 +23,8 @@ from functools import lru_cache
|
|||
from math import (
|
||||
ceil,
|
||||
floor,
|
||||
isnan,
|
||||
log as logf,
|
||||
)
|
||||
from typing import (
|
||||
Literal,
|
||||
|
@ -332,6 +334,8 @@ class Viz(Struct):
|
|||
float,
|
||||
] = {}
|
||||
|
||||
_mxmn_cache_enabled: bool = True
|
||||
|
||||
# to make lru_cache-ing work, see
|
||||
# https://docs.python.org/3/faq/programming.html#how-do-i-cache-method-calls
|
||||
def __eq__(self, other):
|
||||
|
@ -432,12 +436,12 @@ class Viz(Struct):
|
|||
else:
|
||||
if x_range is None:
|
||||
(
|
||||
l,
|
||||
xl,
|
||||
_,
|
||||
lbar,
|
||||
rbar,
|
||||
_,
|
||||
r,
|
||||
xr,
|
||||
) = self.datums_range()
|
||||
|
||||
profiler(f'{self.name} got bars range')
|
||||
|
@ -447,7 +451,10 @@ class Viz(Struct):
|
|||
# https://stackoverflow.com/a/29980872
|
||||
ixrng = lbar, rbar = round(x_range[0]), round(x_range[1])
|
||||
|
||||
if use_caching:
|
||||
if (
|
||||
use_caching
|
||||
and self._mxmn_cache_enabled
|
||||
):
|
||||
cached_result = self._mxmns.get(ixrng)
|
||||
if cached_result:
|
||||
if do_print:
|
||||
|
@ -521,8 +528,31 @@ class Viz(Struct):
|
|||
)
|
||||
|
||||
# cache result for input range
|
||||
assert mxmn
|
||||
self._mxmns[ixrng] = (read_slc, mxmn)
|
||||
ylow, yhi = mxmn
|
||||
|
||||
try:
|
||||
prolly_anomaly: bool = (
|
||||
(
|
||||
abs(logf(ylow, 10)) > 16
|
||||
if ylow
|
||||
else False
|
||||
)
|
||||
or (
|
||||
isnan(ylow) or isnan(yhi)
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
prolly_anomaly = True
|
||||
|
||||
if prolly_anomaly:
|
||||
return None
|
||||
|
||||
if (
|
||||
not isnan(ylow)
|
||||
and not prolly_anomaly
|
||||
):
|
||||
self._mxmns[ixrng] = (read_slc, mxmn)
|
||||
|
||||
self.vs.yrange = mxmn
|
||||
profiler(f'yrange mxmn cacheing: {x_range} -> {mxmn}')
|
||||
return (
|
||||
|
@ -555,12 +585,12 @@ class Viz(Struct):
|
|||
Return a range tuple for the datums present in view.
|
||||
|
||||
'''
|
||||
l, r = view_range or self.view_range()
|
||||
xl, xr = view_range or self.view_range()
|
||||
|
||||
index_field: str = index_field or self.index_field
|
||||
if index_field == 'index':
|
||||
l: int = round(l)
|
||||
r: int = round(r)
|
||||
xl: int = round(xl)
|
||||
xr: int = round(xr)
|
||||
|
||||
if array is None:
|
||||
array = self.shm.array
|
||||
|
@ -571,12 +601,12 @@ class Viz(Struct):
|
|||
|
||||
# invalid view state
|
||||
if (
|
||||
r < l
|
||||
or l < 0
|
||||
or r < 0
|
||||
xr < xl
|
||||
or xl < 0
|
||||
or xr < 0
|
||||
or (
|
||||
l > last
|
||||
and r > last
|
||||
xl > last
|
||||
and xr > last
|
||||
)
|
||||
):
|
||||
leftmost: int = first
|
||||
|
@ -586,12 +616,12 @@ class Viz(Struct):
|
|||
# determine first and last datums in view determined by
|
||||
# l -> r view range.
|
||||
rightmost = max(
|
||||
min(last, ceil(r)),
|
||||
min(last, ceil(xr)),
|
||||
first,
|
||||
)
|
||||
|
||||
leftmost = min(
|
||||
max(first, floor(l)),
|
||||
max(first, floor(xl)),
|
||||
last,
|
||||
rightmost - 1,
|
||||
)
|
||||
|
@ -602,12 +632,12 @@ class Viz(Struct):
|
|||
self.vs.xrange = leftmost, rightmost
|
||||
|
||||
return (
|
||||
l, # left x-in-view
|
||||
xl, # left x-in-view
|
||||
first, # first datum
|
||||
leftmost,
|
||||
rightmost,
|
||||
last, # last_datum
|
||||
r, # right-x-in-view
|
||||
xr, # right-x-in-view
|
||||
)
|
||||
|
||||
def read(
|
||||
|
@ -635,12 +665,12 @@ class Viz(Struct):
|
|||
profiler('self.shm.array READ')
|
||||
|
||||
(
|
||||
l,
|
||||
xl,
|
||||
ifirst,
|
||||
lbar,
|
||||
rbar,
|
||||
ilast,
|
||||
r,
|
||||
xr,
|
||||
) = self.datums_range(
|
||||
index_field=index_field,
|
||||
array=array,
|
||||
|
@ -685,8 +715,8 @@ class Viz(Struct):
|
|||
# a uniform time stamp step size?
|
||||
else:
|
||||
# get read-relative indices adjusting for master shm index.
|
||||
lbar_i = max(l, ifirst) - ifirst
|
||||
rbar_i = min(r, ilast) - ifirst
|
||||
lbar_i = max(xl, ifirst) - ifirst
|
||||
rbar_i = min(xr, ilast) - ifirst
|
||||
|
||||
# NOTE: the slice here does NOT include the extra ``+ 1``
|
||||
# BUT the ``in_view`` slice DOES..
|
||||
|
@ -1214,18 +1244,25 @@ class Viz(Struct):
|
|||
|
||||
'''
|
||||
# get most recent right datum index in-view
|
||||
l, start, datum_start, datum_stop, stop, r = self.datums_range()
|
||||
(
|
||||
xl,
|
||||
start,
|
||||
datum_start,
|
||||
datum_stop,
|
||||
stop,
|
||||
xr,
|
||||
) = self.datums_range()
|
||||
lasts = self.shm.array[-1]
|
||||
i_step = lasts['index'] # last index-specific step.
|
||||
i_step_t = lasts['time'] # last time step.
|
||||
|
||||
# fqsn = self.flume.symbol.fqsn
|
||||
# fqme = self.flume.mkt.fqme
|
||||
|
||||
# check if "last (is) in view" -> is a real-time update necessary?
|
||||
if self.index_field == 'index':
|
||||
liv = (r >= i_step)
|
||||
liv = (xr >= i_step)
|
||||
else:
|
||||
liv = (r >= i_step_t)
|
||||
liv = (xr >= i_step_t)
|
||||
|
||||
# compute the first available graphic obj's x-units-per-pixel
|
||||
# TODO: make this not loop through all vizs each time!
|
||||
|
@ -1274,7 +1311,7 @@ class Viz(Struct):
|
|||
varz['i_last_append'] = i_step
|
||||
|
||||
# print(
|
||||
# f'DOING APPEND => {fqsn}\n'
|
||||
# f'DOING APPEND => {fqme}\n'
|
||||
# f'i_step: {i_step}\n'
|
||||
# f'i_step_t: {i_step_t}\n'
|
||||
# f'glast: {glast}\n'
|
||||
|
|
|
@ -37,6 +37,9 @@ import pyqtgraph as pg
|
|||
from msgspec import field
|
||||
|
||||
# from .. import brokers
|
||||
from ..accounting import (
|
||||
MktPair,
|
||||
)
|
||||
from ..data.feed import (
|
||||
open_feed,
|
||||
Feed,
|
||||
|
@ -62,7 +65,6 @@ from ._style import hcolor
|
|||
from ._fsp import (
|
||||
update_fsp_chart,
|
||||
start_fsp_displays,
|
||||
has_vlm,
|
||||
open_vlm_displays,
|
||||
)
|
||||
from ._forms import (
|
||||
|
@ -163,7 +165,7 @@ class DisplayState(Struct):
|
|||
Chart-local real-time graphics state container.
|
||||
|
||||
'''
|
||||
fqsn: str
|
||||
fqme: str
|
||||
godwidget: GodWidget
|
||||
quotes: dict[str, Any]
|
||||
|
||||
|
@ -223,7 +225,7 @@ async def increment_history_view(
|
|||
async for msg in istream:
|
||||
|
||||
profiler = Profiler(
|
||||
msg=f'History chart cycle for: `{ds.fqsn}`',
|
||||
msg=f'History chart cycle for: `{ds.fqme}`',
|
||||
delayed=True,
|
||||
disabled=not pg_profile_enabled(),
|
||||
ms_threshold=ms_slower_then,
|
||||
|
@ -232,7 +234,7 @@ async def increment_history_view(
|
|||
|
||||
# l3 = ds.viz.shm.array[-3:]
|
||||
# print(
|
||||
# f'fast step for {ds.flume.symbol.fqsn}:\n'
|
||||
# f'fast step for {ds.flume.mkt.fqme}:\n'
|
||||
# f'{list(l3["time"])}\n'
|
||||
# f'{l3}\n'
|
||||
# )
|
||||
|
@ -317,17 +319,17 @@ async def graphics_update_loop(
|
|||
|
||||
dss: dict[str, DisplayState] = {}
|
||||
|
||||
for fqsn, flume in feed.flumes.items():
|
||||
for fqme, flume in feed.flumes.items():
|
||||
ohlcv = flume.rt_shm
|
||||
hist_ohlcv = flume.hist_shm
|
||||
symbol = flume.symbol
|
||||
fqsn = symbol.fqsn
|
||||
mkt = flume.mkt
|
||||
fqme = mkt.fqme
|
||||
|
||||
# update last price sticky
|
||||
fast_viz = fast_chart._vizs[fqsn]
|
||||
fast_viz = fast_chart._vizs[fqme]
|
||||
index_field = fast_viz.index_field
|
||||
fast_pi = fast_viz.plot
|
||||
last_price_sticky = fast_pi.getAxis('right')._stickies[fqsn]
|
||||
last_price_sticky = fast_pi.getAxis('right')._stickies[fqme]
|
||||
last_price_sticky.update_from_data(
|
||||
*ohlcv.array[-1][[
|
||||
index_field,
|
||||
|
@ -336,9 +338,9 @@ async def graphics_update_loop(
|
|||
)
|
||||
last_price_sticky.show()
|
||||
|
||||
hist_viz = hist_chart._vizs[fqsn]
|
||||
hist_viz = hist_chart._vizs[fqme]
|
||||
slow_pi = hist_viz.plot
|
||||
hist_last_price_sticky = slow_pi.getAxis('right')._stickies[fqsn]
|
||||
hist_last_price_sticky = slow_pi.getAxis('right')._stickies[fqme]
|
||||
hist_last_price_sticky.update_from_data(
|
||||
*hist_ohlcv.array[-1][[
|
||||
index_field,
|
||||
|
@ -346,7 +348,7 @@ async def graphics_update_loop(
|
|||
]]
|
||||
)
|
||||
|
||||
vlm_chart = vlm_charts[fqsn]
|
||||
vlm_chart = vlm_charts[fqme]
|
||||
vlm_viz = vlm_chart._vizs.get('volume') if vlm_chart else None
|
||||
|
||||
(
|
||||
|
@ -361,13 +363,13 @@ async def graphics_update_loop(
|
|||
|
||||
last, volume = ohlcv.array[-1][['close', 'volume']]
|
||||
|
||||
symbol = flume.symbol
|
||||
mkt = flume.mkt
|
||||
|
||||
l1 = L1Labels(
|
||||
fast_pi,
|
||||
# determine precision/decimal lengths
|
||||
digits=symbol.tick_size_digits,
|
||||
size_digits=symbol.lot_size_digits,
|
||||
digits=mkt.price_tick_digits,
|
||||
size_digits=mkt.size_tick_digits,
|
||||
)
|
||||
|
||||
# TODO:
|
||||
|
@ -381,8 +383,8 @@ async def graphics_update_loop(
|
|||
fast_chart.show()
|
||||
last_quote_s = time.time()
|
||||
|
||||
dss[fqsn] = ds = linked.display_state = DisplayState(**{
|
||||
'fqsn': fqsn,
|
||||
dss[fqme] = ds = linked.display_state = DisplayState(**{
|
||||
'fqme': fqme,
|
||||
'godwidget': godwidget,
|
||||
'quotes': {},
|
||||
|
||||
|
@ -450,15 +452,15 @@ async def graphics_update_loop(
|
|||
and quote_rate >= display_rate
|
||||
):
|
||||
pass
|
||||
# log.warning(f'High quote rate {symbol.key}: {quote_rate}')
|
||||
# log.warning(f'High quote rate {mkt.fqme}: {quote_rate}')
|
||||
|
||||
last_quote_s = time.time()
|
||||
|
||||
for fqsn, quote in quotes.items():
|
||||
ds = dss[fqsn]
|
||||
for fqme, quote in quotes.items():
|
||||
ds = dss[fqme]
|
||||
ds.quotes = quote
|
||||
|
||||
rt_pi, hist_pi = pis[fqsn]
|
||||
rt_pi, hist_pi = pis[fqme]
|
||||
|
||||
# chart isn't active/shown so skip render cycle and
|
||||
# pause feed(s)
|
||||
|
@ -466,14 +468,14 @@ async def graphics_update_loop(
|
|||
fast_chart.linked.isHidden()
|
||||
or not rt_pi.isVisible()
|
||||
):
|
||||
print(f'{fqsn} skipping update for HIDDEN CHART')
|
||||
print(f'{fqme} skipping update for HIDDEN CHART')
|
||||
fast_chart.pause_all_feeds()
|
||||
continue
|
||||
|
||||
ic = fast_chart.view._in_interact
|
||||
if ic:
|
||||
fast_chart.pause_all_feeds()
|
||||
print(f'{fqsn} PAUSING DURING INTERACTION')
|
||||
print(f'{fqme} PAUSING DURING INTERACTION')
|
||||
await ic.wait()
|
||||
fast_chart.resume_all_feeds()
|
||||
|
||||
|
@ -495,7 +497,7 @@ def graphics_update_cycle(
|
|||
) -> None:
|
||||
|
||||
profiler = Profiler(
|
||||
msg=f'Graphics loop cycle for: `{ds.fqsn}`',
|
||||
msg=f'Graphics loop cycle for: `{ds.fqme}`',
|
||||
disabled=not pg_profile_enabled(),
|
||||
ms_threshold=ms_slower_then,
|
||||
delayed=True,
|
||||
|
@ -509,7 +511,7 @@ def graphics_update_cycle(
|
|||
# - use a streaming minmax algo and drop the use of the
|
||||
# state-tracking ``multi_maxmin()`` routine from above?
|
||||
|
||||
fqsn = ds.fqsn
|
||||
fqme = ds.fqme
|
||||
chart = ds.chart
|
||||
vlm_chart = ds.vlm_chart
|
||||
|
||||
|
@ -548,7 +550,7 @@ def graphics_update_cycle(
|
|||
# the true range? This way you can slap in orders outside the
|
||||
# current L1 (only) book range.
|
||||
main_vb: ChartView = main_viz.plot.vb
|
||||
this_viz: Viz = chart._vizs[fqsn]
|
||||
this_viz: Viz = chart._vizs[fqme]
|
||||
this_vb: ChartView = this_viz.plot.vb
|
||||
this_yr = this_vb._yrange
|
||||
if this_yr:
|
||||
|
@ -600,7 +602,7 @@ def graphics_update_cycle(
|
|||
profiler,
|
||||
)
|
||||
|
||||
profiler(f'{fqsn} `multi_maxmin()` call')
|
||||
profiler(f'{fqme} `multi_maxmin()` call')
|
||||
|
||||
# iterate frames of ticks-by-type such that we only update graphics
|
||||
# using the last update per type where possible.
|
||||
|
@ -828,7 +830,7 @@ def graphics_update_cycle(
|
|||
|
||||
# update any overlayed fsp flows
|
||||
if (
|
||||
curve_name != fqsn
|
||||
curve_name != fqme
|
||||
):
|
||||
update_fsp_chart(
|
||||
viz,
|
||||
|
@ -939,7 +941,7 @@ def graphics_update_cycle(
|
|||
liv and do_rt_update
|
||||
or do_px_step
|
||||
)
|
||||
and curve_name not in {fqsn}
|
||||
and curve_name not in {fqme}
|
||||
):
|
||||
update_fsp_chart(
|
||||
viz,
|
||||
|
@ -1008,7 +1010,7 @@ async def link_views_with_region(
|
|||
hist_pi.addItem(region, ignoreBounds=True)
|
||||
region.setOpacity(6/16)
|
||||
|
||||
viz = rt_chart.get_viz(flume.symbol.fqsn)
|
||||
viz = rt_chart.get_viz(flume.mkt.fqme)
|
||||
assert viz
|
||||
index_field = viz.index_field
|
||||
|
||||
|
@ -1035,7 +1037,7 @@ async def link_views_with_region(
|
|||
# HFT/real-time chart.
|
||||
rng = mn, mx = viewRange[0]
|
||||
|
||||
# hist_viz = hist_chart.get_viz(flume.symbol.fqsn)
|
||||
# hist_viz = hist_chart.get_viz(flume.mkt.fqme)
|
||||
# hist = hist_viz.shm.array[-3:]
|
||||
# print(
|
||||
# f'mn: {mn}\n'
|
||||
|
@ -1153,7 +1155,7 @@ _quote_throttle_rate: int = 60 - 6
|
|||
|
||||
async def display_symbol_data(
|
||||
godwidget: GodWidget,
|
||||
fqsns: list[str],
|
||||
fqmes: list[str],
|
||||
loglevel: str,
|
||||
order_mode_started: trio.Event,
|
||||
|
||||
|
@ -1176,9 +1178,9 @@ async def display_symbol_data(
|
|||
# group_key=loading_sym_key,
|
||||
# )
|
||||
|
||||
for fqsn in fqsns:
|
||||
for fqme in fqmes:
|
||||
loading_sym_key = sbar.open_status(
|
||||
f'loading {fqsn} ->',
|
||||
f'loading {fqme} ->',
|
||||
group_key=True
|
||||
)
|
||||
|
||||
|
@ -1197,7 +1199,7 @@ async def display_symbol_data(
|
|||
# TODO: we should be able to increase this if we use some
|
||||
# `mypyc` speedups elsewhere? 22ish seems to be the sweet
|
||||
# spot for single-feed chart.
|
||||
num_of_feeds = len(fqsns)
|
||||
num_of_feeds = len(fqmes)
|
||||
mx: int = 22
|
||||
if num_of_feeds > 1:
|
||||
# there will be more ctx switches with more than 1 feed so we
|
||||
|
@ -1213,20 +1215,19 @@ async def display_symbol_data(
|
|||
|
||||
feed: Feed
|
||||
async with open_feed(
|
||||
fqsns,
|
||||
fqmes,
|
||||
loglevel=loglevel,
|
||||
tick_throttle=cycles_per_feed,
|
||||
|
||||
) as feed:
|
||||
|
||||
# use expanded contract symbols passed back from feed layer.
|
||||
fqsns = list(feed.flumes.keys())
|
||||
|
||||
fqmes = list(feed.flumes.keys())
|
||||
# step_size_s = 1
|
||||
# tf_key = tf_in_1s[step_size_s]
|
||||
godwidget.window.setWindowTitle(
|
||||
f'{fqsns} '
|
||||
# f'tick:{symbol.tick_size} '
|
||||
f'{fqmes} '
|
||||
# f'tick:{mkt.tick_size} '
|
||||
# f'step:{tf_key} '
|
||||
)
|
||||
# generate order mode side-pane UI
|
||||
|
@ -1236,8 +1237,8 @@ async def display_symbol_data(
|
|||
godwidget.pp_pane = pp_pane
|
||||
|
||||
# create top history view chart above the "main rt chart".
|
||||
rt_linked = godwidget.rt_linked
|
||||
hist_linked = godwidget.hist_linked
|
||||
rt_linked: LinkedSplits = godwidget.rt_linked
|
||||
hist_linked: LinkedSplits = godwidget.hist_linked
|
||||
|
||||
# NOTE: here we insert the slow-history chart set into
|
||||
# the fast chart's splitter -> so it's a splitter of charts
|
||||
|
@ -1277,22 +1278,21 @@ async def display_symbol_data(
|
|||
|
||||
# for the "first"/selected symbol we create new chart widgets
|
||||
# and sub-charts for FSPs
|
||||
fqsn, flume = fitems[0]
|
||||
fqme, flume = fitems[0]
|
||||
|
||||
# TODO NOTE: THIS CONTROLS WHAT SYMBOL IS USED FOR ORDER MODE
|
||||
# SUBMISSIONS, we need to make this switch based on selection.
|
||||
rt_linked._symbol = flume.symbol
|
||||
hist_linked._symbol = flume.symbol
|
||||
rt_linked.set_mkt_info(flume.mkt)
|
||||
hist_linked.set_mkt_info(flume.mkt)
|
||||
|
||||
ohlcv: ShmArray = flume.rt_shm
|
||||
hist_ohlcv: ShmArray = flume.hist_shm
|
||||
|
||||
symbol = flume.symbol
|
||||
brokername = symbol.brokers[0]
|
||||
fqsn = symbol.fqsn
|
||||
mkt: MktPair = flume.mkt
|
||||
fqme = mkt.fqme
|
||||
|
||||
hist_chart = hist_linked.plot_ohlc_main(
|
||||
symbol,
|
||||
mkt,
|
||||
hist_ohlcv,
|
||||
flume,
|
||||
# in the case of history chart we explicitly set `False`
|
||||
|
@ -1306,15 +1306,15 @@ async def display_symbol_data(
|
|||
|
||||
# ensure the last datum graphic is generated
|
||||
# for zoom-interaction purposes.
|
||||
hist_viz = hist_chart.get_viz(fqsn)
|
||||
hist_viz.draw_last(array_key=fqsn)
|
||||
pis.setdefault(fqsn, [None, None])[1] = hist_chart.plotItem
|
||||
hist_viz = hist_chart.get_viz(fqme)
|
||||
hist_viz.draw_last(array_key=fqme)
|
||||
pis.setdefault(fqme, [None, None])[1] = hist_chart.plotItem
|
||||
|
||||
# don't show when not focussed
|
||||
hist_linked.cursor.always_show_xlabel = False
|
||||
|
||||
rt_chart = rt_linked.plot_ohlc_main(
|
||||
symbol,
|
||||
mkt,
|
||||
ohlcv,
|
||||
flume,
|
||||
# in the case of history chart we explicitly set `False`
|
||||
|
@ -1324,8 +1324,8 @@ async def display_symbol_data(
|
|||
'last_step_color': 'original',
|
||||
},
|
||||
)
|
||||
rt_viz = rt_chart.get_viz(fqsn)
|
||||
pis.setdefault(fqsn, [None, None])[0] = rt_chart.plotItem
|
||||
rt_viz = rt_chart.get_viz(fqme)
|
||||
pis.setdefault(fqme, [None, None])[0] = rt_chart.plotItem
|
||||
|
||||
# for pause/resume on mouse interaction
|
||||
rt_chart.feed = feed
|
||||
|
@ -1337,11 +1337,10 @@ async def display_symbol_data(
|
|||
None | ChartPlotWidget
|
||||
] = {}.fromkeys(feed.flumes)
|
||||
if (
|
||||
not symbol.broker_info[brokername].get('no_vlm', False)
|
||||
and has_vlm(ohlcv)
|
||||
flume.has_vlm()
|
||||
and vlm_chart is None
|
||||
):
|
||||
vlm_chart = vlm_charts[fqsn] = await ln.start(
|
||||
vlm_chart = vlm_charts[fqme] = await ln.start(
|
||||
open_vlm_displays,
|
||||
rt_linked,
|
||||
flume,
|
||||
|
@ -1375,26 +1374,26 @@ async def display_symbol_data(
|
|||
godwidget.resize_all()
|
||||
await trio.sleep(0)
|
||||
|
||||
for fqsn, flume in fitems[1:]:
|
||||
for fqme, flume in fitems[1:]:
|
||||
# get a new color from the palette
|
||||
bg_chart_color, bg_last_bar_color = next(palette)
|
||||
|
||||
ohlcv: ShmArray = flume.rt_shm
|
||||
hist_ohlcv: ShmArray = flume.hist_shm
|
||||
|
||||
symbol = flume.symbol
|
||||
fqsn = symbol.fqsn
|
||||
mkt = flume.mkt
|
||||
fqme = mkt.fqme
|
||||
|
||||
hist_pi = hist_chart.overlay_plotitem(
|
||||
name=fqsn,
|
||||
axis_title=fqsn,
|
||||
name=fqme,
|
||||
axis_title=fqme,
|
||||
)
|
||||
|
||||
hist_viz = hist_chart.draw_curve(
|
||||
fqsn,
|
||||
fqme,
|
||||
hist_ohlcv,
|
||||
flume,
|
||||
array_key=fqsn,
|
||||
array_key=fqme,
|
||||
overlay=hist_pi,
|
||||
pi=hist_pi,
|
||||
is_ohlc=True,
|
||||
|
@ -1405,26 +1404,26 @@ async def display_symbol_data(
|
|||
|
||||
# ensure the last datum graphic is generated
|
||||
# for zoom-interaction purposes.
|
||||
hist_viz.draw_last(array_key=fqsn)
|
||||
hist_viz.draw_last(array_key=fqme)
|
||||
|
||||
# TODO: we need a better API to do this..
|
||||
# specially store ref to shm for lookup in display loop
|
||||
# since only a placeholder of `None` is entered in
|
||||
# ``.draw_curve()``.
|
||||
hist_viz = hist_chart._vizs[fqsn]
|
||||
hist_viz = hist_chart._vizs[fqme]
|
||||
assert hist_viz.plot is hist_pi
|
||||
pis.setdefault(fqsn, [None, None])[1] = hist_pi
|
||||
pis.setdefault(fqme, [None, None])[1] = hist_pi
|
||||
|
||||
rt_pi = rt_chart.overlay_plotitem(
|
||||
name=fqsn,
|
||||
axis_title=fqsn,
|
||||
name=fqme,
|
||||
axis_title=fqme,
|
||||
)
|
||||
|
||||
rt_viz = rt_chart.draw_curve(
|
||||
fqsn,
|
||||
fqme,
|
||||
ohlcv,
|
||||
flume,
|
||||
array_key=fqsn,
|
||||
array_key=fqme,
|
||||
overlay=rt_pi,
|
||||
pi=rt_pi,
|
||||
is_ohlc=True,
|
||||
|
@ -1437,9 +1436,9 @@ async def display_symbol_data(
|
|||
# specially store ref to shm for lookup in display loop
|
||||
# since only a placeholder of `None` is entered in
|
||||
# ``.draw_curve()``.
|
||||
rt_viz = rt_chart._vizs[fqsn]
|
||||
rt_viz = rt_chart._vizs[fqme]
|
||||
assert rt_viz.plot is rt_pi
|
||||
pis.setdefault(fqsn, [None, None])[0] = rt_pi
|
||||
pis.setdefault(fqme, [None, None])[0] = rt_pi
|
||||
|
||||
rt_chart.setFocus()
|
||||
|
||||
|
@ -1455,7 +1454,7 @@ async def display_symbol_data(
|
|||
|
||||
# greedily do a view range default and pane resizing
|
||||
# on startup before loading the order-mode machinery.
|
||||
for fqsn, flume in feed.flumes.items():
|
||||
for fqme, flume in feed.flumes.items():
|
||||
|
||||
# size view to data prior to order mode init
|
||||
rt_chart.main_viz.default_view(
|
||||
|
@ -1473,6 +1472,7 @@ async def display_symbol_data(
|
|||
|
||||
hist_chart.main_viz.default_view(
|
||||
do_min_bars=True,
|
||||
do_ds=False,
|
||||
)
|
||||
hist_linked.graphics_cycle()
|
||||
|
||||
|
@ -1497,13 +1497,13 @@ async def display_symbol_data(
|
|||
)
|
||||
|
||||
# boot order-mode
|
||||
order_ctl_symbol: str = fqsns[0]
|
||||
order_ctl_fqme: str = fqmes[0]
|
||||
mode: OrderMode
|
||||
async with (
|
||||
open_order_mode(
|
||||
feed,
|
||||
godwidget,
|
||||
fqsns[0],
|
||||
order_ctl_fqme,
|
||||
order_mode_started,
|
||||
loglevel=loglevel
|
||||
) as mode
|
||||
|
@ -1511,7 +1511,7 @@ async def display_symbol_data(
|
|||
|
||||
rt_linked.mode = mode
|
||||
|
||||
rt_viz = rt_chart.get_viz(order_ctl_symbol)
|
||||
rt_viz = rt_chart.get_viz(order_ctl_fqme)
|
||||
rt_viz.plot.setFocus()
|
||||
|
||||
# default view adjuments and sidepane alignment
|
||||
|
@ -1524,7 +1524,7 @@ async def display_symbol_data(
|
|||
hist_chart.main_viz.default_view(
|
||||
do_min_bars=True,
|
||||
)
|
||||
hist_viz = hist_chart.get_viz(fqsn)
|
||||
hist_viz = hist_chart.get_viz(fqme)
|
||||
await trio.sleep(0)
|
||||
|
||||
godwidget.resize_all()
|
||||
|
|
|
@ -29,7 +29,6 @@ from typing import (
|
|||
Any,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
import msgspec
|
||||
import tractor
|
||||
import pyqtgraph as pg
|
||||
|
@ -46,7 +45,7 @@ from ..data._sharedmem import (
|
|||
try_read,
|
||||
)
|
||||
from ..data.feed import Flume
|
||||
from ..data._source import Symbol
|
||||
from ..accounting import MktPair
|
||||
from ._chart import (
|
||||
ChartPlotWidget,
|
||||
LinkedSplits,
|
||||
|
@ -72,14 +71,6 @@ from .._profile import Profiler
|
|||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def has_vlm(ohlcv: ShmArray) -> bool:
|
||||
# make sure that the instrument supports volume history
|
||||
# (sometimes this is not the case for some commodities and
|
||||
# derivatives)
|
||||
vlm = ohlcv.array['volume']
|
||||
return not bool(np.all(np.isin(vlm, -1)) or np.all(np.isnan(vlm)))
|
||||
|
||||
|
||||
def update_fsp_chart(
|
||||
viz,
|
||||
graphics_name: str,
|
||||
|
@ -398,7 +389,7 @@ class FspAdmin:
|
|||
portal: tractor.Portal,
|
||||
complete: trio.Event,
|
||||
started: trio.Event,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
dst_fsp_flume: Flume,
|
||||
conf: dict,
|
||||
target: Fsp,
|
||||
|
@ -418,7 +409,7 @@ class FspAdmin:
|
|||
cascade,
|
||||
|
||||
# data feed key
|
||||
fqsn=fqsn,
|
||||
fqme=fqme,
|
||||
|
||||
# TODO: pass `Flume.to_msg()`s here?
|
||||
# mems
|
||||
|
@ -436,7 +427,7 @@ class FspAdmin:
|
|||
in self._flow_registry.items()
|
||||
],
|
||||
|
||||
) as (ctx, last_index),
|
||||
) as (ctx, _),
|
||||
ctx.open_stream() as stream,
|
||||
):
|
||||
|
||||
|
@ -444,7 +435,7 @@ class FspAdmin:
|
|||
|
||||
# register output data
|
||||
self._registry[
|
||||
(fqsn, ns_path)
|
||||
(fqme, ns_path)
|
||||
] = (
|
||||
stream,
|
||||
dst_fsp_flume.rt_shm,
|
||||
|
@ -484,26 +475,42 @@ class FspAdmin:
|
|||
|
||||
) -> (Flume, trio.Event):
|
||||
|
||||
fqsn = self.flume.symbol.fqsn
|
||||
src_mkt: MktPair = self.flume.mkt
|
||||
fqme: str = src_mkt.get_fqme(delim_char='')
|
||||
|
||||
# allocate an output shm array
|
||||
key, dst_shm, opened = maybe_mk_fsp_shm(
|
||||
fqsn,
|
||||
fqme,
|
||||
target=target,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
portal = self.cluster.get(worker_name) or self.rr_next_portal()
|
||||
provider_tag = portal.channel.uid
|
||||
portal: tractor.Portal = (
|
||||
self.cluster.get(worker_name)
|
||||
or self.rr_next_portal()
|
||||
)
|
||||
|
||||
symbol = Symbol(
|
||||
key=key,
|
||||
broker_info={
|
||||
provider_tag: {'asset_type': 'fsp'},
|
||||
},
|
||||
# TODO: this should probably be turned into a
|
||||
# ``Cascade`` type which describes the routing
|
||||
# of an fsp's IO in terms of sinc -> source
|
||||
# shm/IPC endpoints?
|
||||
mkt = MktPair(
|
||||
|
||||
# make this a couple addrs encapsing
|
||||
# the flume routing?
|
||||
src=src_mkt.dst,
|
||||
dst=target.name,
|
||||
|
||||
# make this a precision / rounding value?
|
||||
price_tick=src_mkt.price_tick,
|
||||
size_tick=src_mkt.size_tick,
|
||||
|
||||
bs_mktid=target.name,
|
||||
broker='piker',
|
||||
_atype='fsp',
|
||||
)
|
||||
dst_fsp_flume = Flume(
|
||||
symbol=symbol,
|
||||
mkt=mkt,
|
||||
_rt_shm_token=dst_shm.token,
|
||||
first_quote={},
|
||||
|
||||
|
@ -519,7 +526,7 @@ class FspAdmin:
|
|||
|
||||
# if not opened:
|
||||
# raise RuntimeError(
|
||||
# f'Already started FSP `{fqsn}:{func_name}`'
|
||||
# f'Already started FSP `{fqme}:{func_name}`'
|
||||
# )
|
||||
|
||||
complete = trio.Event()
|
||||
|
@ -529,7 +536,7 @@ class FspAdmin:
|
|||
portal,
|
||||
complete,
|
||||
started,
|
||||
fqsn,
|
||||
fqme,
|
||||
dst_fsp_flume,
|
||||
conf,
|
||||
target,
|
||||
|
|
|
@ -123,10 +123,10 @@ class LevelLine(pg.InfiniteLine):
|
|||
self._track_cursor: bool = False
|
||||
self.always_show_labels = always_show_labels
|
||||
|
||||
self._on_drag_start = lambda l: None
|
||||
self._on_drag_end = lambda l: None
|
||||
self._on_drag_start = lambda lvln: None
|
||||
self._on_drag_end = lambda lvln: None
|
||||
|
||||
self._y_incr_mult = 1 / chart.linked.symbol.tick_size
|
||||
self._y_incr_mult = float(1 / chart.linked.mkt.size_tick)
|
||||
self._right_end_sc: float = 0
|
||||
|
||||
# use px caching
|
||||
|
|
|
@ -93,7 +93,7 @@ async def notify_from_ems_status_msg(
|
|||
# TODO: add in standard fill/exec info that maybe we
|
||||
# pack in a broker independent way?
|
||||
f"'{msg.pformat()}'",
|
||||
],
|
||||
],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
check=False,
|
||||
|
@ -104,4 +104,6 @@ async def notify_from_ems_status_msg(
|
|||
log.runtime(result)
|
||||
|
||||
except FileNotFoundError:
|
||||
log.warn('Tried to send a notification but \'notify-send\' not present')
|
||||
log.warn(
|
||||
'Tried to send a notification but \'notify-send\' not present'
|
||||
)
|
||||
|
|
|
@ -14,10 +14,10 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
Position info and display
|
||||
'''
|
||||
Position (pos) info and display to track ur PnLz B)
|
||||
|
||||
"""
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
|
@ -45,8 +45,17 @@ from ..calc import (
|
|||
pnl,
|
||||
puterize,
|
||||
)
|
||||
from ..clearing._allocate import Allocator
|
||||
from ..pp import Position
|
||||
from ..accounting import (
|
||||
Allocator,
|
||||
MktPair,
|
||||
)
|
||||
from ..accounting import (
|
||||
Position,
|
||||
)
|
||||
from ..accounting._mktinfo import (
|
||||
_derivs,
|
||||
)
|
||||
|
||||
from ..data._normalize import iterticks
|
||||
from ..data.feed import (
|
||||
Feed,
|
||||
|
@ -85,7 +94,7 @@ async def update_pnl_from_feed(
|
|||
|
||||
pp: PositionTracker = order_mode.current_pp
|
||||
live: Position = pp.live_pp
|
||||
key: str = live.symbol.front_fqsn()
|
||||
key: str = live.mkt.fqme
|
||||
|
||||
log.info(f'Starting pnl display for {pp.alloc.account}')
|
||||
|
||||
|
@ -119,7 +128,7 @@ async def update_pnl_from_feed(
|
|||
|
||||
# watch out for wrong quote msg-data if you muck
|
||||
# with backend feed subs code..
|
||||
# assert sym == quote['fqsn']
|
||||
# assert sym == quote['fqme']
|
||||
|
||||
for tick in iterticks(quote, types):
|
||||
# print(f'{1/period} Hz')
|
||||
|
@ -238,7 +247,7 @@ class SettingsPane:
|
|||
# a ``brokerd`) then error and switch back to the last
|
||||
# selection.
|
||||
if tracker is None:
|
||||
sym = old_tracker.charts[0].linked.symbol.key
|
||||
sym: str = old_tracker.charts[0].linked.mkt.fqme
|
||||
log.error(
|
||||
f'Account `{account_name}` can not be set for {sym}'
|
||||
)
|
||||
|
@ -409,9 +418,10 @@ class SettingsPane:
|
|||
|
||||
'''
|
||||
mode = self.order_mode
|
||||
sym = mode.chart.linked.symbol
|
||||
mkt: MktPair = mode.chart.linked.mkt
|
||||
size = tracker.live_pp.size
|
||||
flume: Feed = mode.feed.flumes[sym.fqsn]
|
||||
fqme: str = mkt.fqme
|
||||
flume: Feed = mode.feed.flumes[fqme]
|
||||
pnl_value = 0
|
||||
|
||||
if size:
|
||||
|
@ -424,9 +434,8 @@ class SettingsPane:
|
|||
|
||||
# maybe start update task
|
||||
global _pnl_tasks
|
||||
fqsn = sym.front_fqsn()
|
||||
if fqsn not in _pnl_tasks:
|
||||
_pnl_tasks[fqsn] = True
|
||||
if fqme not in _pnl_tasks:
|
||||
_pnl_tasks[fqme] = True
|
||||
self.order_mode.nursery.start_soon(
|
||||
update_pnl_from_feed,
|
||||
flume,
|
||||
|
@ -495,14 +504,6 @@ def pp_line(
|
|||
return line
|
||||
|
||||
|
||||
_derivs = (
|
||||
'future',
|
||||
'continuous_future',
|
||||
'option',
|
||||
'futures_option',
|
||||
)
|
||||
|
||||
|
||||
# TODO: move into annoate module?
|
||||
def mk_level_marker(
|
||||
chart: ChartPlotWidget,
|
||||
|
@ -557,7 +558,7 @@ class Nav(Struct):
|
|||
|
||||
'''
|
||||
for key, chart in self.charts.items():
|
||||
size_digits = size_digits or chart.linked.symbol.lot_size_digits
|
||||
size_digits = size_digits or chart.linked.mkt.size_tick_digits
|
||||
line = self.lines.get(key)
|
||||
level_marker = self.level_markers[key]
|
||||
pp_label = self.pp_labels[key]
|
||||
|
@ -864,7 +865,7 @@ class PositionTracker:
|
|||
alloc = self.alloc
|
||||
|
||||
# update allocator settings
|
||||
asset_type = pp.symbol.type_key
|
||||
asset_type = pp.mkt.type_key
|
||||
|
||||
# specific configs by asset class / type
|
||||
if asset_type in _derivs:
|
||||
|
|
|
@ -639,10 +639,10 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
godw = self.godwidget
|
||||
|
||||
# first entry in the cache is the current symbol(s)
|
||||
fqsns = set()
|
||||
for multi_fqsns in list(godw._chart_cache):
|
||||
for fqsn in set(multi_fqsns):
|
||||
fqsns.add(fqsn)
|
||||
fqmes = set()
|
||||
for multi_fqmes in list(godw._chart_cache):
|
||||
for fqme in set(multi_fqmes):
|
||||
fqmes.add(fqme)
|
||||
|
||||
if keep_current_item_selected:
|
||||
sel = self.view.selectionModel()
|
||||
|
@ -650,7 +650,7 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
|
||||
self.view.set_section_entries(
|
||||
'cache',
|
||||
list(fqsns),
|
||||
list(fqmes),
|
||||
# remove all other completion results except for cache
|
||||
clear_all=only,
|
||||
reverse=True,
|
||||
|
@ -722,18 +722,18 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
cidx, provider, symbol = value
|
||||
godw = self.godwidget
|
||||
|
||||
fqsn = f'{symbol}.{provider}'
|
||||
log.info(f'Requesting symbol: {fqsn}')
|
||||
fqme = f'{symbol}.{provider}'
|
||||
log.info(f'Requesting symbol: {fqme}')
|
||||
|
||||
# assert provider in symbol
|
||||
await godw.load_symbols(
|
||||
fqsns=[fqsn],
|
||||
fqmes=[fqme],
|
||||
loglevel='info',
|
||||
)
|
||||
|
||||
# fully qualified symbol name (SNS i guess is what we're
|
||||
# making?)
|
||||
fqsn = '.'.join([symbol, provider]).lower()
|
||||
fqme = '.'.join([symbol, provider]).lower()
|
||||
|
||||
if clear_to_cache:
|
||||
|
||||
|
@ -743,7 +743,7 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
# LIFO order. this is normally only done internally by
|
||||
# the chart on new symbols being loaded into memory
|
||||
godw.set_chart_symbols(
|
||||
(fqsn,), (
|
||||
(fqme,), (
|
||||
godw.hist_linked,
|
||||
godw.rt_linked,
|
||||
)
|
||||
|
@ -753,7 +753,7 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
)
|
||||
|
||||
self.bar.focus()
|
||||
return fqsn
|
||||
return fqme
|
||||
|
||||
def space_dims(self) -> tuple[float, float]:
|
||||
'''
|
||||
|
|
|
@ -23,7 +23,10 @@ WARNING: this code likely doesn't work at all (yet)
|
|||
"""
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5 import (
|
||||
QtCore,
|
||||
QtWidgets,
|
||||
)
|
||||
|
||||
from .quantdom.charts import CenteredTextItem
|
||||
from .quantdom.base import Quotes
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
# 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
|
||||
|
@ -36,13 +36,18 @@ import trio
|
|||
from PyQt5.QtCore import Qt
|
||||
|
||||
from .. import config
|
||||
from ..pp import Position
|
||||
from ..clearing._client import open_ems, OrderBook
|
||||
from ..clearing._allocate import (
|
||||
from ..accounting import (
|
||||
Allocator,
|
||||
Position,
|
||||
mk_allocator,
|
||||
MktPair,
|
||||
Symbol,
|
||||
)
|
||||
from ..clearing._client import (
|
||||
open_ems,
|
||||
OrderClient,
|
||||
)
|
||||
from ._style import _font
|
||||
from ..data._source import Symbol
|
||||
from ..data.feed import (
|
||||
Feed,
|
||||
Flume,
|
||||
|
@ -89,7 +94,7 @@ class Dialog(Struct):
|
|||
order: Order
|
||||
symbol: str
|
||||
lines: list[LevelLine]
|
||||
last_status_close: Callable = lambda: None
|
||||
last_status_close: Callable | None = None
|
||||
msgs: dict[str, dict] = {}
|
||||
fills: dict[str, Any] = {}
|
||||
|
||||
|
@ -120,7 +125,7 @@ class OrderMode:
|
|||
chart: ChartPlotWidget # type: ignore # noqa
|
||||
hist_chart: ChartPlotWidget # type: ignore # noqa
|
||||
nursery: trio.Nursery # used by ``ui._position`` code?
|
||||
book: OrderBook
|
||||
client: OrderClient
|
||||
lines: LineEditor
|
||||
arrows: ArrowEditor
|
||||
multistatus: MultiStatus
|
||||
|
@ -284,15 +289,29 @@ class OrderMode:
|
|||
# since that's illogical / a no-op.
|
||||
return
|
||||
|
||||
symbol = self.chart.linked.symbol
|
||||
mkt: MktPair = self.chart.linked.mkt
|
||||
|
||||
# NOTE : we could also use instead,
|
||||
# mkt.quantize(price, quantity_type='price')
|
||||
# but it returns a Decimal and it's probably gonna
|
||||
# be slower?
|
||||
# TODO: should we be enforcing this precision
|
||||
# at a different layer in the stack? right now
|
||||
# any precision error will literally be relayed
|
||||
# all the way back from the backend.
|
||||
|
||||
price = round(
|
||||
price,
|
||||
ndigits=mkt.price_tick_digits,
|
||||
)
|
||||
|
||||
order = self._staged_order = Order(
|
||||
action=action,
|
||||
price=price,
|
||||
account=self.current_pp.alloc.account,
|
||||
size=0,
|
||||
symbol=symbol,
|
||||
brokers=symbol.brokers,
|
||||
symbol=mkt.fqme,
|
||||
brokers=[mkt.broker],
|
||||
oid='', # filled in on submit
|
||||
exec_mode=trigger_type, # dark or live
|
||||
)
|
||||
|
@ -349,12 +368,17 @@ class OrderMode:
|
|||
'''
|
||||
if not order:
|
||||
staged: Order = self._staged_order
|
||||
|
||||
# apply order fields for ems
|
||||
oid = str(uuid.uuid4())
|
||||
order = staged.copy()
|
||||
order.oid = oid
|
||||
|
||||
order.symbol = order.symbol.front_fqsn()
|
||||
# NOTE: we have to str-ify `MktPair` first since we can't
|
||||
# cast to it without being mega explicit with
|
||||
# `msgspec.Struct`, which we're not yet..
|
||||
order: Order = staged.copy({
|
||||
'symbol': str(staged.symbol),
|
||||
'oid': oid,
|
||||
})
|
||||
|
||||
lines = self.lines_from_order(
|
||||
order,
|
||||
|
@ -401,13 +425,13 @@ class OrderMode:
|
|||
|
||||
# send order cmd to ems
|
||||
if send_msg:
|
||||
self.book.send(order)
|
||||
self.client.send_nowait(order)
|
||||
else:
|
||||
# just register for control over this order
|
||||
# TODO: some kind of mini-perms system here based on
|
||||
# an out-of-band tagging/auth sub-sys for multiplayer
|
||||
# order control?
|
||||
self.book._sent_orders[order.oid] = order
|
||||
self.client._sent_orders[order.oid] = order
|
||||
|
||||
return dialog
|
||||
|
||||
|
@ -428,14 +452,23 @@ class OrderMode:
|
|||
line: LevelLine,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Retreive the level line's end state, compute the size
|
||||
and price for the new price-level, send an update msg to
|
||||
the EMS, adjust mirrored level line on secondary chart.
|
||||
|
||||
level = line.value()
|
||||
'''
|
||||
mktinfo: MktPair = self.chart.linked.mkt
|
||||
level = round(
|
||||
line.value(),
|
||||
ndigits=mktinfo.price_tick_digits,
|
||||
)
|
||||
# updated by level change callback set in ``.new_line_from_order()``
|
||||
dialog = line.dialog
|
||||
size = dialog.order.size
|
||||
|
||||
# NOTE: sends modified order msg to EMS
|
||||
self.book.send_update(
|
||||
self.client.update_nowait(
|
||||
uuid=line.dialog.uuid,
|
||||
price=level,
|
||||
size=size,
|
||||
|
@ -465,7 +498,9 @@ class OrderMode:
|
|||
# a submission is the start of a new order dialog
|
||||
dialog = self.dialogs[uuid]
|
||||
dialog.lines = lines
|
||||
dialog.last_status_close()
|
||||
cls: Callable | None = dialog.last_status_close
|
||||
if cls:
|
||||
cls()
|
||||
|
||||
for line in lines:
|
||||
|
||||
|
@ -517,7 +552,7 @@ class OrderMode:
|
|||
# XXX: seems to fail on certain types of races?
|
||||
# assert len(lines) == 2
|
||||
if lines:
|
||||
flume: Flume = self.feed.flumes[chart.linked.symbol.fqsn]
|
||||
flume: Flume = self.feed.flumes[chart.linked.mkt.fqme]
|
||||
_, _, ratio = flume.get_ds_info()
|
||||
|
||||
for chart, shm in [
|
||||
|
@ -551,7 +586,7 @@ class OrderMode:
|
|||
|
||||
) -> None:
|
||||
|
||||
msg = self.book._sent_orders.pop(uuid, None)
|
||||
msg = self.client._sent_orders.pop(uuid, None)
|
||||
|
||||
if msg is not None:
|
||||
self.lines.remove_line(uuid=uuid)
|
||||
|
@ -607,7 +642,7 @@ class OrderMode:
|
|||
dialog.last_status_close = cancel_status_close
|
||||
|
||||
ids.append(oid)
|
||||
self.book.cancel(uuid=oid)
|
||||
self.client.cancel_nowait(uuid=oid)
|
||||
|
||||
return ids
|
||||
|
||||
|
@ -629,17 +664,21 @@ class OrderMode:
|
|||
and src not in ('dark', 'paperboi')
|
||||
and src not in symbol
|
||||
):
|
||||
fqsn = symbol + '.' + src
|
||||
fqme = symbol + '.' + src
|
||||
brokername = src
|
||||
else:
|
||||
fqsn = symbol
|
||||
*head, brokername = fqsn.rsplit('.')
|
||||
fqme = symbol
|
||||
*head, brokername = fqme.rsplit('.')
|
||||
|
||||
# fill out complex fields
|
||||
order.oid = str(order.oid)
|
||||
order.brokers = [brokername]
|
||||
order.symbol = Symbol.from_fqsn(
|
||||
fqsn=fqsn,
|
||||
|
||||
# TODO: change this over to `MktPair`, but it's
|
||||
# gonna be tough since we don't have any such data
|
||||
# really in our clearing msg schema..
|
||||
order.symbol = Symbol.from_fqme(
|
||||
fqsn=fqme,
|
||||
info={},
|
||||
)
|
||||
dialog = self.submit_order(
|
||||
|
@ -655,7 +694,7 @@ async def open_order_mode(
|
|||
|
||||
feed: Feed,
|
||||
godw: GodWidget,
|
||||
fqsn: str,
|
||||
fqme: str,
|
||||
started: trio.Event,
|
||||
loglevel: str = 'info'
|
||||
|
||||
|
@ -674,19 +713,22 @@ async def open_order_mode(
|
|||
multistatus = chart.window().status_bar
|
||||
done = multistatus.open_status('starting order mode..')
|
||||
|
||||
book: OrderBook
|
||||
client: OrderClient
|
||||
trades_stream: tractor.MsgStream
|
||||
|
||||
# The keys in this dict **must** be in set our set of "normalized"
|
||||
# symbol names (i.e. the same names you'd get back in search
|
||||
# results) in order for position msgs to correctly trigger the
|
||||
# display of a position indicator on screen.
|
||||
position_msgs: dict[str, list[BrokerdPosition]]
|
||||
position_msgs: dict[str, dict[str, BrokerdPosition]]
|
||||
|
||||
# spawn EMS actor-service
|
||||
async with (
|
||||
open_ems(fqsn, loglevel=loglevel) as (
|
||||
book,
|
||||
open_ems(
|
||||
fqme,
|
||||
loglevel=loglevel,
|
||||
) as (
|
||||
client,
|
||||
trades_stream,
|
||||
position_msgs,
|
||||
brokerd_accounts,
|
||||
|
@ -695,21 +737,21 @@ async def open_order_mode(
|
|||
trio.open_nursery() as tn,
|
||||
|
||||
):
|
||||
log.info(f'Opening order mode for {fqsn}')
|
||||
log.info(f'Opening order mode for {fqme}')
|
||||
|
||||
# annotations editors
|
||||
lines = LineEditor(godw=godw)
|
||||
arrows = ArrowEditor(godw=godw)
|
||||
|
||||
# symbol id
|
||||
symbol = chart.linked.symbol
|
||||
# market endpoint info
|
||||
mkt: MktPair = chart.linked.mkt
|
||||
|
||||
# map of per-provider account keys to position tracker instances
|
||||
trackers: dict[str, PositionTracker] = {}
|
||||
|
||||
# load account names from ``brokers.toml``
|
||||
accounts_def = config.load_accounts(
|
||||
providers=symbol.brokers
|
||||
providers=[mkt.broker],
|
||||
)
|
||||
|
||||
# XXX: ``brokerd`` delivers a set of account names that it
|
||||
|
@ -732,17 +774,17 @@ async def open_order_mode(
|
|||
|
||||
# net-zero pp
|
||||
startup_pp = Position(
|
||||
symbol=symbol,
|
||||
mkt=mkt,
|
||||
size=0,
|
||||
ppu=0,
|
||||
|
||||
# XXX: BLEH, do we care about this on the client side?
|
||||
bsuid=symbol,
|
||||
bs_mktid=mkt.key,
|
||||
)
|
||||
|
||||
# allocator config
|
||||
alloc = mk_allocator(
|
||||
symbol=symbol,
|
||||
alloc: Allocator = mk_allocator(
|
||||
mkt=mkt,
|
||||
account=account_name,
|
||||
|
||||
# if this startup size is greater the allocator limit,
|
||||
|
@ -813,7 +855,7 @@ async def open_order_mode(
|
|||
chart,
|
||||
hist_chart,
|
||||
tn,
|
||||
book,
|
||||
client,
|
||||
lines,
|
||||
arrows,
|
||||
multistatus,
|
||||
|
@ -861,12 +903,14 @@ async def open_order_mode(
|
|||
# Pack position messages by account, should only be one-to-one.
|
||||
# NOTE: requires the backend exactly specifies
|
||||
# the expected symbol key in its positions msg.
|
||||
for (broker, acctid), msgs in position_msgs.items():
|
||||
for msg in msgs:
|
||||
log.info(f'Loading pp for {acctid}@{broker}:\n{pformat(msg)}')
|
||||
for (
|
||||
(broker, acctid),
|
||||
pps_by_fqme
|
||||
) in position_msgs.items():
|
||||
for msg in pps_by_fqme.values():
|
||||
await process_trade_msg(
|
||||
mode,
|
||||
book,
|
||||
client,
|
||||
msg,
|
||||
)
|
||||
|
||||
|
@ -900,7 +944,7 @@ async def open_order_mode(
|
|||
|
||||
await process_trade_msg(
|
||||
mode,
|
||||
book,
|
||||
client,
|
||||
msg,
|
||||
)
|
||||
|
||||
|
@ -908,7 +952,7 @@ async def open_order_mode(
|
|||
process_trades_and_update_ui,
|
||||
trades_stream,
|
||||
mode,
|
||||
book,
|
||||
client,
|
||||
)
|
||||
|
||||
yield mode
|
||||
|
@ -918,7 +962,7 @@ async def process_trades_and_update_ui(
|
|||
|
||||
trades_stream: tractor.MsgStream,
|
||||
mode: OrderMode,
|
||||
book: OrderBook,
|
||||
client: OrderClient,
|
||||
|
||||
) -> None:
|
||||
|
||||
|
@ -927,16 +971,22 @@ async def process_trades_and_update_ui(
|
|||
async for msg in trades_stream:
|
||||
await process_trade_msg(
|
||||
mode,
|
||||
book,
|
||||
client,
|
||||
msg,
|
||||
)
|
||||
|
||||
|
||||
async def process_trade_msg(
|
||||
mode: OrderMode,
|
||||
book: OrderBook,
|
||||
client: OrderClient,
|
||||
msg: dict,
|
||||
|
||||
# emit linux DE notification?
|
||||
# XXX: currently my experience with `dunst` is that this
|
||||
# is horrible slow and clunky and invasive and noisy so i'm
|
||||
# disabling it for now until we find a better UX solution..
|
||||
do_notify: bool = False,
|
||||
|
||||
) -> tuple[Dialog, Status]:
|
||||
|
||||
fmsg = pformat(msg)
|
||||
|
@ -946,18 +996,24 @@ async def process_trade_msg(
|
|||
if name in (
|
||||
'position',
|
||||
):
|
||||
sym = mode.chart.linked.symbol
|
||||
sym: MktPair = mode.chart.linked.mkt
|
||||
pp_msg_symbol = msg['symbol'].lower()
|
||||
fqsn = sym.front_fqsn()
|
||||
broker, key = sym.front_feed()
|
||||
fqme = sym.fqme
|
||||
broker = sym.broker
|
||||
if (
|
||||
pp_msg_symbol == fqsn
|
||||
or pp_msg_symbol == fqsn.removesuffix(f'.{broker}')
|
||||
pp_msg_symbol == fqme
|
||||
or pp_msg_symbol == fqme.removesuffix(f'.{broker}')
|
||||
):
|
||||
log.info(f'{fqsn} matched pp msg: {fmsg}')
|
||||
log.info(
|
||||
f'Loading position for `{fqme}`:\n'
|
||||
f'{fmsg}'
|
||||
)
|
||||
tracker = mode.trackers[msg['account']]
|
||||
tracker.live_pp.update_from_msg(msg)
|
||||
tracker.update_from_pp(set_as_startup=True) # status/pane UI
|
||||
tracker.update_from_pp(
|
||||
set_as_startup=True,
|
||||
)
|
||||
# status/pane UI
|
||||
mode.pane.update_status_ui(tracker)
|
||||
|
||||
if tracker.live_pp.size:
|
||||
|
@ -974,7 +1030,7 @@ async def process_trade_msg(
|
|||
dialog: Dialog = mode.dialogs.get(oid)
|
||||
|
||||
if dialog:
|
||||
fqsn = dialog.symbol
|
||||
fqme = dialog.symbol
|
||||
|
||||
match msg:
|
||||
case Status(
|
||||
|
@ -996,17 +1052,17 @@ async def process_trade_msg(
|
|||
)
|
||||
assert msg.resp in ('open', 'dark_open'), f'Unknown msg: {msg}'
|
||||
|
||||
sym = mode.chart.linked.symbol
|
||||
fqsn = sym.front_fqsn()
|
||||
sym: MktPair = mode.chart.linked.mkt
|
||||
fqme = sym.fqme
|
||||
if (
|
||||
((order.symbol + f'.{msg.src}') == fqsn)
|
||||
((order.symbol + f'.{msg.src}') == fqme)
|
||||
|
||||
# a existing dark order for the same symbol
|
||||
or (
|
||||
order.symbol == fqsn
|
||||
order.symbol == fqme
|
||||
and (
|
||||
msg.src in ('dark', 'paperboi')
|
||||
or (msg.src in fqsn)
|
||||
or (msg.src in fqme)
|
||||
|
||||
)
|
||||
)
|
||||
|
@ -1053,7 +1109,8 @@ async def process_trade_msg(
|
|||
)
|
||||
mode.lines.remove_line(uuid=oid)
|
||||
msg.req = req
|
||||
await notify_from_ems_status_msg(msg)
|
||||
if do_notify:
|
||||
await notify_from_ems_status_msg(msg)
|
||||
|
||||
# response to completed 'dialog' for order request
|
||||
case Status(
|
||||
|
@ -1062,14 +1119,15 @@ async def process_trade_msg(
|
|||
req=req,
|
||||
):
|
||||
msg.req = Order(**req)
|
||||
await notify_from_ems_status_msg(msg)
|
||||
if do_notify:
|
||||
await notify_from_ems_status_msg(msg)
|
||||
mode.lines.remove_line(uuid=oid)
|
||||
|
||||
# each clearing tick is responded individually
|
||||
case Status(resp='fill'):
|
||||
|
||||
# handle out-of-piker fills reporting?
|
||||
order: Order = book._sent_orders.get(oid)
|
||||
order: Order = client._sent_orders.get(oid)
|
||||
if not order:
|
||||
log.warning(f'order {oid} is unknown')
|
||||
order = msg.req
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
pytest
|
||||
docker
|
||||
elasticsearch
|
||||
|
|
|
@ -14,5 +14,7 @@
|
|||
# ``asyncvnc`` for sending interactions to ib-gw inside docker
|
||||
-e git+https://github.com/pikers/asyncvnc.git@main#egg=asyncvnc
|
||||
|
||||
# ``cryptofeed`` for connecting to various crypto exchanges + custom fixes
|
||||
-e git+https://github.com/pikers/cryptofeed.git@date_parsing#egg=cryptofeed
|
||||
|
||||
# ``tomlkit`` for account files and configs; we've
|
||||
# added some new features that need to get upstreamed:
|
||||
-e git+https://github.com/pikers/tomlkit.git@piker_pin#egg=tomlkit
|
||||
|
|
12
setup.py
12
setup.py
|
@ -40,18 +40,21 @@ setup(
|
|||
'console_scripts': [
|
||||
'piker = piker.cli:cli',
|
||||
'pikerd = piker.cli:pikerd',
|
||||
'ledger = piker.accounting.cli:ledger',
|
||||
]
|
||||
},
|
||||
install_requires=[
|
||||
'toml',
|
||||
'tomli', # fastest pure py reader
|
||||
'click',
|
||||
# 'tomlkit', # fork & fix for now..
|
||||
'tomli', # for pre-3.11
|
||||
'tomli-w', # for fast ledger writing
|
||||
'colorlog',
|
||||
'attrs',
|
||||
'pygments',
|
||||
'colorama', # numba traceback coloring
|
||||
'msgspec', # performant IPC messaging and structs
|
||||
'protobuf',
|
||||
'typer',
|
||||
'rich',
|
||||
|
||||
# async
|
||||
'trio',
|
||||
|
@ -63,8 +66,7 @@ setup(
|
|||
# normally pinned to particular git hashes..
|
||||
# 'tractor',
|
||||
# 'asyncvnc',
|
||||
# 'pyqtgraph',
|
||||
# anyio-marketstore # mkts tsdb client
|
||||
# 'anyio-marketstore', # mkts tsdb client
|
||||
|
||||
# brokers
|
||||
'asks', # for non-ws rest apis
|
||||
|
|
|
@ -13,7 +13,6 @@ from piker.service import (
|
|||
Services,
|
||||
)
|
||||
from piker.log import get_console_log
|
||||
from piker.clearing._client import open_ems
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
|
@ -87,8 +86,11 @@ def log(
|
|||
@acm
|
||||
async def _open_test_pikerd(
|
||||
tmpconfdir: str,
|
||||
|
||||
reg_addr: tuple[str, int] | None = None,
|
||||
loglevel: str = 'warning',
|
||||
debug_mode: bool = False,
|
||||
|
||||
**kwargs,
|
||||
|
||||
) -> tuple[
|
||||
|
@ -101,6 +103,9 @@ async def _open_test_pikerd(
|
|||
a different port then the default to allow testing alongside
|
||||
a running stack.
|
||||
|
||||
Calls `.service._actor_runtime.maybe_open_pikerd()``
|
||||
to boot the root actor / tractor runtime.
|
||||
|
||||
'''
|
||||
import random
|
||||
from piker.service import maybe_open_pikerd
|
||||
|
@ -118,10 +123,7 @@ async def _open_test_pikerd(
|
|||
'piker_test_dir': tmpconfdir,
|
||||
},
|
||||
|
||||
# tests may need to spawn containers dynamically
|
||||
# or just in sequence per test, so we keep root.
|
||||
drop_root_perms_for_ahab=False,
|
||||
|
||||
debug_mode=debug_mode,
|
||||
**kwargs,
|
||||
|
||||
) as service_manager,
|
||||
|
@ -143,14 +145,60 @@ async def _open_test_pikerd(
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmpconfdir(
|
||||
tmp_path: Path,
|
||||
) -> Path:
|
||||
'''
|
||||
Ensure the `brokers.toml` file for the test run exists
|
||||
since we changed it to not touch files by default.
|
||||
|
||||
Here we override the default (in the user dir) and
|
||||
set the global module var the same as we do inside
|
||||
the `tmpconfdir` fixture.
|
||||
|
||||
'''
|
||||
tmpconfdir: Path = tmp_path / '_testing'
|
||||
tmpconfdir.mkdir()
|
||||
|
||||
# touch the `brokers.toml` file since it won't
|
||||
# exist in the tmp test dir by default!
|
||||
# override config dir in the root actor (aka
|
||||
# this top level testing process).
|
||||
from piker import config
|
||||
config._config_dir: Path = tmpconfdir
|
||||
|
||||
conf, path = config.load(
|
||||
conf_name='brokers',
|
||||
touch_if_dne=True,
|
||||
)
|
||||
assert path.is_file(), 'WTH.. `brokers.toml` not created!?'
|
||||
|
||||
return tmpconfdir
|
||||
|
||||
# NOTE: the `tmp_dir` fixture will wipe any files older then 3 test
|
||||
# sessions by default:
|
||||
# https://docs.pytest.org/en/6.2.x/tmpdir.html#the-default-base-temporary-directory
|
||||
# BUT, if we wanted to always wipe conf dir and all contained files,
|
||||
# rmtree(str(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root_conf(tmpconfdir) -> dict:
|
||||
return config.load(
|
||||
'conf',
|
||||
touch_if_dne=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_test_pikerd(
|
||||
request: pytest.FixtureRequest,
|
||||
tmp_path: Path,
|
||||
tmpconfdir: Path,
|
||||
loglevel: str,
|
||||
):
|
||||
tmpconfdir: Path = tmp_path / '_testing'
|
||||
tmpconfdir.mkdir()
|
||||
|
||||
tmpconfdir_str: str = str(tmpconfdir)
|
||||
|
||||
# NOTE: on linux the tmp config dir is generally located at:
|
||||
|
@ -160,6 +208,20 @@ def open_test_pikerd(
|
|||
# https://docs.pytest.org/en/6.2.x/tmpdir.html#the-default-base-temporary-directory
|
||||
print(f'CURRENT TEST CONF DIR: {tmpconfdir}')
|
||||
|
||||
conf = request.config
|
||||
debug_mode: bool = conf.option.usepdb
|
||||
if (
|
||||
debug_mode
|
||||
and conf.option.capture != 'no'
|
||||
):
|
||||
# TODO: how to disable capture dynamically?
|
||||
# conf._configured = False
|
||||
# conf._do_configure()
|
||||
pytest.fail(
|
||||
'To use `--pdb` (with `tractor` subactors) you also must also '
|
||||
'pass `-s`!'
|
||||
)
|
||||
|
||||
yield partial(
|
||||
_open_test_pikerd,
|
||||
|
||||
|
@ -171,49 +233,11 @@ def open_test_pikerd(
|
|||
# bind in level from fixture, which is itself set by
|
||||
# `--ll <value>` cli flag.
|
||||
loglevel=loglevel,
|
||||
)
|
||||
|
||||
# NOTE: the `tmp_dir` fixture will wipe any files older then 3 test
|
||||
# sessions by default:
|
||||
# https://docs.pytest.org/en/6.2.x/tmpdir.html#the-default-base-temporary-directory
|
||||
# BUT, if we wanted to always wipe conf dir and all contained files,
|
||||
# rmtree(str(tmp_path))
|
||||
debug_mode=debug_mode,
|
||||
)
|
||||
|
||||
# TODO: teardown checks such as,
|
||||
# - no leaked subprocs or shm buffers
|
||||
# - all requested container service are torn down
|
||||
# - certain ``tractor`` runtime state?
|
||||
|
||||
|
||||
@acm
|
||||
async def _open_test_pikerd_and_ems(
|
||||
fqsn,
|
||||
mode,
|
||||
loglevel,
|
||||
open_test_pikerd
|
||||
):
|
||||
async with (
|
||||
open_test_pikerd() as (_, _, _, services),
|
||||
open_ems(
|
||||
fqsn,
|
||||
mode=mode,
|
||||
loglevel=loglevel,
|
||||
) as ems_services,
|
||||
):
|
||||
yield (services, ems_services)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_test_pikerd_and_ems(
|
||||
open_test_pikerd,
|
||||
fqsn: str = 'xbtusdt.kraken',
|
||||
mode: str = 'paper',
|
||||
loglevel: str = 'info',
|
||||
):
|
||||
yield partial(
|
||||
_open_test_pikerd_and_ems,
|
||||
fqsn,
|
||||
mode,
|
||||
loglevel,
|
||||
open_test_pikerd
|
||||
)
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
'''
|
||||
`piker.accounting` mgmt calculations for
|
||||
- positioning
|
||||
- ledger updates
|
||||
- config file IO
|
||||
|
||||
'''
|
||||
from pathlib import Path
|
||||
|
||||
from piker import config
|
||||
|
||||
|
||||
def test_root_conf_networking_section(
|
||||
root_conf: dict,
|
||||
):
|
||||
conf, path = config.load(
|
||||
'conf',
|
||||
touch_if_dne=True,
|
||||
)
|
||||
assert conf['network']['tsdb']
|
||||
|
||||
|
||||
def test_account_file_default_empty(
|
||||
tmpconfdir: Path,
|
||||
):
|
||||
conf, path = config.load_account(
|
||||
'kraken',
|
||||
'paper',
|
||||
)
|
||||
|
||||
# ensure the account file empty but created
|
||||
# and in the correct place in the filesystem!
|
||||
assert not conf
|
||||
assert path.parent.is_dir()
|
||||
assert path.parent.name == 'accounting'
|
|
@ -1,11 +1,12 @@
|
|||
from typing import AsyncContextManager
|
||||
import logging
|
||||
|
||||
import trio
|
||||
import pytest
|
||||
from elasticsearch import (
|
||||
Elasticsearch,
|
||||
ConnectionError,
|
||||
)
|
||||
import trio
|
||||
|
||||
from piker.service import marketstore
|
||||
from piker.service import elastic
|
||||
|
@ -14,6 +15,7 @@ from piker.service import elastic
|
|||
def test_marketstore_startup_and_version(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
loglevel: str,
|
||||
root_conf: dict,
|
||||
):
|
||||
'''
|
||||
Verify marketstore tsdb starts up and we can
|
||||
|
@ -21,18 +23,39 @@ def test_marketstore_startup_and_version(
|
|||
|
||||
'''
|
||||
async def main():
|
||||
user_conf: dict = {
|
||||
'grpc_listen_port': 5995 + 6,
|
||||
'ws_listen_port': 5993 + 6,
|
||||
}
|
||||
|
||||
dname: str # service name
|
||||
config: dict # service name
|
||||
|
||||
async with (
|
||||
open_test_pikerd(
|
||||
loglevel=loglevel,
|
||||
tsdb=True
|
||||
# tsdb=True
|
||||
) as (
|
||||
_, # host
|
||||
_, # port
|
||||
pikerd_portal,
|
||||
services,
|
||||
),
|
||||
|
||||
marketstore.start_ahab_daemon(
|
||||
services,
|
||||
user_conf,
|
||||
loglevel=loglevel,
|
||||
|
||||
) as (dname, config)
|
||||
):
|
||||
# ensure user config was applied
|
||||
for k, v in user_conf.items():
|
||||
assert config[k] == v
|
||||
|
||||
# netconf: dict = root_conf['network']
|
||||
# tsdbconf = netconf['tsdb']
|
||||
|
||||
# TODO: we should probably make this connection poll
|
||||
# loop part of the `get_client()` implementation no?
|
||||
|
||||
|
@ -45,7 +68,12 @@ def test_marketstore_startup_and_version(
|
|||
for _ in range(3):
|
||||
|
||||
# NOTE: default sockaddr is embedded within
|
||||
async with marketstore.get_client() as client:
|
||||
async with marketstore.get_client(
|
||||
host='localhost',
|
||||
port=user_conf['grpc_listen_port'],
|
||||
|
||||
) as client:
|
||||
print(f'Client is up @ {user_conf}!')
|
||||
|
||||
with trio.move_on_after(1) as cs:
|
||||
syms = await client.list_symbols()
|
||||
|
@ -64,11 +92,18 @@ def test_marketstore_startup_and_version(
|
|||
)
|
||||
print('VERSION CHECKED')
|
||||
|
||||
|
||||
break # get out of retry-connect loop
|
||||
else:
|
||||
raise RuntimeError('Failed to connect to {conf}!')
|
||||
|
||||
# gracefully teardown docker-daemon-service
|
||||
print(f'Cancelling docker service {dname}')
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
@pytest.mark.skip
|
||||
def test_elasticsearch_startup_and_version(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
loglevel: str,
|
||||
|
@ -80,18 +115,29 @@ def test_elasticsearch_startup_and_version(
|
|||
|
||||
'''
|
||||
async def main():
|
||||
port = 19200
|
||||
port: int = 19200
|
||||
user_conf: dict = {
|
||||
'port': port,
|
||||
}
|
||||
|
||||
dname: str # service name
|
||||
config: dict # service name
|
||||
|
||||
async with (
|
||||
open_test_pikerd(
|
||||
loglevel=loglevel,
|
||||
es=True
|
||||
) as (
|
||||
_, # host
|
||||
_, # port
|
||||
pikerd_portal,
|
||||
services,
|
||||
),
|
||||
elastic.start_ahab_daemon(
|
||||
services,
|
||||
user_conf,
|
||||
loglevel=loglevel,
|
||||
|
||||
) as (dname, config)
|
||||
):
|
||||
# TODO: much like the above connect loop for mkts, we should
|
||||
# probably make this sync start part of the
|
|
@ -0,0 +1,406 @@
|
|||
'''
|
||||
Execution mgmt system (EMS) e2e testing.
|
||||
|
||||
Most tests leverage our paper clearing engine found (currently) in
|
||||
``piker.clearing._paper_engine`.
|
||||
|
||||
Ideally in the longer run we are able to support forms of (non-clearing)
|
||||
live order tests against certain backends that make it possible to do
|
||||
so..
|
||||
|
||||
'''
|
||||
from contextlib import (
|
||||
contextmanager as cm,
|
||||
)
|
||||
from typing import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
AsyncContextManager,
|
||||
Literal,
|
||||
)
|
||||
|
||||
import trio
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
|
||||
import pytest
|
||||
import tractor
|
||||
from uuid import uuid4
|
||||
|
||||
from piker.service import Services
|
||||
from piker.log import get_logger
|
||||
from piker.clearing._messages import (
|
||||
Order,
|
||||
Status,
|
||||
# Cancel,
|
||||
BrokerdPosition,
|
||||
)
|
||||
from piker.clearing import (
|
||||
open_ems,
|
||||
OrderClient,
|
||||
)
|
||||
from piker.accounting import (
|
||||
unpack_fqme,
|
||||
)
|
||||
from piker.accounting import (
|
||||
open_pps,
|
||||
Position,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
async def order_and_and_wait_for_ppmsg(
|
||||
client: OrderClient,
|
||||
trades_stream: tractor.MsgStream,
|
||||
fqme: str,
|
||||
|
||||
action: Literal['buy', 'sell'],
|
||||
price: float = 100e3, # just a super high price.
|
||||
size: float = 0.01,
|
||||
|
||||
exec_mode: str = 'live',
|
||||
account: str = 'paper',
|
||||
|
||||
) -> list[Status | BrokerdPosition]:
|
||||
'''
|
||||
Start piker, place a trade and assert data in
|
||||
pps stream, ledger and position table.
|
||||
|
||||
'''
|
||||
sent: list[Order] = []
|
||||
broker, mktep, venue, suffix = unpack_fqme(fqme)
|
||||
|
||||
order = Order(
|
||||
exec_mode=exec_mode,
|
||||
action=action, # TODO: remove this from our schema?
|
||||
oid=str(uuid4()),
|
||||
account=account,
|
||||
size=size,
|
||||
symbol=fqme,
|
||||
price=price,
|
||||
brokers=[broker],
|
||||
)
|
||||
sent.append(order)
|
||||
await client.send(order)
|
||||
|
||||
# TODO: i guess we should still test the old sync-API?
|
||||
# client.send_nowait(order)
|
||||
|
||||
# Wait for position message before moving on to verify flow(s)
|
||||
# for the multi-order position entry/exit.
|
||||
msgs: list[Status | BrokerdPosition] = []
|
||||
async for msg in trades_stream:
|
||||
match msg:
|
||||
case {'name': 'position'}:
|
||||
ppmsg = BrokerdPosition(**msg)
|
||||
msgs.append(ppmsg)
|
||||
break
|
||||
|
||||
case {'name': 'status'}:
|
||||
msgs.append(Status(**msg))
|
||||
|
||||
return sent, msgs
|
||||
|
||||
|
||||
def run_and_tollerate_cancels(
|
||||
fn: Callable[..., Awaitable],
|
||||
|
||||
expect_errs: tuple[Exception] | None = None,
|
||||
tollerate_errs: tuple[Exception] = (tractor.ContextCancelled,),
|
||||
|
||||
):
|
||||
'''
|
||||
Run ``trio``-``piker`` runtime with potential tolerance for
|
||||
inter-actor cancellation during teardown (normally just
|
||||
`tractor.ContextCancelled`s).
|
||||
|
||||
'''
|
||||
if expect_errs:
|
||||
with pytest.raises(BaseExceptionGroup) as exc_info:
|
||||
trio.run(fn)
|
||||
|
||||
for err in exc_info.value.exceptions:
|
||||
assert type(err) in expect_errs
|
||||
else:
|
||||
try:
|
||||
trio.run(fn)
|
||||
except tollerate_errs:
|
||||
pass
|
||||
|
||||
|
||||
@cm
|
||||
def load_and_check_pos(
|
||||
order: Order,
|
||||
ppmsg: BrokerdPosition,
|
||||
|
||||
) -> None:
|
||||
|
||||
with open_pps(ppmsg.broker, ppmsg.account) as table:
|
||||
|
||||
if ppmsg.size == 0:
|
||||
assert ppmsg.symbol not in table.pps
|
||||
yield None
|
||||
return
|
||||
|
||||
else:
|
||||
# NOTE: a special case is here since the `PpTable.pps` are
|
||||
# normally indexed by the particular broker's
|
||||
# `Position.bs_mktid: str` (a unique market / symbol id provided
|
||||
# by their systems/design) but for the paper engine case, this
|
||||
# is the same the fqme.
|
||||
pp: Position = table.pps[ppmsg.symbol]
|
||||
|
||||
assert ppmsg.size == pp.size
|
||||
assert ppmsg.avg_price == pp.ppu
|
||||
|
||||
yield pp
|
||||
|
||||
|
||||
def test_ems_err_on_bad_broker(
|
||||
open_test_pikerd: Services,
|
||||
loglevel: str,
|
||||
):
|
||||
async def load_bad_fqme():
|
||||
try:
|
||||
async with (
|
||||
open_test_pikerd() as (_, _, _, _),
|
||||
|
||||
open_ems(
|
||||
'doggycoin.doggy',
|
||||
mode='paper',
|
||||
loglevel=loglevel,
|
||||
) as _
|
||||
):
|
||||
pytest.fail('EMS is working on non-broker!?')
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
run_and_tollerate_cancels(load_bad_fqme)
|
||||
|
||||
|
||||
async def match_ppmsgs_on_ems_boot(
|
||||
ppmsgs: list[BrokerdPosition],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Given a list of input position msgs, verify they match
|
||||
what is loaded from the EMS on connect.
|
||||
|
||||
'''
|
||||
by_acct: dict[tuple, list[BrokerdPosition]] = {}
|
||||
for msg in ppmsgs:
|
||||
by_acct.setdefault(
|
||||
(msg.broker, msg.account),
|
||||
[],
|
||||
).append(msg)
|
||||
|
||||
# TODO: actually support multi-mkts to `open_ems()`
|
||||
# but for now just pass the first fqme.
|
||||
fqme = msg.symbol
|
||||
|
||||
# disconnect from EMS, reconnect and ensure we get our same
|
||||
# position relayed to us again in the startup msg.
|
||||
async with (
|
||||
open_ems(
|
||||
fqme,
|
||||
mode='paper',
|
||||
loglevel='info',
|
||||
) as (
|
||||
_, # OrderClient
|
||||
_, # tractor.MsgStream
|
||||
startup_pps,
|
||||
accounts,
|
||||
_, # dialogs,
|
||||
)
|
||||
):
|
||||
for (broker, account), ppmsgs in by_acct.items():
|
||||
assert account in accounts
|
||||
|
||||
# lookup all msgs rx-ed for this account
|
||||
rx_msgs = startup_pps[(broker, account)]
|
||||
|
||||
for expect_ppmsg in ppmsgs:
|
||||
rx_msg = BrokerdPosition(**rx_msgs[expect_ppmsg.symbol])
|
||||
assert rx_msg == expect_ppmsg
|
||||
|
||||
|
||||
async def submit_and_check(
|
||||
fills: tuple[dict],
|
||||
loglevel: str,
|
||||
|
||||
) -> tuple[
|
||||
BrokerdPosition,
|
||||
Position,
|
||||
]:
|
||||
'''
|
||||
Enter a trade and assert entries are made in pps and ledger files.
|
||||
|
||||
Shutdown the ems-client and ensure on reconnect we get the expected
|
||||
matching ``BrokerdPosition`` and pps.toml entries.
|
||||
|
||||
'''
|
||||
broker: str = 'kraken'
|
||||
mkt_key: str = 'xbtusdt'
|
||||
fqme: str = f'{mkt_key}.{broker}'
|
||||
|
||||
startup_pps: dict[
|
||||
tuple[str, str], # brokername, acctid
|
||||
list[BrokerdPosition],
|
||||
]
|
||||
async with (
|
||||
open_ems(
|
||||
fqme,
|
||||
mode='paper',
|
||||
loglevel=loglevel,
|
||||
) as (
|
||||
client, # OrderClient
|
||||
trades_stream, # tractor.MsgStream
|
||||
startup_pps,
|
||||
accounts,
|
||||
_, # dialogs
|
||||
)
|
||||
):
|
||||
# no positions on startup
|
||||
assert not startup_pps
|
||||
assert 'paper' in accounts
|
||||
|
||||
od: dict
|
||||
for od in fills:
|
||||
print(f'Sending order {od} for fill')
|
||||
size = od['size']
|
||||
sent, msgs = await order_and_and_wait_for_ppmsg(
|
||||
client,
|
||||
trades_stream,
|
||||
fqme,
|
||||
action='buy' if size > 0 else 'sell',
|
||||
price=100e3 if size > 0 else 0,
|
||||
size=size,
|
||||
)
|
||||
|
||||
last_order: Order = sent[-1]
|
||||
last_resp = msgs[-1]
|
||||
assert isinstance(last_resp, BrokerdPosition)
|
||||
ppmsg = last_resp
|
||||
|
||||
# check that pps.toml for account has been updated
|
||||
# and all ems position msgs match that state.
|
||||
with load_and_check_pos(
|
||||
last_order,
|
||||
ppmsg,
|
||||
) as pos:
|
||||
pass
|
||||
|
||||
return ppmsg, pos
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'fills',
|
||||
[
|
||||
# buy and leave
|
||||
({'size': 0.001},),
|
||||
|
||||
# sell short, then buy back to net-zero in dst
|
||||
(
|
||||
{'size': -0.001},
|
||||
{'size': 0.001},
|
||||
),
|
||||
|
||||
# multi-partial entry and exits from net-zero, to short and back
|
||||
# to net-zero.
|
||||
(
|
||||
# enters
|
||||
{'size': 0.001},
|
||||
{'size': 0.002},
|
||||
|
||||
# partial exit
|
||||
{'size': -0.001},
|
||||
|
||||
# partial enter
|
||||
{'size': 0.0015},
|
||||
{'size': 0.001},
|
||||
{'size': 0.002},
|
||||
|
||||
# nearly back to zero.
|
||||
{'size': -0.001},
|
||||
|
||||
# switch to net-short
|
||||
{'size': -0.025},
|
||||
{'size': -0.0195},
|
||||
|
||||
# another entry
|
||||
{'size': 0.001},
|
||||
|
||||
# final cover to net-zero again.
|
||||
{'size': 0.038},
|
||||
),
|
||||
],
|
||||
ids='fills={}'.format,
|
||||
)
|
||||
def test_multi_fill_positions(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
loglevel: str,
|
||||
|
||||
fills: tuple[dict],
|
||||
|
||||
check_cross_session: bool = False,
|
||||
|
||||
) -> None:
|
||||
|
||||
ppmsg: BrokerdPosition
|
||||
pos: Position
|
||||
|
||||
accum_size: float = 0
|
||||
for fill in fills:
|
||||
accum_size += fill['size']
|
||||
|
||||
async def atest():
|
||||
|
||||
# export to outer scope for audit on second runtime-boot.
|
||||
nonlocal ppmsg, pos
|
||||
|
||||
async with (
|
||||
open_test_pikerd() as (_, _, _, _),
|
||||
):
|
||||
ppmsg, pos = await submit_and_check(
|
||||
fills=fills,
|
||||
loglevel=loglevel,
|
||||
)
|
||||
assert ppmsg.size == accum_size
|
||||
|
||||
run_and_tollerate_cancels(atest)
|
||||
|
||||
if (
|
||||
check_cross_session
|
||||
or accum_size != 0
|
||||
):
|
||||
# rerun just to check that position info is persistent for the paper
|
||||
# account (i.e. a user can expect to see paper pps persist across
|
||||
# runtime sessions.
|
||||
async def just_check_pp():
|
||||
nonlocal ppmsg
|
||||
|
||||
async with (
|
||||
open_test_pikerd() as (_, _, _, _),
|
||||
):
|
||||
await match_ppmsgs_on_ems_boot([ppmsg])
|
||||
|
||||
run_and_tollerate_cancels(just_check_pp)
|
||||
|
||||
|
||||
# TODO: still need to implement offline storage of darks/alerts/paper
|
||||
# lives probably all the same way.. see
|
||||
# https://github.com/pikers/piker/issues/463
|
||||
def test_open_orders_reloaded(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
loglevel: str,
|
||||
|
||||
# fills: tuple[dict],
|
||||
|
||||
check_cross_session: bool = False,
|
||||
):
|
||||
...
|
||||
|
||||
|
||||
def test_dark_order_clearing():
|
||||
...
|
|
@ -7,19 +7,20 @@ from pprint import pprint
|
|||
from typing import AsyncContextManager
|
||||
|
||||
import pytest
|
||||
# import tractor
|
||||
import trio
|
||||
|
||||
from piker.data import (
|
||||
ShmArray,
|
||||
open_feed,
|
||||
)
|
||||
from piker.data._source import (
|
||||
unpack_fqsn,
|
||||
from piker.data.flows import Flume
|
||||
from piker.accounting import (
|
||||
unpack_fqme,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'fqsns',
|
||||
'fqmes',
|
||||
[
|
||||
# binance
|
||||
(100, {'btcusdt.binance', 'ethusdt.binance'}, False),
|
||||
|
@ -30,20 +31,20 @@ from piker.data._source import (
|
|||
# binance + kraken
|
||||
(100, {'btcusdt.binance', 'xbtusd.kraken'}, False),
|
||||
],
|
||||
ids=lambda param: f'quotes={param[0]}@fqsns={param[1]}',
|
||||
ids=lambda param: f'quotes={param[0]}@fqmes={param[1]}',
|
||||
)
|
||||
def test_multi_fqsn_feed(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
fqsns: set[str],
|
||||
fqmes: set[str],
|
||||
loglevel: str,
|
||||
ci_env: bool
|
||||
ci_env: bool,
|
||||
):
|
||||
'''
|
||||
Start a real-time data feed for provided fqsn and pull
|
||||
Start a real-time data feed for provided fqme and pull
|
||||
a few quotes then simply shut down.
|
||||
|
||||
'''
|
||||
max_quotes, fqsns, run_in_ci = fqsns
|
||||
max_quotes, fqmes, run_in_ci = fqmes
|
||||
|
||||
if (
|
||||
ci_env
|
||||
|
@ -52,15 +53,15 @@ def test_multi_fqsn_feed(
|
|||
pytest.skip('Skipping CI disabled test due to feed restrictions')
|
||||
|
||||
brokers = set()
|
||||
for fqsn in fqsns:
|
||||
brokername, key, suffix = unpack_fqsn(fqsn)
|
||||
for fqme in fqmes:
|
||||
brokername, *_ = unpack_fqme(fqme)
|
||||
brokers.add(brokername)
|
||||
|
||||
async def main():
|
||||
async with (
|
||||
open_test_pikerd(),
|
||||
open_feed(
|
||||
fqsns,
|
||||
fqmes,
|
||||
loglevel=loglevel,
|
||||
|
||||
# TODO: ensure throttle rate is applied
|
||||
|
@ -71,20 +72,20 @@ def test_multi_fqsn_feed(
|
|||
) as feed
|
||||
):
|
||||
# verify shm buffers exist
|
||||
for fqin in fqsns:
|
||||
for fqin in fqmes:
|
||||
flume = feed.flumes[fqin]
|
||||
ohlcv: ShmArray = flume.rt_shm
|
||||
hist_ohlcv: ShmArray = flume.hist_shm
|
||||
|
||||
async with feed.open_multi_stream(brokers) as stream:
|
||||
|
||||
# pull the first startup quotes, one for each fqsn, and
|
||||
# pull the first startup quotes, one for each fqme, and
|
||||
# ensure they match each flume's startup quote value.
|
||||
fqsns_copy = fqsns.copy()
|
||||
fqsns_copy = fqmes.copy()
|
||||
with trio.fail_after(0.5):
|
||||
for _ in range(1):
|
||||
first_quotes = await stream.receive()
|
||||
for fqsn, quote in first_quotes.items():
|
||||
for fqme, quote in first_quotes.items():
|
||||
|
||||
# XXX: TODO: WTF apparently this error will get
|
||||
# supressed and only show up in the teardown
|
||||
|
@ -92,18 +93,17 @@ def test_multi_fqsn_feed(
|
|||
# <tractorbugurl>
|
||||
# assert 0
|
||||
|
||||
fqsns_copy.remove(fqsn)
|
||||
flume = feed.flumes[fqsn]
|
||||
fqsns_copy.remove(fqme)
|
||||
flume: Flume = feed.flumes[fqme]
|
||||
assert quote['last'] == flume.first_quote['last']
|
||||
|
||||
cntr = Counter()
|
||||
with trio.fail_after(6):
|
||||
async for quotes in stream:
|
||||
for fqsn, quote in quotes.items():
|
||||
cntr[fqsn] += 1
|
||||
for fqme, quote in quotes.items():
|
||||
cntr[fqme] += 1
|
||||
|
||||
# await tractor.breakpoint()
|
||||
flume = feed.flumes[fqsn]
|
||||
flume = feed.flumes[fqme]
|
||||
ohlcv: ShmArray = flume.rt_shm
|
||||
hist_ohlcv: ShmArray = flume.hist_shm
|
||||
|
||||
|
@ -116,7 +116,7 @@ def test_multi_fqsn_feed(
|
|||
# assert last == rt_row['close']
|
||||
# assert last == hist_row['close']
|
||||
pprint(
|
||||
f'{fqsn}: {quote}\n'
|
||||
f'{fqme}: {quote}\n'
|
||||
f'rt_ohlc: {rt_row}\n'
|
||||
f'hist_ohlc: {hist_row}\n'
|
||||
)
|
||||
|
@ -124,6 +124,6 @@ def test_multi_fqsn_feed(
|
|||
if cntr.total() >= max_quotes:
|
||||
break
|
||||
|
||||
assert set(cntr.keys()) == fqsns
|
||||
assert set(cntr.keys()) == fqmes
|
||||
|
||||
trio.run(main)
|
||||
|
|
|
@ -1,230 +0,0 @@
|
|||
'''
|
||||
Paper-mode testing
|
||||
'''
|
||||
|
||||
import trio
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
from typing import (
|
||||
AsyncContextManager,
|
||||
Literal,
|
||||
)
|
||||
|
||||
import pytest
|
||||
from tractor._exceptions import ContextCancelled
|
||||
from uuid import uuid4
|
||||
from functools import partial
|
||||
|
||||
from piker.log import get_logger
|
||||
from piker.clearing._messages import Order
|
||||
from piker.pp import (
|
||||
open_pps,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
def get_fqsn(broker, symbol):
|
||||
fqsn = f'{symbol}.{broker}'
|
||||
return (fqsn, symbol, broker)
|
||||
|
||||
|
||||
oid = ''
|
||||
test_exec_mode = 'live'
|
||||
(fqsn, symbol, broker) = get_fqsn('kraken', 'xbtusdt')
|
||||
brokers = [broker]
|
||||
account = 'paper'
|
||||
|
||||
|
||||
async def _async_main(
|
||||
open_test_pikerd_and_ems: AsyncContextManager,
|
||||
action: Literal['buy', 'sell'] | None = None,
|
||||
price: int = 30000,
|
||||
executions: int = 1,
|
||||
size: float = 0.01,
|
||||
|
||||
# Assert options
|
||||
assert_entries: bool = False,
|
||||
assert_pps: bool = False,
|
||||
assert_zeroed_pps: bool = False,
|
||||
assert_msg: bool = False,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Start piker, place a trade and assert data in
|
||||
pps stream, ledger and position table.
|
||||
|
||||
'''
|
||||
oid: str = ''
|
||||
last_msg = {}
|
||||
|
||||
async with open_test_pikerd_and_ems() as (
|
||||
services,
|
||||
(book, trades_stream, pps, accounts, dialogs),
|
||||
):
|
||||
if action:
|
||||
for x in range(executions):
|
||||
oid = str(uuid4())
|
||||
order = Order(
|
||||
exec_mode=test_exec_mode,
|
||||
action=action,
|
||||
oid=oid,
|
||||
account=account,
|
||||
size=size,
|
||||
symbol=fqsn,
|
||||
price=price,
|
||||
brokers=brokers,
|
||||
)
|
||||
# This is actually a syncronous call to push a message
|
||||
book.send(order)
|
||||
|
||||
async for msg in trades_stream:
|
||||
last_msg = msg
|
||||
match msg:
|
||||
# Wait for position message before moving on
|
||||
case {'name': 'position'}:
|
||||
break
|
||||
|
||||
# Teardown piker like a user would
|
||||
raise KeyboardInterrupt
|
||||
|
||||
if assert_entries or assert_pps or assert_zeroed_pps or assert_msg:
|
||||
_assert(
|
||||
assert_entries,
|
||||
assert_pps,
|
||||
assert_zeroed_pps,
|
||||
pps,
|
||||
last_msg,
|
||||
size,
|
||||
executions,
|
||||
)
|
||||
|
||||
|
||||
def _assert(
|
||||
assert_entries,
|
||||
assert_pps,
|
||||
assert_zerod_pps,
|
||||
pps,
|
||||
last_msg,
|
||||
size,
|
||||
executions,
|
||||
):
|
||||
with (
|
||||
open_pps(broker, account, write_on_exit=False) as table,
|
||||
):
|
||||
'''
|
||||
Assert multiple cases including pps,
|
||||
ledger and final position message state
|
||||
|
||||
'''
|
||||
if assert_entries:
|
||||
for key, val in [
|
||||
('broker', broker),
|
||||
('account', account),
|
||||
('symbol', fqsn),
|
||||
('size', size * executions),
|
||||
('currency', symbol),
|
||||
('avg_price', table.pps[symbol].ppu)
|
||||
]:
|
||||
assert last_msg[key] == val
|
||||
|
||||
if assert_pps:
|
||||
last_ppu = pps[(broker, account)][-1]
|
||||
assert last_ppu['avg_price'] == table.pps[symbol].ppu
|
||||
|
||||
if assert_zerod_pps:
|
||||
assert not bool(table.pps)
|
||||
|
||||
|
||||
def _run_test_and_check(fn):
|
||||
'''
|
||||
Close position and assert empty position in pps
|
||||
|
||||
'''
|
||||
with pytest.raises(BaseExceptionGroup) as exc_info:
|
||||
trio.run(fn)
|
||||
|
||||
for exception in exc_info.value.exceptions:
|
||||
assert isinstance(exception, KeyboardInterrupt) or isinstance(
|
||||
exception, ContextCancelled
|
||||
)
|
||||
|
||||
|
||||
def test_buy(
|
||||
open_test_pikerd_and_ems: AsyncContextManager,
|
||||
):
|
||||
'''
|
||||
Enter a trade and assert entries are made in pps and ledger files.
|
||||
|
||||
'''
|
||||
_run_test_and_check(
|
||||
partial(
|
||||
_async_main,
|
||||
open_test_pikerd_and_ems=open_test_pikerd_and_ems,
|
||||
action='buy',
|
||||
assert_entries=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Open ems and assert existence of pps entries
|
||||
_run_test_and_check(
|
||||
partial(
|
||||
_async_main,
|
||||
open_test_pikerd_and_ems=open_test_pikerd_and_ems,
|
||||
assert_pps=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_sell(
|
||||
open_test_pikerd_and_ems: AsyncContextManager,
|
||||
):
|
||||
'''
|
||||
Sell position and ensure pps are zeroed.
|
||||
|
||||
'''
|
||||
_run_test_and_check(
|
||||
partial(
|
||||
_async_main,
|
||||
open_test_pikerd_and_ems=open_test_pikerd_and_ems,
|
||||
action='sell',
|
||||
price=1,
|
||||
),
|
||||
)
|
||||
|
||||
_run_test_and_check(
|
||||
partial(
|
||||
_async_main,
|
||||
open_test_pikerd_and_ems=open_test_pikerd_and_ems,
|
||||
assert_zeroed_pps=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_multi_sell(
|
||||
open_test_pikerd_and_ems: AsyncContextManager,
|
||||
):
|
||||
'''
|
||||
Make 5 market limit buy orders and
|
||||
then sell 5 slots at the same price.
|
||||
Finally, assert cleared positions.
|
||||
|
||||
'''
|
||||
_run_test_and_check(
|
||||
partial(
|
||||
_async_main,
|
||||
open_test_pikerd_and_ems=open_test_pikerd_and_ems,
|
||||
action='buy',
|
||||
executions=5,
|
||||
),
|
||||
)
|
||||
|
||||
_run_test_and_check(
|
||||
partial(
|
||||
_async_main,
|
||||
open_test_pikerd_and_ems=open_test_pikerd_and_ems,
|
||||
action='sell',
|
||||
executions=5,
|
||||
price=1,
|
||||
assert_zeroed_pps=True,
|
||||
),
|
||||
)
|
|
@ -2,9 +2,13 @@
|
|||
Actor tree daemon sub-service verifications
|
||||
|
||||
'''
|
||||
from typing import AsyncContextManager
|
||||
from typing import (
|
||||
AsyncContextManager,
|
||||
Callable,
|
||||
)
|
||||
from contextlib import asynccontextmanager as acm
|
||||
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
import pytest
|
||||
import trio
|
||||
import tractor
|
||||
|
@ -24,7 +28,7 @@ from piker.clearing._messages import (
|
|||
Status,
|
||||
)
|
||||
from piker.clearing._client import (
|
||||
OrderBook,
|
||||
OrderClient,
|
||||
)
|
||||
|
||||
|
||||
|
@ -33,8 +37,8 @@ def test_runtime_boot(
|
|||
):
|
||||
'''
|
||||
Verify we can boot the `pikerd` service stack using the
|
||||
`open_test_pikerd` fixture helper and that registry address details
|
||||
match up.
|
||||
`open_test_pikerd()` fixture helper and that contact-registry
|
||||
address details match up.
|
||||
|
||||
'''
|
||||
async def main():
|
||||
|
@ -55,6 +59,46 @@ def test_runtime_boot(
|
|||
assert pikerd_portal.channel.raddr == daemon_addr
|
||||
assert pikerd_portal.channel.raddr == portal.channel.raddr
|
||||
|
||||
# no service tasks should be started
|
||||
assert not services.service_tasks
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
def test_ensure_datafeed_actors(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
loglevel: str,
|
||||
# cancel_method: str,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Verify that booting a data feed starts a `brokerd`
|
||||
actor and a singleton global `samplerd` and opening
|
||||
an order mode in paper opens the `paperboi` service.
|
||||
|
||||
'''
|
||||
actor_name: str = 'brokerd'
|
||||
backend: str = 'kraken'
|
||||
brokerd_name: str = f'{actor_name}.{backend}'
|
||||
|
||||
async def main():
|
||||
async with (
|
||||
open_test_pikerd(),
|
||||
|
||||
open_feed(
|
||||
['xbtusdt.kraken'],
|
||||
loglevel=loglevel,
|
||||
) as feed
|
||||
):
|
||||
# halt rt quote streams since we aren't testing them
|
||||
await feed.pause()
|
||||
|
||||
async with (
|
||||
ensure_service(brokerd_name),
|
||||
ensure_service('samplerd'),
|
||||
):
|
||||
await trio.sleep(0.1)
|
||||
|
||||
trio.run(main)
|
||||
|
||||
|
||||
|
@ -73,45 +117,69 @@ async def ensure_service(
|
|||
yield portal
|
||||
|
||||
|
||||
def test_ensure_datafeed_actors(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
loglevel: str,
|
||||
def run_test_w_cancel_method(
|
||||
cancel_method: str,
|
||||
main: Callable,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Verify that booting a data feed starts a `brokerd`
|
||||
actor and a singleton global `samplerd` and opening
|
||||
an order mode in paper opens the `paperboi` service.
|
||||
Run our runtime under trio and expect a certain type of cancel condition
|
||||
depending on input.
|
||||
|
||||
'''
|
||||
actor_name: str = 'brokerd'
|
||||
backend: str = 'kraken'
|
||||
brokerd_name: str = f'{actor_name}.{backend}'
|
||||
cancelled_msg: str = (
|
||||
"was remotely cancelled by remote actor (\'pikerd\'")
|
||||
|
||||
async def main():
|
||||
async with (
|
||||
open_test_pikerd(),
|
||||
open_feed(
|
||||
['xbtusdt.kraken'],
|
||||
loglevel=loglevel,
|
||||
) as feed
|
||||
):
|
||||
# halt rt quote streams since we aren't testing them
|
||||
await feed.pause()
|
||||
if cancel_method == 'sigint':
|
||||
with pytest.raises(
|
||||
BaseExceptionGroup,
|
||||
) as exc_info:
|
||||
trio.run(main)
|
||||
|
||||
async with (
|
||||
ensure_service(brokerd_name),
|
||||
ensure_service('samplerd'),
|
||||
):
|
||||
pass
|
||||
multi = exc_info.value
|
||||
|
||||
trio.run(main)
|
||||
for suberr in multi.exceptions:
|
||||
match suberr:
|
||||
# ensure we receive a remote cancellation error caused
|
||||
# by the pikerd root actor since we used the
|
||||
# `.cancel_service()` API above B)
|
||||
case tractor.ContextCancelled():
|
||||
assert cancelled_msg in suberr.args[0]
|
||||
|
||||
case KeyboardInterrupt():
|
||||
pass
|
||||
|
||||
case _:
|
||||
pytest.fail(f'Unexpected error {suberr}')
|
||||
|
||||
elif cancel_method == 'services':
|
||||
|
||||
# XXX NOTE: oddly, when you pass --pdb to pytest, i think since
|
||||
# we also use that to enable the underlying tractor debug mode,
|
||||
# it causes this to not raise for some reason? So if you see
|
||||
# that while changing this test.. it's prolly that.
|
||||
|
||||
with pytest.raises(
|
||||
tractor.ContextCancelled
|
||||
) as exc_info:
|
||||
trio.run(main)
|
||||
|
||||
assert cancelled_msg in exc_info.value.args[0]
|
||||
|
||||
else:
|
||||
pytest.fail(f'Test is broken due to {cancel_method}')
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'cancel_method',
|
||||
['services', 'sigint'],
|
||||
)
|
||||
def test_ensure_ems_in_paper_actors(
|
||||
open_test_pikerd: AsyncContextManager,
|
||||
loglevel: str,
|
||||
|
||||
cancel_method: str,
|
||||
|
||||
) -> None:
|
||||
|
||||
actor_name: str = 'brokerd'
|
||||
|
@ -121,8 +189,7 @@ def test_ensure_ems_in_paper_actors(
|
|||
async def main():
|
||||
|
||||
# type declares
|
||||
book: OrderBook
|
||||
trades_stream: tractor.MsgStream
|
||||
client: OrderClient
|
||||
pps: dict[str, list[BrokerdPosition]]
|
||||
accounts: list[str]
|
||||
dialogs: dict[str, Status]
|
||||
|
@ -139,8 +206,8 @@ def test_ensure_ems_in_paper_actors(
|
|||
mode='paper',
|
||||
loglevel=loglevel,
|
||||
) as (
|
||||
book,
|
||||
trades_stream,
|
||||
client,
|
||||
_, # trades_stream: tractor.MsgStream
|
||||
pps,
|
||||
accounts,
|
||||
dialogs,
|
||||
|
@ -151,6 +218,9 @@ def test_ensure_ems_in_paper_actors(
|
|||
# local ledger and `pps.toml` state ;)
|
||||
assert not pps
|
||||
assert not dialogs
|
||||
# XXX: should be new client with no state from other tests
|
||||
assert not client._sent_orders
|
||||
assert accounts
|
||||
|
||||
pikerd_subservices = ['emsd', 'samplerd']
|
||||
|
||||
|
@ -166,13 +236,13 @@ def test_ensure_ems_in_paper_actors(
|
|||
# implicitly by the ems.
|
||||
assert brokerd_name in services.service_tasks
|
||||
|
||||
print('ALL SERVICES STARTED, terminating..')
|
||||
await services.cancel_service('emsd')
|
||||
print('ALL SERVICES STARTED, cancelling runtime with:\n'
|
||||
f'-> {cancel_method}')
|
||||
|
||||
with pytest.raises(
|
||||
tractor._exceptions.ContextCancelled,
|
||||
) as exc_info:
|
||||
trio.run(main)
|
||||
if cancel_method == 'services':
|
||||
await services.cancel_service('emsd')
|
||||
|
||||
cancel_msg: str = '_emsd_main()` was remotely cancelled by its caller'
|
||||
assert cancel_msg in exc_info.value.args[0]
|
||||
elif cancel_method == 'sigint':
|
||||
raise KeyboardInterrupt
|
||||
|
||||
run_test_w_cancel_method(cancel_method, main)
|
||||
|
|
Loading…
Reference in New Issue