commit
8c39ff307e
24
.travis.yml
24
.travis.yml
|
@ -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/
|
|
@ -8,7 +8,7 @@ trading gear for hackers.
|
|||
:target: https://actions-badge.atrox.dev/piker/pikers/goto
|
||||
|
||||
``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):
|
||||
|
||||
|
@ -32,6 +32,7 @@ we use as much bleeding edge tech as possible including (but not limited to):
|
|||
.. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph
|
||||
.. _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/
|
||||
.. _comp_trader: https://jfaleiro.wordpress.com/2019/10/09/computational-trader/
|
||||
|
||||
|
||||
focus and features:
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
"""
|
||||
Async utils no one seems to have built into a core lib (yet).
|
||||
"""
|
||||
from typing import AsyncContextManager
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
def async_lifo_cache(maxsize=128):
|
||||
|
@ -47,3 +49,18 @@ def async_lifo_cache(maxsize=128):
|
|||
return wrapper
|
||||
|
||||
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
|
||||
|
|
459
piker/_ems.py
459
piker/_ems.py
|
@ -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)
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -16,7 +16,9 @@
|
|||
|
||||
"""
|
||||
Actor-aware broker agnostic interface.
|
||||
|
||||
"""
|
||||
from typing import Dict
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
|
||||
import trio
|
||||
|
@ -28,6 +30,7 @@ from ..log import get_logger
|
|||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_cache: Dict[str, 'Client'] = {}
|
||||
|
||||
@asynccontextmanager
|
||||
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.
|
||||
"""
|
||||
# check if a cached client is in the local actor's statespace
|
||||
ss = tractor.current_actor().statespace
|
||||
clients = ss.setdefault('clients', {'_lock': trio.Lock()})
|
||||
global _cache
|
||||
|
||||
clients = _cache.setdefault('clients', {'_lock': trio.Lock()})
|
||||
|
||||
# global cache task lock
|
||||
lock = clients['_lock']
|
||||
|
||||
client = None
|
||||
|
||||
try:
|
||||
log.info(f"Loading existing `{brokername}` daemon")
|
||||
log.info(f"Loading existing `{brokername}` client")
|
||||
|
||||
async with lock:
|
||||
client = clients[brokername]
|
||||
client._consumers += 1
|
||||
|
||||
yield client
|
||||
|
||||
except KeyError:
|
||||
log.info(f"Creating new client for broker {brokername}")
|
||||
|
||||
async with lock:
|
||||
brokermod = get_brokermod(brokername)
|
||||
exit_stack = AsyncExitStack()
|
||||
|
||||
client = await exit_stack.enter_async_context(
|
||||
brokermod.get_client()
|
||||
)
|
||||
client._consumers = 0
|
||||
client._exit_stack = exit_stack
|
||||
clients[brokername] = client
|
||||
|
||||
yield client
|
||||
|
||||
finally:
|
||||
client._consumers -= 1
|
||||
if client._consumers <= 0:
|
||||
|
|
|
@ -180,15 +180,18 @@ async def symbol_data(broker: str, tickers: List[str]):
|
|||
return await feed.client.symbol_info(tickers)
|
||||
|
||||
|
||||
_feeds_cache = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_cached_feed(
|
||||
brokername: str,
|
||||
) -> BrokerFeed:
|
||||
"""Get/create a ``BrokerFeed`` from/in the current actor.
|
||||
"""
|
||||
# check if a cached client is in the local actor's statespace
|
||||
ss = tractor.current_actor().statespace
|
||||
feeds = ss.setdefault('feeds', {'_lock': trio.Lock()})
|
||||
global _feeds_cache
|
||||
|
||||
# check if a cached feed is in the local actor
|
||||
feeds = _feeds_cache.setdefault('feeds', {'_lock': trio.Lock()})
|
||||
lock = feeds['_lock']
|
||||
feed = None
|
||||
try:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -23,37 +23,38 @@ built on it) and thus actor aware API calls must be spawned with
|
|||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
from functools import partial
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable
|
||||
import asyncio
|
||||
import logging
|
||||
from pprint import pformat
|
||||
import inspect
|
||||
import itertools
|
||||
import logging
|
||||
import time
|
||||
|
||||
import trio
|
||||
import tractor
|
||||
from async_generator import aclosing
|
||||
from ib_insync.wrapper import RequestError
|
||||
from ib_insync.contract import Contract, ContractDetails
|
||||
from ib_insync.order import Order
|
||||
from ib_insync.ticker import Ticker
|
||||
from ib_insync.objects import Position
|
||||
import ib_insync as ibis
|
||||
from ib_insync.wrapper import Wrapper
|
||||
from ib_insync.client import Client as ib_Client
|
||||
import trio
|
||||
import tractor
|
||||
|
||||
from ..log import get_logger, get_console_log
|
||||
from ..data import (
|
||||
maybe_spawn_brokerd,
|
||||
iterticks,
|
||||
attach_shm_array,
|
||||
# get_shm_token,
|
||||
subscribe_ohlc_for_increment,
|
||||
_buffer,
|
||||
)
|
||||
from ..data._source import from_df
|
||||
from ._util import SymbolNotFound
|
||||
from .._async_utils import maybe_with_if
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
@ -100,8 +101,9 @@ class NonShittyWrapper(Wrapper):
|
|||
def tcpDataArrived(self):
|
||||
"""Override time stamps to be floats for now.
|
||||
"""
|
||||
# use a float to store epoch time instead of datetime
|
||||
self.lastTime = time.time()
|
||||
# use a ns int to store epoch time instead of datetime
|
||||
self.lastTime = time.time_ns()
|
||||
|
||||
for ticker in self.pendingTickers:
|
||||
ticker.rtTime = None
|
||||
ticker.ticks = []
|
||||
|
@ -109,6 +111,20 @@ class NonShittyWrapper(Wrapper):
|
|||
ticker.domTicks = []
|
||||
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):
|
||||
"""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
|
||||
self.wrapper = NonShittyWrapper(self)
|
||||
self.client = ib_Client(self.wrapper)
|
||||
self.errorEvent += self._onError
|
||||
# self.errorEvent += self._onError
|
||||
self.client.apiEnd += self.disconnectedEvent
|
||||
self._logger = logging.getLogger('ib_insync.ib')
|
||||
|
||||
|
@ -150,6 +166,9 @@ class Client:
|
|||
self.ib = ib
|
||||
self.ib.RaiseRequestErrors = True
|
||||
|
||||
# contract cache
|
||||
self._contracts: Dict[str, Contract] = {}
|
||||
|
||||
# NOTE: the ib.client here is "throttled" to 45 rps by default
|
||||
|
||||
async def bars(
|
||||
|
@ -199,7 +218,6 @@ class Client:
|
|||
|
||||
# barSizeSetting='1 min',
|
||||
|
||||
|
||||
# always use extended hours
|
||||
useRTH=False,
|
||||
|
||||
|
@ -217,9 +235,6 @@ class Client:
|
|||
df = ibis.util.df(bars)
|
||||
return bars, from_df(df)
|
||||
|
||||
def onError(self, reqId, errorCode, errorString, contract) -> None:
|
||||
breakpoint()
|
||||
|
||||
async def search_stocks(
|
||||
self,
|
||||
pattern: str,
|
||||
|
@ -283,6 +298,24 @@ class Client:
|
|||
currency: str = 'USD',
|
||||
**kwargs,
|
||||
) -> 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"
|
||||
try:
|
||||
sym, exch = symbol.upper().rsplit('.', maxsplit=1)
|
||||
|
@ -331,11 +364,10 @@ class Client:
|
|||
exch = 'SMART' if not exch else exch
|
||||
contract = (await self.ib.qualifyContractsAsync(con))[0]
|
||||
|
||||
# head = await self.get_head_time(contract)
|
||||
# print(head)
|
||||
|
||||
except IndexError:
|
||||
raise ValueError(f"No contract could be found {con}")
|
||||
|
||||
self._contracts[symbol] = contract
|
||||
return contract
|
||||
|
||||
async def get_head_time(
|
||||
|
@ -356,9 +388,8 @@ class Client:
|
|||
self,
|
||||
symbol: str,
|
||||
to_trio,
|
||||
opts: Tuple[int] = ('375', '233',),
|
||||
opts: Tuple[int] = ('375', '233', '236'),
|
||||
contract: Optional[Contract] = None,
|
||||
# opts: Tuple[int] = ('459',),
|
||||
) -> None:
|
||||
"""Stream a ticker using the std L1 api.
|
||||
"""
|
||||
|
@ -397,11 +428,174 @@ class Client:
|
|||
|
||||
"""
|
||||
contract = await self.find_contract(symbol)
|
||||
|
||||
details_fute = self.ib.reqContractDetailsAsync(contract)
|
||||
ticker: Ticker = self.ib.reqMktData(
|
||||
contract,
|
||||
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
|
||||
|
@ -422,7 +616,6 @@ async def _aio_get_client(
|
|||
"""
|
||||
# first check cache for existing client
|
||||
|
||||
# breakpoint()
|
||||
try:
|
||||
if port:
|
||||
client = _client_cache[(host, port)]
|
||||
|
@ -456,11 +649,13 @@ async def _aio_get_client(
|
|||
else:
|
||||
raise ConnectionRefusedError(_err)
|
||||
|
||||
# create and cache
|
||||
try:
|
||||
client = Client(ib)
|
||||
_client_cache[(host, port)] = client
|
||||
log.debug(f"Caching client for {(host, port)}")
|
||||
yield client
|
||||
|
||||
except BaseException:
|
||||
ib.disconnect()
|
||||
raise
|
||||
|
@ -527,14 +722,13 @@ class _MethodProxy:
|
|||
**kwargs
|
||||
) -> Any:
|
||||
return await self._portal.run(
|
||||
__name__,
|
||||
'_trio_run_client_method',
|
||||
_trio_run_client_method,
|
||||
method=meth,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def get_method_proxy(portal, target) -> _MethodProxy:
|
||||
def get_client_proxy(portal, target=Client) -> _MethodProxy:
|
||||
|
||||
proxy = _MethodProxy(portal)
|
||||
|
||||
|
@ -558,11 +752,11 @@ async def get_client(
|
|||
"""
|
||||
async with maybe_spawn_brokerd(
|
||||
brokername='ib',
|
||||
expose_mods=[__name__],
|
||||
infect_asyncio=True,
|
||||
**kwargs
|
||||
) 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
|
||||
|
@ -586,6 +780,7 @@ def normalize(
|
|||
# convert named tuples to dicts so we send usable keys
|
||||
new_ticks = []
|
||||
for tick in ticker.ticks:
|
||||
if tick:
|
||||
td = tick._asdict()
|
||||
td['type'] = tick_types.get(td['tickType'], 'n/a')
|
||||
|
||||
|
@ -645,7 +840,7 @@ async def fill_bars(
|
|||
first_bars: list,
|
||||
shm: 'ShmArray', # type: ignore # noqa
|
||||
# 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:
|
||||
"""Fill historical bars into shared mem / storage afap.
|
||||
|
||||
|
@ -663,8 +858,8 @@ async def fill_bars(
|
|||
method='bars',
|
||||
symbol=sym,
|
||||
end_dt=next_dt,
|
||||
|
||||
)
|
||||
|
||||
shm.push(bars_array, prepend=True)
|
||||
i += 1
|
||||
next_dt = bars[0].date
|
||||
|
@ -690,6 +885,25 @@ async def fill_bars(
|
|||
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
|
||||
# the wacky ``ib_insync`` api.
|
||||
# @tractor.msg.pub
|
||||
|
@ -699,6 +913,7 @@ async def stream_quotes(
|
|||
symbols: List[str],
|
||||
shm_token: Tuple[str, str, List[tuple]],
|
||||
loglevel: str = None,
|
||||
|
||||
# compat for @tractor.msg.pub
|
||||
topics: Any = None,
|
||||
get_topics: Callable = None,
|
||||
|
@ -714,7 +929,7 @@ async def stream_quotes(
|
|||
# TODO: support multiple subscriptions
|
||||
sym = symbols[0]
|
||||
|
||||
contract, first_ticker = await _trio_run_client_method(
|
||||
contract, first_ticker, details = await _trio_run_client_method(
|
||||
method='get_quote',
|
||||
symbol=sym,
|
||||
)
|
||||
|
@ -725,8 +940,8 @@ async def stream_quotes(
|
|||
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,
|
||||
# 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
|
||||
# check if shm has already been created by previous
|
||||
# feed initialization
|
||||
async with trio.open_nursery() as ln:
|
||||
if not writer_already_exists:
|
||||
_local_buffer_writers[key] = True
|
||||
|
||||
|
@ -774,9 +988,35 @@ async def stream_quotes(
|
|||
delay_s = times[-1] - times[times != times[-1]][-1]
|
||||
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
|
||||
# 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
|
||||
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
|
||||
suffix = 'secType'
|
||||
calc_price = True
|
||||
ticker = first_ticker
|
||||
# ticker = first_ticker
|
||||
|
||||
# pass first quote asap
|
||||
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
|
||||
|
||||
# with trio.move_on_after(10) as cs:
|
||||
# wait for real volume on feed (trading might be closed)
|
||||
|
||||
async with aclosing(stream):
|
||||
async for ticker in stream:
|
||||
|
||||
# for a real volume contract we rait for the first
|
||||
|
@ -836,6 +1079,34 @@ async def stream_quotes(
|
|||
# ``aclosing()`` above?
|
||||
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
|
||||
async for ticker in stream:
|
||||
|
||||
|
@ -895,3 +1166,113 @@ async def stream_quotes(
|
|||
|
||||
# ugh, clear ticks since we've consumed them
|
||||
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)}
|
||||
|
|
|
@ -17,19 +17,29 @@
|
|||
"""
|
||||
Kraken backend.
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
from dataclasses import asdict, field
|
||||
from types import ModuleType
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import json
|
||||
import time
|
||||
|
||||
import trio_websocket
|
||||
from trio_websocket._impl import ConnectionClosed, DisconnectionTimeout
|
||||
from trio_websocket._impl import (
|
||||
ConnectionClosed,
|
||||
DisconnectionTimeout,
|
||||
ConnectionRejected,
|
||||
HandshakeError,
|
||||
ConnectionTimeout,
|
||||
)
|
||||
|
||||
import arrow
|
||||
import asks
|
||||
import numpy as np
|
||||
import trio
|
||||
import tractor
|
||||
from pydantic.dataclasses import dataclass
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._util import resproc, SymbolNotFound, BrokerError
|
||||
from ..log import get_logger, get_console_log
|
||||
|
@ -68,6 +78,68 @@ ohlc_dtype = np.dtype(_ohlc_dtype)
|
|||
_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:
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
@ -165,52 +237,27 @@ async def get_client() -> Client:
|
|||
yield Client()
|
||||
|
||||
|
||||
@dataclass
|
||||
class OHLC:
|
||||
"""Description of the flattened OHLC quote format.
|
||||
async def stream_messages(ws):
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
too_slow_count += 1
|
||||
if too_slow_count > 2:
|
||||
|
||||
if too_slow_count > 10:
|
||||
log.warning(
|
||||
"Heartbeat is to slow, "
|
||||
"resetting ws connection")
|
||||
raise trio_websocket._impl.ConnectionClosed(
|
||||
"Reset Connection")
|
||||
"Heartbeat is too slow, resetting ws connection")
|
||||
|
||||
await ws._connect()
|
||||
too_slow_count = 0
|
||||
continue
|
||||
|
||||
if isinstance(msg, dict):
|
||||
if msg.get('event') == 'heartbeat':
|
||||
|
@ -218,11 +265,11 @@ async def recv_msg(recv):
|
|||
now = time.time()
|
||||
delay = now - last_hb
|
||||
last_hb = now
|
||||
log.trace(f"Heartbeat after {delay}")
|
||||
|
||||
# TODO: hmm i guess we should use this
|
||||
# for determining when to do connection
|
||||
# resets eh?
|
||||
# XXX: why tf is this not printing without --tl flag?
|
||||
log.debug(f"Heartbeat after {delay}")
|
||||
# print(f"Heartbeat after {delay}")
|
||||
|
||||
continue
|
||||
|
||||
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
|
||||
async def stream_quotes(
|
||||
# get_topics: Callable,
|
||||
|
@ -312,11 +446,17 @@ async def stream_quotes(
|
|||
get_console_log(loglevel or tractor.current_actor().loglevel)
|
||||
|
||||
ws_pairs = {}
|
||||
sym_infos = {}
|
||||
async with get_client() as client:
|
||||
|
||||
# keep client cached for real-time section
|
||||
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
|
||||
# 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]
|
||||
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:
|
||||
try:
|
||||
async with trio_websocket.open_websocket_url(
|
||||
'wss://ws.kraken.com/',
|
||||
) as ws:
|
||||
async with open_autorecon_ws('wss://ws.kraken.com/') as ws:
|
||||
|
||||
# XXX: setup subs
|
||||
# https://docs.kraken.com/websockets/#message-subscribe
|
||||
|
@ -361,21 +508,19 @@ async def stream_quotes(
|
|||
# be completely fine to request from a separate task
|
||||
# since internally the ws methods appear to be FIFO
|
||||
# locked.
|
||||
await ws.send_message(json.dumps(ohlc_sub))
|
||||
await ws.send_msg(ohlc_sub)
|
||||
|
||||
# trade data (aka L1)
|
||||
l1_sub = make_sub(
|
||||
list(ws_pairs.values()),
|
||||
{'name': 'spread'} # 'depth': 10}
|
||||
|
||||
)
|
||||
await ws.send_message(json.dumps(l1_sub))
|
||||
|
||||
async def recv():
|
||||
return json.loads(await ws.get_message())
|
||||
await ws.send_msg(l1_sub)
|
||||
|
||||
# pull a first quote and deliver
|
||||
msg_gen = recv_msg(recv)
|
||||
msg_gen = stream_messages(ws)
|
||||
|
||||
typ, ohlc_last = await msg_gen.__anext__()
|
||||
|
||||
topic, quote = normalize(ohlc_last)
|
||||
|
@ -459,6 +604,3 @@ async def stream_quotes(
|
|||
# XXX: format required by ``tractor.msg.pub``
|
||||
# requires a ``Dict[topic: str, quote: dict]``
|
||||
yield {topic: quote}
|
||||
|
||||
except (ConnectionClosed, DisconnectionTimeout):
|
||||
log.exception("Good job kraken...reconnecting")
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -21,15 +21,15 @@ We provide tsdb integrations for retrieving
|
|||
and storing data from your brokers as well as
|
||||
sharing your feeds with other fellow pikers.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
from typing import (
|
||||
Dict, List, Any,
|
||||
Sequence, AsyncIterator, Optional
|
||||
Dict, Any, Sequence, AsyncIterator, Optional
|
||||
)
|
||||
|
||||
import trio
|
||||
import tractor
|
||||
|
||||
from ..brokers import get_brokermod
|
||||
|
@ -42,7 +42,7 @@ from ._sharedmem import (
|
|||
ShmArray,
|
||||
get_shm_token,
|
||||
)
|
||||
from ._source import base_iohlc_dtype
|
||||
from ._source import base_iohlc_dtype, Symbol
|
||||
from ._buffer import (
|
||||
increment_ohlc_buffer,
|
||||
subscribe_ohlc_for_increment
|
||||
|
@ -87,10 +87,11 @@ _data_mods = [
|
|||
@asynccontextmanager
|
||||
async def maybe_spawn_brokerd(
|
||||
brokername: str,
|
||||
sleep: float = 0.5,
|
||||
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:
|
||||
"""If no ``brokerd.{brokername}`` daemon-actor can be found,
|
||||
spawn one in a local subactor and return a portal to it.
|
||||
|
@ -98,11 +99,6 @@ async def maybe_spawn_brokerd(
|
|||
if loglevel:
|
||||
get_console_log(loglevel)
|
||||
|
||||
# disable debugger in brokerd?
|
||||
# tractor._state._runtime_vars['_debug_mode'] = False
|
||||
|
||||
tractor_kwargs['loglevel'] = loglevel
|
||||
|
||||
brokermod = get_brokermod(brokername)
|
||||
dname = f'brokerd.{brokername}'
|
||||
async with tractor.find_actor(dname) as portal:
|
||||
|
@ -114,18 +110,25 @@ async def maybe_spawn_brokerd(
|
|||
else: # no daemon has been spawned yet
|
||||
|
||||
log.info(f"Spawning {brokername} broker daemon")
|
||||
|
||||
# retrieve any special config from the broker mod
|
||||
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:
|
||||
# spawn new daemon
|
||||
portal = await nursery.start_actor(
|
||||
dname,
|
||||
enable_modules=_data_mods + [brokermod.__name__],
|
||||
loglevel=loglevel,
|
||||
debug_mode=debug_mode,
|
||||
**tractor_kwargs
|
||||
)
|
||||
async with tractor.wait_for_actor(dname) as portal:
|
||||
yield portal
|
||||
|
||||
finally:
|
||||
# client code may block indefinitely so cancel when
|
||||
# teardown is invoked
|
||||
|
@ -144,9 +147,15 @@ class Feed:
|
|||
name: str
|
||||
stream: AsyncIterator[Dict[str, Any]]
|
||||
shm: ShmArray
|
||||
mod: ModuleType
|
||||
# ticks: ShmArray
|
||||
_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:
|
||||
return await self.stream.__anext__()
|
||||
|
@ -164,6 +173,33 @@ class Feed:
|
|||
|
||||
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(
|
||||
broker: str,
|
||||
|
@ -174,23 +210,26 @@ def sym_to_shm_key(
|
|||
|
||||
@asynccontextmanager
|
||||
async def open_feed(
|
||||
name: str,
|
||||
brokername: str,
|
||||
symbols: Sequence[str],
|
||||
loglevel: Optional[str] = None,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Open a "data feed" which provides streamed real-time quotes.
|
||||
"""
|
||||
try:
|
||||
mod = get_brokermod(name)
|
||||
mod = get_brokermod(brokername)
|
||||
except ImportError:
|
||||
mod = get_ingestormod(name)
|
||||
mod = get_ingestormod(brokername)
|
||||
|
||||
if loglevel is None:
|
||||
loglevel = tractor.current_actor().loglevel
|
||||
|
||||
# TODO: do all!
|
||||
sym = symbols[0]
|
||||
|
||||
# Attempt to allocate (or attach to) shm array for this broker/symbol
|
||||
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:
|
||||
dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype),
|
||||
|
@ -200,33 +239,61 @@ async def open_feed(
|
|||
)
|
||||
|
||||
async with maybe_spawn_brokerd(
|
||||
mod.name,
|
||||
|
||||
brokername,
|
||||
loglevel=loglevel,
|
||||
|
||||
# TODO: add a cli flag for this
|
||||
# debug_mode=False,
|
||||
|
||||
) as portal:
|
||||
|
||||
stream = await portal.run(
|
||||
mod.stream_quotes,
|
||||
|
||||
# TODO: actually handy multiple symbols...
|
||||
symbols=symbols,
|
||||
|
||||
shm_token=shm.token,
|
||||
|
||||
# compat with eventual ``tractor.msg.pub``
|
||||
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
|
||||
# ``tractor.msg.pub``, should we maybe just drop this after
|
||||
# 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:
|
||||
assert is_writer
|
||||
assert data['is_shm_writer']
|
||||
log.info("Started shared mem bar writer")
|
||||
|
||||
shm_token['dtype_descr'] = list(shm_token['dtype_descr'])
|
||||
assert shm_token == shm.token # sanity
|
||||
|
||||
yield Feed(
|
||||
name=name,
|
||||
stream=stream,
|
||||
shm=shm,
|
||||
_brokerd_portal=portal,
|
||||
)
|
||||
yield feed
|
||||
|
|
|
@ -65,14 +65,6 @@ async def increment_ohlc_buffer(
|
|||
# adjust delay to compensate for trio processing time
|
||||
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
|
||||
lowest = min(_shms.keys())
|
||||
ad = lowest - 0.001
|
||||
|
@ -83,9 +75,6 @@ async def increment_ohlc_buffer(
|
|||
await trio.sleep(ad)
|
||||
total_s += lowest
|
||||
|
||||
# # sleep for duration of current bar
|
||||
# await sleep()
|
||||
|
||||
# increment all subscribed shm arrays
|
||||
# TODO: this in ``numba``
|
||||
for delay_s, shms in _shms.items():
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# 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
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
|
|
@ -17,12 +17,12 @@
|
|||
"""
|
||||
numpy data source coversion helpers.
|
||||
"""
|
||||
from typing import List
|
||||
from typing import Dict, Any, List
|
||||
import decimal
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel
|
||||
# from numba import from_dtype
|
||||
|
||||
|
||||
|
@ -75,23 +75,41 @@ def ohlc_zeros(length: int) -> np.ndarray:
|
|||
return np.zeros(length, dtype=base_ohlc_dtype)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Symbol:
|
||||
class Symbol(BaseModel):
|
||||
"""I guess this is some kinda container thing for dealing with
|
||||
all the different meta-data formats from brokers?
|
||||
|
||||
Yah, i guess dats what it izz.
|
||||
"""
|
||||
key: str = ''
|
||||
brokers: List[str] = None
|
||||
min_tick: float = 0.01
|
||||
contract: str = ''
|
||||
key: str
|
||||
tick_size: float = 0.01
|
||||
lot_tick_size: float = 0.01 # "volume" precision as min step value
|
||||
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:
|
||||
"""Return the trailing number of digits specified by the
|
||||
min tick size for the instrument.
|
||||
"""Return the trailing number of digits specified by the min
|
||||
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(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
|
|
@ -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.
|
||||
|
||||
"""
|
|
@ -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
|
|
@ -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,
|
||||
)
|
|
@ -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
|
|
@ -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)
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -16,6 +16,7 @@
|
|||
|
||||
"""
|
||||
Chart axes graphics and behavior.
|
||||
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional
|
||||
|
@ -32,7 +33,7 @@ _axis_pen = pg.mkPen(hcolor('bracket'))
|
|||
|
||||
|
||||
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__(
|
||||
|
@ -53,10 +54,10 @@ class Axis(pg.AxisItem):
|
|||
|
||||
self.setTickFont(_font.font)
|
||||
self.setStyle(**{
|
||||
'textFillLimits': [(0, 0.666)],
|
||||
'textFillLimits': [(0, 0.5)],
|
||||
'tickFont': _font.font,
|
||||
# offset of text *away from* axis line in px
|
||||
'tickTextOffset': 2,
|
||||
'tickTextOffset': 6,
|
||||
})
|
||||
|
||||
self.setTickFont(_font.font)
|
||||
|
@ -64,11 +65,17 @@ class Axis(pg.AxisItem):
|
|||
self.typical_br = _font._qfm.boundingRect(typical_max_str)
|
||||
|
||||
# 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:
|
||||
self._min_tick = size
|
||||
|
||||
def txt_offsets(self) -> Tuple[int, int]:
|
||||
return tuple(self.style['tickTextOffset'])
|
||||
|
||||
|
||||
class PriceAxis(Axis):
|
||||
|
||||
|
@ -77,9 +84,13 @@ class PriceAxis(Axis):
|
|||
*args,
|
||||
**kwargs,
|
||||
) -> 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())
|
||||
|
||||
# XXX: drop for now since it just eats up h space
|
||||
|
@ -112,7 +123,7 @@ class DynamicDateAxis(Axis):
|
|||
1: '%H:%M:%S',
|
||||
}
|
||||
|
||||
def resize(self) -> None:
|
||||
def size_to_values(self) -> None:
|
||||
self.setHeight(self.typical_br.height() + 1)
|
||||
|
||||
def _indexes_to_timestrs(
|
||||
|
@ -151,25 +162,33 @@ class DynamicDateAxis(Axis):
|
|||
|
||||
class AxisLabel(pg.GraphicsObject):
|
||||
|
||||
_w_margin = 0
|
||||
_h_margin = 0
|
||||
_x_margin = 0
|
||||
_y_margin = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Axis,
|
||||
parent: pg.GraphicsItem,
|
||||
digits: int = 2,
|
||||
|
||||
font_size_inches: Optional[float] = None,
|
||||
bg_color: str = 'bracket',
|
||||
fg_color: str = 'black',
|
||||
opacity: int = 0,
|
||||
font_size_inches: Optional[float] = None,
|
||||
opacity: int = 1, # XXX: seriously don't set this to 0
|
||||
|
||||
use_arrow: bool = True,
|
||||
|
||||
) -> None:
|
||||
|
||||
super().__init__(parent)
|
||||
super().__init__()
|
||||
self.setParentItem(parent)
|
||||
|
||||
self.setFlag(self.ItemIgnoresTransformations)
|
||||
|
||||
# XXX: pretty sure this is faster
|
||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
self.parent = parent
|
||||
self._parent = parent
|
||||
|
||||
self.opacity = opacity
|
||||
self.label_str = ''
|
||||
self.digits = digits
|
||||
|
@ -177,11 +196,15 @@ class AxisLabel(pg.GraphicsObject):
|
|||
self._txt_br: QtCore.QRect = None
|
||||
|
||||
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.fg_color = pg.mkColor(hcolor(fg_color))
|
||||
|
||||
self._use_arrow = use_arrow
|
||||
|
||||
# create triangle path
|
||||
self.path = None
|
||||
self.rect = None
|
||||
|
||||
def paint(
|
||||
|
@ -190,37 +213,65 @@ class AxisLabel(pg.GraphicsObject):
|
|||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
) -> 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)
|
||||
|
||||
if self.label_str:
|
||||
|
||||
if not self.rect:
|
||||
# if not self.rect:
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
def draw(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
rect: QtCore.QRectF
|
||||
) -> 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
|
||||
# reason; ok by us
|
||||
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
|
||||
"""Size the graphics space from the text contents.
|
||||
|
||||
"""
|
||||
if 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()
|
||||
|
||||
|
@ -232,17 +283,26 @@ class AxisLabel(pg.GraphicsObject):
|
|||
|
||||
"""
|
||||
# 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()
|
||||
|
||||
self.rect = QtCore.QRectF(
|
||||
0, 0,
|
||||
(w or txt_w) + self._w_margin,
|
||||
(h or txt_h) + self._h_margin,
|
||||
(w or txt_w) + self._x_margin /2,
|
||||
(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 = (
|
||||
# QtCore.Qt.TextDontClip |
|
||||
|
@ -254,7 +314,7 @@ class AxisLabel(pg.GraphicsObject):
|
|||
|
||||
|
||||
class XAxisLabel(AxisLabel):
|
||||
_w_margin = 4
|
||||
_x_margin = 8
|
||||
|
||||
text_flags = (
|
||||
QtCore.Qt.TextDontClip
|
||||
|
@ -263,32 +323,53 @@ class XAxisLabel(AxisLabel):
|
|||
|
||||
def size_hint(self) -> Tuple[float, float]:
|
||||
# size to parent axis height
|
||||
return self.parent.height(), None
|
||||
return self._parent.height(), None
|
||||
|
||||
def update_label(
|
||||
self,
|
||||
abs_pos: QPointF, # scene coords
|
||||
value: float, # data for text
|
||||
offset: int = 1 # if have margins, k?
|
||||
offset: int = 0 # if have margins, k?
|
||||
) -> None:
|
||||
|
||||
timestrs = self.parent._indexes_to_timestrs([int(value)])
|
||||
timestrs = self._parent._indexes_to_timestrs([int(value)])
|
||||
|
||||
if not timestrs.any():
|
||||
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()
|
||||
|
||||
self.setPos(QPointF(
|
||||
abs_pos.x() - w / 2 - offset,
|
||||
1,
|
||||
abs_pos.x() - w/2,
|
||||
y_offset/2,
|
||||
))
|
||||
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):
|
||||
_h_margin = 2
|
||||
_y_margin = 4
|
||||
|
||||
text_flags = (
|
||||
QtCore.Qt.AlignLeft
|
||||
|
@ -297,33 +378,6 @@ class YAxisLabel(AxisLabel):
|
|||
| 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__(
|
||||
self,
|
||||
chart,
|
||||
|
@ -334,14 +388,48 @@ class YSticky(YAxisLabel):
|
|||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._chart = chart
|
||||
|
||||
chart.sigRangeChanged.connect(self.update_on_resize)
|
||||
|
||||
self._last_datum = (None, None)
|
||||
|
||||
def update_on_resize(self, vr, r):
|
||||
# TODO: add an `.index` to the array data-buffer layer
|
||||
# and make this way less shitty...
|
||||
# pull text offset from axis from parent axis
|
||||
if getattr(self._parent, 'txt_offsets', False):
|
||||
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
|
||||
if index is not None:
|
||||
self.update_from_data(index, last)
|
||||
|
@ -350,9 +438,27 @@ class YSticky(YAxisLabel):
|
|||
self,
|
||||
index: int,
|
||||
value: float,
|
||||
_save_last: bool = True,
|
||||
) -> None:
|
||||
"""Update the label's text contents **and** position from
|
||||
a view box coordinate datum.
|
||||
|
||||
"""
|
||||
if _save_last:
|
||||
self._last_datum = (index, value)
|
||||
|
||||
self.update_label(
|
||||
self._chart.mapFromView(QPointF(index, 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()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -30,6 +30,7 @@ import trio
|
|||
from ._axes import (
|
||||
DynamicDateAxis,
|
||||
PriceAxis,
|
||||
YAxisLabel,
|
||||
)
|
||||
from ._graphics._cursor import (
|
||||
Cursor,
|
||||
|
@ -37,11 +38,11 @@ from ._graphics._cursor import (
|
|||
)
|
||||
from ._graphics._lines import (
|
||||
level_line,
|
||||
L1Labels,
|
||||
order_line,
|
||||
)
|
||||
from ._l1 import L1Labels
|
||||
from ._graphics._ohlc import BarItems
|
||||
from ._graphics._curve import FastAppendCurve
|
||||
from ._axes import YSticky
|
||||
from ._style import (
|
||||
_font,
|
||||
hcolor,
|
||||
|
@ -51,15 +52,15 @@ from ._style import (
|
|||
_bars_from_right_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 data
|
||||
from ..data import maybe_open_shm_array
|
||||
from ..log import get_logger
|
||||
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 .._ems import spawn_router_stream_alerts
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
@ -136,7 +137,10 @@ class ChartSpace(QtGui.QWidget):
|
|||
Expects a ``numpy`` structured array containing all the ohlcv fields.
|
||||
"""
|
||||
# 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
|
||||
# # of course this doesn't work :eyeroll:
|
||||
|
@ -239,12 +243,10 @@ class LinkedSplitCharts(QtGui.QWidget):
|
|||
|
||||
The data input struct array must include OHLC fields.
|
||||
"""
|
||||
self.digits = symbol.digits()
|
||||
|
||||
# add crosshairs
|
||||
self._cursor = Cursor(
|
||||
linkedsplitcharts=self,
|
||||
digits=self.digits
|
||||
digits=symbol.digits(),
|
||||
)
|
||||
self.chart = self.add_plot(
|
||||
name=symbol.key,
|
||||
|
@ -304,12 +306,14 @@ class LinkedSplitCharts(QtGui.QWidget):
|
|||
linked_charts=self,
|
||||
axisItems={
|
||||
'bottom': xaxis,
|
||||
'right': PriceAxis(linked_charts=self)
|
||||
'right': PriceAxis(linked_charts=self, orientation='right'),
|
||||
'left': PriceAxis(linked_charts=self, orientation='left'),
|
||||
},
|
||||
viewBox=cv,
|
||||
cursor=self._cursor,
|
||||
**cpw_kwargs,
|
||||
)
|
||||
print(f'xaxis ps: {xaxis.pos()}')
|
||||
|
||||
# give viewbox as reference to chart
|
||||
# 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_enter = QtCore.Signal(object)
|
||||
|
||||
_l1_labels: L1Labels = None
|
||||
|
||||
# TODO: can take a ``background`` color setting - maybe there's
|
||||
# a better one?
|
||||
|
||||
|
@ -377,14 +383,22 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
name: str,
|
||||
array: np.ndarray,
|
||||
linked_charts: LinkedSplitCharts,
|
||||
|
||||
view_color: str = 'papas_special',
|
||||
pen_color: str = 'bracket',
|
||||
|
||||
static_yrange: Optional[Tuple[float, float]] = None,
|
||||
cursor: Optional[Cursor] = None,
|
||||
|
||||
**kwargs,
|
||||
):
|
||||
"""Configure chart display settings.
|
||||
"""
|
||||
self.view_color = view_color
|
||||
self.pen_color = pen_color
|
||||
|
||||
super().__init__(
|
||||
background=hcolor('papas_special'),
|
||||
background=hcolor(view_color),
|
||||
# parent=None,
|
||||
# plotItem=None,
|
||||
# antialias=True,
|
||||
|
@ -394,6 +408,10 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
self.name = name
|
||||
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._ohlc = array # readonly view of ohlc data
|
||||
|
||||
|
@ -413,15 +431,16 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# show only right side axes
|
||||
self.hideAxis('left')
|
||||
self.showAxis('right')
|
||||
# self.showAxis('left')
|
||||
|
||||
# show background grid
|
||||
self.showGrid(x=True, y=True, alpha=0.5)
|
||||
self.showGrid(x=False, y=True, alpha=0.3)
|
||||
|
||||
self.default_view()
|
||||
|
||||
# Assign callback for rescaling y-axis automatically
|
||||
# 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
|
||||
self._vb.sigRangeChangedManually.connect(self._set_yrange)
|
||||
|
@ -429,7 +448,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# for when the splitter(s) are resized
|
||||
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']
|
||||
|
||||
def update_contents_labels(
|
||||
|
@ -499,11 +518,13 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
max=end,
|
||||
padding=0,
|
||||
)
|
||||
self._set_yrange()
|
||||
|
||||
def increment_view(
|
||||
self,
|
||||
) -> 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.
|
||||
|
||||
"""
|
||||
|
@ -520,12 +541,15 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
self,
|
||||
name: str,
|
||||
data: np.ndarray,
|
||||
# XXX: pretty sure this is dumb and we don't need an Enum
|
||||
style: pg.GraphicsObject = BarItems,
|
||||
) -> 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
|
||||
# the np array buffer to be drawn on next render cycle
|
||||
|
@ -643,14 +667,22 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
self,
|
||||
name: str,
|
||||
bg_color='bracket',
|
||||
# retreive: Callable[None, np.ndarray],
|
||||
) -> YSticky:
|
||||
) -> YAxisLabel:
|
||||
|
||||
# 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
|
||||
last = self._ysticks[name] = YSticky(
|
||||
last = self._ysticks[name] = YAxisLabel(
|
||||
chart=self,
|
||||
parent=self.getAxis('right'),
|
||||
# TODO: pass this from symbol data
|
||||
# digits=0,
|
||||
digits=digits,
|
||||
opacity=1,
|
||||
bg_color=bg_color,
|
||||
)
|
||||
|
@ -701,6 +733,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
self,
|
||||
*,
|
||||
yrange: Optional[Tuple[float, float]] = None,
|
||||
range_margin: float = 0.06,
|
||||
) -> None:
|
||||
"""Set the viewable y-range based on embedded data.
|
||||
|
||||
|
@ -760,7 +793,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
a = self._ohlc
|
||||
ifirst = a[0]['index']
|
||||
bars = a[lbar - ifirst:rbar - ifirst]
|
||||
bars = a[lbar - ifirst:rbar - ifirst + 1]
|
||||
|
||||
if not len(bars):
|
||||
# likely no data loaded yet or extreme scrolling?
|
||||
|
@ -781,8 +814,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
if set_range:
|
||||
# view margins: stay within a % of the "true range"
|
||||
diff = yhigh - ylow
|
||||
ylow = ylow - (diff * 0.04)
|
||||
yhigh = yhigh + (diff * 0.04)
|
||||
ylow = ylow - (diff * range_margin)
|
||||
yhigh = yhigh + (diff * range_margin)
|
||||
|
||||
self.setLimits(
|
||||
yMin=ylow,
|
||||
|
@ -829,15 +862,91 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
self.scene().leaveEvent(ev)
|
||||
|
||||
|
||||
async def _async_main(
|
||||
sym: str,
|
||||
brokername: str,
|
||||
async def test_bed(
|
||||
ohlcv,
|
||||
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()``
|
||||
widgets: Dict[str, Any],
|
||||
|
||||
# all kwargs are passed through from the CLI entrypoint
|
||||
loglevel: str = None,
|
||||
sym: str,
|
||||
brokername: str,
|
||||
loglevel: str,
|
||||
|
||||
) -> None:
|
||||
"""Main Qt-trio routine invoked by the Qt loop with
|
||||
the widgets ``dict``.
|
||||
|
@ -852,8 +961,6 @@ async def _async_main(
|
|||
# historical data fetch
|
||||
brokermod = brokers.get_brokermod(brokername)
|
||||
|
||||
symbol = Symbol(sym, [brokername])
|
||||
|
||||
async with data.open_feed(
|
||||
brokername,
|
||||
[sym],
|
||||
|
@ -862,6 +969,7 @@ async def _async_main(
|
|||
|
||||
ohlcv = feed.shm
|
||||
bars = ohlcv.array
|
||||
symbol = feed.symbols[sym]
|
||||
|
||||
# load in symbol's ohlc data
|
||||
linked_charts, chart = chart_app.load_symbol(symbol, bars)
|
||||
|
@ -950,26 +1058,14 @@ async def _async_main(
|
|||
linked_charts
|
||||
)
|
||||
|
||||
async with open_order_mode(
|
||||
chart,
|
||||
) as order_mode:
|
||||
|
||||
# TODO: this should probably be implicitly spawned
|
||||
# inside the above mngr?
|
||||
|
||||
# spawn EMS actor-service
|
||||
to_ems_chan = await n.start(
|
||||
spawn_router_stream_alerts,
|
||||
order_mode,
|
||||
symbol,
|
||||
)
|
||||
|
||||
# wait for router to come up before setting
|
||||
# enabling send channel on chart
|
||||
linked_charts._to_ems = to_ems_chan
|
||||
|
||||
# probably where we'll eventually start the user input loop
|
||||
await trio.sleep_forever()
|
||||
# interactive testing
|
||||
# n.start_soon(
|
||||
# test_bed,
|
||||
# ohlcv,
|
||||
# chart,
|
||||
# linked_charts,
|
||||
# )
|
||||
await start_order_mode(chart, symbol, brokername)
|
||||
|
||||
|
||||
async def chart_from_quotes(
|
||||
|
@ -1017,7 +1113,7 @@ async def chart_from_quotes(
|
|||
# sym = chart.name
|
||||
# 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()
|
||||
|
||||
|
@ -1025,12 +1121,15 @@ async def chart_from_quotes(
|
|||
|
||||
last, volume = ohlcv.array[-1][['close', 'volume']]
|
||||
|
||||
symbol = chart._lc.symbol
|
||||
|
||||
l1 = L1Labels(
|
||||
chart,
|
||||
# determine precision/decimal lengths
|
||||
digits=max(float_digits(last), 2),
|
||||
size_digits=min(float_digits(last), 3)
|
||||
digits=symbol.digits(),
|
||||
size_digits=symbol.lot_digits(),
|
||||
)
|
||||
chart._l1_labels = l1
|
||||
|
||||
# TODO:
|
||||
# - 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
|
||||
# present differently?
|
||||
|
||||
tick_size = chart._lc.symbol.tick_size
|
||||
tick_margin = 2 * tick_size
|
||||
|
||||
async for quotes in stream:
|
||||
for sym, quote in quotes.items():
|
||||
|
||||
|
@ -1050,20 +1152,18 @@ async def chart_from_quotes(
|
|||
price = tick.get('price')
|
||||
size = tick.get('size')
|
||||
|
||||
# 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
|
||||
if ticktype == 'n/a' or price == -1:
|
||||
# okkk..
|
||||
continue
|
||||
|
||||
if ticktype in ('trade', 'utrade', 'last'):
|
||||
|
||||
array = ohlcv.array
|
||||
|
||||
# update price sticky(s)
|
||||
last = array[-1]
|
||||
end = array[-1]
|
||||
last_price_sticky.update_from_data(
|
||||
*last[['index', 'close']]
|
||||
*end[['index', 'close']]
|
||||
)
|
||||
|
||||
# plot bars
|
||||
|
@ -1075,53 +1175,64 @@ async def chart_from_quotes(
|
|||
|
||||
if wap_in_history:
|
||||
# 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?
|
||||
# if ticktype in ('trade', 'last'):
|
||||
if ticktype in ('last',): # 'size'):
|
||||
|
||||
label = {
|
||||
l1.ask_label.level: l1.ask_label,
|
||||
l1.bid_label.level: l1.bid_label,
|
||||
l1.ask_label.fields['level']: l1.ask_label,
|
||||
l1.bid_label.fields['level']: l1.bid_label,
|
||||
}.get(price)
|
||||
|
||||
if label is not None:
|
||||
label.size = size
|
||||
label.update_from_data(0, price)
|
||||
label.update_fields({'level': price, 'size': size})
|
||||
|
||||
# on trades should we be knocking down
|
||||
# the relevant L1 queue?
|
||||
# label.size -= size
|
||||
|
||||
elif ticktype in ('ask', 'asize'):
|
||||
l1.ask_label.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)
|
||||
l1.ask_label.update_fields({'level': price, 'size': size})
|
||||
|
||||
elif ticktype in ('bid', 'bsize'):
|
||||
l1.bid_label.size = size
|
||||
l1.bid_label.update_from_data(0, price)
|
||||
l1.bid_label.update_fields({'level': price, 'size': size})
|
||||
|
||||
# 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:
|
||||
chart._set_yrange(yrange=(mn_in_view, mx_in_view))
|
||||
last_mx, last_mn = mx_in_view, mn_in_view
|
||||
if (mx > last_mx) or (
|
||||
mn < last_mn
|
||||
):
|
||||
# print(f'new y range: {(mn, mx)}')
|
||||
|
||||
if brange != last_bars_range:
|
||||
# we **must always** update the last values due to
|
||||
# the x-range change
|
||||
last_mx, last_mn = mx_in_view, mn_in_view
|
||||
last_bars_range = brange
|
||||
chart._set_yrange(
|
||||
yrange=(mn, mx),
|
||||
# TODO: we should probably scale
|
||||
# the view margin based on the size
|
||||
# of the true range? This way you can
|
||||
# slap in orders outside the current
|
||||
# L1 (only) book range.
|
||||
# range_margin=0.1,
|
||||
)
|
||||
|
||||
last_mx, last_mn = mx, mn
|
||||
|
||||
|
||||
async def spawn_fsps(
|
||||
linked_charts: LinkedSplitCharts,
|
||||
# fsp_func_name,
|
||||
fsps: Dict[str, str],
|
||||
sym,
|
||||
src_shm,
|
||||
|
@ -1289,12 +1400,13 @@ async def update_signals(
|
|||
# graphics.curve.setBrush(50, 50, 200, 100)
|
||||
# graphics.curve.setFillLevel(50)
|
||||
|
||||
if fsp_func_name == 'rsi':
|
||||
# add moveable over-[sold/bought] 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, 70, orient_v='bottom')
|
||||
level_line(chart, 80, orient_v='top', show_label=False)
|
||||
level_line(chart, 80, orient_v='top')
|
||||
|
||||
chart._set_yrange()
|
||||
|
||||
|
@ -1331,6 +1443,7 @@ async def update_signals(
|
|||
async def check_for_new_bars(feed, ohlcv, linked_charts):
|
||||
"""Task which updates from new bars in the shared ohlcv buffer every
|
||||
``delay_s`` seconds.
|
||||
|
||||
"""
|
||||
# TODO: right now we'll spin printing bars if the last time
|
||||
# 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
|
||||
# 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
|
||||
# TODO: if we eventually have an x-axis time-step "cursor"
|
||||
# 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,
|
||||
)
|
||||
|
||||
# resize view
|
||||
# price_chart._set_yrange()
|
||||
|
||||
for name in price_chart._overlays:
|
||||
|
||||
price_chart.update_curve_from_array(
|
||||
|
@ -1383,15 +1487,8 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
|
|||
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():
|
||||
chart.update_curve_from_array(chart.name, chart._shm.array)
|
||||
# chart._set_yrange()
|
||||
|
||||
# shift the view if in follow mode
|
||||
price_chart.increment_view()
|
||||
|
@ -1400,14 +1497,16 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
|
|||
def _main(
|
||||
sym: str,
|
||||
brokername: str,
|
||||
piker_loglevel: str,
|
||||
tractor_kwargs,
|
||||
) -> None:
|
||||
"""Sync entry point to start a chart app.
|
||||
|
||||
"""
|
||||
# Qt entry point
|
||||
run_qtractor(
|
||||
func=_async_main,
|
||||
args=(sym, brokername),
|
||||
args=(sym, brokername, piker_loglevel),
|
||||
main_widget=ChartSpace,
|
||||
tractor_kwargs=tractor_kwargs,
|
||||
)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -20,11 +20,11 @@ Trio - Qt integration
|
|||
Run ``trio`` in guest mode on top of the Qt event loop.
|
||||
All global Qt runtime settings are mostly defined here.
|
||||
"""
|
||||
from typing import Tuple, Callable, Dict, Any
|
||||
import os
|
||||
import signal
|
||||
from functools import partial
|
||||
import time
|
||||
import traceback
|
||||
from typing import Tuple, Callable, Dict, Any
|
||||
|
||||
# Qt specific
|
||||
import PyQt5 # noqa
|
||||
|
@ -32,19 +32,29 @@ import pyqtgraph as pg
|
|||
from pyqtgraph import QtGui
|
||||
from PyQt5 import QtCore
|
||||
from PyQt5.QtCore import (
|
||||
pyqtRemoveInputHook, Qt, QCoreApplication
|
||||
pyqtRemoveInputHook,
|
||||
Qt,
|
||||
QCoreApplication,
|
||||
)
|
||||
import qdarkstyle
|
||||
import trio
|
||||
import tractor
|
||||
from outcome import Error
|
||||
|
||||
from ..log import get_logger
|
||||
from ._pg_overrides import _do_overrides
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# pyqtgraph global config
|
||||
# might as well enable this for now?
|
||||
pg.useOpenGL = True
|
||||
pg.enableExperimental = True
|
||||
|
||||
# engage core tweaks that give us better response
|
||||
# latency then the average pg user
|
||||
_do_overrides()
|
||||
|
||||
|
||||
# singleton app per actor
|
||||
_qt_app: QtGui.QApplication = None
|
||||
|
@ -52,18 +62,40 @@ _qt_win: QtGui.QMainWindow = None
|
|||
|
||||
|
||||
def current_screen() -> QtGui.QScreen:
|
||||
"""Get a frickin screen (if we can, gawd).
|
||||
|
||||
"""
|
||||
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
|
||||
# must be set before creating the application
|
||||
if hasattr(Qt, 'AA_EnableHighDpiScaling'):
|
||||
QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
# if hasattr(Qt, 'AA_EnableHighDpiScaling'):
|
||||
# QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
|
||||
if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
|
||||
QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
# if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
|
||||
# QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
|
||||
|
||||
class MainWindow(QtGui.QMainWindow):
|
||||
|
@ -78,7 +110,7 @@ class MainWindow(QtGui.QMainWindow):
|
|||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: 'QCloseEvent'
|
||||
event: QtGui.QCloseEvent,
|
||||
) -> None:
|
||||
"""Cancel the root actor asap.
|
||||
|
||||
|
@ -169,8 +201,8 @@ def run_qtractor(
|
|||
),
|
||||
name='qtractor',
|
||||
**tractor_kwargs,
|
||||
) as a:
|
||||
await func(*(args + (widgets,)))
|
||||
):
|
||||
await func(*((widgets,) + args))
|
||||
|
||||
# guest mode entry
|
||||
trio.lowlevel.start_guest_run(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -13,6 +13,7 @@
|
|||
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
Mouse interaction graphics
|
||||
|
||||
|
@ -23,7 +24,7 @@ import inspect
|
|||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui
|
||||
from PyQt5.QtCore import QPointF
|
||||
from PyQt5.QtCore import QPointF, QRectF
|
||||
|
||||
from .._style import (
|
||||
_xaxis_at,
|
||||
|
@ -54,7 +55,7 @@ class LineDot(pg.CurvePoint):
|
|||
index: int,
|
||||
plot: 'ChartPlotWidget', # type: ingore # noqa
|
||||
pos=None,
|
||||
size: int = 2, # in pxs
|
||||
size: int = 6, # in pxs
|
||||
color: str = 'default_light',
|
||||
) -> None:
|
||||
pg.CurvePoint.__init__(
|
||||
|
@ -112,27 +113,32 @@ class LineDot(pg.CurvePoint):
|
|||
return False
|
||||
|
||||
|
||||
# TODO: likely will need to tweak this based on dpi...
|
||||
_y_margin = 5
|
||||
|
||||
|
||||
# TODO: change this into our own ``Label``
|
||||
class ContentsLabel(pg.LabelItem):
|
||||
"""Label anchored to a ``ViewBox`` typically for displaying
|
||||
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, -5),
|
||||
('top', 'right'): (4, -5),
|
||||
('top', 'left'): (-4, -_y_margin),
|
||||
('top', 'right'): (4, -_y_margin),
|
||||
|
||||
('bottom', 'left'): (-4, lambda font_size: font_size * 2),
|
||||
('bottom', 'right'): (4, lambda font_size: font_size * 2),
|
||||
('bottom', 'left'): (-4, lambda font_size: font_size + 2*_y_margin),
|
||||
('bottom', 'right'): (4, lambda font_size: font_size + 2*_y_margin),
|
||||
}
|
||||
|
||||
|
||||
class ContentsLabel(pg.LabelItem):
|
||||
"""Label anchored to a ``ViewBox`` typically for displaying
|
||||
datum-wise points from the "viewed" contents.
|
||||
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
|
@ -152,8 +158,8 @@ class ContentsLabel(pg.LabelItem):
|
|||
self.chart = chart
|
||||
|
||||
v, h = anchor_at
|
||||
index = (_corner_anchors[h], _corner_anchors[v])
|
||||
margins = _corner_margins[(v, h)]
|
||||
index = (self._corner_anchors[h], self._corner_anchors[v])
|
||||
margins = self._corner_margins[(v, h)]
|
||||
|
||||
ydim = margins[1]
|
||||
if inspect.isfunction(margins[1]):
|
||||
|
@ -213,7 +219,7 @@ class Cursor(pg.GraphicsObject):
|
|||
style=QtCore.Qt.DashLine,
|
||||
)
|
||||
self.lines_pen = pg.mkPen(
|
||||
color='#a9a9a9', # gray?
|
||||
color=hcolor('davies'),
|
||||
style=QtCore.Qt.DashLine,
|
||||
)
|
||||
self.lsc = linkedsplitcharts
|
||||
|
@ -226,6 +232,18 @@ class Cursor(pg.GraphicsObject):
|
|||
self._hovered: 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(
|
||||
self,
|
||||
item: pg.GraphicsObject,
|
||||
|
@ -240,6 +258,7 @@ class Cursor(pg.GraphicsObject):
|
|||
) -> None:
|
||||
# add ``pg.graphicsItems.InfiniteLine``s
|
||||
# vertical and horizonal lines and a y-axis label
|
||||
|
||||
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
|
||||
vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
|
@ -248,10 +267,11 @@ class Cursor(pg.GraphicsObject):
|
|||
hl.hide()
|
||||
|
||||
yl = YAxisLabel(
|
||||
chart=plot,
|
||||
parent=plot.getAxis('right'),
|
||||
digits=digits or self.digits,
|
||||
opacity=_ch_label_opac,
|
||||
bg_color='default',
|
||||
bg_color=self.label_color,
|
||||
)
|
||||
yl.hide() # on startup if mouse is off screen
|
||||
|
||||
|
@ -291,7 +311,7 @@ class Cursor(pg.GraphicsObject):
|
|||
self.xaxis_label = XAxisLabel(
|
||||
parent=self.plots[plot_index].getAxis('bottom'),
|
||||
opacity=_ch_label_opac,
|
||||
bg_color='default',
|
||||
bg_color=self.label_color,
|
||||
)
|
||||
# place label off-screen during startup
|
||||
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()
|
||||
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
|
||||
# (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
|
||||
|
||||
# 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
|
||||
for item in self._trackers:
|
||||
# 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():
|
||||
|
||||
# move the vertical line to the current "center of bar"
|
||||
opts['vl'].setX(ix)
|
||||
|
||||
# update the chart's "contents" label
|
||||
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
|
||||
for cursor in opts.get('cursors', ()):
|
||||
cursor.setIndex(ix)
|
||||
|
@ -386,14 +419,63 @@ class Cursor(pg.GraphicsObject):
|
|||
# otherwise gobbles tons of CPU..
|
||||
|
||||
# map back to abs (label-local) coordinates
|
||||
abs_pos=plot.mapFromView(QPointF(ix, y)),
|
||||
value=x,
|
||||
abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)),
|
||||
value=ix,
|
||||
)
|
||||
|
||||
self._datum_xy = ix, y
|
||||
self._datum_xy = ix, iy
|
||||
|
||||
def boundingRect(self):
|
||||
def boundingRect(self) -> QRectF:
|
||||
try:
|
||||
return self.active_plot.boundingRect()
|
||||
except AttributeError:
|
||||
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'))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -16,6 +16,7 @@
|
|||
|
||||
"""
|
||||
Fast, smooth, sexy curves.
|
||||
|
||||
"""
|
||||
from typing import Tuple
|
||||
|
||||
|
@ -141,6 +142,7 @@ class FastAppendCurve(pg.PlotCurveItem):
|
|||
|
||||
w = hb_size.width() + 1
|
||||
h = hb_size.height() + 1
|
||||
|
||||
br = QtCore.QRectF(
|
||||
|
||||
# top left
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -21,7 +21,7 @@ from typing import List, Optional, Tuple
|
|||
|
||||
import numpy as np
|
||||
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.QtCore import QLineF, QPointF
|
||||
# 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']]
|
||||
|
||||
# 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
|
||||
if low != 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
|
||||
# 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
|
||||
o = QLineF(index - w, open, index, open)
|
||||
|
||||
# close line
|
||||
c = QLineF(index, close, index + w, close)
|
||||
|
||||
return [hl, o, c]
|
||||
|
||||
|
||||
@jit(
|
||||
@njit(
|
||||
# TODO: for now need to construct this manually for readonly arrays, see
|
||||
# https://github.com/numba/numba/issues/4511
|
||||
# ntypes.Tuple((float64[:], float64[:], float64[:]))(
|
||||
|
@ -78,7 +89,6 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
|
|||
# int64,
|
||||
# optional(float64),
|
||||
# ),
|
||||
nopython=True,
|
||||
nogil=True
|
||||
)
|
||||
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
|
||||
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__(
|
||||
self,
|
||||
# scene: 'QGraphicsScene', # noqa
|
||||
plotitem: 'pg.PlotItem', # noqa
|
||||
pen_color: str = 'bracket',
|
||||
) -> None:
|
||||
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
|
||||
# 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.
|
||||
"""
|
||||
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
|
||||
# of current internal "last index"
|
||||
|
@ -228,7 +241,7 @@ class BarItems(pg.GraphicsObject):
|
|||
)
|
||||
|
||||
# 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
|
||||
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
|
||||
|
@ -311,15 +324,17 @@ class BarItems(pg.GraphicsObject):
|
|||
['index', 'open', 'high', 'low', 'close', 'volume']
|
||||
]
|
||||
# assert i == self.start_index - 1
|
||||
assert i == last_index
|
||||
# assert i == last_index
|
||||
body, larm, rarm = self._last_bar_lines
|
||||
|
||||
# XXX: is there a faster way to modify this?
|
||||
rarm.setLine(rarm.x1(), last, rarm.x2(), last)
|
||||
|
||||
# writer is responsible for changing open on "first" volume of bar
|
||||
larm.setLine(larm.x1(), o, larm.x2(), o)
|
||||
|
||||
if l != h: # noqa
|
||||
|
||||
if body is None:
|
||||
body = self._last_bar_lines[0] = QLineF(i, l, i, h)
|
||||
else:
|
||||
|
@ -380,53 +395,29 @@ class BarItems(pg.GraphicsObject):
|
|||
# apparently this a lot faster says the docs?
|
||||
# https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect
|
||||
hb = self.path.controlPointRect()
|
||||
hb_size = hb.size()
|
||||
# print(f'hb_size: {hb_size}')
|
||||
hb_tl, hb_br = hb.topLeft(), hb.bottomRight()
|
||||
|
||||
w = hb_size.width() + 1
|
||||
h = hb_size.height() + 1
|
||||
# need to include last bar height or BR will be off
|
||||
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
|
||||
QPointF(hb.topLeft()),
|
||||
QPointF(
|
||||
hb_tl.x(),
|
||||
mn_y,
|
||||
),
|
||||
|
||||
# total size
|
||||
QtCore.QSizeF(w, h)
|
||||
# bottom right
|
||||
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])
|
||||
)
|
||||
|
|
|
@ -15,22 +15,21 @@
|
|||
# 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 typing import Optional, Dict, Callable
|
||||
import uuid
|
||||
from typing import Optional, Dict
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import QPointF
|
||||
from pyqtgraph import ViewBox, Point, QtCore, QtGui
|
||||
from pyqtgraph import functions as fn
|
||||
import numpy as np
|
||||
|
||||
from ..log import get_logger
|
||||
from ._style import _min_points_to_show, hcolor, _font
|
||||
from ._graphics._lines import level_line, LevelLine
|
||||
from .._ems import get_orders, OrderBook
|
||||
from ._graphics._lines import order_line, LevelLine
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
@ -110,8 +109,8 @@ class SelectRect(QtGui.QGraphicsRectItem):
|
|||
|
||||
def mouse_drag_released(
|
||||
self,
|
||||
p1: QtCore.QPointF,
|
||||
p2: QtCore.QPointF
|
||||
p1: QPointF,
|
||||
p2: QPointF
|
||||
) -> None:
|
||||
"""Called on final button release for mouse drag with start and
|
||||
end positions.
|
||||
|
@ -121,10 +120,10 @@ class SelectRect(QtGui.QGraphicsRectItem):
|
|||
|
||||
def set_pos(
|
||||
self,
|
||||
p1: QtCore.QPointF,
|
||||
p2: QtCore.QPointF
|
||||
p1: QPointF,
|
||||
p2: QPointF
|
||||
) -> None:
|
||||
"""Set position of selection rectagle and accompanying label, move
|
||||
"""Set position of selection rect and accompanying label, move
|
||||
label to match.
|
||||
|
||||
"""
|
||||
|
@ -208,48 +207,77 @@ _order_lines: Dict[str, LevelLine] = {}
|
|||
|
||||
@dataclass
|
||||
class LineEditor:
|
||||
"""The great editor of linez..
|
||||
|
||||
"""
|
||||
view: 'ChartView'
|
||||
|
||||
_order_lines: field(default_factory=_order_lines)
|
||||
chart: 'ChartPlotWidget' = None # type: ignore # noqa
|
||||
_active_staged_line: LevelLine = None
|
||||
_stage_line: LevelLine = None
|
||||
|
||||
def stage_line(self, color: str = 'alert_yellow') -> LevelLine:
|
||||
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
|
||||
and return it.
|
||||
|
||||
"""
|
||||
# chart.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
|
||||
chart = self.chart._cursor.active_plot
|
||||
chart.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
cursor = chart._cursor
|
||||
y = chart._cursor._datum_xy[1]
|
||||
|
||||
line = self._stage_line
|
||||
if not line:
|
||||
symbol = chart._lc.symbol
|
||||
|
||||
# line = self._stage_line
|
||||
# if not line:
|
||||
# add a "staged" cursor-tracking line to view
|
||||
# and cash it in a a var
|
||||
line = level_line(
|
||||
if self._active_staged_line:
|
||||
self.unstage_line()
|
||||
|
||||
line = order_line(
|
||||
chart,
|
||||
|
||||
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,
|
||||
|
||||
# don't highlight the "staging" line
|
||||
hl_on_hover=False,
|
||||
)
|
||||
self._stage_line = line
|
||||
hl_on_hover=hl_on_hover,
|
||||
dotted=dotted,
|
||||
exec_type='dark' if dotted else 'live',
|
||||
action=action,
|
||||
show_markers=True,
|
||||
|
||||
else:
|
||||
# use the existing staged line instead
|
||||
# of allocating more mem / objects repeatedly
|
||||
line.setValue(y)
|
||||
line.show()
|
||||
line.label.show()
|
||||
# prevent flickering of marker while moving/tracking cursor
|
||||
only_show_markers_on_hover=False,
|
||||
)
|
||||
|
||||
self._active_staged_line = line
|
||||
|
||||
# hide crosshair y-line
|
||||
cursor.graphics[chart]['hl'].hide()
|
||||
# hide crosshair y-line and label
|
||||
cursor.hide_xhair()
|
||||
|
||||
# add line to cursor trackers
|
||||
cursor._trackers.add(line)
|
||||
|
@ -260,45 +288,63 @@ class LineEditor:
|
|||
"""Inverse of ``.stage_line()``.
|
||||
|
||||
"""
|
||||
chart = self.chart._cursor.active_plot
|
||||
chart.setCursor(QtCore.Qt.ArrowCursor)
|
||||
cursor = chart._cursor
|
||||
# chart = self.chart._cursor.active_plot
|
||||
# # chart.setCursor(QtCore.Qt.ArrowCursor)
|
||||
cursor = self.chart._cursor
|
||||
|
||||
# delete "staged" cursor tracking line from view
|
||||
line = self._active_staged_line
|
||||
|
||||
if line:
|
||||
cursor._trackers.remove(line)
|
||||
|
||||
if self._stage_line:
|
||||
self._stage_line.hide()
|
||||
self._stage_line.label.hide()
|
||||
line.delete()
|
||||
|
||||
self._active_staged_line = None
|
||||
|
||||
# show the crosshair y line
|
||||
hl = cursor.graphics[chart]['hl']
|
||||
hl.show()
|
||||
# show the crosshair y line and label
|
||||
cursor.show_xhair()
|
||||
|
||||
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
|
||||
if not line:
|
||||
raise RuntimeError("No line commit is currently staged!?")
|
||||
raise RuntimeError("No line is currently staged!?")
|
||||
|
||||
chart = self.chart._cursor.active_plot
|
||||
y = chart._cursor._datum_xy[1]
|
||||
sym = chart._lc.symbol
|
||||
|
||||
line = level_line(
|
||||
line = order_line(
|
||||
chart,
|
||||
level=y,
|
||||
color='alert_yellow',
|
||||
digits=chart._lc.symbol.digits(),
|
||||
show_label=False,
|
||||
|
||||
# label fields default values
|
||||
level=level,
|
||||
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
|
||||
self._order_lines[uuid] = line
|
||||
return line, y
|
||||
|
||||
return line
|
||||
|
||||
def commit_line(self, uuid: str) -> LevelLine:
|
||||
"""Commit a "staged line" to view.
|
||||
|
@ -307,9 +353,14 @@ class LineEditor:
|
|||
graphic in view.
|
||||
|
||||
"""
|
||||
try:
|
||||
line = self._order_lines[uuid]
|
||||
line.oid = uuid
|
||||
line.label.show()
|
||||
except KeyError:
|
||||
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
|
||||
|
||||
|
@ -328,7 +379,7 @@ class LineEditor:
|
|||
self,
|
||||
line: LevelLine = None,
|
||||
uuid: str = None,
|
||||
) -> None:
|
||||
) -> LevelLine:
|
||||
"""Remove a line by refernce or uuid.
|
||||
|
||||
If no lines or ids are provided remove all lines under the
|
||||
|
@ -339,14 +390,20 @@ class LineEditor:
|
|||
uuid = line.oid
|
||||
|
||||
# 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
|
||||
hovered = self.chart._cursor._hovered
|
||||
if line in hovered:
|
||||
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()
|
||||
return line
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -361,22 +418,28 @@ class ArrowEditor:
|
|||
x: float,
|
||||
y: float,
|
||||
color='default',
|
||||
pointing: str = 'up',
|
||||
pointing: Optional[str] = None,
|
||||
) -> pg.ArrowItem:
|
||||
"""Add an arrow graphic to view at given (x, y).
|
||||
|
||||
"""
|
||||
yb = pg.mkBrush(hcolor('alert_yellow'))
|
||||
|
||||
angle = 90 if pointing == 'up' else -90
|
||||
angle = {
|
||||
'up': 90,
|
||||
'down': -90,
|
||||
None: 180, # pointing to right (as in an alert)
|
||||
}[pointing]
|
||||
|
||||
arrow = pg.ArrowItem(
|
||||
angle=angle,
|
||||
baseAngle=0,
|
||||
headLen=5,
|
||||
headWidth=2,
|
||||
headLen=5*3,
|
||||
headWidth=2*3,
|
||||
tailLen=None,
|
||||
brush=yb,
|
||||
pxMode=True,
|
||||
|
||||
# coloring
|
||||
pen=pg.mkPen(hcolor('papas_special')),
|
||||
brush=pg.mkBrush(hcolor(color)),
|
||||
)
|
||||
arrow.setPos(x, y)
|
||||
|
||||
|
@ -391,59 +454,6 @@ class ArrowEditor:
|
|||
self.chart.plotItem.removeItem(arrow)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderMode:
|
||||
"""Major mode for placing orders on a chart view.
|
||||
|
||||
"""
|
||||
chart: 'ChartPlotWidget' # type: ignore # noqa
|
||||
book: OrderBook
|
||||
lines: LineEditor
|
||||
arrows: ArrowEditor
|
||||
_arrow_colors = {
|
||||
'alert': 'alert_yellow',
|
||||
'buy': 'buy_green',
|
||||
'sell': 'sell_red',
|
||||
}
|
||||
|
||||
key_map: Dict[str, Callable] = field(default_factory=dict)
|
||||
|
||||
def uuid(self) -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_order_mode(
|
||||
chart,
|
||||
):
|
||||
# global _order_lines
|
||||
|
||||
view = chart._vb
|
||||
book = get_orders()
|
||||
lines = LineEditor(view=view, _order_lines=_order_lines, chart=chart)
|
||||
arrows = ArrowEditor(chart, {})
|
||||
|
||||
log.info("Opening order mode")
|
||||
|
||||
mode = OrderMode(chart, book, lines, arrows)
|
||||
view.mode = mode
|
||||
|
||||
# # setup local ui event streaming channels for request/resp
|
||||
# # streamging with EMS daemon
|
||||
# global _to_ems, _from_order_book
|
||||
# _to_ems, _from_order_book = trio.open_memory_channel(100)
|
||||
|
||||
try:
|
||||
yield mode
|
||||
|
||||
finally:
|
||||
# XXX special teardown handling like for ex.
|
||||
# - cancelling orders if needed?
|
||||
# - closing positions if desired?
|
||||
# - switching special condition orders to safer/more reliable variants
|
||||
log.info("Closing order mode")
|
||||
|
||||
|
||||
class ChartView(ViewBox):
|
||||
"""Price chart view box with interaction behaviors you'd expect from
|
||||
any interactive platform:
|
||||
|
@ -467,11 +477,11 @@ class ChartView(ViewBox):
|
|||
self.addItem(self.select_box, ignoreBounds=True)
|
||||
self._chart: 'ChartPlotWidget' = None # noqa
|
||||
|
||||
# self._lines_editor = LineEditor(view=self, _lines=_lines)
|
||||
self.mode = None
|
||||
|
||||
# kb ctrls processing
|
||||
self._key_buffer = []
|
||||
self._key_active: bool = False
|
||||
|
||||
@property
|
||||
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
|
||||
|
@ -481,7 +491,6 @@ class ChartView(ViewBox):
|
|||
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa
|
||||
self._chart = chart
|
||||
self.select_box.chart = chart
|
||||
# self._lines_editor.chart = chart
|
||||
|
||||
def wheelEvent(self, ev, axis=None):
|
||||
"""Override "center-point" location for scrolling.
|
||||
|
@ -498,15 +507,17 @@ class ChartView(ViewBox):
|
|||
else:
|
||||
mask = self.state['mouseEnabled'][:]
|
||||
|
||||
chart = self.linked_charts.chart
|
||||
|
||||
# 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
|
||||
|
||||
if ev.delta() > 0 and vl <= _min_points_to_show:
|
||||
log.debug("Max zoom bruh...")
|
||||
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...")
|
||||
return
|
||||
|
||||
|
@ -531,10 +542,34 @@ class ChartView(ViewBox):
|
|||
|
||||
# This seems like the most "intuitive option, a hybrid of
|
||||
# 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.scaleBy(s, last_bar)
|
||||
self.scaleBy(s, focal)
|
||||
ev.accept()
|
||||
self.sigRangeChangedManually.emit(mask)
|
||||
|
||||
|
@ -649,29 +684,10 @@ class ChartView(ViewBox):
|
|||
self.raiseContextMenu(ev)
|
||||
|
||||
elif button == QtCore.Qt.LeftButton:
|
||||
|
||||
# when in order mode, submit execution
|
||||
if self._key_active:
|
||||
ev.accept()
|
||||
|
||||
# self._lines_editor.commit_line()
|
||||
|
||||
# send order to EMS
|
||||
|
||||
# register the "staged" line under the cursor
|
||||
# to be displayed when above order ack arrives
|
||||
# (means the line graphic doesn't show on screen until the
|
||||
# order is live in the emsd).
|
||||
mode = self.mode
|
||||
uuid = mode.uuid()
|
||||
|
||||
# make line graphic
|
||||
line, y = mode.lines.create_line(uuid)
|
||||
|
||||
# send order cmd to ems
|
||||
mode.book.alert(
|
||||
uuid=uuid,
|
||||
symbol=mode.chart._lc._symbol,
|
||||
price=y
|
||||
)
|
||||
self.mode.submit_exec()
|
||||
|
||||
def keyReleaseEvent(self, ev):
|
||||
"""
|
||||
|
@ -684,18 +700,25 @@ class ChartView(ViewBox):
|
|||
return
|
||||
|
||||
ev.accept()
|
||||
text = ev.text()
|
||||
# text = ev.text()
|
||||
key = ev.key()
|
||||
# mods = ev.modifiers()
|
||||
mods = ev.modifiers()
|
||||
|
||||
if key == QtCore.Qt.Key_Shift:
|
||||
if self.state['mouseMode'] == ViewBox.RectMode:
|
||||
# if self.state['mouseMode'] == ViewBox.RectMode:
|
||||
self.setMouseMode(ViewBox.PanMode)
|
||||
|
||||
if text == 'a':
|
||||
# draw "staged" line under cursor position
|
||||
# if self.state['mouseMode'] == ViewBox.RectMode:
|
||||
# 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._key_active = False
|
||||
|
||||
def keyPressEvent(self, ev):
|
||||
"""
|
||||
This routine should capture key presses in the current view box.
|
||||
|
@ -711,43 +734,56 @@ class ChartView(ViewBox):
|
|||
key = ev.key()
|
||||
mods = ev.modifiers()
|
||||
|
||||
print(f'text: {text}, key: {key}')
|
||||
|
||||
if mods == QtCore.Qt.ShiftModifier:
|
||||
if self.state['mouseMode'] == ViewBox.PanMode:
|
||||
self.setMouseMode(ViewBox.RectMode)
|
||||
|
||||
# ctl
|
||||
# ctrl
|
||||
ctrl = False
|
||||
if mods == QtCore.Qt.ControlModifier:
|
||||
# TODO: ctrl-c as cancel?
|
||||
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
||||
# if ev.text() == 'c':
|
||||
# self.rbScaleBox.hide()
|
||||
print(f"CTRL + key:{key} + text:{text}")
|
||||
ctrl = True
|
||||
|
||||
if mods == QtCore.Qt.ControlModifier:
|
||||
self.mode._exec_mode = 'live'
|
||||
|
||||
self._key_active = True
|
||||
|
||||
# alt
|
||||
if mods == QtCore.Qt.AltModifier:
|
||||
pass
|
||||
|
||||
# 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._key_buffer.append(text)
|
||||
|
||||
# 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':
|
||||
|
||||
# cancel order or clear graphics
|
||||
if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete:
|
||||
# delete any lines under the cursor
|
||||
mode = self.mode
|
||||
for line in mode.lines.lines_under_cursor():
|
||||
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
|
||||
# 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))
|
||||
else:
|
||||
ev.ignore()
|
||||
self._key_active = False
|
||||
|
|
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -25,17 +25,22 @@ from PyQt5 import QtCore, QtGui
|
|||
from qdarkstyle.palette import DarkPalette
|
||||
|
||||
from ..log import get_logger
|
||||
from ._exec import current_screen
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# chart-wide fonts specified in inches
|
||||
_default_font_inches_we_like = 6 / 96
|
||||
_down_2_font_inches_we_like = 5 / 96
|
||||
_default_font_inches_we_like_low_dpi = 6 / 64
|
||||
_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:
|
||||
def __init__(
|
||||
self,
|
||||
# TODO: move to config
|
||||
name: str = 'Hack',
|
||||
size_in_inches: Optional[float] = None,
|
||||
) -> None:
|
||||
|
@ -45,12 +50,23 @@ class DpiAwareFont:
|
|||
self._qfm = QtGui.QFontMetrics(self._qfont)
|
||||
self._physical_dpi = None
|
||||
self._screen = None
|
||||
self._dpi_scalar = 1.
|
||||
|
||||
def _set_qfont_px_size(self, px_size: int) -> None:
|
||||
self._qfont.setPixelSize(px_size)
|
||||
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
|
||||
def font(self):
|
||||
return self._qfont
|
||||
|
@ -59,30 +75,37 @@ class DpiAwareFont:
|
|||
def px_size(self):
|
||||
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.
|
||||
|
||||
If we end up needing to generalize this more here there are resources
|
||||
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
|
||||
pdpi = screen.physicalDotsPerInch()
|
||||
ldpi = screen.logicalDotsPerInch()
|
||||
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)
|
||||
log.info(
|
||||
f"\nscreen:{screen.name()} with DPI: {dpi}"
|
||||
f"\nbest font size is {font_size}\n"
|
||||
)
|
||||
|
||||
self._set_qfont_px_size(font_size)
|
||||
self._physical_dpi = dpi
|
||||
self._screen = screen
|
||||
|
||||
def boundingRect(self, value: str) -> QtCore.QRectF:
|
||||
|
||||
screen = self._screen
|
||||
screen = self.screen
|
||||
if screen is None:
|
||||
raise RuntimeError("You must call .configure_to_dpi() first!")
|
||||
|
||||
|
@ -110,8 +133,8 @@ _xaxis_at = 'bottom'
|
|||
# charting config
|
||||
CHART_MARGINS = (0, 0, 2, 2)
|
||||
_min_points_to_show = 6
|
||||
_bars_from_right_in_follow_mode = int(6**2)
|
||||
_bars_to_left_in_follow_mode = int(6**3)
|
||||
_bars_from_right_in_follow_mode = int(130)
|
||||
_bars_to_left_in_follow_mode = int(616)
|
||||
_tina_mode = False
|
||||
|
||||
|
||||
|
@ -125,6 +148,10 @@ def enable_tina_mode() -> None:
|
|||
|
||||
def hcolor(name: str) -> str:
|
||||
"""Hex color codes by hipster speak.
|
||||
|
||||
This is an internal set of color codes hand picked
|
||||
for certain purposes.
|
||||
|
||||
"""
|
||||
return {
|
||||
|
||||
|
@ -137,6 +164,8 @@ def hcolor(name: str) -> str:
|
|||
|
||||
# fifty shades
|
||||
'gray': '#808080', # like the kick
|
||||
'grayer': '#4c4c4c',
|
||||
'grayest': '#3f3f3f',
|
||||
'jet': '#343434',
|
||||
'cadet': '#91A3B0',
|
||||
'marengo': '#91A3B0',
|
||||
|
@ -166,9 +195,39 @@ def hcolor(name: str) -> str:
|
|||
'tina_green': '#00cc00',
|
||||
'tina_red': '#fa0000',
|
||||
|
||||
'cucumber': '#006400',
|
||||
'cool_green': '#33b864',
|
||||
'dull_green': '#74a662',
|
||||
'hedge_green': '#518360',
|
||||
|
||||
# orders and alerts
|
||||
'alert_yellow': '#e2d083',
|
||||
'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]
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
|
||||
"""
|
||||
Console interface to UI components.
|
||||
|
||||
"""
|
||||
from functools import partial
|
||||
import os
|
||||
import click
|
||||
import tractor
|
||||
|
@ -63,10 +63,10 @@ def monitor(config, rate, name, dhost, test, tl):
|
|||
_kivy_import_hack()
|
||||
from .kivy.monitor import _async_main
|
||||
|
||||
async def main(tries):
|
||||
async def main():
|
||||
async with maybe_spawn_brokerd(
|
||||
brokername=brokermod.name,
|
||||
tries=tries, loglevel=loglevel
|
||||
loglevel=loglevel
|
||||
) as portal:
|
||||
# run app "main"
|
||||
await _async_main(
|
||||
|
@ -75,7 +75,7 @@ def monitor(config, rate, name, dhost, test, tl):
|
|||
)
|
||||
|
||||
tractor.run(
|
||||
partial(main, tries=1),
|
||||
main,
|
||||
name='monitor',
|
||||
loglevel=loglevel if tl else None,
|
||||
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.argument('symbol', required=True)
|
||||
@click.pass_obj
|
||||
def optschain(config, symbol, date, tl, rate, test):
|
||||
def optschain(config, symbol, date, rate, test):
|
||||
"""Start an option chain UI
|
||||
"""
|
||||
# global opts
|
||||
|
@ -100,9 +100,9 @@ def optschain(config, symbol, date, tl, rate, test):
|
|||
_kivy_import_hack()
|
||||
from .kivy.option_chain import _async_main
|
||||
|
||||
async def main(tries):
|
||||
async def main():
|
||||
async with maybe_spawn_brokerd(
|
||||
tries=tries, loglevel=loglevel
|
||||
loglevel=loglevel
|
||||
):
|
||||
# run app "main"
|
||||
await _async_main(
|
||||
|
@ -114,9 +114,8 @@ def optschain(config, symbol, date, tl, rate, test):
|
|||
)
|
||||
|
||||
tractor.run(
|
||||
partial(main, tries=1),
|
||||
main,
|
||||
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,
|
||||
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.pass_obj
|
||||
def chart(config, symbol, date, rate, test, profile):
|
||||
def chart(config, symbol, profile):
|
||||
"""Start a real-time chartng UI
|
||||
"""
|
||||
from .. import _profile
|
||||
from ._chart import _main
|
||||
|
||||
# possibly enable profiling
|
||||
# toggle to enable profiling
|
||||
_profile._pg_profile = profile
|
||||
|
||||
# global opts
|
||||
brokername = config['broker']
|
||||
tractorloglevel = config['tractorloglevel']
|
||||
pikerloglevel = config['loglevel']
|
||||
|
||||
_main(
|
||||
sym=symbol,
|
||||
brokername=brokername,
|
||||
piker_loglevel=pikerloglevel,
|
||||
tractor_kwargs={
|
||||
'debug_mode': True,
|
||||
'loglevel': tractorloglevel,
|
||||
'rpc_module_paths': ['piker._ems'],
|
||||
'enable_modules': [
|
||||
'piker.exchange._client'
|
||||
],
|
||||
},
|
||||
)
|
||||
|
|
|
@ -144,14 +144,17 @@ async def update_quotes(
|
|||
log.warn("Data feed connection dropped")
|
||||
|
||||
|
||||
_widgets = {}
|
||||
|
||||
|
||||
async def stream_symbol_selection():
|
||||
"""An RPC async gen for streaming the symbol corresponding
|
||||
value corresponding to the last clicked row.
|
||||
|
||||
Essentially of an event stream of clicked symbol values.
|
||||
"""
|
||||
widgets = tractor.current_actor().statespace['widgets']
|
||||
table = widgets['table']
|
||||
global _widgets
|
||||
table = _widgets['table']
|
||||
send_chan, recv_chan = trio.open_memory_channel(0)
|
||||
table._click_queues.append(send_chan)
|
||||
try:
|
||||
|
@ -238,8 +241,6 @@ async def _async_main(
|
|||
# set up a pager view for large ticker lists
|
||||
table.bind(minimum_height=table.setter('height'))
|
||||
|
||||
ss = tractor.current_actor().statespace
|
||||
|
||||
async def spawn_opts_chain():
|
||||
"""Spawn an options chain UI in a new subactor.
|
||||
"""
|
||||
|
@ -276,7 +277,10 @@ async def _async_main(
|
|||
'header': header,
|
||||
'pager': pager,
|
||||
}
|
||||
ss['widgets'] = widgets
|
||||
|
||||
global _widgets
|
||||
_widgets = widgets
|
||||
|
||||
nursery.start_soon(
|
||||
update_quotes,
|
||||
nursery,
|
||||
|
|
|
@ -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',
|
||||
)
|
|
@ -1,4 +1,3 @@
|
|||
# no pypi package for tractor (yet)
|
||||
# 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/pikers/pyqtgraph.git@use_qt_inverted#egg=pyqtgraph
|
||||
|
|
Loading…
Reference in New Issue