Merge pull request #146 from pikers/basic_alerts

Basic alerts
readme_bumpz
goodboy 2021-01-15 21:09:33 -05:00 committed by GitHub
commit 5a4f5c35ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1196 additions and 137 deletions

459
piker/_ems.py 100644
View File

@ -0,0 +1,459 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
In suit parlance: "Execution management systems"
"""
import time
from dataclasses import dataclass, field
from typing import (
AsyncIterator, Dict, Callable, Tuple,
)
import trio
from trio_typing import TaskStatus
import tractor
from . import data
from .log import get_logger
from .data._source import Symbol
log = get_logger(__name__)
# setup local ui event streaming channels for request/resp
# streamging with EMS daemon
_to_ems, _from_order_book = trio.open_memory_channel(100)
@dataclass
class OrderBook:
"""Buy-side (client-side ?) order book ctl and tracking.
A style similar to "model-view" is used here where this api is
provided as a supervised control for an EMS actor which does all the
hard/fast work of talking to brokers/exchanges to conduct
executions.
Currently, mostly for keeping local state to match the EMS and use
received events to trigger graphics updates.
"""
_sent_orders: Dict[str, dict] = field(default_factory=dict)
_confirmed_orders: Dict[str, dict] = field(default_factory=dict)
_to_ems: trio.abc.SendChannel = _to_ems
_from_order_book: trio.abc.ReceiveChannel = _from_order_book
def on_fill(self, uuid: str) -> None:
cmd = self._sent_orders[uuid]
log.info(f"Order executed: {cmd}")
self._confirmed_orders[uuid] = cmd
def alert(
self,
uuid: str,
symbol: 'Symbol',
price: float
) -> str:
cmd = {
'msg': 'alert',
'price': price,
'symbol': symbol.key,
'brokers': symbol.brokers,
'oid': uuid,
}
self._sent_orders[uuid] = cmd
self._to_ems.send_nowait(cmd)
def buy(self, price: float) -> str:
...
def sell(self, price: float) -> str:
...
def cancel(self, uuid: str) -> bool:
"""Cancel an order (or alert) from the EMS.
"""
cmd = {
'msg': 'cancel',
'oid': uuid,
}
self._sent_orders[uuid] = cmd
self._to_ems.send_nowait(cmd)
# higher level operations
async def transmit_to_broker(self, price: float) -> str:
...
async def modify(self, oid: str, price) -> bool:
...
_orders: OrderBook = None
def get_orders(emsd_uid: Tuple[str, str] = None) -> OrderBook:
if emsd_uid is not None:
# TODO: read in target emsd's active book on startup
pass
global _orders
if _orders is None:
_orders = OrderBook()
return _orders
# TODO: make this a ``tractor.msg.pub``
async def send_order_cmds():
"""Order streaming task: deliver orders transmitted from UI
to downstream consumers.
This is run in the UI actor (usually the one running Qt).
The UI simply delivers order messages to the above ``_to_ems``
send channel (from sync code using ``.send_nowait()``), these values
are pulled from the channel here and send to any consumer(s).
This effectively makes order messages look like they're being
"pushed" from the parent to the EMS actor.
"""
global _from_order_book
async for cmd in _from_order_book:
# send msg over IPC / wire
log.info(f'sending order cmd: {cmd}')
yield cmd
# TODO: numba all of this
def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]:
"""Create a predicate for given ``exec_price`` based on last known
price, ``known_last``.
This is an automatic alert level thunk generator based on where the
current last known value is and where the specified value of
interest is; pick an appropriate comparison operator based on
avoiding the case where the a predicate returns true immediately.
"""
# str compares:
# https://stackoverflow.com/questions/46708708/compare-strings-in-numba-compiled-function
if trigger_price >= known_last:
def check_gt(price: float) -> bool:
return price >= trigger_price
return check_gt, 'down'
elif trigger_price <= known_last:
def check_lt(price: float) -> bool:
return price <= trigger_price
return check_lt, 'up'
@dataclass
class _ExecBook:
"""EMS-side execution book.
Contains conditions for executions (aka "orders").
A singleton instance is created per EMS actor (for now).
"""
# levels which have an executable action (eg. alert, order, signal)
orders: Dict[
Tuple[str, str],
Dict[
str, # uuid
Tuple[
Callable[[float], bool], # predicate
str, # name
dict, # cmd / msg type
]
]
] = field(default_factory=dict)
# tracks most recent values per symbol each from data feed
lasts: Dict[
Tuple[str, str],
float
] = field(default_factory=dict)
_book = None
def get_book() -> _ExecBook:
global _book
if _book is None:
_book = _ExecBook()
return _book
async def exec_orders(
ctx: tractor.Context,
broker: str,
symbol: str,
exec_price: float,
task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED,
) -> AsyncIterator[dict]:
async with data.open_feed(
broker,
[symbol],
loglevel='info',
) as feed:
# TODO: get initial price
first_quote = await feed.receive()
book = get_book()
book.lasts[(broker, symbol)] = first_quote[symbol]['last']
task_status.started(first_quote)
# shield this field so the remote brokerd does not get cancelled
stream = feed.stream
with stream.shield():
async for quotes in stream:
##############################
# begin price actions sequence
# XXX: optimize this for speed
##############################
start = time.time()
for sym, quote in quotes.items():
execs = book.orders.get((broker, sym))
for tick in quote.get('ticks', ()):
price = tick.get('price')
if price < 0:
# lel, fuck you ib
continue
# update to keep new cmds informed
book.lasts[(broker, symbol)] = price
if not execs:
continue
for oid, (pred, name, cmd) in tuple(execs.items()):
# push trigger msg back to parent as an "alert"
# (mocking for eg. a "fill")
if pred(price):
cmd['name'] = name
cmd['index'] = feed.shm._last.value - 1
# current shm array index
cmd['trigger_price'] = price
cmd['msg'] = 'executed'
await ctx.send_yield(cmd)
print(
f"GOT ALERT FOR {exec_price} @ \n{tick}\n")
print(f'removing pred for {oid}')
pred, name, cmd = execs.pop(oid)
print(f'execs are {execs}')
print(f'execs scan took: {time.time() - start}')
# feed teardown
@tractor.stream
async def stream_and_route(ctx, ui_name):
"""Order router (sub)actor entrypoint.
This is the daemon (child) side routine which starts an EMS
runtime per broker/feed and and begins streaming back alerts
from executions back to subscribers.
"""
actor = tractor.current_actor()
book = get_book()
_active_execs: Dict[str, (str, str)] = {}
# new router entry point
async with tractor.wait_for_actor(ui_name) as portal:
# spawn one task per broker feed
async with trio.open_nursery() as n:
async for cmd in await portal.run(send_order_cmds):
log.info(f'{cmd} received in {actor.uid}')
msg = cmd['msg']
oid = cmd['oid']
if msg == 'cancel':
# destroy exec
pred, name, cmd = book.orders[_active_execs[oid]].pop(oid)
# ack-cmdond that order is live
await ctx.send_yield({'msg': 'cancelled', 'oid': oid})
continue
elif msg in ('alert', 'buy', 'sell',):
trigger_price = cmd['price']
sym = cmd['symbol']
brokers = cmd['brokers']
broker = brokers[0]
last = book.lasts.get((broker, sym))
if last is None: # spawn new brokerd feed task
quote = await n.start(
exec_orders,
ctx,
# TODO: eventually support N-brokers
broker,
sym,
trigger_price,
)
print(f"received first quote {quote}")
last = book.lasts[(broker, sym)]
print(f'Known last is {last}')
# Auto-gen scanner predicate:
# we automatically figure out what the alert check
# condition should be based on the current first
# price received from the feed, instead of being
# like every other shitty tina platform that makes
# the user choose the predicate operator.
pred, name = mk_check(trigger_price, last)
# create list of executions on first entry
book.orders.setdefault(
(broker, sym), {})[oid] = (pred, name, cmd)
# reverse lookup for cancellations
_active_execs[oid] = (broker, sym)
# ack-cmdond that order is live
await ctx.send_yield({
'msg': 'active',
'oid': oid
})
# continue and wait on next order cmd
async def spawn_router_stream_alerts(
order_mode,
symbol: Symbol,
# lines: 'LinesEditor',
task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED,
) -> None:
"""Spawn an EMS daemon and begin sending orders and receiving
alerts.
"""
actor = tractor.current_actor()
subactor_name = 'emsd'
# TODO: add ``maybe_spawn_emsd()`` for this
async with tractor.open_nursery() as n:
portal = await n.start_actor(
subactor_name,
enable_modules=[__name__],
)
stream = await portal.run(
stream_and_route,
ui_name=actor.name
)
async with tractor.wait_for_actor(subactor_name):
# let parent task continue
task_status.started(_to_ems)
# begin the trigger-alert stream
# this is where we receive **back** messages
# about executions **from** the EMS actor
async for msg in stream:
# delete the line from view
oid = msg['oid']
resp = msg['msg']
if resp in ('active',):
print(f"order accepted: {msg}")
# show line label once order is live
order_mode.lines.commit_line(oid)
continue
elif resp in ('cancelled',):
# delete level from view
order_mode.lines.remove_line(uuid=oid)
print(f'deleting line with oid: {oid}')
elif resp in ('executed',):
order_mode.lines.remove_line(uuid=oid)
print(f'deleting line with oid: {oid}')
order_mode.arrows.add(
oid,
msg['index'],
msg['price'],
pointing='up' if msg['name'] == 'up' else 'down'
)
# DESKTOP NOTIFICATIONS
#
# TODO: this in another task?
# not sure if this will ever be a bottleneck,
# we probably could do graphics stuff first tho?
# XXX: linux only for now
result = await trio.run_process(
[
'notify-send',
'-u', 'normal',
'-t', '10000',
'piker',
f'alert: {msg}',
],
)
log.runtime(result)

View File

@ -49,9 +49,11 @@ from ..data import (
attach_shm_array,
# get_shm_token,
subscribe_ohlc_for_increment,
_buffer,
)
from ..data._source import from_df
from ._util import SymbolNotFound
from .._async_utils import maybe_with_if
log = get_logger(__name__)
@ -355,11 +357,12 @@ class Client:
symbol: str,
to_trio,
opts: Tuple[int] = ('375', '233',),
contract: Optional[Contract] = None,
# opts: Tuple[int] = ('459',),
) -> None:
"""Stream a ticker using the std L1 api.
"""
contract = await self.find_contract(symbol)
contract = contract or (await self.find_contract(symbol))
ticker: Ticker = self.ib.reqMktData(contract, ','.join(opts))
# define a simple queue push routine that streams quote packets
@ -386,6 +389,20 @@ class Client:
# let the engine run and stream
await self.ib.disconnectedEvent
async def get_quote(
self,
symbol: str,
) -> Ticker:
"""Return a single quote for symbol.
"""
contract = await self.find_contract(symbol)
ticker: Ticker = self.ib.reqMktData(
contract,
snapshot=True,
)
return contract, (await ticker.updateEvent)
# default config ports
_tws_port: int = 7497
@ -604,16 +621,21 @@ _local_buffer_writers = {}
@asynccontextmanager
async def activate_writer(key: str) -> (bool, trio.Nursery):
"""Mark the current actor with module var determining
whether an existing shm writer task is already active.
This avoids more then one writer resulting in data
clobbering.
"""
global _local_buffer_writers
try:
writer_already_exists = _local_buffer_writers.get(key, False)
assert not _local_buffer_writers.get(key, False)
if not writer_already_exists:
_local_buffer_writers[key] = True
_local_buffer_writers[key] = True
async with trio.open_nursery() as n:
yield writer_already_exists, n
else:
yield writer_already_exists, None
async with trio.open_nursery() as n:
yield n
finally:
_local_buffer_writers.pop(key, None)
@ -622,7 +644,7 @@ async def fill_bars(
sym: str,
first_bars: list,
shm: 'ShmArray', # type: ignore # noqa
# count: int = 20, # NOTE: any more and we'll overrun the underlying buffer
# count: int = 20, # NOTE: any more and we'll overrun underlying buffer
count: int = 2, # NOTE: any more and we'll overrun the underlying buffer
) -> None:
"""Fill historical bars into shared mem / storage afap.
@ -692,8 +714,14 @@ async def stream_quotes(
# TODO: support multiple subscriptions
sym = symbols[0]
contract, first_ticker = await _trio_run_client_method(
method='get_quote',
symbol=sym,
)
stream = await _trio_run_client_method(
method='stream_ticker',
contract=contract, # small speedup
symbol=sym,
)
@ -701,14 +729,17 @@ async def stream_quotes(
# check if a writer already is alive in a streaming task,
# otherwise start one and mark it as now existing
async with activate_writer(
shm_token['shm_name']
) as (writer_already_exists, ln):
# maybe load historical ohlcv in to shared mem
# check if shm has already been created by previous
# feed initialization
key = shm_token['shm_name']
writer_already_exists = _local_buffer_writers.get(key, False)
# maybe load historical ohlcv in to shared mem
# check if shm has already been created by previous
# feed initialization
async with trio.open_nursery() as ln:
if not writer_already_exists:
_local_buffer_writers[key] = True
shm = attach_shm_array(
token=shm_token,
@ -744,12 +775,33 @@ async def stream_quotes(
subscribe_ohlc_for_increment(shm, delay_s)
# pass back token, and bool, signalling if we're the writer
# and that history has been written
await ctx.send_yield((shm_token, not writer_already_exists))
# first quote can be ignored as a 2nd with newer data is sent?
first_ticker = await stream.__anext__()
# check for special contract types
if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex):
suffix = 'exchange'
# should be real volume for this contract
calc_price = False
else:
# commodities and forex don't have an exchange name and
# no real volume so we have to calculate the price
suffix = 'secType'
calc_price = True
ticker = first_ticker
quote = normalize(first_ticker)
# pass first quote asap
quote = normalize(first_ticker, calc_price=calc_price)
con = quote['contract']
topic = '.'.join((con['symbol'], con[suffix])).lower()
quote['symbol'] = topic
first_quote = {topic: quote}
# yield first quote asap
await ctx.send_yield(first_quote)
# ticker.ticks = []
# ugh, clear ticks since we've consumed them
# (ahem, ib_insync is stateful trash)
@ -762,39 +814,31 @@ async def stream_quotes(
calc_price = False # should be real volume for contract
async for ticker in stream:
# wait for real volume on feed (trading might be closed)
async for ticker in stream:
# for a real volume contract we rait for the first
# "real" trade to take place
if not calc_price and not ticker.rtTime:
# spin consuming tickers until we get a real market datum
if not ticker.rtTime:
log.debug(f"New unsent ticker: {ticker}")
continue
else:
log.debug("Received first real volume tick")
# ugh, clear ticks since we've consumed them
# (ahem, ib_insync is truly stateful trash)
ticker.ticks = []
log.debug(f"New unsent ticker: {ticker}")
continue
else:
log.debug("Received first real volume tick")
# ugh, clear ticks since we've consumed them
# (ahem, ib_insync is truly stateful trash)
ticker.ticks = []
# XXX: this works because we don't use
# ``aclosing()`` above?
break
else:
# commodities don't have an exchange name for some reason?
suffix = 'secType'
calc_price = True
ticker = first_ticker
# tell incrementer task it can start
_buffer.shm_incrementing(key).set()
quote = normalize(ticker, calc_price=calc_price)
con = quote['contract']
topic = '.'.join((con['symbol'], con[suffix])).lower()
quote['symbol'] = topic
first_quote = {topic: quote}
ticker.ticks = []
# yield first quote asap
await ctx.send_yield(first_quote)
# XXX: this works because we don't use
# ``aclosing()`` above?
break
# real-time stream
async for ticker in stream:
# print(ticker.vwap)
quote = normalize(
ticker,

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@ -34,6 +34,7 @@ import tractor
from ._util import resproc, SymbolNotFound, BrokerError
from ..log import get_logger, get_console_log
from ..data import (
_buffer,
# iterticks,
attach_shm_array,
get_shm_token,
@ -266,6 +267,7 @@ def normalize(
quote['broker_ts'] = quote['time']
quote['brokerd_ts'] = time.time()
quote['symbol'] = quote['pair'] = quote['pair'].replace('/', '')
quote['last'] = quote['close']
# seriously eh? what's with this non-symmetry everywhere
# in subscription systems...
@ -381,6 +383,9 @@ async def stream_quotes(
# packetize as {topic: quote}
yield {topic: quote}
# tell incrementer task it can start
_buffer.shm_incrementing(shm_token['shm_name']).set()
# keep start of last interval for volume tracking
last_interval_start = ohlc_last.etime

View File

@ -75,10 +75,12 @@ def get_ingestormod(name: str) -> ModuleType:
return module
# capable rpc modules
_data_mods = [
'piker.brokers.core',
'piker.brokers.data',
'piker.data',
'piker.data._buffer',
]
@ -104,10 +106,13 @@ async def maybe_spawn_brokerd(
brokermod = get_brokermod(brokername)
dname = f'brokerd.{brokername}'
async with tractor.find_actor(dname) as portal:
# WTF: why doesn't this work?
if portal is not None:
yield portal
else:
else: # no daemon has been spawned yet
log.info(f"Spawning {brokername} broker daemon")
tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
async with tractor.open_nursery() as nursery:
@ -115,7 +120,7 @@ async def maybe_spawn_brokerd(
# spawn new daemon
portal = await nursery.start_actor(
dname,
rpc_module_paths=_data_mods + [brokermod.__name__],
enable_modules=_data_mods + [brokermod.__name__],
loglevel=loglevel,
**tractor_kwargs
)
@ -140,7 +145,7 @@ class Feed:
stream: AsyncIterator[Dict[str, Any]]
shm: ShmArray
# ticks: ShmArray
_broker_portal: tractor._portal.Portal
_brokerd_portal: tractor._portal.Portal
_index_stream: Optional[AsyncIterator[Dict[str, Any]]] = None
async def receive(self) -> dict:
@ -151,9 +156,8 @@ class Feed:
# XXX: this should be singleton on a host,
# a lone broker-daemon per provider should be
# created for all practical purposes
self._index_stream = await self._broker_portal.run(
'piker.data',
'increment_ohlc_buffer',
self._index_stream = await self._brokerd_portal.run(
increment_ohlc_buffer,
shm_token=self.shm.token,
topics=['index'],
)
@ -200,8 +204,7 @@ async def open_feed(
loglevel=loglevel,
) as portal:
stream = await portal.run(
mod.__name__,
'stream_quotes',
mod.stream_quotes,
symbols=symbols,
shm_token=shm.token,
@ -225,5 +228,5 @@ async def open_feed(
name=name,
stream=stream,
shm=shm,
_broker_portal=portal,
_brokerd_portal=portal,
)

View File

@ -27,6 +27,12 @@ from ._sharedmem import ShmArray
_shms: Dict[int, ShmArray] = {}
_start_increment: Dict[str, trio.Event] = {}
def shm_incrementing(shm_token_name: str) -> trio.Event:
global _start_increment
return _start_increment.setdefault(shm_token_name, trio.Event())
@tractor.msg.pub
@ -47,6 +53,10 @@ async def increment_ohlc_buffer(
Note that if **no** actor has initiated this task then **none** of
the underlying buffers will actually be incremented.
"""
# wait for brokerd to signal we should start sampling
await shm_incrementing(shm_token['shm_name']).wait()
# TODO: right now we'll spin printing bars if the last time stamp is
# before a large period of no market activity. Likely the best way
# to solve this is to make this task aware of the instrument's

View File

@ -17,6 +17,7 @@
"""
numpy data source coversion helpers.
"""
from typing import List
import decimal
from dataclasses import dataclass
@ -81,6 +82,7 @@ class Symbol:
"""
key: str = ''
brokers: List[str] = None
min_tick: float = 0.01
contract: str = ''

View File

@ -218,13 +218,13 @@ class AxisLabel(pg.GraphicsObject):
p.drawRect(self.rect)
def boundingRect(self): # noqa
# if self.label_str:
# self._size_br_from_str(self.label_str)
# return self.rect
if self.label_str:
self._size_br_from_str(self.label_str)
return self.rect
# return QtCore.QRectF()
return QtCore.QRectF()
return self.rect or QtCore.QRectF()
# return self.rect or QtCore.QRectF()
def _size_br_from_str(self, value: str) -> None:
"""Do our best to render the bounding rect to a set margin

View File

@ -16,6 +16,7 @@
"""
High level Qt chart widgets.
"""
from typing import Tuple, Dict, Any, Optional, Callable
from functools import partial
@ -31,7 +32,7 @@ from ._axes import (
PriceAxis,
)
from ._graphics._cursor import (
CrossHair,
Cursor,
ContentsLabel,
)
from ._graphics._lines import (
@ -56,8 +57,9 @@ from .. import data
from ..data import maybe_open_shm_array
from ..log import get_logger
from ._exec import run_qtractor, current_screen
from ._interaction import ChartView
from ._interaction import ChartView, open_order_mode
from .. import fsp
from .._ems import spawn_router_stream_alerts
log = get_logger(__name__)
@ -123,10 +125,9 @@ class ChartSpace(QtGui.QWidget):
# def init_strategy_ui(self):
# self.strategy_box = StrategyBoxWidget(self)
# self.toolbar_layout.addWidget(self.strategy_box)
def load_symbol(
self,
symbol: str,
symbol: Symbol,
data: np.ndarray,
ohlc: bool = True,
) -> None:
@ -146,16 +147,15 @@ class ChartSpace(QtGui.QWidget):
# self.symbol_label.setText(f'/`{symbol}`')
linkedcharts = self._chart_cache.setdefault(
symbol,
LinkedSplitCharts()
symbol.key,
LinkedSplitCharts(symbol)
)
s = Symbol(key=symbol)
# remove any existing plots
if not self.v_layout.isEmpty():
self.v_layout.removeWidget(linkedcharts)
main_chart = linkedcharts.plot_ohlc_main(s, data)
main_chart = linkedcharts.plot_ohlc_main(symbol, data)
self.v_layout.addWidget(linkedcharts)
@ -181,10 +181,13 @@ class LinkedSplitCharts(QtGui.QWidget):
zoomIsDisabled = QtCore.pyqtSignal(bool)
def __init__(self):
def __init__(
self,
symbol: Symbol,
) -> None:
super().__init__()
self.signals_visible: bool = False
self._ch: CrossHair = None # crosshair graphics
self._cursor: Cursor = None # crosshair graphics
self.chart: ChartPlotWidget = None # main (ohlc) chart
self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {}
@ -207,6 +210,13 @@ class LinkedSplitCharts(QtGui.QWidget):
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.splitter)
# state tracker?
self._symbol: Symbol = symbol
@property
def symbol(self) -> Symbol:
return self._symbol
def set_split_sizes(
self,
prop: float = 0.28 # proportion allocated to consumer subcharts
@ -232,7 +242,7 @@ class LinkedSplitCharts(QtGui.QWidget):
self.digits = symbol.digits()
# add crosshairs
self._ch = CrossHair(
self._cursor = Cursor(
linkedsplitcharts=self,
digits=self.digits
)
@ -244,7 +254,7 @@ class LinkedSplitCharts(QtGui.QWidget):
_is_main=True,
)
# add crosshair graphic
self.chart.addItem(self._ch)
self.chart.addItem(self._cursor)
# axis placement
if _xaxis_at == 'bottom':
@ -291,18 +301,19 @@ class LinkedSplitCharts(QtGui.QWidget):
array=array,
parent=self.splitter,
linked_charts=self,
axisItems={
'bottom': xaxis,
'right': PriceAxis(linked_charts=self)
},
viewBox=cv,
cursor=self._ch,
cursor=self._cursor,
**cpw_kwargs,
)
# give viewbox a reference to primary chart
# allowing for kb controls and interactions
# (see our custom view in `._interactions.py`)
# give viewbox as reference to chart
# allowing for kb controls and interactions on **this** widget
# (see our custom view mode in `._interactions.py`)
cv.chart = cpw
cpw.plotItem.vb.linked_charts = self
@ -315,7 +326,7 @@ class LinkedSplitCharts(QtGui.QWidget):
cpw.setXLink(self.chart)
# add to cross-hair's known plots
self._ch.add_plot(cpw)
self._cursor.add_plot(cpw)
# draw curve graphics
if style == 'bar':
@ -365,8 +376,9 @@ class ChartPlotWidget(pg.PlotWidget):
# the data view we generate graphics from
name: str,
array: np.ndarray,
linked_charts: LinkedSplitCharts,
static_yrange: Optional[Tuple[float, float]] = None,
cursor: Optional[CrossHair] = None,
cursor: Optional[Cursor] = None,
**kwargs,
):
"""Configure chart display settings.
@ -379,8 +391,8 @@ class ChartPlotWidget(pg.PlotWidget):
useOpenGL=True,
**kwargs
)
self.name = name
self._lc = linked_charts
# self.setViewportMargins(0, 0, 0, 0)
self._ohlc = array # readonly view of ohlc data
@ -407,10 +419,6 @@ class ChartPlotWidget(pg.PlotWidget):
self.default_view()
# TODO: stick in config
# use cross-hair for cursor?
# self.setCursor(QtCore.Qt.CrossCursor)
# Assign callback for rescaling y-axis automatically
# based on data contents and ``ViewBox`` state.
self.sigXRangeChanged.connect(self._set_yrange)
@ -844,6 +852,8 @@ async def _async_main(
# historical data fetch
brokermod = brokers.get_brokermod(brokername)
symbol = Symbol(sym, [brokername])
async with data.open_feed(
brokername,
[sym],
@ -854,8 +864,7 @@ async def _async_main(
bars = ohlcv.array
# load in symbol's ohlc data
# await tractor.breakpoint()
linked_charts, chart = chart_app.load_symbol(sym, bars)
linked_charts, chart = chart_app.load_symbol(symbol, bars)
# plot historical vwap if available
wap_in_history = False
@ -870,12 +879,13 @@ async def _async_main(
add_label=False,
)
# size view to data once at outset
chart._set_yrange()
# TODO: a data view api that makes this less shit
chart._shm = ohlcv
# eventually we'll support some kind of n-compose syntax
# TODO: eventually we'll support some kind of n-compose syntax
fsp_conf = {
'rsi': {
'period': 14,
@ -887,7 +897,8 @@ async def _async_main(
}
# make sure that the instrument supports volume history
# (sometimes this is not the case for some commodities and derivatives)
# (sometimes this is not the case for some commodities and
# derivatives)
volm = ohlcv.array['volume']
if (
np.all(np.isin(volm, -1)) or
@ -928,6 +939,7 @@ async def _async_main(
# wait for a first quote before we start any update tasks
quote = await feed.receive()
log.info(f'Received first quote {quote}')
n.start_soon(
@ -938,8 +950,26 @@ async def _async_main(
linked_charts
)
# probably where we'll eventually start the user input loop
await trio.sleep_forever()
async with open_order_mode(
chart,
) as order_mode:
# TODO: this should probably be implicitly spawned
# inside the above mngr?
# spawn EMS actor-service
to_ems_chan = await n.start(
spawn_router_stream_alerts,
order_mode,
symbol,
)
# wait for router to come up before setting
# enabling send channel on chart
linked_charts._to_ems = to_ems_chan
# probably where we'll eventually start the user input loop
await trio.sleep_forever()
async def chart_from_quotes(
@ -999,7 +1029,7 @@ async def chart_from_quotes(
chart,
# determine precision/decimal lengths
digits=max(float_digits(last), 2),
size_digits=min(float_digits(volume), 3)
size_digits=min(float_digits(last), 3)
)
# TODO:

View File

@ -17,7 +17,7 @@
Mouse interaction graphics
"""
from typing import Optional, Tuple
from typing import Optional, Tuple, Set, Dict
import inspect
import numpy as np
@ -31,6 +31,10 @@ from .._style import (
_font,
)
from .._axes import YAxisLabel, XAxisLabel
from ...log import get_logger
log = get_logger(__name__)
# XXX: these settings seem to result in really decent mouse scroll
# latency (in terms of perceived lag in cross hair) so really be sure
@ -194,7 +198,7 @@ class ContentsLabel(pg.LabelItem):
self.setText(f"{name}: {data:.2f}")
class CrossHair(pg.GraphicsObject):
class Cursor(pg.GraphicsObject):
def __init__(
self,
@ -213,11 +217,21 @@ class CrossHair(pg.GraphicsObject):
style=QtCore.Qt.DashLine,
)
self.lsc = linkedsplitcharts
self.graphics = {}
self.plots = []
self.graphics: Dict[str, pg.GraphicsObject] = {}
self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None
self.digits = digits
self._lastx = None
self.digits: int = digits
self._datum_xy: Tuple[int, float] = (0, 0)
self._hovered: Set[pg.GraphicsObject] = set()
self._trackers: Set[pg.GraphicsObject] = set()
def add_hovered(
self,
item: pg.GraphicsObject,
) -> None:
assert getattr(item, 'delete'), f"{item} must define a ``.delete()``"
self._hovered.add(item)
def add_plot(
self,
@ -289,12 +303,17 @@ class CrossHair(pg.GraphicsObject):
) -> LineDot:
# if this plot contains curves add line dot "cursors" to denote
# the current sample under the mouse
cursor = LineDot(curve, index=plot._ohlc[-1]['index'], plot=plot)
cursor = LineDot(
curve,
index=plot._ohlc[-1]['index'],
plot=plot
)
plot.addItem(cursor)
self.graphics[plot].setdefault('cursors', []).append(cursor)
return cursor
def mouseAction(self, action, plot): # noqa
log.debug(f"{(action, plot.name)}")
if action == 'Enter':
self.active_plot = plot
@ -303,7 +322,6 @@ class CrossHair(pg.GraphicsObject):
self.graphics[plot]['yl'].show()
else: # Leave
self.active_plot = None
# hide horiz line and y-label
self.graphics[plot]['hl'].hide()
@ -332,15 +350,21 @@ class CrossHair(pg.GraphicsObject):
# update y-range items
self.graphics[plot]['hl'].setY(y)
self.graphics[self.active_plot]['yl'].update_label(
abs_pos=pos, value=y
)
# Update x if cursor changed after discretization calc
# (this saves draw cycles on small mouse moves)
lastx = self._lastx
lastx, lasty = self._datum_xy
ix = round(x) # since bars are centered around index
# update all trackers
for item in self._trackers:
# print(f'setting {item} with {(ix, y)}')
item.on_tracked_source(ix, y)
if ix != lastx:
for plot, opts in self.graphics.items():
@ -351,7 +375,6 @@ class CrossHair(pg.GraphicsObject):
plot.update_contents_labels(ix)
# update all subscribed curve dots
# first = plot._ohlc[0]['index']
for cursor in opts.get('cursors', ()):
cursor.setIndex(ix)
@ -367,7 +390,7 @@ class CrossHair(pg.GraphicsObject):
value=x,
)
self._lastx = ix
self._datum_xy = ix, y
def boundingRect(self):
try:

View File

@ -123,6 +123,18 @@ class FastAppendCurve(pg.PlotCurveItem):
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
def boundingRect(self):
if self.path is None:
return QtGui.QPainterPath().boundingRect()
else:
# dynamically override this method after initial
# path is created to avoid requiring the above None check
self.boundingRect = self._br
return self._br()
def _br(self):
"""Post init ``.boundingRect()```.
"""
hb = self.path.controlPointRect()
hb_size = hb.size()
# print(f'hb_size: {hb_size}')

View File

@ -18,6 +18,7 @@
Lines for orders, alerts, L2.
"""
from dataclasses import dataclass
from typing import Tuple
import pyqtgraph as pg
@ -33,8 +34,6 @@ from .._axes import YSticky
class LevelLabel(YSticky):
line_pen = pg.mkPen(hcolor('bracket'))
_w_margin = 4
_h_margin = 3
level: float = 0
@ -43,12 +42,16 @@ class LevelLabel(YSticky):
self,
chart,
*args,
color: str = 'bracket',
orient_v: str = 'bottom',
orient_h: str = 'left',
**kwargs
) -> None:
super().__init__(chart, *args, **kwargs)
# TODO: this is kinda cludgy
self._pen = self.pen = pg.mkPen(hcolor(color))
# orientation around axis options
self._orient_v = orient_v
self._orient_h = orient_h
@ -75,7 +78,7 @@ class LevelLabel(YSticky):
br = self.boundingRect()
h, w = br.height(), br.width()
# this triggers ``.pain()`` implicitly?
# this triggers ``.paint()`` implicitly?
self.setPos(QPointF(
self._h_shift * w - offset,
abs_pos.y() - (self._v_shift * h) - offset
@ -85,10 +88,11 @@ class LevelLabel(YSticky):
self.level = level
def set_label_str(self, level: float):
# this is read inside ``.paint()``
# self.label_str = '{size} x {level:.{digits}f}'.format(
self.label_str = '{level:.{digits}f}'.format(
# size=self._size,
# this is read inside ``.paint()``
self.label_str = '{level:.{digits}f}'.format(
digits=self.digits,
level=level
).replace(',', ' ')
@ -101,7 +105,7 @@ class LevelLabel(YSticky):
p: QtGui.QPainter,
rect: QtCore.QRectF
) -> None:
p.setPen(self.line_pen)
p.setPen(self._pen)
if self._orient_v == 'bottom':
lp, rp = rect.topLeft(), rect.topRight()
@ -111,6 +115,14 @@ class LevelLabel(YSticky):
p.drawLine(lp.x(), lp.y(), rp.x(), rp.y())
def highlight(self, pen) -> None:
self._pen = pen
self.update()
def unhighlight(self):
self._pen = self.pen
self.update()
class L1Label(LevelLabel):
@ -145,7 +157,7 @@ class L1Labels:
self,
chart: 'ChartPlotWidget', # noqa
digits: int = 2,
size_digits: int = 0,
size_digits: int = 3,
font_size_inches: float = _down_2_font_inches_we_like,
) -> None:
@ -181,29 +193,137 @@ class L1Labels:
class LevelLine(pg.InfiniteLine):
# TODO: fill in these slots for orders
# .sigPositionChangeFinished.emit(self)
def __init__(
self,
chart: 'ChartPlotWidget', # type: ignore # noqa
label: LevelLabel,
highlight_color: str = 'default_light',
hl_on_hover: bool = True,
**kwargs,
) -> None:
self.label = label
super().__init__(**kwargs)
self.label = label
self.sigPositionChanged.connect(self.set_level)
self._chart = chart
self._hoh = hl_on_hover
# use slightly thicker highlight
pen = pg.mkPen(hcolor(highlight_color))
pen.setWidth(2)
self.setHoverPen(pen)
self._track_cursor: bool = False
def set_level(self, value: float) -> None:
self.label.update_from_data(0, self.value())
def on_tracked_source(
self,
x: int,
y: float
) -> None:
self.movable = True
self.setPos(y) # implictly calls ``.set_level()``
self.update()
def setMouseHover(self, hover: bool) -> None:
"""Mouse hover callback.
"""
# XXX: currently we'll just return if _hoh is False
if self.mouseHovering == hover or not self._hoh:
return
self.mouseHovering = hover
chart = self._chart
if hover:
self.currentPen = self.hoverPen
self.label.highlight(self.hoverPen)
# add us to cursor state
chart._cursor.add_hovered(self)
# # hide y-crosshair
# chart._cursor.graphics[chart]['hl'].hide()
else:
self.currentPen = self.pen
self.label.unhighlight()
chart._cursor._hovered.remove(self)
# highlight any attached label
# self.setCursor(QtCore.Qt.OpenHandCursor)
# self.setCursor(QtCore.Qt.DragMoveCursor)
self.update()
def mouseDragEvent(self, ev):
chart = self._chart
# hide y-crosshair
chart._cursor.graphics[chart]['hl'].hide()
# highlight
self.currentPen = self.hoverPen
self.label.highlight(self.hoverPen)
# normal tracking behavior
super().mouseDragEvent(ev)
# This is the final position in the drag
if ev.isFinish():
# show y-crosshair again
chart = self._chart
chart._cursor.graphics[chart]['hl'].show()
def mouseDoubleClickEvent(
self,
ev: QtGui.QMouseEvent,
) -> None:
print(f'double click {ev}')
# def mouseMoved(
# self,
# ev: Tuple[QtGui.QMouseEvent],
# ) -> None:
# pos = evt[0]
# print(pos)
def delete(self) -> None:
"""Remove this line from containing chart/view/scene.
"""
scene = self.scene()
if scene:
# self.label.parent.scene().removeItem(self.label)
scene.removeItem(self.label)
self._chart.plotItem.removeItem(self)
def level_line(
chart: 'ChartPlogWidget', # noqa
level: float,
digits: int = 1,
color: str = 'default',
# size 4 font on 4k screen scaled down, so small-ish.
font_size_inches: float = _down_2_font_inches_we_like,
show_label: bool = True,
# whether or not the line placed in view should highlight
# when moused over (aka "hovered")
hl_on_hover: bool = True,
**linelabelkwargs
) -> LevelLine:
"""Convenience routine to add a styled horizontal line to a plot.
@ -214,11 +334,13 @@ def level_line(
parent=chart.getAxis('right'),
# TODO: pass this from symbol data
digits=digits,
opacity=1,
opacity=0.666,
font_size_inches=font_size_inches,
color=color,
# TODO: make this take the view's bg pen
bg_color='papas_special',
fg_color='default',
fg_color=color,
**linelabelkwargs
)
label.update_from_data(0, level)
@ -227,12 +349,17 @@ def level_line(
label._size_br_from_str(label.label_str)
line = LevelLine(
chart,
label,
# lookup "highlight" equivalent
highlight_color=color + '_light',
movable=True,
angle=0,
hl_on_hover=hl_on_hover,
)
line.setValue(level)
line.setPen(pg.mkPen(hcolor('default')))
line.setPen(pg.mkPen(hcolor(color)))
# activate/draw label
line.setValue(level)

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@ -17,7 +17,10 @@
"""
UX interaction customs.
"""
from typing import Optional
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Optional, Dict, Callable
import uuid
import pyqtgraph as pg
from pyqtgraph import ViewBox, Point, QtCore, QtGui
@ -26,6 +29,8 @@ import numpy as np
from ..log import get_logger
from ._style import _min_points_to_show, hcolor, _font
from ._graphics._lines import level_line, LevelLine
from .._ems import get_orders, OrderBook
log = get_logger(__name__)
@ -194,13 +199,260 @@ class SelectRect(QtGui.QGraphicsRectItem):
self.hide()
# global store of order-lines graphics
# keyed by uuid4 strs - used to sync draw
# order lines **after** the order is 100%
# active in emsd
_order_lines: Dict[str, LevelLine] = {}
@dataclass
class LineEditor:
view: 'ChartView'
_order_lines: field(default_factory=_order_lines)
chart: 'ChartPlotWidget' = None # type: ignore # noqa
_active_staged_line: LevelLine = None
_stage_line: LevelLine = None
def stage_line(self, color: str = 'alert_yellow') -> LevelLine:
"""Stage a line at the current chart's cursor position
and return it.
"""
chart = self.chart._cursor.active_plot
chart.setCursor(QtCore.Qt.PointingHandCursor)
cursor = chart._cursor
y = chart._cursor._datum_xy[1]
line = self._stage_line
if not line:
# add a "staged" cursor-tracking line to view
# and cash it in a a var
line = level_line(
chart,
level=y,
digits=chart._lc.symbol.digits(),
color=color,
# don't highlight the "staging" line
hl_on_hover=False,
)
self._stage_line = line
else:
# use the existing staged line instead
# of allocating more mem / objects repeatedly
line.setValue(y)
line.show()
line.label.show()
self._active_staged_line = line
# hide crosshair y-line
cursor.graphics[chart]['hl'].hide()
# add line to cursor trackers
cursor._trackers.add(line)
return line
def unstage_line(self) -> LevelLine:
"""Inverse of ``.stage_line()``.
"""
chart = self.chart._cursor.active_plot
chart.setCursor(QtCore.Qt.ArrowCursor)
cursor = chart._cursor
# delete "staged" cursor tracking line from view
line = self._active_staged_line
cursor._trackers.remove(line)
if self._stage_line:
self._stage_line.hide()
self._stage_line.label.hide()
self._active_staged_line = None
# show the crosshair y line
hl = cursor.graphics[chart]['hl']
hl.show()
def create_line(self, uuid: str) -> LevelLine:
line = self._active_staged_line
if not line:
raise RuntimeError("No line commit is currently staged!?")
chart = self.chart._cursor.active_plot
y = chart._cursor._datum_xy[1]
line = level_line(
chart,
level=y,
color='alert_yellow',
digits=chart._lc.symbol.digits(),
show_label=False,
)
# register for later lookup/deletion
self._order_lines[uuid] = line
return line, y
def commit_line(self, uuid: str) -> LevelLine:
"""Commit a "staged line" to view.
Submits the line graphic under the cursor as a (new) permanent
graphic in view.
"""
line = self._order_lines[uuid]
line.oid = uuid
line.label.show()
# TODO: other flashy things to indicate the order is active
log.debug(f'Level active for level: {line.value()}')
return line
def lines_under_cursor(self):
"""Get the line(s) under the cursor position.
"""
# Delete any hoverable under the cursor
return self.chart._cursor._hovered
def remove_line(
self,
line: LevelLine = None,
uuid: str = None,
) -> None:
"""Remove a line by refernce or uuid.
If no lines or ids are provided remove all lines under the
cursor position.
"""
if line:
uuid = line.oid
# try to look up line from our registry
line = self._order_lines.pop(uuid)
# if hovered remove from cursor set
hovered = self.chart._cursor._hovered
if line in hovered:
hovered.remove(line)
line.delete()
@dataclass
class ArrowEditor:
chart: 'ChartPlotWidget' # noqa
_arrows: field(default_factory=dict)
def add(
self,
uid: str,
x: float,
y: float,
color='default',
pointing: str = 'up',
) -> pg.ArrowItem:
"""Add an arrow graphic to view at given (x, y).
"""
yb = pg.mkBrush(hcolor('alert_yellow'))
angle = 90 if pointing == 'up' else -90
arrow = pg.ArrowItem(
angle=angle,
baseAngle=0,
headLen=5,
headWidth=2,
tailLen=None,
brush=yb,
)
arrow.setPos(x, y)
self._arrows[uid] = arrow
# render to view
self.chart.plotItem.addItem(arrow)
return arrow
def remove(self, arrow) -> bool:
self.chart.plotItem.removeItem(arrow)
@dataclass
class OrderMode:
"""Major mode for placing orders on a chart view.
"""
chart: 'ChartPlotWidget' # type: ignore # noqa
book: OrderBook
lines: LineEditor
arrows: ArrowEditor
_arrow_colors = {
'alert': 'alert_yellow',
'buy': 'buy_green',
'sell': 'sell_red',
}
key_map: Dict[str, Callable] = field(default_factory=dict)
def uuid(self) -> str:
return str(uuid.uuid4())
@asynccontextmanager
async def open_order_mode(
chart,
):
# global _order_lines
view = chart._vb
book = get_orders()
lines = LineEditor(view=view, _order_lines=_order_lines, chart=chart)
arrows = ArrowEditor(chart, {})
log.info("Opening order mode")
mode = OrderMode(chart, book, lines, arrows)
view.mode = mode
# # setup local ui event streaming channels for request/resp
# # streamging with EMS daemon
# global _to_ems, _from_order_book
# _to_ems, _from_order_book = trio.open_memory_channel(100)
try:
yield mode
finally:
# XXX special teardown handling like for ex.
# - cancelling orders if needed?
# - closing positions if desired?
# - switching special condition orders to safer/more reliable variants
log.info("Closing order mode")
class ChartView(ViewBox):
"""Price chart view box with interaction behaviors you'd expect from
any interactive platform:
- zoom on mouse scroll that auto fits y-axis
- no vertical scrolling
- zoom to a "fixed point" on the y-axis
- vertical scrolling on y-axis
- zoom on x to most recent in view datum
- zoom on right-click-n-drag to cursor position
"""
def __init__(
self,
@ -215,14 +467,21 @@ class ChartView(ViewBox):
self.addItem(self.select_box, ignoreBounds=True)
self._chart: 'ChartPlotWidget' = None # noqa
# self._lines_editor = LineEditor(view=self, _lines=_lines)
self.mode = None
# kb ctrls processing
self._key_buffer = []
@property
def chart(self) -> 'ChartPlotWidget': # noqa
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
return self._chart
@chart.setter
def chart(self, chart: 'ChartPlotWidget') -> None: # noqa
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa
self._chart = chart
self.select_box.chart = chart
# self._lines_editor.chart = chart
def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling.
@ -286,6 +545,7 @@ class ChartView(ViewBox):
) -> None:
# if axis is specified, event will only affect that axis.
ev.accept() # we accept all buttons
button = ev.button()
pos = ev.pos()
lastPos = ev.lastPos()
@ -299,13 +559,13 @@ class ChartView(ViewBox):
mask[1-axis] = 0.0
# Scale or translate based on mouse button
if ev.button() & (QtCore.Qt.LeftButton | QtCore.Qt.MidButton):
if button & (QtCore.Qt.LeftButton | QtCore.Qt.MidButton):
# zoom only y-axis when click-n-drag on it
# zoom y-axis ONLY when click-n-drag on it
if axis == 1:
# set a static y range special value on chart widget to
# prevent sizing to data in view.
self._chart._static_yrange = 'axis'
self.chart._static_yrange = 'axis'
scale_y = 1.3 ** (dif.y() * -1 / 20)
self.setLimits(yMin=None, yMax=None)
@ -338,6 +598,8 @@ class ChartView(ViewBox):
# update shape of scale box
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
else:
# default bevavior: click to pan view
tr = self.childGroup.transform()
tr = fn.invertQTransform(tr)
tr = tr.map(dif*mask) - tr.map(Point(0, 0))
@ -346,13 +608,16 @@ class ChartView(ViewBox):
y = tr.y() if mask[1] == 1 else None
self._resetTarget()
if x is not None or y is not None:
self.translateBy(x=x, y=y)
self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
elif ev.button() & QtCore.Qt.RightButton:
elif button & QtCore.Qt.RightButton:
# right click zoom to center behaviour
# print "vb.rightDrag"
if self.state['aspectLocked'] is not False:
mask[0] = 0
@ -372,46 +637,119 @@ class ChartView(ViewBox):
self.scaleBy(x=x, y=y, center=center)
self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
def mouseClickEvent(self, ev):
"""Full-click callback.
"""
button = ev.button()
# pos = ev.pos()
if button == QtCore.Qt.RightButton and self.menuEnabled():
ev.accept()
self.raiseContextMenu(ev)
elif button == QtCore.Qt.LeftButton:
ev.accept()
# self._lines_editor.commit_line()
# send order to EMS
# register the "staged" line under the cursor
# to be displayed when above order ack arrives
# (means the line graphic doesn't show on screen until the
# order is live in the emsd).
mode = self.mode
uuid = mode.uuid()
# make line graphic
line, y = mode.lines.create_line(uuid)
# send order cmd to ems
mode.book.alert(
uuid=uuid,
symbol=mode.chart._lc._symbol,
price=y
)
def keyReleaseEvent(self, ev):
# print(f'release: {ev.text().encode()}')
"""
Key release to normally to trigger release of input mode
"""
# TODO: is there a global setting for this?
if ev.isAutoRepeat():
ev.ignore()
return
ev.accept()
if ev.key() == QtCore.Qt.Key_Shift:
text = ev.text()
key = ev.key()
# mods = ev.modifiers()
if key == QtCore.Qt.Key_Shift:
if self.state['mouseMode'] == ViewBox.RectMode:
self.setMouseMode(ViewBox.PanMode)
if text == 'a':
# draw "staged" line under cursor position
self.mode.lines.unstage_line()
def keyPressEvent(self, ev):
"""
This routine should capture key presses in the current view box.
"""
# print(ev.text().encode())
ev.accept()
if ev.modifiers() == QtCore.Qt.ShiftModifier:
"""
# TODO: is there a global setting for this?
if ev.isAutoRepeat():
ev.ignore()
return
ev.accept()
text = ev.text()
key = ev.key()
mods = ev.modifiers()
if mods == QtCore.Qt.ShiftModifier:
if self.state['mouseMode'] == ViewBox.PanMode:
self.setMouseMode(ViewBox.RectMode)
# ctl
if ev.modifiers() == QtCore.Qt.ControlModifier:
# print("CTRL")
if mods == QtCore.Qt.ControlModifier:
# TODO: ctrl-c as cancel?
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
# if ev.text() == 'c':
# self.rbScaleBox.hide()
pass
print(f"CTRL + key:{key} + text:{text}")
# alt
if ev.modifiers() == QtCore.Qt.AltModifier:
if mods == QtCore.Qt.AltModifier:
pass
# print("ALT")
# esc
if ev.key() == QtCore.Qt.Key_Escape:
if key == QtCore.Qt.Key_Escape:
self.select_box.clear()
if ev.text() == 'r':
self._key_buffer.append(text)
# order modes
if text == 'r':
self.chart.default_view()
# Leaving this for light reference purposes
elif text == 'a':
# add a line at the current cursor
self.mode.lines.stage_line()
elif text == 'd':
# delete any lines under the cursor
mode = self.mode
for line in mode.lines.lines_under_cursor():
mode.book.cancel(uuid=line.oid)
# XXX: Leaving this for light reference purposes, there
# seems to be some work to at least gawk at for history mgmt.
# Key presses are used only when mouse mode is RectMode
# The following events are implemented:

View File

@ -166,4 +166,9 @@ def hcolor(name: str) -> str:
'tina_green': '#00cc00',
'tina_red': '#fa0000',
# orders and alerts
'alert_yellow': '#e2d083',
'alert_yellow_light': '#ffe366',
}[name]

View File

@ -150,5 +150,6 @@ def chart(config, symbol, date, rate, test, profile):
tractor_kwargs={
'debug_mode': True,
'loglevel': tractorloglevel,
'rpc_module_paths': ['piker._ems'],
},
)