commit
d6c9834a9a
|
@ -470,10 +470,14 @@ class Client:
|
|||
|
||||
# TODO add search though our adhoc-locally defined symbol set
|
||||
# for futes/cmdtys/
|
||||
results = await self.search_stocks(
|
||||
pattern,
|
||||
upto=upto,
|
||||
)
|
||||
try:
|
||||
results = await self.search_stocks(
|
||||
pattern,
|
||||
upto=upto,
|
||||
)
|
||||
except ConnectionError:
|
||||
return {}
|
||||
|
||||
for key, deats in results.copy().items():
|
||||
|
||||
tract = deats.contract
|
||||
|
|
|
@ -93,6 +93,9 @@ class Allocator(Struct):
|
|||
else:
|
||||
return self.units_limit
|
||||
|
||||
def limit_info(self) -> tuple[str, float]:
|
||||
return self.size_unit, self.limit()
|
||||
|
||||
def next_order_info(
|
||||
self,
|
||||
|
||||
|
|
|
@ -617,8 +617,9 @@ async def translate_and_relay_brokerd_events(
|
|||
f'Received broker trade event:\n'
|
||||
f'{fmsg}'
|
||||
)
|
||||
match brokerd_msg:
|
||||
status_msg: Optional[Status] = None
|
||||
|
||||
match brokerd_msg:
|
||||
# BrokerdPosition
|
||||
case {
|
||||
'name': 'position',
|
||||
|
@ -866,6 +867,7 @@ async def translate_and_relay_brokerd_events(
|
|||
}:
|
||||
log.error(f'Broker error:\n{fmsg}')
|
||||
# XXX: we presume the brokerd cancels its own order
|
||||
continue
|
||||
|
||||
# TOO FAST ``BrokerdStatus`` that arrives
|
||||
# before the ``BrokerdAck``.
|
||||
|
@ -894,8 +896,8 @@ async def translate_and_relay_brokerd_events(
|
|||
raise ValueError(f'Brokerd message {brokerd_msg} is invalid')
|
||||
|
||||
# XXX: ugh sometimes we don't access it?
|
||||
if status_msg:
|
||||
del status_msg
|
||||
# if status_msg is not None:
|
||||
# del status_msg
|
||||
|
||||
# TODO: do we want this to keep things cleaned up?
|
||||
# it might require a special status from brokerd to affirm the
|
||||
|
@ -1107,7 +1109,7 @@ async def process_client_order_cmds(
|
|||
# sometimes the real-time feed hasn't come up
|
||||
# so just pull from the latest history.
|
||||
if isnan(last):
|
||||
last = feed.shm.array[-1]['close']
|
||||
last = feed.rt_shm.array[-1]['close']
|
||||
|
||||
pred = mk_check(trigger_price, last, action)
|
||||
|
||||
|
|
|
@ -37,6 +37,9 @@ if TYPE_CHECKING:
|
|||
log = get_logger(__name__)
|
||||
|
||||
|
||||
_default_delay_s: float = 1.0
|
||||
|
||||
|
||||
class sampler:
|
||||
'''
|
||||
Global sampling engine registry.
|
||||
|
@ -104,14 +107,18 @@ async def increment_ohlc_buffer(
|
|||
# TODO: do we want to support dynamically
|
||||
# adding a "lower" lowest increment period?
|
||||
await trio.sleep(ad)
|
||||
total_s += lowest
|
||||
total_s += delay_s
|
||||
|
||||
# increment all subscribed shm arrays
|
||||
# TODO:
|
||||
# - this in ``numba``
|
||||
# - just lookup shms for this step instead of iterating?
|
||||
for delay_s, shms in sampler.ohlcv_shms.items():
|
||||
if total_s % delay_s != 0:
|
||||
for this_delay_s, shms in sampler.ohlcv_shms.items():
|
||||
|
||||
# short-circuit on any not-ready because slower sample
|
||||
# rate consuming shm buffers.
|
||||
if total_s % this_delay_s != 0:
|
||||
# print(f'skipping `{this_delay_s}s` sample update')
|
||||
continue
|
||||
|
||||
# TODO: ``numba`` this!
|
||||
|
@ -130,7 +137,7 @@ async def increment_ohlc_buffer(
|
|||
# this copies non-std fields (eg. vwap) from the last datum
|
||||
last[
|
||||
['time', 'volume', 'open', 'high', 'low', 'close']
|
||||
][0] = (t + delay_s, 0, close, close, close, close)
|
||||
][0] = (t + this_delay_s, 0, close, close, close, close)
|
||||
|
||||
# write to the buffer
|
||||
shm.push(last)
|
||||
|
@ -152,7 +159,6 @@ async def broadcast(
|
|||
|
||||
'''
|
||||
subs = sampler.subscribers.get(delay_s, ())
|
||||
|
||||
first = last = -1
|
||||
|
||||
if shm is None:
|
||||
|
@ -221,7 +227,8 @@ async def iter_ohlc_periods(
|
|||
async def sample_and_broadcast(
|
||||
|
||||
bus: _FeedsBus, # noqa
|
||||
shm: ShmArray,
|
||||
rt_shm: ShmArray,
|
||||
hist_shm: ShmArray,
|
||||
quote_stream: trio.abc.ReceiveChannel,
|
||||
brokername: str,
|
||||
sum_tick_vlm: bool = True,
|
||||
|
@ -257,41 +264,45 @@ async def sample_and_broadcast(
|
|||
|
||||
last = tick['price']
|
||||
|
||||
# update last entry
|
||||
# benchmarked in the 4-5 us range
|
||||
o, high, low, v = shm.array[-1][
|
||||
['open', 'high', 'low', 'volume']
|
||||
]
|
||||
# more compact inline-way to do this assignment
|
||||
# to both buffers?
|
||||
for shm in [rt_shm, hist_shm]:
|
||||
# update last entry
|
||||
# benchmarked in the 4-5 us range
|
||||
# for shm in [rt_shm, hist_shm]:
|
||||
o, high, low, v = shm.array[-1][
|
||||
['open', 'high', 'low', 'volume']
|
||||
]
|
||||
|
||||
new_v = tick.get('size', 0)
|
||||
new_v = tick.get('size', 0)
|
||||
|
||||
if v == 0 and new_v:
|
||||
# no trades for this bar yet so the open
|
||||
# is also the close/last trade price
|
||||
o = last
|
||||
if v == 0 and new_v:
|
||||
# no trades for this bar yet so the open
|
||||
# is also the close/last trade price
|
||||
o = last
|
||||
|
||||
if sum_tick_vlm:
|
||||
volume = v + new_v
|
||||
else:
|
||||
# presume backend takes care of summing
|
||||
# it's own vlm
|
||||
volume = quote['volume']
|
||||
if sum_tick_vlm:
|
||||
volume = v + new_v
|
||||
else:
|
||||
# presume backend takes care of summing
|
||||
# it's own vlm
|
||||
volume = quote['volume']
|
||||
|
||||
shm.array[[
|
||||
'open',
|
||||
'high',
|
||||
'low',
|
||||
'close',
|
||||
'bar_wap', # can be optionally provided
|
||||
'volume',
|
||||
]][-1] = (
|
||||
o,
|
||||
max(high, last),
|
||||
min(low, last),
|
||||
last,
|
||||
quote.get('bar_wap', 0),
|
||||
volume,
|
||||
)
|
||||
shm.array[[
|
||||
'open',
|
||||
'high',
|
||||
'low',
|
||||
'close',
|
||||
'bar_wap', # can be optionally provided
|
||||
'volume',
|
||||
]][-1] = (
|
||||
o,
|
||||
max(high, last),
|
||||
min(low, last),
|
||||
last,
|
||||
quote.get('bar_wap', 0),
|
||||
volume,
|
||||
)
|
||||
|
||||
# XXX: we need to be very cautious here that no
|
||||
# context-channel is left lingering which doesn't have
|
||||
|
|
|
@ -56,6 +56,7 @@ from ._sharedmem import (
|
|||
maybe_open_shm_array,
|
||||
attach_shm_array,
|
||||
ShmArray,
|
||||
_secs_in_day,
|
||||
)
|
||||
from .ingest import get_ingestormod
|
||||
from .types import Struct
|
||||
|
@ -72,6 +73,7 @@ from ._sampling import (
|
|||
iter_ohlc_periods,
|
||||
sample_and_broadcast,
|
||||
uniform_rate_send,
|
||||
_default_delay_s,
|
||||
)
|
||||
from ..brokers._util import (
|
||||
NoData,
|
||||
|
@ -256,7 +258,7 @@ async def start_backfill(
|
|||
write_tsdb: bool = True,
|
||||
tsdb_is_up: bool = False,
|
||||
|
||||
task_status: TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED,
|
||||
task_status: TaskStatus[tuple] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
) -> int:
|
||||
|
||||
|
@ -294,7 +296,7 @@ async def start_backfill(
|
|||
bf_done = trio.Event()
|
||||
|
||||
# let caller unblock and deliver latest history frame
|
||||
task_status.started((shm, start_dt, end_dt, bf_done))
|
||||
task_status.started((start_dt, end_dt, bf_done))
|
||||
|
||||
# based on the sample step size, maybe load a certain amount history
|
||||
if last_tsdb_dt is None:
|
||||
|
@ -544,7 +546,6 @@ async def start_backfill(
|
|||
)
|
||||
frames.pop(epoch)
|
||||
continue
|
||||
# await tractor.breakpoint()
|
||||
|
||||
if diff > step_size_s:
|
||||
|
||||
|
@ -672,8 +673,8 @@ async def manage_history(
|
|||
'''
|
||||
# (maybe) allocate shm array for this broker/symbol which will
|
||||
# be used for fast near-term history capture and processing.
|
||||
shm, opened = maybe_open_shm_array(
|
||||
key=fqsn,
|
||||
hist_shm, opened = maybe_open_shm_array(
|
||||
key=f'{fqsn}_hist',
|
||||
|
||||
# use any broker defined ohlc dtype:
|
||||
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
|
||||
|
@ -687,6 +688,21 @@ async def manage_history(
|
|||
"Persistent shm for sym was already open?!"
|
||||
)
|
||||
|
||||
rt_shm, opened = maybe_open_shm_array(
|
||||
key=f'{fqsn}_rt',
|
||||
|
||||
# use any broker defined ohlc dtype:
|
||||
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
|
||||
|
||||
# we expect the sub-actor to write
|
||||
readonly=False,
|
||||
size=3*_secs_in_day,
|
||||
)
|
||||
if not opened:
|
||||
raise RuntimeError(
|
||||
"Persistent shm for sym was already open?!"
|
||||
)
|
||||
|
||||
log.info('Scanning for existing `marketstored`')
|
||||
|
||||
is_up = await check_for_service('marketstored')
|
||||
|
@ -714,7 +730,6 @@ async def manage_history(
|
|||
|
||||
broker, symbol, expiry = unpack_fqsn(fqsn)
|
||||
(
|
||||
shm,
|
||||
latest_start_dt,
|
||||
latest_end_dt,
|
||||
bf_done,
|
||||
|
@ -723,14 +738,14 @@ async def manage_history(
|
|||
start_backfill,
|
||||
mod,
|
||||
bfqsn,
|
||||
shm,
|
||||
hist_shm,
|
||||
last_tsdb_dt=last_tsdb_dt,
|
||||
tsdb_is_up=True,
|
||||
storage=storage,
|
||||
)
|
||||
)
|
||||
|
||||
# if len(shm.array) < 2:
|
||||
# if len(hist_shm.array) < 2:
|
||||
# TODO: there's an edge case here to solve where if the last
|
||||
# frame before market close (at least on ib) was pushed and
|
||||
# there was only "1 new" row pushed from the first backfill
|
||||
|
@ -740,7 +755,7 @@ async def manage_history(
|
|||
# the tsdb series and stash that somewhere as meta data on
|
||||
# the shm buffer?.. no se.
|
||||
|
||||
task_status.started(shm)
|
||||
task_status.started((hist_shm, rt_shm))
|
||||
some_data_ready.set()
|
||||
|
||||
await bf_done.wait()
|
||||
|
@ -758,7 +773,7 @@ async def manage_history(
|
|||
# TODO: see if there's faster multi-field reads:
|
||||
# https://numpy.org/doc/stable/user/basics.rec.html#accessing-multiple-fields
|
||||
# re-index with a `time` and index field
|
||||
prepend_start = shm._first.value
|
||||
prepend_start = hist_shm._first.value
|
||||
|
||||
# sanity check on most-recent-data loading
|
||||
assert prepend_start > dt_diff_s
|
||||
|
@ -768,7 +783,7 @@ async def manage_history(
|
|||
fastest = history[0]
|
||||
to_push = fastest[:prepend_start]
|
||||
|
||||
shm.push(
|
||||
hist_shm.push(
|
||||
to_push,
|
||||
|
||||
# insert the history pre a "days worth" of samples
|
||||
|
@ -784,7 +799,7 @@ async def manage_history(
|
|||
count = 0
|
||||
end = fastest['Epoch'][0]
|
||||
|
||||
while shm._first.value > 0:
|
||||
while hist_shm._first.value > 0:
|
||||
count += 1
|
||||
series = await storage.read_ohlcv(
|
||||
fqsn,
|
||||
|
@ -796,7 +811,7 @@ async def manage_history(
|
|||
prepend_start -= len(to_push)
|
||||
to_push = fastest[:prepend_start]
|
||||
|
||||
shm.push(
|
||||
hist_shm.push(
|
||||
to_push,
|
||||
|
||||
# insert the history pre a "days worth" of samples
|
||||
|
@ -840,12 +855,12 @@ async def manage_history(
|
|||
start_backfill,
|
||||
mod,
|
||||
bfqsn,
|
||||
shm,
|
||||
hist_shm,
|
||||
)
|
||||
)
|
||||
|
||||
# yield back after client connect with filled shm
|
||||
task_status.started(shm)
|
||||
task_status.started((hist_shm, rt_shm))
|
||||
|
||||
# indicate to caller that feed can be delivered to
|
||||
# remote requesting client since we've loaded history
|
||||
|
@ -891,7 +906,7 @@ async def allocate_persistent_feed(
|
|||
|
||||
# mem chan handed to broker backend so it can push real-time
|
||||
# quotes to this task for sampling and history storage (see below).
|
||||
send, quote_stream = trio.open_memory_channel(10)
|
||||
send, quote_stream = trio.open_memory_channel(616)
|
||||
|
||||
# data sync signals for both history loading and market quotes
|
||||
some_data_ready = trio.Event()
|
||||
|
@ -922,7 +937,7 @@ async def allocate_persistent_feed(
|
|||
# https://github.com/python-trio/trio/issues/2258
|
||||
# bus.nursery.start_soon(
|
||||
# await bus.start_task(
|
||||
shm = await bus.nursery.start(
|
||||
hist_shm, rt_shm = await bus.nursery.start(
|
||||
manage_history,
|
||||
mod,
|
||||
bus,
|
||||
|
@ -935,7 +950,9 @@ async def allocate_persistent_feed(
|
|||
# can read directly from the memory which will be written by
|
||||
# this task.
|
||||
msg = init_msg[symbol]
|
||||
msg['shm_token'] = shm.token
|
||||
msg['hist_shm_token'] = hist_shm.token
|
||||
msg['startup_hist_index'] = hist_shm.index - 1
|
||||
msg['rt_shm_token'] = rt_shm.token
|
||||
|
||||
# true fqsn
|
||||
fqsn = '.'.join((bfqsn, brokername))
|
||||
|
@ -971,7 +988,25 @@ async def allocate_persistent_feed(
|
|||
# for ambiguous names we simply apply the retreived
|
||||
# feed to that name (for now).
|
||||
|
||||
# task_status.started((init_msg, generic_first_quotes))
|
||||
sampler.ohlcv_shms.setdefault(
|
||||
1,
|
||||
[]
|
||||
).append(rt_shm)
|
||||
ohlckeys = ['open', 'high', 'low', 'close']
|
||||
|
||||
# set the rt (hft) shm array as append only
|
||||
# (for now).
|
||||
rt_shm._first.value = 0
|
||||
rt_shm._last.value = 0
|
||||
|
||||
# push last sample from history to rt buffer just as a filler datum
|
||||
# but we don't want a history sized datum outlier so set vlm to zero
|
||||
# and ohlc to the close value.
|
||||
rt_shm.push(hist_shm.array[-2:-1])
|
||||
|
||||
rt_shm.array[ohlckeys] = hist_shm.array['close'][-1]
|
||||
rt_shm._array['volume'] = 0
|
||||
|
||||
task_status.started()
|
||||
|
||||
if not start_stream:
|
||||
|
@ -983,14 +1018,18 @@ async def allocate_persistent_feed(
|
|||
|
||||
# start shm incrementer task for OHLC style sampling
|
||||
# at the current detected step period.
|
||||
times = shm.array['time']
|
||||
times = hist_shm.array['time']
|
||||
delay_s = times[-1] - times[times != times[-1]][-1]
|
||||
sampler.ohlcv_shms.setdefault(delay_s, []).append(hist_shm)
|
||||
|
||||
sampler.ohlcv_shms.setdefault(delay_s, []).append(shm)
|
||||
if sampler.incrementers.get(delay_s) is None:
|
||||
# create buffer a single incrementer task broker backend
|
||||
# (aka `brokerd`) using the lowest sampler period.
|
||||
# await tractor.breakpoint()
|
||||
# for delay_s in sampler.ohlcv_shms:
|
||||
if sampler.incrementers.get(_default_delay_s) is None:
|
||||
await bus.start_task(
|
||||
increment_ohlc_buffer,
|
||||
delay_s,
|
||||
_default_delay_s,
|
||||
)
|
||||
|
||||
sum_tick_vlm: bool = init_msg.get(
|
||||
|
@ -1001,7 +1040,8 @@ async def allocate_persistent_feed(
|
|||
try:
|
||||
await sample_and_broadcast(
|
||||
bus,
|
||||
shm,
|
||||
rt_shm,
|
||||
hist_shm,
|
||||
quote_stream,
|
||||
brokername,
|
||||
sum_tick_vlm
|
||||
|
@ -1164,34 +1204,6 @@ async def open_feed_bus(
|
|||
log.warning(f'{sub} for {symbol} was already removed?')
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_sample_step_stream(
|
||||
portal: tractor.Portal,
|
||||
delay_s: int,
|
||||
|
||||
) -> tractor.ReceiveMsgStream:
|
||||
|
||||
# XXX: this should be singleton on a host,
|
||||
# a lone broker-daemon per provider should be
|
||||
# created for all practical purposes
|
||||
async with maybe_open_context(
|
||||
acm_func=partial(
|
||||
portal.open_context,
|
||||
iter_ohlc_periods,
|
||||
),
|
||||
kwargs={'delay_s': delay_s},
|
||||
|
||||
) as (cache_hit, (ctx, first)):
|
||||
async with ctx.open_stream() as istream:
|
||||
if cache_hit:
|
||||
# add a new broadcast subscription for the quote stream
|
||||
# if this feed is likely already in use
|
||||
async with istream.subscribe() as bistream:
|
||||
yield bistream
|
||||
else:
|
||||
yield istream
|
||||
|
||||
|
||||
@dataclass
|
||||
class Feed:
|
||||
'''
|
||||
|
@ -1204,13 +1216,16 @@ class Feed:
|
|||
|
||||
'''
|
||||
name: str
|
||||
shm: ShmArray
|
||||
hist_shm: ShmArray
|
||||
rt_shm: ShmArray
|
||||
mod: ModuleType
|
||||
first_quotes: dict # symbol names to first quote dicts
|
||||
_portal: tractor.Portal
|
||||
stream: trio.abc.ReceiveChannel[dict[str, Any]]
|
||||
status: dict[str, Any]
|
||||
|
||||
startup_hist_index: int = 0
|
||||
|
||||
throttle_rate: Optional[int] = None
|
||||
|
||||
_trade_stream: Optional[AsyncIterator[dict[str, Any]]] = None
|
||||
|
@ -1230,17 +1245,28 @@ class Feed:
|
|||
@asynccontextmanager
|
||||
async def index_stream(
|
||||
self,
|
||||
delay_s: Optional[int] = None
|
||||
delay_s: int = 1,
|
||||
|
||||
) -> AsyncIterator[int]:
|
||||
|
||||
delay_s = delay_s or self._max_sample_rate
|
||||
|
||||
async with open_sample_step_stream(
|
||||
self.portal,
|
||||
delay_s,
|
||||
) as istream:
|
||||
yield istream
|
||||
# XXX: this should be singleton on a host,
|
||||
# a lone broker-daemon per provider should be
|
||||
# created for all practical purposes
|
||||
async with maybe_open_context(
|
||||
acm_func=partial(
|
||||
self.portal.open_context,
|
||||
iter_ohlc_periods,
|
||||
),
|
||||
kwargs={'delay_s': delay_s},
|
||||
) as (cache_hit, (ctx, first)):
|
||||
async with ctx.open_stream() as istream:
|
||||
if cache_hit:
|
||||
# add a new broadcast subscription for the quote stream
|
||||
# if this feed is likely already in use
|
||||
async with istream.subscribe() as bistream:
|
||||
yield bistream
|
||||
else:
|
||||
yield istream
|
||||
|
||||
async def pause(self) -> None:
|
||||
await self.stream.send('pause')
|
||||
|
@ -1248,6 +1274,34 @@ class Feed:
|
|||
async def resume(self) -> None:
|
||||
await self.stream.send('resume')
|
||||
|
||||
def get_ds_info(
|
||||
self,
|
||||
) -> tuple[float, float, float]:
|
||||
'''
|
||||
Compute the "downsampling" ratio info between the historical shm
|
||||
buffer and the real-time (HFT) one.
|
||||
|
||||
Return a tuple of the fast sample period, historical sample
|
||||
period and ratio between them.
|
||||
|
||||
'''
|
||||
times = self.hist_shm.array['time']
|
||||
end = pendulum.from_timestamp(times[-1])
|
||||
start = pendulum.from_timestamp(times[times != times[-1]][-1])
|
||||
hist_step_size_s = (end - start).seconds
|
||||
|
||||
times = self.rt_shm.array['time']
|
||||
end = pendulum.from_timestamp(times[-1])
|
||||
start = pendulum.from_timestamp(times[times != times[-1]][-1])
|
||||
rt_step_size_s = (end - start).seconds
|
||||
|
||||
ratio = hist_step_size_s / rt_step_size_s
|
||||
return (
|
||||
rt_step_size_s,
|
||||
hist_step_size_s,
|
||||
ratio,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def install_brokerd_search(
|
||||
|
@ -1337,21 +1391,29 @@ async def open_feed(
|
|||
) as stream,
|
||||
|
||||
):
|
||||
init = init_msg[bfqsn]
|
||||
# we can only read from shm
|
||||
shm = attach_shm_array(
|
||||
token=init_msg[bfqsn]['shm_token'],
|
||||
hist_shm = attach_shm_array(
|
||||
token=init['hist_shm_token'],
|
||||
readonly=True,
|
||||
)
|
||||
rt_shm = attach_shm_array(
|
||||
token=init['rt_shm_token'],
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
assert fqsn in first_quotes
|
||||
|
||||
feed = Feed(
|
||||
name=brokername,
|
||||
shm=shm,
|
||||
hist_shm=hist_shm,
|
||||
rt_shm=rt_shm,
|
||||
mod=mod,
|
||||
first_quotes=first_quotes,
|
||||
stream=stream,
|
||||
_portal=portal,
|
||||
status={},
|
||||
startup_hist_index=init['startup_hist_index'],
|
||||
throttle_rate=tick_throttle,
|
||||
)
|
||||
|
||||
|
@ -1364,7 +1426,7 @@ async def open_feed(
|
|||
'actor_name': feed.portal.channel.uid[0],
|
||||
'host': host,
|
||||
'port': port,
|
||||
'shm': f'{humanize(feed.shm._shm.size)}',
|
||||
'shm': f'{humanize(feed.hist_shm._shm.size)}',
|
||||
'throttle_rate': feed.throttle_rate,
|
||||
})
|
||||
feed.status.update(init_msg.pop('status', {}))
|
||||
|
@ -1382,13 +1444,17 @@ async def open_feed(
|
|||
feed.symbols[sym] = symbol
|
||||
|
||||
# cast shm dtype to list... can't member why we need this
|
||||
shm_token = data['shm_token']
|
||||
for shm_key, shm in [
|
||||
('rt_shm_token', rt_shm),
|
||||
('hist_shm_token', hist_shm),
|
||||
]:
|
||||
shm_token = data[shm_key]
|
||||
|
||||
# XXX: msgspec won't relay through the tuples XD
|
||||
shm_token['dtype_descr'] = tuple(
|
||||
map(tuple, shm_token['dtype_descr']))
|
||||
# XXX: msgspec won't relay through the tuples XD
|
||||
shm_token['dtype_descr'] = tuple(
|
||||
map(tuple, shm_token['dtype_descr']))
|
||||
|
||||
assert shm_token == shm.token # sanity
|
||||
assert shm_token == shm.token # sanity
|
||||
|
||||
feed._max_sample_rate = 1
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ from .. import data
|
|||
from ..data import attach_shm_array
|
||||
from ..data.feed import Feed
|
||||
from ..data._sharedmem import ShmArray
|
||||
from ..data._sampling import _default_delay_s
|
||||
from ..data._source import Symbol
|
||||
from ._api import (
|
||||
Fsp,
|
||||
|
@ -105,7 +106,7 @@ async def fsp_compute(
|
|||
filter_quotes_by_sym(fqsn, quote_stream),
|
||||
|
||||
# XXX: currently the ``ohlcv`` arg
|
||||
feed.shm,
|
||||
feed.rt_shm,
|
||||
)
|
||||
|
||||
# Conduct a single iteration of fsp with historical bars input
|
||||
|
@ -313,7 +314,7 @@ async def cascade(
|
|||
|
||||
profiler(f'{func}: feed up')
|
||||
|
||||
assert src.token == feed.shm.token
|
||||
assert src.token == feed.rt_shm.token
|
||||
# last_len = new_len = len(src.array)
|
||||
|
||||
func_name = func.__name__
|
||||
|
@ -420,7 +421,11 @@ async def cascade(
|
|||
# detect sample period step for subscription to increment
|
||||
# signal
|
||||
times = src.array['time']
|
||||
delay_s = times[-1] - times[times != times[-1]][-1]
|
||||
if len(times) > 1:
|
||||
delay_s = times[-1] - times[times != times[-1]][-1]
|
||||
else:
|
||||
# our default "HFT" sample rate.
|
||||
delay_s = _default_delay_s
|
||||
|
||||
# Increment the underlying shared memory buffer on every
|
||||
# "increment" msg received from the underlying data feed.
|
||||
|
@ -431,7 +436,8 @@ async def cascade(
|
|||
profiler(f'{func_name}: sample stream up')
|
||||
profiler.finish()
|
||||
|
||||
async for _ in istream:
|
||||
async for i in istream:
|
||||
# log.runtime(f'FSP incrementing {i}')
|
||||
|
||||
# respawn the compute task if the source
|
||||
# array has been updated such that we compute
|
||||
|
|
|
@ -32,16 +32,22 @@ def mk_marker_path(
|
|||
style: str,
|
||||
|
||||
) -> QGraphicsPathItem:
|
||||
"""Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem``
|
||||
ready to be placed using scene coordinates (not view).
|
||||
'''
|
||||
Add a marker to be displayed on the line wrapped in
|
||||
a ``QGraphicsPathItem`` ready to be placed using scene coordinates
|
||||
(not view).
|
||||
|
||||
**Arguments**
|
||||
style String indicating the style of marker to add:
|
||||
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
|
||||
``'>|<'``, ``'^'``, ``'v'``, ``'o'``
|
||||
size Size of the marker in pixels.
|
||||
|
||||
"""
|
||||
This code is taken nearly verbatim from the
|
||||
`InfiniteLine.addMarker()` method but does not attempt do be aware
|
||||
of low(er) level graphics controls and expects for the output
|
||||
polygon to be applied to a ``QGraphicsPathItem``.
|
||||
|
||||
'''
|
||||
path = QtGui.QPainterPath()
|
||||
|
||||
if style == 'o':
|
||||
|
@ -87,7 +93,8 @@ def mk_marker_path(
|
|||
|
||||
|
||||
class LevelMarker(QGraphicsPathItem):
|
||||
'''An arrow marker path graphich which redraws itself
|
||||
'''
|
||||
An arrow marker path graphich which redraws itself
|
||||
to the specified view coordinate level on each paint cycle.
|
||||
|
||||
'''
|
||||
|
@ -114,6 +121,7 @@ class LevelMarker(QGraphicsPathItem):
|
|||
|
||||
self.get_level = get_level
|
||||
self._on_paint = on_paint
|
||||
|
||||
self.scene_x = lambda: chart.marker_right_points()[1]
|
||||
self.level: float = 0
|
||||
self.keep_in_view = keep_in_view
|
||||
|
@ -149,12 +157,9 @@ class LevelMarker(QGraphicsPathItem):
|
|||
def w(self) -> float:
|
||||
return self.path_br().width()
|
||||
|
||||
def position_in_view(
|
||||
self,
|
||||
# level: float,
|
||||
|
||||
) -> None:
|
||||
'''Show a pp off-screen indicator for a level label.
|
||||
def position_in_view(self) -> None:
|
||||
'''
|
||||
Show a pp off-screen indicator for a level label.
|
||||
|
||||
This is like in fps games where you have a gps "nav" indicator
|
||||
but your teammate is outside the range of view, except in 2D, on
|
||||
|
@ -162,7 +167,6 @@ class LevelMarker(QGraphicsPathItem):
|
|||
|
||||
'''
|
||||
level = self.get_level()
|
||||
|
||||
view = self.chart.getViewBox()
|
||||
vr = view.state['viewRange']
|
||||
ymn, ymx = vr[1]
|
||||
|
@ -186,7 +190,6 @@ class LevelMarker(QGraphicsPathItem):
|
|||
)
|
||||
|
||||
elif level < ymn: # pin to bottom of view
|
||||
|
||||
self.setPos(
|
||||
QPointF(
|
||||
x,
|
||||
|
@ -211,7 +214,8 @@ class LevelMarker(QGraphicsPathItem):
|
|||
w: QtWidgets.QWidget
|
||||
|
||||
) -> None:
|
||||
'''Core paint which we override to always update
|
||||
'''
|
||||
Core paint which we override to always update
|
||||
our marker position in scene coordinates from a
|
||||
view cooridnate "level".
|
||||
|
||||
|
@ -235,11 +239,12 @@ def qgo_draw_markers(
|
|||
right_offset: float,
|
||||
|
||||
) -> float:
|
||||
"""Paint markers in ``pg.GraphicsItem`` style by first
|
||||
'''
|
||||
Paint markers in ``pg.GraphicsItem`` style by first
|
||||
removing the view transform for the painter, drawing the markers
|
||||
in scene coords, then restoring the view coords.
|
||||
|
||||
"""
|
||||
'''
|
||||
# paint markers in native coordinate system
|
||||
orig_tr = p.transform()
|
||||
|
||||
|
|
|
@ -107,9 +107,8 @@ async def _async_main(
|
|||
# setup search widget and focus main chart view at startup
|
||||
# search widget is a singleton alongside the godwidget
|
||||
search = _search.SearchWidget(godwidget=godwidget)
|
||||
search.bar.unfocus()
|
||||
|
||||
godwidget.hbox.addWidget(search)
|
||||
# search.bar.unfocus()
|
||||
# godwidget.hbox.addWidget(search)
|
||||
godwidget.search = search
|
||||
|
||||
symbol, _, provider = sym.rpartition('.')
|
||||
|
@ -178,6 +177,6 @@ def _main(
|
|||
run_qtractor(
|
||||
func=_async_main,
|
||||
args=(sym, brokernames, piker_loglevel),
|
||||
main_widget=GodWidget,
|
||||
main_widget_type=GodWidget,
|
||||
tractor_kwargs=tractor_kwargs,
|
||||
)
|
||||
|
|
|
@ -19,7 +19,11 @@ High level chart-widget apis.
|
|||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from typing import (
|
||||
Iterator,
|
||||
Optional,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from PyQt5 import QtCore, QtWidgets
|
||||
from PyQt5.QtCore import (
|
||||
|
@ -68,6 +72,7 @@ from ._forms import FieldsForm
|
|||
from .._profile import pg_profile_enabled, ms_slower_then
|
||||
from ._overlay import PlotItemOverlay
|
||||
from ._flows import Flow
|
||||
from ._search import SearchWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._display import DisplayState
|
||||
|
@ -85,6 +90,9 @@ class GodWidget(QWidget):
|
|||
modify them.
|
||||
|
||||
'''
|
||||
search: SearchWidget
|
||||
mode_name: str = 'god'
|
||||
|
||||
def __init__(
|
||||
|
||||
self,
|
||||
|
@ -94,6 +102,8 @@ class GodWidget(QWidget):
|
|||
|
||||
super().__init__(parent)
|
||||
|
||||
self.search: Optional[SearchWidget] = None
|
||||
|
||||
self.hbox = QHBoxLayout(self)
|
||||
self.hbox.setContentsMargins(0, 0, 0, 0)
|
||||
self.hbox.setSpacing(6)
|
||||
|
@ -115,7 +125,10 @@ class GodWidget(QWidget):
|
|||
# self.vbox.addLayout(self.hbox)
|
||||
|
||||
self._chart_cache: dict[str, LinkedSplits] = {}
|
||||
self.linkedsplits: Optional[LinkedSplits] = None
|
||||
|
||||
self.hist_linked: Optional[LinkedSplits] = None
|
||||
self.rt_linked: Optional[LinkedSplits] = None
|
||||
self._active_cursor: Optional[Cursor] = None
|
||||
|
||||
# assigned in the startup func `_async_main()`
|
||||
self._root_n: trio.Nursery = None
|
||||
|
@ -123,6 +136,14 @@ class GodWidget(QWidget):
|
|||
self._widgets: dict[str, QWidget] = {}
|
||||
self._resizing: bool = False
|
||||
|
||||
# TODO: do we need this, when would god get resized
|
||||
# and the window does not? Never right?!
|
||||
# self.reg_for_resize(self)
|
||||
|
||||
@property
|
||||
def linkedsplits(self) -> LinkedSplits:
|
||||
return self.rt_linked
|
||||
|
||||
# def init_timeframes_ui(self):
|
||||
# self.tf_layout = QHBoxLayout()
|
||||
# self.tf_layout.setSpacing(0)
|
||||
|
@ -148,19 +169,19 @@ class GodWidget(QWidget):
|
|||
def set_chart_symbol(
|
||||
self,
|
||||
symbol_key: str, # of form <fqsn>.<providername>
|
||||
linkedsplits: LinkedSplits, # type: ignore
|
||||
all_linked: tuple[LinkedSplits, LinkedSplits], # type: ignore
|
||||
|
||||
) -> None:
|
||||
# re-sort org cache symbol list in LIFO order
|
||||
cache = self._chart_cache
|
||||
cache.pop(symbol_key, None)
|
||||
cache[symbol_key] = linkedsplits
|
||||
cache[symbol_key] = all_linked
|
||||
|
||||
def get_chart_symbol(
|
||||
self,
|
||||
symbol_key: str,
|
||||
|
||||
) -> LinkedSplits: # type: ignore
|
||||
) -> tuple[LinkedSplits, LinkedSplits]: # type: ignore
|
||||
return self._chart_cache.get(symbol_key)
|
||||
|
||||
async def load_symbol(
|
||||
|
@ -182,28 +203,33 @@ class GodWidget(QWidget):
|
|||
|
||||
# fully qualified symbol name (SNS i guess is what we're making?)
|
||||
fqsn = '.'.join([symbol_key, providername])
|
||||
|
||||
linkedsplits = self.get_chart_symbol(fqsn)
|
||||
|
||||
all_linked = self.get_chart_symbol(fqsn)
|
||||
order_mode_started = trio.Event()
|
||||
|
||||
if not self.vbox.isEmpty():
|
||||
|
||||
# XXX: this is CRITICAL especially with pixel buffer caching
|
||||
self.linkedsplits.hide()
|
||||
self.linkedsplits.unfocus()
|
||||
# XXX: seems to make switching slower?
|
||||
# qframe = self.hist_linked.chart.qframe
|
||||
# if qframe.sidepane is self.search:
|
||||
# qframe.hbox.removeWidget(self.search)
|
||||
|
||||
# XXX: pretty sure we don't need this
|
||||
# remove any existing plots?
|
||||
# XXX: ahh we might want to support cache unloading..
|
||||
# self.vbox.removeWidget(self.linkedsplits)
|
||||
for linked in [self.rt_linked, self.hist_linked]:
|
||||
# XXX: this is CRITICAL especially with pixel buffer caching
|
||||
linked.hide()
|
||||
linked.unfocus()
|
||||
|
||||
# XXX: pretty sure we don't need this
|
||||
# remove any existing plots?
|
||||
# XXX: ahh we might want to support cache unloading..
|
||||
# self.vbox.removeWidget(linked)
|
||||
|
||||
# switching to a new viewable chart
|
||||
if linkedsplits is None or reset:
|
||||
if all_linked is None or reset:
|
||||
from ._display import display_symbol_data
|
||||
|
||||
# we must load a fresh linked charts set
|
||||
linkedsplits = LinkedSplits(self)
|
||||
self.rt_linked = rt_charts = LinkedSplits(self)
|
||||
self.hist_linked = hist_charts = LinkedSplits(self)
|
||||
|
||||
# spawn new task to start up and update new sub-chart instances
|
||||
self._root_n.start_soon(
|
||||
|
@ -215,44 +241,70 @@ class GodWidget(QWidget):
|
|||
order_mode_started,
|
||||
)
|
||||
|
||||
self.set_chart_symbol(fqsn, linkedsplits)
|
||||
self.vbox.addWidget(linkedsplits)
|
||||
# self.vbox.addWidget(hist_charts)
|
||||
self.vbox.addWidget(rt_charts)
|
||||
self.set_chart_symbol(
|
||||
fqsn,
|
||||
(hist_charts, rt_charts),
|
||||
)
|
||||
|
||||
for linked in [hist_charts, rt_charts]:
|
||||
linked.show()
|
||||
linked.focus()
|
||||
|
||||
linkedsplits.show()
|
||||
linkedsplits.focus()
|
||||
await trio.sleep(0)
|
||||
|
||||
else:
|
||||
# symbol is already loaded and ems ready
|
||||
order_mode_started.set()
|
||||
|
||||
# TODO:
|
||||
# - we'll probably want per-instrument/provider state here?
|
||||
# change the order config form over to the new chart
|
||||
self.hist_linked, self.rt_linked = all_linked
|
||||
|
||||
# chart is already in memory so just focus it
|
||||
linkedsplits.show()
|
||||
linkedsplits.focus()
|
||||
linkedsplits.graphics_cycle()
|
||||
for linked in all_linked:
|
||||
# TODO:
|
||||
# - we'll probably want per-instrument/provider state here?
|
||||
# change the order config form over to the new chart
|
||||
|
||||
# chart is already in memory so just focus it
|
||||
linked.show()
|
||||
linked.focus()
|
||||
linked.graphics_cycle()
|
||||
await trio.sleep(0)
|
||||
|
||||
# resume feeds *after* rendering chart view asap
|
||||
chart = linked.chart
|
||||
if chart:
|
||||
chart.resume_all_feeds()
|
||||
|
||||
# TODO: we need a check to see if the chart
|
||||
# last had the xlast in view, if so then shift so it's
|
||||
# still in view, if the user was viewing history then
|
||||
# do nothing yah?
|
||||
self.rt_linked.chart.default_view()
|
||||
|
||||
# if a history chart instance is already up then
|
||||
# set the search widget as its sidepane.
|
||||
hist_chart = self.hist_linked.chart
|
||||
if hist_chart:
|
||||
hist_chart.qframe.set_sidepane(self.search)
|
||||
|
||||
# NOTE: this is really stupid/hard to follow.
|
||||
# we have to reposition the active position nav
|
||||
# **AFTER** applying the search bar as a sidepane
|
||||
# to the newly switched to symbol.
|
||||
await trio.sleep(0)
|
||||
|
||||
# XXX: since the pp config is a singleton widget we have to
|
||||
# also switch it over to the new chart's interal-layout
|
||||
# self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_pane)
|
||||
chart = linkedsplits.chart
|
||||
# TODO: probably stick this in some kinda `LooknFeel` API?
|
||||
for tracker in self.rt_linked.mode.trackers.values():
|
||||
pp_nav = tracker.nav
|
||||
if tracker.live_pp.size:
|
||||
pp_nav.show()
|
||||
pp_nav.hide_info()
|
||||
else:
|
||||
pp_nav.hide()
|
||||
|
||||
# resume feeds *after* rendering chart view asap
|
||||
if chart:
|
||||
chart.resume_all_feeds()
|
||||
|
||||
# TODO: we need a check to see if the chart
|
||||
# last had the xlast in view, if so then shift so it's
|
||||
# still in view, if the user was viewing history then
|
||||
# do nothing yah?
|
||||
chart.default_view()
|
||||
|
||||
self.linkedsplits = linkedsplits
|
||||
symbol = linkedsplits.symbol
|
||||
# set window titlebar info
|
||||
symbol = self.rt_linked.symbol
|
||||
if symbol is not None:
|
||||
self.window.setWindowTitle(
|
||||
f'{symbol.front_fqsn()} '
|
||||
|
@ -269,11 +321,23 @@ class GodWidget(QWidget):
|
|||
'''
|
||||
# go back to view-mode focus (aka chart focus)
|
||||
self.clearFocus()
|
||||
self.linkedsplits.chart.setFocus()
|
||||
chart = self.rt_linked.chart
|
||||
if chart:
|
||||
chart.setFocus()
|
||||
|
||||
def resizeEvent(self, event: QtCore.QEvent) -> None:
|
||||
def reg_for_resize(
|
||||
self,
|
||||
widget: QWidget,
|
||||
) -> None:
|
||||
getattr(widget, 'on_resize')
|
||||
self._widgets[widget.mode_name] = widget
|
||||
|
||||
def on_win_resize(self, event: QtCore.QEvent) -> None:
|
||||
'''
|
||||
Top level god widget resize handler.
|
||||
Top level god widget handler from window (the real yaweh) resize
|
||||
events such that any registered widgets which wish to be
|
||||
notified are invoked using our pythonic `.on_resize()` method
|
||||
api.
|
||||
|
||||
Where we do UX magic to make things not suck B)
|
||||
|
||||
|
@ -289,6 +353,28 @@ class GodWidget(QWidget):
|
|||
|
||||
self._resizing = False
|
||||
|
||||
# on_resize = on_win_resize
|
||||
|
||||
def get_cursor(self) -> Cursor:
|
||||
return self._active_cursor
|
||||
|
||||
def iter_linked(self) -> Iterator[LinkedSplits]:
|
||||
for linked in [self.hist_linked, self.rt_linked]:
|
||||
yield linked
|
||||
|
||||
def resize_all(self) -> None:
|
||||
'''
|
||||
Dynamic resize sequence: adjusts all sub-widgets/charts to
|
||||
sensible default ratios of what space is detected as available
|
||||
on the display / window.
|
||||
|
||||
'''
|
||||
rt_linked = self.rt_linked
|
||||
rt_linked.set_split_sizes()
|
||||
self.rt_linked.resize_sidepanes()
|
||||
self.hist_linked.resize_sidepanes(from_linked=rt_linked)
|
||||
self.search.on_resize()
|
||||
|
||||
|
||||
class ChartnPane(QFrame):
|
||||
'''
|
||||
|
@ -301,9 +387,9 @@ class ChartnPane(QFrame):
|
|||
https://doc.qt.io/qt-5/qwidget.html#composite-widgets
|
||||
|
||||
'''
|
||||
sidepane: FieldsForm
|
||||
sidepane: FieldsForm | SearchWidget
|
||||
hbox: QHBoxLayout
|
||||
chart: Optional['ChartPlotWidget'] = None
|
||||
chart: Optional[ChartPlotWidget] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -315,7 +401,7 @@ class ChartnPane(QFrame):
|
|||
|
||||
super().__init__(parent)
|
||||
|
||||
self.sidepane = sidepane
|
||||
self._sidepane = sidepane
|
||||
self.chart = None
|
||||
|
||||
hbox = self.hbox = QHBoxLayout(self)
|
||||
|
@ -323,6 +409,21 @@ class ChartnPane(QFrame):
|
|||
hbox.setContentsMargins(0, 0, 0, 0)
|
||||
hbox.setSpacing(3)
|
||||
|
||||
def set_sidepane(
|
||||
self,
|
||||
sidepane: FieldsForm | SearchWidget,
|
||||
) -> None:
|
||||
|
||||
# add sidepane **after** chart; place it on axis side
|
||||
self.hbox.addWidget(
|
||||
sidepane,
|
||||
alignment=Qt.AlignTop
|
||||
)
|
||||
self._sidepane = sidepane
|
||||
|
||||
def sidepane(self) -> FieldsForm | SearchWidget:
|
||||
return self._sidepane
|
||||
|
||||
|
||||
class LinkedSplits(QWidget):
|
||||
'''
|
||||
|
@ -357,6 +458,7 @@ class LinkedSplits(QWidget):
|
|||
self.splitter = QSplitter(QtCore.Qt.Vertical)
|
||||
self.splitter.setMidLineWidth(0)
|
||||
self.splitter.setHandleWidth(2)
|
||||
self.splitter.splitterMoved.connect(self.on_splitter_adjust)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
@ -369,6 +471,16 @@ class LinkedSplits(QWidget):
|
|||
|
||||
self._symbol: Symbol = None
|
||||
|
||||
def on_splitter_adjust(
|
||||
self,
|
||||
pos: int,
|
||||
index: int,
|
||||
) -> None:
|
||||
# print(f'splitter moved pos:{pos}, index:{index}')
|
||||
godw = self.godwidget
|
||||
if self is godw.rt_linked:
|
||||
godw.search.on_resize()
|
||||
|
||||
def graphics_cycle(self, **kwargs) -> None:
|
||||
from . import _display
|
||||
ds = self.display_state
|
||||
|
@ -384,28 +496,32 @@ class LinkedSplits(QWidget):
|
|||
prop: Optional[float] = None,
|
||||
|
||||
) -> None:
|
||||
'''Set the proportion of space allocated for linked subcharts.
|
||||
'''
|
||||
Set the proportion of space allocated for linked subcharts.
|
||||
|
||||
'''
|
||||
ln = len(self.subplots)
|
||||
ln = len(self.subplots) or 1
|
||||
|
||||
# proportion allocated to consumer subcharts
|
||||
if not prop:
|
||||
prop = 3/8*5/8
|
||||
prop = 3/8
|
||||
|
||||
# if ln < 2:
|
||||
# prop = 3/8*5/8
|
||||
|
||||
# elif ln >= 2:
|
||||
# prop = 3/8
|
||||
h = self.height()
|
||||
histview_h = h * (6/16)
|
||||
h = h - histview_h
|
||||
|
||||
major = 1 - prop
|
||||
min_h_ind = int((self.height() * prop) / ln)
|
||||
min_h_ind = int((h * prop) / ln)
|
||||
sizes = [
|
||||
int(histview_h),
|
||||
int(h * major),
|
||||
]
|
||||
|
||||
sizes = [int(self.height() * major)]
|
||||
# give all subcharts the same remaining proportional height
|
||||
sizes.extend([min_h_ind] * ln)
|
||||
|
||||
self.splitter.setSizes(sizes)
|
||||
if self.godwidget.rt_linked is self:
|
||||
self.splitter.setSizes(sizes)
|
||||
|
||||
def focus(self) -> None:
|
||||
if self.chart is not None:
|
||||
|
@ -498,10 +614,15 @@ class LinkedSplits(QWidget):
|
|||
'bottom': xaxis,
|
||||
}
|
||||
|
||||
qframe = ChartnPane(
|
||||
sidepane=sidepane,
|
||||
parent=self.splitter,
|
||||
)
|
||||
if sidepane is not False:
|
||||
parent = qframe = ChartnPane(
|
||||
sidepane=sidepane,
|
||||
parent=self.splitter,
|
||||
)
|
||||
else:
|
||||
parent = self.splitter
|
||||
qframe = None
|
||||
|
||||
cpw = ChartPlotWidget(
|
||||
|
||||
# this name will be used to register the primary
|
||||
|
@ -509,7 +630,7 @@ class LinkedSplits(QWidget):
|
|||
name=name,
|
||||
data_key=array_key or name,
|
||||
|
||||
parent=qframe,
|
||||
parent=parent,
|
||||
linkedsplits=self,
|
||||
axisItems=axes,
|
||||
**cpw_kwargs,
|
||||
|
@ -537,22 +658,25 @@ class LinkedSplits(QWidget):
|
|||
self.xaxis_chart = cpw
|
||||
cpw.showAxis('bottom')
|
||||
|
||||
qframe.chart = cpw
|
||||
qframe.hbox.addWidget(cpw)
|
||||
if qframe is not None:
|
||||
qframe.chart = cpw
|
||||
qframe.hbox.addWidget(cpw)
|
||||
|
||||
# so we can look this up and add back to the splitter
|
||||
# on a symbol switch
|
||||
cpw.qframe = qframe
|
||||
assert cpw.parent() == qframe
|
||||
# so we can look this up and add back to the splitter
|
||||
# on a symbol switch
|
||||
cpw.qframe = qframe
|
||||
assert cpw.parent() == qframe
|
||||
|
||||
# add sidepane **after** chart; place it on axis side
|
||||
qframe.hbox.addWidget(
|
||||
sidepane,
|
||||
alignment=Qt.AlignTop
|
||||
)
|
||||
cpw.sidepane = sidepane
|
||||
# add sidepane **after** chart; place it on axis side
|
||||
qframe.set_sidepane(sidepane)
|
||||
# qframe.hbox.addWidget(
|
||||
# sidepane,
|
||||
# alignment=Qt.AlignTop
|
||||
# )
|
||||
|
||||
cpw.plotItem.vb.linkedsplits = self
|
||||
cpw.sidepane = sidepane
|
||||
|
||||
cpw.plotItem.vb.linked = self
|
||||
cpw.setFrameStyle(
|
||||
QtWidgets.QFrame.StyledPanel
|
||||
# | QtWidgets.QFrame.Plain
|
||||
|
@ -613,9 +737,8 @@ class LinkedSplits(QWidget):
|
|||
if not _is_main:
|
||||
# track by name
|
||||
self.subplots[name] = cpw
|
||||
self.splitter.addWidget(qframe)
|
||||
# scale split regions
|
||||
self.set_split_sizes()
|
||||
if qframe is not None:
|
||||
self.splitter.addWidget(qframe)
|
||||
|
||||
else:
|
||||
assert style == 'bar', 'main chart must be OHLC'
|
||||
|
@ -641,19 +764,28 @@ class LinkedSplits(QWidget):
|
|||
|
||||
def resize_sidepanes(
|
||||
self,
|
||||
from_linked: Optional[LinkedSplits] = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Size all sidepanes based on the OHLC "main" plot and its
|
||||
sidepane width.
|
||||
|
||||
'''
|
||||
main_chart = self.chart
|
||||
if main_chart:
|
||||
if from_linked:
|
||||
main_chart = from_linked.chart
|
||||
else:
|
||||
main_chart = self.chart
|
||||
|
||||
if main_chart and main_chart.sidepane:
|
||||
sp_w = main_chart.sidepane.width()
|
||||
for name, cpw in self.subplots.items():
|
||||
cpw.sidepane.setMinimumWidth(sp_w)
|
||||
cpw.sidepane.setMaximumWidth(sp_w)
|
||||
|
||||
if from_linked:
|
||||
self.chart.sidepane.setMinimumWidth(sp_w)
|
||||
|
||||
|
||||
class ChartPlotWidget(pg.PlotWidget):
|
||||
'''
|
||||
|
@ -711,6 +843,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
# NOTE: must be set bfore calling ``.mk_vb()``
|
||||
self.linked = linkedsplits
|
||||
self.sidepane: Optional[FieldsForm] = None
|
||||
|
||||
# source of our custom interactions
|
||||
self.cv = cv = self.mk_vb(name)
|
||||
|
@ -867,7 +1000,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
def default_view(
|
||||
self,
|
||||
bars_from_y: int = 616,
|
||||
bars_from_y: int = int(616 * 3/8),
|
||||
y_offset: int = 0,
|
||||
do_ds: bool = True,
|
||||
|
||||
) -> None:
|
||||
|
@ -906,8 +1040,12 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# terms now that we've scaled either by user control
|
||||
# or to the default set of bars as per the immediate block
|
||||
# above.
|
||||
marker_pos, l1_len = self.pre_l1_xs()
|
||||
end = xlast + l1_len + 1
|
||||
if not y_offset:
|
||||
marker_pos, l1_len = self.pre_l1_xs()
|
||||
end = xlast + l1_len + 1
|
||||
else:
|
||||
end = xlast + y_offset + 1
|
||||
|
||||
begin = end - (r - l)
|
||||
|
||||
# for debugging
|
||||
|
|
|
@ -18,8 +18,13 @@
|
|||
Mouse interaction graphics
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from functools import partial
|
||||
from typing import Optional, Callable
|
||||
from typing import (
|
||||
Optional,
|
||||
Callable,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
import inspect
|
||||
import numpy as np
|
||||
|
@ -36,6 +41,12 @@ from ._style import (
|
|||
from ._axes import YAxisLabel, XAxisLabel
|
||||
from ..log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._chart import (
|
||||
ChartPlotWidget,
|
||||
LinkedSplits,
|
||||
)
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
@ -58,7 +69,7 @@ class LineDot(pg.CurvePoint):
|
|||
curve: pg.PlotCurveItem,
|
||||
index: int,
|
||||
|
||||
plot: 'ChartPlotWidget', # type: ingore # noqa
|
||||
plot: ChartPlotWidget, # type: ingore # noqa
|
||||
pos=None,
|
||||
color: str = 'default_light',
|
||||
|
||||
|
@ -151,7 +162,7 @@ class ContentsLabel(pg.LabelItem):
|
|||
def __init__(
|
||||
self,
|
||||
|
||||
# chart: 'ChartPlotWidget', # noqa
|
||||
# chart: ChartPlotWidget, # noqa
|
||||
view: pg.ViewBox,
|
||||
|
||||
anchor_at: str = ('top', 'right'),
|
||||
|
@ -244,7 +255,7 @@ class ContentsLabels:
|
|||
'''
|
||||
def __init__(
|
||||
self,
|
||||
linkedsplits: 'LinkedSplits', # type: ignore # noqa
|
||||
linkedsplits: LinkedSplits, # type: ignore # noqa
|
||||
|
||||
) -> None:
|
||||
|
||||
|
@ -289,7 +300,7 @@ class ContentsLabels:
|
|||
def add_label(
|
||||
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # type: ignore # noqa
|
||||
chart: ChartPlotWidget, # type: ignore # noqa
|
||||
name: str,
|
||||
anchor_at: tuple[str, str] = ('top', 'left'),
|
||||
update_func: Callable = ContentsLabel.update_from_value,
|
||||
|
@ -316,7 +327,7 @@ class Cursor(pg.GraphicsObject):
|
|||
def __init__(
|
||||
|
||||
self,
|
||||
linkedsplits: 'LinkedSplits', # noqa
|
||||
linkedsplits: LinkedSplits, # noqa
|
||||
digits: int = 0
|
||||
|
||||
) -> None:
|
||||
|
@ -325,6 +336,8 @@ class Cursor(pg.GraphicsObject):
|
|||
|
||||
self.linked = linkedsplits
|
||||
self.graphics: dict[str, pg.GraphicsObject] = {}
|
||||
self.xaxis_label: Optional[XAxisLabel] = None
|
||||
self.always_show_xlabel: bool = True
|
||||
self.plots: list['PlotChartWidget'] = [] # type: ignore # noqa
|
||||
self.active_plot = None
|
||||
self.digits: int = digits
|
||||
|
@ -385,7 +398,7 @@ class Cursor(pg.GraphicsObject):
|
|||
|
||||
def add_plot(
|
||||
self,
|
||||
plot: 'ChartPlotWidget', # noqa
|
||||
plot: ChartPlotWidget, # noqa
|
||||
digits: int = 0,
|
||||
|
||||
) -> None:
|
||||
|
@ -469,7 +482,7 @@ class Cursor(pg.GraphicsObject):
|
|||
|
||||
def add_curve_cursor(
|
||||
self,
|
||||
plot: 'ChartPlotWidget', # noqa
|
||||
plot: ChartPlotWidget, # noqa
|
||||
curve: 'PlotCurveItem', # noqa
|
||||
|
||||
) -> LineDot:
|
||||
|
@ -491,17 +504,29 @@ class Cursor(pg.GraphicsObject):
|
|||
log.debug(f"{(action, plot.name)}")
|
||||
if action == 'Enter':
|
||||
self.active_plot = plot
|
||||
plot.linked.godwidget._active_cursor = self
|
||||
|
||||
# show horiz line and y-label
|
||||
self.graphics[plot]['hl'].show()
|
||||
self.graphics[plot]['yl'].show()
|
||||
|
||||
else: # Leave
|
||||
if (
|
||||
not self.always_show_xlabel
|
||||
and not self.xaxis_label.isVisible()
|
||||
):
|
||||
self.xaxis_label.show()
|
||||
|
||||
# hide horiz line and y-label
|
||||
# Leave: hide horiz line and y-label
|
||||
else:
|
||||
self.graphics[plot]['hl'].hide()
|
||||
self.graphics[plot]['yl'].hide()
|
||||
|
||||
if (
|
||||
not self.always_show_xlabel
|
||||
and self.xaxis_label.isVisible()
|
||||
):
|
||||
self.xaxis_label.hide()
|
||||
|
||||
def mouseMoved(
|
||||
self,
|
||||
coords: tuple[QPointF], # noqa
|
||||
|
@ -590,13 +615,17 @@ class Cursor(pg.GraphicsObject):
|
|||
left_axis_width += left.width()
|
||||
|
||||
# map back to abs (label-local) coordinates
|
||||
self.xaxis_label.update_label(
|
||||
abs_pos=(
|
||||
plot.mapFromView(QPointF(vl_x, iy)) -
|
||||
QPointF(left_axis_width, 0)
|
||||
),
|
||||
value=ix,
|
||||
)
|
||||
if (
|
||||
self.always_show_xlabel
|
||||
or self.xaxis_label.isVisible()
|
||||
):
|
||||
self.xaxis_label.update_label(
|
||||
abs_pos=(
|
||||
plot.mapFromView(QPointF(vl_x, iy)) -
|
||||
QPointF(left_axis_width, 0)
|
||||
),
|
||||
value=ix,
|
||||
)
|
||||
|
||||
self._datum_xy = ix, iy
|
||||
|
||||
|
|
|
@ -21,19 +21,20 @@ this module ties together quote and computational (fsp) streams with
|
|||
graphics update methods via our custom ``pyqtgraph`` charting api.
|
||||
|
||||
'''
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
import time
|
||||
from typing import Optional, Any, Callable
|
||||
|
||||
import numpy as np
|
||||
import tractor
|
||||
import trio
|
||||
import pendulum
|
||||
import pyqtgraph as pg
|
||||
|
||||
# from .. import brokers
|
||||
from ..data.feed import open_feed
|
||||
from ..data.feed import (
|
||||
open_feed,
|
||||
Feed,
|
||||
)
|
||||
from ..data.types import Struct
|
||||
from ._axes import YAxisLabel
|
||||
from ._chart import (
|
||||
ChartPlotWidget,
|
||||
|
@ -41,6 +42,7 @@ from ._chart import (
|
|||
GodWidget,
|
||||
)
|
||||
from ._l1 import L1Labels
|
||||
from ._style import hcolor
|
||||
from ._fsp import (
|
||||
update_fsp_chart,
|
||||
start_fsp_displays,
|
||||
|
@ -53,7 +55,10 @@ from ._forms import (
|
|||
FieldsForm,
|
||||
mk_order_pane_layout,
|
||||
)
|
||||
from .order_mode import open_order_mode
|
||||
from .order_mode import (
|
||||
open_order_mode,
|
||||
OrderMode,
|
||||
)
|
||||
from .._profile import (
|
||||
pg_profile_enabled,
|
||||
ms_slower_then,
|
||||
|
@ -63,7 +68,7 @@ from ..log import get_logger
|
|||
log = get_logger(__name__)
|
||||
|
||||
# TODO: load this from a config.toml!
|
||||
_quote_throttle_rate: int = 22 # Hz
|
||||
_quote_throttle_rate: int = 16 # Hz
|
||||
|
||||
|
||||
# a working tick-type-classes template
|
||||
|
@ -122,39 +127,105 @@ def chart_maxmin(
|
|||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayState:
|
||||
class DisplayState(Struct):
|
||||
'''
|
||||
Chart-local real-time graphics state container.
|
||||
|
||||
'''
|
||||
godwidget: GodWidget
|
||||
quotes: dict[str, Any]
|
||||
|
||||
maxmin: Callable
|
||||
ohlcv: ShmArray
|
||||
hist_ohlcv: ShmArray
|
||||
|
||||
# high level chart handles
|
||||
linked: LinkedSplits
|
||||
chart: ChartPlotWidget
|
||||
|
||||
# axis labels
|
||||
l1: L1Labels
|
||||
last_price_sticky: YAxisLabel
|
||||
hist_last_price_sticky: YAxisLabel
|
||||
|
||||
# misc state tracking
|
||||
vars: dict[str, Any]
|
||||
vars: dict[str, Any] = {
|
||||
'tick_margin': 0,
|
||||
'i_last': 0,
|
||||
'i_last_append': 0,
|
||||
'last_mx_vlm': 0,
|
||||
'last_mx': 0,
|
||||
'last_mn': 0,
|
||||
}
|
||||
|
||||
vlm_chart: Optional[ChartPlotWidget] = None
|
||||
vlm_sticky: Optional[YAxisLabel] = None
|
||||
wap_in_history: bool = False
|
||||
|
||||
def incr_info(
|
||||
self,
|
||||
chart: Optional[ChartPlotWidget] = None,
|
||||
shm: Optional[ShmArray] = None,
|
||||
state: Optional[dict] = None, # pass in a copy if you don't
|
||||
|
||||
update_state: bool = True,
|
||||
update_uppx: float = 16,
|
||||
|
||||
) -> tuple:
|
||||
|
||||
shm = shm or self.ohlcv
|
||||
chart = chart or self.chart
|
||||
state = state or self.vars
|
||||
|
||||
if not update_state:
|
||||
state = state.copy()
|
||||
|
||||
# compute the first available graphic's x-units-per-pixel
|
||||
uppx = chart.view.x_uppx()
|
||||
|
||||
# NOTE: this used to be implemented in a dedicated
|
||||
# "increment task": ``check_for_new_bars()`` but it doesn't
|
||||
# make sense to do a whole task switch when we can just do
|
||||
# this simple index-diff and all the fsp sub-curve graphics
|
||||
# are diffed on each draw cycle anyway; so updates to the
|
||||
# "curve" length is already automatic.
|
||||
|
||||
# increment the view position by the sample offset.
|
||||
i_step = shm.index
|
||||
i_diff = i_step - state['i_last']
|
||||
state['i_last'] = i_step
|
||||
|
||||
append_diff = i_step - state['i_last_append']
|
||||
|
||||
# update the "last datum" (aka extending the flow graphic with
|
||||
# new data) only if the number of unit steps is >= the number of
|
||||
# such unit steps per pixel (aka uppx). Iow, if the zoom level
|
||||
# is such that a datum(s) update to graphics wouldn't span
|
||||
# to a new pixel, we don't update yet.
|
||||
do_append = (append_diff >= uppx)
|
||||
if do_append:
|
||||
state['i_last_append'] = i_step
|
||||
|
||||
do_rt_update = uppx < update_uppx
|
||||
|
||||
_, _, _, r = chart.bars_range()
|
||||
liv = r >= i_step
|
||||
|
||||
# TODO: pack this into a struct
|
||||
return (
|
||||
uppx,
|
||||
liv,
|
||||
do_append,
|
||||
i_diff,
|
||||
append_diff,
|
||||
do_rt_update,
|
||||
)
|
||||
|
||||
|
||||
async def graphics_update_loop(
|
||||
|
||||
linked: LinkedSplits,
|
||||
stream: tractor.MsgStream,
|
||||
ohlcv: np.ndarray,
|
||||
|
||||
nurse: trio.Nursery,
|
||||
godwidget: GodWidget,
|
||||
feed: Feed,
|
||||
wap_in_history: bool = False,
|
||||
vlm_chart: Optional[ChartPlotWidget] = None,
|
||||
|
||||
|
@ -175,9 +246,14 @@ async def graphics_update_loop(
|
|||
# of copying it from last bar's close
|
||||
# - 1-5 sec bar lookback-autocorrection like tws does?
|
||||
# (would require a background history checker task)
|
||||
display_rate = linked.godwidget.window.current_screen().refreshRate()
|
||||
linked: LinkedSplits = godwidget.rt_linked
|
||||
display_rate = godwidget.window.current_screen().refreshRate()
|
||||
|
||||
chart = linked.chart
|
||||
hist_chart = godwidget.hist_linked.chart
|
||||
|
||||
ohlcv = feed.rt_shm
|
||||
hist_ohlcv = feed.hist_shm
|
||||
|
||||
# update last price sticky
|
||||
last_price_sticky = chart._ysticks[chart.name]
|
||||
|
@ -185,6 +261,11 @@ async def graphics_update_loop(
|
|||
*ohlcv.array[-1][['index', 'close']]
|
||||
)
|
||||
|
||||
hist_last_price_sticky = hist_chart._ysticks[hist_chart.name]
|
||||
hist_last_price_sticky.update_from_data(
|
||||
*hist_ohlcv.array[-1][['index', 'close']]
|
||||
)
|
||||
|
||||
maxmin = partial(
|
||||
chart_maxmin,
|
||||
chart,
|
||||
|
@ -227,12 +308,14 @@ async def graphics_update_loop(
|
|||
i_last = ohlcv.index
|
||||
|
||||
ds = linked.display_state = DisplayState(**{
|
||||
'godwidget': godwidget,
|
||||
'quotes': {},
|
||||
'linked': linked,
|
||||
'maxmin': maxmin,
|
||||
'ohlcv': ohlcv,
|
||||
'hist_ohlcv': hist_ohlcv,
|
||||
'chart': chart,
|
||||
'last_price_sticky': last_price_sticky,
|
||||
'hist_last_price_sticky': hist_last_price_sticky,
|
||||
'l1': l1,
|
||||
|
||||
'vars': {
|
||||
|
@ -252,7 +335,62 @@ async def graphics_update_loop(
|
|||
|
||||
chart.default_view()
|
||||
|
||||
# TODO: probably factor this into some kinda `DisplayState`
|
||||
# API that can be reused at least in terms of pulling view
|
||||
# params (eg ``.bars_range()``).
|
||||
async def increment_history_view():
|
||||
i_last = hist_ohlcv.index
|
||||
state = ds.vars.copy() | {
|
||||
'i_last_append': i_last,
|
||||
'i_last': i_last,
|
||||
}
|
||||
_, hist_step_size_s, _ = feed.get_ds_info()
|
||||
|
||||
async with feed.index_stream(
|
||||
# int(hist_step_size_s)
|
||||
# TODO: seems this is more reliable at keeping the slow
|
||||
# chart incremented in view more correctly?
|
||||
# - It might make sense to just inline this logic with the
|
||||
# main display task? => it's a tradeoff of slower task
|
||||
# wakeups/ctx switches verus logic checks (as normal)
|
||||
# - we need increment logic that only does the view shift
|
||||
# call when the uppx permits/needs it
|
||||
int(1),
|
||||
) as istream:
|
||||
async for msg in istream:
|
||||
|
||||
# check if slow chart needs an x-domain shift and/or
|
||||
# y-range resize.
|
||||
(
|
||||
uppx,
|
||||
liv,
|
||||
do_append,
|
||||
i_diff,
|
||||
append_diff,
|
||||
do_rt_update,
|
||||
) = ds.incr_info(
|
||||
chart=hist_chart,
|
||||
shm=ds.hist_ohlcv,
|
||||
state=state,
|
||||
# update_state=False,
|
||||
)
|
||||
# print(
|
||||
# f'liv: {liv}\n'
|
||||
# f'do_append: {do_append}\n'
|
||||
# f'append_diff: {append_diff}\n'
|
||||
# )
|
||||
|
||||
if (
|
||||
do_append
|
||||
and liv
|
||||
):
|
||||
hist_chart.increment_view(steps=i_diff)
|
||||
hist_chart.view._set_yrange(yrange=hist_chart.maxmin())
|
||||
|
||||
nurse.start_soon(increment_history_view)
|
||||
|
||||
# main real-time quotes update loop
|
||||
stream: tractor.MsgStream = feed.stream
|
||||
async for quotes in stream:
|
||||
|
||||
ds.quotes = quotes
|
||||
|
@ -273,7 +411,7 @@ async def graphics_update_loop(
|
|||
|
||||
# chart isn't active/shown so skip render cycle and pause feed(s)
|
||||
if chart.linked.isHidden():
|
||||
print('skipping update')
|
||||
# print('skipping update')
|
||||
chart.pause_all_feeds()
|
||||
continue
|
||||
|
||||
|
@ -298,6 +436,8 @@ def graphics_update_cycle(
|
|||
# hopefully XD
|
||||
|
||||
chart = ds.chart
|
||||
# TODO: just pass this as a direct ref to avoid so many attr accesses?
|
||||
hist_chart = ds.godwidget.hist_linked.chart
|
||||
|
||||
profiler = pg.debug.Profiler(
|
||||
msg=f'Graphics loop cycle for: `{chart.name}`',
|
||||
|
@ -311,53 +451,24 @@ def graphics_update_cycle(
|
|||
|
||||
# unpack multi-referenced components
|
||||
vlm_chart = ds.vlm_chart
|
||||
|
||||
# rt "HFT" chart
|
||||
l1 = ds.l1
|
||||
ohlcv = ds.ohlcv
|
||||
array = ohlcv.array
|
||||
|
||||
vars = ds.vars
|
||||
tick_margin = vars['tick_margin']
|
||||
|
||||
update_uppx = 16
|
||||
|
||||
for sym, quote in ds.quotes.items():
|
||||
|
||||
# compute the first available graphic's x-units-per-pixel
|
||||
uppx = chart.view.x_uppx()
|
||||
|
||||
# NOTE: vlm may be written by the ``brokerd`` backend
|
||||
# event though a tick sample is not emitted.
|
||||
# TODO: show dark trades differently
|
||||
# https://github.com/pikers/piker/issues/116
|
||||
|
||||
# NOTE: this used to be implemented in a dedicated
|
||||
# "increment task": ``check_for_new_bars()`` but it doesn't
|
||||
# make sense to do a whole task switch when we can just do
|
||||
# this simple index-diff and all the fsp sub-curve graphics
|
||||
# are diffed on each draw cycle anyway; so updates to the
|
||||
# "curve" length is already automatic.
|
||||
|
||||
# increment the view position by the sample offset.
|
||||
i_step = ohlcv.index
|
||||
i_diff = i_step - vars['i_last']
|
||||
vars['i_last'] = i_step
|
||||
|
||||
append_diff = i_step - vars['i_last_append']
|
||||
|
||||
# update the "last datum" (aka extending the flow graphic with
|
||||
# new data) only if the number of unit steps is >= the number of
|
||||
# such unit steps per pixel (aka uppx). Iow, if the zoom level
|
||||
# is such that a datum(s) update to graphics wouldn't span
|
||||
# to a new pixel, we don't update yet.
|
||||
do_append = (append_diff >= uppx)
|
||||
if do_append:
|
||||
vars['i_last_append'] = i_step
|
||||
|
||||
do_rt_update = uppx < update_uppx
|
||||
# print(
|
||||
# f'append_diff:{append_diff}\n'
|
||||
# f'uppx:{uppx}\n'
|
||||
# f'do_append: {do_append}'
|
||||
# )
|
||||
(
|
||||
uppx,
|
||||
liv,
|
||||
do_append,
|
||||
i_diff,
|
||||
append_diff,
|
||||
do_rt_update,
|
||||
) = ds.incr_info()
|
||||
|
||||
# TODO: we should only run mxmn when we know
|
||||
# an update is due via ``do_append`` above.
|
||||
|
@ -373,8 +484,6 @@ def graphics_update_cycle(
|
|||
|
||||
profiler('`ds.maxmin()` call')
|
||||
|
||||
liv = r >= i_step # the last datum is in view
|
||||
|
||||
if (
|
||||
prepend_update_index is not None
|
||||
and lbar > prepend_update_index
|
||||
|
@ -389,16 +498,10 @@ def graphics_update_cycle(
|
|||
# don't real-time "shift" the curve to the
|
||||
# left unless we get one of the following:
|
||||
if (
|
||||
(
|
||||
# i_diff > 0 # no new sample step
|
||||
do_append
|
||||
# and uppx < 4 # chart is zoomed out very far
|
||||
and liv
|
||||
)
|
||||
(do_append and liv)
|
||||
or trigger_all
|
||||
):
|
||||
chart.increment_view(steps=i_diff)
|
||||
# chart.increment_view(steps=i_diff + round(append_diff - uppx))
|
||||
|
||||
if vlm_chart:
|
||||
vlm_chart.increment_view(steps=i_diff)
|
||||
|
@ -458,6 +561,10 @@ def graphics_update_cycle(
|
|||
chart.name,
|
||||
do_append=do_append,
|
||||
)
|
||||
hist_chart.update_graphics_from_flow(
|
||||
chart.name,
|
||||
do_append=do_append,
|
||||
)
|
||||
|
||||
# NOTE: we always update the "last" datum
|
||||
# since the current range should at least be updated
|
||||
|
@ -495,6 +602,9 @@ def graphics_update_cycle(
|
|||
ds.last_price_sticky.update_from_data(
|
||||
*end[['index', 'close']]
|
||||
)
|
||||
ds.hist_last_price_sticky.update_from_data(
|
||||
*end[['index', 'close']]
|
||||
)
|
||||
|
||||
if wap_in_history:
|
||||
# update vwap overlay line
|
||||
|
@ -542,26 +652,44 @@ def graphics_update_cycle(
|
|||
l1.bid_label.update_fields({'level': price, 'size': size})
|
||||
|
||||
# check for y-range re-size
|
||||
if (
|
||||
(mx > vars['last_mx']) or (mn < vars['last_mn'])
|
||||
and not chart._static_yrange == 'axis'
|
||||
and liv
|
||||
):
|
||||
main_vb = chart.view
|
||||
if (mx > vars['last_mx']) or (mn < vars['last_mn']):
|
||||
|
||||
# fast chart resize case
|
||||
if (
|
||||
main_vb._ic is None
|
||||
or not main_vb._ic.is_set()
|
||||
liv
|
||||
and not chart._static_yrange == 'axis'
|
||||
):
|
||||
# print(f'updating range due to mxmn')
|
||||
main_vb._set_yrange(
|
||||
# TODO: we should probably scale
|
||||
# the view margin based on the size
|
||||
# of the true range? This way you can
|
||||
# slap in orders outside the current
|
||||
# L1 (only) book range.
|
||||
# range_margin=0.1,
|
||||
yrange=(mn, mx),
|
||||
)
|
||||
main_vb = chart.view
|
||||
if (
|
||||
main_vb._ic is None
|
||||
or not main_vb._ic.is_set()
|
||||
):
|
||||
# print(f'updating range due to mxmn')
|
||||
main_vb._set_yrange(
|
||||
# TODO: we should probably scale
|
||||
# the view margin based on the size
|
||||
# of the true range? This way you can
|
||||
# slap in orders outside the current
|
||||
# L1 (only) book range.
|
||||
# range_margin=0.1,
|
||||
yrange=(mn, mx),
|
||||
)
|
||||
|
||||
# check if slow chart needs a resize
|
||||
(
|
||||
_,
|
||||
hist_liv,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
) = ds.incr_info(
|
||||
chart=hist_chart,
|
||||
shm=ds.hist_ohlcv,
|
||||
update_state=False,
|
||||
)
|
||||
if hist_liv:
|
||||
hist_chart.view._set_yrange(yrange=hist_chart.maxmin())
|
||||
|
||||
# XXX: update this every draw cycle to make L1-always-in-view work.
|
||||
vars['last_mx'], vars['last_mn'] = mx, mn
|
||||
|
@ -719,15 +847,17 @@ async def display_symbol_data(
|
|||
tick_throttle=_quote_throttle_rate,
|
||||
|
||||
) as feed:
|
||||
ohlcv: ShmArray = feed.shm
|
||||
bars = ohlcv.array
|
||||
ohlcv: ShmArray = feed.rt_shm
|
||||
hist_ohlcv: ShmArray = feed.hist_shm
|
||||
|
||||
# this value needs to be pulled once and only once during
|
||||
# startup
|
||||
end_index = feed.startup_hist_index
|
||||
|
||||
symbol = feed.symbols[sym]
|
||||
fqsn = symbol.front_fqsn()
|
||||
|
||||
times = bars['time']
|
||||
end = pendulum.from_timestamp(times[-1])
|
||||
start = pendulum.from_timestamp(times[times != times[-1]][-1])
|
||||
step_size_s = (end - start).seconds
|
||||
step_size_s = 1
|
||||
tf_key = tf_in_1s[step_size_s]
|
||||
|
||||
# load in symbol's ohlc data
|
||||
|
@ -737,51 +867,158 @@ async def display_symbol_data(
|
|||
f'step:{tf_key} '
|
||||
)
|
||||
|
||||
linked = godwidget.linkedsplits
|
||||
linked._symbol = symbol
|
||||
rt_linked = godwidget.rt_linked
|
||||
rt_linked._symbol = symbol
|
||||
|
||||
# create top history view chart above the "main rt chart".
|
||||
hist_linked = godwidget.hist_linked
|
||||
hist_linked._symbol = symbol
|
||||
hist_chart = hist_linked.plot_ohlc_main(
|
||||
symbol,
|
||||
feed.hist_shm,
|
||||
# in the case of history chart we explicitly set `False`
|
||||
# to avoid internal pane creation.
|
||||
# sidepane=False,
|
||||
sidepane=godwidget.search,
|
||||
)
|
||||
# don't show when not focussed
|
||||
hist_linked.cursor.always_show_xlabel = False
|
||||
|
||||
# generate order mode side-pane UI
|
||||
# A ``FieldsForm`` form to configure order entry
|
||||
# and add as next-to-y-axis singleton pane
|
||||
pp_pane: FieldsForm = mk_order_pane_layout(godwidget)
|
||||
|
||||
# add as next-to-y-axis singleton pane
|
||||
godwidget.pp_pane = pp_pane
|
||||
|
||||
# create main OHLC chart
|
||||
chart = linked.plot_ohlc_main(
|
||||
chart = rt_linked.plot_ohlc_main(
|
||||
symbol,
|
||||
ohlcv,
|
||||
# in the case of history chart we explicitly set `False`
|
||||
# to avoid internal pane creation.
|
||||
sidepane=pp_pane,
|
||||
)
|
||||
chart.default_view()
|
||||
|
||||
chart._feeds[symbol.key] = feed
|
||||
chart.setFocus()
|
||||
|
||||
# XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?!
|
||||
# plot historical vwap if available
|
||||
wap_in_history = False
|
||||
# if (
|
||||
# brokermod._show_wap_in_history
|
||||
# and 'bar_wap' in bars.dtype.fields
|
||||
# ):
|
||||
# wap_in_history = True
|
||||
# chart.draw_curve(
|
||||
# name='bar_wap',
|
||||
# shm=ohlcv,
|
||||
# color='default_light',
|
||||
# add_label=False,
|
||||
# )
|
||||
|
||||
# XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?!
|
||||
# if brokermod._show_wap_in_history:
|
||||
# Add the LinearRegionItem to the ViewBox, but tell the ViewBox
|
||||
# to exclude this item when doing auto-range calculations.
|
||||
rt_pi = chart.plotItem
|
||||
hist_pi = hist_chart.plotItem
|
||||
region = pg.LinearRegionItem(
|
||||
# color scheme that matches sidepane styling
|
||||
pen=pg.mkPen(hcolor('gunmetal')),
|
||||
brush=pg.mkBrush(hcolor('default_darkest')),
|
||||
)
|
||||
region.setZValue(10) # put linear region "in front" in layer terms
|
||||
hist_pi.addItem(region, ignoreBounds=True)
|
||||
flow = chart._flows[hist_chart.name]
|
||||
assert flow
|
||||
# XXX: no idea why this doesn't work but it's causing
|
||||
# a weird placement of the region on the way-far-left..
|
||||
# region.setClipItem(flow.graphics)
|
||||
|
||||
# if 'bar_wap' in bars.dtype.fields:
|
||||
# wap_in_history = True
|
||||
# chart.draw_curve(
|
||||
# name='bar_wap',
|
||||
# shm=ohlcv,
|
||||
# color='default_light',
|
||||
# add_label=False,
|
||||
# )
|
||||
# poll for datums load and timestep detection
|
||||
for _ in range(100):
|
||||
try:
|
||||
_, _, ratio = feed.get_ds_info()
|
||||
break
|
||||
except IndexError:
|
||||
await trio.sleep(0.01)
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(
|
||||
'Failed to detect sampling periods from shm!?')
|
||||
|
||||
# size view to data once at outset
|
||||
chart.cv._set_yrange()
|
||||
def update_pi_from_region():
|
||||
region.setZValue(10)
|
||||
mn, mx = region.getRegion()
|
||||
# print(f'region_x: {(mn, mx)}')
|
||||
|
||||
# XXX: seems to cause a real perf hit?
|
||||
rt_pi.setXRange(
|
||||
(mn - end_index) * ratio,
|
||||
(mx - end_index) * ratio,
|
||||
padding=0,
|
||||
)
|
||||
|
||||
region.sigRegionChanged.connect(update_pi_from_region)
|
||||
|
||||
def update_region_from_pi(
|
||||
window,
|
||||
viewRange: tuple[tuple, tuple],
|
||||
is_manual: bool = True,
|
||||
|
||||
) -> None:
|
||||
# set the region on the history chart
|
||||
# to the range currently viewed in the
|
||||
# HFT/real-time chart.
|
||||
mn, mx = viewRange[0]
|
||||
ds_mn = mn/ratio
|
||||
ds_mx = mx/ratio
|
||||
# print(
|
||||
# f'rt_view_range: {(mn, mx)}\n'
|
||||
# f'ds_mn, ds_mx: {(ds_mn, ds_mx)}\n'
|
||||
# )
|
||||
lhmn = ds_mn + end_index
|
||||
lhmx = ds_mx + end_index
|
||||
region.setRegion((
|
||||
lhmn,
|
||||
lhmx,
|
||||
))
|
||||
|
||||
# TODO: if we want to have the slow chart adjust range to
|
||||
# match the fast chart's selection -> results in the
|
||||
# linear region expansion never can go "outside of view".
|
||||
# hmn, hmx = hvr = hist_chart.view.state['viewRange'][0]
|
||||
# print((hmn, hmx))
|
||||
# if (
|
||||
# hvr
|
||||
# and (lhmn < hmn or lhmx > hmx)
|
||||
# ):
|
||||
# hist_pi.setXRange(
|
||||
# lhmn,
|
||||
# lhmx,
|
||||
# padding=0,
|
||||
# )
|
||||
# hist_linked.graphics_cycle()
|
||||
|
||||
# connect region to be updated on plotitem interaction.
|
||||
rt_pi.sigRangeChanged.connect(update_region_from_pi)
|
||||
|
||||
# NOTE: we must immediately tell Qt to show the OHLC chart
|
||||
# to avoid a race where the subplots get added/shown to
|
||||
# the linked set *before* the main price chart!
|
||||
linked.show()
|
||||
linked.focus()
|
||||
rt_linked.show()
|
||||
rt_linked.focus()
|
||||
await trio.sleep(0)
|
||||
|
||||
# NOTE: here we insert the slow-history chart set into
|
||||
# the fast chart's splitter -> so it's a splitter of charts
|
||||
# inside the first widget slot of a splitter of charts XD
|
||||
rt_linked.splitter.insertWidget(0, hist_linked)
|
||||
# XXX: if we wanted it at the bottom?
|
||||
# rt_linked.splitter.addWidget(hist_linked)
|
||||
rt_linked.focus()
|
||||
|
||||
godwidget.resize_all()
|
||||
|
||||
vlm_chart: Optional[ChartPlotWidget] = None
|
||||
async with trio.open_nursery() as ln:
|
||||
|
||||
|
@ -792,7 +1029,7 @@ async def display_symbol_data(
|
|||
):
|
||||
vlm_chart = await ln.start(
|
||||
open_vlm_displays,
|
||||
linked,
|
||||
rt_linked,
|
||||
ohlcv,
|
||||
)
|
||||
|
||||
|
@ -800,7 +1037,7 @@ async def display_symbol_data(
|
|||
# from an input config.
|
||||
ln.start_soon(
|
||||
start_fsp_displays,
|
||||
linked,
|
||||
rt_linked,
|
||||
ohlcv,
|
||||
loading_sym_key,
|
||||
loglevel,
|
||||
|
@ -809,39 +1046,73 @@ async def display_symbol_data(
|
|||
# start graphics update loop after receiving first live quote
|
||||
ln.start_soon(
|
||||
graphics_update_loop,
|
||||
linked,
|
||||
feed.stream,
|
||||
ohlcv,
|
||||
ln,
|
||||
godwidget,
|
||||
feed,
|
||||
wap_in_history,
|
||||
vlm_chart,
|
||||
)
|
||||
|
||||
await trio.sleep(0)
|
||||
|
||||
# size view to data prior to order mode init
|
||||
chart.default_view()
|
||||
rt_linked.graphics_cycle()
|
||||
await trio.sleep(0)
|
||||
|
||||
hist_chart.default_view(
|
||||
bars_from_y=int(len(hist_ohlcv.array)), # size to data
|
||||
y_offset=6116*2, # push it a little away from the y-axis
|
||||
)
|
||||
hist_linked.graphics_cycle()
|
||||
await trio.sleep(0)
|
||||
|
||||
godwidget.resize_all()
|
||||
|
||||
mode: OrderMode
|
||||
async with (
|
||||
open_order_mode(
|
||||
feed,
|
||||
chart,
|
||||
godwidget,
|
||||
fqsn,
|
||||
order_mode_started
|
||||
)
|
||||
) as mode
|
||||
):
|
||||
if not vlm_chart:
|
||||
# trigger another view reset if no sub-chart
|
||||
chart.default_view()
|
||||
|
||||
rt_linked.mode = mode
|
||||
|
||||
# let Qt run to render all widgets and make sure the
|
||||
# sidepanes line up vertically.
|
||||
await trio.sleep(0)
|
||||
linked.resize_sidepanes()
|
||||
|
||||
# dynamic resize steps
|
||||
godwidget.resize_all()
|
||||
|
||||
# TODO: look into this because not sure why it was
|
||||
# commented out / we ever needed it XD
|
||||
# NOTE: we pop the volume chart from the subplots set so
|
||||
# that it isn't double rendered in the display loop
|
||||
# above since we do a maxmin calc on the volume data to
|
||||
# determine if auto-range adjustements should be made.
|
||||
# linked.subplots.pop('volume', None)
|
||||
# rt_linked.subplots.pop('volume', None)
|
||||
|
||||
# TODO: make this not so shit XD
|
||||
# close group status
|
||||
sbar._status_groups[loading_sym_key][1]()
|
||||
|
||||
hist_linked.graphics_cycle()
|
||||
await trio.sleep(0)
|
||||
|
||||
bars_in_mem = int(len(hist_ohlcv.array))
|
||||
hist_chart.default_view(
|
||||
bars_from_y=bars_in_mem, # size to data
|
||||
# push it 1/16th away from the y-axis
|
||||
y_offset=round(bars_in_mem / 16),
|
||||
)
|
||||
godwidget.resize_all()
|
||||
|
||||
# let the app run.. bby
|
||||
# linked.graphics_cycle()
|
||||
await trio.sleep_forever()
|
||||
|
|
|
@ -18,8 +18,12 @@
|
|||
Higher level annotation editors.
|
||||
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from typing import (
|
||||
Optional,
|
||||
TYPE_CHECKING
|
||||
)
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import ViewBox, Point, QtCore, QtGui
|
||||
|
@ -30,28 +34,34 @@ import numpy as np
|
|||
from ._style import hcolor, _font
|
||||
from ._lines import LevelLine
|
||||
from ..log import get_logger
|
||||
from ..data.types import Struct
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._chart import GodWidget
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArrowEditor:
|
||||
class ArrowEditor(Struct):
|
||||
|
||||
chart: 'ChartPlotWidget' # noqa
|
||||
_arrows: field(default_factory=dict)
|
||||
godw: GodWidget = None # type: ignore # noqa
|
||||
_arrows: dict[str, list[pg.ArrowItem]] = {}
|
||||
|
||||
def add(
|
||||
self,
|
||||
plot: pg.PlotItem,
|
||||
uid: str,
|
||||
x: float,
|
||||
y: float,
|
||||
color='default',
|
||||
pointing: Optional[str] = None,
|
||||
) -> pg.ArrowItem:
|
||||
"""Add an arrow graphic to view at given (x, y).
|
||||
|
||||
"""
|
||||
) -> pg.ArrowItem:
|
||||
'''
|
||||
Add an arrow graphic to view at given (x, y).
|
||||
|
||||
'''
|
||||
angle = {
|
||||
'up': 90,
|
||||
'down': -90,
|
||||
|
@ -74,25 +84,25 @@ class ArrowEditor:
|
|||
brush=pg.mkBrush(hcolor(color)),
|
||||
)
|
||||
arrow.setPos(x, y)
|
||||
|
||||
self._arrows[uid] = arrow
|
||||
self._arrows.setdefault(uid, []).append(arrow)
|
||||
|
||||
# render to view
|
||||
self.chart.plotItem.addItem(arrow)
|
||||
plot.addItem(arrow)
|
||||
|
||||
return arrow
|
||||
|
||||
def remove(self, arrow) -> bool:
|
||||
self.chart.plotItem.removeItem(arrow)
|
||||
for linked in self.godw.iter_linked():
|
||||
linked.chart.plotItem.removeItem(arrow)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineEditor:
|
||||
'''The great editor of linez.
|
||||
class LineEditor(Struct):
|
||||
'''
|
||||
The great editor of linez.
|
||||
|
||||
'''
|
||||
chart: 'ChartPlotWidget' = None # type: ignore # noqa
|
||||
_order_lines: dict[str, LevelLine] = field(default_factory=dict)
|
||||
godw: GodWidget = None # type: ignore # noqa
|
||||
_order_lines: defaultdict[str, LevelLine] = defaultdict(list)
|
||||
_active_staged_line: LevelLine = None
|
||||
|
||||
def stage_line(
|
||||
|
@ -100,11 +110,11 @@ class LineEditor:
|
|||
line: LevelLine,
|
||||
|
||||
) -> LevelLine:
|
||||
"""Stage a line at the current chart's cursor position
|
||||
'''
|
||||
Stage a line at the current chart's cursor position
|
||||
and return it.
|
||||
|
||||
"""
|
||||
|
||||
'''
|
||||
# add a "staged" cursor-tracking line to view
|
||||
# and cash it in a a var
|
||||
if self._active_staged_line:
|
||||
|
@ -115,17 +125,25 @@ class LineEditor:
|
|||
return line
|
||||
|
||||
def unstage_line(self) -> LevelLine:
|
||||
"""Inverse of ``.stage_line()``.
|
||||
'''
|
||||
Inverse of ``.stage_line()``.
|
||||
|
||||
"""
|
||||
# chart = self.chart._cursor.active_plot
|
||||
# # chart.setCursor(QtCore.Qt.ArrowCursor)
|
||||
cursor = self.chart.linked.cursor
|
||||
'''
|
||||
cursor = self.godw.get_cursor()
|
||||
if not cursor:
|
||||
return None
|
||||
|
||||
# delete "staged" cursor tracking line from view
|
||||
line = self._active_staged_line
|
||||
if line:
|
||||
cursor._trackers.remove(line)
|
||||
try:
|
||||
cursor._trackers.remove(line)
|
||||
except KeyError:
|
||||
# when the current cursor doesn't have said line
|
||||
# registered (probably means that user held order mode
|
||||
# key while panning to another view) then we just
|
||||
# ignore the remove error.
|
||||
pass
|
||||
line.delete()
|
||||
|
||||
self._active_staged_line = None
|
||||
|
@ -133,9 +151,9 @@ class LineEditor:
|
|||
# show the crosshair y line and label
|
||||
cursor.show_xhair()
|
||||
|
||||
def submit_line(
|
||||
def submit_lines(
|
||||
self,
|
||||
line: LevelLine,
|
||||
lines: list[LevelLine],
|
||||
uuid: str,
|
||||
|
||||
) -> LevelLine:
|
||||
|
@ -145,43 +163,46 @@ class LineEditor:
|
|||
# raise RuntimeError("No line is currently staged!?")
|
||||
|
||||
# for now, until submission reponse arrives
|
||||
line.hide_labels()
|
||||
for line in lines:
|
||||
line.hide_labels()
|
||||
|
||||
# register for later lookup/deletion
|
||||
self._order_lines[uuid] = line
|
||||
self._order_lines[uuid] += lines
|
||||
|
||||
return line
|
||||
return lines
|
||||
|
||||
def commit_line(self, uuid: str) -> LevelLine:
|
||||
"""Commit a "staged line" to view.
|
||||
def commit_line(self, uuid: str) -> list[LevelLine]:
|
||||
'''
|
||||
Commit a "staged line" to view.
|
||||
|
||||
Submits the line graphic under the cursor as a (new) permanent
|
||||
graphic in view.
|
||||
|
||||
"""
|
||||
try:
|
||||
line = self._order_lines[uuid]
|
||||
except KeyError:
|
||||
log.warning(f'No line for {uuid} could be found?')
|
||||
return
|
||||
else:
|
||||
line.show_labels()
|
||||
'''
|
||||
lines = self._order_lines[uuid]
|
||||
if lines:
|
||||
for line in lines:
|
||||
line.show_labels()
|
||||
line.hide_markers()
|
||||
log.debug(f'Level active for level: {line.value()}')
|
||||
# TODO: other flashy things to indicate the order is active
|
||||
|
||||
# TODO: other flashy things to indicate the order is active
|
||||
|
||||
log.debug(f'Level active for level: {line.value()}')
|
||||
|
||||
return line
|
||||
return lines
|
||||
|
||||
def lines_under_cursor(self) -> list[LevelLine]:
|
||||
"""Get the line(s) under the cursor position.
|
||||
'''
|
||||
Get the line(s) under the cursor position.
|
||||
|
||||
"""
|
||||
'''
|
||||
# Delete any hoverable under the cursor
|
||||
return self.chart.linked.cursor._hovered
|
||||
return self.godw.get_cursor()._hovered
|
||||
|
||||
def all_lines(self) -> tuple[LevelLine]:
|
||||
return tuple(self._order_lines.values())
|
||||
def all_lines(self) -> list[LevelLine]:
|
||||
all_lines = []
|
||||
for lines in list(self._order_lines.values()):
|
||||
all_lines.extend(lines)
|
||||
|
||||
return all_lines
|
||||
|
||||
def remove_line(
|
||||
self,
|
||||
|
@ -196,26 +217,27 @@ class LineEditor:
|
|||
|
||||
'''
|
||||
# try to look up line from our registry
|
||||
line = self._order_lines.pop(uuid, line)
|
||||
if line:
|
||||
lines = self._order_lines.pop(uuid, None)
|
||||
if lines:
|
||||
cursor = self.godw.get_cursor()
|
||||
if cursor:
|
||||
for line in lines:
|
||||
# if hovered remove from cursor set
|
||||
hovered = cursor._hovered
|
||||
if line in hovered:
|
||||
hovered.remove(line)
|
||||
|
||||
# if hovered remove from cursor set
|
||||
cursor = self.chart.linked.cursor
|
||||
hovered = cursor._hovered
|
||||
if line in hovered:
|
||||
hovered.remove(line)
|
||||
log.debug(f'deleting {line} with oid: {uuid}')
|
||||
line.delete()
|
||||
|
||||
# make sure the xhair doesn't get left off
|
||||
# just because we never got a un-hover event
|
||||
cursor.show_xhair()
|
||||
|
||||
log.debug(f'deleting {line} with oid: {uuid}')
|
||||
line.delete()
|
||||
# make sure the xhair doesn't get left off
|
||||
# just because we never got a un-hover event
|
||||
cursor.show_xhair()
|
||||
|
||||
else:
|
||||
log.warning(f'Could not find line for {line}')
|
||||
|
||||
return line
|
||||
return lines
|
||||
|
||||
|
||||
class SelectRect(QtGui.QGraphicsRectItem):
|
||||
|
|
|
@ -18,10 +18,11 @@
|
|||
Qt event proxying and processing using ``trio`` mem chans.
|
||||
|
||||
"""
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
from contextlib import asynccontextmanager as acm
|
||||
from typing import Callable
|
||||
|
||||
import trio
|
||||
from tractor.trionics import gather_contexts
|
||||
from PyQt5 import QtCore
|
||||
from PyQt5.QtCore import QEvent, pyqtBoundSignal
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
|
@ -155,7 +156,7 @@ class EventRelay(QtCore.QObject):
|
|||
return False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@acm
|
||||
async def open_event_stream(
|
||||
|
||||
source_widget: QWidget,
|
||||
|
@ -181,7 +182,7 @@ async def open_event_stream(
|
|||
source_widget.removeEventFilter(kc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@acm
|
||||
async def open_signal_handler(
|
||||
|
||||
signal: pyqtBoundSignal,
|
||||
|
@ -206,7 +207,7 @@ async def open_signal_handler(
|
|||
yield
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@acm
|
||||
async def open_handlers(
|
||||
|
||||
source_widgets: list[QWidget],
|
||||
|
@ -215,16 +216,14 @@ async def open_handlers(
|
|||
**kwargs,
|
||||
|
||||
) -> None:
|
||||
|
||||
async with (
|
||||
trio.open_nursery() as n,
|
||||
AsyncExitStack() as stack,
|
||||
gather_contexts([
|
||||
open_event_stream(widget, event_types, **kwargs)
|
||||
for widget in source_widgets
|
||||
]) as streams,
|
||||
):
|
||||
for widget in source_widgets:
|
||||
|
||||
event_recv_stream = await stack.enter_async_context(
|
||||
open_event_stream(widget, event_types, **kwargs)
|
||||
)
|
||||
for widget, event_recv_stream in zip(source_widgets, streams):
|
||||
n.start_soon(async_handler, widget, event_recv_stream)
|
||||
|
||||
yield
|
||||
|
|
|
@ -20,13 +20,16 @@ Trio - Qt integration
|
|||
Run ``trio`` in guest mode on top of the Qt event loop.
|
||||
All global Qt runtime settings are mostly defined here.
|
||||
"""
|
||||
from typing import Tuple, Callable, Dict, Any
|
||||
from typing import (
|
||||
Callable,
|
||||
Any,
|
||||
Type,
|
||||
)
|
||||
import platform
|
||||
import traceback
|
||||
|
||||
# Qt specific
|
||||
import PyQt5 # noqa
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import QtGui
|
||||
from PyQt5 import QtCore
|
||||
# from PyQt5.QtGui import QLabel, QStatusBar
|
||||
|
@ -37,7 +40,7 @@ from PyQt5.QtCore import (
|
|||
)
|
||||
import qdarkstyle
|
||||
from qdarkstyle import DarkPalette
|
||||
# import qdarkgraystyle
|
||||
# import qdarkgraystyle # TODO: play with it
|
||||
import trio
|
||||
from outcome import Error
|
||||
|
||||
|
@ -72,10 +75,11 @@ if platform.system() == "Windows":
|
|||
|
||||
def run_qtractor(
|
||||
func: Callable,
|
||||
args: Tuple,
|
||||
main_widget: QtGui.QWidget,
|
||||
tractor_kwargs: Dict[str, Any] = {},
|
||||
args: tuple,
|
||||
main_widget_type: Type[QtGui.QWidget],
|
||||
tractor_kwargs: dict[str, Any] = {},
|
||||
window_type: QtGui.QMainWindow = None,
|
||||
|
||||
) -> None:
|
||||
# avoids annoying message when entering debugger from qt loop
|
||||
pyqtRemoveInputHook()
|
||||
|
@ -156,7 +160,7 @@ def run_qtractor(
|
|||
# hook into app focus change events
|
||||
app.focusChanged.connect(window.on_focus_change)
|
||||
|
||||
instance = main_widget()
|
||||
instance = main_widget_type()
|
||||
instance.window = window
|
||||
|
||||
# override tractor's defaults
|
||||
|
@ -178,7 +182,7 @@ def run_qtractor(
|
|||
# restrict_keyboard_interrupt_to_checkpoints=True,
|
||||
)
|
||||
|
||||
window.main_widget = main_widget
|
||||
window.godwidget: GodWidget = instance
|
||||
window.setCentralWidget(instance)
|
||||
if is_windows:
|
||||
window.configure_to_desktop()
|
||||
|
|
|
@ -644,7 +644,7 @@ def mk_fill_status_bar(
|
|||
|
||||
# TODO: calc this height from the ``ChartnPane``
|
||||
chart_h = round(parent_pane.height() * 5/8)
|
||||
bar_h = chart_h * 0.375
|
||||
bar_h = chart_h * 0.375*0.9
|
||||
|
||||
# TODO: once things are sized to screen
|
||||
bar_label_font_size = label_font_size or _font.px_size - 2
|
||||
|
|
|
@ -141,13 +141,16 @@ async def handle_viewmode_kb_inputs(
|
|||
Qt.Key_Space,
|
||||
}
|
||||
):
|
||||
view._chart.linked.godwidget.search.focus()
|
||||
godw = view._chart.linked.godwidget
|
||||
godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked)
|
||||
godw.search.focus()
|
||||
|
||||
# esc and ctrl-c
|
||||
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
|
||||
# ctrl-c as cancel
|
||||
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
||||
view.select_box.clear()
|
||||
view.linked.focus()
|
||||
|
||||
# cancel order or clear graphics
|
||||
if key == Qt.Key_C or key == Qt.Key_Delete:
|
||||
|
@ -178,17 +181,17 @@ async def handle_viewmode_kb_inputs(
|
|||
if key in pressed:
|
||||
pressed.remove(key)
|
||||
|
||||
# QUERY/QUOTE MODE #
|
||||
# QUERY/QUOTE MODE
|
||||
# ----------------
|
||||
if {Qt.Key_Q}.intersection(pressed):
|
||||
|
||||
view.linkedsplits.cursor.in_query_mode = True
|
||||
view.linked.cursor.in_query_mode = True
|
||||
|
||||
else:
|
||||
view.linkedsplits.cursor.in_query_mode = False
|
||||
view.linked.cursor.in_query_mode = False
|
||||
|
||||
# SELECTION MODE
|
||||
# --------------
|
||||
|
||||
if shift:
|
||||
if view.state['mouseMode'] == ViewBox.PanMode:
|
||||
view.setMouseMode(ViewBox.RectMode)
|
||||
|
@ -209,14 +212,22 @@ async def handle_viewmode_kb_inputs(
|
|||
|
||||
# ORDER MODE
|
||||
# ----------
|
||||
|
||||
# live vs. dark trigger + an action {buy, sell, alert}
|
||||
order_keys_pressed = ORDER_MODE.intersection(pressed)
|
||||
|
||||
if order_keys_pressed:
|
||||
|
||||
# show the pp size label
|
||||
order_mode.current_pp.show()
|
||||
# TODO: it seems like maybe the composition should be
|
||||
# reversed here? Like, maybe we should have the nav have
|
||||
# access to the pos state and then make encapsulated logic
|
||||
# that shows the right stuff on screen instead or order mode
|
||||
# and position-related abstractions doing this?
|
||||
|
||||
# show the pp size label only if there is
|
||||
# a non-zero pos existing
|
||||
tracker = order_mode.current_pp
|
||||
if tracker.live_pp.size:
|
||||
tracker.nav.show()
|
||||
|
||||
# TODO: show pp config mini-params in status bar widget
|
||||
# mode.pp_config.show()
|
||||
|
@ -257,8 +268,8 @@ async def handle_viewmode_kb_inputs(
|
|||
Qt.Key_S in pressed or
|
||||
order_keys_pressed or
|
||||
Qt.Key_O in pressed
|
||||
) and
|
||||
key in NUMBER_LINE
|
||||
)
|
||||
and key in NUMBER_LINE
|
||||
):
|
||||
# hot key to set order slots size.
|
||||
# change edit field to current number line value,
|
||||
|
@ -276,7 +287,7 @@ async def handle_viewmode_kb_inputs(
|
|||
else: # none active
|
||||
|
||||
# hide pp label
|
||||
order_mode.current_pp.hide_info()
|
||||
order_mode.current_pp.nav.hide_info()
|
||||
|
||||
# if none are pressed, remove "staged" level
|
||||
# line under cursor position
|
||||
|
@ -373,7 +384,7 @@ class ChartView(ViewBox):
|
|||
y=True,
|
||||
)
|
||||
|
||||
self.linkedsplits = None
|
||||
self.linked = None
|
||||
self._chart: 'ChartPlotWidget' = None # noqa
|
||||
|
||||
# add our selection box annotator
|
||||
|
@ -484,7 +495,7 @@ class ChartView(ViewBox):
|
|||
else:
|
||||
mask = self.state['mouseEnabled'][:]
|
||||
|
||||
chart = self.linkedsplits.chart
|
||||
chart = self.linked.chart
|
||||
|
||||
# don't zoom more then the min points setting
|
||||
l, lbar, rbar, r = chart.bars_range()
|
||||
|
@ -919,7 +930,7 @@ class ChartView(ViewBox):
|
|||
|
||||
# TODO: a faster single-loop-iterator way of doing this XD
|
||||
chart = self._chart
|
||||
linked = self.linkedsplits
|
||||
linked = self.linked
|
||||
plots = linked.subplots | {chart.name: chart}
|
||||
for chart_name, chart in plots.items():
|
||||
for name, flow in chart._flows.items():
|
||||
|
|
|
@ -18,9 +18,14 @@
|
|||
Lines for orders, alerts, L2.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from functools import partial
|
||||
from math import floor
|
||||
from typing import Optional, Callable
|
||||
from typing import (
|
||||
Optional,
|
||||
Callable,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import Point, functions as fn
|
||||
|
@ -37,6 +42,9 @@ from ..calc import humanize
|
|||
from ._label import Label
|
||||
from ._style import hcolor, _font
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._cursor import Cursor
|
||||
|
||||
|
||||
# TODO: probably worth investigating if we can
|
||||
# make .boundingRect() faster:
|
||||
|
@ -84,7 +92,7 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
self._marker = None
|
||||
self.only_show_markers_on_hover = only_show_markers_on_hover
|
||||
self.show_markers: bool = True # presuming the line is hovered at init
|
||||
self.track_marker_pos: bool = False
|
||||
|
||||
# should line go all the way to far end or leave a "margin"
|
||||
# space for other graphics (eg. L1 book)
|
||||
|
@ -122,6 +130,9 @@ class LevelLine(pg.InfiniteLine):
|
|||
self._y_incr_mult = 1 / chart.linked.symbol.tick_size
|
||||
self._right_end_sc: float = 0
|
||||
|
||||
# use px caching
|
||||
self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
def txt_offsets(self) -> tuple[int, int]:
|
||||
return 0, 0
|
||||
|
||||
|
@ -216,20 +227,23 @@ class LevelLine(pg.InfiniteLine):
|
|||
y: float
|
||||
|
||||
) -> None:
|
||||
'''Chart coordinates cursor tracking callback.
|
||||
'''
|
||||
Chart coordinates cursor tracking callback.
|
||||
|
||||
this is called by our ``Cursor`` type once this line is set to
|
||||
track the cursor: for every movement this callback is invoked to
|
||||
reposition the line with the current view coordinates.
|
||||
|
||||
'''
|
||||
self.movable = True
|
||||
self.set_level(y) # implictly calls reposition handler
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
"""Override the ``InfiniteLine`` handler since we need more
|
||||
'''
|
||||
Override the ``InfiniteLine`` handler since we need more
|
||||
detailed control and start end signalling.
|
||||
|
||||
"""
|
||||
'''
|
||||
cursor = self._chart.linked.cursor
|
||||
|
||||
# hide y-crosshair
|
||||
|
@ -281,10 +295,20 @@ class LevelLine(pg.InfiniteLine):
|
|||
# show y-crosshair again
|
||||
cursor.show_xhair()
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Remove this line from containing chart/view/scene.
|
||||
def get_cursor(self) -> Optional[Cursor]:
|
||||
|
||||
"""
|
||||
chart = self._chart
|
||||
cur = chart.linked.cursor
|
||||
if self in cur._hovered:
|
||||
return cur
|
||||
|
||||
return None
|
||||
|
||||
def delete(self) -> None:
|
||||
'''
|
||||
Remove this line from containing chart/view/scene.
|
||||
|
||||
'''
|
||||
scene = self.scene()
|
||||
if scene:
|
||||
for label in self._labels:
|
||||
|
@ -298,9 +322,8 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
# remove from chart/cursor states
|
||||
chart = self._chart
|
||||
cur = chart.linked.cursor
|
||||
|
||||
if self in cur._hovered:
|
||||
cur = self.get_cursor()
|
||||
if cur:
|
||||
cur._hovered.remove(self)
|
||||
|
||||
chart.plotItem.removeItem(self)
|
||||
|
@ -308,8 +331,8 @@ class LevelLine(pg.InfiniteLine):
|
|||
def mouseDoubleClickEvent(
|
||||
self,
|
||||
ev: QtGui.QMouseEvent,
|
||||
) -> None:
|
||||
|
||||
) -> None:
|
||||
# TODO: enter labels edit mode
|
||||
print(f'double click {ev}')
|
||||
|
||||
|
@ -334,30 +357,22 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
line_end, marker_right, r_axis_x = self._chart.marker_right_points()
|
||||
|
||||
if self.show_markers and self.markers:
|
||||
|
||||
p.setPen(self.pen)
|
||||
qgo_draw_markers(
|
||||
self.markers,
|
||||
self.pen.color(),
|
||||
p,
|
||||
vb_left,
|
||||
vb_right,
|
||||
marker_right,
|
||||
)
|
||||
# marker_size = self.markers[0][2]
|
||||
self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
|
||||
|
||||
# this seems slower when moving around
|
||||
# order lines.. not sure wtf is up with that.
|
||||
# for now we're just using it on the position line.
|
||||
elif self._marker:
|
||||
# (legacy) NOTE: at one point this seemed slower when moving around
|
||||
# order lines.. not sure if that's still true or why but we've
|
||||
# dropped the original hacky `.pain()` transform stuff for inf
|
||||
# line markers now - check the git history if it needs to be
|
||||
# reverted.
|
||||
if self._marker:
|
||||
if self.track_marker_pos:
|
||||
# make the line end at the marker's x pos
|
||||
line_end = marker_right = self._marker.pos().x()
|
||||
|
||||
# TODO: make this label update part of a scene-aware-marker
|
||||
# composed annotation
|
||||
self._marker.setPos(
|
||||
QPointF(marker_right, self.scene_y())
|
||||
)
|
||||
|
||||
if hasattr(self._marker, 'label'):
|
||||
self._marker.label.update()
|
||||
|
||||
|
@ -379,16 +394,14 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
def hide(self) -> None:
|
||||
super().hide()
|
||||
if self._marker:
|
||||
self._marker.hide()
|
||||
# needed for ``order_line()`` lines currently
|
||||
self._marker.label.hide()
|
||||
mkr = self._marker
|
||||
if mkr:
|
||||
mkr.hide()
|
||||
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
if self._marker:
|
||||
self._marker.show()
|
||||
# self._marker.label.show()
|
||||
|
||||
def scene_y(self) -> float:
|
||||
return self.getViewBox().mapFromView(
|
||||
|
@ -433,17 +446,16 @@ class LevelLine(pg.InfiniteLine):
|
|||
cur = self._chart.linked.cursor
|
||||
|
||||
# hovered
|
||||
if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton):
|
||||
|
||||
if (
|
||||
not ev.isExit()
|
||||
and ev.acceptDrags(QtCore.Qt.LeftButton)
|
||||
):
|
||||
# if already hovered we don't need to run again
|
||||
if self.mouseHovering is True:
|
||||
return
|
||||
|
||||
if self.only_show_markers_on_hover:
|
||||
self.show_markers = True
|
||||
|
||||
if self._marker:
|
||||
self._marker.show()
|
||||
self.show_markers()
|
||||
|
||||
# highlight if so configured
|
||||
if self.highlight_on_hover:
|
||||
|
@ -486,11 +498,7 @@ class LevelLine(pg.InfiniteLine):
|
|||
cur._hovered.remove(self)
|
||||
|
||||
if self.only_show_markers_on_hover:
|
||||
self.show_markers = False
|
||||
|
||||
if self._marker:
|
||||
self._marker.hide()
|
||||
self._marker.label.hide()
|
||||
self.hide_markers()
|
||||
|
||||
if self not in cur._trackers:
|
||||
cur.show_xhair(y_label_level=self.value())
|
||||
|
@ -502,6 +510,15 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
self.update()
|
||||
|
||||
def hide_markers(self) -> None:
|
||||
if self._marker:
|
||||
self._marker.hide()
|
||||
self._marker.label.hide()
|
||||
|
||||
def show_markers(self) -> None:
|
||||
if self._marker:
|
||||
self._marker.show()
|
||||
|
||||
|
||||
def level_line(
|
||||
|
||||
|
@ -522,9 +539,10 @@ def level_line(
|
|||
**kwargs,
|
||||
|
||||
) -> LevelLine:
|
||||
"""Convenience routine to add a styled horizontal line to a plot.
|
||||
'''
|
||||
Convenience routine to add a styled horizontal line to a plot.
|
||||
|
||||
"""
|
||||
'''
|
||||
hl_color = color + '_light' if highlight_on_hover else color
|
||||
|
||||
line = LevelLine(
|
||||
|
@ -706,7 +724,7 @@ def order_line(
|
|||
marker = LevelMarker(
|
||||
chart=chart,
|
||||
style=marker_style,
|
||||
get_level=line.value,
|
||||
get_level=line.value, # callback
|
||||
size=marker_size,
|
||||
keep_in_view=False,
|
||||
)
|
||||
|
@ -715,7 +733,8 @@ def order_line(
|
|||
marker = line.add_marker(marker)
|
||||
|
||||
# XXX: DON'T COMMENT THIS!
|
||||
# this fixes it the artifact issue! .. of course, bounding rect stuff
|
||||
# this fixes it the artifact issue!
|
||||
# .. of course, bounding rect stuff
|
||||
line._maxMarkerSize = marker_size
|
||||
|
||||
assert line._marker is marker
|
||||
|
@ -736,7 +755,8 @@ def order_line(
|
|||
|
||||
if action != 'alert':
|
||||
|
||||
# add a partial position label if we also added a level marker
|
||||
# add a partial position label if we also added a level
|
||||
# marker
|
||||
pp_size_label = Label(
|
||||
view=view,
|
||||
color=line.color,
|
||||
|
@ -770,9 +790,9 @@ def order_line(
|
|||
# XXX: without this the pp proportion label next the marker
|
||||
# seems to lag? this is the same issue we had with position
|
||||
# lines which we handle with ``.update_graphcis()``.
|
||||
# marker._on_paint=lambda marker: pp_size_label.update()
|
||||
marker._on_paint = lambda marker: pp_size_label.update()
|
||||
|
||||
# XXX: THIS IS AN UNTYPED MONKEY PATCH!?!?!
|
||||
marker.label = label
|
||||
|
||||
# sanity check
|
||||
|
|
|
@ -23,7 +23,11 @@ from copy import copy
|
|||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from math import floor, copysign
|
||||
from typing import Optional
|
||||
from typing import (
|
||||
Callable,
|
||||
Optional,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
|
||||
# from PyQt5.QtWidgets import QStyle
|
||||
|
@ -41,12 +45,18 @@ from ..calc import humanize, pnl, puterize
|
|||
from ..clearing._allocate import Allocator, Position
|
||||
from ..data._normalize import iterticks
|
||||
from ..data.feed import Feed
|
||||
from ..data.types import Struct
|
||||
from ._label import Label
|
||||
from ._lines import LevelLine, order_line
|
||||
from ._style import _font
|
||||
from ._forms import FieldsForm, FillStatusBar, QLabel
|
||||
from ..log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._chart import (
|
||||
ChartPlotWidget,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
_pnl_tasks: dict[str, bool] = {}
|
||||
|
||||
|
@ -58,7 +68,8 @@ async def update_pnl_from_feed(
|
|||
tracker: PositionTracker,
|
||||
|
||||
) -> None:
|
||||
'''Real-time display the current pp's PnL in the appropriate label.
|
||||
'''
|
||||
Real-time display the current pp's PnL in the appropriate label.
|
||||
|
||||
``ValueError`` if this task is spawned where there is a net-zero pp.
|
||||
|
||||
|
@ -67,7 +78,7 @@ async def update_pnl_from_feed(
|
|||
|
||||
pp = order_mode.current_pp
|
||||
live = pp.live_pp
|
||||
key = live.symbol.key
|
||||
key = live.symbol.front_fqsn()
|
||||
|
||||
log.info(f'Starting pnl display for {pp.alloc.account}')
|
||||
|
||||
|
@ -168,12 +179,12 @@ class SettingsPane:
|
|||
|
||||
) -> None:
|
||||
'''
|
||||
Try to apply some input setting (by the user), revert to previous setting if it fails
|
||||
display new value if applied.
|
||||
Try to apply some input setting (by the user), revert to
|
||||
previous setting if it fails display new value if applied.
|
||||
|
||||
'''
|
||||
self.apply_setting(key, value)
|
||||
self.update_status_ui(pp=self.order_mode.current_pp)
|
||||
self.update_status_ui(self.order_mode.current_pp)
|
||||
|
||||
def apply_setting(
|
||||
self,
|
||||
|
@ -195,7 +206,7 @@ class SettingsPane:
|
|||
|
||||
# hide details on the old selection
|
||||
old_tracker = mode.current_pp
|
||||
old_tracker.hide_info()
|
||||
old_tracker.nav.hide_info()
|
||||
|
||||
# re-assign the order mode tracker
|
||||
account_name = value
|
||||
|
@ -205,7 +216,7 @@ class SettingsPane:
|
|||
# a ``brokerd`) then error and switch back to the last
|
||||
# selection.
|
||||
if tracker is None:
|
||||
sym = old_tracker.chart.linked.symbol.key
|
||||
sym = old_tracker.charts[0].linked.symbol.key
|
||||
log.error(
|
||||
f'Account `{account_name}` can not be set for {sym}'
|
||||
)
|
||||
|
@ -216,8 +227,8 @@ class SettingsPane:
|
|||
self.order_mode.current_pp = tracker
|
||||
assert tracker.alloc.account == account_name
|
||||
self.form.fields['account'].setCurrentText(account_name)
|
||||
tracker.show()
|
||||
tracker.hide_info()
|
||||
tracker.nav.show()
|
||||
tracker.nav.hide_info()
|
||||
|
||||
self.display_pnl(tracker)
|
||||
|
||||
|
@ -251,7 +262,9 @@ class SettingsPane:
|
|||
log.error(
|
||||
f'limit must > then current pp: {dsize}'
|
||||
)
|
||||
raise ValueError
|
||||
# reset position size value
|
||||
alloc.currency_limit = dsize
|
||||
return False
|
||||
|
||||
alloc.currency_limit = value
|
||||
|
||||
|
@ -288,22 +301,29 @@ class SettingsPane:
|
|||
|
||||
def update_status_ui(
|
||||
self,
|
||||
pp: PositionTracker,
|
||||
tracker: PositionTracker,
|
||||
|
||||
) -> None:
|
||||
|
||||
alloc = pp.alloc
|
||||
alloc = tracker.alloc
|
||||
slots = alloc.slots
|
||||
used = alloc.slots_used(pp.live_pp)
|
||||
used = alloc.slots_used(tracker.live_pp)
|
||||
size = tracker.live_pp.size
|
||||
dsize = tracker.live_pp.dsize
|
||||
|
||||
# READ out settings and update the status UI / settings widgets
|
||||
suffix = {'currency': ' $', 'units': ' u'}[alloc.size_unit]
|
||||
limit = alloc.limit()
|
||||
size_unit, limit = alloc.limit_info()
|
||||
|
||||
step_size, currency_per_slot = alloc.step_sizes()
|
||||
|
||||
if alloc.size_unit == 'currency':
|
||||
step_size = currency_per_slot
|
||||
if dsize >= limit:
|
||||
self.apply_setting('limit', limit)
|
||||
|
||||
elif size >= limit:
|
||||
self.apply_setting('limit', limit)
|
||||
|
||||
self.step_label.format(
|
||||
step_size=str(humanize(step_size)) + suffix
|
||||
|
@ -320,7 +340,7 @@ class SettingsPane:
|
|||
self.form.fields['limit'].setText(str(limit))
|
||||
|
||||
# update of level marker size label based on any new settings
|
||||
pp.update_from_pp()
|
||||
tracker.update_from_pp()
|
||||
|
||||
# calculate proportion of position size limit
|
||||
# that exists and display in fill bar
|
||||
|
@ -332,7 +352,7 @@ class SettingsPane:
|
|||
# min(round(prop * slots), slots)
|
||||
min(used, slots)
|
||||
)
|
||||
self.update_account_icons({alloc.account: pp.live_pp})
|
||||
self.update_account_icons({alloc.account: tracker.live_pp})
|
||||
|
||||
def update_account_icons(
|
||||
self,
|
||||
|
@ -358,7 +378,9 @@ class SettingsPane:
|
|||
tracker: PositionTracker,
|
||||
|
||||
) -> None:
|
||||
'''Display the PnL for the current symbol and personal positioning (pp).
|
||||
'''
|
||||
Display the PnL for the current symbol and personal positioning
|
||||
(pp).
|
||||
|
||||
If a position is open start a background task which will
|
||||
real-time update the pnl label in the settings pane.
|
||||
|
@ -372,7 +394,7 @@ class SettingsPane:
|
|||
|
||||
if size:
|
||||
# last historical close price
|
||||
last = feed.shm.array[-1][['close']][0]
|
||||
last = feed.rt_shm.array[-1][['close']][0]
|
||||
pnl_value = copysign(1, size) * pnl(
|
||||
tracker.live_pp.ppu,
|
||||
last,
|
||||
|
@ -380,8 +402,9 @@ class SettingsPane:
|
|||
|
||||
# maybe start update task
|
||||
global _pnl_tasks
|
||||
if sym.key not in _pnl_tasks:
|
||||
_pnl_tasks[sym.key] = True
|
||||
fqsn = sym.front_fqsn()
|
||||
if fqsn not in _pnl_tasks:
|
||||
_pnl_tasks[fqsn] = True
|
||||
self.order_mode.nursery.start_soon(
|
||||
update_pnl_from_feed,
|
||||
feed,
|
||||
|
@ -393,15 +416,15 @@ class SettingsPane:
|
|||
self.pnl_label.format(pnl=pnl_value)
|
||||
|
||||
|
||||
def position_line(
|
||||
def pp_line(
|
||||
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
chart: ChartPlotWidget, # noqa
|
||||
size: float,
|
||||
level: float,
|
||||
color: str,
|
||||
marker: LevelMarker,
|
||||
|
||||
orient_v: str = 'bottom',
|
||||
marker: Optional[LevelMarker] = None,
|
||||
|
||||
) -> LevelLine:
|
||||
'''
|
||||
|
@ -432,28 +455,20 @@ def position_line(
|
|||
show_markers=False,
|
||||
)
|
||||
|
||||
if marker:
|
||||
# configure marker to position data
|
||||
# TODO: use `LevelLine.add_marker()`` for this instead?
|
||||
# set marker color to same as line
|
||||
marker.setPen(line.currentPen)
|
||||
marker.setBrush(fn.mkBrush(line.currentPen.color()))
|
||||
marker.level = level
|
||||
marker.update()
|
||||
marker.show()
|
||||
|
||||
if size > 0: # long
|
||||
style = '|<' # point "up to" the line
|
||||
elif size < 0: # short
|
||||
style = '>|' # point "down to" the line
|
||||
line._marker = marker
|
||||
line.track_marker_pos = True
|
||||
|
||||
marker.style = style
|
||||
|
||||
# set marker color to same as line
|
||||
marker.setPen(line.currentPen)
|
||||
marker.setBrush(fn.mkBrush(line.currentPen.color()))
|
||||
marker.level = level
|
||||
marker.update()
|
||||
marker.show()
|
||||
|
||||
# show position marker on view "edge" when out of view
|
||||
vb = line.getViewBox()
|
||||
vb.sigRangeChanged.connect(marker.position_in_view)
|
||||
|
||||
line.set_level(level)
|
||||
# show position marker on view "edge" when out of view
|
||||
vb = line.getViewBox()
|
||||
vb.sigRangeChanged.connect(marker.position_in_view)
|
||||
|
||||
return line
|
||||
|
||||
|
@ -466,85 +481,338 @@ _derivs = (
|
|||
)
|
||||
|
||||
|
||||
# TODO: move into annoate module?
|
||||
def mk_level_marker(
|
||||
chart: ChartPlotWidget,
|
||||
size: float,
|
||||
level: float,
|
||||
on_paint: Callable,
|
||||
|
||||
) -> LevelMarker:
|
||||
'''
|
||||
Allocate and return nan arrow graphics element.
|
||||
|
||||
'''
|
||||
# scale marker size with dpi-aware font size
|
||||
font_size = _font.font.pixelSize()
|
||||
arrow_size = floor(1.375 * font_size)
|
||||
arrow = LevelMarker(
|
||||
chart=chart,
|
||||
style='|<', # actual style is set by caller based on size
|
||||
get_level=level,
|
||||
size=arrow_size,
|
||||
on_paint=on_paint,
|
||||
)
|
||||
arrow.show()
|
||||
return arrow
|
||||
|
||||
|
||||
class Nav(Struct):
|
||||
'''
|
||||
Composite for holding a set of charts and respective (by order)
|
||||
graphics-elements which display position information acting as sort
|
||||
of "navigation" system for a position.
|
||||
|
||||
'''
|
||||
charts: dict[int, ChartPlotWidget]
|
||||
pp_labels: dict[str, Label] = {}
|
||||
size_labels: dict[str, Label] = {}
|
||||
lines: dict[str, Optional[LevelLine]] = {}
|
||||
level_markers: dict[str, Optional[LevelMarker]] = {}
|
||||
color: str = 'default_lightest'
|
||||
|
||||
def update_ui(
|
||||
self,
|
||||
account: str,
|
||||
price: float,
|
||||
size: float,
|
||||
slots_used: float,
|
||||
size_digits: Optional[int] = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Update personal position level line.
|
||||
|
||||
'''
|
||||
for key, chart in self.charts.items():
|
||||
size_digits = size_digits or chart.linked.symbol.lot_size_digits
|
||||
line = self.lines.get(key)
|
||||
level_marker = self.level_markers[key]
|
||||
pp_label = self.pp_labels[key]
|
||||
|
||||
if size:
|
||||
# create and show a pp line if none yet exists
|
||||
if line is None:
|
||||
arrow = self.level_markers[key]
|
||||
line = pp_line(
|
||||
chart=chart,
|
||||
level=price,
|
||||
size=size,
|
||||
color=self.color,
|
||||
marker=arrow,
|
||||
)
|
||||
self.lines[key] = line
|
||||
|
||||
# modify existing indicator line
|
||||
line.set_level(price)
|
||||
|
||||
# update LHS sizing label
|
||||
line.update_labels({
|
||||
'size': size,
|
||||
'size_digits': size_digits,
|
||||
'fiat_size': round(price * size, ndigits=2),
|
||||
|
||||
# TODO: per account lines on a single (or very
|
||||
# related) symbol
|
||||
'account': account,
|
||||
})
|
||||
line.show()
|
||||
|
||||
# always show arrow-marker when a non-zero
|
||||
# pos size.
|
||||
level_marker.show()
|
||||
|
||||
# configure marker to position data
|
||||
if size > 0: # long
|
||||
# point "up to" the line
|
||||
level_marker.style = '|<'
|
||||
|
||||
elif size < 0: # short
|
||||
# point "down to" the line
|
||||
level_marker.style = '>|'
|
||||
|
||||
# remove line from view for a net-zero pos
|
||||
else:
|
||||
self.hide()
|
||||
|
||||
# label updates
|
||||
size_label = self.size_labels[key]
|
||||
size_label.fields['slots_used'] = slots_used
|
||||
size_label.render()
|
||||
|
||||
# set arrow marker to correct level
|
||||
level_marker.level = price
|
||||
|
||||
# these updates are critical to avoid lag on view/scene changes
|
||||
# TODO: couldn't we integrate this into
|
||||
# a ``.inter_ui_elements_and_update()``?
|
||||
level_marker.update() # trigger paint
|
||||
pp_label.update()
|
||||
size_label.update()
|
||||
|
||||
def level(self) -> float:
|
||||
'''
|
||||
Return the "level" value from the underlying ``LevelLine`` which tracks
|
||||
the "average position" price defined the represented position instance.
|
||||
|
||||
'''
|
||||
if self.lines:
|
||||
for key, line in self.lines.items():
|
||||
if line:
|
||||
return line.value()
|
||||
return 0
|
||||
|
||||
def iter_ui_elements(self) -> tuple[
|
||||
Label,
|
||||
Label,
|
||||
LevelLine,
|
||||
LevelMarker,
|
||||
]:
|
||||
for key, chart in self.charts.items():
|
||||
yield (
|
||||
self.pp_labels[key],
|
||||
self.size_labels[key],
|
||||
self.lines.get(key),
|
||||
self.level_markers[key],
|
||||
)
|
||||
|
||||
def show(self) -> None:
|
||||
'''
|
||||
Show all UI elements on all managed charts.
|
||||
|
||||
'''
|
||||
for (
|
||||
pp_label,
|
||||
size_label,
|
||||
line,
|
||||
level_marker,
|
||||
) in self.iter_ui_elements():
|
||||
|
||||
# NOTE: be sure to re-trigger arrow/label placement in case
|
||||
# a new sidepane or other widget (like the search bar) was
|
||||
# dynamically swapped into the chart-row-widget-space in
|
||||
# which case we want to reposition in the view but including
|
||||
# the new x-distance added by that sidepane. See details in
|
||||
# ``LevelMarker.position_in_view()`` but more less ``.
|
||||
# ``ChartPlotWidget.self.marker_right_points()`` gets called
|
||||
# which itself eventually calls `.getAxis.pos().x()` and
|
||||
# it's THIS that needs to be called **AFTER** the sidepane
|
||||
# has been added..
|
||||
level_marker.show()
|
||||
level_marker.position_in_view()
|
||||
|
||||
# labels
|
||||
pp_label.show()
|
||||
size_label.show()
|
||||
|
||||
if line:
|
||||
line.show()
|
||||
line.show_labels()
|
||||
|
||||
def hide(self) -> None:
|
||||
for (
|
||||
pp_label,
|
||||
size_label,
|
||||
line,
|
||||
level_marker,
|
||||
) in self.iter_ui_elements():
|
||||
pp_label.hide()
|
||||
level_marker.hide()
|
||||
size_label.hide()
|
||||
if line:
|
||||
line.hide()
|
||||
|
||||
def update_graphics(
|
||||
self,
|
||||
marker: LevelMarker,
|
||||
) -> None:
|
||||
'''
|
||||
Update all labels callback.
|
||||
|
||||
Meant to be called from the marker ``.paint()``
|
||||
for immediate, lag free label draws.
|
||||
|
||||
'''
|
||||
for (
|
||||
pp_label,
|
||||
size_label,
|
||||
line,
|
||||
level_marker,
|
||||
) in self.iter_ui_elements():
|
||||
|
||||
pp_label.update()
|
||||
size_label.update()
|
||||
|
||||
# XXX: can't call this because it causes a recursive paint/render
|
||||
# level_marker.update()
|
||||
|
||||
def hide_info(self) -> None:
|
||||
'''
|
||||
Hide details (just size label?) of position nav elements.
|
||||
|
||||
'''
|
||||
for (
|
||||
pp_label,
|
||||
size_label,
|
||||
line,
|
||||
level_marker,
|
||||
) in self.iter_ui_elements():
|
||||
|
||||
size_label.hide()
|
||||
if line:
|
||||
line.hide_labels()
|
||||
|
||||
|
||||
class PositionTracker:
|
||||
'''
|
||||
Track and display real-time positions for a single symbol
|
||||
over multiple accounts on a single chart.
|
||||
Track and display real-time positions for a single asset-symbol
|
||||
held in a single account, normally shown on a single chart.
|
||||
|
||||
Graphically composed of a level line and marker as well as labels
|
||||
for indcating current position information. Updates are made to the
|
||||
corresponding "settings pane" for the chart's "order mode" UX.
|
||||
|
||||
'''
|
||||
# inputs
|
||||
chart: 'ChartPlotWidget' # noqa
|
||||
|
||||
alloc: Allocator
|
||||
startup_pp: Position
|
||||
live_pp: Position
|
||||
|
||||
# allocated
|
||||
pp_label: Label
|
||||
size_label: Label
|
||||
line: Optional[LevelLine] = None
|
||||
|
||||
_color: str = 'default_lightest'
|
||||
nav: Nav # holds all UI elements across all charts
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
charts: list[ChartPlotWidget],
|
||||
alloc: Allocator,
|
||||
startup_pp: Position,
|
||||
|
||||
) -> None:
|
||||
|
||||
self.chart = chart
|
||||
|
||||
nav = self.nav = Nav(charts={id(chart): chart for chart in charts})
|
||||
self.alloc = alloc
|
||||
self.startup_pp = startup_pp
|
||||
self.live_pp = copy(startup_pp)
|
||||
|
||||
view = chart.getViewBox()
|
||||
# TODO: maybe add this as a method ``Nav.add_chart()``
|
||||
# init all UI elements
|
||||
for key, chart in nav.charts.items():
|
||||
view = chart.getViewBox()
|
||||
|
||||
# literally the 'pp' (pee pee) label that's always in view
|
||||
self.pp_label = pp_label = Label(
|
||||
view=view,
|
||||
fmt_str='pp',
|
||||
color=self._color,
|
||||
update_on_range_change=False,
|
||||
)
|
||||
arrow = mk_level_marker(
|
||||
chart=chart,
|
||||
size=1,
|
||||
level=nav.level,
|
||||
on_paint=nav.update_graphics,
|
||||
)
|
||||
|
||||
# create placeholder 'up' level arrow
|
||||
self._level_marker = None
|
||||
self._level_marker = self.level_marker(size=1)
|
||||
# TODO: we really need some kinda "spacing" manager for all
|
||||
# this stuff...
|
||||
def offset_from_yaxis() -> float:
|
||||
'''
|
||||
If no L1 labels are present beside the x-axis place
|
||||
the line label offset from the y-axis just enough to avoid
|
||||
label overlap with any sticky labels.
|
||||
|
||||
pp_label.scene_anchor = partial(
|
||||
gpath_pin,
|
||||
gpath=self._level_marker,
|
||||
label=pp_label,
|
||||
)
|
||||
pp_label.render()
|
||||
'''
|
||||
x = chart.marker_right_points()[1]
|
||||
if chart._max_l1_line_len == 0:
|
||||
mkw = pp_label.txt.boundingRect().width()
|
||||
x -= 1.5 * mkw
|
||||
|
||||
self.size_label = size_label = Label(
|
||||
view=view,
|
||||
color=self._color,
|
||||
return x
|
||||
|
||||
# this is "static" label
|
||||
# update_on_range_change=False,
|
||||
fmt_str='\n'.join((
|
||||
':{slots_used:.1f}x',
|
||||
)),
|
||||
arrow.scene_x = offset_from_yaxis
|
||||
view.scene().addItem(arrow)
|
||||
arrow.hide() # never show on startup
|
||||
nav.level_markers[key] = arrow
|
||||
|
||||
fields={
|
||||
'slots_used': 0,
|
||||
},
|
||||
)
|
||||
size_label.render()
|
||||
# literally the 'pp' (pee pee) "position price" label that's
|
||||
# always in view
|
||||
pp_label = Label(
|
||||
view=view,
|
||||
fmt_str='pp',
|
||||
color=nav.color,
|
||||
update_on_range_change=False,
|
||||
)
|
||||
pp_label.render()
|
||||
nav.pp_labels[key] = pp_label
|
||||
|
||||
size_label.scene_anchor = partial(
|
||||
pp_tight_and_right,
|
||||
label=self.pp_label,
|
||||
)
|
||||
size_label = Label(
|
||||
view=view,
|
||||
color=self.nav.color,
|
||||
|
||||
# this is "static" label
|
||||
# update_on_range_change=False,
|
||||
fmt_str='\n'.join((
|
||||
':{slots_used:.1f}x',
|
||||
)),
|
||||
|
||||
fields={
|
||||
'slots_used': 0,
|
||||
},
|
||||
)
|
||||
size_label.render()
|
||||
size_label.scene_anchor = partial(
|
||||
pp_tight_and_right,
|
||||
label=pp_label,
|
||||
)
|
||||
nav.size_labels[key] = size_label
|
||||
|
||||
pp_label.scene_anchor = partial(
|
||||
gpath_pin,
|
||||
gpath=arrow,
|
||||
label=pp_label,
|
||||
)
|
||||
|
||||
nav.show()
|
||||
|
||||
@property
|
||||
def pane(self) -> FieldsForm:
|
||||
|
@ -554,21 +822,6 @@ class PositionTracker:
|
|||
'''
|
||||
return self.chart.linked.godwidget.pp_pane
|
||||
|
||||
def update_graphics(
|
||||
self,
|
||||
marker: LevelMarker
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Update all labels.
|
||||
|
||||
Meant to be called from the maker ``.paint()``
|
||||
for immediate, lag free label draws.
|
||||
|
||||
'''
|
||||
self.pp_label.update()
|
||||
self.size_label.update()
|
||||
|
||||
def update_from_pp(
|
||||
self,
|
||||
position: Optional[Position] = None,
|
||||
|
@ -621,142 +874,22 @@ class PositionTracker:
|
|||
if asset_type in _derivs:
|
||||
alloc.slots = alloc.units_limit
|
||||
|
||||
self.update_line(
|
||||
self.nav.update_ui(
|
||||
self.alloc.account,
|
||||
pp.ppu,
|
||||
pp.size,
|
||||
self.chart.linked.symbol.lot_size_digits,
|
||||
round(alloc.slots_used(pp), ndigits=1), # slots used
|
||||
)
|
||||
|
||||
# label updates
|
||||
self.size_label.fields['slots_used'] = round(
|
||||
alloc.slots_used(pp), ndigits=1)
|
||||
self.size_label.render()
|
||||
|
||||
if pp.size == 0:
|
||||
self.hide()
|
||||
|
||||
else:
|
||||
self._level_marker.level = pp.ppu
|
||||
|
||||
# these updates are critical to avoid lag on view/scene changes
|
||||
self._level_marker.update() # trigger paint
|
||||
self.pp_label.update()
|
||||
self.size_label.update()
|
||||
|
||||
self.show()
|
||||
|
||||
# don't show side and status widgets unless
|
||||
# order mode is "engaged" (which done via input controls)
|
||||
self.hide_info()
|
||||
|
||||
def level(self) -> float:
|
||||
if self.line:
|
||||
return self.line.value()
|
||||
else:
|
||||
return 0
|
||||
|
||||
def show(self) -> None:
|
||||
if self.live_pp.size:
|
||||
self.line.show()
|
||||
self.line.show_labels()
|
||||
# print("SHOWING NAV")
|
||||
self.nav.show()
|
||||
|
||||
self._level_marker.show()
|
||||
self.pp_label.show()
|
||||
self.size_label.show()
|
||||
# if pp.size == 0:
|
||||
else:
|
||||
# print("HIDING NAV")
|
||||
self.nav.hide()
|
||||
|
||||
def hide(self) -> None:
|
||||
self.pp_label.hide()
|
||||
self._level_marker.hide()
|
||||
self.size_label.hide()
|
||||
if self.line:
|
||||
self.line.hide()
|
||||
|
||||
def hide_info(self) -> None:
|
||||
'''Hide details (right now just size label?) of position.
|
||||
|
||||
'''
|
||||
self.size_label.hide()
|
||||
if self.line:
|
||||
self.line.hide_labels()
|
||||
|
||||
# TODO: move into annoate module
|
||||
def level_marker(
|
||||
self,
|
||||
size: float,
|
||||
|
||||
) -> LevelMarker:
|
||||
|
||||
if self._level_marker:
|
||||
self._level_marker.delete()
|
||||
|
||||
# arrow marker
|
||||
# scale marker size with dpi-aware font size
|
||||
font_size = _font.font.pixelSize()
|
||||
|
||||
# scale marker size with dpi-aware font size
|
||||
arrow_size = floor(1.375 * font_size)
|
||||
|
||||
if size > 0:
|
||||
style = '|<'
|
||||
|
||||
elif size < 0:
|
||||
style = '>|'
|
||||
|
||||
arrow = LevelMarker(
|
||||
chart=self.chart,
|
||||
style=style,
|
||||
get_level=self.level,
|
||||
size=arrow_size,
|
||||
on_paint=self.update_graphics,
|
||||
)
|
||||
|
||||
self.chart.getViewBox().scene().addItem(arrow)
|
||||
arrow.show()
|
||||
|
||||
return arrow
|
||||
|
||||
def update_line(
|
||||
self,
|
||||
price: float,
|
||||
size: float,
|
||||
size_digits: int,
|
||||
|
||||
) -> None:
|
||||
'''Update personal position level line.
|
||||
|
||||
'''
|
||||
# do line update
|
||||
line = self.line
|
||||
|
||||
if size:
|
||||
if line is None:
|
||||
|
||||
# create and show a pp line
|
||||
line = self.line = position_line(
|
||||
chart=self.chart,
|
||||
level=price,
|
||||
size=size,
|
||||
color=self._color,
|
||||
marker=self._level_marker,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
line.set_level(price)
|
||||
self._level_marker.level = price
|
||||
self._level_marker.update()
|
||||
|
||||
# update LHS sizing label
|
||||
line.update_labels({
|
||||
'size': size,
|
||||
'size_digits': size_digits,
|
||||
'fiat_size': round(price * size, ndigits=2),
|
||||
|
||||
# TODO: per account lines on a single (or very related) symbol
|
||||
'account': self.alloc.account,
|
||||
})
|
||||
line.show()
|
||||
|
||||
elif line: # remove pp line from view if it exists on a net-zero pp
|
||||
line.delete()
|
||||
self.line = None
|
||||
# don't show side and status widgets unless
|
||||
# order mode is "engaged" (which done via input controls)
|
||||
self.nav.hide_info()
|
||||
|
|
|
@ -35,9 +35,13 @@ from collections import defaultdict
|
|||
from contextlib import asynccontextmanager
|
||||
from functools import partial
|
||||
from typing import (
|
||||
Optional, Callable,
|
||||
Awaitable, Sequence,
|
||||
Any, AsyncIterator
|
||||
Optional,
|
||||
Callable,
|
||||
Awaitable,
|
||||
Sequence,
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Iterator,
|
||||
)
|
||||
import time
|
||||
# from pprint import pformat
|
||||
|
@ -119,7 +123,7 @@ class CompleterView(QTreeView):
|
|||
# TODO: size this based on DPI font
|
||||
self.setIndentation(_font.px_size)
|
||||
|
||||
# self.setUniformRowHeights(True)
|
||||
self.setUniformRowHeights(True)
|
||||
# self.setColumnWidth(0, 3)
|
||||
# self.setVerticalBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
# self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
|
||||
|
@ -138,13 +142,15 @@ class CompleterView(QTreeView):
|
|||
model.setHorizontalHeaderLabels(labels)
|
||||
|
||||
self._font_size: int = 0 # pixels
|
||||
self._init: bool = False
|
||||
|
||||
async def on_pressed(self, idx: QModelIndex) -> None:
|
||||
'''Mouse pressed on view handler.
|
||||
'''
|
||||
Mouse pressed on view handler.
|
||||
|
||||
'''
|
||||
search = self.parent()
|
||||
await search.chart_current_item(clear_to_cache=False)
|
||||
await search.chart_current_item()
|
||||
search.focus()
|
||||
|
||||
def set_font_size(self, size: int = 18):
|
||||
|
@ -156,56 +162,64 @@ class CompleterView(QTreeView):
|
|||
|
||||
self.setStyleSheet(f"font: {size}px")
|
||||
|
||||
# def resizeEvent(self, event: 'QEvent') -> None:
|
||||
# event.accept()
|
||||
# super().resizeEvent(event)
|
||||
def resize_to_results(
|
||||
self,
|
||||
w: Optional[float] = 0,
|
||||
h: Optional[float] = None,
|
||||
|
||||
def on_resize(self) -> None:
|
||||
'''
|
||||
Resize relay event from god.
|
||||
|
||||
'''
|
||||
self.resize_to_results()
|
||||
|
||||
def resize_to_results(self):
|
||||
) -> None:
|
||||
model = self.model()
|
||||
cols = model.columnCount()
|
||||
# rows = model.rowCount()
|
||||
cidx = self.selectionModel().currentIndex()
|
||||
rows = model.rowCount()
|
||||
self.expandAll()
|
||||
|
||||
# compute the approx height in pixels needed to include
|
||||
# all result rows in view.
|
||||
row_h = rows_h = self.rowHeight(cidx) * (rows + 1)
|
||||
for idx, item in self.iter_df_rows():
|
||||
row_h = self.rowHeight(idx)
|
||||
rows_h += row_h
|
||||
# print(f'row_h: {row_h}\nrows_h: {rows_h}')
|
||||
|
||||
# TODO: could we just break early here on detection
|
||||
# of ``rows_h >= h``?
|
||||
|
||||
col_w_tot = 0
|
||||
for i in range(cols):
|
||||
# only slap in a rows's height's worth
|
||||
# of padding once at startup.. no idea
|
||||
if (
|
||||
not self._init
|
||||
and row_h
|
||||
):
|
||||
col_w_tot = row_h
|
||||
self._init = True
|
||||
|
||||
self.resizeColumnToContents(i)
|
||||
col_w_tot += self.columnWidth(i)
|
||||
|
||||
win = self.window()
|
||||
win_h = win.height()
|
||||
edit_h = self.parent().bar.height()
|
||||
sb_h = win.statusBar().height()
|
||||
# NOTE: if the heigh `h` set here is **too large** then the
|
||||
# resize event will perpetually trigger as the window causes
|
||||
# some kind of recompute of callbacks.. so we have to ensure
|
||||
# it's limited.
|
||||
if h:
|
||||
h: int = round(h)
|
||||
abs_mx = round(0.91 * h)
|
||||
self.setMaximumHeight(abs_mx)
|
||||
|
||||
# TODO: probably make this more general / less hacky
|
||||
# we should figure out the exact number of rows to allow
|
||||
# inclusive of search bar and header "rows", in pixel terms.
|
||||
# Eventually when we have an "info" widget below the results we
|
||||
# will want space for it and likely terminating the results-view
|
||||
# space **exactly on a row** would be ideal.
|
||||
# if row_px > 0:
|
||||
# rows = ceil(window_h / row_px) - 4
|
||||
# else:
|
||||
# rows = 16
|
||||
# self.setFixedHeight(rows * row_px)
|
||||
# self.resize(self.width(), rows * row_px)
|
||||
if rows_h <= abs_mx:
|
||||
# self.setMinimumHeight(rows_h)
|
||||
self.setMinimumHeight(rows_h)
|
||||
# self.setFixedHeight(rows_h)
|
||||
|
||||
# NOTE: if the heigh set here is **too large** then the resize
|
||||
# event will perpetually trigger as the window causes some kind
|
||||
# of recompute of callbacks.. so we have to ensure it's limited.
|
||||
h = win_h - (edit_h + 1.666*sb_h)
|
||||
assert h > 0
|
||||
self.setFixedHeight(round(h))
|
||||
else:
|
||||
self.setMinimumHeight(abs_mx)
|
||||
|
||||
# size to width of longest result seen thus far
|
||||
# TODO: should we always dynamically scale to longest result?
|
||||
if self.width() < col_w_tot:
|
||||
self.setFixedWidth(col_w_tot)
|
||||
# dyncamically size to width of longest result seen
|
||||
curr_w = self.width()
|
||||
if curr_w < col_w_tot:
|
||||
self.setMinimumWidth(col_w_tot)
|
||||
|
||||
self.update()
|
||||
|
||||
|
@ -331,6 +345,23 @@ class CompleterView(QTreeView):
|
|||
item = model.itemFromIndex(idx)
|
||||
yield idx, item
|
||||
|
||||
def iter_df_rows(
|
||||
self,
|
||||
iparent: QModelIndex = QModelIndex(),
|
||||
|
||||
) -> Iterator[tuple[QModelIndex, QStandardItem]]:
|
||||
|
||||
model = self.model()
|
||||
isections = model.rowCount(iparent)
|
||||
for i in range(isections):
|
||||
idx = model.index(i, 0, iparent)
|
||||
item = model.itemFromIndex(idx)
|
||||
yield idx, item
|
||||
|
||||
if model.hasChildren(idx):
|
||||
# recursively yield child items depth-first
|
||||
yield from self.iter_df_rows(idx)
|
||||
|
||||
def find_section(
|
||||
self,
|
||||
section: str,
|
||||
|
@ -354,7 +385,8 @@ class CompleterView(QTreeView):
|
|||
status_field: str = None,
|
||||
|
||||
) -> None:
|
||||
'''Clear all result-rows from under the depth = 1 section.
|
||||
'''
|
||||
Clear all result-rows from under the depth = 1 section.
|
||||
|
||||
'''
|
||||
idx = self.find_section(section)
|
||||
|
@ -375,8 +407,6 @@ class CompleterView(QTreeView):
|
|||
else:
|
||||
model.setItem(idx.row(), 1, QStandardItem())
|
||||
|
||||
self.resize_to_results()
|
||||
|
||||
return idx
|
||||
else:
|
||||
return None
|
||||
|
@ -444,9 +474,22 @@ class CompleterView(QTreeView):
|
|||
|
||||
self.show_matches()
|
||||
|
||||
def show_matches(self) -> None:
|
||||
def show_matches(
|
||||
self,
|
||||
wh: Optional[tuple[float, float]] = None,
|
||||
|
||||
) -> None:
|
||||
|
||||
if wh:
|
||||
self.resize_to_results(*wh)
|
||||
else:
|
||||
# case where it's just an update from results and *NOT*
|
||||
# a resize of some higher level parent-container widget.
|
||||
search = self.parent()
|
||||
w, h = search.space_dims()
|
||||
self.resize_to_results(w=w, h=h)
|
||||
|
||||
self.show()
|
||||
self.resize_to_results()
|
||||
|
||||
|
||||
class SearchBar(Edit):
|
||||
|
@ -466,18 +509,15 @@ class SearchBar(Edit):
|
|||
self.godwidget = godwidget
|
||||
super().__init__(parent, **kwargs)
|
||||
self.view: CompleterView = view
|
||||
godwidget._widgets[view.mode_name] = view
|
||||
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
self.view.show_matches()
|
||||
|
||||
def unfocus(self) -> None:
|
||||
self.parent().hide()
|
||||
self.clearFocus()
|
||||
|
||||
def hide(self) -> None:
|
||||
if self.view:
|
||||
self.view.hide()
|
||||
super().hide()
|
||||
|
||||
|
||||
class SearchWidget(QtWidgets.QWidget):
|
||||
|
@ -496,15 +536,16 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
parent=None,
|
||||
|
||||
) -> None:
|
||||
super().__init__(parent or godwidget)
|
||||
super().__init__(parent)
|
||||
|
||||
# size it as we specify
|
||||
self.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Fixed,
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
QtWidgets.QSizePolicy.Fixed,
|
||||
)
|
||||
|
||||
self.godwidget = godwidget
|
||||
godwidget.reg_for_resize(self)
|
||||
|
||||
self.vbox = QtWidgets.QVBoxLayout(self)
|
||||
self.vbox.setContentsMargins(0, 4, 4, 0)
|
||||
|
@ -554,17 +595,22 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
self.vbox.setAlignment(self.view, Qt.AlignTop | Qt.AlignLeft)
|
||||
|
||||
def focus(self) -> None:
|
||||
|
||||
if self.view.model().rowCount(QModelIndex()) == 0:
|
||||
# fill cache list if nothing existing
|
||||
self.view.set_section_entries(
|
||||
'cache',
|
||||
list(reversed(self.godwidget._chart_cache)),
|
||||
clear_all=True,
|
||||
)
|
||||
|
||||
self.bar.focus()
|
||||
self.show()
|
||||
self.bar.focus()
|
||||
|
||||
def show_only_cache_entries(self) -> None:
|
||||
'''
|
||||
Clear the search results view and show only cached (aka recently
|
||||
loaded with active data) feeds in the results section.
|
||||
|
||||
'''
|
||||
godw = self.godwidget
|
||||
self.view.set_section_entries(
|
||||
'cache',
|
||||
list(reversed(godw._chart_cache)),
|
||||
# remove all other completion results except for cache
|
||||
clear_all=True,
|
||||
)
|
||||
|
||||
def get_current_item(self) -> Optional[tuple[str, str]]:
|
||||
'''Return the current completer tree selection as
|
||||
|
@ -603,7 +649,8 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
clear_to_cache: bool = True,
|
||||
|
||||
) -> Optional[str]:
|
||||
'''Attempt to load and switch the current selected
|
||||
'''
|
||||
Attempt to load and switch the current selected
|
||||
completion result to the affiliated chart app.
|
||||
|
||||
Return any loaded symbol.
|
||||
|
@ -614,11 +661,11 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
return None
|
||||
|
||||
provider, symbol = value
|
||||
chart = self.godwidget
|
||||
godw = self.godwidget
|
||||
|
||||
log.info(f'Requesting symbol: {symbol}.{provider}')
|
||||
|
||||
await chart.load_symbol(
|
||||
await godw.load_symbol(
|
||||
provider,
|
||||
symbol,
|
||||
'info',
|
||||
|
@ -635,18 +682,46 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
# Re-order the symbol cache on the chart to display in
|
||||
# LIFO order. this is normally only done internally by
|
||||
# the chart on new symbols being loaded into memory
|
||||
chart.set_chart_symbol(fqsn, chart.linkedsplits)
|
||||
|
||||
self.view.set_section_entries(
|
||||
'cache',
|
||||
values=list(reversed(chart._chart_cache)),
|
||||
|
||||
# remove all other completion results except for cache
|
||||
clear_all=True,
|
||||
godw.set_chart_symbol(
|
||||
fqsn, (
|
||||
godw.hist_linked,
|
||||
godw.rt_linked,
|
||||
)
|
||||
)
|
||||
self.show_only_cache_entries()
|
||||
|
||||
self.bar.focus()
|
||||
return fqsn
|
||||
|
||||
def space_dims(self) -> tuple[float, float]:
|
||||
'''
|
||||
Compute and return the "available space dimentions" for this
|
||||
search widget in terms of px space for results by return the
|
||||
pair of width and height.
|
||||
|
||||
'''
|
||||
# XXX: dun need dis rite?
|
||||
# win = self.window()
|
||||
# win_h = win.height()
|
||||
# sb_h = win.statusBar().height()
|
||||
godw = self.godwidget
|
||||
hl = godw.hist_linked
|
||||
edit_h = self.bar.height()
|
||||
h = hl.height() - edit_h
|
||||
w = hl.width()
|
||||
return w, h
|
||||
|
||||
def on_resize(self) -> None:
|
||||
'''
|
||||
Resize relay event from god, resize all child widgets.
|
||||
|
||||
Right now this is just view to contents and/or the fast chart
|
||||
height.
|
||||
|
||||
'''
|
||||
w, h = self.space_dims()
|
||||
self.bar.view.show_matches(wh=(w, h))
|
||||
|
||||
|
||||
_search_active: trio.Event = trio.Event()
|
||||
_search_enabled: bool = False
|
||||
|
@ -712,10 +787,11 @@ async def fill_results(
|
|||
max_pause_time: float = 6/16 + 0.001,
|
||||
|
||||
) -> None:
|
||||
"""Task to search through providers and fill in possible
|
||||
'''
|
||||
Task to search through providers and fill in possible
|
||||
completion results.
|
||||
|
||||
"""
|
||||
'''
|
||||
global _search_active, _search_enabled, _searcher_cache
|
||||
|
||||
bar = search.bar
|
||||
|
@ -729,6 +805,10 @@ async def fill_results(
|
|||
matches = defaultdict(list)
|
||||
has_results: defaultdict[str, set[str]] = defaultdict(set)
|
||||
|
||||
# show cached feed list at startup
|
||||
search.show_only_cache_entries()
|
||||
search.on_resize()
|
||||
|
||||
while True:
|
||||
await _search_active.wait()
|
||||
period = None
|
||||
|
@ -742,7 +822,7 @@ async def fill_results(
|
|||
pattern = await recv_chan.receive()
|
||||
|
||||
period = time.time() - wait_start
|
||||
print(f'{pattern} after {period}')
|
||||
log.debug(f'{pattern} after {period}')
|
||||
|
||||
# during fast multiple key inputs, wait until a pause
|
||||
# (in typing) to initiate search
|
||||
|
@ -841,8 +921,7 @@ async def handle_keyboard_input(
|
|||
godwidget = search.godwidget
|
||||
view = bar.view
|
||||
view.set_font_size(bar.dpi_font.px_size)
|
||||
|
||||
send, recv = trio.open_memory_channel(16)
|
||||
send, recv = trio.open_memory_channel(616)
|
||||
|
||||
async with trio.open_nursery() as n:
|
||||
|
||||
|
@ -857,6 +936,10 @@ async def handle_keyboard_input(
|
|||
)
|
||||
)
|
||||
|
||||
bar.focus()
|
||||
search.show_only_cache_entries()
|
||||
await trio.sleep(0)
|
||||
|
||||
async for kbmsg in recv_chan:
|
||||
event, etype, key, mods, txt = kbmsg.to_tuple()
|
||||
|
||||
|
@ -867,10 +950,11 @@ async def handle_keyboard_input(
|
|||
ctl = True
|
||||
|
||||
if key in (Qt.Key_Enter, Qt.Key_Return):
|
||||
|
||||
await search.chart_current_item(clear_to_cache=True)
|
||||
_search_enabled = False
|
||||
continue
|
||||
await search.chart_current_item(clear_to_cache=True)
|
||||
search.show_only_cache_entries()
|
||||
view.show_matches()
|
||||
search.focus()
|
||||
|
||||
elif not ctl and not bar.text():
|
||||
# if nothing in search text show the cache
|
||||
|
@ -887,7 +971,7 @@ async def handle_keyboard_input(
|
|||
Qt.Key_Space, # i feel like this is the "native" one
|
||||
Qt.Key_Alt,
|
||||
}:
|
||||
search.bar.unfocus()
|
||||
bar.unfocus()
|
||||
|
||||
# kill the search and focus back on main chart
|
||||
if godwidget:
|
||||
|
@ -935,9 +1019,10 @@ async def handle_keyboard_input(
|
|||
if item:
|
||||
parent_item = item.parent()
|
||||
|
||||
# if we're in the cache section and thus the next
|
||||
# selection is a cache item, switch and show it
|
||||
# immediately since it should be very fast.
|
||||
if parent_item and parent_item.text() == 'cache':
|
||||
|
||||
# if it's a cache item, switch and show it immediately
|
||||
await search.chart_current_item(clear_to_cache=False)
|
||||
|
||||
elif not ctl:
|
||||
|
|
|
@ -21,7 +21,11 @@ Qt main window singletons and stuff.
|
|||
import os
|
||||
import signal
|
||||
import time
|
||||
from typing import Callable, Optional, Union
|
||||
from typing import (
|
||||
Callable,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
import uuid
|
||||
|
||||
from pyqtgraph import QtGui
|
||||
|
@ -30,6 +34,7 @@ from PyQt5.QtWidgets import QLabel, QStatusBar
|
|||
|
||||
from ..log import get_logger
|
||||
from ._style import _font_small, hcolor
|
||||
from ._chart import GodWidget
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
@ -153,7 +158,8 @@ class MainWindow(QtGui.QMainWindow):
|
|||
# XXX: for tiling wms this should scale
|
||||
# with the alloted window size.
|
||||
# TODO: detect for tiling and if untrue set some size?
|
||||
size = (300, 500)
|
||||
# size = (300, 500)
|
||||
godwidget: GodWidget
|
||||
|
||||
title = 'piker chart (ur symbol is loading bby)'
|
||||
|
||||
|
@ -162,6 +168,9 @@ class MainWindow(QtGui.QMainWindow):
|
|||
# self.setMinimumSize(*self.size)
|
||||
self.setWindowTitle(self.title)
|
||||
|
||||
# set by runtime after `trio` is engaged.
|
||||
self.godwidget: Optional[GodWidget] = None
|
||||
|
||||
self._status_bar: QStatusBar = None
|
||||
self._status_label: QLabel = None
|
||||
self._size: Optional[tuple[int, int]] = None
|
||||
|
@ -248,9 +257,10 @@ class MainWindow(QtGui.QMainWindow):
|
|||
self.set_mode_name(name)
|
||||
|
||||
def current_screen(self) -> QtGui.QScreen:
|
||||
"""Get a frickin screen (if we can, gawd).
|
||||
'''
|
||||
Get a frickin screen (if we can, gawd).
|
||||
|
||||
"""
|
||||
'''
|
||||
app = QtGui.QApplication.instance()
|
||||
|
||||
for _ in range(3):
|
||||
|
@ -284,7 +294,7 @@ class MainWindow(QtGui.QMainWindow):
|
|||
'''
|
||||
# https://stackoverflow.com/a/18975846
|
||||
if not size and not self._size:
|
||||
app = QtGui.QApplication.instance()
|
||||
# app = QtGui.QApplication.instance()
|
||||
geo = self.current_screen().geometry()
|
||||
h, w = geo.height(), geo.width()
|
||||
# use approx 1/3 of the area of the screen by default
|
||||
|
@ -292,6 +302,33 @@ class MainWindow(QtGui.QMainWindow):
|
|||
|
||||
self.resize(*size or self._size)
|
||||
|
||||
def resizeEvent(self, event: QtCore.QEvent) -> None:
|
||||
if (
|
||||
# event.spontaneous()
|
||||
event.oldSize().height == event.size().height
|
||||
):
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
# XXX: uncomment for debugging..
|
||||
# attrs = {}
|
||||
# for key in dir(event):
|
||||
# if key == '__dir__':
|
||||
# continue
|
||||
# attr = getattr(event, key)
|
||||
# try:
|
||||
# attrs[key] = attr()
|
||||
# except TypeError:
|
||||
# attrs[key] = attr
|
||||
|
||||
# from pprint import pformat
|
||||
# print(
|
||||
# f'{pformat(attrs)}\n'
|
||||
# f'WINDOW RESIZE: {self.size()}\n\n'
|
||||
# )
|
||||
self.godwidget.on_win_resize(event)
|
||||
event.accept()
|
||||
|
||||
|
||||
# singleton app per actor
|
||||
_qt_win: QtGui.QMainWindow = None
|
||||
|
|
|
@ -18,13 +18,19 @@
|
|||
Chart trading, the only way to scalp.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from pprint import pformat
|
||||
import platform
|
||||
import time
|
||||
from typing import Optional, Dict, Callable, Any
|
||||
from typing import (
|
||||
Optional,
|
||||
Callable,
|
||||
Any,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
import uuid
|
||||
|
||||
import tractor
|
||||
|
@ -60,6 +66,12 @@ from ..clearing._messages import (
|
|||
from ._forms import open_form_input_handling
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._chart import (
|
||||
ChartPlotWidget,
|
||||
GodWidget,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
|
@ -73,10 +85,10 @@ class Dialog(Struct):
|
|||
uuid: str
|
||||
order: Order
|
||||
symbol: Symbol
|
||||
line: LevelLine
|
||||
lines: list[LevelLine]
|
||||
last_status_close: Callable = lambda: None
|
||||
msgs: dict[str, dict] = {}
|
||||
fills: Dict[str, Any] = {}
|
||||
fills: dict[str, Any] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -100,8 +112,11 @@ class OrderMode:
|
|||
mouse click and drag -> modify current order under cursor
|
||||
|
||||
'''
|
||||
chart: 'ChartPlotWidget' # type: ignore # noqa
|
||||
nursery: trio.Nursery
|
||||
godw: GodWidget
|
||||
feed: Feed
|
||||
chart: ChartPlotWidget # type: ignore # noqa
|
||||
hist_chart: ChartPlotWidget # type: ignore # noqa
|
||||
nursery: trio.Nursery # used by ``ui._position`` code?
|
||||
quote_feed: Feed
|
||||
book: OrderBook
|
||||
lines: LineEditor
|
||||
|
@ -162,14 +177,15 @@ class OrderMode:
|
|||
def line_from_order(
|
||||
self,
|
||||
order: Order,
|
||||
chart: Optional[ChartPlotWidget] = None,
|
||||
**line_kwargs,
|
||||
|
||||
) -> LevelLine:
|
||||
|
||||
level = order.price
|
||||
line = order_line(
|
||||
|
||||
self.chart,
|
||||
line = order_line(
|
||||
chart or self.chart,
|
||||
# TODO: convert these values into human-readable form
|
||||
# (i.e. with k, m, M, B) type embedded suffixes
|
||||
level=level,
|
||||
|
@ -211,24 +227,61 @@ class OrderMode:
|
|||
|
||||
return line
|
||||
|
||||
def lines_from_order(
|
||||
self,
|
||||
order: Order,
|
||||
**line_kwargs,
|
||||
|
||||
) -> list[LevelLine]:
|
||||
|
||||
lines: list[LevelLine] = []
|
||||
for chart, kwargs in [
|
||||
(self.chart, {}),
|
||||
(self.hist_chart, {'only_show_markers_on_hover': True}),
|
||||
]:
|
||||
kwargs.update(line_kwargs)
|
||||
line = self.line_from_order(
|
||||
order=order,
|
||||
chart=chart,
|
||||
**kwargs,
|
||||
)
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
def stage_order(
|
||||
self,
|
||||
|
||||
action: str,
|
||||
trigger_type: str,
|
||||
|
||||
) -> None:
|
||||
'''Stage an order for submission.
|
||||
) -> list[LevelLine]:
|
||||
'''
|
||||
Stage an order for submission by showing level lines and
|
||||
configuring the order request message dynamically based on
|
||||
allocator settings.
|
||||
|
||||
'''
|
||||
# not initialized yet
|
||||
chart = self.chart
|
||||
cursor = chart.linked.cursor
|
||||
if not (chart and cursor and cursor.active_plot):
|
||||
cursor = self.godw.get_cursor()
|
||||
if not cursor:
|
||||
return
|
||||
|
||||
chart = cursor.linked.chart
|
||||
if (
|
||||
not chart
|
||||
and cursor
|
||||
and cursor.active_plot
|
||||
):
|
||||
return
|
||||
|
||||
chart = cursor.active_plot
|
||||
price = cursor._datum_xy[1]
|
||||
if not price:
|
||||
# zero prices are not supported by any means
|
||||
# since that's illogical / a no-op.
|
||||
return
|
||||
|
||||
symbol = self.chart.linked.symbol
|
||||
|
||||
order = self._staged_order = Order(
|
||||
|
@ -242,27 +295,43 @@ class OrderMode:
|
|||
exec_mode=trigger_type, # dark or live
|
||||
)
|
||||
|
||||
# TODO: staged line mirroring? - need to keep track of multiple
|
||||
# staged lines in editor - need to call
|
||||
# `LineEditor.unstage_line()` on all staged lines..
|
||||
# lines = self.lines_from_order(
|
||||
|
||||
line = self.line_from_order(
|
||||
order,
|
||||
chart=chart,
|
||||
show_markers=True,
|
||||
|
||||
# just for the stage line to avoid
|
||||
# flickering while moving the cursor
|
||||
# around where it might trigger highlight
|
||||
# then non-highlight depending on sensitivity
|
||||
always_show_labels=True,
|
||||
|
||||
# don't highlight the "staging" line
|
||||
highlight_on_hover=False,
|
||||
|
||||
# prevent flickering of marker while moving/tracking cursor
|
||||
only_show_markers_on_hover=False,
|
||||
)
|
||||
line = self.lines.stage_line(line)
|
||||
|
||||
# hide crosshair y-line and label
|
||||
cursor.hide_xhair()
|
||||
self.lines.stage_line(line)
|
||||
|
||||
# add line to cursor trackers
|
||||
cursor._trackers.add(line)
|
||||
|
||||
# TODO: see above about mirroring.
|
||||
# for line in lines:
|
||||
# if line._chart is chart:
|
||||
# self.lines.stage_line(line)
|
||||
# cursor._trackers.add(line)
|
||||
# break
|
||||
|
||||
# hide crosshair y-line and label
|
||||
cursor.hide_xhair()
|
||||
|
||||
return line
|
||||
|
||||
def submit_order(
|
||||
|
@ -285,13 +354,10 @@ class OrderMode:
|
|||
|
||||
order.symbol = order.symbol.front_fqsn()
|
||||
|
||||
line = self.line_from_order(
|
||||
lines = self.lines_from_order(
|
||||
order,
|
||||
|
||||
show_markers=True,
|
||||
only_show_markers_on_hover=True,
|
||||
)
|
||||
|
||||
# register the "submitted" line under the cursor
|
||||
# to be displayed when above order ack arrives
|
||||
# (means the marker graphic doesn't show on screen until the
|
||||
|
@ -302,8 +368,8 @@ class OrderMode:
|
|||
# maybe place a grey line in "submission" mode
|
||||
# which will be updated to it's appropriate action
|
||||
# color once the submission ack arrives.
|
||||
self.lines.submit_line(
|
||||
line=line,
|
||||
self.lines.submit_lines(
|
||||
lines=lines,
|
||||
uuid=order.oid,
|
||||
)
|
||||
|
||||
|
@ -311,24 +377,25 @@ class OrderMode:
|
|||
uuid=order.oid,
|
||||
order=order,
|
||||
symbol=order.symbol,
|
||||
line=line,
|
||||
lines=lines,
|
||||
last_status_close=self.multistatus.open_status(
|
||||
f'submitting {order.exec_mode}-{order.action}',
|
||||
final_msg=f'submitted {order.exec_mode}-{order.action}',
|
||||
clear_on_next=True,
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: create a new ``OrderLine`` with this optional var defined
|
||||
line.dialog = dialog
|
||||
|
||||
# enter submission which will be popped once a response
|
||||
# from the EMS is received to move the order to a different# status
|
||||
self.dialogs[order.oid] = dialog
|
||||
|
||||
# hook up mouse drag handlers
|
||||
line._on_drag_start = self.order_line_modify_start
|
||||
line._on_drag_end = self.order_line_modify_complete
|
||||
for line in lines:
|
||||
|
||||
# TODO: create a new ``OrderLine`` with this optional var defined
|
||||
line.dialog = dialog
|
||||
|
||||
# hook up mouse drag handlers
|
||||
line._on_drag_start = self.order_line_modify_start
|
||||
line._on_drag_end = self.order_line_modify_complete
|
||||
|
||||
# send order cmd to ems
|
||||
if send_msg:
|
||||
|
@ -350,7 +417,7 @@ class OrderMode:
|
|||
|
||||
) -> None:
|
||||
|
||||
print(f'Line modify: {line}')
|
||||
log.info(f'Order modify: {line}')
|
||||
# cancel original order until new position is found?
|
||||
# TODO: make a config option for this behaviour..
|
||||
|
||||
|
@ -361,8 +428,9 @@ class OrderMode:
|
|||
) -> None:
|
||||
|
||||
level = line.value()
|
||||
# updateb by level change callback set in ``.line_from_order()``
|
||||
size = line.dialog.order.size
|
||||
# updated by level change callback set in ``.line_from_order()``
|
||||
dialog = line.dialog
|
||||
size = dialog.order.size
|
||||
|
||||
self.book.update(
|
||||
uuid=line.dialog.uuid,
|
||||
|
@ -370,8 +438,13 @@ class OrderMode:
|
|||
size=size,
|
||||
)
|
||||
|
||||
# ems response loop handlers
|
||||
# adjust corresponding slow/fast chart line
|
||||
# to match level
|
||||
for ln in dialog.lines:
|
||||
if ln is not line:
|
||||
ln.set_level(line.value())
|
||||
|
||||
# EMS response msg handlers
|
||||
def on_submit(
|
||||
self,
|
||||
uuid: str
|
||||
|
@ -383,13 +456,18 @@ class OrderMode:
|
|||
Commit the order line and registered order uuid, store ack time stamp.
|
||||
|
||||
'''
|
||||
line = self.lines.commit_line(uuid)
|
||||
lines = self.lines.commit_line(uuid)
|
||||
|
||||
# a submission is the start of a new order dialog
|
||||
dialog = self.dialogs[uuid]
|
||||
dialog.line = line
|
||||
dialog.lines = lines
|
||||
dialog.last_status_close()
|
||||
|
||||
for line in lines:
|
||||
# hide any lines not currently moused-over
|
||||
if not line.get_cursor():
|
||||
line.hide_labels()
|
||||
|
||||
return dialog
|
||||
|
||||
def on_fill(
|
||||
|
@ -415,17 +493,26 @@ class OrderMode:
|
|||
|
||||
'''
|
||||
dialog = self.dialogs[uuid]
|
||||
line = dialog.line
|
||||
if line:
|
||||
self.arrows.add(
|
||||
uuid,
|
||||
arrow_index,
|
||||
price,
|
||||
pointing=pointing,
|
||||
color=line.color
|
||||
)
|
||||
lines = dialog.lines
|
||||
# XXX: seems to fail on certain types of races?
|
||||
# assert len(lines) == 2
|
||||
if lines:
|
||||
_, _, ratio = self.feed.get_ds_info()
|
||||
for i, chart in [
|
||||
(arrow_index, self.chart),
|
||||
(self.feed.startup_hist_index + round(arrow_index/ratio),
|
||||
self.hist_chart)
|
||||
]:
|
||||
self.arrows.add(
|
||||
chart.plotItem,
|
||||
uuid,
|
||||
i,
|
||||
price,
|
||||
pointing=pointing,
|
||||
color=lines[0].color
|
||||
)
|
||||
else:
|
||||
log.warn("No line for order {uuid}!?")
|
||||
log.warn("No line(s) for order {uuid}!?")
|
||||
|
||||
async def on_exec(
|
||||
self,
|
||||
|
@ -486,7 +573,8 @@ class OrderMode:
|
|||
)
|
||||
|
||||
def cancel_all_orders(self) -> list[str]:
|
||||
'''Cancel all orders for the current chart.
|
||||
'''
|
||||
Cancel all orders for the current chart.
|
||||
|
||||
'''
|
||||
return self.cancel_orders_from_lines(
|
||||
|
@ -568,7 +656,7 @@ class OrderMode:
|
|||
async def open_order_mode(
|
||||
|
||||
feed: Feed,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
godw: GodWidget,
|
||||
fqsn: str,
|
||||
started: trio.Event,
|
||||
|
||||
|
@ -581,6 +669,9 @@ async def open_order_mode(
|
|||
state, mostly graphics / UI.
|
||||
|
||||
'''
|
||||
chart = godw.rt_linked.chart
|
||||
hist_chart = godw.hist_linked.chart
|
||||
|
||||
multistatus = chart.window().status_bar
|
||||
done = multistatus.open_status('starting order mode..')
|
||||
|
||||
|
@ -606,11 +697,10 @@ async def open_order_mode(
|
|||
|
||||
):
|
||||
log.info(f'Opening order mode for {fqsn}')
|
||||
view = chart.view
|
||||
|
||||
# annotations editors
|
||||
lines = LineEditor(chart=chart)
|
||||
arrows = ArrowEditor(chart, {})
|
||||
lines = LineEditor(godw=godw)
|
||||
arrows = ArrowEditor(godw=godw)
|
||||
|
||||
# symbol id
|
||||
symbol = chart.linked.symbol
|
||||
|
@ -663,11 +753,11 @@ async def open_order_mode(
|
|||
)
|
||||
|
||||
pp_tracker = PositionTracker(
|
||||
chart,
|
||||
[chart, hist_chart],
|
||||
alloc,
|
||||
startup_pp
|
||||
)
|
||||
pp_tracker.hide()
|
||||
pp_tracker.nav.hide()
|
||||
trackers[account_name] = pp_tracker
|
||||
|
||||
assert pp_tracker.startup_pp.size == pp_tracker.live_pp.size
|
||||
|
@ -679,8 +769,8 @@ async def open_order_mode(
|
|||
|
||||
# on existing position, show pp tracking graphics
|
||||
if pp_tracker.startup_pp.size != 0:
|
||||
pp_tracker.show()
|
||||
pp_tracker.hide_info()
|
||||
pp_tracker.nav.show()
|
||||
pp_tracker.nav.hide_info()
|
||||
|
||||
# setup order mode sidepane widgets
|
||||
form: FieldsForm = chart.sidepane
|
||||
|
@ -720,7 +810,10 @@ async def open_order_mode(
|
|||
# top level abstraction which wraps all this crazyness into
|
||||
# a namespace..
|
||||
mode = OrderMode(
|
||||
godw,
|
||||
feed,
|
||||
chart,
|
||||
hist_chart,
|
||||
tn,
|
||||
feed,
|
||||
book,
|
||||
|
@ -737,8 +830,8 @@ async def open_order_mode(
|
|||
# select a pp to track
|
||||
tracker: PositionTracker = trackers[pp_account]
|
||||
mode.current_pp = tracker
|
||||
tracker.show()
|
||||
tracker.hide_info()
|
||||
tracker.nav.show()
|
||||
tracker.nav.hide_info()
|
||||
|
||||
# XXX: would love to not have to do this separate from edit
|
||||
# fields (which are done in an async loop - see below)
|
||||
|
@ -754,13 +847,13 @@ async def open_order_mode(
|
|||
)
|
||||
|
||||
# make fill bar and positioning snapshot
|
||||
order_pane.on_ui_settings_change('limit', tracker.alloc.limit())
|
||||
order_pane.update_status_ui(pp=tracker)
|
||||
order_pane.update_status_ui(tracker)
|
||||
|
||||
# TODO: create a mode "manager" of sorts?
|
||||
# -> probably just call it "UxModes" err sumthin?
|
||||
# so that view handlers can access it
|
||||
view.order_mode = mode
|
||||
chart.view.order_mode = mode
|
||||
hist_chart.view.order_mode = mode
|
||||
|
||||
order_pane.on_ui_settings_change('account', pp_account)
|
||||
mode.pane.display_pnl(mode.current_pp)
|
||||
|
@ -785,6 +878,7 @@ async def open_order_mode(
|
|||
|
||||
# ``ChartView`` input async handler startup
|
||||
chart.view.open_async_input_handler(),
|
||||
hist_chart.view.open_async_input_handler(),
|
||||
|
||||
# pp pane kb inputs
|
||||
open_form_input_handling(
|
||||
|
|
Loading…
Reference in New Issue