Merge pull request #395 from pikers/history_view

History view
pin_tractor_main
goodboy 2022-09-23 20:28:02 -04:00 committed by GitHub
commit d6c9834a9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1847 additions and 908 deletions

View File

@ -470,10 +470,14 @@ class Client:
# TODO add search though our adhoc-locally defined symbol set
# for futes/cmdtys/
results = await self.search_stocks(
pattern,
upto=upto,
)
try:
results = await self.search_stocks(
pattern,
upto=upto,
)
except ConnectionError:
return {}
for key, deats in results.copy().items():
tract = deats.contract

View File

@ -93,6 +93,9 @@ class Allocator(Struct):
else:
return self.units_limit
def limit_info(self) -> tuple[str, float]:
return self.size_unit, self.limit()
def next_order_info(
self,

View File

@ -617,8 +617,9 @@ async def translate_and_relay_brokerd_events(
f'Received broker trade event:\n'
f'{fmsg}'
)
match brokerd_msg:
status_msg: Optional[Status] = None
match brokerd_msg:
# BrokerdPosition
case {
'name': 'position',
@ -866,6 +867,7 @@ async def translate_and_relay_brokerd_events(
}:
log.error(f'Broker error:\n{fmsg}')
# XXX: we presume the brokerd cancels its own order
continue
# TOO FAST ``BrokerdStatus`` that arrives
# before the ``BrokerdAck``.
@ -894,8 +896,8 @@ async def translate_and_relay_brokerd_events(
raise ValueError(f'Brokerd message {brokerd_msg} is invalid')
# XXX: ugh sometimes we don't access it?
if status_msg:
del status_msg
# if status_msg is not None:
# del status_msg
# TODO: do we want this to keep things cleaned up?
# it might require a special status from brokerd to affirm the
@ -1107,7 +1109,7 @@ async def process_client_order_cmds(
# sometimes the real-time feed hasn't come up
# so just pull from the latest history.
if isnan(last):
last = feed.shm.array[-1]['close']
last = feed.rt_shm.array[-1]['close']
pred = mk_check(trigger_price, last, action)

View File

@ -37,6 +37,9 @@ if TYPE_CHECKING:
log = get_logger(__name__)
_default_delay_s: float = 1.0
class sampler:
'''
Global sampling engine registry.
@ -104,14 +107,18 @@ async def increment_ohlc_buffer(
# TODO: do we want to support dynamically
# adding a "lower" lowest increment period?
await trio.sleep(ad)
total_s += lowest
total_s += delay_s
# increment all subscribed shm arrays
# TODO:
# - this in ``numba``
# - just lookup shms for this step instead of iterating?
for delay_s, shms in sampler.ohlcv_shms.items():
if total_s % delay_s != 0:
for this_delay_s, shms in sampler.ohlcv_shms.items():
# short-circuit on any not-ready because slower sample
# rate consuming shm buffers.
if total_s % this_delay_s != 0:
# print(f'skipping `{this_delay_s}s` sample update')
continue
# TODO: ``numba`` this!
@ -130,7 +137,7 @@ async def increment_ohlc_buffer(
# this copies non-std fields (eg. vwap) from the last datum
last[
['time', 'volume', 'open', 'high', 'low', 'close']
][0] = (t + delay_s, 0, close, close, close, close)
][0] = (t + this_delay_s, 0, close, close, close, close)
# write to the buffer
shm.push(last)
@ -152,7 +159,6 @@ async def broadcast(
'''
subs = sampler.subscribers.get(delay_s, ())
first = last = -1
if shm is None:
@ -221,7 +227,8 @@ async def iter_ohlc_periods(
async def sample_and_broadcast(
bus: _FeedsBus, # noqa
shm: ShmArray,
rt_shm: ShmArray,
hist_shm: ShmArray,
quote_stream: trio.abc.ReceiveChannel,
brokername: str,
sum_tick_vlm: bool = True,
@ -257,41 +264,45 @@ async def sample_and_broadcast(
last = tick['price']
# update last entry
# benchmarked in the 4-5 us range
o, high, low, v = shm.array[-1][
['open', 'high', 'low', 'volume']
]
# more compact inline-way to do this assignment
# to both buffers?
for shm in [rt_shm, hist_shm]:
# update last entry
# benchmarked in the 4-5 us range
# for shm in [rt_shm, hist_shm]:
o, high, low, v = shm.array[-1][
['open', 'high', 'low', 'volume']
]
new_v = tick.get('size', 0)
new_v = tick.get('size', 0)
if v == 0 and new_v:
# no trades for this bar yet so the open
# is also the close/last trade price
o = last
if v == 0 and new_v:
# no trades for this bar yet so the open
# is also the close/last trade price
o = last
if sum_tick_vlm:
volume = v + new_v
else:
# presume backend takes care of summing
# it's own vlm
volume = quote['volume']
if sum_tick_vlm:
volume = v + new_v
else:
# presume backend takes care of summing
# it's own vlm
volume = quote['volume']
shm.array[[
'open',
'high',
'low',
'close',
'bar_wap', # can be optionally provided
'volume',
]][-1] = (
o,
max(high, last),
min(low, last),
last,
quote.get('bar_wap', 0),
volume,
)
shm.array[[
'open',
'high',
'low',
'close',
'bar_wap', # can be optionally provided
'volume',
]][-1] = (
o,
max(high, last),
min(low, last),
last,
quote.get('bar_wap', 0),
volume,
)
# XXX: we need to be very cautious here that no
# context-channel is left lingering which doesn't have

View File

@ -56,6 +56,7 @@ from ._sharedmem import (
maybe_open_shm_array,
attach_shm_array,
ShmArray,
_secs_in_day,
)
from .ingest import get_ingestormod
from .types import Struct
@ -72,6 +73,7 @@ from ._sampling import (
iter_ohlc_periods,
sample_and_broadcast,
uniform_rate_send,
_default_delay_s,
)
from ..brokers._util import (
NoData,
@ -256,7 +258,7 @@ async def start_backfill(
write_tsdb: bool = True,
tsdb_is_up: bool = False,
task_status: TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED,
task_status: TaskStatus[tuple] = trio.TASK_STATUS_IGNORED,
) -> int:
@ -294,7 +296,7 @@ async def start_backfill(
bf_done = trio.Event()
# let caller unblock and deliver latest history frame
task_status.started((shm, start_dt, end_dt, bf_done))
task_status.started((start_dt, end_dt, bf_done))
# based on the sample step size, maybe load a certain amount history
if last_tsdb_dt is None:
@ -544,7 +546,6 @@ async def start_backfill(
)
frames.pop(epoch)
continue
# await tractor.breakpoint()
if diff > step_size_s:
@ -672,8 +673,8 @@ async def manage_history(
'''
# (maybe) allocate shm array for this broker/symbol which will
# be used for fast near-term history capture and processing.
shm, opened = maybe_open_shm_array(
key=fqsn,
hist_shm, opened = maybe_open_shm_array(
key=f'{fqsn}_hist',
# use any broker defined ohlc dtype:
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
@ -687,6 +688,21 @@ async def manage_history(
"Persistent shm for sym was already open?!"
)
rt_shm, opened = maybe_open_shm_array(
key=f'{fqsn}_rt',
# use any broker defined ohlc dtype:
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
# we expect the sub-actor to write
readonly=False,
size=3*_secs_in_day,
)
if not opened:
raise RuntimeError(
"Persistent shm for sym was already open?!"
)
log.info('Scanning for existing `marketstored`')
is_up = await check_for_service('marketstored')
@ -714,7 +730,6 @@ async def manage_history(
broker, symbol, expiry = unpack_fqsn(fqsn)
(
shm,
latest_start_dt,
latest_end_dt,
bf_done,
@ -723,14 +738,14 @@ async def manage_history(
start_backfill,
mod,
bfqsn,
shm,
hist_shm,
last_tsdb_dt=last_tsdb_dt,
tsdb_is_up=True,
storage=storage,
)
)
# if len(shm.array) < 2:
# if len(hist_shm.array) < 2:
# TODO: there's an edge case here to solve where if the last
# frame before market close (at least on ib) was pushed and
# there was only "1 new" row pushed from the first backfill
@ -740,7 +755,7 @@ async def manage_history(
# the tsdb series and stash that somewhere as meta data on
# the shm buffer?.. no se.
task_status.started(shm)
task_status.started((hist_shm, rt_shm))
some_data_ready.set()
await bf_done.wait()
@ -758,7 +773,7 @@ async def manage_history(
# TODO: see if there's faster multi-field reads:
# https://numpy.org/doc/stable/user/basics.rec.html#accessing-multiple-fields
# re-index with a `time` and index field
prepend_start = shm._first.value
prepend_start = hist_shm._first.value
# sanity check on most-recent-data loading
assert prepend_start > dt_diff_s
@ -768,7 +783,7 @@ async def manage_history(
fastest = history[0]
to_push = fastest[:prepend_start]
shm.push(
hist_shm.push(
to_push,
# insert the history pre a "days worth" of samples
@ -784,7 +799,7 @@ async def manage_history(
count = 0
end = fastest['Epoch'][0]
while shm._first.value > 0:
while hist_shm._first.value > 0:
count += 1
series = await storage.read_ohlcv(
fqsn,
@ -796,7 +811,7 @@ async def manage_history(
prepend_start -= len(to_push)
to_push = fastest[:prepend_start]
shm.push(
hist_shm.push(
to_push,
# insert the history pre a "days worth" of samples
@ -840,12 +855,12 @@ async def manage_history(
start_backfill,
mod,
bfqsn,
shm,
hist_shm,
)
)
# yield back after client connect with filled shm
task_status.started(shm)
task_status.started((hist_shm, rt_shm))
# indicate to caller that feed can be delivered to
# remote requesting client since we've loaded history
@ -891,7 +906,7 @@ async def allocate_persistent_feed(
# mem chan handed to broker backend so it can push real-time
# quotes to this task for sampling and history storage (see below).
send, quote_stream = trio.open_memory_channel(10)
send, quote_stream = trio.open_memory_channel(616)
# data sync signals for both history loading and market quotes
some_data_ready = trio.Event()
@ -922,7 +937,7 @@ async def allocate_persistent_feed(
# https://github.com/python-trio/trio/issues/2258
# bus.nursery.start_soon(
# await bus.start_task(
shm = await bus.nursery.start(
hist_shm, rt_shm = await bus.nursery.start(
manage_history,
mod,
bus,
@ -935,7 +950,9 @@ async def allocate_persistent_feed(
# can read directly from the memory which will be written by
# this task.
msg = init_msg[symbol]
msg['shm_token'] = shm.token
msg['hist_shm_token'] = hist_shm.token
msg['startup_hist_index'] = hist_shm.index - 1
msg['rt_shm_token'] = rt_shm.token
# true fqsn
fqsn = '.'.join((bfqsn, brokername))
@ -971,7 +988,25 @@ async def allocate_persistent_feed(
# for ambiguous names we simply apply the retreived
# feed to that name (for now).
# task_status.started((init_msg, generic_first_quotes))
sampler.ohlcv_shms.setdefault(
1,
[]
).append(rt_shm)
ohlckeys = ['open', 'high', 'low', 'close']
# set the rt (hft) shm array as append only
# (for now).
rt_shm._first.value = 0
rt_shm._last.value = 0
# push last sample from history to rt buffer just as a filler datum
# but we don't want a history sized datum outlier so set vlm to zero
# and ohlc to the close value.
rt_shm.push(hist_shm.array[-2:-1])
rt_shm.array[ohlckeys] = hist_shm.array['close'][-1]
rt_shm._array['volume'] = 0
task_status.started()
if not start_stream:
@ -983,14 +1018,18 @@ async def allocate_persistent_feed(
# start shm incrementer task for OHLC style sampling
# at the current detected step period.
times = shm.array['time']
times = hist_shm.array['time']
delay_s = times[-1] - times[times != times[-1]][-1]
sampler.ohlcv_shms.setdefault(delay_s, []).append(hist_shm)
sampler.ohlcv_shms.setdefault(delay_s, []).append(shm)
if sampler.incrementers.get(delay_s) is None:
# create buffer a single incrementer task broker backend
# (aka `brokerd`) using the lowest sampler period.
# await tractor.breakpoint()
# for delay_s in sampler.ohlcv_shms:
if sampler.incrementers.get(_default_delay_s) is None:
await bus.start_task(
increment_ohlc_buffer,
delay_s,
_default_delay_s,
)
sum_tick_vlm: bool = init_msg.get(
@ -1001,7 +1040,8 @@ async def allocate_persistent_feed(
try:
await sample_and_broadcast(
bus,
shm,
rt_shm,
hist_shm,
quote_stream,
brokername,
sum_tick_vlm
@ -1164,34 +1204,6 @@ async def open_feed_bus(
log.warning(f'{sub} for {symbol} was already removed?')
@asynccontextmanager
async def open_sample_step_stream(
portal: tractor.Portal,
delay_s: int,
) -> tractor.ReceiveMsgStream:
# XXX: this should be singleton on a host,
# a lone broker-daemon per provider should be
# created for all practical purposes
async with maybe_open_context(
acm_func=partial(
portal.open_context,
iter_ohlc_periods,
),
kwargs={'delay_s': delay_s},
) as (cache_hit, (ctx, first)):
async with ctx.open_stream() as istream:
if cache_hit:
# add a new broadcast subscription for the quote stream
# if this feed is likely already in use
async with istream.subscribe() as bistream:
yield bistream
else:
yield istream
@dataclass
class Feed:
'''
@ -1204,13 +1216,16 @@ class Feed:
'''
name: str
shm: ShmArray
hist_shm: ShmArray
rt_shm: ShmArray
mod: ModuleType
first_quotes: dict # symbol names to first quote dicts
_portal: tractor.Portal
stream: trio.abc.ReceiveChannel[dict[str, Any]]
status: dict[str, Any]
startup_hist_index: int = 0
throttle_rate: Optional[int] = None
_trade_stream: Optional[AsyncIterator[dict[str, Any]]] = None
@ -1230,17 +1245,28 @@ class Feed:
@asynccontextmanager
async def index_stream(
self,
delay_s: Optional[int] = None
delay_s: int = 1,
) -> AsyncIterator[int]:
delay_s = delay_s or self._max_sample_rate
async with open_sample_step_stream(
self.portal,
delay_s,
) as istream:
yield istream
# XXX: this should be singleton on a host,
# a lone broker-daemon per provider should be
# created for all practical purposes
async with maybe_open_context(
acm_func=partial(
self.portal.open_context,
iter_ohlc_periods,
),
kwargs={'delay_s': delay_s},
) as (cache_hit, (ctx, first)):
async with ctx.open_stream() as istream:
if cache_hit:
# add a new broadcast subscription for the quote stream
# if this feed is likely already in use
async with istream.subscribe() as bistream:
yield bistream
else:
yield istream
async def pause(self) -> None:
await self.stream.send('pause')
@ -1248,6 +1274,34 @@ class Feed:
async def resume(self) -> None:
await self.stream.send('resume')
def get_ds_info(
self,
) -> tuple[float, float, float]:
'''
Compute the "downsampling" ratio info between the historical shm
buffer and the real-time (HFT) one.
Return a tuple of the fast sample period, historical sample
period and ratio between them.
'''
times = self.hist_shm.array['time']
end = pendulum.from_timestamp(times[-1])
start = pendulum.from_timestamp(times[times != times[-1]][-1])
hist_step_size_s = (end - start).seconds
times = self.rt_shm.array['time']
end = pendulum.from_timestamp(times[-1])
start = pendulum.from_timestamp(times[times != times[-1]][-1])
rt_step_size_s = (end - start).seconds
ratio = hist_step_size_s / rt_step_size_s
return (
rt_step_size_s,
hist_step_size_s,
ratio,
)
@asynccontextmanager
async def install_brokerd_search(
@ -1337,21 +1391,29 @@ async def open_feed(
) as stream,
):
init = init_msg[bfqsn]
# we can only read from shm
shm = attach_shm_array(
token=init_msg[bfqsn]['shm_token'],
hist_shm = attach_shm_array(
token=init['hist_shm_token'],
readonly=True,
)
rt_shm = attach_shm_array(
token=init['rt_shm_token'],
readonly=True,
)
assert fqsn in first_quotes
feed = Feed(
name=brokername,
shm=shm,
hist_shm=hist_shm,
rt_shm=rt_shm,
mod=mod,
first_quotes=first_quotes,
stream=stream,
_portal=portal,
status={},
startup_hist_index=init['startup_hist_index'],
throttle_rate=tick_throttle,
)
@ -1364,7 +1426,7 @@ async def open_feed(
'actor_name': feed.portal.channel.uid[0],
'host': host,
'port': port,
'shm': f'{humanize(feed.shm._shm.size)}',
'shm': f'{humanize(feed.hist_shm._shm.size)}',
'throttle_rate': feed.throttle_rate,
})
feed.status.update(init_msg.pop('status', {}))
@ -1382,13 +1444,17 @@ async def open_feed(
feed.symbols[sym] = symbol
# cast shm dtype to list... can't member why we need this
shm_token = data['shm_token']
for shm_key, shm in [
('rt_shm_token', rt_shm),
('hist_shm_token', hist_shm),
]:
shm_token = data[shm_key]
# XXX: msgspec won't relay through the tuples XD
shm_token['dtype_descr'] = tuple(
map(tuple, shm_token['dtype_descr']))
# XXX: msgspec won't relay through the tuples XD
shm_token['dtype_descr'] = tuple(
map(tuple, shm_token['dtype_descr']))
assert shm_token == shm.token # sanity
assert shm_token == shm.token # sanity
feed._max_sample_rate = 1

View File

@ -37,6 +37,7 @@ from .. import data
from ..data import attach_shm_array
from ..data.feed import Feed
from ..data._sharedmem import ShmArray
from ..data._sampling import _default_delay_s
from ..data._source import Symbol
from ._api import (
Fsp,
@ -105,7 +106,7 @@ async def fsp_compute(
filter_quotes_by_sym(fqsn, quote_stream),
# XXX: currently the ``ohlcv`` arg
feed.shm,
feed.rt_shm,
)
# Conduct a single iteration of fsp with historical bars input
@ -313,7 +314,7 @@ async def cascade(
profiler(f'{func}: feed up')
assert src.token == feed.shm.token
assert src.token == feed.rt_shm.token
# last_len = new_len = len(src.array)
func_name = func.__name__
@ -420,7 +421,11 @@ async def cascade(
# detect sample period step for subscription to increment
# signal
times = src.array['time']
delay_s = times[-1] - times[times != times[-1]][-1]
if len(times) > 1:
delay_s = times[-1] - times[times != times[-1]][-1]
else:
# our default "HFT" sample rate.
delay_s = _default_delay_s
# Increment the underlying shared memory buffer on every
# "increment" msg received from the underlying data feed.
@ -431,7 +436,8 @@ async def cascade(
profiler(f'{func_name}: sample stream up')
profiler.finish()
async for _ in istream:
async for i in istream:
# log.runtime(f'FSP incrementing {i}')
# respawn the compute task if the source
# array has been updated such that we compute

View File

@ -32,16 +32,22 @@ def mk_marker_path(
style: str,
) -> QGraphicsPathItem:
"""Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem``
ready to be placed using scene coordinates (not view).
'''
Add a marker to be displayed on the line wrapped in
a ``QGraphicsPathItem`` ready to be placed using scene coordinates
(not view).
**Arguments**
style String indicating the style of marker to add:
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
``'>|<'``, ``'^'``, ``'v'``, ``'o'``
size Size of the marker in pixels.
"""
This code is taken nearly verbatim from the
`InfiniteLine.addMarker()` method but does not attempt do be aware
of low(er) level graphics controls and expects for the output
polygon to be applied to a ``QGraphicsPathItem``.
'''
path = QtGui.QPainterPath()
if style == 'o':
@ -87,7 +93,8 @@ def mk_marker_path(
class LevelMarker(QGraphicsPathItem):
'''An arrow marker path graphich which redraws itself
'''
An arrow marker path graphich which redraws itself
to the specified view coordinate level on each paint cycle.
'''
@ -114,6 +121,7 @@ class LevelMarker(QGraphicsPathItem):
self.get_level = get_level
self._on_paint = on_paint
self.scene_x = lambda: chart.marker_right_points()[1]
self.level: float = 0
self.keep_in_view = keep_in_view
@ -149,12 +157,9 @@ class LevelMarker(QGraphicsPathItem):
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.
def position_in_view(self) -> 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
@ -162,7 +167,6 @@ class LevelMarker(QGraphicsPathItem):
'''
level = self.get_level()
view = self.chart.getViewBox()
vr = view.state['viewRange']
ymn, ymx = vr[1]
@ -186,7 +190,6 @@ class LevelMarker(QGraphicsPathItem):
)
elif level < ymn: # pin to bottom of view
self.setPos(
QPointF(
x,
@ -211,7 +214,8 @@ class LevelMarker(QGraphicsPathItem):
w: QtWidgets.QWidget
) -> None:
'''Core paint which we override to always update
'''
Core paint which we override to always update
our marker position in scene coordinates from a
view cooridnate "level".
@ -235,11 +239,12 @@ def qgo_draw_markers(
right_offset: float,
) -> float:
"""Paint markers in ``pg.GraphicsItem`` style by first
'''
Paint markers in ``pg.GraphicsItem`` style by first
removing the view transform for the painter, drawing the markers
in scene coords, then restoring the view coords.
"""
'''
# paint markers in native coordinate system
orig_tr = p.transform()

View File

@ -107,9 +107,8 @@ async def _async_main(
# 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()
godwidget.hbox.addWidget(search)
# search.bar.unfocus()
# godwidget.hbox.addWidget(search)
godwidget.search = search
symbol, _, provider = sym.rpartition('.')
@ -178,6 +177,6 @@ def _main(
run_qtractor(
func=_async_main,
args=(sym, brokernames, piker_loglevel),
main_widget=GodWidget,
main_widget_type=GodWidget,
tractor_kwargs=tractor_kwargs,
)

View File

@ -19,7 +19,11 @@ High level chart-widget apis.
'''
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from typing import (
Iterator,
Optional,
TYPE_CHECKING,
)
from PyQt5 import QtCore, QtWidgets
from PyQt5.QtCore import (
@ -68,6 +72,7 @@ from ._forms import FieldsForm
from .._profile import pg_profile_enabled, ms_slower_then
from ._overlay import PlotItemOverlay
from ._flows import Flow
from ._search import SearchWidget
if TYPE_CHECKING:
from ._display import DisplayState
@ -85,6 +90,9 @@ class GodWidget(QWidget):
modify them.
'''
search: SearchWidget
mode_name: str = 'god'
def __init__(
self,
@ -94,6 +102,8 @@ class GodWidget(QWidget):
super().__init__(parent)
self.search: Optional[SearchWidget] = None
self.hbox = QHBoxLayout(self)
self.hbox.setContentsMargins(0, 0, 0, 0)
self.hbox.setSpacing(6)
@ -115,7 +125,10 @@ class GodWidget(QWidget):
# self.vbox.addLayout(self.hbox)
self._chart_cache: dict[str, LinkedSplits] = {}
self.linkedsplits: Optional[LinkedSplits] = None
self.hist_linked: Optional[LinkedSplits] = None
self.rt_linked: Optional[LinkedSplits] = None
self._active_cursor: Optional[Cursor] = None
# assigned in the startup func `_async_main()`
self._root_n: trio.Nursery = None
@ -123,6 +136,14 @@ class GodWidget(QWidget):
self._widgets: dict[str, QWidget] = {}
self._resizing: bool = False
# TODO: do we need this, when would god get resized
# and the window does not? Never right?!
# self.reg_for_resize(self)
@property
def linkedsplits(self) -> LinkedSplits:
return self.rt_linked
# def init_timeframes_ui(self):
# self.tf_layout = QHBoxLayout()
# self.tf_layout.setSpacing(0)
@ -148,19 +169,19 @@ class GodWidget(QWidget):
def set_chart_symbol(
self,
symbol_key: str, # of form <fqsn>.<providername>
linkedsplits: LinkedSplits, # type: ignore
all_linked: tuple[LinkedSplits, LinkedSplits], # type: ignore
) -> None:
# re-sort org cache symbol list in LIFO order
cache = self._chart_cache
cache.pop(symbol_key, None)
cache[symbol_key] = linkedsplits
cache[symbol_key] = all_linked
def get_chart_symbol(
self,
symbol_key: str,
) -> LinkedSplits: # type: ignore
) -> tuple[LinkedSplits, LinkedSplits]: # type: ignore
return self._chart_cache.get(symbol_key)
async def load_symbol(
@ -182,28 +203,33 @@ class GodWidget(QWidget):
# fully qualified symbol name (SNS i guess is what we're making?)
fqsn = '.'.join([symbol_key, providername])
linkedsplits = self.get_chart_symbol(fqsn)
all_linked = self.get_chart_symbol(fqsn)
order_mode_started = trio.Event()
if not self.vbox.isEmpty():
# XXX: this is CRITICAL especially with pixel buffer caching
self.linkedsplits.hide()
self.linkedsplits.unfocus()
# XXX: seems to make switching slower?
# qframe = self.hist_linked.chart.qframe
# if qframe.sidepane is self.search:
# qframe.hbox.removeWidget(self.search)
# 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)
for linked in [self.rt_linked, self.hist_linked]:
# XXX: this is CRITICAL especially with pixel buffer caching
linked.hide()
linked.unfocus()
# XXX: pretty sure we don't need this
# remove any existing plots?
# XXX: ahh we might want to support cache unloading..
# self.vbox.removeWidget(linked)
# switching to a new viewable chart
if linkedsplits is None or reset:
if all_linked is None or reset:
from ._display import display_symbol_data
# we must load a fresh linked charts set
linkedsplits = LinkedSplits(self)
self.rt_linked = rt_charts = LinkedSplits(self)
self.hist_linked = hist_charts = LinkedSplits(self)
# spawn new task to start up and update new sub-chart instances
self._root_n.start_soon(
@ -215,44 +241,70 @@ class GodWidget(QWidget):
order_mode_started,
)
self.set_chart_symbol(fqsn, linkedsplits)
self.vbox.addWidget(linkedsplits)
# self.vbox.addWidget(hist_charts)
self.vbox.addWidget(rt_charts)
self.set_chart_symbol(
fqsn,
(hist_charts, rt_charts),
)
for linked in [hist_charts, rt_charts]:
linked.show()
linked.focus()
linkedsplits.show()
linkedsplits.focus()
await trio.sleep(0)
else:
# symbol is already loaded and ems ready
order_mode_started.set()
# TODO:
# - we'll probably want per-instrument/provider state here?
# change the order config form over to the new chart
self.hist_linked, self.rt_linked = all_linked
# chart is already in memory so just focus it
linkedsplits.show()
linkedsplits.focus()
linkedsplits.graphics_cycle()
for linked in all_linked:
# TODO:
# - we'll probably want per-instrument/provider state here?
# change the order config form over to the new chart
# chart is already in memory so just focus it
linked.show()
linked.focus()
linked.graphics_cycle()
await trio.sleep(0)
# resume feeds *after* rendering chart view asap
chart = linked.chart
if chart:
chart.resume_all_feeds()
# TODO: we need a check to see if the chart
# last had the xlast in view, if so then shift so it's
# still in view, if the user was viewing history then
# do nothing yah?
self.rt_linked.chart.default_view()
# if a history chart instance is already up then
# set the search widget as its sidepane.
hist_chart = self.hist_linked.chart
if hist_chart:
hist_chart.qframe.set_sidepane(self.search)
# NOTE: this is really stupid/hard to follow.
# we have to reposition the active position nav
# **AFTER** applying the search bar as a sidepane
# to the newly switched to symbol.
await trio.sleep(0)
# 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
# TODO: probably stick this in some kinda `LooknFeel` API?
for tracker in self.rt_linked.mode.trackers.values():
pp_nav = tracker.nav
if tracker.live_pp.size:
pp_nav.show()
pp_nav.hide_info()
else:
pp_nav.hide()
# resume feeds *after* rendering chart view asap
if chart:
chart.resume_all_feeds()
# TODO: we need a check to see if the chart
# last had the xlast in view, if so then shift so it's
# still in view, if the user was viewing history then
# do nothing yah?
chart.default_view()
self.linkedsplits = linkedsplits
symbol = linkedsplits.symbol
# set window titlebar info
symbol = self.rt_linked.symbol
if symbol is not None:
self.window.setWindowTitle(
f'{symbol.front_fqsn()} '
@ -269,11 +321,23 @@ class GodWidget(QWidget):
'''
# go back to view-mode focus (aka chart focus)
self.clearFocus()
self.linkedsplits.chart.setFocus()
chart = self.rt_linked.chart
if chart:
chart.setFocus()
def resizeEvent(self, event: QtCore.QEvent) -> None:
def reg_for_resize(
self,
widget: QWidget,
) -> None:
getattr(widget, 'on_resize')
self._widgets[widget.mode_name] = widget
def on_win_resize(self, event: QtCore.QEvent) -> None:
'''
Top level god widget resize handler.
Top level god widget handler from window (the real yaweh) resize
events such that any registered widgets which wish to be
notified are invoked using our pythonic `.on_resize()` method
api.
Where we do UX magic to make things not suck B)
@ -289,6 +353,28 @@ class GodWidget(QWidget):
self._resizing = False
# on_resize = on_win_resize
def get_cursor(self) -> Cursor:
return self._active_cursor
def iter_linked(self) -> Iterator[LinkedSplits]:
for linked in [self.hist_linked, self.rt_linked]:
yield linked
def resize_all(self) -> None:
'''
Dynamic resize sequence: adjusts all sub-widgets/charts to
sensible default ratios of what space is detected as available
on the display / window.
'''
rt_linked = self.rt_linked
rt_linked.set_split_sizes()
self.rt_linked.resize_sidepanes()
self.hist_linked.resize_sidepanes(from_linked=rt_linked)
self.search.on_resize()
class ChartnPane(QFrame):
'''
@ -301,9 +387,9 @@ class ChartnPane(QFrame):
https://doc.qt.io/qt-5/qwidget.html#composite-widgets
'''
sidepane: FieldsForm
sidepane: FieldsForm | SearchWidget
hbox: QHBoxLayout
chart: Optional['ChartPlotWidget'] = None
chart: Optional[ChartPlotWidget] = None
def __init__(
self,
@ -315,7 +401,7 @@ class ChartnPane(QFrame):
super().__init__(parent)
self.sidepane = sidepane
self._sidepane = sidepane
self.chart = None
hbox = self.hbox = QHBoxLayout(self)
@ -323,6 +409,21 @@ class ChartnPane(QFrame):
hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(3)
def set_sidepane(
self,
sidepane: FieldsForm | SearchWidget,
) -> None:
# add sidepane **after** chart; place it on axis side
self.hbox.addWidget(
sidepane,
alignment=Qt.AlignTop
)
self._sidepane = sidepane
def sidepane(self) -> FieldsForm | SearchWidget:
return self._sidepane
class LinkedSplits(QWidget):
'''
@ -357,6 +458,7 @@ class LinkedSplits(QWidget):
self.splitter = QSplitter(QtCore.Qt.Vertical)
self.splitter.setMidLineWidth(0)
self.splitter.setHandleWidth(2)
self.splitter.splitterMoved.connect(self.on_splitter_adjust)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
@ -369,6 +471,16 @@ class LinkedSplits(QWidget):
self._symbol: Symbol = None
def on_splitter_adjust(
self,
pos: int,
index: int,
) -> None:
# print(f'splitter moved pos:{pos}, index:{index}')
godw = self.godwidget
if self is godw.rt_linked:
godw.search.on_resize()
def graphics_cycle(self, **kwargs) -> None:
from . import _display
ds = self.display_state
@ -384,28 +496,32 @@ class LinkedSplits(QWidget):
prop: Optional[float] = None,
) -> None:
'''Set the proportion of space allocated for linked subcharts.
'''
Set the proportion of space allocated for linked subcharts.
'''
ln = len(self.subplots)
ln = len(self.subplots) or 1
# proportion allocated to consumer subcharts
if not prop:
prop = 3/8*5/8
prop = 3/8
# if ln < 2:
# prop = 3/8*5/8
# elif ln >= 2:
# prop = 3/8
h = self.height()
histview_h = h * (6/16)
h = h - histview_h
major = 1 - prop
min_h_ind = int((self.height() * prop) / ln)
min_h_ind = int((h * prop) / ln)
sizes = [
int(histview_h),
int(h * major),
]
sizes = [int(self.height() * major)]
# give all subcharts the same remaining proportional height
sizes.extend([min_h_ind] * ln)
self.splitter.setSizes(sizes)
if self.godwidget.rt_linked is self:
self.splitter.setSizes(sizes)
def focus(self) -> None:
if self.chart is not None:
@ -498,10 +614,15 @@ class LinkedSplits(QWidget):
'bottom': xaxis,
}
qframe = ChartnPane(
sidepane=sidepane,
parent=self.splitter,
)
if sidepane is not False:
parent = qframe = ChartnPane(
sidepane=sidepane,
parent=self.splitter,
)
else:
parent = self.splitter
qframe = None
cpw = ChartPlotWidget(
# this name will be used to register the primary
@ -509,7 +630,7 @@ class LinkedSplits(QWidget):
name=name,
data_key=array_key or name,
parent=qframe,
parent=parent,
linkedsplits=self,
axisItems=axes,
**cpw_kwargs,
@ -537,22 +658,25 @@ class LinkedSplits(QWidget):
self.xaxis_chart = cpw
cpw.showAxis('bottom')
qframe.chart = cpw
qframe.hbox.addWidget(cpw)
if qframe is not None:
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
# 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
# add sidepane **after** chart; place it on axis side
qframe.set_sidepane(sidepane)
# qframe.hbox.addWidget(
# sidepane,
# alignment=Qt.AlignTop
# )
cpw.plotItem.vb.linkedsplits = self
cpw.sidepane = sidepane
cpw.plotItem.vb.linked = self
cpw.setFrameStyle(
QtWidgets.QFrame.StyledPanel
# | QtWidgets.QFrame.Plain
@ -613,9 +737,8 @@ class LinkedSplits(QWidget):
if not _is_main:
# track by name
self.subplots[name] = cpw
self.splitter.addWidget(qframe)
# scale split regions
self.set_split_sizes()
if qframe is not None:
self.splitter.addWidget(qframe)
else:
assert style == 'bar', 'main chart must be OHLC'
@ -641,19 +764,28 @@ class LinkedSplits(QWidget):
def resize_sidepanes(
self,
from_linked: Optional[LinkedSplits] = None,
) -> None:
'''
Size all sidepanes based on the OHLC "main" plot and its
sidepane width.
'''
main_chart = self.chart
if main_chart:
if from_linked:
main_chart = from_linked.chart
else:
main_chart = self.chart
if main_chart and main_chart.sidepane:
sp_w = main_chart.sidepane.width()
for name, cpw in self.subplots.items():
cpw.sidepane.setMinimumWidth(sp_w)
cpw.sidepane.setMaximumWidth(sp_w)
if from_linked:
self.chart.sidepane.setMinimumWidth(sp_w)
class ChartPlotWidget(pg.PlotWidget):
'''
@ -711,6 +843,7 @@ class ChartPlotWidget(pg.PlotWidget):
# NOTE: must be set bfore calling ``.mk_vb()``
self.linked = linkedsplits
self.sidepane: Optional[FieldsForm] = None
# source of our custom interactions
self.cv = cv = self.mk_vb(name)
@ -867,7 +1000,8 @@ class ChartPlotWidget(pg.PlotWidget):
def default_view(
self,
bars_from_y: int = 616,
bars_from_y: int = int(616 * 3/8),
y_offset: int = 0,
do_ds: bool = True,
) -> None:
@ -906,8 +1040,12 @@ class ChartPlotWidget(pg.PlotWidget):
# terms now that we've scaled either by user control
# or to the default set of bars as per the immediate block
# above.
marker_pos, l1_len = self.pre_l1_xs()
end = xlast + l1_len + 1
if not y_offset:
marker_pos, l1_len = self.pre_l1_xs()
end = xlast + l1_len + 1
else:
end = xlast + y_offset + 1
begin = end - (r - l)
# for debugging

View File

@ -18,8 +18,13 @@
Mouse interaction graphics
"""
from __future__ import annotations
from functools import partial
from typing import Optional, Callable
from typing import (
Optional,
Callable,
TYPE_CHECKING,
)
import inspect
import numpy as np
@ -36,6 +41,12 @@ from ._style import (
from ._axes import YAxisLabel, XAxisLabel
from ..log import get_logger
if TYPE_CHECKING:
from ._chart import (
ChartPlotWidget,
LinkedSplits,
)
log = get_logger(__name__)
@ -58,7 +69,7 @@ class LineDot(pg.CurvePoint):
curve: pg.PlotCurveItem,
index: int,
plot: 'ChartPlotWidget', # type: ingore # noqa
plot: ChartPlotWidget, # type: ingore # noqa
pos=None,
color: str = 'default_light',
@ -151,7 +162,7 @@ class ContentsLabel(pg.LabelItem):
def __init__(
self,
# chart: 'ChartPlotWidget', # noqa
# chart: ChartPlotWidget, # noqa
view: pg.ViewBox,
anchor_at: str = ('top', 'right'),
@ -244,7 +255,7 @@ class ContentsLabels:
'''
def __init__(
self,
linkedsplits: 'LinkedSplits', # type: ignore # noqa
linkedsplits: LinkedSplits, # type: ignore # noqa
) -> None:
@ -289,7 +300,7 @@ class ContentsLabels:
def add_label(
self,
chart: 'ChartPlotWidget', # type: ignore # noqa
chart: ChartPlotWidget, # type: ignore # noqa
name: str,
anchor_at: tuple[str, str] = ('top', 'left'),
update_func: Callable = ContentsLabel.update_from_value,
@ -316,7 +327,7 @@ class Cursor(pg.GraphicsObject):
def __init__(
self,
linkedsplits: 'LinkedSplits', # noqa
linkedsplits: LinkedSplits, # noqa
digits: int = 0
) -> None:
@ -325,6 +336,8 @@ class Cursor(pg.GraphicsObject):
self.linked = linkedsplits
self.graphics: dict[str, pg.GraphicsObject] = {}
self.xaxis_label: Optional[XAxisLabel] = None
self.always_show_xlabel: bool = True
self.plots: list['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None
self.digits: int = digits
@ -385,7 +398,7 @@ class Cursor(pg.GraphicsObject):
def add_plot(
self,
plot: 'ChartPlotWidget', # noqa
plot: ChartPlotWidget, # noqa
digits: int = 0,
) -> None:
@ -469,7 +482,7 @@ class Cursor(pg.GraphicsObject):
def add_curve_cursor(
self,
plot: 'ChartPlotWidget', # noqa
plot: ChartPlotWidget, # noqa
curve: 'PlotCurveItem', # noqa
) -> LineDot:
@ -491,17 +504,29 @@ class Cursor(pg.GraphicsObject):
log.debug(f"{(action, plot.name)}")
if action == 'Enter':
self.active_plot = plot
plot.linked.godwidget._active_cursor = self
# show horiz line and y-label
self.graphics[plot]['hl'].show()
self.graphics[plot]['yl'].show()
else: # Leave
if (
not self.always_show_xlabel
and not self.xaxis_label.isVisible()
):
self.xaxis_label.show()
# hide horiz line and y-label
# Leave: hide horiz line and y-label
else:
self.graphics[plot]['hl'].hide()
self.graphics[plot]['yl'].hide()
if (
not self.always_show_xlabel
and self.xaxis_label.isVisible()
):
self.xaxis_label.hide()
def mouseMoved(
self,
coords: tuple[QPointF], # noqa
@ -590,13 +615,17 @@ class Cursor(pg.GraphicsObject):
left_axis_width += left.width()
# map back to abs (label-local) coordinates
self.xaxis_label.update_label(
abs_pos=(
plot.mapFromView(QPointF(vl_x, iy)) -
QPointF(left_axis_width, 0)
),
value=ix,
)
if (
self.always_show_xlabel
or self.xaxis_label.isVisible()
):
self.xaxis_label.update_label(
abs_pos=(
plot.mapFromView(QPointF(vl_x, iy)) -
QPointF(left_axis_width, 0)
),
value=ix,
)
self._datum_xy = ix, iy

View File

@ -21,19 +21,20 @@ this module ties together quote and computational (fsp) streams with
graphics update methods via our custom ``pyqtgraph`` charting api.
'''
from dataclasses import dataclass
from functools import partial
import time
from typing import Optional, Any, Callable
import numpy as np
import tractor
import trio
import pendulum
import pyqtgraph as pg
# from .. import brokers
from ..data.feed import open_feed
from ..data.feed import (
open_feed,
Feed,
)
from ..data.types import Struct
from ._axes import YAxisLabel
from ._chart import (
ChartPlotWidget,
@ -41,6 +42,7 @@ from ._chart import (
GodWidget,
)
from ._l1 import L1Labels
from ._style import hcolor
from ._fsp import (
update_fsp_chart,
start_fsp_displays,
@ -53,7 +55,10 @@ from ._forms import (
FieldsForm,
mk_order_pane_layout,
)
from .order_mode import open_order_mode
from .order_mode import (
open_order_mode,
OrderMode,
)
from .._profile import (
pg_profile_enabled,
ms_slower_then,
@ -63,7 +68,7 @@ from ..log import get_logger
log = get_logger(__name__)
# TODO: load this from a config.toml!
_quote_throttle_rate: int = 22 # Hz
_quote_throttle_rate: int = 16 # Hz
# a working tick-type-classes template
@ -122,39 +127,105 @@ def chart_maxmin(
)
@dataclass
class DisplayState:
class DisplayState(Struct):
'''
Chart-local real-time graphics state container.
'''
godwidget: GodWidget
quotes: dict[str, Any]
maxmin: Callable
ohlcv: ShmArray
hist_ohlcv: ShmArray
# high level chart handles
linked: LinkedSplits
chart: ChartPlotWidget
# axis labels
l1: L1Labels
last_price_sticky: YAxisLabel
hist_last_price_sticky: YAxisLabel
# misc state tracking
vars: dict[str, Any]
vars: dict[str, Any] = {
'tick_margin': 0,
'i_last': 0,
'i_last_append': 0,
'last_mx_vlm': 0,
'last_mx': 0,
'last_mn': 0,
}
vlm_chart: Optional[ChartPlotWidget] = None
vlm_sticky: Optional[YAxisLabel] = None
wap_in_history: bool = False
def incr_info(
self,
chart: Optional[ChartPlotWidget] = None,
shm: Optional[ShmArray] = None,
state: Optional[dict] = None, # pass in a copy if you don't
update_state: bool = True,
update_uppx: float = 16,
) -> tuple:
shm = shm or self.ohlcv
chart = chart or self.chart
state = state or self.vars
if not update_state:
state = state.copy()
# compute the first available graphic's x-units-per-pixel
uppx = chart.view.x_uppx()
# NOTE: this used to be implemented in a dedicated
# "increment task": ``check_for_new_bars()`` but it doesn't
# make sense to do a whole task switch when we can just do
# this simple index-diff and all the fsp sub-curve graphics
# are diffed on each draw cycle anyway; so updates to the
# "curve" length is already automatic.
# increment the view position by the sample offset.
i_step = shm.index
i_diff = i_step - state['i_last']
state['i_last'] = i_step
append_diff = i_step - state['i_last_append']
# update the "last datum" (aka extending the flow graphic with
# new data) only if the number of unit steps is >= the number of
# such unit steps per pixel (aka uppx). Iow, if the zoom level
# is such that a datum(s) update to graphics wouldn't span
# to a new pixel, we don't update yet.
do_append = (append_diff >= uppx)
if do_append:
state['i_last_append'] = i_step
do_rt_update = uppx < update_uppx
_, _, _, r = chart.bars_range()
liv = r >= i_step
# TODO: pack this into a struct
return (
uppx,
liv,
do_append,
i_diff,
append_diff,
do_rt_update,
)
async def graphics_update_loop(
linked: LinkedSplits,
stream: tractor.MsgStream,
ohlcv: np.ndarray,
nurse: trio.Nursery,
godwidget: GodWidget,
feed: Feed,
wap_in_history: bool = False,
vlm_chart: Optional[ChartPlotWidget] = None,
@ -175,9 +246,14 @@ async def graphics_update_loop(
# of copying it from last bar's close
# - 1-5 sec bar lookback-autocorrection like tws does?
# (would require a background history checker task)
display_rate = linked.godwidget.window.current_screen().refreshRate()
linked: LinkedSplits = godwidget.rt_linked
display_rate = godwidget.window.current_screen().refreshRate()
chart = linked.chart
hist_chart = godwidget.hist_linked.chart
ohlcv = feed.rt_shm
hist_ohlcv = feed.hist_shm
# update last price sticky
last_price_sticky = chart._ysticks[chart.name]
@ -185,6 +261,11 @@ async def graphics_update_loop(
*ohlcv.array[-1][['index', 'close']]
)
hist_last_price_sticky = hist_chart._ysticks[hist_chart.name]
hist_last_price_sticky.update_from_data(
*hist_ohlcv.array[-1][['index', 'close']]
)
maxmin = partial(
chart_maxmin,
chart,
@ -227,12 +308,14 @@ async def graphics_update_loop(
i_last = ohlcv.index
ds = linked.display_state = DisplayState(**{
'godwidget': godwidget,
'quotes': {},
'linked': linked,
'maxmin': maxmin,
'ohlcv': ohlcv,
'hist_ohlcv': hist_ohlcv,
'chart': chart,
'last_price_sticky': last_price_sticky,
'hist_last_price_sticky': hist_last_price_sticky,
'l1': l1,
'vars': {
@ -252,7 +335,62 @@ async def graphics_update_loop(
chart.default_view()
# TODO: probably factor this into some kinda `DisplayState`
# API that can be reused at least in terms of pulling view
# params (eg ``.bars_range()``).
async def increment_history_view():
i_last = hist_ohlcv.index
state = ds.vars.copy() | {
'i_last_append': i_last,
'i_last': i_last,
}
_, hist_step_size_s, _ = feed.get_ds_info()
async with feed.index_stream(
# int(hist_step_size_s)
# TODO: seems this is more reliable at keeping the slow
# chart incremented in view more correctly?
# - It might make sense to just inline this logic with the
# main display task? => it's a tradeoff of slower task
# wakeups/ctx switches verus logic checks (as normal)
# - we need increment logic that only does the view shift
# call when the uppx permits/needs it
int(1),
) as istream:
async for msg in istream:
# check if slow chart needs an x-domain shift and/or
# y-range resize.
(
uppx,
liv,
do_append,
i_diff,
append_diff,
do_rt_update,
) = ds.incr_info(
chart=hist_chart,
shm=ds.hist_ohlcv,
state=state,
# update_state=False,
)
# print(
# f'liv: {liv}\n'
# f'do_append: {do_append}\n'
# f'append_diff: {append_diff}\n'
# )
if (
do_append
and liv
):
hist_chart.increment_view(steps=i_diff)
hist_chart.view._set_yrange(yrange=hist_chart.maxmin())
nurse.start_soon(increment_history_view)
# main real-time quotes update loop
stream: tractor.MsgStream = feed.stream
async for quotes in stream:
ds.quotes = quotes
@ -273,7 +411,7 @@ async def graphics_update_loop(
# chart isn't active/shown so skip render cycle and pause feed(s)
if chart.linked.isHidden():
print('skipping update')
# print('skipping update')
chart.pause_all_feeds()
continue
@ -298,6 +436,8 @@ def graphics_update_cycle(
# hopefully XD
chart = ds.chart
# TODO: just pass this as a direct ref to avoid so many attr accesses?
hist_chart = ds.godwidget.hist_linked.chart
profiler = pg.debug.Profiler(
msg=f'Graphics loop cycle for: `{chart.name}`',
@ -311,53 +451,24 @@ def graphics_update_cycle(
# unpack multi-referenced components
vlm_chart = ds.vlm_chart
# rt "HFT" chart
l1 = ds.l1
ohlcv = ds.ohlcv
array = ohlcv.array
vars = ds.vars
tick_margin = vars['tick_margin']
update_uppx = 16
for sym, quote in ds.quotes.items():
# compute the first available graphic's x-units-per-pixel
uppx = chart.view.x_uppx()
# NOTE: vlm may be written by the ``brokerd`` backend
# event though a tick sample is not emitted.
# TODO: show dark trades differently
# https://github.com/pikers/piker/issues/116
# NOTE: this used to be implemented in a dedicated
# "increment task": ``check_for_new_bars()`` but it doesn't
# make sense to do a whole task switch when we can just do
# this simple index-diff and all the fsp sub-curve graphics
# are diffed on each draw cycle anyway; so updates to the
# "curve" length is already automatic.
# increment the view position by the sample offset.
i_step = ohlcv.index
i_diff = i_step - vars['i_last']
vars['i_last'] = i_step
append_diff = i_step - vars['i_last_append']
# update the "last datum" (aka extending the flow graphic with
# new data) only if the number of unit steps is >= the number of
# such unit steps per pixel (aka uppx). Iow, if the zoom level
# is such that a datum(s) update to graphics wouldn't span
# to a new pixel, we don't update yet.
do_append = (append_diff >= uppx)
if do_append:
vars['i_last_append'] = i_step
do_rt_update = uppx < update_uppx
# print(
# f'append_diff:{append_diff}\n'
# f'uppx:{uppx}\n'
# f'do_append: {do_append}'
# )
(
uppx,
liv,
do_append,
i_diff,
append_diff,
do_rt_update,
) = ds.incr_info()
# TODO: we should only run mxmn when we know
# an update is due via ``do_append`` above.
@ -373,8 +484,6 @@ def graphics_update_cycle(
profiler('`ds.maxmin()` call')
liv = r >= i_step # the last datum is in view
if (
prepend_update_index is not None
and lbar > prepend_update_index
@ -389,16 +498,10 @@ def graphics_update_cycle(
# don't real-time "shift" the curve to the
# left unless we get one of the following:
if (
(
# i_diff > 0 # no new sample step
do_append
# and uppx < 4 # chart is zoomed out very far
and liv
)
(do_append and liv)
or trigger_all
):
chart.increment_view(steps=i_diff)
# chart.increment_view(steps=i_diff + round(append_diff - uppx))
if vlm_chart:
vlm_chart.increment_view(steps=i_diff)
@ -458,6 +561,10 @@ def graphics_update_cycle(
chart.name,
do_append=do_append,
)
hist_chart.update_graphics_from_flow(
chart.name,
do_append=do_append,
)
# NOTE: we always update the "last" datum
# since the current range should at least be updated
@ -495,6 +602,9 @@ def graphics_update_cycle(
ds.last_price_sticky.update_from_data(
*end[['index', 'close']]
)
ds.hist_last_price_sticky.update_from_data(
*end[['index', 'close']]
)
if wap_in_history:
# update vwap overlay line
@ -542,26 +652,44 @@ def graphics_update_cycle(
l1.bid_label.update_fields({'level': price, 'size': size})
# check for y-range re-size
if (
(mx > vars['last_mx']) or (mn < vars['last_mn'])
and not chart._static_yrange == 'axis'
and liv
):
main_vb = chart.view
if (mx > vars['last_mx']) or (mn < vars['last_mn']):
# fast chart resize case
if (
main_vb._ic is None
or not main_vb._ic.is_set()
liv
and not chart._static_yrange == 'axis'
):
# print(f'updating range due to mxmn')
main_vb._set_yrange(
# TODO: we should probably scale
# the view margin based on the size
# of the true range? This way you can
# slap in orders outside the current
# L1 (only) book range.
# range_margin=0.1,
yrange=(mn, mx),
)
main_vb = chart.view
if (
main_vb._ic is None
or not main_vb._ic.is_set()
):
# print(f'updating range due to mxmn')
main_vb._set_yrange(
# TODO: we should probably scale
# the view margin based on the size
# of the true range? This way you can
# slap in orders outside the current
# L1 (only) book range.
# range_margin=0.1,
yrange=(mn, mx),
)
# check if slow chart needs a resize
(
_,
hist_liv,
_,
_,
_,
_,
) = ds.incr_info(
chart=hist_chart,
shm=ds.hist_ohlcv,
update_state=False,
)
if hist_liv:
hist_chart.view._set_yrange(yrange=hist_chart.maxmin())
# XXX: update this every draw cycle to make L1-always-in-view work.
vars['last_mx'], vars['last_mn'] = mx, mn
@ -719,15 +847,17 @@ async def display_symbol_data(
tick_throttle=_quote_throttle_rate,
) as feed:
ohlcv: ShmArray = feed.shm
bars = ohlcv.array
ohlcv: ShmArray = feed.rt_shm
hist_ohlcv: ShmArray = feed.hist_shm
# this value needs to be pulled once and only once during
# startup
end_index = feed.startup_hist_index
symbol = feed.symbols[sym]
fqsn = symbol.front_fqsn()
times = bars['time']
end = pendulum.from_timestamp(times[-1])
start = pendulum.from_timestamp(times[times != times[-1]][-1])
step_size_s = (end - start).seconds
step_size_s = 1
tf_key = tf_in_1s[step_size_s]
# load in symbol's ohlc data
@ -737,51 +867,158 @@ async def display_symbol_data(
f'step:{tf_key} '
)
linked = godwidget.linkedsplits
linked._symbol = symbol
rt_linked = godwidget.rt_linked
rt_linked._symbol = symbol
# create top history view chart above the "main rt chart".
hist_linked = godwidget.hist_linked
hist_linked._symbol = symbol
hist_chart = hist_linked.plot_ohlc_main(
symbol,
feed.hist_shm,
# in the case of history chart we explicitly set `False`
# to avoid internal pane creation.
# sidepane=False,
sidepane=godwidget.search,
)
# don't show when not focussed
hist_linked.cursor.always_show_xlabel = False
# generate order mode side-pane UI
# A ``FieldsForm`` form to configure order entry
# and add as next-to-y-axis singleton pane
pp_pane: FieldsForm = mk_order_pane_layout(godwidget)
# add as next-to-y-axis singleton pane
godwidget.pp_pane = pp_pane
# create main OHLC chart
chart = linked.plot_ohlc_main(
chart = rt_linked.plot_ohlc_main(
symbol,
ohlcv,
# in the case of history chart we explicitly set `False`
# to avoid internal pane creation.
sidepane=pp_pane,
)
chart.default_view()
chart._feeds[symbol.key] = feed
chart.setFocus()
# XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?!
# plot historical vwap if available
wap_in_history = False
# if (
# brokermod._show_wap_in_history
# and 'bar_wap' in bars.dtype.fields
# ):
# wap_in_history = True
# chart.draw_curve(
# name='bar_wap',
# shm=ohlcv,
# color='default_light',
# add_label=False,
# )
# XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?!
# if brokermod._show_wap_in_history:
# Add the LinearRegionItem to the ViewBox, but tell the ViewBox
# to exclude this item when doing auto-range calculations.
rt_pi = chart.plotItem
hist_pi = hist_chart.plotItem
region = pg.LinearRegionItem(
# color scheme that matches sidepane styling
pen=pg.mkPen(hcolor('gunmetal')),
brush=pg.mkBrush(hcolor('default_darkest')),
)
region.setZValue(10) # put linear region "in front" in layer terms
hist_pi.addItem(region, ignoreBounds=True)
flow = chart._flows[hist_chart.name]
assert flow
# XXX: no idea why this doesn't work but it's causing
# a weird placement of the region on the way-far-left..
# region.setClipItem(flow.graphics)
# if 'bar_wap' in bars.dtype.fields:
# wap_in_history = True
# chart.draw_curve(
# name='bar_wap',
# shm=ohlcv,
# color='default_light',
# add_label=False,
# )
# poll for datums load and timestep detection
for _ in range(100):
try:
_, _, ratio = feed.get_ds_info()
break
except IndexError:
await trio.sleep(0.01)
continue
else:
raise RuntimeError(
'Failed to detect sampling periods from shm!?')
# size view to data once at outset
chart.cv._set_yrange()
def update_pi_from_region():
region.setZValue(10)
mn, mx = region.getRegion()
# print(f'region_x: {(mn, mx)}')
# XXX: seems to cause a real perf hit?
rt_pi.setXRange(
(mn - end_index) * ratio,
(mx - end_index) * ratio,
padding=0,
)
region.sigRegionChanged.connect(update_pi_from_region)
def update_region_from_pi(
window,
viewRange: tuple[tuple, tuple],
is_manual: bool = True,
) -> None:
# set the region on the history chart
# to the range currently viewed in the
# HFT/real-time chart.
mn, mx = viewRange[0]
ds_mn = mn/ratio
ds_mx = mx/ratio
# print(
# f'rt_view_range: {(mn, mx)}\n'
# f'ds_mn, ds_mx: {(ds_mn, ds_mx)}\n'
# )
lhmn = ds_mn + end_index
lhmx = ds_mx + end_index
region.setRegion((
lhmn,
lhmx,
))
# TODO: if we want to have the slow chart adjust range to
# match the fast chart's selection -> results in the
# linear region expansion never can go "outside of view".
# hmn, hmx = hvr = hist_chart.view.state['viewRange'][0]
# print((hmn, hmx))
# if (
# hvr
# and (lhmn < hmn or lhmx > hmx)
# ):
# hist_pi.setXRange(
# lhmn,
# lhmx,
# padding=0,
# )
# hist_linked.graphics_cycle()
# connect region to be updated on plotitem interaction.
rt_pi.sigRangeChanged.connect(update_region_from_pi)
# NOTE: we must immediately tell Qt to show the OHLC chart
# to avoid a race where the subplots get added/shown to
# the linked set *before* the main price chart!
linked.show()
linked.focus()
rt_linked.show()
rt_linked.focus()
await trio.sleep(0)
# NOTE: here we insert the slow-history chart set into
# the fast chart's splitter -> so it's a splitter of charts
# inside the first widget slot of a splitter of charts XD
rt_linked.splitter.insertWidget(0, hist_linked)
# XXX: if we wanted it at the bottom?
# rt_linked.splitter.addWidget(hist_linked)
rt_linked.focus()
godwidget.resize_all()
vlm_chart: Optional[ChartPlotWidget] = None
async with trio.open_nursery() as ln:
@ -792,7 +1029,7 @@ async def display_symbol_data(
):
vlm_chart = await ln.start(
open_vlm_displays,
linked,
rt_linked,
ohlcv,
)
@ -800,7 +1037,7 @@ async def display_symbol_data(
# from an input config.
ln.start_soon(
start_fsp_displays,
linked,
rt_linked,
ohlcv,
loading_sym_key,
loglevel,
@ -809,39 +1046,73 @@ async def display_symbol_data(
# start graphics update loop after receiving first live quote
ln.start_soon(
graphics_update_loop,
linked,
feed.stream,
ohlcv,
ln,
godwidget,
feed,
wap_in_history,
vlm_chart,
)
await trio.sleep(0)
# size view to data prior to order mode init
chart.default_view()
rt_linked.graphics_cycle()
await trio.sleep(0)
hist_chart.default_view(
bars_from_y=int(len(hist_ohlcv.array)), # size to data
y_offset=6116*2, # push it a little away from the y-axis
)
hist_linked.graphics_cycle()
await trio.sleep(0)
godwidget.resize_all()
mode: OrderMode
async with (
open_order_mode(
feed,
chart,
godwidget,
fqsn,
order_mode_started
)
) as mode
):
if not vlm_chart:
# trigger another view reset if no sub-chart
chart.default_view()
rt_linked.mode = mode
# let Qt run to render all widgets and make sure the
# sidepanes line up vertically.
await trio.sleep(0)
linked.resize_sidepanes()
# dynamic resize steps
godwidget.resize_all()
# TODO: look into this because not sure why it was
# commented out / we ever needed it XD
# NOTE: we pop the volume chart from the subplots set so
# that it isn't double rendered in the display loop
# above since we do a maxmin calc on the volume data to
# determine if auto-range adjustements should be made.
# linked.subplots.pop('volume', None)
# rt_linked.subplots.pop('volume', None)
# TODO: make this not so shit XD
# close group status
sbar._status_groups[loading_sym_key][1]()
hist_linked.graphics_cycle()
await trio.sleep(0)
bars_in_mem = int(len(hist_ohlcv.array))
hist_chart.default_view(
bars_from_y=bars_in_mem, # size to data
# push it 1/16th away from the y-axis
y_offset=round(bars_in_mem / 16),
)
godwidget.resize_all()
# let the app run.. bby
# linked.graphics_cycle()
await trio.sleep_forever()

View File

@ -18,8 +18,12 @@
Higher level annotation editors.
"""
from dataclasses import dataclass, field
from typing import Optional
from __future__ import annotations
from collections import defaultdict
from typing import (
Optional,
TYPE_CHECKING
)
import pyqtgraph as pg
from pyqtgraph import ViewBox, Point, QtCore, QtGui
@ -30,28 +34,34 @@ import numpy as np
from ._style import hcolor, _font
from ._lines import LevelLine
from ..log import get_logger
from ..data.types import Struct
if TYPE_CHECKING:
from ._chart import GodWidget
log = get_logger(__name__)
@dataclass
class ArrowEditor:
class ArrowEditor(Struct):
chart: 'ChartPlotWidget' # noqa
_arrows: field(default_factory=dict)
godw: GodWidget = None # type: ignore # noqa
_arrows: dict[str, list[pg.ArrowItem]] = {}
def add(
self,
plot: pg.PlotItem,
uid: str,
x: float,
y: float,
color='default',
pointing: Optional[str] = None,
) -> pg.ArrowItem:
"""Add an arrow graphic to view at given (x, y).
"""
) -> pg.ArrowItem:
'''
Add an arrow graphic to view at given (x, y).
'''
angle = {
'up': 90,
'down': -90,
@ -74,25 +84,25 @@ class ArrowEditor:
brush=pg.mkBrush(hcolor(color)),
)
arrow.setPos(x, y)
self._arrows[uid] = arrow
self._arrows.setdefault(uid, []).append(arrow)
# render to view
self.chart.plotItem.addItem(arrow)
plot.addItem(arrow)
return arrow
def remove(self, arrow) -> bool:
self.chart.plotItem.removeItem(arrow)
for linked in self.godw.iter_linked():
linked.chart.plotItem.removeItem(arrow)
@dataclass
class LineEditor:
'''The great editor of linez.
class LineEditor(Struct):
'''
The great editor of linez.
'''
chart: 'ChartPlotWidget' = None # type: ignore # noqa
_order_lines: dict[str, LevelLine] = field(default_factory=dict)
godw: GodWidget = None # type: ignore # noqa
_order_lines: defaultdict[str, LevelLine] = defaultdict(list)
_active_staged_line: LevelLine = None
def stage_line(
@ -100,11 +110,11 @@ class LineEditor:
line: LevelLine,
) -> LevelLine:
"""Stage a line at the current chart's cursor position
'''
Stage a line at the current chart's cursor position
and return it.
"""
'''
# add a "staged" cursor-tracking line to view
# and cash it in a a var
if self._active_staged_line:
@ -115,17 +125,25 @@ class LineEditor:
return line
def unstage_line(self) -> LevelLine:
"""Inverse of ``.stage_line()``.
'''
Inverse of ``.stage_line()``.
"""
# chart = self.chart._cursor.active_plot
# # chart.setCursor(QtCore.Qt.ArrowCursor)
cursor = self.chart.linked.cursor
'''
cursor = self.godw.get_cursor()
if not cursor:
return None
# delete "staged" cursor tracking line from view
line = self._active_staged_line
if line:
cursor._trackers.remove(line)
try:
cursor._trackers.remove(line)
except KeyError:
# when the current cursor doesn't have said line
# registered (probably means that user held order mode
# key while panning to another view) then we just
# ignore the remove error.
pass
line.delete()
self._active_staged_line = None
@ -133,9 +151,9 @@ class LineEditor:
# show the crosshair y line and label
cursor.show_xhair()
def submit_line(
def submit_lines(
self,
line: LevelLine,
lines: list[LevelLine],
uuid: str,
) -> LevelLine:
@ -145,43 +163,46 @@ class LineEditor:
# raise RuntimeError("No line is currently staged!?")
# for now, until submission reponse arrives
line.hide_labels()
for line in lines:
line.hide_labels()
# register for later lookup/deletion
self._order_lines[uuid] = line
self._order_lines[uuid] += lines
return line
return lines
def commit_line(self, uuid: str) -> LevelLine:
"""Commit a "staged line" to view.
def commit_line(self, uuid: str) -> list[LevelLine]:
'''
Commit a "staged line" to view.
Submits the line graphic under the cursor as a (new) permanent
graphic in view.
"""
try:
line = self._order_lines[uuid]
except KeyError:
log.warning(f'No line for {uuid} could be found?')
return
else:
line.show_labels()
'''
lines = self._order_lines[uuid]
if lines:
for line in lines:
line.show_labels()
line.hide_markers()
log.debug(f'Level active for level: {line.value()}')
# TODO: other flashy things to indicate the order is active
# TODO: other flashy things to indicate the order is active
log.debug(f'Level active for level: {line.value()}')
return line
return lines
def lines_under_cursor(self) -> list[LevelLine]:
"""Get the line(s) under the cursor position.
'''
Get the line(s) under the cursor position.
"""
'''
# Delete any hoverable under the cursor
return self.chart.linked.cursor._hovered
return self.godw.get_cursor()._hovered
def all_lines(self) -> tuple[LevelLine]:
return tuple(self._order_lines.values())
def all_lines(self) -> list[LevelLine]:
all_lines = []
for lines in list(self._order_lines.values()):
all_lines.extend(lines)
return all_lines
def remove_line(
self,
@ -196,26 +217,27 @@ class LineEditor:
'''
# try to look up line from our registry
line = self._order_lines.pop(uuid, line)
if line:
lines = self._order_lines.pop(uuid, None)
if lines:
cursor = self.godw.get_cursor()
if cursor:
for line in lines:
# if hovered remove from cursor set
hovered = cursor._hovered
if line in hovered:
hovered.remove(line)
# if hovered remove from cursor set
cursor = self.chart.linked.cursor
hovered = cursor._hovered
if line in hovered:
hovered.remove(line)
log.debug(f'deleting {line} with oid: {uuid}')
line.delete()
# make sure the xhair doesn't get left off
# just because we never got a un-hover event
cursor.show_xhair()
log.debug(f'deleting {line} with oid: {uuid}')
line.delete()
# make sure the xhair doesn't get left off
# just because we never got a un-hover event
cursor.show_xhair()
else:
log.warning(f'Could not find line for {line}')
return line
return lines
class SelectRect(QtGui.QGraphicsRectItem):

View File

@ -18,10 +18,11 @@
Qt event proxying and processing using ``trio`` mem chans.
"""
from contextlib import asynccontextmanager, AsyncExitStack
from contextlib import asynccontextmanager as acm
from typing import Callable
import trio
from tractor.trionics import gather_contexts
from PyQt5 import QtCore
from PyQt5.QtCore import QEvent, pyqtBoundSignal
from PyQt5.QtWidgets import QWidget
@ -155,7 +156,7 @@ class EventRelay(QtCore.QObject):
return False
@asynccontextmanager
@acm
async def open_event_stream(
source_widget: QWidget,
@ -181,7 +182,7 @@ async def open_event_stream(
source_widget.removeEventFilter(kc)
@asynccontextmanager
@acm
async def open_signal_handler(
signal: pyqtBoundSignal,
@ -206,7 +207,7 @@ async def open_signal_handler(
yield
@asynccontextmanager
@acm
async def open_handlers(
source_widgets: list[QWidget],
@ -215,16 +216,14 @@ async def open_handlers(
**kwargs,
) -> None:
async with (
trio.open_nursery() as n,
AsyncExitStack() as stack,
gather_contexts([
open_event_stream(widget, event_types, **kwargs)
for widget in source_widgets
]) as streams,
):
for widget in source_widgets:
event_recv_stream = await stack.enter_async_context(
open_event_stream(widget, event_types, **kwargs)
)
for widget, event_recv_stream in zip(source_widgets, streams):
n.start_soon(async_handler, widget, event_recv_stream)
yield

View File

@ -20,13 +20,16 @@ Trio - Qt integration
Run ``trio`` in guest mode on top of the Qt event loop.
All global Qt runtime settings are mostly defined here.
"""
from typing import Tuple, Callable, Dict, Any
from typing import (
Callable,
Any,
Type,
)
import platform
import traceback
# Qt specific
import PyQt5 # noqa
import pyqtgraph as pg
from pyqtgraph import QtGui
from PyQt5 import QtCore
# from PyQt5.QtGui import QLabel, QStatusBar
@ -37,7 +40,7 @@ from PyQt5.QtCore import (
)
import qdarkstyle
from qdarkstyle import DarkPalette
# import qdarkgraystyle
# import qdarkgraystyle # TODO: play with it
import trio
from outcome import Error
@ -72,10 +75,11 @@ if platform.system() == "Windows":
def run_qtractor(
func: Callable,
args: Tuple,
main_widget: QtGui.QWidget,
tractor_kwargs: Dict[str, Any] = {},
args: tuple,
main_widget_type: Type[QtGui.QWidget],
tractor_kwargs: dict[str, Any] = {},
window_type: QtGui.QMainWindow = None,
) -> None:
# avoids annoying message when entering debugger from qt loop
pyqtRemoveInputHook()
@ -156,7 +160,7 @@ def run_qtractor(
# hook into app focus change events
app.focusChanged.connect(window.on_focus_change)
instance = main_widget()
instance = main_widget_type()
instance.window = window
# override tractor's defaults
@ -178,7 +182,7 @@ def run_qtractor(
# restrict_keyboard_interrupt_to_checkpoints=True,
)
window.main_widget = main_widget
window.godwidget: GodWidget = instance
window.setCentralWidget(instance)
if is_windows:
window.configure_to_desktop()

View File

@ -644,7 +644,7 @@ def mk_fill_status_bar(
# TODO: calc this height from the ``ChartnPane``
chart_h = round(parent_pane.height() * 5/8)
bar_h = chart_h * 0.375
bar_h = chart_h * 0.375*0.9
# TODO: once things are sized to screen
bar_label_font_size = label_font_size or _font.px_size - 2

View File

@ -141,13 +141,16 @@ async def handle_viewmode_kb_inputs(
Qt.Key_Space,
}
):
view._chart.linked.godwidget.search.focus()
godw = view._chart.linked.godwidget
godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked)
godw.search.focus()
# esc and ctrl-c
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
# ctrl-c as cancel
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
view.select_box.clear()
view.linked.focus()
# cancel order or clear graphics
if key == Qt.Key_C or key == Qt.Key_Delete:
@ -178,17 +181,17 @@ async def handle_viewmode_kb_inputs(
if key in pressed:
pressed.remove(key)
# QUERY/QUOTE MODE #
# QUERY/QUOTE MODE
# ----------------
if {Qt.Key_Q}.intersection(pressed):
view.linkedsplits.cursor.in_query_mode = True
view.linked.cursor.in_query_mode = True
else:
view.linkedsplits.cursor.in_query_mode = False
view.linked.cursor.in_query_mode = False
# SELECTION MODE
# --------------
if shift:
if view.state['mouseMode'] == ViewBox.PanMode:
view.setMouseMode(ViewBox.RectMode)
@ -209,14 +212,22 @@ async def handle_viewmode_kb_inputs(
# ORDER MODE
# ----------
# live vs. dark trigger + an action {buy, sell, alert}
order_keys_pressed = ORDER_MODE.intersection(pressed)
if order_keys_pressed:
# show the pp size label
order_mode.current_pp.show()
# TODO: it seems like maybe the composition should be
# reversed here? Like, maybe we should have the nav have
# access to the pos state and then make encapsulated logic
# that shows the right stuff on screen instead or order mode
# and position-related abstractions doing this?
# show the pp size label only if there is
# a non-zero pos existing
tracker = order_mode.current_pp
if tracker.live_pp.size:
tracker.nav.show()
# TODO: show pp config mini-params in status bar widget
# mode.pp_config.show()
@ -257,8 +268,8 @@ async def handle_viewmode_kb_inputs(
Qt.Key_S in pressed or
order_keys_pressed or
Qt.Key_O in pressed
) and
key in NUMBER_LINE
)
and key in NUMBER_LINE
):
# hot key to set order slots size.
# change edit field to current number line value,
@ -276,7 +287,7 @@ async def handle_viewmode_kb_inputs(
else: # none active
# hide pp label
order_mode.current_pp.hide_info()
order_mode.current_pp.nav.hide_info()
# if none are pressed, remove "staged" level
# line under cursor position
@ -373,7 +384,7 @@ class ChartView(ViewBox):
y=True,
)
self.linkedsplits = None
self.linked = None
self._chart: 'ChartPlotWidget' = None # noqa
# add our selection box annotator
@ -484,7 +495,7 @@ class ChartView(ViewBox):
else:
mask = self.state['mouseEnabled'][:]
chart = self.linkedsplits.chart
chart = self.linked.chart
# don't zoom more then the min points setting
l, lbar, rbar, r = chart.bars_range()
@ -919,7 +930,7 @@ class ChartView(ViewBox):
# TODO: a faster single-loop-iterator way of doing this XD
chart = self._chart
linked = self.linkedsplits
linked = self.linked
plots = linked.subplots | {chart.name: chart}
for chart_name, chart in plots.items():
for name, flow in chart._flows.items():

View File

@ -18,9 +18,14 @@
Lines for orders, alerts, L2.
"""
from __future__ import annotations
from functools import partial
from math import floor
from typing import Optional, Callable
from typing import (
Optional,
Callable,
TYPE_CHECKING,
)
import pyqtgraph as pg
from pyqtgraph import Point, functions as fn
@ -37,6 +42,9 @@ from ..calc import humanize
from ._label import Label
from ._style import hcolor, _font
if TYPE_CHECKING:
from ._cursor import Cursor
# TODO: probably worth investigating if we can
# make .boundingRect() faster:
@ -84,7 +92,7 @@ class LevelLine(pg.InfiniteLine):
self._marker = None
self.only_show_markers_on_hover = only_show_markers_on_hover
self.show_markers: bool = True # presuming the line is hovered at init
self.track_marker_pos: bool = False
# should line go all the way to far end or leave a "margin"
# space for other graphics (eg. L1 book)
@ -122,6 +130,9 @@ class LevelLine(pg.InfiniteLine):
self._y_incr_mult = 1 / chart.linked.symbol.tick_size
self._right_end_sc: float = 0
# use px caching
self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
def txt_offsets(self) -> tuple[int, int]:
return 0, 0
@ -216,20 +227,23 @@ class LevelLine(pg.InfiniteLine):
y: float
) -> None:
'''Chart coordinates cursor tracking callback.
'''
Chart coordinates cursor tracking callback.
this is called by our ``Cursor`` type once this line is set to
track the cursor: for every movement this callback is invoked to
reposition the line with the current view coordinates.
'''
self.movable = True
self.set_level(y) # implictly calls reposition handler
def mouseDragEvent(self, ev):
"""Override the ``InfiniteLine`` handler since we need more
'''
Override the ``InfiniteLine`` handler since we need more
detailed control and start end signalling.
"""
'''
cursor = self._chart.linked.cursor
# hide y-crosshair
@ -281,10 +295,20 @@ class LevelLine(pg.InfiniteLine):
# show y-crosshair again
cursor.show_xhair()
def delete(self) -> None:
"""Remove this line from containing chart/view/scene.
def get_cursor(self) -> Optional[Cursor]:
"""
chart = self._chart
cur = chart.linked.cursor
if self in cur._hovered:
return cur
return None
def delete(self) -> None:
'''
Remove this line from containing chart/view/scene.
'''
scene = self.scene()
if scene:
for label in self._labels:
@ -298,9 +322,8 @@ class LevelLine(pg.InfiniteLine):
# remove from chart/cursor states
chart = self._chart
cur = chart.linked.cursor
if self in cur._hovered:
cur = self.get_cursor()
if cur:
cur._hovered.remove(self)
chart.plotItem.removeItem(self)
@ -308,8 +331,8 @@ class LevelLine(pg.InfiniteLine):
def mouseDoubleClickEvent(
self,
ev: QtGui.QMouseEvent,
) -> None:
) -> None:
# TODO: enter labels edit mode
print(f'double click {ev}')
@ -334,30 +357,22 @@ class LevelLine(pg.InfiniteLine):
line_end, marker_right, r_axis_x = self._chart.marker_right_points()
if self.show_markers and self.markers:
p.setPen(self.pen)
qgo_draw_markers(
self.markers,
self.pen.color(),
p,
vb_left,
vb_right,
marker_right,
)
# marker_size = self.markers[0][2]
self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
# this seems slower when moving around
# order lines.. not sure wtf is up with that.
# for now we're just using it on the position line.
elif self._marker:
# (legacy) NOTE: at one point this seemed slower when moving around
# order lines.. not sure if that's still true or why but we've
# dropped the original hacky `.pain()` transform stuff for inf
# line markers now - check the git history if it needs to be
# reverted.
if self._marker:
if self.track_marker_pos:
# make the line end at the marker's x pos
line_end = marker_right = self._marker.pos().x()
# TODO: make this label update part of a scene-aware-marker
# composed annotation
self._marker.setPos(
QPointF(marker_right, self.scene_y())
)
if hasattr(self._marker, 'label'):
self._marker.label.update()
@ -379,16 +394,14 @@ class LevelLine(pg.InfiniteLine):
def hide(self) -> None:
super().hide()
if self._marker:
self._marker.hide()
# needed for ``order_line()`` lines currently
self._marker.label.hide()
mkr = self._marker
if mkr:
mkr.hide()
def show(self) -> None:
super().show()
if self._marker:
self._marker.show()
# self._marker.label.show()
def scene_y(self) -> float:
return self.getViewBox().mapFromView(
@ -433,17 +446,16 @@ class LevelLine(pg.InfiniteLine):
cur = self._chart.linked.cursor
# hovered
if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton):
if (
not ev.isExit()
and ev.acceptDrags(QtCore.Qt.LeftButton)
):
# if already hovered we don't need to run again
if self.mouseHovering is True:
return
if self.only_show_markers_on_hover:
self.show_markers = True
if self._marker:
self._marker.show()
self.show_markers()
# highlight if so configured
if self.highlight_on_hover:
@ -486,11 +498,7 @@ class LevelLine(pg.InfiniteLine):
cur._hovered.remove(self)
if self.only_show_markers_on_hover:
self.show_markers = False
if self._marker:
self._marker.hide()
self._marker.label.hide()
self.hide_markers()
if self not in cur._trackers:
cur.show_xhair(y_label_level=self.value())
@ -502,6 +510,15 @@ class LevelLine(pg.InfiniteLine):
self.update()
def hide_markers(self) -> None:
if self._marker:
self._marker.hide()
self._marker.label.hide()
def show_markers(self) -> None:
if self._marker:
self._marker.show()
def level_line(
@ -522,9 +539,10 @@ def level_line(
**kwargs,
) -> LevelLine:
"""Convenience routine to add a styled horizontal line to a plot.
'''
Convenience routine to add a styled horizontal line to a plot.
"""
'''
hl_color = color + '_light' if highlight_on_hover else color
line = LevelLine(
@ -706,7 +724,7 @@ def order_line(
marker = LevelMarker(
chart=chart,
style=marker_style,
get_level=line.value,
get_level=line.value, # callback
size=marker_size,
keep_in_view=False,
)
@ -715,7 +733,8 @@ def order_line(
marker = line.add_marker(marker)
# XXX: DON'T COMMENT THIS!
# this fixes it the artifact issue! .. of course, bounding rect stuff
# this fixes it the artifact issue!
# .. of course, bounding rect stuff
line._maxMarkerSize = marker_size
assert line._marker is marker
@ -736,7 +755,8 @@ def order_line(
if action != 'alert':
# add a partial position label if we also added a level marker
# add a partial position label if we also added a level
# marker
pp_size_label = Label(
view=view,
color=line.color,
@ -770,9 +790,9 @@ def order_line(
# XXX: without this the pp proportion label next the marker
# seems to lag? this is the same issue we had with position
# lines which we handle with ``.update_graphcis()``.
# marker._on_paint=lambda marker: pp_size_label.update()
marker._on_paint = lambda marker: pp_size_label.update()
# XXX: THIS IS AN UNTYPED MONKEY PATCH!?!?!
marker.label = label
# sanity check

View File

@ -23,7 +23,11 @@ from copy import copy
from dataclasses import dataclass
from functools import partial
from math import floor, copysign
from typing import Optional
from typing import (
Callable,
Optional,
TYPE_CHECKING,
)
# from PyQt5.QtWidgets import QStyle
@ -41,12 +45,18 @@ from ..calc import humanize, pnl, puterize
from ..clearing._allocate import Allocator, Position
from ..data._normalize import iterticks
from ..data.feed import Feed
from ..data.types import Struct
from ._label import Label
from ._lines import LevelLine, order_line
from ._style import _font
from ._forms import FieldsForm, FillStatusBar, QLabel
from ..log import get_logger
if TYPE_CHECKING:
from ._chart import (
ChartPlotWidget,
)
log = get_logger(__name__)
_pnl_tasks: dict[str, bool] = {}
@ -58,7 +68,8 @@ async def update_pnl_from_feed(
tracker: PositionTracker,
) -> None:
'''Real-time display the current pp's PnL in the appropriate label.
'''
Real-time display the current pp's PnL in the appropriate label.
``ValueError`` if this task is spawned where there is a net-zero pp.
@ -67,7 +78,7 @@ async def update_pnl_from_feed(
pp = order_mode.current_pp
live = pp.live_pp
key = live.symbol.key
key = live.symbol.front_fqsn()
log.info(f'Starting pnl display for {pp.alloc.account}')
@ -168,12 +179,12 @@ class SettingsPane:
) -> None:
'''
Try to apply some input setting (by the user), revert to previous setting if it fails
display new value if applied.
Try to apply some input setting (by the user), revert to
previous setting if it fails display new value if applied.
'''
self.apply_setting(key, value)
self.update_status_ui(pp=self.order_mode.current_pp)
self.update_status_ui(self.order_mode.current_pp)
def apply_setting(
self,
@ -195,7 +206,7 @@ class SettingsPane:
# hide details on the old selection
old_tracker = mode.current_pp
old_tracker.hide_info()
old_tracker.nav.hide_info()
# re-assign the order mode tracker
account_name = value
@ -205,7 +216,7 @@ class SettingsPane:
# a ``brokerd`) then error and switch back to the last
# selection.
if tracker is None:
sym = old_tracker.chart.linked.symbol.key
sym = old_tracker.charts[0].linked.symbol.key
log.error(
f'Account `{account_name}` can not be set for {sym}'
)
@ -216,8 +227,8 @@ class SettingsPane:
self.order_mode.current_pp = tracker
assert tracker.alloc.account == account_name
self.form.fields['account'].setCurrentText(account_name)
tracker.show()
tracker.hide_info()
tracker.nav.show()
tracker.nav.hide_info()
self.display_pnl(tracker)
@ -251,7 +262,9 @@ class SettingsPane:
log.error(
f'limit must > then current pp: {dsize}'
)
raise ValueError
# reset position size value
alloc.currency_limit = dsize
return False
alloc.currency_limit = value
@ -288,22 +301,29 @@ class SettingsPane:
def update_status_ui(
self,
pp: PositionTracker,
tracker: PositionTracker,
) -> None:
alloc = pp.alloc
alloc = tracker.alloc
slots = alloc.slots
used = alloc.slots_used(pp.live_pp)
used = alloc.slots_used(tracker.live_pp)
size = tracker.live_pp.size
dsize = tracker.live_pp.dsize
# READ out settings and update the status UI / settings widgets
suffix = {'currency': ' $', 'units': ' u'}[alloc.size_unit]
limit = alloc.limit()
size_unit, limit = alloc.limit_info()
step_size, currency_per_slot = alloc.step_sizes()
if alloc.size_unit == 'currency':
step_size = currency_per_slot
if dsize >= limit:
self.apply_setting('limit', limit)
elif size >= limit:
self.apply_setting('limit', limit)
self.step_label.format(
step_size=str(humanize(step_size)) + suffix
@ -320,7 +340,7 @@ class SettingsPane:
self.form.fields['limit'].setText(str(limit))
# update of level marker size label based on any new settings
pp.update_from_pp()
tracker.update_from_pp()
# calculate proportion of position size limit
# that exists and display in fill bar
@ -332,7 +352,7 @@ class SettingsPane:
# min(round(prop * slots), slots)
min(used, slots)
)
self.update_account_icons({alloc.account: pp.live_pp})
self.update_account_icons({alloc.account: tracker.live_pp})
def update_account_icons(
self,
@ -358,7 +378,9 @@ class SettingsPane:
tracker: PositionTracker,
) -> None:
'''Display the PnL for the current symbol and personal positioning (pp).
'''
Display the PnL for the current symbol and personal positioning
(pp).
If a position is open start a background task which will
real-time update the pnl label in the settings pane.
@ -372,7 +394,7 @@ class SettingsPane:
if size:
# last historical close price
last = feed.shm.array[-1][['close']][0]
last = feed.rt_shm.array[-1][['close']][0]
pnl_value = copysign(1, size) * pnl(
tracker.live_pp.ppu,
last,
@ -380,8 +402,9 @@ class SettingsPane:
# maybe start update task
global _pnl_tasks
if sym.key not in _pnl_tasks:
_pnl_tasks[sym.key] = True
fqsn = sym.front_fqsn()
if fqsn not in _pnl_tasks:
_pnl_tasks[fqsn] = True
self.order_mode.nursery.start_soon(
update_pnl_from_feed,
feed,
@ -393,15 +416,15 @@ class SettingsPane:
self.pnl_label.format(pnl=pnl_value)
def position_line(
def pp_line(
chart: 'ChartPlotWidget', # noqa
chart: ChartPlotWidget, # noqa
size: float,
level: float,
color: str,
marker: LevelMarker,
orient_v: str = 'bottom',
marker: Optional[LevelMarker] = None,
) -> LevelLine:
'''
@ -432,28 +455,20 @@ def position_line(
show_markers=False,
)
if marker:
# configure marker to position data
# TODO: use `LevelLine.add_marker()`` for this instead?
# set marker color to same as line
marker.setPen(line.currentPen)
marker.setBrush(fn.mkBrush(line.currentPen.color()))
marker.level = level
marker.update()
marker.show()
if size > 0: # long
style = '|<' # point "up to" the line
elif size < 0: # short
style = '>|' # point "down to" the line
line._marker = marker
line.track_marker_pos = True
marker.style = style
# set marker color to same as line
marker.setPen(line.currentPen)
marker.setBrush(fn.mkBrush(line.currentPen.color()))
marker.level = level
marker.update()
marker.show()
# show position marker on view "edge" when out of view
vb = line.getViewBox()
vb.sigRangeChanged.connect(marker.position_in_view)
line.set_level(level)
# show position marker on view "edge" when out of view
vb = line.getViewBox()
vb.sigRangeChanged.connect(marker.position_in_view)
return line
@ -466,85 +481,338 @@ _derivs = (
)
# TODO: move into annoate module?
def mk_level_marker(
chart: ChartPlotWidget,
size: float,
level: float,
on_paint: Callable,
) -> LevelMarker:
'''
Allocate and return nan arrow graphics element.
'''
# scale marker size with dpi-aware font size
font_size = _font.font.pixelSize()
arrow_size = floor(1.375 * font_size)
arrow = LevelMarker(
chart=chart,
style='|<', # actual style is set by caller based on size
get_level=level,
size=arrow_size,
on_paint=on_paint,
)
arrow.show()
return arrow
class Nav(Struct):
'''
Composite for holding a set of charts and respective (by order)
graphics-elements which display position information acting as sort
of "navigation" system for a position.
'''
charts: dict[int, ChartPlotWidget]
pp_labels: dict[str, Label] = {}
size_labels: dict[str, Label] = {}
lines: dict[str, Optional[LevelLine]] = {}
level_markers: dict[str, Optional[LevelMarker]] = {}
color: str = 'default_lightest'
def update_ui(
self,
account: str,
price: float,
size: float,
slots_used: float,
size_digits: Optional[int] = None,
) -> None:
'''
Update personal position level line.
'''
for key, chart in self.charts.items():
size_digits = size_digits or chart.linked.symbol.lot_size_digits
line = self.lines.get(key)
level_marker = self.level_markers[key]
pp_label = self.pp_labels[key]
if size:
# create and show a pp line if none yet exists
if line is None:
arrow = self.level_markers[key]
line = pp_line(
chart=chart,
level=price,
size=size,
color=self.color,
marker=arrow,
)
self.lines[key] = line
# modify existing indicator line
line.set_level(price)
# update LHS sizing label
line.update_labels({
'size': size,
'size_digits': size_digits,
'fiat_size': round(price * size, ndigits=2),
# TODO: per account lines on a single (or very
# related) symbol
'account': account,
})
line.show()
# always show arrow-marker when a non-zero
# pos size.
level_marker.show()
# configure marker to position data
if size > 0: # long
# point "up to" the line
level_marker.style = '|<'
elif size < 0: # short
# point "down to" the line
level_marker.style = '>|'
# remove line from view for a net-zero pos
else:
self.hide()
# label updates
size_label = self.size_labels[key]
size_label.fields['slots_used'] = slots_used
size_label.render()
# set arrow marker to correct level
level_marker.level = price
# these updates are critical to avoid lag on view/scene changes
# TODO: couldn't we integrate this into
# a ``.inter_ui_elements_and_update()``?
level_marker.update() # trigger paint
pp_label.update()
size_label.update()
def level(self) -> float:
'''
Return the "level" value from the underlying ``LevelLine`` which tracks
the "average position" price defined the represented position instance.
'''
if self.lines:
for key, line in self.lines.items():
if line:
return line.value()
return 0
def iter_ui_elements(self) -> tuple[
Label,
Label,
LevelLine,
LevelMarker,
]:
for key, chart in self.charts.items():
yield (
self.pp_labels[key],
self.size_labels[key],
self.lines.get(key),
self.level_markers[key],
)
def show(self) -> None:
'''
Show all UI elements on all managed charts.
'''
for (
pp_label,
size_label,
line,
level_marker,
) in self.iter_ui_elements():
# NOTE: be sure to re-trigger arrow/label placement in case
# a new sidepane or other widget (like the search bar) was
# dynamically swapped into the chart-row-widget-space in
# which case we want to reposition in the view but including
# the new x-distance added by that sidepane. See details in
# ``LevelMarker.position_in_view()`` but more less ``.
# ``ChartPlotWidget.self.marker_right_points()`` gets called
# which itself eventually calls `.getAxis.pos().x()` and
# it's THIS that needs to be called **AFTER** the sidepane
# has been added..
level_marker.show()
level_marker.position_in_view()
# labels
pp_label.show()
size_label.show()
if line:
line.show()
line.show_labels()
def hide(self) -> None:
for (
pp_label,
size_label,
line,
level_marker,
) in self.iter_ui_elements():
pp_label.hide()
level_marker.hide()
size_label.hide()
if line:
line.hide()
def update_graphics(
self,
marker: LevelMarker,
) -> None:
'''
Update all labels callback.
Meant to be called from the marker ``.paint()``
for immediate, lag free label draws.
'''
for (
pp_label,
size_label,
line,
level_marker,
) in self.iter_ui_elements():
pp_label.update()
size_label.update()
# XXX: can't call this because it causes a recursive paint/render
# level_marker.update()
def hide_info(self) -> None:
'''
Hide details (just size label?) of position nav elements.
'''
for (
pp_label,
size_label,
line,
level_marker,
) in self.iter_ui_elements():
size_label.hide()
if line:
line.hide_labels()
class PositionTracker:
'''
Track and display real-time positions for a single symbol
over multiple accounts on a single chart.
Track and display real-time positions for a single asset-symbol
held in a single account, normally shown on a single chart.
Graphically composed of a level line and marker as well as labels
for indcating current position information. Updates are made to the
corresponding "settings pane" for the chart's "order mode" UX.
'''
# inputs
chart: 'ChartPlotWidget' # noqa
alloc: Allocator
startup_pp: Position
live_pp: Position
# allocated
pp_label: Label
size_label: Label
line: Optional[LevelLine] = None
_color: str = 'default_lightest'
nav: Nav # holds all UI elements across all charts
def __init__(
self,
chart: 'ChartPlotWidget', # noqa
charts: list[ChartPlotWidget],
alloc: Allocator,
startup_pp: Position,
) -> None:
self.chart = chart
nav = self.nav = Nav(charts={id(chart): chart for chart in charts})
self.alloc = alloc
self.startup_pp = startup_pp
self.live_pp = copy(startup_pp)
view = chart.getViewBox()
# TODO: maybe add this as a method ``Nav.add_chart()``
# init all UI elements
for key, chart in nav.charts.items():
view = chart.getViewBox()
# literally the 'pp' (pee pee) label that's always in view
self.pp_label = pp_label = Label(
view=view,
fmt_str='pp',
color=self._color,
update_on_range_change=False,
)
arrow = mk_level_marker(
chart=chart,
size=1,
level=nav.level,
on_paint=nav.update_graphics,
)
# create placeholder 'up' level arrow
self._level_marker = None
self._level_marker = self.level_marker(size=1)
# TODO: we really need some kinda "spacing" manager for all
# this stuff...
def offset_from_yaxis() -> float:
'''
If no L1 labels are present beside the x-axis place
the line label offset from the y-axis just enough to avoid
label overlap with any sticky labels.
pp_label.scene_anchor = partial(
gpath_pin,
gpath=self._level_marker,
label=pp_label,
)
pp_label.render()
'''
x = chart.marker_right_points()[1]
if chart._max_l1_line_len == 0:
mkw = pp_label.txt.boundingRect().width()
x -= 1.5 * mkw
self.size_label = size_label = Label(
view=view,
color=self._color,
return x
# this is "static" label
# update_on_range_change=False,
fmt_str='\n'.join((
':{slots_used:.1f}x',
)),
arrow.scene_x = offset_from_yaxis
view.scene().addItem(arrow)
arrow.hide() # never show on startup
nav.level_markers[key] = arrow
fields={
'slots_used': 0,
},
)
size_label.render()
# literally the 'pp' (pee pee) "position price" label that's
# always in view
pp_label = Label(
view=view,
fmt_str='pp',
color=nav.color,
update_on_range_change=False,
)
pp_label.render()
nav.pp_labels[key] = pp_label
size_label.scene_anchor = partial(
pp_tight_and_right,
label=self.pp_label,
)
size_label = Label(
view=view,
color=self.nav.color,
# this is "static" label
# update_on_range_change=False,
fmt_str='\n'.join((
':{slots_used:.1f}x',
)),
fields={
'slots_used': 0,
},
)
size_label.render()
size_label.scene_anchor = partial(
pp_tight_and_right,
label=pp_label,
)
nav.size_labels[key] = size_label
pp_label.scene_anchor = partial(
gpath_pin,
gpath=arrow,
label=pp_label,
)
nav.show()
@property
def pane(self) -> FieldsForm:
@ -554,21 +822,6 @@ class PositionTracker:
'''
return self.chart.linked.godwidget.pp_pane
def update_graphics(
self,
marker: LevelMarker
) -> None:
'''
Update all labels.
Meant to be called from the maker ``.paint()``
for immediate, lag free label draws.
'''
self.pp_label.update()
self.size_label.update()
def update_from_pp(
self,
position: Optional[Position] = None,
@ -621,142 +874,22 @@ class PositionTracker:
if asset_type in _derivs:
alloc.slots = alloc.units_limit
self.update_line(
self.nav.update_ui(
self.alloc.account,
pp.ppu,
pp.size,
self.chart.linked.symbol.lot_size_digits,
round(alloc.slots_used(pp), ndigits=1), # slots used
)
# label updates
self.size_label.fields['slots_used'] = round(
alloc.slots_used(pp), ndigits=1)
self.size_label.render()
if pp.size == 0:
self.hide()
else:
self._level_marker.level = pp.ppu
# these updates are critical to avoid lag on view/scene changes
self._level_marker.update() # trigger paint
self.pp_label.update()
self.size_label.update()
self.show()
# don't show side and status widgets unless
# order mode is "engaged" (which done via input controls)
self.hide_info()
def level(self) -> float:
if self.line:
return self.line.value()
else:
return 0
def show(self) -> None:
if self.live_pp.size:
self.line.show()
self.line.show_labels()
# print("SHOWING NAV")
self.nav.show()
self._level_marker.show()
self.pp_label.show()
self.size_label.show()
# if pp.size == 0:
else:
# print("HIDING NAV")
self.nav.hide()
def hide(self) -> None:
self.pp_label.hide()
self._level_marker.hide()
self.size_label.hide()
if self.line:
self.line.hide()
def hide_info(self) -> None:
'''Hide details (right now just size label?) of position.
'''
self.size_label.hide()
if self.line:
self.line.hide_labels()
# TODO: move into annoate module
def level_marker(
self,
size: float,
) -> LevelMarker:
if self._level_marker:
self._level_marker.delete()
# arrow marker
# scale marker size with dpi-aware font size
font_size = _font.font.pixelSize()
# scale marker size with dpi-aware font size
arrow_size = floor(1.375 * font_size)
if size > 0:
style = '|<'
elif size < 0:
style = '>|'
arrow = LevelMarker(
chart=self.chart,
style=style,
get_level=self.level,
size=arrow_size,
on_paint=self.update_graphics,
)
self.chart.getViewBox().scene().addItem(arrow)
arrow.show()
return arrow
def update_line(
self,
price: float,
size: float,
size_digits: int,
) -> None:
'''Update personal position level line.
'''
# do line update
line = self.line
if size:
if line is None:
# create and show a pp line
line = self.line = position_line(
chart=self.chart,
level=price,
size=size,
color=self._color,
marker=self._level_marker,
)
else:
line.set_level(price)
self._level_marker.level = price
self._level_marker.update()
# update LHS sizing label
line.update_labels({
'size': size,
'size_digits': size_digits,
'fiat_size': round(price * size, ndigits=2),
# TODO: per account lines on a single (or very related) symbol
'account': self.alloc.account,
})
line.show()
elif line: # remove pp line from view if it exists on a net-zero pp
line.delete()
self.line = None
# don't show side and status widgets unless
# order mode is "engaged" (which done via input controls)
self.nav.hide_info()

View File

@ -35,9 +35,13 @@ from collections import defaultdict
from contextlib import asynccontextmanager
from functools import partial
from typing import (
Optional, Callable,
Awaitable, Sequence,
Any, AsyncIterator
Optional,
Callable,
Awaitable,
Sequence,
Any,
AsyncIterator,
Iterator,
)
import time
# from pprint import pformat
@ -119,7 +123,7 @@ class CompleterView(QTreeView):
# TODO: size this based on DPI font
self.setIndentation(_font.px_size)
# self.setUniformRowHeights(True)
self.setUniformRowHeights(True)
# self.setColumnWidth(0, 3)
# self.setVerticalBarPolicy(Qt.ScrollBarAlwaysOff)
# self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
@ -138,13 +142,15 @@ class CompleterView(QTreeView):
model.setHorizontalHeaderLabels(labels)
self._font_size: int = 0 # pixels
self._init: bool = False
async def on_pressed(self, idx: QModelIndex) -> None:
'''Mouse pressed on view handler.
'''
Mouse pressed on view handler.
'''
search = self.parent()
await search.chart_current_item(clear_to_cache=False)
await search.chart_current_item()
search.focus()
def set_font_size(self, size: int = 18):
@ -156,56 +162,64 @@ class CompleterView(QTreeView):
self.setStyleSheet(f"font: {size}px")
# def resizeEvent(self, event: 'QEvent') -> None:
# event.accept()
# super().resizeEvent(event)
def resize_to_results(
self,
w: Optional[float] = 0,
h: Optional[float] = None,
def on_resize(self) -> None:
'''
Resize relay event from god.
'''
self.resize_to_results()
def resize_to_results(self):
) -> None:
model = self.model()
cols = model.columnCount()
# rows = model.rowCount()
cidx = self.selectionModel().currentIndex()
rows = model.rowCount()
self.expandAll()
# compute the approx height in pixels needed to include
# all result rows in view.
row_h = rows_h = self.rowHeight(cidx) * (rows + 1)
for idx, item in self.iter_df_rows():
row_h = self.rowHeight(idx)
rows_h += row_h
# print(f'row_h: {row_h}\nrows_h: {rows_h}')
# TODO: could we just break early here on detection
# of ``rows_h >= h``?
col_w_tot = 0
for i in range(cols):
# only slap in a rows's height's worth
# of padding once at startup.. no idea
if (
not self._init
and row_h
):
col_w_tot = row_h
self._init = True
self.resizeColumnToContents(i)
col_w_tot += self.columnWidth(i)
win = self.window()
win_h = win.height()
edit_h = self.parent().bar.height()
sb_h = win.statusBar().height()
# NOTE: if the heigh `h` set here is **too large** then the
# resize event will perpetually trigger as the window causes
# some kind of recompute of callbacks.. so we have to ensure
# it's limited.
if h:
h: int = round(h)
abs_mx = round(0.91 * h)
self.setMaximumHeight(abs_mx)
# TODO: probably make this more general / less hacky
# we should figure out the exact number of rows to allow
# inclusive of search bar and header "rows", in pixel terms.
# Eventually when we have an "info" widget below the results we
# will want space for it and likely terminating the results-view
# space **exactly on a row** would be ideal.
# if row_px > 0:
# rows = ceil(window_h / row_px) - 4
# else:
# rows = 16
# self.setFixedHeight(rows * row_px)
# self.resize(self.width(), rows * row_px)
if rows_h <= abs_mx:
# self.setMinimumHeight(rows_h)
self.setMinimumHeight(rows_h)
# self.setFixedHeight(rows_h)
# NOTE: if the heigh set here is **too large** then the resize
# event will perpetually trigger as the window causes some kind
# of recompute of callbacks.. so we have to ensure it's limited.
h = win_h - (edit_h + 1.666*sb_h)
assert h > 0
self.setFixedHeight(round(h))
else:
self.setMinimumHeight(abs_mx)
# size to width of longest result seen thus far
# TODO: should we always dynamically scale to longest result?
if self.width() < col_w_tot:
self.setFixedWidth(col_w_tot)
# dyncamically size to width of longest result seen
curr_w = self.width()
if curr_w < col_w_tot:
self.setMinimumWidth(col_w_tot)
self.update()
@ -331,6 +345,23 @@ class CompleterView(QTreeView):
item = model.itemFromIndex(idx)
yield idx, item
def iter_df_rows(
self,
iparent: QModelIndex = QModelIndex(),
) -> Iterator[tuple[QModelIndex, QStandardItem]]:
model = self.model()
isections = model.rowCount(iparent)
for i in range(isections):
idx = model.index(i, 0, iparent)
item = model.itemFromIndex(idx)
yield idx, item
if model.hasChildren(idx):
# recursively yield child items depth-first
yield from self.iter_df_rows(idx)
def find_section(
self,
section: str,
@ -354,7 +385,8 @@ class CompleterView(QTreeView):
status_field: str = None,
) -> None:
'''Clear all result-rows from under the depth = 1 section.
'''
Clear all result-rows from under the depth = 1 section.
'''
idx = self.find_section(section)
@ -375,8 +407,6 @@ class CompleterView(QTreeView):
else:
model.setItem(idx.row(), 1, QStandardItem())
self.resize_to_results()
return idx
else:
return None
@ -444,9 +474,22 @@ class CompleterView(QTreeView):
self.show_matches()
def show_matches(self) -> None:
def show_matches(
self,
wh: Optional[tuple[float, float]] = None,
) -> None:
if wh:
self.resize_to_results(*wh)
else:
# case where it's just an update from results and *NOT*
# a resize of some higher level parent-container widget.
search = self.parent()
w, h = search.space_dims()
self.resize_to_results(w=w, h=h)
self.show()
self.resize_to_results()
class SearchBar(Edit):
@ -466,18 +509,15 @@ class SearchBar(Edit):
self.godwidget = godwidget
super().__init__(parent, **kwargs)
self.view: CompleterView = view
godwidget._widgets[view.mode_name] = view
def show(self) -> None:
super().show()
self.view.show_matches()
def unfocus(self) -> None:
self.parent().hide()
self.clearFocus()
def hide(self) -> None:
if self.view:
self.view.hide()
super().hide()
class SearchWidget(QtWidgets.QWidget):
@ -496,15 +536,16 @@ class SearchWidget(QtWidgets.QWidget):
parent=None,
) -> None:
super().__init__(parent or godwidget)
super().__init__(parent)
# size it as we specify
self.setSizePolicy(
QtWidgets.QSizePolicy.Fixed,
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Fixed,
)
self.godwidget = godwidget
godwidget.reg_for_resize(self)
self.vbox = QtWidgets.QVBoxLayout(self)
self.vbox.setContentsMargins(0, 4, 4, 0)
@ -554,17 +595,22 @@ class SearchWidget(QtWidgets.QWidget):
self.vbox.setAlignment(self.view, Qt.AlignTop | Qt.AlignLeft)
def focus(self) -> None:
if self.view.model().rowCount(QModelIndex()) == 0:
# fill cache list if nothing existing
self.view.set_section_entries(
'cache',
list(reversed(self.godwidget._chart_cache)),
clear_all=True,
)
self.bar.focus()
self.show()
self.bar.focus()
def show_only_cache_entries(self) -> None:
'''
Clear the search results view and show only cached (aka recently
loaded with active data) feeds in the results section.
'''
godw = self.godwidget
self.view.set_section_entries(
'cache',
list(reversed(godw._chart_cache)),
# remove all other completion results except for cache
clear_all=True,
)
def get_current_item(self) -> Optional[tuple[str, str]]:
'''Return the current completer tree selection as
@ -603,7 +649,8 @@ class SearchWidget(QtWidgets.QWidget):
clear_to_cache: bool = True,
) -> Optional[str]:
'''Attempt to load and switch the current selected
'''
Attempt to load and switch the current selected
completion result to the affiliated chart app.
Return any loaded symbol.
@ -614,11 +661,11 @@ class SearchWidget(QtWidgets.QWidget):
return None
provider, symbol = value
chart = self.godwidget
godw = self.godwidget
log.info(f'Requesting symbol: {symbol}.{provider}')
await chart.load_symbol(
await godw.load_symbol(
provider,
symbol,
'info',
@ -635,18 +682,46 @@ class SearchWidget(QtWidgets.QWidget):
# Re-order the symbol cache on the chart to display in
# LIFO order. this is normally only done internally by
# the chart on new symbols being loaded into memory
chart.set_chart_symbol(fqsn, chart.linkedsplits)
self.view.set_section_entries(
'cache',
values=list(reversed(chart._chart_cache)),
# remove all other completion results except for cache
clear_all=True,
godw.set_chart_symbol(
fqsn, (
godw.hist_linked,
godw.rt_linked,
)
)
self.show_only_cache_entries()
self.bar.focus()
return fqsn
def space_dims(self) -> tuple[float, float]:
'''
Compute and return the "available space dimentions" for this
search widget in terms of px space for results by return the
pair of width and height.
'''
# XXX: dun need dis rite?
# win = self.window()
# win_h = win.height()
# sb_h = win.statusBar().height()
godw = self.godwidget
hl = godw.hist_linked
edit_h = self.bar.height()
h = hl.height() - edit_h
w = hl.width()
return w, h
def on_resize(self) -> None:
'''
Resize relay event from god, resize all child widgets.
Right now this is just view to contents and/or the fast chart
height.
'''
w, h = self.space_dims()
self.bar.view.show_matches(wh=(w, h))
_search_active: trio.Event = trio.Event()
_search_enabled: bool = False
@ -712,10 +787,11 @@ async def fill_results(
max_pause_time: float = 6/16 + 0.001,
) -> None:
"""Task to search through providers and fill in possible
'''
Task to search through providers and fill in possible
completion results.
"""
'''
global _search_active, _search_enabled, _searcher_cache
bar = search.bar
@ -729,6 +805,10 @@ async def fill_results(
matches = defaultdict(list)
has_results: defaultdict[str, set[str]] = defaultdict(set)
# show cached feed list at startup
search.show_only_cache_entries()
search.on_resize()
while True:
await _search_active.wait()
period = None
@ -742,7 +822,7 @@ async def fill_results(
pattern = await recv_chan.receive()
period = time.time() - wait_start
print(f'{pattern} after {period}')
log.debug(f'{pattern} after {period}')
# during fast multiple key inputs, wait until a pause
# (in typing) to initiate search
@ -841,8 +921,7 @@ async def handle_keyboard_input(
godwidget = search.godwidget
view = bar.view
view.set_font_size(bar.dpi_font.px_size)
send, recv = trio.open_memory_channel(16)
send, recv = trio.open_memory_channel(616)
async with trio.open_nursery() as n:
@ -857,6 +936,10 @@ async def handle_keyboard_input(
)
)
bar.focus()
search.show_only_cache_entries()
await trio.sleep(0)
async for kbmsg in recv_chan:
event, etype, key, mods, txt = kbmsg.to_tuple()
@ -867,10 +950,11 @@ async def handle_keyboard_input(
ctl = True
if key in (Qt.Key_Enter, Qt.Key_Return):
await search.chart_current_item(clear_to_cache=True)
_search_enabled = False
continue
await search.chart_current_item(clear_to_cache=True)
search.show_only_cache_entries()
view.show_matches()
search.focus()
elif not ctl and not bar.text():
# if nothing in search text show the cache
@ -887,7 +971,7 @@ async def handle_keyboard_input(
Qt.Key_Space, # i feel like this is the "native" one
Qt.Key_Alt,
}:
search.bar.unfocus()
bar.unfocus()
# kill the search and focus back on main chart
if godwidget:
@ -935,9 +1019,10 @@ async def handle_keyboard_input(
if item:
parent_item = item.parent()
# if we're in the cache section and thus the next
# selection is a cache item, switch and show it
# immediately since it should be very fast.
if parent_item and parent_item.text() == 'cache':
# if it's a cache item, switch and show it immediately
await search.chart_current_item(clear_to_cache=False)
elif not ctl:

View File

@ -21,7 +21,11 @@ Qt main window singletons and stuff.
import os
import signal
import time
from typing import Callable, Optional, Union
from typing import (
Callable,
Optional,
Union,
)
import uuid
from pyqtgraph import QtGui
@ -30,6 +34,7 @@ from PyQt5.QtWidgets import QLabel, QStatusBar
from ..log import get_logger
from ._style import _font_small, hcolor
from ._chart import GodWidget
log = get_logger(__name__)
@ -153,7 +158,8 @@ class MainWindow(QtGui.QMainWindow):
# XXX: for tiling wms this should scale
# with the alloted window size.
# TODO: detect for tiling and if untrue set some size?
size = (300, 500)
# size = (300, 500)
godwidget: GodWidget
title = 'piker chart (ur symbol is loading bby)'
@ -162,6 +168,9 @@ class MainWindow(QtGui.QMainWindow):
# self.setMinimumSize(*self.size)
self.setWindowTitle(self.title)
# set by runtime after `trio` is engaged.
self.godwidget: Optional[GodWidget] = None
self._status_bar: QStatusBar = None
self._status_label: QLabel = None
self._size: Optional[tuple[int, int]] = None
@ -248,9 +257,10 @@ class MainWindow(QtGui.QMainWindow):
self.set_mode_name(name)
def current_screen(self) -> QtGui.QScreen:
"""Get a frickin screen (if we can, gawd).
'''
Get a frickin screen (if we can, gawd).
"""
'''
app = QtGui.QApplication.instance()
for _ in range(3):
@ -284,7 +294,7 @@ class MainWindow(QtGui.QMainWindow):
'''
# https://stackoverflow.com/a/18975846
if not size and not self._size:
app = QtGui.QApplication.instance()
# app = QtGui.QApplication.instance()
geo = self.current_screen().geometry()
h, w = geo.height(), geo.width()
# use approx 1/3 of the area of the screen by default
@ -292,6 +302,33 @@ class MainWindow(QtGui.QMainWindow):
self.resize(*size or self._size)
def resizeEvent(self, event: QtCore.QEvent) -> None:
if (
# event.spontaneous()
event.oldSize().height == event.size().height
):
event.ignore()
return
# XXX: uncomment for debugging..
# attrs = {}
# for key in dir(event):
# if key == '__dir__':
# continue
# attr = getattr(event, key)
# try:
# attrs[key] = attr()
# except TypeError:
# attrs[key] = attr
# from pprint import pformat
# print(
# f'{pformat(attrs)}\n'
# f'WINDOW RESIZE: {self.size()}\n\n'
# )
self.godwidget.on_win_resize(event)
event.accept()
# singleton app per actor
_qt_win: QtGui.QMainWindow = None

View File

@ -18,13 +18,19 @@
Chart trading, the only way to scalp.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from functools import partial
from pprint import pformat
import platform
import time
from typing import Optional, Dict, Callable, Any
from typing import (
Optional,
Callable,
Any,
TYPE_CHECKING,
)
import uuid
import tractor
@ -60,6 +66,12 @@ from ..clearing._messages import (
from ._forms import open_form_input_handling
if TYPE_CHECKING:
from ._chart import (
ChartPlotWidget,
GodWidget,
)
log = get_logger(__name__)
@ -73,10 +85,10 @@ class Dialog(Struct):
uuid: str
order: Order
symbol: Symbol
line: LevelLine
lines: list[LevelLine]
last_status_close: Callable = lambda: None
msgs: dict[str, dict] = {}
fills: Dict[str, Any] = {}
fills: dict[str, Any] = {}
@dataclass
@ -100,8 +112,11 @@ class OrderMode:
mouse click and drag -> modify current order under cursor
'''
chart: 'ChartPlotWidget' # type: ignore # noqa
nursery: trio.Nursery
godw: GodWidget
feed: Feed
chart: ChartPlotWidget # type: ignore # noqa
hist_chart: ChartPlotWidget # type: ignore # noqa
nursery: trio.Nursery # used by ``ui._position`` code?
quote_feed: Feed
book: OrderBook
lines: LineEditor
@ -162,14 +177,15 @@ class OrderMode:
def line_from_order(
self,
order: Order,
chart: Optional[ChartPlotWidget] = None,
**line_kwargs,
) -> LevelLine:
level = order.price
line = order_line(
self.chart,
line = order_line(
chart or self.chart,
# TODO: convert these values into human-readable form
# (i.e. with k, m, M, B) type embedded suffixes
level=level,
@ -211,24 +227,61 @@ class OrderMode:
return line
def lines_from_order(
self,
order: Order,
**line_kwargs,
) -> list[LevelLine]:
lines: list[LevelLine] = []
for chart, kwargs in [
(self.chart, {}),
(self.hist_chart, {'only_show_markers_on_hover': True}),
]:
kwargs.update(line_kwargs)
line = self.line_from_order(
order=order,
chart=chart,
**kwargs,
)
lines.append(line)
return lines
def stage_order(
self,
action: str,
trigger_type: str,
) -> None:
'''Stage an order for submission.
) -> list[LevelLine]:
'''
Stage an order for submission by showing level lines and
configuring the order request message dynamically based on
allocator settings.
'''
# not initialized yet
chart = self.chart
cursor = chart.linked.cursor
if not (chart and cursor and cursor.active_plot):
cursor = self.godw.get_cursor()
if not cursor:
return
chart = cursor.linked.chart
if (
not chart
and cursor
and cursor.active_plot
):
return
chart = cursor.active_plot
price = cursor._datum_xy[1]
if not price:
# zero prices are not supported by any means
# since that's illogical / a no-op.
return
symbol = self.chart.linked.symbol
order = self._staged_order = Order(
@ -242,27 +295,43 @@ class OrderMode:
exec_mode=trigger_type, # dark or live
)
# TODO: staged line mirroring? - need to keep track of multiple
# staged lines in editor - need to call
# `LineEditor.unstage_line()` on all staged lines..
# lines = self.lines_from_order(
line = self.line_from_order(
order,
chart=chart,
show_markers=True,
# 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,
# don't highlight the "staging" line
highlight_on_hover=False,
# prevent flickering of marker while moving/tracking cursor
only_show_markers_on_hover=False,
)
line = self.lines.stage_line(line)
# hide crosshair y-line and label
cursor.hide_xhair()
self.lines.stage_line(line)
# add line to cursor trackers
cursor._trackers.add(line)
# TODO: see above about mirroring.
# for line in lines:
# if line._chart is chart:
# self.lines.stage_line(line)
# cursor._trackers.add(line)
# break
# hide crosshair y-line and label
cursor.hide_xhair()
return line
def submit_order(
@ -285,13 +354,10 @@ class OrderMode:
order.symbol = order.symbol.front_fqsn()
line = self.line_from_order(
lines = self.lines_from_order(
order,
show_markers=True,
only_show_markers_on_hover=True,
)
# register the "submitted" line under the cursor
# to be displayed when above order ack arrives
# (means the marker graphic doesn't show on screen until the
@ -302,8 +368,8 @@ class OrderMode:
# maybe place a grey line in "submission" mode
# which will be updated to it's appropriate action
# color once the submission ack arrives.
self.lines.submit_line(
line=line,
self.lines.submit_lines(
lines=lines,
uuid=order.oid,
)
@ -311,24 +377,25 @@ class OrderMode:
uuid=order.oid,
order=order,
symbol=order.symbol,
line=line,
lines=lines,
last_status_close=self.multistatus.open_status(
f'submitting {order.exec_mode}-{order.action}',
final_msg=f'submitted {order.exec_mode}-{order.action}',
clear_on_next=True,
)
)
# TODO: create a new ``OrderLine`` with this optional var defined
line.dialog = dialog
# enter submission which will be popped once a response
# from the EMS is received to move the order to a different# status
self.dialogs[order.oid] = dialog
# hook up mouse drag handlers
line._on_drag_start = self.order_line_modify_start
line._on_drag_end = self.order_line_modify_complete
for line in lines:
# TODO: create a new ``OrderLine`` with this optional var defined
line.dialog = dialog
# hook up mouse drag handlers
line._on_drag_start = self.order_line_modify_start
line._on_drag_end = self.order_line_modify_complete
# send order cmd to ems
if send_msg:
@ -350,7 +417,7 @@ class OrderMode:
) -> None:
print(f'Line modify: {line}')
log.info(f'Order modify: {line}')
# cancel original order until new position is found?
# TODO: make a config option for this behaviour..
@ -361,8 +428,9 @@ class OrderMode:
) -> None:
level = line.value()
# updateb by level change callback set in ``.line_from_order()``
size = line.dialog.order.size
# updated by level change callback set in ``.line_from_order()``
dialog = line.dialog
size = dialog.order.size
self.book.update(
uuid=line.dialog.uuid,
@ -370,8 +438,13 @@ class OrderMode:
size=size,
)
# ems response loop handlers
# adjust corresponding slow/fast chart line
# to match level
for ln in dialog.lines:
if ln is not line:
ln.set_level(line.value())
# EMS response msg handlers
def on_submit(
self,
uuid: str
@ -383,13 +456,18 @@ class OrderMode:
Commit the order line and registered order uuid, store ack time stamp.
'''
line = self.lines.commit_line(uuid)
lines = self.lines.commit_line(uuid)
# a submission is the start of a new order dialog
dialog = self.dialogs[uuid]
dialog.line = line
dialog.lines = lines
dialog.last_status_close()
for line in lines:
# hide any lines not currently moused-over
if not line.get_cursor():
line.hide_labels()
return dialog
def on_fill(
@ -415,17 +493,26 @@ class OrderMode:
'''
dialog = self.dialogs[uuid]
line = dialog.line
if line:
self.arrows.add(
uuid,
arrow_index,
price,
pointing=pointing,
color=line.color
)
lines = dialog.lines
# XXX: seems to fail on certain types of races?
# assert len(lines) == 2
if lines:
_, _, ratio = self.feed.get_ds_info()
for i, chart in [
(arrow_index, self.chart),
(self.feed.startup_hist_index + round(arrow_index/ratio),
self.hist_chart)
]:
self.arrows.add(
chart.plotItem,
uuid,
i,
price,
pointing=pointing,
color=lines[0].color
)
else:
log.warn("No line for order {uuid}!?")
log.warn("No line(s) for order {uuid}!?")
async def on_exec(
self,
@ -486,7 +573,8 @@ class OrderMode:
)
def cancel_all_orders(self) -> list[str]:
'''Cancel all orders for the current chart.
'''
Cancel all orders for the current chart.
'''
return self.cancel_orders_from_lines(
@ -568,7 +656,7 @@ class OrderMode:
async def open_order_mode(
feed: Feed,
chart: 'ChartPlotWidget', # noqa
godw: GodWidget,
fqsn: str,
started: trio.Event,
@ -581,6 +669,9 @@ async def open_order_mode(
state, mostly graphics / UI.
'''
chart = godw.rt_linked.chart
hist_chart = godw.hist_linked.chart
multistatus = chart.window().status_bar
done = multistatus.open_status('starting order mode..')
@ -606,11 +697,10 @@ async def open_order_mode(
):
log.info(f'Opening order mode for {fqsn}')
view = chart.view
# annotations editors
lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {})
lines = LineEditor(godw=godw)
arrows = ArrowEditor(godw=godw)
# symbol id
symbol = chart.linked.symbol
@ -663,11 +753,11 @@ async def open_order_mode(
)
pp_tracker = PositionTracker(
chart,
[chart, hist_chart],
alloc,
startup_pp
)
pp_tracker.hide()
pp_tracker.nav.hide()
trackers[account_name] = pp_tracker
assert pp_tracker.startup_pp.size == pp_tracker.live_pp.size
@ -679,8 +769,8 @@ async def open_order_mode(
# on existing position, show pp tracking graphics
if pp_tracker.startup_pp.size != 0:
pp_tracker.show()
pp_tracker.hide_info()
pp_tracker.nav.show()
pp_tracker.nav.hide_info()
# setup order mode sidepane widgets
form: FieldsForm = chart.sidepane
@ -720,7 +810,10 @@ async def open_order_mode(
# top level abstraction which wraps all this crazyness into
# a namespace..
mode = OrderMode(
godw,
feed,
chart,
hist_chart,
tn,
feed,
book,
@ -737,8 +830,8 @@ async def open_order_mode(
# select a pp to track
tracker: PositionTracker = trackers[pp_account]
mode.current_pp = tracker
tracker.show()
tracker.hide_info()
tracker.nav.show()
tracker.nav.hide_info()
# XXX: would love to not have to do this separate from edit
# fields (which are done in an async loop - see below)
@ -754,13 +847,13 @@ async def open_order_mode(
)
# make fill bar and positioning snapshot
order_pane.on_ui_settings_change('limit', tracker.alloc.limit())
order_pane.update_status_ui(pp=tracker)
order_pane.update_status_ui(tracker)
# TODO: create a mode "manager" of sorts?
# -> probably just call it "UxModes" err sumthin?
# so that view handlers can access it
view.order_mode = mode
chart.view.order_mode = mode
hist_chart.view.order_mode = mode
order_pane.on_ui_settings_change('account', pp_account)
mode.pane.display_pnl(mode.current_pp)
@ -785,6 +878,7 @@ async def open_order_mode(
# ``ChartView`` input async handler startup
chart.view.open_async_input_handler(),
hist_chart.view.open_async_input_handler(),
# pp pane kb inputs
open_form_input_handling(