diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0e1d3129..00000000 --- a/.travis.yml +++ /dev/null @@ -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/ diff --git a/README.rst b/README.rst index cf8112a2..de58ad56 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,7 @@ trading gear for hackers. :target: https://actions-badge.atrox.dev/piker/pikers/goto ``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 `_ . 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 .. _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/ +.. _comp_trader: https://jfaleiro.wordpress.com/2019/10/09/computational-trader/ focus and features: diff --git a/piker/_async_utils.py b/piker/_async_utils.py index b358e2f0..fb221215 100644 --- a/piker/_async_utils.py +++ b/piker/_async_utils.py @@ -17,7 +17,9 @@ """ Async utils no one seems to have built into a core lib (yet). """ +from typing import AsyncContextManager from collections import OrderedDict +from contextlib import asynccontextmanager def async_lifo_cache(maxsize=128): @@ -47,3 +49,18 @@ def async_lifo_cache(maxsize=128): return wrapper 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 diff --git a/piker/_ems.py b/piker/_ems.py deleted file mode 100644 index 8ede8f31..00000000 --- a/piker/_ems.py +++ /dev/null @@ -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 . - -""" -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) diff --git a/piker/brokers/api.py b/piker/brokers/api.py index ba54a565..75fc8a14 100644 --- a/piker/brokers/api.py +++ b/piker/brokers/api.py @@ -1,5 +1,5 @@ # 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 # it under the terms of the GNU Affero General Public License as published by @@ -16,7 +16,9 @@ """ Actor-aware broker agnostic interface. + """ +from typing import Dict from contextlib import asynccontextmanager, AsyncExitStack import trio @@ -28,6 +30,7 @@ from ..log import get_logger log = get_logger(__name__) +_cache: Dict[str, 'Client'] = {} @asynccontextmanager 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. """ - # check if a cached client is in the local actor's statespace - ss = tractor.current_actor().statespace - clients = ss.setdefault('clients', {'_lock': trio.Lock()}) + global _cache + + clients = _cache.setdefault('clients', {'_lock': trio.Lock()}) + + # global cache task lock lock = clients['_lock'] + client = None + try: - log.info(f"Loading existing `{brokername}` daemon") + log.info(f"Loading existing `{brokername}` client") + async with lock: client = clients[brokername] client._consumers += 1 + yield client + except KeyError: log.info(f"Creating new client for broker {brokername}") + async with lock: brokermod = get_brokermod(brokername) exit_stack = AsyncExitStack() + client = await exit_stack.enter_async_context( brokermod.get_client() ) client._consumers = 0 client._exit_stack = exit_stack clients[brokername] = client + yield client + finally: client._consumers -= 1 if client._consumers <= 0: diff --git a/piker/brokers/data.py b/piker/brokers/data.py index cdf056b4..82e08507 100644 --- a/piker/brokers/data.py +++ b/piker/brokers/data.py @@ -180,15 +180,18 @@ async def symbol_data(broker: str, tickers: List[str]): return await feed.client.symbol_info(tickers) +_feeds_cache = {} + @asynccontextmanager async def get_cached_feed( brokername: str, ) -> BrokerFeed: """Get/create a ``BrokerFeed`` from/in the current actor. """ - # check if a cached client is in the local actor's statespace - ss = tractor.current_actor().statespace - feeds = ss.setdefault('feeds', {'_lock': trio.Lock()}) + global _feeds_cache + + # check if a cached feed is in the local actor + feeds = _feeds_cache.setdefault('feeds', {'_lock': trio.Lock()}) lock = feeds['_lock'] feed = None try: diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index d0645dfe..2679e988 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -1,5 +1,5 @@ # 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 # 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 dataclasses import asdict -from functools import partial from datetime import datetime +from functools import partial from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable import asyncio -import logging +from pprint import pformat import inspect import itertools +import logging import time +import trio +import tractor from async_generator import aclosing from ib_insync.wrapper import RequestError from ib_insync.contract import Contract, ContractDetails +from ib_insync.order import Order from ib_insync.ticker import Ticker +from ib_insync.objects import Position import ib_insync as ibis from ib_insync.wrapper import Wrapper from ib_insync.client import Client as ib_Client -import trio -import tractor from ..log import get_logger, get_console_log from ..data import ( maybe_spawn_brokerd, iterticks, attach_shm_array, - # get_shm_token, subscribe_ohlc_for_increment, _buffer, ) from ..data._source import from_df from ._util import SymbolNotFound -from .._async_utils import maybe_with_if log = get_logger(__name__) @@ -100,8 +101,9 @@ class NonShittyWrapper(Wrapper): def tcpDataArrived(self): """Override time stamps to be floats for now. """ - # use a float to store epoch time instead of datetime - self.lastTime = time.time() + # use a ns int to store epoch time instead of datetime + self.lastTime = time.time_ns() + for ticker in self.pendingTickers: ticker.rtTime = None ticker.ticks = [] @@ -109,6 +111,20 @@ class NonShittyWrapper(Wrapper): ticker.domTicks = [] 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): """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 self.wrapper = NonShittyWrapper(self) self.client = ib_Client(self.wrapper) - self.errorEvent += self._onError + # self.errorEvent += self._onError self.client.apiEnd += self.disconnectedEvent self._logger = logging.getLogger('ib_insync.ib') @@ -150,6 +166,9 @@ class Client: self.ib = ib self.ib.RaiseRequestErrors = True + # contract cache + self._contracts: Dict[str, Contract] = {} + # NOTE: the ib.client here is "throttled" to 45 rps by default async def bars( @@ -199,7 +218,6 @@ class Client: # barSizeSetting='1 min', - # always use extended hours useRTH=False, @@ -217,9 +235,6 @@ class Client: df = ibis.util.df(bars) return bars, from_df(df) - def onError(self, reqId, errorCode, errorString, contract) -> None: - breakpoint() - async def search_stocks( self, pattern: str, @@ -283,6 +298,24 @@ class Client: currency: str = 'USD', **kwargs, ) -> 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" try: sym, exch = symbol.upper().rsplit('.', maxsplit=1) @@ -331,11 +364,10 @@ class Client: exch = 'SMART' if not exch else exch contract = (await self.ib.qualifyContractsAsync(con))[0] - # head = await self.get_head_time(contract) - # print(head) - except IndexError: raise ValueError(f"No contract could be found {con}") + + self._contracts[symbol] = contract return contract async def get_head_time( @@ -356,9 +388,8 @@ class Client: self, symbol: str, to_trio, - opts: Tuple[int] = ('375', '233',), + opts: Tuple[int] = ('375', '233', '236'), contract: Optional[Contract] = None, - # opts: Tuple[int] = ('459',), ) -> None: """Stream a ticker using the std L1 api. """ @@ -397,11 +428,174 @@ class Client: """ contract = await self.find_contract(symbol) + + details_fute = self.ib.reqContractDetailsAsync(contract) ticker: Ticker = self.ib.reqMktData( contract, 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 @@ -422,7 +616,6 @@ async def _aio_get_client( """ # first check cache for existing client - # breakpoint() try: if port: client = _client_cache[(host, port)] @@ -456,11 +649,13 @@ async def _aio_get_client( else: raise ConnectionRefusedError(_err) + # create and cache try: client = Client(ib) _client_cache[(host, port)] = client log.debug(f"Caching client for {(host, port)}") yield client + except BaseException: ib.disconnect() raise @@ -527,14 +722,13 @@ class _MethodProxy: **kwargs ) -> Any: return await self._portal.run( - __name__, - '_trio_run_client_method', + _trio_run_client_method, method=meth, **kwargs ) -def get_method_proxy(portal, target) -> _MethodProxy: +def get_client_proxy(portal, target=Client) -> _MethodProxy: proxy = _MethodProxy(portal) @@ -558,11 +752,11 @@ async def get_client( """ async with maybe_spawn_brokerd( brokername='ib', - expose_mods=[__name__], infect_asyncio=True, **kwargs ) 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 @@ -586,10 +780,11 @@ def normalize( # convert named tuples to dicts so we send usable keys new_ticks = [] for tick in ticker.ticks: - td = tick._asdict() - td['type'] = tick_types.get(td['tickType'], 'n/a') + if tick: + td = tick._asdict() + td['type'] = tick_types.get(td['tickType'], 'n/a') - new_ticks.append(td) + new_ticks.append(td) ticker.ticks = new_ticks @@ -645,7 +840,7 @@ async def fill_bars( first_bars: list, shm: 'ShmArray', # type: ignore # noqa # 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: """Fill historical bars into shared mem / storage afap. @@ -663,8 +858,8 @@ async def fill_bars( method='bars', symbol=sym, end_dt=next_dt, - ) + shm.push(bars_array, prepend=True) i += 1 next_dt = bars[0].date @@ -690,6 +885,25 @@ async def fill_bars( 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 # the wacky ``ib_insync`` api. # @tractor.msg.pub @@ -699,6 +913,7 @@ async def stream_quotes( symbols: List[str], shm_token: Tuple[str, str, List[tuple]], loglevel: str = None, + # compat for @tractor.msg.pub topics: Any = None, get_topics: Callable = None, @@ -714,7 +929,7 @@ async def stream_quotes( # TODO: support multiple subscriptions sym = symbols[0] - contract, first_ticker = await _trio_run_client_method( + contract, first_ticker, details = await _trio_run_client_method( method='get_quote', symbol=sym, ) @@ -725,8 +940,8 @@ async def stream_quotes( 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, # otherwise start one and mark it as now existing @@ -737,84 +952,112 @@ async def stream_quotes( # maybe load historical ohlcv in to shared mem # check if shm has already been created by previous # feed initialization - async with trio.open_nursery() as ln: - if not writer_already_exists: - _local_buffer_writers[key] = True + if not writer_already_exists: + _local_buffer_writers[key] = True - shm = attach_shm_array( - token=shm_token, + shm = attach_shm_array( + token=shm_token, - # we are the buffer writer - readonly=False, - ) + # we are the buffer writer + readonly=False, + ) - # async def retrieve_and_push(): - start = time.time() + # async def retrieve_and_push(): + start = time.time() - bars, bars_array = await _trio_run_client_method( - method='bars', - symbol=sym, + bars, bars_array = await _trio_run_client_method( + method='bars', + symbol=sym, - ) + ) - log.info(f"bars_array request: {time.time() - start}") + log.info(f"bars_array request: {time.time() - start}") - if bars_array is None: - raise SymbolNotFound(sym) + if bars_array is None: + raise SymbolNotFound(sym) - # write historical data to buffer - shm.push(bars_array) - shm_token = shm.token + # write historical data to buffer + shm.push(bars_array) + shm_token = shm.token - # TODO: generalize this for other brokers - # start bar filler task in bg - ln.start_soon(fill_bars, sym, bars, shm) + # TODO: generalize this for other brokers + # start bar filler task in bg + ln.start_soon(fill_bars, sym, bars, shm) - times = shm.array['time'] - delay_s = times[-1] - times[times != times[-1]][-1] - subscribe_ohlc_for_increment(shm, delay_s) + times = shm.array['time'] + delay_s = times[-1] - times[times != times[-1]][-1] + 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 # 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 - if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): - suffix = 'exchange' - # should be real volume for this contract - calc_price = False - else: - # commodities and forex don't have an exchange name and - # no real volume so we have to calculate the price - suffix = 'secType' - calc_price = True - ticker = first_ticker + # check for special contract types + if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): + suffix = 'exchange' + # should be real volume for this contract + calc_price = False + else: + # commodities and forex don't have an exchange name and + # no real volume so we have to calculate the price + suffix = 'secType' + calc_price = True + # ticker = first_ticker - # pass first quote asap - quote = normalize(first_ticker, calc_price=calc_price) - con = quote['contract'] - topic = '.'.join((con['symbol'], con[suffix])).lower() - quote['symbol'] = topic + # pass first quote asap + quote = normalize(first_ticker, calc_price=calc_price) + con = quote['contract'] + topic = '.'.join((con['symbol'], con[suffix])).lower() + quote['symbol'] = topic - first_quote = {topic: quote} + first_quote = {topic: quote} - # yield first quote asap - await ctx.send_yield(first_quote) + # yield first quote asap + await ctx.send_yield(first_quote) - # ticker.ticks = [] + # ticker.ticks = [] - # ugh, clear ticks since we've consumed them - # (ahem, ib_insync is stateful trash) - first_ticker.ticks = [] + # ugh, clear ticks since we've consumed them + # (ahem, ib_insync is stateful trash) + first_ticker.ticks = [] - log.debug(f"First ticker received {quote}") + log.debug(f"First ticker received {quote}") - if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): - suffix = 'exchange' + if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): + suffix = 'exchange' - calc_price = False # should be real volume for contract + calc_price = False # should be real volume for contract - # wait for real volume on feed (trading might be closed) + # with trio.move_on_after(10) as cs: + # wait for real volume on feed (trading might be closed) + + async with aclosing(stream): async for ticker in stream: # for a real volume contract we rait for the first @@ -836,62 +1079,200 @@ async def stream_quotes( # ``aclosing()`` above? break - # real-time stream - async for ticker in stream: - - # print(ticker.vwap) - quote = normalize( - ticker, - calc_price=calc_price + # 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, ) - quote['symbol'] = topic - # TODO: in theory you can send the IPC msg *before* - # writing to the sharedmem array to decrease latency, - # however, that will require `tractor.msg.pub` support - # here or at least some way to prevent task switching - # at the yield such that the array write isn't delayed - # while another consumer is serviced.. - - # if we are the lone tick writer start writing - # the buffer with appropriate trade data + finally: if not writer_already_exists: - for tick in iterticks(quote, types=('trade', 'utrade',)): - last = tick['price'] + _local_buffer_writers[key] = False - # print(f"{quote['symbol']}: {tick}") - # update last entry - # benchmarked in the 4-5 us range - o, high, low, v = shm.array[-1][ - ['open', 'high', 'low', 'volume'] - ] +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! - new_v = tick.get('size', 0) + """ + # real-time stream + async for ticker in stream: - if v == 0 and new_v: - # no trades for this bar yet so the open - # is also the close/last trade price - o = last + # print(ticker.vwap) + quote = normalize( + ticker, + calc_price=calc_price + ) + quote['symbol'] = topic + # TODO: in theory you can send the IPC msg *before* + # writing to the sharedmem array to decrease latency, + # however, that will require `tractor.msg.pub` support + # here or at least some way to prevent task switching + # at the yield such that the array write isn't delayed + # while another consumer is serviced.. - shm.array[[ - 'open', - 'high', - 'low', - 'close', - 'volume', - ]][-1] = ( - o, - max(high, last), - min(low, last), - last, - v + new_v, - ) + # if we are the lone tick writer start writing + # the buffer with appropriate trade data + if not writer_already_exists: + for tick in iterticks(quote, types=('trade', 'utrade',)): + last = tick['price'] - con = quote['contract'] - topic = '.'.join((con['symbol'], con[suffix])).lower() - quote['symbol'] = topic + # print(f"{quote['symbol']}: {tick}") - await ctx.send_yield({topic: quote}) + # update last entry + # benchmarked in the 4-5 us range + o, high, low, v = shm.array[-1][ + ['open', 'high', 'low', 'volume'] + ] - # ugh, clear ticks since we've consumed them - ticker.ticks = [] + new_v = tick.get('size', 0) + + if v == 0 and new_v: + # no trades for this bar yet so the open + # is also the close/last trade price + o = last + + shm.array[[ + 'open', + 'high', + 'low', + 'close', + 'volume', + ]][-1] = ( + o, + max(high, last), + min(low, last), + last, + v + new_v, + ) + + con = quote['contract'] + topic = '.'.join((con['symbol'], con[suffix])).lower() + quote['symbol'] = topic + + await ctx.send_yield({topic: quote}) + + # ugh, clear ticks since we've consumed them + 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)} diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index dfbf3c0f..5d8763c3 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -17,19 +17,29 @@ """ Kraken backend. """ -from contextlib import asynccontextmanager -from dataclasses import dataclass, asdict, field +from contextlib import asynccontextmanager, AsyncExitStack +from dataclasses import asdict, field +from types import ModuleType from typing import List, Dict, Any, Tuple, Optional import json import time import trio_websocket -from trio_websocket._impl import ConnectionClosed, DisconnectionTimeout +from trio_websocket._impl import ( + ConnectionClosed, + DisconnectionTimeout, + ConnectionRejected, + HandshakeError, + ConnectionTimeout, +) + import arrow import asks import numpy as np import trio import tractor +from pydantic.dataclasses import dataclass +from pydantic import BaseModel from ._util import resproc, SymbolNotFound, BrokerError from ..log import get_logger, get_console_log @@ -68,6 +78,68 @@ ohlc_dtype = np.dtype(_ohlc_dtype) _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: def __init__(self) -> None: @@ -165,52 +237,27 @@ async def get_client() -> Client: yield Client() -@dataclass -class OHLC: - """Description of the flattened OHLC quote format. +async def stream_messages(ws): - 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 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: + too_slow_count += 1 - if too_slow_count > 2: + + if too_slow_count > 10: log.warning( - "Heartbeat is to slow, " - "resetting ws connection") - raise trio_websocket._impl.ConnectionClosed( - "Reset Connection") + "Heartbeat is too slow, resetting ws connection") + + await ws._connect() + too_slow_count = 0 + continue if isinstance(msg, dict): if msg.get('event') == 'heartbeat': @@ -218,11 +265,11 @@ async def recv_msg(recv): now = time.time() delay = now - last_hb last_hb = now - log.trace(f"Heartbeat after {delay}") - # TODO: hmm i guess we should use this - # for determining when to do connection - # resets eh? + # XXX: why tf is this not printing without --tl flag? + log.debug(f"Heartbeat after {delay}") + # print(f"Heartbeat after {delay}") + continue 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 async def stream_quotes( # get_topics: Callable, @@ -312,11 +446,17 @@ async def stream_quotes( get_console_log(loglevel or tractor.current_actor().loglevel) ws_pairs = {} + sym_infos = {} async with get_client() as client: # keep client cached for real-time section 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 # check if shm has already been created by previous @@ -340,125 +480,127 @@ async def stream_quotes( delay_s = times[-1] - times[times != times[-1]][-1] 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: - try: - async with trio_websocket.open_websocket_url( - 'wss://ws.kraken.com/', - ) as ws: + async with open_autorecon_ws('wss://ws.kraken.com/') as ws: - # XXX: setup subs - # https://docs.kraken.com/websockets/#message-subscribe - # specific logic for this in kraken's shitty sync client: - # https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188 - ohlc_sub = make_sub( - list(ws_pairs.values()), - {'name': 'ohlc', 'interval': 1} - ) + # XXX: setup subs + # https://docs.kraken.com/websockets/#message-subscribe + # specific logic for this in kraken's shitty sync client: + # https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188 + ohlc_sub = make_sub( + list(ws_pairs.values()), + {'name': 'ohlc', 'interval': 1} + ) - # TODO: we want to eventually allow unsubs which should - # be completely fine to request from a separate task - # since internally the ws methods appear to be FIFO - # locked. - await ws.send_message(json.dumps(ohlc_sub)) + # TODO: we want to eventually allow unsubs which should + # be completely fine to request from a separate task + # since internally the ws methods appear to be FIFO + # locked. + await ws.send_msg(ohlc_sub) - # trade data (aka L1) - l1_sub = make_sub( - list(ws_pairs.values()), - {'name': 'spread'} # 'depth': 10} + # trade data (aka L1) + l1_sub = make_sub( + list(ws_pairs.values()), + {'name': 'spread'} # 'depth': 10} + ) - ) - await ws.send_message(json.dumps(l1_sub)) + await ws.send_msg(l1_sub) - async def recv(): - return json.loads(await ws.get_message()) + # pull a first quote and deliver + msg_gen = stream_messages(ws) - # pull a first quote and deliver - msg_gen = recv_msg(recv) - typ, ohlc_last = await msg_gen.__anext__() + typ, ohlc_last = await msg_gen.__anext__() - topic, quote = normalize(ohlc_last) + topic, quote = normalize(ohlc_last) - # packetize as {topic: quote} - yield {topic: quote} + # packetize as {topic: quote} + yield {topic: quote} - # tell incrementer task it can start - _buffer.shm_incrementing(shm_token['shm_name']).set() + # tell incrementer task it can start + _buffer.shm_incrementing(shm_token['shm_name']).set() - # keep start of last interval for volume tracking - last_interval_start = ohlc_last.etime + # keep start of last interval for volume tracking + last_interval_start = ohlc_last.etime - # start streaming - async for typ, ohlc in msg_gen: + # start streaming + async for typ, ohlc in msg_gen: - if typ == 'ohlc': + if typ == 'ohlc': - # TODO: can get rid of all this by using - # ``trades`` subscription... + # TODO: can get rid of all this by using + # ``trades`` subscription... - # generate tick values to match time & sales pane: - # https://trade.kraken.com/charts/KRAKEN:BTC-USD?period=1m - volume = ohlc.volume + # generate tick values to match time & sales pane: + # https://trade.kraken.com/charts/KRAKEN:BTC-USD?period=1m + volume = ohlc.volume - # new interval - if ohlc.etime > last_interval_start: - last_interval_start = ohlc.etime - tick_volume = volume - else: - # this is the tick volume *within the interval* - tick_volume = volume - ohlc_last.volume + # new interval + if ohlc.etime > last_interval_start: + last_interval_start = ohlc.etime + tick_volume = volume + else: + # this is the tick volume *within the interval* + tick_volume = volume - ohlc_last.volume - last = ohlc.close - if tick_volume: - ohlc.ticks.append({ - 'type': 'trade', - 'price': last, - 'size': tick_volume, - }) + last = ohlc.close + if tick_volume: + ohlc.ticks.append({ + 'type': 'trade', + 'price': last, + 'size': tick_volume, + }) - topic, quote = normalize(ohlc) + topic, quote = normalize(ohlc) - # if we are the lone tick writer start writing - # the buffer with appropriate trade data - if not writer_exists: - # update last entry - # benchmarked in the 4-5 us range - o, high, low, v = shm.array[-1][ - ['open', 'high', 'low', 'volume'] - ] - new_v = tick_volume + # if we are the lone tick writer start writing + # the buffer with appropriate trade data + if not writer_exists: + # update last entry + # benchmarked in the 4-5 us range + o, high, low, v = shm.array[-1][ + ['open', 'high', 'low', 'volume'] + ] + new_v = tick_volume - if v == 0 and new_v: - # no trades for this bar yet so the open - # is also the close/last trade price - o = last + if v == 0 and new_v: + # no trades for this bar yet so the open + # is also the close/last trade price + o = last - # write shm - shm.array[ - ['open', - 'high', - 'low', - 'close', - 'bar_wap', # in this case vwap of bar - 'volume'] - ][-1] = ( - o, - max(high, last), - min(low, last), - last, - ohlc.vwap, - volume, - ) - ohlc_last = ohlc + # write shm + shm.array[ + ['open', + 'high', + 'low', + 'close', + 'bar_wap', # in this case vwap of bar + 'volume'] + ][-1] = ( + o, + max(high, last), + min(low, last), + last, + ohlc.vwap, + volume, + ) + ohlc_last = ohlc - elif typ == 'l1': - quote = ohlc - topic = quote['symbol'] + elif typ == 'l1': + quote = ohlc + topic = quote['symbol'] - # XXX: format required by ``tractor.msg.pub`` - # requires a ``Dict[topic: str, quote: dict]`` - yield {topic: quote} - - except (ConnectionClosed, DisconnectionTimeout): - log.exception("Good job kraken...reconnecting") + # XXX: format required by ``tractor.msg.pub`` + # requires a ``Dict[topic: str, quote: dict]`` + yield {topic: quote} diff --git a/piker/data/__init__.py b/piker/data/__init__.py index 579e596f..49b0acb9 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -1,5 +1,5 @@ # 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 # 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 sharing your feeds with other fellow pikers. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from contextlib import asynccontextmanager from importlib import import_module from types import ModuleType from typing import ( - Dict, List, Any, - Sequence, AsyncIterator, Optional + Dict, Any, Sequence, AsyncIterator, Optional ) +import trio import tractor from ..brokers import get_brokermod @@ -42,7 +42,7 @@ from ._sharedmem import ( ShmArray, get_shm_token, ) -from ._source import base_iohlc_dtype +from ._source import base_iohlc_dtype, Symbol from ._buffer import ( increment_ohlc_buffer, subscribe_ohlc_for_increment @@ -87,10 +87,11 @@ _data_mods = [ @asynccontextmanager async def maybe_spawn_brokerd( brokername: str, - sleep: float = 0.5, 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: """If no ``brokerd.{brokername}`` daemon-actor can be found, spawn one in a local subactor and return a portal to it. @@ -98,11 +99,6 @@ async def maybe_spawn_brokerd( if loglevel: get_console_log(loglevel) - # disable debugger in brokerd? - # tractor._state._runtime_vars['_debug_mode'] = False - - tractor_kwargs['loglevel'] = loglevel - brokermod = get_brokermod(brokername) dname = f'brokerd.{brokername}' async with tractor.find_actor(dname) as portal: @@ -114,18 +110,25 @@ async def maybe_spawn_brokerd( else: # no daemon has been spawned yet log.info(f"Spawning {brokername} broker daemon") + + # retrieve any special config from the broker mod 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: # spawn new daemon portal = await nursery.start_actor( dname, enable_modules=_data_mods + [brokermod.__name__], loglevel=loglevel, + debug_mode=debug_mode, **tractor_kwargs ) async with tractor.wait_for_actor(dname) as portal: yield portal + finally: # client code may block indefinitely so cancel when # teardown is invoked @@ -144,9 +147,15 @@ class Feed: name: str stream: AsyncIterator[Dict[str, Any]] shm: ShmArray + mod: ModuleType # ticks: ShmArray _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: return await self.stream.__anext__() @@ -164,6 +173,33 @@ class Feed: 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( broker: str, @@ -174,23 +210,26 @@ def sym_to_shm_key( @asynccontextmanager async def open_feed( - name: str, + brokername: str, symbols: Sequence[str], loglevel: Optional[str] = None, ) -> AsyncIterator[Dict[str, Any]]: """Open a "data feed" which provides streamed real-time quotes. """ try: - mod = get_brokermod(name) + mod = get_brokermod(brokername) except ImportError: - mod = get_ingestormod(name) + mod = get_ingestormod(brokername) if loglevel is None: loglevel = tractor.current_actor().loglevel + # TODO: do all! + sym = symbols[0] + # Attempt to allocate (or attach to) shm array for this broker/symbol 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: dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype), @@ -200,33 +239,61 @@ async def open_feed( ) async with maybe_spawn_brokerd( - mod.name, + + brokername, loglevel=loglevel, + + # TODO: add a cli flag for this + # debug_mode=False, + ) as portal: + stream = await portal.run( mod.stream_quotes, + + # TODO: actually handy multiple symbols... symbols=symbols, + shm_token=shm.token, # compat with eventual ``tractor.msg.pub`` 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 # ``tractor.msg.pub``, should we maybe just drop this after # tests are in? - shm_token, is_writer = await stream.receive() + init_msg = await stream.receive() - if opened: - assert is_writer - log.info("Started shared mem bar writer") + 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: + assert data['is_shm_writer'] + log.info("Started shared mem bar writer") shm_token['dtype_descr'] = list(shm_token['dtype_descr']) assert shm_token == shm.token # sanity - yield Feed( - name=name, - stream=stream, - shm=shm, - _brokerd_portal=portal, - ) + yield feed diff --git a/piker/data/_buffer.py b/piker/data/_buffer.py index 9a496e7a..896b503b 100644 --- a/piker/data/_buffer.py +++ b/piker/data/_buffer.py @@ -65,14 +65,6 @@ async def increment_ohlc_buffer( # adjust delay to compensate for trio processing time 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 lowest = min(_shms.keys()) ad = lowest - 0.001 @@ -83,9 +75,6 @@ async def increment_ohlc_buffer( await trio.sleep(ad) total_s += lowest - # # sleep for duration of current bar - # await sleep() - # increment all subscribed shm arrays # TODO: this in ``numba`` for delay_s, shms in _shms.items(): diff --git a/piker/data/_normalize.py b/piker/data/_normalize.py index 363f3c01..3474879e 100644 --- a/piker/data/_normalize.py +++ b/piker/data/_normalize.py @@ -1,5 +1,5 @@ # 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 # it under the terms of the GNU Affero General Public License as published by diff --git a/piker/data/_source.py b/piker/data/_source.py index a77839ff..23524426 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -17,12 +17,12 @@ """ numpy data source coversion helpers. """ -from typing import List +from typing import Dict, Any, List import decimal -from dataclasses import dataclass import numpy as np import pandas as pd +from pydantic import BaseModel # from numba import from_dtype @@ -75,23 +75,41 @@ def ohlc_zeros(length: int) -> np.ndarray: return np.zeros(length, dtype=base_ohlc_dtype) -@dataclass -class Symbol: +class Symbol(BaseModel): """I guess this is some kinda container thing for dealing with all the different meta-data formats from brokers? + Yah, i guess dats what it izz. """ - key: str = '' - brokers: List[str] = None - min_tick: float = 0.01 - contract: str = '' + key: str + tick_size: float = 0.01 + lot_tick_size: float = 0.01 # "volume" precision as min step value + 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: - """Return the trailing number of digits specified by the - min tick size for the instrument. + """Return the trailing number of digits specified by the min + 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( diff --git a/piker/data/marketstore.py b/piker/data/marketstore.py index d8cb3930..27bcda70 100644 --- a/piker/data/marketstore.py +++ b/piker/data/marketstore.py @@ -1,5 +1,5 @@ # 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 # it under the terms of the GNU Affero General Public License as published by diff --git a/piker/exchange/__init__.py b/piker/exchange/__init__.py new file mode 100644 index 00000000..c4fc2647 --- /dev/null +++ b/piker/exchange/__init__.py @@ -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 . + +""" +Market machinery for order executions, book, management. + +""" diff --git a/piker/exchange/_client.py b/piker/exchange/_client.py new file mode 100644 index 00000000..0fa23e60 --- /dev/null +++ b/piker/exchange/_client.py @@ -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 . + +""" +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 diff --git a/piker/exchange/_ems.py b/piker/exchange/_ems.py new file mode 100644 index 00000000..5ebfee4a --- /dev/null +++ b/piker/exchange/_ems.py @@ -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 . + +""" +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_', 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, + ) diff --git a/piker/exchange/_paper_engine.py b/piker/exchange/_paper_engine.py new file mode 100644 index 00000000..740345f5 --- /dev/null +++ b/piker/exchange/_paper_engine.py @@ -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 . + +""" +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 diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py new file mode 100644 index 00000000..e1711bf1 --- /dev/null +++ b/piker/ui/_annotate.py @@ -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 . + +""" +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) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index f9893347..72de9f48 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -1,5 +1,5 @@ # 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 # it under the terms of the GNU Affero General Public License as published by @@ -16,6 +16,7 @@ """ Chart axes graphics and behavior. + """ from typing import List, Tuple, Optional @@ -32,7 +33,7 @@ _axis_pen = pg.mkPen(hcolor('bracket')) 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__( @@ -53,10 +54,10 @@ class Axis(pg.AxisItem): self.setTickFont(_font.font) self.setStyle(**{ - 'textFillLimits': [(0, 0.666)], + 'textFillLimits': [(0, 0.5)], 'tickFont': _font.font, # offset of text *away from* axis line in px - 'tickTextOffset': 2, + 'tickTextOffset': 6, }) self.setTickFont(_font.font) @@ -64,11 +65,17 @@ class Axis(pg.AxisItem): self.typical_br = _font._qfm.boundingRect(typical_max_str) # 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: self._min_tick = size + def txt_offsets(self) -> Tuple[int, int]: + return tuple(self.style['tickTextOffset']) + class PriceAxis(Axis): @@ -77,9 +84,13 @@ class PriceAxis(Axis): *args, **kwargs, ) -> 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()) # XXX: drop for now since it just eats up h space @@ -112,7 +123,7 @@ class DynamicDateAxis(Axis): 1: '%H:%M:%S', } - def resize(self) -> None: + def size_to_values(self) -> None: self.setHeight(self.typical_br.height() + 1) def _indexes_to_timestrs( @@ -151,25 +162,33 @@ class DynamicDateAxis(Axis): class AxisLabel(pg.GraphicsObject): - _w_margin = 0 - _h_margin = 0 + _x_margin = 0 + _y_margin = 0 def __init__( self, - parent: Axis, + parent: pg.GraphicsItem, digits: int = 2, + + font_size_inches: Optional[float] = None, bg_color: str = 'bracket', fg_color: str = 'black', - opacity: int = 0, - font_size_inches: Optional[float] = None, + opacity: int = 1, # XXX: seriously don't set this to 0 + + use_arrow: bool = True, + ) -> None: - super().__init__(parent) + super().__init__() + self.setParentItem(parent) + self.setFlag(self.ItemIgnoresTransformations) + # XXX: pretty sure this is faster self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) - self.parent = parent + self._parent = parent + self.opacity = opacity self.label_str = '' self.digits = digits @@ -177,11 +196,15 @@ class AxisLabel(pg.GraphicsObject): self._txt_br: QtCore.QRect = None 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.fg_color = pg.mkColor(hcolor(fg_color)) + self._use_arrow = use_arrow + + # create triangle path + self.path = None self.rect = None def paint( @@ -190,37 +213,65 @@ class AxisLabel(pg.GraphicsObject): opt: QtWidgets.QStyleOptionGraphicsItem, w: QtWidgets.QWidget ) -> 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) if self.label_str: - if not self.rect: - 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) + # if not self.rect: + self._size_br_from_str(self.label_str) # can be overrided in subtype 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) + def draw( self, p: QtGui.QPainter, rect: QtCore.QRectF ) -> 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 # reason; ok by us 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 + """Size the graphics space from the text contents. + + """ if 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() @@ -232,17 +283,26 @@ class AxisLabel(pg.GraphicsObject): """ # 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() self.rect = QtCore.QRectF( 0, 0, - (w or txt_w) + self._w_margin, - (h or txt_h) + self._h_margin, + (w or txt_w) + self._x_margin /2, + (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 = ( # QtCore.Qt.TextDontClip | @@ -254,7 +314,7 @@ class AxisLabel(pg.GraphicsObject): class XAxisLabel(AxisLabel): - _w_margin = 4 + _x_margin = 8 text_flags = ( QtCore.Qt.TextDontClip @@ -263,32 +323,53 @@ class XAxisLabel(AxisLabel): def size_hint(self) -> Tuple[float, float]: # size to parent axis height - return self.parent.height(), None + return self._parent.height(), None def update_label( self, abs_pos: QPointF, # scene coords value: float, # data for text - offset: int = 1 # if have margins, k? + offset: int = 0 # if have margins, k? ) -> None: - timestrs = self.parent._indexes_to_timestrs([int(value)]) + timestrs = self._parent._indexes_to_timestrs([int(value)]) if not timestrs.any(): 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() + self.setPos(QPointF( - abs_pos.x() - w / 2 - offset, - 1, + abs_pos.x() - w/2, + y_offset/2, )) 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): - _h_margin = 2 + _y_margin = 4 text_flags = ( QtCore.Qt.AlignLeft @@ -297,33 +378,6 @@ class YAxisLabel(AxisLabel): | 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__( self, chart, @@ -334,14 +388,48 @@ class YSticky(YAxisLabel): super().__init__(*args, **kwargs) self._chart = chart + chart.sigRangeChanged.connect(self.update_on_resize) + self._last_datum = (None, None) - def update_on_resize(self, vr, r): - # TODO: add an `.index` to the array data-buffer layer - # and make this way less shitty... + # pull text offset from axis from parent axis + if getattr(self._parent, 'txt_offsets', False): + 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 if index is not None: self.update_from_data(index, last) @@ -350,9 +438,27 @@ class YSticky(YAxisLabel): self, index: int, value: float, + _save_last: bool = True, ) -> None: - self._last_datum = (index, value) + """Update the label's text contents **and** position from + a view box coordinate datum. + + """ + if _save_last: + self._last_datum = (index, value) + self.update_label( self._chart.mapFromView(QPointF(index, 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() diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 39211e61..d62fef5b 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -1,5 +1,5 @@ # 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 # it under the terms of the GNU Affero General Public License as published by @@ -30,6 +30,7 @@ import trio from ._axes import ( DynamicDateAxis, PriceAxis, + YAxisLabel, ) from ._graphics._cursor import ( Cursor, @@ -37,11 +38,11 @@ from ._graphics._cursor import ( ) from ._graphics._lines import ( level_line, - L1Labels, + order_line, ) +from ._l1 import L1Labels from ._graphics._ohlc import BarItems from ._graphics._curve import FastAppendCurve -from ._axes import YSticky from ._style import ( _font, hcolor, @@ -51,15 +52,15 @@ from ._style import ( _bars_from_right_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 data from ..data import maybe_open_shm_array from ..log import get_logger 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 .._ems import spawn_router_stream_alerts log = get_logger(__name__) @@ -136,7 +137,10 @@ class ChartSpace(QtGui.QWidget): Expects a ``numpy`` structured array containing all the ohlcv fields. """ # 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 # # of course this doesn't work :eyeroll: @@ -239,12 +243,10 @@ class LinkedSplitCharts(QtGui.QWidget): The data input struct array must include OHLC fields. """ - self.digits = symbol.digits() - # add crosshairs self._cursor = Cursor( linkedsplitcharts=self, - digits=self.digits + digits=symbol.digits(), ) self.chart = self.add_plot( name=symbol.key, @@ -304,12 +306,14 @@ class LinkedSplitCharts(QtGui.QWidget): linked_charts=self, axisItems={ 'bottom': xaxis, - 'right': PriceAxis(linked_charts=self) + 'right': PriceAxis(linked_charts=self, orientation='right'), + 'left': PriceAxis(linked_charts=self, orientation='left'), }, viewBox=cv, cursor=self._cursor, **cpw_kwargs, ) + print(f'xaxis ps: {xaxis.pos()}') # give viewbox as reference to chart # 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_enter = QtCore.Signal(object) + _l1_labels: L1Labels = None + # TODO: can take a ``background`` color setting - maybe there's # a better one? @@ -377,14 +383,22 @@ class ChartPlotWidget(pg.PlotWidget): name: str, array: np.ndarray, linked_charts: LinkedSplitCharts, + + view_color: str = 'papas_special', + pen_color: str = 'bracket', + static_yrange: Optional[Tuple[float, float]] = None, cursor: Optional[Cursor] = None, + **kwargs, ): """Configure chart display settings. """ + self.view_color = view_color + self.pen_color = pen_color + super().__init__( - background=hcolor('papas_special'), + background=hcolor(view_color), # parent=None, # plotItem=None, # antialias=True, @@ -394,6 +408,10 @@ class ChartPlotWidget(pg.PlotWidget): self.name = name 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._ohlc = array # readonly view of ohlc data @@ -413,15 +431,16 @@ class ChartPlotWidget(pg.PlotWidget): # show only right side axes self.hideAxis('left') self.showAxis('right') + # self.showAxis('left') # show background grid - self.showGrid(x=True, y=True, alpha=0.5) + self.showGrid(x=False, y=True, alpha=0.3) self.default_view() # Assign callback for rescaling y-axis automatically # 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 self._vb.sigRangeChangedManually.connect(self._set_yrange) @@ -429,7 +448,7 @@ class ChartPlotWidget(pg.PlotWidget): # for when the splitter(s) are resized 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'] def update_contents_labels( @@ -499,11 +518,13 @@ class ChartPlotWidget(pg.PlotWidget): max=end, padding=0, ) + self._set_yrange() def increment_view( self, ) -> 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. """ @@ -520,12 +541,15 @@ class ChartPlotWidget(pg.PlotWidget): self, name: str, data: np.ndarray, - # XXX: pretty sure this is dumb and we don't need an Enum - style: pg.GraphicsObject = BarItems, ) -> 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 # the np array buffer to be drawn on next render cycle @@ -643,14 +667,22 @@ class ChartPlotWidget(pg.PlotWidget): self, name: str, bg_color='bracket', - # retreive: Callable[None, np.ndarray], - ) -> YSticky: + ) -> YAxisLabel: + + # 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 - last = self._ysticks[name] = YSticky( + last = self._ysticks[name] = YAxisLabel( chart=self, parent=self.getAxis('right'), # TODO: pass this from symbol data - # digits=0, + digits=digits, opacity=1, bg_color=bg_color, ) @@ -701,6 +733,7 @@ class ChartPlotWidget(pg.PlotWidget): self, *, yrange: Optional[Tuple[float, float]] = None, + range_margin: float = 0.06, ) -> None: """Set the viewable y-range based on embedded data. @@ -760,7 +793,7 @@ class ChartPlotWidget(pg.PlotWidget): a = self._ohlc ifirst = a[0]['index'] - bars = a[lbar - ifirst:rbar - ifirst] + bars = a[lbar - ifirst:rbar - ifirst + 1] if not len(bars): # likely no data loaded yet or extreme scrolling? @@ -781,8 +814,8 @@ class ChartPlotWidget(pg.PlotWidget): if set_range: # view margins: stay within a % of the "true range" diff = yhigh - ylow - ylow = ylow - (diff * 0.04) - yhigh = yhigh + (diff * 0.04) + ylow = ylow - (diff * range_margin) + yhigh = yhigh + (diff * range_margin) self.setLimits( yMin=ylow, @@ -829,15 +862,91 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) -async def _async_main( - sym: str, - brokername: str, +async def test_bed( + ohlcv, + 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()`` widgets: Dict[str, Any], - # all kwargs are passed through from the CLI entrypoint - loglevel: str = None, + sym: str, + brokername: str, + loglevel: str, + ) -> None: """Main Qt-trio routine invoked by the Qt loop with the widgets ``dict``. @@ -852,8 +961,6 @@ async def _async_main( # historical data fetch brokermod = brokers.get_brokermod(brokername) - symbol = Symbol(sym, [brokername]) - async with data.open_feed( brokername, [sym], @@ -862,6 +969,7 @@ async def _async_main( ohlcv = feed.shm bars = ohlcv.array + symbol = feed.symbols[sym] # load in symbol's ohlc data linked_charts, chart = chart_app.load_symbol(symbol, bars) @@ -950,26 +1058,14 @@ async def _async_main( linked_charts ) - async with open_order_mode( - chart, - ) as order_mode: - - # TODO: this should probably be implicitly spawned - # inside the above mngr? - - # spawn EMS actor-service - 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() + # interactive testing + # n.start_soon( + # test_bed, + # ohlcv, + # chart, + # linked_charts, + # ) + await start_order_mode(chart, symbol, brokername) async def chart_from_quotes( @@ -1017,7 +1113,7 @@ async def chart_from_quotes( # sym = chart.name # 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() @@ -1025,12 +1121,15 @@ async def chart_from_quotes( last, volume = ohlcv.array[-1][['close', 'volume']] + symbol = chart._lc.symbol + l1 = L1Labels( chart, # determine precision/decimal lengths - digits=max(float_digits(last), 2), - size_digits=min(float_digits(last), 3) + digits=symbol.digits(), + size_digits=symbol.lot_digits(), ) + chart._l1_labels = l1 # TODO: # - 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 # present differently? + tick_size = chart._lc.symbol.tick_size + tick_margin = 2 * tick_size + async for quotes in stream: for sym, quote in quotes.items(): @@ -1050,20 +1152,18 @@ async def chart_from_quotes( price = tick.get('price') size = tick.get('size') - # 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 + if ticktype == 'n/a' or price == -1: + # okkk.. + continue if ticktype in ('trade', 'utrade', 'last'): array = ohlcv.array # update price sticky(s) - last = array[-1] + end = array[-1] last_price_sticky.update_from_data( - *last[['index', 'close']] + *end[['index', 'close']] ) # plot bars @@ -1075,53 +1175,64 @@ async def chart_from_quotes( if wap_in_history: # 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? # if ticktype in ('trade', 'last'): if ticktype in ('last',): # 'size'): - label = { - l1.ask_label.level: l1.ask_label, - l1.bid_label.level: l1.bid_label, + l1.ask_label.fields['level']: l1.ask_label, + l1.bid_label.fields['level']: l1.bid_label, }.get(price) if label is not None: - label.size = size - label.update_from_data(0, price) + label.update_fields({'level': price, 'size': size}) # on trades should we be knocking down # the relevant L1 queue? # label.size -= size elif ticktype in ('ask', 'asize'): - l1.ask_label.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) + l1.ask_label.update_fields({'level': price, 'size': size}) elif ticktype in ('bid', 'bsize'): - l1.bid_label.size = size - l1.bid_label.update_from_data(0, price) + l1.bid_label.update_fields({'level': price, 'size': size}) - # update min price in view to keep bid on screen - mn_in_view = min(price, mn_in_view) + # update min price in view to keep bid on screen + 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: - chart._set_yrange(yrange=(mn_in_view, mx_in_view)) - last_mx, last_mn = mx_in_view, mn_in_view + if (mx > last_mx) or ( + mn < last_mn + ): + # print(f'new y range: {(mn, mx)}') - if brange != last_bars_range: - # we **must always** update the last values due to - # the x-range change - last_mx, last_mn = mx_in_view, mn_in_view - last_bars_range = brange + chart._set_yrange( + yrange=(mn, mx), + # TODO: we should probably scale + # the view margin based on the size + # 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( linked_charts: LinkedSplitCharts, - # fsp_func_name, fsps: Dict[str, str], sym, src_shm, @@ -1289,12 +1400,13 @@ async def update_signals( # graphics.curve.setBrush(50, 50, 200, 100) # graphics.curve.setFillLevel(50) - # add moveable over-[sold/bought] lines - # and labels only for the 70/30 lines - level_line(chart, 20, show_label=False) - level_line(chart, 30, orient_v='top') - level_line(chart, 70, orient_v='bottom') - level_line(chart, 80, orient_v='top', show_label=False) + if fsp_func_name == 'rsi': + # add moveable over-[sold/bought] lines + # and labels only for the 70/30 lines + level_line(chart, 20) + level_line(chart, 30, orient_v='top') + level_line(chart, 70, orient_v='bottom') + level_line(chart, 80, orient_v='top') chart._set_yrange() @@ -1331,6 +1443,7 @@ async def update_signals( async def check_for_new_bars(feed, ohlcv, linked_charts): """Task which updates from new bars in the shared ohlcv buffer every ``delay_s`` seconds. + """ # TODO: right now we'll spin printing bars if the last time # 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 # 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 # TODO: if we eventually have an x-axis time-step "cursor" # 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, ) - # resize view - # price_chart._set_yrange() - for name in price_chart._overlays: price_chart.update_curve_from_array( @@ -1383,15 +1487,8 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): 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(): chart.update_curve_from_array(chart.name, chart._shm.array) - # chart._set_yrange() # shift the view if in follow mode price_chart.increment_view() @@ -1400,14 +1497,16 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): def _main( sym: str, brokername: str, + piker_loglevel: str, tractor_kwargs, ) -> None: """Sync entry point to start a chart app. + """ # Qt entry point run_qtractor( func=_async_main, - args=(sym, brokername), + args=(sym, brokername, piker_loglevel), main_widget=ChartSpace, tractor_kwargs=tractor_kwargs, ) diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 30a93e04..eb0d662c 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -1,5 +1,5 @@ # 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 # 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. All global Qt runtime settings are mostly defined here. """ +from typing import Tuple, Callable, Dict, Any import os import signal -from functools import partial +import time import traceback -from typing import Tuple, Callable, Dict, Any # Qt specific import PyQt5 # noqa @@ -32,19 +32,29 @@ import pyqtgraph as pg from pyqtgraph import QtGui from PyQt5 import QtCore from PyQt5.QtCore import ( - pyqtRemoveInputHook, Qt, QCoreApplication + pyqtRemoveInputHook, + Qt, + QCoreApplication, ) import qdarkstyle import trio import tractor from outcome import Error +from ..log import get_logger +from ._pg_overrides import _do_overrides + +log = get_logger(__name__) # pyqtgraph global config # might as well enable this for now? pg.useOpenGL = True pg.enableExperimental = True +# engage core tweaks that give us better response +# latency then the average pg user +_do_overrides() + # singleton app per actor _qt_app: QtGui.QApplication = None @@ -52,18 +62,40 @@ _qt_win: QtGui.QMainWindow = None def current_screen() -> QtGui.QScreen: + """Get a frickin screen (if we can, gawd). + """ 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 # must be set before creating the application -if hasattr(Qt, 'AA_EnableHighDpiScaling'): - QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) +# if hasattr(Qt, 'AA_EnableHighDpiScaling'): +# QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) -if hasattr(Qt, 'AA_UseHighDpiPixmaps'): - QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) +# if hasattr(Qt, 'AA_UseHighDpiPixmaps'): +# QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) class MainWindow(QtGui.QMainWindow): @@ -78,7 +110,7 @@ class MainWindow(QtGui.QMainWindow): def closeEvent( self, - event: 'QCloseEvent' + event: QtGui.QCloseEvent, ) -> None: """Cancel the root actor asap. @@ -169,8 +201,8 @@ def run_qtractor( ), name='qtractor', **tractor_kwargs, - ) as a: - await func(*(args + (widgets,))) + ): + await func(*((widgets,) + args)) # guest mode entry trio.lowlevel.start_guest_run( diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 5d312f39..4144e3fd 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -1,5 +1,5 @@ # 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 # 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 # along with this program. If not, see . + """ Mouse interaction graphics @@ -23,7 +24,7 @@ import inspect import numpy as np import pyqtgraph as pg from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import QPointF +from PyQt5.QtCore import QPointF, QRectF from .._style import ( _xaxis_at, @@ -54,7 +55,7 @@ class LineDot(pg.CurvePoint): index: int, plot: 'ChartPlotWidget', # type: ingore # noqa pos=None, - size: int = 2, # in pxs + size: int = 6, # in pxs color: str = 'default_light', ) -> None: pg.CurvePoint.__init__( @@ -112,27 +113,32 @@ class LineDot(pg.CurvePoint): return False -_corner_anchors = { - 'top': 0, - 'left': 0, - 'bottom': 1, - 'right': 1, -} -# XXX: fyi naming here is confusing / opposite to coords -_corner_margins = { - ('top', 'left'): (-4, -5), - ('top', 'right'): (4, -5), - - ('bottom', 'left'): (-4, lambda font_size: font_size * 2), - ('bottom', 'right'): (4, lambda font_size: font_size * 2), -} +# 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 = { + 'top': 0, + 'left': 0, + 'bottom': 1, + 'right': 1, + } + + # XXX: fyi naming here is confusing / opposite to coords + _corner_margins = { + ('top', 'left'): (-4, -_y_margin), + ('top', 'right'): (4, -_y_margin), + + ('bottom', 'left'): (-4, lambda font_size: font_size + 2*_y_margin), + ('bottom', 'right'): (4, lambda font_size: font_size + 2*_y_margin), + } + def __init__( self, chart: 'ChartPlotWidget', # noqa @@ -152,8 +158,8 @@ class ContentsLabel(pg.LabelItem): self.chart = chart v, h = anchor_at - index = (_corner_anchors[h], _corner_anchors[v]) - margins = _corner_margins[(v, h)] + index = (self._corner_anchors[h], self._corner_anchors[v]) + margins = self._corner_margins[(v, h)] ydim = margins[1] if inspect.isfunction(margins[1]): @@ -213,7 +219,7 @@ class Cursor(pg.GraphicsObject): style=QtCore.Qt.DashLine, ) self.lines_pen = pg.mkPen( - color='#a9a9a9', # gray? + color=hcolor('davies'), style=QtCore.Qt.DashLine, ) self.lsc = linkedsplitcharts @@ -226,6 +232,18 @@ class Cursor(pg.GraphicsObject): self._hovered: 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( self, item: pg.GraphicsObject, @@ -240,6 +258,7 @@ class Cursor(pg.GraphicsObject): ) -> None: # add ``pg.graphicsItems.InfiniteLine``s # vertical and horizonal lines and a y-axis label + vl = plot.addLine(x=0, pen=self.lines_pen, movable=False) vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) @@ -248,10 +267,11 @@ class Cursor(pg.GraphicsObject): hl.hide() yl = YAxisLabel( + chart=plot, parent=plot.getAxis('right'), digits=digits or self.digits, opacity=_ch_label_opac, - bg_color='default', + bg_color=self.label_color, ) yl.hide() # on startup if mouse is off screen @@ -291,7 +311,7 @@ class Cursor(pg.GraphicsObject): self.xaxis_label = XAxisLabel( parent=self.plots[plot_index].getAxis('bottom'), opacity=_ch_label_opac, - bg_color='default', + bg_color=self.label_color, ) # place label off-screen during startup 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() 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 # (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 - # update all trackers - for item in self._trackers: - # print(f'setting {item} with {(ix, y)}') - item.on_tracked_source(ix, y) + # round y value to nearest tick step + m = self._y_incr_mult + iy = round(y * m) / m - if ix != lastx: + # 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 + for item in self._trackers: + # print(f'setting {item} with {(ix, y)}') + item.on_tracked_source(ix, iy) + + if ix != last_ix: 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 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 for cursor in opts.get('cursors', ()): cursor.setIndex(ix) @@ -386,14 +419,63 @@ class Cursor(pg.GraphicsObject): # otherwise gobbles tons of CPU.. # map back to abs (label-local) coordinates - abs_pos=plot.mapFromView(QPointF(ix, y)), - value=x, + abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)), + value=ix, ) - self._datum_xy = ix, y + self._datum_xy = ix, iy - def boundingRect(self): + def boundingRect(self) -> QRectF: try: return self.active_plot.boundingRect() except AttributeError: 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')) diff --git a/piker/ui/_graphics/_curve.py b/piker/ui/_graphics/_curve.py index a9b24e7f..9236ed79 100644 --- a/piker/ui/_graphics/_curve.py +++ b/piker/ui/_graphics/_curve.py @@ -1,5 +1,5 @@ # 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 # it under the terms of the GNU Affero General Public License as published by @@ -16,6 +16,7 @@ """ Fast, smooth, sexy curves. + """ from typing import Tuple @@ -141,6 +142,7 @@ class FastAppendCurve(pg.PlotCurveItem): w = hb_size.width() + 1 h = hb_size.height() + 1 + br = QtCore.QRectF( # top left diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index b697692b..63ca4f53 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -1,5 +1,5 @@ # 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 # it under the terms of the GNU Affero General Public License as published by @@ -18,284 +18,300 @@ Lines for orders, alerts, L2. """ -from dataclasses import dataclass -from typing import Tuple +from typing import Tuple, Optional, List import pyqtgraph as pg -from PyQt5 import QtCore, QtGui +from pyqtgraph import Point, functions as fn +from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF +from .._annotate import mk_marker, qgo_draw_markers +from .._label import Label, vbr_left, right_axis from .._style import ( hcolor, _down_2_font_inches_we_like, ) -from .._axes import YSticky - - -class LevelLabel(YSticky): - - _w_margin = 4 - _h_margin = 3 - level: float = 0 - - def __init__( - self, - chart, - *args, - color: str = 'bracket', - orient_v: str = 'bottom', - orient_h: str = 'left', - **kwargs - ) -> None: - super().__init__(chart, *args, **kwargs) - - # TODO: this is kinda cludgy - self._pen = self.pen = pg.mkPen(hcolor(color)) - - # orientation around axis options - self._orient_v = orient_v - self._orient_h = orient_h - self._v_shift = { - 'top': 1., - 'bottom': 0, - 'middle': 1 / 2. - }[orient_v] - - self._h_shift = { - 'left': -1., 'right': 0 - }[orient_h] - - def update_label( - self, - abs_pos: QPointF, # scene coords - level: float, # data for text - offset: int = 1 # if have margins, k? - ) -> None: - - # write contents, type specific - self.set_label_str(level) - - br = self.boundingRect() - h, w = br.height(), br.width() - - # this triggers ``.paint()`` implicitly? - self.setPos(QPointF( - self._h_shift * w - offset, - abs_pos.y() - (self._v_shift * h) - offset - )) - self.update() - - self.level = level - - def set_label_str(self, level: float): - # self.label_str = '{size} x {level:.{digits}f}'.format( - # size=self._size, - - # this is read inside ``.paint()`` - self.label_str = '{level:.{digits}f}'.format( - digits=self.digits, - level=level - ).replace(',', ' ') - - def size_hint(self) -> Tuple[None, None]: - return None, None - - def draw( - self, - p: QtGui.QPainter, - rect: QtCore.QRectF - ) -> None: - p.setPen(self._pen) - - 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): - - size: float = 0 - size_digits: float = 3 - - text_flags = ( - QtCore.Qt.TextDontClip - | QtCore.Qt.AlignLeft - ) - - def set_label_str(self, level: float) -> None: - """Reimplement the label string write to include the level's order-queue's - size in the text, eg. 100 x 323.3. - - """ - self.label_str = '{size:.{size_digits}f} x {level:,.{digits}f}'.format( - size_digits=self.size_digits, - size=self.size or '?', - digits=self.digits, - level=level - ).replace(',', ' ') - - -class L1Labels: - """Level 1 bid ask labels for dynamic update on price-axis. - - """ - max_value: float = '100.0 x 100 000.00' - - 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 - - self.bid_label = L1Label( - chart=chart, - parent=chart.getAxis('right'), - # TODO: pass this from symbol data - digits=digits, - opacity=1, - font_size_inches=font_size_inches, - bg_color='papas_special', - fg_color='bracket', - orient_v='bottom', - ) - self.bid_label.size_digits = size_digits - self.bid_label._size_br_from_str(self.max_value) - - self.ask_label = L1Label( - chart=chart, - parent=chart.getAxis('right'), - # TODO: pass this from symbol data - digits=digits, - opacity=1, - font_size_inches=font_size_inches, - bg_color='papas_special', - fg_color='bracket', - orient_v='top', - ) - self.ask_label.size_digits = size_digits - self.ask_label._size_br_from_str(self.max_value) +# TODO: probably worth investigating if we can +# make .boundingRect() faster: +# https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt class LevelLine(pg.InfiniteLine): # TODO: fill in these slots for orders - # .sigPositionChangeFinished.emit(self) + # available parent signals + # sigDragged(self) + # sigPositionChangeFinished(self) + # sigPositionChanged(self) def __init__( self, chart: 'ChartPlotWidget', # type: ignore # noqa - label: LevelLabel, + + # style + color: str = 'default', highlight_color: str = 'default_light', + dotted: bool = False, + marker_size: int = 20, + + # UX look and feel opts + always_show_labels: bool = False, hl_on_hover: bool = True, - **kwargs, + hide_xhair_on_hover: bool = True, + only_show_markers_on_hover: bool = True, + use_marker_margin: bool = False, + + movable: bool = True, + ) -> None: - super().__init__(**kwargs) - self.label = label + super().__init__( + movable=movable, + angle=0, + + # don't use the shitty ``InfLineLabel`` + label=None, + ) - self.sigPositionChanged.connect(self.set_level) self._chart = chart self._hoh = hl_on_hover + self._dotted = dotted + self._hide_xhair_on_hover = hide_xhair_on_hover - # use slightly thicker highlight - pen = pg.mkPen(hcolor(highlight_color)) - pen.setWidth(2) - self.setHoverPen(pen) + self._marker = None + self._default_mkr_size = marker_size + self._moh = only_show_markers_on_hover + self.show_markers: bool = True # presuming the line is hovered at init + + # should line go all the way to far end or leave a "margin" + # space for other graphics (eg. L1 book) + self.use_marker_margin: bool = use_marker_margin + + if dotted: + self._style = QtCore.Qt.DashLine + else: + self._style = QtCore.Qt.SolidLine + + self._hcolor: str = None + + # the float y-value in the view coords + self.level: float = 0 + + # list of labels anchored at one of the 2 line endpoints + # inside the viewbox + self._labels: List[(int, Label)] = [] + self._markers: List[(int, Label)] = [] + + # whenever this line is moved trigger label updates + self.sigPositionChanged.connect(self.on_pos_change) + + # sets color to value triggering pen creation + self._hl_color = highlight_color + self.color = color + + # TODO: for when we want to move groups of lines? self._track_cursor: bool = False + self._always_show_labels = always_show_labels - def set_level(self, value: float) -> None: - self.label.update_from_data(0, self.value()) + self._on_drag_start = lambda l: None + self._on_drag_end = lambda l: None + + self._y_incr_mult = 1 / chart._lc._symbol.tick_size + self._last_scene_y: float = 0 + + self._right_end_sc: float = 0 + + def txt_offsets(self) -> Tuple[int, int]: + return 0, 0 + + @property + def color(self): + return self._hcolor + + @color.setter + def color(self, color: str) -> None: + # set pens to new color + self._hcolor = color + pen = pg.mkPen(hcolor(color)) + hoverpen = pg.mkPen(hcolor(self._hl_color)) + + pen.setStyle(self._style) + hoverpen.setStyle(self._style) + + # set regular pen + self.setPen(pen) + + # use slightly thicker highlight for hover pen + hoverpen.setWidth(2) + self.hoverPen = hoverpen + + def add_label( + self, + + # by default we only display the line's level value + # in the label + fmt_str: str = ( + '{level:,.{level_digits}f}' + ), + side: str = 'right', + side_of_axis: str = 'left', + x_offset: float = 0, + + font_size_inches: float = _down_2_font_inches_we_like, + color: str = None, + bg_color: str = None, + avoid_book: bool = True, + + **label_kwargs, + ) -> Label: + """Add a ``LevelLabel`` anchored at one of the line endpoints in view. + + """ + label = Label( + view=self.getViewBox(), + fmt_str=fmt_str, + color=self.color, + ) + + # set anchor callback + if side == 'right': + label.set_x_anchor_func( + right_axis( + self._chart, + label, + side=side_of_axis, + offset=x_offset, + avoid_book=avoid_book, + ) + ) + + elif side == 'left': + label.set_x_anchor_func(vbr_left(label)) + + self._labels.append((side, label)) + + return label + + def on_pos_change( + self, + line: 'LevelLine', # noqa + ) -> None: + """Position changed handler. + + """ + self.update_labels({'level': self.value()}) + + def update_labels( + self, + fields_data: dict, + ) -> None: + + for at, label in self._labels: + label.color = self.color + # print(f'color is {self.color}') + + label.fields.update(fields_data) + + level = fields_data.get('level') + if level: + label.set_view_y(level) + + label.render() + + self.update() + + def hide_labels(self) -> None: + for at, label in self._labels: + label.hide() + + def show_labels(self) -> None: + for at, label in self._labels: + label.show() + + def set_level( + self, + level: float, + ) -> None: + last = self.value() + + # if the position hasn't changed then ``.update_labels()`` + # will not be called by a non-triggered `.on_pos_change()`, + # so we need to call it manually to avoid mismatching + # label-to-line color when the line is updated but not + # "moved". + if level == last: + self.update_labels({'level': level}) + + self.setPos(level) + self.level = self.value() + self.update() def on_tracked_source( self, x: int, y: float ) -> None: + # XXX: this is called by our ``Cursor`` type once this + # line is set to track the cursor: for every movement + # this callback is invoked to reposition the line self.movable = True - self.setPos(y) # implictly calls ``.set_level()`` - self.update() - - def setMouseHover(self, hover: bool) -> None: - """Mouse hover callback. - - """ - # XXX: currently we'll just return if _hoh is False - if self.mouseHovering == hover or not self._hoh: - return - - self.mouseHovering = hover - - chart = self._chart - - if hover: - - self.currentPen = self.hoverPen - self.label.highlight(self.hoverPen) - - # add us to cursor state - chart._cursor.add_hovered(self) - - # # hide y-crosshair - # chart._cursor.graphics[chart]['hl'].hide() - - else: - self.currentPen = self.pen - self.label.unhighlight() - - chart._cursor._hovered.remove(self) - - # highlight any attached label - - # self.setCursor(QtCore.Qt.OpenHandCursor) - # self.setCursor(QtCore.Qt.DragMoveCursor) - self.update() + self.set_level(y) # implictly calls reposition handler def mouseDragEvent(self, ev): + """Override the ``InfiniteLine`` handler since we need more + detailed control and start end signalling. + + """ chart = self._chart + # hide y-crosshair - chart._cursor.graphics[chart]['hl'].hide() + chart._cursor.hide_xhair() # highlight self.currentPen = self.hoverPen - self.label.highlight(self.hoverPen) + self.show_labels() - # normal tracking behavior - super().mouseDragEvent(ev) + # XXX: normal tracking behavior pulled out from parent type + if self.movable and ev.button() == QtCore.Qt.LeftButton: + ev.accept() + + if ev.isStart(): + self.moving = True + down_pos = ev.buttonDownPos() + self.cursorOffset = self.pos() - self.mapToParent(down_pos) + self.startPosition = self.pos() + + self._on_drag_start(self) + + if not self.moving: + return + + pos = self.cursorOffset + self.mapToParent(ev.pos()) + + # TODO: we should probably figure out a std api + # for this kind of thing given we already have + # it on the cursor system... + + # round to nearest symbol tick + m = self._y_incr_mult + self.setPos( + QPointF( + self.pos().x(), # don't allow shifting horizontally + round(pos.y() * m) / m + ) + ) + + self.sigDragged.emit(self) + + if ev.isFinish(): + self.moving = False + self.sigPositionChangeFinished.emit(self) + self._on_drag_end(self) # This is the final position in the drag if ev.isFinish(): # show y-crosshair again - chart = self._chart - chart._cursor.graphics[chart]['hl'].show() - - def mouseDoubleClickEvent( - self, - ev: QtGui.QMouseEvent, - ) -> None: - print(f'double click {ev}') - - # def mouseMoved( - # self, - # ev: Tuple[QtGui.QMouseEvent], - # ) -> None: - # pos = evt[0] - # print(pos) + chart._cursor.show_xhair() def delete(self) -> None: """Remove this line from containing chart/view/scene. @@ -303,69 +319,472 @@ class LevelLine(pg.InfiniteLine): """ scene = self.scene() if scene: - # self.label.parent.scene().removeItem(self.label) - scene.removeItem(self.label) + for at, label in self._labels: + label.delete() - self._chart.plotItem.removeItem(self) + self._labels.clear() + + if self._marker: + self.scene().removeItem(self._marker) + + # remove from chart/cursor states + chart = self._chart + cur = chart._cursor + + if self in cur._hovered: + cur._hovered.remove(self) + + chart.plotItem.removeItem(self) + + def mouseDoubleClickEvent( + self, + ev: QtGui.QMouseEvent, + ) -> None: + + # TODO: enter labels edit mode + print(f'double click {ev}') + + def right_point( + self, + ) -> float: + + chart = self._chart + l1_len = chart._max_l1_line_len + ryaxis = chart.getAxis('right') + up_to_l1_sc = ryaxis.pos().x() - l1_len + + return up_to_l1_sc + + def paint( + self, + p: QtGui.QPainter, + opt: QtWidgets.QStyleOptionGraphicsItem, + w: QtWidgets.QWidget + ) -> None: + """Core paint which we override (yet again) + from pg.. + + """ + p.setRenderHint(p.Antialiasing) + + # these are in viewbox coords + vb_left, vb_right = self._endPoints + + chart = self._chart + l1_len = chart._max_l1_line_len + ryaxis = chart.getAxis('right') + + r_axis_x = ryaxis.pos().x() + up_to_l1_sc = r_axis_x - l1_len + + vb = self.getViewBox() + + size = self._default_mkr_size + marker_right = up_to_l1_sc - (1.375 * 2*size) + line_end = marker_right - (6/16 * size) + + if self.show_markers and self.markers: + + size = self.markers[0][2] + + p.setPen(self.pen) + size = qgo_draw_markers( + self.markers, + self.pen.color(), + p, + vb_left, + vb_right, + marker_right, + ) + # marker_size = self.markers[0][2] + self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + + # this seems slower when moving around + # order lines.. not sure wtf is up with that. + # for now we're just using it on the position line. + elif self._marker: + self._marker.setPos( + QPointF(marker_right, self.scene_y()) + ) + + elif not self.use_marker_margin: + # basically means **don't** shorten the line with normally + # reserved space for a direction marker but, leave small + # blank gap for style + line_end = r_axis_x - 10 + + line_end_view = vb.mapToView(Point(line_end, 0)).x() + + # self.currentPen.setJoinStyle(QtCore.Qt.MiterJoin) + p.setPen(self.currentPen) + p.drawLine( + Point(vb_left, 0), + Point(line_end_view, 0) + ) + self._right_end_sc = line_end + + def hide(self) -> None: + super().hide() + if self._marker: + self._marker.hide() + + def scene_right_xy(self) -> QPointF: + return self.getViewBox().mapFromView( + QPointF(0, self.value()) + ) + + def scene_y(self) -> float: + return self.getViewBox().mapFromView(Point(0, self.value())).y() + + def add_marker( + self, + path: QtGui.QGraphicsPathItem, + ) -> None: + + # chart = self._chart + vb = self.getViewBox() + vb.scene().addItem(path) + + self._marker = path + + rsc = self.right_point() + + self._marker.setPen(self.currentPen) + self._marker.setBrush(fn.mkBrush(self.currentPen.color())) + # y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y() + path.setPos(QPointF(rsc, self.scene_y())) + + # self.update() + + def hoverEvent(self, ev): + """Mouse hover callback. + + """ + chart = self._chart + cur = chart._cursor + + # hovered + if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): + + # if already hovered we don't need to run again + if self.mouseHovering is True: + return + + if self._moh: + self.show_markers = True + + # highlight if so configured + if self._hoh: + + self.currentPen = self.hoverPen + + if self not in cur._trackers: + # only disable cursor y-label updates + # if we're highlighting a line + cur._y_label_update = False + + # add us to cursor state + cur.add_hovered(self) + + if self._hide_xhair_on_hover: + cur.hide_xhair( + # set y-label to current value + y_label_level=self.value(), + just_vertical=True, + + # fg_color=self._hcolor, + # bg_color=self._hcolor, + ) + + # if we want highlighting of labels + # it should be delegated into this method + self.show_labels() + + self.mouseHovering = True + + # un-hovered + else: + if self.mouseHovering is False: + return + + cur._y_label_update = True + + self.currentPen = self.pen + + cur._hovered.remove(self) + + if self._moh: + self.show_markers = False + + if self not in cur._trackers: + cur.show_xhair(y_label_level=self.value()) + + if not self._always_show_labels: + for at, label in self._labels: + label.hide() + label.txt.update() + # label.unhighlight() + + self.mouseHovering = False + + self.update() def level_line( chart: 'ChartPlogWidget', # noqa level: float, - digits: int = 1, color: str = 'default', # size 4 font on 4k screen scaled down, so small-ish. font_size_inches: float = _down_2_font_inches_we_like, - show_label: bool = True, - # whether or not the line placed in view should highlight # when moused over (aka "hovered") hl_on_hover: bool = True, - **linelabelkwargs + # line style + dotted: bool = False, + + # label fields and options + digits: int = 1, + + always_show_labels: bool = False, + + add_label: bool = True, + + orient_v: str = 'bottom', + + **kwargs, + ) -> LevelLine: """Convenience routine to add a styled horizontal line to a plot. """ - label = LevelLabel( - chart=chart, - parent=chart.getAxis('right'), - # TODO: pass this from symbol data - digits=digits, - opacity=0.666, - font_size_inches=font_size_inches, - color=color, - - # TODO: make this take the view's bg pen - bg_color='papas_special', - fg_color=color, - **linelabelkwargs - ) - label.update_from_data(0, level) - - # TODO: can we somehow figure out a max value from the parent axis? - label._size_br_from_str(label.label_str) + hl_color = color + '_light' if hl_on_hover else color line = LevelLine( chart, - label, - # lookup "highlight" equivalent - highlight_color=color + '_light', - movable=True, - angle=0, - hl_on_hover=hl_on_hover, - ) - line.setValue(level) - line.setPen(pg.mkPen(hcolor(color))) + color=color, - # activate/draw label - line.setValue(level) + # lookup "highlight" equivalent + highlight_color=hl_color, + + dotted=dotted, + + # UX related options + hl_on_hover=hl_on_hover, + + # when set to True the label is always shown instead of just on + # highlight (which is a privacy thing for orders) + always_show_labels=always_show_labels, + + **kwargs, + ) chart.plotItem.addItem(line) - if not show_label: - label.hide() + if add_label: + + label = line.add_label( + side='right', + opacity=1, + x_offset=0, + avoid_book=False, + ) + label.orient_v = orient_v + + line.update_labels({'level': level, 'level_digits': 2}) + label.render() + + line.hide_labels() + + # activate/draw label + line.set_level(level) + + return line + + +def order_line( + chart, + level: float, + level_digits: float, + action: str, # buy or sell + + size: Optional[int] = 1, + size_digits: int = 0, + show_markers: bool = False, + submit_price: float = None, + exec_type: str = 'dark', + order_type: str = 'limit', + orient_v: str = 'bottom', + + **line_kwargs, + +) -> LevelLine: + """Convenience routine to add a line graphic representing an order + execution submitted to the EMS via the chart's "order mode". + + """ + line = level_line( + chart, + level, + add_label=False, + use_marker_margin=True, + # only_show_markers_on_hover=True, + **line_kwargs + ) + + if show_markers: + # add arrow marker on end of line nearest y-axis + marker_style, marker_size = { + 'buy': ('|<', 20), + 'sell': ('>|', 20), + 'alert': ('v', 12), + }[action] + + # this fixes it the artifact issue! .. of course, bouding rect stuff + line._maxMarkerSize = marker_size + + # use ``QPathGraphicsItem``s to draw markers in scene coords + # instead of the old way that was doing the same but by + # resetting the graphics item transform intermittently + # line.add_marker(mk_marker(marker_style, marker_size)) + assert not line.markers + + # the old way which is still somehow faster? + path = mk_marker( + marker_style, + # the "position" here is now ignored since we modified + # internals to pin markers to the right end of the line + marker_size, + use_qgpath=False, + ) + # manually append for later ``.pain()`` drawing + line.markers.append((path, 0, marker_size)) + + orient_v = 'top' if action == 'sell' else 'bottom' + + if action == 'alert': + # completely different labelling for alerts + fmt_str = 'alert => {level}' + + # for now, we're just duplicating the label contents i guess.. + llabel = line.add_label( + side='left', + fmt_str=fmt_str, + ) + llabel.fields = { + 'level': level, + 'level_digits': level_digits, + } + llabel.orient_v = orient_v + llabel.render() + llabel.show() + + else: + # left side label + llabel = line.add_label( + side='left', + fmt_str=' {exec_type}-{order_type}:\n ${$value}', + ) + llabel.fields = { + 'order_type': order_type, + 'level': level, + '$value': lambda f: f['level'] * f['size'], + 'size': size, + 'exec_type': exec_type, + } + llabel.orient_v = orient_v + llabel.render() + llabel.show() + + # right before L1 label + rlabel = line.add_label( + side='right', + side_of_axis='left', + x_offset=3*marker_size + 5, + fmt_str=( + '{size:.{size_digits}f} ' + ), + ) + rlabel.fields = { + 'size': size, + 'size_digits': size_digits, + } + + rlabel.orient_v = orient_v + rlabel.render() + rlabel.show() + + # sanity check + line.update_labels({'level': level}) + + return line + + +def position_line( + chart, + size: float, + + level: float, + + orient_v: str = 'bottom', + +) -> LevelLine: + """Convenience routine to add a line graphic representing an order + execution submitted to the EMS via the chart's "order mode". + + """ + line = level_line( + chart, + level, + color='default_light', + add_label=False, + hl_on_hover=False, + movable=False, + always_show_labels=False, + hide_xhair_on_hover=False, + use_marker_margin=True, + ) + if size > 0: + arrow_path = mk_marker('|<') + + elif size < 0: + arrow_path = mk_marker('>|') + + line.add_marker(arrow_path) + + # hide position marker when out of view (for now) + vb = line.getViewBox() + + def update_pp_nav(chartview): + vr = vb.state['viewRange'] + ymn, ymx = vr[1] + level = line.value() + + if level > ymx or level < ymn: + line._marker.hide() + else: + line._marker.show() + + vb.sigYRangeChanged.connect(update_pp_nav) + + rlabel = line.add_label( + side='left', + fmt_str='{direction}: {size} -> ${$:.2f}', + ) + rlabel.fields = { + 'direction': 'long' if size > 0 else 'short', + '$': size * level, + 'size': size, + } + rlabel.orient_v = orient_v + rlabel.render() + rlabel.show() + + line.set_level(level) + + # sanity check + line.update_labels({'level': level}) return line diff --git a/piker/ui/_graphics/_ohlc.py b/piker/ui/_graphics/_ohlc.py index 0be7853f..044ce679 100644 --- a/piker/ui/_graphics/_ohlc.py +++ b/piker/ui/_graphics/_ohlc.py @@ -21,7 +21,7 @@ from typing import List, Optional, Tuple import numpy as np 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.QtCore import QLineF, QPointF # 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']] + # 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 if low != 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 # 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 o = QLineF(index - w, open, index, open) + # close line c = QLineF(index, close, index + w, close) return [hl, o, c] -@jit( +@njit( # TODO: for now need to construct this manually for readonly arrays, see # https://github.com/numba/numba/issues/4511 # ntypes.Tuple((float64[:], float64[:], float64[:]))( @@ -78,7 +89,6 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]: # int64, # optional(float64), # ), - nopython=True, nogil=True ) 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 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__( self, # scene: 'QGraphicsScene', # noqa plotitem: 'pg.PlotItem', # noqa + pen_color: str = 'bracket', ) -> None: 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 # 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. """ - 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 # of current internal "last index" @@ -228,7 +241,7 @@ class BarItems(pg.GraphicsObject): ) # 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 # https://doc.qt.io/qt-5/qgraphicsitem.html#update @@ -311,15 +324,17 @@ class BarItems(pg.GraphicsObject): ['index', 'open', 'high', 'low', 'close', 'volume'] ] # assert i == self.start_index - 1 - assert i == last_index + # assert i == last_index body, larm, rarm = self._last_bar_lines # XXX: is there a faster way to modify this? rarm.setLine(rarm.x1(), last, rarm.x2(), last) + # writer is responsible for changing open on "first" volume of bar larm.setLine(larm.x1(), o, larm.x2(), o) if l != h: # noqa + if body is None: body = self._last_bar_lines[0] = QLineF(i, l, i, h) else: @@ -380,53 +395,29 @@ class BarItems(pg.GraphicsObject): # apparently this a lot faster says the docs? # https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect hb = self.path.controlPointRect() - hb_size = hb.size() - # print(f'hb_size: {hb_size}') + hb_tl, hb_br = hb.topLeft(), hb.bottomRight() - w = hb_size.width() + 1 - h = hb_size.height() + 1 + # need to include last bar height or BR will be off + 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 - QPointF(hb.topLeft()), + QPointF( + hb_tl.x(), + mn_y, + ), + + # bottom right + QPointF( + hb_br.x() + 1, + mx_y, + ) - # total size - QtCore.QSizeF(w, h) ) - # 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]) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 02c95230..1f4665f0 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -15,22 +15,21 @@ # along with this program. If not, see . """ -UX interaction customs. +Chart view box primitives + """ -from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Optional, Dict, Callable -import uuid +from typing import Optional, Dict import pyqtgraph as pg +from PyQt5.QtCore import QPointF from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import functions as fn import numpy as np from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font -from ._graphics._lines import level_line, LevelLine -from .._ems import get_orders, OrderBook +from ._graphics._lines import order_line, LevelLine log = get_logger(__name__) @@ -110,8 +109,8 @@ class SelectRect(QtGui.QGraphicsRectItem): def mouse_drag_released( self, - p1: QtCore.QPointF, - p2: QtCore.QPointF + p1: QPointF, + p2: QPointF ) -> None: """Called on final button release for mouse drag with start and end positions. @@ -121,10 +120,10 @@ class SelectRect(QtGui.QGraphicsRectItem): def set_pos( self, - p1: QtCore.QPointF, - p2: QtCore.QPointF + p1: QPointF, + p2: QPointF ) -> None: - """Set position of selection rectagle and accompanying label, move + """Set position of selection rect and accompanying label, move label to match. """ @@ -208,48 +207,77 @@ _order_lines: Dict[str, LevelLine] = {} @dataclass class LineEditor: + """The great editor of linez.. + + """ view: 'ChartView' + _order_lines: field(default_factory=_order_lines) chart: 'ChartPlotWidget' = None # type: ignore # noqa _active_staged_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 and return it. """ + # chart.setCursor(QtCore.Qt.PointingHandCursor) + chart = self.chart._cursor.active_plot - chart.setCursor(QtCore.Qt.PointingHandCursor) cursor = chart._cursor y = chart._cursor._datum_xy[1] - line = self._stage_line - if not line: - # add a "staged" cursor-tracking line to view - # and cash it in a a var - line = level_line( - chart, - level=y, - digits=chart._lc.symbol.digits(), - color=color, + symbol = chart._lc.symbol - # don't highlight the "staging" line - hl_on_hover=False, - ) - self._stage_line = line + # line = self._stage_line + # if not line: + # add a "staged" cursor-tracking line to view + # and cash it in a a var + if self._active_staged_line: + self.unstage_line() - else: - # use the existing staged line instead - # of allocating more mem / objects repeatedly - line.setValue(y) - line.show() - line.label.show() + line = order_line( + chart, + + level=y, + 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, + # don't highlight the "staging" line + hl_on_hover=hl_on_hover, + dotted=dotted, + exec_type='dark' if dotted else 'live', + action=action, + show_markers=True, + + # prevent flickering of marker while moving/tracking cursor + only_show_markers_on_hover=False, + ) self._active_staged_line = line - # hide crosshair y-line - cursor.graphics[chart]['hl'].hide() + # hide crosshair y-line and label + cursor.hide_xhair() # add line to cursor trackers cursor._trackers.add(line) @@ -260,45 +288,63 @@ class LineEditor: """Inverse of ``.stage_line()``. """ - chart = self.chart._cursor.active_plot - chart.setCursor(QtCore.Qt.ArrowCursor) - cursor = chart._cursor + # chart = self.chart._cursor.active_plot + # # chart.setCursor(QtCore.Qt.ArrowCursor) + cursor = self.chart._cursor # delete "staged" cursor tracking line from view line = self._active_staged_line - - cursor._trackers.remove(line) - - if self._stage_line: - self._stage_line.hide() - self._stage_line.label.hide() + if line: + cursor._trackers.remove(line) + line.delete() self._active_staged_line = None - # show the crosshair y line - hl = cursor.graphics[chart]['hl'] - hl.show() + # show the crosshair y line and label + cursor.show_xhair() - 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 if not line: - raise RuntimeError("No line commit is currently staged!?") + raise RuntimeError("No line is currently staged!?") - chart = self.chart._cursor.active_plot - y = chart._cursor._datum_xy[1] + sym = chart._lc.symbol - line = level_line( + line = order_line( chart, - level=y, - color='alert_yellow', - digits=chart._lc.symbol.digits(), - show_label=False, + + # label fields default values + level=level, + 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 self._order_lines[uuid] = line - return line, y + + return line def commit_line(self, uuid: str) -> LevelLine: """Commit a "staged line" to view. @@ -307,15 +353,20 @@ class LineEditor: graphic in view. """ - line = self._order_lines[uuid] - line.oid = uuid - line.label.show() + try: + line = self._order_lines[uuid] + except KeyError: + 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 - log.debug(f'Level active for level: {line.value()}') + log.debug(f'Level active for level: {line.value()}') - return line + return line def lines_under_cursor(self): """Get the line(s) under the cursor position. @@ -328,7 +379,7 @@ class LineEditor: self, line: LevelLine = None, uuid: str = None, - ) -> None: + ) -> LevelLine: """Remove a line by refernce or uuid. If no lines or ids are provided remove all lines under the @@ -339,14 +390,20 @@ class LineEditor: uuid = line.oid # 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 - hovered = self.chart._cursor._hovered - if line in hovered: - hovered.remove(line) + # if hovered remove from cursor set + hovered = self.chart._cursor._hovered + if line in hovered: + hovered.remove(line) - line.delete() + # 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() + return line @dataclass @@ -361,22 +418,28 @@ class ArrowEditor: x: float, y: float, color='default', - pointing: str = 'up', + pointing: Optional[str] = None, ) -> pg.ArrowItem: """Add an arrow graphic to view at given (x, y). """ - yb = pg.mkBrush(hcolor('alert_yellow')) - - angle = 90 if pointing == 'up' else -90 + angle = { + 'up': 90, + 'down': -90, + None: 180, # pointing to right (as in an alert) + }[pointing] arrow = pg.ArrowItem( angle=angle, baseAngle=0, - headLen=5, - headWidth=2, + headLen=5*3, + headWidth=2*3, tailLen=None, - brush=yb, + pxMode=True, + + # coloring + pen=pg.mkPen(hcolor('papas_special')), + brush=pg.mkBrush(hcolor(color)), ) arrow.setPos(x, y) @@ -391,59 +454,6 @@ class ArrowEditor: 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): """Price chart view box with interaction behaviors you'd expect from any interactive platform: @@ -467,11 +477,11 @@ class ChartView(ViewBox): self.addItem(self.select_box, ignoreBounds=True) self._chart: 'ChartPlotWidget' = None # noqa - # self._lines_editor = LineEditor(view=self, _lines=_lines) self.mode = None # kb ctrls processing self._key_buffer = [] + self._key_active: bool = False @property def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa @@ -481,7 +491,6 @@ class ChartView(ViewBox): def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa self._chart = chart self.select_box.chart = chart - # self._lines_editor.chart = chart def wheelEvent(self, ev, axis=None): """Override "center-point" location for scrolling. @@ -498,15 +507,17 @@ class ChartView(ViewBox): else: mask = self.state['mouseEnabled'][:] + chart = self.linked_charts.chart + # 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 if ev.delta() > 0 and vl <= _min_points_to_show: log.debug("Max zoom bruh...") 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...") return @@ -531,10 +542,34 @@ class ChartView(ViewBox): # This seems like the most "intuitive option, a hybrid of # 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.scaleBy(s, last_bar) + self.scaleBy(s, focal) ev.accept() self.sigRangeChangedManually.emit(mask) @@ -649,29 +684,10 @@ class ChartView(ViewBox): self.raiseContextMenu(ev) elif button == QtCore.Qt.LeftButton: - - ev.accept() - - # 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 - ) + # when in order mode, submit execution + if self._key_active: + ev.accept() + self.mode.submit_exec() def keyReleaseEvent(self, ev): """ @@ -684,18 +700,25 @@ class ChartView(ViewBox): return ev.accept() - text = ev.text() + # text = ev.text() key = ev.key() - # mods = ev.modifiers() + mods = ev.modifiers() if key == QtCore.Qt.Key_Shift: - if self.state['mouseMode'] == ViewBox.RectMode: - self.setMouseMode(ViewBox.PanMode) + # if self.state['mouseMode'] == ViewBox.RectMode: + self.setMouseMode(ViewBox.PanMode) - if text == 'a': - # draw "staged" line under cursor position + # if self.state['mouseMode'] == ViewBox.RectMode: + # 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._key_active = False + def keyPressEvent(self, ev): """ This routine should capture key presses in the current view box. @@ -711,43 +734,56 @@ class ChartView(ViewBox): key = ev.key() mods = ev.modifiers() + print(f'text: {text}, key: {key}') + if mods == QtCore.Qt.ShiftModifier: if self.state['mouseMode'] == ViewBox.PanMode: self.setMouseMode(ViewBox.RectMode) - # ctl + # ctrl + ctrl = False if mods == QtCore.Qt.ControlModifier: - # TODO: ctrl-c as cancel? - # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 - # if ev.text() == 'c': - # self.rbScaleBox.hide() - print(f"CTRL + key:{key} + text:{text}") + ctrl = True + + if mods == QtCore.Qt.ControlModifier: + self.mode._exec_mode = 'live' + + self._key_active = True # alt if mods == QtCore.Qt.AltModifier: pass # 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._key_buffer.append(text) - - # 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': - + # cancel order or clear graphics + if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete: # delete any lines under the cursor mode = self.mode for line in mode.lines.lines_under_cursor(): 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 # 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)) else: ev.ignore() + self._key_active = False diff --git a/piker/ui/_l1.py b/piker/ui/_l1.py new file mode 100644 index 00000000..02e3a49a --- /dev/null +++ b/piker/ui/_l1.py @@ -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 . + +""" +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() diff --git a/piker/ui/_label.py b/piker/ui/_label.py new file mode 100644 index 00000000..fe629c23 --- /dev/null +++ b/piker/ui/_label.py @@ -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 . + +""" +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) diff --git a/piker/ui/_pg_overrides.py b/piker/ui/_pg_overrides.py new file mode 100644 index 00000000..71e5f40a --- /dev/null +++ b/piker/ui/_pg_overrides.py @@ -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 . + +""" +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 diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 656877cc..a0232a08 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -1,5 +1,5 @@ # 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 # 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 ..log import get_logger +from ._exec import current_screen log = get_logger(__name__) # chart-wide fonts specified in inches -_default_font_inches_we_like = 6 / 96 -_down_2_font_inches_we_like = 5 / 96 +_default_font_inches_we_like_low_dpi = 6 / 64 +_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: def __init__( self, + # TODO: move to config name: str = 'Hack', size_in_inches: Optional[float] = None, ) -> None: @@ -45,12 +50,23 @@ class DpiAwareFont: self._qfm = QtGui.QFontMetrics(self._qfont) self._physical_dpi = None self._screen = None - self._dpi_scalar = 1. def _set_qfont_px_size(self, px_size: int) -> None: self._qfont.setPixelSize(px_size) 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 def font(self): return self._qfont @@ -59,30 +75,37 @@ class DpiAwareFont: def px_size(self): 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. If we end up needing to generalize this more here there are resources 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 pdpi = screen.physicalDotsPerInch() ldpi = screen.logicalDotsPerInch() 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) log.info( f"\nscreen:{screen.name()} with DPI: {dpi}" f"\nbest font size is {font_size}\n" ) + self._set_qfont_px_size(font_size) self._physical_dpi = dpi - self._screen = screen def boundingRect(self, value: str) -> QtCore.QRectF: - screen = self._screen + screen = self.screen if screen is None: raise RuntimeError("You must call .configure_to_dpi() first!") @@ -110,8 +133,8 @@ _xaxis_at = 'bottom' # charting config CHART_MARGINS = (0, 0, 2, 2) _min_points_to_show = 6 -_bars_from_right_in_follow_mode = int(6**2) -_bars_to_left_in_follow_mode = int(6**3) +_bars_from_right_in_follow_mode = int(130) +_bars_to_left_in_follow_mode = int(616) _tina_mode = False @@ -125,6 +148,10 @@ def enable_tina_mode() -> None: def hcolor(name: str) -> str: """Hex color codes by hipster speak. + + This is an internal set of color codes hand picked + for certain purposes. + """ return { @@ -137,6 +164,8 @@ def hcolor(name: str) -> str: # fifty shades 'gray': '#808080', # like the kick + 'grayer': '#4c4c4c', + 'grayest': '#3f3f3f', 'jet': '#343434', 'cadet': '#91A3B0', 'marengo': '#91A3B0', @@ -166,9 +195,39 @@ def hcolor(name: str) -> str: 'tina_green': '#00cc00', 'tina_red': '#fa0000', + 'cucumber': '#006400', + 'cool_green': '#33b864', + 'dull_green': '#74a662', + 'hedge_green': '#518360', # orders and alerts 'alert_yellow': '#e2d083', '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] diff --git a/piker/ui/cli.py b/piker/ui/cli.py index d2050bbc..387a2b4b 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -16,8 +16,8 @@ """ Console interface to UI components. + """ -from functools import partial import os import click import tractor @@ -63,10 +63,10 @@ def monitor(config, rate, name, dhost, test, tl): _kivy_import_hack() from .kivy.monitor import _async_main - async def main(tries): + async def main(): async with maybe_spawn_brokerd( brokername=brokermod.name, - tries=tries, loglevel=loglevel + loglevel=loglevel ) as portal: # run app "main" await _async_main( @@ -75,7 +75,7 @@ def monitor(config, rate, name, dhost, test, tl): ) tractor.run( - partial(main, tries=1), + main, name='monitor', loglevel=loglevel if tl else None, 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.argument('symbol', required=True) @click.pass_obj -def optschain(config, symbol, date, tl, rate, test): +def optschain(config, symbol, date, rate, test): """Start an option chain UI """ # global opts @@ -100,9 +100,9 @@ def optschain(config, symbol, date, tl, rate, test): _kivy_import_hack() from .kivy.option_chain import _async_main - async def main(tries): + async def main(): async with maybe_spawn_brokerd( - tries=tries, loglevel=loglevel + loglevel=loglevel ): # run app "main" await _async_main( @@ -114,9 +114,8 @@ def optschain(config, symbol, date, tl, rate, test): ) tractor.run( - partial(main, tries=1), + main, 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, 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.pass_obj -def chart(config, symbol, date, rate, test, profile): +def chart(config, symbol, profile): """Start a real-time chartng UI """ from .. import _profile from ._chart import _main - # possibly enable profiling + # toggle to enable profiling _profile._pg_profile = profile # global opts brokername = config['broker'] tractorloglevel = config['tractorloglevel'] + pikerloglevel = config['loglevel'] _main( sym=symbol, brokername=brokername, + piker_loglevel=pikerloglevel, tractor_kwargs={ 'debug_mode': True, 'loglevel': tractorloglevel, - 'rpc_module_paths': ['piker._ems'], + 'enable_modules': [ + 'piker.exchange._client' + ], }, ) diff --git a/piker/ui/kivy/monitor.py b/piker/ui/kivy/monitor.py index 47e52605..6c0a7736 100644 --- a/piker/ui/kivy/monitor.py +++ b/piker/ui/kivy/monitor.py @@ -62,7 +62,7 @@ async def update_quotes( color = colorcode('gray') # if the cell has been "highlighted" make sure to change its color - if hdrcell.background_color != [0]*4: + if hdrcell.background_color != [0] * 4: hdrcell.background_color = color # update row header and '%' cell text color @@ -144,14 +144,17 @@ async def update_quotes( log.warn("Data feed connection dropped") +_widgets = {} + + async def stream_symbol_selection(): """An RPC async gen for streaming the symbol corresponding value corresponding to the last clicked row. Essentially of an event stream of clicked symbol values. """ - widgets = tractor.current_actor().statespace['widgets'] - table = widgets['table'] + global _widgets + table = _widgets['table'] send_chan, recv_chan = trio.open_memory_channel(0) table._click_queues.append(send_chan) try: @@ -238,8 +241,6 @@ async def _async_main( # set up a pager view for large ticker lists table.bind(minimum_height=table.setter('height')) - ss = tractor.current_actor().statespace - async def spawn_opts_chain(): """Spawn an options chain UI in a new subactor. """ @@ -276,7 +277,10 @@ async def _async_main( 'header': header, 'pager': pager, } - ss['widgets'] = widgets + + global _widgets + _widgets = widgets + nursery.start_soon( update_quotes, nursery, diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py new file mode 100644 index 00000000..84806156 --- /dev/null +++ b/piker/ui/order_mode.py @@ -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 . + +""" +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', + ) diff --git a/requirements.txt b/requirements.txt index 71fbd571..18ec5994 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ # no pypi package for tractor (yet) # 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/pikers/pyqtgraph.git@use_qt_inverted#egg=pyqtgraph diff --git a/setup.py b/setup.py index 4f8818f5..6a37f47e 100755 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ setup( 'attrs', 'pygments', 'colorama', # numba traceback coloring + 'pydantic', # structured data # async 'trio', @@ -58,6 +59,7 @@ setup( # numerics 'arrow', # better datetimes + 'bidict', # 2 way map 'cython', 'numpy', 'numba',