commit
8c39ff307e
24
.travis.yml
24
.travis.yml
|
@ -1,24 +0,0 @@
|
||||||
language: python
|
|
||||||
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- python: 3.7
|
|
||||||
dist: xenial
|
|
||||||
sudo: required
|
|
||||||
|
|
||||||
before_install:
|
|
||||||
- sudo apt-get -qq update
|
|
||||||
# deps to build kivy from sources for use with trio
|
|
||||||
- sudo apt-get install -y build-essential libav-tools libgles2-mesa-dev libgles2-mesa-dev libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev zlib1g-dev
|
|
||||||
|
|
||||||
install:
|
|
||||||
- pip install pipenv
|
|
||||||
- cd $TRAVIS_BUILD_DIR
|
|
||||||
- pipenv install --dev -e .
|
|
||||||
|
|
||||||
cache:
|
|
||||||
directories:
|
|
||||||
- $HOME/.config/piker/
|
|
||||||
|
|
||||||
script:
|
|
||||||
- pipenv run pytest tests/
|
|
|
@ -8,7 +8,7 @@ trading gear for hackers.
|
||||||
:target: https://actions-badge.atrox.dev/piker/pikers/goto
|
:target: https://actions-badge.atrox.dev/piker/pikers/goto
|
||||||
|
|
||||||
``piker`` is a broker agnostic, next-gen FOSS toolset for real-time
|
``piker`` is a broker agnostic, next-gen FOSS toolset for real-time
|
||||||
trading targeted at hardcore Linux users.
|
computational trading targeted at `hardcore Linux users <comp_trader>`_ .
|
||||||
|
|
||||||
we use as much bleeding edge tech as possible including (but not limited to):
|
we use as much bleeding edge tech as possible including (but not limited to):
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ we use as much bleeding edge tech as possible including (but not limited to):
|
||||||
.. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph
|
.. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph
|
||||||
.. _glue: https://numpy.org/doc/stable/user/c-info.python-as-glue.html#using-python-as-glue
|
.. _glue: https://numpy.org/doc/stable/user/c-info.python-as-glue.html#using-python-as-glue
|
||||||
.. _fast numerics: https://zerowithdot.com/python-numpy-and-pandas-performance/
|
.. _fast numerics: https://zerowithdot.com/python-numpy-and-pandas-performance/
|
||||||
|
.. _comp_trader: https://jfaleiro.wordpress.com/2019/10/09/computational-trader/
|
||||||
|
|
||||||
|
|
||||||
focus and features:
|
focus and features:
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
"""
|
"""
|
||||||
Async utils no one seems to have built into a core lib (yet).
|
Async utils no one seems to have built into a core lib (yet).
|
||||||
"""
|
"""
|
||||||
|
from typing import AsyncContextManager
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
|
||||||
def async_lifo_cache(maxsize=128):
|
def async_lifo_cache(maxsize=128):
|
||||||
|
@ -47,3 +49,18 @@ def async_lifo_cache(maxsize=128):
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _just_none():
|
||||||
|
# noop -> skip entering context
|
||||||
|
yield None
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def maybe_with_if(
|
||||||
|
predicate: bool,
|
||||||
|
context: AsyncContextManager,
|
||||||
|
) -> AsyncContextManager:
|
||||||
|
async with context if predicate else _just_none() as output:
|
||||||
|
yield output
|
||||||
|
|
459
piker/_ems.py
459
piker/_ems.py
|
@ -1,459 +0,0 @@
|
||||||
# piker: trading gear for hackers
|
|
||||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
"""
|
|
||||||
In suit parlance: "Execution management systems"
|
|
||||||
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import (
|
|
||||||
AsyncIterator, Dict, Callable, Tuple,
|
|
||||||
)
|
|
||||||
|
|
||||||
import trio
|
|
||||||
from trio_typing import TaskStatus
|
|
||||||
import tractor
|
|
||||||
|
|
||||||
from . import data
|
|
||||||
from .log import get_logger
|
|
||||||
from .data._source import Symbol
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
|
||||||
|
|
||||||
# setup local ui event streaming channels for request/resp
|
|
||||||
# streamging with EMS daemon
|
|
||||||
_to_ems, _from_order_book = trio.open_memory_channel(100)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OrderBook:
|
|
||||||
"""Buy-side (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, mostly for keeping local state to match the EMS and use
|
|
||||||
received events to trigger graphics updates.
|
|
||||||
|
|
||||||
"""
|
|
||||||
_sent_orders: Dict[str, dict] = field(default_factory=dict)
|
|
||||||
_confirmed_orders: Dict[str, dict] = field(default_factory=dict)
|
|
||||||
|
|
||||||
_to_ems: trio.abc.SendChannel = _to_ems
|
|
||||||
_from_order_book: trio.abc.ReceiveChannel = _from_order_book
|
|
||||||
|
|
||||||
def on_fill(self, uuid: str) -> None:
|
|
||||||
cmd = self._sent_orders[uuid]
|
|
||||||
log.info(f"Order executed: {cmd}")
|
|
||||||
self._confirmed_orders[uuid] = cmd
|
|
||||||
|
|
||||||
def alert(
|
|
||||||
self,
|
|
||||||
uuid: str,
|
|
||||||
symbol: 'Symbol',
|
|
||||||
price: float
|
|
||||||
) -> str:
|
|
||||||
cmd = {
|
|
||||||
'msg': 'alert',
|
|
||||||
'price': price,
|
|
||||||
'symbol': symbol.key,
|
|
||||||
'brokers': symbol.brokers,
|
|
||||||
'oid': uuid,
|
|
||||||
}
|
|
||||||
self._sent_orders[uuid] = cmd
|
|
||||||
self._to_ems.send_nowait(cmd)
|
|
||||||
|
|
||||||
def buy(self, price: float) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
def sell(self, price: float) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
def cancel(self, uuid: str) -> bool:
|
|
||||||
"""Cancel an order (or alert) from the EMS.
|
|
||||||
|
|
||||||
"""
|
|
||||||
cmd = {
|
|
||||||
'msg': 'cancel',
|
|
||||||
'oid': uuid,
|
|
||||||
}
|
|
||||||
self._sent_orders[uuid] = cmd
|
|
||||||
self._to_ems.send_nowait(cmd)
|
|
||||||
|
|
||||||
# higher level operations
|
|
||||||
|
|
||||||
async def transmit_to_broker(self, price: float) -> str:
|
|
||||||
...
|
|
||||||
|
|
||||||
async def modify(self, oid: str, price) -> bool:
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
_orders: OrderBook = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_orders(emsd_uid: Tuple[str, str] = None) -> OrderBook:
|
|
||||||
|
|
||||||
if emsd_uid is not None:
|
|
||||||
# TODO: read in target emsd's active book on startup
|
|
||||||
pass
|
|
||||||
|
|
||||||
global _orders
|
|
||||||
|
|
||||||
if _orders is None:
|
|
||||||
_orders = OrderBook()
|
|
||||||
|
|
||||||
return _orders
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: make this a ``tractor.msg.pub``
|
|
||||||
async def send_order_cmds():
|
|
||||||
"""Order streaming task: deliver orders transmitted from UI
|
|
||||||
to downstream consumers.
|
|
||||||
|
|
||||||
This is run in the UI actor (usually the one running Qt).
|
|
||||||
The UI simply delivers order messages to the above ``_to_ems``
|
|
||||||
send channel (from sync code using ``.send_nowait()``), these values
|
|
||||||
are pulled from the channel here and send to any consumer(s).
|
|
||||||
|
|
||||||
This effectively makes order messages look like they're being
|
|
||||||
"pushed" from the parent to the EMS actor.
|
|
||||||
|
|
||||||
"""
|
|
||||||
global _from_order_book
|
|
||||||
|
|
||||||
async for cmd in _from_order_book:
|
|
||||||
|
|
||||||
# send msg over IPC / wire
|
|
||||||
log.info(f'sending order cmd: {cmd}')
|
|
||||||
yield cmd
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: numba all of this
|
|
||||||
def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]:
|
|
||||||
"""Create a predicate for given ``exec_price`` based on last known
|
|
||||||
price, ``known_last``.
|
|
||||||
|
|
||||||
This is an automatic alert level thunk generator based on where the
|
|
||||||
current last known value is and where the specified value of
|
|
||||||
interest is; pick an appropriate comparison operator based on
|
|
||||||
avoiding the case where the a predicate returns true immediately.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# str compares:
|
|
||||||
# https://stackoverflow.com/questions/46708708/compare-strings-in-numba-compiled-function
|
|
||||||
|
|
||||||
if trigger_price >= known_last:
|
|
||||||
|
|
||||||
def check_gt(price: float) -> bool:
|
|
||||||
return price >= trigger_price
|
|
||||||
|
|
||||||
return check_gt, 'down'
|
|
||||||
|
|
||||||
elif trigger_price <= known_last:
|
|
||||||
|
|
||||||
def check_lt(price: float) -> bool:
|
|
||||||
return price <= trigger_price
|
|
||||||
|
|
||||||
return check_lt, 'up'
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _ExecBook:
|
|
||||||
"""EMS-side execution book.
|
|
||||||
|
|
||||||
Contains conditions for executions (aka "orders").
|
|
||||||
A singleton instance is created per EMS actor (for now).
|
|
||||||
|
|
||||||
"""
|
|
||||||
# levels which have an executable action (eg. alert, order, signal)
|
|
||||||
orders: Dict[
|
|
||||||
Tuple[str, str],
|
|
||||||
Dict[
|
|
||||||
str, # uuid
|
|
||||||
Tuple[
|
|
||||||
Callable[[float], bool], # predicate
|
|
||||||
str, # name
|
|
||||||
dict, # cmd / msg type
|
|
||||||
]
|
|
||||||
]
|
|
||||||
] = field(default_factory=dict)
|
|
||||||
|
|
||||||
# tracks most recent values per symbol each from data feed
|
|
||||||
lasts: Dict[
|
|
||||||
Tuple[str, str],
|
|
||||||
float
|
|
||||||
] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
_book = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_book() -> _ExecBook:
|
|
||||||
global _book
|
|
||||||
|
|
||||||
if _book is None:
|
|
||||||
_book = _ExecBook()
|
|
||||||
|
|
||||||
return _book
|
|
||||||
|
|
||||||
|
|
||||||
async def exec_orders(
|
|
||||||
ctx: tractor.Context,
|
|
||||||
broker: str,
|
|
||||||
symbol: str,
|
|
||||||
exec_price: float,
|
|
||||||
task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED,
|
|
||||||
) -> AsyncIterator[dict]:
|
|
||||||
|
|
||||||
async with data.open_feed(
|
|
||||||
broker,
|
|
||||||
[symbol],
|
|
||||||
loglevel='info',
|
|
||||||
) as feed:
|
|
||||||
|
|
||||||
# TODO: get initial price
|
|
||||||
|
|
||||||
first_quote = await feed.receive()
|
|
||||||
|
|
||||||
book = get_book()
|
|
||||||
book.lasts[(broker, symbol)] = first_quote[symbol]['last']
|
|
||||||
|
|
||||||
task_status.started(first_quote)
|
|
||||||
|
|
||||||
# shield this field so the remote brokerd does not get cancelled
|
|
||||||
stream = feed.stream
|
|
||||||
|
|
||||||
with stream.shield():
|
|
||||||
async for quotes in stream:
|
|
||||||
|
|
||||||
##############################
|
|
||||||
# begin price actions sequence
|
|
||||||
# XXX: optimize this for speed
|
|
||||||
##############################
|
|
||||||
|
|
||||||
start = time.time()
|
|
||||||
for sym, quote in quotes.items():
|
|
||||||
|
|
||||||
execs = book.orders.get((broker, sym))
|
|
||||||
|
|
||||||
for tick in quote.get('ticks', ()):
|
|
||||||
price = tick.get('price')
|
|
||||||
if price < 0:
|
|
||||||
# lel, fuck you ib
|
|
||||||
continue
|
|
||||||
|
|
||||||
# update to keep new cmds informed
|
|
||||||
book.lasts[(broker, symbol)] = price
|
|
||||||
|
|
||||||
if not execs:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for oid, (pred, name, cmd) in tuple(execs.items()):
|
|
||||||
|
|
||||||
# push trigger msg back to parent as an "alert"
|
|
||||||
# (mocking for eg. a "fill")
|
|
||||||
if pred(price):
|
|
||||||
|
|
||||||
cmd['name'] = name
|
|
||||||
cmd['index'] = feed.shm._last.value - 1
|
|
||||||
# current shm array index
|
|
||||||
cmd['trigger_price'] = price
|
|
||||||
cmd['msg'] = 'executed'
|
|
||||||
|
|
||||||
await ctx.send_yield(cmd)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"GOT ALERT FOR {exec_price} @ \n{tick}\n")
|
|
||||||
|
|
||||||
print(f'removing pred for {oid}')
|
|
||||||
pred, name, cmd = execs.pop(oid)
|
|
||||||
|
|
||||||
print(f'execs are {execs}')
|
|
||||||
|
|
||||||
print(f'execs scan took: {time.time() - start}')
|
|
||||||
# feed teardown
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.stream
|
|
||||||
async def stream_and_route(ctx, ui_name):
|
|
||||||
"""Order router (sub)actor entrypoint.
|
|
||||||
|
|
||||||
This is the daemon (child) side routine which starts an EMS
|
|
||||||
runtime per broker/feed and and begins streaming back alerts
|
|
||||||
from executions back to subscribers.
|
|
||||||
|
|
||||||
"""
|
|
||||||
actor = tractor.current_actor()
|
|
||||||
book = get_book()
|
|
||||||
|
|
||||||
_active_execs: Dict[str, (str, str)] = {}
|
|
||||||
|
|
||||||
# new router entry point
|
|
||||||
async with tractor.wait_for_actor(ui_name) as portal:
|
|
||||||
|
|
||||||
# spawn one task per broker feed
|
|
||||||
async with trio.open_nursery() as n:
|
|
||||||
|
|
||||||
async for cmd in await portal.run(send_order_cmds):
|
|
||||||
|
|
||||||
log.info(f'{cmd} received in {actor.uid}')
|
|
||||||
msg = cmd['msg']
|
|
||||||
oid = cmd['oid']
|
|
||||||
|
|
||||||
if msg == 'cancel':
|
|
||||||
# destroy exec
|
|
||||||
pred, name, cmd = book.orders[_active_execs[oid]].pop(oid)
|
|
||||||
|
|
||||||
# ack-cmdond that order is live
|
|
||||||
await ctx.send_yield({'msg': 'cancelled', 'oid': oid})
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
elif msg in ('alert', 'buy', 'sell',):
|
|
||||||
|
|
||||||
trigger_price = cmd['price']
|
|
||||||
sym = cmd['symbol']
|
|
||||||
brokers = cmd['brokers']
|
|
||||||
|
|
||||||
broker = brokers[0]
|
|
||||||
last = book.lasts.get((broker, sym))
|
|
||||||
|
|
||||||
if last is None: # spawn new brokerd feed task
|
|
||||||
|
|
||||||
quote = await n.start(
|
|
||||||
exec_orders,
|
|
||||||
ctx,
|
|
||||||
# TODO: eventually support N-brokers
|
|
||||||
broker,
|
|
||||||
sym,
|
|
||||||
trigger_price,
|
|
||||||
)
|
|
||||||
print(f"received first quote {quote}")
|
|
||||||
|
|
||||||
last = book.lasts[(broker, sym)]
|
|
||||||
print(f'Known last is {last}')
|
|
||||||
|
|
||||||
# Auto-gen scanner predicate:
|
|
||||||
# we automatically figure out what the alert check
|
|
||||||
# condition should be based on the current first
|
|
||||||
# price received from the feed, instead of being
|
|
||||||
# like every other shitty tina platform that makes
|
|
||||||
# the user choose the predicate operator.
|
|
||||||
pred, name = mk_check(trigger_price, last)
|
|
||||||
|
|
||||||
# create list of executions on first entry
|
|
||||||
book.orders.setdefault(
|
|
||||||
(broker, sym), {})[oid] = (pred, name, cmd)
|
|
||||||
|
|
||||||
# reverse lookup for cancellations
|
|
||||||
_active_execs[oid] = (broker, sym)
|
|
||||||
|
|
||||||
# ack-cmdond that order is live
|
|
||||||
await ctx.send_yield({
|
|
||||||
'msg': 'active',
|
|
||||||
'oid': oid
|
|
||||||
})
|
|
||||||
|
|
||||||
# continue and wait on next order cmd
|
|
||||||
|
|
||||||
|
|
||||||
async def spawn_router_stream_alerts(
|
|
||||||
order_mode,
|
|
||||||
symbol: Symbol,
|
|
||||||
# lines: 'LinesEditor',
|
|
||||||
task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED,
|
|
||||||
) -> None:
|
|
||||||
"""Spawn an EMS daemon and begin sending orders and receiving
|
|
||||||
alerts.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
actor = tractor.current_actor()
|
|
||||||
subactor_name = 'emsd'
|
|
||||||
|
|
||||||
# TODO: add ``maybe_spawn_emsd()`` for this
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
|
|
||||||
portal = await n.start_actor(
|
|
||||||
subactor_name,
|
|
||||||
enable_modules=[__name__],
|
|
||||||
)
|
|
||||||
stream = await portal.run(
|
|
||||||
stream_and_route,
|
|
||||||
ui_name=actor.name
|
|
||||||
)
|
|
||||||
|
|
||||||
async with tractor.wait_for_actor(subactor_name):
|
|
||||||
# let parent task continue
|
|
||||||
task_status.started(_to_ems)
|
|
||||||
|
|
||||||
# begin the trigger-alert stream
|
|
||||||
# this is where we receive **back** messages
|
|
||||||
# about executions **from** the EMS actor
|
|
||||||
async for msg in stream:
|
|
||||||
|
|
||||||
# delete the line from view
|
|
||||||
oid = msg['oid']
|
|
||||||
resp = msg['msg']
|
|
||||||
|
|
||||||
if resp in ('active',):
|
|
||||||
print(f"order accepted: {msg}")
|
|
||||||
|
|
||||||
# show line label once order is live
|
|
||||||
order_mode.lines.commit_line(oid)
|
|
||||||
|
|
||||||
continue
|
|
||||||
|
|
||||||
elif resp in ('cancelled',):
|
|
||||||
|
|
||||||
# delete level from view
|
|
||||||
order_mode.lines.remove_line(uuid=oid)
|
|
||||||
print(f'deleting line with oid: {oid}')
|
|
||||||
|
|
||||||
elif resp in ('executed',):
|
|
||||||
|
|
||||||
order_mode.lines.remove_line(uuid=oid)
|
|
||||||
print(f'deleting line with oid: {oid}')
|
|
||||||
|
|
||||||
order_mode.arrows.add(
|
|
||||||
oid,
|
|
||||||
msg['index'],
|
|
||||||
msg['price'],
|
|
||||||
pointing='up' if msg['name'] == 'up' else 'down'
|
|
||||||
)
|
|
||||||
|
|
||||||
# DESKTOP NOTIFICATIONS
|
|
||||||
#
|
|
||||||
# TODO: this in another task?
|
|
||||||
# not sure if this will ever be a bottleneck,
|
|
||||||
# we probably could do graphics stuff first tho?
|
|
||||||
|
|
||||||
# XXX: linux only for now
|
|
||||||
result = await trio.run_process(
|
|
||||||
[
|
|
||||||
'notify-send',
|
|
||||||
'-u', 'normal',
|
|
||||||
'-t', '10000',
|
|
||||||
'piker',
|
|
||||||
f'alert: {msg}',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
log.runtime(result)
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -16,7 +16,9 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Actor-aware broker agnostic interface.
|
Actor-aware broker agnostic interface.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from typing import Dict
|
||||||
from contextlib import asynccontextmanager, AsyncExitStack
|
from contextlib import asynccontextmanager, AsyncExitStack
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
|
@ -28,6 +30,7 @@ from ..log import get_logger
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_cache: Dict[str, 'Client'] = {}
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def get_cached_client(
|
async def get_cached_client(
|
||||||
|
@ -39,29 +42,40 @@ async def get_cached_client(
|
||||||
|
|
||||||
If one has not been setup do it and cache it.
|
If one has not been setup do it and cache it.
|
||||||
"""
|
"""
|
||||||
# check if a cached client is in the local actor's statespace
|
global _cache
|
||||||
ss = tractor.current_actor().statespace
|
|
||||||
clients = ss.setdefault('clients', {'_lock': trio.Lock()})
|
clients = _cache.setdefault('clients', {'_lock': trio.Lock()})
|
||||||
|
|
||||||
|
# global cache task lock
|
||||||
lock = clients['_lock']
|
lock = clients['_lock']
|
||||||
|
|
||||||
client = None
|
client = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log.info(f"Loading existing `{brokername}` daemon")
|
log.info(f"Loading existing `{brokername}` client")
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
client = clients[brokername]
|
client = clients[brokername]
|
||||||
client._consumers += 1
|
client._consumers += 1
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
log.info(f"Creating new client for broker {brokername}")
|
log.info(f"Creating new client for broker {brokername}")
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
brokermod = get_brokermod(brokername)
|
brokermod = get_brokermod(brokername)
|
||||||
exit_stack = AsyncExitStack()
|
exit_stack = AsyncExitStack()
|
||||||
|
|
||||||
client = await exit_stack.enter_async_context(
|
client = await exit_stack.enter_async_context(
|
||||||
brokermod.get_client()
|
brokermod.get_client()
|
||||||
)
|
)
|
||||||
client._consumers = 0
|
client._consumers = 0
|
||||||
client._exit_stack = exit_stack
|
client._exit_stack = exit_stack
|
||||||
clients[brokername] = client
|
clients[brokername] = client
|
||||||
|
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
client._consumers -= 1
|
client._consumers -= 1
|
||||||
if client._consumers <= 0:
|
if client._consumers <= 0:
|
||||||
|
|
|
@ -180,15 +180,18 @@ async def symbol_data(broker: str, tickers: List[str]):
|
||||||
return await feed.client.symbol_info(tickers)
|
return await feed.client.symbol_info(tickers)
|
||||||
|
|
||||||
|
|
||||||
|
_feeds_cache = {}
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def get_cached_feed(
|
async def get_cached_feed(
|
||||||
brokername: str,
|
brokername: str,
|
||||||
) -> BrokerFeed:
|
) -> BrokerFeed:
|
||||||
"""Get/create a ``BrokerFeed`` from/in the current actor.
|
"""Get/create a ``BrokerFeed`` from/in the current actor.
|
||||||
"""
|
"""
|
||||||
# check if a cached client is in the local actor's statespace
|
global _feeds_cache
|
||||||
ss = tractor.current_actor().statespace
|
|
||||||
feeds = ss.setdefault('feeds', {'_lock': trio.Lock()})
|
# check if a cached feed is in the local actor
|
||||||
|
feeds = _feeds_cache.setdefault('feeds', {'_lock': trio.Lock()})
|
||||||
lock = feeds['_lock']
|
lock = feeds['_lock']
|
||||||
feed = None
|
feed = None
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -23,37 +23,38 @@ built on it) and thus actor aware API calls must be spawned with
|
||||||
"""
|
"""
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from functools import partial
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from functools import partial
|
||||||
from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable
|
from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
from pprint import pformat
|
||||||
import inspect
|
import inspect
|
||||||
import itertools
|
import itertools
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
from ib_insync.wrapper import RequestError
|
from ib_insync.wrapper import RequestError
|
||||||
from ib_insync.contract import Contract, ContractDetails
|
from ib_insync.contract import Contract, ContractDetails
|
||||||
|
from ib_insync.order import Order
|
||||||
from ib_insync.ticker import Ticker
|
from ib_insync.ticker import Ticker
|
||||||
|
from ib_insync.objects import Position
|
||||||
import ib_insync as ibis
|
import ib_insync as ibis
|
||||||
from ib_insync.wrapper import Wrapper
|
from ib_insync.wrapper import Wrapper
|
||||||
from ib_insync.client import Client as ib_Client
|
from ib_insync.client import Client as ib_Client
|
||||||
import trio
|
|
||||||
import tractor
|
|
||||||
|
|
||||||
from ..log import get_logger, get_console_log
|
from ..log import get_logger, get_console_log
|
||||||
from ..data import (
|
from ..data import (
|
||||||
maybe_spawn_brokerd,
|
maybe_spawn_brokerd,
|
||||||
iterticks,
|
iterticks,
|
||||||
attach_shm_array,
|
attach_shm_array,
|
||||||
# get_shm_token,
|
|
||||||
subscribe_ohlc_for_increment,
|
subscribe_ohlc_for_increment,
|
||||||
_buffer,
|
_buffer,
|
||||||
)
|
)
|
||||||
from ..data._source import from_df
|
from ..data._source import from_df
|
||||||
from ._util import SymbolNotFound
|
from ._util import SymbolNotFound
|
||||||
from .._async_utils import maybe_with_if
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
@ -100,8 +101,9 @@ class NonShittyWrapper(Wrapper):
|
||||||
def tcpDataArrived(self):
|
def tcpDataArrived(self):
|
||||||
"""Override time stamps to be floats for now.
|
"""Override time stamps to be floats for now.
|
||||||
"""
|
"""
|
||||||
# use a float to store epoch time instead of datetime
|
# use a ns int to store epoch time instead of datetime
|
||||||
self.lastTime = time.time()
|
self.lastTime = time.time_ns()
|
||||||
|
|
||||||
for ticker in self.pendingTickers:
|
for ticker in self.pendingTickers:
|
||||||
ticker.rtTime = None
|
ticker.rtTime = None
|
||||||
ticker.ticks = []
|
ticker.ticks = []
|
||||||
|
@ -109,6 +111,20 @@ class NonShittyWrapper(Wrapper):
|
||||||
ticker.domTicks = []
|
ticker.domTicks = []
|
||||||
self.pendingTickers = set()
|
self.pendingTickers = set()
|
||||||
|
|
||||||
|
def execDetails(
|
||||||
|
self,
|
||||||
|
reqId: int,
|
||||||
|
contract: Contract,
|
||||||
|
execu,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get rid of datetime on executions.
|
||||||
|
"""
|
||||||
|
# this is the IB server's execution time supposedly
|
||||||
|
# https://interactivebrokers.github.io/tws-api/classIBApi_1_1Execution.html#a2e05cace0aa52d809654c7248e052ef2
|
||||||
|
execu.time = execu.time.timestamp()
|
||||||
|
return super().execDetails(reqId, contract, execu)
|
||||||
|
|
||||||
|
|
||||||
class NonShittyIB(ibis.IB):
|
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.
|
||||||
|
@ -121,7 +137,7 @@ class NonShittyIB(ibis.IB):
|
||||||
# XXX: just to override this wrapper
|
# XXX: just to override this wrapper
|
||||||
self.wrapper = NonShittyWrapper(self)
|
self.wrapper = NonShittyWrapper(self)
|
||||||
self.client = ib_Client(self.wrapper)
|
self.client = ib_Client(self.wrapper)
|
||||||
self.errorEvent += self._onError
|
# self.errorEvent += self._onError
|
||||||
self.client.apiEnd += self.disconnectedEvent
|
self.client.apiEnd += self.disconnectedEvent
|
||||||
self._logger = logging.getLogger('ib_insync.ib')
|
self._logger = logging.getLogger('ib_insync.ib')
|
||||||
|
|
||||||
|
@ -150,6 +166,9 @@ class Client:
|
||||||
self.ib = ib
|
self.ib = ib
|
||||||
self.ib.RaiseRequestErrors = True
|
self.ib.RaiseRequestErrors = True
|
||||||
|
|
||||||
|
# contract cache
|
||||||
|
self._contracts: Dict[str, Contract] = {}
|
||||||
|
|
||||||
# NOTE: the ib.client here is "throttled" to 45 rps by default
|
# NOTE: the ib.client here is "throttled" to 45 rps by default
|
||||||
|
|
||||||
async def bars(
|
async def bars(
|
||||||
|
@ -199,7 +218,6 @@ class Client:
|
||||||
|
|
||||||
# barSizeSetting='1 min',
|
# barSizeSetting='1 min',
|
||||||
|
|
||||||
|
|
||||||
# always use extended hours
|
# always use extended hours
|
||||||
useRTH=False,
|
useRTH=False,
|
||||||
|
|
||||||
|
@ -217,9 +235,6 @@ class Client:
|
||||||
df = ibis.util.df(bars)
|
df = ibis.util.df(bars)
|
||||||
return bars, from_df(df)
|
return bars, from_df(df)
|
||||||
|
|
||||||
def onError(self, reqId, errorCode, errorString, contract) -> None:
|
|
||||||
breakpoint()
|
|
||||||
|
|
||||||
async def search_stocks(
|
async def search_stocks(
|
||||||
self,
|
self,
|
||||||
pattern: str,
|
pattern: str,
|
||||||
|
@ -283,6 +298,24 @@ class Client:
|
||||||
currency: str = 'USD',
|
currency: str = 'USD',
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Contract:
|
) -> Contract:
|
||||||
|
|
||||||
|
# TODO: we can't use this currently because
|
||||||
|
# ``wrapper.starTicker()`` currently cashes ticker instances
|
||||||
|
# which means getting a singel quote will potentially look up
|
||||||
|
# a quote for a ticker that it already streaming and thus run
|
||||||
|
# into state clobbering (eg. List: Ticker.ticks). It probably
|
||||||
|
# makes sense to try this once we get the pub-sub working on
|
||||||
|
# individual symbols...
|
||||||
|
|
||||||
|
# XXX UPDATE: we can probably do the tick/trades scraping
|
||||||
|
# inside our eventkit handler instead to bypass this entirely?
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# # give the cache a go
|
||||||
|
# return self._contracts[symbol]
|
||||||
|
# except KeyError:
|
||||||
|
# log.debug(f'Looking up contract for {symbol}')
|
||||||
|
|
||||||
# use heuristics to figure out contract "type"
|
# use heuristics to figure out contract "type"
|
||||||
try:
|
try:
|
||||||
sym, exch = symbol.upper().rsplit('.', maxsplit=1)
|
sym, exch = symbol.upper().rsplit('.', maxsplit=1)
|
||||||
|
@ -331,11 +364,10 @@ class Client:
|
||||||
exch = 'SMART' if not exch else exch
|
exch = 'SMART' if not exch else exch
|
||||||
contract = (await self.ib.qualifyContractsAsync(con))[0]
|
contract = (await self.ib.qualifyContractsAsync(con))[0]
|
||||||
|
|
||||||
# head = await self.get_head_time(contract)
|
|
||||||
# print(head)
|
|
||||||
|
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise ValueError(f"No contract could be found {con}")
|
raise ValueError(f"No contract could be found {con}")
|
||||||
|
|
||||||
|
self._contracts[symbol] = contract
|
||||||
return contract
|
return contract
|
||||||
|
|
||||||
async def get_head_time(
|
async def get_head_time(
|
||||||
|
@ -356,9 +388,8 @@ class Client:
|
||||||
self,
|
self,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
to_trio,
|
to_trio,
|
||||||
opts: Tuple[int] = ('375', '233',),
|
opts: Tuple[int] = ('375', '233', '236'),
|
||||||
contract: Optional[Contract] = None,
|
contract: Optional[Contract] = None,
|
||||||
# opts: Tuple[int] = ('459',),
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stream a ticker using the std L1 api.
|
"""Stream a ticker using the std L1 api.
|
||||||
"""
|
"""
|
||||||
|
@ -397,11 +428,174 @@ class Client:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
contract = await self.find_contract(symbol)
|
contract = await self.find_contract(symbol)
|
||||||
|
|
||||||
|
details_fute = self.ib.reqContractDetailsAsync(contract)
|
||||||
ticker: Ticker = self.ib.reqMktData(
|
ticker: Ticker = self.ib.reqMktData(
|
||||||
contract,
|
contract,
|
||||||
snapshot=True,
|
snapshot=True,
|
||||||
)
|
)
|
||||||
return contract, (await ticker.updateEvent)
|
ticker = await ticker.updateEvent
|
||||||
|
details = (await details_fute)[0]
|
||||||
|
return contract, ticker, details
|
||||||
|
|
||||||
|
# async to be consistent for the client proxy, and cuz why not.
|
||||||
|
async def submit_limit(
|
||||||
|
self,
|
||||||
|
# ignored since ib doesn't support defining your
|
||||||
|
# own order id
|
||||||
|
oid: str,
|
||||||
|
symbol: str,
|
||||||
|
price: float,
|
||||||
|
action: str,
|
||||||
|
size: int,
|
||||||
|
|
||||||
|
# XXX: by default 0 tells ``ib_insync`` methods that there is no
|
||||||
|
# existing order so ask the client to create a new one (which it
|
||||||
|
# seems to do by allocating an int counter - collision prone..)
|
||||||
|
brid: int = None,
|
||||||
|
) -> int:
|
||||||
|
"""Place an order and return integer request id provided by client.
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
contract = self._contracts[symbol]
|
||||||
|
except KeyError:
|
||||||
|
# require that the symbol has been previously cached by
|
||||||
|
# a data feed request - ensure we aren't making orders
|
||||||
|
# against non-known prices.
|
||||||
|
raise RuntimeError("Can not order {symbol}, no live feed?")
|
||||||
|
|
||||||
|
trade = self.ib.placeOrder(
|
||||||
|
contract,
|
||||||
|
Order(
|
||||||
|
orderId=brid or 0, # stupid api devs..
|
||||||
|
action=action.upper(), # BUY/SELL
|
||||||
|
orderType='LMT',
|
||||||
|
lmtPrice=price,
|
||||||
|
totalQuantity=size,
|
||||||
|
outsideRth=True,
|
||||||
|
|
||||||
|
optOutSmartRouting=True,
|
||||||
|
routeMarketableToBbo=True,
|
||||||
|
designatedLocation='SMART',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ib doesn't support setting your own id outside
|
||||||
|
# their own weird client int counting ids..
|
||||||
|
return trade.order.orderId
|
||||||
|
|
||||||
|
async def submit_cancel(
|
||||||
|
self,
|
||||||
|
reqid: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send cancel request for order id ``oid``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.ib.cancelOrder(
|
||||||
|
Order(
|
||||||
|
orderId=reqid,
|
||||||
|
clientId=self.ib.client.clientId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def recv_trade_updates(
|
||||||
|
self,
|
||||||
|
to_trio: trio.abc.SendChannel,
|
||||||
|
) -> None:
|
||||||
|
"""Stream a ticker using the std L1 api.
|
||||||
|
"""
|
||||||
|
self.inline_errors(to_trio)
|
||||||
|
|
||||||
|
def push_tradesies(eventkit_obj, obj, fill=None):
|
||||||
|
"""Push events to trio task.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if fill is not None:
|
||||||
|
# execution details event
|
||||||
|
item = ('fill', (obj, fill))
|
||||||
|
|
||||||
|
elif eventkit_obj.name() == 'positionEvent':
|
||||||
|
item = ('position', obj)
|
||||||
|
|
||||||
|
else:
|
||||||
|
item = ('status', obj)
|
||||||
|
|
||||||
|
log.info(f'eventkit event -> {eventkit_obj}: {item}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
to_trio.send_nowait(item)
|
||||||
|
except trio.BrokenResourceError:
|
||||||
|
log.exception(f'Disconnected from {eventkit_obj} updates')
|
||||||
|
eventkit_obj.disconnect(push_tradesies)
|
||||||
|
|
||||||
|
# hook up to the weird eventkit object - event stream api
|
||||||
|
for ev_name in [
|
||||||
|
'orderStatusEvent', # all order updates
|
||||||
|
'execDetailsEvent', # all "fill" updates
|
||||||
|
'positionEvent', # avg price updates per symbol per account
|
||||||
|
|
||||||
|
# 'commissionReportEvent',
|
||||||
|
# XXX: ugh, it is a separate event from IB and it's
|
||||||
|
# emitted as follows:
|
||||||
|
# self.ib.commissionReportEvent.emit(trade, fill, report)
|
||||||
|
|
||||||
|
# XXX: not sure yet if we need these
|
||||||
|
# 'updatePortfolioEvent',
|
||||||
|
|
||||||
|
# XXX: these all seem to be weird ib_insync intrernal
|
||||||
|
# events that we probably don't care that much about
|
||||||
|
# given the internal design is wonky af..
|
||||||
|
# 'newOrderEvent',
|
||||||
|
# 'orderModifyEvent',
|
||||||
|
# 'cancelOrderEvent',
|
||||||
|
# 'openOrderEvent',
|
||||||
|
]:
|
||||||
|
eventkit_obj = getattr(self.ib, ev_name)
|
||||||
|
handler = partial(push_tradesies, eventkit_obj)
|
||||||
|
eventkit_obj.connect(handler)
|
||||||
|
|
||||||
|
# let the engine run and stream
|
||||||
|
await self.ib.disconnectedEvent
|
||||||
|
|
||||||
|
def inline_errors(
|
||||||
|
self,
|
||||||
|
to_trio: trio.abc.SendChannel,
|
||||||
|
) -> None:
|
||||||
|
# connect error msgs
|
||||||
|
def push_err(
|
||||||
|
reqId: int,
|
||||||
|
errorCode: int,
|
||||||
|
errorString: str,
|
||||||
|
contract: Contract,
|
||||||
|
) -> None:
|
||||||
|
log.error(errorString)
|
||||||
|
try:
|
||||||
|
to_trio.send_nowait((
|
||||||
|
'error',
|
||||||
|
# error "object"
|
||||||
|
{'reqid': reqId,
|
||||||
|
'message': errorString,
|
||||||
|
'contract': contract}
|
||||||
|
))
|
||||||
|
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('Disconnected from errorEvent updates')
|
||||||
|
self.ib.errorEvent.disconnect(push_err)
|
||||||
|
|
||||||
|
self.ib.errorEvent.connect(push_err)
|
||||||
|
|
||||||
|
async def positions(
|
||||||
|
self,
|
||||||
|
account: str = '',
|
||||||
|
) -> List[Position]:
|
||||||
|
"""
|
||||||
|
Retrieve position info for ``account``.
|
||||||
|
"""
|
||||||
|
return self.ib.positions(account=account)
|
||||||
|
|
||||||
|
|
||||||
# default config ports
|
# default config ports
|
||||||
|
@ -422,7 +616,6 @@ async def _aio_get_client(
|
||||||
"""
|
"""
|
||||||
# first check cache for existing client
|
# first check cache for existing client
|
||||||
|
|
||||||
# breakpoint()
|
|
||||||
try:
|
try:
|
||||||
if port:
|
if port:
|
||||||
client = _client_cache[(host, port)]
|
client = _client_cache[(host, port)]
|
||||||
|
@ -456,11 +649,13 @@ async def _aio_get_client(
|
||||||
else:
|
else:
|
||||||
raise ConnectionRefusedError(_err)
|
raise ConnectionRefusedError(_err)
|
||||||
|
|
||||||
|
# create and cache
|
||||||
try:
|
try:
|
||||||
client = Client(ib)
|
client = Client(ib)
|
||||||
_client_cache[(host, port)] = client
|
_client_cache[(host, port)] = client
|
||||||
log.debug(f"Caching client for {(host, port)}")
|
log.debug(f"Caching client for {(host, port)}")
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
ib.disconnect()
|
ib.disconnect()
|
||||||
raise
|
raise
|
||||||
|
@ -527,14 +722,13 @@ class _MethodProxy:
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Any:
|
) -> Any:
|
||||||
return await self._portal.run(
|
return await self._portal.run(
|
||||||
__name__,
|
_trio_run_client_method,
|
||||||
'_trio_run_client_method',
|
|
||||||
method=meth,
|
method=meth,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_method_proxy(portal, target) -> _MethodProxy:
|
def get_client_proxy(portal, target=Client) -> _MethodProxy:
|
||||||
|
|
||||||
proxy = _MethodProxy(portal)
|
proxy = _MethodProxy(portal)
|
||||||
|
|
||||||
|
@ -558,11 +752,11 @@ async def get_client(
|
||||||
"""
|
"""
|
||||||
async with maybe_spawn_brokerd(
|
async with maybe_spawn_brokerd(
|
||||||
brokername='ib',
|
brokername='ib',
|
||||||
expose_mods=[__name__],
|
|
||||||
infect_asyncio=True,
|
infect_asyncio=True,
|
||||||
**kwargs
|
**kwargs
|
||||||
) as portal:
|
) as portal:
|
||||||
yield get_method_proxy(portal, Client)
|
proxy_client = get_client_proxy(portal)
|
||||||
|
yield proxy_client
|
||||||
|
|
||||||
|
|
||||||
# https://interactivebrokers.github.io/tws-api/tick_types.html
|
# https://interactivebrokers.github.io/tws-api/tick_types.html
|
||||||
|
@ -586,6 +780,7 @@ def normalize(
|
||||||
# convert named tuples to dicts so we send usable keys
|
# convert named tuples to dicts so we send usable keys
|
||||||
new_ticks = []
|
new_ticks = []
|
||||||
for tick in ticker.ticks:
|
for tick in ticker.ticks:
|
||||||
|
if tick:
|
||||||
td = tick._asdict()
|
td = tick._asdict()
|
||||||
td['type'] = tick_types.get(td['tickType'], 'n/a')
|
td['type'] = tick_types.get(td['tickType'], 'n/a')
|
||||||
|
|
||||||
|
@ -645,7 +840,7 @@ async def fill_bars(
|
||||||
first_bars: list,
|
first_bars: list,
|
||||||
shm: 'ShmArray', # type: ignore # noqa
|
shm: 'ShmArray', # type: ignore # noqa
|
||||||
# count: int = 20, # NOTE: any more and we'll overrun underlying buffer
|
# count: int = 20, # NOTE: any more and we'll overrun underlying buffer
|
||||||
count: int = 2, # NOTE: any more and we'll overrun the underlying buffer
|
count: int = 6, # NOTE: any more and we'll overrun the underlying buffer
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fill historical bars into shared mem / storage afap.
|
"""Fill historical bars into shared mem / storage afap.
|
||||||
|
|
||||||
|
@ -663,8 +858,8 @@ async def fill_bars(
|
||||||
method='bars',
|
method='bars',
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
end_dt=next_dt,
|
end_dt=next_dt,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
shm.push(bars_array, prepend=True)
|
shm.push(bars_array, prepend=True)
|
||||||
i += 1
|
i += 1
|
||||||
next_dt = bars[0].date
|
next_dt = bars[0].date
|
||||||
|
@ -690,6 +885,25 @@ async def fill_bars(
|
||||||
await tractor.breakpoint()
|
await tractor.breakpoint()
|
||||||
|
|
||||||
|
|
||||||
|
asset_type_map = {
|
||||||
|
'STK': 'stock',
|
||||||
|
'OPT': 'option',
|
||||||
|
'FUT': 'future',
|
||||||
|
'CONTFUT': 'continuous_future',
|
||||||
|
'CASH': 'forex',
|
||||||
|
'IND': 'index',
|
||||||
|
'CFD': 'cfd',
|
||||||
|
'BOND': 'bond',
|
||||||
|
'CMDTY': 'commodity',
|
||||||
|
'FOP': 'futures_option',
|
||||||
|
'FUND': 'mutual_fund',
|
||||||
|
'WAR': 'warrant',
|
||||||
|
'IOPT': 'warran',
|
||||||
|
'BAG': 'bag',
|
||||||
|
# 'NEWS': 'news',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# TODO: figure out how to share quote feeds sanely despite
|
# TODO: figure out how to share quote feeds sanely despite
|
||||||
# the wacky ``ib_insync`` api.
|
# the wacky ``ib_insync`` api.
|
||||||
# @tractor.msg.pub
|
# @tractor.msg.pub
|
||||||
|
@ -699,6 +913,7 @@ async def stream_quotes(
|
||||||
symbols: List[str],
|
symbols: List[str],
|
||||||
shm_token: Tuple[str, str, List[tuple]],
|
shm_token: Tuple[str, str, List[tuple]],
|
||||||
loglevel: str = None,
|
loglevel: str = None,
|
||||||
|
|
||||||
# compat for @tractor.msg.pub
|
# compat for @tractor.msg.pub
|
||||||
topics: Any = None,
|
topics: Any = None,
|
||||||
get_topics: Callable = None,
|
get_topics: Callable = None,
|
||||||
|
@ -714,7 +929,7 @@ async def stream_quotes(
|
||||||
# TODO: support multiple subscriptions
|
# TODO: support multiple subscriptions
|
||||||
sym = symbols[0]
|
sym = symbols[0]
|
||||||
|
|
||||||
contract, first_ticker = await _trio_run_client_method(
|
contract, first_ticker, details = await _trio_run_client_method(
|
||||||
method='get_quote',
|
method='get_quote',
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
)
|
)
|
||||||
|
@ -725,8 +940,8 @@ async def stream_quotes(
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with aclosing(stream):
|
shm = None
|
||||||
|
async with trio.open_nursery() as ln:
|
||||||
# check if a writer already is alive in a streaming task,
|
# check if a writer already is alive in a streaming task,
|
||||||
# otherwise start one and mark it as now existing
|
# otherwise start one and mark it as now existing
|
||||||
|
|
||||||
|
@ -737,7 +952,6 @@ async def stream_quotes(
|
||||||
# maybe load historical ohlcv in to shared mem
|
# maybe load historical ohlcv in to shared mem
|
||||||
# check if shm has already been created by previous
|
# check if shm has already been created by previous
|
||||||
# feed initialization
|
# feed initialization
|
||||||
async with trio.open_nursery() as ln:
|
|
||||||
if not writer_already_exists:
|
if not writer_already_exists:
|
||||||
_local_buffer_writers[key] = True
|
_local_buffer_writers[key] = True
|
||||||
|
|
||||||
|
@ -774,9 +988,35 @@ async def stream_quotes(
|
||||||
delay_s = times[-1] - times[times != times[-1]][-1]
|
delay_s = times[-1] - times[times != times[-1]][-1]
|
||||||
subscribe_ohlc_for_increment(shm, delay_s)
|
subscribe_ohlc_for_increment(shm, delay_s)
|
||||||
|
|
||||||
|
# pass back some symbol info like min_tick, trading_hours, etc.
|
||||||
|
# con = asdict(contract)
|
||||||
|
# syminfo = contract
|
||||||
|
syminfo = asdict(details)
|
||||||
|
syminfo.update(syminfo['contract'])
|
||||||
|
|
||||||
|
# TODO: more consistent field translation
|
||||||
|
atype = syminfo['asset_type'] = asset_type_map[syminfo['secType']]
|
||||||
|
|
||||||
|
# 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 "traditional" assets, volume is normally discreet, not a float
|
||||||
|
syminfo['lot_tick_size'] = 0.0
|
||||||
|
|
||||||
|
# TODO: for loop through all symbols passed in
|
||||||
|
init_msgs = {
|
||||||
# pass back token, and bool, signalling if we're the writer
|
# pass back token, and bool, signalling if we're the writer
|
||||||
# and that history has been written
|
# and that history has been written
|
||||||
await ctx.send_yield((shm_token, not writer_already_exists))
|
sym: {
|
||||||
|
'is_shm_writer': not writer_already_exists,
|
||||||
|
'shm_token': shm_token,
|
||||||
|
'symbol_info': syminfo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ctx.send_yield(init_msgs)
|
||||||
|
|
||||||
# check for special contract types
|
# check for special contract types
|
||||||
if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex):
|
if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex):
|
||||||
|
@ -788,7 +1028,7 @@ async def stream_quotes(
|
||||||
# no real volume so we have to calculate the price
|
# no real volume so we have to calculate the price
|
||||||
suffix = 'secType'
|
suffix = 'secType'
|
||||||
calc_price = True
|
calc_price = True
|
||||||
ticker = first_ticker
|
# ticker = first_ticker
|
||||||
|
|
||||||
# pass first quote asap
|
# pass first quote asap
|
||||||
quote = normalize(first_ticker, calc_price=calc_price)
|
quote = normalize(first_ticker, calc_price=calc_price)
|
||||||
|
@ -814,7 +1054,10 @@ async def stream_quotes(
|
||||||
|
|
||||||
calc_price = False # should be real volume for contract
|
calc_price = False # should be real volume for contract
|
||||||
|
|
||||||
|
# with trio.move_on_after(10) as cs:
|
||||||
# wait for real volume on feed (trading might be closed)
|
# wait for real volume on feed (trading might be closed)
|
||||||
|
|
||||||
|
async with aclosing(stream):
|
||||||
async for ticker in stream:
|
async for ticker in stream:
|
||||||
|
|
||||||
# for a real volume contract we rait for the first
|
# for a real volume contract we rait for the first
|
||||||
|
@ -836,6 +1079,34 @@ async def stream_quotes(
|
||||||
# ``aclosing()`` above?
|
# ``aclosing()`` above?
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# enter stream loop
|
||||||
|
try:
|
||||||
|
await stream_and_write(
|
||||||
|
stream=stream,
|
||||||
|
calc_price=calc_price,
|
||||||
|
topic=topic,
|
||||||
|
writer_already_exists=writer_already_exists,
|
||||||
|
shm=shm,
|
||||||
|
suffix=suffix,
|
||||||
|
ctx=ctx,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if not writer_already_exists:
|
||||||
|
_local_buffer_writers[key] = False
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_and_write(
|
||||||
|
stream,
|
||||||
|
calc_price: bool,
|
||||||
|
topic: str,
|
||||||
|
writer_already_exists: bool,
|
||||||
|
suffix: str,
|
||||||
|
ctx: tractor.Context,
|
||||||
|
shm: Optional['SharedArray'], # noqa
|
||||||
|
) -> None:
|
||||||
|
"""Core quote streaming and shm writing loop; optimize for speed!
|
||||||
|
|
||||||
|
"""
|
||||||
# real-time stream
|
# real-time stream
|
||||||
async for ticker in stream:
|
async for ticker in stream:
|
||||||
|
|
||||||
|
@ -895,3 +1166,113 @@ async def stream_quotes(
|
||||||
|
|
||||||
# ugh, clear ticks since we've consumed them
|
# ugh, clear ticks since we've consumed them
|
||||||
ticker.ticks = []
|
ticker.ticks = []
|
||||||
|
|
||||||
|
|
||||||
|
def pack_position(pos: Position) -> Dict[str, Any]:
|
||||||
|
con = pos.contract
|
||||||
|
return {
|
||||||
|
'broker': 'ib',
|
||||||
|
'account': pos.account,
|
||||||
|
'symbol': con.symbol,
|
||||||
|
'currency': con.currency,
|
||||||
|
'size': float(pos.position),
|
||||||
|
'avg_price': float(pos.avgCost) / float(con.multiplier or 1.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.msg.pub(
|
||||||
|
send_on_connect={'local_trades': 'start'}
|
||||||
|
)
|
||||||
|
async def stream_trades(
|
||||||
|
loglevel: str = None,
|
||||||
|
get_topics: Callable = None,
|
||||||
|
) -> AsyncIterator[Dict[str, Any]]:
|
||||||
|
|
||||||
|
# XXX: required to propagate ``tractor`` loglevel to piker logging
|
||||||
|
get_console_log(loglevel or tractor.current_actor().loglevel)
|
||||||
|
|
||||||
|
stream = await _trio_run_client_method(
|
||||||
|
method='recv_trade_updates',
|
||||||
|
)
|
||||||
|
|
||||||
|
# deliver positions to subscriber before anything else
|
||||||
|
positions = await _trio_run_client_method(method='positions')
|
||||||
|
for pos in positions:
|
||||||
|
yield {'local_trades': ('position', pack_position(pos))}
|
||||||
|
|
||||||
|
action_map = {'BOT': 'buy', 'SLD': 'sell'}
|
||||||
|
|
||||||
|
async for event_name, item in stream:
|
||||||
|
|
||||||
|
# XXX: begin normalization of nonsense ib_insync internal
|
||||||
|
# object-state tracking representations...
|
||||||
|
|
||||||
|
if event_name == 'status':
|
||||||
|
|
||||||
|
# unwrap needed data from ib_insync internal objects
|
||||||
|
trade = item
|
||||||
|
status = trade.orderStatus
|
||||||
|
|
||||||
|
# skip duplicate filled updates - we get the deats
|
||||||
|
# from the execution details event
|
||||||
|
msg = {
|
||||||
|
'reqid': trade.order.orderId,
|
||||||
|
'status': status.status,
|
||||||
|
'filled': status.filled,
|
||||||
|
'reason': status.whyHeld,
|
||||||
|
|
||||||
|
# this seems to not be necessarily up to date in the
|
||||||
|
# execDetails event.. so we have to send it here I guess?
|
||||||
|
'remaining': status.remaining,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif event_name == 'fill':
|
||||||
|
|
||||||
|
trade, fill = item
|
||||||
|
execu = fill.execution
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
'reqid': execu.orderId,
|
||||||
|
'execid': execu.execId,
|
||||||
|
|
||||||
|
# supposedly IB server fill time
|
||||||
|
'broker_time': execu.time, # converted to float by us
|
||||||
|
# ns from main TCP handler by us inside ``ib_insync`` override
|
||||||
|
'time': fill.time,
|
||||||
|
'time_ns': time.time_ns(), # cuz why not
|
||||||
|
'action': action_map[execu.side],
|
||||||
|
'size': execu.shares,
|
||||||
|
'price': execu.price,
|
||||||
|
}
|
||||||
|
|
||||||
|
elif event_name == 'error':
|
||||||
|
msg = item
|
||||||
|
|
||||||
|
# f$#$% gawd dammit insync..
|
||||||
|
con = msg['contract']
|
||||||
|
if isinstance(con, Contract):
|
||||||
|
msg['contract'] = asdict(con)
|
||||||
|
|
||||||
|
if msg['reqid'] == -1:
|
||||||
|
log.error(pformat(msg))
|
||||||
|
|
||||||
|
# don't forward, it's pointless..
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif event_name == 'position':
|
||||||
|
msg = pack_position(item)
|
||||||
|
|
||||||
|
if msg.get('reqid', 0) < -1:
|
||||||
|
# it's a trade event generated by TWS usage.
|
||||||
|
log.warning(f"TWS triggered trade:\n{pformat(msg)}")
|
||||||
|
|
||||||
|
msg['reqid'] = 'tws-' + str(-1 * msg['reqid'])
|
||||||
|
|
||||||
|
# mark msg as from "external system"
|
||||||
|
# TODO: probably something better then this..
|
||||||
|
msg['external'] = True
|
||||||
|
|
||||||
|
yield {'remote_trades': (event_name, msg)}
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield {'local_trades': (event_name, msg)}
|
||||||
|
|
|
@ -17,19 +17,29 @@
|
||||||
"""
|
"""
|
||||||
Kraken backend.
|
Kraken backend.
|
||||||
"""
|
"""
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager, AsyncExitStack
|
||||||
from dataclasses import dataclass, asdict, field
|
from dataclasses import asdict, field
|
||||||
|
from types import ModuleType
|
||||||
from typing import List, Dict, Any, Tuple, Optional
|
from typing import List, Dict, Any, Tuple, Optional
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import trio_websocket
|
import trio_websocket
|
||||||
from trio_websocket._impl import ConnectionClosed, DisconnectionTimeout
|
from trio_websocket._impl import (
|
||||||
|
ConnectionClosed,
|
||||||
|
DisconnectionTimeout,
|
||||||
|
ConnectionRejected,
|
||||||
|
HandshakeError,
|
||||||
|
ConnectionTimeout,
|
||||||
|
)
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
import asks
|
import asks
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
from pydantic.dataclasses import dataclass
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ._util import resproc, SymbolNotFound, BrokerError
|
from ._util import resproc, SymbolNotFound, BrokerError
|
||||||
from ..log import get_logger, get_console_log
|
from ..log import get_logger, get_console_log
|
||||||
|
@ -68,6 +78,68 @@ ohlc_dtype = np.dtype(_ohlc_dtype)
|
||||||
_show_wap_in_history = True
|
_show_wap_in_history = True
|
||||||
|
|
||||||
|
|
||||||
|
_symbol_info_translation: Dict[str, str] = {
|
||||||
|
'tick_decimals': 'pair_decimals',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# https://www.kraken.com/features/api#get-tradable-pairs
|
||||||
|
class Pair(BaseModel):
|
||||||
|
altname: str # alternate pair name
|
||||||
|
wsname: str # WebSocket pair name (if available)
|
||||||
|
aclass_base: str # asset class of base component
|
||||||
|
base: str # asset id of base component
|
||||||
|
aclass_quote: str # asset class of quote component
|
||||||
|
quote: str # asset id of quote component
|
||||||
|
lot: str # volume lot size
|
||||||
|
|
||||||
|
pair_decimals: int # scaling decimal places for pair
|
||||||
|
lot_decimals: int # scaling decimal places for volume
|
||||||
|
|
||||||
|
# amount to multiply lot volume by to get currency volume
|
||||||
|
lot_multiplier: float
|
||||||
|
|
||||||
|
# array of leverage amounts available when buying
|
||||||
|
leverage_buy: List[int]
|
||||||
|
# array of leverage amounts available when selling
|
||||||
|
leverage_sell: List[int]
|
||||||
|
|
||||||
|
# fee schedule array in [volume, percent fee] tuples
|
||||||
|
fees: List[Tuple[int, float]]
|
||||||
|
|
||||||
|
# maker fee schedule array in [volume, percent fee] tuples (if on
|
||||||
|
# maker/taker)
|
||||||
|
fees_maker: List[Tuple[int, float]]
|
||||||
|
|
||||||
|
fee_volume_currency: str # volume discount currency
|
||||||
|
margin_call: str # margin call level
|
||||||
|
margin_stop: str # stop-out/liquidation margin level
|
||||||
|
ordermin: float # minimum order volume for pair
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OHLC:
|
||||||
|
"""Description of the flattened OHLC quote format.
|
||||||
|
|
||||||
|
For schema details see:
|
||||||
|
https://docs.kraken.com/websockets/#message-ohlc
|
||||||
|
"""
|
||||||
|
chan_id: int # internal kraken id
|
||||||
|
chan_name: str # eg. ohlc-1 (name-interval)
|
||||||
|
pair: str # fx pair
|
||||||
|
time: float # Begin time of interval, in seconds since epoch
|
||||||
|
etime: float # End time of interval, in seconds since epoch
|
||||||
|
open: float # Open price of interval
|
||||||
|
high: float # High price within interval
|
||||||
|
low: float # Low price within interval
|
||||||
|
close: float # Close price of interval
|
||||||
|
vwap: float # Volume weighted average price within interval
|
||||||
|
volume: float # Accumulated volume **within interval**
|
||||||
|
count: int # Number of trades within interval
|
||||||
|
# (sampled) generated tick data
|
||||||
|
ticks: List[Any] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
@ -165,52 +237,27 @@ async def get_client() -> Client:
|
||||||
yield Client()
|
yield Client()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
async def stream_messages(ws):
|
||||||
class OHLC:
|
|
||||||
"""Description of the flattened OHLC quote format.
|
|
||||||
|
|
||||||
For schema details see:
|
|
||||||
https://docs.kraken.com/websockets/#message-ohlc
|
|
||||||
"""
|
|
||||||
chan_id: int # internal kraken id
|
|
||||||
chan_name: str # eg. ohlc-1 (name-interval)
|
|
||||||
pair: str # fx pair
|
|
||||||
time: float # Begin time of interval, in seconds since epoch
|
|
||||||
etime: float # End time of interval, in seconds since epoch
|
|
||||||
open: float # Open price of interval
|
|
||||||
high: float # High price within interval
|
|
||||||
low: float # Low price within interval
|
|
||||||
close: float # Close price of interval
|
|
||||||
vwap: float # Volume weighted average price within interval
|
|
||||||
volume: float # Accumulated volume **within interval**
|
|
||||||
count: int # Number of trades within interval
|
|
||||||
# (sampled) generated tick data
|
|
||||||
ticks: List[Any] = field(default_factory=list)
|
|
||||||
|
|
||||||
# XXX: ugh, super hideous.. needs built-in converters.
|
|
||||||
def __post_init__(self):
|
|
||||||
for f, val in self.__dataclass_fields__.items():
|
|
||||||
if f == 'ticks':
|
|
||||||
continue
|
|
||||||
setattr(self, f, val.type(getattr(self, f)))
|
|
||||||
|
|
||||||
|
|
||||||
async def recv_msg(recv):
|
|
||||||
too_slow_count = last_hb = 0
|
too_slow_count = last_hb = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
with trio.move_on_after(1.5) as cs:
|
|
||||||
msg = await recv()
|
|
||||||
|
|
||||||
# trigger reconnection logic if too slow
|
with trio.move_on_after(5) as cs:
|
||||||
|
msg = await ws.recv_msg()
|
||||||
|
|
||||||
|
# trigger reconnection if heartbeat is laggy
|
||||||
if cs.cancelled_caught:
|
if cs.cancelled_caught:
|
||||||
|
|
||||||
too_slow_count += 1
|
too_slow_count += 1
|
||||||
if too_slow_count > 2:
|
|
||||||
|
if too_slow_count > 10:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Heartbeat is to slow, "
|
"Heartbeat is too slow, resetting ws connection")
|
||||||
"resetting ws connection")
|
|
||||||
raise trio_websocket._impl.ConnectionClosed(
|
await ws._connect()
|
||||||
"Reset Connection")
|
too_slow_count = 0
|
||||||
|
continue
|
||||||
|
|
||||||
if isinstance(msg, dict):
|
if isinstance(msg, dict):
|
||||||
if msg.get('event') == 'heartbeat':
|
if msg.get('event') == 'heartbeat':
|
||||||
|
@ -218,11 +265,11 @@ async def recv_msg(recv):
|
||||||
now = time.time()
|
now = time.time()
|
||||||
delay = now - last_hb
|
delay = now - last_hb
|
||||||
last_hb = now
|
last_hb = now
|
||||||
log.trace(f"Heartbeat after {delay}")
|
|
||||||
|
|
||||||
# TODO: hmm i guess we should use this
|
# XXX: why tf is this not printing without --tl flag?
|
||||||
# for determining when to do connection
|
log.debug(f"Heartbeat after {delay}")
|
||||||
# resets eh?
|
# print(f"Heartbeat after {delay}")
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
err = msg.get('errorMessage')
|
err = msg.get('errorMessage')
|
||||||
|
@ -292,6 +339,93 @@ def make_sub(pairs: List[str], data: Dict[str, Any]) -> Dict[str, str]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AutoReconWs:
|
||||||
|
"""Make ``trio_websocketw` sockets stay up no matter the bs.
|
||||||
|
|
||||||
|
"""
|
||||||
|
recon_errors = (
|
||||||
|
ConnectionClosed,
|
||||||
|
DisconnectionTimeout,
|
||||||
|
ConnectionRejected,
|
||||||
|
HandshakeError,
|
||||||
|
ConnectionTimeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
stack: AsyncExitStack,
|
||||||
|
serializer: ModuleType = json,
|
||||||
|
):
|
||||||
|
self.url = url
|
||||||
|
self._stack = stack
|
||||||
|
self._ws: 'WebSocketConnection' = None # noqa
|
||||||
|
|
||||||
|
async def _connect(
|
||||||
|
self,
|
||||||
|
tries: int = 10000,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await self._stack.aclose()
|
||||||
|
except (DisconnectionTimeout, RuntimeError):
|
||||||
|
await trio.sleep(1)
|
||||||
|
|
||||||
|
last_err = None
|
||||||
|
for i in range(tries):
|
||||||
|
try:
|
||||||
|
self._ws = await self._stack.enter_async_context(
|
||||||
|
trio_websocket.open_websocket_url(self.url)
|
||||||
|
)
|
||||||
|
log.info(f'Connection success: {self.url}')
|
||||||
|
return
|
||||||
|
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(1)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
log.exception('ws connection fail...')
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
async def send_msg(
|
||||||
|
self,
|
||||||
|
data: Any,
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return await self._ws.send_message(json.dumps(data))
|
||||||
|
except self.recon_errors:
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
async def recv_msg(
|
||||||
|
self,
|
||||||
|
) -> Any:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return json.loads(await self._ws.get_message())
|
||||||
|
except self.recon_errors:
|
||||||
|
await self._connect()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def open_autorecon_ws(url):
|
||||||
|
"""Apparently we can QoS for all sorts of reasons..so catch em.
|
||||||
|
|
||||||
|
"""
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
ws = AutoReconWs(url, stack)
|
||||||
|
|
||||||
|
await ws._connect()
|
||||||
|
try:
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
# @tractor.msg.pub
|
# @tractor.msg.pub
|
||||||
async def stream_quotes(
|
async def stream_quotes(
|
||||||
# get_topics: Callable,
|
# get_topics: Callable,
|
||||||
|
@ -312,11 +446,17 @@ async def stream_quotes(
|
||||||
get_console_log(loglevel or tractor.current_actor().loglevel)
|
get_console_log(loglevel or tractor.current_actor().loglevel)
|
||||||
|
|
||||||
ws_pairs = {}
|
ws_pairs = {}
|
||||||
|
sym_infos = {}
|
||||||
async with get_client() as client:
|
async with get_client() as client:
|
||||||
|
|
||||||
# keep client cached for real-time section
|
# keep client cached for real-time section
|
||||||
for sym in symbols:
|
for sym in symbols:
|
||||||
ws_pairs[sym] = (await client.symbol_info(sym))['wsname']
|
si = Pair(**await client.symbol_info(sym)) # validation
|
||||||
|
syminfo = si.dict()
|
||||||
|
syminfo['price_tick_size'] = 1 / 10**si.pair_decimals
|
||||||
|
syminfo['lot_tick_size'] = 1 / 10**si.lot_decimals
|
||||||
|
sym_infos[sym] = syminfo
|
||||||
|
ws_pairs[sym] = si.wsname
|
||||||
|
|
||||||
# maybe load historical ohlcv in to shared mem
|
# maybe load historical ohlcv in to shared mem
|
||||||
# check if shm has already been created by previous
|
# check if shm has already been created by previous
|
||||||
|
@ -340,13 +480,20 @@ async def stream_quotes(
|
||||||
delay_s = times[-1] - times[times != times[-1]][-1]
|
delay_s = times[-1] - times[times != times[-1]][-1]
|
||||||
subscribe_ohlc_for_increment(shm, delay_s)
|
subscribe_ohlc_for_increment(shm, delay_s)
|
||||||
|
|
||||||
yield shm_token, not writer_exists
|
# yield shm_token, not writer_exists
|
||||||
|
init_msgs = {
|
||||||
|
# pass back token, and bool, signalling if we're the writer
|
||||||
|
# and that history has been written
|
||||||
|
symbol: {
|
||||||
|
'is_shm_writer': not writer_exists,
|
||||||
|
'shm_token': shm_token,
|
||||||
|
'symbol_info': sym_infos[sym],
|
||||||
|
}
|
||||||
|
# for sym in symbols
|
||||||
|
}
|
||||||
|
yield init_msgs
|
||||||
|
|
||||||
while True:
|
async with open_autorecon_ws('wss://ws.kraken.com/') as ws:
|
||||||
try:
|
|
||||||
async with trio_websocket.open_websocket_url(
|
|
||||||
'wss://ws.kraken.com/',
|
|
||||||
) as ws:
|
|
||||||
|
|
||||||
# XXX: setup subs
|
# XXX: setup subs
|
||||||
# https://docs.kraken.com/websockets/#message-subscribe
|
# https://docs.kraken.com/websockets/#message-subscribe
|
||||||
|
@ -361,21 +508,19 @@ async def stream_quotes(
|
||||||
# be completely fine to request from a separate task
|
# be completely fine to request from a separate task
|
||||||
# since internally the ws methods appear to be FIFO
|
# since internally the ws methods appear to be FIFO
|
||||||
# locked.
|
# locked.
|
||||||
await ws.send_message(json.dumps(ohlc_sub))
|
await ws.send_msg(ohlc_sub)
|
||||||
|
|
||||||
# trade data (aka L1)
|
# trade data (aka L1)
|
||||||
l1_sub = make_sub(
|
l1_sub = make_sub(
|
||||||
list(ws_pairs.values()),
|
list(ws_pairs.values()),
|
||||||
{'name': 'spread'} # 'depth': 10}
|
{'name': 'spread'} # 'depth': 10}
|
||||||
|
|
||||||
)
|
)
|
||||||
await ws.send_message(json.dumps(l1_sub))
|
|
||||||
|
|
||||||
async def recv():
|
await ws.send_msg(l1_sub)
|
||||||
return json.loads(await ws.get_message())
|
|
||||||
|
|
||||||
# pull a first quote and deliver
|
# pull a first quote and deliver
|
||||||
msg_gen = recv_msg(recv)
|
msg_gen = stream_messages(ws)
|
||||||
|
|
||||||
typ, ohlc_last = await msg_gen.__anext__()
|
typ, ohlc_last = await msg_gen.__anext__()
|
||||||
|
|
||||||
topic, quote = normalize(ohlc_last)
|
topic, quote = normalize(ohlc_last)
|
||||||
|
@ -459,6 +604,3 @@ async def stream_quotes(
|
||||||
# XXX: format required by ``tractor.msg.pub``
|
# XXX: format required by ``tractor.msg.pub``
|
||||||
# requires a ``Dict[topic: str, quote: dict]``
|
# requires a ``Dict[topic: str, quote: dict]``
|
||||||
yield {topic: quote}
|
yield {topic: quote}
|
||||||
|
|
||||||
except (ConnectionClosed, DisconnectionTimeout):
|
|
||||||
log.exception("Good job kraken...reconnecting")
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -21,15 +21,15 @@ We provide tsdb integrations for retrieving
|
||||||
and storing data from your brokers as well as
|
and storing data from your brokers as well as
|
||||||
sharing your feeds with other fellow pikers.
|
sharing your feeds with other fellow pikers.
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import (
|
from typing import (
|
||||||
Dict, List, Any,
|
Dict, Any, Sequence, AsyncIterator, Optional
|
||||||
Sequence, AsyncIterator, Optional
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
from ..brokers import get_brokermod
|
from ..brokers import get_brokermod
|
||||||
|
@ -42,7 +42,7 @@ from ._sharedmem import (
|
||||||
ShmArray,
|
ShmArray,
|
||||||
get_shm_token,
|
get_shm_token,
|
||||||
)
|
)
|
||||||
from ._source import base_iohlc_dtype
|
from ._source import base_iohlc_dtype, Symbol
|
||||||
from ._buffer import (
|
from ._buffer import (
|
||||||
increment_ohlc_buffer,
|
increment_ohlc_buffer,
|
||||||
subscribe_ohlc_for_increment
|
subscribe_ohlc_for_increment
|
||||||
|
@ -87,10 +87,11 @@ _data_mods = [
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def maybe_spawn_brokerd(
|
async def maybe_spawn_brokerd(
|
||||||
brokername: str,
|
brokername: str,
|
||||||
sleep: float = 0.5,
|
|
||||||
loglevel: Optional[str] = None,
|
loglevel: Optional[str] = None,
|
||||||
expose_mods: List = [],
|
|
||||||
**tractor_kwargs,
|
# XXX: you should pretty much never want debug mode
|
||||||
|
# for data daemons when running in production.
|
||||||
|
debug_mode: bool = True,
|
||||||
) -> tractor._portal.Portal:
|
) -> tractor._portal.Portal:
|
||||||
"""If no ``brokerd.{brokername}`` daemon-actor can be found,
|
"""If no ``brokerd.{brokername}`` daemon-actor can be found,
|
||||||
spawn one in a local subactor and return a portal to it.
|
spawn one in a local subactor and return a portal to it.
|
||||||
|
@ -98,11 +99,6 @@ async def maybe_spawn_brokerd(
|
||||||
if loglevel:
|
if loglevel:
|
||||||
get_console_log(loglevel)
|
get_console_log(loglevel)
|
||||||
|
|
||||||
# disable debugger in brokerd?
|
|
||||||
# tractor._state._runtime_vars['_debug_mode'] = False
|
|
||||||
|
|
||||||
tractor_kwargs['loglevel'] = loglevel
|
|
||||||
|
|
||||||
brokermod = get_brokermod(brokername)
|
brokermod = get_brokermod(brokername)
|
||||||
dname = f'brokerd.{brokername}'
|
dname = f'brokerd.{brokername}'
|
||||||
async with tractor.find_actor(dname) as portal:
|
async with tractor.find_actor(dname) as portal:
|
||||||
|
@ -114,18 +110,25 @@ async def maybe_spawn_brokerd(
|
||||||
else: # no daemon has been spawned yet
|
else: # no daemon has been spawned yet
|
||||||
|
|
||||||
log.info(f"Spawning {brokername} broker daemon")
|
log.info(f"Spawning {brokername} broker daemon")
|
||||||
|
|
||||||
|
# retrieve any special config from the broker mod
|
||||||
tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
|
tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
|
||||||
async with tractor.open_nursery() as nursery:
|
|
||||||
|
async with tractor.open_nursery(
|
||||||
|
#debug_mode=debug_mode,
|
||||||
|
) as nursery:
|
||||||
try:
|
try:
|
||||||
# spawn new daemon
|
# spawn new daemon
|
||||||
portal = await nursery.start_actor(
|
portal = await nursery.start_actor(
|
||||||
dname,
|
dname,
|
||||||
enable_modules=_data_mods + [brokermod.__name__],
|
enable_modules=_data_mods + [brokermod.__name__],
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
|
debug_mode=debug_mode,
|
||||||
**tractor_kwargs
|
**tractor_kwargs
|
||||||
)
|
)
|
||||||
async with tractor.wait_for_actor(dname) as portal:
|
async with tractor.wait_for_actor(dname) as portal:
|
||||||
yield portal
|
yield portal
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# client code may block indefinitely so cancel when
|
# client code may block indefinitely so cancel when
|
||||||
# teardown is invoked
|
# teardown is invoked
|
||||||
|
@ -144,9 +147,15 @@ class Feed:
|
||||||
name: str
|
name: str
|
||||||
stream: AsyncIterator[Dict[str, Any]]
|
stream: AsyncIterator[Dict[str, Any]]
|
||||||
shm: ShmArray
|
shm: ShmArray
|
||||||
|
mod: ModuleType
|
||||||
# ticks: ShmArray
|
# ticks: ShmArray
|
||||||
_brokerd_portal: tractor._portal.Portal
|
_brokerd_portal: tractor._portal.Portal
|
||||||
_index_stream: Optional[AsyncIterator[Dict[str, Any]]] = None
|
_index_stream: Optional[AsyncIterator[int]] = None
|
||||||
|
_trade_stream: Optional[AsyncIterator[Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
# cache of symbol info messages received as first message when
|
||||||
|
# a stream startsc.
|
||||||
|
symbols: Dict[str, Symbol] = field(default_factory=dict)
|
||||||
|
|
||||||
async def receive(self) -> dict:
|
async def receive(self) -> dict:
|
||||||
return await self.stream.__anext__()
|
return await self.stream.__anext__()
|
||||||
|
@ -164,6 +173,33 @@ class Feed:
|
||||||
|
|
||||||
return self._index_stream
|
return self._index_stream
|
||||||
|
|
||||||
|
async def recv_trades_data(self) -> AsyncIterator[dict]:
|
||||||
|
|
||||||
|
if not getattr(self.mod, 'stream_trades', False):
|
||||||
|
log.warning(
|
||||||
|
f"{self.mod.name} doesn't have trade data support yet :(")
|
||||||
|
|
||||||
|
if not self._trade_stream:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Can not stream trade data from {self.mod.name}')
|
||||||
|
|
||||||
|
# NOTE: this can be faked by setting a rx chan
|
||||||
|
# using the ``_.set_fake_trades_stream()`` method
|
||||||
|
if self._trade_stream is None:
|
||||||
|
|
||||||
|
self._trade_stream = await self._brokerd_portal.run(
|
||||||
|
|
||||||
|
self.mod.stream_trades,
|
||||||
|
|
||||||
|
# do we need this? -> yes
|
||||||
|
# the broker side must declare this key
|
||||||
|
# in messages, though we could probably use
|
||||||
|
# more then one?
|
||||||
|
topics=['local_trades'],
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._trade_stream
|
||||||
|
|
||||||
|
|
||||||
def sym_to_shm_key(
|
def sym_to_shm_key(
|
||||||
broker: str,
|
broker: str,
|
||||||
|
@ -174,23 +210,26 @@ def sym_to_shm_key(
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def open_feed(
|
async def open_feed(
|
||||||
name: str,
|
brokername: str,
|
||||||
symbols: Sequence[str],
|
symbols: Sequence[str],
|
||||||
loglevel: Optional[str] = None,
|
loglevel: Optional[str] = None,
|
||||||
) -> AsyncIterator[Dict[str, Any]]:
|
) -> AsyncIterator[Dict[str, Any]]:
|
||||||
"""Open a "data feed" which provides streamed real-time quotes.
|
"""Open a "data feed" which provides streamed real-time quotes.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
mod = get_brokermod(name)
|
mod = get_brokermod(brokername)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
mod = get_ingestormod(name)
|
mod = get_ingestormod(brokername)
|
||||||
|
|
||||||
if loglevel is None:
|
if loglevel is None:
|
||||||
loglevel = tractor.current_actor().loglevel
|
loglevel = tractor.current_actor().loglevel
|
||||||
|
|
||||||
|
# TODO: do all!
|
||||||
|
sym = symbols[0]
|
||||||
|
|
||||||
# Attempt to allocate (or attach to) shm array for this broker/symbol
|
# Attempt to allocate (or attach to) shm array for this broker/symbol
|
||||||
shm, opened = maybe_open_shm_array(
|
shm, opened = maybe_open_shm_array(
|
||||||
key=sym_to_shm_key(name, symbols[0]),
|
key=sym_to_shm_key(brokername, sym),
|
||||||
|
|
||||||
# use any broker defined ohlc dtype:
|
# use any broker defined ohlc dtype:
|
||||||
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
|
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
|
||||||
|
@ -200,33 +239,61 @@ async def open_feed(
|
||||||
)
|
)
|
||||||
|
|
||||||
async with maybe_spawn_brokerd(
|
async with maybe_spawn_brokerd(
|
||||||
mod.name,
|
|
||||||
|
brokername,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
|
|
||||||
|
# TODO: add a cli flag for this
|
||||||
|
# debug_mode=False,
|
||||||
|
|
||||||
) as portal:
|
) as portal:
|
||||||
|
|
||||||
stream = await portal.run(
|
stream = await portal.run(
|
||||||
mod.stream_quotes,
|
mod.stream_quotes,
|
||||||
|
|
||||||
|
# TODO: actually handy multiple symbols...
|
||||||
symbols=symbols,
|
symbols=symbols,
|
||||||
|
|
||||||
shm_token=shm.token,
|
shm_token=shm.token,
|
||||||
|
|
||||||
# compat with eventual ``tractor.msg.pub``
|
# compat with eventual ``tractor.msg.pub``
|
||||||
topics=symbols,
|
topics=symbols,
|
||||||
|
loglevel=loglevel,
|
||||||
|
)
|
||||||
|
|
||||||
|
feed = Feed(
|
||||||
|
name=brokername,
|
||||||
|
stream=stream,
|
||||||
|
shm=shm,
|
||||||
|
mod=mod,
|
||||||
|
_brokerd_portal=portal,
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: we can't do this **and** be compate with
|
# TODO: we can't do this **and** be compate with
|
||||||
# ``tractor.msg.pub``, should we maybe just drop this after
|
# ``tractor.msg.pub``, should we maybe just drop this after
|
||||||
# tests are in?
|
# tests are in?
|
||||||
shm_token, is_writer = await stream.receive()
|
init_msg = await stream.receive()
|
||||||
|
|
||||||
|
for sym, data in init_msg.items():
|
||||||
|
|
||||||
|
si = data['symbol_info']
|
||||||
|
|
||||||
|
symbol = Symbol(
|
||||||
|
key=sym,
|
||||||
|
type_key=si.get('asset_type', 'forex'),
|
||||||
|
tick_size=si.get('price_tick_size', 0.01),
|
||||||
|
lot_tick_size=si.get('lot_tick_size', 0.0),
|
||||||
|
)
|
||||||
|
symbol.broker_info[brokername] = si
|
||||||
|
|
||||||
|
feed.symbols[sym] = symbol
|
||||||
|
|
||||||
|
shm_token = data['shm_token']
|
||||||
if opened:
|
if opened:
|
||||||
assert is_writer
|
assert data['is_shm_writer']
|
||||||
log.info("Started shared mem bar writer")
|
log.info("Started shared mem bar writer")
|
||||||
|
|
||||||
shm_token['dtype_descr'] = list(shm_token['dtype_descr'])
|
shm_token['dtype_descr'] = list(shm_token['dtype_descr'])
|
||||||
assert shm_token == shm.token # sanity
|
assert shm_token == shm.token # sanity
|
||||||
|
|
||||||
yield Feed(
|
yield feed
|
||||||
name=name,
|
|
||||||
stream=stream,
|
|
||||||
shm=shm,
|
|
||||||
_brokerd_portal=portal,
|
|
||||||
)
|
|
||||||
|
|
|
@ -65,14 +65,6 @@ async def increment_ohlc_buffer(
|
||||||
# adjust delay to compensate for trio processing time
|
# adjust delay to compensate for trio processing time
|
||||||
ad = min(_shms.keys()) - 0.001
|
ad = min(_shms.keys()) - 0.001
|
||||||
|
|
||||||
# async def sleep():
|
|
||||||
# """Sleep until next time frames worth has passed from last bar.
|
|
||||||
# """
|
|
||||||
# # last_ts = shm.array[-1]['time']
|
|
||||||
# # delay = max((last_ts + ad) - time.time(), 0)
|
|
||||||
# # await trio.sleep(delay)
|
|
||||||
# await trio.sleep(ad)
|
|
||||||
|
|
||||||
total_s = 0 # total seconds counted
|
total_s = 0 # total seconds counted
|
||||||
lowest = min(_shms.keys())
|
lowest = min(_shms.keys())
|
||||||
ad = lowest - 0.001
|
ad = lowest - 0.001
|
||||||
|
@ -83,9 +75,6 @@ async def increment_ohlc_buffer(
|
||||||
await trio.sleep(ad)
|
await trio.sleep(ad)
|
||||||
total_s += lowest
|
total_s += lowest
|
||||||
|
|
||||||
# # sleep for duration of current bar
|
|
||||||
# await sleep()
|
|
||||||
|
|
||||||
# increment all subscribed shm arrays
|
# increment all subscribed shm arrays
|
||||||
# TODO: this in ``numba``
|
# TODO: this in ``numba``
|
||||||
for delay_s, shms in _shms.items():
|
for delay_s, shms in _shms.items():
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
|
|
@ -17,12 +17,12 @@
|
||||||
"""
|
"""
|
||||||
numpy data source coversion helpers.
|
numpy data source coversion helpers.
|
||||||
"""
|
"""
|
||||||
from typing import List
|
from typing import Dict, Any, List
|
||||||
import decimal
|
import decimal
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
from pydantic import BaseModel
|
||||||
# from numba import from_dtype
|
# from numba import from_dtype
|
||||||
|
|
||||||
|
|
||||||
|
@ -75,23 +75,41 @@ def ohlc_zeros(length: int) -> np.ndarray:
|
||||||
return np.zeros(length, dtype=base_ohlc_dtype)
|
return np.zeros(length, dtype=base_ohlc_dtype)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class Symbol(BaseModel):
|
||||||
class Symbol:
|
|
||||||
"""I guess this is some kinda container thing for dealing with
|
"""I guess this is some kinda container thing for dealing with
|
||||||
all the different meta-data formats from brokers?
|
all the different meta-data formats from brokers?
|
||||||
|
|
||||||
|
Yah, i guess dats what it izz.
|
||||||
"""
|
"""
|
||||||
key: str = ''
|
key: str
|
||||||
brokers: List[str] = None
|
tick_size: float = 0.01
|
||||||
min_tick: float = 0.01
|
lot_tick_size: float = 0.01 # "volume" precision as min step value
|
||||||
contract: str = ''
|
broker_info: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# specifies a "class" of financial instrument
|
||||||
|
# ex. stock, futer, option, bond etc.
|
||||||
|
type_key: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brokers(self) -> List[str]:
|
||||||
|
return list(self.broker_info.keys())
|
||||||
|
|
||||||
def digits(self) -> int:
|
def digits(self) -> int:
|
||||||
"""Return the trailing number of digits specified by the
|
"""Return the trailing number of digits specified by the min
|
||||||
min tick size for the instrument.
|
tick size for the instrument.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return float_digits(self.min_tick)
|
return float_digits(self.tick_size)
|
||||||
|
|
||||||
|
def lot_digits(self) -> int:
|
||||||
|
return float_digits(self.lot_tick_size)
|
||||||
|
|
||||||
|
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 from_df(
|
def from_df(
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Market machinery for order executions, book, management.
|
||||||
|
|
||||||
|
"""
|
|
@ -0,0 +1,250 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Orders and execution client API.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Dict, Tuple, List
|
||||||
|
from pprint import pformat
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
# import msgspec
|
||||||
|
|
||||||
|
from ..data._source import Symbol
|
||||||
|
from ..log import get_logger
|
||||||
|
from ._ems import _emsd_main
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# class Order(msgspec.Struct):
|
||||||
|
# action: str
|
||||||
|
# price: float
|
||||||
|
# size: float
|
||||||
|
# symbol: str
|
||||||
|
# brokers: List[str]
|
||||||
|
# oid: str
|
||||||
|
# exec_mode: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrderBook:
|
||||||
|
"""Buy-side (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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# mem channels used to relay order requests to the EMS daemon
|
||||||
|
_to_ems: trio.abc.SendChannel
|
||||||
|
_from_order_book: trio.abc.ReceiveChannel
|
||||||
|
|
||||||
|
_sent_orders: Dict[str, dict] = field(default_factory=dict)
|
||||||
|
_ready_to_receive: trio.Event = trio.Event()
|
||||||
|
|
||||||
|
def send(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
symbol: str,
|
||||||
|
brokers: List[str],
|
||||||
|
price: float,
|
||||||
|
size: float,
|
||||||
|
action: str,
|
||||||
|
exec_mode: str,
|
||||||
|
) -> dict:
|
||||||
|
cmd = {
|
||||||
|
'action': action,
|
||||||
|
'price': price,
|
||||||
|
'size': size,
|
||||||
|
'symbol': symbol,
|
||||||
|
'brokers': brokers,
|
||||||
|
'oid': uuid,
|
||||||
|
'exec_mode': exec_mode, # dark or live
|
||||||
|
}
|
||||||
|
self._sent_orders[uuid] = cmd
|
||||||
|
self._to_ems.send_nowait(cmd)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
**data: dict,
|
||||||
|
) -> dict:
|
||||||
|
cmd = self._sent_orders[uuid]
|
||||||
|
cmd.update(data)
|
||||||
|
self._sent_orders[uuid] = cmd
|
||||||
|
self._to_ems.send_nowait(cmd)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def cancel(self, uuid: str) -> bool:
|
||||||
|
"""Cancel an order (or alert) from the EMS.
|
||||||
|
|
||||||
|
"""
|
||||||
|
cmd = self._sent_orders[uuid]
|
||||||
|
msg = {
|
||||||
|
'action': '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:
|
||||||
|
# setup local ui event streaming channels for request/resp
|
||||||
|
# streamging with EMS daemon
|
||||||
|
_orders = OrderBook(*trio.open_memory_channel(1))
|
||||||
|
|
||||||
|
return _orders
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: make this a ``tractor.msg.pub``
|
||||||
|
async def send_order_cmds():
|
||||||
|
"""Order streaming task: deliver orders transmitted from UI
|
||||||
|
to downstream consumers.
|
||||||
|
|
||||||
|
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
|
||||||
|
``.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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
book = get_orders()
|
||||||
|
orders_stream = book._from_order_book
|
||||||
|
|
||||||
|
# signal that ems connection is up and ready
|
||||||
|
book._ready_to_receive.set()
|
||||||
|
|
||||||
|
async for cmd in orders_stream:
|
||||||
|
|
||||||
|
# send msg over IPC / wire
|
||||||
|
log.info(f'Send order cmd:\n{pformat(cmd)}')
|
||||||
|
yield cmd
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def maybe_open_emsd(
|
||||||
|
) -> 'StreamReceiveChannel': # noqa
|
||||||
|
|
||||||
|
async with tractor.find_actor('emsd') as portal:
|
||||||
|
if portal is not None:
|
||||||
|
yield portal
|
||||||
|
|
||||||
|
else:
|
||||||
|
# we gotta spawn it
|
||||||
|
log.info("Spawning EMS daemon")
|
||||||
|
|
||||||
|
# TODO: add ``maybe_spawn_emsd()`` for this
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'emsd',
|
||||||
|
enable_modules=[
|
||||||
|
'piker.exchange._ems',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
yield portal
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def open_ems(
|
||||||
|
broker: str,
|
||||||
|
symbol: Symbol,
|
||||||
|
) -> None:
|
||||||
|
"""Spawn an EMS daemon and begin sending orders and receiving
|
||||||
|
alerts.
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
"""
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
|
||||||
|
# wait for service to connect back to us signalling
|
||||||
|
# ready for order commands
|
||||||
|
book = get_orders()
|
||||||
|
|
||||||
|
async with maybe_open_emsd() as portal:
|
||||||
|
|
||||||
|
trades_stream = await portal.run(
|
||||||
|
_emsd_main,
|
||||||
|
client_actor_name=actor.name,
|
||||||
|
broker=broker,
|
||||||
|
symbol=symbol.key,
|
||||||
|
|
||||||
|
)
|
||||||
|
with trio.fail_after(10):
|
||||||
|
await book._ready_to_receive.wait()
|
||||||
|
|
||||||
|
yield book, trades_stream
|
|
@ -0,0 +1,681 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
In da suit parlances: "Execution management systems"
|
||||||
|
|
||||||
|
"""
|
||||||
|
from pprint import pformat
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import (
|
||||||
|
AsyncIterator, Dict, Callable, Tuple,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bidict import bidict
|
||||||
|
import trio
|
||||||
|
from trio_typing import TaskStatus
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
from .. import data
|
||||||
|
from ..log import get_logger
|
||||||
|
from ..data._normalize import iterticks
|
||||||
|
from ._paper_engine import PaperBoi, simulate_fills
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: numba all of this
|
||||||
|
def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]:
|
||||||
|
"""Create a predicate for given ``exec_price`` based on last known
|
||||||
|
price, ``known_last``.
|
||||||
|
|
||||||
|
This is an automatic alert level thunk generator based on where the
|
||||||
|
current last known value is and where the specified value of
|
||||||
|
interest is; pick an appropriate comparison operator based on
|
||||||
|
avoiding the case where the a predicate returns true immediately.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# str compares:
|
||||||
|
# https://stackoverflow.com/questions/46708708/compare-strings-in-numba-compiled-function
|
||||||
|
|
||||||
|
if trigger_price >= known_last:
|
||||||
|
|
||||||
|
def check_gt(price: float) -> bool:
|
||||||
|
return price >= trigger_price
|
||||||
|
|
||||||
|
return check_gt
|
||||||
|
|
||||||
|
elif trigger_price <= known_last:
|
||||||
|
|
||||||
|
def check_lt(price: float) -> bool:
|
||||||
|
return price <= trigger_price
|
||||||
|
|
||||||
|
return check_lt
|
||||||
|
|
||||||
|
else:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _DarkBook:
|
||||||
|
"""Client-side execution book.
|
||||||
|
|
||||||
|
Contains conditions for executions (aka "orders") which are not
|
||||||
|
exposed to brokers and thus the market; i.e. these are privacy
|
||||||
|
focussed "client side" orders.
|
||||||
|
|
||||||
|
A singleton instance is created per EMS actor (for now).
|
||||||
|
|
||||||
|
"""
|
||||||
|
broker: str
|
||||||
|
|
||||||
|
# levels which have an executable action (eg. alert, order, signal)
|
||||||
|
orders: Dict[
|
||||||
|
str, # symbol
|
||||||
|
Dict[
|
||||||
|
str, # uuid
|
||||||
|
Tuple[
|
||||||
|
Callable[[float], bool], # predicate
|
||||||
|
str, # name
|
||||||
|
dict, # cmd / msg type
|
||||||
|
]
|
||||||
|
]
|
||||||
|
] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# tracks most recent values per symbol each from data feed
|
||||||
|
lasts: Dict[
|
||||||
|
Tuple[str, str],
|
||||||
|
float
|
||||||
|
] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# mapping of broker order ids to piker ems ids
|
||||||
|
_broker2ems_ids: Dict[str, str] = field(default_factory=bidict)
|
||||||
|
|
||||||
|
|
||||||
|
_books: Dict[str, _DarkBook] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_dark_book(broker: str) -> _DarkBook:
|
||||||
|
|
||||||
|
global _books
|
||||||
|
return _books.setdefault(broker, _DarkBook(broker))
|
||||||
|
|
||||||
|
|
||||||
|
# XXX: this is in place to prevent accidental positions that are too
|
||||||
|
# big. Now obviously this won't make sense for crypto like BTC, but
|
||||||
|
# for most traditional brokers it should be fine unless you start
|
||||||
|
# slinging NQ futes or something.
|
||||||
|
_DEFAULT_SIZE: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_triggers(
|
||||||
|
broker: str,
|
||||||
|
symbol: str,
|
||||||
|
stream: 'tractor.ReceiveStream', # noqa
|
||||||
|
ctx: tractor.Context,
|
||||||
|
client: 'Client', # noqa
|
||||||
|
book: _DarkBook,
|
||||||
|
) -> None:
|
||||||
|
"""Core dark order trigger loop.
|
||||||
|
|
||||||
|
Scan the (price) data feed and submit triggered orders
|
||||||
|
to broker.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# this stream may eventually contain multiple symbols
|
||||||
|
# XXX: optimize this for speed!
|
||||||
|
async for quotes in stream:
|
||||||
|
|
||||||
|
# TODO: numba all this!
|
||||||
|
|
||||||
|
# start = time.time()
|
||||||
|
for sym, quote in quotes.items():
|
||||||
|
|
||||||
|
execs = book.orders.get(sym, None)
|
||||||
|
if execs is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for tick in iterticks(
|
||||||
|
quote,
|
||||||
|
# dark order price filter(s)
|
||||||
|
types=('ask', 'bid', 'trade', 'last')
|
||||||
|
):
|
||||||
|
price = tick.get('price')
|
||||||
|
ttype = tick['type']
|
||||||
|
|
||||||
|
# update to keep new cmds informed
|
||||||
|
book.lasts[(broker, symbol)] = price
|
||||||
|
|
||||||
|
for oid, (
|
||||||
|
pred,
|
||||||
|
tf,
|
||||||
|
cmd,
|
||||||
|
percent_away,
|
||||||
|
abs_diff_away
|
||||||
|
) in (
|
||||||
|
tuple(execs.items())
|
||||||
|
):
|
||||||
|
|
||||||
|
if (ttype not in tf) or (not pred(price)):
|
||||||
|
# majority of iterations will be non-matches
|
||||||
|
continue
|
||||||
|
|
||||||
|
# submit_price = price + price*percent_away
|
||||||
|
submit_price = price + abs_diff_away
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
f'Dark order triggered for price {price}\n'
|
||||||
|
f'Submitting order @ price {submit_price}')
|
||||||
|
|
||||||
|
reqid = await client.submit_limit(
|
||||||
|
oid=oid,
|
||||||
|
|
||||||
|
# this is a brand new order request for the
|
||||||
|
# underlying broker so we set out "broker request
|
||||||
|
# id" (brid) as nothing so that the broker
|
||||||
|
# client knows that we aren't trying to modify
|
||||||
|
# an existing order.
|
||||||
|
brid=None,
|
||||||
|
|
||||||
|
symbol=sym,
|
||||||
|
action=cmd['action'],
|
||||||
|
price=submit_price,
|
||||||
|
size=cmd['size'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# register broker request id to ems id
|
||||||
|
book._broker2ems_ids[reqid] = oid
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
'resp': 'dark_executed',
|
||||||
|
'time_ns': time.time_ns(),
|
||||||
|
'trigger_price': price,
|
||||||
|
|
||||||
|
'cmd': cmd, # original request message
|
||||||
|
|
||||||
|
'broker_reqid': reqid,
|
||||||
|
'broker': broker,
|
||||||
|
'oid': oid, # piker order id
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
# remove exec-condition from set
|
||||||
|
log.info(f'removing pred for {oid}')
|
||||||
|
execs.pop(oid)
|
||||||
|
|
||||||
|
await ctx.send_yield(resp)
|
||||||
|
|
||||||
|
else: # condition scan loop complete
|
||||||
|
log.debug(f'execs are {execs}')
|
||||||
|
if execs:
|
||||||
|
book.orders[symbol] = execs
|
||||||
|
|
||||||
|
# print(f'execs scan took: {time.time() - start}')
|
||||||
|
|
||||||
|
|
||||||
|
async def exec_loop(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
broker: str,
|
||||||
|
symbol: str,
|
||||||
|
_exec_mode: str,
|
||||||
|
task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED,
|
||||||
|
) -> AsyncIterator[dict]:
|
||||||
|
"""Main scan loop for order execution conditions and submission
|
||||||
|
to brokers.
|
||||||
|
|
||||||
|
"""
|
||||||
|
async with data.open_feed(
|
||||||
|
broker,
|
||||||
|
[symbol],
|
||||||
|
loglevel='info',
|
||||||
|
) as feed:
|
||||||
|
|
||||||
|
# TODO: get initial price quote from target broker
|
||||||
|
first_quote = await feed.receive()
|
||||||
|
|
||||||
|
book = get_dark_book(broker)
|
||||||
|
book.lasts[(broker, symbol)] = first_quote[symbol]['last']
|
||||||
|
|
||||||
|
# TODO: wrap this in a more re-usable general api
|
||||||
|
client_factory = getattr(feed.mod, 'get_client_proxy', None)
|
||||||
|
|
||||||
|
if client_factory is not None and _exec_mode != 'paper':
|
||||||
|
|
||||||
|
# we have an order API for this broker
|
||||||
|
client = client_factory(feed._brokerd_portal)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# force paper mode
|
||||||
|
log.warning(f'Entering paper trading mode for {broker}')
|
||||||
|
|
||||||
|
client = PaperBoi(
|
||||||
|
broker,
|
||||||
|
*trio.open_memory_channel(100),
|
||||||
|
_buys={},
|
||||||
|
_sells={},
|
||||||
|
_reqids={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# for paper mode we need to mock this trades response feed
|
||||||
|
# so we pass a duck-typed feed-looking mem chan which is fed
|
||||||
|
# fill and submission events from the exec loop
|
||||||
|
feed._trade_stream = client.trade_stream
|
||||||
|
|
||||||
|
# init the trades stream
|
||||||
|
client._to_trade_stream.send_nowait({'local_trades': 'start'})
|
||||||
|
|
||||||
|
_exec_mode = 'paper'
|
||||||
|
|
||||||
|
# return control to parent task
|
||||||
|
task_status.started((first_quote, feed, client))
|
||||||
|
|
||||||
|
# shield this field so the remote brokerd does not get cancelled
|
||||||
|
stream = feed.stream
|
||||||
|
with stream.shield():
|
||||||
|
async with trio.open_nursery() as n:
|
||||||
|
n.start_soon(
|
||||||
|
execute_triggers,
|
||||||
|
broker,
|
||||||
|
symbol,
|
||||||
|
stream,
|
||||||
|
ctx,
|
||||||
|
client,
|
||||||
|
book
|
||||||
|
)
|
||||||
|
|
||||||
|
if _exec_mode == 'paper':
|
||||||
|
n.start_soon(simulate_fills, stream.clone(), client)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: lots of cases still to handle
|
||||||
|
# XXX: right now this is very very ad-hoc to IB
|
||||||
|
# - short-sale but securities haven't been located, in this case we
|
||||||
|
# should probably keep the order in some kind of weird state or cancel
|
||||||
|
# it outright?
|
||||||
|
# status='PendingSubmit', message=''),
|
||||||
|
# status='Cancelled', message='Error 404,
|
||||||
|
# reqId 1550: Order held while securities are located.'),
|
||||||
|
# status='PreSubmitted', message='')],
|
||||||
|
|
||||||
|
async def process_broker_trades(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
feed: 'Feed', # noqa
|
||||||
|
book: _DarkBook,
|
||||||
|
task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED,
|
||||||
|
) -> AsyncIterator[dict]:
|
||||||
|
"""Trades update loop - receive updates from broker, convert
|
||||||
|
to EMS responses, transmit to ordering client(s).
|
||||||
|
|
||||||
|
This is where trade confirmations from the broker are processed
|
||||||
|
and appropriate responses relayed back to the original EMS client
|
||||||
|
actor. There is a messaging translation layer throughout.
|
||||||
|
|
||||||
|
Expected message translation(s):
|
||||||
|
|
||||||
|
broker ems
|
||||||
|
'error' -> log it locally (for now)
|
||||||
|
'status' -> relabel as 'broker_<status>', if complete send 'executed'
|
||||||
|
'fill' -> 'broker_filled'
|
||||||
|
|
||||||
|
Currently accepted status values from IB:
|
||||||
|
{'presubmitted', 'submitted', 'cancelled', 'inactive'}
|
||||||
|
|
||||||
|
"""
|
||||||
|
broker = feed.mod.name
|
||||||
|
|
||||||
|
with trio.fail_after(5):
|
||||||
|
# in the paper engine case this is just a mem receive channel
|
||||||
|
trades_stream = await feed.recv_trades_data()
|
||||||
|
first = await trades_stream.__anext__()
|
||||||
|
|
||||||
|
# startup msg expected as first from broker backend
|
||||||
|
assert first['local_trades'] == 'start'
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
|
async for event in trades_stream:
|
||||||
|
|
||||||
|
name, msg = event['local_trades']
|
||||||
|
|
||||||
|
log.info(f'Received broker trade event:\n{pformat(msg)}')
|
||||||
|
|
||||||
|
if name == 'position':
|
||||||
|
msg['resp'] = 'position'
|
||||||
|
|
||||||
|
# relay through
|
||||||
|
await ctx.send_yield(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the broker (order) request id, this **must** be normalized
|
||||||
|
# into messaging provided by the broker backend
|
||||||
|
reqid = msg['reqid']
|
||||||
|
|
||||||
|
# make response packet to EMS client(s)
|
||||||
|
oid = book._broker2ems_ids.get(reqid)
|
||||||
|
|
||||||
|
if oid is None:
|
||||||
|
# paper engine race case: ``Client.submit_limit()`` hasn't
|
||||||
|
# returned yet and provided an output reqid to register
|
||||||
|
# locally, so we need to retreive the oid that was already
|
||||||
|
# packed at submission since we already know it ahead of
|
||||||
|
# time
|
||||||
|
paper = msg.get('paper_info')
|
||||||
|
if paper:
|
||||||
|
oid = paper['oid']
|
||||||
|
|
||||||
|
else:
|
||||||
|
msg.get('external')
|
||||||
|
if not msg:
|
||||||
|
log.error(f"Unknown trade event {event}")
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
'resp': None, # placeholder
|
||||||
|
'oid': oid
|
||||||
|
}
|
||||||
|
|
||||||
|
if name in (
|
||||||
|
'error',
|
||||||
|
):
|
||||||
|
# TODO: figure out how this will interact with EMS clients
|
||||||
|
# for ex. on an error do we react with a dark orders
|
||||||
|
# management response, like cancelling all dark orders?
|
||||||
|
|
||||||
|
# This looks like a supervision policy for pending orders on
|
||||||
|
# some unexpected failure - something we need to think more
|
||||||
|
# about. In most default situations, with composed orders
|
||||||
|
# (ex. brackets), most brokers seem to use a oca policy.
|
||||||
|
|
||||||
|
message = msg['message']
|
||||||
|
|
||||||
|
# XXX should we make one when it's blank?
|
||||||
|
log.error(pformat(message))
|
||||||
|
|
||||||
|
# TODO: getting this bs, prolly need to handle status messages
|
||||||
|
# 'Market data farm connection is OK:usfarm.nj'
|
||||||
|
|
||||||
|
# another stupid ib error to handle
|
||||||
|
# if 10147 in message: cancel
|
||||||
|
|
||||||
|
# don't relay message to order requester client
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif name in (
|
||||||
|
'status',
|
||||||
|
):
|
||||||
|
# TODO: templating the ib statuses in comparison with other
|
||||||
|
# brokers is likely the way to go:
|
||||||
|
# https://interactivebrokers.github.io/tws-api/interfaceIBApi_1_1EWrapper.html#a17f2a02d6449710b6394d0266a353313
|
||||||
|
# short list:
|
||||||
|
# - PendingSubmit
|
||||||
|
# - PendingCancel
|
||||||
|
# - PreSubmitted (simulated orders)
|
||||||
|
# - ApiCancelled (cancelled by client before submission to routing)
|
||||||
|
# - Cancelled
|
||||||
|
# - Filled
|
||||||
|
# - Inactive (reject or cancelled but not by trader)
|
||||||
|
|
||||||
|
# everyone doin camel case
|
||||||
|
status = msg['status'].lower()
|
||||||
|
|
||||||
|
if status == 'filled':
|
||||||
|
|
||||||
|
# conditional execution is fully complete, no more
|
||||||
|
# fills for the noted order
|
||||||
|
if not msg['remaining']:
|
||||||
|
|
||||||
|
resp['resp'] = 'broker_executed'
|
||||||
|
|
||||||
|
log.info(f'Execution for {oid} is complete!')
|
||||||
|
|
||||||
|
# just log it
|
||||||
|
else:
|
||||||
|
log.info(f'{broker} filled {msg}')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# one of (submitted, cancelled)
|
||||||
|
resp['resp'] = 'broker_' + status
|
||||||
|
|
||||||
|
elif name in (
|
||||||
|
'fill',
|
||||||
|
):
|
||||||
|
# proxy through the "fill" result(s)
|
||||||
|
resp['resp'] = 'broker_filled'
|
||||||
|
resp.update(msg)
|
||||||
|
|
||||||
|
log.info(f'\nFill for {oid} cleared with:\n{pformat(resp)}')
|
||||||
|
|
||||||
|
# respond to requesting client
|
||||||
|
await ctx.send_yield(resp)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_order_cmds(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
cmd_stream: 'tractor.ReceiveStream', # noqa
|
||||||
|
symbol: str,
|
||||||
|
feed: 'Feed', # noqa
|
||||||
|
client: 'Client', # noqa
|
||||||
|
dark_book: _DarkBook,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
async for cmd in cmd_stream:
|
||||||
|
|
||||||
|
log.info(f'Received order cmd:\n{pformat(cmd)}')
|
||||||
|
|
||||||
|
action = cmd['action']
|
||||||
|
oid = cmd['oid']
|
||||||
|
|
||||||
|
brid = dark_book._broker2ems_ids.inverse.get(oid)
|
||||||
|
|
||||||
|
# TODO: can't wait for this stuff to land in 3.10
|
||||||
|
# https://www.python.org/dev/peps/pep-0636/#going-to-the-cloud-mappings
|
||||||
|
if action in ('cancel',):
|
||||||
|
|
||||||
|
# check for live-broker order
|
||||||
|
if brid:
|
||||||
|
log.info("Submitting cancel for live order")
|
||||||
|
await client.submit_cancel(reqid=brid)
|
||||||
|
|
||||||
|
# check for EMS active exec
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dark_book.orders[symbol].pop(oid, None)
|
||||||
|
|
||||||
|
await ctx.send_yield({
|
||||||
|
'resp': 'dark_cancelled',
|
||||||
|
'oid': oid
|
||||||
|
})
|
||||||
|
except KeyError:
|
||||||
|
log.exception(f'No dark order for {symbol}?')
|
||||||
|
|
||||||
|
elif action in ('alert', 'buy', 'sell',):
|
||||||
|
|
||||||
|
sym = cmd['symbol']
|
||||||
|
trigger_price = cmd['price']
|
||||||
|
size = cmd['size']
|
||||||
|
brokers = cmd['brokers']
|
||||||
|
exec_mode = cmd['exec_mode']
|
||||||
|
|
||||||
|
broker = brokers[0]
|
||||||
|
last = dark_book.lasts[(broker, sym)]
|
||||||
|
|
||||||
|
if exec_mode == 'live' and action in ('buy', 'sell',):
|
||||||
|
|
||||||
|
# register broker id for ems id
|
||||||
|
order_id = await client.submit_limit(
|
||||||
|
|
||||||
|
oid=oid, # no ib support for oids...
|
||||||
|
|
||||||
|
# if this is None, creates a new order
|
||||||
|
# otherwise will modify any existing one
|
||||||
|
brid=brid,
|
||||||
|
|
||||||
|
symbol=sym,
|
||||||
|
action=action,
|
||||||
|
price=trigger_price,
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
|
||||||
|
if brid:
|
||||||
|
assert dark_book._broker2ems_ids[brid] == oid
|
||||||
|
|
||||||
|
# if we already had a broker order id then
|
||||||
|
# this is likely an order update commmand.
|
||||||
|
log.info(f"Modifying order: {brid}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
dark_book._broker2ems_ids[order_id] = oid
|
||||||
|
|
||||||
|
# XXX: the trades data broker response loop
|
||||||
|
# (``process_broker_trades()`` above) will
|
||||||
|
# handle sending the ems side acks back to
|
||||||
|
# the cmd sender from here
|
||||||
|
|
||||||
|
elif exec_mode in ('dark', 'paper') or (
|
||||||
|
action in ('alert')
|
||||||
|
):
|
||||||
|
# submit order to local EMS
|
||||||
|
|
||||||
|
# Auto-gen scanner predicate:
|
||||||
|
# we automatically figure out what the alert check
|
||||||
|
# condition should be based on the current first
|
||||||
|
# price received from the feed, instead of being
|
||||||
|
# like every other shitty tina platform that makes
|
||||||
|
# the user choose the predicate operator.
|
||||||
|
pred = mk_check(trigger_price, last)
|
||||||
|
|
||||||
|
tick_slap: float = 5
|
||||||
|
min_tick = feed.symbols[sym].tick_size
|
||||||
|
|
||||||
|
if action == 'buy':
|
||||||
|
tickfilter = ('ask', 'last', 'trade')
|
||||||
|
percent_away = 0.005
|
||||||
|
|
||||||
|
# TODO: we probably need to scale this based
|
||||||
|
# on some near term historical spread
|
||||||
|
# measure?
|
||||||
|
abs_diff_away = tick_slap * min_tick
|
||||||
|
|
||||||
|
elif action == 'sell':
|
||||||
|
tickfilter = ('bid', 'last', 'trade')
|
||||||
|
percent_away = -0.005
|
||||||
|
abs_diff_away = -tick_slap * min_tick
|
||||||
|
|
||||||
|
else: # alert
|
||||||
|
tickfilter = ('trade', 'utrade', 'last')
|
||||||
|
percent_away = 0
|
||||||
|
abs_diff_away = 0
|
||||||
|
|
||||||
|
# submit execution/order to EMS scan loop
|
||||||
|
# FYI: this may result in an override of an existing
|
||||||
|
# dark book entry if the order id already exists
|
||||||
|
dark_book.orders.setdefault(
|
||||||
|
sym, {}
|
||||||
|
)[oid] = (
|
||||||
|
pred,
|
||||||
|
tickfilter,
|
||||||
|
cmd,
|
||||||
|
percent_away,
|
||||||
|
abs_diff_away
|
||||||
|
)
|
||||||
|
# TODO: if the predicate resolves immediately send the
|
||||||
|
# execution to the broker asap? Or no?
|
||||||
|
|
||||||
|
# ack-response that order is live in EMS
|
||||||
|
await ctx.send_yield({
|
||||||
|
'resp': 'dark_submitted',
|
||||||
|
'oid': oid
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.stream
|
||||||
|
async def _emsd_main(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
client_actor_name: str,
|
||||||
|
broker: str,
|
||||||
|
symbol: str,
|
||||||
|
_mode: str = 'dark', # ('paper', 'dark', 'live')
|
||||||
|
) -> None:
|
||||||
|
"""EMS (sub)actor entrypoint providing the
|
||||||
|
execution management (micro)service which conducts broker
|
||||||
|
order control on behalf of clients.
|
||||||
|
|
||||||
|
This is the daemon (child) side routine which starts an EMS runtime
|
||||||
|
(one per broker-feed) and and begins streaming back alerts from
|
||||||
|
broker executions/fills.
|
||||||
|
|
||||||
|
``send_order_cmds()`` is called here to execute in a task back in
|
||||||
|
the actor which started this service (spawned this actor), presuming
|
||||||
|
capabilities allow it, such that requests for EMS executions are
|
||||||
|
received in a stream from that client actor and then responses are
|
||||||
|
streamed back up to the original calling task in the same client.
|
||||||
|
|
||||||
|
The task tree is:
|
||||||
|
- ``_emsd_main()``:
|
||||||
|
accepts order cmds, registers execs with exec loop
|
||||||
|
|
||||||
|
- ``exec_loop()``:
|
||||||
|
run (dark) conditions on inputs and trigger broker submissions
|
||||||
|
|
||||||
|
- ``process_broker_trades()``:
|
||||||
|
accept normalized trades responses, process and relay to ems client(s)
|
||||||
|
|
||||||
|
"""
|
||||||
|
from ._client import send_order_cmds
|
||||||
|
|
||||||
|
dark_book = get_dark_book(broker)
|
||||||
|
|
||||||
|
# get a portal back to the client
|
||||||
|
async with tractor.wait_for_actor(client_actor_name) as portal:
|
||||||
|
|
||||||
|
# spawn one task per broker feed
|
||||||
|
async with trio.open_nursery() as n:
|
||||||
|
|
||||||
|
# TODO: eventually support N-brokers
|
||||||
|
|
||||||
|
# start the condition scan loop
|
||||||
|
quote, feed, client = await n.start(
|
||||||
|
exec_loop,
|
||||||
|
ctx,
|
||||||
|
broker,
|
||||||
|
symbol,
|
||||||
|
_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
await n.start(
|
||||||
|
process_broker_trades,
|
||||||
|
ctx,
|
||||||
|
feed,
|
||||||
|
dark_book,
|
||||||
|
)
|
||||||
|
|
||||||
|
# connect back to the calling actor (the one that is
|
||||||
|
# acting as an EMS client and will submit orders) to
|
||||||
|
# receive requests pushed over a tractor stream
|
||||||
|
# using (for now) an async generator.
|
||||||
|
order_stream = await portal.run(send_order_cmds)
|
||||||
|
|
||||||
|
# start inbound order request processing
|
||||||
|
await process_order_cmds(
|
||||||
|
ctx,
|
||||||
|
order_stream,
|
||||||
|
symbol,
|
||||||
|
feed,
|
||||||
|
client,
|
||||||
|
dark_book,
|
||||||
|
)
|
|
@ -0,0 +1,329 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Fake trading for forward testing.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from operator import itemgetter
|
||||||
|
import time
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from bidict import bidict
|
||||||
|
import trio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from ..data._normalize import iterticks
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaperBoi:
|
||||||
|
"""
|
||||||
|
Emulates a broker order client providing the same API and
|
||||||
|
delivering an order-event response stream but with methods for
|
||||||
|
triggering desired events based on forward testing engine
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
"""
|
||||||
|
broker: str
|
||||||
|
_to_trade_stream: trio.abc.SendChannel
|
||||||
|
trade_stream: trio.abc.ReceiveChannel
|
||||||
|
|
||||||
|
# map of paper "live" orders which be used
|
||||||
|
# to simulate fills based on paper engine settings
|
||||||
|
_buys: bidict
|
||||||
|
_sells: bidict
|
||||||
|
_reqids: bidict
|
||||||
|
|
||||||
|
# init edge case L1 spread
|
||||||
|
last_ask: Tuple[float, float] = (float('inf'), 0) # price, size
|
||||||
|
last_bid: Tuple[float, float] = (0, 0)
|
||||||
|
|
||||||
|
async def submit_limit(
|
||||||
|
self,
|
||||||
|
oid: str, # XXX: see return value
|
||||||
|
symbol: str,
|
||||||
|
price: float,
|
||||||
|
action: str,
|
||||||
|
size: float,
|
||||||
|
brid: Optional[str],
|
||||||
|
) -> int:
|
||||||
|
"""Place an order and return integer request id provided by client.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if brid is None:
|
||||||
|
reqid = str(uuid.uuid4())
|
||||||
|
|
||||||
|
else:
|
||||||
|
# order is already existing, this is a modify
|
||||||
|
(oid, symbol, action, old_price) = self._reqids[brid]
|
||||||
|
assert old_price != price
|
||||||
|
reqid = brid
|
||||||
|
|
||||||
|
# register order internally
|
||||||
|
self._reqids[reqid] = (oid, symbol, action, price)
|
||||||
|
|
||||||
|
if action == 'alert':
|
||||||
|
# bypass all fill simulation
|
||||||
|
return reqid
|
||||||
|
|
||||||
|
# TODO: net latency model
|
||||||
|
# we checkpoint here quickly particulalry
|
||||||
|
# for dark orders since we want the dark_executed
|
||||||
|
# to trigger first thus creating a lookup entry
|
||||||
|
# in the broker trades event processing loop
|
||||||
|
await trio.sleep(0.05)
|
||||||
|
|
||||||
|
await self._to_trade_stream.send({
|
||||||
|
|
||||||
|
'local_trades': ('status', {
|
||||||
|
|
||||||
|
'time_ns': time.time_ns(),
|
||||||
|
'reqid': reqid,
|
||||||
|
|
||||||
|
'status': 'submitted',
|
||||||
|
'broker': self.broker,
|
||||||
|
# 'cmd': cmd, # original request message
|
||||||
|
|
||||||
|
'paper_info': {
|
||||||
|
'oid': oid,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
# if we're already a clearing price simulate an immediate fill
|
||||||
|
if (
|
||||||
|
action == 'buy' and (clear_price := self.last_ask[0]) <= price
|
||||||
|
) or (
|
||||||
|
action == 'sell' and (clear_price := self.last_bid[0]) >= price
|
||||||
|
):
|
||||||
|
await self.fake_fill(clear_price, size, action, reqid, oid)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# register this submissions as a paper live order
|
||||||
|
|
||||||
|
# submit order to book simulation fill loop
|
||||||
|
if action == 'buy':
|
||||||
|
orders = self._buys
|
||||||
|
|
||||||
|
elif action == 'sell':
|
||||||
|
orders = self._sells
|
||||||
|
|
||||||
|
# set the simulated order in the respective table for lookup
|
||||||
|
# and trigger by the simulated clearing task normally
|
||||||
|
# running ``simulate_fills()``.
|
||||||
|
|
||||||
|
if brid is not None:
|
||||||
|
# remove any existing order for the old price
|
||||||
|
orders[symbol].pop((oid, old_price))
|
||||||
|
|
||||||
|
# buys/sells: (symbol -> (price -> order))
|
||||||
|
orders.setdefault(symbol, {})[(oid, price)] = (size, reqid, action)
|
||||||
|
|
||||||
|
return reqid
|
||||||
|
|
||||||
|
async def submit_cancel(
|
||||||
|
self,
|
||||||
|
reqid: str,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# TODO: fake market simulation effects
|
||||||
|
# await self._to_trade_stream.send(
|
||||||
|
oid, symbol, action, price = self._reqids[reqid]
|
||||||
|
|
||||||
|
if action == 'buy':
|
||||||
|
self._buys[symbol].pop((oid, price))
|
||||||
|
elif action == 'sell':
|
||||||
|
self._sells[symbol].pop((oid, price))
|
||||||
|
|
||||||
|
# TODO: net latency model
|
||||||
|
await trio.sleep(0.05)
|
||||||
|
|
||||||
|
await self._to_trade_stream.send({
|
||||||
|
|
||||||
|
'local_trades': ('status', {
|
||||||
|
|
||||||
|
'time_ns': time.time_ns(),
|
||||||
|
'oid': oid,
|
||||||
|
'reqid': reqid,
|
||||||
|
|
||||||
|
'status': 'cancelled',
|
||||||
|
'broker': self.broker,
|
||||||
|
# 'cmd': cmd, # original request message
|
||||||
|
|
||||||
|
'paper': True,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def fake_fill(
|
||||||
|
self,
|
||||||
|
price: float,
|
||||||
|
size: float,
|
||||||
|
action: str, # one of {'buy', 'sell'}
|
||||||
|
|
||||||
|
reqid: str,
|
||||||
|
oid: str,
|
||||||
|
|
||||||
|
# determine whether to send a filled status that has zero
|
||||||
|
# remaining lots to fill
|
||||||
|
order_complete: bool = True,
|
||||||
|
remaining: float = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Pretend to fill a broker order @ price and size.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# TODO: net latency model
|
||||||
|
await trio.sleep(0.05)
|
||||||
|
|
||||||
|
# the trades stream expects events in the form
|
||||||
|
# {'local_trades': (event_name, msg)}
|
||||||
|
await self._to_trade_stream.send({
|
||||||
|
|
||||||
|
'local_trades': ('fill', {
|
||||||
|
|
||||||
|
'status': 'filled',
|
||||||
|
'broker': self.broker,
|
||||||
|
# converted to float by us in ib backend
|
||||||
|
'broker_time': datetime.now().timestamp(),
|
||||||
|
|
||||||
|
'action': action,
|
||||||
|
'size': size,
|
||||||
|
'price': price,
|
||||||
|
'remaining': 0 if order_complete else remaining,
|
||||||
|
|
||||||
|
# normally filled by real `brokerd` daemon
|
||||||
|
'time': time.time_ns(),
|
||||||
|
'time_ns': time.time_ns(), # cuz why not
|
||||||
|
|
||||||
|
# fake ids
|
||||||
|
'reqid': reqid,
|
||||||
|
|
||||||
|
'paper_info': {
|
||||||
|
'oid': oid,
|
||||||
|
},
|
||||||
|
|
||||||
|
# XXX: fields we might not need to emulate?
|
||||||
|
# execution id from broker
|
||||||
|
# 'execid': execu.execId,
|
||||||
|
# 'cmd': cmd, # original request message?
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if order_complete:
|
||||||
|
await self._to_trade_stream.send({
|
||||||
|
|
||||||
|
'local_trades': ('status', {
|
||||||
|
'reqid': reqid,
|
||||||
|
'status': 'filled',
|
||||||
|
'broker': self.broker,
|
||||||
|
'filled': size,
|
||||||
|
'remaining': 0 if order_complete else remaining,
|
||||||
|
|
||||||
|
# converted to float by us in ib backend
|
||||||
|
'broker_time': datetime.now().timestamp(),
|
||||||
|
'paper_info': {
|
||||||
|
'oid': oid,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def simulate_fills(
|
||||||
|
quote_stream: 'tractor.ReceiveStream', # noqa
|
||||||
|
client: PaperBoi,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# TODO: more machinery to better simulate real-world market things:
|
||||||
|
|
||||||
|
# - slippage models, check what quantopian has:
|
||||||
|
# https://github.com/quantopian/zipline/blob/master/zipline/finance/slippage.py
|
||||||
|
# * this should help with simulating partial fills in a fast moving mkt
|
||||||
|
# afaiu
|
||||||
|
|
||||||
|
# - commisions models, also quantopian has em:
|
||||||
|
# https://github.com/quantopian/zipline/blob/master/zipline/finance/commission.py
|
||||||
|
|
||||||
|
# - network latency models ??
|
||||||
|
|
||||||
|
# - position tracking:
|
||||||
|
# https://github.com/quantopian/zipline/blob/master/zipline/finance/ledger.py
|
||||||
|
|
||||||
|
# this stream may eventually contain multiple symbols
|
||||||
|
async for quotes in quote_stream:
|
||||||
|
for sym, quote in quotes.items():
|
||||||
|
|
||||||
|
for tick in iterticks(
|
||||||
|
quote,
|
||||||
|
# dark order price filter(s)
|
||||||
|
types=('ask', 'bid', 'trade', 'last')
|
||||||
|
):
|
||||||
|
# print(tick)
|
||||||
|
tick_price = tick.get('price')
|
||||||
|
ttype = tick['type']
|
||||||
|
|
||||||
|
if ttype in ('ask',):
|
||||||
|
|
||||||
|
client.last_ask = (
|
||||||
|
tick_price,
|
||||||
|
tick.get('size', client.last_ask[1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
orders = client._buys.get(sym, {})
|
||||||
|
book_sequence = reversed(
|
||||||
|
sorted(orders.keys(), key=itemgetter(1)))
|
||||||
|
|
||||||
|
def pred(our_price):
|
||||||
|
return tick_price < our_price
|
||||||
|
|
||||||
|
elif ttype in ('bid',):
|
||||||
|
|
||||||
|
client.last_bid = (
|
||||||
|
tick_price,
|
||||||
|
tick.get('size', client.last_bid[1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
orders = client._sells.get(sym, {})
|
||||||
|
book_sequence = sorted(orders.keys(), key=itemgetter(1))
|
||||||
|
|
||||||
|
def pred(our_price):
|
||||||
|
return tick_price > our_price
|
||||||
|
|
||||||
|
elif ttype in ('trade', 'last'):
|
||||||
|
# TODO: simulate actual book queues and our orders
|
||||||
|
# place in it, might require full L2 data?
|
||||||
|
continue
|
||||||
|
|
||||||
|
# iterate book prices descending
|
||||||
|
for oid, our_price in book_sequence:
|
||||||
|
if pred(our_price):
|
||||||
|
|
||||||
|
# retreive order info
|
||||||
|
(size, reqid, action) = orders.pop((oid, our_price))
|
||||||
|
|
||||||
|
# clearing price would have filled entirely
|
||||||
|
await client.fake_fill(
|
||||||
|
# todo slippage to determine fill price
|
||||||
|
price=tick_price,
|
||||||
|
size=size,
|
||||||
|
action=action,
|
||||||
|
reqid=reqid,
|
||||||
|
oid=oid,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# prices are iterated in sorted order so we're done
|
||||||
|
break
|
|
@ -0,0 +1,144 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Annotations for ur faces.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt5 import QtCore, QtGui
|
||||||
|
from PyQt5.QtGui import QGraphicsPathItem
|
||||||
|
from pyqtgraph import Point, functions as fn, Color
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def mk_marker(
|
||||||
|
style,
|
||||||
|
size: float = 20.0,
|
||||||
|
use_qgpath: bool = True,
|
||||||
|
) -> QGraphicsPathItem:
|
||||||
|
"""Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem``
|
||||||
|
ready to be placed using scene coordinates (not view).
|
||||||
|
|
||||||
|
**Arguments**
|
||||||
|
style String indicating the style of marker to add:
|
||||||
|
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
|
||||||
|
``'>|<'``, ``'^'``, ``'v'``, ``'o'``
|
||||||
|
size Size of the marker in pixels. Default is 10.0.
|
||||||
|
|
||||||
|
"""
|
||||||
|
path = QtGui.QPainterPath()
|
||||||
|
|
||||||
|
if style == 'o':
|
||||||
|
path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1))
|
||||||
|
|
||||||
|
# arrow pointing away-from the top of line
|
||||||
|
if '<|' in style:
|
||||||
|
p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)])
|
||||||
|
path.addPolygon(p)
|
||||||
|
path.closeSubpath()
|
||||||
|
|
||||||
|
# arrow pointing away-from the bottom of line
|
||||||
|
if '|>' in style:
|
||||||
|
p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)])
|
||||||
|
path.addPolygon(p)
|
||||||
|
path.closeSubpath()
|
||||||
|
|
||||||
|
# arrow pointing in-to the top of line
|
||||||
|
if '>|' in style:
|
||||||
|
p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)])
|
||||||
|
path.addPolygon(p)
|
||||||
|
path.closeSubpath()
|
||||||
|
|
||||||
|
# arrow pointing in-to the bottom of line
|
||||||
|
if '|<' in style:
|
||||||
|
p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)])
|
||||||
|
path.addPolygon(p)
|
||||||
|
path.closeSubpath()
|
||||||
|
|
||||||
|
if '^' in style:
|
||||||
|
p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)])
|
||||||
|
path.addPolygon(p)
|
||||||
|
path.closeSubpath()
|
||||||
|
|
||||||
|
if 'v' in style:
|
||||||
|
p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)])
|
||||||
|
path.addPolygon(p)
|
||||||
|
path.closeSubpath()
|
||||||
|
|
||||||
|
# self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
|
||||||
|
|
||||||
|
if use_qgpath:
|
||||||
|
path = QGraphicsPathItem(path)
|
||||||
|
path.scale(size, size)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def qgo_draw_markers(
|
||||||
|
|
||||||
|
markers: list,
|
||||||
|
color: Color,
|
||||||
|
p: QtGui.QPainter,
|
||||||
|
left: float,
|
||||||
|
right: float,
|
||||||
|
right_offset: float,
|
||||||
|
|
||||||
|
) -> float:
|
||||||
|
"""Paint markers in ``pg.GraphicsItem`` style by first
|
||||||
|
removing the view transform for the painter, drawing the markers
|
||||||
|
in scene coords, then restoring the view coords.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# paint markers in native coordinate system
|
||||||
|
orig_tr = p.transform()
|
||||||
|
|
||||||
|
start = orig_tr.map(Point(left, 0))
|
||||||
|
end = orig_tr.map(Point(right, 0))
|
||||||
|
up = orig_tr.map(Point(left, 1))
|
||||||
|
|
||||||
|
dif = end - start
|
||||||
|
# length = Point(dif).length()
|
||||||
|
angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi
|
||||||
|
|
||||||
|
p.resetTransform()
|
||||||
|
|
||||||
|
p.translate(start)
|
||||||
|
p.rotate(angle)
|
||||||
|
|
||||||
|
up = up - start
|
||||||
|
det = up.x() * dif.y() - dif.x() * up.y()
|
||||||
|
p.scale(1, 1 if det > 0 else -1)
|
||||||
|
|
||||||
|
p.setBrush(fn.mkBrush(color))
|
||||||
|
# p.setBrush(fn.mkBrush(self.currentPen.color()))
|
||||||
|
tr = p.transform()
|
||||||
|
|
||||||
|
sizes = []
|
||||||
|
for path, pos, size in markers:
|
||||||
|
p.setTransform(tr)
|
||||||
|
|
||||||
|
# XXX: we drop the "scale / %" placement
|
||||||
|
# x = length * pos
|
||||||
|
x = right_offset
|
||||||
|
|
||||||
|
p.translate(x, 0)
|
||||||
|
p.scale(size, size)
|
||||||
|
p.drawPath(path)
|
||||||
|
sizes.append(size)
|
||||||
|
|
||||||
|
p.setTransform(orig_tr)
|
||||||
|
return max(sizes)
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Chart axes graphics and behavior.
|
Chart axes graphics and behavior.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List, Tuple, Optional
|
from typing import List, Tuple, Optional
|
||||||
|
@ -32,7 +33,7 @@ _axis_pen = pg.mkPen(hcolor('bracket'))
|
||||||
|
|
||||||
|
|
||||||
class Axis(pg.AxisItem):
|
class Axis(pg.AxisItem):
|
||||||
"""A better axis that sizes to typical tick contents considering font size.
|
"""A better axis that sizes tick contents considering font size.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -53,10 +54,10 @@ class Axis(pg.AxisItem):
|
||||||
|
|
||||||
self.setTickFont(_font.font)
|
self.setTickFont(_font.font)
|
||||||
self.setStyle(**{
|
self.setStyle(**{
|
||||||
'textFillLimits': [(0, 0.666)],
|
'textFillLimits': [(0, 0.5)],
|
||||||
'tickFont': _font.font,
|
'tickFont': _font.font,
|
||||||
# offset of text *away from* axis line in px
|
# offset of text *away from* axis line in px
|
||||||
'tickTextOffset': 2,
|
'tickTextOffset': 6,
|
||||||
})
|
})
|
||||||
|
|
||||||
self.setTickFont(_font.font)
|
self.setTickFont(_font.font)
|
||||||
|
@ -64,11 +65,17 @@ class Axis(pg.AxisItem):
|
||||||
self.typical_br = _font._qfm.boundingRect(typical_max_str)
|
self.typical_br = _font._qfm.boundingRect(typical_max_str)
|
||||||
|
|
||||||
# size the pertinent axis dimension to a "typical value"
|
# size the pertinent axis dimension to a "typical value"
|
||||||
self.resize()
|
self.size_to_values()
|
||||||
|
|
||||||
|
def size_to_values(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
def set_min_tick(self, size: int) -> None:
|
def set_min_tick(self, size: int) -> None:
|
||||||
self._min_tick = size
|
self._min_tick = size
|
||||||
|
|
||||||
|
def txt_offsets(self) -> Tuple[int, int]:
|
||||||
|
return tuple(self.style['tickTextOffset'])
|
||||||
|
|
||||||
|
|
||||||
class PriceAxis(Axis):
|
class PriceAxis(Axis):
|
||||||
|
|
||||||
|
@ -77,9 +84,13 @@ class PriceAxis(Axis):
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(*args, orientation='right', **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self.setStyle(**{
|
||||||
|
# offset of text *away from* axis line in px
|
||||||
|
'tickTextOffset': 9,
|
||||||
|
})
|
||||||
|
|
||||||
def resize(self) -> None:
|
def size_to_values(self) -> None:
|
||||||
self.setWidth(self.typical_br.width())
|
self.setWidth(self.typical_br.width())
|
||||||
|
|
||||||
# XXX: drop for now since it just eats up h space
|
# XXX: drop for now since it just eats up h space
|
||||||
|
@ -112,7 +123,7 @@ class DynamicDateAxis(Axis):
|
||||||
1: '%H:%M:%S',
|
1: '%H:%M:%S',
|
||||||
}
|
}
|
||||||
|
|
||||||
def resize(self) -> None:
|
def size_to_values(self) -> None:
|
||||||
self.setHeight(self.typical_br.height() + 1)
|
self.setHeight(self.typical_br.height() + 1)
|
||||||
|
|
||||||
def _indexes_to_timestrs(
|
def _indexes_to_timestrs(
|
||||||
|
@ -151,25 +162,33 @@ class DynamicDateAxis(Axis):
|
||||||
|
|
||||||
class AxisLabel(pg.GraphicsObject):
|
class AxisLabel(pg.GraphicsObject):
|
||||||
|
|
||||||
_w_margin = 0
|
_x_margin = 0
|
||||||
_h_margin = 0
|
_y_margin = 0
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
parent: Axis,
|
parent: pg.GraphicsItem,
|
||||||
digits: int = 2,
|
digits: int = 2,
|
||||||
|
|
||||||
|
font_size_inches: Optional[float] = None,
|
||||||
bg_color: str = 'bracket',
|
bg_color: str = 'bracket',
|
||||||
fg_color: str = 'black',
|
fg_color: str = 'black',
|
||||||
opacity: int = 0,
|
opacity: int = 1, # XXX: seriously don't set this to 0
|
||||||
font_size_inches: Optional[float] = None,
|
|
||||||
|
use_arrow: bool = True,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__(parent)
|
super().__init__()
|
||||||
|
self.setParentItem(parent)
|
||||||
|
|
||||||
self.setFlag(self.ItemIgnoresTransformations)
|
self.setFlag(self.ItemIgnoresTransformations)
|
||||||
|
|
||||||
# XXX: pretty sure this is faster
|
# XXX: pretty sure this is faster
|
||||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||||
|
|
||||||
self.parent = parent
|
self._parent = parent
|
||||||
|
|
||||||
self.opacity = opacity
|
self.opacity = opacity
|
||||||
self.label_str = ''
|
self.label_str = ''
|
||||||
self.digits = digits
|
self.digits = digits
|
||||||
|
@ -177,11 +196,15 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
self._txt_br: QtCore.QRect = None
|
self._txt_br: QtCore.QRect = None
|
||||||
|
|
||||||
self._dpifont = DpiAwareFont(size_in_inches=font_size_inches)
|
self._dpifont = DpiAwareFont(size_in_inches=font_size_inches)
|
||||||
self._dpifont.configure_to_dpi(_font._screen)
|
self._dpifont.configure_to_dpi()
|
||||||
|
|
||||||
self.bg_color = pg.mkColor(hcolor(bg_color))
|
self.bg_color = pg.mkColor(hcolor(bg_color))
|
||||||
self.fg_color = pg.mkColor(hcolor(fg_color))
|
self.fg_color = pg.mkColor(hcolor(fg_color))
|
||||||
|
|
||||||
|
self._use_arrow = use_arrow
|
||||||
|
|
||||||
|
# create triangle path
|
||||||
|
self.path = None
|
||||||
self.rect = None
|
self.rect = None
|
||||||
|
|
||||||
def paint(
|
def paint(
|
||||||
|
@ -190,37 +213,65 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||||
w: QtWidgets.QWidget
|
w: QtWidgets.QWidget
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Draw a filled rectangle based on the size of ``.label_str`` text.
|
||||||
|
|
||||||
|
Subtypes can customize further by overloading ``.draw()``.
|
||||||
|
|
||||||
|
"""
|
||||||
# p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
|
# p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
|
||||||
|
|
||||||
if self.label_str:
|
if self.label_str:
|
||||||
|
|
||||||
if not self.rect:
|
# if not self.rect:
|
||||||
self._size_br_from_str(self.label_str)
|
self._size_br_from_str(self.label_str)
|
||||||
|
|
||||||
p.setFont(self._dpifont.font)
|
|
||||||
p.setPen(self.fg_color)
|
|
||||||
p.setOpacity(self.opacity)
|
|
||||||
p.fillRect(self.rect, self.bg_color)
|
|
||||||
|
|
||||||
# can be overrided in subtype
|
# can be overrided in subtype
|
||||||
self.draw(p, self.rect)
|
self.draw(p, self.rect)
|
||||||
|
|
||||||
|
p.setFont(self._dpifont.font)
|
||||||
|
p.setPen(self.fg_color)
|
||||||
p.drawText(self.rect, self.text_flags, self.label_str)
|
p.drawText(self.rect, self.text_flags, self.label_str)
|
||||||
|
|
||||||
|
|
||||||
def draw(
|
def draw(
|
||||||
self,
|
self,
|
||||||
p: QtGui.QPainter,
|
p: QtGui.QPainter,
|
||||||
rect: QtCore.QRectF
|
rect: QtCore.QRectF
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
if self._use_arrow:
|
||||||
|
if not self.path:
|
||||||
|
self._draw_arrow_path()
|
||||||
|
|
||||||
|
p.drawPath(self.path)
|
||||||
|
p.fillPath(self.path, pg.mkBrush(self.bg_color))
|
||||||
|
|
||||||
# this adds a nice black outline around the label for some odd
|
# this adds a nice black outline around the label for some odd
|
||||||
# reason; ok by us
|
# reason; ok by us
|
||||||
p.setOpacity(self.opacity)
|
p.setOpacity(self.opacity)
|
||||||
p.drawRect(self.rect)
|
|
||||||
|
# this cause the L1 labels to glitch out if used
|
||||||
|
# in the subtype and it will leave a small black strip
|
||||||
|
# with the arrow path if done before the above
|
||||||
|
p.fillRect(self.rect, self.bg_color)
|
||||||
|
|
||||||
|
|
||||||
def boundingRect(self): # noqa
|
def boundingRect(self): # noqa
|
||||||
|
"""Size the graphics space from the text contents.
|
||||||
|
|
||||||
|
"""
|
||||||
if self.label_str:
|
if self.label_str:
|
||||||
self._size_br_from_str(self.label_str)
|
self._size_br_from_str(self.label_str)
|
||||||
return self.rect
|
|
||||||
|
# if self.path:
|
||||||
|
# self.tl = self.path.controlPointRect().topLeft()
|
||||||
|
if not self.path:
|
||||||
|
self.tl = self.rect.topLeft()
|
||||||
|
|
||||||
|
return QtCore.QRectF(
|
||||||
|
self.tl,
|
||||||
|
self.rect.bottomRight(),
|
||||||
|
)
|
||||||
|
|
||||||
return QtCore.QRectF()
|
return QtCore.QRectF()
|
||||||
|
|
||||||
|
@ -232,17 +283,26 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# size the filled rect to text and/or parent axis
|
# size the filled rect to text and/or parent axis
|
||||||
br = self._txt_br = self._dpifont.boundingRect(value)
|
# if not self._txt_br:
|
||||||
|
# # XXX: this can't be c
|
||||||
|
# self._txt_br = self._dpifont.boundingRect(value)
|
||||||
|
|
||||||
txt_h, txt_w = br.height(), br.width()
|
txt_br = self._txt_br = self._dpifont.boundingRect(value)
|
||||||
|
txt_h, txt_w = txt_br.height(), txt_br.width()
|
||||||
|
|
||||||
|
# allow subtypes to specify a static width and height
|
||||||
h, w = self.size_hint()
|
h, w = self.size_hint()
|
||||||
|
|
||||||
self.rect = QtCore.QRectF(
|
self.rect = QtCore.QRectF(
|
||||||
0, 0,
|
0, 0,
|
||||||
(w or txt_w) + self._w_margin,
|
(w or txt_w) + self._x_margin /2,
|
||||||
(h or txt_h) + self._h_margin,
|
(h or txt_h) + self._y_margin /2,
|
||||||
)
|
)
|
||||||
|
# print(self.rect)
|
||||||
|
# hb = self.path.controlPointRect()
|
||||||
|
# hb_size = hb.size()
|
||||||
|
|
||||||
|
return self.rect
|
||||||
|
|
||||||
# _common_text_flags = (
|
# _common_text_flags = (
|
||||||
# QtCore.Qt.TextDontClip |
|
# QtCore.Qt.TextDontClip |
|
||||||
|
@ -254,7 +314,7 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
|
|
||||||
|
|
||||||
class XAxisLabel(AxisLabel):
|
class XAxisLabel(AxisLabel):
|
||||||
_w_margin = 4
|
_x_margin = 8
|
||||||
|
|
||||||
text_flags = (
|
text_flags = (
|
||||||
QtCore.Qt.TextDontClip
|
QtCore.Qt.TextDontClip
|
||||||
|
@ -263,32 +323,53 @@ class XAxisLabel(AxisLabel):
|
||||||
|
|
||||||
def size_hint(self) -> Tuple[float, float]:
|
def size_hint(self) -> Tuple[float, float]:
|
||||||
# size to parent axis height
|
# size to parent axis height
|
||||||
return self.parent.height(), None
|
return self._parent.height(), None
|
||||||
|
|
||||||
def update_label(
|
def update_label(
|
||||||
self,
|
self,
|
||||||
abs_pos: QPointF, # scene coords
|
abs_pos: QPointF, # scene coords
|
||||||
value: float, # data for text
|
value: float, # data for text
|
||||||
offset: int = 1 # if have margins, k?
|
offset: int = 0 # if have margins, k?
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
timestrs = self.parent._indexes_to_timestrs([int(value)])
|
timestrs = self._parent._indexes_to_timestrs([int(value)])
|
||||||
|
|
||||||
if not timestrs.any():
|
if not timestrs.any():
|
||||||
return
|
return
|
||||||
|
|
||||||
self.label_str = timestrs[0]
|
pad = 1*' '
|
||||||
|
self.label_str = pad + timestrs[0] + pad
|
||||||
|
|
||||||
|
_, y_offset = self._parent.txt_offsets()
|
||||||
|
|
||||||
w = self.boundingRect().width()
|
w = self.boundingRect().width()
|
||||||
|
|
||||||
self.setPos(QPointF(
|
self.setPos(QPointF(
|
||||||
abs_pos.x() - w / 2 - offset,
|
abs_pos.x() - w/2,
|
||||||
1,
|
y_offset/2,
|
||||||
))
|
))
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def _draw_arrow_path(self):
|
||||||
|
y_offset = self._parent.style['tickTextOffset'][1]
|
||||||
|
path = QtGui.QPainterPath()
|
||||||
|
h, w = self.rect.height(), self.rect.width()
|
||||||
|
middle = w/2 - 0.5
|
||||||
|
aw = h/2
|
||||||
|
left = middle - aw
|
||||||
|
right = middle + aw
|
||||||
|
path.moveTo(left, 0)
|
||||||
|
path.lineTo(middle, -y_offset)
|
||||||
|
path.lineTo(right, 0)
|
||||||
|
path.closeSubpath()
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
# top left point is local origin and tip of the arrow path
|
||||||
|
self.tl = QtCore.QPointF(0, -y_offset)
|
||||||
|
|
||||||
|
|
||||||
class YAxisLabel(AxisLabel):
|
class YAxisLabel(AxisLabel):
|
||||||
_h_margin = 2
|
_y_margin = 4
|
||||||
|
|
||||||
text_flags = (
|
text_flags = (
|
||||||
QtCore.Qt.AlignLeft
|
QtCore.Qt.AlignLeft
|
||||||
|
@ -297,33 +378,6 @@ class YAxisLabel(AxisLabel):
|
||||||
| QtCore.Qt.TextDontClip
|
| QtCore.Qt.TextDontClip
|
||||||
)
|
)
|
||||||
|
|
||||||
def size_hint(self) -> Tuple[float, float]:
|
|
||||||
# size to parent axis width
|
|
||||||
return None, self.parent.width()
|
|
||||||
|
|
||||||
def update_label(
|
|
||||||
self,
|
|
||||||
abs_pos: QPointF, # scene coords
|
|
||||||
value: float, # data for text
|
|
||||||
offset: int = 1 # on odd dimension and/or adds nice black line
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
# this is read inside ``.paint()``
|
|
||||||
self.label_str = ' {value:,.{digits}f}'.format(
|
|
||||||
digits=self.digits, value=value).replace(',', ' ')
|
|
||||||
|
|
||||||
br = self.boundingRect()
|
|
||||||
h = br.height()
|
|
||||||
self.setPos(QPointF(
|
|
||||||
1,
|
|
||||||
abs_pos.y() - h / 2 - offset
|
|
||||||
))
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
|
|
||||||
class YSticky(YAxisLabel):
|
|
||||||
"""Y-axis label that sticks to where it's placed despite chart resizing.
|
|
||||||
"""
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
chart,
|
chart,
|
||||||
|
@ -334,14 +388,48 @@ class YSticky(YAxisLabel):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self._chart = chart
|
self._chart = chart
|
||||||
|
|
||||||
chart.sigRangeChanged.connect(self.update_on_resize)
|
chart.sigRangeChanged.connect(self.update_on_resize)
|
||||||
|
|
||||||
self._last_datum = (None, None)
|
self._last_datum = (None, None)
|
||||||
|
|
||||||
def update_on_resize(self, vr, r):
|
# pull text offset from axis from parent axis
|
||||||
# TODO: add an `.index` to the array data-buffer layer
|
if getattr(self._parent, 'txt_offsets', False):
|
||||||
# and make this way less shitty...
|
self.x_offset, y_offset = self._parent.txt_offsets()
|
||||||
|
|
||||||
# pretty sure we did that ^ ?
|
def size_hint(self) -> Tuple[float, float]:
|
||||||
|
# size to parent axis width
|
||||||
|
return None, self._parent.width()
|
||||||
|
|
||||||
|
def update_label(
|
||||||
|
self,
|
||||||
|
abs_pos: QPointF, # scene coords
|
||||||
|
value: float, # data for text
|
||||||
|
|
||||||
|
# on odd dimension and/or adds nice black line
|
||||||
|
x_offset: Optional[int] = None
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# this is read inside ``.paint()``
|
||||||
|
self.label_str = '{value:,.{digits}f}'.format(
|
||||||
|
digits=self.digits, value=value).replace(',', ' ')
|
||||||
|
|
||||||
|
# pull text offset from axis from parent axis
|
||||||
|
x_offset = x_offset or self.x_offset
|
||||||
|
|
||||||
|
br = self.boundingRect()
|
||||||
|
h = br.height()
|
||||||
|
|
||||||
|
self.setPos(QPointF(
|
||||||
|
x_offset,
|
||||||
|
abs_pos.y() - h / 2 - self._y_margin / 2
|
||||||
|
))
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def update_on_resize(self, vr, r):
|
||||||
|
"""Tiis is a ``.sigRangeChanged()`` handler.
|
||||||
|
|
||||||
|
"""
|
||||||
index, last = self._last_datum
|
index, last = self._last_datum
|
||||||
if index is not None:
|
if index is not None:
|
||||||
self.update_from_data(index, last)
|
self.update_from_data(index, last)
|
||||||
|
@ -350,9 +438,27 @@ class YSticky(YAxisLabel):
|
||||||
self,
|
self,
|
||||||
index: int,
|
index: int,
|
||||||
value: float,
|
value: float,
|
||||||
|
_save_last: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Update the label's text contents **and** position from
|
||||||
|
a view box coordinate datum.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if _save_last:
|
||||||
self._last_datum = (index, value)
|
self._last_datum = (index, value)
|
||||||
|
|
||||||
self.update_label(
|
self.update_label(
|
||||||
self._chart.mapFromView(QPointF(index, value)),
|
self._chart.mapFromView(QPointF(index, value)),
|
||||||
value
|
value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _draw_arrow_path(self):
|
||||||
|
x_offset = self._parent.style['tickTextOffset'][0]
|
||||||
|
path = QtGui.QPainterPath()
|
||||||
|
h = self.rect.height()
|
||||||
|
path.moveTo(0, 0)
|
||||||
|
path.lineTo(-x_offset - 4, h/2.)
|
||||||
|
path.lineTo(0, h)
|
||||||
|
path.closeSubpath()
|
||||||
|
self.path = path
|
||||||
|
self.tl = path.controlPointRect().topLeft()
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -30,6 +30,7 @@ import trio
|
||||||
from ._axes import (
|
from ._axes import (
|
||||||
DynamicDateAxis,
|
DynamicDateAxis,
|
||||||
PriceAxis,
|
PriceAxis,
|
||||||
|
YAxisLabel,
|
||||||
)
|
)
|
||||||
from ._graphics._cursor import (
|
from ._graphics._cursor import (
|
||||||
Cursor,
|
Cursor,
|
||||||
|
@ -37,11 +38,11 @@ from ._graphics._cursor import (
|
||||||
)
|
)
|
||||||
from ._graphics._lines import (
|
from ._graphics._lines import (
|
||||||
level_line,
|
level_line,
|
||||||
L1Labels,
|
order_line,
|
||||||
)
|
)
|
||||||
|
from ._l1 import L1Labels
|
||||||
from ._graphics._ohlc import BarItems
|
from ._graphics._ohlc import BarItems
|
||||||
from ._graphics._curve import FastAppendCurve
|
from ._graphics._curve import FastAppendCurve
|
||||||
from ._axes import YSticky
|
|
||||||
from ._style import (
|
from ._style import (
|
||||||
_font,
|
_font,
|
||||||
hcolor,
|
hcolor,
|
||||||
|
@ -51,15 +52,15 @@ from ._style import (
|
||||||
_bars_from_right_in_follow_mode,
|
_bars_from_right_in_follow_mode,
|
||||||
_bars_to_left_in_follow_mode,
|
_bars_to_left_in_follow_mode,
|
||||||
)
|
)
|
||||||
from ..data._source import Symbol, float_digits
|
from ..data._source import Symbol
|
||||||
from .. import brokers
|
from .. import brokers
|
||||||
from .. import data
|
from .. import data
|
||||||
from ..data import maybe_open_shm_array
|
from ..data import maybe_open_shm_array
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._exec import run_qtractor, current_screen
|
from ._exec import run_qtractor, current_screen
|
||||||
from ._interaction import ChartView, open_order_mode
|
from ._interaction import ChartView
|
||||||
|
from .order_mode import start_order_mode
|
||||||
from .. import fsp
|
from .. import fsp
|
||||||
from .._ems import spawn_router_stream_alerts
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
@ -136,7 +137,10 @@ class ChartSpace(QtGui.QWidget):
|
||||||
Expects a ``numpy`` structured array containing all the ohlcv fields.
|
Expects a ``numpy`` structured array containing all the ohlcv fields.
|
||||||
"""
|
"""
|
||||||
# XXX: let's see if this causes mem problems
|
# XXX: let's see if this causes mem problems
|
||||||
self.window.setWindowTitle(f'piker chart {symbol}')
|
self.window.setWindowTitle(
|
||||||
|
f'piker chart {symbol.key}@{symbol.brokers} '
|
||||||
|
f'tick:{symbol.tick_size}'
|
||||||
|
)
|
||||||
|
|
||||||
# TODO: symbol search
|
# TODO: symbol search
|
||||||
# # of course this doesn't work :eyeroll:
|
# # of course this doesn't work :eyeroll:
|
||||||
|
@ -239,12 +243,10 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
|
|
||||||
The data input struct array must include OHLC fields.
|
The data input struct array must include OHLC fields.
|
||||||
"""
|
"""
|
||||||
self.digits = symbol.digits()
|
|
||||||
|
|
||||||
# add crosshairs
|
# add crosshairs
|
||||||
self._cursor = Cursor(
|
self._cursor = Cursor(
|
||||||
linkedsplitcharts=self,
|
linkedsplitcharts=self,
|
||||||
digits=self.digits
|
digits=symbol.digits(),
|
||||||
)
|
)
|
||||||
self.chart = self.add_plot(
|
self.chart = self.add_plot(
|
||||||
name=symbol.key,
|
name=symbol.key,
|
||||||
|
@ -304,12 +306,14 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
linked_charts=self,
|
linked_charts=self,
|
||||||
axisItems={
|
axisItems={
|
||||||
'bottom': xaxis,
|
'bottom': xaxis,
|
||||||
'right': PriceAxis(linked_charts=self)
|
'right': PriceAxis(linked_charts=self, orientation='right'),
|
||||||
|
'left': PriceAxis(linked_charts=self, orientation='left'),
|
||||||
},
|
},
|
||||||
viewBox=cv,
|
viewBox=cv,
|
||||||
cursor=self._cursor,
|
cursor=self._cursor,
|
||||||
**cpw_kwargs,
|
**cpw_kwargs,
|
||||||
)
|
)
|
||||||
|
print(f'xaxis ps: {xaxis.pos()}')
|
||||||
|
|
||||||
# give viewbox as reference to chart
|
# give viewbox as reference to chart
|
||||||
# allowing for kb controls and interactions on **this** widget
|
# allowing for kb controls and interactions on **this** widget
|
||||||
|
@ -368,6 +372,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
sig_mouse_leave = QtCore.Signal(object)
|
sig_mouse_leave = QtCore.Signal(object)
|
||||||
sig_mouse_enter = QtCore.Signal(object)
|
sig_mouse_enter = QtCore.Signal(object)
|
||||||
|
|
||||||
|
_l1_labels: L1Labels = None
|
||||||
|
|
||||||
# TODO: can take a ``background`` color setting - maybe there's
|
# TODO: can take a ``background`` color setting - maybe there's
|
||||||
# a better one?
|
# a better one?
|
||||||
|
|
||||||
|
@ -377,14 +383,22 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
name: str,
|
name: str,
|
||||||
array: np.ndarray,
|
array: np.ndarray,
|
||||||
linked_charts: LinkedSplitCharts,
|
linked_charts: LinkedSplitCharts,
|
||||||
|
|
||||||
|
view_color: str = 'papas_special',
|
||||||
|
pen_color: str = 'bracket',
|
||||||
|
|
||||||
static_yrange: Optional[Tuple[float, float]] = None,
|
static_yrange: Optional[Tuple[float, float]] = None,
|
||||||
cursor: Optional[Cursor] = None,
|
cursor: Optional[Cursor] = None,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Configure chart display settings.
|
"""Configure chart display settings.
|
||||||
"""
|
"""
|
||||||
|
self.view_color = view_color
|
||||||
|
self.pen_color = pen_color
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
background=hcolor('papas_special'),
|
background=hcolor(view_color),
|
||||||
# parent=None,
|
# parent=None,
|
||||||
# plotItem=None,
|
# plotItem=None,
|
||||||
# antialias=True,
|
# antialias=True,
|
||||||
|
@ -394,6 +408,10 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self.name = name
|
self.name = name
|
||||||
self._lc = linked_charts
|
self._lc = linked_charts
|
||||||
|
|
||||||
|
# scene-local placeholder for book graphics
|
||||||
|
# sizing to avoid overlap with data contents
|
||||||
|
self._max_l1_line_len: float = 0
|
||||||
|
|
||||||
# self.setViewportMargins(0, 0, 0, 0)
|
# self.setViewportMargins(0, 0, 0, 0)
|
||||||
self._ohlc = array # readonly view of ohlc data
|
self._ohlc = array # readonly view of ohlc data
|
||||||
|
|
||||||
|
@ -413,15 +431,16 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# show only right side axes
|
# show only right side axes
|
||||||
self.hideAxis('left')
|
self.hideAxis('left')
|
||||||
self.showAxis('right')
|
self.showAxis('right')
|
||||||
|
# self.showAxis('left')
|
||||||
|
|
||||||
# show background grid
|
# show background grid
|
||||||
self.showGrid(x=True, y=True, alpha=0.5)
|
self.showGrid(x=False, y=True, alpha=0.3)
|
||||||
|
|
||||||
self.default_view()
|
self.default_view()
|
||||||
|
|
||||||
# Assign callback for rescaling y-axis automatically
|
# Assign callback for rescaling y-axis automatically
|
||||||
# based on data contents and ``ViewBox`` state.
|
# based on data contents and ``ViewBox`` state.
|
||||||
self.sigXRangeChanged.connect(self._set_yrange)
|
# self.sigXRangeChanged.connect(self._set_yrange)
|
||||||
|
|
||||||
# for mouse wheel which doesn't seem to emit XRangeChanged
|
# for mouse wheel which doesn't seem to emit XRangeChanged
|
||||||
self._vb.sigRangeChangedManually.connect(self._set_yrange)
|
self._vb.sigRangeChangedManually.connect(self._set_yrange)
|
||||||
|
@ -429,7 +448,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# for when the splitter(s) are resized
|
# for when the splitter(s) are resized
|
||||||
self._vb.sigResized.connect(self._set_yrange)
|
self._vb.sigResized.connect(self._set_yrange)
|
||||||
|
|
||||||
def last_bar_in_view(self) -> bool:
|
def last_bar_in_view(self) -> int:
|
||||||
self._ohlc[-1]['index']
|
self._ohlc[-1]['index']
|
||||||
|
|
||||||
def update_contents_labels(
|
def update_contents_labels(
|
||||||
|
@ -499,11 +518,13 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
max=end,
|
max=end,
|
||||||
padding=0,
|
padding=0,
|
||||||
)
|
)
|
||||||
|
self._set_yrange()
|
||||||
|
|
||||||
def increment_view(
|
def increment_view(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Increment the data view one step to the right thus "following"
|
"""
|
||||||
|
Increment the data view one step to the right thus "following"
|
||||||
the current time slot/step/bar.
|
the current time slot/step/bar.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -520,12 +541,15 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
data: np.ndarray,
|
data: np.ndarray,
|
||||||
# XXX: pretty sure this is dumb and we don't need an Enum
|
|
||||||
style: pg.GraphicsObject = BarItems,
|
|
||||||
) -> pg.GraphicsObject:
|
) -> pg.GraphicsObject:
|
||||||
"""Draw OHLC datums to chart.
|
|
||||||
"""
|
"""
|
||||||
graphics = style(self.plotItem)
|
Draw OHLC datums to chart.
|
||||||
|
|
||||||
|
"""
|
||||||
|
graphics = BarItems(
|
||||||
|
self.plotItem,
|
||||||
|
pen_color=self.pen_color
|
||||||
|
)
|
||||||
|
|
||||||
# adds all bar/candle graphics objects for each data point in
|
# adds all bar/candle graphics objects for each data point in
|
||||||
# the np array buffer to be drawn on next render cycle
|
# the np array buffer to be drawn on next render cycle
|
||||||
|
@ -643,14 +667,22 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
bg_color='bracket',
|
bg_color='bracket',
|
||||||
# retreive: Callable[None, np.ndarray],
|
) -> YAxisLabel:
|
||||||
) -> YSticky:
|
|
||||||
|
# if the sticky is for our symbol
|
||||||
|
# use the tick size precision for display
|
||||||
|
sym = self._lc.symbol
|
||||||
|
if name == sym.key:
|
||||||
|
digits = sym.digits()
|
||||||
|
else:
|
||||||
|
digits = 2
|
||||||
|
|
||||||
# add y-axis "last" value label
|
# add y-axis "last" value label
|
||||||
last = self._ysticks[name] = YSticky(
|
last = self._ysticks[name] = YAxisLabel(
|
||||||
chart=self,
|
chart=self,
|
||||||
parent=self.getAxis('right'),
|
parent=self.getAxis('right'),
|
||||||
# TODO: pass this from symbol data
|
# TODO: pass this from symbol data
|
||||||
# digits=0,
|
digits=digits,
|
||||||
opacity=1,
|
opacity=1,
|
||||||
bg_color=bg_color,
|
bg_color=bg_color,
|
||||||
)
|
)
|
||||||
|
@ -701,6 +733,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
yrange: Optional[Tuple[float, float]] = None,
|
yrange: Optional[Tuple[float, float]] = None,
|
||||||
|
range_margin: float = 0.06,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set the viewable y-range based on embedded data.
|
"""Set the viewable y-range based on embedded data.
|
||||||
|
|
||||||
|
@ -760,7 +793,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
a = self._ohlc
|
a = self._ohlc
|
||||||
ifirst = a[0]['index']
|
ifirst = a[0]['index']
|
||||||
bars = a[lbar - ifirst:rbar - ifirst]
|
bars = a[lbar - ifirst:rbar - ifirst + 1]
|
||||||
|
|
||||||
if not len(bars):
|
if not len(bars):
|
||||||
# likely no data loaded yet or extreme scrolling?
|
# likely no data loaded yet or extreme scrolling?
|
||||||
|
@ -781,8 +814,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
if set_range:
|
if set_range:
|
||||||
# view margins: stay within a % of the "true range"
|
# view margins: stay within a % of the "true range"
|
||||||
diff = yhigh - ylow
|
diff = yhigh - ylow
|
||||||
ylow = ylow - (diff * 0.04)
|
ylow = ylow - (diff * range_margin)
|
||||||
yhigh = yhigh + (diff * 0.04)
|
yhigh = yhigh + (diff * range_margin)
|
||||||
|
|
||||||
self.setLimits(
|
self.setLimits(
|
||||||
yMin=ylow,
|
yMin=ylow,
|
||||||
|
@ -829,15 +862,91 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self.scene().leaveEvent(ev)
|
self.scene().leaveEvent(ev)
|
||||||
|
|
||||||
|
|
||||||
async def _async_main(
|
async def test_bed(
|
||||||
sym: str,
|
ohlcv,
|
||||||
brokername: str,
|
chart,
|
||||||
|
lc,
|
||||||
|
):
|
||||||
|
sleep = 6
|
||||||
|
|
||||||
|
# from PyQt5.QtCore import QPointF
|
||||||
|
vb = chart._vb
|
||||||
|
# scene = vb.scene()
|
||||||
|
|
||||||
|
# raxis = chart.getAxis('right')
|
||||||
|
# vb_right = vb.boundingRect().right()
|
||||||
|
|
||||||
|
last, i_end = ohlcv.array[-1][['close', 'index']]
|
||||||
|
|
||||||
|
line = order_line(
|
||||||
|
chart,
|
||||||
|
level=last,
|
||||||
|
level_digits=2
|
||||||
|
)
|
||||||
|
# eps = line.getEndpoints()
|
||||||
|
|
||||||
|
# llabel = line._labels[1][1]
|
||||||
|
|
||||||
|
line.update_labels({'level': last})
|
||||||
|
return
|
||||||
|
|
||||||
|
# rl = eps[1]
|
||||||
|
# rlabel.setPos(rl)
|
||||||
|
|
||||||
|
# ti = pg.TextItem(text='Fuck you')
|
||||||
|
# ti.setPos(pg.Point(i_end, last))
|
||||||
|
# ti.setParentItem(line)
|
||||||
|
# ti.setAnchor(pg.Point(1, 1))
|
||||||
|
# vb.addItem(ti)
|
||||||
|
# chart.plotItem.addItem(ti)
|
||||||
|
|
||||||
|
from ._label import Label
|
||||||
|
|
||||||
|
txt = Label(
|
||||||
|
vb,
|
||||||
|
fmt_str='fuck {it}',
|
||||||
|
)
|
||||||
|
txt.format(it='boy')
|
||||||
|
txt.place_on_scene('left')
|
||||||
|
txt.set_view_y(last)
|
||||||
|
|
||||||
|
# txt = QtGui.QGraphicsTextItem()
|
||||||
|
# txt.setPlainText("FUCK YOU")
|
||||||
|
# txt.setFont(_font.font)
|
||||||
|
# txt.setDefaultTextColor(pg.mkColor(hcolor('bracket')))
|
||||||
|
# # txt.setParentItem(vb)
|
||||||
|
# w = txt.boundingRect().width()
|
||||||
|
# scene.addItem(txt)
|
||||||
|
|
||||||
|
# txt.setParentItem(line)
|
||||||
|
# d_coords = vb.mapFromView(QPointF(i_end, last))
|
||||||
|
# txt.setPos(vb_right - w, d_coords.y())
|
||||||
|
# txt.show()
|
||||||
|
# txt.update()
|
||||||
|
|
||||||
|
# rlabel.setPos(vb_right - 2*w, d_coords.y())
|
||||||
|
# rlabel.show()
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
await trio.sleep(sleep)
|
||||||
|
await tractor.breakpoint()
|
||||||
|
txt.format(it=f'dog_{i}')
|
||||||
|
# d_coords = vb.mapFromView(QPointF(i_end, last))
|
||||||
|
# txt.setPos(vb_right - w, d_coords.y())
|
||||||
|
# txt.setPlainText(f"FUCK YOU {i}")
|
||||||
|
i += 1
|
||||||
|
# rlabel.setPos(vb_right - 2*w, d_coords.y())
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_main(
|
||||||
# implicit required argument provided by ``qtractor_run()``
|
# implicit required argument provided by ``qtractor_run()``
|
||||||
widgets: Dict[str, Any],
|
widgets: Dict[str, Any],
|
||||||
|
|
||||||
# all kwargs are passed through from the CLI entrypoint
|
sym: str,
|
||||||
loglevel: str = None,
|
brokername: str,
|
||||||
|
loglevel: str,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main Qt-trio routine invoked by the Qt loop with
|
"""Main Qt-trio routine invoked by the Qt loop with
|
||||||
the widgets ``dict``.
|
the widgets ``dict``.
|
||||||
|
@ -852,8 +961,6 @@ async def _async_main(
|
||||||
# historical data fetch
|
# historical data fetch
|
||||||
brokermod = brokers.get_brokermod(brokername)
|
brokermod = brokers.get_brokermod(brokername)
|
||||||
|
|
||||||
symbol = Symbol(sym, [brokername])
|
|
||||||
|
|
||||||
async with data.open_feed(
|
async with data.open_feed(
|
||||||
brokername,
|
brokername,
|
||||||
[sym],
|
[sym],
|
||||||
|
@ -862,6 +969,7 @@ async def _async_main(
|
||||||
|
|
||||||
ohlcv = feed.shm
|
ohlcv = feed.shm
|
||||||
bars = ohlcv.array
|
bars = ohlcv.array
|
||||||
|
symbol = feed.symbols[sym]
|
||||||
|
|
||||||
# load in symbol's ohlc data
|
# load in symbol's ohlc data
|
||||||
linked_charts, chart = chart_app.load_symbol(symbol, bars)
|
linked_charts, chart = chart_app.load_symbol(symbol, bars)
|
||||||
|
@ -950,26 +1058,14 @@ async def _async_main(
|
||||||
linked_charts
|
linked_charts
|
||||||
)
|
)
|
||||||
|
|
||||||
async with open_order_mode(
|
# interactive testing
|
||||||
chart,
|
# n.start_soon(
|
||||||
) as order_mode:
|
# test_bed,
|
||||||
|
# ohlcv,
|
||||||
# TODO: this should probably be implicitly spawned
|
# chart,
|
||||||
# inside the above mngr?
|
# linked_charts,
|
||||||
|
# )
|
||||||
# spawn EMS actor-service
|
await start_order_mode(chart, symbol, brokername)
|
||||||
to_ems_chan = await n.start(
|
|
||||||
spawn_router_stream_alerts,
|
|
||||||
order_mode,
|
|
||||||
symbol,
|
|
||||||
)
|
|
||||||
|
|
||||||
# wait for router to come up before setting
|
|
||||||
# enabling send channel on chart
|
|
||||||
linked_charts._to_ems = to_ems_chan
|
|
||||||
|
|
||||||
# probably where we'll eventually start the user input loop
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
|
|
||||||
async def chart_from_quotes(
|
async def chart_from_quotes(
|
||||||
|
@ -1017,7 +1113,7 @@ async def chart_from_quotes(
|
||||||
# sym = chart.name
|
# sym = chart.name
|
||||||
# mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym])
|
# mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym])
|
||||||
|
|
||||||
return last_bars_range, mx, mn
|
return last_bars_range, mx, max(mn, 0)
|
||||||
|
|
||||||
chart.default_view()
|
chart.default_view()
|
||||||
|
|
||||||
|
@ -1025,12 +1121,15 @@ async def chart_from_quotes(
|
||||||
|
|
||||||
last, volume = ohlcv.array[-1][['close', 'volume']]
|
last, volume = ohlcv.array[-1][['close', 'volume']]
|
||||||
|
|
||||||
|
symbol = chart._lc.symbol
|
||||||
|
|
||||||
l1 = L1Labels(
|
l1 = L1Labels(
|
||||||
chart,
|
chart,
|
||||||
# determine precision/decimal lengths
|
# determine precision/decimal lengths
|
||||||
digits=max(float_digits(last), 2),
|
digits=symbol.digits(),
|
||||||
size_digits=min(float_digits(last), 3)
|
size_digits=symbol.lot_digits(),
|
||||||
)
|
)
|
||||||
|
chart._l1_labels = l1
|
||||||
|
|
||||||
# TODO:
|
# TODO:
|
||||||
# - in theory we should be able to read buffer data faster
|
# - in theory we should be able to read buffer data faster
|
||||||
|
@ -1040,6 +1139,9 @@ async def chart_from_quotes(
|
||||||
# levels this might be dark volume we need to
|
# levels this might be dark volume we need to
|
||||||
# present differently?
|
# present differently?
|
||||||
|
|
||||||
|
tick_size = chart._lc.symbol.tick_size
|
||||||
|
tick_margin = 2 * tick_size
|
||||||
|
|
||||||
async for quotes in stream:
|
async for quotes in stream:
|
||||||
for sym, quote in quotes.items():
|
for sym, quote in quotes.items():
|
||||||
|
|
||||||
|
@ -1050,20 +1152,18 @@ async def chart_from_quotes(
|
||||||
price = tick.get('price')
|
price = tick.get('price')
|
||||||
size = tick.get('size')
|
size = tick.get('size')
|
||||||
|
|
||||||
# compute max and min trade values to display in view
|
if ticktype == 'n/a' or price == -1:
|
||||||
# TODO: we need a streaming minmax algorithm here, see
|
# okkk..
|
||||||
# def above.
|
continue
|
||||||
brange, mx_in_view, mn_in_view = maxmin()
|
|
||||||
l, lbar, rbar, r = brange
|
|
||||||
|
|
||||||
if ticktype in ('trade', 'utrade', 'last'):
|
if ticktype in ('trade', 'utrade', 'last'):
|
||||||
|
|
||||||
array = ohlcv.array
|
array = ohlcv.array
|
||||||
|
|
||||||
# update price sticky(s)
|
# update price sticky(s)
|
||||||
last = array[-1]
|
end = array[-1]
|
||||||
last_price_sticky.update_from_data(
|
last_price_sticky.update_from_data(
|
||||||
*last[['index', 'close']]
|
*end[['index', 'close']]
|
||||||
)
|
)
|
||||||
|
|
||||||
# plot bars
|
# plot bars
|
||||||
|
@ -1075,53 +1175,64 @@ async def chart_from_quotes(
|
||||||
|
|
||||||
if wap_in_history:
|
if wap_in_history:
|
||||||
# update vwap overlay line
|
# update vwap overlay line
|
||||||
chart.update_curve_from_array('bar_wap', ohlcv.array)
|
chart.update_curve_from_array(
|
||||||
|
'bar_wap', ohlcv.array)
|
||||||
|
|
||||||
|
# compute max and min trade values to display in view
|
||||||
|
# TODO: we need a streaming minmax algorithm here, see
|
||||||
|
# def above.
|
||||||
|
brange, mx_in_view, mn_in_view = maxmin()
|
||||||
|
l, lbar, rbar, r = brange
|
||||||
|
|
||||||
|
mx = mx_in_view + tick_margin
|
||||||
|
mn = mn_in_view - tick_margin
|
||||||
|
|
||||||
# XXX: prettty sure this is correct?
|
# XXX: prettty sure this is correct?
|
||||||
# if ticktype in ('trade', 'last'):
|
# if ticktype in ('trade', 'last'):
|
||||||
if ticktype in ('last',): # 'size'):
|
if ticktype in ('last',): # 'size'):
|
||||||
|
|
||||||
label = {
|
label = {
|
||||||
l1.ask_label.level: l1.ask_label,
|
l1.ask_label.fields['level']: l1.ask_label,
|
||||||
l1.bid_label.level: l1.bid_label,
|
l1.bid_label.fields['level']: l1.bid_label,
|
||||||
}.get(price)
|
}.get(price)
|
||||||
|
|
||||||
if label is not None:
|
if label is not None:
|
||||||
label.size = size
|
label.update_fields({'level': price, 'size': size})
|
||||||
label.update_from_data(0, price)
|
|
||||||
|
|
||||||
# on trades should we be knocking down
|
# on trades should we be knocking down
|
||||||
# the relevant L1 queue?
|
# the relevant L1 queue?
|
||||||
# label.size -= size
|
# label.size -= size
|
||||||
|
|
||||||
elif ticktype in ('ask', 'asize'):
|
elif ticktype in ('ask', 'asize'):
|
||||||
l1.ask_label.size = size
|
l1.ask_label.update_fields({'level': price, 'size': size})
|
||||||
l1.ask_label.update_from_data(0, price)
|
|
||||||
|
|
||||||
# update max price in view to keep ask on screen
|
|
||||||
mx_in_view = max(price, mx_in_view)
|
|
||||||
|
|
||||||
elif ticktype in ('bid', 'bsize'):
|
elif ticktype in ('bid', 'bsize'):
|
||||||
l1.bid_label.size = size
|
l1.bid_label.update_fields({'level': price, 'size': size})
|
||||||
l1.bid_label.update_from_data(0, price)
|
|
||||||
|
|
||||||
# update min price in view to keep bid on screen
|
# update min price in view to keep bid on screen
|
||||||
mn_in_view = min(price, mn_in_view)
|
mn = min(price - tick_margin, mn)
|
||||||
|
# update max price in view to keep ask on screen
|
||||||
|
mx = max(price + tick_margin, mx)
|
||||||
|
|
||||||
if mx_in_view > last_mx or mn_in_view < last_mn:
|
if (mx > last_mx) or (
|
||||||
chart._set_yrange(yrange=(mn_in_view, mx_in_view))
|
mn < last_mn
|
||||||
last_mx, last_mn = mx_in_view, mn_in_view
|
):
|
||||||
|
# print(f'new y range: {(mn, mx)}')
|
||||||
|
|
||||||
if brange != last_bars_range:
|
chart._set_yrange(
|
||||||
# we **must always** update the last values due to
|
yrange=(mn, mx),
|
||||||
# the x-range change
|
# TODO: we should probably scale
|
||||||
last_mx, last_mn = mx_in_view, mn_in_view
|
# the view margin based on the size
|
||||||
last_bars_range = brange
|
# of the true range? This way you can
|
||||||
|
# slap in orders outside the current
|
||||||
|
# L1 (only) book range.
|
||||||
|
# range_margin=0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_mx, last_mn = mx, mn
|
||||||
|
|
||||||
|
|
||||||
async def spawn_fsps(
|
async def spawn_fsps(
|
||||||
linked_charts: LinkedSplitCharts,
|
linked_charts: LinkedSplitCharts,
|
||||||
# fsp_func_name,
|
|
||||||
fsps: Dict[str, str],
|
fsps: Dict[str, str],
|
||||||
sym,
|
sym,
|
||||||
src_shm,
|
src_shm,
|
||||||
|
@ -1289,12 +1400,13 @@ async def update_signals(
|
||||||
# graphics.curve.setBrush(50, 50, 200, 100)
|
# graphics.curve.setBrush(50, 50, 200, 100)
|
||||||
# graphics.curve.setFillLevel(50)
|
# graphics.curve.setFillLevel(50)
|
||||||
|
|
||||||
|
if fsp_func_name == 'rsi':
|
||||||
# add moveable over-[sold/bought] lines
|
# add moveable over-[sold/bought] lines
|
||||||
# and labels only for the 70/30 lines
|
# and labels only for the 70/30 lines
|
||||||
level_line(chart, 20, show_label=False)
|
level_line(chart, 20)
|
||||||
level_line(chart, 30, orient_v='top')
|
level_line(chart, 30, orient_v='top')
|
||||||
level_line(chart, 70, orient_v='bottom')
|
level_line(chart, 70, orient_v='bottom')
|
||||||
level_line(chart, 80, orient_v='top', show_label=False)
|
level_line(chart, 80, orient_v='top')
|
||||||
|
|
||||||
chart._set_yrange()
|
chart._set_yrange()
|
||||||
|
|
||||||
|
@ -1331,6 +1443,7 @@ async def update_signals(
|
||||||
async def check_for_new_bars(feed, ohlcv, linked_charts):
|
async def check_for_new_bars(feed, ohlcv, linked_charts):
|
||||||
"""Task which updates from new bars in the shared ohlcv buffer every
|
"""Task which updates from new bars in the shared ohlcv buffer every
|
||||||
``delay_s`` seconds.
|
``delay_s`` seconds.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# TODO: right now we'll spin printing bars if the last time
|
# TODO: right now we'll spin printing bars if the last time
|
||||||
# stamp is before a large period of no market activity.
|
# stamp is before a large period of no market activity.
|
||||||
|
@ -1358,12 +1471,6 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
|
||||||
# current bar) and then either write the current bar manually
|
# current bar) and then either write the current bar manually
|
||||||
# or place a cursor for visual cue of the current time step.
|
# or place a cursor for visual cue of the current time step.
|
||||||
|
|
||||||
# price_chart.update_ohlc_from_array(
|
|
||||||
# price_chart.name,
|
|
||||||
# ohlcv.array,
|
|
||||||
# just_history=True,
|
|
||||||
# )
|
|
||||||
|
|
||||||
# XXX: this puts a flat bar on the current time step
|
# XXX: this puts a flat bar on the current time step
|
||||||
# TODO: if we eventually have an x-axis time-step "cursor"
|
# TODO: if we eventually have an x-axis time-step "cursor"
|
||||||
# we can get rid of this since it is extra overhead.
|
# we can get rid of this since it is extra overhead.
|
||||||
|
@ -1373,9 +1480,6 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
|
||||||
just_history=False,
|
just_history=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# resize view
|
|
||||||
# price_chart._set_yrange()
|
|
||||||
|
|
||||||
for name in price_chart._overlays:
|
for name in price_chart._overlays:
|
||||||
|
|
||||||
price_chart.update_curve_from_array(
|
price_chart.update_curve_from_array(
|
||||||
|
@ -1383,15 +1487,8 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
|
||||||
price_chart._arrays[name]
|
price_chart._arrays[name]
|
||||||
)
|
)
|
||||||
|
|
||||||
# # TODO: standard api for signal lookups per plot
|
|
||||||
# if name in price_chart._ohlc.dtype.fields:
|
|
||||||
|
|
||||||
# # should have already been incremented above
|
|
||||||
# price_chart.update_curve_from_array(name, price_chart._ohlc)
|
|
||||||
|
|
||||||
for name, chart in linked_charts.subplots.items():
|
for name, chart in linked_charts.subplots.items():
|
||||||
chart.update_curve_from_array(chart.name, chart._shm.array)
|
chart.update_curve_from_array(chart.name, chart._shm.array)
|
||||||
# chart._set_yrange()
|
|
||||||
|
|
||||||
# shift the view if in follow mode
|
# shift the view if in follow mode
|
||||||
price_chart.increment_view()
|
price_chart.increment_view()
|
||||||
|
@ -1400,14 +1497,16 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
|
||||||
def _main(
|
def _main(
|
||||||
sym: str,
|
sym: str,
|
||||||
brokername: str,
|
brokername: str,
|
||||||
|
piker_loglevel: str,
|
||||||
tractor_kwargs,
|
tractor_kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Sync entry point to start a chart app.
|
"""Sync entry point to start a chart app.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Qt entry point
|
# Qt entry point
|
||||||
run_qtractor(
|
run_qtractor(
|
||||||
func=_async_main,
|
func=_async_main,
|
||||||
args=(sym, brokername),
|
args=(sym, brokername, piker_loglevel),
|
||||||
main_widget=ChartSpace,
|
main_widget=ChartSpace,
|
||||||
tractor_kwargs=tractor_kwargs,
|
tractor_kwargs=tractor_kwargs,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -20,11 +20,11 @@ Trio - Qt integration
|
||||||
Run ``trio`` in guest mode on top of the Qt event loop.
|
Run ``trio`` in guest mode on top of the Qt event loop.
|
||||||
All global Qt runtime settings are mostly defined here.
|
All global Qt runtime settings are mostly defined here.
|
||||||
"""
|
"""
|
||||||
|
from typing import Tuple, Callable, Dict, Any
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from functools import partial
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Tuple, Callable, Dict, Any
|
|
||||||
|
|
||||||
# Qt specific
|
# Qt specific
|
||||||
import PyQt5 # noqa
|
import PyQt5 # noqa
|
||||||
|
@ -32,19 +32,29 @@ import pyqtgraph as pg
|
||||||
from pyqtgraph import QtGui
|
from pyqtgraph import QtGui
|
||||||
from PyQt5 import QtCore
|
from PyQt5 import QtCore
|
||||||
from PyQt5.QtCore import (
|
from PyQt5.QtCore import (
|
||||||
pyqtRemoveInputHook, Qt, QCoreApplication
|
pyqtRemoveInputHook,
|
||||||
|
Qt,
|
||||||
|
QCoreApplication,
|
||||||
)
|
)
|
||||||
import qdarkstyle
|
import qdarkstyle
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
from outcome import Error
|
from outcome import Error
|
||||||
|
|
||||||
|
from ..log import get_logger
|
||||||
|
from ._pg_overrides import _do_overrides
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
# pyqtgraph global config
|
# pyqtgraph global config
|
||||||
# might as well enable this for now?
|
# might as well enable this for now?
|
||||||
pg.useOpenGL = True
|
pg.useOpenGL = True
|
||||||
pg.enableExperimental = True
|
pg.enableExperimental = True
|
||||||
|
|
||||||
|
# engage core tweaks that give us better response
|
||||||
|
# latency then the average pg user
|
||||||
|
_do_overrides()
|
||||||
|
|
||||||
|
|
||||||
# singleton app per actor
|
# singleton app per actor
|
||||||
_qt_app: QtGui.QApplication = None
|
_qt_app: QtGui.QApplication = None
|
||||||
|
@ -52,18 +62,40 @@ _qt_win: QtGui.QMainWindow = None
|
||||||
|
|
||||||
|
|
||||||
def current_screen() -> QtGui.QScreen:
|
def current_screen() -> QtGui.QScreen:
|
||||||
|
"""Get a frickin screen (if we can, gawd).
|
||||||
|
|
||||||
|
"""
|
||||||
global _qt_win, _qt_app
|
global _qt_win, _qt_app
|
||||||
return _qt_app.screenAt(_qt_win.centralWidget().geometry().center())
|
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
tries = 3
|
||||||
|
for _ in range(3):
|
||||||
|
screen = _qt_app.screenAt(_qt_win.pos())
|
||||||
|
print(f'trying to get screen....')
|
||||||
|
if screen is None:
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if screen is None:
|
||||||
|
# try for the first one we can find
|
||||||
|
screen = _qt_app.screens()[0]
|
||||||
|
|
||||||
|
assert screen, "Wow Qt is dumb as shit and has no screen..."
|
||||||
|
return screen
|
||||||
|
|
||||||
|
# XXX: pretty sure none of this shit works
|
||||||
|
# https://bugreports.qt.io/browse/QTBUG-53022
|
||||||
|
|
||||||
# Proper high DPI scaling is available in Qt >= 5.6.0. This attibute
|
# Proper high DPI scaling is available in Qt >= 5.6.0. This attibute
|
||||||
# must be set before creating the application
|
# must be set before creating the application
|
||||||
if hasattr(Qt, 'AA_EnableHighDpiScaling'):
|
# if hasattr(Qt, 'AA_EnableHighDpiScaling'):
|
||||||
QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
# QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||||
|
|
||||||
if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
|
# if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
|
||||||
QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
# QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QtGui.QMainWindow):
|
class MainWindow(QtGui.QMainWindow):
|
||||||
|
@ -78,7 +110,7 @@ class MainWindow(QtGui.QMainWindow):
|
||||||
|
|
||||||
def closeEvent(
|
def closeEvent(
|
||||||
self,
|
self,
|
||||||
event: 'QCloseEvent'
|
event: QtGui.QCloseEvent,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Cancel the root actor asap.
|
"""Cancel the root actor asap.
|
||||||
|
|
||||||
|
@ -169,8 +201,8 @@ def run_qtractor(
|
||||||
),
|
),
|
||||||
name='qtractor',
|
name='qtractor',
|
||||||
**tractor_kwargs,
|
**tractor_kwargs,
|
||||||
) as a:
|
):
|
||||||
await func(*(args + (widgets,)))
|
await func(*((widgets,) + args))
|
||||||
|
|
||||||
# guest mode entry
|
# guest mode entry
|
||||||
trio.lowlevel.start_guest_run(
|
trio.lowlevel.start_guest_run(
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Mouse interaction graphics
|
Mouse interaction graphics
|
||||||
|
|
||||||
|
@ -23,7 +24,7 @@ import inspect
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PyQt5 import QtCore, QtGui
|
from PyQt5 import QtCore, QtGui
|
||||||
from PyQt5.QtCore import QPointF
|
from PyQt5.QtCore import QPointF, QRectF
|
||||||
|
|
||||||
from .._style import (
|
from .._style import (
|
||||||
_xaxis_at,
|
_xaxis_at,
|
||||||
|
@ -54,7 +55,7 @@ class LineDot(pg.CurvePoint):
|
||||||
index: int,
|
index: int,
|
||||||
plot: 'ChartPlotWidget', # type: ingore # noqa
|
plot: 'ChartPlotWidget', # type: ingore # noqa
|
||||||
pos=None,
|
pos=None,
|
||||||
size: int = 2, # in pxs
|
size: int = 6, # in pxs
|
||||||
color: str = 'default_light',
|
color: str = 'default_light',
|
||||||
) -> None:
|
) -> None:
|
||||||
pg.CurvePoint.__init__(
|
pg.CurvePoint.__init__(
|
||||||
|
@ -112,27 +113,32 @@ class LineDot(pg.CurvePoint):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: likely will need to tweak this based on dpi...
|
||||||
|
_y_margin = 5
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: change this into our own ``Label``
|
||||||
|
class ContentsLabel(pg.LabelItem):
|
||||||
|
"""Label anchored to a ``ViewBox`` typically for displaying
|
||||||
|
datum-wise points from the "viewed" contents.
|
||||||
|
|
||||||
|
"""
|
||||||
_corner_anchors = {
|
_corner_anchors = {
|
||||||
'top': 0,
|
'top': 0,
|
||||||
'left': 0,
|
'left': 0,
|
||||||
'bottom': 1,
|
'bottom': 1,
|
||||||
'right': 1,
|
'right': 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
# XXX: fyi naming here is confusing / opposite to coords
|
# XXX: fyi naming here is confusing / opposite to coords
|
||||||
_corner_margins = {
|
_corner_margins = {
|
||||||
('top', 'left'): (-4, -5),
|
('top', 'left'): (-4, -_y_margin),
|
||||||
('top', 'right'): (4, -5),
|
('top', 'right'): (4, -_y_margin),
|
||||||
|
|
||||||
('bottom', 'left'): (-4, lambda font_size: font_size * 2),
|
('bottom', 'left'): (-4, lambda font_size: font_size + 2*_y_margin),
|
||||||
('bottom', 'right'): (4, lambda font_size: font_size * 2),
|
('bottom', 'right'): (4, lambda font_size: font_size + 2*_y_margin),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ContentsLabel(pg.LabelItem):
|
|
||||||
"""Label anchored to a ``ViewBox`` typically for displaying
|
|
||||||
datum-wise points from the "viewed" contents.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
chart: 'ChartPlotWidget', # noqa
|
chart: 'ChartPlotWidget', # noqa
|
||||||
|
@ -152,8 +158,8 @@ class ContentsLabel(pg.LabelItem):
|
||||||
self.chart = chart
|
self.chart = chart
|
||||||
|
|
||||||
v, h = anchor_at
|
v, h = anchor_at
|
||||||
index = (_corner_anchors[h], _corner_anchors[v])
|
index = (self._corner_anchors[h], self._corner_anchors[v])
|
||||||
margins = _corner_margins[(v, h)]
|
margins = self._corner_margins[(v, h)]
|
||||||
|
|
||||||
ydim = margins[1]
|
ydim = margins[1]
|
||||||
if inspect.isfunction(margins[1]):
|
if inspect.isfunction(margins[1]):
|
||||||
|
@ -213,7 +219,7 @@ class Cursor(pg.GraphicsObject):
|
||||||
style=QtCore.Qt.DashLine,
|
style=QtCore.Qt.DashLine,
|
||||||
)
|
)
|
||||||
self.lines_pen = pg.mkPen(
|
self.lines_pen = pg.mkPen(
|
||||||
color='#a9a9a9', # gray?
|
color=hcolor('davies'),
|
||||||
style=QtCore.Qt.DashLine,
|
style=QtCore.Qt.DashLine,
|
||||||
)
|
)
|
||||||
self.lsc = linkedsplitcharts
|
self.lsc = linkedsplitcharts
|
||||||
|
@ -226,6 +232,18 @@ class Cursor(pg.GraphicsObject):
|
||||||
self._hovered: Set[pg.GraphicsObject] = set()
|
self._hovered: Set[pg.GraphicsObject] = set()
|
||||||
self._trackers: Set[pg.GraphicsObject] = set()
|
self._trackers: Set[pg.GraphicsObject] = set()
|
||||||
|
|
||||||
|
# value used for rounding y-axis discreet tick steps
|
||||||
|
# computing once, up front, here cuz why not
|
||||||
|
self._y_incr_mult = 1 / self.lsc._symbol.tick_size
|
||||||
|
|
||||||
|
# line width in view coordinates
|
||||||
|
self._lw = self.pixelWidth() * self.lines_pen.width()
|
||||||
|
|
||||||
|
# xhair label's color name
|
||||||
|
self.label_color: str = 'default'
|
||||||
|
|
||||||
|
self._y_label_update: bool = True
|
||||||
|
|
||||||
def add_hovered(
|
def add_hovered(
|
||||||
self,
|
self,
|
||||||
item: pg.GraphicsObject,
|
item: pg.GraphicsObject,
|
||||||
|
@ -240,6 +258,7 @@ class Cursor(pg.GraphicsObject):
|
||||||
) -> None:
|
) -> None:
|
||||||
# add ``pg.graphicsItems.InfiniteLine``s
|
# add ``pg.graphicsItems.InfiniteLine``s
|
||||||
# vertical and horizonal lines and a y-axis label
|
# vertical and horizonal lines and a y-axis label
|
||||||
|
|
||||||
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
|
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
|
||||||
vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||||
|
|
||||||
|
@ -248,10 +267,11 @@ class Cursor(pg.GraphicsObject):
|
||||||
hl.hide()
|
hl.hide()
|
||||||
|
|
||||||
yl = YAxisLabel(
|
yl = YAxisLabel(
|
||||||
|
chart=plot,
|
||||||
parent=plot.getAxis('right'),
|
parent=plot.getAxis('right'),
|
||||||
digits=digits or self.digits,
|
digits=digits or self.digits,
|
||||||
opacity=_ch_label_opac,
|
opacity=_ch_label_opac,
|
||||||
bg_color='default',
|
bg_color=self.label_color,
|
||||||
)
|
)
|
||||||
yl.hide() # on startup if mouse is off screen
|
yl.hide() # on startup if mouse is off screen
|
||||||
|
|
||||||
|
@ -291,7 +311,7 @@ class Cursor(pg.GraphicsObject):
|
||||||
self.xaxis_label = XAxisLabel(
|
self.xaxis_label = XAxisLabel(
|
||||||
parent=self.plots[plot_index].getAxis('bottom'),
|
parent=self.plots[plot_index].getAxis('bottom'),
|
||||||
opacity=_ch_label_opac,
|
opacity=_ch_label_opac,
|
||||||
bg_color='default',
|
bg_color=self.label_color,
|
||||||
)
|
)
|
||||||
# place label off-screen during startup
|
# place label off-screen during startup
|
||||||
self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0)))
|
self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0)))
|
||||||
|
@ -347,33 +367,46 @@ class Cursor(pg.GraphicsObject):
|
||||||
x, y = mouse_point.x(), mouse_point.y()
|
x, y = mouse_point.x(), mouse_point.y()
|
||||||
plot = self.active_plot
|
plot = self.active_plot
|
||||||
|
|
||||||
# update y-range items
|
|
||||||
self.graphics[plot]['hl'].setY(y)
|
|
||||||
|
|
||||||
|
|
||||||
self.graphics[self.active_plot]['yl'].update_label(
|
|
||||||
abs_pos=pos, value=y
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update x if cursor changed after discretization calc
|
# Update x if cursor changed after discretization calc
|
||||||
# (this saves draw cycles on small mouse moves)
|
# (this saves draw cycles on small mouse moves)
|
||||||
lastx, lasty = self._datum_xy
|
last_ix, last_iy = self._datum_xy
|
||||||
|
|
||||||
ix = round(x) # since bars are centered around index
|
ix = round(x) # since bars are centered around index
|
||||||
|
|
||||||
|
# round y value to nearest tick step
|
||||||
|
m = self._y_incr_mult
|
||||||
|
iy = round(y * m) / m
|
||||||
|
|
||||||
|
# px perfect...
|
||||||
|
line_offset = self._lw / 2
|
||||||
|
|
||||||
|
# update y-range items
|
||||||
|
if iy != last_iy:
|
||||||
|
|
||||||
|
if self._y_label_update:
|
||||||
|
self.graphics[self.active_plot]['yl'].update_label(
|
||||||
|
abs_pos=plot.mapFromView(QPointF(ix, iy + line_offset)),
|
||||||
|
value=iy
|
||||||
|
)
|
||||||
|
|
||||||
|
# only update horizontal xhair line if label is enabled
|
||||||
|
self.graphics[plot]['hl'].setY(iy + line_offset)
|
||||||
|
|
||||||
|
|
||||||
# update all trackers
|
# update all trackers
|
||||||
for item in self._trackers:
|
for item in self._trackers:
|
||||||
# print(f'setting {item} with {(ix, y)}')
|
# print(f'setting {item} with {(ix, y)}')
|
||||||
item.on_tracked_source(ix, y)
|
item.on_tracked_source(ix, iy)
|
||||||
|
|
||||||
if ix != lastx:
|
if ix != last_ix:
|
||||||
for plot, opts in self.graphics.items():
|
for plot, opts in self.graphics.items():
|
||||||
|
|
||||||
# move the vertical line to the current "center of bar"
|
|
||||||
opts['vl'].setX(ix)
|
|
||||||
|
|
||||||
# update the chart's "contents" label
|
# update the chart's "contents" label
|
||||||
plot.update_contents_labels(ix)
|
plot.update_contents_labels(ix)
|
||||||
|
|
||||||
|
# move the vertical line to the current "center of bar"
|
||||||
|
opts['vl'].setX(ix + line_offset)
|
||||||
|
|
||||||
# update all subscribed curve dots
|
# update all subscribed curve dots
|
||||||
for cursor in opts.get('cursors', ()):
|
for cursor in opts.get('cursors', ()):
|
||||||
cursor.setIndex(ix)
|
cursor.setIndex(ix)
|
||||||
|
@ -386,14 +419,63 @@ class Cursor(pg.GraphicsObject):
|
||||||
# otherwise gobbles tons of CPU..
|
# otherwise gobbles tons of CPU..
|
||||||
|
|
||||||
# map back to abs (label-local) coordinates
|
# map back to abs (label-local) coordinates
|
||||||
abs_pos=plot.mapFromView(QPointF(ix, y)),
|
abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)),
|
||||||
value=x,
|
value=ix,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._datum_xy = ix, y
|
self._datum_xy = ix, iy
|
||||||
|
|
||||||
def boundingRect(self):
|
def boundingRect(self) -> QRectF:
|
||||||
try:
|
try:
|
||||||
return self.active_plot.boundingRect()
|
return self.active_plot.boundingRect()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return self.plots[0].boundingRect()
|
return self.plots[0].boundingRect()
|
||||||
|
|
||||||
|
def show_xhair(
|
||||||
|
self,
|
||||||
|
y_label_level: float = None,
|
||||||
|
) -> None:
|
||||||
|
g = self.graphics[self.active_plot]
|
||||||
|
# show horiz line and y-label
|
||||||
|
g['hl'].show()
|
||||||
|
g['vl'].show()
|
||||||
|
|
||||||
|
self._y_label_update = True
|
||||||
|
yl = g['yl']
|
||||||
|
# yl.fg_color = pg.mkColor(hcolor('black'))
|
||||||
|
# yl.bg_color = pg.mkColor(hcolor(self.label_color))
|
||||||
|
if y_label_level:
|
||||||
|
yl.update_from_data(0, y_label_level, _save_last=False)
|
||||||
|
|
||||||
|
yl.show()
|
||||||
|
|
||||||
|
def hide_xhair(
|
||||||
|
self,
|
||||||
|
hide_label: bool = False,
|
||||||
|
y_label_level: float = None,
|
||||||
|
just_vertical: bool = False,
|
||||||
|
fg_color: str = None,
|
||||||
|
# bg_color: str = 'papas_special',
|
||||||
|
) -> None:
|
||||||
|
g = self.graphics[self.active_plot]
|
||||||
|
|
||||||
|
hl = g['hl']
|
||||||
|
if not just_vertical:
|
||||||
|
hl.hide()
|
||||||
|
|
||||||
|
g['vl'].hide()
|
||||||
|
|
||||||
|
# only disable cursor y-label updates
|
||||||
|
# if we're highlighting a line
|
||||||
|
yl = g['yl']
|
||||||
|
|
||||||
|
if hide_label:
|
||||||
|
yl.hide()
|
||||||
|
|
||||||
|
elif y_label_level:
|
||||||
|
yl.update_from_data(0, y_label_level, _save_last=False)
|
||||||
|
hl.setY(y_label_level)
|
||||||
|
|
||||||
|
if fg_color is not None:
|
||||||
|
yl.fg_color = pg.mkColor(hcolor(fg_color))
|
||||||
|
yl.bg_color = pg.mkColor(hcolor('papas_special'))
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Fast, smooth, sexy curves.
|
Fast, smooth, sexy curves.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
@ -141,6 +142,7 @@ class FastAppendCurve(pg.PlotCurveItem):
|
||||||
|
|
||||||
w = hb_size.width() + 1
|
w = hb_size.width() + 1
|
||||||
h = hb_size.height() + 1
|
h = hb_size.height() + 1
|
||||||
|
|
||||||
br = QtCore.QRectF(
|
br = QtCore.QRectF(
|
||||||
|
|
||||||
# top left
|
# top left
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -21,7 +21,7 @@ from typing import List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from numba import jit, float64, int64 # , optional
|
from numba import njit, float64, int64 # , optional
|
||||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
from PyQt5.QtCore import QLineF, QPointF
|
from PyQt5.QtCore import QLineF, QPointF
|
||||||
# from numba import types as ntypes
|
# from numba import types as ntypes
|
||||||
|
@ -46,10 +46,20 @@ def _mk_lines_array(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
|
def lines_from_ohlc(
|
||||||
|
row: np.ndarray,
|
||||||
|
w: float
|
||||||
|
) -> Tuple[QLineF]:
|
||||||
|
|
||||||
open, high, low, close, index = row[
|
open, high, low, close, index = row[
|
||||||
['open', 'high', 'low', 'close', 'index']]
|
['open', 'high', 'low', 'close', 'index']]
|
||||||
|
|
||||||
|
# TODO: maybe consider using `QGraphicsLineItem` ??
|
||||||
|
# gives us a ``.boundingRect()`` on the objects which may make
|
||||||
|
# computing the composite bounding rect of the last bars + the
|
||||||
|
# history path faster since it's done in C++:
|
||||||
|
# https://doc.qt.io/qt-5/qgraphicslineitem.html
|
||||||
|
|
||||||
# high -> low vertical (body) line
|
# high -> low vertical (body) line
|
||||||
if low != high:
|
if low != high:
|
||||||
hl = QLineF(index, low, index, high)
|
hl = QLineF(index, low, index, high)
|
||||||
|
@ -60,17 +70,18 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
|
||||||
|
|
||||||
# NOTE: place the x-coord start as "middle" of the drawing range such
|
# NOTE: place the x-coord start as "middle" of the drawing range such
|
||||||
# that the open arm line-graphic is at the left-most-side of
|
# that the open arm line-graphic is at the left-most-side of
|
||||||
# the index's range according to the view mapping.
|
# the index's range according to the view mapping coordinates.
|
||||||
|
|
||||||
# open line
|
# open line
|
||||||
o = QLineF(index - w, open, index, open)
|
o = QLineF(index - w, open, index, open)
|
||||||
|
|
||||||
# close line
|
# close line
|
||||||
c = QLineF(index, close, index + w, close)
|
c = QLineF(index, close, index + w, close)
|
||||||
|
|
||||||
return [hl, o, c]
|
return [hl, o, c]
|
||||||
|
|
||||||
|
|
||||||
@jit(
|
@njit(
|
||||||
# TODO: for now need to construct this manually for readonly arrays, see
|
# TODO: for now need to construct this manually for readonly arrays, see
|
||||||
# https://github.com/numba/numba/issues/4511
|
# https://github.com/numba/numba/issues/4511
|
||||||
# ntypes.Tuple((float64[:], float64[:], float64[:]))(
|
# ntypes.Tuple((float64[:], float64[:], float64[:]))(
|
||||||
|
@ -78,7 +89,6 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
|
||||||
# int64,
|
# int64,
|
||||||
# optional(float64),
|
# optional(float64),
|
||||||
# ),
|
# ),
|
||||||
nopython=True,
|
|
||||||
nogil=True
|
nogil=True
|
||||||
)
|
)
|
||||||
def path_arrays_from_ohlc(
|
def path_arrays_from_ohlc(
|
||||||
|
@ -167,17 +177,18 @@ class BarItems(pg.GraphicsObject):
|
||||||
# 0.5 is no overlap between arms, 1.0 is full overlap
|
# 0.5 is no overlap between arms, 1.0 is full overlap
|
||||||
w: float = 0.43
|
w: float = 0.43
|
||||||
|
|
||||||
# XXX: for the mega-lulz increasing width here increases draw latency...
|
|
||||||
# so probably don't do it until we figure that out.
|
|
||||||
bars_pen = pg.mkPen(hcolor('bracket'))
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
# scene: 'QGraphicsScene', # noqa
|
# scene: 'QGraphicsScene', # noqa
|
||||||
plotitem: 'pg.PlotItem', # noqa
|
plotitem: 'pg.PlotItem', # noqa
|
||||||
|
pen_color: str = 'bracket',
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
# XXX: for the mega-lulz increasing width here increases draw latency...
|
||||||
|
# so probably don't do it until we figure that out.
|
||||||
|
self.bars_pen = pg.mkPen(hcolor(pen_color), width=1)
|
||||||
|
|
||||||
# NOTE: this prevents redraws on mouse interaction which is
|
# NOTE: this prevents redraws on mouse interaction which is
|
||||||
# a huge boon for avg interaction latency.
|
# a huge boon for avg interaction latency.
|
||||||
|
|
||||||
|
@ -215,7 +226,9 @@ class BarItems(pg.GraphicsObject):
|
||||||
|
|
||||||
This routine is usually only called to draw the initial history.
|
This routine is usually only called to draw the initial history.
|
||||||
"""
|
"""
|
||||||
self.path = gen_qpath(data, start, self.w)
|
hist, last = data[:-1], data[-1]
|
||||||
|
|
||||||
|
self.path = gen_qpath(hist, start, self.w)
|
||||||
|
|
||||||
# save graphics for later reference and keep track
|
# save graphics for later reference and keep track
|
||||||
# of current internal "last index"
|
# of current internal "last index"
|
||||||
|
@ -228,7 +241,7 @@ class BarItems(pg.GraphicsObject):
|
||||||
)
|
)
|
||||||
|
|
||||||
# up to last to avoid double draw of last bar
|
# up to last to avoid double draw of last bar
|
||||||
self._last_bar_lines = lines_from_ohlc(data[-1], self.w)
|
self._last_bar_lines = lines_from_ohlc(last, self.w)
|
||||||
|
|
||||||
# trigger render
|
# trigger render
|
||||||
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
|
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
|
||||||
|
@ -311,15 +324,17 @@ class BarItems(pg.GraphicsObject):
|
||||||
['index', 'open', 'high', 'low', 'close', 'volume']
|
['index', 'open', 'high', 'low', 'close', 'volume']
|
||||||
]
|
]
|
||||||
# assert i == self.start_index - 1
|
# assert i == self.start_index - 1
|
||||||
assert i == last_index
|
# assert i == last_index
|
||||||
body, larm, rarm = self._last_bar_lines
|
body, larm, rarm = self._last_bar_lines
|
||||||
|
|
||||||
# XXX: is there a faster way to modify this?
|
# XXX: is there a faster way to modify this?
|
||||||
rarm.setLine(rarm.x1(), last, rarm.x2(), last)
|
rarm.setLine(rarm.x1(), last, rarm.x2(), last)
|
||||||
|
|
||||||
# writer is responsible for changing open on "first" volume of bar
|
# writer is responsible for changing open on "first" volume of bar
|
||||||
larm.setLine(larm.x1(), o, larm.x2(), o)
|
larm.setLine(larm.x1(), o, larm.x2(), o)
|
||||||
|
|
||||||
if l != h: # noqa
|
if l != h: # noqa
|
||||||
|
|
||||||
if body is None:
|
if body is None:
|
||||||
body = self._last_bar_lines[0] = QLineF(i, l, i, h)
|
body = self._last_bar_lines[0] = QLineF(i, l, i, h)
|
||||||
else:
|
else:
|
||||||
|
@ -380,53 +395,29 @@ class BarItems(pg.GraphicsObject):
|
||||||
# apparently this a lot faster says the docs?
|
# apparently this a lot faster says the docs?
|
||||||
# https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect
|
# https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect
|
||||||
hb = self.path.controlPointRect()
|
hb = self.path.controlPointRect()
|
||||||
hb_size = hb.size()
|
hb_tl, hb_br = hb.topLeft(), hb.bottomRight()
|
||||||
# print(f'hb_size: {hb_size}')
|
|
||||||
|
|
||||||
w = hb_size.width() + 1
|
# need to include last bar height or BR will be off
|
||||||
h = hb_size.height() + 1
|
mx_y = hb_br.y()
|
||||||
|
mn_y = hb_tl.y()
|
||||||
|
|
||||||
br = QtCore.QRectF(
|
body_line = self._last_bar_lines[0]
|
||||||
|
if body_line:
|
||||||
|
mx_y = max(mx_y, max(body_line.y1(), body_line.y2()))
|
||||||
|
mn_y = min(mn_y, min(body_line.y1(), body_line.y2()))
|
||||||
|
|
||||||
|
return QtCore.QRectF(
|
||||||
|
|
||||||
# top left
|
# top left
|
||||||
QPointF(hb.topLeft()),
|
QPointF(
|
||||||
|
hb_tl.x(),
|
||||||
|
mn_y,
|
||||||
|
),
|
||||||
|
|
||||||
# total size
|
# bottom right
|
||||||
QtCore.QSizeF(w, h)
|
QPointF(
|
||||||
|
hb_br.x() + 1,
|
||||||
|
mx_y,
|
||||||
)
|
)
|
||||||
# print(f'bounding rect: {br}')
|
|
||||||
return br
|
|
||||||
|
|
||||||
|
)
|
||||||
# XXX: when we get back to enabling tina mode for xb
|
|
||||||
# class CandlestickItems(BarItems):
|
|
||||||
|
|
||||||
# w2 = 0.7
|
|
||||||
# line_pen = pg.mkPen('#000000')
|
|
||||||
# bull_brush = pg.mkBrush('#00ff00')
|
|
||||||
# bear_brush = pg.mkBrush('#ff0000')
|
|
||||||
|
|
||||||
# def _generate(self, p):
|
|
||||||
# rects = np.array(
|
|
||||||
# [
|
|
||||||
# QtCore.QRectF(
|
|
||||||
# q.id - self.w,
|
|
||||||
# q.open,
|
|
||||||
# self.w2,
|
|
||||||
# q.close - q.open
|
|
||||||
# )
|
|
||||||
# for q in Quotes
|
|
||||||
# ]
|
|
||||||
# )
|
|
||||||
|
|
||||||
# p.setPen(self.line_pen)
|
|
||||||
# p.drawLines(
|
|
||||||
# [QtCore.QLineF(q.id, q.low, q.id, q.high)
|
|
||||||
# for q in Quotes]
|
|
||||||
# )
|
|
||||||
|
|
||||||
# p.setBrush(self.bull_brush)
|
|
||||||
# p.drawRects(*rects[Quotes.close > Quotes.open])
|
|
||||||
|
|
||||||
# p.setBrush(self.bear_brush)
|
|
||||||
# p.drawRects(*rects[Quotes.close < Quotes.open])
|
|
||||||
|
|
|
@ -15,22 +15,21 @@
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
UX interaction customs.
|
Chart view box primitives
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Dict, Callable
|
from typing import Optional, Dict
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
from PyQt5.QtCore import QPointF
|
||||||
from pyqtgraph import ViewBox, Point, QtCore, QtGui
|
from pyqtgraph import ViewBox, Point, QtCore, QtGui
|
||||||
from pyqtgraph import functions as fn
|
from pyqtgraph import functions as fn
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._style import _min_points_to_show, hcolor, _font
|
from ._style import _min_points_to_show, hcolor, _font
|
||||||
from ._graphics._lines import level_line, LevelLine
|
from ._graphics._lines import order_line, LevelLine
|
||||||
from .._ems import get_orders, OrderBook
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
@ -110,8 +109,8 @@ class SelectRect(QtGui.QGraphicsRectItem):
|
||||||
|
|
||||||
def mouse_drag_released(
|
def mouse_drag_released(
|
||||||
self,
|
self,
|
||||||
p1: QtCore.QPointF,
|
p1: QPointF,
|
||||||
p2: QtCore.QPointF
|
p2: QPointF
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Called on final button release for mouse drag with start and
|
"""Called on final button release for mouse drag with start and
|
||||||
end positions.
|
end positions.
|
||||||
|
@ -121,10 +120,10 @@ class SelectRect(QtGui.QGraphicsRectItem):
|
||||||
|
|
||||||
def set_pos(
|
def set_pos(
|
||||||
self,
|
self,
|
||||||
p1: QtCore.QPointF,
|
p1: QPointF,
|
||||||
p2: QtCore.QPointF
|
p2: QPointF
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set position of selection rectagle and accompanying label, move
|
"""Set position of selection rect and accompanying label, move
|
||||||
label to match.
|
label to match.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@ -208,48 +207,77 @@ _order_lines: Dict[str, LevelLine] = {}
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LineEditor:
|
class LineEditor:
|
||||||
|
"""The great editor of linez..
|
||||||
|
|
||||||
|
"""
|
||||||
view: 'ChartView'
|
view: 'ChartView'
|
||||||
|
|
||||||
_order_lines: field(default_factory=_order_lines)
|
_order_lines: field(default_factory=_order_lines)
|
||||||
chart: 'ChartPlotWidget' = None # type: ignore # noqa
|
chart: 'ChartPlotWidget' = None # type: ignore # noqa
|
||||||
_active_staged_line: LevelLine = None
|
_active_staged_line: LevelLine = None
|
||||||
_stage_line: LevelLine = None
|
_stage_line: LevelLine = None
|
||||||
|
|
||||||
def stage_line(self, color: str = 'alert_yellow') -> LevelLine:
|
def stage_line(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
|
||||||
|
color: str = 'alert_yellow',
|
||||||
|
hl_on_hover: bool = False,
|
||||||
|
dotted: bool = False,
|
||||||
|
|
||||||
|
# fields settings
|
||||||
|
size: Optional[int] = None,
|
||||||
|
) -> LevelLine:
|
||||||
"""Stage a line at the current chart's cursor position
|
"""Stage a line at the current chart's cursor position
|
||||||
and return it.
|
and return it.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# chart.setCursor(QtCore.Qt.PointingHandCursor)
|
||||||
|
|
||||||
chart = self.chart._cursor.active_plot
|
chart = self.chart._cursor.active_plot
|
||||||
chart.setCursor(QtCore.Qt.PointingHandCursor)
|
|
||||||
cursor = chart._cursor
|
cursor = chart._cursor
|
||||||
y = chart._cursor._datum_xy[1]
|
y = chart._cursor._datum_xy[1]
|
||||||
|
|
||||||
line = self._stage_line
|
symbol = chart._lc.symbol
|
||||||
if not line:
|
|
||||||
|
# line = self._stage_line
|
||||||
|
# if not line:
|
||||||
# add a "staged" cursor-tracking line to view
|
# add a "staged" cursor-tracking line to view
|
||||||
# and cash it in a a var
|
# and cash it in a a var
|
||||||
line = level_line(
|
if self._active_staged_line:
|
||||||
|
self.unstage_line()
|
||||||
|
|
||||||
|
line = order_line(
|
||||||
chart,
|
chart,
|
||||||
|
|
||||||
level=y,
|
level=y,
|
||||||
digits=chart._lc.symbol.digits(),
|
level_digits=symbol.digits(),
|
||||||
|
size=size,
|
||||||
|
size_digits=symbol.lot_digits(),
|
||||||
|
|
||||||
|
# just for the stage line to avoid
|
||||||
|
# flickering while moving the cursor
|
||||||
|
# around where it might trigger highlight
|
||||||
|
# then non-highlight depending on sensitivity
|
||||||
|
always_show_labels=True,
|
||||||
|
|
||||||
|
# kwargs
|
||||||
color=color,
|
color=color,
|
||||||
|
|
||||||
# don't highlight the "staging" line
|
# don't highlight the "staging" line
|
||||||
hl_on_hover=False,
|
hl_on_hover=hl_on_hover,
|
||||||
)
|
dotted=dotted,
|
||||||
self._stage_line = line
|
exec_type='dark' if dotted else 'live',
|
||||||
|
action=action,
|
||||||
|
show_markers=True,
|
||||||
|
|
||||||
else:
|
# prevent flickering of marker while moving/tracking cursor
|
||||||
# use the existing staged line instead
|
only_show_markers_on_hover=False,
|
||||||
# of allocating more mem / objects repeatedly
|
)
|
||||||
line.setValue(y)
|
|
||||||
line.show()
|
|
||||||
line.label.show()
|
|
||||||
|
|
||||||
self._active_staged_line = line
|
self._active_staged_line = line
|
||||||
|
|
||||||
# hide crosshair y-line
|
# hide crosshair y-line and label
|
||||||
cursor.graphics[chart]['hl'].hide()
|
cursor.hide_xhair()
|
||||||
|
|
||||||
# add line to cursor trackers
|
# add line to cursor trackers
|
||||||
cursor._trackers.add(line)
|
cursor._trackers.add(line)
|
||||||
|
@ -260,45 +288,63 @@ class LineEditor:
|
||||||
"""Inverse of ``.stage_line()``.
|
"""Inverse of ``.stage_line()``.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
chart = self.chart._cursor.active_plot
|
# chart = self.chart._cursor.active_plot
|
||||||
chart.setCursor(QtCore.Qt.ArrowCursor)
|
# # chart.setCursor(QtCore.Qt.ArrowCursor)
|
||||||
cursor = chart._cursor
|
cursor = self.chart._cursor
|
||||||
|
|
||||||
# delete "staged" cursor tracking line from view
|
# delete "staged" cursor tracking line from view
|
||||||
line = self._active_staged_line
|
line = self._active_staged_line
|
||||||
|
if line:
|
||||||
cursor._trackers.remove(line)
|
cursor._trackers.remove(line)
|
||||||
|
line.delete()
|
||||||
if self._stage_line:
|
|
||||||
self._stage_line.hide()
|
|
||||||
self._stage_line.label.hide()
|
|
||||||
|
|
||||||
self._active_staged_line = None
|
self._active_staged_line = None
|
||||||
|
|
||||||
# show the crosshair y line
|
# show the crosshair y line and label
|
||||||
hl = cursor.graphics[chart]['hl']
|
cursor.show_xhair()
|
||||||
hl.show()
|
|
||||||
|
|
||||||
def create_line(self, uuid: str) -> LevelLine:
|
def create_order_line(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
level: float,
|
||||||
|
chart: 'ChartPlotWidget', # noqa
|
||||||
|
size: float,
|
||||||
|
action: str,
|
||||||
|
) -> LevelLine:
|
||||||
|
|
||||||
line = self._active_staged_line
|
line = self._active_staged_line
|
||||||
if not line:
|
if not line:
|
||||||
raise RuntimeError("No line commit is currently staged!?")
|
raise RuntimeError("No line is currently staged!?")
|
||||||
|
|
||||||
chart = self.chart._cursor.active_plot
|
sym = chart._lc.symbol
|
||||||
y = chart._cursor._datum_xy[1]
|
|
||||||
|
|
||||||
line = level_line(
|
line = order_line(
|
||||||
chart,
|
chart,
|
||||||
level=y,
|
|
||||||
color='alert_yellow',
|
# label fields default values
|
||||||
digits=chart._lc.symbol.digits(),
|
level=level,
|
||||||
show_label=False,
|
level_digits=sym.digits(),
|
||||||
|
|
||||||
|
size=size,
|
||||||
|
size_digits=sym.lot_digits(),
|
||||||
|
|
||||||
|
# LevelLine kwargs
|
||||||
|
color=line.color,
|
||||||
|
dotted=line._dotted,
|
||||||
|
|
||||||
|
show_markers=True,
|
||||||
|
only_show_markers_on_hover=True,
|
||||||
|
|
||||||
|
action=action,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# for now, until submission reponse arrives
|
||||||
|
line.hide_labels()
|
||||||
|
|
||||||
# register for later lookup/deletion
|
# register for later lookup/deletion
|
||||||
self._order_lines[uuid] = line
|
self._order_lines[uuid] = line
|
||||||
return line, y
|
|
||||||
|
return line
|
||||||
|
|
||||||
def commit_line(self, uuid: str) -> LevelLine:
|
def commit_line(self, uuid: str) -> LevelLine:
|
||||||
"""Commit a "staged line" to view.
|
"""Commit a "staged line" to view.
|
||||||
|
@ -307,9 +353,14 @@ class LineEditor:
|
||||||
graphic in view.
|
graphic in view.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
line = self._order_lines[uuid]
|
line = self._order_lines[uuid]
|
||||||
line.oid = uuid
|
except KeyError:
|
||||||
line.label.show()
|
log.warning(f'No line for {uuid} could be found?')
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
assert line.oid == uuid
|
||||||
|
line.show_labels()
|
||||||
|
|
||||||
# TODO: other flashy things to indicate the order is active
|
# TODO: other flashy things to indicate the order is active
|
||||||
|
|
||||||
|
@ -328,7 +379,7 @@ class LineEditor:
|
||||||
self,
|
self,
|
||||||
line: LevelLine = None,
|
line: LevelLine = None,
|
||||||
uuid: str = None,
|
uuid: str = None,
|
||||||
) -> None:
|
) -> LevelLine:
|
||||||
"""Remove a line by refernce or uuid.
|
"""Remove a line by refernce or uuid.
|
||||||
|
|
||||||
If no lines or ids are provided remove all lines under the
|
If no lines or ids are provided remove all lines under the
|
||||||
|
@ -339,14 +390,20 @@ class LineEditor:
|
||||||
uuid = line.oid
|
uuid = line.oid
|
||||||
|
|
||||||
# try to look up line from our registry
|
# try to look up line from our registry
|
||||||
line = self._order_lines.pop(uuid)
|
line = self._order_lines.pop(uuid, None)
|
||||||
|
if line:
|
||||||
|
|
||||||
# if hovered remove from cursor set
|
# if hovered remove from cursor set
|
||||||
hovered = self.chart._cursor._hovered
|
hovered = self.chart._cursor._hovered
|
||||||
if line in hovered:
|
if line in hovered:
|
||||||
hovered.remove(line)
|
hovered.remove(line)
|
||||||
|
|
||||||
|
# make sure the xhair doesn't get left off
|
||||||
|
# just because we never got a un-hover event
|
||||||
|
self.chart._cursor.show_xhair()
|
||||||
|
|
||||||
line.delete()
|
line.delete()
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -361,22 +418,28 @@ class ArrowEditor:
|
||||||
x: float,
|
x: float,
|
||||||
y: float,
|
y: float,
|
||||||
color='default',
|
color='default',
|
||||||
pointing: str = 'up',
|
pointing: Optional[str] = None,
|
||||||
) -> pg.ArrowItem:
|
) -> pg.ArrowItem:
|
||||||
"""Add an arrow graphic to view at given (x, y).
|
"""Add an arrow graphic to view at given (x, y).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
yb = pg.mkBrush(hcolor('alert_yellow'))
|
angle = {
|
||||||
|
'up': 90,
|
||||||
angle = 90 if pointing == 'up' else -90
|
'down': -90,
|
||||||
|
None: 180, # pointing to right (as in an alert)
|
||||||
|
}[pointing]
|
||||||
|
|
||||||
arrow = pg.ArrowItem(
|
arrow = pg.ArrowItem(
|
||||||
angle=angle,
|
angle=angle,
|
||||||
baseAngle=0,
|
baseAngle=0,
|
||||||
headLen=5,
|
headLen=5*3,
|
||||||
headWidth=2,
|
headWidth=2*3,
|
||||||
tailLen=None,
|
tailLen=None,
|
||||||
brush=yb,
|
pxMode=True,
|
||||||
|
|
||||||
|
# coloring
|
||||||
|
pen=pg.mkPen(hcolor('papas_special')),
|
||||||
|
brush=pg.mkBrush(hcolor(color)),
|
||||||
)
|
)
|
||||||
arrow.setPos(x, y)
|
arrow.setPos(x, y)
|
||||||
|
|
||||||
|
@ -391,59 +454,6 @@ class ArrowEditor:
|
||||||
self.chart.plotItem.removeItem(arrow)
|
self.chart.plotItem.removeItem(arrow)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OrderMode:
|
|
||||||
"""Major mode for placing orders on a chart view.
|
|
||||||
|
|
||||||
"""
|
|
||||||
chart: 'ChartPlotWidget' # type: ignore # noqa
|
|
||||||
book: OrderBook
|
|
||||||
lines: LineEditor
|
|
||||||
arrows: ArrowEditor
|
|
||||||
_arrow_colors = {
|
|
||||||
'alert': 'alert_yellow',
|
|
||||||
'buy': 'buy_green',
|
|
||||||
'sell': 'sell_red',
|
|
||||||
}
|
|
||||||
|
|
||||||
key_map: Dict[str, Callable] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def uuid(self) -> str:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def open_order_mode(
|
|
||||||
chart,
|
|
||||||
):
|
|
||||||
# global _order_lines
|
|
||||||
|
|
||||||
view = chart._vb
|
|
||||||
book = get_orders()
|
|
||||||
lines = LineEditor(view=view, _order_lines=_order_lines, chart=chart)
|
|
||||||
arrows = ArrowEditor(chart, {})
|
|
||||||
|
|
||||||
log.info("Opening order mode")
|
|
||||||
|
|
||||||
mode = OrderMode(chart, book, lines, arrows)
|
|
||||||
view.mode = mode
|
|
||||||
|
|
||||||
# # setup local ui event streaming channels for request/resp
|
|
||||||
# # streamging with EMS daemon
|
|
||||||
# global _to_ems, _from_order_book
|
|
||||||
# _to_ems, _from_order_book = trio.open_memory_channel(100)
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield mode
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# XXX special teardown handling like for ex.
|
|
||||||
# - cancelling orders if needed?
|
|
||||||
# - closing positions if desired?
|
|
||||||
# - switching special condition orders to safer/more reliable variants
|
|
||||||
log.info("Closing order mode")
|
|
||||||
|
|
||||||
|
|
||||||
class ChartView(ViewBox):
|
class ChartView(ViewBox):
|
||||||
"""Price chart view box with interaction behaviors you'd expect from
|
"""Price chart view box with interaction behaviors you'd expect from
|
||||||
any interactive platform:
|
any interactive platform:
|
||||||
|
@ -467,11 +477,11 @@ class ChartView(ViewBox):
|
||||||
self.addItem(self.select_box, ignoreBounds=True)
|
self.addItem(self.select_box, ignoreBounds=True)
|
||||||
self._chart: 'ChartPlotWidget' = None # noqa
|
self._chart: 'ChartPlotWidget' = None # noqa
|
||||||
|
|
||||||
# self._lines_editor = LineEditor(view=self, _lines=_lines)
|
|
||||||
self.mode = None
|
self.mode = None
|
||||||
|
|
||||||
# kb ctrls processing
|
# kb ctrls processing
|
||||||
self._key_buffer = []
|
self._key_buffer = []
|
||||||
|
self._key_active: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
|
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
|
||||||
|
@ -481,7 +491,6 @@ class ChartView(ViewBox):
|
||||||
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa
|
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa
|
||||||
self._chart = chart
|
self._chart = chart
|
||||||
self.select_box.chart = chart
|
self.select_box.chart = chart
|
||||||
# self._lines_editor.chart = chart
|
|
||||||
|
|
||||||
def wheelEvent(self, ev, axis=None):
|
def wheelEvent(self, ev, axis=None):
|
||||||
"""Override "center-point" location for scrolling.
|
"""Override "center-point" location for scrolling.
|
||||||
|
@ -498,15 +507,17 @@ class ChartView(ViewBox):
|
||||||
else:
|
else:
|
||||||
mask = self.state['mouseEnabled'][:]
|
mask = self.state['mouseEnabled'][:]
|
||||||
|
|
||||||
|
chart = self.linked_charts.chart
|
||||||
|
|
||||||
# don't zoom more then the min points setting
|
# don't zoom more then the min points setting
|
||||||
l, lbar, rbar, r = self.linked_charts.chart.bars_range()
|
l, lbar, rbar, r = chart.bars_range()
|
||||||
vl = r - l
|
vl = r - l
|
||||||
|
|
||||||
if ev.delta() > 0 and vl <= _min_points_to_show:
|
if ev.delta() > 0 and vl <= _min_points_to_show:
|
||||||
log.debug("Max zoom bruh...")
|
log.debug("Max zoom bruh...")
|
||||||
return
|
return
|
||||||
|
|
||||||
if ev.delta() < 0 and vl >= len(self.linked_charts.chart._ohlc) + 666:
|
if ev.delta() < 0 and vl >= len(chart._ohlc) + 666:
|
||||||
log.debug("Min zoom bruh...")
|
log.debug("Min zoom bruh...")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -531,10 +542,34 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
# This seems like the most "intuitive option, a hybrid of
|
# This seems like the most "intuitive option, a hybrid of
|
||||||
# tws and tv styles
|
# tws and tv styles
|
||||||
last_bar = pg.Point(int(rbar))
|
last_bar = pg.Point(int(rbar)) + 1
|
||||||
|
|
||||||
|
ryaxis = chart.getAxis('right')
|
||||||
|
r_axis_x = ryaxis.pos().x()
|
||||||
|
|
||||||
|
end_of_l1 = pg.Point(
|
||||||
|
round(
|
||||||
|
chart._vb.mapToView(
|
||||||
|
pg.Point(r_axis_x - chart._max_l1_line_len)
|
||||||
|
# QPointF(chart._max_l1_line_len, 0)
|
||||||
|
).x()
|
||||||
|
)
|
||||||
|
) # .x()
|
||||||
|
|
||||||
|
# self.state['viewRange'][0][1] = end_of_l1
|
||||||
|
|
||||||
|
# focal = pg.Point((last_bar.x() + end_of_l1)/2)
|
||||||
|
|
||||||
|
focal = min(
|
||||||
|
last_bar,
|
||||||
|
end_of_l1,
|
||||||
|
key=lambda p: p.x()
|
||||||
|
)
|
||||||
|
# breakpoint()
|
||||||
|
# focal = pg.Point(last_bar.x() + end_of_l1)
|
||||||
|
|
||||||
self._resetTarget()
|
self._resetTarget()
|
||||||
self.scaleBy(s, last_bar)
|
self.scaleBy(s, focal)
|
||||||
ev.accept()
|
ev.accept()
|
||||||
self.sigRangeChangedManually.emit(mask)
|
self.sigRangeChangedManually.emit(mask)
|
||||||
|
|
||||||
|
@ -649,29 +684,10 @@ class ChartView(ViewBox):
|
||||||
self.raiseContextMenu(ev)
|
self.raiseContextMenu(ev)
|
||||||
|
|
||||||
elif button == QtCore.Qt.LeftButton:
|
elif button == QtCore.Qt.LeftButton:
|
||||||
|
# when in order mode, submit execution
|
||||||
|
if self._key_active:
|
||||||
ev.accept()
|
ev.accept()
|
||||||
|
self.mode.submit_exec()
|
||||||
# self._lines_editor.commit_line()
|
|
||||||
|
|
||||||
# send order to EMS
|
|
||||||
|
|
||||||
# register the "staged" line under the cursor
|
|
||||||
# to be displayed when above order ack arrives
|
|
||||||
# (means the line graphic doesn't show on screen until the
|
|
||||||
# order is live in the emsd).
|
|
||||||
mode = self.mode
|
|
||||||
uuid = mode.uuid()
|
|
||||||
|
|
||||||
# make line graphic
|
|
||||||
line, y = mode.lines.create_line(uuid)
|
|
||||||
|
|
||||||
# send order cmd to ems
|
|
||||||
mode.book.alert(
|
|
||||||
uuid=uuid,
|
|
||||||
symbol=mode.chart._lc._symbol,
|
|
||||||
price=y
|
|
||||||
)
|
|
||||||
|
|
||||||
def keyReleaseEvent(self, ev):
|
def keyReleaseEvent(self, ev):
|
||||||
"""
|
"""
|
||||||
|
@ -684,18 +700,25 @@ class ChartView(ViewBox):
|
||||||
return
|
return
|
||||||
|
|
||||||
ev.accept()
|
ev.accept()
|
||||||
text = ev.text()
|
# text = ev.text()
|
||||||
key = ev.key()
|
key = ev.key()
|
||||||
# mods = ev.modifiers()
|
mods = ev.modifiers()
|
||||||
|
|
||||||
if key == QtCore.Qt.Key_Shift:
|
if key == QtCore.Qt.Key_Shift:
|
||||||
if self.state['mouseMode'] == ViewBox.RectMode:
|
# if self.state['mouseMode'] == ViewBox.RectMode:
|
||||||
self.setMouseMode(ViewBox.PanMode)
|
self.setMouseMode(ViewBox.PanMode)
|
||||||
|
|
||||||
if text == 'a':
|
# if self.state['mouseMode'] == ViewBox.RectMode:
|
||||||
# draw "staged" line under cursor position
|
# if key == QtCore.Qt.Key_Space:
|
||||||
|
if mods == QtCore.Qt.ControlModifier or key == QtCore.Qt.Key_Control:
|
||||||
|
self.mode._exec_mode = 'dark'
|
||||||
|
|
||||||
|
if key in {QtCore.Qt.Key_A, QtCore.Qt.Key_F, QtCore.Qt.Key_D}:
|
||||||
|
# remove "staged" level line under cursor position
|
||||||
self.mode.lines.unstage_line()
|
self.mode.lines.unstage_line()
|
||||||
|
|
||||||
|
self._key_active = False
|
||||||
|
|
||||||
def keyPressEvent(self, ev):
|
def keyPressEvent(self, ev):
|
||||||
"""
|
"""
|
||||||
This routine should capture key presses in the current view box.
|
This routine should capture key presses in the current view box.
|
||||||
|
@ -711,43 +734,56 @@ class ChartView(ViewBox):
|
||||||
key = ev.key()
|
key = ev.key()
|
||||||
mods = ev.modifiers()
|
mods = ev.modifiers()
|
||||||
|
|
||||||
|
print(f'text: {text}, key: {key}')
|
||||||
|
|
||||||
if mods == QtCore.Qt.ShiftModifier:
|
if mods == QtCore.Qt.ShiftModifier:
|
||||||
if self.state['mouseMode'] == ViewBox.PanMode:
|
if self.state['mouseMode'] == ViewBox.PanMode:
|
||||||
self.setMouseMode(ViewBox.RectMode)
|
self.setMouseMode(ViewBox.RectMode)
|
||||||
|
|
||||||
# ctl
|
# ctrl
|
||||||
|
ctrl = False
|
||||||
if mods == QtCore.Qt.ControlModifier:
|
if mods == QtCore.Qt.ControlModifier:
|
||||||
# TODO: ctrl-c as cancel?
|
ctrl = True
|
||||||
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
|
||||||
# if ev.text() == 'c':
|
if mods == QtCore.Qt.ControlModifier:
|
||||||
# self.rbScaleBox.hide()
|
self.mode._exec_mode = 'live'
|
||||||
print(f"CTRL + key:{key} + text:{text}")
|
|
||||||
|
self._key_active = True
|
||||||
|
|
||||||
# alt
|
# alt
|
||||||
if mods == QtCore.Qt.AltModifier:
|
if mods == QtCore.Qt.AltModifier:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# esc
|
# esc
|
||||||
if key == QtCore.Qt.Key_Escape:
|
if key == QtCore.Qt.Key_Escape or (ctrl and key == QtCore.Qt.Key_C):
|
||||||
|
# ctrl-c as cancel
|
||||||
|
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
||||||
self.select_box.clear()
|
self.select_box.clear()
|
||||||
|
|
||||||
self._key_buffer.append(text)
|
# cancel order or clear graphics
|
||||||
|
if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete:
|
||||||
# order modes
|
|
||||||
if text == 'r':
|
|
||||||
self.chart.default_view()
|
|
||||||
|
|
||||||
elif text == 'a':
|
|
||||||
# add a line at the current cursor
|
|
||||||
self.mode.lines.stage_line()
|
|
||||||
|
|
||||||
elif text == 'd':
|
|
||||||
|
|
||||||
# delete any lines under the cursor
|
# delete any lines under the cursor
|
||||||
mode = self.mode
|
mode = self.mode
|
||||||
for line in mode.lines.lines_under_cursor():
|
for line in mode.lines.lines_under_cursor():
|
||||||
mode.book.cancel(uuid=line.oid)
|
mode.book.cancel(uuid=line.oid)
|
||||||
|
|
||||||
|
self._key_buffer.append(text)
|
||||||
|
|
||||||
|
# View modes
|
||||||
|
if key == QtCore.Qt.Key_R:
|
||||||
|
self.chart.default_view()
|
||||||
|
|
||||||
|
# Order modes: stage orders at the current cursor level
|
||||||
|
|
||||||
|
elif key == QtCore.Qt.Key_D: # for "damp eet"
|
||||||
|
self.mode.set_exec('sell')
|
||||||
|
|
||||||
|
elif key == QtCore.Qt.Key_F: # for "fillz eet"
|
||||||
|
self.mode.set_exec('buy')
|
||||||
|
|
||||||
|
elif key == QtCore.Qt.Key_A:
|
||||||
|
self.mode.set_exec('alert')
|
||||||
|
|
||||||
# XXX: Leaving this for light reference purposes, there
|
# XXX: Leaving this for light reference purposes, there
|
||||||
# seems to be some work to at least gawk at for history mgmt.
|
# seems to be some work to at least gawk at for history mgmt.
|
||||||
|
|
||||||
|
@ -764,3 +800,4 @@ class ChartView(ViewBox):
|
||||||
# self.scaleHistory(len(self.axHistory))
|
# self.scaleHistory(len(self.axHistory))
|
||||||
else:
|
else:
|
||||||
ev.ignore()
|
ev.ignore()
|
||||||
|
self._key_active = False
|
||||||
|
|
|
@ -0,0 +1,290 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Double auction top-of-book (L1) graphics.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
import pyqtgraph as pg
|
||||||
|
from PyQt5 import QtCore, QtGui
|
||||||
|
from PyQt5.QtCore import QPointF
|
||||||
|
|
||||||
|
from ._axes import YAxisLabel
|
||||||
|
from ._style import (
|
||||||
|
hcolor,
|
||||||
|
_down_2_font_inches_we_like,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LevelLabel(YAxisLabel):
|
||||||
|
"""Y-axis (vertically) oriented, horizontal label that sticks to
|
||||||
|
where it's placed despite chart resizing and supports displaying
|
||||||
|
multiple fields.
|
||||||
|
|
||||||
|
|
||||||
|
TODO: replace the rectangle-text part with our new ``Label`` type.
|
||||||
|
|
||||||
|
"""
|
||||||
|
_x_margin = 0
|
||||||
|
_y_margin = 0
|
||||||
|
|
||||||
|
# adjustment "further away from" anchor point
|
||||||
|
_x_offset = 9
|
||||||
|
_y_offset = 0
|
||||||
|
|
||||||
|
# fields to be displayed in the label string
|
||||||
|
_fields = {
|
||||||
|
'level': 0,
|
||||||
|
'level_digits': 2,
|
||||||
|
}
|
||||||
|
# default label template is just a y-level with so much precision
|
||||||
|
_fmt_str = '{level:,.{level_digits}f} '
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
chart,
|
||||||
|
parent,
|
||||||
|
|
||||||
|
color: str = 'bracket',
|
||||||
|
|
||||||
|
orient_v: str = 'bottom',
|
||||||
|
orient_h: str = 'left',
|
||||||
|
|
||||||
|
opacity: float = 0,
|
||||||
|
|
||||||
|
# makes order line labels offset from their parent axis
|
||||||
|
# such that they don't collide with the L1/L2 lines/prices
|
||||||
|
# that are displayed on the axis
|
||||||
|
adjust_to_l1: bool = False,
|
||||||
|
|
||||||
|
**axis_label_kwargs,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
chart,
|
||||||
|
parent=parent,
|
||||||
|
use_arrow=False,
|
||||||
|
opacity=opacity,
|
||||||
|
**axis_label_kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: this is kinda cludgy
|
||||||
|
self._hcolor: pg.Pen = None
|
||||||
|
self.color: str = color
|
||||||
|
|
||||||
|
# orientation around axis options
|
||||||
|
self._orient_v = orient_v
|
||||||
|
self._orient_h = orient_h
|
||||||
|
|
||||||
|
self._adjust_to_l1 = adjust_to_l1
|
||||||
|
|
||||||
|
self._v_shift = {
|
||||||
|
'top': -1.,
|
||||||
|
'bottom': 0.,
|
||||||
|
'middle': 1 / 2.
|
||||||
|
}[orient_v]
|
||||||
|
|
||||||
|
self._h_shift = {
|
||||||
|
'left': -1.,
|
||||||
|
'right': 0.
|
||||||
|
}[orient_h]
|
||||||
|
|
||||||
|
self.fields = self._fields.copy()
|
||||||
|
# ensure default format fields are in correct
|
||||||
|
self.set_fmt_str(self._fmt_str, self.fields)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color(self):
|
||||||
|
return self._hcolor
|
||||||
|
|
||||||
|
@color.setter
|
||||||
|
def color(self, color: str) -> None:
|
||||||
|
self._hcolor = color
|
||||||
|
self._pen = self.pen = pg.mkPen(hcolor(color))
|
||||||
|
|
||||||
|
def update_on_resize(self, vr, r):
|
||||||
|
"""Tiis is a ``.sigRangeChanged()`` handler.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.update_fields(self.fields)
|
||||||
|
|
||||||
|
def update_fields(
|
||||||
|
self,
|
||||||
|
fields: dict = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update the label's text contents **and** position from
|
||||||
|
a view box coordinate datum.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.fields.update(fields)
|
||||||
|
level = self.fields['level']
|
||||||
|
|
||||||
|
# map "level" to local coords
|
||||||
|
abs_xy = self._chart.mapFromView(QPointF(0, level))
|
||||||
|
|
||||||
|
self.update_label(
|
||||||
|
abs_xy,
|
||||||
|
self.fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_label(
|
||||||
|
self,
|
||||||
|
abs_pos: QPointF, # scene coords
|
||||||
|
fields: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# write contents, type specific
|
||||||
|
h, w = self.set_label_str(fields)
|
||||||
|
|
||||||
|
if self._adjust_to_l1:
|
||||||
|
self._x_offset = self._chart._max_l1_line_len
|
||||||
|
|
||||||
|
self.setPos(QPointF(
|
||||||
|
self._h_shift * (w + self._x_offset),
|
||||||
|
abs_pos.y() + self._v_shift * h
|
||||||
|
))
|
||||||
|
|
||||||
|
def set_fmt_str(
|
||||||
|
self,
|
||||||
|
fmt_str: str,
|
||||||
|
fields: dict,
|
||||||
|
) -> (str, str):
|
||||||
|
# test that new fmt str can be rendered
|
||||||
|
self._fmt_str = fmt_str
|
||||||
|
self.set_label_str(fields)
|
||||||
|
self.fields.update(fields)
|
||||||
|
return fmt_str, self.label_str
|
||||||
|
|
||||||
|
def set_label_str(
|
||||||
|
self,
|
||||||
|
fields: dict,
|
||||||
|
):
|
||||||
|
# use space as e3 delim
|
||||||
|
self.label_str = self._fmt_str.format(**fields).replace(',', ' ')
|
||||||
|
|
||||||
|
br = self.boundingRect()
|
||||||
|
h, w = br.height(), br.width()
|
||||||
|
return h, w
|
||||||
|
|
||||||
|
def size_hint(self) -> Tuple[None, None]:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def draw(
|
||||||
|
self,
|
||||||
|
p: QtGui.QPainter,
|
||||||
|
rect: QtCore.QRectF
|
||||||
|
) -> None:
|
||||||
|
p.setPen(self._pen)
|
||||||
|
|
||||||
|
rect = self.rect
|
||||||
|
|
||||||
|
if self._orient_v == 'bottom':
|
||||||
|
lp, rp = rect.topLeft(), rect.topRight()
|
||||||
|
# p.drawLine(rect.topLeft(), rect.topRight())
|
||||||
|
|
||||||
|
elif self._orient_v == 'top':
|
||||||
|
lp, rp = rect.bottomLeft(), rect.bottomRight()
|
||||||
|
|
||||||
|
p.drawLine(lp.x(), lp.y(), rp.x(), rp.y())
|
||||||
|
|
||||||
|
def highlight(self, pen) -> None:
|
||||||
|
self._pen = pen
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def unhighlight(self):
|
||||||
|
self._pen = self.pen
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
|
||||||
|
class L1Label(LevelLabel):
|
||||||
|
|
||||||
|
text_flags = (
|
||||||
|
QtCore.Qt.TextDontClip
|
||||||
|
| QtCore.Qt.AlignLeft
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_label_str(
|
||||||
|
self,
|
||||||
|
fields: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Make sure the max L1 line module var is kept up to date.
|
||||||
|
|
||||||
|
"""
|
||||||
|
h, w = super().set_label_str(fields)
|
||||||
|
|
||||||
|
# Set a global "max L1 label length" so we can
|
||||||
|
# look it up on order lines and adjust their
|
||||||
|
# labels not to overlap with it.
|
||||||
|
chart = self._chart
|
||||||
|
chart._max_l1_line_len: float = max(
|
||||||
|
chart._max_l1_line_len,
|
||||||
|
w
|
||||||
|
)
|
||||||
|
|
||||||
|
return h, w
|
||||||
|
|
||||||
|
|
||||||
|
class L1Labels:
|
||||||
|
"""Level 1 bid ask labels for dynamic update on price-axis.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
chart: 'ChartPlotWidget', # noqa
|
||||||
|
digits: int = 2,
|
||||||
|
size_digits: int = 3,
|
||||||
|
font_size_inches: float = _down_2_font_inches_we_like,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
self.chart = chart
|
||||||
|
|
||||||
|
raxis = chart.getAxis('right')
|
||||||
|
kwargs = {
|
||||||
|
'chart': chart,
|
||||||
|
'parent': raxis,
|
||||||
|
|
||||||
|
'opacity': 1,
|
||||||
|
'font_size_inches': font_size_inches,
|
||||||
|
'fg_color': chart.pen_color,
|
||||||
|
'bg_color': chart.view_color,
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt_str = (
|
||||||
|
' {size:.{size_digits}f} x '
|
||||||
|
'{level:,.{level_digits}f} '
|
||||||
|
)
|
||||||
|
fields = {
|
||||||
|
'level': 0,
|
||||||
|
'level_digits': digits,
|
||||||
|
'size': 0,
|
||||||
|
'size_digits': size_digits,
|
||||||
|
}
|
||||||
|
|
||||||
|
bid = self.bid_label = L1Label(
|
||||||
|
orient_v='bottom',
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
bid.set_fmt_str(fmt_str=fmt_str, fields=fields)
|
||||||
|
bid.show()
|
||||||
|
|
||||||
|
ask = self.ask_label = L1Label(
|
||||||
|
orient_v='top',
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
ask.set_fmt_str(fmt_str=fmt_str, fields=fields)
|
||||||
|
ask.show()
|
|
@ -0,0 +1,248 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Non-shitty labels that don't re-invent the wheel.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from inspect import isfunction
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import pyqtgraph as pg
|
||||||
|
from PyQt5 import QtGui
|
||||||
|
from PyQt5.QtCore import QPointF, QRectF
|
||||||
|
|
||||||
|
from ._style import (
|
||||||
|
DpiAwareFont,
|
||||||
|
hcolor,
|
||||||
|
_down_2_font_inches_we_like,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def vbr_left(label) -> Callable[..., float]:
|
||||||
|
"""Return a closure which gives the scene x-coordinate for the
|
||||||
|
leftmost point of the containing view box.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return label.vbr().left
|
||||||
|
|
||||||
|
|
||||||
|
def right_axis(
|
||||||
|
|
||||||
|
chart: 'ChartPlotWidget', # noqa
|
||||||
|
label: 'Label', # noqa
|
||||||
|
side: str = 'left',
|
||||||
|
offset: float = 10,
|
||||||
|
avoid_book: bool = True,
|
||||||
|
width: float = None,
|
||||||
|
|
||||||
|
) -> Callable[..., float]:
|
||||||
|
"""Return a position closure which gives the scene x-coordinate for
|
||||||
|
the x point on the right y-axis minus the width of the label given
|
||||||
|
it's contents.
|
||||||
|
|
||||||
|
"""
|
||||||
|
ryaxis = chart.getAxis('right')
|
||||||
|
|
||||||
|
if side == 'left':
|
||||||
|
|
||||||
|
if avoid_book:
|
||||||
|
def right_axis_offset_by_w() -> float:
|
||||||
|
|
||||||
|
# l1 spread graphics x-size
|
||||||
|
l1_len = chart._max_l1_line_len
|
||||||
|
|
||||||
|
# sum of all distances "from" the y-axis
|
||||||
|
right_offset = l1_len + label.w + offset
|
||||||
|
|
||||||
|
return ryaxis.pos().x() - right_offset
|
||||||
|
|
||||||
|
else:
|
||||||
|
def right_axis_offset_by_w() -> float:
|
||||||
|
|
||||||
|
return ryaxis.pos().x() - (label.w + offset)
|
||||||
|
|
||||||
|
return right_axis_offset_by_w
|
||||||
|
|
||||||
|
elif 'right':
|
||||||
|
|
||||||
|
# axis_offset = ryaxis.style['tickTextOffset'][0]
|
||||||
|
|
||||||
|
def on_axis() -> float:
|
||||||
|
|
||||||
|
return ryaxis.pos().x() # + axis_offset - 2
|
||||||
|
|
||||||
|
return on_axis
|
||||||
|
|
||||||
|
|
||||||
|
class Label:
|
||||||
|
"""
|
||||||
|
After hacking for many days on multiple "label" systems inside
|
||||||
|
``pyqtgraph`` yet again we're left writing our own since it seems
|
||||||
|
all of those are over complicated, ad-hoc, pieces of garbage that
|
||||||
|
can't accomplish the simplest things, such as pinning to the left
|
||||||
|
hand side of a view box.
|
||||||
|
|
||||||
|
This type is another effort (see our graphics) to start making
|
||||||
|
small, re-usable label components that can actually be used to build
|
||||||
|
production grade UIs...
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
|
||||||
|
self,
|
||||||
|
view: pg.ViewBox,
|
||||||
|
fmt_str: str,
|
||||||
|
color: str = 'bracket',
|
||||||
|
x_offset: float = 0,
|
||||||
|
font_size_inches: float = _down_2_font_inches_we_like,
|
||||||
|
opacity: float = 0.666,
|
||||||
|
fields: dict = {}
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
vb = self.vb = view
|
||||||
|
self._fmt_str = fmt_str
|
||||||
|
self._view_xy = QPointF(0, 0)
|
||||||
|
|
||||||
|
self._x_offset = x_offset
|
||||||
|
|
||||||
|
txt = self.txt = QtGui.QGraphicsTextItem()
|
||||||
|
vb.scene().addItem(txt)
|
||||||
|
|
||||||
|
# configure font size based on DPI
|
||||||
|
dpi_font = DpiAwareFont(
|
||||||
|
size_in_inches=font_size_inches
|
||||||
|
)
|
||||||
|
dpi_font.configure_to_dpi()
|
||||||
|
txt.setFont(dpi_font.font)
|
||||||
|
|
||||||
|
txt.setOpacity(opacity)
|
||||||
|
|
||||||
|
# register viewbox callbacks
|
||||||
|
vb.sigRangeChanged.connect(self.on_sigrange_change)
|
||||||
|
|
||||||
|
self._hcolor: str = ''
|
||||||
|
self.color = color
|
||||||
|
|
||||||
|
self.fields = fields
|
||||||
|
self.orient_v = 'bottom'
|
||||||
|
|
||||||
|
self._anchor_func = self.txt.pos().x
|
||||||
|
|
||||||
|
# not sure if this makes a diff
|
||||||
|
self.txt.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||||
|
|
||||||
|
# TODO: edit and selection support
|
||||||
|
# https://doc.qt.io/qt-5/qt.html#TextInteractionFlag-enum
|
||||||
|
# self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def color(self):
|
||||||
|
return self._hcolor
|
||||||
|
|
||||||
|
@color.setter
|
||||||
|
def color(self, color: str) -> None:
|
||||||
|
self.txt.setDefaultTextColor(pg.mkColor(hcolor(color)))
|
||||||
|
self._hcolor = color
|
||||||
|
|
||||||
|
def on_sigrange_change(self, vr, r) -> None:
|
||||||
|
self.set_view_y(self._view_xy.y())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def w(self) -> float:
|
||||||
|
return self.txt.boundingRect().width()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def h(self) -> float:
|
||||||
|
return self.txt.boundingRect().height()
|
||||||
|
|
||||||
|
def vbr(self) -> QRectF:
|
||||||
|
return self.vb.boundingRect()
|
||||||
|
|
||||||
|
def set_x_anchor_func(
|
||||||
|
self,
|
||||||
|
func: Callable,
|
||||||
|
) -> None:
|
||||||
|
assert isinstance(func(), float)
|
||||||
|
self._anchor_func = func
|
||||||
|
|
||||||
|
def set_view_y(
|
||||||
|
self,
|
||||||
|
y: float,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
scene_x = self._anchor_func() or self.txt.pos().x()
|
||||||
|
|
||||||
|
# get new (inside the) view coordinates / position
|
||||||
|
self._view_xy = QPointF(
|
||||||
|
self.vb.mapToView(QPointF(scene_x, scene_x)).x(),
|
||||||
|
y,
|
||||||
|
)
|
||||||
|
|
||||||
|
# map back to the outer UI-land "scene" coordinates
|
||||||
|
s_xy = self.vb.mapFromView(self._view_xy)
|
||||||
|
|
||||||
|
if self.orient_v == 'top':
|
||||||
|
s_xy = QPointF(s_xy.x(), s_xy.y() - self.h)
|
||||||
|
|
||||||
|
# move label in scene coords to desired position
|
||||||
|
self.txt.setPos(s_xy)
|
||||||
|
|
||||||
|
assert s_xy == self.txt.pos()
|
||||||
|
|
||||||
|
def orient_on(self, h: str, v: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fmt_str(self) -> str:
|
||||||
|
return self._fmt_str
|
||||||
|
|
||||||
|
@fmt_str.setter
|
||||||
|
def fmt_str(self, fmt_str: str) -> None:
|
||||||
|
self._fmt_str = fmt_str
|
||||||
|
|
||||||
|
def format(self, **fields: dict) -> str:
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
|
||||||
|
# this is hacky support for single depth
|
||||||
|
# calcs of field data from field data
|
||||||
|
# ex. to calculate a $value = price * size
|
||||||
|
for k, v in fields.items():
|
||||||
|
if isfunction(v):
|
||||||
|
out[k] = v(fields)
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
|
||||||
|
text = self._fmt_str.format(**out)
|
||||||
|
|
||||||
|
# for large numbers with a thousands place
|
||||||
|
text = text.replace(',', ' ')
|
||||||
|
|
||||||
|
self.txt.setPlainText(text)
|
||||||
|
|
||||||
|
def render(self) -> None:
|
||||||
|
self.format(**self.fields)
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
self.txt.show()
|
||||||
|
|
||||||
|
def hide(self) -> None:
|
||||||
|
self.txt.hide()
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
self.vb.scene().removeItem(self.txt)
|
|
@ -0,0 +1,48 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Customization of ``pyqtgraph`` core routines to speed up our use mostly
|
||||||
|
based on not requiring "scentific precision" for pixel perfect view
|
||||||
|
transforms.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import pyqtgraph as pg
|
||||||
|
|
||||||
|
|
||||||
|
def invertQTransform(tr):
|
||||||
|
"""Return a QTransform that is the inverse of *tr*.
|
||||||
|
Raises an exception if tr is not invertible.
|
||||||
|
|
||||||
|
Note that this function is preferred over QTransform.inverted() due to
|
||||||
|
bugs in that method. (specifically, Qt has floating-point precision issues
|
||||||
|
when determining whether a matrix is invertible)
|
||||||
|
|
||||||
|
"""
|
||||||
|
# see https://doc.qt.io/qt-5/qtransform.html#inverted
|
||||||
|
|
||||||
|
# NOTE: if ``invertable == False``, ``qt_t`` is an identity
|
||||||
|
qt_t, invertable = tr.inverted()
|
||||||
|
|
||||||
|
return qt_t
|
||||||
|
|
||||||
|
|
||||||
|
def _do_overrides() -> None:
|
||||||
|
"""Dooo eeet.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# we don't care about potential fp issues inside Qt
|
||||||
|
pg.functions.invertQTransform = invertQTransform
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# piker: trading gear for hackers
|
||||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -25,17 +25,22 @@ from PyQt5 import QtCore, QtGui
|
||||||
from qdarkstyle.palette import DarkPalette
|
from qdarkstyle.palette import DarkPalette
|
||||||
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
|
from ._exec import current_screen
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
# chart-wide fonts specified in inches
|
# chart-wide fonts specified in inches
|
||||||
_default_font_inches_we_like = 6 / 96
|
_default_font_inches_we_like_low_dpi = 6 / 64
|
||||||
_down_2_font_inches_we_like = 5 / 96
|
_down_2_font_inches_we_like_low_dpi = 4 / 64
|
||||||
|
|
||||||
|
_default_font_inches_we_like = 0.0616 # 5 / 96
|
||||||
|
_down_2_font_inches_we_like = 0.055 # 4 / 96
|
||||||
|
|
||||||
|
|
||||||
class DpiAwareFont:
|
class DpiAwareFont:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
# TODO: move to config
|
||||||
name: str = 'Hack',
|
name: str = 'Hack',
|
||||||
size_in_inches: Optional[float] = None,
|
size_in_inches: Optional[float] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -45,12 +50,23 @@ class DpiAwareFont:
|
||||||
self._qfm = QtGui.QFontMetrics(self._qfont)
|
self._qfm = QtGui.QFontMetrics(self._qfont)
|
||||||
self._physical_dpi = None
|
self._physical_dpi = None
|
||||||
self._screen = None
|
self._screen = None
|
||||||
self._dpi_scalar = 1.
|
|
||||||
|
|
||||||
def _set_qfont_px_size(self, px_size: int) -> None:
|
def _set_qfont_px_size(self, px_size: int) -> None:
|
||||||
self._qfont.setPixelSize(px_size)
|
self._qfont.setPixelSize(px_size)
|
||||||
self._qfm = QtGui.QFontMetrics(self._qfont)
|
self._qfm = QtGui.QFontMetrics(self._qfont)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def screen(self) -> QtGui.QScreen:
|
||||||
|
if self._screen is not None:
|
||||||
|
try:
|
||||||
|
self._screen.refreshRate()
|
||||||
|
except RuntimeError:
|
||||||
|
self._screen = current_screen()
|
||||||
|
else:
|
||||||
|
self._screen = current_screen()
|
||||||
|
|
||||||
|
return self._screen
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def font(self):
|
def font(self):
|
||||||
return self._qfont
|
return self._qfont
|
||||||
|
@ -59,30 +75,37 @@ class DpiAwareFont:
|
||||||
def px_size(self):
|
def px_size(self):
|
||||||
return self._qfont.pixelSize()
|
return self._qfont.pixelSize()
|
||||||
|
|
||||||
def configure_to_dpi(self, screen: QtGui.QScreen):
|
def configure_to_dpi(self, screen: Optional[QtGui.QScreen] = None):
|
||||||
"""Set an appropriately sized font size depending on the screen DPI.
|
"""Set an appropriately sized font size depending on the screen DPI.
|
||||||
|
|
||||||
If we end up needing to generalize this more here there are resources
|
If we end up needing to generalize this more here there are resources
|
||||||
listed in the script in ``snippets/qt_screen_info.py``.
|
listed in the script in ``snippets/qt_screen_info.py``.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
if screen is None:
|
||||||
|
screen = self.screen
|
||||||
|
|
||||||
# take the max since scaling can make things ugly in some cases
|
# take the max since scaling can make things ugly in some cases
|
||||||
pdpi = screen.physicalDotsPerInch()
|
pdpi = screen.physicalDotsPerInch()
|
||||||
ldpi = screen.logicalDotsPerInch()
|
ldpi = screen.logicalDotsPerInch()
|
||||||
dpi = max(pdpi, ldpi)
|
dpi = max(pdpi, ldpi)
|
||||||
|
|
||||||
|
# for low dpi scale everything down
|
||||||
|
if dpi <= 96:
|
||||||
|
self._iwl = _default_font_inches_we_like_low_dpi
|
||||||
|
|
||||||
font_size = math.floor(self._iwl * dpi)
|
font_size = math.floor(self._iwl * dpi)
|
||||||
log.info(
|
log.info(
|
||||||
f"\nscreen:{screen.name()} with DPI: {dpi}"
|
f"\nscreen:{screen.name()} with DPI: {dpi}"
|
||||||
f"\nbest font size is {font_size}\n"
|
f"\nbest font size is {font_size}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
self._set_qfont_px_size(font_size)
|
self._set_qfont_px_size(font_size)
|
||||||
self._physical_dpi = dpi
|
self._physical_dpi = dpi
|
||||||
self._screen = screen
|
|
||||||
|
|
||||||
def boundingRect(self, value: str) -> QtCore.QRectF:
|
def boundingRect(self, value: str) -> QtCore.QRectF:
|
||||||
|
|
||||||
screen = self._screen
|
screen = self.screen
|
||||||
if screen is None:
|
if screen is None:
|
||||||
raise RuntimeError("You must call .configure_to_dpi() first!")
|
raise RuntimeError("You must call .configure_to_dpi() first!")
|
||||||
|
|
||||||
|
@ -110,8 +133,8 @@ _xaxis_at = 'bottom'
|
||||||
# charting config
|
# charting config
|
||||||
CHART_MARGINS = (0, 0, 2, 2)
|
CHART_MARGINS = (0, 0, 2, 2)
|
||||||
_min_points_to_show = 6
|
_min_points_to_show = 6
|
||||||
_bars_from_right_in_follow_mode = int(6**2)
|
_bars_from_right_in_follow_mode = int(130)
|
||||||
_bars_to_left_in_follow_mode = int(6**3)
|
_bars_to_left_in_follow_mode = int(616)
|
||||||
_tina_mode = False
|
_tina_mode = False
|
||||||
|
|
||||||
|
|
||||||
|
@ -125,6 +148,10 @@ def enable_tina_mode() -> None:
|
||||||
|
|
||||||
def hcolor(name: str) -> str:
|
def hcolor(name: str) -> str:
|
||||||
"""Hex color codes by hipster speak.
|
"""Hex color codes by hipster speak.
|
||||||
|
|
||||||
|
This is an internal set of color codes hand picked
|
||||||
|
for certain purposes.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
|
|
||||||
|
@ -137,6 +164,8 @@ def hcolor(name: str) -> str:
|
||||||
|
|
||||||
# fifty shades
|
# fifty shades
|
||||||
'gray': '#808080', # like the kick
|
'gray': '#808080', # like the kick
|
||||||
|
'grayer': '#4c4c4c',
|
||||||
|
'grayest': '#3f3f3f',
|
||||||
'jet': '#343434',
|
'jet': '#343434',
|
||||||
'cadet': '#91A3B0',
|
'cadet': '#91A3B0',
|
||||||
'marengo': '#91A3B0',
|
'marengo': '#91A3B0',
|
||||||
|
@ -166,9 +195,39 @@ def hcolor(name: str) -> str:
|
||||||
'tina_green': '#00cc00',
|
'tina_green': '#00cc00',
|
||||||
'tina_red': '#fa0000',
|
'tina_red': '#fa0000',
|
||||||
|
|
||||||
|
'cucumber': '#006400',
|
||||||
|
'cool_green': '#33b864',
|
||||||
|
'dull_green': '#74a662',
|
||||||
|
'hedge_green': '#518360',
|
||||||
|
|
||||||
# orders and alerts
|
# orders and alerts
|
||||||
'alert_yellow': '#e2d083',
|
'alert_yellow': '#e2d083',
|
||||||
'alert_yellow_light': '#ffe366',
|
'alert_yellow_light': '#ffe366',
|
||||||
|
|
||||||
|
# buys
|
||||||
|
# 'hedge': '#768a75',
|
||||||
|
# 'hedge': '#41694d',
|
||||||
|
# 'hedge': '#558964',
|
||||||
|
# 'hedge_light': '#5e9870',
|
||||||
|
|
||||||
|
'80s_neon_green': '#00b677',
|
||||||
|
# 'buy_green': '#41694d',
|
||||||
|
'buy_green': '#558964',
|
||||||
|
'buy_green_light': '#558964',
|
||||||
|
|
||||||
|
# sells
|
||||||
|
# techincally "raspberry"
|
||||||
|
# 'sell_red': '#990036',
|
||||||
|
# 'sell_red': '#9A0036',
|
||||||
|
|
||||||
|
# brighter then above
|
||||||
|
# 'sell_red': '#8c0030',
|
||||||
|
|
||||||
|
'sell_red': '#b6003f',
|
||||||
|
# 'sell_red': '#d00048',
|
||||||
|
'sell_red_light': '#f85462',
|
||||||
|
|
||||||
|
# 'sell_red': '#f85462',
|
||||||
|
# 'sell_red_light': '#ff4d5c',
|
||||||
|
|
||||||
}[name]
|
}[name]
|
||||||
|
|
|
@ -16,8 +16,8 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Console interface to UI components.
|
Console interface to UI components.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from functools import partial
|
|
||||||
import os
|
import os
|
||||||
import click
|
import click
|
||||||
import tractor
|
import tractor
|
||||||
|
@ -63,10 +63,10 @@ def monitor(config, rate, name, dhost, test, tl):
|
||||||
_kivy_import_hack()
|
_kivy_import_hack()
|
||||||
from .kivy.monitor import _async_main
|
from .kivy.monitor import _async_main
|
||||||
|
|
||||||
async def main(tries):
|
async def main():
|
||||||
async with maybe_spawn_brokerd(
|
async with maybe_spawn_brokerd(
|
||||||
brokername=brokermod.name,
|
brokername=brokermod.name,
|
||||||
tries=tries, loglevel=loglevel
|
loglevel=loglevel
|
||||||
) as portal:
|
) as portal:
|
||||||
# run app "main"
|
# run app "main"
|
||||||
await _async_main(
|
await _async_main(
|
||||||
|
@ -75,7 +75,7 @@ def monitor(config, rate, name, dhost, test, tl):
|
||||||
)
|
)
|
||||||
|
|
||||||
tractor.run(
|
tractor.run(
|
||||||
partial(main, tries=1),
|
main,
|
||||||
name='monitor',
|
name='monitor',
|
||||||
loglevel=loglevel if tl else None,
|
loglevel=loglevel if tl else None,
|
||||||
rpc_module_paths=['piker.ui.kivy.monitor'],
|
rpc_module_paths=['piker.ui.kivy.monitor'],
|
||||||
|
@ -90,7 +90,7 @@ def monitor(config, rate, name, dhost, test, tl):
|
||||||
@click.option('--rate', '-r', default=1, help='Logging level')
|
@click.option('--rate', '-r', default=1, help='Logging level')
|
||||||
@click.argument('symbol', required=True)
|
@click.argument('symbol', required=True)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def optschain(config, symbol, date, tl, rate, test):
|
def optschain(config, symbol, date, rate, test):
|
||||||
"""Start an option chain UI
|
"""Start an option chain UI
|
||||||
"""
|
"""
|
||||||
# global opts
|
# global opts
|
||||||
|
@ -100,9 +100,9 @@ def optschain(config, symbol, date, tl, rate, test):
|
||||||
_kivy_import_hack()
|
_kivy_import_hack()
|
||||||
from .kivy.option_chain import _async_main
|
from .kivy.option_chain import _async_main
|
||||||
|
|
||||||
async def main(tries):
|
async def main():
|
||||||
async with maybe_spawn_brokerd(
|
async with maybe_spawn_brokerd(
|
||||||
tries=tries, loglevel=loglevel
|
loglevel=loglevel
|
||||||
):
|
):
|
||||||
# run app "main"
|
# run app "main"
|
||||||
await _async_main(
|
await _async_main(
|
||||||
|
@ -114,9 +114,8 @@ def optschain(config, symbol, date, tl, rate, test):
|
||||||
)
|
)
|
||||||
|
|
||||||
tractor.run(
|
tractor.run(
|
||||||
partial(main, tries=1),
|
main,
|
||||||
name='kivy-options-chain',
|
name='kivy-options-chain',
|
||||||
loglevel=loglevel if tl else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -126,30 +125,31 @@ def optschain(config, symbol, date, tl, rate, test):
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
help='Enable pyqtgraph profiling'
|
help='Enable pyqtgraph profiling'
|
||||||
)
|
)
|
||||||
@click.option('--date', '-d', help='Contracts expiry date')
|
|
||||||
@click.option('--test', '-t', help='Test quote stream file')
|
|
||||||
@click.option('--rate', '-r', default=1, help='Logging level')
|
|
||||||
@click.argument('symbol', required=True)
|
@click.argument('symbol', required=True)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def chart(config, symbol, date, rate, test, profile):
|
def chart(config, symbol, profile):
|
||||||
"""Start a real-time chartng UI
|
"""Start a real-time chartng UI
|
||||||
"""
|
"""
|
||||||
from .. import _profile
|
from .. import _profile
|
||||||
from ._chart import _main
|
from ._chart import _main
|
||||||
|
|
||||||
# possibly enable profiling
|
# toggle to enable profiling
|
||||||
_profile._pg_profile = profile
|
_profile._pg_profile = profile
|
||||||
|
|
||||||
# global opts
|
# global opts
|
||||||
brokername = config['broker']
|
brokername = config['broker']
|
||||||
tractorloglevel = config['tractorloglevel']
|
tractorloglevel = config['tractorloglevel']
|
||||||
|
pikerloglevel = config['loglevel']
|
||||||
|
|
||||||
_main(
|
_main(
|
||||||
sym=symbol,
|
sym=symbol,
|
||||||
brokername=brokername,
|
brokername=brokername,
|
||||||
|
piker_loglevel=pikerloglevel,
|
||||||
tractor_kwargs={
|
tractor_kwargs={
|
||||||
'debug_mode': True,
|
'debug_mode': True,
|
||||||
'loglevel': tractorloglevel,
|
'loglevel': tractorloglevel,
|
||||||
'rpc_module_paths': ['piker._ems'],
|
'enable_modules': [
|
||||||
|
'piker.exchange._client'
|
||||||
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -144,14 +144,17 @@ async def update_quotes(
|
||||||
log.warn("Data feed connection dropped")
|
log.warn("Data feed connection dropped")
|
||||||
|
|
||||||
|
|
||||||
|
_widgets = {}
|
||||||
|
|
||||||
|
|
||||||
async def stream_symbol_selection():
|
async def stream_symbol_selection():
|
||||||
"""An RPC async gen for streaming the symbol corresponding
|
"""An RPC async gen for streaming the symbol corresponding
|
||||||
value corresponding to the last clicked row.
|
value corresponding to the last clicked row.
|
||||||
|
|
||||||
Essentially of an event stream of clicked symbol values.
|
Essentially of an event stream of clicked symbol values.
|
||||||
"""
|
"""
|
||||||
widgets = tractor.current_actor().statespace['widgets']
|
global _widgets
|
||||||
table = widgets['table']
|
table = _widgets['table']
|
||||||
send_chan, recv_chan = trio.open_memory_channel(0)
|
send_chan, recv_chan = trio.open_memory_channel(0)
|
||||||
table._click_queues.append(send_chan)
|
table._click_queues.append(send_chan)
|
||||||
try:
|
try:
|
||||||
|
@ -238,8 +241,6 @@ async def _async_main(
|
||||||
# set up a pager view for large ticker lists
|
# set up a pager view for large ticker lists
|
||||||
table.bind(minimum_height=table.setter('height'))
|
table.bind(minimum_height=table.setter('height'))
|
||||||
|
|
||||||
ss = tractor.current_actor().statespace
|
|
||||||
|
|
||||||
async def spawn_opts_chain():
|
async def spawn_opts_chain():
|
||||||
"""Spawn an options chain UI in a new subactor.
|
"""Spawn an options chain UI in a new subactor.
|
||||||
"""
|
"""
|
||||||
|
@ -276,7 +277,10 @@ async def _async_main(
|
||||||
'header': header,
|
'header': header,
|
||||||
'pager': pager,
|
'pager': pager,
|
||||||
}
|
}
|
||||||
ss['widgets'] = widgets
|
|
||||||
|
global _widgets
|
||||||
|
_widgets = widgets
|
||||||
|
|
||||||
nursery.start_soon(
|
nursery.start_soon(
|
||||||
update_quotes,
|
update_quotes,
|
||||||
nursery,
|
nursery,
|
||||||
|
|
|
@ -0,0 +1,417 @@
|
||||||
|
# piker: trading gear for hackers
|
||||||
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Chart trading, the only way to scalp.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pprint import pformat
|
||||||
|
import time
|
||||||
|
from typing import Optional, Dict, Callable, Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pyqtgraph as pg
|
||||||
|
import trio
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ._graphics._lines import LevelLine, position_line
|
||||||
|
from ._interaction import LineEditor, ArrowEditor, _order_lines
|
||||||
|
from ..exchange._client import open_ems, OrderBook
|
||||||
|
from ..data._source import Symbol
|
||||||
|
from ..log import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Position(BaseModel):
|
||||||
|
symbol: Symbol
|
||||||
|
size: float
|
||||||
|
avg_price: float
|
||||||
|
fills: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrderMode:
|
||||||
|
"""Major mode for placing orders on a chart view.
|
||||||
|
|
||||||
|
This is the default mode that pairs with "follow mode"
|
||||||
|
(when wathing the rt price update at the current time step)
|
||||||
|
and allows entering orders using the ``a, d, f`` keys and
|
||||||
|
cancelling moused-over orders with the ``c`` key.
|
||||||
|
|
||||||
|
"""
|
||||||
|
chart: 'ChartPlotWidget' # type: ignore # noqa
|
||||||
|
book: OrderBook
|
||||||
|
lines: LineEditor
|
||||||
|
arrows: ArrowEditor
|
||||||
|
_colors = {
|
||||||
|
'alert': 'alert_yellow',
|
||||||
|
'buy': 'buy_green',
|
||||||
|
'sell': 'sell_red',
|
||||||
|
}
|
||||||
|
_action: str = 'alert'
|
||||||
|
_exec_mode: str = 'dark'
|
||||||
|
_size: float = 100.0
|
||||||
|
_position: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
_position_line: dict = None
|
||||||
|
|
||||||
|
key_map: Dict[str, Callable] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def on_position_update(
|
||||||
|
self,
|
||||||
|
msg: dict,
|
||||||
|
) -> None:
|
||||||
|
print(f'Position update {msg}')
|
||||||
|
|
||||||
|
sym = self.chart._lc._symbol
|
||||||
|
if msg['symbol'].lower() not in sym.key:
|
||||||
|
return
|
||||||
|
|
||||||
|
size = msg['size']
|
||||||
|
|
||||||
|
self._position.update(msg)
|
||||||
|
if self._position_line:
|
||||||
|
self._position_line.delete()
|
||||||
|
|
||||||
|
if size != 0.0:
|
||||||
|
line = self._position_line = position_line(
|
||||||
|
self.chart,
|
||||||
|
level=msg['avg_price'],
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
line.show()
|
||||||
|
|
||||||
|
def uuid(self) -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
def set_exec(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
size: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set execution mode.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self._action = action
|
||||||
|
self.lines.stage_line(
|
||||||
|
|
||||||
|
color=self._colors[action],
|
||||||
|
# hl_on_hover=True if self._exec_mode == 'live' else False,
|
||||||
|
dotted=True if self._exec_mode == 'dark' else False,
|
||||||
|
size=size or self._size,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_submit(self, uuid: str) -> dict:
|
||||||
|
"""On order submitted event, commit the order line
|
||||||
|
and registered order uuid, store ack time stamp.
|
||||||
|
|
||||||
|
TODO: annotate order line with submission type ('live' vs.
|
||||||
|
'dark').
|
||||||
|
|
||||||
|
"""
|
||||||
|
line = self.lines.commit_line(uuid)
|
||||||
|
req_msg = self.book._sent_orders.get(uuid)
|
||||||
|
if req_msg:
|
||||||
|
req_msg['ack_time_ns'] = time.time_ns()
|
||||||
|
|
||||||
|
return line
|
||||||
|
|
||||||
|
def on_fill(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
price: float,
|
||||||
|
arrow_index: float,
|
||||||
|
pointing: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
line = self.lines._order_lines.get(uuid)
|
||||||
|
if line:
|
||||||
|
self.arrows.add(
|
||||||
|
uuid,
|
||||||
|
arrow_index,
|
||||||
|
price,
|
||||||
|
pointing=pointing,
|
||||||
|
color=line.color
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_exec(
|
||||||
|
self,
|
||||||
|
uuid: str,
|
||||||
|
msg: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# only once all fills have cleared and the execution
|
||||||
|
# is complet do we remove our "order line"
|
||||||
|
line = self.lines.remove_line(uuid=uuid)
|
||||||
|
log.debug(f'deleting {line} with oid: {uuid}')
|
||||||
|
|
||||||
|
# DESKTOP NOTIFICATIONS
|
||||||
|
#
|
||||||
|
# TODO: this in another task?
|
||||||
|
# not sure if this will ever be a bottleneck,
|
||||||
|
# we probably could do graphics stuff first tho?
|
||||||
|
|
||||||
|
# XXX: linux only for now
|
||||||
|
result = await trio.run_process(
|
||||||
|
[
|
||||||
|
'notify-send',
|
||||||
|
'-u', 'normal',
|
||||||
|
'-t', '10000',
|
||||||
|
'piker',
|
||||||
|
f'alert: {msg}',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log.runtime(result)
|
||||||
|
|
||||||
|
def on_cancel(self, uuid: str) -> None:
|
||||||
|
|
||||||
|
msg = self.book._sent_orders.pop(uuid, None)
|
||||||
|
|
||||||
|
if msg is not None:
|
||||||
|
self.lines.remove_line(uuid=uuid)
|
||||||
|
self.chart._cursor.show_xhair()
|
||||||
|
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f'Received cancel for unsubmitted order {pformat(msg)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
def submit_exec(
|
||||||
|
self,
|
||||||
|
size: Optional[float] = None,
|
||||||
|
) -> LevelLine:
|
||||||
|
"""Send execution order to EMS.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# register the "staged" line under the cursor
|
||||||
|
# to be displayed when above order ack arrives
|
||||||
|
# (means the line graphic doesn't show on screen until the
|
||||||
|
# order is live in the emsd).
|
||||||
|
uid = str(uuid.uuid4())
|
||||||
|
|
||||||
|
size = size or self._size
|
||||||
|
|
||||||
|
chart = self.chart._cursor.active_plot
|
||||||
|
y = chart._cursor._datum_xy[1]
|
||||||
|
|
||||||
|
symbol = self.chart._lc._symbol
|
||||||
|
|
||||||
|
action = self._action
|
||||||
|
|
||||||
|
# send order cmd to ems
|
||||||
|
self.book.send(
|
||||||
|
uuid=uid,
|
||||||
|
symbol=symbol.key,
|
||||||
|
brokers=symbol.brokers,
|
||||||
|
price=y,
|
||||||
|
size=size,
|
||||||
|
action=action,
|
||||||
|
exec_mode=self._exec_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# make line graphic if order push was
|
||||||
|
# sucessful
|
||||||
|
line = self.lines.create_order_line(
|
||||||
|
uid,
|
||||||
|
level=y,
|
||||||
|
chart=chart,
|
||||||
|
size=size,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
line.oid = uid
|
||||||
|
|
||||||
|
# hook up mouse drag handlers
|
||||||
|
line._on_drag_start = self.order_line_modify_start
|
||||||
|
line._on_drag_end = self.order_line_modify_complete
|
||||||
|
|
||||||
|
return line
|
||||||
|
|
||||||
|
def cancel_order_under_cursor(self) -> None:
|
||||||
|
for line in self.lines.lines_under_cursor():
|
||||||
|
self.book.cancel(uuid=line.oid)
|
||||||
|
|
||||||
|
# order-line modify handlers
|
||||||
|
def order_line_modify_start(
|
||||||
|
self,
|
||||||
|
line: LevelLine,
|
||||||
|
) -> None:
|
||||||
|
print(f'Line modify: {line}')
|
||||||
|
# cancel original order until new position is found
|
||||||
|
|
||||||
|
def order_line_modify_complete(
|
||||||
|
self,
|
||||||
|
line: LevelLine,
|
||||||
|
) -> None:
|
||||||
|
self.book.update(
|
||||||
|
uuid=line.oid,
|
||||||
|
|
||||||
|
# TODO: should we round this to a nearest tick here?
|
||||||
|
price=line.value(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# def on_key_press(
|
||||||
|
# self,
|
||||||
|
# key:
|
||||||
|
# mods:
|
||||||
|
# text: str,
|
||||||
|
# ) -> None:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def open_order_mode(
|
||||||
|
symbol: Symbol,
|
||||||
|
chart: pg.PlotWidget,
|
||||||
|
book: OrderBook,
|
||||||
|
):
|
||||||
|
view = chart._vb
|
||||||
|
lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines)
|
||||||
|
arrows = ArrowEditor(chart, {})
|
||||||
|
|
||||||
|
log.info("Opening order mode")
|
||||||
|
|
||||||
|
mode = OrderMode(chart, book, lines, arrows)
|
||||||
|
view.mode = mode
|
||||||
|
|
||||||
|
asset_type = symbol.type_key
|
||||||
|
|
||||||
|
if asset_type == 'stock':
|
||||||
|
mode._size = 100.0
|
||||||
|
|
||||||
|
elif asset_type in ('future', 'option', 'futures_option'):
|
||||||
|
mode._size = 1.0
|
||||||
|
|
||||||
|
else: # to be safe
|
||||||
|
mode._size = 1.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield mode
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# XXX special teardown handling like for ex.
|
||||||
|
# - cancelling orders if needed?
|
||||||
|
# - closing positions if desired?
|
||||||
|
# - switching special condition orders to safer/more reliable variants
|
||||||
|
log.info("Closing order mode")
|
||||||
|
|
||||||
|
|
||||||
|
async def start_order_mode(
|
||||||
|
chart: 'ChartPlotWidget', # noqa
|
||||||
|
symbol: Symbol,
|
||||||
|
brokername: str,
|
||||||
|
) -> None:
|
||||||
|
# spawn EMS actor-service
|
||||||
|
async with open_ems(
|
||||||
|
brokername,
|
||||||
|
symbol,
|
||||||
|
) as (book, trades_stream):
|
||||||
|
|
||||||
|
async with open_order_mode(
|
||||||
|
symbol,
|
||||||
|
chart,
|
||||||
|
book,
|
||||||
|
) as order_mode:
|
||||||
|
|
||||||
|
def get_index(time: float):
|
||||||
|
|
||||||
|
# XXX: not sure why the time is so off here
|
||||||
|
# looks like we're gonna have to do some fixing..
|
||||||
|
|
||||||
|
ohlc = chart._shm.array
|
||||||
|
indexes = ohlc['time'] >= time
|
||||||
|
|
||||||
|
if any(indexes):
|
||||||
|
return ohlc['index'][indexes[-1]]
|
||||||
|
else:
|
||||||
|
return ohlc['index'][-1]
|
||||||
|
|
||||||
|
# Begin order-response streaming
|
||||||
|
|
||||||
|
# this is where we receive **back** messages
|
||||||
|
# about executions **from** the EMS actor
|
||||||
|
async for msg in trades_stream:
|
||||||
|
|
||||||
|
fmsg = pformat(msg)
|
||||||
|
log.info(f'Received order msg:\n{fmsg}')
|
||||||
|
|
||||||
|
resp = msg['resp']
|
||||||
|
|
||||||
|
if resp in (
|
||||||
|
'position',
|
||||||
|
):
|
||||||
|
# show line label once order is live
|
||||||
|
order_mode.on_position_update(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# delete the line from view
|
||||||
|
oid = msg['oid']
|
||||||
|
|
||||||
|
# response to 'action' request (buy/sell)
|
||||||
|
if resp in (
|
||||||
|
'dark_submitted',
|
||||||
|
'broker_submitted'
|
||||||
|
):
|
||||||
|
|
||||||
|
# show line label once order is live
|
||||||
|
order_mode.on_submit(oid)
|
||||||
|
|
||||||
|
# resp to 'cancel' request or error condition
|
||||||
|
# for action request
|
||||||
|
elif resp in (
|
||||||
|
'broker_cancelled',
|
||||||
|
'broker_inactive',
|
||||||
|
'dark_cancelled'
|
||||||
|
):
|
||||||
|
# delete level line from view
|
||||||
|
order_mode.on_cancel(oid)
|
||||||
|
|
||||||
|
elif resp in (
|
||||||
|
'dark_executed'
|
||||||
|
):
|
||||||
|
log.info(f'Dark order triggered for {fmsg}')
|
||||||
|
|
||||||
|
# for alerts add a triangle and remove the
|
||||||
|
# level line
|
||||||
|
if msg['cmd']['action'] == 'alert':
|
||||||
|
|
||||||
|
# should only be one "fill" for an alert
|
||||||
|
order_mode.on_fill(
|
||||||
|
oid,
|
||||||
|
price=msg['trigger_price'],
|
||||||
|
arrow_index=get_index(time.time())
|
||||||
|
)
|
||||||
|
await order_mode.on_exec(oid, msg)
|
||||||
|
|
||||||
|
# response to completed 'action' request for buy/sell
|
||||||
|
elif resp in (
|
||||||
|
'broker_executed',
|
||||||
|
):
|
||||||
|
await order_mode.on_exec(oid, msg)
|
||||||
|
|
||||||
|
# each clearing tick is responded individually
|
||||||
|
elif resp in ('broker_filled',):
|
||||||
|
action = msg['action']
|
||||||
|
# TODO: some kinda progress system
|
||||||
|
order_mode.on_fill(
|
||||||
|
oid,
|
||||||
|
price=msg['price'],
|
||||||
|
arrow_index=get_index(msg['broker_time']),
|
||||||
|
pointing='up' if action == 'buy' else 'down',
|
||||||
|
)
|
|
@ -1,4 +1,3 @@
|
||||||
# no pypi package for tractor (yet)
|
# no pypi package for tractor (yet)
|
||||||
# we require the asyncio-via-guest-mode dev branch
|
# we require the asyncio-via-guest-mode dev branch
|
||||||
-e git+git://github.com/goodboy/tractor.git@infect_asyncio#egg=tractor
|
-e git+git://github.com/goodboy/tractor.git@infect_asyncio#egg=tractor
|
||||||
-e git+git://github.com/pikers/pyqtgraph.git@use_qt_inverted#egg=pyqtgraph
|
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -45,6 +45,7 @@ setup(
|
||||||
'attrs',
|
'attrs',
|
||||||
'pygments',
|
'pygments',
|
||||||
'colorama', # numba traceback coloring
|
'colorama', # numba traceback coloring
|
||||||
|
'pydantic', # structured data
|
||||||
|
|
||||||
# async
|
# async
|
||||||
'trio',
|
'trio',
|
||||||
|
@ -58,6 +59,7 @@ setup(
|
||||||
|
|
||||||
# numerics
|
# numerics
|
||||||
'arrow', # better datetimes
|
'arrow', # better datetimes
|
||||||
|
'bidict', # 2 way map
|
||||||
'cython',
|
'cython',
|
||||||
'numpy',
|
'numpy',
|
||||||
'numba',
|
'numba',
|
||||||
|
|
Loading…
Reference in New Issue