Merge pull request #147 from pikers/basic_orders

Basic orders bbys!
readme_bumpz
goodboy 2021-03-19 12:11:20 -04:00 committed by GitHub
commit 8c39ff307e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 5014 additions and 1606 deletions

View File

@ -1,24 +0,0 @@
language: python
matrix:
include:
- python: 3.7
dist: xenial
sudo: required
before_install:
- sudo apt-get -qq update
# deps to build kivy from sources for use with trio
- sudo apt-get install -y build-essential libav-tools libgles2-mesa-dev libgles2-mesa-dev libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev zlib1g-dev
install:
- pip install pipenv
- cd $TRAVIS_BUILD_DIR
- pipenv install --dev -e .
cache:
directories:
- $HOME/.config/piker/
script:
- pipenv run pytest tests/

View File

@ -8,7 +8,7 @@ trading gear for hackers.
:target: https://actions-badge.atrox.dev/piker/pikers/goto :target: https://actions-badge.atrox.dev/piker/pikers/goto
``piker`` is a broker agnostic, next-gen FOSS toolset for real-time ``piker`` is a broker agnostic, next-gen FOSS toolset for real-time
trading targeted at hardcore Linux users. computational trading targeted at `hardcore Linux users <comp_trader>`_ .
we use as much bleeding edge tech as possible including (but not limited to): we use as much bleeding edge tech as possible including (but not limited to):
@ -32,6 +32,7 @@ we use as much bleeding edge tech as possible including (but not limited to):
.. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph .. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph
.. _glue: https://numpy.org/doc/stable/user/c-info.python-as-glue.html#using-python-as-glue .. _glue: https://numpy.org/doc/stable/user/c-info.python-as-glue.html#using-python-as-glue
.. _fast numerics: https://zerowithdot.com/python-numpy-and-pandas-performance/ .. _fast numerics: https://zerowithdot.com/python-numpy-and-pandas-performance/
.. _comp_trader: https://jfaleiro.wordpress.com/2019/10/09/computational-trader/
focus and features: focus and features:

View File

@ -17,7 +17,9 @@
""" """
Async utils no one seems to have built into a core lib (yet). Async utils no one seems to have built into a core lib (yet).
""" """
from typing import AsyncContextManager
from collections import OrderedDict from collections import OrderedDict
from contextlib import asynccontextmanager
def async_lifo_cache(maxsize=128): def async_lifo_cache(maxsize=128):
@ -47,3 +49,18 @@ def async_lifo_cache(maxsize=128):
return wrapper return wrapper
return decorator return decorator
@asynccontextmanager
async def _just_none():
# noop -> skip entering context
yield None
@asynccontextmanager
async def maybe_with_if(
predicate: bool,
context: AsyncContextManager,
) -> AsyncContextManager:
async with context if predicate else _just_none() as output:
yield output

View File

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

View File

