commit
5a4f5c35ae
|
@ -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)
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = ''
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}')
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -150,5 +150,6 @@ def chart(config, symbol, date, rate, test, profile):
|
|||
tractor_kwargs={
|
||||
'debug_mode': True,
|
||||
'loglevel': tractorloglevel,
|
||||
'rpc_module_paths': ['piker._ems'],
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue