diff --git a/README.rst b/README.rst index 41c5f2d8..e1c9405c 100644 --- a/README.rst +++ b/README.rst @@ -98,12 +98,38 @@ if you want your broker supported and they have an API let us know. check out our charts ******************** -bet you weren't expecting this from the foss bby:: +bet you weren't expecting this from the foss:: piker -l info -b kraken -b binance chart btcusdt.binance --pdb -this runs the main chart in in debug mode. +this runs the main chart (currently with 1m sampled OHLC) in in debug +mode and you can practice paper trading using the following +micro-manual: + +``order_mode`` ( + edge triggered activation by any of the following keys, + ``mouse-click`` on y-level to submit at that price + ): + + - ``f``/ ``ctl-f`` to stage buy + - ``d``/ ``ctl-d`` to stage sell + - ``a`` to stage alert + + +``search_mode`` ( + ``ctl-l`` or ``ctl-space`` to open, + ``ctl-c`` or ``ctl-space`` to close + ) : + + - begin typing to have symbol search automatically lookup + symbols from all loaded backend (broker) providers + - arrow keys and mouse click to navigate selection + - vi-like ``ctl-[hjkl]`` for navigation + + +you can also configure your position allocation limits from the +sidepane. run in distributed mode @@ -119,10 +145,10 @@ connect your chart:: piker -l info -b kraken -b binance chart xmrusdt.binance --pdb -enjoy persistent real-time data feeds tied to daemon lifetime. - -key-bindings and mouse interaction is currently only documented in the -doce base. help us write some docs dawg. +enjoy persistent real-time data feeds tied to daemon lifetime. the next +time you spawn a chart it will load much faster since the data feed has +been cached and is now always running live in the background until you +kill ``pikerd``. if anyone asks you what this project is about @@ -138,3 +164,5 @@ enter the matrix. how come there ain't that many docs *********************************** suck it up, learn the code; no one is trying to sell you on anything. +also, we need lotsa help so if you want to start somewhere and can't +necessarily write serious code, this might be the place for you! diff --git a/piker/_cacheables.py b/piker/_cacheables.py index 7ffa29a0..07ad2319 100644 --- a/piker/_cacheables.py +++ b/piker/_cacheables.py @@ -140,7 +140,7 @@ async def maybe_open_ctx( yield True, value except KeyError: - log.info(f'Allocating new feed for {key}') + log.info(f'Allocating new resource for {key}') # **critical section** that should prevent other tasks from # checking the cache until complete otherwise the scheduler diff --git a/piker/brokers/binance.py b/piker/brokers/binance.py index 59fb3ea4..8a3f42e9 100644 --- a/piker/brokers/binance.py +++ b/piker/brokers/binance.py @@ -418,6 +418,7 @@ async def stream_quotes( # just directly pick out the info we need si['price_tick_size'] = syminfo.filters[0]['tickSize'] si['lot_tick_size'] = syminfo.filters[2]['stepSize'] + si['asset_type'] = 'crypto' symbol = symbols[0] diff --git a/piker/brokers/config.py b/piker/brokers/config.py index 9a8f6360..1fbd8ce1 100644 --- a/piker/brokers/config.py +++ b/piker/brokers/config.py @@ -20,6 +20,7 @@ Broker configuration mgmt. import os from os.path import dirname import shutil +from typing import Optional import toml import click @@ -101,3 +102,21 @@ def write( log.debug(f"Writing config file {path}") with open(path, 'w') as cf: return toml.dump(config, cf) + + +def load_accounts() -> dict[str, Optional[str]]: + + # our default paper engine entry + accounts: dict[str, Optional[str]] = {'paper': None} + + conf, path = load() + section = conf.get('accounts') + if section is None: + log.warning('No accounts config found?') + + else: + for brokername, account_labels in section.items(): + for name, value in account_labels.items(): + accounts[f'{brokername}.{name}'] = value + + return accounts diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 85a34527..121ad428 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -196,6 +196,8 @@ _adhoc_futes_set = { 'mgc.nymex', 'xagusd.cmdty', # silver spot + 'ni.nymex', # silver futes + 'qi.comex', # mini-silver futes } # exchanges we don't support at the moment due to not knowing @@ -1295,10 +1297,14 @@ def pack_position(pos: Position) -> dict[str, Any]: else: symbol = con.symbol + symkey = '.'.join([ + symbol.lower(), + (con.primaryExchange or con.exchange).lower(), + ]) return BrokerdPosition( broker='ib', account=pos.account, - symbol=symbol, + symbol=symkey, currency=con.currency, size=float(pos.position), avg_price=float(pos.avgCost) / float(con.multiplier or 1.0), diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index cfce2d5a..3278e40b 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -439,6 +439,7 @@ async def stream_quotes( syminfo = si.dict() syminfo['price_tick_size'] = 1 / 10**si.pair_decimals syminfo['lot_tick_size'] = 1 / 10**si.lot_decimals + syminfo['asset_type'] = 'crypto' sym_infos[sym] = syminfo ws_pairs[sym] = si.wsname diff --git a/piker/calc.py b/piker/calc.py index 2e64c684..0cf42cf8 100644 --- a/piker/calc.py +++ b/piker/calc.py @@ -21,29 +21,52 @@ import math import itertools -def humanize(number, digits=1): - """Convert large numbers to something with at most 3 digits and +def humanize( + number: float, + digits: int = 1 +) -> str: + '''Convert large numbers to something with at most ``digits`` and a letter suffix (eg. k: thousand, M: million, B: billion). - """ + + ''' try: float(number) except ValueError: return 0 if not number or number <= 0: - return number + return round(number, ndigits=digits) + mag2suffix = {3: 'k', 6: 'M', 9: 'B'} mag = math.floor(math.log(number, 10)) if mag < 3: - return number + return round(number, ndigits=digits) + maxmag = max(itertools.takewhile(lambda key: mag >= key, mag2suffix)) - return "{:.{digits}f}{}".format( - number/10**maxmag, mag2suffix[maxmag], digits=digits) + + return "{value}{suffix}".format( + value=round(number/10**maxmag, ndigits=digits), + suffix=mag2suffix[maxmag], + ) -def percent_change(init, new): - """Calcuate the percentage change of some ``new`` value +def pnl( + + init: float, + new: float, + +) -> float: + '''Calcuate the percentage change of some ``new`` value from some initial value, ``init``. - """ + + ''' if not (init and new): return 0 - return (new - init) / init * 100. + + return (new - init) / init + + +def percent_change( + init: float, + new: float, +) -> float: + return pnl(init, new) * 100. diff --git a/piker/clearing/_client.py b/piker/clearing/_client.py index 89630722..a23fdb5e 100644 --- a/piker/clearing/_client.py +++ b/piker/clearing/_client.py @@ -58,35 +58,20 @@ class OrderBook: _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, + msg: Order, ) -> dict: - msg = Order( - action=action, - price=price, - size=size, - symbol=symbol, - brokers=brokers, - oid=uuid, - exec_mode=exec_mode, # dark or live - ) - - self._sent_orders[uuid] = msg + self._sent_orders[msg.oid] = msg self._to_ems.send_nowait(msg.dict()) return msg def update( self, + uuid: str, **data: dict, + ) -> dict: cmd = self._sent_orders[uuid] msg = cmd.dict() diff --git a/piker/clearing/_ems.py b/piker/clearing/_ems.py index 91197d60..f5eeff87 100644 --- a/piker/clearing/_ems.py +++ b/piker/clearing/_ems.py @@ -576,7 +576,8 @@ async def translate_and_relay_brokerd_events( # cancelled by the ems controlling client before we # received this ack, in which case we relay that cancel # signal **asap** to the backend broker - if entry.action == 'cancel': + action = getattr(entry, 'action', None) + if action and action == 'cancel': # assign newly providerd broker backend request id entry.reqid = reqid @@ -796,11 +797,10 @@ async def process_client_order_cmds( # sanity check on emsd id assert live_entry.oid == oid - + reqid = live_entry.reqid # if we already had a broker order id then # this is likely an order update commmand. - log.info( - f"Modifying live {broker} order: {live_entry.reqid}") + log.info(f"Modifying live {broker} order: {reqid}") msg = BrokerdOrder( oid=oid, # no ib support for oids... @@ -966,10 +966,10 @@ async def _emsd_main( ): # XXX: this should be initial price quote from target provider - first_quote = feed.first_quote + first_quote = feed.first_quotes[symbol] book = _router.get_dark_book(broker) - book.lasts[(broker, symbol)] = first_quote[symbol]['last'] + book.lasts[(broker, symbol)] = first_quote['last'] # open a stream with the brokerd backend for order # flow dialogue diff --git a/piker/clearing/_messages.py b/piker/clearing/_messages.py index 5667cb96..126326ab 100644 --- a/piker/clearing/_messages.py +++ b/piker/clearing/_messages.py @@ -24,6 +24,8 @@ from typing import Optional, Union # import msgspec from pydantic import BaseModel +from ..data._source import Symbol + # Client -> emsd @@ -42,7 +44,7 @@ class Order(BaseModel): action: str # {'buy', 'sell', 'alert'} # internal ``emdsd`` unique "order id" oid: str # uuid4 - symbol: str + symbol: Union[str, Symbol] price: float size: float @@ -56,6 +58,13 @@ class Order(BaseModel): # the backend broker exec_mode: str # {'dark', 'live', 'paper'} + class Config: + # just for pre-loading a ``Symbol`` when used + # in the order mode staging process + arbitrary_types_allowed = True + # don't copy this model instance when used in + # a recursive model + copy_on_model_validation = False # Client <- emsd # update msgs from ems which relay state change info @@ -81,8 +90,6 @@ class Status(BaseModel): # 'alert_submitted', # 'alert_triggered', - # 'position', - # } resp: str # "response", see above diff --git a/piker/data/_sampling.py b/piker/data/_sampling.py index bf9ecbba..4c5aaded 100644 --- a/piker/data/_sampling.py +++ b/piker/data/_sampling.py @@ -151,7 +151,12 @@ async def iter_ohlc_periods( # stream and block until cancelled await trio.sleep_forever() finally: - subs.remove(ctx) + try: + subs.remove(ctx) + except ValueError: + log.error( + f'iOHLC step stream was already dropped for {ctx.chan.uid}?' + ) async def sample_and_broadcast( @@ -233,16 +238,23 @@ async def sample_and_broadcast( # thus other consumers still attached. subs = bus._subscribers[sym.lower()] + lags = 0 for (stream, tick_throttle) in subs: try: - if tick_throttle: - # this is a send mem chan that likely - # pushes to the ``uniform_rate_send()`` below. - await stream.send(quote) + with trio.move_on_after(0.2) as cs: + if tick_throttle: + # this is a send mem chan that likely + # pushes to the ``uniform_rate_send()`` below. + await stream.send(quote) - else: - await stream.send({sym: quote}) + else: + await stream.send({sym: quote}) + + if cs.cancelled_caught: + lags += 1 + if lags > 10: + await tractor.breakpoint() except ( trio.BrokenResourceError, diff --git a/piker/data/_source.py b/piker/data/_source.py index 23524426..1a8c635d 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -1,5 +1,5 @@ # piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0) +# Copyright (C) 2018-present Tyler Goodlet (in stewardship 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 @@ -22,7 +22,7 @@ import decimal import numpy as np import pandas as pd -from pydantic import BaseModel +from pydantic import BaseModel, validate_arguments # from numba import from_dtype @@ -62,6 +62,9 @@ tf_in_1m = { def float_digits( value: float, ) -> int: + if value == 0: + return 0 + return int(-decimal.Decimal(str(value)).as_tuple().exponent) @@ -82,28 +85,20 @@ class Symbol(BaseModel): Yah, i guess dats what it izz. """ key: str - tick_size: float = 0.01 - lot_tick_size: float = 0.01 # "volume" precision as min step value + type_key: str # {'stock', 'forex', 'future', ... etc.} + tick_size: float + lot_tick_size: float # "volume" precision as min step value + tick_size_digits: int + lot_size_digits: int 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 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. @@ -111,6 +106,30 @@ class Symbol(BaseModel): mult = 1 / self.tick_size return round(value * mult) / mult +@validate_arguments +def mk_symbol( + + key: str, + type_key: str, + tick_size: float = 0.01, + lot_tick_size: float = 0, + broker_info: dict[str, Any] = {}, + +) -> Symbol: + '''Create and return an instrument description for the + "symbol" named as ``key``. + + ''' + return Symbol( + key=key, + type_key=type_key, + tick_size=tick_size, + lot_tick_size=lot_tick_size, + tick_size_digits=float_digits(tick_size), + lot_size_digits=float_digits(lot_tick_size), + broker_info=broker_info, + ) + def from_df( df: pd.DataFrame, diff --git a/piker/data/feed.py b/piker/data/feed.py index 72d3c50d..9bfe95a9 100644 --- a/piker/data/feed.py +++ b/piker/data/feed.py @@ -49,7 +49,7 @@ from ._sharedmem import ( ShmArray, ) from .ingest import get_ingestormod -from ._source import base_iohlc_dtype, Symbol +from ._source import base_iohlc_dtype, mk_symbol, Symbol from ..ui import _search from ._sampling import ( _shms, @@ -192,7 +192,7 @@ async def allocate_persistent_feed( # establish broker backend quote stream # ``stream_quotes()`` is a required backend func - init_msg, first_quote = await bus.nursery.start( + init_msg, first_quotes = await bus.nursery.start( partial( mod.stream_quotes, send_chan=send, @@ -212,7 +212,7 @@ async def allocate_persistent_feed( # XXX: the ``symbol`` here is put into our native piker format (i.e. # lower case). - bus.feeds[symbol.lower()] = (cs, init_msg, first_quote) + bus.feeds[symbol.lower()] = (cs, init_msg, first_quotes) if opened: # start history backfill task ``backfill_bars()`` is @@ -227,7 +227,7 @@ async def allocate_persistent_feed( init_msg[symbol]['sample_rate'] = int(delay_s) # yield back control to starting nursery - task_status.started((init_msg, first_quote)) + task_status.started((init_msg, first_quotes)) await feed_is_live.wait() @@ -277,7 +277,7 @@ async def attach_feed_bus( # service nursery async with bus.task_lock: if entry is None: - init_msg, first_quote = await bus.nursery.start( + init_msg, first_quotes = await bus.nursery.start( partial( allocate_persistent_feed, @@ -294,13 +294,13 @@ async def attach_feed_bus( ) assert isinstance(bus.feeds[symbol], tuple) - # XXX: ``first_quote`` may be outdated here if this is secondary + # XXX: ``first_quotes`` may be outdated here if this is secondary # subscriber - cs, init_msg, first_quote = bus.feeds[symbol] + cs, init_msg, first_quotes = bus.feeds[symbol] # send this even to subscribers to existing feed? # deliver initial info message a first quote asap - await ctx.started((init_msg, first_quote)) + await ctx.started((init_msg, first_quotes)) async with ( ctx.open_stream() as stream, @@ -392,7 +392,7 @@ class Feed: name: str shm: ShmArray mod: ModuleType - first_quote: dict + first_quotes: dict # symbol names to first quote dicts stream: trio.abc.ReceiveChannel[dict[str, Any]] _brokerd_portal: tractor._portal.Portal @@ -509,7 +509,7 @@ async def open_feed( tick_throttle=tick_throttle, - ) as (ctx, (init_msg, first_quote)), + ) as (ctx, (init_msg, first_quotes)), ctx.open_stream() as stream, @@ -524,7 +524,7 @@ async def open_feed( name=brokername, shm=shm, mod=mod, - first_quote=first_quote, + first_quotes=first_quotes, stream=stream, _brokerd_portal=portal, ) @@ -535,7 +535,7 @@ async def open_feed( si = data['symbol_info'] ohlc_sample_rates.append(data['sample_rate']) - symbol = Symbol( + symbol = mk_symbol( key=sym, type_key=si.get('asset_type', 'forex'), tick_size=si.get('price_tick_size', 0.01), diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py new file mode 100644 index 00000000..19a2013b --- /dev/null +++ b/piker/ui/_anchors.py @@ -0,0 +1,153 @@ +# 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 . + +''' +Anchor funtions for UI placement of annotions. + +''' +from typing import Callable + +from PyQt5.QtCore import QPointF +from PyQt5.QtWidgets import QGraphicsPathItem + +from ._label import Label + + +def marker_right_points( + + chart: 'ChartPlotWidget', # noqa + marker_size: int = 20, + +) -> (float, float, float): + '''Return x-dimension, y-axis-aware, level-line marker oriented scene values. + + X values correspond to set the end of a level line, end of + a paried level line marker, and the right most side of the "right" + axis respectively. + + ''' + # TODO: compute some sensible maximum value here + # and use a humanized scheme to limit to that length. + 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 - 10 + + marker_right = up_to_l1_sc - (1.375 * 2 * marker_size) + line_end = marker_right - (6/16 * marker_size) + + return line_end, marker_right, r_axis_x + + +def vbr_left( + label: 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, + + 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 + + +def gpath_pin( + + gpath: QGraphicsPathItem, + label: Label, # noqa + + location_description: str = 'right-of-path-centered', + use_right_of_pp_label: bool = False, + +) -> QPointF: + + # get actual arrow graphics path + path_br = gpath.mapToScene(gpath.path()).boundingRect() + + # label.vb.locate(label.txt) #, children=True) + + if location_description == 'right-of-path-centered': + return path_br.topRight() - QPointF(label.h/16, label.h / 3) + + if location_description == 'left-of-path-centered': + return path_br.topLeft() - QPointF(label.w, label.h / 6) + + elif location_description == 'below-path-left-aligned': + return path_br.bottomLeft() - QPointF(0, label.h / 6) + + elif location_description == 'below-path-right-aligned': + return path_br.bottomRight() - QPointF(label.w, label.h / 6) + + + +def pp_tight_and_right( + label: Label + +) -> QPointF: + '''Place *just* right of the pp label. + + ''' + txt = label.txt + return label.txt.pos() + QPointF(label.w - label.h/3, 0) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 36765026..6af8ffe7 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -18,18 +18,20 @@ Annotations for ur faces. """ -import PyQt5 -from PyQt5 import QtCore, QtGui +from typing import Callable, Optional + +from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtCore import QPointF, QRectF from PyQt5.QtWidgets import QGraphicsPathItem from pyqtgraph import Point, functions as fn, Color import numpy as np +from ._anchors import marker_right_points -def mk_marker( - style, - size: float = 20.0, - use_qgpath: bool = True, +def mk_marker_path( + + style: str, ) -> QGraphicsPathItem: """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` @@ -39,7 +41,7 @@ def mk_marker( style String indicating the style of marker to add: ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` - size Size of the marker in pixels. Default is 10.0. + size Size of the marker in pixels. """ path = QtGui.QPainterPath() @@ -83,13 +85,148 @@ def mk_marker( # self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) - if use_qgpath: - path = QGraphicsPathItem(path) - path.scale(size, size) - return path +class LevelMarker(QGraphicsPathItem): + '''An arrow marker path graphich which redraws itself + to the specified view coordinate level on each paint cycle. + + ''' + def __init__( + self, + chart: 'ChartPlotWidget', # noqa + style: str, + get_level: Callable[..., float], + size: float = 20, + keep_in_view: bool = True, + on_paint: Optional[Callable] = None, + + ) -> None: + + # get polygon and scale + super().__init__() + self.scale(size, size) + + # interally generates path + self._style = None + self.style = style + + self.chart = chart + + self.get_level = get_level + self._on_paint = on_paint + self.scene_x = lambda: marker_right_points(chart)[1] + self.level: float = 0 + self.keep_in_view = keep_in_view + + @property + def style(self) -> str: + return self._style + + @style.setter + def style(self, value: str) -> None: + if self._style != value: + polygon = mk_marker_path(value) + self.setPath(polygon) + self._style = value + + def path_br(self) -> QRectF: + '''Return the bounding rect for the opaque path part + of this item. + + ''' + return self.mapToScene( + self.path() + ).boundingRect() + + def delete(self) -> None: + self.scene().removeItem(self) + + @property + def h(self) -> float: + return self.path_br().height() + + @property + def w(self) -> float: + return self.path_br().width() + + def position_in_view( + self, + # level: float, + + ) -> None: + '''Show a pp off-screen indicator for a level label. + + This is like in fps games where you have a gps "nav" indicator + but your teammate is outside the range of view, except in 2D, on + the y-dimension. + + ''' + level = self.get_level() + + view = self.chart.getViewBox() + vr = view.state['viewRange'] + ymn, ymx = vr[1] + + # _, marker_right, _ = marker_right_points(line._chart) + x = self.scene_x() + + if self.style == '>|': # short style, points "down-to" line + top_offset = self.h + bottom_offset = 0 + else: + top_offset = 0 + bottom_offset = self.h + + if level > ymx: # pin to top of view + self.setPos( + QPointF( + x, + top_offset + self.h/3, + ) + ) + + elif level < ymn: # pin to bottom of view + + self.setPos( + QPointF( + x, + view.height() - (bottom_offset + self.h/3), + ) + ) + + else: + # pp line is viewable so show marker normally + self.setPos( + x, + self.chart.view.mapFromView( + QPointF(0, self.get_level()) + ).y() + ) + + def paint( + self, + + p: QtGui.QPainter, + opt: QtWidgets.QStyleOptionGraphicsItem, + w: QtWidgets.QWidget + + ) -> None: + '''Core paint which we override to always update + our marker position in scene coordinates from a + view cooridnate "level". + + ''' + if self.keep_in_view: + self.position_in_view() + + super().paint(p, opt, w) + + if self._on_paint: + self._on_paint(self) + + def qgo_draw_markers( markers: list, diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 41647481..2ae846c8 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -26,8 +26,14 @@ from functools import partial from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt from PyQt5.QtCore import QEvent +from PyQt5.QtWidgets import ( + QFrame, + QWidget, + # QSizePolicy, +) import numpy as np import pyqtgraph as pg +from pydantic import BaseModel import tractor import trio @@ -57,23 +63,28 @@ from ._style import ( ) from . import _search from . import _event +from ..data import maybe_open_shm_array +from ..data.feed import open_feed, Feed, install_brokerd_search from ..data._source import Symbol from ..data._sharedmem import ShmArray -from ..data import maybe_open_shm_array from .. import brokers -from .. import data from ..log import get_logger from ._exec import run_qtractor from ._interaction import ChartView -from .order_mode import start_order_mode +from .order_mode import open_order_mode from .. import fsp -from ..data import feed +from ._forms import ( + FieldsForm, + mk_form, + mk_order_pane_layout, + open_form_input_handling, +) log = get_logger(__name__) -class GodWidget(QtWidgets.QWidget): +class GodWidget(QWidget): ''' "Our lord and savior, the holy child of window-shua, there is no widget above thee." - 6|6 @@ -94,11 +105,13 @@ class GodWidget(QtWidgets.QWidget): self.hbox = QtWidgets.QHBoxLayout(self) self.hbox.setContentsMargins(0, 0, 0, 0) - self.hbox.setSpacing(2) + self.hbox.setSpacing(6) + self.hbox.setAlignment(Qt.AlignTop) self.vbox = QtWidgets.QVBoxLayout() self.vbox.setContentsMargins(0, 0, 0, 0) self.vbox.setSpacing(2) + self.vbox.setAlignment(Qt.AlignTop) self.hbox.addLayout(self.vbox) @@ -155,13 +168,13 @@ class GodWidget(QtWidgets.QWidget): # self.strategy_box = StrategyBoxWidget(self) # self.toolbar_layout.addWidget(self.strategy_box) - def load_symbol( - + async def load_symbol( self, + providername: str, symbol_key: str, loglevel: str, - ohlc: bool = True, + reset: bool = False, ) -> trio.Event: @@ -181,13 +194,14 @@ class GodWidget(QtWidgets.QWidget): order_mode_started = trio.Event() if not self.vbox.isEmpty(): + # XXX: this is CRITICAL especially with pixel buffer caching self.linkedsplits.hide() # XXX: pretty sure we don't need this # remove any existing plots? # XXX: ahh we might want to support cache unloading.. - self.vbox.removeWidget(self.linkedsplits) + # self.vbox.removeWidget(self.linkedsplits) # switching to a new viewable chart if linkedsplits is None or reset: @@ -211,15 +225,25 @@ class GodWidget(QtWidgets.QWidget): # symbol is already loaded and ems ready order_mode_started.set() - self.vbox.addWidget(linkedsplits) + # TODO: + # - we'll probably want per-instrument/provider state here? + # change the order config form over to the new chart + + # XXX: since the pp config is a singleton widget we have to + # also switch it over to the new chart's interal-layout + # self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_pane) + chart = linkedsplits.chart + await chart.resume_all_feeds() # chart is already in memory so just focus it if self.linkedsplits: self.linkedsplits.unfocus() - # self.vbox.addWidget(linkedsplits) + self.vbox.addWidget(linkedsplits) + linkedsplits.show() linkedsplits.focus() + self.linkedsplits = linkedsplits symbol = linkedsplits.symbol @@ -232,8 +256,50 @@ class GodWidget(QtWidgets.QWidget): return order_mode_started + def focus(self) -> None: + '''Focus the top level widget which in turn focusses the chart + ala "view mode". -class LinkedSplits(QtWidgets.QWidget): + ''' + # go back to view-mode focus (aka chart focus) + self.clearFocus() + self.linkedsplits.chart.setFocus() + + +class ChartnPane(QFrame): + '''One-off ``QFrame`` composite which pairs a chart + + sidepane (often a ``FieldsForm`` + other widgets if + provided) forming a, sort of, "chart row" with a side panel + for configuration and display of off-chart data. + + See composite widgets docs for deats: + https://doc.qt.io/qt-5/qwidget.html#composite-widgets + + ''' + sidepane: FieldsForm + hbox: QtGui.QHBoxLayout + chart: Optional['ChartPlotWidget'] = None + + def __init__( + self, + + sidepane: FieldsForm, + parent=None, + + ) -> None: + + super().__init__(parent) + + self.sidepane = sidepane + self.chart = None + + hbox = self.hbox = QtGui.QHBoxLayout(self) + hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(3) + + +class LinkedSplits(QWidget): ''' Widget that holds a central chart plus derived subcharts computed from the original data set apart @@ -280,14 +346,13 @@ class LinkedSplits(QtWidgets.QWidget): # self.xaxis.hide() self.splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical) - self.splitter.setMidLineWidth(2) + self.splitter.setMidLineWidth(1) self.splitter.setHandleWidth(0) self.layout = QtWidgets.QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.addWidget(self.splitter) - # state tracker? self._symbol: Symbol = None @property @@ -297,13 +362,17 @@ class LinkedSplits(QtWidgets.QWidget): def set_split_sizes( self, prop: float = 0.375 # proportion allocated to consumer subcharts + ) -> None: - """Set the proportion of space allocated for linked subcharts. - """ + '''Set the proportion of space allocated for linked subcharts. + + ''' major = 1 - prop min_h_ind = int((self.height() * prop) / len(self.subplots)) + sizes = [int(self.height() * major)] sizes.extend([min_h_ind] * len(self.subplots)) + self.splitter.setSizes(sizes) # , int(self.height()*0.2) def focus(self) -> None: @@ -316,9 +385,13 @@ class LinkedSplits(QtWidgets.QWidget): def plot_ohlc_main( self, + symbol: Symbol, array: np.ndarray, + sidepane: FieldsForm, + style: str = 'bar', + ) -> 'ChartPlotWidget': """Start up and show main (price) chart and all linked subcharts. @@ -327,14 +400,18 @@ class LinkedSplits(QtWidgets.QWidget): # add crosshairs self.cursor = Cursor( linkedsplits=self, - digits=symbol.digits(), + digits=symbol.tick_size_digits, ) + self.chart = self.add_plot( + name=symbol.key, array=array, # xaxis=self.xaxis, style=style, _is_main=True, + + sidepane=sidepane, ) # add crosshair graphic self.chart.addItem(self.cursor) @@ -344,23 +421,34 @@ class LinkedSplits(QtWidgets.QWidget): self.chart.hideAxis('bottom') # style? - self.chart.setFrameStyle(QtWidgets.QFrame.StyledPanel | QtWidgets.QFrame.Plain) + self.chart.setFrameStyle( + QFrame.StyledPanel | + QFrame.Plain + ) return self.chart def add_plot( self, + name: str, array: np.ndarray, - xaxis: DynamicDateAxis = None, + + array_key: Optional[str] = None, + # xaxis: Optional[DynamicDateAxis] = None, style: str = 'line', _is_main: bool = False, + + sidepane: Optional[QWidget] = None, + **cpw_kwargs, + ) -> 'ChartPlotWidget': - """Add (sub)plots to chart widget by name. + '''Add (sub)plots to chart widget by name. If ``name`` == ``"main"`` the chart will be the the primary view. - """ + + ''' if self.chart is None and not _is_main: raise RuntimeError( "A main plot must be created first with `.plot_ohlc_main()`") @@ -370,20 +458,30 @@ class LinkedSplits(QtWidgets.QWidget): cv.linkedsplits = self # use "indicator axis" by default - if xaxis is None: - xaxis = DynamicDateAxis( - orientation='bottom', - linkedsplits=self - ) + + # TODO: we gotta possibly assign this back + # to the last subplot on removal of some last subplot + + xaxis = DynamicDateAxis( + orientation='bottom', + linkedsplits=self + ) + + if self.xaxis: + self.xaxis.hide() + self.xaxis = xaxis + + qframe = ChartnPane(sidepane=sidepane, parent=self.splitter) cpw = ChartPlotWidget( # this name will be used to register the primary # graphics curve managed by the subchart name=name, + data_key=array_key or name, array=array, - parent=self.splitter, + parent=qframe, linkedsplits=self, axisItems={ 'bottom': xaxis, @@ -391,10 +489,23 @@ class LinkedSplits(QtWidgets.QWidget): 'left': PriceAxis(linkedsplits=self, orientation='left'), }, viewBox=cv, - # cursor=self.cursor, **cpw_kwargs, ) - print(f'xaxis ps: {xaxis.pos()}') + + qframe.chart = cpw + qframe.hbox.addWidget(cpw) + + # so we can look this up and add back to the splitter + # on a symbol switch + cpw.qframe = qframe + assert cpw.parent() == qframe + + # add sidepane **after** chart; place it on axis side + qframe.hbox.addWidget( + sidepane, + alignment=Qt.AlignTop + ) + cpw.sidepane = sidepane # give viewbox as reference to chart # allowing for kb controls and interactions on **this** widget @@ -402,12 +513,18 @@ class LinkedSplits(QtWidgets.QWidget): cv.chart = cpw cpw.plotItem.vb.linkedsplits = self - cpw.setFrameStyle(QtWidgets.QFrame.StyledPanel) # | QtWidgets.QFrame.Plain) + cpw.setFrameStyle( + QtWidgets.QFrame.StyledPanel + # | QtWidgets.QFrame.Plain) + ) cpw.hideButtons() + # XXX: gives us outline on backside of y-axis cpw.getPlotItem().setContentsMargins(*CHART_MARGINS) - # link chart x-axis to main quotes chart + # link chart x-axis to main chart + # this is 1/2 of where the `Link` in ``LinkedSplit`` + # comes from ;) cpw.setXLink(self.chart) # add to cross-hair's known plots @@ -415,10 +532,10 @@ class LinkedSplits(QtWidgets.QWidget): # draw curve graphics if style == 'bar': - cpw.draw_ohlc(name, array) + cpw.draw_ohlc(name, array, array_key=array_key) elif style == 'line': - cpw.draw_curve(name, array) + cpw.draw_curve(name, array, array_key=array_key) else: raise ValueError(f"Chart style {style} is currently unsupported") @@ -427,11 +544,16 @@ class LinkedSplits(QtWidgets.QWidget): # track by name self.subplots[name] = cpw + if sidepane: + # TODO: use a "panes" collection to manage this? + sidepane.setMinimumWidth(self.chart.sidepane.width()) + sidepane.setMaximumWidth(self.chart.sidepane.width()) + + self.splitter.addWidget(qframe) + # scale split regions self.set_split_sizes() - # XXX: we need this right? - # self.splitter.addWidget(cpw) else: assert style == 'bar', 'main chart must be OHLC' @@ -457,23 +579,24 @@ class ChartPlotWidget(pg.PlotWidget): _l1_labels: L1Labels = None - mode_name: str = 'mode: view' + mode_name: str = 'view' # TODO: can take a ``background`` color setting - maybe there's # a better one? def __init__( self, - # the data view we generate graphics from + + # the "data view" we generate graphics from name: str, array: np.ndarray, + data_key: str, linkedsplits: LinkedSplits, view_color: str = 'papas_special', pen_color: str = 'bracket', static_yrange: Optional[Tuple[float, float]] = None, - cursor: Optional[Cursor] = None, **kwargs, ): @@ -491,7 +614,7 @@ class ChartPlotWidget(pg.PlotWidget): **kwargs ) self.name = name - self._lc = linkedsplits + self.data_key = data_key self.linked = linkedsplits # scene-local placeholder for book graphics @@ -508,6 +631,8 @@ class ChartPlotWidget(pg.PlotWidget): self._graphics = {} # registry of underlying graphics self._overlays = set() # registry of overlay curve names + self._feeds: dict[Symbol, Feed] = {} + self._labels = {} # registry of underlying graphics self._ysticks = {} # registry of underlying graphics @@ -535,8 +660,19 @@ class ChartPlotWidget(pg.PlotWidget): # for when the splitter(s) are resized self._vb.sigResized.connect(self._set_yrange) + async def resume_all_feeds(self): + for feed in self._feeds.values(): + await feed.resume() + + async def pause_all_feeds(self): + for feed in self._feeds.values(): + await feed.pause() + + @property + def view(self) -> ChartView: + return self._vb + def focus(self) -> None: - # self.setFocus() self._vb.setFocus() def last_bar_in_view(self) -> int: @@ -570,8 +706,6 @@ class ChartPlotWidget(pg.PlotWidget): a = self._arrays['ohlc'] lbar = max(l, a[0]['index']) rbar = min(r, a[-1]['index']) - # lbar = max(l, 0) - # rbar = min(r, len(self._arrays['ohlc'])) return l, lbar, rbar, r def default_view( @@ -615,8 +749,12 @@ class ChartPlotWidget(pg.PlotWidget): def draw_ohlc( self, + name: str, data: np.ndarray, + + array_key: Optional[str] = None, + ) -> pg.GraphicsObject: """ Draw OHLC datums to chart. @@ -634,7 +772,8 @@ class ChartPlotWidget(pg.PlotWidget): # draw after to allow self.scene() to work... graphics.draw_from_data(data) - self._graphics[name] = graphics + data_key = array_key or name + self._graphics[data_key] = graphics self.linked.cursor.contents_labels.add_label( self, @@ -649,12 +788,17 @@ class ChartPlotWidget(pg.PlotWidget): def draw_curve( self, + name: str, data: np.ndarray, + + array_key: Optional[str] = None, overlay: bool = False, color: str = 'default_light', add_label: bool = True, + **pdi_kwargs, + ) -> pg.PlotDataItem: """Draw a "curve" (line plot graphics) for the provided data in the input array ``data``. @@ -665,10 +809,12 @@ class ChartPlotWidget(pg.PlotWidget): } pdi_kwargs.update(_pdi_defaults) + data_key = array_key or name + # curve = pg.PlotDataItem( # curve = pg.PlotCurveItem( curve = FastAppendCurve( - y=data[name], + y=data[data_key], x=data['index'], # antialias=True, name=name, @@ -700,7 +846,7 @@ class ChartPlotWidget(pg.PlotWidget): # register curve graphics and backing array for name self._graphics[name] = curve - self._arrays[name] = data + self._arrays[data_key or name] = data if overlay: anchor_at = ('bottom', 'left') @@ -719,7 +865,7 @@ class ChartPlotWidget(pg.PlotWidget): if add_label: self.linked.cursor.contents_labels.add_label( self, - name, + data_key or name, anchor_at=anchor_at ) @@ -727,15 +873,17 @@ class ChartPlotWidget(pg.PlotWidget): def _add_sticky( self, + name: str, bg_color='bracket', + ) -> YAxisLabel: # if the sticky is for our symbol # use the tick size precision for display - sym = self._lc.symbol + sym = self.linked.symbol if name == sym.key: - digits = sym.digits() + digits = sym.tick_size_digits else: digits = 2 @@ -766,18 +914,23 @@ class ChartPlotWidget(pg.PlotWidget): def update_curve_from_array( self, + name: str, array: np.ndarray, + array_key: Optional[str] = None, + **kwargs, + ) -> pg.GraphicsObject: """Update the named internal graphics from ``array``. """ + data_key = array_key or name if name not in self._overlays: self._arrays['ohlc'] = array else: - self._arrays[name] = array + self._arrays[data_key] = array curve = self._graphics[name] @@ -787,7 +940,11 @@ class ChartPlotWidget(pg.PlotWidget): # one place to dig around this might be the `QBackingStore` # https://doc.qt.io/qt-5/qbackingstore.html # curve.setData(y=array[name], x=array['index'], **kwargs) - curve.update_from_array(x=array['index'], y=array[name], **kwargs) + curve.update_from_array( + x=array['index'], + y=array[data_key], + **kwargs + ) return curve @@ -923,6 +1080,22 @@ class ChartPlotWidget(pg.PlotWidget): self.sig_mouse_leave.emit(self) self.scene().leaveEvent(ev) + def get_index(self, time: float) -> int: + + # TODO: this should go onto some sort of + # data-view strimg thinger..right? + ohlc = self._shm.array + # ohlc = chart._shm.array + + # XXX: not sure why the time is so off here + # looks like we're gonna have to do some fixing.. + indexes = ohlc['time'] >= time + + if any(indexes): + return ohlc['index'][indexes][-1] + else: + return ohlc['index'][-1] + _clear_throttle_rate: int = 60 # Hz _book_throttle_rate: int = 16 # Hz @@ -983,13 +1156,13 @@ async def chart_from_quotes( last, volume = ohlcv.array[-1][['close', 'volume']] - symbol = chart._lc.symbol + symbol = chart.linked.symbol l1 = L1Labels( chart, # determine precision/decimal lengths - digits=symbol.digits(), - size_digits=symbol.lot_digits(), + digits=symbol.tick_size_digits, + size_digits=symbol.lot_size_digits, ) chart._l1_labels = l1 @@ -1001,7 +1174,7 @@ async def chart_from_quotes( # levels this might be dark volume we need to # present differently? - tick_size = chart._lc.symbol.tick_size + tick_size = chart.linked.symbol.tick_size tick_margin = 2 * tick_size last_ask = last_bid = last_clear = time.time() @@ -1010,7 +1183,8 @@ async def chart_from_quotes( async for quotes in stream: # chart isn't actively shown so just skip render cycle - if chart._lc.isHidden(): + if chart.linked.isHidden(): + await chart.pause_all_feeds() continue for sym, quote in quotes.items(): @@ -1058,8 +1232,7 @@ 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) # l1 book events # throttle the book graphics updates at a lower rate @@ -1154,6 +1327,8 @@ async def spawn_fsps( linkedsplits.focus() + uid = tractor.current_actor().uid + # spawns sub-processes which execute cpu bound FSP code async with tractor.open_nursery(loglevel=loglevel) as n: @@ -1164,9 +1339,9 @@ async def spawn_fsps( # Currently we spawn an actor per fsp chain but # likely we'll want to pool them eventually to # scale horizonatlly once cores are used up. - for fsp_func_name, conf in fsps.items(): + for display_name, conf in fsps.items(): - display_name = f'fsp.{fsp_func_name}' + fsp_func_name = conf['fsp_func_name'] # TODO: load function here and introspect # return stream type(s) @@ -1174,7 +1349,7 @@ async def spawn_fsps( # TODO: should `index` be a required internal field? fsp_dtype = np.dtype([('index', int), (fsp_func_name, float)]) - key = f'{sym}.' + display_name + key = f'{sym}.fsp.{display_name}.{".".join(uid)}' # this is all sync currently shm, opened = maybe_open_shm_array( @@ -1184,15 +1359,16 @@ async def spawn_fsps( readonly=True, ) - # XXX: fsp may have been opened by a duplicate chart. Error for - # now until we figure out how to wrap fsps as "feeds". - # assert opened, f"A chart for {key} likely already exists?" + # XXX: fsp may have been opened by a duplicate chart. + # Error for now until we figure out how to wrap fsps as + # "feeds". assert opened, f"A chart for {key} likely + # already exists?" conf['shm'] = shm portal = await n.start_actor( enable_modules=['piker.fsp'], - name=display_name, + name='fsp.' + display_name, ) # init async @@ -1231,23 +1407,68 @@ async def run_fsp( config map. """ done = linkedsplits.window().status_bar.open_status( - f'loading {display_name}..', + f'loading fsp, {display_name}..', group_key=group_status_key, ) - async with portal.open_stream_from( + # make sidepane config widget + class FspConfig(BaseModel): - # subactor entrypoint - fsp.cascade, + class Config: + validate_assignment = True - # name as title of sub-chart - brokername=brokermod.name, - src_shm_token=src_shm.token, - dst_shm_token=conf['shm'].token, - symbol=sym, - fsp_func_name=fsp_func_name, + name: str + period: int - ) as stream: + sidepane: FieldsForm = mk_form( + parent=linkedsplits.godwidget, + fields_schema={ + 'name': { + 'label': '**fsp**:', + 'type': 'select', + 'default_value': [ + f'{display_name}' + ], + }, + 'period': { + 'label': '**period**:', + 'type': 'edit', + 'default_value': 14, + }, + }, + ) + sidepane.model = FspConfig( + name=display_name, + period=14, + ) + + # just a logger for now until we get fsp configs up and running. + async def settings_change(key: str, value: str) -> bool: + print(f'{key}: {value}') + return True + + async with ( + portal.open_stream_from( + + # subactor entrypoint + fsp.cascade, + + # name as title of sub-chart + brokername=brokermod.name, + src_shm_token=src_shm.token, + dst_shm_token=conf['shm'].token, + symbol=sym, + fsp_func_name=fsp_func_name, + + ) as stream, + + # TODO: + open_form_input_handling( + sidepane, + focus_next=linkedsplits.godwidget, + on_value_change=settings_change, + ), + ): # receive last index for processed historical # data-array as first msg @@ -1267,9 +1488,12 @@ async def run_fsp( else: chart = linkedsplits.add_plot( - name=fsp_func_name, + name=display_name, array=shm.array, + array_key=conf['fsp_func_name'], + sidepane=sidepane, + # curve by default ohlc=False, @@ -1278,12 +1502,6 @@ async def run_fsp( # static_yrange=(0, 100), ) - # display contents labels asap - chart.linked.cursor.contents_labels.update_labels( - len(shm.array) - 1, - # fsp_func_name - ) - # XXX: ONLY for sub-chart fsps, overlays have their # data looked up from the chart's internal array set. # TODO: we must get a data view api going STAT!! @@ -1297,14 +1515,23 @@ async def run_fsp( # read from last calculated value array = shm.array + + # XXX: fsp func names are unique meaning we don't have + # duplicates of the underlying data even if multiple + # sub-charts reference it under different 'named charts'. value = array[fsp_func_name][-1] + last_val_sticky.update_from_data(-1, value) - chart._lc.focus() + chart.linked.focus() # works also for overlays in which case data is looked up from # internal chart array set.... - chart.update_curve_from_array(fsp_func_name, shm.array) + chart.update_curve_from_array( + display_name, + shm.array, + array_key=fsp_func_name + ) # TODO: figure out if we can roll our own `FillToThreshold` to # get brush filled polygons for OS/OB conditions. @@ -1331,11 +1558,14 @@ async def run_fsp( done() + # i = 0 # update chart graphics async for value in stream: # chart isn't actively shown so just skip render cycle - if chart._lc.isHidden(): + if chart.linked.isHidden(): + # print(f'{i} unseen fsp cyclce') + # i += 1 continue now = time.time() @@ -1368,7 +1598,11 @@ async def run_fsp( last_val_sticky.update_from_data(-1, value) # update graphics - chart.update_curve_from_array(fsp_func_name, array) + chart.update_curve_from_array( + display_name, + array, + array_key=fsp_func_name, + ) # set time of last graphics update last = now @@ -1389,7 +1623,6 @@ async def check_for_new_bars(feed, ohlcv, linkedsplits): async with feed.index_stream() as stream: async for index in stream: - # update chart historical bars graphics by incrementing # a time step and drawing the history and new bar @@ -1423,7 +1656,11 @@ async def check_for_new_bars(feed, ohlcv, linkedsplits): ) for name, chart in linkedsplits.subplots.items(): - chart.update_curve_from_array(chart.name, chart._shm.array) + chart.update_curve_from_array( + chart.name, + chart._shm.array, + array_key=chart.data_key + ) # shift the view if in follow mode price_chart.increment_view() @@ -1462,8 +1699,7 @@ async def display_symbol_data( # ) async with( - - data.open_feed( + open_feed( provider, [sym], loglevel=loglevel, @@ -1472,7 +1708,6 @@ async def display_symbol_data( tick_throttle=_clear_throttle_rate, ) as feed, - ): ohlcv: ShmArray = feed.shm @@ -1488,7 +1723,19 @@ async def display_symbol_data( linkedsplits = godwidget.linkedsplits linkedsplits._symbol = symbol - chart = linkedsplits.plot_ohlc_main(symbol, bars) + # generate order mode side-pane UI + # A ``FieldsForm`` form to configure order entry + pp_pane: FieldsForm = mk_order_pane_layout(godwidget) + + # add as next-to-y-axis singleton pane + godwidget.pp_pane = pp_pane + + chart = linkedsplits.plot_ohlc_main( + symbol, + bars, + sidepane=pp_pane, + ) + chart._feeds[symbol.key] = feed chart.setFocus() # plot historical vwap if available @@ -1513,11 +1760,20 @@ async def display_symbol_data( # TODO: eventually we'll support some kind of n-compose syntax fsp_conf = { 'rsi': { + 'fsp_func_name': 'rsi', 'period': 14, 'chart_kwargs': { 'static_yrange': (0, 100), }, }, + # test for duplicate fsps on same chart + # 'rsi2': { + # 'fsp_func_name': 'rsi', + # 'period': 14, + # 'chart_kwargs': { + # 'static_yrange': (0, 100), + # }, + # }, } @@ -1535,14 +1791,19 @@ async def display_symbol_data( else: fsp_conf.update({ 'vwap': { + 'fsp_func_name': 'vwap', 'overlay': True, 'anchor': 'session', }, }) - async with trio.open_nursery() as n: + async with ( + + trio.open_nursery() as ln, + + ): # load initial fsp chain (otherwise known as "indicators") - n.start_soon( + ln.start_soon( spawn_fsps, linkedsplits, fsp_conf, @@ -1554,7 +1815,7 @@ async def display_symbol_data( ) # start graphics update loop(s)after receiving first live quote - n.start_soon( + ln.start_soon( chart_from_quotes, chart, feed.stream, @@ -1562,19 +1823,24 @@ async def display_symbol_data( wap_in_history, ) - # TODO: instead we should start based on instrument trading hours? - # wait for a first quote before we start any update tasks - # quote = await feed.receive() - # log.info(f'Received first quote {quote}') - - n.start_soon( + ln.start_soon( check_for_new_bars, feed, ohlcv, linkedsplits ) - await start_order_mode(chart, symbol, provider, order_mode_started) + async with ( + + open_order_mode( + feed, + chart, + symbol, + provider, + order_mode_started + ) + ): + await trio.sleep_forever() async def load_provider_search( @@ -1593,7 +1859,7 @@ async def load_provider_search( loglevel=loglevel ) as portal, - feed.install_brokerd_search( + install_brokerd_search( portal, get_brokermod(broker), ), @@ -1640,30 +1906,29 @@ async def _async_main( sbar = godwidget.window.status_bar starting_done = sbar.open_status('starting ze sexy chartz') - async with trio.open_nursery() as root_n: - + async with ( + trio.open_nursery() as root_n, + ): # set root nursery and task stack for spawning other charts/feeds # that run cached in the bg godwidget._root_n = root_n # setup search widget and focus main chart view at startup + # search widget is a singleton alongside the godwidget search = _search.SearchWidget(godwidget=godwidget) search.bar.unfocus() - # add search singleton to global chart-space widget - godwidget.hbox.addWidget( - search, - - # alights to top and uses minmial space based on - # search bar size hint (i think?) - alignment=Qt.AlignTop - ) + godwidget.hbox.addWidget(search) godwidget.search = search symbol, _, provider = sym.rpartition('.') # this internally starts a ``display_symbol_data()`` task above - order_mode_ready = godwidget.load_symbol(provider, symbol, loglevel) + order_mode_ready = await godwidget.load_symbol( + provider, + symbol, + loglevel + ) # spin up a search engine for the local cached symbol set async with _search.register_symbol_search( @@ -1684,16 +1949,24 @@ async def _async_main( await order_mode_ready.wait() - # start handling search bar kb inputs + # start handling peripherals input for top level widgets async with ( - _event.open_handler( - search.bar, - event_types={QEvent.KeyPress}, + # search bar kb input handling + _event.open_handlers( + [search.bar], + event_types={ + QEvent.KeyPress, + }, async_handler=_search.handle_keyboard_input, - # let key repeats pass through for search - filter_auto_repeats=False, - ) + filter_auto_repeats=False, # let repeats passthrough + ), + + # completer view mouse click signal handling + _event.open_signal_handler( + search.view.pressed, + search.view.on_pressed, + ), ): # remove startup status text starting_done() diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 8a74ffc7..fd9df0f0 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -31,6 +31,7 @@ from ._style import ( _xaxis_at, hcolor, _font_small, + _font, ) from ._axes import YAxisLabel, XAxisLabel from ..log import get_logger @@ -41,8 +42,9 @@ log = get_logger(__name__) # XXX: these settings seem to result in really decent mouse scroll # latency (in terms of perceived lag in cross hair) so really be sure # there's an improvement if you want to change it! -_mouse_rate_limit = 60 # TODO; should we calc current screen refresh rate? -_debounce_delay = 1 / 2e3 + +_mouse_rate_limit = 120 # TODO; should we calc current screen refresh rate? +_debounce_delay = 1 / 40 _ch_label_opac = 1 @@ -52,13 +54,18 @@ class LineDot(pg.CurvePoint): def __init__( self, + curve: pg.PlotCurveItem, index: int, + plot: 'ChartPlotWidget', # type: ingore # noqa pos=None, - size: int = 6, # in pxs color: str = 'default_light', + ) -> None: + # scale from dpi aware font size + size = int(_font.px_size * 0.375) + pg.CurvePoint.__init__( self, curve, @@ -88,7 +95,9 @@ class LineDot(pg.CurvePoint): def event( self, + ev: QtCore.QEvent, + ) -> None: if not isinstance( ev, QtCore.QDynamicPropertyChangeEvent @@ -132,8 +141,8 @@ class ContentsLabel(pg.LabelItem): } def __init__( - self, + # chart: 'ChartPlotWidget', # noqa view: pg.ViewBox, @@ -167,8 +176,8 @@ class ContentsLabel(pg.LabelItem): self.anchor(itemPos=index, parentPos=index, offset=margins) def update_from_ohlc( - self, + name: str, index: int, array: np.ndarray, @@ -194,8 +203,8 @@ class ContentsLabel(pg.LabelItem): ) def update_from_value( - self, + name: str, index: int, array: np.ndarray, @@ -239,6 +248,7 @@ class ContentsLabels: if not (index >= 0 and index < chart._arrays['ohlc'][-1]['index']): # out of range + print('out of range?') continue array = chart._arrays[name] @@ -272,13 +282,15 @@ class ContentsLabels: self._labels.append( (chart, name, label, partial(update_func, label, name)) ) - # label.hide() + label.hide() return label class Cursor(pg.GraphicsObject): + '''Multi-plot cursor for use on a ``LinkedSplits`` chart (set). + ''' def __init__( self, diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 1bb9d7e2..883f7a15 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -28,7 +28,7 @@ from PyQt5.QtCore import QPointF import numpy as np from ._style import hcolor, _font -from ._lines import order_line, LevelLine +from ._lines import LevelLine from ..log import get_logger @@ -97,69 +97,21 @@ class LineEditor: def stage_line( self, - action: str, + line: LevelLine, - 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) - cursor = self.chart.linked.cursor - if not cursor: - return None - - chart = cursor.active_plot - y = cursor._datum_xy[1] - - symbol = chart._lc.symbol # add a "staged" cursor-tracking line to view # and cash it in a a var if self._active_staged_line: self.unstage_line() - 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 and label - cursor.hide_xhair() - - # add line to cursor trackers - cursor._trackers.add(line) - return line def unstage_line(self) -> LevelLine: @@ -181,41 +133,17 @@ class LineEditor: # show the crosshair y line and label cursor.show_xhair() - def create_order_line( + def submit_line( self, + line: LevelLine, uuid: str, - level: float, - chart: 'ChartPlotWidget', # noqa - size: float, - action: str, + ) -> LevelLine: - line = self._active_staged_line - if not line: + staged_line = self._active_staged_line + if not staged_line: raise RuntimeError("No line is currently staged!?") - sym = chart._lc.symbol - - line = order_line( - chart, - - # 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() diff --git a/piker/ui/_event.py b/piker/ui/_event.py index a99b3241..9e087dd4 100644 --- a/piker/ui/_event.py +++ b/piker/ui/_event.py @@ -18,13 +18,64 @@ Qt event proxying and processing using ``trio`` mem chans. """ -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, AsyncExitStack from typing import Callable -from PyQt5 import QtCore -from PyQt5.QtCore import QEvent -from PyQt5.QtWidgets import QWidget +from pydantic import BaseModel import trio +from PyQt5 import QtCore +from PyQt5.QtCore import QEvent, pyqtBoundSignal +from PyQt5.QtWidgets import QWidget +from PyQt5.QtWidgets import QGraphicsSceneMouseEvent as gs_mouse + + +MOUSE_EVENTS = { + gs_mouse.GraphicsSceneMousePress, + gs_mouse.GraphicsSceneMouseRelease, + QEvent.MouseButtonPress, + QEvent.MouseButtonRelease, + # QtGui.QMouseEvent, +} + + +# TODO: maybe consider some constrained ints down the road? +# https://pydantic-docs.helpmanual.io/usage/types/#constrained-types + +class KeyboardMsg(BaseModel): + '''Unpacked Qt keyboard event data. + + ''' + class Config: + arbitrary_types_allowed = True + + event: QEvent + etype: int + key: int + mods: int + txt: str + + def to_tuple(self) -> tuple: + return tuple(self.dict().values()) + + +class MouseMsg(BaseModel): + '''Unpacked Qt keyboard event data. + + ''' + class Config: + arbitrary_types_allowed = True + + event: QEvent + etype: int + button: int + + +# TODO: maybe add some methods to detect key combos? Or is that gonna be +# better with pattern matching? +# # ctl + alt as combo +# ctlalt = False +# if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: +# ctlalt = True class EventRelay(QtCore.QObject): @@ -38,8 +89,10 @@ class EventRelay(QtCore.QObject): def eventFilter( self, + source: QWidget, ev: QEvent, + ) -> None: ''' Qt global event filter: return `False` to pass through and `True` @@ -50,48 +103,57 @@ class EventRelay(QtCore.QObject): ''' etype = ev.type() - # print(f'etype: {etype}') + # TODO: turn this on and see what we can filter by default (such + # as mouseWheelEvent). + # print(f'ev: {ev}') - if etype in self._event_types: - # ev.accept() - - # TODO: what's the right way to allow this? - # if ev.isAutoRepeat(): - # ev.ignore() - - # XXX: we unpack here because apparently doing it - # after pop from the mem chan isn't showing the same - # event object? no clue wtf is going on there, likely - # something to do with Qt internals and calling the - # parent handler? - - if etype in {QEvent.KeyPress, QEvent.KeyRelease}: - - # TODO: is there a global setting for this? - if ev.isAutoRepeat() and self._filter_auto_repeats: - ev.ignore() - return True - - key = ev.key() - mods = ev.modifiers() - txt = ev.text() - - # NOTE: the event object instance coming out - # the other side is mutated since Qt resumes event - # processing **before** running a ``trio`` guest mode - # tick, thus special handling or copying must be done. - - # send elements to async handler - self._send_chan.send_nowait((ev, etype, key, mods, txt)) - - else: - # send event to async handler - self._send_chan.send_nowait(ev) - - # **do not** filter out this event - # and instead forward to the source widget + if etype not in self._event_types: return False + # XXX: we unpack here because apparently doing it + # after pop from the mem chan isn't showing the same + # event object? no clue wtf is going on there, likely + # something to do with Qt internals and calling the + # parent handler? + + if etype in {QEvent.KeyPress, QEvent.KeyRelease}: + + msg = KeyboardMsg( + event=ev, + etype=etype, + key=ev.key(), + mods=ev.modifiers(), + txt=ev.text(), + ) + + # TODO: is there a global setting for this? + if ev.isAutoRepeat() and self._filter_auto_repeats: + ev.ignore() + return True + + # NOTE: the event object instance coming out + # the other side is mutated since Qt resumes event + # processing **before** running a ``trio`` guest mode + # tick, thus special handling or copying must be done. + + elif etype in MOUSE_EVENTS: + # print('f mouse event: {ev}') + msg = MouseMsg( + event=ev, + etype=etype, + button=ev.button(), + ) + + else: + msg = ev + + # send event-msg to async handler + self._send_chan.send_nowait(msg) + + # **do not** filter out this event + # and instead forward to the source widget + return False + # filter out this event # https://doc.qt.io/qt-5/qobject.html#installEventFilter return False @@ -124,9 +186,34 @@ async def open_event_stream( @asynccontextmanager -async def open_handler( +async def open_signal_handler( - source_widget: QWidget, + signal: pyqtBoundSignal, + async_handler: Callable, + +) -> trio.abc.ReceiveChannel: + + send, recv = trio.open_memory_channel(0) + + def proxy_args_to_chan(*args): + send.send_nowait(args) + + signal.connect(proxy_args_to_chan) + + async def proxy_to_handler(): + async for args in recv: + await async_handler(*args) + + async with trio.open_nursery() as n: + n.start_soon(proxy_to_handler) + async with send: + yield + + +@asynccontextmanager +async def open_handlers( + + source_widgets: list[QWidget], event_types: set[QEvent], async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None], **kwargs, @@ -135,7 +222,13 @@ async def open_handler( async with ( trio.open_nursery() as n, - open_event_stream(source_widget, event_types, **kwargs) as event_recv_stream, + AsyncExitStack() as stack, ): - n.start_soon(async_handler, source_widget, event_recv_stream) + for widget in source_widgets: + + event_recv_stream = await stack.enter_async_context( + open_event_stream(widget, event_types, **kwargs) + ) + n.start_soon(async_handler, widget, event_recv_stream) + yield diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 3b72e8b0..284ddd9b 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -99,6 +99,9 @@ def run_qtractor( # "This is substantially faster than using a signal... for some # reason Qt signal dispatch is really slow (and relies on events # underneath anyway, so this is strictly less work)." + + # source gist and credit to njs: + # https://gist.github.com/njsmith/d996e80b700a339e0623f97f48bcf0cb REENTER_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType()) class ReenterEvent(QtCore.QEvent): diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py new file mode 100644 index 00000000..b504a408 --- /dev/null +++ b/piker/ui/_forms.py @@ -0,0 +1,727 @@ +# piker: trading gear for hackers +# Copyright (C) Tyler Goodlet (in stewardship for pikers) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +''' +Text entry "forms" widgets (mostly for configuration and UI user input). + +''' +from __future__ import annotations +from contextlib import asynccontextmanager +from functools import partial +from textwrap import dedent +from typing import ( + Optional, Any, Callable, Awaitable +) + +import trio +from PyQt5 import QtGui +from PyQt5.QtCore import QSize, QModelIndex, Qt, QEvent +from PyQt5.QtWidgets import ( + QWidget, + QLabel, + QComboBox, + QLineEdit, + QHBoxLayout, + QVBoxLayout, + QFormLayout, + QProgressBar, + QSizePolicy, + QStyledItemDelegate, + QStyleOptionViewItem, +) +# import pydantic + +from ._event import open_handlers +from ._style import hcolor, _font, _font_small, DpiAwareFont +from ._label import FormatLabel +from .. import brokers + + +class FontAndChartAwareLineEdit(QLineEdit): + + def __init__( + + self, + parent: QWidget, + # parent_chart: QWidget, # noqa + font: DpiAwareFont = _font, + width_in_chars: int = None, + + ) -> None: + + # self.setContextMenuPolicy(Qt.CustomContextMenu) + # self.customContextMenuRequested.connect(self.show_menu) + # self.setStyleSheet(f"font: 18px") + + self.dpi_font = font + # self.godwidget = parent_chart + + if width_in_chars: + self._chars = int(width_in_chars) + + else: + # chart count which will be used to calculate + # width of input field. + self._chars: int = 9 + + super().__init__(parent) + # size it as we specify + # https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum + self.setSizePolicy( + QSizePolicy.Expanding, + QSizePolicy.Fixed, + ) + self.setFont(font.font) + + # witty bit of margin + self.setTextMargins(2, 2, 2, 2) + + def sizeHint(self) -> QSize: + """ + Scale edit box to size of dpi aware font. + + """ + psh = super().sizeHint() + + dpi_font = self.dpi_font + psh.setHeight(dpi_font.px_size) + + # space for ``._chars: int`` + char_w_pxs = dpi_font.boundingRect(self.text()).width() + chars_w = char_w_pxs + 6 # * dpi_font.scale() * self._chars + psh.setWidth(chars_w) + + return psh + + def set_width_in_chars( + self, + chars: int, + + ) -> None: + self._chars = chars + self.sizeHint() + self.update() + + def focus(self) -> None: + self.selectAll() + self.show() + self.setFocus() + + +class FontScaledDelegate(QStyledItemDelegate): + ''' + Super simple view delegate to render text in the same + font size as the search widget. + + ''' + def __init__( + self, + + parent=None, + font: DpiAwareFont = _font, + + ) -> None: + + super().__init__(parent) + self.dpi_font = font + + def sizeHint( + self, + + option: QStyleOptionViewItem, + index: QModelIndex, + + ) -> QSize: + + # value = index.data() + # br = self.dpi_font.boundingRect(value) + # w, h = br.width(), br.height() + parent = self.parent() + + if getattr(parent, '_max_item_size', None): + return QSize(*self.parent()._max_item_size) + + else: + return super().sizeHint(option, index) + + +# slew of resources which helped get this where it is: +# https://stackoverflow.com/questions/20648210/qcombobox-adjusttocontents-changing-height +# https://stackoverflow.com/questions/3151798/how-do-i-set-the-qcombobox-width-to-fit-the-largest-item +# https://stackoverflow.com/questions/6337589/qlistwidget-adjust-size-to-content#6370892 +# https://stackoverflow.com/questions/25304267/qt-resize-of-qlistview +# https://stackoverflow.com/questions/28227406/how-to-set-qlistview-rows-height-permanently + +class FieldsForm(QWidget): + + vbox: QVBoxLayout + form: QFormLayout + + def __init__( + self, + parent=None, + + ) -> None: + + super().__init__(parent) + + # size it as we specify + self.setSizePolicy( + QSizePolicy.Expanding, + QSizePolicy.Expanding, + ) + + # XXX: not sure why we have to create this here exactly + # (instead of in the pane creation routine) but it's + # here and is managed by downstream layout routines. + # best guess is that you have to create layouts in order + # of hierarchy in order for things to display correctly? + # TODO: we may want to hand this *down* from some "pane manager" + # thing eventually? + self.vbox = QVBoxLayout(self) + # self.vbox.setAlignment(Qt.AlignVCenter) + self.vbox.setAlignment(Qt.AlignBottom) + self.vbox.setContentsMargins(0, 4, 3, 6) + self.vbox.setSpacing(0) + + # split layout for the (