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 # TODO add search though our adhoc-locally defined symbol set
# for futes/cmdtys/ # for futes/cmdtys/
results = await self.search_stocks( try:
pattern, results = await self.search_stocks(
upto=upto, pattern,
) upto=upto,
)
except ConnectionError:
return {}
for key, deats in results.copy().items(): for key, deats in results.copy().items():
tract = deats.contract tract = deats.contract

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,16 +32,22 @@ def mk_marker_path(
style: str, style: str,
) -> QGraphicsPathItem: ) -> 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** **Arguments**
style String indicating the style of marker to add: style String indicating the style of marker to add:
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
``'>|<'``, ``'^'``, ``'v'``, ``'o'`` ``'>|<'``, ``'^'``, ``'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() path = QtGui.QPainterPath()
if style == 'o': if style == 'o':
@ -87,7 +93,8 @@ def mk_marker_path(
class LevelMarker(QGraphicsPathItem): 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. to the specified view coordinate level on each paint cycle.
''' '''
@ -114,6 +121,7 @@ class LevelMarker(QGraphicsPathItem):
self.get_level = get_level self.get_level = get_level
self._on_paint = on_paint self._on_paint = on_paint
self.scene_x = lambda: chart.marker_right_points()[1] self.scene_x = lambda: chart.marker_right_points()[1]
self.level: float = 0 self.level: float = 0
self.keep_in_view = keep_in_view self.keep_in_view = keep_in_view
@ -149,12 +157,9 @@ class LevelMarker(QGraphicsPathItem):
def w(self) -> float: def w(self) -> float:
return self.path_br().width() return self.path_br().width()
def position_in_view( def position_in_view(self) -> None:
self, '''
# level: float, Show a pp off-screen indicator for a level label.
) -> None:
'''Show a pp off-screen indicator for a level label.
This is like in fps games where you have a gps "nav" indicator 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 but your teammate is outside the range of view, except in 2D, on
@ -162,7 +167,6 @@ class LevelMarker(QGraphicsPathItem):
''' '''
level = self.get_level() level = self.get_level()
view = self.chart.getViewBox() view = self.chart.getViewBox()
vr = view.state['viewRange'] vr = view.state['viewRange']
ymn, ymx = vr[1] ymn, ymx = vr[1]
@ -186,7 +190,6 @@ class LevelMarker(QGraphicsPathItem):
) )
elif level < ymn: # pin to bottom of view elif level < ymn: # pin to bottom of view
self.setPos( self.setPos(
QPointF( QPointF(
x, x,
@ -211,7 +214,8 @@ class LevelMarker(QGraphicsPathItem):
w: QtWidgets.QWidget w: QtWidgets.QWidget
) -> None: ) -> 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 our marker position in scene coordinates from a
view cooridnate "level". view cooridnate "level".
@ -235,11 +239,12 @@ def qgo_draw_markers(
right_offset: float, right_offset: float,
) -> 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 removing the view transform for the painter, drawing the markers
in scene coords, then restoring the view coords. in scene coords, then restoring the view coords.
""" '''
# paint markers in native coordinate system # paint markers in native coordinate system
orig_tr = p.transform() orig_tr = p.transform()

View File

@ -107,9 +107,8 @@ async def _async_main(
# setup search widget and focus main chart view at startup # setup search widget and focus main chart view at startup
# search widget is a singleton alongside the godwidget # search widget is a singleton alongside the godwidget
search = _search.SearchWidget(godwidget=godwidget) search = _search.SearchWidget(godwidget=godwidget)
search.bar.unfocus() # search.bar.unfocus()
# godwidget.hbox.addWidget(search)
godwidget.hbox.addWidget(search)
godwidget.search = search godwidget.search = search
symbol, _, provider = sym.rpartition('.') symbol, _, provider = sym.rpartition('.')
@ -178,6 +177,6 @@ def _main(
run_qtractor( run_qtractor(
func=_async_main, func=_async_main,
args=(sym, brokernames, piker_loglevel), args=(sym, brokernames, piker_loglevel),
main_widget=GodWidget, main_widget_type=GodWidget,
tractor_kwargs=tractor_kwargs, tractor_kwargs=tractor_kwargs,
) )

View File

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

View File

@ -18,8 +18,13 @@
Mouse interaction graphics Mouse interaction graphics
""" """
from __future__ import annotations
from functools import partial from functools import partial
from typing import Optional, Callable from typing import (
Optional,
Callable,
TYPE_CHECKING,
)
import inspect import inspect
import numpy as np import numpy as np
@ -36,6 +41,12 @@ from ._style import (
from ._axes import YAxisLabel, XAxisLabel from ._axes import YAxisLabel, XAxisLabel
from ..log import get_logger from ..log import get_logger
if TYPE_CHECKING:
from ._chart import (
ChartPlotWidget,
LinkedSplits,
)
log = get_logger(__name__) log = get_logger(__name__)
@ -58,7 +69,7 @@ class LineDot(pg.CurvePoint):
curve: pg.PlotCurveItem, curve: pg.PlotCurveItem,
index: int, index: int,
plot: 'ChartPlotWidget', # type: ingore # noqa plot: ChartPlotWidget, # type: ingore # noqa
pos=None, pos=None,
color: str = 'default_light', color: str = 'default_light',
@ -151,7 +162,7 @@ class ContentsLabel(pg.LabelItem):
def __init__( def __init__(
self, self,
# chart: 'ChartPlotWidget', # noqa # chart: ChartPlotWidget, # noqa
view: pg.ViewBox, view: pg.ViewBox,
anchor_at: str = ('top', 'right'), anchor_at: str = ('top', 'right'),
@ -244,7 +255,7 @@ class ContentsLabels:
''' '''
def __init__( def __init__(
self, self,
linkedsplits: 'LinkedSplits', # type: ignore # noqa linkedsplits: LinkedSplits, # type: ignore # noqa
) -> None: ) -> None:
@ -289,7 +300,7 @@ class ContentsLabels:
def add_label( def add_label(
self, self,
chart: 'ChartPlotWidget', # type: ignore # noqa chart: ChartPlotWidget, # type: ignore # noqa
name: str, name: str,
anchor_at: tuple[str, str] = ('top', 'left'), anchor_at: tuple[str, str] = ('top', 'left'),
update_func: Callable = ContentsLabel.update_from_value, update_func: Callable = ContentsLabel.update_from_value,
@ -316,7 +327,7 @@ class Cursor(pg.GraphicsObject):
def __init__( def __init__(
self, self,
linkedsplits: 'LinkedSplits', # noqa linkedsplits: LinkedSplits, # noqa
digits: int = 0 digits: int = 0
) -> None: ) -> None:
@ -325,6 +336,8 @@ class Cursor(pg.GraphicsObject):
self.linked = linkedsplits self.linked = linkedsplits
self.graphics: dict[str, pg.GraphicsObject] = {} 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.plots: list['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None self.active_plot = None
self.digits: int = digits self.digits: int = digits
@ -385,7 +398,7 @@ class Cursor(pg.GraphicsObject):
def add_plot( def add_plot(
self, self,
plot: 'ChartPlotWidget', # noqa plot: ChartPlotWidget, # noqa
digits: int = 0, digits: int = 0,
) -> None: ) -> None:
@ -469,7 +482,7 @@ class Cursor(pg.GraphicsObject):
def add_curve_cursor( def add_curve_cursor(
self, self,
plot: 'ChartPlotWidget', # noqa plot: ChartPlotWidget, # noqa
curve: 'PlotCurveItem', # noqa curve: 'PlotCurveItem', # noqa
) -> LineDot: ) -> LineDot:
@ -491,17 +504,29 @@ class Cursor(pg.GraphicsObject):
log.debug(f"{(action, plot.name)}") log.debug(f"{(action, plot.name)}")
if action == 'Enter': if action == 'Enter':
self.active_plot = plot self.active_plot = plot
plot.linked.godwidget._active_cursor = self
# show horiz line and y-label # show horiz line and y-label
self.graphics[plot]['hl'].show() self.graphics[plot]['hl'].show()
self.graphics[plot]['yl'].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]['hl'].hide()
self.graphics[plot]['yl'].hide() self.graphics[plot]['yl'].hide()
if (
not self.always_show_xlabel
and self.xaxis_label.isVisible()
):
self.xaxis_label.hide()
def mouseMoved( def mouseMoved(
self, self,
coords: tuple[QPointF], # noqa coords: tuple[QPointF], # noqa
@ -590,13 +615,17 @@ class Cursor(pg.GraphicsObject):
left_axis_width += left.width() left_axis_width += left.width()
# map back to abs (label-local) coordinates # map back to abs (label-local) coordinates
self.xaxis_label.update_label( if (
abs_pos=( self.always_show_xlabel
plot.mapFromView(QPointF(vl_x, iy)) - or self.xaxis_label.isVisible()
QPointF(left_axis_width, 0) ):
), self.xaxis_label.update_label(
value=ix, abs_pos=(
) plot.mapFromView(QPointF(vl_x, iy)) -
QPointF(left_axis_width, 0)
),
value=ix,
)
self._datum_xy = ix, iy 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. graphics update methods via our custom ``pyqtgraph`` charting api.
''' '''
from dataclasses import dataclass
from functools import partial from functools import partial
import time import time
from typing import Optional, Any, Callable from typing import Optional, Any, Callable
import numpy as np
import tractor import tractor
import trio import trio
import pendulum
import pyqtgraph as pg import pyqtgraph as pg
# from .. import brokers # 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 ._axes import YAxisLabel
from ._chart import ( from ._chart import (
ChartPlotWidget, ChartPlotWidget,
@ -41,6 +42,7 @@ from ._chart import (
GodWidget, GodWidget,
) )
from ._l1 import L1Labels from ._l1 import L1Labels
from ._style import hcolor
from ._fsp import ( from ._fsp import (
update_fsp_chart, update_fsp_chart,
start_fsp_displays, start_fsp_displays,
@ -53,7 +55,10 @@ from ._forms import (
FieldsForm, FieldsForm,
mk_order_pane_layout, mk_order_pane_layout,
) )
from .order_mode import open_order_mode from .order_mode import (
open_order_mode,
OrderMode,
)
from .._profile import ( from .._profile import (
pg_profile_enabled, pg_profile_enabled,
ms_slower_then, ms_slower_then,
@ -63,7 +68,7 @@ from ..log import get_logger
log = get_logger(__name__) log = get_logger(__name__)
# TODO: load this from a config.toml! # 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 # a working tick-type-classes template
@ -122,39 +127,105 @@ def chart_maxmin(
) )
@dataclass class DisplayState(Struct):
class DisplayState:
''' '''
Chart-local real-time graphics state container. Chart-local real-time graphics state container.
''' '''
godwidget: GodWidget
quotes: dict[str, Any] quotes: dict[str, Any]
maxmin: Callable maxmin: Callable
ohlcv: ShmArray ohlcv: ShmArray
hist_ohlcv: ShmArray
# high level chart handles # high level chart handles
linked: LinkedSplits
chart: ChartPlotWidget chart: ChartPlotWidget
# axis labels # axis labels
l1: L1Labels l1: L1Labels
last_price_sticky: YAxisLabel last_price_sticky: YAxisLabel
hist_last_price_sticky: YAxisLabel
# misc state tracking # 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_chart: Optional[ChartPlotWidget] = None
vlm_sticky: Optional[YAxisLabel] = None vlm_sticky: Optional[YAxisLabel] = None
wap_in_history: bool = False 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( async def graphics_update_loop(
linked: LinkedSplits, nurse: trio.Nursery,
stream: tractor.MsgStream, godwidget: GodWidget,
ohlcv: np.ndarray, feed: Feed,
wap_in_history: bool = False, wap_in_history: bool = False,
vlm_chart: Optional[ChartPlotWidget] = None, vlm_chart: Optional[ChartPlotWidget] = None,
@ -175,9 +246,14 @@ async def graphics_update_loop(
# of copying it from last bar's close # of copying it from last bar's close
# - 1-5 sec bar lookback-autocorrection like tws does? # - 1-5 sec bar lookback-autocorrection like tws does?
# (would require a background history checker task) # (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 chart = linked.chart
hist_chart = godwidget.hist_linked.chart
ohlcv = feed.rt_shm
hist_ohlcv = feed.hist_shm
# update last price sticky # update last price sticky
last_price_sticky = chart._ysticks[chart.name] last_price_sticky = chart._ysticks[chart.name]
@ -185,6 +261,11 @@ async def graphics_update_loop(
*ohlcv.array[-1][['index', 'close']] *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( maxmin = partial(
chart_maxmin, chart_maxmin,
chart, chart,
@ -227,12 +308,14 @@ async def graphics_update_loop(
i_last = ohlcv.index i_last = ohlcv.index
ds = linked.display_state = DisplayState(**{ ds = linked.display_state = DisplayState(**{
'godwidget': godwidget,
'quotes': {}, 'quotes': {},
'linked': linked,
'maxmin': maxmin, 'maxmin': maxmin,
'ohlcv': ohlcv, 'ohlcv': ohlcv,
'hist_ohlcv': hist_ohlcv,
'chart': chart, 'chart': chart,
'last_price_sticky': last_price_sticky, 'last_price_sticky': last_price_sticky,
'hist_last_price_sticky': hist_last_price_sticky,
'l1': l1, 'l1': l1,
'vars': { 'vars': {
@ -252,7 +335,62 @@ async def graphics_update_loop(
chart.default_view() 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 # main real-time quotes update loop
stream: tractor.MsgStream = feed.stream
async for quotes in stream: async for quotes in stream:
ds.quotes = quotes 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) # chart isn't active/shown so skip render cycle and pause feed(s)
if chart.linked.isHidden(): if chart.linked.isHidden():
print('skipping update') # print('skipping update')
chart.pause_all_feeds() chart.pause_all_feeds()
continue continue
@ -298,6 +436,8 @@ def graphics_update_cycle(
# hopefully XD # hopefully XD
chart = ds.chart 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( profiler = pg.debug.Profiler(
msg=f'Graphics loop cycle for: `{chart.name}`', msg=f'Graphics loop cycle for: `{chart.name}`',
@ -311,53 +451,24 @@ def graphics_update_cycle(
# unpack multi-referenced components # unpack multi-referenced components
vlm_chart = ds.vlm_chart vlm_chart = ds.vlm_chart
# rt "HFT" chart
l1 = ds.l1 l1 = ds.l1
ohlcv = ds.ohlcv ohlcv = ds.ohlcv
array = ohlcv.array array = ohlcv.array
vars = ds.vars vars = ds.vars
tick_margin = vars['tick_margin'] tick_margin = vars['tick_margin']
update_uppx = 16
for sym, quote in ds.quotes.items(): for sym, quote in ds.quotes.items():
(
# compute the first available graphic's x-units-per-pixel uppx,
uppx = chart.view.x_uppx() liv,
do_append,
# NOTE: vlm may be written by the ``brokerd`` backend i_diff,
# event though a tick sample is not emitted. append_diff,
# TODO: show dark trades differently do_rt_update,
# https://github.com/pikers/piker/issues/116 ) = ds.incr_info()
# 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}'
# )
# TODO: we should only run mxmn when we know # TODO: we should only run mxmn when we know
# an update is due via ``do_append`` above. # an update is due via ``do_append`` above.
@ -373,8 +484,6 @@ def graphics_update_cycle(
profiler('`ds.maxmin()` call') profiler('`ds.maxmin()` call')
liv = r >= i_step # the last datum is in view
if ( if (
prepend_update_index is not None prepend_update_index is not None
and lbar > prepend_update_index and lbar > prepend_update_index
@ -389,16 +498,10 @@ def graphics_update_cycle(
# don't real-time "shift" the curve to the # don't real-time "shift" the curve to the
# left unless we get one of the following: # left unless we get one of the following:
if ( if (
( (do_append and liv)
# i_diff > 0 # no new sample step
do_append
# and uppx < 4 # chart is zoomed out very far
and liv
)
or trigger_all or trigger_all
): ):
chart.increment_view(steps=i_diff) chart.increment_view(steps=i_diff)
# chart.increment_view(steps=i_diff + round(append_diff - uppx))
if vlm_chart: if vlm_chart:
vlm_chart.increment_view(steps=i_diff) vlm_chart.increment_view(steps=i_diff)
@ -458,6 +561,10 @@ def graphics_update_cycle(
chart.name, chart.name,
do_append=do_append, do_append=do_append,
) )
hist_chart.update_graphics_from_flow(
chart.name,
do_append=do_append,
)
# NOTE: we always update the "last" datum # NOTE: we always update the "last" datum
# since the current range should at least be updated # since the current range should at least be updated
@ -495,6 +602,9 @@ def graphics_update_cycle(
ds.last_price_sticky.update_from_data( ds.last_price_sticky.update_from_data(
*end[['index', 'close']] *end[['index', 'close']]
) )
ds.hist_last_price_sticky.update_from_data(
*end[['index', 'close']]
)
if wap_in_history: if wap_in_history:
# update vwap overlay line # update vwap overlay line
@ -542,26 +652,44 @@ def graphics_update_cycle(
l1.bid_label.update_fields({'level': price, 'size': size}) l1.bid_label.update_fields({'level': price, 'size': size})
# check for y-range re-size # check for y-range re-size
if ( if (mx > vars['last_mx']) or (mn < vars['last_mn']):
(mx > vars['last_mx']) or (mn < vars['last_mn'])
and not chart._static_yrange == 'axis' # fast chart resize case
and liv
):
main_vb = chart.view
if ( if (
main_vb._ic is None liv
or not main_vb._ic.is_set() and not chart._static_yrange == 'axis'
): ):
# print(f'updating range due to mxmn') main_vb = chart.view
main_vb._set_yrange( if (
# TODO: we should probably scale main_vb._ic is None
# the view margin based on the size or not main_vb._ic.is_set()
# of the true range? This way you can ):
# slap in orders outside the current # print(f'updating range due to mxmn')
# L1 (only) book range. main_vb._set_yrange(
# range_margin=0.1, # TODO: we should probably scale
yrange=(mn, mx), # 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. # XXX: update this every draw cycle to make L1-always-in-view work.
vars['last_mx'], vars['last_mn'] = mx, mn vars['last_mx'], vars['last_mn'] = mx, mn
@ -719,15 +847,17 @@ async def display_symbol_data(
tick_throttle=_quote_throttle_rate, tick_throttle=_quote_throttle_rate,
) as feed: ) as feed:
ohlcv: ShmArray = feed.shm ohlcv: ShmArray = feed.rt_shm
bars = ohlcv.array 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] symbol = feed.symbols[sym]
fqsn = symbol.front_fqsn() fqsn = symbol.front_fqsn()
times = bars['time'] step_size_s = 1
end = pendulum.from_timestamp(times[-1])
start = pendulum.from_timestamp(times[times != times[-1]][-1])
step_size_s = (end - start).seconds
tf_key = tf_in_1s[step_size_s] tf_key = tf_in_1s[step_size_s]
# load in symbol's ohlc data # load in symbol's ohlc data
@ -737,51 +867,158 @@ async def display_symbol_data(
f'step:{tf_key} ' f'step:{tf_key} '
) )
linked = godwidget.linkedsplits rt_linked = godwidget.rt_linked
linked._symbol = symbol 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 # generate order mode side-pane UI
# A ``FieldsForm`` form to configure order entry # A ``FieldsForm`` form to configure order entry
# and add as next-to-y-axis singleton pane
pp_pane: FieldsForm = mk_order_pane_layout(godwidget) pp_pane: FieldsForm = mk_order_pane_layout(godwidget)
# add as next-to-y-axis singleton pane
godwidget.pp_pane = pp_pane godwidget.pp_pane = pp_pane
# create main OHLC chart # create main OHLC chart
chart = linked.plot_ohlc_main( chart = rt_linked.plot_ohlc_main(
symbol, symbol,
ohlcv, ohlcv,
# in the case of history chart we explicitly set `False`
# to avoid internal pane creation.
sidepane=pp_pane, sidepane=pp_pane,
) )
chart.default_view()
chart._feeds[symbol.key] = feed chart._feeds[symbol.key] = feed
chart.setFocus() chart.setFocus()
# XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?!
# plot historical vwap if available # plot historical vwap if available
wap_in_history = False 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!?! # Add the LinearRegionItem to the ViewBox, but tell the ViewBox
# if brokermod._show_wap_in_history: # 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: # poll for datums load and timestep detection
# wap_in_history = True for _ in range(100):
# chart.draw_curve( try:
# name='bar_wap', _, _, ratio = feed.get_ds_info()
# shm=ohlcv, break
# color='default_light', except IndexError:
# add_label=False, await trio.sleep(0.01)
# ) continue
else:
raise RuntimeError(
'Failed to detect sampling periods from shm!?')
# size view to data once at outset def update_pi_from_region():
chart.cv._set_yrange() 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 # NOTE: we must immediately tell Qt to show the OHLC chart
# to avoid a race where the subplots get added/shown to # to avoid a race where the subplots get added/shown to
# the linked set *before* the main price chart! # the linked set *before* the main price chart!
linked.show() rt_linked.show()
linked.focus() rt_linked.focus()
await trio.sleep(0) 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 vlm_chart: Optional[ChartPlotWidget] = None
async with trio.open_nursery() as ln: async with trio.open_nursery() as ln:
@ -792,7 +1029,7 @@ async def display_symbol_data(
): ):
vlm_chart = await ln.start( vlm_chart = await ln.start(
open_vlm_displays, open_vlm_displays,
linked, rt_linked,
ohlcv, ohlcv,
) )
@ -800,7 +1037,7 @@ async def display_symbol_data(
# from an input config. # from an input config.
ln.start_soon( ln.start_soon(
start_fsp_displays, start_fsp_displays,
linked, rt_linked,
ohlcv, ohlcv,
loading_sym_key, loading_sym_key,
loglevel, loglevel,
@ -809,39 +1046,73 @@ async def display_symbol_data(
# start graphics update loop after receiving first live quote # start graphics update loop after receiving first live quote
ln.start_soon( ln.start_soon(
graphics_update_loop, graphics_update_loop,
linked, ln,
feed.stream, godwidget,
ohlcv, feed,
wap_in_history, wap_in_history,
vlm_chart, 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 ( async with (
open_order_mode( open_order_mode(
feed, feed,
chart, godwidget,
fqsn, fqsn,
order_mode_started order_mode_started
) ) as mode
): ):
if not vlm_chart: if not vlm_chart:
# trigger another view reset if no sub-chart
chart.default_view() chart.default_view()
rt_linked.mode = mode
# let Qt run to render all widgets and make sure the # let Qt run to render all widgets and make sure the
# sidepanes line up vertically. # sidepanes line up vertically.
await trio.sleep(0) 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 # NOTE: we pop the volume chart from the subplots set so
# that it isn't double rendered in the display loop # that it isn't double rendered in the display loop
# above since we do a maxmin calc on the volume data to # above since we do a maxmin calc on the volume data to
# determine if auto-range adjustements should be made. # 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 # TODO: make this not so shit XD
# close group status # close group status
sbar._status_groups[loading_sym_key][1]() 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 # let the app run.. bby
# linked.graphics_cycle()
await trio.sleep_forever() await trio.sleep_forever()

View File

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

View File

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

View File

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

View File

@ -644,7 +644,7 @@ def mk_fill_status_bar(
# TODO: calc this height from the ``ChartnPane`` # TODO: calc this height from the ``ChartnPane``
chart_h = round(parent_pane.height() * 5/8) 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 # TODO: once things are sized to screen
bar_label_font_size = label_font_size or _font.px_size - 2 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, 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 # esc and ctrl-c
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
# ctrl-c as cancel # ctrl-c as cancel
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
view.select_box.clear() view.select_box.clear()
view.linked.focus()
# cancel order or clear graphics # cancel order or clear graphics
if key == Qt.Key_C or key == Qt.Key_Delete: if key == Qt.Key_C or key == Qt.Key_Delete:
@ -178,17 +181,17 @@ async def handle_viewmode_kb_inputs(
if key in pressed: if key in pressed:
pressed.remove(key) pressed.remove(key)
# QUERY/QUOTE MODE # # QUERY/QUOTE MODE
# ----------------
if {Qt.Key_Q}.intersection(pressed): if {Qt.Key_Q}.intersection(pressed):
view.linkedsplits.cursor.in_query_mode = True view.linked.cursor.in_query_mode = True
else: else:
view.linkedsplits.cursor.in_query_mode = False view.linked.cursor.in_query_mode = False
# SELECTION MODE # SELECTION MODE
# -------------- # --------------
if shift: if shift:
if view.state['mouseMode'] == ViewBox.PanMode: if view.state['mouseMode'] == ViewBox.PanMode:
view.setMouseMode(ViewBox.RectMode) view.setMouseMode(ViewBox.RectMode)
@ -209,14 +212,22 @@ async def handle_viewmode_kb_inputs(
# ORDER MODE # ORDER MODE
# ---------- # ----------
# live vs. dark trigger + an action {buy, sell, alert} # live vs. dark trigger + an action {buy, sell, alert}
order_keys_pressed = ORDER_MODE.intersection(pressed) order_keys_pressed = ORDER_MODE.intersection(pressed)
if order_keys_pressed: if order_keys_pressed:
# show the pp size label # TODO: it seems like maybe the composition should be
order_mode.current_pp.show() # 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 # TODO: show pp config mini-params in status bar widget
# mode.pp_config.show() # mode.pp_config.show()
@ -257,8 +268,8 @@ async def handle_viewmode_kb_inputs(
Qt.Key_S in pressed or Qt.Key_S in pressed or
order_keys_pressed or order_keys_pressed or
Qt.Key_O in pressed Qt.Key_O in pressed
) and )
key in NUMBER_LINE and key in NUMBER_LINE
): ):
# hot key to set order slots size. # hot key to set order slots size.
# change edit field to current number line value, # change edit field to current number line value,
@ -276,7 +287,7 @@ async def handle_viewmode_kb_inputs(
else: # none active else: # none active
# hide pp label # hide pp label
order_mode.current_pp.hide_info() order_mode.current_pp.nav.hide_info()
# if none are pressed, remove "staged" level # if none are pressed, remove "staged" level
# line under cursor position # line under cursor position
@ -373,7 +384,7 @@ class ChartView(ViewBox):
y=True, y=True,
) )
self.linkedsplits = None self.linked = None
self._chart: 'ChartPlotWidget' = None # noqa self._chart: 'ChartPlotWidget' = None # noqa
# add our selection box annotator # add our selection box annotator
@ -484,7 +495,7 @@ class ChartView(ViewBox):
else: else:
mask = self.state['mouseEnabled'][:] mask = self.state['mouseEnabled'][:]
chart = self.linkedsplits.chart chart = self.linked.chart
# don't zoom more then the min points setting # don't zoom more then the min points setting
l, lbar, rbar, r = chart.bars_range() 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 # TODO: a faster single-loop-iterator way of doing this XD
chart = self._chart chart = self._chart
linked = self.linkedsplits linked = self.linked
plots = linked.subplots | {chart.name: chart} plots = linked.subplots | {chart.name: chart}
for chart_name, chart in plots.items(): for chart_name, chart in plots.items():
for name, flow in chart._flows.items(): for name, flow in chart._flows.items():

View File

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

View File

@ -23,7 +23,11 @@ from copy import copy
from dataclasses import dataclass from dataclasses import dataclass
from functools import partial from functools import partial
from math import floor, copysign from math import floor, copysign
from typing import Optional from typing import (
Callable,
Optional,
TYPE_CHECKING,
)
# from PyQt5.QtWidgets import QStyle # from PyQt5.QtWidgets import QStyle
@ -41,12 +45,18 @@ from ..calc import humanize, pnl, puterize
from ..clearing._allocate import Allocator, Position from ..clearing._allocate import Allocator, Position
from ..data._normalize import iterticks from ..data._normalize import iterticks
from ..data.feed import Feed from ..data.feed import Feed
from ..data.types import Struct
from ._label import Label from ._label import Label
from ._lines import LevelLine, order_line from ._lines import LevelLine, order_line
from ._style import _font from ._style import _font
from ._forms import FieldsForm, FillStatusBar, QLabel from ._forms import FieldsForm, FillStatusBar, QLabel
from ..log import get_logger from ..log import get_logger
if TYPE_CHECKING:
from ._chart import (
ChartPlotWidget,
)
log = get_logger(__name__) log = get_logger(__name__)
_pnl_tasks: dict[str, bool] = {} _pnl_tasks: dict[str, bool] = {}
@ -58,7 +68,8 @@ async def update_pnl_from_feed(
tracker: PositionTracker, tracker: PositionTracker,
) -> None: ) -> 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. ``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 pp = order_mode.current_pp
live = pp.live_pp live = pp.live_pp
key = live.symbol.key key = live.symbol.front_fqsn()
log.info(f'Starting pnl display for {pp.alloc.account}') log.info(f'Starting pnl display for {pp.alloc.account}')
@ -168,12 +179,12 @@ class SettingsPane:
) -> None: ) -> None:
''' '''
Try to apply some input setting (by the user), revert to previous setting if it fails Try to apply some input setting (by the user), revert to
display new value if applied. previous setting if it fails display new value if applied.
''' '''
self.apply_setting(key, value) 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( def apply_setting(
self, self,
@ -195,7 +206,7 @@ class SettingsPane:
# hide details on the old selection # hide details on the old selection
old_tracker = mode.current_pp old_tracker = mode.current_pp
old_tracker.hide_info() old_tracker.nav.hide_info()
# re-assign the order mode tracker # re-assign the order mode tracker
account_name = value account_name = value
@ -205,7 +216,7 @@ class SettingsPane:
# a ``brokerd`) then error and switch back to the last # a ``brokerd`) then error and switch back to the last
# selection. # selection.
if tracker is None: if tracker is None:
sym = old_tracker.chart.linked.symbol.key sym = old_tracker.charts[0].linked.symbol.key
log.error( log.error(
f'Account `{account_name}` can not be set for {sym}' f'Account `{account_name}` can not be set for {sym}'
) )
@ -216,8 +227,8 @@ class SettingsPane:
self.order_mode.current_pp = tracker self.order_mode.current_pp = tracker
assert tracker.alloc.account == account_name assert tracker.alloc.account == account_name
self.form.fields['account'].setCurrentText(account_name) self.form.fields['account'].setCurrentText(account_name)
tracker.show() tracker.nav.show()
tracker.hide_info() tracker.nav.hide_info()
self.display_pnl(tracker) self.display_pnl(tracker)
@ -251,7 +262,9 @@ class SettingsPane:
log.error( log.error(
f'limit must > then current pp: {dsize}' f'limit must > then current pp: {dsize}'
) )
raise ValueError # reset position size value
alloc.currency_limit = dsize
return False
alloc.currency_limit = value alloc.currency_limit = value
@ -288,22 +301,29 @@ class SettingsPane:
def update_status_ui( def update_status_ui(
self, self,
pp: PositionTracker, tracker: PositionTracker,
) -> None: ) -> None:
alloc = pp.alloc alloc = tracker.alloc
slots = alloc.slots 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 # READ out settings and update the status UI / settings widgets
suffix = {'currency': ' $', 'units': ' u'}[alloc.size_unit] suffix = {'currency': ' $', 'units': ' u'}[alloc.size_unit]
limit = alloc.limit() size_unit, limit = alloc.limit_info()
step_size, currency_per_slot = alloc.step_sizes() step_size, currency_per_slot = alloc.step_sizes()
if alloc.size_unit == 'currency': if alloc.size_unit == 'currency':
step_size = currency_per_slot 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( self.step_label.format(
step_size=str(humanize(step_size)) + suffix step_size=str(humanize(step_size)) + suffix
@ -320,7 +340,7 @@ class SettingsPane:
self.form.fields['limit'].setText(str(limit)) self.form.fields['limit'].setText(str(limit))
# update of level marker size label based on any new settings # 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 # calculate proportion of position size limit
# that exists and display in fill bar # that exists and display in fill bar
@ -332,7 +352,7 @@ class SettingsPane:
# min(round(prop * slots), slots) # min(round(prop * slots), slots)
min(used, 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( def update_account_icons(
self, self,
@ -358,7 +378,9 @@ class SettingsPane:
tracker: PositionTracker, tracker: PositionTracker,
) -> None: ) -> 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 If a position is open start a background task which will
real-time update the pnl label in the settings pane. real-time update the pnl label in the settings pane.
@ -372,7 +394,7 @@ class SettingsPane:
if size: if size:
# last historical close price # 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( pnl_value = copysign(1, size) * pnl(
tracker.live_pp.ppu, tracker.live_pp.ppu,
last, last,
@ -380,8 +402,9 @@ class SettingsPane:
# maybe start update task # maybe start update task
global _pnl_tasks global _pnl_tasks
if sym.key not in _pnl_tasks: fqsn = sym.front_fqsn()
_pnl_tasks[sym.key] = True if fqsn not in _pnl_tasks:
_pnl_tasks[fqsn] = True
self.order_mode.nursery.start_soon( self.order_mode.nursery.start_soon(
update_pnl_from_feed, update_pnl_from_feed,
feed, feed,
@ -393,15 +416,15 @@ class SettingsPane:
self.pnl_label.format(pnl=pnl_value) self.pnl_label.format(pnl=pnl_value)
def position_line( def pp_line(
chart: 'ChartPlotWidget', # noqa chart: ChartPlotWidget, # noqa
size: float, size: float,
level: float, level: float,
color: str, color: str,
marker: LevelMarker,
orient_v: str = 'bottom', orient_v: str = 'bottom',
marker: Optional[LevelMarker] = None,
) -> LevelLine: ) -> LevelLine:
''' '''
@ -432,28 +455,20 @@ def position_line(
show_markers=False, show_markers=False,
) )
if marker: # TODO: use `LevelLine.add_marker()`` for this instead?
# configure marker to position data # 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 line._marker = marker
style = '|<' # point "up to" the line line.track_marker_pos = True
elif size < 0: # short
style = '>|' # point "down to" the line
marker.style = style # show position marker on view "edge" when out of view
vb = line.getViewBox()
# set marker color to same as line vb.sigRangeChanged.connect(marker.position_in_view)
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)
return line 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: class PositionTracker:
''' '''
Track and display real-time positions for a single symbol Track and display real-time positions for a single asset-symbol
over multiple accounts on a single chart. held in a single account, normally shown on a single chart.
Graphically composed of a level line and marker as well as labels Graphically composed of a level line and marker as well as labels
for indcating current position information. Updates are made to the for indcating current position information. Updates are made to the
corresponding "settings pane" for the chart's "order mode" UX. corresponding "settings pane" for the chart's "order mode" UX.
''' '''
# inputs
chart: 'ChartPlotWidget' # noqa
alloc: Allocator alloc: Allocator
startup_pp: Position startup_pp: Position
live_pp: Position live_pp: Position
nav: Nav # holds all UI elements across all charts
# allocated
pp_label: Label
size_label: Label
line: Optional[LevelLine] = None
_color: str = 'default_lightest'
def __init__( def __init__(
self, self,
chart: 'ChartPlotWidget', # noqa charts: list[ChartPlotWidget],
alloc: Allocator, alloc: Allocator,
startup_pp: Position, startup_pp: Position,
) -> None: ) -> None:
self.chart = chart nav = self.nav = Nav(charts={id(chart): chart for chart in charts})
self.alloc = alloc self.alloc = alloc
self.startup_pp = startup_pp self.startup_pp = startup_pp
self.live_pp = copy(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 arrow = mk_level_marker(
self.pp_label = pp_label = Label( chart=chart,
view=view, size=1,
fmt_str='pp', level=nav.level,
color=self._color, on_paint=nav.update_graphics,
update_on_range_change=False, )
)
# create placeholder 'up' level arrow # TODO: we really need some kinda "spacing" manager for all
self._level_marker = None # this stuff...
self._level_marker = self.level_marker(size=1) 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, x = chart.marker_right_points()[1]
gpath=self._level_marker, if chart._max_l1_line_len == 0:
label=pp_label, mkw = pp_label.txt.boundingRect().width()
) x -= 1.5 * mkw
pp_label.render()
self.size_label = size_label = Label( return x
view=view,
color=self._color,
# this is "static" label arrow.scene_x = offset_from_yaxis
# update_on_range_change=False, view.scene().addItem(arrow)
fmt_str='\n'.join(( arrow.hide() # never show on startup
':{slots_used:.1f}x', nav.level_markers[key] = arrow
)),
fields={ # literally the 'pp' (pee pee) "position price" label that's
'slots_used': 0, # always in view
}, pp_label = Label(
) view=view,
size_label.render() 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( size_label = Label(
pp_tight_and_right, view=view,
label=self.pp_label, 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 @property
def pane(self) -> FieldsForm: def pane(self) -> FieldsForm:
@ -554,21 +822,6 @@ class PositionTracker:
''' '''
return self.chart.linked.godwidget.pp_pane 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( def update_from_pp(
self, self,
position: Optional[Position] = None, position: Optional[Position] = None,
@ -621,142 +874,22 @@ class PositionTracker:
if asset_type in _derivs: if asset_type in _derivs:
alloc.slots = alloc.units_limit alloc.slots = alloc.units_limit
self.update_line( self.nav.update_ui(
self.alloc.account,
pp.ppu, pp.ppu,
pp.size, 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: if self.live_pp.size:
self.line.show() # print("SHOWING NAV")
self.line.show_labels() self.nav.show()
self._level_marker.show() # if pp.size == 0:
self.pp_label.show() else:
self.size_label.show() # print("HIDING NAV")
self.nav.hide()
def hide(self) -> None: # don't show side and status widgets unless
self.pp_label.hide() # order mode is "engaged" (which done via input controls)
self._level_marker.hide() self.nav.hide_info()
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

View File

@ -35,9 +35,13 @@ from collections import defaultdict
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from functools import partial from functools import partial
from typing import ( from typing import (
Optional, Callable, Optional,
Awaitable, Sequence, Callable,
Any, AsyncIterator Awaitable,
Sequence,
Any,
AsyncIterator,
Iterator,
) )
import time import time
# from pprint import pformat # from pprint import pformat
@ -119,7 +123,7 @@ class CompleterView(QTreeView):
# TODO: size this based on DPI font # TODO: size this based on DPI font
self.setIndentation(_font.px_size) self.setIndentation(_font.px_size)
# self.setUniformRowHeights(True) self.setUniformRowHeights(True)
# self.setColumnWidth(0, 3) # self.setColumnWidth(0, 3)
# self.setVerticalBarPolicy(Qt.ScrollBarAlwaysOff) # self.setVerticalBarPolicy(Qt.ScrollBarAlwaysOff)
# self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored) # self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
@ -138,13 +142,15 @@ class CompleterView(QTreeView):
model.setHorizontalHeaderLabels(labels) model.setHorizontalHeaderLabels(labels)
self._font_size: int = 0 # pixels self._font_size: int = 0 # pixels
self._init: bool = False
async def on_pressed(self, idx: QModelIndex) -> None: async def on_pressed(self, idx: QModelIndex) -> None:
'''Mouse pressed on view handler. '''
Mouse pressed on view handler.
''' '''
search = self.parent() search = self.parent()
await search.chart_current_item(clear_to_cache=False) await search.chart_current_item()
search.focus() search.focus()
def set_font_size(self, size: int = 18): def set_font_size(self, size: int = 18):
@ -156,56 +162,64 @@ class CompleterView(QTreeView):
self.setStyleSheet(f"font: {size}px") self.setStyleSheet(f"font: {size}px")
# def resizeEvent(self, event: 'QEvent') -> None: def resize_to_results(
# event.accept() self,
# super().resizeEvent(event) w: Optional[float] = 0,
h: Optional[float] = None,
def on_resize(self) -> None: ) -> None:
'''
Resize relay event from god.
'''
self.resize_to_results()
def resize_to_results(self):
model = self.model() model = self.model()
cols = model.columnCount() 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 col_w_tot = 0
for i in range(cols): 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) self.resizeColumnToContents(i)
col_w_tot += self.columnWidth(i) col_w_tot += self.columnWidth(i)
win = self.window() # NOTE: if the heigh `h` set here is **too large** then the
win_h = win.height() # resize event will perpetually trigger as the window causes
edit_h = self.parent().bar.height() # some kind of recompute of callbacks.. so we have to ensure
sb_h = win.statusBar().height() # 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 if rows_h <= abs_mx:
# we should figure out the exact number of rows to allow # self.setMinimumHeight(rows_h)
# inclusive of search bar and header "rows", in pixel terms. self.setMinimumHeight(rows_h)
# Eventually when we have an "info" widget below the results we # self.setFixedHeight(rows_h)
# 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)
# NOTE: if the heigh set here is **too large** then the resize else:
# event will perpetually trigger as the window causes some kind self.setMinimumHeight(abs_mx)
# 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))
# size to width of longest result seen thus far # dyncamically size to width of longest result seen
# TODO: should we always dynamically scale to longest result? curr_w = self.width()
if self.width() < col_w_tot: if curr_w < col_w_tot:
self.setFixedWidth(col_w_tot) self.setMinimumWidth(col_w_tot)
self.update() self.update()
@ -331,6 +345,23 @@ class CompleterView(QTreeView):
item = model.itemFromIndex(idx) item = model.itemFromIndex(idx)
yield idx, item 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( def find_section(
self, self,
section: str, section: str,
@ -354,7 +385,8 @@ class CompleterView(QTreeView):
status_field: str = None, status_field: str = None,
) -> 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) idx = self.find_section(section)
@ -375,8 +407,6 @@ class CompleterView(QTreeView):
else: else:
model.setItem(idx.row(), 1, QStandardItem()) model.setItem(idx.row(), 1, QStandardItem())
self.resize_to_results()
return idx return idx
else: else:
return None return None
@ -444,9 +474,22 @@ class CompleterView(QTreeView):
self.show_matches() 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.show()
self.resize_to_results()
class SearchBar(Edit): class SearchBar(Edit):
@ -466,18 +509,15 @@ class SearchBar(Edit):
self.godwidget = godwidget self.godwidget = godwidget
super().__init__(parent, **kwargs) super().__init__(parent, **kwargs)
self.view: CompleterView = view self.view: CompleterView = view
godwidget._widgets[view.mode_name] = view
def show(self) -> None:
super().show()
self.view.show_matches()
def unfocus(self) -> None: def unfocus(self) -> None:
self.parent().hide() self.parent().hide()
self.clearFocus() self.clearFocus()
def hide(self) -> None:
if self.view: if self.view:
self.view.hide() self.view.hide()
super().hide()
class SearchWidget(QtWidgets.QWidget): class SearchWidget(QtWidgets.QWidget):
@ -496,15 +536,16 @@ class SearchWidget(QtWidgets.QWidget):
parent=None, parent=None,
) -> None: ) -> None:
super().__init__(parent or godwidget) super().__init__(parent)
# size it as we specify # size it as we specify
self.setSizePolicy( self.setSizePolicy(
QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed,
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed,
) )
self.godwidget = godwidget self.godwidget = godwidget
godwidget.reg_for_resize(self)
self.vbox = QtWidgets.QVBoxLayout(self) self.vbox = QtWidgets.QVBoxLayout(self)
self.vbox.setContentsMargins(0, 4, 4, 0) self.vbox.setContentsMargins(0, 4, 4, 0)
@ -554,17 +595,22 @@ class SearchWidget(QtWidgets.QWidget):
self.vbox.setAlignment(self.view, Qt.AlignTop | Qt.AlignLeft) self.vbox.setAlignment(self.view, Qt.AlignTop | Qt.AlignLeft)
def focus(self) -> None: 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.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]]: def get_current_item(self) -> Optional[tuple[str, str]]:
'''Return the current completer tree selection as '''Return the current completer tree selection as
@ -603,7 +649,8 @@ class SearchWidget(QtWidgets.QWidget):
clear_to_cache: bool = True, clear_to_cache: bool = True,
) -> Optional[str]: ) -> 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. completion result to the affiliated chart app.
Return any loaded symbol. Return any loaded symbol.
@ -614,11 +661,11 @@ class SearchWidget(QtWidgets.QWidget):
return None return None
provider, symbol = value provider, symbol = value
chart = self.godwidget godw = self.godwidget
log.info(f'Requesting symbol: {symbol}.{provider}') log.info(f'Requesting symbol: {symbol}.{provider}')
await chart.load_symbol( await godw.load_symbol(
provider, provider,
symbol, symbol,
'info', 'info',
@ -635,18 +682,46 @@ class SearchWidget(QtWidgets.QWidget):
# Re-order the symbol cache on the chart to display in # Re-order the symbol cache on the chart to display in
# LIFO order. this is normally only done internally by # LIFO order. this is normally only done internally by
# the chart on new symbols being loaded into memory # the chart on new symbols being loaded into memory
chart.set_chart_symbol(fqsn, chart.linkedsplits) godw.set_chart_symbol(
fqsn, (
self.view.set_section_entries( godw.hist_linked,
'cache', godw.rt_linked,
values=list(reversed(chart._chart_cache)), )
# remove all other completion results except for cache
clear_all=True,
) )
self.show_only_cache_entries()
self.bar.focus()
return fqsn 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_active: trio.Event = trio.Event()
_search_enabled: bool = False _search_enabled: bool = False
@ -712,10 +787,11 @@ async def fill_results(
max_pause_time: float = 6/16 + 0.001, max_pause_time: float = 6/16 + 0.001,
) -> None: ) -> None:
"""Task to search through providers and fill in possible '''
Task to search through providers and fill in possible
completion results. completion results.
""" '''
global _search_active, _search_enabled, _searcher_cache global _search_active, _search_enabled, _searcher_cache
bar = search.bar bar = search.bar
@ -729,6 +805,10 @@ async def fill_results(
matches = defaultdict(list) matches = defaultdict(list)
has_results: defaultdict[str, set[str]] = defaultdict(set) has_results: defaultdict[str, set[str]] = defaultdict(set)
# show cached feed list at startup
search.show_only_cache_entries()
search.on_resize()
while True: while True:
await _search_active.wait() await _search_active.wait()
period = None period = None
@ -742,7 +822,7 @@ async def fill_results(
pattern = await recv_chan.receive() pattern = await recv_chan.receive()
period = time.time() - wait_start 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 # during fast multiple key inputs, wait until a pause
# (in typing) to initiate search # (in typing) to initiate search
@ -841,8 +921,7 @@ async def handle_keyboard_input(
godwidget = search.godwidget godwidget = search.godwidget
view = bar.view view = bar.view
view.set_font_size(bar.dpi_font.px_size) view.set_font_size(bar.dpi_font.px_size)
send, recv = trio.open_memory_channel(616)
send, recv = trio.open_memory_channel(16)
async with trio.open_nursery() as n: 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: async for kbmsg in recv_chan:
event, etype, key, mods, txt = kbmsg.to_tuple() event, etype, key, mods, txt = kbmsg.to_tuple()
@ -867,10 +950,11 @@ async def handle_keyboard_input(
ctl = True ctl = True
if key in (Qt.Key_Enter, Qt.Key_Return): if key in (Qt.Key_Enter, Qt.Key_Return):
await search.chart_current_item(clear_to_cache=True)
_search_enabled = False _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(): elif not ctl and not bar.text():
# if nothing in search text show the cache # 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_Space, # i feel like this is the "native" one
Qt.Key_Alt, Qt.Key_Alt,
}: }:
search.bar.unfocus() bar.unfocus()
# kill the search and focus back on main chart # kill the search and focus back on main chart
if godwidget: if godwidget:
@ -935,9 +1019,10 @@ async def handle_keyboard_input(
if item: if item:
parent_item = item.parent() 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 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) await search.chart_current_item(clear_to_cache=False)
elif not ctl: elif not ctl:

View File

@ -21,7 +21,11 @@ Qt main window singletons and stuff.
import os import os
import signal import signal
import time import time
from typing import Callable, Optional, Union from typing import (
Callable,
Optional,
Union,
)
import uuid import uuid
from pyqtgraph import QtGui from pyqtgraph import QtGui
@ -30,6 +34,7 @@ from PyQt5.QtWidgets import QLabel, QStatusBar
from ..log import get_logger from ..log import get_logger
from ._style import _font_small, hcolor from ._style import _font_small, hcolor
from ._chart import GodWidget
log = get_logger(__name__) log = get_logger(__name__)
@ -153,7 +158,8 @@ class MainWindow(QtGui.QMainWindow):
# XXX: for tiling wms this should scale # XXX: for tiling wms this should scale
# with the alloted window size. # with the alloted window size.
# TODO: detect for tiling and if untrue set some 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)' title = 'piker chart (ur symbol is loading bby)'
@ -162,6 +168,9 @@ class MainWindow(QtGui.QMainWindow):
# self.setMinimumSize(*self.size) # self.setMinimumSize(*self.size)
self.setWindowTitle(self.title) self.setWindowTitle(self.title)
# set by runtime after `trio` is engaged.
self.godwidget: Optional[GodWidget] = None
self._status_bar: QStatusBar = None self._status_bar: QStatusBar = None
self._status_label: QLabel = None self._status_label: QLabel = None
self._size: Optional[tuple[int, int]] = None self._size: Optional[tuple[int, int]] = None
@ -248,9 +257,10 @@ class MainWindow(QtGui.QMainWindow):
self.set_mode_name(name) self.set_mode_name(name)
def current_screen(self) -> QtGui.QScreen: 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() app = QtGui.QApplication.instance()
for _ in range(3): for _ in range(3):
@ -284,7 +294,7 @@ class MainWindow(QtGui.QMainWindow):
''' '''
# https://stackoverflow.com/a/18975846 # https://stackoverflow.com/a/18975846
if not size and not self._size: if not size and not self._size:
app = QtGui.QApplication.instance() # app = QtGui.QApplication.instance()
geo = self.current_screen().geometry() geo = self.current_screen().geometry()
h, w = geo.height(), geo.width() h, w = geo.height(), geo.width()
# use approx 1/3 of the area of the screen by default # 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) 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 # singleton app per actor
_qt_win: QtGui.QMainWindow = None _qt_win: QtGui.QMainWindow = None

View File

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