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 (