Merge pull request #226 from pikers/account_select_icons

Account select icons
fsp_feeds
goodboy 2021-09-15 11:36:16 -04:00 committed by GitHub
commit 3aadd49e07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 356 additions and 167 deletions

View File

@ -1469,6 +1469,7 @@ async def trades_dialogue(
# deliver positions to subscriber before anything else
all_positions = []
accounts = set()
clients: list[tuple[Client, trio.MemoryReceiveChannel]] = []
for account, client in _accounts2clients.items():
@ -1484,9 +1485,10 @@ async def trades_dialogue(
for pos in client.positions():
msg = pack_position(pos)
msg.account = accounts_def.inverse[msg.account]
accounts.add(msg.account)
all_positions.append(msg.dict())
await ctx.started(all_positions)
await ctx.started((all_positions, accounts))
async with (
ctx.open_stream() as ems_stream,

View File

@ -86,14 +86,7 @@ class Allocator(BaseModel):
underscore_attrs_are_private = False
symbol: Symbol
accounts: bidict[str, Optional[str]]
account: Optional[str] = 'paper'
@validator('account', pre=False)
def set_account(cls, v, values):
if v:
return values['accounts'][v]
size_unit: SizeUnit = 'currency'
_size_units: dict[str, Optional[str]] = _size_units
@ -128,9 +121,6 @@ class Allocator(BaseModel):
else:
return self.units_limit
def account_name(self) -> str:
return self.accounts.inverse[self.account]
def next_order_info(
self,
@ -234,7 +224,7 @@ class Allocator(BaseModel):
'slots_used': slots_used,
# update line LHS label with account name
'account': self.account_name(),
'account': self.account,
}
def slots_used(
@ -264,7 +254,6 @@ class Allocator(BaseModel):
def mk_allocator(
symbol: Symbol,
accounts: dict[str, str],
startup_pp: Position,
# default allocation settings
@ -293,7 +282,6 @@ def mk_allocator(
alloc = Allocator(
symbol=symbol,
accounts=accounts,
**defaults,
)

View File

@ -210,7 +210,7 @@ async def open_ems(
broker=broker,
symbol=symbol.key,
) as (ctx, positions),
) as (ctx, (positions, accounts)),
# open 2-way trade command stream
ctx.open_stream() as trades_stream,
@ -222,4 +222,4 @@ async def open_ems(
trades_stream
)
yield book, trades_stream, positions
yield book, trades_stream, positions, accounts

View File

@ -268,6 +268,9 @@ class TradesRelay:
# map of symbols to dicts of accounts to pp msgs
positions: dict[str, dict[str, BrokerdPosition]]
# allowed account names
accounts: set[str]
# count of connected ems clients for this ``brokerd``
consumers: int = 0
@ -410,8 +413,7 @@ async def open_brokerd_trades_dialogue(
try:
async with (
open_trades_endpoint as (brokerd_ctx, positions),
open_trades_endpoint as (brokerd_ctx, (positions, accounts,)),
brokerd_ctx.open_stream() as brokerd_trades_stream,
):
@ -433,15 +435,20 @@ async def open_brokerd_trades_dialogue(
# locally cache and track positions per account.
pps = {}
for msg in positions:
account = msg['account']
assert account in accounts
pps.setdefault(
msg['symbol'],
{}
)[msg['account']] = msg
)[account] = msg
relay = TradesRelay(
brokerd_dialogue=brokerd_trades_stream,
positions=pps,
consumers=1
accounts=set(accounts),
consumers=1,
)
_router.relays[broker] = relay
@ -936,11 +943,11 @@ async def _emsd_main(
) -> None:
'''EMS (sub)actor entrypoint providing the
execution management (micro)service which conducts broker
order control on behalf of clients.
order clearing control on behalf of clients.
This is the daemon (child) side routine which starts an EMS runtime
(one per broker-feed) and and begins streaming back alerts from
broker executions/fills.
task (one per broker-feed) and and begins streaming back alerts from
each broker's executions/fills.
``send_order_cmds()`` is called here to execute in a task back in
the actor which started this service (spawned this actor), presuming
@ -964,8 +971,8 @@ async def _emsd_main(
reponse" proxy-broker.
|
- ``process_client_order_cmds()``:
accepts order cmds from requesting piker clients, registers
execs with exec loop
accepts order cmds from requesting clients, registers dark orders and
alerts with clearing loop.
'''
global _router
@ -1015,13 +1022,15 @@ async def _emsd_main(
brokerd_stream = relay.brokerd_dialogue # .clone()
# signal to client that we're started
# TODO: we could eventually send back **all** brokerd
# positions here?
await ems_ctx.started(
{sym: list(pps.values())
for sym, pps in relay.positions.items()}
)
# flatten out collected pps from brokerd for delivery
pp_msgs = {
sym: list(pps.values())
for sym, pps in relay.positions.items()
}
# signal to client that we're started and deliver
# all known pps and accounts for this ``brokerd``.
await ems_ctx.started((pp_msgs, relay.accounts))
# establish 2-way stream with requesting order-client and
# begin handling inbound order requests and updates

View File

@ -463,7 +463,7 @@ async def trades_dialogue(
# TODO: load paper positions per broker from .toml config file
# and pass as symbol to position data mapping: ``dict[str, dict]``
# await ctx.started(all_positions)
await ctx.started({})
await ctx.started(({}, {'paper',}))
async with (
ctx.open_stream() as ems_stream,

View File

@ -42,12 +42,11 @@ from PyQt5.QtWidgets import (
QStyledItemDelegate,
QStyleOptionViewItem,
)
# import pydantic
from ._event import open_handlers
from ._icons import mk_icons
from ._style import hcolor, _font, _font_small, DpiAwareFont
from ._label import FormatLabel
from .. import config
class FontAndChartAwareLineEdit(QLineEdit):
@ -71,17 +70,21 @@ class FontAndChartAwareLineEdit(QLineEdit):
if width_in_chars:
self._chars = int(width_in_chars)
x_size_policy = QSizePolicy.Fixed
else:
# chart count which will be used to calculate
# width of input field.
self._chars: int = 9
self._chars: int = 6
# fit to surroundingn frame width
x_size_policy = QSizePolicy.Expanding
super().__init__(parent)
# size it as we specify
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
self.setSizePolicy(
QSizePolicy.Expanding,
x_size_policy,
QSizePolicy.Fixed,
)
self.setFont(font.font)
@ -99,11 +102,11 @@ class FontAndChartAwareLineEdit(QLineEdit):
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)
# make space for ``._chars: int`` for of characters in view
# TODO: somehow this math ain't right?
chars_w_pxs = dpi_font.boundingRect('0'*self._chars).width()
scale = round(dpi_font.scale())
psh.setWidth(chars_w_pxs * scale)
return psh
def set_width_in_chars(
@ -157,6 +160,130 @@ class FontScaledDelegate(QStyledItemDelegate):
else:
return super().sizeHint(option, index)
# NOTE: hack to display icons on RHS
# TODO: is there a way to set this stype option once?
# def paint(self, painter, option, index):
# # display icons on RHS
# # https://stackoverflow.com/a/39943629
# option.decorationPosition = QtGui.QStyleOptionViewItem.Right
# option.decorationAlignment = Qt.AlignRight | Qt.AlignVCenter
# QStyledItemDelegate.paint(self, painter, option, index)
class Selection(QComboBox):
def __init__(
self,
parent=None,
) -> None:
self._items: dict[str, int] = {}
super().__init__(parent=parent)
self.setSizeAdjustPolicy(QComboBox.AdjustToContents)
# make line edit expand to surrounding frame
self.setSizePolicy(
QSizePolicy.Expanding,
QSizePolicy.Fixed,
)
view = self.view()
view.setUniformItemSizes(True)
# TODO: this doesn't seem to work for the currently selected item?
self.setItemDelegate(FontScaledDelegate(self))
self.resize()
self._icons = mk_icons(
self.style(),
self.iconSize()
)
def set_style(
self,
color: str,
font_size: int,
) -> None:
self.setStyleSheet(
f"""QComboBox {{
color : {hcolor(color)};
font-size : {font_size}px;
}}
"""
)
def resize(
self,
char: str = 'W',
) -> None:
br = _font.boundingRect(str(char))
_, h = br.width(), br.height()
# TODO: something better then this monkey patch
view = self.view()
# XXX: see size policy settings of line edit
# view._max_item_size = w, h
self.setMinimumHeight(h) # at least one entry in view
view.setMaximumHeight(6*h) # limit to 6 items max in view
icon_size = round(h * 0.75)
self.setIconSize(QSize(icon_size, icon_size))
def set_items(
self,
keys: list[str],
) -> None:
'''Write keys to the selection verbatim.
All other items are cleared beforehand.
'''
self.clear()
self._items.clear()
for i, key in enumerate(keys):
strkey = str(key)
self.insertItem(i, strkey)
# store map of entry keys to row indexes
self._items[strkey] = i
# compute max item size so that the weird
# "style item delegate" thing can then specify
# that size on each item...
keys.sort()
self.resize(keys[-1])
def set_icon(
self,
key: str,
icon_name: Optional[str],
) -> None:
self.setItemIcon(
self._items[key],
self._icons[icon_name],
)
def items(self) -> list[(str, int)]:
return list(self._items.items())
# NOTE: in theory we can put icons on the RHS side with this hackery:
# https://stackoverflow.com/a/64256969
# def showPopup(self):
# print('show')
# QComboBox.showPopup(self)
# def hidePopup(self):
# # self.setItemDelegate(FontScaledDelegate(self.parent()))
# print('hide')
# QComboBox.hidePopup(self)
# slew of resources which helped get this where it is:
# https://stackoverflow.com/questions/20648210/qcombobox-adjusttocontents-changing-height
@ -164,7 +291,6 @@ class FontScaledDelegate(QStyledItemDelegate):
# 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
@ -252,6 +378,7 @@ class FieldsForm(QWidget):
edit = FontAndChartAwareLineEdit(
parent=self,
# width_in_chars=6,
)
edit.setStyleSheet(
f"""QLineEdit {{
@ -274,56 +401,23 @@ class FieldsForm(QWidget):
label_name: str,
values: list[str],
) -> QComboBox:
) -> Selection:
# TODO: maybe a distint layout per "field" item?
label = self.add_field_label(label_name)
select = QComboBox(self)
select = Selection(self)
select.set_style(color='gunmetal', font_size=self._font_size)
select._key = key
select.set_items(values)
for i, value in enumerate(values):
select.insertItem(i, str(value))
select.setStyleSheet(
f"""QComboBox {{
color : {hcolor('gunmetal')};
font-size : {self._font_size}px;
}}
"""
)
select.setSizeAdjustPolicy(QComboBox.AdjustToContents)
select.setIconSize(QSize(0, 0))
self.setSizePolicy(
QSizePolicy.Fixed,
QSizePolicy.Fixed,
)
view = select.view()
view.setUniformItemSizes(True)
view.setItemDelegate(FontScaledDelegate(view))
# compute maximum item size so that the weird
# "style item delegate" thing can then specify
# that size on each item...
values.sort()
br = _font.boundingRect(str(values[-1]))
_, h = br.width(), br.height()
# TODO: something better then this monkey patch
# view._max_item_size = w, h
# limit to 6 items?
view.setMaximumHeight(6*h)
# one entry in view
select.setMinimumHeight(h)
select.show()
self.form.addRow(label, select)
self.fields[key] = select
return select
@ -631,9 +725,7 @@ def mk_order_pane_layout(
) -> FieldsForm:
# font_size: int = _font_small.px_size - 2
font_size: int = _font.px_size - 2
accounts = config.load_accounts()
font_size: int = _font.px_size - 1
# TODO: maybe just allocate the whole fields form here
# and expect an async ctx entry?
@ -643,7 +735,7 @@ def mk_order_pane_layout(
'account': {
'label': '**account**:',
'type': 'select',
'default_value': accounts.keys(),
'default_value': ['paper'],
},
'size_unit': {
'label': '**allocate**:',
@ -685,7 +777,6 @@ def mk_order_pane_layout(
form,
pane_vbox=vbox,
label_font_size=font_size,
)
# TODO: would be nice to have some better way of reffing these over
# monkey patching...

90
piker/ui/_icons.py 100644
View File

@ -0,0 +1,90 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
``QIcon`` hackery.
'''
from PyQt5.QtWidgets import QStyle
from PyQt5.QtGui import (
QIcon, QPixmap, QColor
)
from PyQt5.QtCore import QSize
from ._style import hcolor
# https://www.pythonguis.com/faq/built-in-qicons-pyqt/
# account status icons taken from built-in set
_icon_names: dict[str, str] = {
# these two seem to work for our mask hack done below to
# change the coloring.
'long_pp': 'SP_TitleBarShadeButton',
'short_pp': 'SP_TitleBarUnshadeButton',
'ready': 'SP_MediaPlay',
}
_icons: dict[str, QIcon] = {}
def mk_icons(
style: QStyle,
size: QSize,
) -> dict[str, QIcon]:
'''This helper is indempotent.
'''
global _icons, _icon_names
if _icons:
return _icons
_icons[None] = QIcon() # the "null" icon
# load account selection using current style
for name, icon_name in _icon_names.items():
stdpixmap = getattr(QStyle, icon_name)
stdicon = style.standardIcon(stdpixmap)
pixmap = stdicon.pixmap(size)
# fill hack from SO to change icon color:
# https://stackoverflow.com/a/38369468
out_pixmap = QPixmap(size)
out_pixmap.fill(QColor(hcolor('default_spotlight')))
out_pixmap.setMask(pixmap.createHeuristicMask())
# TODO: not idea why this doesn't work / looks like
# trash. Sure would be nice to just generate our own
# pixmaps on the fly..
# p = QPainter(out_pixmap)
# p.setOpacity(1)
# p.setBrush(QColor(hcolor('papas_special')))
# p.setPen(QColor(hcolor('default_lightest')))
# path = mk_marker_path(style='|<')
# p.scale(6, 6)
# # p.translate(0, 0)
# p.drawPath(path)
# p.save()
# p.end()
# del p
# icon = QIcon(out_pixmap)
icon = QIcon()
icon.addPixmap(out_pixmap)
_icons[name] = icon
return _icons

View File

@ -270,7 +270,7 @@ async def handle_viewmode_kb_inputs(
edit.selectAll()
# un-highlight on ctrl release
on_next_release = edit.deselect
pp_pane.update_status_ui()
pp_pane.update_status_ui(pp_pane.order_mode.current_pp)
else: # none active

View File

@ -25,6 +25,10 @@ from math import floor, copysign
from typing import Optional
# from PyQt5.QtWidgets import QStyle
# from PyQt5.QtGui import (
# QIcon, QPixmap, QColor
# )
from pyqtgraph import functions as fn
from ._annotate import LevelMarker
@ -46,7 +50,7 @@ log = get_logger(__name__)
_pnl_tasks: dict[str, bool] = {}
async def display_pnl(
async def update_pnl_from_feed(
feed: Feed,
order_mode: OrderMode, # noqa
@ -63,6 +67,8 @@ async def display_pnl(
live = pp.live_pp
key = live.symbol.key
log.info(f'Starting pnl display for {pp.alloc.account}')
if live.size < 0:
types = ('ask', 'last', 'last', 'utrade')
@ -128,9 +134,17 @@ class SettingsPane:
# encompasing high level namespace
order_mode: Optional['OrderMode'] = None # typing: ignore # noqa
def set_accounts(
self,
names: list[str],
sizes: Optional[list[float]] = None,
) -> None:
combo = self.form.fields['account']
return combo.set_items(names)
def on_selection_change(
self,
text: str,
key: str,
@ -173,11 +187,11 @@ class SettingsPane:
f'Account `{account_name}` can not be set for {sym}'
)
self.form.fields['account'].setCurrentText(
old_tracker.alloc.account_name())
old_tracker.alloc.account)
return
self.order_mode.current_pp = tracker
assert tracker.alloc.account_name() == account_name
assert tracker.alloc.account == account_name
self.form.fields['account'].setCurrentText(account_name)
tracker.show()
tracker.hide_info()
@ -206,7 +220,7 @@ class SettingsPane:
elif key == 'size_unit':
# TODO: if there's a limit size unit change re-compute
# the current settings in the new units
pass
alloc.size_unit = value
elif key != 'account':
raise ValueError(f'Unknown setting {key}')
@ -266,12 +280,32 @@ class SettingsPane:
# min(round(prop * slots), slots)
min(used, slots)
)
self.update_account_icons({alloc.account: pp.live_pp})
def update_account_icons(
self,
pps: dict[str, Position],
) -> None:
form = self.form
accounts = form.fields['account']
for account_name, pp in pps.items():
icon_name = None
if pp.size > 0:
icon_name = 'long_pp'
elif pp.size < 0:
icon_name = 'short_pp'
accounts.set_icon(account_name, icon_name)
def display_pnl(
self,
tracker: PositionTracker,
) -> bool:
) -> None:
'''Display the PnL for the current symbol and personal positioning (pp).
If a position is open start a background task which will
@ -282,36 +316,28 @@ class SettingsPane:
sym = mode.chart.linked.symbol
size = tracker.live_pp.size
feed = mode.quote_feed
global _pnl_tasks
pnl_value = 0
if (
size and
sym.key not in _pnl_tasks
):
_pnl_tasks[sym.key] = True
# immediately compute and display pnl status from last quote
self.pnl_label.format(
pnl=copysign(1, size) * pnl(
tracker.live_pp.avg_price,
# last historical close price
feed.shm.array[-1][['close']][0],
),
if size:
# last historical close price
last = feed.shm.array[-1][['close']][0]
pnl_value = copysign(1, size) * pnl(
tracker.live_pp.avg_price,
last,
)
log.info(
f'Starting pnl display for {tracker.alloc.account_name()}')
self.order_mode.nursery.start_soon(
display_pnl,
feed,
mode,
)
return True
# maybe start update task
global _pnl_tasks
if sym.key not in _pnl_tasks:
_pnl_tasks[sym.key] = True
self.order_mode.nursery.start_soon(
update_pnl_from_feed,
feed,
mode,
)
else:
# set 0% pnl
self.pnl_label.format(pnl=0)
return False
# immediately display in status label
self.pnl_label.format(pnl=pnl_value)
def position_line(
@ -622,7 +648,7 @@ class PositionTracker:
'fiat_size': round(price * size, ndigits=2),
# TODO: per account lines on a single (or very related) symbol
'account': self.alloc.account_name(),
'account': self.alloc.account,
})
line.show()

View File

@ -118,7 +118,7 @@ class CompleterView(QTreeView):
self.setModel(model)
self.setAlternatingRowColors(True)
# TODO: size this based on DPI font
self.setIndentation(20)
self.setIndentation(_font.px_size)
# self.setUniformRowHeights(True)
# self.setColumnWidth(0, 3)

View File

@ -222,7 +222,7 @@ class OrderMode:
order = self._staged_order = Order(
action=action,
price=price,
account=self.current_pp.alloc.account_name(),
account=self.current_pp.alloc.account,
size=0,
symbol=symbol,
brokers=symbol.brokers,
@ -536,7 +536,8 @@ async def open_order_mode(
open_ems(brokername, symbol) as (
book,
trades_stream,
position_msgs
position_msgs,
brokerd_accounts,
),
trio.open_nursery() as tn,
@ -549,9 +550,6 @@ async def open_order_mode(
lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {})
# allocation and account settings side pane
form = chart.sidepane
# symbol id
symbol = chart.linked.symbol
symkey = symbol.key
@ -560,27 +558,27 @@ async def open_order_mode(
trackers: dict[str, PositionTracker] = {}
# load account names from ``brokers.toml``
accounts = config.load_accounts(providers=symbol.brokers).copy()
if accounts:
# first account listed is the one we select at startup
# (aka order based selection).
pp_account = next(iter(accounts.keys()))
else:
pp_account = 'paper'
accounts_def = config.load_accounts(
providers=symbol.brokers
)
# use only loaded accounts according to brokerd
accounts = {}
for name in brokerd_accounts:
# ensure name is in ``brokers.toml``
accounts[name] = accounts_def[name]
# first account listed is the one we select at startup
# (aka order based selection).
pp_account = next(iter(accounts.keys())) if accounts else 'paper'
# NOTE: requires the backend exactly specifies
# the expected symbol key in its positions msg.
pp_msgs = position_msgs.get(symkey, ())
pps_by_account = {msg['account']: msg for msg in pp_msgs}
# update all pp trackers with existing data relayed
# from ``brokerd``.
for msg in pp_msgs:
log.info(f'Loading pp for {symkey}:\n{pformat(msg)}')
account_name = msg.get('account')
account_value = accounts.get(account_name)
if not account_name and account_value == 'paper':
account_name = 'paper'
# update pp trackers with data relayed from ``brokerd``.
for account_name in accounts:
# net-zero pp
startup_pp = Position(
@ -588,12 +586,14 @@ async def open_order_mode(
size=0,
avg_price=0,
)
startup_pp.update_from_msg(msg)
msg = pps_by_account.get(account_name)
if msg:
log.info(f'Loading pp for {symkey}:\n{pformat(msg)}')
startup_pp.update_from_msg(msg)
# allocator
alloc = mk_allocator(
symbol=symbol,
accounts=accounts,
account=account_name,
# if this startup size is greater the allocator limit,
@ -621,31 +621,9 @@ async def open_order_mode(
pp_tracker.show()
pp_tracker.hide_info()
# fill out trackers for accounts with net-zero pps
zero_pp_accounts = set(accounts) - set(trackers)
for account_name in zero_pp_accounts:
startup_pp = Position(
symbol=symbol,
size=0,
avg_price=0,
)
# setup order mode sidepane widgets
form = chart.sidepane
# allocator
alloc = mk_allocator(
symbol=symbol,
accounts=accounts,
account=account_name,
startup_pp=startup_pp,
)
pp_tracker = PositionTracker(
chart,
alloc,
startup_pp
)
pp_tracker.hide()
trackers[account_name] = pp_tracker
# order pane widgets and allocation model
order_pane = SettingsPane(
form=form,
# XXX: ugh, so hideous...
@ -654,6 +632,11 @@ async def open_order_mode(
step_label=form.bottom_label,
limit_label=form.top_label,
)
order_pane.set_accounts(list(trackers.keys()))
# update pp icons
for name, tracker in trackers.items():
order_pane.update_account_icons({name: tracker.live_pp})
# top level abstraction which wraps all this crazyness into
# a namespace..