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,
|
attach_shm_array,
|
||||||
# get_shm_token,
|
# get_shm_token,
|
||||||
subscribe_ohlc_for_increment,
|
subscribe_ohlc_for_increment,
|
||||||
|
_buffer,
|
||||||
)
|
)
|
||||||
from ..data._source import from_df
|
from ..data._source import from_df
|
||||||
from ._util import SymbolNotFound
|
from ._util import SymbolNotFound
|
||||||
|
from .._async_utils import maybe_with_if
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
@ -355,11 +357,12 @@ class Client:
|
||||||
symbol: str,
|
symbol: str,
|
||||||
to_trio,
|
to_trio,
|
||||||
opts: Tuple[int] = ('375', '233',),
|
opts: Tuple[int] = ('375', '233',),
|
||||||
|
contract: Optional[Contract] = None,
|
||||||
# opts: Tuple[int] = ('459',),
|
# opts: Tuple[int] = ('459',),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stream a ticker using the std L1 api.
|
"""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))
|
ticker: Ticker = self.ib.reqMktData(contract, ','.join(opts))
|
||||||
|
|
||||||
# define a simple queue push routine that streams quote packets
|
# define a simple queue push routine that streams quote packets
|
||||||
|
@ -386,6 +389,20 @@ class Client:
|
||||||
# let the engine run and stream
|
# let the engine run and stream
|
||||||
await self.ib.disconnectedEvent
|
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
|
# default config ports
|
||||||
_tws_port: int = 7497
|
_tws_port: int = 7497
|
||||||
|
@ -604,16 +621,21 @@ _local_buffer_writers = {}
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def activate_writer(key: str) -> (bool, trio.Nursery):
|
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:
|
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:
|
async with trio.open_nursery() as n:
|
||||||
yield writer_already_exists, n
|
yield n
|
||||||
else:
|
|
||||||
yield writer_already_exists, None
|
|
||||||
finally:
|
finally:
|
||||||
_local_buffer_writers.pop(key, None)
|
_local_buffer_writers.pop(key, None)
|
||||||
|
|
||||||
|
@ -622,7 +644,7 @@ async def fill_bars(
|
||||||
sym: str,
|
sym: str,
|
||||||
first_bars: list,
|
first_bars: list,
|
||||||
shm: 'ShmArray', # type: ignore # noqa
|
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
|
count: int = 2, # NOTE: any more and we'll overrun the underlying buffer
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fill historical bars into shared mem / storage afap.
|
"""Fill historical bars into shared mem / storage afap.
|
||||||
|
@ -692,8 +714,14 @@ async def stream_quotes(
|
||||||
# TODO: support multiple subscriptions
|
# TODO: support multiple subscriptions
|
||||||
sym = symbols[0]
|
sym = symbols[0]
|
||||||
|
|
||||||
|
contract, first_ticker = await _trio_run_client_method(
|
||||||
|
method='get_quote',
|
||||||
|
symbol=sym,
|
||||||
|
)
|
||||||
|
|
||||||
stream = await _trio_run_client_method(
|
stream = await _trio_run_client_method(
|
||||||
method='stream_ticker',
|
method='stream_ticker',
|
||||||
|
contract=contract, # small speedup
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -701,14 +729,17 @@ async def stream_quotes(
|
||||||
|
|
||||||
# check if a writer already is alive in a streaming task,
|
# check if a writer already is alive in a streaming task,
|
||||||
# otherwise start one and mark it as now existing
|
# 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
|
key = shm_token['shm_name']
|
||||||
# check if shm has already been created by previous
|
|
||||||
# feed initialization
|
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:
|
if not writer_already_exists:
|
||||||
|
_local_buffer_writers[key] = True
|
||||||
|
|
||||||
shm = attach_shm_array(
|
shm = attach_shm_array(
|
||||||
token=shm_token,
|
token=shm_token,
|
||||||
|
@ -744,12 +775,33 @@ async def stream_quotes(
|
||||||
subscribe_ohlc_for_increment(shm, delay_s)
|
subscribe_ohlc_for_increment(shm, delay_s)
|
||||||
|
|
||||||
# pass back token, and bool, signalling if we're the writer
|
# 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))
|
await ctx.send_yield((shm_token, not writer_already_exists))
|
||||||
|
|
||||||
# first quote can be ignored as a 2nd with newer data is sent?
|
# check for special contract types
|
||||||
first_ticker = await stream.__anext__()
|
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
|
# ugh, clear ticks since we've consumed them
|
||||||
# (ahem, ib_insync is stateful trash)
|
# (ahem, ib_insync is stateful trash)
|
||||||
|
@ -762,39 +814,31 @@ async def stream_quotes(
|
||||||
|
|
||||||
calc_price = False # should be real volume for contract
|
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
|
# spin consuming tickers until we get a real market datum
|
||||||
if not ticker.rtTime:
|
log.debug(f"New unsent ticker: {ticker}")
|
||||||
log.debug(f"New unsent ticker: {ticker}")
|
continue
|
||||||
continue
|
else:
|
||||||
else:
|
log.debug("Received first real volume tick")
|
||||||
log.debug("Received first real volume tick")
|
# ugh, clear ticks since we've consumed them
|
||||||
# ugh, clear ticks since we've consumed them
|
# (ahem, ib_insync is truly stateful trash)
|
||||||
# (ahem, ib_insync is truly stateful trash)
|
ticker.ticks = []
|
||||||
ticker.ticks = []
|
|
||||||
|
|
||||||
# XXX: this works because we don't use
|
# tell incrementer task it can start
|
||||||
# ``aclosing()`` above?
|
_buffer.shm_incrementing(key).set()
|
||||||
break
|
|
||||||
else:
|
|
||||||
# commodities don't have an exchange name for some reason?
|
|
||||||
suffix = 'secType'
|
|
||||||
calc_price = True
|
|
||||||
ticker = first_ticker
|
|
||||||
|
|
||||||
quote = normalize(ticker, calc_price=calc_price)
|
# XXX: this works because we don't use
|
||||||
con = quote['contract']
|
# ``aclosing()`` above?
|
||||||
topic = '.'.join((con['symbol'], con[suffix])).lower()
|
break
|
||||||
quote['symbol'] = topic
|
|
||||||
|
|
||||||
first_quote = {topic: quote}
|
|
||||||
ticker.ticks = []
|
|
||||||
|
|
||||||
# yield first quote asap
|
|
||||||
await ctx.send_yield(first_quote)
|
|
||||||
|
|
||||||
# real-time stream
|
# real-time stream
|
||||||
async for ticker in stream:
|
async for ticker in stream:
|
||||||
|
|
||||||
# print(ticker.vwap)
|
# print(ticker.vwap)
|
||||||
quote = normalize(
|
quote = normalize(
|
||||||
ticker,
|
ticker,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# 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
|
# 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
|
# 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 ._util import resproc, SymbolNotFound, BrokerError
|
||||||
from ..log import get_logger, get_console_log
|
from ..log import get_logger, get_console_log
|
||||||
from ..data import (
|
from ..data import (
|
||||||
|
_buffer,
|
||||||
# iterticks,
|
# iterticks,
|
||||||
attach_shm_array,
|
attach_shm_array,
|
||||||
get_shm_token,
|
get_shm_token,
|
||||||
|
@ -266,6 +267,7 @@ def normalize(
|
||||||
quote['broker_ts'] = quote['time']
|
quote['broker_ts'] = quote['time']
|
||||||
quote['brokerd_ts'] = time.time()
|
quote['brokerd_ts'] = time.time()
|
||||||
quote['symbol'] = quote['pair'] = quote['pair'].replace('/', '')
|
quote['symbol'] = quote['pair'] = quote['pair'].replace('/', '')
|
||||||
|
quote['last'] = quote['close']
|
||||||
|
|
||||||
# seriously eh? what's with this non-symmetry everywhere
|
# seriously eh? what's with this non-symmetry everywhere
|
||||||
# in subscription systems...
|
# in subscription systems...
|
||||||
|
@ -381,6 +383,9 @@ async def stream_quotes(
|
||||||
# packetize as {topic: quote}
|
# packetize as {topic: quote}
|
||||||
yield {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
|
# keep start of last interval for volume tracking
|
||||||
last_interval_start = ohlc_last.etime
|
last_interval_start = ohlc_last.etime
|
||||||
|
|
||||||
|
|
|
@ -75,10 +75,12 @@ def get_ingestormod(name: str) -> ModuleType:
|
||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
# capable rpc modules
|
||||||
_data_mods = [
|
_data_mods = [
|
||||||
'piker.brokers.core',
|
'piker.brokers.core',
|
||||||
'piker.brokers.data',
|
'piker.brokers.data',
|
||||||
'piker.data',
|
'piker.data',
|
||||||
|
'piker.data._buffer',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -104,10 +106,13 @@ async def maybe_spawn_brokerd(
|
||||||
brokermod = get_brokermod(brokername)
|
brokermod = get_brokermod(brokername)
|
||||||
dname = f'brokerd.{brokername}'
|
dname = f'brokerd.{brokername}'
|
||||||
async with tractor.find_actor(dname) as portal:
|
async with tractor.find_actor(dname) as portal:
|
||||||
|
|
||||||
# WTF: why doesn't this work?
|
# WTF: why doesn't this work?
|
||||||
if portal is not None:
|
if portal is not None:
|
||||||
yield portal
|
yield portal
|
||||||
else:
|
|
||||||
|
else: # no daemon has been spawned yet
|
||||||
|
|
||||||
log.info(f"Spawning {brokername} broker daemon")
|
log.info(f"Spawning {brokername} broker daemon")
|
||||||
tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
|
tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
|
||||||
async with tractor.open_nursery() as nursery:
|
async with tractor.open_nursery() as nursery:
|
||||||
|
@ -115,7 +120,7 @@ async def maybe_spawn_brokerd(
|
||||||
# spawn new daemon
|
# spawn new daemon
|
||||||
portal = await nursery.start_actor(
|
portal = await nursery.start_actor(
|
||||||
dname,
|
dname,
|
||||||
rpc_module_paths=_data_mods + [brokermod.__name__],
|
enable_modules=_data_mods + [brokermod.__name__],
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
**tractor_kwargs
|
**tractor_kwargs
|
||||||
)
|
)
|
||||||
|
@ -140,7 +145,7 @@ class Feed:
|
||||||
stream: AsyncIterator[Dict[str, Any]]
|
stream: AsyncIterator[Dict[str, Any]]
|
||||||
shm: ShmArray
|
shm: ShmArray
|
||||||
# ticks: ShmArray
|
# ticks: ShmArray
|
||||||
_broker_portal: tractor._portal.Portal
|
_brokerd_portal: tractor._portal.Portal
|
||||||
_index_stream: Optional[AsyncIterator[Dict[str, Any]]] = None
|
_index_stream: Optional[AsyncIterator[Dict[str, Any]]] = None
|
||||||
|
|
||||||
async def receive(self) -> dict:
|
async def receive(self) -> dict:
|
||||||
|
@ -151,9 +156,8 @@ class Feed:
|
||||||
# XXX: this should be singleton on a host,
|
# XXX: this should be singleton on a host,
|
||||||
# a lone broker-daemon per provider should be
|
# a lone broker-daemon per provider should be
|
||||||
# created for all practical purposes
|
# created for all practical purposes
|
||||||
self._index_stream = await self._broker_portal.run(
|
self._index_stream = await self._brokerd_portal.run(
|
||||||
'piker.data',
|
increment_ohlc_buffer,
|
||||||
'increment_ohlc_buffer',
|
|
||||||
shm_token=self.shm.token,
|
shm_token=self.shm.token,
|
||||||
topics=['index'],
|
topics=['index'],
|
||||||
)
|
)
|
||||||
|
@ -200,8 +204,7 @@ async def open_feed(
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
) as portal:
|
) as portal:
|
||||||
stream = await portal.run(
|
stream = await portal.run(
|
||||||
mod.__name__,
|
mod.stream_quotes,
|
||||||
'stream_quotes',
|
|
||||||
symbols=symbols,
|
symbols=symbols,
|
||||||
shm_token=shm.token,
|
shm_token=shm.token,
|
||||||
|
|
||||||
|
@ -225,5 +228,5 @@ async def open_feed(
|
||||||
name=name,
|
name=name,
|
||||||
stream=stream,
|
stream=stream,
|
||||||
shm=shm,
|
shm=shm,
|
||||||
_broker_portal=portal,
|
_brokerd_portal=portal,
|
||||||
)
|
)
|
||||||
|
|
|
@ -27,6 +27,12 @@ from ._sharedmem import ShmArray
|
||||||
|
|
||||||
|
|
||||||
_shms: Dict[int, 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
|
@tractor.msg.pub
|
||||||
|
@ -47,6 +53,10 @@ async def increment_ohlc_buffer(
|
||||||
Note that if **no** actor has initiated this task then **none** of
|
Note that if **no** actor has initiated this task then **none** of
|
||||||
the underlying buffers will actually be incremented.
|
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
|
# 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
|
# 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
|
# to solve this is to make this task aware of the instrument's
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
"""
|
"""
|
||||||
numpy data source coversion helpers.
|
numpy data source coversion helpers.
|
||||||
"""
|
"""
|
||||||
|
from typing import List
|
||||||
import decimal
|
import decimal
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@ -81,6 +82,7 @@ class Symbol:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
key: str = ''
|
key: str = ''
|
||||||
|
brokers: List[str] = None
|
||||||
min_tick: float = 0.01
|
min_tick: float = 0.01
|
||||||
contract: str = ''
|
contract: str = ''
|
||||||
|
|
||||||
|
|
|
@ -218,13 +218,13 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
p.drawRect(self.rect)
|
p.drawRect(self.rect)
|
||||||
|
|
||||||
def boundingRect(self): # noqa
|
def boundingRect(self): # noqa
|
||||||
# if self.label_str:
|
if self.label_str:
|
||||||
# self._size_br_from_str(self.label_str)
|
self._size_br_from_str(self.label_str)
|
||||||
# return self.rect
|
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:
|
def _size_br_from_str(self, value: str) -> None:
|
||||||
"""Do our best to render the bounding rect to a set margin
|
"""Do our best to render the bounding rect to a set margin
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
High level Qt chart widgets.
|
High level Qt chart widgets.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Tuple, Dict, Any, Optional, Callable
|
from typing import Tuple, Dict, Any, Optional, Callable
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
@ -31,7 +32,7 @@ from ._axes import (
|
||||||
PriceAxis,
|
PriceAxis,
|
||||||
)
|
)
|
||||||
from ._graphics._cursor import (
|
from ._graphics._cursor import (
|
||||||
CrossHair,
|
Cursor,
|
||||||
ContentsLabel,
|
ContentsLabel,
|
||||||
)
|
)
|
||||||
from ._graphics._lines import (
|
from ._graphics._lines import (
|
||||||
|
@ -56,8 +57,9 @@ from .. import data
|
||||||
from ..data import maybe_open_shm_array
|
from ..data import maybe_open_shm_array
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._exec import run_qtractor, current_screen
|
from ._exec import run_qtractor, current_screen
|
||||||
from ._interaction import ChartView
|
from ._interaction import ChartView, open_order_mode
|
||||||
from .. import fsp
|
from .. import fsp
|
||||||
|
from .._ems import spawn_router_stream_alerts
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
@ -123,10 +125,9 @@ class ChartSpace(QtGui.QWidget):
|
||||||
# def init_strategy_ui(self):
|
# def init_strategy_ui(self):
|
||||||
# self.strategy_box = StrategyBoxWidget(self)
|
# self.strategy_box = StrategyBoxWidget(self)
|
||||||
# self.toolbar_layout.addWidget(self.strategy_box)
|
# self.toolbar_layout.addWidget(self.strategy_box)
|
||||||
|
|
||||||
def load_symbol(
|
def load_symbol(
|
||||||
self,
|
self,
|
||||||
symbol: str,
|
symbol: Symbol,
|
||||||
data: np.ndarray,
|
data: np.ndarray,
|
||||||
ohlc: bool = True,
|
ohlc: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -146,16 +147,15 @@ class ChartSpace(QtGui.QWidget):
|
||||||
# self.symbol_label.setText(f'/`{symbol}`')
|
# self.symbol_label.setText(f'/`{symbol}`')
|
||||||
|
|
||||||
linkedcharts = self._chart_cache.setdefault(
|
linkedcharts = self._chart_cache.setdefault(
|
||||||
symbol,
|
symbol.key,
|
||||||
LinkedSplitCharts()
|
LinkedSplitCharts(symbol)
|
||||||
)
|
)
|
||||||
s = Symbol(key=symbol)
|
|
||||||
|
|
||||||
# remove any existing plots
|
# remove any existing plots
|
||||||
if not self.v_layout.isEmpty():
|
if not self.v_layout.isEmpty():
|
||||||
self.v_layout.removeWidget(linkedcharts)
|
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)
|
self.v_layout.addWidget(linkedcharts)
|
||||||
|
|
||||||
|
@ -181,10 +181,13 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
|
|
||||||
zoomIsDisabled = QtCore.pyqtSignal(bool)
|
zoomIsDisabled = QtCore.pyqtSignal(bool)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(
|
||||||
|
self,
|
||||||
|
symbol: Symbol,
|
||||||
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.signals_visible: bool = False
|
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.chart: ChartPlotWidget = None # main (ohlc) chart
|
||||||
self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {}
|
self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {}
|
||||||
|
|
||||||
|
@ -207,6 +210,13 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.layout.addWidget(self.splitter)
|
self.layout.addWidget(self.splitter)
|
||||||
|
|
||||||
|
# state tracker?
|
||||||
|
self._symbol: Symbol = symbol
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbol(self) -> Symbol:
|
||||||
|
return self._symbol
|
||||||
|
|
||||||
def set_split_sizes(
|
def set_split_sizes(
|
||||||
self,
|
self,
|
||||||
prop: float = 0.28 # proportion allocated to consumer subcharts
|
prop: float = 0.28 # proportion allocated to consumer subcharts
|
||||||
|
@ -232,7 +242,7 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
self.digits = symbol.digits()
|
self.digits = symbol.digits()
|
||||||
|
|
||||||
# add crosshairs
|
# add crosshairs
|
||||||
self._ch = CrossHair(
|
self._cursor = Cursor(
|
||||||
linkedsplitcharts=self,
|
linkedsplitcharts=self,
|
||||||
digits=self.digits
|
digits=self.digits
|
||||||
)
|
)
|
||||||
|
@ -244,7 +254,7 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
_is_main=True,
|
_is_main=True,
|
||||||
)
|
)
|
||||||
# add crosshair graphic
|
# add crosshair graphic
|
||||||
self.chart.addItem(self._ch)
|
self.chart.addItem(self._cursor)
|
||||||
|
|
||||||
# axis placement
|
# axis placement
|
||||||
if _xaxis_at == 'bottom':
|
if _xaxis_at == 'bottom':
|
||||||
|
@ -291,18 +301,19 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
|
|
||||||
array=array,
|
array=array,
|
||||||
parent=self.splitter,
|
parent=self.splitter,
|
||||||
|
linked_charts=self,
|
||||||
axisItems={
|
axisItems={
|
||||||
'bottom': xaxis,
|
'bottom': xaxis,
|
||||||
'right': PriceAxis(linked_charts=self)
|
'right': PriceAxis(linked_charts=self)
|
||||||
},
|
},
|
||||||
viewBox=cv,
|
viewBox=cv,
|
||||||
cursor=self._ch,
|
cursor=self._cursor,
|
||||||
**cpw_kwargs,
|
**cpw_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# give viewbox a reference to primary chart
|
# give viewbox as reference to chart
|
||||||
# allowing for kb controls and interactions
|
# allowing for kb controls and interactions on **this** widget
|
||||||
# (see our custom view in `._interactions.py`)
|
# (see our custom view mode in `._interactions.py`)
|
||||||
cv.chart = cpw
|
cv.chart = cpw
|
||||||
|
|
||||||
cpw.plotItem.vb.linked_charts = self
|
cpw.plotItem.vb.linked_charts = self
|
||||||
|
@ -315,7 +326,7 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
cpw.setXLink(self.chart)
|
cpw.setXLink(self.chart)
|
||||||
|
|
||||||
# add to cross-hair's known plots
|
# add to cross-hair's known plots
|
||||||
self._ch.add_plot(cpw)
|
self._cursor.add_plot(cpw)
|
||||||
|
|
||||||
# draw curve graphics
|
# draw curve graphics
|
||||||
if style == 'bar':
|
if style == 'bar':
|
||||||
|
@ -365,8 +376,9 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# the data view we generate graphics from
|
# the data view we generate graphics from
|
||||||
name: str,
|
name: str,
|
||||||
array: np.ndarray,
|
array: np.ndarray,
|
||||||
|
linked_charts: LinkedSplitCharts,
|
||||||
static_yrange: Optional[Tuple[float, float]] = None,
|
static_yrange: Optional[Tuple[float, float]] = None,
|
||||||
cursor: Optional[CrossHair] = None,
|
cursor: Optional[Cursor] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Configure chart display settings.
|
"""Configure chart display settings.
|
||||||
|
@ -379,8 +391,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
useOpenGL=True,
|
useOpenGL=True,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self._lc = linked_charts
|
||||||
|
|
||||||
# self.setViewportMargins(0, 0, 0, 0)
|
# self.setViewportMargins(0, 0, 0, 0)
|
||||||
self._ohlc = array # readonly view of ohlc data
|
self._ohlc = array # readonly view of ohlc data
|
||||||
|
@ -407,10 +419,6 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
self.default_view()
|
self.default_view()
|
||||||
|
|
||||||
# TODO: stick in config
|
|
||||||
# use cross-hair for cursor?
|
|
||||||
# self.setCursor(QtCore.Qt.CrossCursor)
|
|
||||||
|
|
||||||
# Assign callback for rescaling y-axis automatically
|
# Assign callback for rescaling y-axis automatically
|
||||||
# based on data contents and ``ViewBox`` state.
|
# based on data contents and ``ViewBox`` state.
|
||||||
self.sigXRangeChanged.connect(self._set_yrange)
|
self.sigXRangeChanged.connect(self._set_yrange)
|
||||||
|
@ -844,6 +852,8 @@ async def _async_main(
|
||||||
# historical data fetch
|
# historical data fetch
|
||||||
brokermod = brokers.get_brokermod(brokername)
|
brokermod = brokers.get_brokermod(brokername)
|
||||||
|
|
||||||
|
symbol = Symbol(sym, [brokername])
|
||||||
|
|
||||||
async with data.open_feed(
|
async with data.open_feed(
|
||||||
brokername,
|
brokername,
|
||||||
[sym],
|
[sym],
|
||||||
|
@ -854,8 +864,7 @@ async def _async_main(
|
||||||
bars = ohlcv.array
|
bars = ohlcv.array
|
||||||
|
|
||||||
# load in symbol's ohlc data
|
# load in symbol's ohlc data
|
||||||
# await tractor.breakpoint()
|
linked_charts, chart = chart_app.load_symbol(symbol, bars)
|
||||||
linked_charts, chart = chart_app.load_symbol(sym, bars)
|
|
||||||
|
|
||||||
# plot historical vwap if available
|
# plot historical vwap if available
|
||||||
wap_in_history = False
|
wap_in_history = False
|
||||||
|
@ -870,12 +879,13 @@ async def _async_main(
|
||||||
add_label=False,
|
add_label=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# size view to data once at outset
|
||||||
chart._set_yrange()
|
chart._set_yrange()
|
||||||
|
|
||||||
# TODO: a data view api that makes this less shit
|
# TODO: a data view api that makes this less shit
|
||||||
chart._shm = ohlcv
|
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 = {
|
fsp_conf = {
|
||||||
'rsi': {
|
'rsi': {
|
||||||
'period': 14,
|
'period': 14,
|
||||||
|
@ -887,7 +897,8 @@ async def _async_main(
|
||||||
}
|
}
|
||||||
|
|
||||||
# make sure that the instrument supports volume history
|
# 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']
|
volm = ohlcv.array['volume']
|
||||||
if (
|
if (
|
||||||
np.all(np.isin(volm, -1)) or
|
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
|
# wait for a first quote before we start any update tasks
|
||||||
quote = await feed.receive()
|
quote = await feed.receive()
|
||||||
|
|
||||||
log.info(f'Received first quote {quote}')
|
log.info(f'Received first quote {quote}')
|
||||||
|
|
||||||
n.start_soon(
|
n.start_soon(
|
||||||
|
@ -938,8 +950,26 @@ async def _async_main(
|
||||||
linked_charts
|
linked_charts
|
||||||
)
|
)
|
||||||
|
|
||||||
# probably where we'll eventually start the user input loop
|
async with open_order_mode(
|
||||||
await trio.sleep_forever()
|
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(
|
async def chart_from_quotes(
|
||||||
|
@ -999,7 +1029,7 @@ async def chart_from_quotes(
|
||||||
chart,
|
chart,
|
||||||
# determine precision/decimal lengths
|
# determine precision/decimal lengths
|
||||||
digits=max(float_digits(last), 2),
|
digits=max(float_digits(last), 2),
|
||||||
size_digits=min(float_digits(volume), 3)
|
size_digits=min(float_digits(last), 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO:
|
# TODO:
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
Mouse interaction graphics
|
Mouse interaction graphics
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple, Set, Dict
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
@ -31,6 +31,10 @@ from .._style import (
|
||||||
_font,
|
_font,
|
||||||
)
|
)
|
||||||
from .._axes import YAxisLabel, XAxisLabel
|
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
|
# XXX: these settings seem to result in really decent mouse scroll
|
||||||
# latency (in terms of perceived lag in cross hair) so really be sure
|
# 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}")
|
self.setText(f"{name}: {data:.2f}")
|
||||||
|
|
||||||
|
|
||||||
class CrossHair(pg.GraphicsObject):
|
class Cursor(pg.GraphicsObject):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -213,11 +217,21 @@ class CrossHair(pg.GraphicsObject):
|
||||||
style=QtCore.Qt.DashLine,
|
style=QtCore.Qt.DashLine,
|
||||||
)
|
)
|
||||||
self.lsc = linkedsplitcharts
|
self.lsc = linkedsplitcharts
|
||||||
self.graphics = {}
|
self.graphics: Dict[str, pg.GraphicsObject] = {}
|
||||||
self.plots = []
|
self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa
|
||||||
self.active_plot = None
|
self.active_plot = None
|
||||||
self.digits = digits
|
self.digits: int = digits
|
||||||
self._lastx = None
|
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(
|
def add_plot(
|
||||||
self,
|
self,
|
||||||
|
@ -289,12 +303,17 @@ class CrossHair(pg.GraphicsObject):
|
||||||
) -> LineDot:
|
) -> LineDot:
|
||||||
# if this plot contains curves add line dot "cursors" to denote
|
# if this plot contains curves add line dot "cursors" to denote
|
||||||
# the current sample under the mouse
|
# 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)
|
plot.addItem(cursor)
|
||||||
self.graphics[plot].setdefault('cursors', []).append(cursor)
|
self.graphics[plot].setdefault('cursors', []).append(cursor)
|
||||||
return cursor
|
return cursor
|
||||||
|
|
||||||
def mouseAction(self, action, plot): # noqa
|
def mouseAction(self, action, plot): # noqa
|
||||||
|
log.debug(f"{(action, plot.name)}")
|
||||||
if action == 'Enter':
|
if action == 'Enter':
|
||||||
self.active_plot = plot
|
self.active_plot = plot
|
||||||
|
|
||||||
|
@ -303,7 +322,6 @@ class CrossHair(pg.GraphicsObject):
|
||||||
self.graphics[plot]['yl'].show()
|
self.graphics[plot]['yl'].show()
|
||||||
|
|
||||||
else: # Leave
|
else: # Leave
|
||||||
self.active_plot = None
|
|
||||||
|
|
||||||
# hide horiz line and y-label
|
# hide horiz line and y-label
|
||||||
self.graphics[plot]['hl'].hide()
|
self.graphics[plot]['hl'].hide()
|
||||||
|
@ -332,15 +350,21 @@ class CrossHair(pg.GraphicsObject):
|
||||||
# update y-range items
|
# update y-range items
|
||||||
self.graphics[plot]['hl'].setY(y)
|
self.graphics[plot]['hl'].setY(y)
|
||||||
|
|
||||||
|
|
||||||
self.graphics[self.active_plot]['yl'].update_label(
|
self.graphics[self.active_plot]['yl'].update_label(
|
||||||
abs_pos=pos, value=y
|
abs_pos=pos, value=y
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update x if cursor changed after discretization calc
|
# Update x if cursor changed after discretization calc
|
||||||
# (this saves draw cycles on small mouse moves)
|
# (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
|
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:
|
if ix != lastx:
|
||||||
for plot, opts in self.graphics.items():
|
for plot, opts in self.graphics.items():
|
||||||
|
|
||||||
|
@ -351,7 +375,6 @@ class CrossHair(pg.GraphicsObject):
|
||||||
plot.update_contents_labels(ix)
|
plot.update_contents_labels(ix)
|
||||||
|
|
||||||
# update all subscribed curve dots
|
# update all subscribed curve dots
|
||||||
# first = plot._ohlc[0]['index']
|
|
||||||
for cursor in opts.get('cursors', ()):
|
for cursor in opts.get('cursors', ()):
|
||||||
cursor.setIndex(ix)
|
cursor.setIndex(ix)
|
||||||
|
|
||||||
|
@ -367,7 +390,7 @@ class CrossHair(pg.GraphicsObject):
|
||||||
value=x,
|
value=x,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._lastx = ix
|
self._datum_xy = ix, y
|
||||||
|
|
||||||
def boundingRect(self):
|
def boundingRect(self):
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -123,6 +123,18 @@ class FastAppendCurve(pg.PlotCurveItem):
|
||||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||||
|
|
||||||
def boundingRect(self):
|
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 = self.path.controlPointRect()
|
||||||
hb_size = hb.size()
|
hb_size = hb.size()
|
||||||
# print(f'hb_size: {hb_size}')
|
# print(f'hb_size: {hb_size}')
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
Lines for orders, alerts, L2.
|
Lines for orders, alerts, L2.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
@ -33,8 +34,6 @@ from .._axes import YSticky
|
||||||
|
|
||||||
class LevelLabel(YSticky):
|
class LevelLabel(YSticky):
|
||||||
|
|
||||||
line_pen = pg.mkPen(hcolor('bracket'))
|
|
||||||
|
|
||||||
_w_margin = 4
|
_w_margin = 4
|
||||||
_h_margin = 3
|
_h_margin = 3
|
||||||
level: float = 0
|
level: float = 0
|
||||||
|
@ -43,12 +42,16 @@ class LevelLabel(YSticky):
|
||||||
self,
|
self,
|
||||||
chart,
|
chart,
|
||||||
*args,
|
*args,
|
||||||
|
color: str = 'bracket',
|
||||||
orient_v: str = 'bottom',
|
orient_v: str = 'bottom',
|
||||||
orient_h: str = 'left',
|
orient_h: str = 'left',
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(chart, *args, **kwargs)
|
super().__init__(chart, *args, **kwargs)
|
||||||
|
|
||||||
|
# TODO: this is kinda cludgy
|
||||||
|
self._pen = self.pen = pg.mkPen(hcolor(color))
|
||||||
|
|
||||||
# orientation around axis options
|
# orientation around axis options
|
||||||
self._orient_v = orient_v
|
self._orient_v = orient_v
|
||||||
self._orient_h = orient_h
|
self._orient_h = orient_h
|
||||||
|
@ -75,7 +78,7 @@ class LevelLabel(YSticky):
|
||||||
br = self.boundingRect()
|
br = self.boundingRect()
|
||||||
h, w = br.height(), br.width()
|
h, w = br.height(), br.width()
|
||||||
|
|
||||||
# this triggers ``.pain()`` implicitly?
|
# this triggers ``.paint()`` implicitly?
|
||||||
self.setPos(QPointF(
|
self.setPos(QPointF(
|
||||||
self._h_shift * w - offset,
|
self._h_shift * w - offset,
|
||||||
abs_pos.y() - (self._v_shift * h) - offset
|
abs_pos.y() - (self._v_shift * h) - offset
|
||||||
|
@ -85,10 +88,11 @@ class LevelLabel(YSticky):
|
||||||
self.level = level
|
self.level = level
|
||||||
|
|
||||||
def set_label_str(self, level: float):
|
def set_label_str(self, level: float):
|
||||||
# this is read inside ``.paint()``
|
|
||||||
# self.label_str = '{size} x {level:.{digits}f}'.format(
|
# self.label_str = '{size} x {level:.{digits}f}'.format(
|
||||||
self.label_str = '{level:.{digits}f}'.format(
|
|
||||||
# size=self._size,
|
# size=self._size,
|
||||||
|
|
||||||
|
# this is read inside ``.paint()``
|
||||||
|
self.label_str = '{level:.{digits}f}'.format(
|
||||||
digits=self.digits,
|
digits=self.digits,
|
||||||
level=level
|
level=level
|
||||||
).replace(',', ' ')
|
).replace(',', ' ')
|
||||||
|
@ -101,7 +105,7 @@ class LevelLabel(YSticky):
|
||||||
p: QtGui.QPainter,
|
p: QtGui.QPainter,
|
||||||
rect: QtCore.QRectF
|
rect: QtCore.QRectF
|
||||||
) -> None:
|
) -> None:
|
||||||
p.setPen(self.line_pen)
|
p.setPen(self._pen)
|
||||||
|
|
||||||
if self._orient_v == 'bottom':
|
if self._orient_v == 'bottom':
|
||||||
lp, rp = rect.topLeft(), rect.topRight()
|
lp, rp = rect.topLeft(), rect.topRight()
|
||||||
|
@ -111,6 +115,14 @@ class LevelLabel(YSticky):
|
||||||
|
|
||||||
p.drawLine(lp.x(), lp.y(), rp.x(), rp.y())
|
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):
|
class L1Label(LevelLabel):
|
||||||
|
|
||||||
|
@ -145,7 +157,7 @@ class L1Labels:
|
||||||
self,
|
self,
|
||||||
chart: 'ChartPlotWidget', # noqa
|
chart: 'ChartPlotWidget', # noqa
|
||||||
digits: int = 2,
|
digits: int = 2,
|
||||||
size_digits: int = 0,
|
size_digits: int = 3,
|
||||||
font_size_inches: float = _down_2_font_inches_we_like,
|
font_size_inches: float = _down_2_font_inches_we_like,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
@ -181,29 +193,137 @@ class L1Labels:
|
||||||
|
|
||||||
|
|
||||||
class LevelLine(pg.InfiniteLine):
|
class LevelLine(pg.InfiniteLine):
|
||||||
|
|
||||||
|
# TODO: fill in these slots for orders
|
||||||
|
# .sigPositionChangeFinished.emit(self)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
chart: 'ChartPlotWidget', # type: ignore # noqa
|
||||||
label: LevelLabel,
|
label: LevelLabel,
|
||||||
|
highlight_color: str = 'default_light',
|
||||||
|
hl_on_hover: bool = True,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.label = label
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
self.label = label
|
||||||
|
|
||||||
self.sigPositionChanged.connect(self.set_level)
|
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:
|
def set_level(self, value: float) -> None:
|
||||||
self.label.update_from_data(0, self.value())
|
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(
|
def level_line(
|
||||||
chart: 'ChartPlogWidget', # noqa
|
chart: 'ChartPlogWidget', # noqa
|
||||||
level: float,
|
level: float,
|
||||||
digits: int = 1,
|
digits: int = 1,
|
||||||
|
color: str = 'default',
|
||||||
|
|
||||||
# size 4 font on 4k screen scaled down, so small-ish.
|
# size 4 font on 4k screen scaled down, so small-ish.
|
||||||
font_size_inches: float = _down_2_font_inches_we_like,
|
font_size_inches: float = _down_2_font_inches_we_like,
|
||||||
|
|
||||||
show_label: bool = True,
|
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
|
**linelabelkwargs
|
||||||
) -> LevelLine:
|
) -> LevelLine:
|
||||||
"""Convenience routine to add a styled horizontal line to a plot.
|
"""Convenience routine to add a styled horizontal line to a plot.
|
||||||
|
@ -214,11 +334,13 @@ def level_line(
|
||||||
parent=chart.getAxis('right'),
|
parent=chart.getAxis('right'),
|
||||||
# TODO: pass this from symbol data
|
# TODO: pass this from symbol data
|
||||||
digits=digits,
|
digits=digits,
|
||||||
opacity=1,
|
opacity=0.666,
|
||||||
font_size_inches=font_size_inches,
|
font_size_inches=font_size_inches,
|
||||||
|
color=color,
|
||||||
|
|
||||||
# TODO: make this take the view's bg pen
|
# TODO: make this take the view's bg pen
|
||||||
bg_color='papas_special',
|
bg_color='papas_special',
|
||||||
fg_color='default',
|
fg_color=color,
|
||||||
**linelabelkwargs
|
**linelabelkwargs
|
||||||
)
|
)
|
||||||
label.update_from_data(0, level)
|
label.update_from_data(0, level)
|
||||||
|
@ -227,12 +349,17 @@ def level_line(
|
||||||
label._size_br_from_str(label.label_str)
|
label._size_br_from_str(label.label_str)
|
||||||
|
|
||||||
line = LevelLine(
|
line = LevelLine(
|
||||||
|
chart,
|
||||||
label,
|
label,
|
||||||
|
# lookup "highlight" equivalent
|
||||||
|
highlight_color=color + '_light',
|
||||||
movable=True,
|
movable=True,
|
||||||
angle=0,
|
angle=0,
|
||||||
|
hl_on_hover=hl_on_hover,
|
||||||
)
|
)
|
||||||
line.setValue(level)
|
line.setValue(level)
|
||||||
line.setPen(pg.mkPen(hcolor('default')))
|
line.setPen(pg.mkPen(hcolor(color)))
|
||||||
|
|
||||||
# activate/draw label
|
# activate/draw label
|
||||||
line.setValue(level)
|
line.setValue(level)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# piker: trading gear for hackers
|
# 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
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -17,7 +17,10 @@
|
||||||
"""
|
"""
|
||||||
UX interaction customs.
|
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
|
import pyqtgraph as pg
|
||||||
from pyqtgraph import ViewBox, Point, QtCore, QtGui
|
from pyqtgraph import ViewBox, Point, QtCore, QtGui
|
||||||
|
@ -26,6 +29,8 @@ import numpy as np
|
||||||
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._style import _min_points_to_show, hcolor, _font
|
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__)
|
log = get_logger(__name__)
|
||||||
|
@ -194,13 +199,260 @@ class SelectRect(QtGui.QGraphicsRectItem):
|
||||||
self.hide()
|
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):
|
class ChartView(ViewBox):
|
||||||
"""Price chart view box with interaction behaviors you'd expect from
|
"""Price chart view box with interaction behaviors you'd expect from
|
||||||
any interactive platform:
|
any interactive platform:
|
||||||
|
|
||||||
- zoom on mouse scroll that auto fits y-axis
|
- zoom on mouse scroll that auto fits y-axis
|
||||||
- no vertical scrolling
|
- vertical scrolling on y-axis
|
||||||
- zoom to a "fixed point" on the y-axis
|
- zoom on x to most recent in view datum
|
||||||
|
- zoom on right-click-n-drag to cursor position
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -215,14 +467,21 @@ class ChartView(ViewBox):
|
||||||
self.addItem(self.select_box, ignoreBounds=True)
|
self.addItem(self.select_box, ignoreBounds=True)
|
||||||
self._chart: 'ChartPlotWidget' = None # noqa
|
self._chart: 'ChartPlotWidget' = None # noqa
|
||||||
|
|
||||||
|
# self._lines_editor = LineEditor(view=self, _lines=_lines)
|
||||||
|
self.mode = None
|
||||||
|
|
||||||
|
# kb ctrls processing
|
||||||
|
self._key_buffer = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def chart(self) -> 'ChartPlotWidget': # noqa
|
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
|
||||||
return self._chart
|
return self._chart
|
||||||
|
|
||||||
@chart.setter
|
@chart.setter
|
||||||
def chart(self, chart: 'ChartPlotWidget') -> None: # noqa
|
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa
|
||||||
self._chart = chart
|
self._chart = chart
|
||||||
self.select_box.chart = chart
|
self.select_box.chart = chart
|
||||||
|
# self._lines_editor.chart = chart
|
||||||
|
|
||||||
def wheelEvent(self, ev, axis=None):
|
def wheelEvent(self, ev, axis=None):
|
||||||
"""Override "center-point" location for scrolling.
|
"""Override "center-point" location for scrolling.
|
||||||
|
@ -286,6 +545,7 @@ class ChartView(ViewBox):
|
||||||
) -> None:
|
) -> None:
|
||||||
# if axis is specified, event will only affect that axis.
|
# if axis is specified, event will only affect that axis.
|
||||||
ev.accept() # we accept all buttons
|
ev.accept() # we accept all buttons
|
||||||
|
button = ev.button()
|
||||||
|
|
||||||
pos = ev.pos()
|
pos = ev.pos()
|
||||||
lastPos = ev.lastPos()
|
lastPos = ev.lastPos()
|
||||||
|
@ -299,13 +559,13 @@ class ChartView(ViewBox):
|
||||||
mask[1-axis] = 0.0
|
mask[1-axis] = 0.0
|
||||||
|
|
||||||
# Scale or translate based on mouse button
|
# 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:
|
if axis == 1:
|
||||||
# set a static y range special value on chart widget to
|
# set a static y range special value on chart widget to
|
||||||
# prevent sizing to data in view.
|
# prevent sizing to data in view.
|
||||||
self._chart._static_yrange = 'axis'
|
self.chart._static_yrange = 'axis'
|
||||||
|
|
||||||
scale_y = 1.3 ** (dif.y() * -1 / 20)
|
scale_y = 1.3 ** (dif.y() * -1 / 20)
|
||||||
self.setLimits(yMin=None, yMax=None)
|
self.setLimits(yMin=None, yMax=None)
|
||||||
|
@ -338,6 +598,8 @@ class ChartView(ViewBox):
|
||||||
# update shape of scale box
|
# update shape of scale box
|
||||||
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
||||||
else:
|
else:
|
||||||
|
# default bevavior: click to pan view
|
||||||
|
|
||||||
tr = self.childGroup.transform()
|
tr = self.childGroup.transform()
|
||||||
tr = fn.invertQTransform(tr)
|
tr = fn.invertQTransform(tr)
|
||||||
tr = tr.map(dif*mask) - tr.map(Point(0, 0))
|
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
|
y = tr.y() if mask[1] == 1 else None
|
||||||
|
|
||||||
self._resetTarget()
|
self._resetTarget()
|
||||||
|
|
||||||
if x is not None or y is not None:
|
if x is not None or y is not None:
|
||||||
self.translateBy(x=x, y=y)
|
self.translateBy(x=x, y=y)
|
||||||
|
|
||||||
self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
|
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:
|
if self.state['aspectLocked'] is not False:
|
||||||
mask[0] = 0
|
mask[0] = 0
|
||||||
|
|
||||||
|
@ -372,46 +637,119 @@ class ChartView(ViewBox):
|
||||||
self.scaleBy(x=x, y=y, center=center)
|
self.scaleBy(x=x, y=y, center=center)
|
||||||
self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
|
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):
|
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()
|
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:
|
if self.state['mouseMode'] == ViewBox.RectMode:
|
||||||
self.setMouseMode(ViewBox.PanMode)
|
self.setMouseMode(ViewBox.PanMode)
|
||||||
|
|
||||||
|
if text == 'a':
|
||||||
|
# draw "staged" line under cursor position
|
||||||
|
self.mode.lines.unstage_line()
|
||||||
|
|
||||||
def keyPressEvent(self, ev):
|
def keyPressEvent(self, ev):
|
||||||
"""
|
"""
|
||||||
This routine should capture key presses in the current view box.
|
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:
|
if self.state['mouseMode'] == ViewBox.PanMode:
|
||||||
self.setMouseMode(ViewBox.RectMode)
|
self.setMouseMode(ViewBox.RectMode)
|
||||||
|
|
||||||
# ctl
|
# ctl
|
||||||
if ev.modifiers() == QtCore.Qt.ControlModifier:
|
if mods == QtCore.Qt.ControlModifier:
|
||||||
# print("CTRL")
|
|
||||||
# TODO: ctrl-c as cancel?
|
# TODO: ctrl-c as cancel?
|
||||||
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
||||||
# if ev.text() == 'c':
|
# if ev.text() == 'c':
|
||||||
# self.rbScaleBox.hide()
|
# self.rbScaleBox.hide()
|
||||||
pass
|
print(f"CTRL + key:{key} + text:{text}")
|
||||||
|
|
||||||
# alt
|
# alt
|
||||||
if ev.modifiers() == QtCore.Qt.AltModifier:
|
if mods == QtCore.Qt.AltModifier:
|
||||||
pass
|
pass
|
||||||
# print("ALT")
|
|
||||||
|
|
||||||
# esc
|
# esc
|
||||||
if ev.key() == QtCore.Qt.Key_Escape:
|
if key == QtCore.Qt.Key_Escape:
|
||||||
self.select_box.clear()
|
self.select_box.clear()
|
||||||
|
|
||||||
if ev.text() == 'r':
|
self._key_buffer.append(text)
|
||||||
|
|
||||||
|
# order modes
|
||||||
|
if text == 'r':
|
||||||
self.chart.default_view()
|
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
|
# Key presses are used only when mouse mode is RectMode
|
||||||
# The following events are implemented:
|
# The following events are implemented:
|
||||||
|
|
|
@ -166,4 +166,9 @@ def hcolor(name: str) -> str:
|
||||||
'tina_green': '#00cc00',
|
'tina_green': '#00cc00',
|
||||||
'tina_red': '#fa0000',
|
'tina_red': '#fa0000',
|
||||||
|
|
||||||
|
|
||||||
|
# orders and alerts
|
||||||
|
'alert_yellow': '#e2d083',
|
||||||
|
'alert_yellow_light': '#ffe366',
|
||||||
|
|
||||||
}[name]
|
}[name]
|
||||||
|
|
|
@ -150,5 +150,6 @@ def chart(config, symbol, date, rate, test, profile):
|
||||||
tractor_kwargs={
|
tractor_kwargs={
|
||||||
'debug_mode': True,
|
'debug_mode': True,
|
||||||
'loglevel': tractorloglevel,
|
'loglevel': tractorloglevel,
|
||||||
|
'rpc_module_paths': ['piker._ems'],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue