Compare commits

...

107 Commits

Author SHA1 Message Date
Tyler Goodlet 7592ae7be7 Pass labels to form builder, toy with broadcast consumer task 2021-08-10 17:04:19 -04:00
Tyler Goodlet 112615e374 Add (lack of proper) ring buffer note 2021-08-10 17:02:52 -04:00
Tyler Goodlet ef27a4f4e2 Position tracker is passed at init 2021-08-10 17:02:17 -04:00
Tyler Goodlet 27ba57217a Lol, initial size calcs on order line update 2021-08-10 17:01:46 -04:00
Tyler Goodlet d7cc234a78 Basic allocator state updates from pp sidepane 2021-08-10 17:00:52 -04:00
Tyler Goodlet 7a8e612228 Validate allocator assignments with pydantic 2021-08-10 16:59:44 -04:00
Tyler Goodlet ebfb700cd2 Add reference gist for Qt guest mode stuff 2021-08-10 16:58:41 -04:00
Tyler Goodlet 61c6bbb592 Add disclaimer to old data mod 2021-08-10 16:58:10 -04:00
Tyler Goodlet cc40048ab2 Unpack keyboard events into an explicit msg model 2021-08-10 16:57:19 -04:00
Tyler Goodlet 3d4898c4d5 Use `maybe_open_feed()` in ems and fsp daemons 2021-08-10 16:50:40 -04:00
Tyler Goodlet 6f30ae448a Add a `maybe_open_feed()` which uses new broadcast chans
Try out he new broadcast channels from `tractor` for data feeds
we already have cached. Any time there's a cache hit we load the
cached feed and just slap a broadcast receiver on it for the local
consumer task.
2021-08-10 16:00:34 -04:00
Tyler Goodlet cab1cf4a00 Drop feed refs 2021-08-10 09:34:33 -04:00
Tyler Goodlet 2340a1666b Add an njs cache gist link 2021-08-10 08:51:03 -04:00
Tyler Goodlet b2a1c8882b Let's abstractify: -> 2021-08-09 19:27:42 -04:00
Tyler Goodlet e9f892916e Add lifo cache to new module; drop "utils", bleh 2021-08-09 14:35:11 -04:00
Tyler Goodlet b535effc52 Move feed cacheing to cache mod; put entry retreival into ctx mng 2021-08-09 13:21:12 -04:00
Tyler Goodlet 79000b93cb Start top level cacheing apis module 2021-08-09 11:43:45 -04:00
Tyler Goodlet 9b2b40598d Cache `brokerd` feeds for reuse in clearing loop 2021-08-09 11:31:38 -04:00
Tyler Goodlet 68d2000909 Order mode docs/comments updates 2021-08-09 11:21:05 -04:00
Tyler Goodlet 5ae16bf73e Add draft `pydantic`-`QWidget` ORM system
Move all the ``pydantic`` finagling to an `_orm.py` and
just keep an `Allocator` as the backing model for our pp controls
in the position module. This all needs to be tied together in some sane
with with facility for multiple symbols/streams per chart for when we
get to charting-trading aggregate feeds.
2021-08-06 09:14:30 -04:00
Tyler Goodlet a57d92c8bd Drop uneeded `typing` types for py3.9+ 2021-08-03 10:03:51 -04:00
Tyler Goodlet 5fe8cb7e53 "last" and "current" are better names 2021-08-03 10:03:51 -04:00
Tyler Goodlet d0ad5e43f9 Remove dead OHLC index consumers from subs list on error 2021-08-02 22:08:59 -04:00
Tyler Goodlet f5beb22d6e Flip to view mode on field exit key combos 2021-08-02 18:53:02 -04:00
Tyler Goodlet 37de9e581c Make god widget focus to chart / "view mode" 2021-08-02 18:52:22 -04:00
Tyler Goodlet 2e086375e7 Call god what it is 2021-08-01 18:53:59 -04:00
Tyler Goodlet 8296bc2699 Use lightest default for pp line 2021-08-01 18:53:30 -04:00
Tyler Goodlet d1f5e3f62a Allocate minority to OHLC chart since 2 fsps by default is likely 2021-08-01 16:29:50 -04:00
Tyler Goodlet 4974579e73 "bracket"-ify fills bar + labels and try to evenly space the pane sections 2021-07-30 23:50:03 -04:00
Tyler Goodlet 637364d1c3 Drop old pp config widget inserts; use new pane layout func 2021-07-30 14:23:46 -04:00
Tyler Goodlet d69b52ac8c Break health bar and pane layout into separate routines 2021-07-30 14:22:51 -04:00
Tyler Goodlet ccf79aecf1 Match search bar margins to pp pane 2021-07-30 10:52:21 -04:00
Tyler Goodlet 12e7ceae2b Fix pp pane to show on symbol switches 2021-07-30 10:51:50 -04:00
Tyler Goodlet 202817bc4d Use `QFormLayout` instead of rolling our own; add pp and feed status sections 2021-07-30 10:50:05 -04:00
Tyler Goodlet 66b242e19e Just always use a lambda ; it's innocuous 2021-07-27 10:41:51 -04:00
Tyler Goodlet 177a75adcc Fixup missing ib section handling; drop `.api` subsection 2021-07-27 08:28:44 -04:00
Tyler Goodlet 770ae75210 Move status back to gunmetal 2021-07-27 07:30:53 -04:00
Tyler Goodlet 2ddf40b8d3 Add a "health bar" factor B) 2021-07-27 07:30:53 -04:00
Tyler Goodlet 472cf036cb WIP add a lambda-QFrame to get per chart sidpanes for each linkedsplits row 2021-07-27 07:30:52 -04:00
Tyler Goodlet a68f4b0593 Support (sub)plot names separate from data array keys 2021-07-27 07:28:24 -04:00
Tyler Goodlet 4d66c7ad88 Add position status (health) bar math for sizing and styling 2021-07-27 07:28:24 -04:00
Tyler Goodlet 457cc1a128 Always hide contents labels at startup 2021-07-27 07:28:24 -04:00
Tyler Goodlet 622da73c40 Better search label styling 2021-07-27 07:28:23 -04:00
Tyler Goodlet 8ca6cc180d Add ctrl-p as "pane toggle" 2021-07-27 07:27:14 -04:00
Tyler Goodlet 12c37f3388 Make field form a vertical layout, add formatted style sheets 2021-07-27 07:27:14 -04:00
Tyler Goodlet 01261d601a Allocate pp config form alongside god widget as a side-pane 2021-07-27 07:27:14 -04:00
Tyler Goodlet f27db80bf4 Start using a small schema for generating forms 2021-07-27 07:27:14 -04:00
Tyler Goodlet 4336939507 WIP add input handler for each widget in the form 2021-07-27 07:27:14 -04:00
Tyler Goodlet fd73d1eef1 Support opening a handler on a collection of widgets 2021-07-27 07:27:14 -04:00
Tyler Goodlet 3302d21086 Use font scaled delegate from forms module 2021-07-27 07:27:14 -04:00
Tyler Goodlet 39ad1ab18f Size view delegate from monkey patched parent 2021-07-27 07:27:14 -04:00
Tyler Goodlet 43a9fc60e3 OMG Qt view item sizing is sooo dumb.. 2021-07-27 07:27:14 -04:00
Tyler Goodlet 27cece20c5 Use "slots" as name for "number of entries" 2021-07-27 07:27:14 -04:00
Tyler Goodlet a94a86fed1 Mock up initial selection field and progress bar 2021-07-27 07:27:14 -04:00
Tyler Goodlet 0a7ef0cb67 "Forms" is a better module name 2021-07-27 07:27:14 -04:00
Tyler Goodlet e80ca26649 Allocate pp config with new actory, drop old line update method 2021-07-27 07:27:14 -04:00
Tyler Goodlet 9c07db368d Use mode name setter throughout 2021-07-27 07:27:14 -04:00
Tyler Goodlet 5c58d0b5fc Add mode name setter 2021-07-27 07:27:14 -04:00
Tyler Goodlet af3e5e53bc Drop stale anchors 2021-07-27 07:27:14 -04:00
Tyler Goodlet df9e3654f0 Move font-aware line edit to "text entry" mod 2021-07-27 07:27:14 -04:00
Tyler Goodlet 39edbc126a Toggle pp config widget on order mode active 2021-07-27 07:27:14 -04:00
Tyler Goodlet f30bf3102d Change order label format to color:count 2021-07-27 07:27:14 -04:00
Tyler Goodlet ec6639f275 First WIP of pp config entry widget on status bar 2021-07-27 07:27:14 -04:00
Tyler Goodlet 501828d906 Use one marker, drop old anchors, add graphics update on marker paint 2021-07-27 07:27:14 -04:00
Tyler Goodlet 67721a5832 Add dpi font scale getter 2021-07-27 07:27:14 -04:00
Tyler Goodlet 9887b14518 Skip line stage when chart not yet initialized 2021-07-27 07:27:14 -04:00
Tyler Goodlet 0f417f8c80 Add a tight pp anchor 2021-07-27 07:27:14 -04:00
Tyler Goodlet 393446b933 Start a "text entry widgets" module 2021-07-27 07:27:14 -04:00
Tyler Goodlet d034d7e6b1 Factor font-size-based labeled-line edit into generics widget 2021-07-27 07:27:14 -04:00
Tyler Goodlet 1048ea14d3 Add support for a marker "on paint" callback 2021-07-27 07:27:12 -04:00
Tyler Goodlet 37cd800135 Add a scene bounding rect getter to our label 2021-07-27 07:26:37 -04:00
Tyler Goodlet 5d94ee7479 Just warn for now on unknown dialogs 2021-07-27 07:26:37 -04:00
Tyler Goodlet 40a38284df Move level marker to annotate module 2021-07-27 07:26:36 -04:00
Tyler Goodlet 6cdb2fca41 Actually position msgs get relayed verbatim 2021-07-27 07:19:01 -04:00
Tyler Goodlet fd425dca29 Move DPI / screen get logging to debug; reduce cli noise 2021-07-27 07:19:01 -04:00
Tyler Goodlet 5eada47cbf Drop all `ChartPlotWidget._lc` remap to `.linked 2021-07-27 07:19:01 -04:00
Tyler Goodlet ca2729d2c0 Pass position msg to tracker, append fill msgs 2021-07-27 07:19:01 -04:00
Tyler Goodlet 174b9ce0cf Fixup commented view locate call 2021-07-27 07:19:01 -04:00
Tyler Goodlet 86e71a232f Only hide position (extra) info on order mode exit 2021-07-27 07:19:01 -04:00
Tyler Goodlet c98b60f7aa Fix oustanding label bugs, make `.update()` accept a position msg 2021-07-27 07:19:01 -04:00
Tyler Goodlet 480e5634c4 Stop pulling lot size precision from symbol for now in the UI 2021-07-27 07:19:01 -04:00
Tyler Goodlet 271bf67e78 Drop position-line factory from lines module, add endpoint getter 2021-07-27 07:19:01 -04:00
Tyler Goodlet 23b77fffc6 Make our default label opaque (since it's normally just text) 2021-07-27 07:19:01 -04:00
Tyler Goodlet f9f4fdca7e Increase cursor debounce delay slightly? 2021-07-27 07:19:01 -04:00
Tyler Goodlet c6a02d1bbf Switch mode to touch `.pp` 2021-07-27 07:19:01 -04:00
Tyler Goodlet 5bd764e0e9 Add `.view` property, throttle to 50Hz by default 2021-07-27 07:19:01 -04:00
Tyler Goodlet 8df399b8c1 Add a left-side-of-marker orientation 2021-07-27 07:19:01 -04:00
Tyler Goodlet d323b0c34b Move position tracking to new module
It was becoming too much with all the labels and markers and lines..
Might as well package it all together instead of cramming it in the
order mode loop, chief.

The techincal summary,
- move `_lines.position_line()` -> `PositionInfo.position_line()`.
- slap a `.pp` on the order mode instance which *is* a `PositionInfo`
- drop the position info info label for now (let's see what users want
  eventually but for now let's keep it super minimal).
- add a `LevelMarker` type to replace the old `LevelLine` internal
  marker system (includes ability to change the style and level on the
  fly).
- change `_annotate.mk_marker()` -> `mk_maker_path()` and expect caller
  to wrap in a `QGraphicsPathItem` if needed.
2021-07-27 07:19:01 -04:00
Tyler Goodlet 08fe6fc4f3 Use `QGraphicsPathItem` for marker, add line hide method 2021-07-27 07:19:01 -04:00
Tyler Goodlet 6485e64d41 Update entry count on position msgs, draft a composite position info type 2021-07-27 07:19:01 -04:00
Tyler Goodlet 1a870364c5 Add label location description param for graphics path anchor 2021-07-27 07:19:01 -04:00
Tyler Goodlet 34a773821e Drop the open ctx mng; add wip pp label 2021-07-27 07:19:01 -04:00
Tyler Goodlet 2d42da6f1a Move marker label anchor to anchors mod 2021-07-27 07:19:01 -04:00
Tyler Goodlet 0ddeded03d Move all anchor funcs to new mod 2021-07-27 07:19:01 -04:00
Tyler Goodlet ee5d5b0cef Move marker level-line-positioning anchor to new module 2021-07-27 07:19:01 -04:00
Tyler Goodlet d25be4c970 Use label anchor 2021-07-27 07:19:01 -04:00
Tyler Goodlet 422b81f0eb Remove `LevelLine.add_label()`, add dynamic pp marker label 2021-07-27 07:18:59 -04:00
Tyler Goodlet 2fd7ea812a Add user defined anchor support to label; reorg mod 2021-07-27 07:17:37 -04:00
Tyler Goodlet 2d787901f9 Add a client side order dialog type for tracking flows in the UI 2021-07-27 07:17:37 -04:00
Tyler Goodlet 5a271f9a5e Only re-calc avg pp price on pp size increases 2021-07-27 07:17:37 -04:00
Tyler Goodlet 94275c9be8 Drop `_graphics` subpkg; flat is better then nested 2021-07-27 07:17:37 -04:00
Tyler Goodlet 581134f39c Add per session paper position tracking
Generate and maintain position messages in the paper engine for each
`pikerd` session. We no longer tear down the engine on each client
disconnect. Ensure -ve size on sells to make the math work.
2021-07-27 07:17:37 -04:00
Tyler Goodlet 5a303ede1e Add more futes, add in order status comments 2021-07-27 07:17:35 -04:00
Tyler Goodlet ee25b57895 Make subplot proportion slightly larger 2021-07-27 07:16:50 -04:00
Tyler Goodlet bc3bcd6a07 WIP position market offscreen nav 2021-07-27 07:16:49 -04:00
Tyler Goodlet 55d67cc5c6 Fix TWS triggered trades msg packing 2021-07-27 07:13:11 -04:00
Tyler Goodlet 155d7b2a73 Add more futes, add in order status comments 2021-07-27 07:13:06 -04:00
40 changed files with 2923 additions and 1023 deletions

View File

@ -11,15 +11,15 @@ key_descr = "api_0"
public_key = ""
private_key = ""
[ib.api]
ipaddr = "127.0.0.1"
[ib]
host = "127.0.0.1"
[ib.accounts]
margin = ""
registered = ""
paper = ""
[ib.api.ports]
[ib.ports]
gw = 4002
tws = 7497
order = [ "gw", "tws",]

View File

@ -1,66 +0,0 @@
# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of 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/>.
"""
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):
"""Async ``cache`` with a LIFO policy.
Implemented my own since no one else seems to have
a standard. I'll wait for the smarter people to come
up with one, but until then...
"""
cache = OrderedDict()
def decorator(fn):
async def wrapper(*args):
key = args
try:
return cache[key]
except KeyError:
if len(cache) >= maxsize:
# discard last added new entry
cache.popitem()
# do it
cache[key] = await fn(*args)
return cache[key]
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

View File

@ -0,0 +1,203 @@
# 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/>.
"""
Cacheing apis and toolz.
"""
# further examples of interest:
# https://gist.github.com/njsmith/cf6fc0a97f53865f2c671659c88c1798#file-cache-py-L8
from collections import OrderedDict
from typing import (
Optional,
Hashable,
TypeVar,
AsyncContextManager,
AsyncIterable,
)
from contextlib import (
asynccontextmanager,
AsyncExitStack,
contextmanager,
)
import trio
from .brokers import get_brokermod
from .log import get_logger
T = TypeVar('T')
log = get_logger(__name__)
def async_lifo_cache(maxsize=128):
"""Async ``cache`` with a LIFO policy.
Implemented my own since no one else seems to have
a standard. I'll wait for the smarter people to come
up with one, but until then...
"""
cache = OrderedDict()
def decorator(fn):
async def wrapper(*args):
key = args
try:
return cache[key]
except KeyError:
if len(cache) >= maxsize:
# discard last added new entry
cache.popitem()
# do it
cache[key] = await fn(*args)
return cache[key]
return wrapper
return decorator
_cache: dict[str, 'Client'] = {} # noqa
# XXX: this mis mostly an alt-implementation of
# maybe_open_ctx() below except it uses an async exit statck.
# ideally wer pick one or the other.
@asynccontextmanager
async def open_cached_client(
brokername: str,
) -> 'Client': # noqa
'''Get a cached broker client from the current actor's local vars.
If one has not been setup do it and cache it.
'''
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}` 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:
if client is not None:
# if no more consumers, teardown the client
client._consumers -= 1
if client._consumers <= 0:
await client._exit_stack.aclose()
class cache:
'''Globally (processs wide) cached, task access to a
kept-alive-while-in-use data feed.
'''
lock = trio.Lock()
users: int = 0
ctxs: dict[tuple[str, str], AsyncIterable] = {}
no_more_users: Optional[trio.Event] = None
@asynccontextmanager
async def maybe_open_ctx(
key: Hashable,
mngr: AsyncContextManager[T],
) -> (bool, T):
'''Maybe open a context manager if there is not already a cached
version for the provided ``key``. Return the cached instance on
a cache hit.
'''
@contextmanager
def get_and_use() -> AsyncIterable[T]:
# key error must bubble here
feed = cache.ctxs[key]
log.info(f'Reusing cached feed for {key}')
try:
cache.users += 1
yield True, feed
finally:
cache.users -= 1
if cache.users == 0:
# signal to original allocator task feed use is complete
cache.no_more_users.set()
try:
with get_and_use() as feed:
yield True, feed
except KeyError:
# lock feed acquisition around task racing / ``trio``'s
# scheduler protocol
await cache.lock.acquire()
try:
with get_and_use() as feed:
cache.lock.release()
yield feed
return
except KeyError:
# **critical section** that should prevent other tasks from
# checking the cache until complete otherwise the scheduler
# may switch and by accident we create more then one feed.
cache.no_more_users = trio.Event()
log.info(f'Allocating new feed for {key}')
# TODO: eventually support N-brokers
async with mngr as value:
cache.ctxs[key] = value
cache.lock.release()
try:
yield True, value
finally:
# don't tear down the feed until there are zero
# users of it left.
if cache.users > 0:
await cache.no_more_users.wait()
log.warning('De-allocating feed for {key}')
cache.ctxs.pop(key)

View File

@ -283,7 +283,7 @@ async def maybe_spawn_daemon(
lock = Brokerd.locks[service_name]
await lock.acquire()
# attach to existing brokerd if possible
# attach to existing daemon by name if possible
async with tractor.find_actor(service_name) as portal:
if portal is not None:
lock.release()

View File

@ -1,85 +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/>.
"""
Actor-aware broker agnostic interface.
"""
from typing import Dict
from contextlib import asynccontextmanager, AsyncExitStack
import trio
from . import get_brokermod
from ..log import get_logger
log = get_logger(__name__)
_cache: Dict[str, 'Client'] = {} # noqa
@asynccontextmanager
async def open_cached_client(
brokername: str,
*args,
**kwargs,
) -> 'Client': # noqa
"""Get a cached broker client from the current actor's local vars.
If one has not been setup do it and cache it.
"""
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}` 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:
if client is not None:
# if no more consumers, teardown the client
client._consumers -= 1
if client._consumers <= 0:
await client._exit_stack.aclose()

View File

@ -33,7 +33,7 @@ from pydantic.dataclasses import dataclass
from pydantic import BaseModel
import wsproto
from .api import open_cached_client
from .._cacheables import open_cached_client
from ._util import resproc, SymbolNotFound
from ..log import get_logger, get_console_log
from ..data import ShmArray

View File

@ -29,7 +29,7 @@ import trio
from ..log import get_logger
from . import get_brokermod
from .._daemon import maybe_spawn_brokerd
from .api import open_cached_client
from .._cacheables import open_cached_client
log = get_logger(__name__)

View File

@ -14,9 +14,14 @@
# 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/>.
"""
Real-time data feed machinery
"""
'''
NB: this is the old original implementation that was used way way back
when the project started with ``kivy``.
This code is left for reference but will likely be merged in
appropriately and removed.
'''
import time
from functools import partial
from dataclasses import dataclass, field

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
# 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
@ -171,6 +171,7 @@ _adhoc_futes_set = {
# equities
'nq.globex',
'mnq.globex',
'es.globex',
'mes.globex',
@ -178,8 +179,20 @@ _adhoc_futes_set = {
'brr.cmecrypto',
'ethusdrr.cmecrypto',
# agriculture
'he.globex', # lean hogs
'le.globex', # live cattle (geezers)
'gf.globex', # feeder cattle (younguns)
# raw
'lb.globex', # random len lumber
# metals
'xauusd.cmdty',
'xauusd.cmdty', # gold spot
'gc.nymex',
'mgc.nymex',
'xagusd.cmdty', # silver spot
}
# exchanges we don't support at the moment due to not knowing
@ -556,7 +569,7 @@ class Client:
else:
item = ('status', obj)
log.info(f'eventkit event -> {eventkit_obj}: {item}')
log.info(f'eventkit event ->\n{pformat(item)}')
try:
to_trio.send_nowait(item)
@ -656,25 +669,28 @@ def get_config() -> dict[str, Any]:
section = conf.get('ib')
if not section:
if section is None:
log.warning(f'No config section found for ib in {path}')
return
return {}
return section
@asynccontextmanager
async def _aio_get_client(
host: str = '127.0.0.1',
port: int = None,
client_id: Optional[int] = None,
) -> Client:
"""Return an ``ib_insync.IB`` instance wrapped in our client API.
'''Return an ``ib_insync.IB`` instance wrapped in our client API.
Client instances are cached for later use.
TODO: consider doing this with a ctx mngr eventually?
"""
'''
conf = get_config()
# first check cache for existing client
@ -699,17 +715,21 @@ async def _aio_get_client(
ib = NonShittyIB()
# attempt to get connection info from config
ports = conf['api'].get(
# attempt to get connection info from config; if no .toml entry
# exists, we try to load from a default localhost connection.
host = conf.get('host', '127.0.0.1')
ports = conf.get(
'ports',
# default order is to check for gw first
{
# default order is to check for gw first
'gw': 4002,
'tws': 7497,
'order': ['gw', 'tws']
}
)
order = ports['order']
try_ports = [ports[key] for key in order]
ports = try_ports if port is None else [port]
@ -1351,13 +1371,40 @@ async def trades_dialogue(
# start order request handler **before** local trades event loop
n.start_soon(handle_order_requests, ems_stream)
# TODO: for some reason we can receive a ``None`` here when the
# ib-gw goes down? Not sure exactly how that's happening looking
# at the eventkit code above but we should probably handle it...
async for event_name, item in ib_trade_events_stream:
print(f' ib sending {item}')
# XXX: begin normalization of nonsense ib_insync internal
# object-state tracking representations...
# 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)
# XXX: here's some other sucky cases from the api
# - 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='')],
if event_name == 'status':
# XXX: begin normalization of nonsense ib_insync internal
# object-state tracking representations...
# unwrap needed data from ib_insync internal types
trade: Trade = item
status: OrderStatus = trade.orderStatus
@ -1368,10 +1415,13 @@ async def trades_dialogue(
reqid=trade.order.orderId,
time_ns=time.time_ns(), # cuz why not
# everyone doin camel case..
status=status.status.lower(), # force lower case
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,
@ -1442,14 +1492,14 @@ async def trades_dialogue(
if getattr(msg, 'reqid', 0) < -1:
# it's a trade event generated by TWS usage.
log.warning(f"TWS triggered trade:\n{pformat(msg)}")
log.info(f"TWS triggered trade\n{pformat(msg.dict())}")
msg.reqid = 'tws-' + str(-1 * msg.reqid)
# mark msg as from "external system"
# TODO: probably something better then this.. and start
# considering multiplayer/group trades tracking
msg.external = True
msg.broker_details['external_src'] = 'tws'
continue
# XXX: we always serialize to a dict for msgpack
@ -1462,9 +1512,8 @@ async def trades_dialogue(
@tractor.context
async def open_symbol_search(
ctx: tractor.Context,
) -> None:
# async with open_cached_client('ib') as client:
) -> None:
# load all symbols locally for fast search
await ctx.started({})
@ -1491,6 +1540,12 @@ async def open_symbol_search(
if not pattern or pattern.isspace():
log.warning('empty pattern received, skipping..')
# TODO: *BUG* if nothing is returned here the client
# side will cache a null set result and not showing
# anything to the use on re-searches when this query
# timed out. We probably need a special "timeout" msg
# or something...
# XXX: this unblocks the far end search task which may
# hold up a multi-search nursery block
await stream.send({})
@ -1498,7 +1553,7 @@ async def open_symbol_search(
continue
log.debug(f'searching for {pattern}')
# await tractor.breakpoint()
last = time.time()
results = await _trio_run_client_method(
method='search_stocks',

View File

@ -34,7 +34,7 @@ from pydantic.dataclasses import dataclass
from pydantic import BaseModel
import wsproto
from .api import open_cached_client
from .._cacheables import open_cached_client
from ._util import resproc, SymbolNotFound, BrokerError
from ..log import get_logger, get_console_log
from ..data import ShmArray

View File

@ -42,10 +42,10 @@ import wrapt
import asks
from ..calc import humanize, percent_change
from .._cacheables import open_cached_client, async_lifo_cache
from . import config
from ._util import resproc, BrokerError, SymbolNotFound
from ..log import get_logger, colorize_json, get_console_log
from .._async_utils import async_lifo_cache
from . import get_brokermod
from . import api
@ -1197,7 +1197,7 @@ async def stream_quotes(
# XXX: required to propagate ``tractor`` loglevel to piker logging
get_console_log(loglevel)
async with api.open_cached_client('questrade') as client:
async with open_cached_client('questrade') as client:
if feed_type == 'stock':
formatter = format_stock_quote
get_quotes = await stock_quoter(client, symbols)

View File

@ -38,7 +38,7 @@ log = get_logger(__name__)
@dataclass
class OrderBook:
"""Buy-side (client-side ?) order book ctl and tracking.
'''EMS-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
@ -48,7 +48,7 @@ class OrderBook:
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

View File

@ -22,7 +22,7 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pprint import pformat
import time
from typing import AsyncIterator, Callable, Any
from typing import AsyncIterator, Callable
from bidict import bidict
from pydantic import BaseModel
@ -30,10 +30,9 @@ import trio
from trio_typing import TaskStatus
import tractor
from .. import data
from ..log import get_logger
from ..data._normalize import iterticks
from ..data.feed import Feed
from ..data.feed import Feed, maybe_open_feed
from .._daemon import maybe_spawn_brokerd
from . import _paper_engine as paper
from ._messages import (
@ -123,7 +122,7 @@ class _DarkBook:
# 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.
# slinging NQ futes or something; check ur margin.
_DEFAULT_SIZE: float = 1.0
@ -132,7 +131,6 @@ async def clear_dark_triggers(
brokerd_orders_stream: tractor.MsgStream,
ems_client_order_stream: tractor.MsgStream,
quote_stream: tractor.ReceiveMsgStream, # noqa
broker: str,
symbol: str,
@ -266,7 +264,7 @@ class TradesRelay:
consumers: int = 0
class _Router(BaseModel):
class Router(BaseModel):
'''Order router which manages and tracks per-broker dark book,
alerts, clearing and related data feed management.
@ -276,8 +274,6 @@ class _Router(BaseModel):
# setup at actor spawn time
nursery: trio.Nursery
feeds: dict[tuple[str, str], Any] = {}
# broker to book map
books: dict[str, _DarkBook] = {}
@ -343,12 +339,12 @@ class _Router(BaseModel):
relay.consumers -= 1
_router: _Router = None
_router: Router = None
async def open_brokerd_trades_dialogue(
router: _Router,
router: Router,
feed: Feed,
symbol: str,
_exec_mode: str,
@ -466,7 +462,7 @@ async def _setup_persistent_emsd(
# open a root "service nursery" for the ``emsd`` actor
async with trio.open_nursery() as service_nursery:
_router = _Router(nursery=service_nursery)
_router = Router(nursery=service_nursery)
# TODO: send back the full set of persistent
# orders/execs?
@ -480,7 +476,7 @@ async def translate_and_relay_brokerd_events(
broker: str,
brokerd_trades_stream: tractor.MsgStream,
router: _Router,
router: Router,
) -> AsyncIterator[dict]:
'''Trades update loop - receive updates from ``brokerd`` trades
@ -704,7 +700,7 @@ async def process_client_order_cmds(
symbol: str,
feed: Feed, # noqa
dark_book: _DarkBook,
router: _Router,
router: Router,
) -> None:
@ -958,32 +954,25 @@ async def _emsd_main(
# tractor.Context instead of strictly requiring a ctx arg.
ems_ctx = ctx
cached_feed = _router.feeds.get((broker, symbol))
if cached_feed:
# TODO: use cached feeds per calling-actor
log.warning(f'Opening duplicate feed for {(broker, symbol)}')
feed: Feed
# spawn one task per broker feed
async with (
# TODO: eventually support N-brokers
data.open_feed(
maybe_open_feed(
broker,
[symbol],
loglevel=loglevel,
) as feed,
) as (feed, stream),
):
if not cached_feed:
_router.feeds[(broker, symbol)] = feed
# XXX: this should be initial price quote from target provider
first_quote = feed.first_quote
# open a stream with the brokerd backend for order
# flow dialogue
book = _router.get_dark_book(broker)
book.lasts[(broker, symbol)] = first_quote[symbol]['last']
# open a stream with the brokerd backend for order
# flow dialogue
async with (
# only open if one isn't already up: we try to keep
@ -1015,11 +1004,9 @@ async def _emsd_main(
n.start_soon(
clear_dark_triggers,
# relay.brokerd_dialogue,
brokerd_stream,
ems_client_order_stream,
feed.stream,
stream,
broker,
symbol,
book

View File

@ -81,8 +81,6 @@ class Status(BaseModel):
# 'alert_submitted',
# 'alert_triggered',
# 'position',
# }
resp: str # "response", see above

View File

@ -35,7 +35,7 @@ from ..data._normalize import iterticks
from ..log import get_logger
from ._messages import (
BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus,
BrokerdFill,
BrokerdFill, BrokerdPosition,
)
@ -60,6 +60,7 @@ class PaperBoi:
_buys: bidict
_sells: bidict
_reqids: bidict
_positions: dict[str, BrokerdPosition]
# init edge case L1 spread
last_ask: Tuple[float, float] = (float('inf'), 0) # price, size
@ -101,6 +102,9 @@ class PaperBoi:
# in the broker trades event processing loop
await trio.sleep(0.05)
if action == 'sell':
size = -size
msg = BrokerdStatus(
status='submitted',
reqid=reqid,
@ -118,7 +122,7 @@ class PaperBoi:
) or (
action == 'sell' and (clear_price := self.last_bid[0]) >= price
):
await self.fake_fill(clear_price, size, action, reqid, oid)
await self.fake_fill(symbol, clear_price, size, action, reqid, oid)
else:
# register this submissions as a paper live order
@ -170,6 +174,8 @@ class PaperBoi:
async def fake_fill(
self,
symbol: str,
price: float,
size: float,
action: str, # one of {'buy', 'sell'}
@ -181,6 +187,7 @@ class PaperBoi:
# remaining lots to fill
order_complete: bool = True,
remaining: float = 0,
) -> None:
"""Pretend to fill a broker order @ price and size.
@ -232,6 +239,49 @@ class PaperBoi:
)
await self.ems_trades_stream.send(msg.dict())
# lookup any existing position
token = f'{symbol}.{self.broker}'
pp_msg = self._positions.setdefault(
token,
BrokerdPosition(
broker=self.broker,
account='paper',
symbol=symbol,
# TODO: we need to look up the asset currency from
# broker info. i guess for crypto this can be
# inferred from the pair?
currency='',
size=0.0,
avg_price=0,
)
)
# "avg position price" calcs
# TODO: eventually it'd be nice to have a small set of routines
# to do this stuff from a sequence of cleared orders to enable
# so called "contextual positions".
new_size = size + pp_msg.size
# old size minus the new size gives us size differential with
# +ve -> increase in pp size
# -ve -> decrease in pp size
size_diff = abs(new_size) - abs(pp_msg.size)
if new_size == 0:
pp_msg.avg_price = 0
elif size_diff > 0:
# only update the "average position price" when the position
# size increases not when it decreases (i.e. the position is
# being made smaller)
pp_msg.avg_price = (
abs(size) * price + pp_msg.avg_price * abs(pp_msg.size)
) / abs(new_size)
pp_msg.size = new_size
await self.ems_trades_stream.send(pp_msg.dict())
async def simulate_fills(
quote_stream: 'tractor.ReceiveStream', # noqa
@ -255,6 +305,7 @@ async def simulate_fills(
# this stream may eventually contain multiple symbols
async for quotes in quote_stream:
for sym, quote in quotes.items():
for tick in iterticks(
@ -274,6 +325,7 @@ async def simulate_fills(
)
orders = client._buys.get(sym, {})
book_sequence = reversed(
sorted(orders.keys(), key=itemgetter(1)))
@ -307,6 +359,7 @@ async def simulate_fills(
# clearing price would have filled entirely
await client.fake_fill(
symbol=sym,
# todo slippage to determine fill price
price=tick_price,
size=size,
@ -411,6 +464,9 @@ async def trades_dialogue(
_sells={},
_reqids={},
# TODO: load paper positions from ``positions.toml``
_positions={},
)
n.start_soon(handle_order_requests, client, ems_stream)
@ -452,10 +508,5 @@ async def open_paperboi(
loglevel=loglevel,
) as (ctx, first):
try:
yield ctx, first
finally:
# be sure to tear down the paper service on exit
with trio.CancelScope(shield=True):
await portal.cancel_actor()
yield ctx, first

View File

@ -118,8 +118,9 @@ async def increment_ohlc_buffer(
shm.push(last)
# broadcast the buffer index step
# yield {'index': shm._last.value}
for ctx in _subscribers.get(delay_s, ()):
subs = _subscribers.get(delay_s, ())
for ctx in subs:
try:
await ctx.send_yield({'index': shm._last.value})
except (
@ -127,6 +128,7 @@ async def increment_ohlc_buffer(
trio.ClosedResourceError
):
log.error(f'{ctx.chan.uid} dropped connection')
subs.remove(ctx)
@tractor.stream

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
NumPy compatible shared memory buffers for real-time FSP.
NumPy compatible shared memory buffers for real-time IPC streaming.
"""
from dataclasses import dataclass, asdict
@ -207,11 +207,16 @@ class ShmArray:
def push(
self,
data: np.ndarray,
prepend: bool = False,
) -> int:
"""Ring buffer like "push" to append data
'''Ring buffer like "push" to append data
into the buffer and return updated "last" index.
"""
NB: no actual ring logic yet to give a "loop around" on overflow
condition, lel.
'''
length = len(data)
if prepend:

View File

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

View File

@ -31,11 +31,14 @@ from typing import (
)
import trio
from trio.abc import ReceiveChannel
from trio_typing import TaskStatus
import tractor
from tractor import _broadcast
from pydantic import BaseModel
from ..brokers import get_brokermod
from .._cacheables import maybe_open_ctx
from ..log import get_logger, get_console_log
from .._daemon import (
maybe_spawn_brokerd,
@ -345,10 +348,10 @@ class Feed:
memory buffer orchestration.
"""
name: str
stream: AsyncIterator[dict[str, Any]]
shm: ShmArray
mod: ModuleType
first_quote: dict
stream: trio.abc.ReceiveChannel[dict[str, Any]]
_brokerd_portal: tractor._portal.Portal
_index_stream: Optional[AsyncIterator[int]] = None
@ -362,7 +365,7 @@ class Feed:
symbols: dict[str, Symbol] = field(default_factory=dict)
async def receive(self) -> dict:
return await self.stream.__anext__()
return await self.stream.receive()
@asynccontextmanager
async def index_stream(
@ -376,8 +379,10 @@ class Feed:
# a lone broker-daemon per provider should be
# created for all practical purposes
async with self._brokerd_portal.open_stream_from(
iter_ohlc_periods,
delay_s=delay_s or self._max_sample_rate,
) as self._index_stream:
yield self._index_stream
@ -395,7 +400,7 @@ def sym_to_shm_key(
@asynccontextmanager
async def install_brokerd_search(
portal: tractor._portal.Portal,
portal: tractor.Portal,
brokermod: ModuleType,
) -> None:
@ -434,34 +439,21 @@ async def open_feed(
loglevel: Optional[str] = None,
tick_throttle: Optional[float] = None, # Hz
shielded_stream: bool = False,
) -> AsyncIterator[dict[str, Any]]:
) -> Feed:
'''
Open a "data feed" which provides streamed real-time quotes.
'''
sym = symbols[0].lower()
# TODO: feed cache locking, right now this is causing
# issues when reconnecting to a long running emsd?
# global _searcher_cache
# async with _cache_lock:
# feed = _searcher_cache.get((brokername, sym))
# # if feed is not None and sym in feed.symbols:
# if feed is not None:
# yield feed
# # short circuit
# return
try:
mod = get_brokermod(brokername)
except ImportError:
mod = get_ingestormod(brokername)
# no feed for broker exists so maybe spawn a data brokerd
async with (
maybe_spawn_brokerd(
@ -480,21 +472,25 @@ async def open_feed(
) as (ctx, (init_msg, first_quote)),
ctx.open_stream() as stream,
):
ctx.open_stream(shield=shielded_stream) as stream,
):
# we can only read from shm
shm = attach_shm_array(
token=init_msg[sym]['shm_token'],
readonly=True,
)
bstream = _broadcast.broadcast_receiver(
stream,
2**10,
)
feed = Feed(
name=brokername,
stream=stream,
shm=shm,
mod=mod,
first_quote=first_quote,
stream=bstream, #brx_stream,
_brokerd_portal=portal,
)
ohlc_sample_rates = []
@ -530,3 +526,39 @@ async def open_feed(
finally:
# drop the infinite stream connection
await ctx.cancel()
@asynccontextmanager
async def maybe_open_feed(
brokername: str,
symbols: Sequence[str],
loglevel: Optional[str] = None,
tick_throttle: Optional[float] = None, # Hz
shielded_stream: bool = False,
) -> (Feed, ReceiveChannel[dict[str, Any]]):
'''Maybe open a data to a ``brokerd`` daemon only if there is no
local one for the broker-symbol pair, if one is cached use it wrapped
in a tractor broadcast receiver.
'''
sym = symbols[0].lower()
async with maybe_open_ctx(
key=(brokername, sym),
mngr=open_feed(
brokername,
[sym],
loglevel=loglevel,
),
) as (cache_hit, feed):
if cache_hit:
# add a new broadcast subscription for the quote stream
# if this feed is likely already in use
async with feed.stream.subscribe() as bstream:
yield feed, bstream
else:
yield feed, stream

View File

@ -69,6 +69,7 @@ async def fsp_compute(
ctx: tractor.Context,
symbol: str,
feed: Feed,
stream: trio.abc.ReceiveChannel,
src: ShmArray,
dst: ShmArray,
@ -93,14 +94,14 @@ async def fsp_compute(
yield {}
# task cancellation won't kill the channel
with stream.shield():
async for quotes in stream:
for symbol, quotes in quotes.items():
if symbol == sym:
yield quotes
# since we shielded at the `open_feed()` call
async for quotes in stream:
for symbol, quotes in quotes.items():
if symbol == sym:
yield quotes
out_stream = func(
filter_by_sym(symbol, feed.stream),
filter_by_sym(symbol, stream),
feed.shm,
)
@ -164,7 +165,8 @@ async def cascade(
dst_shm_token: Tuple[str, np.dtype],
symbol: str,
fsp_func_name: str,
) -> AsyncIterator[dict]:
) -> None:
"""Chain streaming signal processors and deliver output to
destination mem buf.
@ -175,7 +177,11 @@ async def cascade(
func: Callable = _fsps[fsp_func_name]
# open a data feed stream with requested broker
async with data.open_feed(brokername, [symbol]) as feed:
async with data.feed.maybe_open_feed(
brokername,
[symbol],
shielded_stream=True,
) as (feed, stream):
assert src.token == feed.shm.token
@ -186,6 +192,7 @@ async def cascade(
ctx=ctx,
symbol=symbol,
feed=feed,
stream=stream,
src=src,
dst=dst,

View File

@ -0,0 +1,153 @@
# 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/>.
'''
Anchor funtions for UI placement of annotions.
'''
from typing import Callable
from PyQt5.QtCore import QPointF
from PyQt5.QtGui import QGraphicsPathItem
from ._label import Label
def marker_right_points(
chart: 'ChartPlotWidget', # noqa
marker_size: int = 20,
) -> (float, float, float):
'''Return x-dimension, y-axis-aware, level-line marker oriented scene values.
X values correspond to set the end of a level line, end of
a paried level line marker, and the right most side of the "right"
axis respectively.
'''
# TODO: compute some sensible maximum value here
# and use a humanized scheme to limit to that length.
l1_len = chart._max_l1_line_len
ryaxis = chart.getAxis('right')
r_axis_x = ryaxis.pos().x()
up_to_l1_sc = r_axis_x - l1_len - 10
marker_right = up_to_l1_sc - (1.375 * 2 * marker_size)
line_end = marker_right - (6/16 * marker_size)
return line_end, marker_right, r_axis_x
def vbr_left(
label: 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,
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
def gpath_pin(
gpath: QGraphicsPathItem,
label: Label, # noqa
location_description: str = 'right-of-path-centered',
use_right_of_pp_label: bool = False,
) -> QPointF:
# get actual arrow graphics path
path_br = gpath.mapToScene(gpath.path()).boundingRect()
# label.vb.locate(label.txt) #, children=True)
if location_description == 'right-of-path-centered':
return path_br.topRight() - QPointF(label.h/16, label.h / 3)
if location_description == 'left-of-path-centered':
return path_br.topLeft() - QPointF(label.w, label.h / 6)
elif location_description == 'below-path-left-aligned':
return path_br.bottomLeft() - QPointF(0, label.h / 6)
elif location_description == 'below-path-right-aligned':
return path_br.bottomRight() - QPointF(label.w, label.h / 6)
def pp_tight_and_right(
label: Label
) -> QPointF:
'''Place *just* right of the pp label.
'''
txt = label.txt
return label.txt.pos() + QPointF(label.w - label.h/3, 0)

View File

@ -18,17 +18,21 @@
Annotations for ur faces.
"""
import PyQt5
from PyQt5 import QtCore, QtGui
from typing import Callable, Optional
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF, QRectF
from PyQt5.QtWidgets import QGraphicsPathItem
from pyqtgraph import Point, functions as fn, Color
import numpy as np
from ._anchors import marker_right_points
def mk_marker_path(
style: str,
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).
@ -37,7 +41,7 @@ def mk_marker(
style String indicating the style of marker to add:
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
``'>|<'``, ``'^'``, ``'v'``, ``'o'``
size Size of the marker in pixels. Default is 10.0.
size Size of the marker in pixels.
"""
path = QtGui.QPainterPath()
@ -81,13 +85,147 @@ def mk_marker(
# self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
if use_qgpath:
path = QGraphicsPathItem(path)
path.scale(size, size)
return path
class LevelMarker(QGraphicsPathItem):
'''An arrow marker path graphich which redraws itself
to the specified view coordinate level on each paint cycle.
'''
def __init__(
self,
chart: 'ChartPlotWidget', # noqa
style: str,
get_level: Callable[..., float],
size: float = 20,
keep_in_view: bool = True,
on_paint: Optional[Callable] = None,
) -> None:
# get polygon and scale
super().__init__()
self.scale(size, size)
# interally generates path
self._style = None
self.style = style
self.chart = chart
self.get_level = get_level
self._on_paint = on_paint
self.scene_x = lambda: marker_right_points(chart)[1]
self.level: float = 0
self.keep_in_view = keep_in_view
@property
def style(self) -> str:
return self._style
@style.setter
def style(self, value: str) -> None:
if self._style != value:
polygon = mk_marker_path(value)
self.setPath(polygon)
self._style = value
def path_br(self) -> QRectF:
'''Return the bounding rect for the opaque path part
of this item.
'''
return self.mapToScene(
self.path()
).boundingRect()
def delete(self) -> None:
self.scene().removeItem(self)
@property
def h(self) -> float:
return self.path_br().height()
@property
def w(self) -> float:
return self.path_br().width()
def position_in_view(
self,
# level: float,
) -> None:
'''Show a pp off-screen indicator for a level label.
This is like in fps games where you have a gps "nav" indicator
but your teammate is outside the range of view, except in 2D, on
the y-dimension.
'''
level = self.get_level()
view = self.chart.getViewBox()
vr = view.state['viewRange']
ymn, ymx = vr[1]
# _, marker_right, _ = marker_right_points(line._chart)
x = self.scene_x()
if level > ymx: # pin to top of view
self.setPos(
QPointF(
x,
self.h/3,
)
)
elif level < ymn: # pin to bottom of view
self.setPos(
QPointF(
x,
view.height() - 4/3*self.h,
)
)
else:
# pp line is viewable so show marker normally
self.setPos(
x,
self.chart.view.mapFromView(
QPointF(0, self.get_level())
).y()
)
def paint(
self,
p: QtGui.QPainter,
opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.QWidget
) -> None:
'''Core paint which we override to always update
our marker position in scene coordinates from a
view cooridnate "level".
'''
if self.keep_in_view:
self.position_in_view()
else: # just place at desired level even if not in view
self.setPos(
self.scene_x(),
self.mapToScene(QPointF(0, self.get_level())).y()
)
super().paint(p, opt, w)
if self._on_paint:
self._on_paint(self)
def qgo_draw_markers(
markers: list,

View File

@ -26,6 +26,11 @@ from functools import partial
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt
from PyQt5.QtCore import QEvent
from PyQt5.QtWidgets import (
QFrame,
QWidget,
# QSizePolicy,
)
import numpy as np
import pyqtgraph as pg
import tractor
@ -40,13 +45,13 @@ from ._axes import (
PriceAxis,
YAxisLabel,
)
from ._graphics._cursor import (
from ._cursor import (
Cursor,
ContentsLabel,
)
from ._l1 import L1Labels
from ._graphics._ohlc import BarItems
from ._graphics._curve import FastAppendCurve
from ._ohlc import BarItems
from ._curve import FastAppendCurve
from ._style import (
hcolor,
CHART_MARGINS,
@ -65,15 +70,20 @@ from .. import data
from ..log import get_logger
from ._exec import run_qtractor
from ._interaction import ChartView
from .order_mode import start_order_mode
from .order_mode import run_order_mode
from .. import fsp
from ..data import feed
from ._forms import (
FieldsForm,
open_form,
mk_order_pane_layout,
)
log = get_logger(__name__)
class GodWidget(QtWidgets.QWidget):
class GodWidget(QWidget):
'''
"Our lord and savior, the holy child of window-shua, there is no
widget above thee." - 6|6
@ -94,11 +104,13 @@ class GodWidget(QtWidgets.QWidget):
self.hbox = QtWidgets.QHBoxLayout(self)
self.hbox.setContentsMargins(0, 0, 0, 0)
self.hbox.setSpacing(2)
self.hbox.setSpacing(6)
self.hbox.setAlignment(Qt.AlignTop)
self.vbox = QtWidgets.QVBoxLayout()
self.vbox.setContentsMargins(0, 0, 0, 0)
self.vbox.setSpacing(2)
self.vbox.setAlignment(Qt.AlignTop)
self.hbox.addLayout(self.vbox)
@ -181,6 +193,7 @@ class GodWidget(QtWidgets.QWidget):
order_mode_started = trio.Event()
if not self.vbox.isEmpty():
# XXX: this is CRITICAL especially with pixel buffer caching
self.linkedsplits.hide()
@ -211,15 +224,26 @@ class GodWidget(QtWidgets.QWidget):
# symbol is already loaded and ems ready
order_mode_started.set()
self.vbox.addWidget(linkedsplits)
# TODO: we'll probably want per-instrument/provider state here?
# change the order config form over to the new chart
# XXX: since the pp config is a singleton widget we have to
# also switch it over to the new chart's interal-layout
self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_config)
linkedsplits.chart.qframe.hbox.addWidget(
self.pp_config,
alignment=Qt.AlignTop
)
# chart is already in memory so just focus it
if self.linkedsplits:
self.linkedsplits.unfocus()
self.vbox.addWidget(linkedsplits)
# self.vbox.addWidget(linkedsplits)
linkedsplits.show()
linkedsplits.focus()
self.linkedsplits = linkedsplits
symbol = linkedsplits.symbol
@ -232,8 +256,17 @@ class GodWidget(QtWidgets.QWidget):
return order_mode_started
def focus(self) -> None:
'''Focus the top level widget which in turn focusses the chart
ala "view mode".
class LinkedSplits(QtWidgets.QWidget):
'''
# go back to view-mode focus (aka chart focus)
self.clearFocus()
self.linkedsplits.chart.setFocus()
class LinkedSplits(QWidget):
'''
Widget that holds a central chart plus derived
subcharts computed from the original data set apart
@ -287,7 +320,6 @@ class LinkedSplits(QtWidgets.QWidget):
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.splitter)
# state tracker?
self._symbol: Symbol = None
@property
@ -296,14 +328,18 @@ class LinkedSplits(QtWidgets.QWidget):
def set_split_sizes(
self,
prop: float = 0.28 # proportion allocated to consumer subcharts
prop: float = 0.625 # proportion allocated to consumer subcharts
) -> None:
"""Set the proportion of space allocated for linked subcharts.
"""
'''Set the proportion of space allocated for linked subcharts.
'''
major = 1 - prop
min_h_ind = int((self.height() * prop) / len(self.subplots))
sizes = [int(self.height() * major)]
sizes.extend([min_h_ind] * len(self.subplots))
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def focus(self) -> None:
@ -316,9 +352,12 @@ class LinkedSplits(QtWidgets.QWidget):
def plot_ohlc_main(
self,
symbol: Symbol,
array: np.ndarray,
style: str = 'bar',
) -> 'ChartPlotWidget':
"""Start up and show main (price) chart and all linked subcharts.
@ -329,12 +368,16 @@ class LinkedSplits(QtWidgets.QWidget):
linkedsplits=self,
digits=symbol.digits(),
)
self.chart = self.add_plot(
name=symbol.key,
array=array,
# xaxis=self.xaxis,
style=style,
_is_main=True,
sidepane=self.godwidget.pp_config,
)
# add crosshair graphic
self.chart.addItem(self.cursor)
@ -344,23 +387,34 @@ class LinkedSplits(QtWidgets.QWidget):
self.chart.hideAxis('bottom')
# style?
self.chart.setFrameStyle(QtWidgets.QFrame.StyledPanel | QtWidgets.QFrame.Plain)
self.chart.setFrameStyle(
QFrame.StyledPanel |
QFrame.Plain
)
return self.chart
def add_plot(
self,
name: str,
array: np.ndarray,
xaxis: DynamicDateAxis = None,
array_key: Optional[str] = None,
# xaxis: Optional[DynamicDateAxis] = None,
style: str = 'line',
_is_main: bool = False,
sidepane: Optional[QWidget] = None,
**cpw_kwargs,
) -> 'ChartPlotWidget':
"""Add (sub)plots to chart widget by name.
'''Add (sub)plots to chart widget by name.
If ``name`` == ``"main"`` the chart will be the the primary view.
"""
'''
if self.chart is None and not _is_main:
raise RuntimeError(
"A main plot must be created first with `.plot_ohlc_main()`")
@ -370,20 +424,58 @@ class LinkedSplits(QtWidgets.QWidget):
cv.linkedsplits = self
# use "indicator axis" by default
if xaxis is None:
xaxis = DynamicDateAxis(
orientation='bottom',
linkedsplits=self
)
# TODO: we gotta possibly assign this back
# to the last subplot on removal of some last subplot
xaxis = DynamicDateAxis(
orientation='bottom',
linkedsplits=self
)
if self.xaxis:
self.xaxis.hide()
self.xaxis = xaxis
# TODO: probably should formalize and call this something else?
class LambdaQFrame(QFrame):
'''One-off ``QFrame`` composite which pairs a chart + sidepane
``FieldsForm`` (if provided).
See composite widgets docs for deats:
https://doc.qt.io/qt-5/qwidget.html#composite-widgets
'''
sidepane: FieldsForm
hbox: QtGui.QHBoxLayout
chart: Optional['ChartPlotWidget'] = None
def __init__(
self,
parent=None,
) -> None:
super().__init__(parent)
self.sidepane = sidepane
hbox = self.hbox = QtGui.QHBoxLayout(self)
hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft)
hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(3)
qframe = LambdaQFrame(self.splitter)
cpw = ChartPlotWidget(
# this name will be used to register the primary
# graphics curve managed by the subchart
name=name,
data_key=array_key or name,
array=array,
parent=self.splitter,
parent=qframe,
linkedsplits=self,
axisItems={
'bottom': xaxis,
@ -391,10 +483,23 @@ class LinkedSplits(QtWidgets.QWidget):
'left': PriceAxis(linkedsplits=self, orientation='left'),
},
viewBox=cv,
# cursor=self.cursor,
**cpw_kwargs,
)
print(f'xaxis ps: {xaxis.pos()}')
qframe.chart = cpw
qframe.hbox.addWidget(cpw)
# so we can look this up and add back to the splitter
# on a symbol switch
cpw.qframe = qframe
# add sidepane **after** chart; place it on axis side
if sidepane:
qframe.hbox.addWidget(
sidepane,
alignment=Qt.AlignTop
)
cpw.sidepane = sidepane
# give viewbox as reference to chart
# allowing for kb controls and interactions on **this** widget
@ -402,8 +507,12 @@ class LinkedSplits(QtWidgets.QWidget):
cv.chart = cpw
cpw.plotItem.vb.linkedsplits = self
cpw.setFrameStyle(QtWidgets.QFrame.StyledPanel) # | QtWidgets.QFrame.Plain)
cpw.setFrameStyle(
QtWidgets.QFrame.StyledPanel
# | QtWidgets.QFrame.Plain)
)
cpw.hideButtons()
# XXX: gives us outline on backside of y-axis
cpw.getPlotItem().setContentsMargins(*CHART_MARGINS)
@ -415,10 +524,10 @@ class LinkedSplits(QtWidgets.QWidget):
# draw curve graphics
if style == 'bar':
cpw.draw_ohlc(name, array)
cpw.draw_ohlc(name, array, array_key=array_key)
elif style == 'line':
cpw.draw_curve(name, array)
cpw.draw_curve(name, array, array_key=array_key)
else:
raise ValueError(f"Chart style {style} is currently unsupported")
@ -427,11 +536,15 @@ class LinkedSplits(QtWidgets.QWidget):
# track by name
self.subplots[name] = cpw
if sidepane:
# TODO: use a "panes" collection to manage this?
sidepane.setMinimumWidth(self.chart.sidepane.width())
self.splitter.addWidget(qframe)
# scale split regions
self.set_split_sizes()
# XXX: we need this right?
# self.splitter.addWidget(cpw)
else:
assert style == 'bar', 'main chart must be OHLC'
@ -457,23 +570,24 @@ class ChartPlotWidget(pg.PlotWidget):
_l1_labels: L1Labels = None
mode_name: str = 'mode: view'
mode_name: str = 'view'
# TODO: can take a ``background`` color setting - maybe there's
# a better one?
def __init__(
self,
# the data view we generate graphics from
# the "data view" we generate graphics from
name: str,
array: np.ndarray,
data_key: str,
linkedsplits: LinkedSplits,
view_color: str = 'papas_special',
pen_color: str = 'bracket',
static_yrange: Optional[Tuple[float, float]] = None,
cursor: Optional[Cursor] = None,
**kwargs,
):
@ -491,7 +605,7 @@ class ChartPlotWidget(pg.PlotWidget):
**kwargs
)
self.name = name
self._lc = linkedsplits
self.data_key = data_key
self.linked = linkedsplits
# scene-local placeholder for book graphics
@ -535,8 +649,11 @@ class ChartPlotWidget(pg.PlotWidget):
# for when the splitter(s) are resized
self._vb.sigResized.connect(self._set_yrange)
@property
def view(self) -> ChartView:
return self._vb
def focus(self) -> None:
# self.setFocus()
self._vb.setFocus()
def last_bar_in_view(self) -> int:
@ -570,8 +687,6 @@ class ChartPlotWidget(pg.PlotWidget):
a = self._arrays['ohlc']
lbar = max(l, a[0]['index'])
rbar = min(r, a[-1]['index'])
# lbar = max(l, 0)
# rbar = min(r, len(self._arrays['ohlc']))
return l, lbar, rbar, r
def default_view(
@ -615,8 +730,12 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_ohlc(
self,
name: str,
data: np.ndarray,
array_key: Optional[str] = None,
) -> pg.GraphicsObject:
"""
Draw OHLC datums to chart.
@ -634,7 +753,8 @@ class ChartPlotWidget(pg.PlotWidget):
# draw after to allow self.scene() to work...
graphics.draw_from_data(data)
self._graphics[name] = graphics
data_key = array_key or name
self._graphics[data_key] = graphics
self.linked.cursor.contents_labels.add_label(
self,
@ -649,12 +769,17 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_curve(
self,
name: str,
data: np.ndarray,
array_key: Optional[str] = None,
overlay: bool = False,
color: str = 'default_light',
add_label: bool = True,
**pdi_kwargs,
) -> pg.PlotDataItem:
"""Draw a "curve" (line plot graphics) for the provided data in
the input array ``data``.
@ -665,10 +790,12 @@ class ChartPlotWidget(pg.PlotWidget):
}
pdi_kwargs.update(_pdi_defaults)
data_key = array_key or name
# curve = pg.PlotDataItem(
# curve = pg.PlotCurveItem(
curve = FastAppendCurve(
y=data[name],
y=data[data_key],
x=data['index'],
# antialias=True,
name=name,
@ -700,7 +827,7 @@ class ChartPlotWidget(pg.PlotWidget):
# register curve graphics and backing array for name
self._graphics[name] = curve
self._arrays[name] = data
self._arrays[data_key or name] = data
if overlay:
anchor_at = ('bottom', 'left')
@ -719,7 +846,7 @@ class ChartPlotWidget(pg.PlotWidget):
if add_label:
self.linked.cursor.contents_labels.add_label(
self,
name,
data_key or name,
anchor_at=anchor_at
)
@ -727,13 +854,15 @@ class ChartPlotWidget(pg.PlotWidget):
def _add_sticky(
self,
name: str,
bg_color='bracket',
) -> YAxisLabel:
# if the sticky is for our symbol
# use the tick size precision for display
sym = self._lc.symbol
sym = self.linked.symbol
if name == sym.key:
digits = sym.digits()
else:
@ -766,18 +895,23 @@ class ChartPlotWidget(pg.PlotWidget):
def update_curve_from_array(
self,
name: str,
array: np.ndarray,
array_key: Optional[str] = None,
**kwargs,
) -> pg.GraphicsObject:
"""Update the named internal graphics from ``array``.
"""
data_key = array_key or name
if name not in self._overlays:
self._arrays['ohlc'] = array
else:
self._arrays[name] = array
self._arrays[data_key] = array
curve = self._graphics[name]
@ -787,7 +921,11 @@ class ChartPlotWidget(pg.PlotWidget):
# one place to dig around this might be the `QBackingStore`
# https://doc.qt.io/qt-5/qbackingstore.html
# curve.setData(y=array[name], x=array['index'], **kwargs)
curve.update_from_array(x=array['index'], y=array[name], **kwargs)
curve.update_from_array(
x=array['index'],
y=array[data_key],
**kwargs
)
return curve
@ -983,7 +1121,7 @@ async def chart_from_quotes(
last, volume = ohlcv.array[-1][['close', 'volume']]
symbol = chart._lc.symbol
symbol = chart.linked.symbol
l1 = L1Labels(
chart,
@ -1001,7 +1139,7 @@ async def chart_from_quotes(
# levels this might be dark volume we need to
# present differently?
tick_size = chart._lc.symbol.tick_size
tick_size = chart.linked.symbol.tick_size
tick_margin = 2 * tick_size
last_ask = last_bid = last_clear = time.time()
@ -1010,7 +1148,7 @@ async def chart_from_quotes(
async for quotes in stream:
# chart isn't actively shown so just skip render cycle
if chart._lc.isHidden():
if chart.linked.isHidden():
continue
for sym, quote in quotes.items():
@ -1058,8 +1196,7 @@ 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)
# l1 book events
# throttle the book graphics updates at a lower rate
@ -1164,9 +1301,9 @@ async def spawn_fsps(
# Currently we spawn an actor per fsp chain but
# likely we'll want to pool them eventually to
# scale horizonatlly once cores are used up.
for fsp_func_name, conf in fsps.items():
for display_name, conf in fsps.items():
display_name = f'fsp.{fsp_func_name}'
fsp_func_name = conf['fsp_func_name']
# TODO: load function here and introspect
# return stream type(s)
@ -1174,7 +1311,7 @@ async def spawn_fsps(
# TODO: should `index` be a required internal field?
fsp_dtype = np.dtype([('index', int), (fsp_func_name, float)])
key = f'{sym}.' + display_name
key = f'{sym}.fsp.' + display_name
# this is all sync currently
shm, opened = maybe_open_shm_array(
@ -1192,7 +1329,7 @@ async def spawn_fsps(
portal = await n.start_actor(
enable_modules=['piker.fsp'],
name=display_name,
name='fsp.' + display_name,
)
# init async
@ -1231,23 +1368,45 @@ async def run_fsp(
config map.
"""
done = linkedsplits.window().status_bar.open_status(
f'loading {display_name}..',
f'loading fsp, {display_name}..',
group_key=group_status_key,
)
async with portal.open_stream_from(
async with (
portal.open_stream_from(
# subactor entrypoint
fsp.cascade,
# subactor entrypoint
fsp.cascade,
# name as title of sub-chart
brokername=brokermod.name,
src_shm_token=src_shm.token,
dst_shm_token=conf['shm'].token,
symbol=sym,
fsp_func_name=fsp_func_name,
# name as title of sub-chart
brokername=brokermod.name,
src_shm_token=src_shm.token,
dst_shm_token=conf['shm'].token,
symbol=sym,
fsp_func_name=fsp_func_name,
) as stream:
) as stream,
open_form(
godwidget=linkedsplits.godwidget,
parent=linkedsplits.godwidget,
fields_schema={
'name': {
'label': '**fsp**:',
'type': 'select',
'default_value': [
f'{display_name}'
],
},
'period': {
'label': '**period**:',
'type': 'edit',
'default_value': 14,
},
},
) as sidepane,
):
# receive last index for processed historical
# data-array as first msg
@ -1267,9 +1426,12 @@ async def run_fsp(
else:
chart = linkedsplits.add_plot(
name=fsp_func_name,
name=display_name,
array=shm.array,
array_key=conf['fsp_func_name'],
sidepane=sidepane,
# curve by default
ohlc=False,
@ -1278,12 +1440,6 @@ async def run_fsp(
# static_yrange=(0, 100),
)
# display contents labels asap
chart.linked.cursor.contents_labels.update_labels(
len(shm.array) - 1,
# fsp_func_name
)
# XXX: ONLY for sub-chart fsps, overlays have their
# data looked up from the chart's internal array set.
# TODO: we must get a data view api going STAT!!
@ -1297,14 +1453,23 @@ async def run_fsp(
# read from last calculated value
array = shm.array
# XXX: fsp func names are unique meaning we don't have
# duplicates of the underlying data even if multiple
# sub-charts reference it under different 'named charts'.
value = array[fsp_func_name][-1]
last_val_sticky.update_from_data(-1, value)
chart._lc.focus()
chart.linked.focus()
# works also for overlays in which case data is looked up from
# internal chart array set....
chart.update_curve_from_array(fsp_func_name, shm.array)
chart.update_curve_from_array(
display_name,
shm.array,
array_key=fsp_func_name
)
# TODO: figure out if we can roll our own `FillToThreshold` to
# get brush filled polygons for OS/OB conditions.
@ -1317,7 +1482,7 @@ async def run_fsp(
# graphics.curve.setFillLevel(50)
if fsp_func_name == 'rsi':
from ._graphics._lines import level_line
from ._lines import level_line
# add moveable over-[sold/bought] lines
# and labels only for the 70/30 lines
level_line(chart, 20)
@ -1335,7 +1500,7 @@ async def run_fsp(
async for value in stream:
# chart isn't actively shown so just skip render cycle
if chart._lc.isHidden():
if chart.linked.isHidden():
continue
now = time.time()
@ -1368,7 +1533,11 @@ async def run_fsp(
last_val_sticky.update_from_data(-1, value)
# update graphics
chart.update_curve_from_array(fsp_func_name, array)
chart.update_curve_from_array(
display_name,
array,
array_key=fsp_func_name,
)
# set time of last graphics update
last = now
@ -1423,7 +1592,11 @@ async def check_for_new_bars(feed, ohlcv, linkedsplits):
)
for name, chart in linkedsplits.subplots.items():
chart.update_curve_from_array(chart.name, chart._shm.array)
chart.update_curve_from_array(
chart.name,
chart._shm.array,
array_key=chart.data_key
)
# shift the view if in follow mode
price_chart.increment_view()
@ -1462,8 +1635,7 @@ async def display_symbol_data(
# )
async with(
data.open_feed(
data.feed.open_feed(
provider,
[sym],
loglevel=loglevel,
@ -1472,8 +1644,21 @@ async def display_symbol_data(
tick_throttle=_clear_throttle_rate,
) as feed,
trio.open_nursery() as n,
):
async def print_quotes():
async with feed.stream.subscribe() as bstream:
last_tick = time.time()
async for quotes in bstream:
now = time.time()
period = now - last_tick
for sym, quote in quotes.items():
ticks = quote.get('ticks', ())
if ticks:
print(f'{1/period} Hz')
last_tick = time.time()
n.start_soon(print_quotes)
ohlcv: ShmArray = feed.shm
bars = ohlcv.array
@ -1513,6 +1698,14 @@ async def display_symbol_data(
# TODO: eventually we'll support some kind of n-compose syntax
fsp_conf = {
'rsi': {
'fsp_func_name': 'rsi',
'period': 14,
'chart_kwargs': {
'static_yrange': (0, 100),
},
},
'rsi2': {
'fsp_func_name': 'rsi',
'period': 14,
'chart_kwargs': {
'static_yrange': (0, 100),
@ -1535,6 +1728,7 @@ async def display_symbol_data(
else:
fsp_conf.update({
'vwap': {
'fsp_func_name': 'vwap',
'overlay': True,
'anchor': 'session',
},
@ -1574,7 +1768,12 @@ async def display_symbol_data(
linkedsplits
)
await start_order_mode(chart, symbol, provider, order_mode_started)
await run_order_mode(
chart,
symbol,
provider,
order_mode_started
)
async def load_provider_search(
@ -1640,7 +1839,56 @@ async def _async_main(
sbar = godwidget.window.status_bar
starting_done = sbar.open_status('starting ze sexy chartz')
async with trio.open_nursery() as root_n:
# generate order mode side-pane UI
async with (
trio.open_nursery() as root_n,
# fields form to configure order entry
open_form(
godwidget=godwidget,
parent=godwidget,
fields_schema={
'account': {
'type': 'select',
'default_value': [
'paper',
# 'ib.margin',
# 'ib.paper',
],
},
'size_unit': {
'label': '**allocate**:',
'type': 'select',
'default_value': [
'$ size',
'% of port',
'# shares'
],
},
'disti_weight': {
'label': '**weight**:',
'type': 'select',
'default_value': ['uniform'],
},
'size': {
'label': '**size**:',
'type': 'edit',
'default_value': 5000,
},
'slots': {
'type': 'edit',
'default_value': 4,
},
},
) as pp_config,
):
pp_config: FieldsForm
mk_order_pane_layout(pp_config)
pp_config.show()
# add as next-to-y-axis pane
godwidget.pp_config = pp_config
# set root nursery and task stack for spawning other charts/feeds
# that run cached in the bg
@ -1687,13 +1935,13 @@ async def _async_main(
# start handling search bar kb inputs
async with (
_event.open_handler(
search.bar,
_event.open_handlers(
[search.bar],
event_types={QEvent.KeyPress},
async_handler=_search.handle_keyboard_input,
# let key repeats pass through for search
filter_auto_repeats=False,
)
),
):
# remove startup status text
starting_done()

View File

@ -27,13 +27,13 @@ import pyqtgraph as pg
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF, QRectF
from .._style import (
from ._style import (
_xaxis_at,
hcolor,
_font_small,
)
from .._axes import YAxisLabel, XAxisLabel
from ...log import get_logger
from ._axes import YAxisLabel, XAxisLabel
from ..log import get_logger
log = get_logger(__name__)
@ -42,7 +42,7 @@ log = get_logger(__name__)
# latency (in terms of perceived lag in cross hair) so really be sure
# there's an improvement if you want to change it!
_mouse_rate_limit = 60 # TODO; should we calc current screen refresh rate?
_debounce_delay = 1 / 2e3
_debounce_delay = 1 / 1e3
_ch_label_opac = 1
@ -52,12 +52,15 @@ class LineDot(pg.CurvePoint):
def __init__(
self,
curve: pg.PlotCurveItem,
index: int,
plot: 'ChartPlotWidget', # type: ingore # noqa
pos=None,
size: int = 6, # in pxs
color: str = 'default_light',
) -> None:
pg.CurvePoint.__init__(
self,
@ -88,7 +91,9 @@ class LineDot(pg.CurvePoint):
def event(
self,
ev: QtCore.QEvent,
) -> None:
if not isinstance(
ev, QtCore.QDynamicPropertyChangeEvent
@ -132,8 +137,8 @@ class ContentsLabel(pg.LabelItem):
}
def __init__(
self,
# chart: 'ChartPlotWidget', # noqa
view: pg.ViewBox,
@ -167,8 +172,8 @@ class ContentsLabel(pg.LabelItem):
self.anchor(itemPos=index, parentPos=index, offset=margins)
def update_from_ohlc(
self,
name: str,
index: int,
array: np.ndarray,
@ -194,8 +199,8 @@ class ContentsLabel(pg.LabelItem):
)
def update_from_value(
self,
name: str,
index: int,
array: np.ndarray,
@ -239,6 +244,7 @@ class ContentsLabels:
if not (index >= 0 and index < chart._arrays['ohlc'][-1]['index']):
# out of range
print('out of range?')
continue
array = chart._arrays[name]
@ -272,13 +278,15 @@ class ContentsLabels:
self._labels.append(
(chart, name, label, partial(update_func, label, name))
)
# label.hide()
label.hide()
return label
class Cursor(pg.GraphicsObject):
'''Multi-plot cursor for use on a ``LinkedSplits`` chart (set).
'''
def __init__(
self,

View File

@ -23,7 +23,7 @@ from typing import Tuple
import pyqtgraph as pg
from PyQt5 import QtCore, QtGui, QtWidgets
from ..._profile import pg_profile_enabled
from .._profile import pg_profile_enabled
# TODO: got a feeling that dropping this inheritance gets us even more speedups

View File

@ -28,7 +28,7 @@ from PyQt5.QtCore import QPointF
import numpy as np
from ._style import hcolor, _font
from ._graphics._lines import order_line, LevelLine
from ._lines import order_line, LevelLine
from ..log import get_logger
@ -105,11 +105,16 @@ class LineEditor:
# fields settings
size: Optional[int] = None,
) -> LevelLine:
"""Stage a line at the current chart's cursor position
and return it.
"""
if self.chart is None:
log.error('No chart interaction yet available')
return None
# chart.setCursor(QtCore.Qt.PointingHandCursor)
cursor = self.chart.linked.cursor
if not cursor:
@ -118,7 +123,7 @@ class LineEditor:
chart = cursor.active_plot
y = cursor._datum_xy[1]
symbol = chart._lc.symbol
symbol = chart.linked.symbol
# add a "staged" cursor-tracking line to view
# and cash it in a a var
@ -128,10 +133,14 @@ class LineEditor:
line = order_line(
chart,
# TODO: convert these values into human-readable form
# (i.e. with k, m, M, B) type embedded suffixes
level=y,
level_digits=symbol.digits(),
size=size,
size_digits=symbol.lot_digits(),
# TODO: we need truncation checks in the EMS for this?
# size_digits=min(symbol.lot_digits(), 3),
# just for the stage line to avoid
# flickering while moving the cursor
@ -194,7 +203,7 @@ class LineEditor:
if not line:
raise RuntimeError("No line is currently staged!?")
sym = chart._lc.symbol
sym = chart.linked.symbol
line = order_line(
chart,
@ -204,7 +213,8 @@ class LineEditor:
level_digits=sym.digits(),
size=size,
size_digits=sym.lot_digits(),
# TODO: we need truncation checks in the EMS for this?
# size_digits=sym.lot_digits(),
# LevelLine kwargs
color=line.color,
@ -237,7 +247,6 @@ class LineEditor:
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
@ -260,18 +269,16 @@ class LineEditor:
self,
line: LevelLine = None,
uuid: str = None,
) -> LevelLine:
"""Remove a line by refernce or uuid.
) -> Optional[LevelLine]:
'''Remove a line by refernce or uuid.
If no lines or ids are provided remove all lines under the
cursor position.
"""
if line:
uuid = line.oid
'''
# try to look up line from our registry
line = self._order_lines.pop(uuid, None)
line = self._order_lines.pop(uuid, line)
if line:
# if hovered remove from cursor set
@ -284,8 +291,13 @@ class LineEditor:
# just because we never got a un-hover event
cursor.show_xhair()
log.debug(f'deleting {line} with oid: {uuid}')
line.delete()
return line
else:
log.warning(f'Could not find line for {line}')
return line
class SelectRect(QtGui.QGraphicsRectItem):

View File

@ -18,13 +18,42 @@
Qt event proxying and processing using ``trio`` mem chans.
"""
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, AsyncExitStack
from typing import Callable
from PyQt5 import QtCore
from PyQt5.QtCore import QEvent
from PyQt5.QtWidgets import QWidget
import trio
from pydantic import BaseModel
# TODO: maybe consider some constrained ints down the road?
# https://pydantic-docs.helpmanual.io/usage/types/#constrained-types
class KeyboardMsg(BaseModel):
'''Unpacked Qt keyboard event data.
'''
event: QEvent
etype: int
key: int
mods: int
txt: str
class Config:
arbitrary_types_allowed = True
def to_tuple(self) -> tuple:
return tuple(self.dict().values())
# TODO: maybe add some methods to detect key combos? Or is that gonna be
# better with pattern matching?
# # ctl + alt as combo
# ctlalt = False
# if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods:
# ctlalt = True
class EventRelay(QtCore.QObject):
@ -67,22 +96,26 @@ class EventRelay(QtCore.QObject):
if etype in {QEvent.KeyPress, QEvent.KeyRelease}:
msg = KeyboardMsg(
event=ev,
etype=ev.type(),
key=ev.key(),
mods=ev.modifiers(),
txt=ev.text(),
)
# TODO: is there a global setting for this?
if ev.isAutoRepeat() and self._filter_auto_repeats:
ev.ignore()
return True
key = ev.key()
mods = ev.modifiers()
txt = ev.text()
# NOTE: the event object instance coming out
# the other side is mutated since Qt resumes event
# processing **before** running a ``trio`` guest mode
# tick, thus special handling or copying must be done.
# send elements to async handler
self._send_chan.send_nowait((ev, etype, key, mods, txt))
# send keyboard msg to async handler
self._send_chan.send_nowait(msg)
else:
# send event to async handler
@ -124,9 +157,9 @@ async def open_event_stream(
@asynccontextmanager
async def open_handler(
async def open_handlers(
source_widget: QWidget,
source_widgets: list[QWidget],
event_types: set[QEvent],
async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None],
**kwargs,
@ -135,7 +168,13 @@ async def open_handler(
async with (
trio.open_nursery() as n,
open_event_stream(source_widget, event_types, **kwargs) as event_recv_stream,
AsyncExitStack() as stack,
):
n.start_soon(async_handler, source_widget, event_recv_stream)
for widget in source_widgets:
event_recv_stream = await stack.enter_async_context(
open_event_stream(widget, event_types, **kwargs)
)
n.start_soon(async_handler, widget, event_recv_stream)
yield

View File

@ -99,6 +99,9 @@ def run_qtractor(
# "This is substantially faster than using a signal... for some
# reason Qt signal dispatch is really slow (and relies on events
# underneath anyway, so this is strictly less work)."
# source gist and credit to njs:
# https://gist.github.com/njsmith/d996e80b700a339e0623f97f48bcf0cb
REENTER_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType())
class ReenterEvent(QtCore.QEvent):

621
piker/ui/_forms.py 100644
View File

@ -0,0 +1,621 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
# 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/>.
'''
Text entry "forms" widgets (mostly for configuration and UI user input).
'''
from __future__ import annotations
from contextlib import asynccontextmanager
from functools import partial
from textwrap import dedent
import math
from typing import Optional
import trio
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QSize, QModelIndex, Qt, QEvent
from PyQt5.QtWidgets import (
QWidget,
QLabel,
QComboBox,
QLineEdit,
QHBoxLayout,
QVBoxLayout,
QFormLayout,
QProgressBar,
QSizePolicy,
QStyledItemDelegate,
QStyleOptionViewItem,
)
from ._event import open_handlers
from ._style import hcolor, _font, _font_small, DpiAwareFont
class FontAndChartAwareLineEdit(QLineEdit):
def __init__(
self,
parent: QWidget,
# parent_chart: QWidget, # noqa
font: DpiAwareFont = _font,
width_in_chars: int = None,
) -> None:
# self.setContextMenuPolicy(Qt.CustomContextMenu)
# self.customContextMenuRequested.connect(self.show_menu)
# self.setStyleSheet(f"font: 18px")
self.dpi_font = font
# self.godwidget = parent_chart
if width_in_chars:
self._chars = int(width_in_chars)
else:
# chart count which will be used to calculate
# width of input field.
self._chars: int = 9
super().__init__(parent)
# size it as we specify
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
self.setSizePolicy(
QSizePolicy.Expanding,
QSizePolicy.Fixed,
)
self.setFont(font.font)
# witty bit of margin
self.setTextMargins(2, 2, 2, 2)
def sizeHint(self) -> QSize:
"""
Scale edit box to size of dpi aware font.
"""
psh = super().sizeHint()
dpi_font = self.dpi_font
psh.setHeight(dpi_font.px_size + 2)
# space for ``._chars: int``
char_w_pxs = dpi_font.boundingRect(self.text()).width()
chars_w = char_w_pxs + 6 # * dpi_font.scale() * self._chars
psh.setWidth(chars_w)
return psh
def set_width_in_chars(
self,
chars: int,
) -> None:
self._chars = chars
self.sizeHint()
self.update()
def focus(self) -> None:
self.selectAll()
self.show()
self.setFocus()
class FontScaledDelegate(QStyledItemDelegate):
'''
Super simple view delegate to render text in the same
font size as the search widget.
'''
def __init__(
self,
parent=None,
font: DpiAwareFont = _font,
) -> None:
super().__init__(parent)
self.dpi_font = font
def sizeHint(
self,
option: QStyleOptionViewItem,
index: QModelIndex,
) -> QSize:
# value = index.data()
# br = self.dpi_font.boundingRect(value)
# w, h = br.width(), br.height()
parent = self.parent()
if getattr(parent, '_max_item_size', None):
return QSize(*self.parent()._max_item_size)
else:
return super().sizeHint(option, index)
# slew of resources which helped get this where it is:
# https://stackoverflow.com/questions/20648210/qcombobox-adjusttocontents-changing-height
# https://stackoverflow.com/questions/3151798/how-do-i-set-the-qcombobox-width-to-fit-the-largest-item
# https://stackoverflow.com/questions/6337589/qlistwidget-adjust-size-to-content#6370892
# https://stackoverflow.com/questions/25304267/qt-resize-of-qlistview
# https://stackoverflow.com/questions/28227406/how-to-set-qlistview-rows-height-permanently
class FieldsForm(QWidget):
godwidget: 'GodWidget' # noqa
vbox: QVBoxLayout
form: QFormLayout
def __init__(
self,
godwidget: 'GodWidget', # type: ignore # noqa
parent=None,
) -> None:
super().__init__(parent or godwidget)
self.godwidget = godwidget
# size it as we specify
self.setSizePolicy(
QSizePolicy.Expanding,
QSizePolicy.Expanding,
)
# XXX: not sure why we have to create this here exactly
# (instead of in the pane creation routine) but it's
# here and is managed by downstream layout routines.
# best guess is that you have to create layouts in order
# of hierarchy in order for things to display correctly?
# TODO: we may want to hand this *down* from some "pane manager"
# thing eventually?
self.vbox = QVBoxLayout(self)
self.vbox.setAlignment(Qt.AlignVCenter)
self.vbox.setContentsMargins(0, 4, 3, 6)
self.vbox.setSpacing(0)
# split layout for the (<label>: |<widget>|) parameters entry
self.form = QFormLayout(self)
self.form.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.form.setContentsMargins(0, 0, 0, 0)
self.form.setSpacing(3)
self.form.setHorizontalSpacing(0)
self.vbox.addLayout(self.form, stretch=1/3)
self.labels: dict[str, QLabel] = {}
self.fields: dict[str, QWidget] = {}
self._font_size = _font_small.px_size - 2
self._max_item_width: (float, float) = 0, 0
def add_field_label(
self,
name: str,
font_size: Optional[int] = None,
font_color: str = 'default_lightest',
) -> QtGui.QLabel:
# add label to left of search bar
self.label = label = QtGui.QLabel()
font_size = font_size or self._font_size - 2
label.setStyleSheet(
f"""QLabel {{
color : {hcolor(font_color)};
font-size : {font_size}px;
}}
"""
)
label.setFont(_font.font)
label.setTextFormat(Qt.MarkdownText) # markdown
label.setMargin(0)
label.setText(name)
label.setAlignment(
QtCore.Qt.AlignVCenter
| QtCore.Qt.AlignLeft
)
# for later lookup
self.labels[name] = label
return label
def add_edit_field(
self,
name: str,
value: str,
) -> FontAndChartAwareLineEdit:
# TODO: maybe a distint layout per "field" item?
label = self.add_field_label(name)
edit = FontAndChartAwareLineEdit(
parent=self,
)
edit.setStyleSheet(
f"""QLineEdit {{
color : {hcolor('gunmetal')};
font-size : {self._font_size}px;
}}
"""
)
edit.setText(str(value))
self.form.addRow(label, edit)
self.fields[name] = edit
return edit
def add_select_field(
self,
name: str,
values: list[str],
) -> QComboBox:
# TODO: maybe a distint layout per "field" item?
label = self.add_field_label(name)
select = QComboBox(self)
select._key = name
for i, value in enumerate(values):
select.insertItem(i, str(value))
select.setStyleSheet(
f"""QComboBox {{
color : {hcolor('gunmetal')};
font-size : {self._font_size}px;
}}
"""
)
select.setSizeAdjustPolicy(QComboBox.AdjustToContents)
select.setIconSize(QSize(0, 0))
self.setSizePolicy(
QSizePolicy.Fixed,
QSizePolicy.Fixed,
)
view = select.view()
view.setUniformItemSizes(True)
view.setItemDelegate(FontScaledDelegate(view))
# compute maximum item size so that the weird
# "style item delegate" thing can then specify
# that size on each item...
values.sort()
br = _font.boundingRect(str(values[-1]))
w, h = br.width(), br.height()
# TODO: something better then this monkey patch
view._max_item_size = w, h
# limit to 6 items?
view.setMaximumHeight(6*h)
# one entry in view
select.setMinimumHeight(h)
select.show()
self.form.addRow(label, select)
return select
async def handle_field_input(
widget: QWidget,
# last_widget: QWidget, # had focus prior
recv_chan: trio.abc.ReceiveChannel,
fields: FieldsForm,
allocator: Allocator, # noqa
) -> None:
async for kbmsg in recv_chan:
if kbmsg.etype in {QEvent.KeyPress, QEvent.KeyRelease}:
event, etype, key, mods, txt = kbmsg.to_tuple()
print(f'key: {kbmsg.key}, mods: {kbmsg.mods}, txt: {kbmsg.txt}')
# default controls set
ctl = False
if kbmsg.mods == Qt.ControlModifier:
ctl = True
if ctl and key in { # cancel and refocus
Qt.Key_C,
Qt.Key_Space, # i feel like this is the "native" one
Qt.Key_Alt,
}:
widget.clearFocus()
fields.godwidget.focus()
continue
# process field input
if key in (Qt.Key_Enter, Qt.Key_Return):
value = widget.text()
key = widget._key
setattr(allocator, key, value)
print(allocator.dict())
@asynccontextmanager
async def open_form(
godwidget: QWidget,
parent: QWidget,
fields_schema: dict,
# alloc: Allocator,
# orientation: str = 'horizontal',
) -> FieldsForm:
fields = FieldsForm(godwidget, parent=parent)
from ._position import mk_pp_alloc
alloc = mk_pp_alloc()
fields.model = alloc
for name, config in fields_schema.items():
wtype = config['type']
label = str(config.get('label', name))
# plain (line) edit field
if wtype == 'edit':
w = fields.add_edit_field(
label,
config['default_value']
)
# drop-down selection
elif wtype == 'select':
values = list(config['default_value'])
w = fields.add_select_field(
label,
values
)
def write_model(text: str):
print(f'{text}')
setattr(alloc, name, text)
w.currentTextChanged.connect(write_model)
w._key = name
async with open_handlers(
list(fields.fields.values()),
event_types={
QEvent.KeyPress,
},
async_handler=partial(
handle_field_input,
fields=fields,
allocator=alloc,
),
# block key repeats?
filter_auto_repeats=True,
):
yield fields
def mk_fill_status_bar(
fields: FieldsForm,
pane_vbox: QVBoxLayout,
bar_h: int = 250,
) -> (QHBoxLayout, QProgressBar):
w = fields.width()
# indent = 18
# bracket_val = 0.375 * 0.666 * w
# indent = bracket_val / (1 + 5/8)
# TODO: once things are sized to screen
label_font = DpiAwareFont()
label_font._set_qfont_px_size(_font.px_size - 6)
br = label_font.boundingRect(f'{3.32:.1f}% port')
w, h = br.width(), br.height()
bar_h = 8/3 * w
# PnL on lhs
bar_labels_lhs = QVBoxLayout(fields)
left_label = fields.add_field_label(
dedent(f"""
-{30}% PnL
"""),
font_size=_font.px_size - 6,
font_color='gunmetal',
)
bar_labels_lhs.addSpacing(5/8 * bar_h)
bar_labels_lhs.addWidget(
left_label,
alignment=Qt.AlignLeft | Qt.AlignTop,
)
hbox = QHBoxLayout(fields)
hbox.addLayout(bar_labels_lhs)
# hbox.addSpacing(indent) # push to right a bit
# config
# hbox.setSpacing(indent * 0.375)
hbox.setSpacing(0)
hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft)
hbox.setContentsMargins(0, 0, 0, 0)
# TODO: use percentage str formatter:
# https://docs.python.org/3/library/string.html#grammar-token-precision
top_label = fields.add_field_label(
dedent(f"""
{3.32:.1f}% port
"""),
font_size=_font.px_size - 6,
font_color='gunmetal',
)
bottom_label = fields.add_field_label(
dedent(f"""
{5e3/4/1e3:.2f}k $fill\n
"""),
font_size=_font.px_size - 6,
font_color='gunmetal',
)
bar = QProgressBar(fields)
hbox.addWidget(bar, alignment=Qt.AlignLeft | Qt.AlignTop)
bar_labels_rhs_vbox = QVBoxLayout(fields)
bar_labels_rhs_vbox.addWidget(
top_label,
alignment=Qt.AlignLeft | Qt.AlignTop
)
bar_labels_rhs_vbox.addWidget(
bottom_label,
alignment=Qt.AlignLeft | Qt.AlignBottom
)
hbox.addLayout(bar_labels_rhs_vbox)
# compute "chunk" sizes for fill-status-bar based on some static height
slots = 4
border_size_px = 2
slot_margin_px = 2
# TODO: compute "used height" thus far and mostly fill the rest
slot_height_px = math.floor(
(bar_h - 2*border_size_px)/slots
) - slot_margin_px*1
bar.setOrientation(Qt.Vertical)
bar.setStyleSheet(
f"""
QProgressBar {{
text-align: center;
font-size : {fields._font_size - 2}px;
background-color: {hcolor('papas_special')};
color : {hcolor('papas_special')};
border: {border_size_px}px solid {hcolor('default_light')};
border-radius: 2px;
}}
QProgressBar::chunk {{
background-color: {hcolor('default_spotlight')};
color: {hcolor('papas_special')};
border-radius: 2px;
margin: {slot_margin_px}px;
height: {slot_height_px}px;
}}
"""
)
# margin-bottom: {slot_margin_px*2}px;
# margin-top: {slot_margin_px*2}px;
# color: #19232D;
# width: 10px;
bar.setRange(0, slots)
bar.setValue(1)
bar.setFormat('')
bar.setMinimumHeight(bar_h)
bar.setMaximumHeight(bar_h + slots*slot_margin_px)
bar.setMinimumWidth(h * 1.375)
bar.setMaximumWidth(h * 1.375)
return hbox, bar
def mk_order_pane_layout(
fields: FieldsForm,
font_size: int = _font_small.px_size - 2
) -> FieldsForm:
# TODO: maybe just allocate the whole fields form here
# and expect an async ctx entry?
fields._font_size = font_size
# top level pane layout
# XXX: see ``FieldsForm.__init__()`` for why we can't do basic
# config of the vbox here
vbox = fields.vbox
# _, h = fields.width(), fields.height()
# print(f'w, h: {w, h}')
hbox, bar = mk_fill_status_bar(fields, pane_vbox=vbox)
# add pp fill bar + spacing
vbox.addLayout(hbox, stretch=1/3)
feed_label = fields.add_field_label(
dedent("""
brokerd.ib\n
|_@localhost:8509\n
|_consumers: 4\n
|_streams: 9\n
"""),
font_size=_font.px_size - 5,
)
# add feed info label
vbox.addWidget(
feed_label,
alignment=Qt.AlignBottom,
stretch=1/3,
)
# TODO: handle resize events and appropriately scale this
# to the sidepane height?
# https://doc.qt.io/qt-5/layout.html#adding-widgets-to-a-layout
vbox.setSpacing(36)
return fields

View File

@ -1,20 +0,0 @@
# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of 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/>.
"""
Internal custom graphics mostly built for low latency and reuse.
"""

View File

@ -32,7 +32,6 @@ import trio
from ..log import get_logger
from ._style import _min_points_to_show
from ._editors import SelectRect
from ._window import main_window
log = get_logger(__name__)
@ -65,7 +64,8 @@ async def handle_viewmode_inputs(
'cc': mode.cancel_all_orders,
}
async for event, etype, key, mods, text in recv_chan:
async for kbmsg in recv_chan:
event, etype, key, mods, text = kbmsg.to_tuple()
log.debug(f'key: {key}, mods: {mods}, text: {text}')
now = time.time()
period = now - last
@ -115,7 +115,7 @@ async def handle_viewmode_inputs(
Qt.Key_Space,
}
):
view._chart._lc.godwidget.search.focus()
view._chart.linked.godwidget.search.focus()
# esc and ctrl-c
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
@ -163,9 +163,20 @@ async def handle_viewmode_inputs(
else:
view.setMouseMode(ViewBox.PanMode)
# Toggle position config pane
if (
ctrl and key in {
Qt.Key_P,
}
):
pp_conf = mode.pp_config
if pp_conf.isHidden():
pp_conf.show()
else:
pp_conf.hide()
# ORDER MODE #
# live vs. dark trigger + an action {buy, sell, alert}
order_keys_pressed = {
Qt.Key_A,
Qt.Key_F,
@ -173,6 +184,13 @@ async def handle_viewmode_inputs(
}.intersection(pressed)
if order_keys_pressed:
# show the pp label
mode.pp.show()
# TODO: show pp config mini-params in status bar widget
# mode.pp_config.show()
if (
# 's' for "submit" to activate "live" order
Qt.Key_S in pressed or
@ -201,17 +219,21 @@ async def handle_viewmode_inputs(
view.mode.set_exec(action)
prefix = trigger_mode + '-' if action != 'alert' else ''
view._chart.window().mode_label.setText(
f'mode: {prefix}{action}')
view._chart.window().set_mode_name(f'{prefix}{action}')
else: # none active
# hide pp label
mode.pp.hide_info()
# mode.pp_config.hide()
# if none are pressed, remove "staged" level
# line under cursor position
view.mode.lines.unstage_line()
if view.hasFocus():
# update mode label
view._chart.window().mode_label.setText('mode: view')
view._chart.window().set_mode_name('view')
view.order_mode = False
@ -229,12 +251,13 @@ class ChartView(ViewBox):
- zoom on right-click-n-drag to cursor position
'''
mode_name: str = 'mode: view'
mode_name: str = 'view'
def __init__(
self,
name: str,
parent: pg.PlotItem = None,
**kwargs,
@ -251,7 +274,6 @@ class ChartView(ViewBox):
self.select_box = SelectRect(self)
self.addItem(self.select_box, ignoreBounds=True)
self.name = name
self.mode = None
self.order_mode: bool = False
@ -260,11 +282,12 @@ class ChartView(ViewBox):
@asynccontextmanager
async def open_async_input_handler(
self,
) -> 'ChartView':
from . import _event
async with _event.open_handler(
self,
async with _event.open_handlers(
[self],
event_types={QEvent.KeyPress, QEvent.KeyRelease},
async_handler=handle_viewmode_inputs,
):

View File

@ -19,7 +19,7 @@ Non-shitty labels that don't re-invent the wheel.
"""
from inspect import isfunction
from typing import Callable
from typing import Callable, Optional
import pyqtgraph as pg
from PyQt5 import QtGui, QtWidgets
@ -31,62 +31,6 @@ from ._style import (
)
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:
"""
A plain ol' "scene label" using an underlying ``QGraphicsTextItem``.
@ -110,13 +54,14 @@ class Label:
self,
view: pg.ViewBox,
fmt_str: str,
color: str = 'bracket',
color: str = 'default_light',
x_offset: float = 0,
font_size: str = 'small',
opacity: float = 0.666,
fields: dict = {}
opacity: float = 1,
fields: dict = {},
update_on_range_change: bool = True,
) -> None:
@ -124,6 +69,8 @@ class Label:
self._fmt_str = fmt_str
self._view_xy = QPointF(0, 0)
self.scene_anchor: Optional[Callable[..., QPointF]] = None
self._x_offset = x_offset
txt = self.txt = QtWidgets.QGraphicsTextItem()
@ -139,7 +86,8 @@ class Label:
txt.setOpacity(opacity)
# register viewbox callbacks
vb.sigRangeChanged.connect(self.on_sigrange_change)
if update_on_range_change:
vb.sigRangeChanged.connect(self.on_sigrange_change)
self._hcolor: str = ''
self.color = color
@ -165,13 +113,34 @@ class Label:
self.txt.setDefaultTextColor(pg.mkColor(hcolor(color)))
self._hcolor = color
def update(self) -> None:
'''Update this label either by invoking its
user defined anchoring function, or by positioning
to the last recorded data view coordinates.
'''
# move label in scene coords to desired position
anchor = self.scene_anchor
if anchor:
self.txt.setPos(anchor())
else:
# position based on last computed view coordinate
self.set_view_pos(self._view_xy.y())
def on_sigrange_change(self, vr, r) -> None:
self.set_view_y(self._view_xy.y())
return self.update()
@property
def w(self) -> float:
return self.txt.boundingRect().width()
def scene_br(self) -> QRectF:
txt = self.txt
return txt.mapToScene(
txt.boundingRect()
).boundingRect()
@property
def h(self) -> float:
return self.txt.boundingRect().height()
@ -186,18 +155,20 @@ class Label:
assert isinstance(func(), float)
self._anchor_func = func
def set_view_y(
def set_view_pos(
self,
y: float,
x: Optional[float] = None,
) -> None:
scene_x = self._anchor_func() or self.txt.pos().x()
if x is None:
scene_x = self._anchor_func() or self.txt.pos().x()
x = self.vb.mapToView(QPointF(scene_x, scene_x)).x()
# get new (inside the) view coordinates / position
self._view_xy = QPointF(
self.vb.mapToView(QPointF(scene_x, scene_x)).x(),
y,
)
self._view_xy = QPointF(x, y)
# map back to the outer UI-land "scene" coordinates
s_xy = self.vb.mapFromView(self._view_xy)
@ -210,9 +181,6 @@ class Label:
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
@ -221,7 +189,11 @@ class Label:
def fmt_str(self, fmt_str: str) -> None:
self._fmt_str = fmt_str
def format(self, **fields: dict) -> str:
def format(
self,
**fields: dict
) -> str:
out = {}
@ -229,8 +201,10 @@ class Label:
# 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

View File

@ -18,6 +18,7 @@
Lines for orders, alerts, L2.
"""
from functools import partial
from math import floor
from typing import Tuple, Optional, List
@ -25,10 +26,17 @@ import pyqtgraph as pg
from pyqtgraph import Point, functions as fn
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF
from PyQt5.QtGui import QGraphicsPathItem
from .._annotate import mk_marker, qgo_draw_markers
from .._label import Label, vbr_left, right_axis
from .._style import hcolor, _font
from ._annotate import mk_marker_path, qgo_draw_markers
from ._anchors import (
marker_right_points,
vbr_left,
right_axis,
gpath_pin,
)
from ._label import Label
from ._style import hcolor, _font
# TODO: probably worth investigating if we can
@ -36,12 +44,6 @@ from .._style import hcolor, _font
# https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt
class LevelLine(pg.InfiniteLine):
# TODO: fill in these slots for orders
# available parent signals
# sigDragged(self)
# sigPositionChangeFinished(self)
# sigPositionChanged(self)
def __init__(
self,
chart: 'ChartPlotWidget', # type: ignore # noqa
@ -50,7 +52,7 @@ class LevelLine(pg.InfiniteLine):
color: str = 'default',
highlight_color: str = 'default_light',
dotted: bool = False,
marker_size: int = 20,
# marker_size: int = 20,
# UX look and feel opts
always_show_labels: bool = False,
@ -63,6 +65,9 @@ class LevelLine(pg.InfiniteLine):
) -> None:
# TODO: at this point it's probably not worth the inheritance
# any more since we've reimplemented ``.pain()`` among other
# things..
super().__init__(
movable=movable,
angle=0,
@ -77,7 +82,7 @@ class LevelLine(pg.InfiniteLine):
self._hide_xhair_on_hover = hide_xhair_on_hover
self._marker = None
self._default_mkr_size = marker_size
# self._default_mkr_size = marker_size
self._moh = only_show_markers_on_hover
self.show_markers: bool = True # presuming the line is hovered at init
@ -97,7 +102,7 @@ class LevelLine(pg.InfiniteLine):
# list of labels anchored at one of the 2 line endpoints
# inside the viewbox
self._labels: List[(int, Label)] = []
self._labels: List[Label] = []
self._markers: List[(int, Label)] = []
# whenever this line is moved trigger label updates
@ -114,9 +119,7 @@ class LevelLine(pg.InfiniteLine):
self._on_drag_start = lambda l: None
self._on_drag_end = lambda l: None
self._y_incr_mult = 1 / chart._lc._symbol.tick_size
self._last_scene_y: float = 0
self._y_incr_mult = 1 / chart.linked.symbol.tick_size
self._right_end_sc: float = 0
def txt_offsets(self) -> Tuple[int, int]:
@ -143,52 +146,6 @@ class LevelLine(pg.InfiniteLine):
hoverpen.setWidth(2)
self.hoverPen = hoverpen
def add_label(
self,
# by default we only display the line's level value
# in the label
fmt_str: str = (
'{level:,.{level_digits}f}'
),
side: str = 'right',
side_of_axis: str = 'left',
x_offset: float = 0,
color: str = None,
bg_color: str = None,
avoid_book: bool = True,
**label_kwargs,
) -> Label:
"""Add a ``LevelLabel`` anchored at one of the line endpoints in view.
"""
label = Label(
view=self.getViewBox(),
fmt_str=fmt_str,
color=self.color,
)
# set anchor callback
if side == 'right':
label.set_x_anchor_func(
right_axis(
self._chart,
label,
side=side_of_axis,
offset=x_offset,
avoid_book=avoid_book,
)
)
elif side == 'left':
label.set_x_anchor_func(vbr_left(label))
self._labels.append((side, label))
return label
def on_pos_change(
self,
line: 'LevelLine', # noqa
@ -201,9 +158,11 @@ class LevelLine(pg.InfiniteLine):
def update_labels(
self,
fields_data: dict,
) -> None:
for at, label in self._labels:
for label in self._labels:
label.color = self.color
# print(f'color is {self.color}')
@ -211,18 +170,18 @@ class LevelLine(pg.InfiniteLine):
level = fields_data.get('level')
if level:
label.set_view_y(level)
label.set_view_pos(y=level)
label.render()
self.update()
def hide_labels(self) -> None:
for at, label in self._labels:
for label in self._labels:
label.hide()
def show_labels(self) -> None:
for at, label in self._labels:
for label in self._labels:
label.show()
def set_level(
@ -245,15 +204,24 @@ class LevelLine(pg.InfiniteLine):
def on_tracked_source(
self,
x: int,
y: float
) -> None:
# XXX: this is called by our ``Cursor`` type once this
# line is set to track the cursor: for every movement
# this callback is invoked to reposition the line
'''Chart coordinates cursor tracking callback.
this is called by our ``Cursor`` type once this line is set to
track the cursor: for every movement this callback is invoked to
reposition the line
'''
self.movable = True
self.set_level(y) # implictly calls reposition handler
self._chart.linked.godwidget.pp_config.model.get_order_info(
price=y
)
def mouseDragEvent(self, ev):
"""Override the ``InfiniteLine`` handler since we need more
detailed control and start end signalling.
@ -316,9 +284,10 @@ class LevelLine(pg.InfiniteLine):
"""
scene = self.scene()
if scene:
for at, label in self._labels:
for label in self._labels:
label.delete()
# gc managed labels?
self._labels.clear()
if self._marker:
@ -354,9 +323,11 @@ class LevelLine(pg.InfiniteLine):
def paint(
self,
p: QtGui.QPainter,
opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.QWidget
) -> None:
"""Core paint which we override (yet again)
from pg..
@ -366,26 +337,14 @@ class LevelLine(pg.InfiniteLine):
# these are in viewbox coords
vb_left, vb_right = self._endPoints
chart = self._chart
l1_len = chart._max_l1_line_len
ryaxis = chart.getAxis('right')
r_axis_x = ryaxis.pos().x()
up_to_l1_sc = r_axis_x - l1_len
vb = self.getViewBox()
size = self._default_mkr_size
marker_right = up_to_l1_sc - (1.375 * 2*size)
line_end = marker_right - (6/16 * size)
line_end, marker_right, r_axis_x = marker_right_points(self._chart)
if self.show_markers and self.markers:
size = self.markers[0][2]
p.setPen(self.pen)
size = qgo_draw_markers(
qgo_draw_markers(
self.markers,
self.pen.color(),
p,
@ -400,9 +359,14 @@ class LevelLine(pg.InfiniteLine):
# order lines.. not sure wtf is up with that.
# for now we're just using it on the position line.
elif self._marker:
# TODO: make this label update part of a scene-aware-marker
# composed annotation
self._marker.setPos(
QPointF(marker_right, self.scene_y())
)
if hasattr(self._marker, 'label'):
self._marker.label.update()
elif not self.use_marker_margin:
# basically means **don't** shorten the line with normally
@ -424,23 +388,35 @@ class LevelLine(pg.InfiniteLine):
super().hide()
if self._marker:
self._marker.hide()
# self._marker.label.hide()
def scene_right_xy(self) -> QPointF:
return self.getViewBox().mapFromView(
QPointF(0, self.value())
)
def show(self) -> None:
super().show()
if self._marker:
self._marker.show()
# self._marker.label.show()
def scene_y(self) -> float:
return self.getViewBox().mapFromView(Point(0, self.value())).y()
return self.getViewBox().mapFromView(
Point(0, self.value())
).y()
def scene_endpoint(self) -> QPointF:
if not self._right_end_sc:
line_end, _, _ = marker_right_points(self._chart)
self._right_end_sc = line_end - 10
return QPointF(self._right_end_sc, self.scene_y())
def add_marker(
self,
path: QtWidgets.QGraphicsPathItem,
) -> None:
# chart = self._chart
vb = self.getViewBox()
vb.scene().addItem(path)
) -> QtWidgets.QGraphicsPathItem:
# add path to scene
self.getViewBox().scene().addItem(path)
self._marker = path
@ -451,7 +427,7 @@ class LevelLine(pg.InfiniteLine):
# y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y()
path.setPos(QPointF(rsc, self.scene_y()))
# self.update()
return path
def hoverEvent(self, ev):
"""Mouse hover callback.
@ -469,6 +445,9 @@ class LevelLine(pg.InfiniteLine):
if self._moh:
self.show_markers = True
if self._marker:
self._marker.show()
# highlight if so configured
if self._hoh:
@ -512,11 +491,14 @@ class LevelLine(pg.InfiniteLine):
if self._moh:
self.show_markers = False
if self._marker:
self._marker.hide()
if self not in cur._trackers:
cur.show_xhair(y_label_level=self.value())
if not self._always_show_labels:
for at, label in self._labels:
for label in self._labels:
label.hide()
label.txt.update()
# label.unhighlight()
@ -529,24 +511,18 @@ class LevelLine(pg.InfiniteLine):
def level_line(
chart: 'ChartPlotWidget', # noqa
level: float,
color: str = 'default',
# whether or not the line placed in view should highlight
# when moused over (aka "hovered")
hl_on_hover: bool = True,
# line style
dotted: bool = False,
color: str = 'default',
# ux
hl_on_hover: bool = True,
# label fields and options
digits: int = 1,
always_show_labels: bool = False,
add_label: bool = True,
orient_v: str = 'bottom',
**kwargs,
) -> LevelLine:
@ -578,14 +554,31 @@ def level_line(
if add_label:
label = line.add_label(
side='right',
opacity=1,
x_offset=0,
avoid_book=False,
)
label.orient_v = orient_v
label = Label(
view=line.getViewBox(),
# by default we only display the line's level value
# in the label
fmt_str=('{level:,.{level_digits}f}'),
color=color,
)
# anchor to right side (of view ) label
label.set_x_anchor_func(
right_axis(
chart,
label,
side='left', # side of axis
offset=0,
avoid_book=False,
)
)
# add to label set which will be updated on level changes
line._labels.append(label)
label.orient_v = orient_v
line.update_labels({'level': level, 'level_digits': 2})
label.render()
@ -598,13 +591,14 @@ def level_line(
def order_line(
chart,
level: float,
level_digits: float,
action: str, # buy or sell
size: Optional[int] = 1,
size_digits: int = 0,
size_digits: int = 1,
show_markers: bool = False,
submit_price: float = None,
exec_type: str = 'dark',
@ -641,43 +635,62 @@ def order_line(
'alert': ('v', alert_size),
}[action]
# this fixes it the artifact issue! .. of course, bouding rect stuff
# this fixes it the artifact issue! .. of course, bounding rect stuff
line._maxMarkerSize = marker_size
# use ``QPathGraphicsItem``s to draw markers in scene coords
# instead of the old way that was doing the same but by
# resetting the graphics item transform intermittently
# the old way which is still somehow faster?
path = QGraphicsPathItem(
mk_marker_path(
marker_style,
# the "position" here is now ignored since we modified
# internals to pin markers to the right end of the line
# marker_size,
# uncommment for the old transform / .paint() marker method
# use_qgpath=False,
)
)
path.scale(marker_size, marker_size)
# XXX: this is our new approach but seems slower?
# line.add_marker(mk_marker(marker_style, marker_size))
path = line.add_marker(path)
# XXX: old
# path = line.add_marker(mk_marker(marker_style, marker_size))
line._marker = path
assert not line.markers
# the old way which is still somehow faster?
path = mk_marker(
marker_style,
# the "position" here is now ignored since we modified
# internals to pin markers to the right end of the line
marker_size,
use_qgpath=False,
)
# manually append for later ``InfiniteLine.paint()`` drawing
# XXX: this was manually tested as faster then using the
# QGraphicsItem around a painter path.. probably needs further
# testing to figure out why tf that's true.
line.markers.append((path, 0, marker_size))
# # manually append for later ``InfiniteLine.paint()`` drawing
# # XXX: this was manually tested as faster then using the
# # QGraphicsItem around a painter path.. probably needs further
# # testing to figure out why tf that's true.
# line.markers.append((path, 0, marker_size))
orient_v = 'top' if action == 'sell' else 'bottom'
if action == 'alert':
# completely different labelling for alerts
fmt_str = 'alert => {level}'
llabel = Label(
view=line.getViewBox(),
color=line.color,
# completely different labelling for alerts
fmt_str='alert => {level}',
)
# for now, we're just duplicating the label contents i guess..
llabel = line.add_label(
side='left',
fmt_str=fmt_str,
)
line._labels.append(llabel)
# anchor to left side of view / line
llabel.set_x_anchor_func(vbr_left(llabel))
llabel.fields = {
'level': level,
'level_digits': level_digits,
@ -686,35 +699,34 @@ def order_line(
llabel.render()
llabel.show()
else:
# # left side label
# llabel = line.add_label(
# side='left',
# fmt_str=' {exec_type}-{order_type}:\n ${$value}',
# )
# llabel.fields = {
# 'order_type': order_type,
# 'level': level,
# '$value': lambda f: f['level'] * f['size'],
# 'size': size,
# 'exec_type': exec_type,
# }
# llabel.orient_v = orient_v
# llabel.render()
# llabel.show()
path.label = llabel
# right before L1 label
rlabel = line.add_label(
side='right',
side_of_axis='left',
x_offset=4*marker_size,
fmt_str=(
'{size:.{size_digits}f} '
),
else:
rlabel = Label(
view=line.getViewBox(),
# display the order pos size, which is some multiple
# of the user defined base unit size
fmt_str=(':{size:.0f}'),
# fmt_str=('{size:.{size_digits}f}'), # old
color=line.color,
)
path.label = rlabel
rlabel.scene_anchor = partial(
gpath_pin,
location_description='right-of-path-centered',
gpath=path,
label=rlabel,
)
line._labels.append(rlabel)
rlabel.fields = {
'size': size,
'size_digits': size_digits,
# 'size_digits': size_digits,
}
rlabel.orient_v = orient_v
@ -725,98 +737,3 @@ def order_line(
line.update_labels({'level': level})
return line
def position_line(
chart,
size: float,
level: float,
orient_v: str = 'bottom',
) -> LevelLine:
"""Convenience routine to add a line graphic representing an order
execution submitted to the EMS via the chart's "order mode".
"""
line = level_line(
chart,
level,
color='default_light',
add_label=False,
hl_on_hover=False,
movable=False,
always_show_labels=False,
hide_xhair_on_hover=False,
use_marker_margin=True,
)
# hide position marker when out of view (for now)
vb = line.getViewBox()
def update_pp_nav(chartview):
vr = vb.state['viewRange']
ymn, ymx = vr[1]
level = line.value()
if gt := level > ymx or (lt := level < ymn):
if chartview.mode.name == 'order':
# provide "nav hub" like indicator for where
# the position is on the y-dimension
if gt:
# pin to top of view since position is above current
# y-range
pass
elif lt:
# pin to bottom of view since position is above
# below y-range
pass
else:
# order mode is not active
# so hide the pp market
line._marker.hide()
else:
# pp line is viewable so show marker
line._marker.show()
vb.sigYRangeChanged.connect(update_pp_nav)
rlabel = line.add_label(
side='right',
fmt_str='{direction}: {size} -> ${$:.2f}',
)
rlabel.fields = {
'direction': 'long' if size > 0 else 'short',
'$': size * level,
'size': size,
}
rlabel.orient_v = orient_v
rlabel.render()
rlabel.show()
# arrow marker
# scale marker size with dpi-aware font size
font_size = _font.font.pixelSize()
# scale marker size with dpi-aware font size
arrow_size = floor(1.375 * font_size)
if size > 0:
style = '|<'
elif size < 0:
style = '>|'
arrow_path = mk_marker(style, size=arrow_size)
# XXX: uses new marker drawing approach
line.add_marker(arrow_path)
line.set_level(level)
# sanity check
line.update_labels({'level': level})
return line

View File

@ -27,8 +27,8 @@ from PyQt5.QtCore import QLineF, QPointF
# from numba import types as ntypes
# from ..data._source import numba_ohlc_dtype
from ..._profile import pg_profile_enabled
from .._style import hcolor
from .._profile import pg_profile_enabled
from ._style import hcolor
def _mk_lines_array(

129
piker/ui/_orm.py 100644
View File

@ -0,0 +1,129 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet
# 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/>.
"""
micro-ORM for coupling ``pydantic`` models with Qt input/output widgets.
"""
from __future__ import annotations
from typing import (
Optional, Generic,
TypeVar, Callable,
Literal,
)
import enum
import sys
from pydantic import BaseModel, validator
from pydantic.generics import GenericModel
from PyQt5.QtWidgets import (
QWidget,
QComboBox,
)
from ._forms import (
# FontScaledDelegate,
FontAndChartAwareLineEdit,
)
DataType = TypeVar('DataType')
class Field(GenericModel, Generic[DataType]):
widget_factory: Optional[
Callable[
[QWidget, 'Field'],
QWidget
]
]
value: Optional[DataType] = None
class Selection(Field[DataType], Generic[DataType]):
'''Model which maps to a finite set of drop down entries declared as
a ``dict[str, DataType]``.
'''
widget_factory = QComboBox
options: dict[str, DataType]
# value: DataType = None
@validator('value') # , always=True)
def set_value_first(
cls,
v: DataType,
values: dict[str, DataType],
) -> DataType:
'''If no initial value is set, use the first in
the ``options`` dict.
'''
# breakpoint()
options = values['options']
if v is None:
return next(options.values())
else:
assert v in options, f'{v} is not in {options}'
return v
# class SizeUnit(Enum):
# currency = '$ size'
# percent_of_port = '% of port'
# shares = '# shares'
# class Weighter(str, Enum):
# uniform = 'uniform'
class Edit(Field[DataType], Generic[DataType]):
'''An edit field which takes a number.
'''
widget_factory = FontAndChartAwareLineEdit
class AllocatorPane(BaseModel):
account = Selection[str](
options=dict.fromkeys(
['paper', 'ib.paper', 'ib.margin'],
'paper',
),
)
allocate = Selection[str](
# options=list(Size),
options={
'$ size': 'currency',
'% of port': 'percent_of_port',
'# shares': 'shares',
},
# TODO: save/load from config and/or last session
# value='currency'
)
weight = Selection[str](
options={
'uniform': 'uniform',
},
# value='uniform',
)
size = Edit[float](value=1000)
slots = Edit[int](value=4)

View File

@ -0,0 +1,421 @@
# 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/>.
"""
Position info and display
"""
from __future__ import annotations
import enum
from functools import partial
from math import floor
import sys
from typing import Optional
from bidict import bidict
from pyqtgraph import functions as fn
from pydantic import BaseModel, validator
# from pydantic.generics import GenericModel
# from PyQt5.QtCore import QPointF
# from PyQt5.QtGui import QGraphicsPathItem
from ._annotate import LevelMarker
from ._anchors import (
pp_tight_and_right, # wanna keep it straight in the long run
gpath_pin,
)
from ..clearing._messages import BrokerdPosition, Status
from ..data._source import Symbol
from ._label import Label
from ._lines import LevelLine, level_line
from ._style import _font
class Position(BaseModel):
'''Basic pp (personal position) model with attached fills history.
This type should be IPC wire ready?
'''
symbol: Symbol
# last size and avg entry price
size: float
avg_price: float # TODO: contextual pricing
# ordered record of known constituent trade messages
fills: list[Status] = []
def mk_pp_alloc(
accounts: dict[str, Optional[str]] = {
'paper': None,
'ib.paper': 'DU1435481',
'ib.margin': 'U10983%',
},
) -> Allocator: # noqa
# lol we have to do this module patching bc ``pydantic``
# needs types to exist at module level:
# https://pydantic-docs.helpmanual.io/usage/postponed_annotations/
mod = sys.modules[__name__]
accounts = bidict(accounts)
Account = mod.Account = enum.Enum('Account', accounts)
size_units = bidict({
'$ size': 'currency',
'% of port': 'percent_of_port',
'# shares': 'shares',
})
SizeUnit = mod.SizeUnit = enum.Enum(
'SizeUnit',
size_units.inverse
)
class Allocator(BaseModel):
class Config:
validate_assignment = True
account: Account = None
_accounts: dict[str, Optional[str]] = accounts
size_unit: SizeUnit = 'currency'
_size_units: dict[str, Optional[str]] = size_units
disti_weight: str = 'uniform'
size: float
slots: int
_position: Position = None
def get_order_info(
self,
price: float,
) -> dict:
units, r = divmod(
round((self.size / self.slots)),
price,
)
print(f'# shares: {units}, r: {r}')
# Allocator.update_forward_refs()
return Allocator(
account=None,
size_unit=size_units.inverse['currency'],
size=2000,
slots=4,
)
class PositionTracker:
'''Track and display a real-time position for a single symbol
on a chart.
'''
# inputs
chart: 'ChartPlotWidget' # noqa
# allocated
info: Position
pp_label: Label
size_label: Label
line: Optional[LevelLine] = None
_color: str = 'default_lightest'
def __init__(
self,
chart: 'ChartPlotWidget', # noqa
) -> None:
self.chart = chart
self.info = Position(
symbol=chart.linked.symbol,
size=0,
avg_price=0,
)
view = chart.getViewBox()
# literally the 'pp' (pee pee) label that's always in view
self.pp_label = pp_label = Label(
view=view,
fmt_str='pp',
color=self._color,
update_on_range_change=False,
)
# create placeholder 'up' level arrow
self._level_marker = None
self._level_marker = self.level_marker(size=1)
pp_label.scene_anchor = partial(
gpath_pin,
gpath=self._level_marker,
label=pp_label,
)
pp_label.render()
self.size_label = size_label = Label(
view=view,
color=self._color,
# this is "static" label
# update_on_range_change=False,
fmt_str='\n'.join((
':{entry_size:.0f}',
)),
fields={
'entry_size': 0,
},
)
size_label.render()
size_label.scene_anchor = partial(
pp_tight_and_right,
label=self.pp_label,
)
# size_label.scene_anchor = lambda: (
# self.pp_label.txt.pos() + QPointF(self.pp_label.w, 0)
# )
# size_label.scene_anchor = lambda: (
# self.pp_label.scene_br().bottomRight() - QPointF(
# self.size_label.w, self.size_label.h/3)
# )
# TODO: if we want to show more position-y info?
# fmt_str='\n'.join((
# # '{entry_size}x ',
# '{percent_pnl} % PnL',
# # '{percent_of_port}% of port',
# '${base_unit_value}',
# )),
# fields={
# # 'entry_size': 0,
# 'percent_pnl': 0,
# 'percent_of_port': 2,
# 'base_unit_value': '1k',
# },
# )
def update_graphics(
self,
marker: LevelMarker
) -> None:
'''Update all labels.
Meant to be called from the maker ``.paint()``
for immediate, lag free label draws.
'''
self.pp_label.update()
self.size_label.update()
def update(
self,
msg: BrokerdPosition,
) -> None:
'''Update graphics and data from average price and size.
'''
avg_price, size = msg['avg_price'], msg['size']
# info updates
self.info.avg_price = avg_price
self.info.size = size
self.update_line(avg_price, size)
# label updates
self.size_label.fields['entry_size'] = size
self.size_label.render()
if size == 0:
self.hide()
else:
self._level_marker.level = avg_price
# these updates are critical to avoid lag on view/scene changes
self._level_marker.update() # trigger paint
self.pp_label.update()
self.size_label.update()
self.show()
# don't show side and status widgets unless
# order mode is "engaged" (which done via input controls)
self.hide_info()
def level(self) -> float:
if self.line:
return self.line.value()
else:
return 0
def show(self) -> None:
if self.info.size:
self.line.show()
self._level_marker.show()
self.pp_label.show()
self.size_label.show()
def hide(self) -> None:
self.pp_label.hide()
self._level_marker.hide()
self.size_label.hide()
if self.line:
self.line.hide()
def hide_info(self) -> None:
'''Hide details (right now just size label?) of position.
'''
# TODO: add remove status bar widgets here
self.size_label.hide()
# TODO: move into annoate module
def level_marker(
self,
size: float,
) -> LevelMarker:
if self._level_marker:
self._level_marker.delete()
# arrow marker
# scale marker size with dpi-aware font size
font_size = _font.font.pixelSize()
# scale marker size with dpi-aware font size
arrow_size = floor(1.375 * font_size)
if size > 0:
style = '|<'
elif size < 0:
style = '>|'
arrow = LevelMarker(
chart=self.chart,
style=style,
get_level=self.level,
size=arrow_size,
on_paint=self.update_graphics,
)
self.chart.getViewBox().scene().addItem(arrow)
arrow.show()
return arrow
def position_line(
self,
size: float,
level: float,
orient_v: str = 'bottom',
) -> LevelLine:
'''Convenience routine to add a line graphic representing an order
execution submitted to the EMS via the chart's "order mode".
'''
self.line = line = level_line(
self.chart,
level,
color=self._color,
add_label=False,
hl_on_hover=False,
movable=False,
hide_xhair_on_hover=False,
use_marker_margin=True,
only_show_markers_on_hover=False,
always_show_labels=True,
)
if size > 0:
style = '|<'
elif size < 0:
style = '>|'
marker = self._level_marker
marker.style = style
# set marker color to same as line
marker.setPen(line.currentPen)
marker.setBrush(fn.mkBrush(line.currentPen.color()))
marker.level = level
marker.update()
marker.show()
# show position marker on view "edge" when out of view
vb = line.getViewBox()
vb.sigRangeChanged.connect(marker.position_in_view)
line.set_level(level)
return line
def update_line(
self,
price: float,
size: float,
) -> None:
'''Update personal position level line.
'''
# do line update
line = self.line
if line is None and size:
# create and show a pp line
line = self.line = self.position_line(
level=price,
size=size,
)
line.show()
elif line:
if size != 0.0:
line.set_level(price)
self._level_marker.level = price
self._level_marker.update()
# line.update_labels({'size': size})
line.show()
else:
# remove pp line from view
line.delete()
self.line = None

View File

@ -35,9 +35,9 @@ from collections import defaultdict
from contextlib import asynccontextmanager
from functools import partial
from typing import (
List, Optional, Callable,
Awaitable, Sequence, Dict,
Any, AsyncIterator, Tuple,
Optional, Callable,
Awaitable, Sequence,
Any, AsyncIterator
)
import time
# from pprint import pformat
@ -45,7 +45,7 @@ import time
from fuzzywuzzy import process as fuzzy
import trio
from trio_typing import TaskStatus
from PyQt5 import QtCore, QtGui
from PyQt5 import QtCore
from PyQt5 import QtWidgets
from PyQt5.QtCore import (
Qt,
@ -63,40 +63,24 @@ from PyQt5.QtWidgets import (
QTreeView,
# QListWidgetItem,
# QAbstractScrollArea,
QStyledItemDelegate,
# QStyledItemDelegate,
)
from ..log import get_logger
from ._style import (
_font,
DpiAwareFont,
# hcolor,
hcolor,
)
from ._forms import FontAndChartAwareLineEdit, FontScaledDelegate
log = get_logger(__name__)
class SimpleDelegate(QStyledItemDelegate):
"""
Super simple view delegate to render text in the same
font size as the search widget.
"""
def __init__(
self,
parent=None,
font: DpiAwareFont = _font,
) -> None:
super().__init__(parent)
self.dpi_font = font
class CompleterView(QTreeView):
mode_name: str = 'mode: search-nav'
mode_name: str = 'search-nav'
# XXX: relevant docs links:
# - simple widget version of this:
@ -121,7 +105,7 @@ class CompleterView(QTreeView):
def __init__(
self,
parent=None,
labels: List[str] = [],
labels: list[str] = [],
) -> None:
super().__init__(parent)
@ -130,7 +114,7 @@ class CompleterView(QTreeView):
self.labels = labels
# a std "tabular" config
self.setItemDelegate(SimpleDelegate())
self.setItemDelegate(FontScaledDelegate(self))
self.setModel(model)
self.setAlternatingRowColors(True)
# TODO: size this based on DPI font
@ -425,59 +409,28 @@ class CompleterView(QTreeView):
self.resize()
class SearchBar(QtWidgets.QLineEdit):
class SearchBar(FontAndChartAwareLineEdit):
mode_name: str = 'mode: search'
mode_name: str = 'search'
def __init__(
self,
parent: QWidget,
parent_chart: QWidget, # noqa
godwidget: QWidget,
view: Optional[CompleterView] = None,
font: DpiAwareFont = _font,
**kwargs,
) -> None:
super().__init__(parent)
# self.setContextMenuPolicy(Qt.CustomContextMenu)
# self.customContextMenuRequested.connect(self.show_menu)
# self.setStyleSheet(f"font: 18px")
self.godwidget = godwidget
super().__init__(parent, **kwargs)
self.view: CompleterView = view
self.dpi_font = font
self.godwidget = parent_chart
# size it as we specify
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
self.setSizePolicy(
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Fixed,
)
self.setFont(font.font)
# witty bit of margin
self.setTextMargins(2, 2, 2, 2)
def focus(self) -> None:
self.selectAll()
self.show()
self.setFocus()
def show(self) -> None:
super().show()
self.view.show_matches()
def sizeHint(self) -> QtCore.QSize:
"""
Scale edit box to size of dpi aware font.
"""
psh = super().sizeHint()
psh.setHeight(self.dpi_font.px_size + 2)
return psh
def unfocus(self) -> None:
self.parent().hide()
self.clearFocus()
@ -492,12 +445,12 @@ class SearchWidget(QtWidgets.QWidget):
Includes helper methods for item management in the sub-widgets.
'''
mode_name: str = 'mode: search'
mode_name: str = 'search'
def __init__(
self,
godwidget: 'GodWidget', # type: ignore # noqa
columns: List[str] = ['src', 'symbol'],
columns: list[str] = ['src', 'symbol'],
parent=None,
) -> None:
@ -512,7 +465,7 @@ class SearchWidget(QtWidgets.QWidget):
self.godwidget = godwidget
self.vbox = QtWidgets.QVBoxLayout(self)
self.vbox.setContentsMargins(0, 0, 0, 0)
self.vbox.setContentsMargins(0, 4, 4, 0)
self.vbox.setSpacing(4)
# split layout for the (label:| search bar entry)
@ -522,10 +475,17 @@ class SearchWidget(QtWidgets.QWidget):
# add label to left of search bar
self.label = label = QtWidgets.QLabel(parent=self)
label.setStyleSheet(
f"""QLabel {{
color : {hcolor('default_lightest')};
font-size : {_font.px_size - 2}px;
}}
"""
)
label.setTextFormat(3) # markdown
label.setFont(_font.font)
label.setMargin(4)
label.setText("`search`:")
label.setText("search:")
label.show()
label.setAlignment(
QtCore.Qt.AlignVCenter
@ -540,8 +500,8 @@ class SearchWidget(QtWidgets.QWidget):
)
self.bar = SearchBar(
parent=self,
parent_chart=godwidget,
view=self.view,
godwidget=godwidget,
)
self.bar_hbox.addWidget(self.bar)
@ -564,7 +524,7 @@ class SearchWidget(QtWidgets.QWidget):
self.bar.focus()
self.show()
def get_current_item(self) -> Optional[Tuple[str, str]]:
def get_current_item(self) -> Optional[tuple[str, str]]:
'''Return the current completer tree selection as
a tuple ``(parent: str, child: str)`` if valid, else ``None``.
@ -599,11 +559,12 @@ class SearchWidget(QtWidgets.QWidget):
def chart_current_item(
self,
clear_to_cache: bool = True,
) -> Optional[str]:
'''Attempt to load and switch the current selected
completion result to the affiliated chart app.
Return any loaded symbol
Return any loaded symbol.
'''
value = self.get_current_item()
@ -653,10 +614,11 @@ async def pack_matches(
view: CompleterView,
has_results: dict[str, set[str]],
matches: dict[(str, str), List[str]],
matches: dict[(str, str), list[str]],
provider: str,
pattern: str,
search: Callable[..., Awaitable[dict]],
task_status: TaskStatus[
trio.CancelScope] = trio.TASK_STATUS_IGNORED,
@ -834,7 +796,7 @@ async def handle_keyboard_input(
# startup
bar = searchbar
search = searchbar.parent()
chart = search.godwidget
godwidget = search.godwidget
view = bar.view
view.set_font_size(bar.dpi_font.px_size)
@ -853,7 +815,8 @@ async def handle_keyboard_input(
)
)
async for event, etype, key, mods, txt in recv_chan:
async for kbmsg in recv_chan:
event, etype, key, mods, txt = kbmsg.to_tuple()
log.debug(f'key: {key}, mods: {mods}, txt: {txt}')
@ -861,11 +824,6 @@ async def handle_keyboard_input(
if mods == Qt.ControlModifier:
ctl = True
# # ctl + alt as combo
# ctlalt = False
# if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods:
# ctlalt = True
if key in (Qt.Key_Enter, Qt.Key_Return):
search.chart_current_item(clear_to_cache=True)
@ -876,7 +834,7 @@ async def handle_keyboard_input(
# if nothing in search text show the cache
view.set_section_entries(
'cache',
list(reversed(chart._chart_cache)),
list(reversed(godwidget._chart_cache)),
clear_all=True,
)
continue
@ -890,8 +848,8 @@ async def handle_keyboard_input(
search.bar.unfocus()
# kill the search and focus back on main chart
if chart:
chart.linkedsplits.focus()
if godwidget:
godwidget.focus()
continue
@ -950,7 +908,7 @@ async def handle_keyboard_input(
async def search_simple_dict(
text: str,
source: dict,
) -> Dict[str, Any]:
) -> dict[str, Any]:
# search routine can be specified as a function such
# as in the case of the current app's local symbol cache
@ -964,7 +922,7 @@ async def search_simple_dict(
# cache of provider names to async search routines
_searcher_cache: Dict[str, Callable[..., Awaitable]] = {}
_searcher_cache: dict[str, Callable[..., Awaitable]] = {}
@asynccontextmanager

View File

@ -56,7 +56,6 @@ class DpiAwareFont:
self._qfont = QtGui.QFont(name)
self._font_size: str = font_size
self._qfm = QtGui.QFontMetrics(self._qfont)
self._physical_dpi = None
self._font_inches: float = None
self._screen = None
@ -82,6 +81,10 @@ class DpiAwareFont:
def font(self):
return self._qfont
def scale(self) -> float:
screen = self.screen
return screen.logicalDotsPerInch() / screen.physicalDotsPerInch()
@property
def px_size(self) -> int:
return self._qfont.pixelSize()
@ -114,14 +117,14 @@ class DpiAwareFont:
# dpi is likely somewhat scaled down so use slightly larger font size
if scale > 1 and self._font_size:
# TODO: this denominator should probably be determined from
# relative aspect rations or something?
# relative aspect ratios or something?
inches = inches * (1 / scale) * (1 + 6/16)
dpi = mx_dpi
self._font_inches = inches
font_size = math.floor(inches * dpi)
log.info(
log.debug(
f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}"
f"\nOur best guess font size is {font_size}\n"
)

View File

@ -165,7 +165,11 @@ class MainWindow(QtGui.QMainWindow):
self._status_label = label = QtGui.QLabel()
label.setStyleSheet(
f"QLabel {{ color : {hcolor('gunmetal')}; }}"
f"""QLabel {{
color : {hcolor('gunmetal')};
}}
"""
# font-size : {font_size}px;
)
label.setTextFormat(3) # markdown
label.setFont(_font_small.font)
@ -181,11 +185,13 @@ class MainWindow(QtGui.QMainWindow):
def closeEvent(
self,
event: QtGui.QCloseEvent,
) -> None:
"""Cancel the root actor asap.
"""
event: QtGui.QCloseEvent,
) -> None:
'''Cancel the root actor asap.
'''
# raising KBI seems to get intercepted by by Qt so just use the system.
os.kill(os.getpid(), signal.SIGINT)
@ -209,18 +215,28 @@ class MainWindow(QtGui.QMainWindow):
return self._status_bar
def on_focus_change(
def set_mode_name(
self,
old: QtGui.QWidget,
new: QtGui.QWidget,
name: str,
) -> None:
log.debug(f'widget focus changed from {old} -> {new}')
self.mode_label.setText(f'mode:{name}')
if new is not None:
def on_focus_change(
self,
last: QtGui.QWidget,
current: QtGui.QWidget,
) -> None:
log.info(f'widget focus changed from {last} -> {current}')
if current is not None:
# cursor left window?
name = getattr(new, 'mode_name', '')
self.mode_label.setText(name)
name = getattr(current, 'mode_name', '')
self.set_mode_name(name)
def current_screen(self) -> QtGui.QScreen:
"""Get a frickin screen (if we can, gawd).
@ -230,7 +246,7 @@ class MainWindow(QtGui.QMainWindow):
for _ in range(3):
screen = app.screenAt(self.pos())
print('trying to access QScreen...')
log.debug('trying to access QScreen...')
if screen is None:
time.sleep(0.5)
continue

View File

@ -18,50 +18,61 @@
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
from pydantic import BaseModel
import tractor
import trio
from ._graphics._lines import LevelLine, position_line
from ._editors import LineEditor, ArrowEditor
from ._window import MultiStatus, main_window
from ..clearing._client import open_ems, OrderBook
from ..data._source import Symbol
from ..log import get_logger
from ._editors import LineEditor, ArrowEditor
from ._lines import LevelLine
from ._position import PositionTracker
from ._window import MultiStatus
from ._forms import FieldsForm
log = get_logger(__name__)
class Position(BaseModel):
symbol: Symbol
size: float
avg_price: float
class OrderDialog(BaseModel):
'''Trade dialogue meta-data describing the lifetime
of an order submission to ``emsd`` from a chart.
'''
# TODO: use ``pydantic.UUID4`` field
uuid: str
line: LevelLine
last_status_close: Callable = lambda: None
msgs: dict[str, dict] = {}
fills: Dict[str, Any] = {}
class Config:
arbitrary_types_allowed = True
underscore_attrs_are_private = False
@dataclass
class OrderMode:
'''Major mode for placing orders on a chart view.
'''Major UX mode for placing orders on a chart view providing so
called, "chart trading".
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 mouse and keyboard.
This object is chart oriented, so there is an instance per
chart / view currently.
This is the other "main" mode that pairs with "view mode" (when
wathing the rt price update at the current time step) and allows
entering orders using mouse and keyboard. This object is chart
oriented, so there is an instance per chart / view currently.
Current manual:
a -> alert
s/ctrl -> submission type modifier {on: live, off: dark}
f (fill) -> buy limit order
d (dump) -> sell limit order
f (fill) -> 'buy' limit order
d (dump) -> 'sell' limit order
c (cancel) -> cancel order under cursor
cc -> cancel all submitted orders on chart
mouse click and drag -> modify current order under cursor
@ -71,7 +82,9 @@ class OrderMode:
book: OrderBook
lines: LineEditor
arrows: ArrowEditor
status_bar: MultiStatus
multistatus: MultiStatus
pp: PositionTracker
name: str = 'order'
_colors = {
@ -82,47 +95,27 @@ class OrderMode:
_action: str = 'alert'
_exec_mode: str = 'dark'
_size: float = 100.0
_position: Dict[str, Any] = field(default_factory=dict)
_position_line: dict = None
_pending_submissions: dict[str, (LevelLine, 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()
dialogs: dict[str, OrderDialog] = field(default_factory=dict)
def uuid(self) -> str:
return str(uuid.uuid4())
@property
def pp_config(self) -> FieldsForm:
return self.chart.linked.godwidget.pp_config
def set_exec(
self,
action: str,
size: Optional[int] = None,
) -> None:
"""Set execution mode.
"""
) -> None:
'''
Set execution mode.
'''
# not initialized yet
if not self.chart.linked.cursor:
return
@ -139,33 +132,50 @@ class OrderMode:
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.
def on_submit(
self,
uuid: str
TODO: annotate order line with submission type ('live' vs.
'dark').
) -> OrderDialog:
'''
Order submitted status event handler.
"""
Commit the order line and registered order uuid, store ack time stamp.
'''
line = self.lines.commit_line(uuid)
pending = self._pending_submissions.get(uuid)
if pending:
order_line, func = pending
assert order_line is line
func()
# a submission is the start of a new order dialog
dialog = self.dialogs[uuid]
dialog.line = line
dialog.last_status_close()
return line
return dialog
def on_fill(
self,
uuid: str,
price: float,
arrow_index: float,
pointing: Optional[str] = None
) -> None:
line = self.lines._order_lines.get(uuid)
pointing: Optional[str] = None,
) -> None:
'''
Fill msg handler.
Triggered on reception of a `filled` message from the
EMS.
Update relevant UIs:
- add arrow annotation on bar
- update fill bar size
'''
dialog = self.dialogs[uuid]
line = dialog.line
if line:
self.arrows.add(
uuid,
@ -174,17 +184,16 @@ class OrderMode:
pointing=pointing,
color=line.color
)
else:
log.warn("No line for order {uuid}!?")
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}')
) -> None:
# DESKTOP NOTIFICATIONS
#
@ -192,6 +201,7 @@ class OrderMode:
# not sure if this will ever be a bottleneck,
# we probably could do graphics stuff first tho?
# TODO: make this not trash.
# XXX: linux only for now
result = await trio.run_process(
[
@ -204,7 +214,11 @@ class OrderMode:
)
log.runtime(result)
def on_cancel(self, uuid: str) -> None:
def on_cancel(
self,
uuid: str
) -> None:
msg = self.book._sent_orders.pop(uuid, None)
@ -212,10 +226,9 @@ class OrderMode:
self.lines.remove_line(uuid=uuid)
self.chart.linked.cursor.show_xhair()
pending = self._pending_submissions.pop(uuid, None)
if pending:
order_line, func = pending
func()
dialog = self.dialogs.pop(uuid, None)
if dialog:
dialog.last_status_close()
else:
log.warning(
f'Received cancel for unsubmitted order {pformat(msg)}'
@ -225,7 +238,7 @@ class OrderMode:
self,
size: Optional[float] = None,
) -> LevelLine:
) -> OrderDialog:
"""Send execution order to EMS return a level line to
represent the order on a chart.
@ -234,7 +247,7 @@ class OrderMode:
# 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())
oid = str(uuid.uuid4())
size = size or self._size
@ -242,13 +255,49 @@ class OrderMode:
chart = cursor.active_plot
y = cursor._datum_xy[1]
symbol = self.chart._lc._symbol
symbol = self.chart.linked.symbol
action = self._action
# TODO: update the line once an ack event comes back
# from the EMS!
# TODO: place a grey line in "submission" mode
# which will be updated to it's appropriate action
# color once the submission ack arrives.
# make line graphic if order push was sucessful
line = self.lines.create_order_line(
oid,
level=y,
chart=chart,
size=size,
action=action,
)
dialog = OrderDialog(
uuid=oid,
line=line,
last_status_close=self.multistatus.open_status(
f'submitting {self._exec_mode}-{action}',
final_msg=f'submitted {self._exec_mode}-{action}',
clear_on_next=True,
)
)
# TODO: create a new ``OrderLine`` with this optional var defined
line.dialog = dialog
# enter submission which will be popped once a response
# from the EMS is received to move the order to a different# status
self.dialogs[oid] = dialog
# hook up mouse drag handlers
line._on_drag_start = self.order_line_modify_start
line._on_drag_end = self.order_line_modify_complete
# send order cmd to ems
self.book.send(
uuid=uid,
uuid=oid,
symbol=symbol.key,
brokers=symbol.brokers,
price=y,
@ -257,36 +306,7 @@ class OrderMode:
exec_mode=self._exec_mode,
)
# TODO: update the line once an ack event comes back
# from the EMS!
# 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
# enter submission which will be popped once a response
# from the EMS is received to move the order to a different# status
self._pending_submissions[uid] = (
line,
self.status_bar.open_status(
f'submitting {self._exec_mode}-{action}',
final_msg=f'submitted {self._exec_mode}-{action}',
clear_on_next=True,
)
)
# 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
return dialog
def cancel_orders_under_cursor(self) -> list[str]:
return self.cancel_orders_from_lines(
@ -309,7 +329,7 @@ class OrderMode:
ids: list = []
if lines:
key = self.status_bar.open_status(
key = self.multistatus.open_status(
f'cancelling {len(lines)} orders',
final_msg=f'cancelled {len(lines)} orders',
group_key=True
@ -317,16 +337,16 @@ class OrderMode:
# cancel all active orders and triggers
for line in lines:
oid = getattr(line, 'oid', None)
dialog = getattr(line, 'dialog', None)
if oid:
self._pending_submissions[oid] = (
line,
self.status_bar.open_status(
f'cancelling order {oid[:6]}',
group_key=key,
),
if dialog:
oid = dialog.uuid
cancel_status_close = self.multistatus.open_status(
f'cancelling order {oid[:6]}',
group_key=key,
)
dialog.last_status_close = cancel_status_close
ids.append(oid)
self.book.cancel(uuid=oid)
@ -338,91 +358,105 @@ class OrderMode:
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?
) -> None:
self.book.update(
uuid=line.dialog.uuid,
# TODO: must adjust sizing
# - should we round this to a nearest tick here and how?
# - need to recompute the size from the pp allocator
price=line.value(),
)
@asynccontextmanager
async def open_order_mode(
symbol: Symbol,
chart: pg.PlotWidget,
book: OrderBook,
):
status_bar: MultiStatus = main_window().status_bar
view = chart._vb
lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {})
log.info("Opening order mode")
mode = OrderMode(chart, book, lines, arrows, status_bar)
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(
async def run_order_mode(
chart: 'ChartPlotWidget', # noqa
symbol: Symbol,
brokername: str,
started: trio.Event,
) -> None:
'''Activate chart-trader order mode loop:
- connect to emsd
- load existing positions
- begin order handling loop
- begin EMS response handling loop which updates local
state, mostly graphics / UI.
'''
done = chart.window().status_bar.open_status('starting order mode..')
multistatus = chart.window().status_bar
done = multistatus.open_status('starting order mode..')
book: OrderBook
trades_stream: tractor.MsgStream
positions: dict
# spawn EMS actor-service
async with (
open_ems(brokername, symbol) as (book, trades_stream, positions),
open_order_mode(symbol, chart, book) as order_mode,
# # start async input handling for chart's view
# # await godwidget._task_stack.enter_async_context(
# chart._vb.open_async_input_handler(),
open_ems(brokername, symbol) as (
book,
trades_stream,
positions
),
):
view = chart.view
lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {})
# update any exising positions
log.info("Opening order mode")
pp = PositionTracker(chart)
pp.hide()
mode = OrderMode(
chart,
book,
lines,
arrows,
multistatus,
pp,
)
# TODO: create a mode "manager" of sorts?
# -> probably just call it "UxModes" err sumthin?
# so that view handlers can access it
view.mode = mode
asset_type = symbol.type_key
# default entry sizing
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
# update any exising position
for sym, msg in positions.items():
order_mode.on_position_update(msg)
our_sym = mode.chart.linked._symbol.key
if sym.lower() in our_sym:
pp.update(msg)
# TODO: this should go onto some sort of
# data-view strimg thinger..right?
def get_index(time: float):
# XXX: not sure why the time is so off here
@ -440,7 +474,12 @@ async def start_order_mode(
done()
# start async input handling for chart's view
async with chart._vb.open_async_input_handler():
async with (
chart._vb.open_async_input_handler(),
# TODO: config form handler nursery
):
# signal to top level symbol loading task we're ready
# to handle input since the ems connection is ready
@ -458,12 +497,29 @@ async def start_order_mode(
'position',
):
# show line label once order is live
order_mode.on_position_update(msg)
sym = mode.chart.linked.symbol
if msg['symbol'].lower() in sym.key:
pp.update(msg)
# short circuit to next msg to avoid
# uncessary msg content lookups
continue
resp = msg['resp']
oid = msg['oid']
dialog = mode.dialogs.get(oid)
if dialog is None:
log.warning(f'received msg for untracked dialog:\n{fmsg}')
# TODO: enable pure tracking / mirroring of dialogs
# is desired.
continue
# record message to dialog tracking
dialog.msgs[oid] = msg
# response to 'action' request (buy/sell)
if resp in (
'dark_submitted',
@ -471,7 +527,7 @@ async def start_order_mode(
):
# show line label once order is live
order_mode.on_submit(oid)
mode.on_submit(oid)
# resp to 'cancel' request or error condition
# for action request
@ -481,7 +537,7 @@ async def start_order_mode(
'dark_cancelled'
):
# delete level line from view
order_mode.on_cancel(oid)
mode.on_cancel(oid)
elif resp in (
'dark_triggered'
@ -493,18 +549,23 @@ async def start_order_mode(
):
# should only be one "fill" for an alert
# add a triangle and remove the level line
order_mode.on_fill(
mode.on_fill(
oid,
price=msg['trigger_price'],
arrow_index=get_index(time.time())
arrow_index=get_index(time.time()),
)
await order_mode.on_exec(oid, msg)
mode.lines.remove_line(uuid=oid)
await 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)
# right now this is just triggering a system alert
await mode.on_exec(oid, msg)
if msg['brokerd_msg']['remaining'] == 0:
mode.lines.remove_line(uuid=oid)
# each clearing tick is responded individually
elif resp in ('broker_filled',):
@ -518,7 +579,7 @@ async def start_order_mode(
details = msg['brokerd_msg']
# TODO: some kinda progress system
order_mode.on_fill(
mode.on_fill(
oid,
price=details['price'],
pointing='up' if action == 'buy' else 'down',
@ -526,3 +587,5 @@ async def start_order_mode(
# TODO: put the actual exchange timestamp
arrow_index=get_index(details['broker_time']),
)
pp.info.fills.append(msg)