@ -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
@ -16,7 +16,9 @@
""" """
Actor-aware broker agnostic interface. Actor-aware broker agnostic interface.
""" """
from typing import Dict
from contextlib import asynccontextmanager, AsyncExitStack from contextlib import asynccontextmanager, AsyncExitStack
import trio import trio
@ -28,6 +30,7 @@ from ..log import get_logger
log = get_logger(__name__) log = get_logger(__name__)
_cache: Dict[str, 'Client'] = {}
@asynccontextmanager @asynccontextmanager
async def get_cached_client( async def get_cached_client(
@ -39,29 +42,40 @@ async def get_cached_client(
If one has not been setup do it and cache it. If one has not been setup do it and cache it.
""" """
# check if a cached client is in the local actor's statespace global _cache
ss = tractor.current_actor().statespace
clients = ss.setdefault('clients', {'_lock': trio.Lock()}) clients = _cache.setdefault('clients', {'_lock': trio.Lock()})
# global cache task lock
lock = clients['_lock'] lock = clients['_lock']
client = None client = None
try: try:
log.info(f"Loading existing `{brokername}` daemon") log.info(f"Loading existing `{brokername}` client")
async with lock: async with lock:
client = clients[brokername] client = clients[brokername]
client._consumers += 1 client._consumers += 1
yield client yield client
except KeyError: except KeyError:
log.info(f"Creating new client for broker {brokername}") log.info(f"Creating new client for broker {brokername}")
async with lock: async with lock:
brokermod = get_brokermod(brokername) brokermod = get_brokermod(brokername)
exit_stack = AsyncExitStack() exit_stack = AsyncExitStack()
client = await exit_stack.enter_async_context( client = await exit_stack.enter_async_context(
brokermod.get_client() brokermod.get_client()
) )
client._consumers = 0 client._consumers = 0
client._exit_stack = exit_stack client._exit_stack = exit_stack
clients[brokername] = client clients[brokername] = client
yield client yield client
finally: finally:
client._consumers -= 1 client._consumers -= 1
if client._consumers <= 0: if client._consumers <= 0:

View File

@ -180,15 +180,18 @@ async def symbol_data(broker: str, tickers: List[str]):
return await feed.client.symbol_info(tickers) return await feed.client.symbol_info(tickers)
_feeds_cache = {}
@asynccontextmanager @asynccontextmanager
async def get_cached_feed( async def get_cached_feed(
brokername: str, brokername: str,
) -> BrokerFeed: ) -> BrokerFeed:
"""Get/create a ``BrokerFeed`` from/in the current actor. """Get/create a ``BrokerFeed`` from/in the current actor.
""" """
# check if a cached client is in the local actor's statespace global _feeds_cache
ss = tractor.current_actor().statespace
feeds = ss.setdefault('feeds', {'_lock': trio.Lock()}) # check if a cached feed is in the local actor
feeds = _feeds_cache.setdefault('feeds', {'_lock': trio.Lock()})
lock = feeds['_lock'] lock = feeds['_lock']
feed = None feed = None
try: try:

View File

@ -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
@ -23,37 +23,38 @@ built on it) and thus actor aware API calls must be spawned with
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import asdict from dataclasses import asdict
from functools import partial
from datetime import datetime from datetime import datetime
from functools import partial
from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable
import asyncio import asyncio
import logging from pprint import pformat
import inspect import inspect
import itertools import itertools
import logging
import time import time
import trio
import tractor
from async_generator import aclosing from async_generator import aclosing
from ib_insync.wrapper import RequestError from ib_insync.wrapper import RequestError
from ib_insync.contract import Contract, ContractDetails from ib_insync.contract import Contract, ContractDetails
from ib_insync.order import Order
from ib_insync.ticker import Ticker from ib_insync.ticker import Ticker
from ib_insync.objects import Position
import ib_insync as ibis import ib_insync as ibis
from ib_insync.wrapper import Wrapper from ib_insync.wrapper import Wrapper
from ib_insync.client import Client as ib_Client from ib_insync.client import Client as ib_Client
import trio
import tractor
from ..log import get_logger, get_console_log from ..log import get_logger, get_console_log
from ..data import ( from ..data import (
maybe_spawn_brokerd, maybe_spawn_brokerd,
iterticks, iterticks,
attach_shm_array, attach_shm_array,
# get_shm_token,
subscribe_ohlc_for_increment, subscribe_ohlc_for_increment,
_buffer, _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__)
@ -100,8 +101,9 @@ class NonShittyWrapper(Wrapper):
def tcpDataArrived(self): def tcpDataArrived(self):
"""Override time stamps to be floats for now. """Override time stamps to be floats for now.
""" """
# use a float to store epoch time instead of datetime # use a ns int to store epoch time instead of datetime
self.lastTime = time.time() self.lastTime = time.time_ns()
for ticker in self.pendingTickers: for ticker in self.pendingTickers:
ticker.rtTime = None ticker.rtTime = None
ticker.ticks = [] ticker.ticks = []
@ -109,6 +111,20 @@ class NonShittyWrapper(Wrapper):
ticker.domTicks = [] ticker.domTicks = []
self.pendingTickers = set() self.pendingTickers = set()
def execDetails(
self,
reqId: int,
contract: Contract,
execu,
):
"""
Get rid of datetime on executions.
"""
# this is the IB server's execution time supposedly
# https://interactivebrokers.github.io/tws-api/classIBApi_1_1Execution.html#a2e05cace0aa52d809654c7248e052ef2
execu.time = execu.time.timestamp()
return super().execDetails(reqId, contract, execu)
class NonShittyIB(ibis.IB): class NonShittyIB(ibis.IB):
"""The beginning of overriding quite a few decisions in this lib. """The beginning of overriding quite a few decisions in this lib.
@ -121,7 +137,7 @@ class NonShittyIB(ibis.IB):
# XXX: just to override this wrapper # XXX: just to override this wrapper
self.wrapper = NonShittyWrapper(self) self.wrapper = NonShittyWrapper(self)
self.client = ib_Client(self.wrapper) self.client = ib_Client(self.wrapper)
self.errorEvent += self._onError # self.errorEvent += self._onError
self.client.apiEnd += self.disconnectedEvent self.client.apiEnd += self.disconnectedEvent
self._logger = logging.getLogger('ib_insync.ib') self._logger = logging.getLogger('ib_insync.ib')
@ -150,6 +166,9 @@ class Client:
self.ib = ib self.ib = ib
self.ib.RaiseRequestErrors = True self.ib.RaiseRequestErrors = True
# contract cache
self._contracts: Dict[str, Contract] = {}
# NOTE: the ib.client here is "throttled" to 45 rps by default # NOTE: the ib.client here is "throttled" to 45 rps by default
async def bars( async def bars(
@ -199,7 +218,6 @@ class Client:
# barSizeSetting='1 min', # barSizeSetting='1 min',
# always use extended hours # always use extended hours
useRTH=False, useRTH=False,
@ -217,9 +235,6 @@ class Client:
df = ibis.util.df(bars) df = ibis.util.df(bars)
return bars, from_df(df) return bars, from_df(df)
def onError(self, reqId, errorCode, errorString, contract) -> None:
breakpoint()
async def search_stocks( async def search_stocks(
self, self,
pattern: str, pattern: str,
@ -283,6 +298,24 @@ class Client:
currency: str = 'USD', currency: str = 'USD',
**kwargs, **kwargs,
) -> Contract: ) -> Contract:
# TODO: we can't use this currently because
# ``wrapper.starTicker()`` currently cashes ticker instances
# which means getting a singel quote will potentially look up
# a quote for a ticker that it already streaming and thus run
# into state clobbering (eg. List: Ticker.ticks). It probably
# makes sense to try this once we get the pub-sub working on
# individual symbols...
# XXX UPDATE: we can probably do the tick/trades scraping
# inside our eventkit handler instead to bypass this entirely?
# try:
# # give the cache a go
# return self._contracts[symbol]
# except KeyError:
# log.debug(f'Looking up contract for {symbol}')
# use heuristics to figure out contract "type" # use heuristics to figure out contract "type"
try: try:
sym, exch = symbol.upper().rsplit('.', maxsplit=1) sym, exch = symbol.upper().rsplit('.', maxsplit=1)
@ -331,11 +364,10 @@ class Client:
exch = 'SMART' if not exch else exch exch = 'SMART' if not exch else exch
contract = (await self.ib.qualifyContractsAsync(con))[0] contract = (await self.ib.qualifyContractsAsync(con))[0]
# head = await self.get_head_time(contract)
# print(head)
except IndexError: except IndexError:
raise ValueError(f"No contract could be found {con}") raise ValueError(f"No contract could be found {con}")
self._contracts[symbol] = contract
return contract return contract
async def get_head_time( async def get_head_time(
@ -356,9 +388,8 @@ class Client:
self, self,
symbol: str, symbol: str,
to_trio, to_trio,
opts: Tuple[int] = ('375', '233',), opts: Tuple[int] = ('375', '233', '236'),
contract: Optional[Contract] = None, contract: Optional[Contract] = None,
# opts: Tuple[int] = ('459',),
) -> None: ) -> None:
"""Stream a ticker using the std L1 api. """Stream a ticker using the std L1 api.
""" """
@ -397,11 +428,174 @@ class Client:
""" """
contract = await self.find_contract(symbol) contract = await self.find_contract(symbol)
details_fute = self.ib.reqContractDetailsAsync(contract)
ticker: Ticker = self.ib.reqMktData( ticker: Ticker = self.ib.reqMktData(
contract, contract,
snapshot=True, snapshot=True,
) )
return contract, (await ticker.updateEvent) ticker = await ticker.updateEvent
details = (await details_fute)[0]
return contract, ticker, details
# async to be consistent for the client proxy, and cuz why not.
async def submit_limit(
self,
# ignored since ib doesn't support defining your
# own order id
oid: str,
symbol: str,
price: float,
action: str,
size: int,
# XXX: by default 0 tells ``ib_insync`` methods that there is no
# existing order so ask the client to create a new one (which it
# seems to do by allocating an int counter - collision prone..)
brid: int = None,
) -> int:
"""Place an order and return integer request id provided by client.
"""
try:
contract = self._contracts[symbol]
except KeyError:
# require that the symbol has been previously cached by
# a data feed request - ensure we aren't making orders
# against non-known prices.
raise RuntimeError("Can not order {symbol}, no live feed?")
trade = self.ib.placeOrder(
contract,
Order(
orderId=brid or 0, # stupid api devs..
action=action.upper(), # BUY/SELL
orderType='LMT',
lmtPrice=price,
totalQuantity=size,
outsideRth=True,
optOutSmartRouting=True,
routeMarketableToBbo=True,
designatedLocation='SMART',
),
)
# ib doesn't support setting your own id outside
# their own weird client int counting ids..
return trade.order.orderId
async def submit_cancel(
self,
reqid: str,
) -> None:
"""Send cancel request for order id ``oid``.
"""
self.ib.cancelOrder(
Order(
orderId=reqid,
clientId=self.ib.client.clientId,
)
)
async def recv_trade_updates(
self,
to_trio: trio.abc.SendChannel,
) -> None:
"""Stream a ticker using the std L1 api.
"""
self.inline_errors(to_trio)
def push_tradesies(eventkit_obj, obj, fill=None):
"""Push events to trio task.
"""
if fill is not None:
# execution details event
item = ('fill', (obj, fill))
elif eventkit_obj.name() == 'positionEvent':
item = ('position', obj)
else:
item = ('status', obj)
log.info(f'eventkit event -> {eventkit_obj}: {item}')
try:
to_trio.send_nowait(item)
except trio.BrokenResourceError:
log.exception(f'Disconnected from {eventkit_obj} updates')
eventkit_obj.disconnect(push_tradesies)
# hook up to the weird eventkit object - event stream api
for ev_name in [
'orderStatusEvent', # all order updates
'execDetailsEvent', # all "fill" updates
'positionEvent', # avg price updates per symbol per account
# 'commissionReportEvent',
# XXX: ugh, it is a separate event from IB and it's
# emitted as follows:
# self.ib.commissionReportEvent.emit(trade, fill, report)
# XXX: not sure yet if we need these
# 'updatePortfolioEvent',
# XXX: these all seem to be weird ib_insync intrernal
# events that we probably don't care that much about
# given the internal design is wonky af..
# 'newOrderEvent',
# 'orderModifyEvent',
# 'cancelOrderEvent',
# 'openOrderEvent',
]:
eventkit_obj = getattr(self.ib, ev_name)
handler = partial(push_tradesies, eventkit_obj)
eventkit_obj.connect(handler)
# let the engine run and stream
await self.ib.disconnectedEvent
def inline_errors(
self,
to_trio: trio.abc.SendChannel,
) -> None:
# connect error msgs
def push_err(
reqId: int,
errorCode: int,
errorString: str,
contract: Contract,
) -> None:
log.error(errorString)
try:
to_trio.send_nowait((
'error',
# error "object"
{'reqid': reqId,
'message': errorString,
'contract': contract}
))
except trio.BrokenResourceError:
# XXX: eventkit's ``Event.emit()`` for whatever redic
# reason will catch and ignore regular exceptions
# resulting in tracebacks spammed to console..
# Manually do the dereg ourselves.
log.exception('Disconnected from errorEvent updates')
self.ib.errorEvent.disconnect(push_err)
self.ib.errorEvent.connect(push_err)
async def positions(
self,
account: str = '',
) -> List[Position]:
"""
Retrieve position info for ``account``.
"""
return self.ib.positions(account=account)
# default config ports # default config ports
@ -422,7 +616,6 @@ async def _aio_get_client(
""" """
# first check cache for existing client # first check cache for existing client
# breakpoint()
try: try:
if port: if port:
client = _client_cache[(host, port)] client = _client_cache[(host, port)]
@ -456,11 +649,13 @@ async def _aio_get_client(
else: else:
raise ConnectionRefusedError(_err) raise ConnectionRefusedError(_err)
# create and cache
try: try:
client = Client(ib) client = Client(ib)
_client_cache[(host, port)] = client _client_cache[(host, port)] = client
log.debug(f"Caching client for {(host, port)}") log.debug(f"Caching client for {(host, port)}")
yield client yield client
except BaseException: except BaseException:
ib.disconnect() ib.disconnect()
raise raise
@ -527,14 +722,13 @@ class _MethodProxy:
**kwargs **kwargs
) -> Any: ) -> Any:
return await self._portal.run( return await self._portal.run(
__name__, _trio_run_client_method,
'_trio_run_client_method',
method=meth, method=meth,
**kwargs **kwargs
) )
def get_method_proxy(portal, target) -> _MethodProxy: def get_client_proxy(portal, target=Client) -> _MethodProxy:
proxy = _MethodProxy(portal) proxy = _MethodProxy(portal)
@ -558,11 +752,11 @@ async def get_client(
""" """
async with maybe_spawn_brokerd( async with maybe_spawn_brokerd(
brokername='ib', brokername='ib',
expose_mods=[__name__],
infect_asyncio=True, infect_asyncio=True,
**kwargs **kwargs
) as portal: ) as portal:
yield get_method_proxy(portal, Client) proxy_client = get_client_proxy(portal)
yield proxy_client
# https://interactivebrokers.github.io/tws-api/tick_types.html # https://interactivebrokers.github.io/tws-api/tick_types.html
@ -586,6 +780,7 @@ def normalize(
# convert named tuples to dicts so we send usable keys # convert named tuples to dicts so we send usable keys
new_ticks = [] new_ticks = []
for tick in ticker.ticks: for tick in ticker.ticks:
if tick:
td = tick._asdict() td = tick._asdict()
td['type'] = tick_types.get(td['tickType'], 'n/a') td['type'] = tick_types.get(td['tickType'], 'n/a')
@ -645,7 +840,7 @@ async def fill_bars(
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 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 = 6, # 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.
@ -663,8 +858,8 @@ async def fill_bars(
method='bars', method='bars',
symbol=sym, symbol=sym,
end_dt=next_dt, end_dt=next_dt,
) )
shm.push(bars_array, prepend=True) shm.push(bars_array, prepend=True)
i += 1 i += 1
next_dt = bars[0].date next_dt = bars[0].date
@ -690,6 +885,25 @@ async def fill_bars(
await tractor.breakpoint() await tractor.breakpoint()
asset_type_map = {
'STK': 'stock',
'OPT': 'option',
'FUT': 'future',
'CONTFUT': 'continuous_future',
'CASH': 'forex',
'IND': 'index',
'CFD': 'cfd',
'BOND': 'bond',
'CMDTY': 'commodity',
'FOP': 'futures_option',
'FUND': 'mutual_fund',
'WAR': 'warrant',
'IOPT': 'warran',
'BAG': 'bag',
# 'NEWS': 'news',
}
# TODO: figure out how to share quote feeds sanely despite # TODO: figure out how to share quote feeds sanely despite
# the wacky ``ib_insync`` api. # the wacky ``ib_insync`` api.
# @tractor.msg.pub # @tractor.msg.pub
@ -699,6 +913,7 @@ async def stream_quotes(
symbols: List[str], symbols: List[str],
shm_token: Tuple[str, str, List[tuple]], shm_token: Tuple[str, str, List[tuple]],
loglevel: str = None, loglevel: str = None,
# compat for @tractor.msg.pub # compat for @tractor.msg.pub
topics: Any = None, topics: Any = None,
get_topics: Callable = None, get_topics: Callable = None,
@ -714,7 +929,7 @@ 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( contract, first_ticker, details = await _trio_run_client_method(
method='get_quote', method='get_quote',
symbol=sym, symbol=sym,
) )
@ -725,8 +940,8 @@ async def stream_quotes(
symbol=sym, symbol=sym,
) )
async with aclosing(stream): shm = None
async with trio.open_nursery() as ln:
# 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
@ -737,7 +952,6 @@ async def stream_quotes(
# maybe load historical ohlcv in to shared mem # maybe load historical ohlcv in to shared mem
# check if shm has already been created by previous # check if shm has already been created by previous
# feed initialization # feed initialization
async with trio.open_nursery() as ln:
if not writer_already_exists: if not writer_already_exists:
_local_buffer_writers[key] = True _local_buffer_writers[key] = True
@ -774,9 +988,35 @@ async def stream_quotes(
delay_s = times[-1] - times[times != times[-1]][-1] delay_s = times[-1] - times[times != times[-1]][-1]
subscribe_ohlc_for_increment(shm, delay_s) subscribe_ohlc_for_increment(shm, delay_s)
# pass back some symbol info like min_tick, trading_hours, etc.
# con = asdict(contract)
# syminfo = contract
syminfo = asdict(details)
syminfo.update(syminfo['contract'])
# TODO: more consistent field translation
atype = syminfo['asset_type'] = asset_type_map[syminfo['secType']]
# for stocks it seems TWS reports too small a tick size
# such that you can't submit orders with that granularity?
min_tick = 0.01 if atype == 'stock' else 0
syminfo['price_tick_size'] = max(syminfo['minTick'], min_tick)
# for "traditional" assets, volume is normally discreet, not a float
syminfo['lot_tick_size'] = 0.0
# TODO: for loop through all symbols passed in
init_msgs = {
# 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 # and that history has been written
await ctx.send_yield((shm_token, not writer_already_exists)) sym: {
'is_shm_writer': not writer_already_exists,
'shm_token': shm_token,
'symbol_info': syminfo,
}
}
await ctx.send_yield(init_msgs)
# check for special contract types # check for special contract types
if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex):
@ -788,7 +1028,7 @@ async def stream_quotes(
# no real volume so we have to calculate the price # no real volume so we have to calculate the price
suffix = 'secType' suffix = 'secType'
calc_price = True calc_price = True
ticker = first_ticker # ticker = first_ticker
# pass first quote asap # pass first quote asap
quote = normalize(first_ticker, calc_price=calc_price) quote = normalize(first_ticker, calc_price=calc_price)
@ -814,7 +1054,10 @@ async def stream_quotes(
calc_price = False # should be real volume for contract calc_price = False # should be real volume for contract
# with trio.move_on_after(10) as cs:
# wait for real volume on feed (trading might be closed) # wait for real volume on feed (trading might be closed)
async with aclosing(stream):
async for ticker in stream: async for ticker in stream:
# for a real volume contract we rait for the first # for a real volume contract we rait for the first
@ -836,6 +1079,34 @@ async def stream_quotes(
# ``aclosing()`` above? # ``aclosing()`` above?
break break
# enter stream loop
try:
await stream_and_write(
stream=stream,
calc_price=calc_price,
topic=topic,
writer_already_exists=writer_already_exists,
shm=shm,
suffix=suffix,
ctx=ctx,
)
finally:
if not writer_already_exists:
_local_buffer_writers[key] = False
async def stream_and_write(
stream,
calc_price: bool,
topic: str,
writer_already_exists: bool,
suffix: str,
ctx: tractor.Context,
shm: Optional['SharedArray'], # noqa
) -> None:
"""Core quote streaming and shm writing loop; optimize for speed!
"""
# real-time stream # real-time stream
async for ticker in stream: async for ticker in stream:
@ -895,3 +1166,113 @@ async def stream_quotes(
# ugh, clear ticks since we've consumed them # ugh, clear ticks since we've consumed them
ticker.ticks = [] ticker.ticks = []
def pack_position(pos: Position) -> Dict[str, Any]:
con = pos.contract
return {
'broker': 'ib',
'account': pos.account,
'symbol': con.symbol,
'currency': con.currency,
'size': float(pos.position),
'avg_price': float(pos.avgCost) / float(con.multiplier or 1.0),
}
@tractor.msg.pub(
send_on_connect={'local_trades': 'start'}
)
async def stream_trades(
loglevel: str = None,
get_topics: Callable = None,
) -> AsyncIterator[Dict[str, Any]]:
# XXX: required to propagate ``tractor`` loglevel to piker logging
get_console_log(loglevel or tractor.current_actor().loglevel)
stream = await _trio_run_client_method(
method='recv_trade_updates',
)
# deliver positions to subscriber before anything else
positions = await _trio_run_client_method(method='positions')
for pos in positions:
yield {'local_trades': ('position', pack_position(pos))}
action_map = {'BOT': 'buy', 'SLD': 'sell'}
async for event_name, item in stream:
# XXX: begin normalization of nonsense ib_insync internal
# object-state tracking representations...
if event_name == 'status':
# unwrap needed data from ib_insync internal objects
trade = item
status = trade.orderStatus
# skip duplicate filled updates - we get the deats
# from the execution details event
msg = {
'reqid': trade.order.orderId,
'status': status.status,
'filled': status.filled,
'reason': status.whyHeld,
# this seems to not be necessarily up to date in the
# execDetails event.. so we have to send it here I guess?
'remaining': status.remaining,
}
elif event_name == 'fill':
trade, fill = item
execu = fill.execution
msg = {
'reqid': execu.orderId,
'execid': execu.execId,
# supposedly IB server fill time
'broker_time': execu.time, # converted to float by us
# ns from main TCP handler by us inside ``ib_insync`` override
'time': fill.time,
'time_ns': time.time_ns(), # cuz why not
'action': action_map[execu.side],
'size': execu.shares,
'price': execu.price,
}
elif event_name == 'error':
msg = item
# f$#$% gawd dammit insync..
con = msg['contract']
if isinstance(con, Contract):
msg['contract'] = asdict(con)
if msg['reqid'] == -1:
log.error(pformat(msg))
# don't forward, it's pointless..
continue
elif event_name == 'position':
msg = pack_position(item)
if msg.get('reqid', 0) < -1:
# it's a trade event generated by TWS usage.
log.warning(f"TWS triggered trade:\n{pformat(msg)}")
msg['reqid'] = 'tws-' + str(-1 * msg['reqid'])
# mark msg as from "external system"
# TODO: probably something better then this..
msg['external'] = True
yield {'remote_trades': (event_name, msg)}
continue
yield {'local_trades': (event_name, msg)}

View File

@ -17,19 +17,29 @@
""" """
Kraken backend. Kraken backend.
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass, asdict, field from dataclasses import asdict, field
from types import ModuleType
from typing import List, Dict, Any, Tuple, Optional from typing import List, Dict, Any, Tuple, Optional
import json import json
import time import time
import trio_websocket import trio_websocket
from trio_websocket._impl import ConnectionClosed, DisconnectionTimeout from trio_websocket._impl import (
ConnectionClosed,
DisconnectionTimeout,
ConnectionRejected,
HandshakeError,
ConnectionTimeout,
)
import arrow import arrow
import asks import asks
import numpy as np import numpy as np
import trio import trio
import tractor import tractor
from pydantic.dataclasses import dataclass
from pydantic import BaseModel
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
@ -68,6 +78,68 @@ ohlc_dtype = np.dtype(_ohlc_dtype)
_show_wap_in_history = True _show_wap_in_history = True
_symbol_info_translation: Dict[str, str] = {
'tick_decimals': 'pair_decimals',
}
# https://www.kraken.com/features/api#get-tradable-pairs
class Pair(BaseModel):
altname: str # alternate pair name
wsname: str # WebSocket pair name (if available)
aclass_base: str # asset class of base component
base: str # asset id of base component
aclass_quote: str # asset class of quote component
quote: str # asset id of quote component
lot: str # volume lot size
pair_decimals: int # scaling decimal places for pair
lot_decimals: int # scaling decimal places for volume
# amount to multiply lot volume by to get currency volume
lot_multiplier: float
# array of leverage amounts available when buying
leverage_buy: List[int]
# array of leverage amounts available when selling
leverage_sell: List[int]
# fee schedule array in [volume, percent fee] tuples
fees: List[Tuple[int, float]]
# maker fee schedule array in [volume, percent fee] tuples (if on
# maker/taker)
fees_maker: List[Tuple[int, float]]
fee_volume_currency: str # volume discount currency
margin_call: str # margin call level
margin_stop: str # stop-out/liquidation margin level
ordermin: float # minimum order volume for pair
@dataclass
class OHLC:
"""Description of the flattened OHLC quote format.
For schema details see:
https://docs.kraken.com/websockets/#message-ohlc
"""
chan_id: int # internal kraken id
chan_name: str # eg. ohlc-1 (name-interval)
pair: str # fx pair
time: float # Begin time of interval, in seconds since epoch
etime: float # End time of interval, in seconds since epoch
open: float # Open price of interval
high: float # High price within interval
low: float # Low price within interval
close: float # Close price of interval
vwap: float # Volume weighted average price within interval
volume: float # Accumulated volume **within interval**
count: int # Number of trades within interval
# (sampled) generated tick data
ticks: List[Any] = field(default_factory=list)
class Client: class Client:
def __init__(self) -> None: def __init__(self) -> None:
@ -165,52 +237,27 @@ async def get_client() -> Client:
yield Client() yield Client()
@dataclass async def stream_messages(ws):
class OHLC:
"""Description of the flattened OHLC quote format.
For schema details see:
https://docs.kraken.com/websockets/#message-ohlc
"""
chan_id: int # internal kraken id
chan_name: str # eg. ohlc-1 (name-interval)
pair: str # fx pair
time: float # Begin time of interval, in seconds since epoch
etime: float # End time of interval, in seconds since epoch
open: float # Open price of interval
high: float # High price within interval
low: float # Low price within interval
close: float # Close price of interval
vwap: float # Volume weighted average price within interval
volume: float # Accumulated volume **within interval**
count: int # Number of trades within interval
# (sampled) generated tick data
ticks: List[Any] = field(default_factory=list)
# XXX: ugh, super hideous.. needs built-in converters.
def __post_init__(self):
for f, val in self.__dataclass_fields__.items():
if f == 'ticks':
continue
setattr(self, f, val.type(getattr(self, f)))
async def recv_msg(recv):
too_slow_count = last_hb = 0 too_slow_count = last_hb = 0
while True: while True:
with trio.move_on_after(1.5) as cs:
msg = await recv()
# trigger reconnection logic if too slow with trio.move_on_after(5) as cs:
msg = await ws.recv_msg()
# trigger reconnection if heartbeat is laggy
if cs.cancelled_caught: if cs.cancelled_caught:
too_slow_count += 1 too_slow_count += 1
if too_slow_count > 2:
if too_slow_count > 10:
log.warning( log.warning(
"Heartbeat is to slow, " "Heartbeat is too slow, resetting ws connection")
"resetting ws connection")
raise trio_websocket._impl.ConnectionClosed( await ws._connect()
"Reset Connection") too_slow_count = 0
continue
if isinstance(msg, dict): if isinstance(msg, dict):
if msg.get('event') == 'heartbeat': if msg.get('event') == 'heartbeat':
@ -218,11 +265,11 @@ async def recv_msg(recv):
now = time.time() now = time.time()
delay = now - last_hb delay = now - last_hb
last_hb = now last_hb = now
log.trace(f"Heartbeat after {delay}")
# TODO: hmm i guess we should use this # XXX: why tf is this not printing without --tl flag?
# for determining when to do connection log.debug(f"Heartbeat after {delay}")
# resets eh? # print(f"Heartbeat after {delay}")
continue continue
err = msg.get('errorMessage') err = msg.get('errorMessage')
@ -292,6 +339,93 @@ def make_sub(pairs: List[str], data: Dict[str, Any]) -> Dict[str, str]:
} }
class AutoReconWs:
"""Make ``trio_websocketw` sockets stay up no matter the bs.
"""
recon_errors = (
ConnectionClosed,
DisconnectionTimeout,
ConnectionRejected,
HandshakeError,
ConnectionTimeout,
)
def __init__(
self,
url: str,
stack: AsyncExitStack,
serializer: ModuleType = json,
):
self.url = url
self._stack = stack
self._ws: 'WebSocketConnection' = None # noqa
async def _connect(
self,
tries: int = 10000,
) -> None:
try:
await self._stack.aclose()
except (DisconnectionTimeout, RuntimeError):
await trio.sleep(1)
last_err = None
for i in range(tries):
try:
self._ws = await self._stack.enter_async_context(
trio_websocket.open_websocket_url(self.url)
)
log.info(f'Connection success: {self.url}')
return
except self.recon_errors as err:
last_err = err
log.error(
f'{self} connection bail with '
f'{type(err)}...retry attempt {i}'
)
await trio.sleep(1)
continue
else:
log.exception('ws connection fail...')
raise last_err
async def send_msg(
self,
data: Any,
) -> None:
while True:
try:
return await self._ws.send_message(json.dumps(data))
except self.recon_errors:
await self._connect()
async def recv_msg(
self,
) -> Any:
while True:
try:
return json.loads(await self._ws.get_message())
except self.recon_errors:
await self._connect()
@asynccontextmanager
async def open_autorecon_ws(url):
"""Apparently we can QoS for all sorts of reasons..so catch em.
"""
async with AsyncExitStack() as stack:
ws = AutoReconWs(url, stack)
await ws._connect()
try:
yield ws
finally:
await stack.aclose()
# @tractor.msg.pub # @tractor.msg.pub
async def stream_quotes( async def stream_quotes(
# get_topics: Callable, # get_topics: Callable,
@ -312,11 +446,17 @@ async def stream_quotes(
get_console_log(loglevel or tractor.current_actor().loglevel) get_console_log(loglevel or tractor.current_actor().loglevel)
ws_pairs = {} ws_pairs = {}
sym_infos = {}
async with get_client() as client: async with get_client() as client:
# keep client cached for real-time section # keep client cached for real-time section
for sym in symbols: for sym in symbols:
ws_pairs[sym] = (await client.symbol_info(sym))['wsname'] si = Pair(**await client.symbol_info(sym)) # validation
syminfo = si.dict()
syminfo['price_tick_size'] = 1 / 10**si.pair_decimals
syminfo['lot_tick_size'] = 1 / 10**si.lot_decimals
sym_infos[sym] = syminfo
ws_pairs[sym] = si.wsname
# maybe load historical ohlcv in to shared mem # maybe load historical ohlcv in to shared mem
# check if shm has already been created by previous # check if shm has already been created by previous
@ -340,13 +480,20 @@ async def stream_quotes(
delay_s = times[-1] - times[times != times[-1]][-1] delay_s = times[-1] - times[times != times[-1]][-1]
subscribe_ohlc_for_increment(shm, delay_s) subscribe_ohlc_for_increment(shm, delay_s)
yield shm_token, not writer_exists # yield shm_token, not writer_exists
init_msgs = {
# pass back token, and bool, signalling if we're the writer
# and that history has been written
symbol: {
'is_shm_writer': not writer_exists,
'shm_token': shm_token,
'symbol_info': sym_infos[sym],
}
# for sym in symbols
}
yield init_msgs
while True: async with open_autorecon_ws('wss://ws.kraken.com/') as ws:
try:
async with trio_websocket.open_websocket_url(
'wss://ws.kraken.com/',
) as ws:
# XXX: setup subs # XXX: setup subs
# https://docs.kraken.com/websockets/#message-subscribe # https://docs.kraken.com/websockets/#message-subscribe
@ -361,21 +508,19 @@ async def stream_quotes(
# be completely fine to request from a separate task # be completely fine to request from a separate task
# since internally the ws methods appear to be FIFO # since internally the ws methods appear to be FIFO
# locked. # locked.
await ws.send_message(json.dumps(ohlc_sub)) await ws.send_msg(ohlc_sub)
# trade data (aka L1) # trade data (aka L1)
l1_sub = make_sub( l1_sub = make_sub(
list(ws_pairs.values()), list(ws_pairs.values()),
{'name': 'spread'} # 'depth': 10} {'name': 'spread'} # 'depth': 10}
) )
await ws.send_message(json.dumps(l1_sub))
async def recv(): await ws.send_msg(l1_sub)
return json.loads(await ws.get_message())
# pull a first quote and deliver # pull a first quote and deliver
msg_gen = recv_msg(recv) msg_gen = stream_messages(ws)
typ, ohlc_last = await msg_gen.__anext__() typ, ohlc_last = await msg_gen.__anext__()
topic, quote = normalize(ohlc_last) topic, quote = normalize(ohlc_last)
@ -459,6 +604,3 @@ async def stream_quotes(
# XXX: format required by ``tractor.msg.pub`` # XXX: format required by ``tractor.msg.pub``
# requires a ``Dict[topic: str, quote: dict]`` # requires a ``Dict[topic: str, quote: dict]``
yield {topic: quote} yield {topic: quote}
except (ConnectionClosed, DisconnectionTimeout):
log.exception("Good job kraken...reconnecting")

View File

@ -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
@ -21,15 +21,15 @@ We provide tsdb integrations for retrieving
and storing data from your brokers as well as and storing data from your brokers as well as
sharing your feeds with other fellow pikers. sharing your feeds with other fellow pikers.
""" """
from dataclasses import dataclass from dataclasses import dataclass, field
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from importlib import import_module from importlib import import_module
from types import ModuleType from types import ModuleType
from typing import ( from typing import (
Dict, List, Any, Dict, Any, Sequence, AsyncIterator, Optional
Sequence, AsyncIterator, Optional
) )
import trio
import tractor import tractor
from ..brokers import get_brokermod from ..brokers import get_brokermod
@ -42,7 +42,7 @@ from ._sharedmem import (
ShmArray, ShmArray,
get_shm_token, get_shm_token,
) )
from ._source import base_iohlc_dtype from ._source import base_iohlc_dtype, Symbol
from ._buffer import ( from ._buffer import (
increment_ohlc_buffer, increment_ohlc_buffer,
subscribe_ohlc_for_increment subscribe_ohlc_for_increment
@ -87,10 +87,11 @@ _data_mods = [
@asynccontextmanager @asynccontextmanager
async def maybe_spawn_brokerd( async def maybe_spawn_brokerd(
brokername: str, brokername: str,
sleep: float = 0.5,
loglevel: Optional[str] = None, loglevel: Optional[str] = None,
expose_mods: List = [],
**tractor_kwargs, # XXX: you should pretty much never want debug mode
# for data daemons when running in production.
debug_mode: bool = True,
) -> tractor._portal.Portal: ) -> tractor._portal.Portal:
"""If no ``brokerd.{brokername}`` daemon-actor can be found, """If no ``brokerd.{brokername}`` daemon-actor can be found,
spawn one in a local subactor and return a portal to it. spawn one in a local subactor and return a portal to it.
@ -98,11 +99,6 @@ async def maybe_spawn_brokerd(
if loglevel: if loglevel:
get_console_log(loglevel) get_console_log(loglevel)
# disable debugger in brokerd?
# tractor._state._runtime_vars['_debug_mode'] = False
tractor_kwargs['loglevel'] = loglevel
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:
@ -114,18 +110,25 @@ async def maybe_spawn_brokerd(
else: # no daemon has been spawned yet else: # no daemon has been spawned yet
log.info(f"Spawning {brokername} broker daemon") log.info(f"Spawning {brokername} broker daemon")
# retrieve any special config from the broker mod
tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {}) tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {})
async with tractor.open_nursery() as nursery:
async with tractor.open_nursery(
#debug_mode=debug_mode,
) as nursery:
try: try:
# spawn new daemon # spawn new daemon
portal = await nursery.start_actor( portal = await nursery.start_actor(
dname, dname,
enable_modules=_data_mods + [brokermod.__name__], enable_modules=_data_mods + [brokermod.__name__],
loglevel=loglevel, loglevel=loglevel,
debug_mode=debug_mode,
**tractor_kwargs **tractor_kwargs
) )
async with tractor.wait_for_actor(dname) as portal: async with tractor.wait_for_actor(dname) as portal:
yield portal yield portal
finally: finally:
# client code may block indefinitely so cancel when # client code may block indefinitely so cancel when
# teardown is invoked # teardown is invoked
@ -144,9 +147,15 @@ class Feed:
name: str name: str
stream: AsyncIterator[Dict[str, Any]] stream: AsyncIterator[Dict[str, Any]]
shm: ShmArray shm: ShmArray
mod: ModuleType
# ticks: ShmArray # ticks: ShmArray
_brokerd_portal: tractor._portal.Portal _brokerd_portal: tractor._portal.Portal
_index_stream: Optional[AsyncIterator[Dict[str, Any]]] = None _index_stream: Optional[AsyncIterator[int]] = None
_trade_stream: Optional[AsyncIterator[Dict[str, Any]]] = None
# cache of symbol info messages received as first message when
# a stream startsc.
symbols: Dict[str, Symbol] = field(default_factory=dict)
async def receive(self) -> dict: async def receive(self) -> dict:
return await self.stream.__anext__() return await self.stream.__anext__()
@ -164,6 +173,33 @@ class Feed:
return self._index_stream return self._index_stream
async def recv_trades_data(self) -> AsyncIterator[dict]:
if not getattr(self.mod, 'stream_trades', False):
log.warning(
f"{self.mod.name} doesn't have trade data support yet :(")
if not self._trade_stream:
raise RuntimeError(
f'Can not stream trade data from {self.mod.name}')
# NOTE: this can be faked by setting a rx chan
# using the ``_.set_fake_trades_stream()`` method
if self._trade_stream is None:
self._trade_stream = await self._brokerd_portal.run(
self.mod.stream_trades,
# do we need this? -> yes
# the broker side must declare this key
# in messages, though we could probably use
# more then one?
topics=['local_trades'],
)
return self._trade_stream
def sym_to_shm_key( def sym_to_shm_key(
broker: str, broker: str,
@ -174,23 +210,26 @@ def sym_to_shm_key(
@asynccontextmanager @asynccontextmanager
async def open_feed( async def open_feed(
name: str, brokername: str,
symbols: Sequence[str], symbols: Sequence[str],
loglevel: Optional[str] = None, loglevel: Optional[str] = None,
) -> AsyncIterator[Dict[str, Any]]: ) -> AsyncIterator[Dict[str, Any]]:
"""Open a "data feed" which provides streamed real-time quotes. """Open a "data feed" which provides streamed real-time quotes.
""" """
try: try:
mod = get_brokermod(name) mod = get_brokermod(brokername)
except ImportError: except ImportError:
mod = get_ingestormod(name) mod = get_ingestormod(brokername)
if loglevel is None: if loglevel is None:
loglevel = tractor.current_actor().loglevel loglevel = tractor.current_actor().loglevel
# TODO: do all!
sym = symbols[0]
# Attempt to allocate (or attach to) shm array for this broker/symbol # Attempt to allocate (or attach to) shm array for this broker/symbol
shm, opened = maybe_open_shm_array( shm, opened = maybe_open_shm_array(
key=sym_to_shm_key(name, symbols[0]), key=sym_to_shm_key(brokername, sym),
# use any broker defined ohlc dtype: # use any broker defined ohlc dtype:
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype), dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
@ -200,33 +239,61 @@ async def open_feed(
) )
async with maybe_spawn_brokerd( async with maybe_spawn_brokerd(
mod.name,
brokername,
loglevel=loglevel, loglevel=loglevel,
# TODO: add a cli flag for this
# debug_mode=False,
) as portal: ) as portal:
stream = await portal.run( stream = await portal.run(
mod.stream_quotes, mod.stream_quotes,
# TODO: actually handy multiple symbols...
symbols=symbols, symbols=symbols,
shm_token=shm.token, shm_token=shm.token,
# compat with eventual ``tractor.msg.pub`` # compat with eventual ``tractor.msg.pub``
topics=symbols, topics=symbols,
loglevel=loglevel,
)
feed = Feed(
name=brokername,
stream=stream,
shm=shm,
mod=mod,
_brokerd_portal=portal,
) )
# TODO: we can't do this **and** be compate with # TODO: we can't do this **and** be compate with
# ``tractor.msg.pub``, should we maybe just drop this after # ``tractor.msg.pub``, should we maybe just drop this after
# tests are in? # tests are in?
shm_token, is_writer = await stream.receive() init_msg = await stream.receive()
for sym, data in init_msg.items():
si = data['symbol_info']
symbol = Symbol(
key=sym,
type_key=si.get('asset_type', 'forex'),
tick_size=si.get('price_tick_size', 0.01),
lot_tick_size=si.get('lot_tick_size', 0.0),
)
symbol.broker_info[brokername] = si
feed.symbols[sym] = symbol
shm_token = data['shm_token']
if opened: if opened:
assert is_writer assert data['is_shm_writer']
log.info("Started shared mem bar writer") log.info("Started shared mem bar writer")
shm_token['dtype_descr'] = list(shm_token['dtype_descr']) shm_token['dtype_descr'] = list(shm_token['dtype_descr'])
assert shm_token == shm.token # sanity assert shm_token == shm.token # sanity
yield Feed( yield feed
name=name,
stream=stream,
shm=shm,
_brokerd_portal=portal,
)

View File

@ -65,14 +65,6 @@ async def increment_ohlc_buffer(
# adjust delay to compensate for trio processing time # adjust delay to compensate for trio processing time
ad = min(_shms.keys()) - 0.001 ad = min(_shms.keys()) - 0.001
# async def sleep():
# """Sleep until next time frames worth has passed from last bar.
# """
# # last_ts = shm.array[-1]['time']
# # delay = max((last_ts + ad) - time.time(), 0)
# # await trio.sleep(delay)
# await trio.sleep(ad)
total_s = 0 # total seconds counted total_s = 0 # total seconds counted
lowest = min(_shms.keys()) lowest = min(_shms.keys())
ad = lowest - 0.001 ad = lowest - 0.001
@ -83,9 +75,6 @@ async def increment_ohlc_buffer(
await trio.sleep(ad) await trio.sleep(ad)
total_s += lowest total_s += lowest
# # sleep for duration of current bar
# await sleep()
# increment all subscribed shm arrays # increment all subscribed shm arrays
# TODO: this in ``numba`` # TODO: this in ``numba``
for delay_s, shms in _shms.items(): for delay_s, shms in _shms.items():

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet # 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

View File

@ -17,12 +17,12 @@
""" """
numpy data source coversion helpers. numpy data source coversion helpers.
""" """
from typing import List from typing import Dict, Any, List
import decimal import decimal
from dataclasses import dataclass
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from pydantic import BaseModel
# from numba import from_dtype # from numba import from_dtype
@ -75,23 +75,41 @@ def ohlc_zeros(length: int) -> np.ndarray:
return np.zeros(length, dtype=base_ohlc_dtype) return np.zeros(length, dtype=base_ohlc_dtype)
@dataclass class Symbol(BaseModel):
class Symbol:
"""I guess this is some kinda container thing for dealing with """I guess this is some kinda container thing for dealing with
all the different meta-data formats from brokers? all the different meta-data formats from brokers?
Yah, i guess dats what it izz.
""" """
key: str = '' key: str
brokers: List[str] = None tick_size: float = 0.01
min_tick: float = 0.01 lot_tick_size: float = 0.01 # "volume" precision as min step value
contract: str = '' broker_info: Dict[str, Dict[str, Any]] = {}
# specifies a "class" of financial instrument
# ex. stock, futer, option, bond etc.
type_key: str
@property
def brokers(self) -> List[str]:
return list(self.broker_info.keys())
def digits(self) -> int: def digits(self) -> int:
"""Return the trailing number of digits specified by the """Return the trailing number of digits specified by the min
min tick size for the instrument. tick size for the instrument.
""" """
return float_digits(self.min_tick) return float_digits(self.tick_size)
def lot_digits(self) -> int:
return float_digits(self.lot_tick_size)
def nearest_tick(self, value: float) -> float:
"""Return the nearest tick value based on mininum increment.
"""
mult = 1 / self.tick_size
return round(value * mult) / mult
def from_df( def from_df(

View File

@ -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

View File

@ -0,0 +1,20 @@
# 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/>.
"""
Market machinery for order executions, book, management.
"""

View File

@ -0,0 +1,250 @@
# 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/>.
"""
Orders and execution client API.
"""
from contextlib import asynccontextmanager
from typing import Dict, Tuple, List
from pprint import pformat
from dataclasses import dataclass, field
import trio
import tractor
# import msgspec
from ..data._source import Symbol
from ..log import get_logger
from ._ems import _emsd_main
log = get_logger(__name__)
# class Order(msgspec.Struct):
# action: str
# price: float
# size: float
# symbol: str
# brokers: List[str]
# oid: str
# exec_mode: str
@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, this is mostly for keeping local state to match the EMS
and use received events to trigger graphics updates.
"""
# mem channels used to relay order requests to the EMS daemon
_to_ems: trio.abc.SendChannel
_from_order_book: trio.abc.ReceiveChannel
_sent_orders: Dict[str, dict] = field(default_factory=dict)
_ready_to_receive: trio.Event = trio.Event()
def send(
self,
uuid: str,
symbol: str,
brokers: List[str],
price: float,
size: float,
action: str,
exec_mode: str,
) -> dict:
cmd = {
'action': action,
'price': price,
'size': size,
'symbol': symbol,
'brokers': brokers,
'oid': uuid,
'exec_mode': exec_mode, # dark or live
}
self._sent_orders[uuid] = cmd
self._to_ems.send_nowait(cmd)
return cmd
def update(
self,
uuid: str,
**data: dict,
) -> dict:
cmd = self._sent_orders[uuid]
cmd.update(data)
self._sent_orders[uuid] = cmd
self._to_ems.send_nowait(cmd)
return cmd
def cancel(self, uuid: str) -> bool:
"""Cancel an order (or alert) from the EMS.
"""
cmd = self._sent_orders[uuid]
msg = {
'action': 'cancel',
'oid': uuid,
'symbol': cmd['symbol'],
}
self._to_ems.send_nowait(msg)
_orders: OrderBook = None
def get_orders(
emsd_uid: Tuple[str, str] = None
) -> OrderBook:
""""
OrderBook singleton factory per actor.
"""
if emsd_uid is not None:
# TODO: read in target emsd's active book on startup
pass
global _orders
if _orders is None:
# setup local ui event streaming channels for request/resp
# streamging with EMS daemon
_orders = OrderBook(*trio.open_memory_channel(1))
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 but could be
any other client service code). This process 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 relayed to any consumer(s) that called this function using
a ``tractor`` portal.
This effectively makes order messages look like they're being
"pushed" from the parent to the EMS where local sync code is likely
doing the pushing from some UI.
"""
book = get_orders()
orders_stream = book._from_order_book
# signal that ems connection is up and ready
book._ready_to_receive.set()
async for cmd in orders_stream:
# send msg over IPC / wire
log.info(f'Send order cmd:\n{pformat(cmd)}')
yield cmd
@asynccontextmanager
async def maybe_open_emsd(
) -> 'StreamReceiveChannel': # noqa
async with tractor.find_actor('emsd') as portal:
if portal is not None:
yield portal
else:
# we gotta spawn it
log.info("Spawning EMS daemon")
# TODO: add ``maybe_spawn_emsd()`` for this
async with tractor.open_nursery() as n:
portal = await n.start_actor(
'emsd',
enable_modules=[
'piker.exchange._ems',
],
)
yield portal
@asynccontextmanager
async def open_ems(
broker: str,
symbol: Symbol,
) -> None:
"""Spawn an EMS daemon and begin sending orders and receiving
alerts.
This EMS tries to reduce most broker's terrible order entry apis to
a very simple protocol built on a few easy to grok and/or
"rantsy" premises:
- most users will prefer "dark mode" where orders are not submitted
to a broker until and execution condition is triggered
(aka client-side "hidden orders")
- Brokers over-complicate their apis and generally speaking hire
poor designers to create them. We're better off using creating a super
minimal, schema-simple, request-event-stream protocol to unify all the
existing piles of shit (and shocker, it'll probably just end up
looking like a decent crypto exchange's api)
- all order types can be implemented with client-side limit orders
- we aren't reinventing a wheel in this case since none of these
brokers are exposing FIX protocol; it is they doing the re-invention.
TODO: make some fancy diagrams using mermaid.io
the possible set of responses from the stream is currently:
- 'dark_submitted', 'broker_submitted'
- 'dark_cancelled', 'broker_cancelled'
- 'dark_executed', 'broker_executed'
- 'broker_filled'
"""
actor = tractor.current_actor()
# wait for service to connect back to us signalling
# ready for order commands
book = get_orders()
async with maybe_open_emsd() as portal:
trades_stream = await portal.run(
_emsd_main,
client_actor_name=actor.name,
broker=broker,
symbol=symbol.key,
)
with trio.fail_after(10):
await book._ready_to_receive.wait()
yield book, trades_stream

View File

@ -0,0 +1,681 @@
# 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 da suit parlances: "Execution management systems"
"""
from pprint import pformat
import time
from dataclasses import dataclass, field
from typing import (
AsyncIterator, Dict, Callable, Tuple,
)
from bidict import bidict
import trio
from trio_typing import TaskStatus
import tractor
from .. import data
from ..log import get_logger
from ..data._normalize import iterticks
from ._paper_engine import PaperBoi, simulate_fills
log = get_logger(__name__)
# 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
elif trigger_price <= known_last:
def check_lt(price: float) -> bool:
return price <= trigger_price
return check_lt
else:
return None, None
@dataclass
class _DarkBook:
"""Client-side execution book.
Contains conditions for executions (aka "orders") which are not
exposed to brokers and thus the market; i.e. these are privacy
focussed "client side" orders.
A singleton instance is created per EMS actor (for now).
"""
broker: str
# levels which have an executable action (eg. alert, order, signal)
orders: Dict[
str, # symbol
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)
# mapping of broker order ids to piker ems ids
_broker2ems_ids: Dict[str, str] = field(default_factory=bidict)
_books: Dict[str, _DarkBook] = {}
def get_dark_book(broker: str) -> _DarkBook:
global _books
return _books.setdefault(broker, _DarkBook(broker))
# XXX: this is in place to prevent accidental positions that are too
# big. Now obviously this won't make sense for crypto like BTC, but
# for most traditional brokers it should be fine unless you start
# slinging NQ futes or something.
_DEFAULT_SIZE: float = 1.0
async def execute_triggers(
broker: str,
symbol: str,
stream: 'tractor.ReceiveStream', # noqa
ctx: tractor.Context,
client: 'Client', # noqa
book: _DarkBook,
) -> None:
"""Core dark order trigger loop.
Scan the (price) data feed and submit triggered orders
to broker.
"""
# this stream may eventually contain multiple symbols
# XXX: optimize this for speed!
async for quotes in stream:
# TODO: numba all this!
# start = time.time()
for sym, quote in quotes.items():
execs = book.orders.get(sym, None)
if execs is None:
continue
for tick in iterticks(
quote,
# dark order price filter(s)
types=('ask', 'bid', 'trade', 'last')
):
price = tick.get('price')
ttype = tick['type']
# update to keep new cmds informed
book.lasts[(broker, symbol)] = price
for oid, (
pred,
tf,
cmd,
percent_away,
abs_diff_away
) in (
tuple(execs.items())
):
if (ttype not in tf) or (not pred(price)):
# majority of iterations will be non-matches
continue
# submit_price = price + price*percent_away
submit_price = price + abs_diff_away
log.info(
f'Dark order triggered for price {price}\n'
f'Submitting order @ price {submit_price}')
reqid = await client.submit_limit(
oid=oid,
# this is a brand new order request for the
# underlying broker so we set out "broker request
# id" (brid) as nothing so that the broker
# client knows that we aren't trying to modify
# an existing order.
brid=None,
symbol=sym,
action=cmd['action'],
price=submit_price,
size=cmd['size'],
)
# register broker request id to ems id
book._broker2ems_ids[reqid] = oid
resp = {
'resp': 'dark_executed',
'time_ns': time.time_ns(),
'trigger_price': price,
'cmd': cmd, # original request message
'broker_reqid': reqid,
'broker': broker,
'oid': oid, # piker order id
}
# remove exec-condition from set
log.info(f'removing pred for {oid}')
execs.pop(oid)
await ctx.send_yield(resp)
else: # condition scan loop complete
log.debug(f'execs are {execs}')
if execs:
book.orders[symbol] = execs
# print(f'execs scan took: {time.time() - start}')
async def exec_loop(
ctx: tractor.Context,
broker: str,
symbol: str,
_exec_mode: str,
task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED,
) -> AsyncIterator[dict]:
"""Main scan loop for order execution conditions and submission
to brokers.
"""
async with data.open_feed(
broker,
[symbol],
loglevel='info',
) as feed:
# TODO: get initial price quote from target broker
first_quote = await feed.receive()
book = get_dark_book(broker)
book.lasts[(broker, symbol)] = first_quote[symbol]['last']
# TODO: wrap this in a more re-usable general api
client_factory = getattr(feed.mod, 'get_client_proxy', None)
if client_factory is not None and _exec_mode != 'paper':
# we have an order API for this broker
client = client_factory(feed._brokerd_portal)
else:
# force paper mode
log.warning(f'Entering paper trading mode for {broker}')
client = PaperBoi(
broker,
*trio.open_memory_channel(100),
_buys={},
_sells={},
_reqids={},
)
# for paper mode we need to mock this trades response feed
# so we pass a duck-typed feed-looking mem chan which is fed
# fill and submission events from the exec loop
feed._trade_stream = client.trade_stream
# init the trades stream
client._to_trade_stream.send_nowait({'local_trades': 'start'})
_exec_mode = 'paper'
# return control to parent task
task_status.started((first_quote, feed, client))
# shield this field so the remote brokerd does not get cancelled
stream = feed.stream
with stream.shield():
async with trio.open_nursery() as n:
n.start_soon(
execute_triggers,
broker,
symbol,
stream,
ctx,
client,
book
)
if _exec_mode == 'paper':
n.start_soon(simulate_fills, stream.clone(), client)
# TODO: lots of cases still to handle
# XXX: right now this is very very ad-hoc to IB
# - short-sale but securities haven't been located, in this case we
# should probably keep the order in some kind of weird state or cancel
# it outright?
# status='PendingSubmit', message=''),
# status='Cancelled', message='Error 404,
# reqId 1550: Order held while securities are located.'),
# status='PreSubmitted', message='')],
async def process_broker_trades(
ctx: tractor.Context,
feed: 'Feed', # noqa
book: _DarkBook,
task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED,
) -> AsyncIterator[dict]:
"""Trades update loop - receive updates from broker, convert
to EMS responses, transmit to ordering client(s).
This is where trade confirmations from the broker are processed
and appropriate responses relayed back to the original EMS client
actor. There is a messaging translation layer throughout.
Expected message translation(s):
broker ems
'error' -> log it locally (for now)
'status' -> relabel as 'broker_<status>', if complete send 'executed'
'fill' -> 'broker_filled'
Currently accepted status values from IB:
{'presubmitted', 'submitted', 'cancelled', 'inactive'}
"""
broker = feed.mod.name
with trio.fail_after(5):
# in the paper engine case this is just a mem receive channel
trades_stream = await feed.recv_trades_data()
first = await trades_stream.__anext__()
# startup msg expected as first from broker backend
assert first['local_trades'] == 'start'
task_status.started()
async for event in trades_stream:
name, msg = event['local_trades']
log.info(f'Received broker trade event:\n{pformat(msg)}')
if name == 'position':
msg['resp'] = 'position'
# relay through
await ctx.send_yield(msg)
continue
# Get the broker (order) request id, this **must** be normalized
# into messaging provided by the broker backend
reqid = msg['reqid']
# make response packet to EMS client(s)
oid = book._broker2ems_ids.get(reqid)
if oid is None:
# paper engine race case: ``Client.submit_limit()`` hasn't
# returned yet and provided an output reqid to register
# locally, so we need to retreive the oid that was already
# packed at submission since we already know it ahead of
# time
paper = msg.get('paper_info')
if paper:
oid = paper['oid']
else:
msg.get('external')
if not msg:
log.error(f"Unknown trade event {event}")
continue
resp = {
'resp': None, # placeholder
'oid': oid
}
if name in (
'error',
):
# TODO: figure out how this will interact with EMS clients
# for ex. on an error do we react with a dark orders
# management response, like cancelling all dark orders?
# This looks like a supervision policy for pending orders on
# some unexpected failure - something we need to think more
# about. In most default situations, with composed orders
# (ex. brackets), most brokers seem to use a oca policy.
message = msg['message']
# XXX should we make one when it's blank?
log.error(pformat(message))
# TODO: getting this bs, prolly need to handle status messages
# 'Market data farm connection is OK:usfarm.nj'
# another stupid ib error to handle
# if 10147 in message: cancel
# don't relay message to order requester client
continue
elif name in (
'status',
):
# TODO: templating the ib statuses in comparison with other
# brokers is likely the way to go:
# https://interactivebrokers.github.io/tws-api/interfaceIBApi_1_1EWrapper.html#a17f2a02d6449710b6394d0266a353313
# short list:
# - PendingSubmit
# - PendingCancel
# - PreSubmitted (simulated orders)
# - ApiCancelled (cancelled by client before submission to routing)
# - Cancelled
# - Filled
# - Inactive (reject or cancelled but not by trader)
# everyone doin camel case
status = msg['status'].lower()
if status == 'filled':
# conditional execution is fully complete, no more
# fills for the noted order
if not msg['remaining']:
resp['resp'] = 'broker_executed'
log.info(f'Execution for {oid} is complete!')
# just log it
else:
log.info(f'{broker} filled {msg}')
else:
# one of (submitted, cancelled)
resp['resp'] = 'broker_' + status
elif name in (
'fill',
):
# proxy through the "fill" result(s)
resp['resp'] = 'broker_filled'
resp.update(msg)
log.info(f'\nFill for {oid} cleared with:\n{pformat(resp)}')
# respond to requesting client
await ctx.send_yield(resp)
async def process_order_cmds(
ctx: tractor.Context,
cmd_stream: 'tractor.ReceiveStream', # noqa
symbol: str,
feed: 'Feed', # noqa
client: 'Client', # noqa
dark_book: _DarkBook,
) -> None:
async for cmd in cmd_stream:
log.info(f'Received order cmd:\n{pformat(cmd)}')
action = cmd['action']
oid = cmd['oid']
brid = dark_book._broker2ems_ids.inverse.get(oid)
# TODO: can't wait for this stuff to land in 3.10
# https://www.python.org/dev/peps/pep-0636/#going-to-the-cloud-mappings
if action in ('cancel',):
# check for live-broker order
if brid:
log.info("Submitting cancel for live order")
await client.submit_cancel(reqid=brid)
# check for EMS active exec
else:
try:
dark_book.orders[symbol].pop(oid, None)
await ctx.send_yield({
'resp': 'dark_cancelled',
'oid': oid
})
except KeyError:
log.exception(f'No dark order for {symbol}?')
elif action in ('alert', 'buy', 'sell',):
sym = cmd['symbol']
trigger_price = cmd['price']
size = cmd['size']
brokers = cmd['brokers']
exec_mode = cmd['exec_mode']
broker = brokers[0]
last = dark_book.lasts[(broker, sym)]
if exec_mode == 'live' and action in ('buy', 'sell',):
# register broker id for ems id
order_id = await client.submit_limit(
oid=oid, # no ib support for oids...
# if this is None, creates a new order
# otherwise will modify any existing one
brid=brid,
symbol=sym,
action=action,
price=trigger_price,
size=size,
)
if brid:
assert dark_book._broker2ems_ids[brid] == oid
# if we already had a broker order id then
# this is likely an order update commmand.
log.info(f"Modifying order: {brid}")
else:
dark_book._broker2ems_ids[order_id] = oid
# XXX: the trades data broker response loop
# (``process_broker_trades()`` above) will
# handle sending the ems side acks back to
# the cmd sender from here
elif exec_mode in ('dark', 'paper') or (
action in ('alert')
):
# submit order to local EMS
# 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 = mk_check(trigger_price, last)
tick_slap: float = 5
min_tick = feed.symbols[sym].tick_size
if action == 'buy':
tickfilter = ('ask', 'last', 'trade')
percent_away = 0.005
# TODO: we probably need to scale this based
# on some near term historical spread
# measure?
abs_diff_away = tick_slap * min_tick
elif action == 'sell':
tickfilter = ('bid', 'last', 'trade')
percent_away = -0.005
abs_diff_away = -tick_slap * min_tick
else: # alert
tickfilter = ('trade', 'utrade', 'last')
percent_away = 0
abs_diff_away = 0
# submit execution/order to EMS scan loop
# FYI: this may result in an override of an existing
# dark book entry if the order id already exists
dark_book.orders.setdefault(
sym, {}
)[oid] = (
pred,
tickfilter,
cmd,
percent_away,
abs_diff_away
)
# TODO: if the predicate resolves immediately send the
# execution to the broker asap? Or no?
# ack-response that order is live in EMS
await ctx.send_yield({
'resp': 'dark_submitted',
'oid': oid
})
@tractor.stream
async def _emsd_main(
ctx: tractor.Context,
client_actor_name: str,
broker: str,
symbol: str,
_mode: str = 'dark', # ('paper', 'dark', 'live')
) -> None:
"""EMS (sub)actor entrypoint providing the
execution management (micro)service which conducts broker
order control on behalf of clients.
This is the daemon (child) side routine which starts an EMS runtime
(one per broker-feed) and and begins streaming back alerts from
broker executions/fills.
``send_order_cmds()`` is called here to execute in a task back in
the actor which started this service (spawned this actor), presuming
capabilities allow it, such that requests for EMS executions are
received in a stream from that client actor and then responses are
streamed back up to the original calling task in the same client.
The task tree is:
- ``_emsd_main()``:
accepts order cmds, registers execs with exec loop
- ``exec_loop()``:
run (dark) conditions on inputs and trigger broker submissions
- ``process_broker_trades()``:
accept normalized trades responses, process and relay to ems client(s)
"""
from ._client import send_order_cmds
dark_book = get_dark_book(broker)
# get a portal back to the client
async with tractor.wait_for_actor(client_actor_name) as portal:
# spawn one task per broker feed
async with trio.open_nursery() as n:
# TODO: eventually support N-brokers
# start the condition scan loop
quote, feed, client = await n.start(
exec_loop,
ctx,
broker,
symbol,
_mode,
)
await n.start(
process_broker_trades,
ctx,
feed,
dark_book,
)
# connect back to the calling actor (the one that is
# acting as an EMS client and will submit orders) to
# receive requests pushed over a tractor stream
# using (for now) an async generator.
order_stream = await portal.run(send_order_cmds)
# start inbound order request processing
await process_order_cmds(
ctx,
order_stream,
symbol,
feed,
client,
dark_book,
)

View File

@ -0,0 +1,329 @@
# 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/>.
"""
Fake trading for forward testing.
"""
from datetime import datetime
from operator import itemgetter
import time
from typing import Tuple, Optional
import uuid
from bidict import bidict
import trio
from dataclasses import dataclass
from ..data._normalize import iterticks
@dataclass
class PaperBoi:
"""
Emulates a broker order client providing the same API and
delivering an order-event response stream but with methods for
triggering desired events based on forward testing engine
requirements.
"""
broker: str
_to_trade_stream: trio.abc.SendChannel
trade_stream: trio.abc.ReceiveChannel
# map of paper "live" orders which be used
# to simulate fills based on paper engine settings
_buys: bidict
_sells: bidict
_reqids: bidict
# init edge case L1 spread
last_ask: Tuple[float, float] = (float('inf'), 0) # price, size
last_bid: Tuple[float, float] = (0, 0)
async def submit_limit(
self,
oid: str, # XXX: see return value
symbol: str,
price: float,
action: str,
size: float,
brid: Optional[str],
) -> int:
"""Place an order and return integer request id provided by client.
"""
if brid is None:
reqid = str(uuid.uuid4())
else:
# order is already existing, this is a modify
(oid, symbol, action, old_price) = self._reqids[brid]
assert old_price != price
reqid = brid
# register order internally
self._reqids[reqid] = (oid, symbol, action, price)
if action == 'alert':
# bypass all fill simulation
return reqid
# TODO: net latency model
# we checkpoint here quickly particulalry
# for dark orders since we want the dark_executed
# to trigger first thus creating a lookup entry
# in the broker trades event processing loop
await trio.sleep(0.05)
await self._to_trade_stream.send({
'local_trades': ('status', {
'time_ns': time.time_ns(),
'reqid': reqid,
'status': 'submitted',
'broker': self.broker,
# 'cmd': cmd, # original request message
'paper_info': {
'oid': oid,
},
}),
})
# if we're already a clearing price simulate an immediate fill
if (
action == 'buy' and (clear_price := self.last_ask[0]) <= price
) or (
action == 'sell' and (clear_price := self.last_bid[0]) >= price
):
await self.fake_fill(clear_price, size, action, reqid, oid)
else:
# register this submissions as a paper live order
# submit order to book simulation fill loop
if action == 'buy':
orders = self._buys
elif action == 'sell':
orders = self._sells
# set the simulated order in the respective table for lookup
# and trigger by the simulated clearing task normally
# running ``simulate_fills()``.
if brid is not None:
# remove any existing order for the old price
orders[symbol].pop((oid, old_price))
# buys/sells: (symbol -> (price -> order))
orders.setdefault(symbol, {})[(oid, price)] = (size, reqid, action)
return reqid
async def submit_cancel(
self,
reqid: str,
) -> None:
# TODO: fake market simulation effects
# await self._to_trade_stream.send(
oid, symbol, action, price = self._reqids[reqid]
if action == 'buy':
self._buys[symbol].pop((oid, price))
elif action == 'sell':
self._sells[symbol].pop((oid, price))
# TODO: net latency model
await trio.sleep(0.05)
await self._to_trade_stream.send({
'local_trades': ('status', {
'time_ns': time.time_ns(),
'oid': oid,
'reqid': reqid,
'status': 'cancelled',
'broker': self.broker,
# 'cmd': cmd, # original request message
'paper': True,
}),
})
async def fake_fill(
self,
price: float,
size: float,
action: str, # one of {'buy', 'sell'}
reqid: str,
oid: str,
# determine whether to send a filled status that has zero
# remaining lots to fill
order_complete: bool = True,
remaining: float = 0,
) -> None:
"""Pretend to fill a broker order @ price and size.
"""
# TODO: net latency model
await trio.sleep(0.05)
# the trades stream expects events in the form
# {'local_trades': (event_name, msg)}
await self._to_trade_stream.send({
'local_trades': ('fill', {
'status': 'filled',
'broker': self.broker,
# converted to float by us in ib backend
'broker_time': datetime.now().timestamp(),
'action': action,
'size': size,
'price': price,
'remaining': 0 if order_complete else remaining,
# normally filled by real `brokerd` daemon
'time': time.time_ns(),
'time_ns': time.time_ns(), # cuz why not
# fake ids
'reqid': reqid,
'paper_info': {
'oid': oid,
},
# XXX: fields we might not need to emulate?
# execution id from broker
# 'execid': execu.execId,
# 'cmd': cmd, # original request message?
}),
})
if order_complete:
await self._to_trade_stream.send({
'local_trades': ('status', {
'reqid': reqid,
'status': 'filled',
'broker': self.broker,
'filled': size,
'remaining': 0 if order_complete else remaining,
# converted to float by us in ib backend
'broker_time': datetime.now().timestamp(),
'paper_info': {
'oid': oid,
},
}),
})
async def simulate_fills(
quote_stream: 'tractor.ReceiveStream', # noqa
client: PaperBoi,
) -> None:
# TODO: more machinery to better simulate real-world market things:
# - slippage models, check what quantopian has:
# https://github.com/quantopian/zipline/blob/master/zipline/finance/slippage.py
# * this should help with simulating partial fills in a fast moving mkt
# afaiu
# - commisions models, also quantopian has em:
# https://github.com/quantopian/zipline/blob/master/zipline/finance/commission.py
# - network latency models ??
# - position tracking:
# https://github.com/quantopian/zipline/blob/master/zipline/finance/ledger.py
# this stream may eventually contain multiple symbols
async for quotes in quote_stream:
for sym, quote in quotes.items():
for tick in iterticks(
quote,
# dark order price filter(s)
types=('ask', 'bid', 'trade', 'last')
):
# print(tick)
tick_price = tick.get('price')
ttype = tick['type']
if ttype in ('ask',):
client.last_ask = (
tick_price,
tick.get('size', client.last_ask[1]),
)
orders = client._buys.get(sym, {})
book_sequence = reversed(
sorted(orders.keys(), key=itemgetter(1)))
def pred(our_price):
return tick_price < our_price
elif ttype in ('bid',):
client.last_bid = (
tick_price,
tick.get('size', client.last_bid[1]),
)
orders = client._sells.get(sym, {})
book_sequence = sorted(orders.keys(), key=itemgetter(1))
def pred(our_price):
return tick_price > our_price
elif ttype in ('trade', 'last'):
# TODO: simulate actual book queues and our orders
# place in it, might require full L2 data?
continue
# iterate book prices descending
for oid, our_price in book_sequence:
if pred(our_price):
# retreive order info
(size, reqid, action) = orders.pop((oid, our_price))
# clearing price would have filled entirely
await client.fake_fill(
# todo slippage to determine fill price
price=tick_price,
size=size,
action=action,
reqid=reqid,
oid=oid,
)
else:
# prices are iterated in sorted order so we're done
break

View File

@ -0,0 +1,144 @@
# 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/>.
"""
Annotations for ur faces.
"""
from PyQt5 import QtCore, QtGui
from PyQt5.QtGui import QGraphicsPathItem
from pyqtgraph import Point, functions as fn, Color
import numpy as np
def mk_marker(
style,
size: float = 20.0,
use_qgpath: bool = True,
) -> QGraphicsPathItem:
"""Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem``
ready to be placed using scene coordinates (not view).
**Arguments**
style String indicating the style of marker to add:
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
``'>|<'``, ``'^'``, ``'v'``, ``'o'``
size Size of the marker in pixels. Default is 10.0.
"""
path = QtGui.QPainterPath()
if style == 'o':
path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1))
# arrow pointing away-from the top of line
if '<|' in style:
p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)])
path.addPolygon(p)
path.closeSubpath()
# arrow pointing away-from the bottom of line
if '|>' in style:
p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)])
path.addPolygon(p)
path.closeSubpath()
# arrow pointing in-to the top of line
if '>|' in style:
p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)])
path.addPolygon(p)
path.closeSubpath()
# arrow pointing in-to the bottom of line
if '|<' in style:
p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)])
path.addPolygon(p)
path.closeSubpath()
if '^' in style:
p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)])
path.addPolygon(p)
path.closeSubpath()
if 'v' in style:
p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)])
path.addPolygon(p)
path.closeSubpath()
# self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
if use_qgpath:
path = QGraphicsPathItem(path)
path.scale(size, size)
return path
def qgo_draw_markers(
markers: list,
color: Color,
p: QtGui.QPainter,
left: float,
right: float,
right_offset: float,
) -> float:
"""Paint markers in ``pg.GraphicsItem`` style by first
removing the view transform for the painter, drawing the markers
in scene coords, then restoring the view coords.
"""
# paint markers in native coordinate system
orig_tr = p.transform()
start = orig_tr.map(Point(left, 0))
end = orig_tr.map(Point(right, 0))
up = orig_tr.map(Point(left, 1))
dif = end - start
# length = Point(dif).length()
angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi
p.resetTransform()
p.translate(start)
p.rotate(angle)
up = up - start
det = up.x() * dif.y() - dif.x() * up.y()
p.scale(1, 1 if det > 0 else -1)
p.setBrush(fn.mkBrush(color))
# p.setBrush(fn.mkBrush(self.currentPen.color()))
tr = p.transform()
sizes = []
for path, pos, size in markers:
p.setTransform(tr)
# XXX: we drop the "scale / %" placement
# x = length * pos
x = right_offset
p.translate(x, 0)
p.scale(size, size)
p.drawPath(path)
sizes.append(size)
p.setTransform(orig_tr)
return max(sizes)

View File

@ -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
@ -16,6 +16,7 @@
""" """
Chart axes graphics and behavior. Chart axes graphics and behavior.
""" """
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
@ -32,7 +33,7 @@ _axis_pen = pg.mkPen(hcolor('bracket'))
class Axis(pg.AxisItem): class Axis(pg.AxisItem):
"""A better axis that sizes to typical tick contents considering font size. """A better axis that sizes tick contents considering font size.
""" """
def __init__( def __init__(
@ -53,10 +54,10 @@ class Axis(pg.AxisItem):
self.setTickFont(_font.font) self.setTickFont(_font.font)
self.setStyle(**{ self.setStyle(**{
'textFillLimits': [(0, 0.666)], 'textFillLimits': [(0, 0.5)],
'tickFont': _font.font, 'tickFont': _font.font,
# offset of text *away from* axis line in px # offset of text *away from* axis line in px
'tickTextOffset': 2, 'tickTextOffset': 6,
}) })
self.setTickFont(_font.font) self.setTickFont(_font.font)
@ -64,11 +65,17 @@ class Axis(pg.AxisItem):
self.typical_br = _font._qfm.boundingRect(typical_max_str) self.typical_br = _font._qfm.boundingRect(typical_max_str)
# size the pertinent axis dimension to a "typical value" # size the pertinent axis dimension to a "typical value"
self.resize() self.size_to_values()
def size_to_values(self) -> None:
pass
def set_min_tick(self, size: int) -> None: def set_min_tick(self, size: int) -> None:
self._min_tick = size self._min_tick = size
def txt_offsets(self) -> Tuple[int, int]:
return tuple(self.style['tickTextOffset'])
class PriceAxis(Axis): class PriceAxis(Axis):
@ -77,9 +84,13 @@ class PriceAxis(Axis):
*args, *args,
**kwargs, **kwargs,
) -> None: ) -> None:
super().__init__(*args, orientation='right', **kwargs) super().__init__(*args, **kwargs)
self.setStyle(**{
# offset of text *away from* axis line in px
'tickTextOffset': 9,
})
def resize(self) -> None: def size_to_values(self) -> None:
self.setWidth(self.typical_br.width()) self.setWidth(self.typical_br.width())
# XXX: drop for now since it just eats up h space # XXX: drop for now since it just eats up h space
@ -112,7 +123,7 @@ class DynamicDateAxis(Axis):
1: '%H:%M:%S', 1: '%H:%M:%S',
} }
def resize(self) -> None: def size_to_values(self) -> None:
self.setHeight(self.typical_br.height() + 1) self.setHeight(self.typical_br.height() + 1)
def _indexes_to_timestrs( def _indexes_to_timestrs(
@ -151,25 +162,33 @@ class DynamicDateAxis(Axis):
class AxisLabel(pg.GraphicsObject): class AxisLabel(pg.GraphicsObject):
_w_margin = 0 _x_margin = 0
_h_margin = 0 _y_margin = 0
def __init__( def __init__(
self, self,
parent: Axis, parent: pg.GraphicsItem,
digits: int = 2, digits: int = 2,
font_size_inches: Optional[float] = None,
bg_color: str = 'bracket', bg_color: str = 'bracket',
fg_color: str = 'black', fg_color: str = 'black',
opacity: int = 0, opacity: int = 1, # XXX: seriously don't set this to 0
font_size_inches: Optional[float] = None,
use_arrow: bool = True,
) -> None: ) -> None:
super().__init__(parent) super().__init__()
self.setParentItem(parent)
self.setFlag(self.ItemIgnoresTransformations) self.setFlag(self.ItemIgnoresTransformations)
# XXX: pretty sure this is faster # XXX: pretty sure this is faster
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
self.parent = parent self._parent = parent
self.opacity = opacity self.opacity = opacity
self.label_str = '' self.label_str = ''
self.digits = digits self.digits = digits
@ -177,11 +196,15 @@ class AxisLabel(pg.GraphicsObject):
self._txt_br: QtCore.QRect = None self._txt_br: QtCore.QRect = None
self._dpifont = DpiAwareFont(size_in_inches=font_size_inches) self._dpifont = DpiAwareFont(size_in_inches=font_size_inches)
self._dpifont.configure_to_dpi(_font._screen) self._dpifont.configure_to_dpi()
self.bg_color = pg.mkColor(hcolor(bg_color)) self.bg_color = pg.mkColor(hcolor(bg_color))
self.fg_color = pg.mkColor(hcolor(fg_color)) self.fg_color = pg.mkColor(hcolor(fg_color))
self._use_arrow = use_arrow
# create triangle path
self.path = None
self.rect = None self.rect = None
def paint( def paint(
@ -190,37 +213,65 @@ class AxisLabel(pg.GraphicsObject):
opt: QtWidgets.QStyleOptionGraphicsItem, opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.QWidget w: QtWidgets.QWidget
) -> None: ) -> None:
"""Draw a filled rectangle based on the size of ``.label_str`` text.
Subtypes can customize further by overloading ``.draw()``.
"""
# p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver) # p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
if self.label_str: if self.label_str:
if not self.rect: # if not self.rect:
self._size_br_from_str(self.label_str) self._size_br_from_str(self.label_str)
p.setFont(self._dpifont.font)
p.setPen(self.fg_color)
p.setOpacity(self.opacity)
p.fillRect(self.rect, self.bg_color)
# can be overrided in subtype # can be overrided in subtype
self.draw(p, self.rect) self.draw(p, self.rect)
p.setFont(self._dpifont.font)
p.setPen(self.fg_color)
p.drawText(self.rect, self.text_flags, self.label_str) p.drawText(self.rect, self.text_flags, self.label_str)
def draw( def draw(
self, self,
p: QtGui.QPainter, p: QtGui.QPainter,
rect: QtCore.QRectF rect: QtCore.QRectF
) -> None: ) -> None:
if self._use_arrow:
if not self.path:
self._draw_arrow_path()
p.drawPath(self.path)
p.fillPath(self.path, pg.mkBrush(self.bg_color))
# this adds a nice black outline around the label for some odd # this adds a nice black outline around the label for some odd
# reason; ok by us # reason; ok by us
p.setOpacity(self.opacity) p.setOpacity(self.opacity)
p.drawRect(self.rect)
# this cause the L1 labels to glitch out if used
# in the subtype and it will leave a small black strip
# with the arrow path if done before the above
p.fillRect(self.rect, self.bg_color)
def boundingRect(self): # noqa def boundingRect(self): # noqa
"""Size the graphics space from the text contents.
"""
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
# if self.path:
# self.tl = self.path.controlPointRect().topLeft()
if not self.path:
self.tl = self.rect.topLeft()
return QtCore.QRectF(
self.tl,
self.rect.bottomRight(),
)
return QtCore.QRectF() return QtCore.QRectF()
@ -232,17 +283,26 @@ class AxisLabel(pg.GraphicsObject):
""" """
# size the filled rect to text and/or parent axis # size the filled rect to text and/or parent axis
br = self._txt_br = self._dpifont.boundingRect(value) # if not self._txt_br:
# # XXX: this can't be c
# self._txt_br = self._dpifont.boundingRect(value)
txt_h, txt_w = br.height(), br.width() txt_br = self._txt_br = self._dpifont.boundingRect(value)
txt_h, txt_w = txt_br.height(), txt_br.width()
# allow subtypes to specify a static width and height
h, w = self.size_hint() h, w = self.size_hint()
self.rect = QtCore.QRectF( self.rect = QtCore.QRectF(
0, 0, 0, 0,
(w or txt_w) + self._w_margin, (w or txt_w) + self._x_margin /2,
(h or txt_h) + self._h_margin, (h or txt_h) + self._y_margin /2,
) )
# print(self.rect)
# hb = self.path.controlPointRect()
# hb_size = hb.size()
return self.rect
# _common_text_flags = ( # _common_text_flags = (
# QtCore.Qt.TextDontClip | # QtCore.Qt.TextDontClip |
@ -254,7 +314,7 @@ class AxisLabel(pg.GraphicsObject):
class XAxisLabel(AxisLabel): class XAxisLabel(AxisLabel):
_w_margin = 4 _x_margin = 8
text_flags = ( text_flags = (
QtCore.Qt.TextDontClip QtCore.Qt.TextDontClip
@ -263,32 +323,53 @@ class XAxisLabel(AxisLabel):
def size_hint(self) -> Tuple[float, float]: def size_hint(self) -> Tuple[float, float]:
# size to parent axis height # size to parent axis height
return self.parent.height(), None return self._parent.height(), None
def update_label( def update_label(
self, self,
abs_pos: QPointF, # scene coords abs_pos: QPointF, # scene coords
value: float, # data for text value: float, # data for text
offset: int = 1 # if have margins, k? offset: int = 0 # if have margins, k?
) -> None: ) -> None:
timestrs = self.parent._indexes_to_timestrs([int(value)]) timestrs = self._parent._indexes_to_timestrs([int(value)])
if not timestrs.any(): if not timestrs.any():
return return
self.label_str = timestrs[0] pad = 1*' '
self.label_str = pad + timestrs[0] + pad
_, y_offset = self._parent.txt_offsets()
w = self.boundingRect().width() w = self.boundingRect().width()
self.setPos(QPointF( self.setPos(QPointF(
abs_pos.x() - w / 2 - offset, abs_pos.x() - w/2,
1, y_offset/2,
)) ))
self.update() self.update()
def _draw_arrow_path(self):
y_offset = self._parent.style['tickTextOffset'][1]
path = QtGui.QPainterPath()
h, w = self.rect.height(), self.rect.width()
middle = w/2 - 0.5
aw = h/2
left = middle - aw
right = middle + aw
path.moveTo(left, 0)
path.lineTo(middle, -y_offset)
path.lineTo(right, 0)
path.closeSubpath()
self.path = path
# top left point is local origin and tip of the arrow path
self.tl = QtCore.QPointF(0, -y_offset)
class YAxisLabel(AxisLabel): class YAxisLabel(AxisLabel):
_h_margin = 2 _y_margin = 4
text_flags = ( text_flags = (
QtCore.Qt.AlignLeft QtCore.Qt.AlignLeft
@ -297,33 +378,6 @@ class YAxisLabel(AxisLabel):
| QtCore.Qt.TextDontClip | QtCore.Qt.TextDontClip
) )
def size_hint(self) -> Tuple[float, float]:
# size to parent axis width
return None, self.parent.width()
def update_label(
self,
abs_pos: QPointF, # scene coords
value: float, # data for text
offset: int = 1 # on odd dimension and/or adds nice black line
) -> None:
# this is read inside ``.paint()``
self.label_str = ' {value:,.{digits}f}'.format(
digits=self.digits, value=value).replace(',', ' ')
br = self.boundingRect()
h = br.height()
self.setPos(QPointF(
1,
abs_pos.y() - h / 2 - offset
))
self.update()
class YSticky(YAxisLabel):
"""Y-axis label that sticks to where it's placed despite chart resizing.
"""
def __init__( def __init__(
self, self,
chart, chart,
@ -334,14 +388,48 @@ class YSticky(YAxisLabel):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._chart = chart self._chart = chart
chart.sigRangeChanged.connect(self.update_on_resize) chart.sigRangeChanged.connect(self.update_on_resize)
self._last_datum = (None, None) self._last_datum = (None, None)
def update_on_resize(self, vr, r): # pull text offset from axis from parent axis
# TODO: add an `.index` to the array data-buffer layer if getattr(self._parent, 'txt_offsets', False):
# and make this way less shitty... self.x_offset, y_offset = self._parent.txt_offsets()
# pretty sure we did that ^ ? def size_hint(self) -> Tuple[float, float]:
# size to parent axis width
return None, self._parent.width()
def update_label(
self,
abs_pos: QPointF, # scene coords
value: float, # data for text
# on odd dimension and/or adds nice black line
x_offset: Optional[int] = None
) -> None:
# this is read inside ``.paint()``
self.label_str = '{value:,.{digits}f}'.format(
digits=self.digits, value=value).replace(',', ' ')
# pull text offset from axis from parent axis
x_offset = x_offset or self.x_offset
br = self.boundingRect()
h = br.height()
self.setPos(QPointF(
x_offset,
abs_pos.y() - h / 2 - self._y_margin / 2
))
self.update()
def update_on_resize(self, vr, r):
"""Tiis is a ``.sigRangeChanged()`` handler.
"""
index, last = self._last_datum index, last = self._last_datum
if index is not None: if index is not None:
self.update_from_data(index, last) self.update_from_data(index, last)
@ -350,9 +438,27 @@ class YSticky(YAxisLabel):
self, self,
index: int, index: int,
value: float, value: float,
_save_last: bool = True,
) -> None: ) -> None:
"""Update the label's text contents **and** position from
a view box coordinate datum.
"""
if _save_last:
self._last_datum = (index, value) self._last_datum = (index, value)
self.update_label( self.update_label(
self._chart.mapFromView(QPointF(index, value)), self._chart.mapFromView(QPointF(index, value)),
value value
) )
def _draw_arrow_path(self):
x_offset = self._parent.style['tickTextOffset'][0]
path = QtGui.QPainterPath()
h = self.rect.height()
path.moveTo(0, 0)
path.lineTo(-x_offset - 4, h/2.)
path.lineTo(0, h)
path.closeSubpath()
self.path = path
self.tl = path.controlPointRect().topLeft()

View File

@ -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
@ -30,6 +30,7 @@ import trio
from ._axes import ( from ._axes import (
DynamicDateAxis, DynamicDateAxis,
PriceAxis, PriceAxis,
YAxisLabel,
) )
from ._graphics._cursor import ( from ._graphics._cursor import (
Cursor, Cursor,
@ -37,11 +38,11 @@ from ._graphics._cursor import (
) )
from ._graphics._lines import ( from ._graphics._lines import (
level_line, level_line,
L1Labels, order_line,
) )
from ._l1 import L1Labels
from ._graphics._ohlc import BarItems from ._graphics._ohlc import BarItems
from ._graphics._curve import FastAppendCurve from ._graphics._curve import FastAppendCurve
from ._axes import YSticky
from ._style import ( from ._style import (
_font, _font,
hcolor, hcolor,
@ -51,15 +52,15 @@ from ._style import (
_bars_from_right_in_follow_mode, _bars_from_right_in_follow_mode,
_bars_to_left_in_follow_mode, _bars_to_left_in_follow_mode,
) )
from ..data._source import Symbol, float_digits from ..data._source import Symbol
from .. import brokers from .. import brokers
from .. import data 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, open_order_mode from ._interaction import ChartView
from .order_mode import start_order_mode
from .. import fsp from .. import fsp
from .._ems import spawn_router_stream_alerts
log = get_logger(__name__) log = get_logger(__name__)
@ -136,7 +137,10 @@ class ChartSpace(QtGui.QWidget):
Expects a ``numpy`` structured array containing all the ohlcv fields. Expects a ``numpy`` structured array containing all the ohlcv fields.
""" """
# XXX: let's see if this causes mem problems # XXX: let's see if this causes mem problems
self.window.setWindowTitle(f'piker chart {symbol}') self.window.setWindowTitle(
f'piker chart {symbol.key}@{symbol.brokers} '
f'tick:{symbol.tick_size}'
)
# TODO: symbol search # TODO: symbol search
# # of course this doesn't work :eyeroll: # # of course this doesn't work :eyeroll:
@ -239,12 +243,10 @@ class LinkedSplitCharts(QtGui.QWidget):
The data input struct array must include OHLC fields. The data input struct array must include OHLC fields.
""" """
self.digits = symbol.digits()
# add crosshairs # add crosshairs
self._cursor = Cursor( self._cursor = Cursor(
linkedsplitcharts=self, linkedsplitcharts=self,
digits=self.digits digits=symbol.digits(),
) )
self.chart = self.add_plot( self.chart = self.add_plot(
name=symbol.key, name=symbol.key,
@ -304,12 +306,14 @@ class LinkedSplitCharts(QtGui.QWidget):
linked_charts=self, linked_charts=self,
axisItems={ axisItems={
'bottom': xaxis, 'bottom': xaxis,
'right': PriceAxis(linked_charts=self) 'right': PriceAxis(linked_charts=self, orientation='right'),
'left': PriceAxis(linked_charts=self, orientation='left'),
}, },
viewBox=cv, viewBox=cv,
cursor=self._cursor, cursor=self._cursor,
**cpw_kwargs, **cpw_kwargs,
) )
print(f'xaxis ps: {xaxis.pos()}')
# give viewbox as reference to chart # give viewbox as reference to chart
# allowing for kb controls and interactions on **this** widget # allowing for kb controls and interactions on **this** widget
@ -368,6 +372,8 @@ class ChartPlotWidget(pg.PlotWidget):
sig_mouse_leave = QtCore.Signal(object) sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object)
_l1_labels: L1Labels = None
# TODO: can take a ``background`` color setting - maybe there's # TODO: can take a ``background`` color setting - maybe there's
# a better one? # a better one?
@ -377,14 +383,22 @@ class ChartPlotWidget(pg.PlotWidget):
name: str, name: str,
array: np.ndarray, array: np.ndarray,
linked_charts: LinkedSplitCharts, linked_charts: LinkedSplitCharts,
view_color: str = 'papas_special',
pen_color: str = 'bracket',
static_yrange: Optional[Tuple[float, float]] = None, static_yrange: Optional[Tuple[float, float]] = None,
cursor: Optional[Cursor] = None, cursor: Optional[Cursor] = None,
**kwargs, **kwargs,
): ):
"""Configure chart display settings. """Configure chart display settings.
""" """
self.view_color = view_color
self.pen_color = pen_color
super().__init__( super().__init__(
background=hcolor('papas_special'), background=hcolor(view_color),
# parent=None, # parent=None,
# plotItem=None, # plotItem=None,
# antialias=True, # antialias=True,
@ -394,6 +408,10 @@ class ChartPlotWidget(pg.PlotWidget):
self.name = name self.name = name
self._lc = linked_charts self._lc = linked_charts
# scene-local placeholder for book graphics
# sizing to avoid overlap with data contents
self._max_l1_line_len: float = 0
# 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
@ -413,15 +431,16 @@ class ChartPlotWidget(pg.PlotWidget):
# show only right side axes # show only right side axes
self.hideAxis('left') self.hideAxis('left')
self.showAxis('right') self.showAxis('right')
# self.showAxis('left')
# show background grid # show background grid
self.showGrid(x=True, y=True, alpha=0.5) self.showGrid(x=False, y=True, alpha=0.3)
self.default_view() self.default_view()
# 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)
# for mouse wheel which doesn't seem to emit XRangeChanged # for mouse wheel which doesn't seem to emit XRangeChanged
self._vb.sigRangeChangedManually.connect(self._set_yrange) self._vb.sigRangeChangedManually.connect(self._set_yrange)
@ -429,7 +448,7 @@ class ChartPlotWidget(pg.PlotWidget):
# for when the splitter(s) are resized # for when the splitter(s) are resized
self._vb.sigResized.connect(self._set_yrange) self._vb.sigResized.connect(self._set_yrange)
def last_bar_in_view(self) -> bool: def last_bar_in_view(self) -> int:
self._ohlc[-1]['index'] self._ohlc[-1]['index']
def update_contents_labels( def update_contents_labels(
@ -499,11 +518,13 @@ class ChartPlotWidget(pg.PlotWidget):
max=end, max=end,
padding=0, padding=0,
) )
self._set_yrange()
def increment_view( def increment_view(
self, self,
) -> None: ) -> None:
"""Increment the data view one step to the right thus "following" """
Increment the data view one step to the right thus "following"
the current time slot/step/bar. the current time slot/step/bar.
""" """
@ -520,12 +541,15 @@ class ChartPlotWidget(pg.PlotWidget):
self, self,
name: str, name: str,
data: np.ndarray, data: np.ndarray,
# XXX: pretty sure this is dumb and we don't need an Enum
style: pg.GraphicsObject = BarItems,
) -> pg.GraphicsObject: ) -> pg.GraphicsObject:
"""Draw OHLC datums to chart.
""" """
graphics = style(self.plotItem) Draw OHLC datums to chart.
"""
graphics = BarItems(
self.plotItem,
pen_color=self.pen_color
)
# adds all bar/candle graphics objects for each data point in # adds all bar/candle graphics objects for each data point in
# the np array buffer to be drawn on next render cycle # the np array buffer to be drawn on next render cycle
@ -643,14 +667,22 @@ class ChartPlotWidget(pg.PlotWidget):
self, self,
name: str, name: str,
bg_color='bracket', bg_color='bracket',
# retreive: Callable[None, np.ndarray], ) -> YAxisLabel:
) -> YSticky:
# if the sticky is for our symbol
# use the tick size precision for display
sym = self._lc.symbol
if name == sym.key:
digits = sym.digits()
else:
digits = 2
# add y-axis "last" value label # add y-axis "last" value label
last = self._ysticks[name] = YSticky( last = self._ysticks[name] = YAxisLabel(
chart=self, chart=self,
parent=self.getAxis('right'), parent=self.getAxis('right'),
# TODO: pass this from symbol data # TODO: pass this from symbol data
# digits=0, digits=digits,
opacity=1, opacity=1,
bg_color=bg_color, bg_color=bg_color,
) )
@ -701,6 +733,7 @@ class ChartPlotWidget(pg.PlotWidget):
self, self,
*, *,
yrange: Optional[Tuple[float, float]] = None, yrange: Optional[Tuple[float, float]] = None,
range_margin: float = 0.06,
) -> None: ) -> None:
"""Set the viewable y-range based on embedded data. """Set the viewable y-range based on embedded data.
@ -760,7 +793,7 @@ class ChartPlotWidget(pg.PlotWidget):
a = self._ohlc a = self._ohlc
ifirst = a[0]['index'] ifirst = a[0]['index']
bars = a[lbar - ifirst:rbar - ifirst] bars = a[lbar - ifirst:rbar - ifirst + 1]
if not len(bars): if not len(bars):
# likely no data loaded yet or extreme scrolling? # likely no data loaded yet or extreme scrolling?
@ -781,8 +814,8 @@ class ChartPlotWidget(pg.PlotWidget):
if set_range: if set_range:
# view margins: stay within a % of the "true range" # view margins: stay within a % of the "true range"
diff = yhigh - ylow diff = yhigh - ylow
ylow = ylow - (diff * 0.04) ylow = ylow - (diff * range_margin)
yhigh = yhigh + (diff * 0.04) yhigh = yhigh + (diff * range_margin)
self.setLimits( self.setLimits(
yMin=ylow, yMin=ylow,
@ -829,15 +862,91 @@ class ChartPlotWidget(pg.PlotWidget):
self.scene().leaveEvent(ev) self.scene().leaveEvent(ev)
async def _async_main( async def test_bed(
sym: str, ohlcv,
brokername: str, chart,
lc,
):
sleep = 6
# from PyQt5.QtCore import QPointF
vb = chart._vb
# scene = vb.scene()
# raxis = chart.getAxis('right')
# vb_right = vb.boundingRect().right()
last, i_end = ohlcv.array[-1][['close', 'index']]
line = order_line(
chart,
level=last,
level_digits=2
)
# eps = line.getEndpoints()
# llabel = line._labels[1][1]
line.update_labels({'level': last})
return
# rl = eps[1]
# rlabel.setPos(rl)
# ti = pg.TextItem(text='Fuck you')
# ti.setPos(pg.Point(i_end, last))
# ti.setParentItem(line)
# ti.setAnchor(pg.Point(1, 1))
# vb.addItem(ti)
# chart.plotItem.addItem(ti)
from ._label import Label
txt = Label(
vb,
fmt_str='fuck {it}',
)
txt.format(it='boy')
txt.place_on_scene('left')
txt.set_view_y(last)
# txt = QtGui.QGraphicsTextItem()
# txt.setPlainText("FUCK YOU")
# txt.setFont(_font.font)
# txt.setDefaultTextColor(pg.mkColor(hcolor('bracket')))
# # txt.setParentItem(vb)
# w = txt.boundingRect().width()
# scene.addItem(txt)
# txt.setParentItem(line)
# d_coords = vb.mapFromView(QPointF(i_end, last))
# txt.setPos(vb_right - w, d_coords.y())
# txt.show()
# txt.update()
# rlabel.setPos(vb_right - 2*w, d_coords.y())
# rlabel.show()
i = 0
while True:
await trio.sleep(sleep)
await tractor.breakpoint()
txt.format(it=f'dog_{i}')
# d_coords = vb.mapFromView(QPointF(i_end, last))
# txt.setPos(vb_right - w, d_coords.y())
# txt.setPlainText(f"FUCK YOU {i}")
i += 1
# rlabel.setPos(vb_right - 2*w, d_coords.y())
async def _async_main(
# implicit required argument provided by ``qtractor_run()`` # implicit required argument provided by ``qtractor_run()``
widgets: Dict[str, Any], widgets: Dict[str, Any],
# all kwargs are passed through from the CLI entrypoint sym: str,
loglevel: str = None, brokername: str,
loglevel: str,
) -> None: ) -> None:
"""Main Qt-trio routine invoked by the Qt loop with """Main Qt-trio routine invoked by the Qt loop with
the widgets ``dict``. the widgets ``dict``.
@ -852,8 +961,6 @@ 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],
@ -862,6 +969,7 @@ async def _async_main(
ohlcv = feed.shm ohlcv = feed.shm
bars = ohlcv.array bars = ohlcv.array
symbol = feed.symbols[sym]
# load in symbol's ohlc data # load in symbol's ohlc data
linked_charts, chart = chart_app.load_symbol(symbol, bars) linked_charts, chart = chart_app.load_symbol(symbol, bars)
@ -950,26 +1058,14 @@ async def _async_main(
linked_charts linked_charts
) )
async with open_order_mode( # interactive testing
chart, # n.start_soon(
) as order_mode: # test_bed,
# ohlcv,
# TODO: this should probably be implicitly spawned # chart,
# inside the above mngr? # linked_charts,
# )
# spawn EMS actor-service await start_order_mode(chart, symbol, brokername)
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(
@ -1017,7 +1113,7 @@ async def chart_from_quotes(
# sym = chart.name # sym = chart.name
# mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym]) # mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym])
return last_bars_range, mx, mn return last_bars_range, mx, max(mn, 0)
chart.default_view() chart.default_view()
@ -1025,12 +1121,15 @@ async def chart_from_quotes(
last, volume = ohlcv.array[-1][['close', 'volume']] last, volume = ohlcv.array[-1][['close', 'volume']]
symbol = chart._lc.symbol
l1 = L1Labels( l1 = L1Labels(
chart, chart,
# determine precision/decimal lengths # determine precision/decimal lengths
digits=max(float_digits(last), 2), digits=symbol.digits(),
size_digits=min(float_digits(last), 3) size_digits=symbol.lot_digits(),
) )
chart._l1_labels = l1
# TODO: # TODO:
# - in theory we should be able to read buffer data faster # - in theory we should be able to read buffer data faster
@ -1040,6 +1139,9 @@ async def chart_from_quotes(
# levels this might be dark volume we need to # levels this might be dark volume we need to
# present differently? # present differently?
tick_size = chart._lc.symbol.tick_size
tick_margin = 2 * tick_size
async for quotes in stream: async for quotes in stream:
for sym, quote in quotes.items(): for sym, quote in quotes.items():
@ -1050,20 +1152,18 @@ async def chart_from_quotes(
price = tick.get('price') price = tick.get('price')
size = tick.get('size') size = tick.get('size')
# compute max and min trade values to display in view if ticktype == 'n/a' or price == -1:
# TODO: we need a streaming minmax algorithm here, see # okkk..
# def above. continue
brange, mx_in_view, mn_in_view = maxmin()
l, lbar, rbar, r = brange
if ticktype in ('trade', 'utrade', 'last'): if ticktype in ('trade', 'utrade', 'last'):
array = ohlcv.array array = ohlcv.array
# update price sticky(s) # update price sticky(s)
last = array[-1] end = array[-1]
last_price_sticky.update_from_data( last_price_sticky.update_from_data(
*last[['index', 'close']] *end[['index', 'close']]
) )
# plot bars # plot bars
@ -1075,53 +1175,64 @@ async def chart_from_quotes(
if wap_in_history: if wap_in_history:
# update vwap overlay line # update vwap overlay line
chart.update_curve_from_array('bar_wap', ohlcv.array) chart.update_curve_from_array(
'bar_wap', ohlcv.array)
# compute max and min trade values to display in view
# TODO: we need a streaming minmax algorithm here, see
# def above.
brange, mx_in_view, mn_in_view = maxmin()
l, lbar, rbar, r = brange
mx = mx_in_view + tick_margin
mn = mn_in_view - tick_margin
# XXX: prettty sure this is correct? # XXX: prettty sure this is correct?
# if ticktype in ('trade', 'last'): # if ticktype in ('trade', 'last'):
if ticktype in ('last',): # 'size'): if ticktype in ('last',): # 'size'):
label = { label = {
l1.ask_label.level: l1.ask_label, l1.ask_label.fields['level']: l1.ask_label,
l1.bid_label.level: l1.bid_label, l1.bid_label.fields['level']: l1.bid_label,
}.get(price) }.get(price)
if label is not None: if label is not None:
label.size = size label.update_fields({'level': price, 'size': size})
label.update_from_data(0, price)
# on trades should we be knocking down # on trades should we be knocking down
# the relevant L1 queue? # the relevant L1 queue?
# label.size -= size # label.size -= size
elif ticktype in ('ask', 'asize'): elif ticktype in ('ask', 'asize'):
l1.ask_label.size = size l1.ask_label.update_fields({'level': price, 'size': size})
l1.ask_label.update_from_data(0, price)
# update max price in view to keep ask on screen
mx_in_view = max(price, mx_in_view)
elif ticktype in ('bid', 'bsize'): elif ticktype in ('bid', 'bsize'):
l1.bid_label.size = size l1.bid_label.update_fields({'level': price, 'size': size})
l1.bid_label.update_from_data(0, price)
# update min price in view to keep bid on screen # update min price in view to keep bid on screen
mn_in_view = min(price, mn_in_view) mn = min(price - tick_margin, mn)
# update max price in view to keep ask on screen
mx = max(price + tick_margin, mx)
if mx_in_view > last_mx or mn_in_view < last_mn: if (mx > last_mx) or (
chart._set_yrange(yrange=(mn_in_view, mx_in_view)) mn < last_mn
last_mx, last_mn = mx_in_view, mn_in_view ):
# print(f'new y range: {(mn, mx)}')
if brange != last_bars_range: chart._set_yrange(
# we **must always** update the last values due to yrange=(mn, mx),
# the x-range change # TODO: we should probably scale
last_mx, last_mn = mx_in_view, mn_in_view # the view margin based on the size
last_bars_range = brange # of the true range? This way you can
# slap in orders outside the current
# L1 (only) book range.
# range_margin=0.1,
)
last_mx, last_mn = mx, mn
async def spawn_fsps( async def spawn_fsps(
linked_charts: LinkedSplitCharts, linked_charts: LinkedSplitCharts,
# fsp_func_name,
fsps: Dict[str, str], fsps: Dict[str, str],
sym, sym,
src_shm, src_shm,
@ -1289,12 +1400,13 @@ async def update_signals(
# graphics.curve.setBrush(50, 50, 200, 100) # graphics.curve.setBrush(50, 50, 200, 100)
# graphics.curve.setFillLevel(50) # graphics.curve.setFillLevel(50)
if fsp_func_name == 'rsi':
# add moveable over-[sold/bought] lines # add moveable over-[sold/bought] lines
# and labels only for the 70/30 lines # and labels only for the 70/30 lines
level_line(chart, 20, show_label=False) level_line(chart, 20)
level_line(chart, 30, orient_v='top') level_line(chart, 30, orient_v='top')
level_line(chart, 70, orient_v='bottom') level_line(chart, 70, orient_v='bottom')
level_line(chart, 80, orient_v='top', show_label=False) level_line(chart, 80, orient_v='top')
chart._set_yrange() chart._set_yrange()
@ -1331,6 +1443,7 @@ async def update_signals(
async def check_for_new_bars(feed, ohlcv, linked_charts): async def check_for_new_bars(feed, ohlcv, linked_charts):
"""Task which updates from new bars in the shared ohlcv buffer every """Task which updates from new bars in the shared ohlcv buffer every
``delay_s`` seconds. ``delay_s`` seconds.
""" """
# TODO: right now we'll spin printing bars if the last time # TODO: right now we'll spin printing bars if the last time
# stamp is before a large period of no market activity. # stamp is before a large period of no market activity.
@ -1358,12 +1471,6 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
# current bar) and then either write the current bar manually # current bar) and then either write the current bar manually
# or place a cursor for visual cue of the current time step. # or place a cursor for visual cue of the current time step.
# price_chart.update_ohlc_from_array(
# price_chart.name,
# ohlcv.array,
# just_history=True,
# )
# XXX: this puts a flat bar on the current time step # XXX: this puts a flat bar on the current time step
# TODO: if we eventually have an x-axis time-step "cursor" # TODO: if we eventually have an x-axis time-step "cursor"
# we can get rid of this since it is extra overhead. # we can get rid of this since it is extra overhead.
@ -1373,9 +1480,6 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
just_history=False, just_history=False,
) )
# resize view
# price_chart._set_yrange()
for name in price_chart._overlays: for name in price_chart._overlays:
price_chart.update_curve_from_array( price_chart.update_curve_from_array(
@ -1383,15 +1487,8 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
price_chart._arrays[name] price_chart._arrays[name]
) )
# # TODO: standard api for signal lookups per plot
# if name in price_chart._ohlc.dtype.fields:
# # should have already been incremented above
# price_chart.update_curve_from_array(name, price_chart._ohlc)
for name, chart in linked_charts.subplots.items(): for name, chart in linked_charts.subplots.items():
chart.update_curve_from_array(chart.name, chart._shm.array) chart.update_curve_from_array(chart.name, chart._shm.array)
# chart._set_yrange()
# shift the view if in follow mode # shift the view if in follow mode
price_chart.increment_view() price_chart.increment_view()
@ -1400,14 +1497,16 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
def _main( def _main(
sym: str, sym: str,
brokername: str, brokername: str,
piker_loglevel: str,
tractor_kwargs, tractor_kwargs,
) -> None: ) -> None:
"""Sync entry point to start a chart app. """Sync entry point to start a chart app.
""" """
# Qt entry point # Qt entry point
run_qtractor( run_qtractor(
func=_async_main, func=_async_main,
args=(sym, brokername), args=(sym, brokername, piker_loglevel),
main_widget=ChartSpace, main_widget=ChartSpace,
tractor_kwargs=tractor_kwargs, tractor_kwargs=tractor_kwargs,
) )

View File

@ -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
@ -20,11 +20,11 @@ Trio - Qt integration
Run ``trio`` in guest mode on top of the Qt event loop. Run ``trio`` in guest mode on top of the Qt event loop.
All global Qt runtime settings are mostly defined here. All global Qt runtime settings are mostly defined here.
""" """
from typing import Tuple, Callable, Dict, Any
import os import os
import signal import signal
from functools import partial import time
import traceback import traceback
from typing import Tuple, Callable, Dict, Any
# Qt specific # Qt specific
import PyQt5 # noqa import PyQt5 # noqa
@ -32,19 +32,29 @@ import pyqtgraph as pg
from pyqtgraph import QtGui from pyqtgraph import QtGui
from PyQt5 import QtCore from PyQt5 import QtCore
from PyQt5.QtCore import ( from PyQt5.QtCore import (
pyqtRemoveInputHook, Qt, QCoreApplication pyqtRemoveInputHook,
Qt,
QCoreApplication,
) )
import qdarkstyle import qdarkstyle
import trio import trio
import tractor import tractor
from outcome import Error from outcome import Error
from ..log import get_logger
from ._pg_overrides import _do_overrides
log = get_logger(__name__)
# pyqtgraph global config # pyqtgraph global config
# might as well enable this for now? # might as well enable this for now?
pg.useOpenGL = True pg.useOpenGL = True
pg.enableExperimental = True pg.enableExperimental = True
# engage core tweaks that give us better response
# latency then the average pg user
_do_overrides()
# singleton app per actor # singleton app per actor
_qt_app: QtGui.QApplication = None _qt_app: QtGui.QApplication = None
@ -52,18 +62,40 @@ _qt_win: QtGui.QMainWindow = None
def current_screen() -> QtGui.QScreen: def current_screen() -> QtGui.QScreen:
"""Get a frickin screen (if we can, gawd).
"""
global _qt_win, _qt_app global _qt_win, _qt_app
return _qt_app.screenAt(_qt_win.centralWidget().geometry().center())
start = time.time()
tries = 3
for _ in range(3):
screen = _qt_app.screenAt(_qt_win.pos())
print(f'trying to get screen....')
if screen is None:
time.sleep(0.5)
continue
break
else:
if screen is None:
# try for the first one we can find
screen = _qt_app.screens()[0]
assert screen, "Wow Qt is dumb as shit and has no screen..."
return screen
# XXX: pretty sure none of this shit works
# https://bugreports.qt.io/browse/QTBUG-53022
# Proper high DPI scaling is available in Qt >= 5.6.0. This attibute # Proper high DPI scaling is available in Qt >= 5.6.0. This attibute
# must be set before creating the application # must be set before creating the application
if hasattr(Qt, 'AA_EnableHighDpiScaling'): # if hasattr(Qt, 'AA_EnableHighDpiScaling'):
QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) # QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
if hasattr(Qt, 'AA_UseHighDpiPixmaps'): # if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) # QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
class MainWindow(QtGui.QMainWindow): class MainWindow(QtGui.QMainWindow):
@ -78,7 +110,7 @@ class MainWindow(QtGui.QMainWindow):
def closeEvent( def closeEvent(
self, self,
event: 'QCloseEvent' event: QtGui.QCloseEvent,
) -> None: ) -> None:
"""Cancel the root actor asap. """Cancel the root actor asap.
@ -169,8 +201,8 @@ def run_qtractor(
), ),
name='qtractor', name='qtractor',
**tractor_kwargs, **tractor_kwargs,
) as a: ):
await func(*(args + (widgets,))) await func(*((widgets,) + args))
# guest mode entry # guest mode entry
trio.lowlevel.start_guest_run( trio.lowlevel.start_guest_run(

View File

@ -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
@ -13,6 +13,7 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
Mouse interaction graphics Mouse interaction graphics
@ -23,7 +24,7 @@ import inspect
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QPointF from PyQt5.QtCore import QPointF, QRectF
from .._style import ( from .._style import (
_xaxis_at, _xaxis_at,
@ -54,7 +55,7 @@ class LineDot(pg.CurvePoint):
index: int, index: int,
plot: 'ChartPlotWidget', # type: ingore # noqa plot: 'ChartPlotWidget', # type: ingore # noqa
pos=None, pos=None,
size: int = 2, # in pxs size: int = 6, # in pxs
color: str = 'default_light', color: str = 'default_light',
) -> None: ) -> None:
pg.CurvePoint.__init__( pg.CurvePoint.__init__(
@ -112,27 +113,32 @@ class LineDot(pg.CurvePoint):
return False return False
_corner_anchors = { # TODO: likely will need to tweak this based on dpi...
'top': 0, _y_margin = 5
'left': 0,
'bottom': 1,
'right': 1,
}
# XXX: fyi naming here is confusing / opposite to coords
_corner_margins = {
('top', 'left'): (-4, -5),
('top', 'right'): (4, -5),
('bottom', 'left'): (-4, lambda font_size: font_size * 2),
('bottom', 'right'): (4, lambda font_size: font_size * 2),
}
# TODO: change this into our own ``Label``
class ContentsLabel(pg.LabelItem): class ContentsLabel(pg.LabelItem):
"""Label anchored to a ``ViewBox`` typically for displaying """Label anchored to a ``ViewBox`` typically for displaying
datum-wise points from the "viewed" contents. datum-wise points from the "viewed" contents.
""" """
_corner_anchors = {
'top': 0,
'left': 0,
'bottom': 1,
'right': 1,
}
# XXX: fyi naming here is confusing / opposite to coords
_corner_margins = {
('top', 'left'): (-4, -_y_margin),
('top', 'right'): (4, -_y_margin),
('bottom', 'left'): (-4, lambda font_size: font_size + 2*_y_margin),
('bottom', 'right'): (4, lambda font_size: font_size + 2*_y_margin),
}
def __init__( def __init__(
self, self,
chart: 'ChartPlotWidget', # noqa chart: 'ChartPlotWidget', # noqa
@ -152,8 +158,8 @@ class ContentsLabel(pg.LabelItem):
self.chart = chart self.chart = chart
v, h = anchor_at v, h = anchor_at
index = (_corner_anchors[h], _corner_anchors[v]) index = (self._corner_anchors[h], self._corner_anchors[v])
margins = _corner_margins[(v, h)] margins = self._corner_margins[(v, h)]
ydim = margins[1] ydim = margins[1]
if inspect.isfunction(margins[1]): if inspect.isfunction(margins[1]):
@ -213,7 +219,7 @@ class Cursor(pg.GraphicsObject):
style=QtCore.Qt.DashLine, style=QtCore.Qt.DashLine,
) )
self.lines_pen = pg.mkPen( self.lines_pen = pg.mkPen(
color='#a9a9a9', # gray? color=hcolor('davies'),
style=QtCore.Qt.DashLine, style=QtCore.Qt.DashLine,
) )
self.lsc = linkedsplitcharts self.lsc = linkedsplitcharts
@ -226,6 +232,18 @@ class Cursor(pg.GraphicsObject):
self._hovered: Set[pg.GraphicsObject] = set() self._hovered: Set[pg.GraphicsObject] = set()
self._trackers: Set[pg.GraphicsObject] = set() self._trackers: Set[pg.GraphicsObject] = set()
# value used for rounding y-axis discreet tick steps
# computing once, up front, here cuz why not
self._y_incr_mult = 1 / self.lsc._symbol.tick_size
# line width in view coordinates
self._lw = self.pixelWidth() * self.lines_pen.width()
# xhair label's color name
self.label_color: str = 'default'
self._y_label_update: bool = True
def add_hovered( def add_hovered(
self, self,
item: pg.GraphicsObject, item: pg.GraphicsObject,
@ -240,6 +258,7 @@ class Cursor(pg.GraphicsObject):
) -> None: ) -> None:
# add ``pg.graphicsItems.InfiniteLine``s # add ``pg.graphicsItems.InfiniteLine``s
# vertical and horizonal lines and a y-axis label # vertical and horizonal lines and a y-axis label
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False) vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
@ -248,10 +267,11 @@ class Cursor(pg.GraphicsObject):
hl.hide() hl.hide()
yl = YAxisLabel( yl = YAxisLabel(
chart=plot,
parent=plot.getAxis('right'), parent=plot.getAxis('right'),
digits=digits or self.digits, digits=digits or self.digits,
opacity=_ch_label_opac, opacity=_ch_label_opac,
bg_color='default', bg_color=self.label_color,
) )
yl.hide() # on startup if mouse is off screen yl.hide() # on startup if mouse is off screen
@ -291,7 +311,7 @@ class Cursor(pg.GraphicsObject):
self.xaxis_label = XAxisLabel( self.xaxis_label = XAxisLabel(
parent=self.plots[plot_index].getAxis('bottom'), parent=self.plots[plot_index].getAxis('bottom'),
opacity=_ch_label_opac, opacity=_ch_label_opac,
bg_color='default', bg_color=self.label_color,
) )
# place label off-screen during startup # place label off-screen during startup
self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0))) self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0)))
@ -347,33 +367,46 @@ class Cursor(pg.GraphicsObject):
x, y = mouse_point.x(), mouse_point.y() x, y = mouse_point.x(), mouse_point.y()
plot = self.active_plot plot = self.active_plot
# 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 # 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, lasty = self._datum_xy last_ix, last_iy = self._datum_xy
ix = round(x) # since bars are centered around index ix = round(x) # since bars are centered around index
# round y value to nearest tick step
m = self._y_incr_mult
iy = round(y * m) / m
# px perfect...
line_offset = self._lw / 2
# update y-range items
if iy != last_iy:
if self._y_label_update:
self.graphics[self.active_plot]['yl'].update_label(
abs_pos=plot.mapFromView(QPointF(ix, iy + line_offset)),
value=iy
)
# only update horizontal xhair line if label is enabled
self.graphics[plot]['hl'].setY(iy + line_offset)
# update all trackers # update all trackers
for item in self._trackers: for item in self._trackers:
# print(f'setting {item} with {(ix, y)}') # print(f'setting {item} with {(ix, y)}')
item.on_tracked_source(ix, y) item.on_tracked_source(ix, iy)
if ix != lastx: if ix != last_ix:
for plot, opts in self.graphics.items(): for plot, opts in self.graphics.items():
# move the vertical line to the current "center of bar"
opts['vl'].setX(ix)
# update the chart's "contents" label # update the chart's "contents" label
plot.update_contents_labels(ix) plot.update_contents_labels(ix)
# move the vertical line to the current "center of bar"
opts['vl'].setX(ix + line_offset)
# update all subscribed curve dots # update all subscribed curve dots
for cursor in opts.get('cursors', ()): for cursor in opts.get('cursors', ()):
cursor.setIndex(ix) cursor.setIndex(ix)
@ -386,14 +419,63 @@ class Cursor(pg.GraphicsObject):
# otherwise gobbles tons of CPU.. # otherwise gobbles tons of CPU..
# map back to abs (label-local) coordinates # map back to abs (label-local) coordinates
abs_pos=plot.mapFromView(QPointF(ix, y)), abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)),
value=x, value=ix,
) )
self._datum_xy = ix, y self._datum_xy = ix, iy
def boundingRect(self): def boundingRect(self) -> QRectF:
try: try:
return self.active_plot.boundingRect() return self.active_plot.boundingRect()
except AttributeError: except AttributeError:
return self.plots[0].boundingRect() return self.plots[0].boundingRect()
def show_xhair(
self,
y_label_level: float = None,
) -> None:
g = self.graphics[self.active_plot]
# show horiz line and y-label
g['hl'].show()
g['vl'].show()
self._y_label_update = True
yl = g['yl']
# yl.fg_color = pg.mkColor(hcolor('black'))
# yl.bg_color = pg.mkColor(hcolor(self.label_color))
if y_label_level:
yl.update_from_data(0, y_label_level, _save_last=False)
yl.show()
def hide_xhair(
self,
hide_label: bool = False,
y_label_level: float = None,
just_vertical: bool = False,
fg_color: str = None,
# bg_color: str = 'papas_special',
) -> None:
g = self.graphics[self.active_plot]
hl = g['hl']
if not just_vertical:
hl.hide()
g['vl'].hide()
# only disable cursor y-label updates
# if we're highlighting a line
yl = g['yl']
if hide_label:
yl.hide()
elif y_label_level:
yl.update_from_data(0, y_label_level, _save_last=False)
hl.setY(y_label_level)
if fg_color is not None:
yl.fg_color = pg.mkColor(hcolor(fg_color))
yl.bg_color = pg.mkColor(hcolor('papas_special'))

View File

@ -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
@ -16,6 +16,7 @@
""" """
Fast, smooth, sexy curves. Fast, smooth, sexy curves.
""" """
from typing import Tuple from typing import Tuple
@ -141,6 +142,7 @@ class FastAppendCurve(pg.PlotCurveItem):
w = hb_size.width() + 1 w = hb_size.width() + 1
h = hb_size.height() + 1 h = hb_size.height() + 1
br = QtCore.QRectF( br = QtCore.QRectF(
# top left # top left

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ from typing import List, Optional, Tuple
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from numba import jit, float64, int64 # , optional from numba import njit, float64, int64 # , optional
from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QLineF, QPointF from PyQt5.QtCore import QLineF, QPointF
# from numba import types as ntypes # from numba import types as ntypes
@ -46,10 +46,20 @@ def _mk_lines_array(
) )
def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]: def lines_from_ohlc(
row: np.ndarray,
w: float
) -> Tuple[QLineF]:
open, high, low, close, index = row[ open, high, low, close, index = row[
['open', 'high', 'low', 'close', 'index']] ['open', 'high', 'low', 'close', 'index']]
# TODO: maybe consider using `QGraphicsLineItem` ??
# gives us a ``.boundingRect()`` on the objects which may make
# computing the composite bounding rect of the last bars + the
# history path faster since it's done in C++:
# https://doc.qt.io/qt-5/qgraphicslineitem.html
# high -> low vertical (body) line # high -> low vertical (body) line
if low != high: if low != high:
hl = QLineF(index, low, index, high) hl = QLineF(index, low, index, high)
@ -60,17 +70,18 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
# NOTE: place the x-coord start as "middle" of the drawing range such # NOTE: place the x-coord start as "middle" of the drawing range such
# that the open arm line-graphic is at the left-most-side of # that the open arm line-graphic is at the left-most-side of
# the index's range according to the view mapping. # the index's range according to the view mapping coordinates.
# open line # open line
o = QLineF(index - w, open, index, open) o = QLineF(index - w, open, index, open)
# close line # close line
c = QLineF(index, close, index + w, close) c = QLineF(index, close, index + w, close)
return [hl, o, c] return [hl, o, c]
@jit( @njit(
# TODO: for now need to construct this manually for readonly arrays, see # TODO: for now need to construct this manually for readonly arrays, see
# https://github.com/numba/numba/issues/4511 # https://github.com/numba/numba/issues/4511
# ntypes.Tuple((float64[:], float64[:], float64[:]))( # ntypes.Tuple((float64[:], float64[:], float64[:]))(
@ -78,7 +89,6 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
# int64, # int64,
# optional(float64), # optional(float64),
# ), # ),
nopython=True,
nogil=True nogil=True
) )
def path_arrays_from_ohlc( def path_arrays_from_ohlc(
@ -167,17 +177,18 @@ class BarItems(pg.GraphicsObject):
# 0.5 is no overlap between arms, 1.0 is full overlap # 0.5 is no overlap between arms, 1.0 is full overlap
w: float = 0.43 w: float = 0.43
# XXX: for the mega-lulz increasing width here increases draw latency...
# so probably don't do it until we figure that out.
bars_pen = pg.mkPen(hcolor('bracket'))
def __init__( def __init__(
self, self,
# scene: 'QGraphicsScene', # noqa # scene: 'QGraphicsScene', # noqa
plotitem: 'pg.PlotItem', # noqa plotitem: 'pg.PlotItem', # noqa
pen_color: str = 'bracket',
) -> None: ) -> None:
super().__init__() super().__init__()
# XXX: for the mega-lulz increasing width here increases draw latency...
# so probably don't do it until we figure that out.
self.bars_pen = pg.mkPen(hcolor(pen_color), width=1)
# NOTE: this prevents redraws on mouse interaction which is # NOTE: this prevents redraws on mouse interaction which is
# a huge boon for avg interaction latency. # a huge boon for avg interaction latency.
@ -215,7 +226,9 @@ class BarItems(pg.GraphicsObject):
This routine is usually only called to draw the initial history. This routine is usually only called to draw the initial history.
""" """
self.path = gen_qpath(data, start, self.w) hist, last = data[:-1], data[-1]
self.path = gen_qpath(hist, start, self.w)
# save graphics for later reference and keep track # save graphics for later reference and keep track
# of current internal "last index" # of current internal "last index"
@ -228,7 +241,7 @@ class BarItems(pg.GraphicsObject):
) )
# up to last to avoid double draw of last bar # up to last to avoid double draw of last bar
self._last_bar_lines = lines_from_ohlc(data[-1], self.w) self._last_bar_lines = lines_from_ohlc(last, self.w)
# trigger render # trigger render
# https://doc.qt.io/qt-5/qgraphicsitem.html#update # https://doc.qt.io/qt-5/qgraphicsitem.html#update
@ -311,15 +324,17 @@ class BarItems(pg.GraphicsObject):
['index', 'open', 'high', 'low', 'close', 'volume'] ['index', 'open', 'high', 'low', 'close', 'volume']
] ]
# assert i == self.start_index - 1 # assert i == self.start_index - 1
assert i == last_index # assert i == last_index
body, larm, rarm = self._last_bar_lines body, larm, rarm = self._last_bar_lines
# XXX: is there a faster way to modify this? # XXX: is there a faster way to modify this?
rarm.setLine(rarm.x1(), last, rarm.x2(), last) rarm.setLine(rarm.x1(), last, rarm.x2(), last)
# writer is responsible for changing open on "first" volume of bar # writer is responsible for changing open on "first" volume of bar
larm.setLine(larm.x1(), o, larm.x2(), o) larm.setLine(larm.x1(), o, larm.x2(), o)
if l != h: # noqa if l != h: # noqa
if body is None: if body is None:
body = self._last_bar_lines[0] = QLineF(i, l, i, h) body = self._last_bar_lines[0] = QLineF(i, l, i, h)
else: else:
@ -380,53 +395,29 @@ class BarItems(pg.GraphicsObject):
# apparently this a lot faster says the docs? # apparently this a lot faster says the docs?
# https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect # https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect
hb = self.path.controlPointRect() hb = self.path.controlPointRect()
hb_size = hb.size() hb_tl, hb_br = hb.topLeft(), hb.bottomRight()
# print(f'hb_size: {hb_size}')
w = hb_size.width() + 1 # need to include last bar height or BR will be off
h = hb_size.height() + 1 mx_y = hb_br.y()
mn_y = hb_tl.y()
br = QtCore.QRectF( body_line = self._last_bar_lines[0]
if body_line:
mx_y = max(mx_y, max(body_line.y1(), body_line.y2()))
mn_y = min(mn_y, min(body_line.y1(), body_line.y2()))
return QtCore.QRectF(
# top left # top left
QPointF(hb.topLeft()), QPointF(
hb_tl.x(),
mn_y,
),
# total size # bottom right
QtCore.QSizeF(w, h) QPointF(
hb_br.x() + 1,
mx_y,
) )
# print(f'bounding rect: {br}')
return br
)
# XXX: when we get back to enabling tina mode for xb
# class CandlestickItems(BarItems):
# w2 = 0.7
# line_pen = pg.mkPen('#000000')
# bull_brush = pg.mkBrush('#00ff00')
# bear_brush = pg.mkBrush('#ff0000')
# def _generate(self, p):
# rects = np.array(
# [
# QtCore.QRectF(
# q.id - self.w,
# q.open,
# self.w2,
# q.close - q.open
# )
# for q in Quotes
# ]
# )
# p.setPen(self.line_pen)
# p.drawLines(
# [QtCore.QLineF(q.id, q.low, q.id, q.high)
# for q in Quotes]
# )
# p.setBrush(self.bull_brush)
# p.drawRects(*rects[Quotes.close > Quotes.open])
# p.setBrush(self.bear_brush)
# p.drawRects(*rects[Quotes.close < Quotes.open])

View File

@ -15,22 +15,21 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
UX interaction customs. Chart view box primitives
""" """
from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, Dict, Callable from typing import Optional, Dict
import uuid
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5.QtCore import QPointF
from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import ViewBox, Point, QtCore, QtGui
from pyqtgraph import functions as fn from pyqtgraph import functions as fn
import numpy as np 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 ._graphics._lines import order_line, LevelLine
from .._ems import get_orders, OrderBook
log = get_logger(__name__) log = get_logger(__name__)
@ -110,8 +109,8 @@ class SelectRect(QtGui.QGraphicsRectItem):
def mouse_drag_released( def mouse_drag_released(
self, self,
p1: QtCore.QPointF, p1: QPointF,
p2: QtCore.QPointF p2: QPointF
) -> None: ) -> None:
"""Called on final button release for mouse drag with start and """Called on final button release for mouse drag with start and
end positions. end positions.
@ -121,10 +120,10 @@ class SelectRect(QtGui.QGraphicsRectItem):
def set_pos( def set_pos(
self, self,
p1: QtCore.QPointF, p1: QPointF,
p2: QtCore.QPointF p2: QPointF
) -> None: ) -> None:
"""Set position of selection rectagle and accompanying label, move """Set position of selection rect and accompanying label, move
label to match. label to match.
""" """
@ -208,48 +207,77 @@ _order_lines: Dict[str, LevelLine] = {}
@dataclass @dataclass
class LineEditor: class LineEditor:
"""The great editor of linez..
"""
view: 'ChartView' view: 'ChartView'
_order_lines: field(default_factory=_order_lines) _order_lines: field(default_factory=_order_lines)
chart: 'ChartPlotWidget' = None # type: ignore # noqa chart: 'ChartPlotWidget' = None # type: ignore # noqa
_active_staged_line: LevelLine = None _active_staged_line: LevelLine = None
_stage_line: LevelLine = None _stage_line: LevelLine = None
def stage_line(self, color: str = 'alert_yellow') -> LevelLine: def stage_line(
self,
action: str,
color: str = 'alert_yellow',
hl_on_hover: bool = False,
dotted: bool = False,
# fields settings
size: Optional[int] = None,
) -> LevelLine:
"""Stage a line at the current chart's cursor position """Stage a line at the current chart's cursor position
and return it. and return it.
""" """
# chart.setCursor(QtCore.Qt.PointingHandCursor)
chart = self.chart._cursor.active_plot chart = self.chart._cursor.active_plot
chart.setCursor(QtCore.Qt.PointingHandCursor)
cursor = chart._cursor cursor = chart._cursor
y = chart._cursor._datum_xy[1] y = chart._cursor._datum_xy[1]
line = self._stage_line symbol = chart._lc.symbol
if not line:
# line = self._stage_line
# if not line:
# add a "staged" cursor-tracking line to view # add a "staged" cursor-tracking line to view
# and cash it in a a var # and cash it in a a var
line = level_line( if self._active_staged_line:
self.unstage_line()
line = order_line(
chart, chart,
level=y, level=y,
digits=chart._lc.symbol.digits(), level_digits=symbol.digits(),
size=size,
size_digits=symbol.lot_digits(),
# just for the stage line to avoid
# flickering while moving the cursor
# around where it might trigger highlight
# then non-highlight depending on sensitivity
always_show_labels=True,
# kwargs
color=color, color=color,
# don't highlight the "staging" line # don't highlight the "staging" line
hl_on_hover=False, hl_on_hover=hl_on_hover,
) dotted=dotted,
self._stage_line = line exec_type='dark' if dotted else 'live',
action=action,
show_markers=True,
else: # prevent flickering of marker while moving/tracking cursor
# use the existing staged line instead only_show_markers_on_hover=False,
# of allocating more mem / objects repeatedly )
line.setValue(y)
line.show()
line.label.show()
self._active_staged_line = line self._active_staged_line = line
# hide crosshair y-line # hide crosshair y-line and label
cursor.graphics[chart]['hl'].hide() cursor.hide_xhair()
# add line to cursor trackers # add line to cursor trackers
cursor._trackers.add(line) cursor._trackers.add(line)
@ -260,45 +288,63 @@ class LineEditor:
"""Inverse of ``.stage_line()``. """Inverse of ``.stage_line()``.
""" """
chart = self.chart._cursor.active_plot # chart = self.chart._cursor.active_plot
chart.setCursor(QtCore.Qt.ArrowCursor) # # chart.setCursor(QtCore.Qt.ArrowCursor)
cursor = chart._cursor cursor = self.chart._cursor
# delete "staged" cursor tracking line from view # delete "staged" cursor tracking line from view
line = self._active_staged_line line = self._active_staged_line
if line:
cursor._trackers.remove(line) cursor._trackers.remove(line)
line.delete()
if self._stage_line:
self._stage_line.hide()
self._stage_line.label.hide()
self._active_staged_line = None self._active_staged_line = None
# show the crosshair y line # show the crosshair y line and label
hl = cursor.graphics[chart]['hl'] cursor.show_xhair()
hl.show()
def create_line(self, uuid: str) -> LevelLine: def create_order_line(
self,
uuid: str,
level: float,
chart: 'ChartPlotWidget', # noqa
size: float,
action: str,
) -> LevelLine:
line = self._active_staged_line line = self._active_staged_line
if not line: if not line:
raise RuntimeError("No line commit is currently staged!?") raise RuntimeError("No line is currently staged!?")
chart = self.chart._cursor.active_plot sym = chart._lc.symbol
y = chart._cursor._datum_xy[1]
line = level_line( line = order_line(
chart, chart,
level=y,
color='alert_yellow', # label fields default values
digits=chart._lc.symbol.digits(), level=level,
show_label=False, level_digits=sym.digits(),
size=size,
size_digits=sym.lot_digits(),
# LevelLine kwargs
color=line.color,
dotted=line._dotted,
show_markers=True,
only_show_markers_on_hover=True,
action=action,
) )
# for now, until submission reponse arrives
line.hide_labels()
# register for later lookup/deletion # register for later lookup/deletion
self._order_lines[uuid] = line self._order_lines[uuid] = line
return line, y
return line
def commit_line(self, uuid: str) -> LevelLine: def commit_line(self, uuid: str) -> LevelLine:
"""Commit a "staged line" to view. """Commit a "staged line" to view.
@ -307,9 +353,14 @@ class LineEditor:
graphic in view. graphic in view.
""" """
try:
line = self._order_lines[uuid] line = self._order_lines[uuid]
line.oid = uuid except KeyError:
line.label.show() log.warning(f'No line for {uuid} could be found?')
return
else:
assert line.oid == uuid
line.show_labels()
# TODO: other flashy things to indicate the order is active # TODO: other flashy things to indicate the order is active
@ -328,7 +379,7 @@ class LineEditor:
self, self,
line: LevelLine = None, line: LevelLine = None,
uuid: str = None, uuid: str = None,
) -> None: ) -> LevelLine:
"""Remove a line by refernce or uuid. """Remove a line by refernce or uuid.
If no lines or ids are provided remove all lines under the If no lines or ids are provided remove all lines under the
@ -339,14 +390,20 @@ class LineEditor:
uuid = line.oid uuid = line.oid
# try to look up line from our registry # try to look up line from our registry
line = self._order_lines.pop(uuid) line = self._order_lines.pop(uuid, None)
if line:
# if hovered remove from cursor set # if hovered remove from cursor set
hovered = self.chart._cursor._hovered hovered = self.chart._cursor._hovered
if line in hovered: if line in hovered:
hovered.remove(line) hovered.remove(line)
# make sure the xhair doesn't get left off
# just because we never got a un-hover event
self.chart._cursor.show_xhair()
line.delete() line.delete()
return line
@dataclass @dataclass
@ -361,22 +418,28 @@ class ArrowEditor:
x: float, x: float,
y: float, y: float,
color='default', color='default',
pointing: str = 'up', pointing: Optional[str] = None,
) -> pg.ArrowItem: ) -> pg.ArrowItem:
"""Add an arrow graphic to view at given (x, y). """Add an arrow graphic to view at given (x, y).
""" """
yb = pg.mkBrush(hcolor('alert_yellow')) angle = {
'up': 90,
angle = 90 if pointing == 'up' else -90 'down': -90,
None: 180, # pointing to right (as in an alert)
}[pointing]
arrow = pg.ArrowItem( arrow = pg.ArrowItem(
angle=angle, angle=angle,
baseAngle=0, baseAngle=0,
headLen=5, headLen=5*3,
headWidth=2, headWidth=2*3,
tailLen=None, tailLen=None,
brush=yb, pxMode=True,
# coloring
pen=pg.mkPen(hcolor('papas_special')),
brush=pg.mkBrush(hcolor(color)),
) )
arrow.setPos(x, y) arrow.setPos(x, y)
@ -391,59 +454,6 @@ class ArrowEditor:
self.chart.plotItem.removeItem(arrow) 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:
@ -467,11 +477,11 @@ 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 self.mode = None
# kb ctrls processing # kb ctrls processing
self._key_buffer = [] self._key_buffer = []
self._key_active: bool = False
@property @property
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
@ -481,7 +491,6 @@ class ChartView(ViewBox):
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # 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.
@ -498,15 +507,17 @@ class ChartView(ViewBox):
else: else:
mask = self.state['mouseEnabled'][:] mask = self.state['mouseEnabled'][:]
chart = self.linked_charts.chart
# don't zoom more then the min points setting # don't zoom more then the min points setting
l, lbar, rbar, r = self.linked_charts.chart.bars_range() l, lbar, rbar, r = chart.bars_range()
vl = r - l vl = r - l
if ev.delta() > 0 and vl <= _min_points_to_show: if ev.delta() > 0 and vl <= _min_points_to_show:
log.debug("Max zoom bruh...") log.debug("Max zoom bruh...")
return return
if ev.delta() < 0 and vl >= len(self.linked_charts.chart._ohlc) + 666: if ev.delta() < 0 and vl >= len(chart._ohlc) + 666:
log.debug("Min zoom bruh...") log.debug("Min zoom bruh...")
return return
@ -531,10 +542,34 @@ class ChartView(ViewBox):
# This seems like the most "intuitive option, a hybrid of # This seems like the most "intuitive option, a hybrid of
# tws and tv styles # tws and tv styles
last_bar = pg.Point(int(rbar)) last_bar = pg.Point(int(rbar)) + 1
ryaxis = chart.getAxis('right')
r_axis_x = ryaxis.pos().x()
end_of_l1 = pg.Point(
round(
chart._vb.mapToView(
pg.Point(r_axis_x - chart._max_l1_line_len)
# QPointF(chart._max_l1_line_len, 0)
).x()
)
) # .x()
# self.state['viewRange'][0][1] = end_of_l1
# focal = pg.Point((last_bar.x() + end_of_l1)/2)
focal = min(
last_bar,
end_of_l1,
key=lambda p: p.x()
)
# breakpoint()
# focal = pg.Point(last_bar.x() + end_of_l1)
self._resetTarget() self._resetTarget()
self.scaleBy(s, last_bar) self.scaleBy(s, focal)
ev.accept() ev.accept()
self.sigRangeChangedManually.emit(mask) self.sigRangeChangedManually.emit(mask)
@ -649,29 +684,10 @@ class ChartView(ViewBox):
self.raiseContextMenu(ev) self.raiseContextMenu(ev)
elif button == QtCore.Qt.LeftButton: elif button == QtCore.Qt.LeftButton:
# when in order mode, submit execution
if self._key_active:
ev.accept() ev.accept()
self.mode.submit_exec()
# 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):
""" """
@ -684,18 +700,25 @@ class ChartView(ViewBox):
return return
ev.accept() ev.accept()
text = ev.text() # text = ev.text()
key = ev.key() key = ev.key()
# mods = ev.modifiers() mods = ev.modifiers()
if key == QtCore.Qt.Key_Shift: 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': # if self.state['mouseMode'] == ViewBox.RectMode:
# draw "staged" line under cursor position # if key == QtCore.Qt.Key_Space:
if mods == QtCore.Qt.ControlModifier or key == QtCore.Qt.Key_Control:
self.mode._exec_mode = 'dark'
if key in {QtCore.Qt.Key_A, QtCore.Qt.Key_F, QtCore.Qt.Key_D}:
# remove "staged" level line under cursor position
self.mode.lines.unstage_line() self.mode.lines.unstage_line()
self._key_active = False
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.
@ -711,43 +734,56 @@ class ChartView(ViewBox):
key = ev.key() key = ev.key()
mods = ev.modifiers() mods = ev.modifiers()
print(f'text: {text}, key: {key}')
if mods == QtCore.Qt.ShiftModifier: 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 # ctrl
ctrl = False
if mods == QtCore.Qt.ControlModifier: if mods == QtCore.Qt.ControlModifier:
# TODO: ctrl-c as cancel? ctrl = True
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
# if ev.text() == 'c': if mods == QtCore.Qt.ControlModifier:
# self.rbScaleBox.hide() self.mode._exec_mode = 'live'
print(f"CTRL + key:{key} + text:{text}")
self._key_active = True
# alt # alt
if mods == QtCore.Qt.AltModifier: if mods == QtCore.Qt.AltModifier:
pass pass
# esc # esc
if key == QtCore.Qt.Key_Escape: if key == QtCore.Qt.Key_Escape or (ctrl and key == QtCore.Qt.Key_C):
# ctrl-c as cancel
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
self.select_box.clear() self.select_box.clear()
self._key_buffer.append(text) # cancel order or clear graphics
if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete:
# order modes
if text == 'r':
self.chart.default_view()
elif text == 'a':
# add a line at the current cursor
self.mode.lines.stage_line()
elif text == 'd':
# delete any lines under the cursor # delete any lines under the cursor
mode = self.mode mode = self.mode
for line in mode.lines.lines_under_cursor(): for line in mode.lines.lines_under_cursor():
mode.book.cancel(uuid=line.oid) mode.book.cancel(uuid=line.oid)
self._key_buffer.append(text)
# View modes
if key == QtCore.Qt.Key_R:
self.chart.default_view()
# Order modes: stage orders at the current cursor level
elif key == QtCore.Qt.Key_D: # for "damp eet"
self.mode.set_exec('sell')
elif key == QtCore.Qt.Key_F: # for "fillz eet"
self.mode.set_exec('buy')
elif key == QtCore.Qt.Key_A:
self.mode.set_exec('alert')
# XXX: Leaving this for light reference purposes, there # XXX: Leaving this for light reference purposes, there
# seems to be some work to at least gawk at for history mgmt. # seems to be some work to at least gawk at for history mgmt.
@ -764,3 +800,4 @@ class ChartView(ViewBox):
# self.scaleHistory(len(self.axHistory)) # self.scaleHistory(len(self.axHistory))
else: else:
ev.ignore() ev.ignore()
self._key_active = False

290
piker/ui/_l1.py 100644
View File

@ -0,0 +1,290 @@
# 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/>.
"""
Double auction top-of-book (L1) graphics.
"""
from typing import Tuple
import pyqtgraph as pg
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QPointF
from ._axes import YAxisLabel
from ._style import (
hcolor,
_down_2_font_inches_we_like,
)
class LevelLabel(YAxisLabel):
"""Y-axis (vertically) oriented, horizontal label that sticks to
where it's placed despite chart resizing and supports displaying
multiple fields.
TODO: replace the rectangle-text part with our new ``Label`` type.
"""
_x_margin = 0
_y_margin = 0
# adjustment "further away from" anchor point
_x_offset = 9
_y_offset = 0
# fields to be displayed in the label string
_fields = {
'level': 0,
'level_digits': 2,
}
# default label template is just a y-level with so much precision
_fmt_str = '{level:,.{level_digits}f} '
def __init__(
self,
chart,
parent,
color: str = 'bracket',
orient_v: str = 'bottom',
orient_h: str = 'left',
opacity: float = 0,
# makes order line labels offset from their parent axis
# such that they don't collide with the L1/L2 lines/prices
# that are displayed on the axis
adjust_to_l1: bool = False,
**axis_label_kwargs,
) -> None:
super().__init__(
chart,
parent=parent,
use_arrow=False,
opacity=opacity,
**axis_label_kwargs
)
# TODO: this is kinda cludgy
self._hcolor: pg.Pen = None
self.color: str = color
# orientation around axis options
self._orient_v = orient_v
self._orient_h = orient_h
self._adjust_to_l1 = adjust_to_l1
self._v_shift = {
'top': -1.,
'bottom': 0.,
'middle': 1 / 2.
}[orient_v]
self._h_shift = {
'left': -1.,
'right': 0.
}[orient_h]
self.fields = self._fields.copy()
# ensure default format fields are in correct
self.set_fmt_str(self._fmt_str, self.fields)
@property
def color(self):
return self._hcolor
@color.setter
def color(self, color: str) -> None:
self._hcolor = color
self._pen = self.pen = pg.mkPen(hcolor(color))
def update_on_resize(self, vr, r):
"""Tiis is a ``.sigRangeChanged()`` handler.
"""
self.update_fields(self.fields)
def update_fields(
self,
fields: dict = None,
) -> None:
"""Update the label's text contents **and** position from
a view box coordinate datum.
"""
self.fields.update(fields)
level = self.fields['level']
# map "level" to local coords
abs_xy = self._chart.mapFromView(QPointF(0, level))
self.update_label(
abs_xy,
self.fields,
)
def update_label(
self,
abs_pos: QPointF, # scene coords
fields: dict,
) -> None:
# write contents, type specific
h, w = self.set_label_str(fields)
if self._adjust_to_l1:
self._x_offset = self._chart._max_l1_line_len
self.setPos(QPointF(
self._h_shift * (w + self._x_offset),
abs_pos.y() + self._v_shift * h
))
def set_fmt_str(
self,
fmt_str: str,
fields: dict,
) -> (str, str):
# test that new fmt str can be rendered
self._fmt_str = fmt_str
self.set_label_str(fields)
self.fields.update(fields)
return fmt_str, self.label_str
def set_label_str(
self,
fields: dict,
):
# use space as e3 delim
self.label_str = self._fmt_str.format(**fields).replace(',', ' ')
br = self.boundingRect()
h, w = br.height(), br.width()
return h, w
def size_hint(self) -> Tuple[None, None]:
return None, None
def draw(
self,
p: QtGui.QPainter,
rect: QtCore.QRectF
) -> None:
p.setPen(self._pen)
rect = self.rect
if self._orient_v == 'bottom':
lp, rp = rect.topLeft(), rect.topRight()
# p.drawLine(rect.topLeft(), rect.topRight())
elif self._orient_v == 'top':
lp, rp = rect.bottomLeft(), rect.bottomRight()
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):
text_flags = (
QtCore.Qt.TextDontClip
| QtCore.Qt.AlignLeft
)
def set_label_str(
self,
fields: dict,
) -> None:
"""Make sure the max L1 line module var is kept up to date.
"""
h, w = super().set_label_str(fields)
# Set a global "max L1 label length" so we can
# look it up on order lines and adjust their
# labels not to overlap with it.
chart = self._chart
chart._max_l1_line_len: float = max(
chart._max_l1_line_len,
w
)
return h, w
class L1Labels:
"""Level 1 bid ask labels for dynamic update on price-axis.
"""
def __init__(
self,
chart: 'ChartPlotWidget', # noqa
digits: int = 2,
size_digits: int = 3,
font_size_inches: float = _down_2_font_inches_we_like,
) -> None:
self.chart = chart
raxis = chart.getAxis('right')
kwargs = {
'chart': chart,
'parent': raxis,
'opacity': 1,
'font_size_inches': font_size_inches,
'fg_color': chart.pen_color,
'bg_color': chart.view_color,
}
fmt_str = (
' {size:.{size_digits}f} x '
'{level:,.{level_digits}f} '
)
fields = {
'level': 0,
'level_digits': digits,
'size': 0,
'size_digits': size_digits,
}
bid = self.bid_label = L1Label(
orient_v='bottom',
**kwargs,
)
bid.set_fmt_str(fmt_str=fmt_str, fields=fields)
bid.show()
ask = self.ask_label = L1Label(
orient_v='top',
**kwargs,
)
ask.set_fmt_str(fmt_str=fmt_str, fields=fields)
ask.show()

248
piker/ui/_label.py 100644
View File

@ -0,0 +1,248 @@
# 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/>.
"""
Non-shitty labels that don't re-invent the wheel.
"""
from inspect import isfunction
from typing import Callable
import pyqtgraph as pg
from PyQt5 import QtGui
from PyQt5.QtCore import QPointF, QRectF
from ._style import (
DpiAwareFont,
hcolor,
_down_2_font_inches_we_like,
)
def vbr_left(label) -> Callable[..., float]:
"""Return a closure which gives the scene x-coordinate for the
leftmost point of the containing view box.
"""
return label.vbr().left
def right_axis(
chart: 'ChartPlotWidget', # noqa
label: 'Label', # noqa
side: str = 'left',
offset: float = 10,
avoid_book: bool = True,
width: float = None,
) -> Callable[..., float]:
"""Return a position closure which gives the scene x-coordinate for
the x point on the right y-axis minus the width of the label given
it's contents.
"""
ryaxis = chart.getAxis('right')
if side == 'left':
if avoid_book:
def right_axis_offset_by_w() -> float:
# l1 spread graphics x-size
l1_len = chart._max_l1_line_len
# sum of all distances "from" the y-axis
right_offset = l1_len + label.w + offset
return ryaxis.pos().x() - right_offset
else:
def right_axis_offset_by_w() -> float:
return ryaxis.pos().x() - (label.w + offset)
return right_axis_offset_by_w
elif 'right':
# axis_offset = ryaxis.style['tickTextOffset'][0]
def on_axis() -> float:
return ryaxis.pos().x() # + axis_offset - 2
return on_axis
class Label:
"""
After hacking for many days on multiple "label" systems inside
``pyqtgraph`` yet again we're left writing our own since it seems
all of those are over complicated, ad-hoc, pieces of garbage that
can't accomplish the simplest things, such as pinning to the left
hand side of a view box.
This type is another effort (see our graphics) to start making
small, re-usable label components that can actually be used to build
production grade UIs...
"""
def __init__(
self,
view: pg.ViewBox,
fmt_str: str,
color: str = 'bracket',
x_offset: float = 0,
font_size_inches: float = _down_2_font_inches_we_like,
opacity: float = 0.666,
fields: dict = {}
) -> None:
vb = self.vb = view
self._fmt_str = fmt_str
self._view_xy = QPointF(0, 0)
self._x_offset = x_offset
txt = self.txt = QtGui.QGraphicsTextItem()
vb.scene().addItem(txt)
# configure font size based on DPI
dpi_font = DpiAwareFont(
size_in_inches=font_size_inches
)
dpi_font.configure_to_dpi()
txt.setFont(dpi_font.font)
txt.setOpacity(opacity)
# register viewbox callbacks
vb.sigRangeChanged.connect(self.on_sigrange_change)
self._hcolor: str = ''
self.color = color
self.fields = fields
self.orient_v = 'bottom'
self._anchor_func = self.txt.pos().x
# not sure if this makes a diff
self.txt.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
# TODO: edit and selection support
# https://doc.qt.io/qt-5/qt.html#TextInteractionFlag-enum
# self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction)
@property
def color(self):
return self._hcolor
@color.setter
def color(self, color: str) -> None:
self.txt.setDefaultTextColor(pg.mkColor(hcolor(color)))
self._hcolor = color
def on_sigrange_change(self, vr, r) -> None:
self.set_view_y(self._view_xy.y())
@property
def w(self) -> float:
return self.txt.boundingRect().width()
@property
def h(self) -> float:
return self.txt.boundingRect().height()
def vbr(self) -> QRectF:
return self.vb.boundingRect()
def set_x_anchor_func(
self,
func: Callable,
) -> None:
assert isinstance(func(), float)
self._anchor_func = func
def set_view_y(
self,
y: float,
) -> None:
scene_x = self._anchor_func() or self.txt.pos().x()
# get new (inside the) view coordinates / position
self._view_xy = QPointF(
self.vb.mapToView(QPointF(scene_x, scene_x)).x(),
y,
)
# map back to the outer UI-land "scene" coordinates
s_xy = self.vb.mapFromView(self._view_xy)
if self.orient_v == 'top':
s_xy = QPointF(s_xy.x(), s_xy.y() - self.h)
# move label in scene coords to desired position
self.txt.setPos(s_xy)
assert s_xy == self.txt.pos()
def orient_on(self, h: str, v: str) -> None:
pass
@property
def fmt_str(self) -> str:
return self._fmt_str
@fmt_str.setter
def fmt_str(self, fmt_str: str) -> None:
self._fmt_str = fmt_str
def format(self, **fields: dict) -> str:
out = {}
# this is hacky support for single depth
# calcs of field data from field data
# ex. to calculate a $value = price * size
for k, v in fields.items():
if isfunction(v):
out[k] = v(fields)
else:
out[k] = v
text = self._fmt_str.format(**out)
# for large numbers with a thousands place
text = text.replace(',', ' ')
self.txt.setPlainText(text)
def render(self) -> None:
self.format(**self.fields)
def show(self) -> None:
self.txt.show()
def hide(self) -> None:
self.txt.hide()
def delete(self) -> None:
self.vb.scene().removeItem(self.txt)

View File

@ -0,0 +1,48 @@
# 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/>.
"""
Customization of ``pyqtgraph`` core routines to speed up our use mostly
based on not requiring "scentific precision" for pixel perfect view
transforms.
"""
import pyqtgraph as pg
def invertQTransform(tr):
"""Return a QTransform that is the inverse of *tr*.
Raises an exception if tr is not invertible.
Note that this function is preferred over QTransform.inverted() due to
bugs in that method. (specifically, Qt has floating-point precision issues
when determining whether a matrix is invertible)
"""
# see https://doc.qt.io/qt-5/qtransform.html#inverted
# NOTE: if ``invertable == False``, ``qt_t`` is an identity
qt_t, invertable = tr.inverted()
return qt_t
def _do_overrides() -> None:
"""Dooo eeet.
"""
# we don't care about potential fp issues inside Qt
pg.functions.invertQTransform = invertQTransform

View File

@ -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
@ -25,17 +25,22 @@ from PyQt5 import QtCore, QtGui
from qdarkstyle.palette import DarkPalette from qdarkstyle.palette import DarkPalette
from ..log import get_logger from ..log import get_logger
from ._exec import current_screen
log = get_logger(__name__) log = get_logger(__name__)
# chart-wide fonts specified in inches # chart-wide fonts specified in inches
_default_font_inches_we_like = 6 / 96 _default_font_inches_we_like_low_dpi = 6 / 64
_down_2_font_inches_we_like = 5 / 96 _down_2_font_inches_we_like_low_dpi = 4 / 64
_default_font_inches_we_like = 0.0616 # 5 / 96
_down_2_font_inches_we_like = 0.055 # 4 / 96
class DpiAwareFont: class DpiAwareFont:
def __init__( def __init__(
self, self,
# TODO: move to config
name: str = 'Hack', name: str = 'Hack',
size_in_inches: Optional[float] = None, size_in_inches: Optional[float] = None,
) -> None: ) -> None:
@ -45,12 +50,23 @@ class DpiAwareFont:
self._qfm = QtGui.QFontMetrics(self._qfont) self._qfm = QtGui.QFontMetrics(self._qfont)
self._physical_dpi = None self._physical_dpi = None
self._screen = None self._screen = None
self._dpi_scalar = 1.
def _set_qfont_px_size(self, px_size: int) -> None: def _set_qfont_px_size(self, px_size: int) -> None:
self._qfont.setPixelSize(px_size) self._qfont.setPixelSize(px_size)
self._qfm = QtGui.QFontMetrics(self._qfont) self._qfm = QtGui.QFontMetrics(self._qfont)
@property
def screen(self) -> QtGui.QScreen:
if self._screen is not None:
try:
self._screen.refreshRate()
except RuntimeError:
self._screen = current_screen()
else:
self._screen = current_screen()
return self._screen
@property @property
def font(self): def font(self):
return self._qfont return self._qfont
@ -59,30 +75,37 @@ class DpiAwareFont:
def px_size(self): def px_size(self):
return self._qfont.pixelSize() return self._qfont.pixelSize()
def configure_to_dpi(self, screen: QtGui.QScreen): def configure_to_dpi(self, screen: Optional[QtGui.QScreen] = None):
"""Set an appropriately sized font size depending on the screen DPI. """Set an appropriately sized font size depending on the screen DPI.
If we end up needing to generalize this more here there are resources If we end up needing to generalize this more here there are resources
listed in the script in ``snippets/qt_screen_info.py``. listed in the script in ``snippets/qt_screen_info.py``.
""" """
if screen is None:
screen = self.screen
# take the max since scaling can make things ugly in some cases # take the max since scaling can make things ugly in some cases
pdpi = screen.physicalDotsPerInch() pdpi = screen.physicalDotsPerInch()
ldpi = screen.logicalDotsPerInch() ldpi = screen.logicalDotsPerInch()
dpi = max(pdpi, ldpi) dpi = max(pdpi, ldpi)
# for low dpi scale everything down
if dpi <= 96:
self._iwl = _default_font_inches_we_like_low_dpi
font_size = math.floor(self._iwl * dpi) font_size = math.floor(self._iwl * dpi)
log.info( log.info(
f"\nscreen:{screen.name()} with DPI: {dpi}" f"\nscreen:{screen.name()} with DPI: {dpi}"
f"\nbest font size is {font_size}\n" f"\nbest font size is {font_size}\n"
) )
self._set_qfont_px_size(font_size) self._set_qfont_px_size(font_size)
self._physical_dpi = dpi self._physical_dpi = dpi
self._screen = screen
def boundingRect(self, value: str) -> QtCore.QRectF: def boundingRect(self, value: str) -> QtCore.QRectF:
screen = self._screen screen = self.screen
if screen is None: if screen is None:
raise RuntimeError("You must call .configure_to_dpi() first!") raise RuntimeError("You must call .configure_to_dpi() first!")
@ -110,8 +133,8 @@ _xaxis_at = 'bottom'
# charting config # charting config
CHART_MARGINS = (0, 0, 2, 2) CHART_MARGINS = (0, 0, 2, 2)
_min_points_to_show = 6 _min_points_to_show = 6
_bars_from_right_in_follow_mode = int(6**2) _bars_from_right_in_follow_mode = int(130)
_bars_to_left_in_follow_mode = int(6**3) _bars_to_left_in_follow_mode = int(616)
_tina_mode = False _tina_mode = False
@ -125,6 +148,10 @@ def enable_tina_mode() -> None:
def hcolor(name: str) -> str: def hcolor(name: str) -> str:
"""Hex color codes by hipster speak. """Hex color codes by hipster speak.
This is an internal set of color codes hand picked
for certain purposes.
""" """
return { return {
@ -137,6 +164,8 @@ def hcolor(name: str) -> str:
# fifty shades # fifty shades
'gray': '#808080', # like the kick 'gray': '#808080', # like the kick
'grayer': '#4c4c4c',
'grayest': '#3f3f3f',
'jet': '#343434', 'jet': '#343434',
'cadet': '#91A3B0', 'cadet': '#91A3B0',
'marengo': '#91A3B0', 'marengo': '#91A3B0',
@ -166,9 +195,39 @@ def hcolor(name: str) -> str:
'tina_green': '#00cc00', 'tina_green': '#00cc00',
'tina_red': '#fa0000', 'tina_red': '#fa0000',
'cucumber': '#006400',
'cool_green': '#33b864',
'dull_green': '#74a662',
'hedge_green': '#518360',
# orders and alerts # orders and alerts
'alert_yellow': '#e2d083', 'alert_yellow': '#e2d083',
'alert_yellow_light': '#ffe366', 'alert_yellow_light': '#ffe366',
# buys
# 'hedge': '#768a75',
# 'hedge': '#41694d',
# 'hedge': '#558964',
# 'hedge_light': '#5e9870',
'80s_neon_green': '#00b677',
# 'buy_green': '#41694d',
'buy_green': '#558964',
'buy_green_light': '#558964',
# sells
# techincally "raspberry"
# 'sell_red': '#990036',
# 'sell_red': '#9A0036',
# brighter then above
# 'sell_red': '#8c0030',
'sell_red': '#b6003f',
# 'sell_red': '#d00048',
'sell_red_light': '#f85462',
# 'sell_red': '#f85462',
# 'sell_red_light': '#ff4d5c',
}[name] }[name]

View File

@ -16,8 +16,8 @@
""" """
Console interface to UI components. Console interface to UI components.
""" """
from functools import partial
import os import os
import click import click
import tractor import tractor
@ -63,10 +63,10 @@ def monitor(config, rate, name, dhost, test, tl):
_kivy_import_hack() _kivy_import_hack()
from .kivy.monitor import _async_main from .kivy.monitor import _async_main
async def main(tries): async def main():
async with maybe_spawn_brokerd( async with maybe_spawn_brokerd(
brokername=brokermod.name, brokername=brokermod.name,
tries=tries, loglevel=loglevel loglevel=loglevel
) as portal: ) as portal:
# run app "main" # run app "main"
await _async_main( await _async_main(
@ -75,7 +75,7 @@ def monitor(config, rate, name, dhost, test, tl):
) )
tractor.run( tractor.run(
partial(main, tries=1), main,
name='monitor', name='monitor',
loglevel=loglevel if tl else None, loglevel=loglevel if tl else None,
rpc_module_paths=['piker.ui.kivy.monitor'], rpc_module_paths=['piker.ui.kivy.monitor'],
@ -90,7 +90,7 @@ def monitor(config, rate, name, dhost, test, tl):
@click.option('--rate', '-r', default=1, help='Logging level') @click.option('--rate', '-r', default=1, help='Logging level')
@click.argument('symbol', required=True) @click.argument('symbol', required=True)
@click.pass_obj @click.pass_obj
def optschain(config, symbol, date, tl, rate, test): def optschain(config, symbol, date, rate, test):
"""Start an option chain UI """Start an option chain UI
""" """
# global opts # global opts
@ -100,9 +100,9 @@ def optschain(config, symbol, date, tl, rate, test):
_kivy_import_hack() _kivy_import_hack()
from .kivy.option_chain import _async_main from .kivy.option_chain import _async_main
async def main(tries): async def main():
async with maybe_spawn_brokerd( async with maybe_spawn_brokerd(
tries=tries, loglevel=loglevel loglevel=loglevel
): ):
# run app "main" # run app "main"
await _async_main( await _async_main(
@ -114,9 +114,8 @@ def optschain(config, symbol, date, tl, rate, test):
) )
tractor.run( tractor.run(
partial(main, tries=1), main,
name='kivy-options-chain', name='kivy-options-chain',
loglevel=loglevel if tl else None,
) )
@ -126,30 +125,31 @@ def optschain(config, symbol, date, tl, rate, test):
is_flag=True, is_flag=True,
help='Enable pyqtgraph profiling' help='Enable pyqtgraph profiling'
) )
@click.option('--date', '-d', help='Contracts expiry date')
@click.option('--test', '-t', help='Test quote stream file')
@click.option('--rate', '-r', default=1, help='Logging level')
@click.argument('symbol', required=True) @click.argument('symbol', required=True)
@click.pass_obj @click.pass_obj
def chart(config, symbol, date, rate, test, profile): def chart(config, symbol, profile):
"""Start a real-time chartng UI """Start a real-time chartng UI
""" """
from .. import _profile from .. import _profile
from ._chart import _main from ._chart import _main
# possibly enable profiling # toggle to enable profiling
_profile._pg_profile = profile _profile._pg_profile = profile
# global opts # global opts
brokername = config['broker'] brokername = config['broker']
tractorloglevel = config['tractorloglevel'] tractorloglevel = config['tractorloglevel']
pikerloglevel = config['loglevel']
_main( _main(
sym=symbol, sym=symbol,
brokername=brokername, brokername=brokername,
piker_loglevel=pikerloglevel,
tractor_kwargs={ tractor_kwargs={
'debug_mode': True, 'debug_mode': True,
'loglevel': tractorloglevel, 'loglevel': tractorloglevel,
'rpc_module_paths': ['piker._ems'], 'enable_modules': [
'piker.exchange._client'
],
}, },
) )

View File

@ -62,7 +62,7 @@ async def update_quotes(
color = colorcode('gray') color = colorcode('gray')
# if the cell has been "highlighted" make sure to change its color # if the cell has been "highlighted" make sure to change its color
if hdrcell.background_color != [0]*4: if hdrcell.background_color != [0] * 4:
hdrcell.background_color = color hdrcell.background_color = color
# update row header and '%' cell text color # update row header and '%' cell text color
@ -144,14 +144,17 @@ async def update_quotes(
log.warn("Data feed connection dropped") log.warn("Data feed connection dropped")
_widgets = {}
async def stream_symbol_selection(): async def stream_symbol_selection():
"""An RPC async gen for streaming the symbol corresponding """An RPC async gen for streaming the symbol corresponding
value corresponding to the last clicked row. value corresponding to the last clicked row.
Essentially of an event stream of clicked symbol values. Essentially of an event stream of clicked symbol values.
""" """
widgets = tractor.current_actor().statespace['widgets'] global _widgets
table = widgets['table'] table = _widgets['table']
send_chan, recv_chan = trio.open_memory_channel(0) send_chan, recv_chan = trio.open_memory_channel(0)
table._click_queues.append(send_chan) table._click_queues.append(send_chan)
try: try:
@ -238,8 +241,6 @@ async def _async_main(
# set up a pager view for large ticker lists # set up a pager view for large ticker lists
table.bind(minimum_height=table.setter('height')) table.bind(minimum_height=table.setter('height'))
ss = tractor.current_actor().statespace
async def spawn_opts_chain(): async def spawn_opts_chain():
"""Spawn an options chain UI in a new subactor. """Spawn an options chain UI in a new subactor.
""" """
@ -276,7 +277,10 @@ async def _async_main(
'header': header, 'header': header,
'pager': pager, 'pager': pager,
} }
ss['widgets'] = widgets
global _widgets
_widgets = widgets
nursery.start_soon( nursery.start_soon(
update_quotes, update_quotes,
nursery, nursery,

View File

@ -0,0 +1,417 @@
# 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/>.
"""
Chart trading, the only way to scalp.
"""
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pprint import pformat
import time
from typing import Optional, Dict, Callable, Any
import uuid
import pyqtgraph as pg
import trio
from pydantic import BaseModel
from ._graphics._lines import LevelLine, position_line
from ._interaction import LineEditor, ArrowEditor, _order_lines
from ..exchange._client import open_ems, OrderBook
from ..data._source import Symbol
from ..log import get_logger
log = get_logger(__name__)
class Position(BaseModel):
symbol: Symbol
size: float
avg_price: float
fills: Dict[str, Any] = {}
@dataclass
class OrderMode:
"""Major mode for placing orders on a chart view.
This is the default mode that pairs with "follow mode"
(when wathing the rt price update at the current time step)
and allows entering orders using the ``a, d, f`` keys and
cancelling moused-over orders with the ``c`` key.
"""
chart: 'ChartPlotWidget' # type: ignore # noqa
book: OrderBook
lines: LineEditor
arrows: ArrowEditor
_colors = {
'alert': 'alert_yellow',
'buy': 'buy_green',
'sell': 'sell_red',
}
_action: str = 'alert'
_exec_mode: str = 'dark'
_size: float = 100.0
_position: Dict[str, Any] = field(default_factory=dict)
_position_line: dict = None
key_map: Dict[str, Callable] = field(default_factory=dict)
def on_position_update(
self,
msg: dict,
) -> None:
print(f'Position update {msg}')
sym = self.chart._lc._symbol
if msg['symbol'].lower() not in sym.key:
return
size = msg['size']
self._position.update(msg)
if self._position_line:
self._position_line.delete()
if size != 0.0:
line = self._position_line = position_line(
self.chart,
level=msg['avg_price'],
size=size,
)
line.show()
def uuid(self) -> str:
return str(uuid.uuid4())
def set_exec(
self,
action: str,
size: Optional[int] = None,
) -> None:
"""Set execution mode.
"""
self._action = action
self.lines.stage_line(
color=self._colors[action],
# hl_on_hover=True if self._exec_mode == 'live' else False,
dotted=True if self._exec_mode == 'dark' else False,
size=size or self._size,
action=action,
)
def on_submit(self, uuid: str) -> dict:
"""On order submitted event, commit the order line
and registered order uuid, store ack time stamp.
TODO: annotate order line with submission type ('live' vs.
'dark').
"""
line = self.lines.commit_line(uuid)
req_msg = self.book._sent_orders.get(uuid)
if req_msg:
req_msg['ack_time_ns'] = time.time_ns()
return line
def on_fill(
self,
uuid: str,
price: float,
arrow_index: float,
pointing: Optional[str] = None
) -> None:
line = self.lines._order_lines.get(uuid)
if line:
self.arrows.add(
uuid,
arrow_index,
price,
pointing=pointing,
color=line.color
)
async def on_exec(
self,
uuid: str,
msg: Dict[str, Any],
) -> None:
# only once all fills have cleared and the execution
# is complet do we remove our "order line"
line = self.lines.remove_line(uuid=uuid)
log.debug(f'deleting {line} with oid: {uuid}')
# 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)
def on_cancel(self, uuid: str) -> None:
msg = self.book._sent_orders.pop(uuid, None)
if msg is not None:
self.lines.remove_line(uuid=uuid)
self.chart._cursor.show_xhair()
else:
log.warning(
f'Received cancel for unsubmitted order {pformat(msg)}'
)
def submit_exec(
self,
size: Optional[float] = None,
) -> LevelLine:
"""Send execution 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).
uid = str(uuid.uuid4())
size = size or self._size
chart = self.chart._cursor.active_plot
y = chart._cursor._datum_xy[1]
symbol = self.chart._lc._symbol
action = self._action
# send order cmd to ems
self.book.send(
uuid=uid,
symbol=symbol.key,
brokers=symbol.brokers,
price=y,
size=size,
action=action,
exec_mode=self._exec_mode,
)
# make line graphic if order push was
# sucessful
line = self.lines.create_order_line(
uid,
level=y,
chart=chart,
size=size,
action=action,
)
line.oid = uid
# hook up mouse drag handlers
line._on_drag_start = self.order_line_modify_start
line._on_drag_end = self.order_line_modify_complete
return line
def cancel_order_under_cursor(self) -> None:
for line in self.lines.lines_under_cursor():
self.book.cancel(uuid=line.oid)
# order-line modify handlers
def order_line_modify_start(
self,
line: LevelLine,
) -> None:
print(f'Line modify: {line}')
# cancel original order until new position is found
def order_line_modify_complete(
self,
line: LevelLine,
) -> None:
self.book.update(
uuid=line.oid,
# TODO: should we round this to a nearest tick here?
price=line.value(),
)
# def on_key_press(
# self,
# key:
# mods:
# text: str,
# ) -> None:
# pass
@asynccontextmanager
async def open_order_mode(
symbol: Symbol,
chart: pg.PlotWidget,
book: OrderBook,
):
view = chart._vb
lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines)
arrows = ArrowEditor(chart, {})
log.info("Opening order mode")
mode = OrderMode(chart, book, lines, arrows)
view.mode = mode
asset_type = symbol.type_key
if asset_type == 'stock':
mode._size = 100.0
elif asset_type in ('future', 'option', 'futures_option'):
mode._size = 1.0
else: # to be safe
mode._size = 1.0
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")
async def start_order_mode(
chart: 'ChartPlotWidget', # noqa
symbol: Symbol,
brokername: str,
) -> None:
# spawn EMS actor-service
async with open_ems(
brokername,
symbol,
) as (book, trades_stream):
async with open_order_mode(
symbol,
chart,
book,
) as order_mode:
def get_index(time: float):
# XXX: not sure why the time is so off here
# looks like we're gonna have to do some fixing..
ohlc = chart._shm.array
indexes = ohlc['time'] >= time
if any(indexes):
return ohlc['index'][indexes[-1]]
else:
return ohlc['index'][-1]
# Begin order-response streaming
# this is where we receive **back** messages
# about executions **from** the EMS actor
async for msg in trades_stream:
fmsg = pformat(msg)
log.info(f'Received order msg:\n{fmsg}')
resp = msg['resp']
if resp in (
'position',
):
# show line label once order is live
order_mode.on_position_update(msg)
continue
# delete the line from view
oid = msg['oid']
# response to 'action' request (buy/sell)
if resp in (
'dark_submitted',
'broker_submitted'
):
# show line label once order is live
order_mode.on_submit(oid)
# resp to 'cancel' request or error condition
# for action request
elif resp in (
'broker_cancelled',
'broker_inactive',
'dark_cancelled'
):
# delete level line from view
order_mode.on_cancel(oid)
elif resp in (
'dark_executed'
):
log.info(f'Dark order triggered for {fmsg}')
# for alerts add a triangle and remove the
# level line
if msg['cmd']['action'] == 'alert':
# should only be one "fill" for an alert
order_mode.on_fill(
oid,
price=msg['trigger_price'],
arrow_index=get_index(time.time())
)
await order_mode.on_exec(oid, msg)
# response to completed 'action' request for buy/sell
elif resp in (
'broker_executed',
):
await order_mode.on_exec(oid, msg)
# each clearing tick is responded individually
elif resp in ('broker_filled',):
action = msg['action']
# TODO: some kinda progress system
order_mode.on_fill(
oid,
price=msg['price'],
arrow_index=get_index(msg['broker_time']),
pointing='up' if action == 'buy' else 'down',
)

View File

@ -1,4 +1,3 @@
# no pypi package for tractor (yet) # no pypi package for tractor (yet)
# we require the asyncio-via-guest-mode dev branch # we require the asyncio-via-guest-mode dev branch
-e git+git://github.com/goodboy/tractor.git@infect_asyncio#egg=tractor -e git+git://github.com/goodboy/tractor.git@infect_asyncio#egg=tractor
-e git+git://github.com/pikers/pyqtgraph.git@use_qt_inverted#egg=pyqtgraph

View File

@ -45,6 +45,7 @@ setup(
'attrs', 'attrs',
'pygments', 'pygments',
'colorama', # numba traceback coloring 'colorama', # numba traceback coloring
'pydantic', # structured data
# async # async
'trio', 'trio',
@ -58,6 +59,7 @@ setup(
# numerics # numerics
'arrow', # better datetimes 'arrow', # better datetimes
'bidict', # 2 way map
'cython', 'cython',
'numpy', 'numpy',
'numba', 'numba',