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 = "" public_key = ""
private_key = "" private_key = ""
[ib.api] [ib]
ipaddr = "127.0.0.1" host = "127.0.0.1"
[ib.accounts] [ib.accounts]
margin = "" margin = ""
registered = "" registered = ""
paper = "" paper = ""
[ib.api.ports] [ib.ports]
gw = 4002 gw = 4002
tws = 7497 tws = 7497
order = [ "gw", "tws",] 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] lock = Brokerd.locks[service_name]
await lock.acquire() 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: async with tractor.find_actor(service_name) as portal:
if portal is not None: if portal is not None:
lock.release() 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 from pydantic import BaseModel
import wsproto import wsproto
from .api import open_cached_client from .._cacheables import open_cached_client
from ._util import resproc, SymbolNotFound from ._util import resproc, SymbolNotFound
from ..log import get_logger, get_console_log from ..log import get_logger, get_console_log
from ..data import ShmArray from ..data import ShmArray

View File

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

View File

@ -14,9 +14,14 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
""" '''
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 import time
from functools import partial from functools import partial
from dataclasses import dataclass, field from dataclasses import dataclass, field

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # 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 # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@ -171,6 +171,7 @@ _adhoc_futes_set = {
# equities # equities
'nq.globex', 'nq.globex',
'mnq.globex', 'mnq.globex',
'es.globex', 'es.globex',
'mes.globex', 'mes.globex',
@ -178,8 +179,20 @@ _adhoc_futes_set = {
'brr.cmecrypto', 'brr.cmecrypto',
'ethusdrr.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 # 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 # exchanges we don't support at the moment due to not knowing
@ -556,7 +569,7 @@ class Client:
else: else:
item = ('status', obj) item = ('status', obj)
log.info(f'eventkit event -> {eventkit_obj}: {item}') log.info(f'eventkit event ->\n{pformat(item)}')
try: try:
to_trio.send_nowait(item) to_trio.send_nowait(item)
@ -656,25 +669,28 @@ def get_config() -> dict[str, Any]:
section = conf.get('ib') section = conf.get('ib')
if not section: if section is None:
log.warning(f'No config section found for ib in {path}') log.warning(f'No config section found for ib in {path}')
return return {}
return section return section
@asynccontextmanager @asynccontextmanager
async def _aio_get_client( async def _aio_get_client(
host: str = '127.0.0.1', host: str = '127.0.0.1',
port: int = None, port: int = None,
client_id: Optional[int] = None, client_id: Optional[int] = None,
) -> Client: ) -> 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. Client instances are cached for later use.
TODO: consider doing this with a ctx mngr eventually? TODO: consider doing this with a ctx mngr eventually?
""" '''
conf = get_config() conf = get_config()
# first check cache for existing client # first check cache for existing client
@ -699,17 +715,21 @@ async def _aio_get_client(
ib = NonShittyIB() ib = NonShittyIB()
# attempt to get connection info from config # attempt to get connection info from config; if no .toml entry
ports = conf['api'].get( # exists, we try to load from a default localhost connection.
host = conf.get('host', '127.0.0.1')
ports = conf.get(
'ports', 'ports',
# default order is to check for gw first
{ {
# default order is to check for gw first
'gw': 4002, 'gw': 4002,
'tws': 7497, 'tws': 7497,
'order': ['gw', 'tws'] 'order': ['gw', 'tws']
} }
) )
order = ports['order'] order = ports['order']
try_ports = [ports[key] for key in order] try_ports = [ports[key] for key in order]
ports = try_ports if port is None else [port] 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 # start order request handler **before** local trades event loop
n.start_soon(handle_order_requests, ems_stream) 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: async for event_name, item in ib_trade_events_stream:
print(f' ib sending {item}')
# XXX: begin normalization of nonsense ib_insync internal # TODO: templating the ib statuses in comparison with other
# object-state tracking representations... # 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': if event_name == 'status':
# XXX: begin normalization of nonsense ib_insync internal
# object-state tracking representations...
# unwrap needed data from ib_insync internal types # unwrap needed data from ib_insync internal types
trade: Trade = item trade: Trade = item
status: OrderStatus = trade.orderStatus status: OrderStatus = trade.orderStatus
@ -1368,10 +1415,13 @@ async def trades_dialogue(
reqid=trade.order.orderId, reqid=trade.order.orderId,
time_ns=time.time_ns(), # cuz why not time_ns=time.time_ns(), # cuz why not
# everyone doin camel case..
status=status.status.lower(), # force lower case status=status.status.lower(), # force lower case
filled=status.filled, filled=status.filled,
reason=status.whyHeld, reason=status.whyHeld,
# this seems to not be necessarily up to date in the # this seems to not be necessarily up to date in the
# execDetails event.. so we have to send it here I guess? # execDetails event.. so we have to send it here I guess?
remaining=status.remaining, remaining=status.remaining,
@ -1442,14 +1492,14 @@ async def trades_dialogue(
if getattr(msg, 'reqid', 0) < -1: if getattr(msg, 'reqid', 0) < -1:
# it's a trade event generated by TWS usage. # 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) msg.reqid = 'tws-' + str(-1 * msg.reqid)
# mark msg as from "external system" # mark msg as from "external system"
# TODO: probably something better then this.. and start # TODO: probably something better then this.. and start
# considering multiplayer/group trades tracking # considering multiplayer/group trades tracking
msg.external = True msg.broker_details['external_src'] = 'tws'
continue continue
# XXX: we always serialize to a dict for msgpack # XXX: we always serialize to a dict for msgpack
@ -1462,9 +1512,8 @@ async def trades_dialogue(
@tractor.context @tractor.context
async def open_symbol_search( async def open_symbol_search(
ctx: tractor.Context, ctx: tractor.Context,
) -> None:
# async with open_cached_client('ib') as client:
) -> None:
# load all symbols locally for fast search # load all symbols locally for fast search
await ctx.started({}) await ctx.started({})
@ -1491,6 +1540,12 @@ async def open_symbol_search(
if not pattern or pattern.isspace(): if not pattern or pattern.isspace():
log.warning('empty pattern received, skipping..') 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 # XXX: this unblocks the far end search task which may
# hold up a multi-search nursery block # hold up a multi-search nursery block
await stream.send({}) await stream.send({})
@ -1498,7 +1553,7 @@ async def open_symbol_search(
continue continue
log.debug(f'searching for {pattern}') log.debug(f'searching for {pattern}')
# await tractor.breakpoint()
last = time.time() last = time.time()
results = await _trio_run_client_method( results = await _trio_run_client_method(
method='search_stocks', method='search_stocks',

View File

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

View File

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

View File

@ -38,7 +38,7 @@ log = get_logger(__name__)
@dataclass @dataclass
class OrderBook: 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 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 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 Currently, this is mostly for keeping local state to match the EMS
and use received events to trigger graphics updates. and use received events to trigger graphics updates.
""" '''
# mem channels used to relay order requests to the EMS daemon # mem channels used to relay order requests to the EMS daemon
_to_ems: trio.abc.SendChannel _to_ems: trio.abc.SendChannel
_from_order_book: trio.abc.ReceiveChannel _from_order_book: trio.abc.ReceiveChannel

View File

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

View File

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

View File

@ -35,7 +35,7 @@ from ..data._normalize import iterticks
from ..log import get_logger from ..log import get_logger
from ._messages import ( from ._messages import (
BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus, BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus,
BrokerdFill, BrokerdFill, BrokerdPosition,
) )
@ -60,6 +60,7 @@ class PaperBoi:
_buys: bidict _buys: bidict
_sells: bidict _sells: bidict
_reqids: bidict _reqids: bidict
_positions: dict[str, BrokerdPosition]
# init edge case L1 spread # init edge case L1 spread
last_ask: Tuple[float, float] = (float('inf'), 0) # price, size last_ask: Tuple[float, float] = (float('inf'), 0) # price, size
@ -101,6 +102,9 @@ class PaperBoi:
# in the broker trades event processing loop # in the broker trades event processing loop
await trio.sleep(0.05) await trio.sleep(0.05)
if action == 'sell':
size = -size
msg = BrokerdStatus( msg = BrokerdStatus(
status='submitted', status='submitted',
reqid=reqid, reqid=reqid,
@ -118,7 +122,7 @@ class PaperBoi:
) or ( ) or (
action == 'sell' and (clear_price := self.last_bid[0]) >= price 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: else:
# register this submissions as a paper live order # register this submissions as a paper live order
@ -170,6 +174,8 @@ class PaperBoi:
async def fake_fill( async def fake_fill(
self, self,
symbol: str,
price: float, price: float,
size: float, size: float,
action: str, # one of {'buy', 'sell'} action: str, # one of {'buy', 'sell'}
@ -181,6 +187,7 @@ class PaperBoi:
# remaining lots to fill # remaining lots to fill
order_complete: bool = True, order_complete: bool = True,
remaining: float = 0, remaining: float = 0,
) -> None: ) -> None:
"""Pretend to fill a broker order @ price and size. """Pretend to fill a broker order @ price and size.
@ -232,6 +239,49 @@ class PaperBoi:
) )
await self.ems_trades_stream.send(msg.dict()) 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( async def simulate_fills(
quote_stream: 'tractor.ReceiveStream', # noqa quote_stream: 'tractor.ReceiveStream', # noqa
@ -255,6 +305,7 @@ async def simulate_fills(
# this stream may eventually contain multiple symbols # this stream may eventually contain multiple symbols
async for quotes in quote_stream: async for quotes in quote_stream:
for sym, quote in quotes.items(): for sym, quote in quotes.items():
for tick in iterticks( for tick in iterticks(
@ -274,6 +325,7 @@ async def simulate_fills(
) )
orders = client._buys.get(sym, {}) orders = client._buys.get(sym, {})
book_sequence = reversed( book_sequence = reversed(
sorted(orders.keys(), key=itemgetter(1))) sorted(orders.keys(), key=itemgetter(1)))
@ -307,6 +359,7 @@ async def simulate_fills(
# clearing price would have filled entirely # clearing price would have filled entirely
await client.fake_fill( await client.fake_fill(
symbol=sym,
# todo slippage to determine fill price # todo slippage to determine fill price
price=tick_price, price=tick_price,
size=size, size=size,
@ -411,6 +464,9 @@ async def trades_dialogue(
_sells={}, _sells={},
_reqids={}, _reqids={},
# TODO: load paper positions from ``positions.toml``
_positions={},
) )
n.start_soon(handle_order_requests, client, ems_stream) n.start_soon(handle_order_requests, client, ems_stream)
@ -452,10 +508,5 @@ async def open_paperboi(
loglevel=loglevel, loglevel=loglevel,
) as (ctx, first): ) as (ctx, first):
try:
yield ctx, first
finally: yield ctx, first
# be sure to tear down the paper service on exit
with trio.CancelScope(shield=True):
await portal.cancel_actor()

View File

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

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # 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 from dataclasses import dataclass, asdict
@ -207,11 +207,16 @@ class ShmArray:
def push( def push(
self, self,
data: np.ndarray, data: np.ndarray,
prepend: bool = False, prepend: bool = False,
) -> int: ) -> int:
"""Ring buffer like "push" to append data '''Ring buffer like "push" to append data
into the buffer and return updated "last" index. 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) length = len(data)
if prepend: if prepend:

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0) # Copyright (C) 2018-present Tyler Goodlet (in stewardship for piker0)
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by

View File

@ -31,11 +31,14 @@ from typing import (
) )
import trio import trio
from trio.abc import ReceiveChannel
from trio_typing import TaskStatus from trio_typing import TaskStatus
import tractor import tractor
from tractor import _broadcast
from pydantic import BaseModel from pydantic import BaseModel
from ..brokers import get_brokermod from ..brokers import get_brokermod
from .._cacheables import maybe_open_ctx
from ..log import get_logger, get_console_log from ..log import get_logger, get_console_log
from .._daemon import ( from .._daemon import (
maybe_spawn_brokerd, maybe_spawn_brokerd,
@ -345,10 +348,10 @@ class Feed:
memory buffer orchestration. memory buffer orchestration.
""" """
name: str name: str
stream: AsyncIterator[dict[str, Any]]
shm: ShmArray shm: ShmArray
mod: ModuleType mod: ModuleType
first_quote: dict first_quote: dict
stream: trio.abc.ReceiveChannel[dict[str, Any]]
_brokerd_portal: tractor._portal.Portal _brokerd_portal: tractor._portal.Portal
_index_stream: Optional[AsyncIterator[int]] = None _index_stream: Optional[AsyncIterator[int]] = None
@ -362,7 +365,7 @@ class Feed:
symbols: dict[str, Symbol] = field(default_factory=dict) symbols: dict[str, Symbol] = field(default_factory=dict)
async def receive(self) -> dict: async def receive(self) -> dict:
return await self.stream.__anext__() return await self.stream.receive()
@asynccontextmanager @asynccontextmanager
async def index_stream( async def index_stream(
@ -376,8 +379,10 @@ class Feed:
# a lone broker-daemon per provider should be # a lone broker-daemon per provider should be
# created for all practical purposes # created for all practical purposes
async with self._brokerd_portal.open_stream_from( async with self._brokerd_portal.open_stream_from(
iter_ohlc_periods, iter_ohlc_periods,
delay_s=delay_s or self._max_sample_rate, delay_s=delay_s or self._max_sample_rate,
) as self._index_stream: ) as self._index_stream:
yield self._index_stream yield self._index_stream
@ -395,7 +400,7 @@ def sym_to_shm_key(
@asynccontextmanager @asynccontextmanager
async def install_brokerd_search( async def install_brokerd_search(
portal: tractor._portal.Portal, portal: tractor.Portal,
brokermod: ModuleType, brokermod: ModuleType,
) -> None: ) -> None:
@ -434,34 +439,21 @@ async def open_feed(
loglevel: Optional[str] = None, loglevel: Optional[str] = None,
tick_throttle: Optional[float] = None, # Hz 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. Open a "data feed" which provides streamed real-time quotes.
''' '''
sym = symbols[0].lower() 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: try:
mod = get_brokermod(brokername) mod = get_brokermod(brokername)
except ImportError: except ImportError:
mod = get_ingestormod(brokername) mod = get_ingestormod(brokername)
# no feed for broker exists so maybe spawn a data brokerd # no feed for broker exists so maybe spawn a data brokerd
async with ( async with (
maybe_spawn_brokerd( maybe_spawn_brokerd(
@ -480,21 +472,25 @@ async def open_feed(
) as (ctx, (init_msg, first_quote)), ) 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 # we can only read from shm
shm = attach_shm_array( shm = attach_shm_array(
token=init_msg[sym]['shm_token'], token=init_msg[sym]['shm_token'],
readonly=True, readonly=True,
) )
bstream = _broadcast.broadcast_receiver(
stream,
2**10,
)
feed = Feed( feed = Feed(
name=brokername, name=brokername,
stream=stream,
shm=shm, shm=shm,
mod=mod, mod=mod,
first_quote=first_quote, first_quote=first_quote,
stream=bstream, #brx_stream,
_brokerd_portal=portal, _brokerd_portal=portal,
) )
ohlc_sample_rates = [] ohlc_sample_rates = []
@ -530,3 +526,39 @@ async def open_feed(
finally: finally:
# drop the infinite stream connection # drop the infinite stream connection
await ctx.cancel() 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, ctx: tractor.Context,
symbol: str, symbol: str,
feed: Feed, feed: Feed,
stream: trio.abc.ReceiveChannel,
src: ShmArray, src: ShmArray,
dst: ShmArray, dst: ShmArray,
@ -93,14 +94,14 @@ async def fsp_compute(
yield {} yield {}
# task cancellation won't kill the channel # task cancellation won't kill the channel
with stream.shield(): # since we shielded at the `open_feed()` call
async for quotes in stream: async for quotes in stream:
for symbol, quotes in quotes.items(): for symbol, quotes in quotes.items():
if symbol == sym: if symbol == sym:
yield quotes yield quotes
out_stream = func( out_stream = func(
filter_by_sym(symbol, feed.stream), filter_by_sym(symbol, stream),
feed.shm, feed.shm,
) )
@ -164,7 +165,8 @@ async def cascade(
dst_shm_token: Tuple[str, np.dtype], dst_shm_token: Tuple[str, np.dtype],
symbol: str, symbol: str,
fsp_func_name: str, fsp_func_name: str,
) -> AsyncIterator[dict]:
) -> None:
"""Chain streaming signal processors and deliver output to """Chain streaming signal processors and deliver output to
destination mem buf. destination mem buf.
@ -175,7 +177,11 @@ async def cascade(
func: Callable = _fsps[fsp_func_name] func: Callable = _fsps[fsp_func_name]
# open a data feed stream with requested broker # 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 assert src.token == feed.shm.token
@ -186,6 +192,7 @@ async def cascade(
ctx=ctx, ctx=ctx,
symbol=symbol, symbol=symbol,
feed=feed, feed=feed,
stream=stream,
src=src, src=src,
dst=dst, 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. Annotations for ur faces.
""" """
import PyQt5 from typing import Callable, Optional
from PyQt5 import QtCore, QtGui
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF, QRectF
from PyQt5.QtWidgets import QGraphicsPathItem from PyQt5.QtWidgets import QGraphicsPathItem
from pyqtgraph import Point, functions as fn, Color from pyqtgraph import Point, functions as fn, Color
import numpy as np 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: ) -> QGraphicsPathItem:
"""Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem``
ready to be placed using scene coordinates (not view). 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: style String indicating the style of marker to add:
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
``'>|<'``, ``'^'``, ``'v'``, ``'o'`` ``'>|<'``, ``'^'``, ``'v'``, ``'o'``
size Size of the marker in pixels. Default is 10.0. size Size of the marker in pixels.
""" """
path = QtGui.QPainterPath() path = QtGui.QPainterPath()
@ -81,13 +85,147 @@ def mk_marker(
# self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) # self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
if use_qgpath:
path = QGraphicsPathItem(path)
path.scale(size, size)
return path 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( def qgo_draw_markers(
markers: list, markers: list,

View File

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

View File

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

View File

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

View File

@ -18,13 +18,42 @@
Qt event proxying and processing using ``trio`` mem chans. Qt event proxying and processing using ``trio`` mem chans.
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager, AsyncExitStack
from typing import Callable from typing import Callable
from PyQt5 import QtCore from PyQt5 import QtCore
from PyQt5.QtCore import QEvent from PyQt5.QtCore import QEvent
from PyQt5.QtWidgets import QWidget from PyQt5.QtWidgets import QWidget
import trio 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): class EventRelay(QtCore.QObject):
@ -67,22 +96,26 @@ class EventRelay(QtCore.QObject):
if etype in {QEvent.KeyPress, QEvent.KeyRelease}: 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? # TODO: is there a global setting for this?
if ev.isAutoRepeat() and self._filter_auto_repeats: if ev.isAutoRepeat() and self._filter_auto_repeats:
ev.ignore() ev.ignore()
return True return True
key = ev.key()
mods = ev.modifiers()
txt = ev.text()
# NOTE: the event object instance coming out # NOTE: the event object instance coming out
# the other side is mutated since Qt resumes event # the other side is mutated since Qt resumes event
# processing **before** running a ``trio`` guest mode # processing **before** running a ``trio`` guest mode
# tick, thus special handling or copying must be done. # tick, thus special handling or copying must be done.
# send elements to async handler # send keyboard msg to async handler
self._send_chan.send_nowait((ev, etype, key, mods, txt)) self._send_chan.send_nowait(msg)
else: else:
# send event to async handler # send event to async handler
@ -124,9 +157,9 @@ async def open_event_stream(
@asynccontextmanager @asynccontextmanager
async def open_handler( async def open_handlers(
source_widget: QWidget, source_widgets: list[QWidget],
event_types: set[QEvent], event_types: set[QEvent],
async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None], async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None],
**kwargs, **kwargs,
@ -135,7 +168,13 @@ async def open_handler(
async with ( async with (
trio.open_nursery() as n, 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 yield

View File

@ -99,6 +99,9 @@ def run_qtractor(
# "This is substantially faster than using a signal... for some # "This is substantially faster than using a signal... for some
# reason Qt signal dispatch is really slow (and relies on events # reason Qt signal dispatch is really slow (and relies on events
# underneath anyway, so this is strictly less work)." # 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()) REENTER_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType())
class ReenterEvent(QtCore.QEvent): 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 ..log import get_logger
from ._style import _min_points_to_show from ._style import _min_points_to_show
from ._editors import SelectRect from ._editors import SelectRect
from ._window import main_window
log = get_logger(__name__) log = get_logger(__name__)
@ -65,7 +64,8 @@ async def handle_viewmode_inputs(
'cc': mode.cancel_all_orders, '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}') log.debug(f'key: {key}, mods: {mods}, text: {text}')
now = time.time() now = time.time()
period = now - last period = now - last
@ -115,7 +115,7 @@ async def handle_viewmode_inputs(
Qt.Key_Space, Qt.Key_Space,
} }
): ):
view._chart._lc.godwidget.search.focus() view._chart.linked.godwidget.search.focus()
# esc and ctrl-c # esc and ctrl-c
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
@ -163,9 +163,20 @@ async def handle_viewmode_inputs(
else: else:
view.setMouseMode(ViewBox.PanMode) 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 # # ORDER MODE #
# live vs. dark trigger + an action {buy, sell, alert} # live vs. dark trigger + an action {buy, sell, alert}
order_keys_pressed = { order_keys_pressed = {
Qt.Key_A, Qt.Key_A,
Qt.Key_F, Qt.Key_F,
@ -173,6 +184,13 @@ async def handle_viewmode_inputs(
}.intersection(pressed) }.intersection(pressed)
if order_keys_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 ( if (
# 's' for "submit" to activate "live" order # 's' for "submit" to activate "live" order
Qt.Key_S in pressed or Qt.Key_S in pressed or
@ -201,17 +219,21 @@ async def handle_viewmode_inputs(
view.mode.set_exec(action) view.mode.set_exec(action)
prefix = trigger_mode + '-' if action != 'alert' else '' prefix = trigger_mode + '-' if action != 'alert' else ''
view._chart.window().mode_label.setText( view._chart.window().set_mode_name(f'{prefix}{action}')
f'mode: {prefix}{action}')
else: # none active else: # none active
# hide pp label
mode.pp.hide_info()
# mode.pp_config.hide()
# if none are pressed, remove "staged" level # if none are pressed, remove "staged" level
# line under cursor position # line under cursor position
view.mode.lines.unstage_line() view.mode.lines.unstage_line()
if view.hasFocus(): if view.hasFocus():
# update mode label # update mode label
view._chart.window().mode_label.setText('mode: view') view._chart.window().set_mode_name('view')
view.order_mode = False view.order_mode = False
@ -229,12 +251,13 @@ class ChartView(ViewBox):
- zoom on right-click-n-drag to cursor position - zoom on right-click-n-drag to cursor position
''' '''
mode_name: str = 'mode: view' mode_name: str = 'view'
def __init__( def __init__(
self, self,
name: str, name: str,
parent: pg.PlotItem = None, parent: pg.PlotItem = None,
**kwargs, **kwargs,
@ -251,7 +274,6 @@ class ChartView(ViewBox):
self.select_box = SelectRect(self) self.select_box = SelectRect(self)
self.addItem(self.select_box, ignoreBounds=True) self.addItem(self.select_box, ignoreBounds=True)
self.name = name
self.mode = None self.mode = None
self.order_mode: bool = False self.order_mode: bool = False
@ -260,11 +282,12 @@ class ChartView(ViewBox):
@asynccontextmanager @asynccontextmanager
async def open_async_input_handler( async def open_async_input_handler(
self, self,
) -> 'ChartView': ) -> 'ChartView':
from . import _event from . import _event
async with _event.open_handler( async with _event.open_handlers(
self, [self],
event_types={QEvent.KeyPress, QEvent.KeyRelease}, event_types={QEvent.KeyPress, QEvent.KeyRelease},
async_handler=handle_viewmode_inputs, 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 inspect import isfunction
from typing import Callable from typing import Callable, Optional
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtGui, QtWidgets 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: class Label:
""" """
A plain ol' "scene label" using an underlying ``QGraphicsTextItem``. A plain ol' "scene label" using an underlying ``QGraphicsTextItem``.
@ -110,13 +54,14 @@ class Label:
self, self,
view: pg.ViewBox, view: pg.ViewBox,
fmt_str: str, fmt_str: str,
color: str = 'bracket',
color: str = 'default_light',
x_offset: float = 0, x_offset: float = 0,
font_size: str = 'small', font_size: str = 'small',
opacity: float = 0.666, opacity: float = 1,
fields: dict = {} fields: dict = {},
update_on_range_change: bool = True,
) -> None: ) -> None:
@ -124,6 +69,8 @@ class Label:
self._fmt_str = fmt_str self._fmt_str = fmt_str
self._view_xy = QPointF(0, 0) self._view_xy = QPointF(0, 0)
self.scene_anchor: Optional[Callable[..., QPointF]] = None
self._x_offset = x_offset self._x_offset = x_offset
txt = self.txt = QtWidgets.QGraphicsTextItem() txt = self.txt = QtWidgets.QGraphicsTextItem()
@ -139,7 +86,8 @@ class Label:
txt.setOpacity(opacity) txt.setOpacity(opacity)
# register viewbox callbacks # 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._hcolor: str = ''
self.color = color self.color = color
@ -165,13 +113,34 @@ class Label:
self.txt.setDefaultTextColor(pg.mkColor(hcolor(color))) self.txt.setDefaultTextColor(pg.mkColor(hcolor(color)))
self._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: def on_sigrange_change(self, vr, r) -> None:
self.set_view_y(self._view_xy.y()) return self.update()
@property @property
def w(self) -> float: def w(self) -> float:
return self.txt.boundingRect().width() return self.txt.boundingRect().width()
def scene_br(self) -> QRectF:
txt = self.txt
return txt.mapToScene(
txt.boundingRect()
).boundingRect()
@property @property
def h(self) -> float: def h(self) -> float:
return self.txt.boundingRect().height() return self.txt.boundingRect().height()
@ -186,18 +155,20 @@ class Label:
assert isinstance(func(), float) assert isinstance(func(), float)
self._anchor_func = func self._anchor_func = func
def set_view_y( def set_view_pos(
self, self,
y: float, y: float,
x: Optional[float] = None,
) -> 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 # get new (inside the) view coordinates / position
self._view_xy = QPointF( self._view_xy = QPointF(x, y)
self.vb.mapToView(QPointF(scene_x, scene_x)).x(),
y,
)
# map back to the outer UI-land "scene" coordinates # map back to the outer UI-land "scene" coordinates
s_xy = self.vb.mapFromView(self._view_xy) s_xy = self.vb.mapFromView(self._view_xy)
@ -210,9 +181,6 @@ class Label:
assert s_xy == self.txt.pos() assert s_xy == self.txt.pos()
def orient_on(self, h: str, v: str) -> None:
pass
@property @property
def fmt_str(self) -> str: def fmt_str(self) -> str:
return self._fmt_str return self._fmt_str
@ -221,7 +189,11 @@ class Label:
def fmt_str(self, fmt_str: str) -> None: def fmt_str(self, fmt_str: str) -> None:
self._fmt_str = fmt_str self._fmt_str = fmt_str
def format(self, **fields: dict) -> str: def format(
self,
**fields: dict
) -> str:
out = {} out = {}
@ -229,8 +201,10 @@ class Label:
# calcs of field data from field data # calcs of field data from field data
# ex. to calculate a $value = price * size # ex. to calculate a $value = price * size
for k, v in fields.items(): for k, v in fields.items():
if isfunction(v): if isfunction(v):
out[k] = v(fields) out[k] = v(fields)
else: else:
out[k] = v out[k] = v

View File

@ -18,6 +18,7 @@
Lines for orders, alerts, L2. Lines for orders, alerts, L2.
""" """
from functools import partial
from math import floor from math import floor
from typing import Tuple, Optional, List from typing import Tuple, Optional, List
@ -25,10 +26,17 @@ import pyqtgraph as pg
from pyqtgraph import Point, functions as fn from pyqtgraph import Point, functions as fn
from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF from PyQt5.QtCore import QPointF
from PyQt5.QtGui import QGraphicsPathItem
from .._annotate import mk_marker, qgo_draw_markers from ._annotate import mk_marker_path, qgo_draw_markers
from .._label import Label, vbr_left, right_axis from ._anchors import (
from .._style import hcolor, _font 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 # 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 # https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt
class LevelLine(pg.InfiniteLine): class LevelLine(pg.InfiniteLine):
# TODO: fill in these slots for orders
# available parent signals
# sigDragged(self)
# sigPositionChangeFinished(self)
# sigPositionChanged(self)
def __init__( def __init__(
self, self,
chart: 'ChartPlotWidget', # type: ignore # noqa chart: 'ChartPlotWidget', # type: ignore # noqa
@ -50,7 +52,7 @@ class LevelLine(pg.InfiniteLine):
color: str = 'default', color: str = 'default',
highlight_color: str = 'default_light', highlight_color: str = 'default_light',
dotted: bool = False, dotted: bool = False,
marker_size: int = 20, # marker_size: int = 20,
# UX look and feel opts # UX look and feel opts
always_show_labels: bool = False, always_show_labels: bool = False,
@ -63,6 +65,9 @@ class LevelLine(pg.InfiniteLine):
) -> None: ) -> None:
# TODO: at this point it's probably not worth the inheritance
# any more since we've reimplemented ``.pain()`` among other
# things..
super().__init__( super().__init__(
movable=movable, movable=movable,
angle=0, angle=0,
@ -77,7 +82,7 @@ class LevelLine(pg.InfiniteLine):
self._hide_xhair_on_hover = hide_xhair_on_hover self._hide_xhair_on_hover = hide_xhair_on_hover
self._marker = None self._marker = None
self._default_mkr_size = marker_size # self._default_mkr_size = marker_size
self._moh = only_show_markers_on_hover self._moh = only_show_markers_on_hover
self.show_markers: bool = True # presuming the line is hovered at init 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 # list of labels anchored at one of the 2 line endpoints
# inside the viewbox # inside the viewbox
self._labels: List[(int, Label)] = [] self._labels: List[Label] = []
self._markers: List[(int, Label)] = [] self._markers: List[(int, Label)] = []
# whenever this line is moved trigger label updates # 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_start = lambda l: None
self._on_drag_end = lambda l: None self._on_drag_end = lambda l: None
self._y_incr_mult = 1 / chart._lc._symbol.tick_size self._y_incr_mult = 1 / chart.linked.symbol.tick_size
self._last_scene_y: float = 0
self._right_end_sc: float = 0 self._right_end_sc: float = 0
def txt_offsets(self) -> Tuple[int, int]: def txt_offsets(self) -> Tuple[int, int]:
@ -143,52 +146,6 @@ class LevelLine(pg.InfiniteLine):
hoverpen.setWidth(2) hoverpen.setWidth(2)
self.hoverPen = hoverpen 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( def on_pos_change(
self, self,
line: 'LevelLine', # noqa line: 'LevelLine', # noqa
@ -201,9 +158,11 @@ class LevelLine(pg.InfiniteLine):
def update_labels( def update_labels(
self, self,
fields_data: dict, fields_data: dict,
) -> None: ) -> None:
for at, label in self._labels: for label in self._labels:
label.color = self.color label.color = self.color
# print(f'color is {self.color}') # print(f'color is {self.color}')
@ -211,18 +170,18 @@ class LevelLine(pg.InfiniteLine):
level = fields_data.get('level') level = fields_data.get('level')
if level: if level:
label.set_view_y(level) label.set_view_pos(y=level)
label.render() label.render()
self.update() self.update()
def hide_labels(self) -> None: def hide_labels(self) -> None:
for at, label in self._labels: for label in self._labels:
label.hide() label.hide()
def show_labels(self) -> None: def show_labels(self) -> None:
for at, label in self._labels: for label in self._labels:
label.show() label.show()
def set_level( def set_level(
@ -245,15 +204,24 @@ class LevelLine(pg.InfiniteLine):
def on_tracked_source( def on_tracked_source(
self, self,
x: int, x: int,
y: float y: float
) -> None: ) -> None:
# XXX: this is called by our ``Cursor`` type once this '''Chart coordinates cursor tracking callback.
# line is set to track the cursor: for every movement
# this callback is invoked to reposition the line 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.movable = True
self.set_level(y) # implictly calls reposition handler self.set_level(y) # implictly calls reposition handler
self._chart.linked.godwidget.pp_config.model.get_order_info(
price=y
)
def mouseDragEvent(self, ev): def mouseDragEvent(self, ev):
"""Override the ``InfiniteLine`` handler since we need more """Override the ``InfiniteLine`` handler since we need more
detailed control and start end signalling. detailed control and start end signalling.
@ -316,9 +284,10 @@ class LevelLine(pg.InfiniteLine):
""" """
scene = self.scene() scene = self.scene()
if scene: if scene:
for at, label in self._labels: for label in self._labels:
label.delete() label.delete()
# gc managed labels?
self._labels.clear() self._labels.clear()
if self._marker: if self._marker:
@ -354,9 +323,11 @@ class LevelLine(pg.InfiniteLine):
def paint( def paint(
self, self,
p: QtGui.QPainter, p: QtGui.QPainter,
opt: QtWidgets.QStyleOptionGraphicsItem, opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.QWidget w: QtWidgets.QWidget
) -> None: ) -> None:
"""Core paint which we override (yet again) """Core paint which we override (yet again)
from pg.. from pg..
@ -366,26 +337,14 @@ class LevelLine(pg.InfiniteLine):
# these are in viewbox coords # these are in viewbox coords
vb_left, vb_right = self._endPoints 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() vb = self.getViewBox()
size = self._default_mkr_size line_end, marker_right, r_axis_x = marker_right_points(self._chart)
marker_right = up_to_l1_sc - (1.375 * 2*size)
line_end = marker_right - (6/16 * size)
if self.show_markers and self.markers: if self.show_markers and self.markers:
size = self.markers[0][2]
p.setPen(self.pen) p.setPen(self.pen)
size = qgo_draw_markers( qgo_draw_markers(
self.markers, self.markers,
self.pen.color(), self.pen.color(),
p, p,
@ -400,9 +359,14 @@ class LevelLine(pg.InfiniteLine):
# order lines.. not sure wtf is up with that. # order lines.. not sure wtf is up with that.
# for now we're just using it on the position line. # for now we're just using it on the position line.
elif self._marker: elif self._marker:
# TODO: make this label update part of a scene-aware-marker
# composed annotation
self._marker.setPos( self._marker.setPos(
QPointF(marker_right, self.scene_y()) QPointF(marker_right, self.scene_y())
) )
if hasattr(self._marker, 'label'):
self._marker.label.update()
elif not self.use_marker_margin: elif not self.use_marker_margin:
# basically means **don't** shorten the line with normally # basically means **don't** shorten the line with normally
@ -424,23 +388,35 @@ class LevelLine(pg.InfiniteLine):
super().hide() super().hide()
if self._marker: if self._marker:
self._marker.hide() self._marker.hide()
# self._marker.label.hide()
def scene_right_xy(self) -> QPointF: def show(self) -> None:
return self.getViewBox().mapFromView( super().show()
QPointF(0, self.value()) if self._marker:
) self._marker.show()
# self._marker.label.show()
def scene_y(self) -> float: 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( def add_marker(
self, self,
path: QtWidgets.QGraphicsPathItem, path: QtWidgets.QGraphicsPathItem,
) -> None:
# chart = self._chart ) -> QtWidgets.QGraphicsPathItem:
vb = self.getViewBox()
vb.scene().addItem(path) # add path to scene
self.getViewBox().scene().addItem(path)
self._marker = path self._marker = path
@ -451,7 +427,7 @@ class LevelLine(pg.InfiniteLine):
# y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y() # y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y()
path.setPos(QPointF(rsc, self.scene_y())) path.setPos(QPointF(rsc, self.scene_y()))
# self.update() return path
def hoverEvent(self, ev): def hoverEvent(self, ev):
"""Mouse hover callback. """Mouse hover callback.
@ -469,6 +445,9 @@ class LevelLine(pg.InfiniteLine):
if self._moh: if self._moh:
self.show_markers = True self.show_markers = True
if self._marker:
self._marker.show()
# highlight if so configured # highlight if so configured
if self._hoh: if self._hoh:
@ -512,11 +491,14 @@ class LevelLine(pg.InfiniteLine):
if self._moh: if self._moh:
self.show_markers = False self.show_markers = False
if self._marker:
self._marker.hide()
if self not in cur._trackers: if self not in cur._trackers:
cur.show_xhair(y_label_level=self.value()) cur.show_xhair(y_label_level=self.value())
if not self._always_show_labels: if not self._always_show_labels:
for at, label in self._labels: for label in self._labels:
label.hide() label.hide()
label.txt.update() label.txt.update()
# label.unhighlight() # label.unhighlight()
@ -529,24 +511,18 @@ class LevelLine(pg.InfiniteLine):
def level_line( def level_line(
chart: 'ChartPlotWidget', # noqa chart: 'ChartPlotWidget', # noqa
level: float, 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 # line style
dotted: bool = False, dotted: bool = False,
color: str = 'default',
# ux
hl_on_hover: bool = True,
# label fields and options # label fields and options
digits: int = 1,
always_show_labels: bool = False, always_show_labels: bool = False,
add_label: bool = True, add_label: bool = True,
orient_v: str = 'bottom', orient_v: str = 'bottom',
**kwargs, **kwargs,
) -> LevelLine: ) -> LevelLine:
@ -578,14 +554,31 @@ def level_line(
if add_label: if add_label:
label = line.add_label( label = Label(
side='right',
opacity=1,
x_offset=0,
avoid_book=False,
)
label.orient_v = orient_v
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}) line.update_labels({'level': level, 'level_digits': 2})
label.render() label.render()
@ -598,13 +591,14 @@ def level_line(
def order_line( def order_line(
chart, chart,
level: float, level: float,
level_digits: float, level_digits: float,
action: str, # buy or sell action: str, # buy or sell
size: Optional[int] = 1, size: Optional[int] = 1,
size_digits: int = 0, size_digits: int = 1,
show_markers: bool = False, show_markers: bool = False,
submit_price: float = None, submit_price: float = None,
exec_type: str = 'dark', exec_type: str = 'dark',
@ -641,43 +635,62 @@ def order_line(
'alert': ('v', alert_size), 'alert': ('v', alert_size),
}[action] }[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 line._maxMarkerSize = marker_size
# use ``QPathGraphicsItem``s to draw markers in scene coords # use ``QPathGraphicsItem``s to draw markers in scene coords
# instead of the old way that was doing the same but by # instead of the old way that was doing the same but by
# resetting the graphics item transform intermittently # 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? # 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 assert not line.markers
# the old way which is still somehow faster? # # manually append for later ``InfiniteLine.paint()`` drawing
path = mk_marker( # # XXX: this was manually tested as faster then using the
marker_style, # # QGraphicsItem around a painter path.. probably needs further
# the "position" here is now ignored since we modified # # testing to figure out why tf that's true.
# internals to pin markers to the right end of the line # line.markers.append((path, 0, marker_size))
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))
orient_v = 'top' if action == 'sell' else 'bottom' orient_v = 'top' if action == 'sell' else 'bottom'
if action == 'alert': 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.. # for now, we're just duplicating the label contents i guess..
llabel = line.add_label( line._labels.append(llabel)
side='left',
fmt_str=fmt_str, # anchor to left side of view / line
) llabel.set_x_anchor_func(vbr_left(llabel))
llabel.fields = { llabel.fields = {
'level': level, 'level': level,
'level_digits': level_digits, 'level_digits': level_digits,
@ -686,35 +699,34 @@ def order_line(
llabel.render() llabel.render()
llabel.show() llabel.show()
else: path.label = llabel
# # 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()
# right before L1 label else:
rlabel = line.add_label(
side='right', rlabel = Label(
side_of_axis='left',
x_offset=4*marker_size, view=line.getViewBox(),
fmt_str=(
'{size:.{size_digits}f} ' # 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 = { rlabel.fields = {
'size': size, 'size': size,
'size_digits': size_digits, # 'size_digits': size_digits,
} }
rlabel.orient_v = orient_v rlabel.orient_v = orient_v
@ -725,98 +737,3 @@ def order_line(
line.update_labels({'level': level}) line.update_labels({'level': level})
return line 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 numba import types as ntypes
# from ..data._source import numba_ohlc_dtype # from ..data._source import numba_ohlc_dtype
from ..._profile import pg_profile_enabled from .._profile import pg_profile_enabled
from .._style import hcolor from ._style import hcolor
def _mk_lines_array( 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 contextlib import asynccontextmanager
from functools import partial from functools import partial
from typing import ( from typing import (
List, Optional, Callable, Optional, Callable,
Awaitable, Sequence, Dict, Awaitable, Sequence,
Any, AsyncIterator, Tuple, Any, AsyncIterator
) )
import time import time
# from pprint import pformat # from pprint import pformat
@ -45,7 +45,7 @@ import time
from fuzzywuzzy import process as fuzzy from fuzzywuzzy import process as fuzzy
import trio import trio
from trio_typing import TaskStatus from trio_typing import TaskStatus
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore
from PyQt5 import QtWidgets from PyQt5 import QtWidgets
from PyQt5.QtCore import ( from PyQt5.QtCore import (
Qt, Qt,
@ -63,40 +63,24 @@ from PyQt5.QtWidgets import (
QTreeView, QTreeView,
# QListWidgetItem, # QListWidgetItem,
# QAbstractScrollArea, # QAbstractScrollArea,
QStyledItemDelegate, # QStyledItemDelegate,
) )
from ..log import get_logger from ..log import get_logger
from ._style import ( from ._style import (
_font, _font,
DpiAwareFont, hcolor,
# hcolor,
) )
from ._forms import FontAndChartAwareLineEdit, FontScaledDelegate
log = get_logger(__name__) 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): class CompleterView(QTreeView):
mode_name: str = 'mode: search-nav' mode_name: str = 'search-nav'
# XXX: relevant docs links: # XXX: relevant docs links:
# - simple widget version of this: # - simple widget version of this:
@ -121,7 +105,7 @@ class CompleterView(QTreeView):
def __init__( def __init__(
self, self,
parent=None, parent=None,
labels: List[str] = [], labels: list[str] = [],
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
@ -130,7 +114,7 @@ class CompleterView(QTreeView):
self.labels = labels self.labels = labels
# a std "tabular" config # a std "tabular" config
self.setItemDelegate(SimpleDelegate()) self.setItemDelegate(FontScaledDelegate(self))
self.setModel(model) self.setModel(model)
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
# TODO: size this based on DPI font # TODO: size this based on DPI font
@ -425,59 +409,28 @@ class CompleterView(QTreeView):
self.resize() self.resize()
class SearchBar(QtWidgets.QLineEdit): class SearchBar(FontAndChartAwareLineEdit):
mode_name: str = 'mode: search' mode_name: str = 'search'
def __init__( def __init__(
self, self,
parent: QWidget, parent: QWidget,
parent_chart: QWidget, # noqa godwidget: QWidget,
view: Optional[CompleterView] = None, view: Optional[CompleterView] = None,
font: DpiAwareFont = _font, **kwargs,
) -> None: ) -> None:
super().__init__(parent) self.godwidget = godwidget
super().__init__(parent, **kwargs)
# self.setContextMenuPolicy(Qt.CustomContextMenu)
# self.customContextMenuRequested.connect(self.show_menu)
# self.setStyleSheet(f"font: 18px")
self.view: CompleterView = view 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: def show(self) -> None:
super().show() super().show()
self.view.show_matches() 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: def unfocus(self) -> None:
self.parent().hide() self.parent().hide()
self.clearFocus() self.clearFocus()
@ -492,12 +445,12 @@ class SearchWidget(QtWidgets.QWidget):
Includes helper methods for item management in the sub-widgets. Includes helper methods for item management in the sub-widgets.
''' '''
mode_name: str = 'mode: search' mode_name: str = 'search'
def __init__( def __init__(
self, self,
godwidget: 'GodWidget', # type: ignore # noqa godwidget: 'GodWidget', # type: ignore # noqa
columns: List[str] = ['src', 'symbol'], columns: list[str] = ['src', 'symbol'],
parent=None, parent=None,
) -> None: ) -> None:
@ -512,7 +465,7 @@ class SearchWidget(QtWidgets.QWidget):
self.godwidget = godwidget self.godwidget = godwidget
self.vbox = QtWidgets.QVBoxLayout(self) self.vbox = QtWidgets.QVBoxLayout(self)
self.vbox.setContentsMargins(0, 0, 0, 0) self.vbox.setContentsMargins(0, 4, 4, 0)
self.vbox.setSpacing(4) self.vbox.setSpacing(4)
# split layout for the (label:| search bar entry) # split layout for the (label:| search bar entry)
@ -522,10 +475,17 @@ class SearchWidget(QtWidgets.QWidget):
# add label to left of search bar # add label to left of search bar
self.label = label = QtWidgets.QLabel(parent=self) 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.setTextFormat(3) # markdown
label.setFont(_font.font) label.setFont(_font.font)
label.setMargin(4) label.setMargin(4)
label.setText("`search`:") label.setText("search:")
label.show() label.show()
label.setAlignment( label.setAlignment(
QtCore.Qt.AlignVCenter QtCore.Qt.AlignVCenter
@ -540,8 +500,8 @@ class SearchWidget(QtWidgets.QWidget):
) )
self.bar = SearchBar( self.bar = SearchBar(
parent=self, parent=self,
parent_chart=godwidget,
view=self.view, view=self.view,
godwidget=godwidget,
) )
self.bar_hbox.addWidget(self.bar) self.bar_hbox.addWidget(self.bar)
@ -564,7 +524,7 @@ class SearchWidget(QtWidgets.QWidget):
self.bar.focus() self.bar.focus()
self.show() 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 '''Return the current completer tree selection as
a tuple ``(parent: str, child: str)`` if valid, else ``None``. a tuple ``(parent: str, child: str)`` if valid, else ``None``.
@ -599,11 +559,12 @@ class SearchWidget(QtWidgets.QWidget):
def chart_current_item( def chart_current_item(
self, self,
clear_to_cache: bool = True, clear_to_cache: bool = True,
) -> Optional[str]: ) -> Optional[str]:
'''Attempt to load and switch the current selected '''Attempt to load and switch the current selected
completion result to the affiliated chart app. completion result to the affiliated chart app.
Return any loaded symbol Return any loaded symbol.
''' '''
value = self.get_current_item() value = self.get_current_item()
@ -653,10 +614,11 @@ async def pack_matches(
view: CompleterView, view: CompleterView,
has_results: dict[str, set[str]], has_results: dict[str, set[str]],
matches: dict[(str, str), List[str]], matches: dict[(str, str), list[str]],
provider: str, provider: str,
pattern: str, pattern: str,
search: Callable[..., Awaitable[dict]], search: Callable[..., Awaitable[dict]],
task_status: TaskStatus[ task_status: TaskStatus[
trio.CancelScope] = trio.TASK_STATUS_IGNORED, trio.CancelScope] = trio.TASK_STATUS_IGNORED,
@ -834,7 +796,7 @@ async def handle_keyboard_input(
# startup # startup
bar = searchbar bar = searchbar
search = searchbar.parent() search = searchbar.parent()
chart = search.godwidget godwidget = search.godwidget
view = bar.view view = bar.view
view.set_font_size(bar.dpi_font.px_size) 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}') log.debug(f'key: {key}, mods: {mods}, txt: {txt}')
@ -861,11 +824,6 @@ async def handle_keyboard_input(
if mods == Qt.ControlModifier: if mods == Qt.ControlModifier:
ctl = True 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): if key in (Qt.Key_Enter, Qt.Key_Return):
search.chart_current_item(clear_to_cache=True) 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 # if nothing in search text show the cache
view.set_section_entries( view.set_section_entries(
'cache', 'cache',
list(reversed(chart._chart_cache)), list(reversed(godwidget._chart_cache)),
clear_all=True, clear_all=True,
) )
continue continue
@ -890,8 +848,8 @@ async def handle_keyboard_input(
search.bar.unfocus() search.bar.unfocus()
# kill the search and focus back on main chart # kill the search and focus back on main chart
if chart: if godwidget:
chart.linkedsplits.focus() godwidget.focus()
continue continue
@ -950,7 +908,7 @@ async def handle_keyboard_input(
async def search_simple_dict( async def search_simple_dict(
text: str, text: str,
source: dict, source: dict,
) -> Dict[str, Any]: ) -> dict[str, Any]:
# search routine can be specified as a function such # search routine can be specified as a function such
# as in the case of the current app's local symbol cache # 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 # cache of provider names to async search routines
_searcher_cache: Dict[str, Callable[..., Awaitable]] = {} _searcher_cache: dict[str, Callable[..., Awaitable]] = {}
@asynccontextmanager @asynccontextmanager

View File

@ -56,7 +56,6 @@ class DpiAwareFont:
self._qfont = QtGui.QFont(name) self._qfont = QtGui.QFont(name)
self._font_size: str = font_size self._font_size: str = font_size
self._qfm = QtGui.QFontMetrics(self._qfont) self._qfm = QtGui.QFontMetrics(self._qfont)
self._physical_dpi = None
self._font_inches: float = None self._font_inches: float = None
self._screen = None self._screen = None
@ -82,6 +81,10 @@ class DpiAwareFont:
def font(self): def font(self):
return self._qfont return self._qfont
def scale(self) -> float:
screen = self.screen
return screen.logicalDotsPerInch() / screen.physicalDotsPerInch()
@property @property
def px_size(self) -> int: def px_size(self) -> int:
return self._qfont.pixelSize() return self._qfont.pixelSize()
@ -114,14 +117,14 @@ class DpiAwareFont:
# dpi is likely somewhat scaled down so use slightly larger font size # dpi is likely somewhat scaled down so use slightly larger font size
if scale > 1 and self._font_size: if scale > 1 and self._font_size:
# TODO: this denominator should probably be determined from # 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) inches = inches * (1 / scale) * (1 + 6/16)
dpi = mx_dpi dpi = mx_dpi
self._font_inches = inches self._font_inches = inches
font_size = math.floor(inches * dpi) font_size = math.floor(inches * dpi)
log.info( log.debug(
f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}" f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}"
f"\nOur best guess font size is {font_size}\n" 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() self._status_label = label = QtGui.QLabel()
label.setStyleSheet( label.setStyleSheet(
f"QLabel {{ color : {hcolor('gunmetal')}; }}" f"""QLabel {{
color : {hcolor('gunmetal')};
}}
"""
# font-size : {font_size}px;
) )
label.setTextFormat(3) # markdown label.setTextFormat(3) # markdown
label.setFont(_font_small.font) label.setFont(_font_small.font)
@ -181,11 +185,13 @@ class MainWindow(QtGui.QMainWindow):
def closeEvent( def closeEvent(
self, 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. # raising KBI seems to get intercepted by by Qt so just use the system.
os.kill(os.getpid(), signal.SIGINT) os.kill(os.getpid(), signal.SIGINT)
@ -209,18 +215,28 @@ class MainWindow(QtGui.QMainWindow):
return self._status_bar return self._status_bar
def on_focus_change( def set_mode_name(
self, self,
old: QtGui.QWidget, name: str,
new: QtGui.QWidget,
) -> None: ) -> 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? # cursor left window?
name = getattr(new, 'mode_name', '') name = getattr(current, 'mode_name', '')
self.mode_label.setText(name) self.set_mode_name(name)
def current_screen(self) -> QtGui.QScreen: def current_screen(self) -> QtGui.QScreen:
"""Get a frickin screen (if we can, gawd). """Get a frickin screen (if we can, gawd).
@ -230,7 +246,7 @@ class MainWindow(QtGui.QMainWindow):
for _ in range(3): for _ in range(3):
screen = app.screenAt(self.pos()) screen = app.screenAt(self.pos())
print('trying to access QScreen...') log.debug('trying to access QScreen...')
if screen is None: if screen is None:
time.sleep(0.5) time.sleep(0.5)
continue continue

View File

@ -18,50 +18,61 @@
Chart trading, the only way to scalp. Chart trading, the only way to scalp.
""" """
from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pprint import pformat from pprint import pformat
import time import time
from typing import Optional, Dict, Callable, Any from typing import Optional, Dict, Callable, Any
import uuid import uuid
import pyqtgraph as pg
from pydantic import BaseModel from pydantic import BaseModel
import tractor
import trio 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 ..clearing._client import open_ems, OrderBook
from ..data._source import Symbol from ..data._source import Symbol
from ..log import get_logger 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__) log = get_logger(__name__)
class Position(BaseModel): class OrderDialog(BaseModel):
symbol: Symbol '''Trade dialogue meta-data describing the lifetime
size: float of an order submission to ``emsd`` from a chart.
avg_price: float
'''
# TODO: use ``pydantic.UUID4`` field
uuid: str
line: LevelLine
last_status_close: Callable = lambda: None
msgs: dict[str, dict] = {}
fills: Dict[str, Any] = {} fills: Dict[str, Any] = {}
class Config:
arbitrary_types_allowed = True
underscore_attrs_are_private = False
@dataclass @dataclass
class OrderMode: 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" This is the other "main" mode that pairs with "view mode" (when
(when wathing the rt price update at the current time step) wathing the rt price update at the current time step) and allows
and allows entering orders using mouse and keyboard. entering orders using mouse and keyboard. This object is chart
This object is chart oriented, so there is an instance per oriented, so there is an instance per chart / view currently.
chart / view currently.
Current manual: Current manual:
a -> alert a -> alert
s/ctrl -> submission type modifier {on: live, off: dark} s/ctrl -> submission type modifier {on: live, off: dark}
f (fill) -> buy limit order f (fill) -> 'buy' limit order
d (dump) -> sell limit order d (dump) -> 'sell' limit order
c (cancel) -> cancel order under cursor c (cancel) -> cancel order under cursor
cc -> cancel all submitted orders on chart cc -> cancel all submitted orders on chart
mouse click and drag -> modify current order under cursor mouse click and drag -> modify current order under cursor
@ -71,7 +82,9 @@ class OrderMode:
book: OrderBook book: OrderBook
lines: LineEditor lines: LineEditor
arrows: ArrowEditor arrows: ArrowEditor
status_bar: MultiStatus multistatus: MultiStatus
pp: PositionTracker
name: str = 'order' name: str = 'order'
_colors = { _colors = {
@ -82,47 +95,27 @@ class OrderMode:
_action: str = 'alert' _action: str = 'alert'
_exec_mode: str = 'dark' _exec_mode: str = 'dark'
_size: float = 100.0 _size: float = 100.0
_position: Dict[str, Any] = field(default_factory=dict)
_position_line: dict = None
_pending_submissions: dict[str, (LevelLine, Callable)] = field( dialogs: dict[str, OrderDialog] = field(default_factory=dict)
default_factory=dict)
def on_position_update(
self,
msg: dict,
) -> None:
print(f'Position update {msg}')
sym = self.chart._lc._symbol
if msg['symbol'].lower() not in sym.key:
return
size = msg['size']
self._position.update(msg)
if self._position_line:
self._position_line.delete()
if size != 0.0:
line = self._position_line = position_line(
self.chart,
level=msg['avg_price'],
size=size,
)
line.show()
def uuid(self) -> str: def uuid(self) -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
@property
def pp_config(self) -> FieldsForm:
return self.chart.linked.godwidget.pp_config
def set_exec( def set_exec(
self, self,
action: str, action: str,
size: Optional[int] = None, size: Optional[int] = None,
) -> None:
"""Set execution mode.
""" ) -> None:
'''
Set execution mode.
'''
# not initialized yet # not initialized yet
if not self.chart.linked.cursor: if not self.chart.linked.cursor:
return return
@ -139,33 +132,50 @@ class OrderMode:
action=action, action=action,
) )
def on_submit(self, uuid: str) -> dict: def on_submit(
"""On order submitted event, commit the order line self,
and registered order uuid, store ack time stamp. uuid: str
TODO: annotate order line with submission type ('live' vs. ) -> OrderDialog:
'dark'). '''
Order submitted status event handler.
""" Commit the order line and registered order uuid, store ack time stamp.
'''
line = self.lines.commit_line(uuid) line = self.lines.commit_line(uuid)
pending = self._pending_submissions.get(uuid) # a submission is the start of a new order dialog
if pending: dialog = self.dialogs[uuid]
order_line, func = pending dialog.line = line
assert order_line is line dialog.last_status_close()
func()
return line return dialog
def on_fill( def on_fill(
self, self,
uuid: str, uuid: str,
price: float, price: float,
arrow_index: 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: if line:
self.arrows.add( self.arrows.add(
uuid, uuid,
@ -174,17 +184,16 @@ class OrderMode:
pointing=pointing, pointing=pointing,
color=line.color color=line.color
) )
else:
log.warn("No line for order {uuid}!?")
async def on_exec( async def on_exec(
self, self,
uuid: str, uuid: str,
msg: Dict[str, Any], msg: Dict[str, Any],
) -> None:
# only once all fills have cleared and the execution ) -> None:
# is complet do we remove our "order line"
line = self.lines.remove_line(uuid=uuid)
log.debug(f'deleting {line} with oid: {uuid}')
# DESKTOP NOTIFICATIONS # DESKTOP NOTIFICATIONS
# #
@ -192,6 +201,7 @@ class OrderMode:
# not sure if this will ever be a bottleneck, # not sure if this will ever be a bottleneck,
# we probably could do graphics stuff first tho? # we probably could do graphics stuff first tho?
# TODO: make this not trash.
# XXX: linux only for now # XXX: linux only for now
result = await trio.run_process( result = await trio.run_process(
[ [
@ -204,7 +214,11 @@ class OrderMode:
) )
log.runtime(result) 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) msg = self.book._sent_orders.pop(uuid, None)
@ -212,10 +226,9 @@ class OrderMode:
self.lines.remove_line(uuid=uuid) self.lines.remove_line(uuid=uuid)
self.chart.linked.cursor.show_xhair() self.chart.linked.cursor.show_xhair()
pending = self._pending_submissions.pop(uuid, None) dialog = self.dialogs.pop(uuid, None)
if pending: if dialog:
order_line, func = pending dialog.last_status_close()
func()
else: else:
log.warning( log.warning(
f'Received cancel for unsubmitted order {pformat(msg)}' f'Received cancel for unsubmitted order {pformat(msg)}'
@ -225,7 +238,7 @@ class OrderMode:
self, self,
size: Optional[float] = None, size: Optional[float] = None,
) -> LevelLine: ) -> OrderDialog:
"""Send execution order to EMS return a level line to """Send execution order to EMS return a level line to
represent the order on a chart. represent the order on a chart.
@ -234,7 +247,7 @@ class OrderMode:
# to be displayed when above order ack arrives # to be displayed when above order ack arrives
# (means the line graphic doesn't show on screen until the # (means the line graphic doesn't show on screen until the
# order is live in the emsd). # order is live in the emsd).
uid = str(uuid.uuid4()) oid = str(uuid.uuid4())
size = size or self._size size = size or self._size
@ -242,13 +255,49 @@ class OrderMode:
chart = cursor.active_plot chart = cursor.active_plot
y = cursor._datum_xy[1] y = cursor._datum_xy[1]
symbol = self.chart._lc._symbol symbol = self.chart.linked.symbol
action = self._action 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 # send order cmd to ems
self.book.send( self.book.send(
uuid=uid, uuid=oid,
symbol=symbol.key, symbol=symbol.key,
brokers=symbol.brokers, brokers=symbol.brokers,
price=y, price=y,
@ -257,36 +306,7 @@ class OrderMode:
exec_mode=self._exec_mode, exec_mode=self._exec_mode,
) )
# TODO: update the line once an ack event comes back return dialog
# 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
def cancel_orders_under_cursor(self) -> list[str]: def cancel_orders_under_cursor(self) -> list[str]:
return self.cancel_orders_from_lines( return self.cancel_orders_from_lines(
@ -309,7 +329,7 @@ class OrderMode:
ids: list = [] ids: list = []
if lines: if lines:
key = self.status_bar.open_status( key = self.multistatus.open_status(
f'cancelling {len(lines)} orders', f'cancelling {len(lines)} orders',
final_msg=f'cancelled {len(lines)} orders', final_msg=f'cancelled {len(lines)} orders',
group_key=True group_key=True
@ -317,16 +337,16 @@ class OrderMode:
# cancel all active orders and triggers # cancel all active orders and triggers
for line in lines: for line in lines:
oid = getattr(line, 'oid', None) dialog = getattr(line, 'dialog', None)
if oid: if dialog:
self._pending_submissions[oid] = ( oid = dialog.uuid
line,
self.status_bar.open_status( cancel_status_close = self.multistatus.open_status(
f'cancelling order {oid[:6]}', f'cancelling order {oid[:6]}',
group_key=key, group_key=key,
),
) )
dialog.last_status_close = cancel_status_close
ids.append(oid) ids.append(oid)
self.book.cancel(uuid=oid) self.book.cancel(uuid=oid)
@ -338,91 +358,105 @@ class OrderMode:
def order_line_modify_start( def order_line_modify_start(
self, self,
line: LevelLine, line: LevelLine,
) -> None: ) -> None:
print(f'Line modify: {line}') print(f'Line modify: {line}')
# cancel original order until new position is found # cancel original order until new position is found
def order_line_modify_complete( def order_line_modify_complete(
self, self,
line: LevelLine, 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(), price=line.value(),
) )
@asynccontextmanager async def run_order_mode(
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(
chart: 'ChartPlotWidget', # noqa chart: 'ChartPlotWidget', # noqa
symbol: Symbol, symbol: Symbol,
brokername: str, brokername: str,
started: trio.Event, started: trio.Event,
) -> None: ) -> None:
'''Activate chart-trader order mode loop: '''Activate chart-trader order mode loop:
- connect to emsd - connect to emsd
- load existing positions - 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 # spawn EMS actor-service
async with ( 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 open_ems(brokername, symbol) as (
# # await godwidget._task_stack.enter_async_context( book,
# chart._vb.open_async_input_handler(), 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(): 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): def get_index(time: float):
# XXX: not sure why the time is so off here # XXX: not sure why the time is so off here
@ -440,7 +474,12 @@ async def start_order_mode(
done() done()
# start async input handling for chart's view # 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 # signal to top level symbol loading task we're ready
# to handle input since the ems connection is ready # to handle input since the ems connection is ready
@ -458,12 +497,29 @@ async def start_order_mode(
'position', 'position',
): ):
# show line label once order is live # 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 continue
resp = msg['resp'] resp = msg['resp']
oid = msg['oid'] 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) # response to 'action' request (buy/sell)
if resp in ( if resp in (
'dark_submitted', 'dark_submitted',
@ -471,7 +527,7 @@ async def start_order_mode(
): ):
# show line label once order is live # show line label once order is live
order_mode.on_submit(oid) mode.on_submit(oid)
# resp to 'cancel' request or error condition # resp to 'cancel' request or error condition
# for action request # for action request
@ -481,7 +537,7 @@ async def start_order_mode(
'dark_cancelled' 'dark_cancelled'
): ):
# delete level line from view # delete level line from view
order_mode.on_cancel(oid) mode.on_cancel(oid)
elif resp in ( elif resp in (
'dark_triggered' 'dark_triggered'
@ -493,18 +549,23 @@ async def start_order_mode(
): ):
# should only be one "fill" for an alert # should only be one "fill" for an alert
# add a triangle and remove the level line # add a triangle and remove the level line
order_mode.on_fill( mode.on_fill(
oid, oid,
price=msg['trigger_price'], 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 # response to completed 'action' request for buy/sell
elif resp in ( elif resp in (
'broker_executed', '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 # each clearing tick is responded individually
elif resp in ('broker_filled',): elif resp in ('broker_filled',):
@ -518,7 +579,7 @@ async def start_order_mode(
details = msg['brokerd_msg'] details = msg['brokerd_msg']
# TODO: some kinda progress system # TODO: some kinda progress system
order_mode.on_fill( mode.on_fill(
oid, oid,
price=details['price'], price=details['price'],
pointing='up' if action == 'buy' else 'down', pointing='up' if action == 'buy' else 'down',
@ -526,3 +587,5 @@ async def start_order_mode(
# TODO: put the actual exchange timestamp # TODO: put the actual exchange timestamp
arrow_index=get_index(details['broker_time']), arrow_index=get_index(details['broker_time']),
) )
pp.info.fills.append(msg)