Compare commits
107 Commits
310_plus
...
ordermodep
Author | SHA1 | Date |
---|---|---|
Tyler Goodlet | 7592ae7be7 | |
Tyler Goodlet | 112615e374 | |
Tyler Goodlet | ef27a4f4e2 | |
Tyler Goodlet | 27ba57217a | |
Tyler Goodlet | d7cc234a78 | |
Tyler Goodlet | 7a8e612228 | |
Tyler Goodlet | ebfb700cd2 | |
Tyler Goodlet | 61c6bbb592 | |
Tyler Goodlet | cc40048ab2 | |
Tyler Goodlet | 3d4898c4d5 | |
Tyler Goodlet | 6f30ae448a | |
Tyler Goodlet | cab1cf4a00 | |
Tyler Goodlet | 2340a1666b | |
Tyler Goodlet | b2a1c8882b | |
Tyler Goodlet | e9f892916e | |
Tyler Goodlet | b535effc52 | |
Tyler Goodlet | 79000b93cb | |
Tyler Goodlet | 9b2b40598d | |
Tyler Goodlet | 68d2000909 | |
Tyler Goodlet | 5ae16bf73e | |
Tyler Goodlet | a57d92c8bd | |
Tyler Goodlet | 5fe8cb7e53 | |
Tyler Goodlet | d0ad5e43f9 | |
Tyler Goodlet | f5beb22d6e | |
Tyler Goodlet | 37de9e581c | |
Tyler Goodlet | 2e086375e7 | |
Tyler Goodlet | 8296bc2699 | |
Tyler Goodlet | d1f5e3f62a | |
Tyler Goodlet | 4974579e73 | |
Tyler Goodlet | 637364d1c3 | |
Tyler Goodlet | d69b52ac8c | |
Tyler Goodlet | ccf79aecf1 | |
Tyler Goodlet | 12e7ceae2b | |
Tyler Goodlet | 202817bc4d | |
Tyler Goodlet | 66b242e19e | |
Tyler Goodlet | 177a75adcc | |
Tyler Goodlet | 770ae75210 | |
Tyler Goodlet | 2ddf40b8d3 | |
Tyler Goodlet | 472cf036cb | |
Tyler Goodlet | a68f4b0593 | |
Tyler Goodlet | 4d66c7ad88 | |
Tyler Goodlet | 457cc1a128 | |
Tyler Goodlet | 622da73c40 | |
Tyler Goodlet | 8ca6cc180d | |
Tyler Goodlet | 12c37f3388 | |
Tyler Goodlet | 01261d601a | |
Tyler Goodlet | f27db80bf4 | |
Tyler Goodlet | 4336939507 | |
Tyler Goodlet | fd73d1eef1 | |
Tyler Goodlet | 3302d21086 | |
Tyler Goodlet | 39ad1ab18f | |
Tyler Goodlet | 43a9fc60e3 | |
Tyler Goodlet | 27cece20c5 | |
Tyler Goodlet | a94a86fed1 | |
Tyler Goodlet | 0a7ef0cb67 | |
Tyler Goodlet | e80ca26649 | |
Tyler Goodlet | 9c07db368d | |
Tyler Goodlet | 5c58d0b5fc | |
Tyler Goodlet | af3e5e53bc | |
Tyler Goodlet | df9e3654f0 | |
Tyler Goodlet | 39edbc126a | |
Tyler Goodlet | f30bf3102d | |
Tyler Goodlet | ec6639f275 | |
Tyler Goodlet | 501828d906 | |
Tyler Goodlet | 67721a5832 | |
Tyler Goodlet | 9887b14518 | |
Tyler Goodlet | 0f417f8c80 | |
Tyler Goodlet | 393446b933 | |
Tyler Goodlet | d034d7e6b1 | |
Tyler Goodlet | 1048ea14d3 | |
Tyler Goodlet | 37cd800135 | |
Tyler Goodlet | 5d94ee7479 | |
Tyler Goodlet | 40a38284df | |
Tyler Goodlet | 6cdb2fca41 | |
Tyler Goodlet | fd425dca29 | |
Tyler Goodlet | 5eada47cbf | |
Tyler Goodlet | ca2729d2c0 | |
Tyler Goodlet | 174b9ce0cf | |
Tyler Goodlet | 86e71a232f | |
Tyler Goodlet | c98b60f7aa | |
Tyler Goodlet | 480e5634c4 | |
Tyler Goodlet | 271bf67e78 | |
Tyler Goodlet | 23b77fffc6 | |
Tyler Goodlet | f9f4fdca7e | |
Tyler Goodlet | c6a02d1bbf | |
Tyler Goodlet | 5bd764e0e9 | |
Tyler Goodlet | 8df399b8c1 | |
Tyler Goodlet | d323b0c34b | |
Tyler Goodlet | 08fe6fc4f3 | |
Tyler Goodlet | 6485e64d41 | |
Tyler Goodlet | 1a870364c5 | |
Tyler Goodlet | 34a773821e | |
Tyler Goodlet | 2d42da6f1a | |
Tyler Goodlet | 0ddeded03d | |
Tyler Goodlet | ee5d5b0cef | |
Tyler Goodlet | d25be4c970 | |
Tyler Goodlet | 422b81f0eb | |
Tyler Goodlet | 2fd7ea812a | |
Tyler Goodlet | 2d787901f9 | |
Tyler Goodlet | 5a271f9a5e | |
Tyler Goodlet | 94275c9be8 | |
Tyler Goodlet | 581134f39c | |
Tyler Goodlet | 5a303ede1e | |
Tyler Goodlet | ee25b57895 | |
Tyler Goodlet | bc3bcd6a07 | |
Tyler Goodlet | 55d67cc5c6 | |
Tyler Goodlet | 155d7b2a73 |
|
@ -11,15 +11,15 @@ key_descr = "api_0"
|
|||
public_key = ""
|
||||
private_key = ""
|
||||
|
||||
[ib.api]
|
||||
ipaddr = "127.0.0.1"
|
||||
[ib]
|
||||
host = "127.0.0.1"
|
||||
|
||||
[ib.accounts]
|
||||
margin = ""
|
||||
registered = ""
|
||||
paper = ""
|
||||
|
||||
[ib.api.ports]
|
||||
[ib.ports]
|
||||
gw = 4002
|
||||
tws = 7497
|
||||
order = [ "gw", "tws",]
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -283,7 +283,7 @@ async def maybe_spawn_daemon(
|
|||
lock = Brokerd.locks[service_name]
|
||||
await lock.acquire()
|
||||
|
||||
# attach to existing brokerd if possible
|
||||
# attach to existing daemon by name if possible
|
||||
async with tractor.find_actor(service_name) as portal:
|
||||
if portal is not None:
|
||||
lock.release()
|
||||
|
|
|
@ -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()
|
|
@ -33,7 +33,7 @@ from pydantic.dataclasses import dataclass
|
|||
from pydantic import BaseModel
|
||||
import wsproto
|
||||
|
||||
from .api import open_cached_client
|
||||
from .._cacheables import open_cached_client
|
||||
from ._util import resproc, SymbolNotFound
|
||||
from ..log import get_logger, get_console_log
|
||||
from ..data import ShmArray
|
||||
|
|
|
@ -29,7 +29,7 @@ import trio
|
|||
from ..log import get_logger
|
||||
from . import get_brokermod
|
||||
from .._daemon import maybe_spawn_brokerd
|
||||
from .api import open_cached_client
|
||||
from .._cacheables import open_cached_client
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
|
@ -14,9 +14,14 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Real-time data feed machinery
|
||||
"""
|
||||
'''
|
||||
NB: this is the old original implementation that was used way way back
|
||||
when the project started with ``kivy``.
|
||||
|
||||
This code is left for reference but will likely be merged in
|
||||
appropriately and removed.
|
||||
|
||||
'''
|
||||
import time
|
||||
from functools import partial
|
||||
from dataclasses import dataclass, field
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
||||
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
@ -171,6 +171,7 @@ _adhoc_futes_set = {
|
|||
# equities
|
||||
'nq.globex',
|
||||
'mnq.globex',
|
||||
|
||||
'es.globex',
|
||||
'mes.globex',
|
||||
|
||||
|
@ -178,8 +179,20 @@ _adhoc_futes_set = {
|
|||
'brr.cmecrypto',
|
||||
'ethusdrr.cmecrypto',
|
||||
|
||||
# agriculture
|
||||
'he.globex', # lean hogs
|
||||
'le.globex', # live cattle (geezers)
|
||||
'gf.globex', # feeder cattle (younguns)
|
||||
|
||||
# raw
|
||||
'lb.globex', # random len lumber
|
||||
|
||||
# metals
|
||||
'xauusd.cmdty',
|
||||
'xauusd.cmdty', # gold spot
|
||||
'gc.nymex',
|
||||
'mgc.nymex',
|
||||
|
||||
'xagusd.cmdty', # silver spot
|
||||
}
|
||||
|
||||
# exchanges we don't support at the moment due to not knowing
|
||||
|
@ -556,7 +569,7 @@ class Client:
|
|||
else:
|
||||
item = ('status', obj)
|
||||
|
||||
log.info(f'eventkit event -> {eventkit_obj}: {item}')
|
||||
log.info(f'eventkit event ->\n{pformat(item)}')
|
||||
|
||||
try:
|
||||
to_trio.send_nowait(item)
|
||||
|
@ -656,25 +669,28 @@ def get_config() -> dict[str, Any]:
|
|||
|
||||
section = conf.get('ib')
|
||||
|
||||
if not section:
|
||||
if section is None:
|
||||
log.warning(f'No config section found for ib in {path}')
|
||||
return
|
||||
return {}
|
||||
|
||||
return section
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _aio_get_client(
|
||||
|
||||
host: str = '127.0.0.1',
|
||||
port: int = None,
|
||||
|
||||
client_id: Optional[int] = None,
|
||||
|
||||
) -> Client:
|
||||
"""Return an ``ib_insync.IB`` instance wrapped in our client API.
|
||||
'''Return an ``ib_insync.IB`` instance wrapped in our client API.
|
||||
|
||||
Client instances are cached for later use.
|
||||
|
||||
TODO: consider doing this with a ctx mngr eventually?
|
||||
"""
|
||||
'''
|
||||
conf = get_config()
|
||||
|
||||
# first check cache for existing client
|
||||
|
@ -699,17 +715,21 @@ async def _aio_get_client(
|
|||
|
||||
ib = NonShittyIB()
|
||||
|
||||
# attempt to get connection info from config
|
||||
ports = conf['api'].get(
|
||||
# attempt to get connection info from config; if no .toml entry
|
||||
# exists, we try to load from a default localhost connection.
|
||||
host = conf.get('host', '127.0.0.1')
|
||||
ports = conf.get(
|
||||
'ports',
|
||||
{
|
||||
|
||||
# default order is to check for gw first
|
||||
{
|
||||
'gw': 4002,
|
||||
'tws': 7497,
|
||||
'order': ['gw', 'tws']
|
||||
}
|
||||
)
|
||||
order = ports['order']
|
||||
|
||||
try_ports = [ports[key] for key in order]
|
||||
ports = try_ports if port is None else [port]
|
||||
|
||||
|
@ -1351,13 +1371,40 @@ async def trades_dialogue(
|
|||
# start order request handler **before** local trades event loop
|
||||
n.start_soon(handle_order_requests, ems_stream)
|
||||
|
||||
# TODO: for some reason we can receive a ``None`` here when the
|
||||
# ib-gw goes down? Not sure exactly how that's happening looking
|
||||
# at the eventkit code above but we should probably handle it...
|
||||
async for event_name, item in ib_trade_events_stream:
|
||||
print(f' ib sending {item}')
|
||||
|
||||
# TODO: templating the ib statuses in comparison with other
|
||||
# brokers is likely the way to go:
|
||||
# https://interactivebrokers.github.io/tws-api/interfaceIBApi_1_1EWrapper.html#a17f2a02d6449710b6394d0266a353313
|
||||
# short list:
|
||||
# - PendingSubmit
|
||||
# - PendingCancel
|
||||
# - PreSubmitted (simulated orders)
|
||||
# - ApiCancelled (cancelled by client before submission
|
||||
# to routing)
|
||||
# - Cancelled
|
||||
# - Filled
|
||||
# - Inactive (reject or cancelled but not by trader)
|
||||
|
||||
# XXX: here's some other sucky cases from the api
|
||||
# - short-sale but securities haven't been located, in this
|
||||
# case we should probably keep the order in some kind of
|
||||
# weird state or cancel it outright?
|
||||
|
||||
# status='PendingSubmit', message=''),
|
||||
# status='Cancelled', message='Error 404,
|
||||
# reqId 1550: Order held while securities are located.'),
|
||||
# status='PreSubmitted', message='')],
|
||||
|
||||
if event_name == 'status':
|
||||
|
||||
# XXX: begin normalization of nonsense ib_insync internal
|
||||
# object-state tracking representations...
|
||||
|
||||
if event_name == 'status':
|
||||
|
||||
# unwrap needed data from ib_insync internal types
|
||||
trade: Trade = item
|
||||
status: OrderStatus = trade.orderStatus
|
||||
|
@ -1368,10 +1415,13 @@ async def trades_dialogue(
|
|||
|
||||
reqid=trade.order.orderId,
|
||||
time_ns=time.time_ns(), # cuz why not
|
||||
|
||||
# everyone doin camel case..
|
||||
status=status.status.lower(), # force lower case
|
||||
|
||||
filled=status.filled,
|
||||
reason=status.whyHeld,
|
||||
|
||||
# this seems to not be necessarily up to date in the
|
||||
# execDetails event.. so we have to send it here I guess?
|
||||
remaining=status.remaining,
|
||||
|
@ -1442,14 +1492,14 @@ async def trades_dialogue(
|
|||
if getattr(msg, 'reqid', 0) < -1:
|
||||
|
||||
# it's a trade event generated by TWS usage.
|
||||
log.warning(f"TWS triggered trade:\n{pformat(msg)}")
|
||||
log.info(f"TWS triggered trade\n{pformat(msg.dict())}")
|
||||
|
||||
msg.reqid = 'tws-' + str(-1 * msg.reqid)
|
||||
|
||||
# mark msg as from "external system"
|
||||
# TODO: probably something better then this.. and start
|
||||
# considering multiplayer/group trades tracking
|
||||
msg.external = True
|
||||
msg.broker_details['external_src'] = 'tws'
|
||||
continue
|
||||
|
||||
# XXX: we always serialize to a dict for msgpack
|
||||
|
@ -1462,9 +1512,8 @@ async def trades_dialogue(
|
|||
@tractor.context
|
||||
async def open_symbol_search(
|
||||
ctx: tractor.Context,
|
||||
) -> None:
|
||||
# async with open_cached_client('ib') as client:
|
||||
|
||||
) -> None:
|
||||
# load all symbols locally for fast search
|
||||
await ctx.started({})
|
||||
|
||||
|
@ -1491,6 +1540,12 @@ async def open_symbol_search(
|
|||
if not pattern or pattern.isspace():
|
||||
log.warning('empty pattern received, skipping..')
|
||||
|
||||
# TODO: *BUG* if nothing is returned here the client
|
||||
# side will cache a null set result and not showing
|
||||
# anything to the use on re-searches when this query
|
||||
# timed out. We probably need a special "timeout" msg
|
||||
# or something...
|
||||
|
||||
# XXX: this unblocks the far end search task which may
|
||||
# hold up a multi-search nursery block
|
||||
await stream.send({})
|
||||
|
@ -1498,7 +1553,7 @@ async def open_symbol_search(
|
|||
continue
|
||||
|
||||
log.debug(f'searching for {pattern}')
|
||||
# await tractor.breakpoint()
|
||||
|
||||
last = time.time()
|
||||
results = await _trio_run_client_method(
|
||||
method='search_stocks',
|
||||
|
|
|
@ -34,7 +34,7 @@ from pydantic.dataclasses import dataclass
|
|||
from pydantic import BaseModel
|
||||
import wsproto
|
||||
|
||||
from .api import open_cached_client
|
||||
from .._cacheables import open_cached_client
|
||||
from ._util import resproc, SymbolNotFound, BrokerError
|
||||
from ..log import get_logger, get_console_log
|
||||
from ..data import ShmArray
|
||||
|
|
|
@ -42,10 +42,10 @@ import wrapt
|
|||
import asks
|
||||
|
||||
from ..calc import humanize, percent_change
|
||||
from .._cacheables import open_cached_client, async_lifo_cache
|
||||
from . import config
|
||||
from ._util import resproc, BrokerError, SymbolNotFound
|
||||
from ..log import get_logger, colorize_json, get_console_log
|
||||
from .._async_utils import async_lifo_cache
|
||||
from . import get_brokermod
|
||||
from . import api
|
||||
|
||||
|
@ -1197,7 +1197,7 @@ async def stream_quotes(
|
|||
# XXX: required to propagate ``tractor`` loglevel to piker logging
|
||||
get_console_log(loglevel)
|
||||
|
||||
async with api.open_cached_client('questrade') as client:
|
||||
async with open_cached_client('questrade') as client:
|
||||
if feed_type == 'stock':
|
||||
formatter = format_stock_quote
|
||||
get_quotes = await stock_quoter(client, symbols)
|
||||
|
|
|
@ -38,7 +38,7 @@ log = get_logger(__name__)
|
|||
|
||||
@dataclass
|
||||
class OrderBook:
|
||||
"""Buy-side (client-side ?) order book ctl and tracking.
|
||||
'''EMS-client-side order book ctl and tracking.
|
||||
|
||||
A style similar to "model-view" is used here where this api is
|
||||
provided as a supervised control for an EMS actor which does all the
|
||||
|
@ -48,7 +48,7 @@ class OrderBook:
|
|||
Currently, this is mostly for keeping local state to match the EMS
|
||||
and use received events to trigger graphics updates.
|
||||
|
||||
"""
|
||||
'''
|
||||
# mem channels used to relay order requests to the EMS daemon
|
||||
_to_ems: trio.abc.SendChannel
|
||||
_from_order_book: trio.abc.ReceiveChannel
|
||||
|
|
|
@ -22,7 +22,7 @@ from contextlib import asynccontextmanager
|
|||
from dataclasses import dataclass, field
|
||||
from pprint import pformat
|
||||
import time
|
||||
from typing import AsyncIterator, Callable, Any
|
||||
from typing import AsyncIterator, Callable
|
||||
|
||||
from bidict import bidict
|
||||
from pydantic import BaseModel
|
||||
|
@ -30,10 +30,9 @@ import trio
|
|||
from trio_typing import TaskStatus
|
||||
import tractor
|
||||
|
||||
from .. import data
|
||||
from ..log import get_logger
|
||||
from ..data._normalize import iterticks
|
||||
from ..data.feed import Feed
|
||||
from ..data.feed import Feed, maybe_open_feed
|
||||
from .._daemon import maybe_spawn_brokerd
|
||||
from . import _paper_engine as paper
|
||||
from ._messages import (
|
||||
|
@ -123,7 +122,7 @@ class _DarkBook:
|
|||
# XXX: this is in place to prevent accidental positions that are too
|
||||
# big. Now obviously this won't make sense for crypto like BTC, but
|
||||
# for most traditional brokers it should be fine unless you start
|
||||
# slinging NQ futes or something.
|
||||
# slinging NQ futes or something; check ur margin.
|
||||
_DEFAULT_SIZE: float = 1.0
|
||||
|
||||
|
||||
|
@ -132,7 +131,6 @@ async def clear_dark_triggers(
|
|||
brokerd_orders_stream: tractor.MsgStream,
|
||||
ems_client_order_stream: tractor.MsgStream,
|
||||
quote_stream: tractor.ReceiveMsgStream, # noqa
|
||||
|
||||
broker: str,
|
||||
symbol: str,
|
||||
|
||||
|
@ -266,7 +264,7 @@ class TradesRelay:
|
|||
consumers: int = 0
|
||||
|
||||
|
||||
class _Router(BaseModel):
|
||||
class Router(BaseModel):
|
||||
'''Order router which manages and tracks per-broker dark book,
|
||||
alerts, clearing and related data feed management.
|
||||
|
||||
|
@ -276,8 +274,6 @@ class _Router(BaseModel):
|
|||
# setup at actor spawn time
|
||||
nursery: trio.Nursery
|
||||
|
||||
feeds: dict[tuple[str, str], Any] = {}
|
||||
|
||||
# broker to book map
|
||||
books: dict[str, _DarkBook] = {}
|
||||
|
||||
|
@ -343,12 +339,12 @@ class _Router(BaseModel):
|
|||
relay.consumers -= 1
|
||||
|
||||
|
||||
_router: _Router = None
|
||||
_router: Router = None
|
||||
|
||||
|
||||
async def open_brokerd_trades_dialogue(
|
||||
|
||||
router: _Router,
|
||||
router: Router,
|
||||
feed: Feed,
|
||||
symbol: str,
|
||||
_exec_mode: str,
|
||||
|
@ -466,7 +462,7 @@ async def _setup_persistent_emsd(
|
|||
# open a root "service nursery" for the ``emsd`` actor
|
||||
async with trio.open_nursery() as service_nursery:
|
||||
|
||||
_router = _Router(nursery=service_nursery)
|
||||
_router = Router(nursery=service_nursery)
|
||||
|
||||
# TODO: send back the full set of persistent
|
||||
# orders/execs?
|
||||
|
@ -480,7 +476,7 @@ async def translate_and_relay_brokerd_events(
|
|||
|
||||
broker: str,
|
||||
brokerd_trades_stream: tractor.MsgStream,
|
||||
router: _Router,
|
||||
router: Router,
|
||||
|
||||
) -> AsyncIterator[dict]:
|
||||
'''Trades update loop - receive updates from ``brokerd`` trades
|
||||
|
@ -704,7 +700,7 @@ async def process_client_order_cmds(
|
|||
symbol: str,
|
||||
feed: Feed, # noqa
|
||||
dark_book: _DarkBook,
|
||||
router: _Router,
|
||||
router: Router,
|
||||
|
||||
) -> None:
|
||||
|
||||
|
@ -958,32 +954,25 @@ async def _emsd_main(
|
|||
# tractor.Context instead of strictly requiring a ctx arg.
|
||||
ems_ctx = ctx
|
||||
|
||||
cached_feed = _router.feeds.get((broker, symbol))
|
||||
if cached_feed:
|
||||
# TODO: use cached feeds per calling-actor
|
||||
log.warning(f'Opening duplicate feed for {(broker, symbol)}')
|
||||
feed: Feed
|
||||
|
||||
# spawn one task per broker feed
|
||||
async with (
|
||||
# TODO: eventually support N-brokers
|
||||
data.open_feed(
|
||||
maybe_open_feed(
|
||||
broker,
|
||||
[symbol],
|
||||
loglevel=loglevel,
|
||||
) as feed,
|
||||
) as (feed, stream),
|
||||
):
|
||||
if not cached_feed:
|
||||
_router.feeds[(broker, symbol)] = feed
|
||||
|
||||
# XXX: this should be initial price quote from target provider
|
||||
first_quote = feed.first_quote
|
||||
|
||||
# open a stream with the brokerd backend for order
|
||||
# flow dialogue
|
||||
|
||||
book = _router.get_dark_book(broker)
|
||||
book.lasts[(broker, symbol)] = first_quote[symbol]['last']
|
||||
|
||||
# open a stream with the brokerd backend for order
|
||||
# flow dialogue
|
||||
async with (
|
||||
|
||||
# only open if one isn't already up: we try to keep
|
||||
|
@ -1015,11 +1004,9 @@ async def _emsd_main(
|
|||
n.start_soon(
|
||||
clear_dark_triggers,
|
||||
|
||||
# relay.brokerd_dialogue,
|
||||
brokerd_stream,
|
||||
ems_client_order_stream,
|
||||
feed.stream,
|
||||
|
||||
stream,
|
||||
broker,
|
||||
symbol,
|
||||
book
|
||||
|
|
|
@ -81,8 +81,6 @@ class Status(BaseModel):
|
|||
# 'alert_submitted',
|
||||
# 'alert_triggered',
|
||||
|
||||
# 'position',
|
||||
|
||||
# }
|
||||
resp: str # "response", see above
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ from ..data._normalize import iterticks
|
|||
from ..log import get_logger
|
||||
from ._messages import (
|
||||
BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus,
|
||||
BrokerdFill,
|
||||
BrokerdFill, BrokerdPosition,
|
||||
)
|
||||
|
||||
|
||||
|
@ -60,6 +60,7 @@ class PaperBoi:
|
|||
_buys: bidict
|
||||
_sells: bidict
|
||||
_reqids: bidict
|
||||
_positions: dict[str, BrokerdPosition]
|
||||
|
||||
# init edge case L1 spread
|
||||
last_ask: Tuple[float, float] = (float('inf'), 0) # price, size
|
||||
|
@ -101,6 +102,9 @@ class PaperBoi:
|
|||
# in the broker trades event processing loop
|
||||
await trio.sleep(0.05)
|
||||
|
||||
if action == 'sell':
|
||||
size = -size
|
||||
|
||||
msg = BrokerdStatus(
|
||||
status='submitted',
|
||||
reqid=reqid,
|
||||
|
@ -118,7 +122,7 @@ class PaperBoi:
|
|||
) or (
|
||||
action == 'sell' and (clear_price := self.last_bid[0]) >= price
|
||||
):
|
||||
await self.fake_fill(clear_price, size, action, reqid, oid)
|
||||
await self.fake_fill(symbol, clear_price, size, action, reqid, oid)
|
||||
|
||||
else:
|
||||
# register this submissions as a paper live order
|
||||
|
@ -170,6 +174,8 @@ class PaperBoi:
|
|||
|
||||
async def fake_fill(
|
||||
self,
|
||||
|
||||
symbol: str,
|
||||
price: float,
|
||||
size: float,
|
||||
action: str, # one of {'buy', 'sell'}
|
||||
|
@ -181,6 +187,7 @@ class PaperBoi:
|
|||
# remaining lots to fill
|
||||
order_complete: bool = True,
|
||||
remaining: float = 0,
|
||||
|
||||
) -> None:
|
||||
"""Pretend to fill a broker order @ price and size.
|
||||
|
||||
|
@ -232,6 +239,49 @@ class PaperBoi:
|
|||
)
|
||||
await self.ems_trades_stream.send(msg.dict())
|
||||
|
||||
# lookup any existing position
|
||||
token = f'{symbol}.{self.broker}'
|
||||
pp_msg = self._positions.setdefault(
|
||||
token,
|
||||
BrokerdPosition(
|
||||
broker=self.broker,
|
||||
account='paper',
|
||||
symbol=symbol,
|
||||
# TODO: we need to look up the asset currency from
|
||||
# broker info. i guess for crypto this can be
|
||||
# inferred from the pair?
|
||||
currency='',
|
||||
size=0.0,
|
||||
avg_price=0,
|
||||
)
|
||||
)
|
||||
|
||||
# "avg position price" calcs
|
||||
# TODO: eventually it'd be nice to have a small set of routines
|
||||
# to do this stuff from a sequence of cleared orders to enable
|
||||
# so called "contextual positions".
|
||||
new_size = size + pp_msg.size
|
||||
|
||||
# old size minus the new size gives us size differential with
|
||||
# +ve -> increase in pp size
|
||||
# -ve -> decrease in pp size
|
||||
size_diff = abs(new_size) - abs(pp_msg.size)
|
||||
|
||||
if new_size == 0:
|
||||
pp_msg.avg_price = 0
|
||||
|
||||
elif size_diff > 0:
|
||||
# only update the "average position price" when the position
|
||||
# size increases not when it decreases (i.e. the position is
|
||||
# being made smaller)
|
||||
pp_msg.avg_price = (
|
||||
abs(size) * price + pp_msg.avg_price * abs(pp_msg.size)
|
||||
) / abs(new_size)
|
||||
|
||||
pp_msg.size = new_size
|
||||
|
||||
await self.ems_trades_stream.send(pp_msg.dict())
|
||||
|
||||
|
||||
async def simulate_fills(
|
||||
quote_stream: 'tractor.ReceiveStream', # noqa
|
||||
|
@ -255,6 +305,7 @@ async def simulate_fills(
|
|||
|
||||
# this stream may eventually contain multiple symbols
|
||||
async for quotes in quote_stream:
|
||||
|
||||
for sym, quote in quotes.items():
|
||||
|
||||
for tick in iterticks(
|
||||
|
@ -274,6 +325,7 @@ async def simulate_fills(
|
|||
)
|
||||
|
||||
orders = client._buys.get(sym, {})
|
||||
|
||||
book_sequence = reversed(
|
||||
sorted(orders.keys(), key=itemgetter(1)))
|
||||
|
||||
|
@ -307,6 +359,7 @@ async def simulate_fills(
|
|||
|
||||
# clearing price would have filled entirely
|
||||
await client.fake_fill(
|
||||
symbol=sym,
|
||||
# todo slippage to determine fill price
|
||||
price=tick_price,
|
||||
size=size,
|
||||
|
@ -411,6 +464,9 @@ async def trades_dialogue(
|
|||
_sells={},
|
||||
|
||||
_reqids={},
|
||||
|
||||
# TODO: load paper positions from ``positions.toml``
|
||||
_positions={},
|
||||
)
|
||||
|
||||
n.start_soon(handle_order_requests, client, ems_stream)
|
||||
|
@ -452,10 +508,5 @@ async def open_paperboi(
|
|||
loglevel=loglevel,
|
||||
|
||||
) as (ctx, first):
|
||||
try:
|
||||
yield ctx, first
|
||||
|
||||
finally:
|
||||
# be sure to tear down the paper service on exit
|
||||
with trio.CancelScope(shield=True):
|
||||
await portal.cancel_actor()
|
||||
yield ctx, first
|
||||
|
|
|
@ -118,8 +118,9 @@ async def increment_ohlc_buffer(
|
|||
shm.push(last)
|
||||
|
||||
# broadcast the buffer index step
|
||||
# yield {'index': shm._last.value}
|
||||
for ctx in _subscribers.get(delay_s, ()):
|
||||
subs = _subscribers.get(delay_s, ())
|
||||
|
||||
for ctx in subs:
|
||||
try:
|
||||
await ctx.send_yield({'index': shm._last.value})
|
||||
except (
|
||||
|
@ -127,6 +128,7 @@ async def increment_ohlc_buffer(
|
|||
trio.ClosedResourceError
|
||||
):
|
||||
log.error(f'{ctx.chan.uid} dropped connection')
|
||||
subs.remove(ctx)
|
||||
|
||||
|
||||
@tractor.stream
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
NumPy compatible shared memory buffers for real-time FSP.
|
||||
NumPy compatible shared memory buffers for real-time IPC streaming.
|
||||
|
||||
"""
|
||||
from dataclasses import dataclass, asdict
|
||||
|
@ -207,11 +207,16 @@ class ShmArray:
|
|||
def push(
|
||||
self,
|
||||
data: np.ndarray,
|
||||
|
||||
prepend: bool = False,
|
||||
|
||||
) -> int:
|
||||
"""Ring buffer like "push" to append data
|
||||
'''Ring buffer like "push" to append data
|
||||
into the buffer and return updated "last" index.
|
||||
"""
|
||||
|
||||
NB: no actual ring logic yet to give a "loop around" on overflow
|
||||
condition, lel.
|
||||
'''
|
||||
length = len(data)
|
||||
|
||||
if prepend:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0)
|
||||
# Copyright (C) 2018-present Tyler Goodlet (in stewardship for piker0)
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
|
|
|
@ -31,11 +31,14 @@ from typing import (
|
|||
)
|
||||
|
||||
import trio
|
||||
from trio.abc import ReceiveChannel
|
||||
from trio_typing import TaskStatus
|
||||
import tractor
|
||||
from tractor import _broadcast
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..brokers import get_brokermod
|
||||
from .._cacheables import maybe_open_ctx
|
||||
from ..log import get_logger, get_console_log
|
||||
from .._daemon import (
|
||||
maybe_spawn_brokerd,
|
||||
|
@ -345,10 +348,10 @@ class Feed:
|
|||
memory buffer orchestration.
|
||||
"""
|
||||
name: str
|
||||
stream: AsyncIterator[dict[str, Any]]
|
||||
shm: ShmArray
|
||||
mod: ModuleType
|
||||
first_quote: dict
|
||||
stream: trio.abc.ReceiveChannel[dict[str, Any]]
|
||||
|
||||
_brokerd_portal: tractor._portal.Portal
|
||||
_index_stream: Optional[AsyncIterator[int]] = None
|
||||
|
@ -362,7 +365,7 @@ class Feed:
|
|||
symbols: dict[str, Symbol] = field(default_factory=dict)
|
||||
|
||||
async def receive(self) -> dict:
|
||||
return await self.stream.__anext__()
|
||||
return await self.stream.receive()
|
||||
|
||||
@asynccontextmanager
|
||||
async def index_stream(
|
||||
|
@ -376,8 +379,10 @@ class Feed:
|
|||
# a lone broker-daemon per provider should be
|
||||
# created for all practical purposes
|
||||
async with self._brokerd_portal.open_stream_from(
|
||||
|
||||
iter_ohlc_periods,
|
||||
delay_s=delay_s or self._max_sample_rate,
|
||||
|
||||
) as self._index_stream:
|
||||
|
||||
yield self._index_stream
|
||||
|
@ -395,7 +400,7 @@ def sym_to_shm_key(
|
|||
@asynccontextmanager
|
||||
async def install_brokerd_search(
|
||||
|
||||
portal: tractor._portal.Portal,
|
||||
portal: tractor.Portal,
|
||||
brokermod: ModuleType,
|
||||
|
||||
) -> None:
|
||||
|
@ -434,34 +439,21 @@ async def open_feed(
|
|||
loglevel: Optional[str] = None,
|
||||
|
||||
tick_throttle: Optional[float] = None, # Hz
|
||||
shielded_stream: bool = False,
|
||||
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
) -> Feed:
|
||||
'''
|
||||
Open a "data feed" which provides streamed real-time quotes.
|
||||
|
||||
'''
|
||||
sym = symbols[0].lower()
|
||||
|
||||
# TODO: feed cache locking, right now this is causing
|
||||
# issues when reconnecting to a long running emsd?
|
||||
# global _searcher_cache
|
||||
|
||||
# async with _cache_lock:
|
||||
# feed = _searcher_cache.get((brokername, sym))
|
||||
|
||||
# # if feed is not None and sym in feed.symbols:
|
||||
# if feed is not None:
|
||||
# yield feed
|
||||
# # short circuit
|
||||
# return
|
||||
|
||||
try:
|
||||
mod = get_brokermod(brokername)
|
||||
except ImportError:
|
||||
mod = get_ingestormod(brokername)
|
||||
|
||||
# no feed for broker exists so maybe spawn a data brokerd
|
||||
|
||||
async with (
|
||||
|
||||
maybe_spawn_brokerd(
|
||||
|
@ -480,21 +472,25 @@ async def open_feed(
|
|||
|
||||
) as (ctx, (init_msg, first_quote)),
|
||||
|
||||
ctx.open_stream() as stream,
|
||||
):
|
||||
ctx.open_stream(shield=shielded_stream) as stream,
|
||||
|
||||
):
|
||||
# we can only read from shm
|
||||
shm = attach_shm_array(
|
||||
token=init_msg[sym]['shm_token'],
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
bstream = _broadcast.broadcast_receiver(
|
||||
stream,
|
||||
2**10,
|
||||
)
|
||||
feed = Feed(
|
||||
name=brokername,
|
||||
stream=stream,
|
||||
shm=shm,
|
||||
mod=mod,
|
||||
first_quote=first_quote,
|
||||
stream=bstream, #brx_stream,
|
||||
_brokerd_portal=portal,
|
||||
)
|
||||
ohlc_sample_rates = []
|
||||
|
@ -530,3 +526,39 @@ async def open_feed(
|
|||
finally:
|
||||
# drop the infinite stream connection
|
||||
await ctx.cancel()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def maybe_open_feed(
|
||||
|
||||
brokername: str,
|
||||
symbols: Sequence[str],
|
||||
loglevel: Optional[str] = None,
|
||||
|
||||
tick_throttle: Optional[float] = None, # Hz
|
||||
shielded_stream: bool = False,
|
||||
|
||||
) -> (Feed, ReceiveChannel[dict[str, Any]]):
|
||||
'''Maybe open a data to a ``brokerd`` daemon only if there is no
|
||||
local one for the broker-symbol pair, if one is cached use it wrapped
|
||||
in a tractor broadcast receiver.
|
||||
|
||||
'''
|
||||
sym = symbols[0].lower()
|
||||
|
||||
async with maybe_open_ctx(
|
||||
key=(brokername, sym),
|
||||
mngr=open_feed(
|
||||
brokername,
|
||||
[sym],
|
||||
loglevel=loglevel,
|
||||
),
|
||||
) as (cache_hit, feed):
|
||||
|
||||
if cache_hit:
|
||||
# add a new broadcast subscription for the quote stream
|
||||
# if this feed is likely already in use
|
||||
async with feed.stream.subscribe() as bstream:
|
||||
yield feed, bstream
|
||||
else:
|
||||
yield feed, stream
|
||||
|
|
|
@ -69,6 +69,7 @@ async def fsp_compute(
|
|||
ctx: tractor.Context,
|
||||
symbol: str,
|
||||
feed: Feed,
|
||||
stream: trio.abc.ReceiveChannel,
|
||||
|
||||
src: ShmArray,
|
||||
dst: ShmArray,
|
||||
|
@ -93,14 +94,14 @@ async def fsp_compute(
|
|||
yield {}
|
||||
|
||||
# task cancellation won't kill the channel
|
||||
with stream.shield():
|
||||
# since we shielded at the `open_feed()` call
|
||||
async for quotes in stream:
|
||||
for symbol, quotes in quotes.items():
|
||||
if symbol == sym:
|
||||
yield quotes
|
||||
|
||||
out_stream = func(
|
||||
filter_by_sym(symbol, feed.stream),
|
||||
filter_by_sym(symbol, stream),
|
||||
feed.shm,
|
||||
)
|
||||
|
||||
|
@ -164,7 +165,8 @@ async def cascade(
|
|||
dst_shm_token: Tuple[str, np.dtype],
|
||||
symbol: str,
|
||||
fsp_func_name: str,
|
||||
) -> AsyncIterator[dict]:
|
||||
|
||||
) -> None:
|
||||
"""Chain streaming signal processors and deliver output to
|
||||
destination mem buf.
|
||||
|
||||
|
@ -175,7 +177,11 @@ async def cascade(
|
|||
func: Callable = _fsps[fsp_func_name]
|
||||
|
||||
# open a data feed stream with requested broker
|
||||
async with data.open_feed(brokername, [symbol]) as feed:
|
||||
async with data.feed.maybe_open_feed(
|
||||
brokername,
|
||||
[symbol],
|
||||
shielded_stream=True,
|
||||
) as (feed, stream):
|
||||
|
||||
assert src.token == feed.shm.token
|
||||
|
||||
|
@ -186,6 +192,7 @@ async def cascade(
|
|||
ctx=ctx,
|
||||
symbol=symbol,
|
||||
feed=feed,
|
||||
stream=stream,
|
||||
|
||||
src=src,
|
||||
dst=dst,
|
||||
|
|
|
@ -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)
|
|
@ -18,17 +18,21 @@
|
|||
Annotations for ur faces.
|
||||
|
||||
"""
|
||||
import PyQt5
|
||||
from PyQt5 import QtCore, QtGui
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5.QtCore import QPointF, QRectF
|
||||
from PyQt5.QtWidgets import QGraphicsPathItem
|
||||
from pyqtgraph import Point, functions as fn, Color
|
||||
import numpy as np
|
||||
|
||||
from ._anchors import marker_right_points
|
||||
|
||||
|
||||
def mk_marker_path(
|
||||
|
||||
style: str,
|
||||
|
||||
def mk_marker(
|
||||
style,
|
||||
size: float = 20.0,
|
||||
use_qgpath: bool = True,
|
||||
) -> QGraphicsPathItem:
|
||||
"""Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem``
|
||||
ready to be placed using scene coordinates (not view).
|
||||
|
@ -37,7 +41,7 @@ def mk_marker(
|
|||
style String indicating the style of marker to add:
|
||||
``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
|
||||
``'>|<'``, ``'^'``, ``'v'``, ``'o'``
|
||||
size Size of the marker in pixels. Default is 10.0.
|
||||
size Size of the marker in pixels.
|
||||
|
||||
"""
|
||||
path = QtGui.QPainterPath()
|
||||
|
@ -81,13 +85,147 @@ def mk_marker(
|
|||
|
||||
# self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
|
||||
|
||||
if use_qgpath:
|
||||
path = QGraphicsPathItem(path)
|
||||
path.scale(size, size)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
class LevelMarker(QGraphicsPathItem):
|
||||
'''An arrow marker path graphich which redraws itself
|
||||
to the specified view coordinate level on each paint cycle.
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
style: str,
|
||||
get_level: Callable[..., float],
|
||||
size: float = 20,
|
||||
keep_in_view: bool = True,
|
||||
on_paint: Optional[Callable] = None,
|
||||
|
||||
) -> None:
|
||||
|
||||
# get polygon and scale
|
||||
super().__init__()
|
||||
self.scale(size, size)
|
||||
|
||||
# interally generates path
|
||||
self._style = None
|
||||
self.style = style
|
||||
|
||||
self.chart = chart
|
||||
|
||||
self.get_level = get_level
|
||||
self._on_paint = on_paint
|
||||
self.scene_x = lambda: marker_right_points(chart)[1]
|
||||
self.level: float = 0
|
||||
self.keep_in_view = keep_in_view
|
||||
|
||||
@property
|
||||
def style(self) -> str:
|
||||
return self._style
|
||||
|
||||
@style.setter
|
||||
def style(self, value: str) -> None:
|
||||
if self._style != value:
|
||||
polygon = mk_marker_path(value)
|
||||
self.setPath(polygon)
|
||||
self._style = value
|
||||
|
||||
def path_br(self) -> QRectF:
|
||||
'''Return the bounding rect for the opaque path part
|
||||
of this item.
|
||||
|
||||
'''
|
||||
return self.mapToScene(
|
||||
self.path()
|
||||
).boundingRect()
|
||||
|
||||
def delete(self) -> None:
|
||||
self.scene().removeItem(self)
|
||||
|
||||
@property
|
||||
def h(self) -> float:
|
||||
return self.path_br().height()
|
||||
|
||||
@property
|
||||
def w(self) -> float:
|
||||
return self.path_br().width()
|
||||
|
||||
def position_in_view(
|
||||
self,
|
||||
# level: float,
|
||||
|
||||
) -> None:
|
||||
'''Show a pp off-screen indicator for a level label.
|
||||
|
||||
This is like in fps games where you have a gps "nav" indicator
|
||||
but your teammate is outside the range of view, except in 2D, on
|
||||
the y-dimension.
|
||||
|
||||
'''
|
||||
level = self.get_level()
|
||||
|
||||
view = self.chart.getViewBox()
|
||||
vr = view.state['viewRange']
|
||||
ymn, ymx = vr[1]
|
||||
|
||||
# _, marker_right, _ = marker_right_points(line._chart)
|
||||
x = self.scene_x()
|
||||
|
||||
if level > ymx: # pin to top of view
|
||||
self.setPos(
|
||||
QPointF(
|
||||
x,
|
||||
self.h/3,
|
||||
)
|
||||
)
|
||||
|
||||
elif level < ymn: # pin to bottom of view
|
||||
|
||||
self.setPos(
|
||||
QPointF(
|
||||
x,
|
||||
view.height() - 4/3*self.h,
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
# pp line is viewable so show marker normally
|
||||
self.setPos(
|
||||
x,
|
||||
self.chart.view.mapFromView(
|
||||
QPointF(0, self.get_level())
|
||||
).y()
|
||||
)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
|
||||
p: QtGui.QPainter,
|
||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
|
||||
) -> None:
|
||||
'''Core paint which we override to always update
|
||||
our marker position in scene coordinates from a
|
||||
view cooridnate "level".
|
||||
|
||||
'''
|
||||
if self.keep_in_view:
|
||||
self.position_in_view()
|
||||
|
||||
else: # just place at desired level even if not in view
|
||||
self.setPos(
|
||||
self.scene_x(),
|
||||
self.mapToScene(QPointF(0, self.get_level())).y()
|
||||
)
|
||||
|
||||
super().paint(p, opt, w)
|
||||
|
||||
if self._on_paint:
|
||||
self._on_paint(self)
|
||||
|
||||
|
||||
def qgo_draw_markers(
|
||||
|
||||
markers: list,
|
||||
|
|
|
@ -26,6 +26,11 @@ from functools import partial
|
|||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtCore import QEvent
|
||||
from PyQt5.QtWidgets import (
|
||||
QFrame,
|
||||
QWidget,
|
||||
# QSizePolicy,
|
||||
)
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import tractor
|
||||
|
@ -40,13 +45,13 @@ from ._axes import (
|
|||
PriceAxis,
|
||||
YAxisLabel,
|
||||
)
|
||||
from ._graphics._cursor import (
|
||||
from ._cursor import (
|
||||
Cursor,
|
||||
ContentsLabel,
|
||||
)
|
||||
from ._l1 import L1Labels
|
||||
from ._graphics._ohlc import BarItems
|
||||
from ._graphics._curve import FastAppendCurve
|
||||
from ._ohlc import BarItems
|
||||
from ._curve import FastAppendCurve
|
||||
from ._style import (
|
||||
hcolor,
|
||||
CHART_MARGINS,
|
||||
|
@ -65,15 +70,20 @@ from .. import data
|
|||
from ..log import get_logger
|
||||
from ._exec import run_qtractor
|
||||
from ._interaction import ChartView
|
||||
from .order_mode import start_order_mode
|
||||
from .order_mode import run_order_mode
|
||||
from .. import fsp
|
||||
from ..data import feed
|
||||
from ._forms import (
|
||||
FieldsForm,
|
||||
open_form,
|
||||
mk_order_pane_layout,
|
||||
)
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class GodWidget(QtWidgets.QWidget):
|
||||
class GodWidget(QWidget):
|
||||
'''
|
||||
"Our lord and savior, the holy child of window-shua, there is no
|
||||
widget above thee." - 6|6
|
||||
|
@ -94,11 +104,13 @@ class GodWidget(QtWidgets.QWidget):
|
|||
|
||||
self.hbox = QtWidgets.QHBoxLayout(self)
|
||||
self.hbox.setContentsMargins(0, 0, 0, 0)
|
||||
self.hbox.setSpacing(2)
|
||||
self.hbox.setSpacing(6)
|
||||
self.hbox.setAlignment(Qt.AlignTop)
|
||||
|
||||
self.vbox = QtWidgets.QVBoxLayout()
|
||||
self.vbox.setContentsMargins(0, 0, 0, 0)
|
||||
self.vbox.setSpacing(2)
|
||||
self.vbox.setAlignment(Qt.AlignTop)
|
||||
|
||||
self.hbox.addLayout(self.vbox)
|
||||
|
||||
|
@ -181,6 +193,7 @@ class GodWidget(QtWidgets.QWidget):
|
|||
order_mode_started = trio.Event()
|
||||
|
||||
if not self.vbox.isEmpty():
|
||||
|
||||
# XXX: this is CRITICAL especially with pixel buffer caching
|
||||
self.linkedsplits.hide()
|
||||
|
||||
|
@ -211,15 +224,26 @@ class GodWidget(QtWidgets.QWidget):
|
|||
# symbol is already loaded and ems ready
|
||||
order_mode_started.set()
|
||||
|
||||
self.vbox.addWidget(linkedsplits)
|
||||
# TODO: we'll probably want per-instrument/provider state here?
|
||||
# change the order config form over to the new chart
|
||||
# XXX: since the pp config is a singleton widget we have to
|
||||
# also switch it over to the new chart's interal-layout
|
||||
self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_config)
|
||||
linkedsplits.chart.qframe.hbox.addWidget(
|
||||
self.pp_config,
|
||||
alignment=Qt.AlignTop
|
||||
)
|
||||
|
||||
# chart is already in memory so just focus it
|
||||
if self.linkedsplits:
|
||||
self.linkedsplits.unfocus()
|
||||
|
||||
self.vbox.addWidget(linkedsplits)
|
||||
|
||||
# self.vbox.addWidget(linkedsplits)
|
||||
linkedsplits.show()
|
||||
linkedsplits.focus()
|
||||
|
||||
self.linkedsplits = linkedsplits
|
||||
|
||||
symbol = linkedsplits.symbol
|
||||
|
@ -232,8 +256,17 @@ class GodWidget(QtWidgets.QWidget):
|
|||
|
||||
return order_mode_started
|
||||
|
||||
def focus(self) -> None:
|
||||
'''Focus the top level widget which in turn focusses the chart
|
||||
ala "view mode".
|
||||
|
||||
class LinkedSplits(QtWidgets.QWidget):
|
||||
'''
|
||||
# go back to view-mode focus (aka chart focus)
|
||||
self.clearFocus()
|
||||
self.linkedsplits.chart.setFocus()
|
||||
|
||||
|
||||
class LinkedSplits(QWidget):
|
||||
'''
|
||||
Widget that holds a central chart plus derived
|
||||
subcharts computed from the original data set apart
|
||||
|
@ -287,7 +320,6 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.addWidget(self.splitter)
|
||||
|
||||
# state tracker?
|
||||
self._symbol: Symbol = None
|
||||
|
||||
@property
|
||||
|
@ -296,14 +328,18 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
|
||||
def set_split_sizes(
|
||||
self,
|
||||
prop: float = 0.28 # proportion allocated to consumer subcharts
|
||||
prop: float = 0.625 # proportion allocated to consumer subcharts
|
||||
|
||||
) -> None:
|
||||
"""Set the proportion of space allocated for linked subcharts.
|
||||
"""
|
||||
'''Set the proportion of space allocated for linked subcharts.
|
||||
|
||||
'''
|
||||
major = 1 - prop
|
||||
min_h_ind = int((self.height() * prop) / len(self.subplots))
|
||||
|
||||
sizes = [int(self.height() * major)]
|
||||
sizes.extend([min_h_ind] * len(self.subplots))
|
||||
|
||||
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
|
||||
|
||||
def focus(self) -> None:
|
||||
|
@ -316,9 +352,12 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
|
||||
def plot_ohlc_main(
|
||||
self,
|
||||
|
||||
symbol: Symbol,
|
||||
array: np.ndarray,
|
||||
|
||||
style: str = 'bar',
|
||||
|
||||
) -> 'ChartPlotWidget':
|
||||
"""Start up and show main (price) chart and all linked subcharts.
|
||||
|
||||
|
@ -329,12 +368,16 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
linkedsplits=self,
|
||||
digits=symbol.digits(),
|
||||
)
|
||||
|
||||
self.chart = self.add_plot(
|
||||
|
||||
name=symbol.key,
|
||||
array=array,
|
||||
# xaxis=self.xaxis,
|
||||
style=style,
|
||||
_is_main=True,
|
||||
|
||||
sidepane=self.godwidget.pp_config,
|
||||
)
|
||||
# add crosshair graphic
|
||||
self.chart.addItem(self.cursor)
|
||||
|
@ -344,23 +387,34 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
self.chart.hideAxis('bottom')
|
||||
|
||||
# style?
|
||||
self.chart.setFrameStyle(QtWidgets.QFrame.StyledPanel | QtWidgets.QFrame.Plain)
|
||||
self.chart.setFrameStyle(
|
||||
QFrame.StyledPanel |
|
||||
QFrame.Plain
|
||||
)
|
||||
|
||||
return self.chart
|
||||
|
||||
def add_plot(
|
||||
self,
|
||||
|
||||
name: str,
|
||||
array: np.ndarray,
|
||||
xaxis: DynamicDateAxis = None,
|
||||
|
||||
array_key: Optional[str] = None,
|
||||
# xaxis: Optional[DynamicDateAxis] = None,
|
||||
style: str = 'line',
|
||||
_is_main: bool = False,
|
||||
|
||||
sidepane: Optional[QWidget] = None,
|
||||
|
||||
**cpw_kwargs,
|
||||
|
||||
) -> 'ChartPlotWidget':
|
||||
"""Add (sub)plots to chart widget by name.
|
||||
'''Add (sub)plots to chart widget by name.
|
||||
|
||||
If ``name`` == ``"main"`` the chart will be the the primary view.
|
||||
"""
|
||||
|
||||
'''
|
||||
if self.chart is None and not _is_main:
|
||||
raise RuntimeError(
|
||||
"A main plot must be created first with `.plot_ohlc_main()`")
|
||||
|
@ -370,20 +424,58 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
cv.linkedsplits = self
|
||||
|
||||
# use "indicator axis" by default
|
||||
if xaxis is None:
|
||||
|
||||
# TODO: we gotta possibly assign this back
|
||||
# to the last subplot on removal of some last subplot
|
||||
|
||||
xaxis = DynamicDateAxis(
|
||||
orientation='bottom',
|
||||
linkedsplits=self
|
||||
)
|
||||
|
||||
if self.xaxis:
|
||||
self.xaxis.hide()
|
||||
self.xaxis = xaxis
|
||||
|
||||
# TODO: probably should formalize and call this something else?
|
||||
class LambdaQFrame(QFrame):
|
||||
'''One-off ``QFrame`` composite which pairs a chart + sidepane
|
||||
``FieldsForm`` (if provided).
|
||||
|
||||
See composite widgets docs for deats:
|
||||
https://doc.qt.io/qt-5/qwidget.html#composite-widgets
|
||||
|
||||
'''
|
||||
sidepane: FieldsForm
|
||||
hbox: QtGui.QHBoxLayout
|
||||
chart: Optional['ChartPlotWidget'] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
|
||||
) -> None:
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self.sidepane = sidepane
|
||||
|
||||
hbox = self.hbox = QtGui.QHBoxLayout(self)
|
||||
hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
hbox.setContentsMargins(0, 0, 0, 0)
|
||||
hbox.setSpacing(3)
|
||||
|
||||
qframe = LambdaQFrame(self.splitter)
|
||||
|
||||
cpw = ChartPlotWidget(
|
||||
|
||||
# this name will be used to register the primary
|
||||
# graphics curve managed by the subchart
|
||||
name=name,
|
||||
data_key=array_key or name,
|
||||
|
||||
array=array,
|
||||
parent=self.splitter,
|
||||
parent=qframe,
|
||||
linkedsplits=self,
|
||||
axisItems={
|
||||
'bottom': xaxis,
|
||||
|
@ -391,10 +483,23 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
'left': PriceAxis(linkedsplits=self, orientation='left'),
|
||||
},
|
||||
viewBox=cv,
|
||||
# cursor=self.cursor,
|
||||
**cpw_kwargs,
|
||||
)
|
||||
print(f'xaxis ps: {xaxis.pos()}')
|
||||
|
||||
qframe.chart = cpw
|
||||
qframe.hbox.addWidget(cpw)
|
||||
|
||||
# so we can look this up and add back to the splitter
|
||||
# on a symbol switch
|
||||
cpw.qframe = qframe
|
||||
|
||||
# add sidepane **after** chart; place it on axis side
|
||||
if sidepane:
|
||||
qframe.hbox.addWidget(
|
||||
sidepane,
|
||||
alignment=Qt.AlignTop
|
||||
)
|
||||
cpw.sidepane = sidepane
|
||||
|
||||
# give viewbox as reference to chart
|
||||
# allowing for kb controls and interactions on **this** widget
|
||||
|
@ -402,8 +507,12 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
cv.chart = cpw
|
||||
|
||||
cpw.plotItem.vb.linkedsplits = self
|
||||
cpw.setFrameStyle(QtWidgets.QFrame.StyledPanel) # | QtWidgets.QFrame.Plain)
|
||||
cpw.setFrameStyle(
|
||||
QtWidgets.QFrame.StyledPanel
|
||||
# | QtWidgets.QFrame.Plain)
|
||||
)
|
||||
cpw.hideButtons()
|
||||
|
||||
# XXX: gives us outline on backside of y-axis
|
||||
cpw.getPlotItem().setContentsMargins(*CHART_MARGINS)
|
||||
|
||||
|
@ -415,10 +524,10 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
|
||||
# draw curve graphics
|
||||
if style == 'bar':
|
||||
cpw.draw_ohlc(name, array)
|
||||
cpw.draw_ohlc(name, array, array_key=array_key)
|
||||
|
||||
elif style == 'line':
|
||||
cpw.draw_curve(name, array)
|
||||
cpw.draw_curve(name, array, array_key=array_key)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Chart style {style} is currently unsupported")
|
||||
|
@ -427,11 +536,15 @@ class LinkedSplits(QtWidgets.QWidget):
|
|||
# track by name
|
||||
self.subplots[name] = cpw
|
||||
|
||||
if sidepane:
|
||||
# TODO: use a "panes" collection to manage this?
|
||||
sidepane.setMinimumWidth(self.chart.sidepane.width())
|
||||
|
||||
self.splitter.addWidget(qframe)
|
||||
|
||||
# scale split regions
|
||||
self.set_split_sizes()
|
||||
|
||||
# XXX: we need this right?
|
||||
# self.splitter.addWidget(cpw)
|
||||
else:
|
||||
assert style == 'bar', 'main chart must be OHLC'
|
||||
|
||||
|
@ -457,23 +570,24 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
_l1_labels: L1Labels = None
|
||||
|
||||
mode_name: str = 'mode: view'
|
||||
mode_name: str = 'view'
|
||||
|
||||
# TODO: can take a ``background`` color setting - maybe there's
|
||||
# a better one?
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# the data view we generate graphics from
|
||||
|
||||
# the "data view" we generate graphics from
|
||||
name: str,
|
||||
array: np.ndarray,
|
||||
data_key: str,
|
||||
linkedsplits: LinkedSplits,
|
||||
|
||||
view_color: str = 'papas_special',
|
||||
pen_color: str = 'bracket',
|
||||
|
||||
static_yrange: Optional[Tuple[float, float]] = None,
|
||||
cursor: Optional[Cursor] = None,
|
||||
|
||||
**kwargs,
|
||||
):
|
||||
|
@ -491,7 +605,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
**kwargs
|
||||
)
|
||||
self.name = name
|
||||
self._lc = linkedsplits
|
||||
self.data_key = data_key
|
||||
self.linked = linkedsplits
|
||||
|
||||
# scene-local placeholder for book graphics
|
||||
|
@ -535,8 +649,11 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# for when the splitter(s) are resized
|
||||
self._vb.sigResized.connect(self._set_yrange)
|
||||
|
||||
@property
|
||||
def view(self) -> ChartView:
|
||||
return self._vb
|
||||
|
||||
def focus(self) -> None:
|
||||
# self.setFocus()
|
||||
self._vb.setFocus()
|
||||
|
||||
def last_bar_in_view(self) -> int:
|
||||
|
@ -570,8 +687,6 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
a = self._arrays['ohlc']
|
||||
lbar = max(l, a[0]['index'])
|
||||
rbar = min(r, a[-1]['index'])
|
||||
# lbar = max(l, 0)
|
||||
# rbar = min(r, len(self._arrays['ohlc']))
|
||||
return l, lbar, rbar, r
|
||||
|
||||
def default_view(
|
||||
|
@ -615,8 +730,12 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
def draw_ohlc(
|
||||
self,
|
||||
|
||||
name: str,
|
||||
data: np.ndarray,
|
||||
|
||||
array_key: Optional[str] = None,
|
||||
|
||||
) -> pg.GraphicsObject:
|
||||
"""
|
||||
Draw OHLC datums to chart.
|
||||
|
@ -634,7 +753,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# draw after to allow self.scene() to work...
|
||||
graphics.draw_from_data(data)
|
||||
|
||||
self._graphics[name] = graphics
|
||||
data_key = array_key or name
|
||||
self._graphics[data_key] = graphics
|
||||
|
||||
self.linked.cursor.contents_labels.add_label(
|
||||
self,
|
||||
|
@ -649,12 +769,17 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
def draw_curve(
|
||||
self,
|
||||
|
||||
name: str,
|
||||
data: np.ndarray,
|
||||
|
||||
array_key: Optional[str] = None,
|
||||
overlay: bool = False,
|
||||
color: str = 'default_light',
|
||||
add_label: bool = True,
|
||||
|
||||
**pdi_kwargs,
|
||||
|
||||
) -> pg.PlotDataItem:
|
||||
"""Draw a "curve" (line plot graphics) for the provided data in
|
||||
the input array ``data``.
|
||||
|
@ -665,10 +790,12 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
}
|
||||
pdi_kwargs.update(_pdi_defaults)
|
||||
|
||||
data_key = array_key or name
|
||||
|
||||
# curve = pg.PlotDataItem(
|
||||
# curve = pg.PlotCurveItem(
|
||||
curve = FastAppendCurve(
|
||||
y=data[name],
|
||||
y=data[data_key],
|
||||
x=data['index'],
|
||||
# antialias=True,
|
||||
name=name,
|
||||
|
@ -700,7 +827,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
# register curve graphics and backing array for name
|
||||
self._graphics[name] = curve
|
||||
self._arrays[name] = data
|
||||
self._arrays[data_key or name] = data
|
||||
|
||||
if overlay:
|
||||
anchor_at = ('bottom', 'left')
|
||||
|
@ -719,7 +846,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
if add_label:
|
||||
self.linked.cursor.contents_labels.add_label(
|
||||
self,
|
||||
name,
|
||||
data_key or name,
|
||||
anchor_at=anchor_at
|
||||
)
|
||||
|
||||
|
@ -727,13 +854,15 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
def _add_sticky(
|
||||
self,
|
||||
|
||||
name: str,
|
||||
bg_color='bracket',
|
||||
|
||||
) -> YAxisLabel:
|
||||
|
||||
# if the sticky is for our symbol
|
||||
# use the tick size precision for display
|
||||
sym = self._lc.symbol
|
||||
sym = self.linked.symbol
|
||||
if name == sym.key:
|
||||
digits = sym.digits()
|
||||
else:
|
||||
|
@ -766,18 +895,23 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
def update_curve_from_array(
|
||||
self,
|
||||
|
||||
name: str,
|
||||
array: np.ndarray,
|
||||
array_key: Optional[str] = None,
|
||||
|
||||
**kwargs,
|
||||
|
||||
) -> pg.GraphicsObject:
|
||||
"""Update the named internal graphics from ``array``.
|
||||
|
||||
"""
|
||||
|
||||
data_key = array_key or name
|
||||
if name not in self._overlays:
|
||||
self._arrays['ohlc'] = array
|
||||
else:
|
||||
self._arrays[name] = array
|
||||
self._arrays[data_key] = array
|
||||
|
||||
curve = self._graphics[name]
|
||||
|
||||
|
@ -787,7 +921,11 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# one place to dig around this might be the `QBackingStore`
|
||||
# https://doc.qt.io/qt-5/qbackingstore.html
|
||||
# curve.setData(y=array[name], x=array['index'], **kwargs)
|
||||
curve.update_from_array(x=array['index'], y=array[name], **kwargs)
|
||||
curve.update_from_array(
|
||||
x=array['index'],
|
||||
y=array[data_key],
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return curve
|
||||
|
||||
|
@ -983,7 +1121,7 @@ async def chart_from_quotes(
|
|||
|
||||
last, volume = ohlcv.array[-1][['close', 'volume']]
|
||||
|
||||
symbol = chart._lc.symbol
|
||||
symbol = chart.linked.symbol
|
||||
|
||||
l1 = L1Labels(
|
||||
chart,
|
||||
|
@ -1001,7 +1139,7 @@ async def chart_from_quotes(
|
|||
# levels this might be dark volume we need to
|
||||
# present differently?
|
||||
|
||||
tick_size = chart._lc.symbol.tick_size
|
||||
tick_size = chart.linked.symbol.tick_size
|
||||
tick_margin = 2 * tick_size
|
||||
|
||||
last_ask = last_bid = last_clear = time.time()
|
||||
|
@ -1010,7 +1148,7 @@ async def chart_from_quotes(
|
|||
async for quotes in stream:
|
||||
|
||||
# chart isn't actively shown so just skip render cycle
|
||||
if chart._lc.isHidden():
|
||||
if chart.linked.isHidden():
|
||||
continue
|
||||
|
||||
for sym, quote in quotes.items():
|
||||
|
@ -1058,8 +1196,7 @@ async def chart_from_quotes(
|
|||
|
||||
if wap_in_history:
|
||||
# update vwap overlay line
|
||||
chart.update_curve_from_array(
|
||||
'bar_wap', ohlcv.array)
|
||||
chart.update_curve_from_array('bar_wap', ohlcv.array)
|
||||
|
||||
# l1 book events
|
||||
# throttle the book graphics updates at a lower rate
|
||||
|
@ -1164,9 +1301,9 @@ async def spawn_fsps(
|
|||
# Currently we spawn an actor per fsp chain but
|
||||
# likely we'll want to pool them eventually to
|
||||
# scale horizonatlly once cores are used up.
|
||||
for fsp_func_name, conf in fsps.items():
|
||||
for display_name, conf in fsps.items():
|
||||
|
||||
display_name = f'fsp.{fsp_func_name}'
|
||||
fsp_func_name = conf['fsp_func_name']
|
||||
|
||||
# TODO: load function here and introspect
|
||||
# return stream type(s)
|
||||
|
@ -1174,7 +1311,7 @@ async def spawn_fsps(
|
|||
# TODO: should `index` be a required internal field?
|
||||
fsp_dtype = np.dtype([('index', int), (fsp_func_name, float)])
|
||||
|
||||
key = f'{sym}.' + display_name
|
||||
key = f'{sym}.fsp.' + display_name
|
||||
|
||||
# this is all sync currently
|
||||
shm, opened = maybe_open_shm_array(
|
||||
|
@ -1192,7 +1329,7 @@ async def spawn_fsps(
|
|||
|
||||
portal = await n.start_actor(
|
||||
enable_modules=['piker.fsp'],
|
||||
name=display_name,
|
||||
name='fsp.' + display_name,
|
||||
)
|
||||
|
||||
# init async
|
||||
|
@ -1231,11 +1368,12 @@ async def run_fsp(
|
|||
config map.
|
||||
"""
|
||||
done = linkedsplits.window().status_bar.open_status(
|
||||
f'loading {display_name}..',
|
||||
f'loading fsp, {display_name}..',
|
||||
group_key=group_status_key,
|
||||
)
|
||||
|
||||
async with portal.open_stream_from(
|
||||
async with (
|
||||
portal.open_stream_from(
|
||||
|
||||
# subactor entrypoint
|
||||
fsp.cascade,
|
||||
|
@ -1247,7 +1385,28 @@ async def run_fsp(
|
|||
symbol=sym,
|
||||
fsp_func_name=fsp_func_name,
|
||||
|
||||
) as stream:
|
||||
) as stream,
|
||||
|
||||
open_form(
|
||||
godwidget=linkedsplits.godwidget,
|
||||
parent=linkedsplits.godwidget,
|
||||
fields_schema={
|
||||
'name': {
|
||||
'label': '**fsp**:',
|
||||
'type': 'select',
|
||||
'default_value': [
|
||||
f'{display_name}'
|
||||
],
|
||||
},
|
||||
'period': {
|
||||
'label': '**period**:',
|
||||
'type': 'edit',
|
||||
'default_value': 14,
|
||||
},
|
||||
},
|
||||
) as sidepane,
|
||||
|
||||
):
|
||||
|
||||
# receive last index for processed historical
|
||||
# data-array as first msg
|
||||
|
@ -1267,9 +1426,12 @@ async def run_fsp(
|
|||
else:
|
||||
|
||||
chart = linkedsplits.add_plot(
|
||||
name=fsp_func_name,
|
||||
name=display_name,
|
||||
array=shm.array,
|
||||
|
||||
array_key=conf['fsp_func_name'],
|
||||
sidepane=sidepane,
|
||||
|
||||
# curve by default
|
||||
ohlc=False,
|
||||
|
||||
|
@ -1278,12 +1440,6 @@ async def run_fsp(
|
|||
# static_yrange=(0, 100),
|
||||
)
|
||||
|
||||
# display contents labels asap
|
||||
chart.linked.cursor.contents_labels.update_labels(
|
||||
len(shm.array) - 1,
|
||||
# fsp_func_name
|
||||
)
|
||||
|
||||
# XXX: ONLY for sub-chart fsps, overlays have their
|
||||
# data looked up from the chart's internal array set.
|
||||
# TODO: we must get a data view api going STAT!!
|
||||
|
@ -1297,14 +1453,23 @@ async def run_fsp(
|
|||
|
||||
# read from last calculated value
|
||||
array = shm.array
|
||||
|
||||
# XXX: fsp func names are unique meaning we don't have
|
||||
# duplicates of the underlying data even if multiple
|
||||
# sub-charts reference it under different 'named charts'.
|
||||
value = array[fsp_func_name][-1]
|
||||
|
||||
last_val_sticky.update_from_data(-1, value)
|
||||
|
||||
chart._lc.focus()
|
||||
chart.linked.focus()
|
||||
|
||||
# works also for overlays in which case data is looked up from
|
||||
# internal chart array set....
|
||||
chart.update_curve_from_array(fsp_func_name, shm.array)
|
||||
chart.update_curve_from_array(
|
||||
display_name,
|
||||
shm.array,
|
||||
array_key=fsp_func_name
|
||||
)
|
||||
|
||||
# TODO: figure out if we can roll our own `FillToThreshold` to
|
||||
# get brush filled polygons for OS/OB conditions.
|
||||
|
@ -1317,7 +1482,7 @@ async def run_fsp(
|
|||
# graphics.curve.setFillLevel(50)
|
||||
|
||||
if fsp_func_name == 'rsi':
|
||||
from ._graphics._lines import level_line
|
||||
from ._lines import level_line
|
||||
# add moveable over-[sold/bought] lines
|
||||
# and labels only for the 70/30 lines
|
||||
level_line(chart, 20)
|
||||
|
@ -1335,7 +1500,7 @@ async def run_fsp(
|
|||
async for value in stream:
|
||||
|
||||
# chart isn't actively shown so just skip render cycle
|
||||
if chart._lc.isHidden():
|
||||
if chart.linked.isHidden():
|
||||
continue
|
||||
|
||||
now = time.time()
|
||||
|
@ -1368,7 +1533,11 @@ async def run_fsp(
|
|||
last_val_sticky.update_from_data(-1, value)
|
||||
|
||||
# update graphics
|
||||
chart.update_curve_from_array(fsp_func_name, array)
|
||||
chart.update_curve_from_array(
|
||||
display_name,
|
||||
array,
|
||||
array_key=fsp_func_name,
|
||||
)
|
||||
|
||||
# set time of last graphics update
|
||||
last = now
|
||||
|
@ -1423,7 +1592,11 @@ async def check_for_new_bars(feed, ohlcv, linkedsplits):
|
|||
)
|
||||
|
||||
for name, chart in linkedsplits.subplots.items():
|
||||
chart.update_curve_from_array(chart.name, chart._shm.array)
|
||||
chart.update_curve_from_array(
|
||||
chart.name,
|
||||
chart._shm.array,
|
||||
array_key=chart.data_key
|
||||
)
|
||||
|
||||
# shift the view if in follow mode
|
||||
price_chart.increment_view()
|
||||
|
@ -1462,8 +1635,7 @@ async def display_symbol_data(
|
|||
# )
|
||||
|
||||
async with(
|
||||
|
||||
data.open_feed(
|
||||
data.feed.open_feed(
|
||||
provider,
|
||||
[sym],
|
||||
loglevel=loglevel,
|
||||
|
@ -1472,8 +1644,21 @@ async def display_symbol_data(
|
|||
tick_throttle=_clear_throttle_rate,
|
||||
|
||||
) as feed,
|
||||
|
||||
trio.open_nursery() as n,
|
||||
):
|
||||
async def print_quotes():
|
||||
async with feed.stream.subscribe() as bstream:
|
||||
last_tick = time.time()
|
||||
async for quotes in bstream:
|
||||
now = time.time()
|
||||
period = now - last_tick
|
||||
for sym, quote in quotes.items():
|
||||
ticks = quote.get('ticks', ())
|
||||
if ticks:
|
||||
print(f'{1/period} Hz')
|
||||
last_tick = time.time()
|
||||
|
||||
n.start_soon(print_quotes)
|
||||
|
||||
ohlcv: ShmArray = feed.shm
|
||||
bars = ohlcv.array
|
||||
|
@ -1513,6 +1698,14 @@ async def display_symbol_data(
|
|||
# TODO: eventually we'll support some kind of n-compose syntax
|
||||
fsp_conf = {
|
||||
'rsi': {
|
||||
'fsp_func_name': 'rsi',
|
||||
'period': 14,
|
||||
'chart_kwargs': {
|
||||
'static_yrange': (0, 100),
|
||||
},
|
||||
},
|
||||
'rsi2': {
|
||||
'fsp_func_name': 'rsi',
|
||||
'period': 14,
|
||||
'chart_kwargs': {
|
||||
'static_yrange': (0, 100),
|
||||
|
@ -1535,6 +1728,7 @@ async def display_symbol_data(
|
|||
else:
|
||||
fsp_conf.update({
|
||||
'vwap': {
|
||||
'fsp_func_name': 'vwap',
|
||||
'overlay': True,
|
||||
'anchor': 'session',
|
||||
},
|
||||
|
@ -1574,7 +1768,12 @@ async def display_symbol_data(
|
|||
linkedsplits
|
||||
)
|
||||
|
||||
await start_order_mode(chart, symbol, provider, order_mode_started)
|
||||
await run_order_mode(
|
||||
chart,
|
||||
symbol,
|
||||
provider,
|
||||
order_mode_started
|
||||
)
|
||||
|
||||
|
||||
async def load_provider_search(
|
||||
|
@ -1640,7 +1839,56 @@ async def _async_main(
|
|||
sbar = godwidget.window.status_bar
|
||||
starting_done = sbar.open_status('starting ze sexy chartz')
|
||||
|
||||
async with trio.open_nursery() as root_n:
|
||||
# generate order mode side-pane UI
|
||||
|
||||
async with (
|
||||
trio.open_nursery() as root_n,
|
||||
|
||||
# fields form to configure order entry
|
||||
open_form(
|
||||
godwidget=godwidget,
|
||||
parent=godwidget,
|
||||
fields_schema={
|
||||
'account': {
|
||||
'type': 'select',
|
||||
'default_value': [
|
||||
'paper',
|
||||
# 'ib.margin',
|
||||
# 'ib.paper',
|
||||
],
|
||||
},
|
||||
'size_unit': {
|
||||
'label': '**allocate**:',
|
||||
'type': 'select',
|
||||
'default_value': [
|
||||
'$ size',
|
||||
'% of port',
|
||||
'# shares'
|
||||
],
|
||||
},
|
||||
'disti_weight': {
|
||||
'label': '**weight**:',
|
||||
'type': 'select',
|
||||
'default_value': ['uniform'],
|
||||
},
|
||||
'size': {
|
||||
'label': '**size**:',
|
||||
'type': 'edit',
|
||||
'default_value': 5000,
|
||||
},
|
||||
'slots': {
|
||||
'type': 'edit',
|
||||
'default_value': 4,
|
||||
},
|
||||
},
|
||||
) as pp_config,
|
||||
):
|
||||
pp_config: FieldsForm
|
||||
mk_order_pane_layout(pp_config)
|
||||
pp_config.show()
|
||||
|
||||
# add as next-to-y-axis pane
|
||||
godwidget.pp_config = pp_config
|
||||
|
||||
# set root nursery and task stack for spawning other charts/feeds
|
||||
# that run cached in the bg
|
||||
|
@ -1687,13 +1935,13 @@ async def _async_main(
|
|||
# start handling search bar kb inputs
|
||||
async with (
|
||||
|
||||
_event.open_handler(
|
||||
search.bar,
|
||||
_event.open_handlers(
|
||||
[search.bar],
|
||||
event_types={QEvent.KeyPress},
|
||||
async_handler=_search.handle_keyboard_input,
|
||||
# let key repeats pass through for search
|
||||
filter_auto_repeats=False,
|
||||
)
|
||||
),
|
||||
):
|
||||
# remove startup status text
|
||||
starting_done()
|
||||
|
|
|
@ -27,13 +27,13 @@ import pyqtgraph as pg
|
|||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5.QtCore import QPointF, QRectF
|
||||
|
||||
from .._style import (
|
||||
from ._style import (
|
||||
_xaxis_at,
|
||||
hcolor,
|
||||
_font_small,
|
||||
)
|
||||
from .._axes import YAxisLabel, XAxisLabel
|
||||
from ...log import get_logger
|
||||
from ._axes import YAxisLabel, XAxisLabel
|
||||
from ..log import get_logger
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
@ -42,7 +42,7 @@ log = get_logger(__name__)
|
|||
# latency (in terms of perceived lag in cross hair) so really be sure
|
||||
# there's an improvement if you want to change it!
|
||||
_mouse_rate_limit = 60 # TODO; should we calc current screen refresh rate?
|
||||
_debounce_delay = 1 / 2e3
|
||||
_debounce_delay = 1 / 1e3
|
||||
_ch_label_opac = 1
|
||||
|
||||
|
||||
|
@ -52,12 +52,15 @@ class LineDot(pg.CurvePoint):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
curve: pg.PlotCurveItem,
|
||||
index: int,
|
||||
|
||||
plot: 'ChartPlotWidget', # type: ingore # noqa
|
||||
pos=None,
|
||||
size: int = 6, # in pxs
|
||||
color: str = 'default_light',
|
||||
|
||||
) -> None:
|
||||
pg.CurvePoint.__init__(
|
||||
self,
|
||||
|
@ -88,7 +91,9 @@ class LineDot(pg.CurvePoint):
|
|||
|
||||
def event(
|
||||
self,
|
||||
|
||||
ev: QtCore.QEvent,
|
||||
|
||||
) -> None:
|
||||
if not isinstance(
|
||||
ev, QtCore.QDynamicPropertyChangeEvent
|
||||
|
@ -132,8 +137,8 @@ class ContentsLabel(pg.LabelItem):
|
|||
}
|
||||
|
||||
def __init__(
|
||||
|
||||
self,
|
||||
|
||||
# chart: 'ChartPlotWidget', # noqa
|
||||
view: pg.ViewBox,
|
||||
|
||||
|
@ -167,8 +172,8 @@ class ContentsLabel(pg.LabelItem):
|
|||
self.anchor(itemPos=index, parentPos=index, offset=margins)
|
||||
|
||||
def update_from_ohlc(
|
||||
|
||||
self,
|
||||
|
||||
name: str,
|
||||
index: int,
|
||||
array: np.ndarray,
|
||||
|
@ -194,8 +199,8 @@ class ContentsLabel(pg.LabelItem):
|
|||
)
|
||||
|
||||
def update_from_value(
|
||||
|
||||
self,
|
||||
|
||||
name: str,
|
||||
index: int,
|
||||
array: np.ndarray,
|
||||
|
@ -239,6 +244,7 @@ class ContentsLabels:
|
|||
|
||||
if not (index >= 0 and index < chart._arrays['ohlc'][-1]['index']):
|
||||
# out of range
|
||||
print('out of range?')
|
||||
continue
|
||||
|
||||
array = chart._arrays[name]
|
||||
|
@ -272,13 +278,15 @@ class ContentsLabels:
|
|||
self._labels.append(
|
||||
(chart, name, label, partial(update_func, label, name))
|
||||
)
|
||||
# label.hide()
|
||||
label.hide()
|
||||
|
||||
return label
|
||||
|
||||
|
||||
class Cursor(pg.GraphicsObject):
|
||||
'''Multi-plot cursor for use on a ``LinkedSplits`` chart (set).
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
|
||||
self,
|
|
@ -23,7 +23,7 @@ from typing import Tuple
|
|||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from ..._profile import pg_profile_enabled
|
||||
from .._profile import pg_profile_enabled
|
||||
|
||||
|
||||
# TODO: got a feeling that dropping this inheritance gets us even more speedups
|
|
@ -28,7 +28,7 @@ from PyQt5.QtCore import QPointF
|
|||
import numpy as np
|
||||
|
||||
from ._style import hcolor, _font
|
||||
from ._graphics._lines import order_line, LevelLine
|
||||
from ._lines import order_line, LevelLine
|
||||
from ..log import get_logger
|
||||
|
||||
|
||||
|
@ -105,11 +105,16 @@ class LineEditor:
|
|||
|
||||
# fields settings
|
||||
size: Optional[int] = None,
|
||||
|
||||
) -> LevelLine:
|
||||
"""Stage a line at the current chart's cursor position
|
||||
and return it.
|
||||
|
||||
"""
|
||||
if self.chart is None:
|
||||
log.error('No chart interaction yet available')
|
||||
return None
|
||||
|
||||
# chart.setCursor(QtCore.Qt.PointingHandCursor)
|
||||
cursor = self.chart.linked.cursor
|
||||
if not cursor:
|
||||
|
@ -118,7 +123,7 @@ class LineEditor:
|
|||
chart = cursor.active_plot
|
||||
y = cursor._datum_xy[1]
|
||||
|
||||
symbol = chart._lc.symbol
|
||||
symbol = chart.linked.symbol
|
||||
|
||||
# add a "staged" cursor-tracking line to view
|
||||
# and cash it in a a var
|
||||
|
@ -128,10 +133,14 @@ class LineEditor:
|
|||
line = order_line(
|
||||
chart,
|
||||
|
||||
# TODO: convert these values into human-readable form
|
||||
# (i.e. with k, m, M, B) type embedded suffixes
|
||||
level=y,
|
||||
level_digits=symbol.digits(),
|
||||
|
||||
size=size,
|
||||
size_digits=symbol.lot_digits(),
|
||||
# TODO: we need truncation checks in the EMS for this?
|
||||
# size_digits=min(symbol.lot_digits(), 3),
|
||||
|
||||
# just for the stage line to avoid
|
||||
# flickering while moving the cursor
|
||||
|
@ -194,7 +203,7 @@ class LineEditor:
|
|||
if not line:
|
||||
raise RuntimeError("No line is currently staged!?")
|
||||
|
||||
sym = chart._lc.symbol
|
||||
sym = chart.linked.symbol
|
||||
|
||||
line = order_line(
|
||||
chart,
|
||||
|
@ -204,7 +213,8 @@ class LineEditor:
|
|||
level_digits=sym.digits(),
|
||||
|
||||
size=size,
|
||||
size_digits=sym.lot_digits(),
|
||||
# TODO: we need truncation checks in the EMS for this?
|
||||
# size_digits=sym.lot_digits(),
|
||||
|
||||
# LevelLine kwargs
|
||||
color=line.color,
|
||||
|
@ -237,7 +247,6 @@ class LineEditor:
|
|||
log.warning(f'No line for {uuid} could be found?')
|
||||
return
|
||||
else:
|
||||
assert line.oid == uuid
|
||||
line.show_labels()
|
||||
|
||||
# TODO: other flashy things to indicate the order is active
|
||||
|
@ -260,18 +269,16 @@ class LineEditor:
|
|||
self,
|
||||
line: LevelLine = None,
|
||||
uuid: str = None,
|
||||
) -> LevelLine:
|
||||
"""Remove a line by refernce or uuid.
|
||||
|
||||
) -> Optional[LevelLine]:
|
||||
'''Remove a line by refernce or uuid.
|
||||
|
||||
If no lines or ids are provided remove all lines under the
|
||||
cursor position.
|
||||
|
||||
"""
|
||||
if line:
|
||||
uuid = line.oid
|
||||
|
||||
'''
|
||||
# try to look up line from our registry
|
||||
line = self._order_lines.pop(uuid, None)
|
||||
line = self._order_lines.pop(uuid, line)
|
||||
if line:
|
||||
|
||||
# if hovered remove from cursor set
|
||||
|
@ -284,7 +291,12 @@ class LineEditor:
|
|||
# just because we never got a un-hover event
|
||||
cursor.show_xhair()
|
||||
|
||||
log.debug(f'deleting {line} with oid: {uuid}')
|
||||
line.delete()
|
||||
|
||||
else:
|
||||
log.warning(f'Could not find line for {line}')
|
||||
|
||||
return line
|
||||
|
||||
|
||||
|
|
|
@ -18,13 +18,42 @@
|
|||
Qt event proxying and processing using ``trio`` mem chans.
|
||||
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
from typing import Callable
|
||||
|
||||
from PyQt5 import QtCore
|
||||
from PyQt5.QtCore import QEvent
|
||||
from PyQt5.QtWidgets import QWidget
|
||||
import trio
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# TODO: maybe consider some constrained ints down the road?
|
||||
# https://pydantic-docs.helpmanual.io/usage/types/#constrained-types
|
||||
|
||||
class KeyboardMsg(BaseModel):
|
||||
'''Unpacked Qt keyboard event data.
|
||||
|
||||
'''
|
||||
event: QEvent
|
||||
etype: int
|
||||
key: int
|
||||
mods: int
|
||||
txt: str
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def to_tuple(self) -> tuple:
|
||||
return tuple(self.dict().values())
|
||||
|
||||
|
||||
# TODO: maybe add some methods to detect key combos? Or is that gonna be
|
||||
# better with pattern matching?
|
||||
# # ctl + alt as combo
|
||||
# ctlalt = False
|
||||
# if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods:
|
||||
# ctlalt = True
|
||||
|
||||
|
||||
class EventRelay(QtCore.QObject):
|
||||
|
@ -67,22 +96,26 @@ class EventRelay(QtCore.QObject):
|
|||
|
||||
if etype in {QEvent.KeyPress, QEvent.KeyRelease}:
|
||||
|
||||
msg = KeyboardMsg(
|
||||
event=ev,
|
||||
etype=ev.type(),
|
||||
key=ev.key(),
|
||||
mods=ev.modifiers(),
|
||||
txt=ev.text(),
|
||||
)
|
||||
|
||||
# TODO: is there a global setting for this?
|
||||
if ev.isAutoRepeat() and self._filter_auto_repeats:
|
||||
ev.ignore()
|
||||
return True
|
||||
|
||||
key = ev.key()
|
||||
mods = ev.modifiers()
|
||||
txt = ev.text()
|
||||
|
||||
# NOTE: the event object instance coming out
|
||||
# the other side is mutated since Qt resumes event
|
||||
# processing **before** running a ``trio`` guest mode
|
||||
# tick, thus special handling or copying must be done.
|
||||
|
||||
# send elements to async handler
|
||||
self._send_chan.send_nowait((ev, etype, key, mods, txt))
|
||||
# send keyboard msg to async handler
|
||||
self._send_chan.send_nowait(msg)
|
||||
|
||||
else:
|
||||
# send event to async handler
|
||||
|
@ -124,9 +157,9 @@ async def open_event_stream(
|
|||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_handler(
|
||||
async def open_handlers(
|
||||
|
||||
source_widget: QWidget,
|
||||
source_widgets: list[QWidget],
|
||||
event_types: set[QEvent],
|
||||
async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None],
|
||||
**kwargs,
|
||||
|
@ -135,7 +168,13 @@ async def open_handler(
|
|||
|
||||
async with (
|
||||
trio.open_nursery() as n,
|
||||
open_event_stream(source_widget, event_types, **kwargs) as event_recv_stream,
|
||||
AsyncExitStack() as stack,
|
||||
):
|
||||
n.start_soon(async_handler, source_widget, event_recv_stream)
|
||||
for widget in source_widgets:
|
||||
|
||||
event_recv_stream = await stack.enter_async_context(
|
||||
open_event_stream(widget, event_types, **kwargs)
|
||||
)
|
||||
n.start_soon(async_handler, widget, event_recv_stream)
|
||||
|
||||
yield
|
||||
|
|
|
@ -99,6 +99,9 @@ def run_qtractor(
|
|||
# "This is substantially faster than using a signal... for some
|
||||
# reason Qt signal dispatch is really slow (and relies on events
|
||||
# underneath anyway, so this is strictly less work)."
|
||||
|
||||
# source gist and credit to njs:
|
||||
# https://gist.github.com/njsmith/d996e80b700a339e0623f97f48bcf0cb
|
||||
REENTER_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType())
|
||||
|
||||
class ReenterEvent(QtCore.QEvent):
|
||||
|
|
|
@ -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
|
|
@ -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.
|
||||
|
||||
"""
|
|
@ -32,7 +32,6 @@ import trio
|
|||
from ..log import get_logger
|
||||
from ._style import _min_points_to_show
|
||||
from ._editors import SelectRect
|
||||
from ._window import main_window
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
@ -65,7 +64,8 @@ async def handle_viewmode_inputs(
|
|||
'cc': mode.cancel_all_orders,
|
||||
}
|
||||
|
||||
async for event, etype, key, mods, text in recv_chan:
|
||||
async for kbmsg in recv_chan:
|
||||
event, etype, key, mods, text = kbmsg.to_tuple()
|
||||
log.debug(f'key: {key}, mods: {mods}, text: {text}')
|
||||
now = time.time()
|
||||
period = now - last
|
||||
|
@ -115,7 +115,7 @@ async def handle_viewmode_inputs(
|
|||
Qt.Key_Space,
|
||||
}
|
||||
):
|
||||
view._chart._lc.godwidget.search.focus()
|
||||
view._chart.linked.godwidget.search.focus()
|
||||
|
||||
# esc and ctrl-c
|
||||
if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
|
||||
|
@ -163,9 +163,20 @@ async def handle_viewmode_inputs(
|
|||
else:
|
||||
view.setMouseMode(ViewBox.PanMode)
|
||||
|
||||
# Toggle position config pane
|
||||
if (
|
||||
ctrl and key in {
|
||||
Qt.Key_P,
|
||||
}
|
||||
):
|
||||
pp_conf = mode.pp_config
|
||||
if pp_conf.isHidden():
|
||||
pp_conf.show()
|
||||
else:
|
||||
pp_conf.hide()
|
||||
|
||||
# ORDER MODE #
|
||||
# live vs. dark trigger + an action {buy, sell, alert}
|
||||
|
||||
order_keys_pressed = {
|
||||
Qt.Key_A,
|
||||
Qt.Key_F,
|
||||
|
@ -173,6 +184,13 @@ async def handle_viewmode_inputs(
|
|||
}.intersection(pressed)
|
||||
|
||||
if order_keys_pressed:
|
||||
|
||||
# show the pp label
|
||||
mode.pp.show()
|
||||
|
||||
# TODO: show pp config mini-params in status bar widget
|
||||
# mode.pp_config.show()
|
||||
|
||||
if (
|
||||
# 's' for "submit" to activate "live" order
|
||||
Qt.Key_S in pressed or
|
||||
|
@ -201,17 +219,21 @@ async def handle_viewmode_inputs(
|
|||
view.mode.set_exec(action)
|
||||
|
||||
prefix = trigger_mode + '-' if action != 'alert' else ''
|
||||
view._chart.window().mode_label.setText(
|
||||
f'mode: {prefix}{action}')
|
||||
view._chart.window().set_mode_name(f'{prefix}{action}')
|
||||
|
||||
else: # none active
|
||||
|
||||
# hide pp label
|
||||
mode.pp.hide_info()
|
||||
# mode.pp_config.hide()
|
||||
|
||||
# if none are pressed, remove "staged" level
|
||||
# line under cursor position
|
||||
view.mode.lines.unstage_line()
|
||||
|
||||
if view.hasFocus():
|
||||
# update mode label
|
||||
view._chart.window().mode_label.setText('mode: view')
|
||||
view._chart.window().set_mode_name('view')
|
||||
|
||||
view.order_mode = False
|
||||
|
||||
|
@ -229,12 +251,13 @@ class ChartView(ViewBox):
|
|||
- zoom on right-click-n-drag to cursor position
|
||||
|
||||
'''
|
||||
mode_name: str = 'mode: view'
|
||||
mode_name: str = 'view'
|
||||
|
||||
def __init__(
|
||||
|
||||
self,
|
||||
|
||||
name: str,
|
||||
|
||||
parent: pg.PlotItem = None,
|
||||
**kwargs,
|
||||
|
||||
|
@ -251,7 +274,6 @@ class ChartView(ViewBox):
|
|||
self.select_box = SelectRect(self)
|
||||
self.addItem(self.select_box, ignoreBounds=True)
|
||||
|
||||
self.name = name
|
||||
self.mode = None
|
||||
self.order_mode: bool = False
|
||||
|
||||
|
@ -260,11 +282,12 @@ class ChartView(ViewBox):
|
|||
@asynccontextmanager
|
||||
async def open_async_input_handler(
|
||||
self,
|
||||
|
||||
) -> 'ChartView':
|
||||
from . import _event
|
||||
|
||||
async with _event.open_handler(
|
||||
self,
|
||||
async with _event.open_handlers(
|
||||
[self],
|
||||
event_types={QEvent.KeyPress, QEvent.KeyRelease},
|
||||
async_handler=handle_viewmode_inputs,
|
||||
):
|
||||
|
|
|
@ -19,7 +19,7 @@ Non-shitty labels that don't re-invent the wheel.
|
|||
|
||||
"""
|
||||
from inspect import isfunction
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtGui, QtWidgets
|
||||
|
@ -31,62 +31,6 @@ from ._style import (
|
|||
)
|
||||
|
||||
|
||||
def vbr_left(label) -> Callable[..., float]:
|
||||
"""Return a closure which gives the scene x-coordinate for the
|
||||
leftmost point of the containing view box.
|
||||
|
||||
"""
|
||||
return label.vbr().left
|
||||
|
||||
|
||||
def right_axis(
|
||||
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
label: 'Label', # noqa
|
||||
side: str = 'left',
|
||||
offset: float = 10,
|
||||
avoid_book: bool = True,
|
||||
width: float = None,
|
||||
|
||||
) -> Callable[..., float]:
|
||||
"""Return a position closure which gives the scene x-coordinate for
|
||||
the x point on the right y-axis minus the width of the label given
|
||||
it's contents.
|
||||
|
||||
"""
|
||||
ryaxis = chart.getAxis('right')
|
||||
|
||||
if side == 'left':
|
||||
|
||||
if avoid_book:
|
||||
def right_axis_offset_by_w() -> float:
|
||||
|
||||
# l1 spread graphics x-size
|
||||
l1_len = chart._max_l1_line_len
|
||||
|
||||
# sum of all distances "from" the y-axis
|
||||
right_offset = l1_len + label.w + offset
|
||||
|
||||
return ryaxis.pos().x() - right_offset
|
||||
|
||||
else:
|
||||
def right_axis_offset_by_w() -> float:
|
||||
|
||||
return ryaxis.pos().x() - (label.w + offset)
|
||||
|
||||
return right_axis_offset_by_w
|
||||
|
||||
elif 'right':
|
||||
|
||||
# axis_offset = ryaxis.style['tickTextOffset'][0]
|
||||
|
||||
def on_axis() -> float:
|
||||
|
||||
return ryaxis.pos().x() # + axis_offset - 2
|
||||
|
||||
return on_axis
|
||||
|
||||
|
||||
class Label:
|
||||
"""
|
||||
A plain ol' "scene label" using an underlying ``QGraphicsTextItem``.
|
||||
|
@ -110,13 +54,14 @@ class Label:
|
|||
|
||||
self,
|
||||
view: pg.ViewBox,
|
||||
|
||||
fmt_str: str,
|
||||
color: str = 'bracket',
|
||||
|
||||
color: str = 'default_light',
|
||||
x_offset: float = 0,
|
||||
font_size: str = 'small',
|
||||
opacity: float = 0.666,
|
||||
fields: dict = {}
|
||||
opacity: float = 1,
|
||||
fields: dict = {},
|
||||
update_on_range_change: bool = True,
|
||||
|
||||
) -> None:
|
||||
|
||||
|
@ -124,6 +69,8 @@ class Label:
|
|||
self._fmt_str = fmt_str
|
||||
self._view_xy = QPointF(0, 0)
|
||||
|
||||
self.scene_anchor: Optional[Callable[..., QPointF]] = None
|
||||
|
||||
self._x_offset = x_offset
|
||||
|
||||
txt = self.txt = QtWidgets.QGraphicsTextItem()
|
||||
|
@ -139,6 +86,7 @@ class Label:
|
|||
txt.setOpacity(opacity)
|
||||
|
||||
# register viewbox callbacks
|
||||
if update_on_range_change:
|
||||
vb.sigRangeChanged.connect(self.on_sigrange_change)
|
||||
|
||||
self._hcolor: str = ''
|
||||
|
@ -165,13 +113,34 @@ class Label:
|
|||
self.txt.setDefaultTextColor(pg.mkColor(hcolor(color)))
|
||||
self._hcolor = color
|
||||
|
||||
def update(self) -> None:
|
||||
'''Update this label either by invoking its
|
||||
user defined anchoring function, or by positioning
|
||||
to the last recorded data view coordinates.
|
||||
|
||||
'''
|
||||
# move label in scene coords to desired position
|
||||
anchor = self.scene_anchor
|
||||
if anchor:
|
||||
self.txt.setPos(anchor())
|
||||
|
||||
else:
|
||||
# position based on last computed view coordinate
|
||||
self.set_view_pos(self._view_xy.y())
|
||||
|
||||
def on_sigrange_change(self, vr, r) -> None:
|
||||
self.set_view_y(self._view_xy.y())
|
||||
return self.update()
|
||||
|
||||
@property
|
||||
def w(self) -> float:
|
||||
return self.txt.boundingRect().width()
|
||||
|
||||
def scene_br(self) -> QRectF:
|
||||
txt = self.txt
|
||||
return txt.mapToScene(
|
||||
txt.boundingRect()
|
||||
).boundingRect()
|
||||
|
||||
@property
|
||||
def h(self) -> float:
|
||||
return self.txt.boundingRect().height()
|
||||
|
@ -186,18 +155,20 @@ class Label:
|
|||
assert isinstance(func(), float)
|
||||
self._anchor_func = func
|
||||
|
||||
def set_view_y(
|
||||
def set_view_pos(
|
||||
self,
|
||||
|
||||
y: float,
|
||||
x: Optional[float] = None,
|
||||
|
||||
) -> None:
|
||||
|
||||
if x is None:
|
||||
scene_x = self._anchor_func() or self.txt.pos().x()
|
||||
x = self.vb.mapToView(QPointF(scene_x, scene_x)).x()
|
||||
|
||||
# get new (inside the) view coordinates / position
|
||||
self._view_xy = QPointF(
|
||||
self.vb.mapToView(QPointF(scene_x, scene_x)).x(),
|
||||
y,
|
||||
)
|
||||
self._view_xy = QPointF(x, y)
|
||||
|
||||
# map back to the outer UI-land "scene" coordinates
|
||||
s_xy = self.vb.mapFromView(self._view_xy)
|
||||
|
@ -210,9 +181,6 @@ class Label:
|
|||
|
||||
assert s_xy == self.txt.pos()
|
||||
|
||||
def orient_on(self, h: str, v: str) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def fmt_str(self) -> str:
|
||||
return self._fmt_str
|
||||
|
@ -221,7 +189,11 @@ class Label:
|
|||
def fmt_str(self, fmt_str: str) -> None:
|
||||
self._fmt_str = fmt_str
|
||||
|
||||
def format(self, **fields: dict) -> str:
|
||||
def format(
|
||||
self,
|
||||
**fields: dict
|
||||
|
||||
) -> str:
|
||||
|
||||
out = {}
|
||||
|
||||
|
@ -229,8 +201,10 @@ class Label:
|
|||
# calcs of field data from field data
|
||||
# ex. to calculate a $value = price * size
|
||||
for k, v in fields.items():
|
||||
|
||||
if isfunction(v):
|
||||
out[k] = v(fields)
|
||||
|
||||
else:
|
||||
out[k] = v
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
Lines for orders, alerts, L2.
|
||||
|
||||
"""
|
||||
from functools import partial
|
||||
from math import floor
|
||||
from typing import Tuple, Optional, List
|
||||
|
||||
|
@ -25,10 +26,17 @@ import pyqtgraph as pg
|
|||
from pyqtgraph import Point, functions as fn
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5.QtCore import QPointF
|
||||
from PyQt5.QtGui import QGraphicsPathItem
|
||||
|
||||
from .._annotate import mk_marker, qgo_draw_markers
|
||||
from .._label import Label, vbr_left, right_axis
|
||||
from .._style import hcolor, _font
|
||||
from ._annotate import mk_marker_path, qgo_draw_markers
|
||||
from ._anchors import (
|
||||
marker_right_points,
|
||||
vbr_left,
|
||||
right_axis,
|
||||
gpath_pin,
|
||||
)
|
||||
from ._label import Label
|
||||
from ._style import hcolor, _font
|
||||
|
||||
|
||||
# TODO: probably worth investigating if we can
|
||||
|
@ -36,12 +44,6 @@ from .._style import hcolor, _font
|
|||
# https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt
|
||||
class LevelLine(pg.InfiniteLine):
|
||||
|
||||
# TODO: fill in these slots for orders
|
||||
# available parent signals
|
||||
# sigDragged(self)
|
||||
# sigPositionChangeFinished(self)
|
||||
# sigPositionChanged(self)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # type: ignore # noqa
|
||||
|
@ -50,7 +52,7 @@ class LevelLine(pg.InfiniteLine):
|
|||
color: str = 'default',
|
||||
highlight_color: str = 'default_light',
|
||||
dotted: bool = False,
|
||||
marker_size: int = 20,
|
||||
# marker_size: int = 20,
|
||||
|
||||
# UX look and feel opts
|
||||
always_show_labels: bool = False,
|
||||
|
@ -63,6 +65,9 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
) -> None:
|
||||
|
||||
# TODO: at this point it's probably not worth the inheritance
|
||||
# any more since we've reimplemented ``.pain()`` among other
|
||||
# things..
|
||||
super().__init__(
|
||||
movable=movable,
|
||||
angle=0,
|
||||
|
@ -77,7 +82,7 @@ class LevelLine(pg.InfiniteLine):
|
|||
self._hide_xhair_on_hover = hide_xhair_on_hover
|
||||
|
||||
self._marker = None
|
||||
self._default_mkr_size = marker_size
|
||||
# self._default_mkr_size = marker_size
|
||||
self._moh = only_show_markers_on_hover
|
||||
self.show_markers: bool = True # presuming the line is hovered at init
|
||||
|
||||
|
@ -97,7 +102,7 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
# list of labels anchored at one of the 2 line endpoints
|
||||
# inside the viewbox
|
||||
self._labels: List[(int, Label)] = []
|
||||
self._labels: List[Label] = []
|
||||
self._markers: List[(int, Label)] = []
|
||||
|
||||
# whenever this line is moved trigger label updates
|
||||
|
@ -114,9 +119,7 @@ class LevelLine(pg.InfiniteLine):
|
|||
self._on_drag_start = lambda l: None
|
||||
self._on_drag_end = lambda l: None
|
||||
|
||||
self._y_incr_mult = 1 / chart._lc._symbol.tick_size
|
||||
self._last_scene_y: float = 0
|
||||
|
||||
self._y_incr_mult = 1 / chart.linked.symbol.tick_size
|
||||
self._right_end_sc: float = 0
|
||||
|
||||
def txt_offsets(self) -> Tuple[int, int]:
|
||||
|
@ -143,52 +146,6 @@ class LevelLine(pg.InfiniteLine):
|
|||
hoverpen.setWidth(2)
|
||||
self.hoverPen = hoverpen
|
||||
|
||||
def add_label(
|
||||
self,
|
||||
|
||||
# by default we only display the line's level value
|
||||
# in the label
|
||||
fmt_str: str = (
|
||||
'{level:,.{level_digits}f}'
|
||||
),
|
||||
side: str = 'right',
|
||||
side_of_axis: str = 'left',
|
||||
x_offset: float = 0,
|
||||
|
||||
color: str = None,
|
||||
bg_color: str = None,
|
||||
avoid_book: bool = True,
|
||||
|
||||
**label_kwargs,
|
||||
) -> Label:
|
||||
"""Add a ``LevelLabel`` anchored at one of the line endpoints in view.
|
||||
|
||||
"""
|
||||
label = Label(
|
||||
view=self.getViewBox(),
|
||||
fmt_str=fmt_str,
|
||||
color=self.color,
|
||||
)
|
||||
|
||||
# set anchor callback
|
||||
if side == 'right':
|
||||
label.set_x_anchor_func(
|
||||
right_axis(
|
||||
self._chart,
|
||||
label,
|
||||
side=side_of_axis,
|
||||
offset=x_offset,
|
||||
avoid_book=avoid_book,
|
||||
)
|
||||
)
|
||||
|
||||
elif side == 'left':
|
||||
label.set_x_anchor_func(vbr_left(label))
|
||||
|
||||
self._labels.append((side, label))
|
||||
|
||||
return label
|
||||
|
||||
def on_pos_change(
|
||||
self,
|
||||
line: 'LevelLine', # noqa
|
||||
|
@ -201,9 +158,11 @@ class LevelLine(pg.InfiniteLine):
|
|||
def update_labels(
|
||||
self,
|
||||
fields_data: dict,
|
||||
|
||||
) -> None:
|
||||
|
||||
for at, label in self._labels:
|
||||
for label in self._labels:
|
||||
|
||||
label.color = self.color
|
||||
# print(f'color is {self.color}')
|
||||
|
||||
|
@ -211,18 +170,18 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
level = fields_data.get('level')
|
||||
if level:
|
||||
label.set_view_y(level)
|
||||
label.set_view_pos(y=level)
|
||||
|
||||
label.render()
|
||||
|
||||
self.update()
|
||||
|
||||
def hide_labels(self) -> None:
|
||||
for at, label in self._labels:
|
||||
for label in self._labels:
|
||||
label.hide()
|
||||
|
||||
def show_labels(self) -> None:
|
||||
for at, label in self._labels:
|
||||
for label in self._labels:
|
||||
label.show()
|
||||
|
||||
def set_level(
|
||||
|
@ -245,15 +204,24 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
def on_tracked_source(
|
||||
self,
|
||||
|
||||
x: int,
|
||||
y: float
|
||||
|
||||
) -> None:
|
||||
# XXX: this is called by our ``Cursor`` type once this
|
||||
# line is set to track the cursor: for every movement
|
||||
# this callback is invoked to reposition the line
|
||||
'''Chart coordinates cursor tracking callback.
|
||||
|
||||
this is called by our ``Cursor`` type once this line is set to
|
||||
track the cursor: for every movement this callback is invoked to
|
||||
reposition the line
|
||||
'''
|
||||
self.movable = True
|
||||
self.set_level(y) # implictly calls reposition handler
|
||||
|
||||
self._chart.linked.godwidget.pp_config.model.get_order_info(
|
||||
price=y
|
||||
)
|
||||
|
||||
def mouseDragEvent(self, ev):
|
||||
"""Override the ``InfiniteLine`` handler since we need more
|
||||
detailed control and start end signalling.
|
||||
|
@ -316,9 +284,10 @@ class LevelLine(pg.InfiniteLine):
|
|||
"""
|
||||
scene = self.scene()
|
||||
if scene:
|
||||
for at, label in self._labels:
|
||||
for label in self._labels:
|
||||
label.delete()
|
||||
|
||||
# gc managed labels?
|
||||
self._labels.clear()
|
||||
|
||||
if self._marker:
|
||||
|
@ -354,9 +323,11 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
def paint(
|
||||
self,
|
||||
|
||||
p: QtGui.QPainter,
|
||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
|
||||
) -> None:
|
||||
"""Core paint which we override (yet again)
|
||||
from pg..
|
||||
|
@ -366,26 +337,14 @@ class LevelLine(pg.InfiniteLine):
|
|||
|
||||
# these are in viewbox coords
|
||||
vb_left, vb_right = self._endPoints
|
||||
|
||||
chart = self._chart
|
||||
l1_len = chart._max_l1_line_len
|
||||
ryaxis = chart.getAxis('right')
|
||||
|
||||
r_axis_x = ryaxis.pos().x()
|
||||
up_to_l1_sc = r_axis_x - l1_len
|
||||
|
||||
vb = self.getViewBox()
|
||||
|
||||
size = self._default_mkr_size
|
||||
marker_right = up_to_l1_sc - (1.375 * 2*size)
|
||||
line_end = marker_right - (6/16 * size)
|
||||
line_end, marker_right, r_axis_x = marker_right_points(self._chart)
|
||||
|
||||
if self.show_markers and self.markers:
|
||||
|
||||
size = self.markers[0][2]
|
||||
|
||||
p.setPen(self.pen)
|
||||
size = qgo_draw_markers(
|
||||
qgo_draw_markers(
|
||||
self.markers,
|
||||
self.pen.color(),
|
||||
p,
|
||||
|
@ -400,9 +359,14 @@ class LevelLine(pg.InfiniteLine):
|
|||
# order lines.. not sure wtf is up with that.
|
||||
# for now we're just using it on the position line.
|
||||
elif self._marker:
|
||||
|
||||
# TODO: make this label update part of a scene-aware-marker
|
||||
# composed annotation
|
||||
self._marker.setPos(
|
||||
QPointF(marker_right, self.scene_y())
|
||||
)
|
||||
if hasattr(self._marker, 'label'):
|
||||
self._marker.label.update()
|
||||
|
||||
elif not self.use_marker_margin:
|
||||
# basically means **don't** shorten the line with normally
|
||||
|
@ -424,23 +388,35 @@ class LevelLine(pg.InfiniteLine):
|
|||
super().hide()
|
||||
if self._marker:
|
||||
self._marker.hide()
|
||||
# self._marker.label.hide()
|
||||
|
||||
def scene_right_xy(self) -> QPointF:
|
||||
return self.getViewBox().mapFromView(
|
||||
QPointF(0, self.value())
|
||||
)
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
if self._marker:
|
||||
self._marker.show()
|
||||
# self._marker.label.show()
|
||||
|
||||
def scene_y(self) -> float:
|
||||
return self.getViewBox().mapFromView(Point(0, self.value())).y()
|
||||
return self.getViewBox().mapFromView(
|
||||
Point(0, self.value())
|
||||
).y()
|
||||
|
||||
def scene_endpoint(self) -> QPointF:
|
||||
|
||||
if not self._right_end_sc:
|
||||
line_end, _, _ = marker_right_points(self._chart)
|
||||
self._right_end_sc = line_end - 10
|
||||
|
||||
return QPointF(self._right_end_sc, self.scene_y())
|
||||
|
||||
def add_marker(
|
||||
self,
|
||||
path: QtWidgets.QGraphicsPathItem,
|
||||
) -> None:
|
||||
|
||||
# chart = self._chart
|
||||
vb = self.getViewBox()
|
||||
vb.scene().addItem(path)
|
||||
) -> QtWidgets.QGraphicsPathItem:
|
||||
|
||||
# add path to scene
|
||||
self.getViewBox().scene().addItem(path)
|
||||
|
||||
self._marker = path
|
||||
|
||||
|
@ -451,7 +427,7 @@ class LevelLine(pg.InfiniteLine):
|
|||
# y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y()
|
||||
path.setPos(QPointF(rsc, self.scene_y()))
|
||||
|
||||
# self.update()
|
||||
return path
|
||||
|
||||
def hoverEvent(self, ev):
|
||||
"""Mouse hover callback.
|
||||
|
@ -469,6 +445,9 @@ class LevelLine(pg.InfiniteLine):
|
|||
if self._moh:
|
||||
self.show_markers = True
|
||||
|
||||
if self._marker:
|
||||
self._marker.show()
|
||||
|
||||
# highlight if so configured
|
||||
if self._hoh:
|
||||
|
||||
|
@ -512,11 +491,14 @@ class LevelLine(pg.InfiniteLine):
|
|||
if self._moh:
|
||||
self.show_markers = False
|
||||
|
||||
if self._marker:
|
||||
self._marker.hide()
|
||||
|
||||
if self not in cur._trackers:
|
||||
cur.show_xhair(y_label_level=self.value())
|
||||
|
||||
if not self._always_show_labels:
|
||||
for at, label in self._labels:
|
||||
for label in self._labels:
|
||||
label.hide()
|
||||
label.txt.update()
|
||||
# label.unhighlight()
|
||||
|
@ -529,24 +511,18 @@ class LevelLine(pg.InfiniteLine):
|
|||
def level_line(
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
level: float,
|
||||
color: str = 'default',
|
||||
|
||||
# whether or not the line placed in view should highlight
|
||||
# when moused over (aka "hovered")
|
||||
hl_on_hover: bool = True,
|
||||
|
||||
# line style
|
||||
dotted: bool = False,
|
||||
color: str = 'default',
|
||||
|
||||
# ux
|
||||
hl_on_hover: bool = True,
|
||||
|
||||
# label fields and options
|
||||
digits: int = 1,
|
||||
|
||||
always_show_labels: bool = False,
|
||||
|
||||
add_label: bool = True,
|
||||
|
||||
orient_v: str = 'bottom',
|
||||
|
||||
**kwargs,
|
||||
|
||||
) -> LevelLine:
|
||||
|
@ -578,14 +554,31 @@ def level_line(
|
|||
|
||||
if add_label:
|
||||
|
||||
label = line.add_label(
|
||||
side='right',
|
||||
opacity=1,
|
||||
x_offset=0,
|
||||
label = Label(
|
||||
|
||||
view=line.getViewBox(),
|
||||
|
||||
# by default we only display the line's level value
|
||||
# in the label
|
||||
fmt_str=('{level:,.{level_digits}f}'),
|
||||
color=color,
|
||||
)
|
||||
|
||||
# anchor to right side (of view ) label
|
||||
label.set_x_anchor_func(
|
||||
right_axis(
|
||||
chart,
|
||||
label,
|
||||
side='left', # side of axis
|
||||
offset=0,
|
||||
avoid_book=False,
|
||||
)
|
||||
label.orient_v = orient_v
|
||||
)
|
||||
|
||||
# add to label set which will be updated on level changes
|
||||
line._labels.append(label)
|
||||
|
||||
label.orient_v = orient_v
|
||||
line.update_labels({'level': level, 'level_digits': 2})
|
||||
label.render()
|
||||
|
||||
|
@ -598,13 +591,14 @@ def level_line(
|
|||
|
||||
|
||||
def order_line(
|
||||
|
||||
chart,
|
||||
level: float,
|
||||
level_digits: float,
|
||||
action: str, # buy or sell
|
||||
|
||||
size: Optional[int] = 1,
|
||||
size_digits: int = 0,
|
||||
size_digits: int = 1,
|
||||
show_markers: bool = False,
|
||||
submit_price: float = None,
|
||||
exec_type: str = 'dark',
|
||||
|
@ -641,43 +635,62 @@ def order_line(
|
|||
'alert': ('v', alert_size),
|
||||
}[action]
|
||||
|
||||
# this fixes it the artifact issue! .. of course, bouding rect stuff
|
||||
# this fixes it the artifact issue! .. of course, bounding rect stuff
|
||||
line._maxMarkerSize = marker_size
|
||||
|
||||
# use ``QPathGraphicsItem``s to draw markers in scene coords
|
||||
# instead of the old way that was doing the same but by
|
||||
# resetting the graphics item transform intermittently
|
||||
|
||||
# XXX: this is our new approach but seems slower?
|
||||
# line.add_marker(mk_marker(marker_style, marker_size))
|
||||
|
||||
assert not line.markers
|
||||
|
||||
# the old way which is still somehow faster?
|
||||
path = mk_marker(
|
||||
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,
|
||||
use_qgpath=False,
|
||||
# marker_size,
|
||||
|
||||
# uncommment for the old transform / .paint() marker method
|
||||
# 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))
|
||||
)
|
||||
path.scale(marker_size, marker_size)
|
||||
|
||||
# XXX: this is our new approach but seems slower?
|
||||
path = line.add_marker(path)
|
||||
|
||||
# XXX: old
|
||||
# path = line.add_marker(mk_marker(marker_style, marker_size))
|
||||
|
||||
line._marker = path
|
||||
|
||||
assert not line.markers
|
||||
|
||||
# # manually append for later ``InfiniteLine.paint()`` drawing
|
||||
# # XXX: this was manually tested as faster then using the
|
||||
# # QGraphicsItem around a painter path.. probably needs further
|
||||
# # testing to figure out why tf that's true.
|
||||
# line.markers.append((path, 0, marker_size))
|
||||
|
||||
orient_v = 'top' if action == 'sell' else 'bottom'
|
||||
|
||||
if action == 'alert':
|
||||
|
||||
llabel = Label(
|
||||
|
||||
view=line.getViewBox(),
|
||||
color=line.color,
|
||||
|
||||
# completely different labelling for alerts
|
||||
fmt_str = 'alert => {level}'
|
||||
fmt_str='alert => {level}',
|
||||
)
|
||||
|
||||
# for now, we're just duplicating the label contents i guess..
|
||||
llabel = line.add_label(
|
||||
side='left',
|
||||
fmt_str=fmt_str,
|
||||
)
|
||||
line._labels.append(llabel)
|
||||
|
||||
# anchor to left side of view / line
|
||||
llabel.set_x_anchor_func(vbr_left(llabel))
|
||||
|
||||
llabel.fields = {
|
||||
'level': level,
|
||||
'level_digits': level_digits,
|
||||
|
@ -686,35 +699,34 @@ def order_line(
|
|||
llabel.render()
|
||||
llabel.show()
|
||||
|
||||
else:
|
||||
# # left side label
|
||||
# llabel = line.add_label(
|
||||
# side='left',
|
||||
# fmt_str=' {exec_type}-{order_type}:\n ${$value}',
|
||||
# )
|
||||
# llabel.fields = {
|
||||
# 'order_type': order_type,
|
||||
# 'level': level,
|
||||
# '$value': lambda f: f['level'] * f['size'],
|
||||
# 'size': size,
|
||||
# 'exec_type': exec_type,
|
||||
# }
|
||||
# llabel.orient_v = orient_v
|
||||
# llabel.render()
|
||||
# llabel.show()
|
||||
path.label = llabel
|
||||
|
||||
# right before L1 label
|
||||
rlabel = line.add_label(
|
||||
side='right',
|
||||
side_of_axis='left',
|
||||
x_offset=4*marker_size,
|
||||
fmt_str=(
|
||||
'{size:.{size_digits}f} '
|
||||
),
|
||||
else:
|
||||
|
||||
rlabel = Label(
|
||||
|
||||
view=line.getViewBox(),
|
||||
|
||||
# display the order pos size, which is some multiple
|
||||
# of the user defined base unit size
|
||||
fmt_str=(':{size:.0f}'),
|
||||
# fmt_str=('{size:.{size_digits}f}'), # old
|
||||
color=line.color,
|
||||
)
|
||||
path.label = rlabel
|
||||
|
||||
rlabel.scene_anchor = partial(
|
||||
gpath_pin,
|
||||
location_description='right-of-path-centered',
|
||||
gpath=path,
|
||||
label=rlabel,
|
||||
)
|
||||
|
||||
line._labels.append(rlabel)
|
||||
|
||||
rlabel.fields = {
|
||||
'size': size,
|
||||
'size_digits': size_digits,
|
||||
# 'size_digits': size_digits,
|
||||
}
|
||||
|
||||
rlabel.orient_v = orient_v
|
||||
|
@ -725,98 +737,3 @@ def order_line(
|
|||
line.update_labels({'level': level})
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def position_line(
|
||||
chart,
|
||||
size: float,
|
||||
|
||||
level: float,
|
||||
|
||||
orient_v: str = 'bottom',
|
||||
|
||||
) -> LevelLine:
|
||||
"""Convenience routine to add a line graphic representing an order
|
||||
execution submitted to the EMS via the chart's "order mode".
|
||||
|
||||
"""
|
||||
line = level_line(
|
||||
chart,
|
||||
level,
|
||||
color='default_light',
|
||||
add_label=False,
|
||||
hl_on_hover=False,
|
||||
movable=False,
|
||||
always_show_labels=False,
|
||||
hide_xhair_on_hover=False,
|
||||
use_marker_margin=True,
|
||||
)
|
||||
# hide position marker when out of view (for now)
|
||||
vb = line.getViewBox()
|
||||
|
||||
def update_pp_nav(chartview):
|
||||
vr = vb.state['viewRange']
|
||||
ymn, ymx = vr[1]
|
||||
level = line.value()
|
||||
|
||||
if gt := level > ymx or (lt := level < ymn):
|
||||
|
||||
if chartview.mode.name == 'order':
|
||||
|
||||
# provide "nav hub" like indicator for where
|
||||
# the position is on the y-dimension
|
||||
if gt:
|
||||
# pin to top of view since position is above current
|
||||
# y-range
|
||||
pass
|
||||
|
||||
elif lt:
|
||||
# pin to bottom of view since position is above
|
||||
# below y-range
|
||||
pass
|
||||
|
||||
else:
|
||||
# order mode is not active
|
||||
# so hide the pp market
|
||||
line._marker.hide()
|
||||
|
||||
else:
|
||||
# pp line is viewable so show marker
|
||||
line._marker.show()
|
||||
|
||||
vb.sigYRangeChanged.connect(update_pp_nav)
|
||||
|
||||
rlabel = line.add_label(
|
||||
side='right',
|
||||
fmt_str='{direction}: {size} -> ${$:.2f}',
|
||||
)
|
||||
rlabel.fields = {
|
||||
'direction': 'long' if size > 0 else 'short',
|
||||
'$': size * level,
|
||||
'size': size,
|
||||
}
|
||||
rlabel.orient_v = orient_v
|
||||
rlabel.render()
|
||||
rlabel.show()
|
||||
|
||||
# arrow marker
|
||||
# scale marker size with dpi-aware font size
|
||||
font_size = _font.font.pixelSize()
|
||||
|
||||
# scale marker size with dpi-aware font size
|
||||
arrow_size = floor(1.375 * font_size)
|
||||
|
||||
if size > 0:
|
||||
style = '|<'
|
||||
elif size < 0:
|
||||
style = '>|'
|
||||
|
||||
arrow_path = mk_marker(style, size=arrow_size)
|
||||
# XXX: uses new marker drawing approach
|
||||
line.add_marker(arrow_path)
|
||||
line.set_level(level)
|
||||
|
||||
# sanity check
|
||||
line.update_labels({'level': level})
|
||||
|
||||
return line
|
|
@ -27,8 +27,8 @@ from PyQt5.QtCore import QLineF, QPointF
|
|||
# from numba import types as ntypes
|
||||
# from ..data._source import numba_ohlc_dtype
|
||||
|
||||
from ..._profile import pg_profile_enabled
|
||||
from .._style import hcolor
|
||||
from .._profile import pg_profile_enabled
|
||||
from ._style import hcolor
|
||||
|
||||
|
||||
def _mk_lines_array(
|
|
@ -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)
|
|
@ -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
|
|
@ -35,9 +35,9 @@ from collections import defaultdict
|
|||
from contextlib import asynccontextmanager
|
||||
from functools import partial
|
||||
from typing import (
|
||||
List, Optional, Callable,
|
||||
Awaitable, Sequence, Dict,
|
||||
Any, AsyncIterator, Tuple,
|
||||
Optional, Callable,
|
||||
Awaitable, Sequence,
|
||||
Any, AsyncIterator
|
||||
)
|
||||
import time
|
||||
# from pprint import pformat
|
||||
|
@ -45,7 +45,7 @@ import time
|
|||
from fuzzywuzzy import process as fuzzy
|
||||
import trio
|
||||
from trio_typing import TaskStatus
|
||||
from PyQt5 import QtCore, QtGui
|
||||
from PyQt5 import QtCore
|
||||
from PyQt5 import QtWidgets
|
||||
from PyQt5.QtCore import (
|
||||
Qt,
|
||||
|
@ -63,40 +63,24 @@ from PyQt5.QtWidgets import (
|
|||
QTreeView,
|
||||
# QListWidgetItem,
|
||||
# QAbstractScrollArea,
|
||||
QStyledItemDelegate,
|
||||
# QStyledItemDelegate,
|
||||
)
|
||||
|
||||
|
||||
from ..log import get_logger
|
||||
from ._style import (
|
||||
_font,
|
||||
DpiAwareFont,
|
||||
# hcolor,
|
||||
hcolor,
|
||||
)
|
||||
from ._forms import FontAndChartAwareLineEdit, FontScaledDelegate
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class SimpleDelegate(QStyledItemDelegate):
|
||||
"""
|
||||
Super simple view delegate to render text in the same
|
||||
font size as the search widget.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
font: DpiAwareFont = _font,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.dpi_font = font
|
||||
|
||||
|
||||
class CompleterView(QTreeView):
|
||||
|
||||
mode_name: str = 'mode: search-nav'
|
||||
mode_name: str = 'search-nav'
|
||||
|
||||
# XXX: relevant docs links:
|
||||
# - simple widget version of this:
|
||||
|
@ -121,7 +105,7 @@ class CompleterView(QTreeView):
|
|||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
labels: List[str] = [],
|
||||
labels: list[str] = [],
|
||||
) -> None:
|
||||
|
||||
super().__init__(parent)
|
||||
|
@ -130,7 +114,7 @@ class CompleterView(QTreeView):
|
|||
self.labels = labels
|
||||
|
||||
# a std "tabular" config
|
||||
self.setItemDelegate(SimpleDelegate())
|
||||
self.setItemDelegate(FontScaledDelegate(self))
|
||||
self.setModel(model)
|
||||
self.setAlternatingRowColors(True)
|
||||
# TODO: size this based on DPI font
|
||||
|
@ -425,59 +409,28 @@ class CompleterView(QTreeView):
|
|||
self.resize()
|
||||
|
||||
|
||||
class SearchBar(QtWidgets.QLineEdit):
|
||||
class SearchBar(FontAndChartAwareLineEdit):
|
||||
|
||||
mode_name: str = 'mode: search'
|
||||
mode_name: str = 'search'
|
||||
|
||||
def __init__(
|
||||
|
||||
self,
|
||||
parent: QWidget,
|
||||
parent_chart: QWidget, # noqa
|
||||
godwidget: QWidget,
|
||||
view: Optional[CompleterView] = None,
|
||||
font: DpiAwareFont = _font,
|
||||
**kwargs,
|
||||
|
||||
) -> None:
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
# self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
# self.customContextMenuRequested.connect(self.show_menu)
|
||||
# self.setStyleSheet(f"font: 18px")
|
||||
|
||||
self.godwidget = godwidget
|
||||
super().__init__(parent, **kwargs)
|
||||
self.view: CompleterView = view
|
||||
self.dpi_font = font
|
||||
self.godwidget = parent_chart
|
||||
|
||||
# size it as we specify
|
||||
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
|
||||
self.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
QtWidgets.QSizePolicy.Fixed,
|
||||
)
|
||||
self.setFont(font.font)
|
||||
|
||||
# witty bit of margin
|
||||
self.setTextMargins(2, 2, 2, 2)
|
||||
|
||||
def focus(self) -> None:
|
||||
self.selectAll()
|
||||
self.show()
|
||||
self.setFocus()
|
||||
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
self.view.show_matches()
|
||||
|
||||
def sizeHint(self) -> QtCore.QSize:
|
||||
"""
|
||||
Scale edit box to size of dpi aware font.
|
||||
|
||||
"""
|
||||
psh = super().sizeHint()
|
||||
psh.setHeight(self.dpi_font.px_size + 2)
|
||||
return psh
|
||||
|
||||
def unfocus(self) -> None:
|
||||
self.parent().hide()
|
||||
self.clearFocus()
|
||||
|
@ -492,12 +445,12 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
Includes helper methods for item management in the sub-widgets.
|
||||
|
||||
'''
|
||||
mode_name: str = 'mode: search'
|
||||
mode_name: str = 'search'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
godwidget: 'GodWidget', # type: ignore # noqa
|
||||
columns: List[str] = ['src', 'symbol'],
|
||||
columns: list[str] = ['src', 'symbol'],
|
||||
parent=None,
|
||||
|
||||
) -> None:
|
||||
|
@ -512,7 +465,7 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
self.godwidget = godwidget
|
||||
|
||||
self.vbox = QtWidgets.QVBoxLayout(self)
|
||||
self.vbox.setContentsMargins(0, 0, 0, 0)
|
||||
self.vbox.setContentsMargins(0, 4, 4, 0)
|
||||
self.vbox.setSpacing(4)
|
||||
|
||||
# split layout for the (label:| search bar entry)
|
||||
|
@ -522,10 +475,17 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
|
||||
# add label to left of search bar
|
||||
self.label = label = QtWidgets.QLabel(parent=self)
|
||||
label.setStyleSheet(
|
||||
f"""QLabel {{
|
||||
color : {hcolor('default_lightest')};
|
||||
font-size : {_font.px_size - 2}px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
label.setTextFormat(3) # markdown
|
||||
label.setFont(_font.font)
|
||||
label.setMargin(4)
|
||||
label.setText("`search`:")
|
||||
label.setText("search:")
|
||||
label.show()
|
||||
label.setAlignment(
|
||||
QtCore.Qt.AlignVCenter
|
||||
|
@ -540,8 +500,8 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
)
|
||||
self.bar = SearchBar(
|
||||
parent=self,
|
||||
parent_chart=godwidget,
|
||||
view=self.view,
|
||||
godwidget=godwidget,
|
||||
)
|
||||
self.bar_hbox.addWidget(self.bar)
|
||||
|
||||
|
@ -564,7 +524,7 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
self.bar.focus()
|
||||
self.show()
|
||||
|
||||
def get_current_item(self) -> Optional[Tuple[str, str]]:
|
||||
def get_current_item(self) -> Optional[tuple[str, str]]:
|
||||
'''Return the current completer tree selection as
|
||||
a tuple ``(parent: str, child: str)`` if valid, else ``None``.
|
||||
|
||||
|
@ -599,11 +559,12 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
def chart_current_item(
|
||||
self,
|
||||
clear_to_cache: bool = True,
|
||||
|
||||
) -> Optional[str]:
|
||||
'''Attempt to load and switch the current selected
|
||||
completion result to the affiliated chart app.
|
||||
|
||||
Return any loaded symbol
|
||||
Return any loaded symbol.
|
||||
|
||||
'''
|
||||
value = self.get_current_item()
|
||||
|
@ -653,10 +614,11 @@ async def pack_matches(
|
|||
|
||||
view: CompleterView,
|
||||
has_results: dict[str, set[str]],
|
||||
matches: dict[(str, str), List[str]],
|
||||
matches: dict[(str, str), list[str]],
|
||||
provider: str,
|
||||
pattern: str,
|
||||
search: Callable[..., Awaitable[dict]],
|
||||
|
||||
task_status: TaskStatus[
|
||||
trio.CancelScope] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
|
@ -834,7 +796,7 @@ async def handle_keyboard_input(
|
|||
# startup
|
||||
bar = searchbar
|
||||
search = searchbar.parent()
|
||||
chart = search.godwidget
|
||||
godwidget = search.godwidget
|
||||
view = bar.view
|
||||
view.set_font_size(bar.dpi_font.px_size)
|
||||
|
||||
|
@ -853,7 +815,8 @@ async def handle_keyboard_input(
|
|||
)
|
||||
)
|
||||
|
||||
async for event, etype, key, mods, txt in recv_chan:
|
||||
async for kbmsg in recv_chan:
|
||||
event, etype, key, mods, txt = kbmsg.to_tuple()
|
||||
|
||||
log.debug(f'key: {key}, mods: {mods}, txt: {txt}')
|
||||
|
||||
|
@ -861,11 +824,6 @@ async def handle_keyboard_input(
|
|||
if mods == Qt.ControlModifier:
|
||||
ctl = True
|
||||
|
||||
# # ctl + alt as combo
|
||||
# ctlalt = False
|
||||
# if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods:
|
||||
# ctlalt = True
|
||||
|
||||
if key in (Qt.Key_Enter, Qt.Key_Return):
|
||||
|
||||
search.chart_current_item(clear_to_cache=True)
|
||||
|
@ -876,7 +834,7 @@ async def handle_keyboard_input(
|
|||
# if nothing in search text show the cache
|
||||
view.set_section_entries(
|
||||
'cache',
|
||||
list(reversed(chart._chart_cache)),
|
||||
list(reversed(godwidget._chart_cache)),
|
||||
clear_all=True,
|
||||
)
|
||||
continue
|
||||
|
@ -890,8 +848,8 @@ async def handle_keyboard_input(
|
|||
search.bar.unfocus()
|
||||
|
||||
# kill the search and focus back on main chart
|
||||
if chart:
|
||||
chart.linkedsplits.focus()
|
||||
if godwidget:
|
||||
godwidget.focus()
|
||||
|
||||
continue
|
||||
|
||||
|
@ -950,7 +908,7 @@ async def handle_keyboard_input(
|
|||
async def search_simple_dict(
|
||||
text: str,
|
||||
source: dict,
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, Any]:
|
||||
|
||||
# search routine can be specified as a function such
|
||||
# as in the case of the current app's local symbol cache
|
||||
|
@ -964,7 +922,7 @@ async def search_simple_dict(
|
|||
|
||||
|
||||
# cache of provider names to async search routines
|
||||
_searcher_cache: Dict[str, Callable[..., Awaitable]] = {}
|
||||
_searcher_cache: dict[str, Callable[..., Awaitable]] = {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
|
@ -56,7 +56,6 @@ class DpiAwareFont:
|
|||
self._qfont = QtGui.QFont(name)
|
||||
self._font_size: str = font_size
|
||||
self._qfm = QtGui.QFontMetrics(self._qfont)
|
||||
self._physical_dpi = None
|
||||
self._font_inches: float = None
|
||||
self._screen = None
|
||||
|
||||
|
@ -82,6 +81,10 @@ class DpiAwareFont:
|
|||
def font(self):
|
||||
return self._qfont
|
||||
|
||||
def scale(self) -> float:
|
||||
screen = self.screen
|
||||
return screen.logicalDotsPerInch() / screen.physicalDotsPerInch()
|
||||
|
||||
@property
|
||||
def px_size(self) -> int:
|
||||
return self._qfont.pixelSize()
|
||||
|
@ -114,14 +117,14 @@ class DpiAwareFont:
|
|||
# dpi is likely somewhat scaled down so use slightly larger font size
|
||||
if scale > 1 and self._font_size:
|
||||
# TODO: this denominator should probably be determined from
|
||||
# relative aspect rations or something?
|
||||
# relative aspect ratios or something?
|
||||
inches = inches * (1 / scale) * (1 + 6/16)
|
||||
dpi = mx_dpi
|
||||
|
||||
self._font_inches = inches
|
||||
|
||||
font_size = math.floor(inches * dpi)
|
||||
log.info(
|
||||
log.debug(
|
||||
f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}"
|
||||
f"\nOur best guess font size is {font_size}\n"
|
||||
)
|
||||
|
|
|
@ -165,7 +165,11 @@ class MainWindow(QtGui.QMainWindow):
|
|||
|
||||
self._status_label = label = QtGui.QLabel()
|
||||
label.setStyleSheet(
|
||||
f"QLabel {{ color : {hcolor('gunmetal')}; }}"
|
||||
f"""QLabel {{
|
||||
color : {hcolor('gunmetal')};
|
||||
}}
|
||||
"""
|
||||
# font-size : {font_size}px;
|
||||
)
|
||||
label.setTextFormat(3) # markdown
|
||||
label.setFont(_font_small.font)
|
||||
|
@ -181,11 +185,13 @@ class MainWindow(QtGui.QMainWindow):
|
|||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QtGui.QCloseEvent,
|
||||
) -> None:
|
||||
"""Cancel the root actor asap.
|
||||
|
||||
"""
|
||||
event: QtGui.QCloseEvent,
|
||||
|
||||
) -> None:
|
||||
'''Cancel the root actor asap.
|
||||
|
||||
'''
|
||||
# raising KBI seems to get intercepted by by Qt so just use the system.
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
|
||||
|
@ -209,18 +215,28 @@ class MainWindow(QtGui.QMainWindow):
|
|||
|
||||
return self._status_bar
|
||||
|
||||
def on_focus_change(
|
||||
def set_mode_name(
|
||||
self,
|
||||
old: QtGui.QWidget,
|
||||
new: QtGui.QWidget,
|
||||
name: str,
|
||||
|
||||
) -> None:
|
||||
|
||||
log.debug(f'widget focus changed from {old} -> {new}')
|
||||
self.mode_label.setText(f'mode:{name}')
|
||||
|
||||
if new is not None:
|
||||
def on_focus_change(
|
||||
self,
|
||||
|
||||
last: QtGui.QWidget,
|
||||
current: QtGui.QWidget,
|
||||
|
||||
) -> None:
|
||||
|
||||
log.info(f'widget focus changed from {last} -> {current}')
|
||||
|
||||
if current is not None:
|
||||
# cursor left window?
|
||||
name = getattr(new, 'mode_name', '')
|
||||
self.mode_label.setText(name)
|
||||
name = getattr(current, 'mode_name', '')
|
||||
self.set_mode_name(name)
|
||||
|
||||
def current_screen(self) -> QtGui.QScreen:
|
||||
"""Get a frickin screen (if we can, gawd).
|
||||
|
@ -230,7 +246,7 @@ class MainWindow(QtGui.QMainWindow):
|
|||
|
||||
for _ in range(3):
|
||||
screen = app.screenAt(self.pos())
|
||||
print('trying to access QScreen...')
|
||||
log.debug('trying to access QScreen...')
|
||||
if screen is None:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
|
|
@ -18,50 +18,61 @@
|
|||
Chart trading, the only way to scalp.
|
||||
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pprint import pformat
|
||||
import time
|
||||
from typing import Optional, Dict, Callable, Any
|
||||
import uuid
|
||||
|
||||
import pyqtgraph as pg
|
||||
from pydantic import BaseModel
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from ._graphics._lines import LevelLine, position_line
|
||||
from ._editors import LineEditor, ArrowEditor
|
||||
from ._window import MultiStatus, main_window
|
||||
from ..clearing._client import open_ems, OrderBook
|
||||
from ..data._source import Symbol
|
||||
from ..log import get_logger
|
||||
from ._editors import LineEditor, ArrowEditor
|
||||
from ._lines import LevelLine
|
||||
from ._position import PositionTracker
|
||||
from ._window import MultiStatus
|
||||
from ._forms import FieldsForm
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class Position(BaseModel):
|
||||
symbol: Symbol
|
||||
size: float
|
||||
avg_price: float
|
||||
class OrderDialog(BaseModel):
|
||||
'''Trade dialogue meta-data describing the lifetime
|
||||
of an order submission to ``emsd`` from a chart.
|
||||
|
||||
'''
|
||||
# TODO: use ``pydantic.UUID4`` field
|
||||
uuid: str
|
||||
line: LevelLine
|
||||
last_status_close: Callable = lambda: None
|
||||
msgs: dict[str, dict] = {}
|
||||
fills: Dict[str, Any] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
underscore_attrs_are_private = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderMode:
|
||||
'''Major mode for placing orders on a chart view.
|
||||
'''Major UX mode for placing orders on a chart view providing so
|
||||
called, "chart trading".
|
||||
|
||||
This is the default mode that pairs with "follow mode"
|
||||
(when wathing the rt price update at the current time step)
|
||||
and allows entering orders using mouse and keyboard.
|
||||
This object is chart oriented, so there is an instance per
|
||||
chart / view currently.
|
||||
This is the other "main" mode that pairs with "view mode" (when
|
||||
wathing the rt price update at the current time step) and allows
|
||||
entering orders using mouse and keyboard. This object is chart
|
||||
oriented, so there is an instance per chart / view currently.
|
||||
|
||||
Current manual:
|
||||
a -> alert
|
||||
s/ctrl -> submission type modifier {on: live, off: dark}
|
||||
f (fill) -> buy limit order
|
||||
d (dump) -> sell limit order
|
||||
f (fill) -> 'buy' limit order
|
||||
d (dump) -> 'sell' limit order
|
||||
c (cancel) -> cancel order under cursor
|
||||
cc -> cancel all submitted orders on chart
|
||||
mouse click and drag -> modify current order under cursor
|
||||
|
@ -71,7 +82,9 @@ class OrderMode:
|
|||
book: OrderBook
|
||||
lines: LineEditor
|
||||
arrows: ArrowEditor
|
||||
status_bar: MultiStatus
|
||||
multistatus: MultiStatus
|
||||
pp: PositionTracker
|
||||
|
||||
name: str = 'order'
|
||||
|
||||
_colors = {
|
||||
|
@ -82,47 +95,27 @@ class OrderMode:
|
|||
_action: str = 'alert'
|
||||
_exec_mode: str = 'dark'
|
||||
_size: float = 100.0
|
||||
_position: Dict[str, Any] = field(default_factory=dict)
|
||||
_position_line: dict = None
|
||||
|
||||
_pending_submissions: dict[str, (LevelLine, Callable)] = field(
|
||||
default_factory=dict)
|
||||
|
||||
def on_position_update(
|
||||
self,
|
||||
msg: dict,
|
||||
) -> None:
|
||||
print(f'Position update {msg}')
|
||||
|
||||
sym = self.chart._lc._symbol
|
||||
if msg['symbol'].lower() not in sym.key:
|
||||
return
|
||||
|
||||
size = msg['size']
|
||||
|
||||
self._position.update(msg)
|
||||
if self._position_line:
|
||||
self._position_line.delete()
|
||||
|
||||
if size != 0.0:
|
||||
line = self._position_line = position_line(
|
||||
self.chart,
|
||||
level=msg['avg_price'],
|
||||
size=size,
|
||||
)
|
||||
line.show()
|
||||
dialogs: dict[str, OrderDialog] = field(default_factory=dict)
|
||||
|
||||
def uuid(self) -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@property
|
||||
def pp_config(self) -> FieldsForm:
|
||||
return self.chart.linked.godwidget.pp_config
|
||||
|
||||
def set_exec(
|
||||
self,
|
||||
|
||||
action: str,
|
||||
size: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Set execution mode.
|
||||
|
||||
"""
|
||||
) -> None:
|
||||
'''
|
||||
Set execution mode.
|
||||
|
||||
'''
|
||||
# not initialized yet
|
||||
if not self.chart.linked.cursor:
|
||||
return
|
||||
|
@ -139,33 +132,50 @@ class OrderMode:
|
|||
action=action,
|
||||
)
|
||||
|
||||
def on_submit(self, uuid: str) -> dict:
|
||||
"""On order submitted event, commit the order line
|
||||
and registered order uuid, store ack time stamp.
|
||||
def on_submit(
|
||||
self,
|
||||
uuid: str
|
||||
|
||||
TODO: annotate order line with submission type ('live' vs.
|
||||
'dark').
|
||||
) -> OrderDialog:
|
||||
'''
|
||||
Order submitted status event handler.
|
||||
|
||||
"""
|
||||
Commit the order line and registered order uuid, store ack time stamp.
|
||||
|
||||
'''
|
||||
line = self.lines.commit_line(uuid)
|
||||
|
||||
pending = self._pending_submissions.get(uuid)
|
||||
if pending:
|
||||
order_line, func = pending
|
||||
assert order_line is line
|
||||
func()
|
||||
# a submission is the start of a new order dialog
|
||||
dialog = self.dialogs[uuid]
|
||||
dialog.line = line
|
||||
dialog.last_status_close()
|
||||
|
||||
return line
|
||||
return dialog
|
||||
|
||||
def on_fill(
|
||||
self,
|
||||
|
||||
uuid: str,
|
||||
price: float,
|
||||
arrow_index: float,
|
||||
pointing: Optional[str] = None
|
||||
) -> None:
|
||||
|
||||
line = self.lines._order_lines.get(uuid)
|
||||
pointing: Optional[str] = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Fill msg handler.
|
||||
|
||||
Triggered on reception of a `filled` message from the
|
||||
EMS.
|
||||
|
||||
Update relevant UIs:
|
||||
|
||||
- add arrow annotation on bar
|
||||
- update fill bar size
|
||||
|
||||
'''
|
||||
dialog = self.dialogs[uuid]
|
||||
line = dialog.line
|
||||
if line:
|
||||
self.arrows.add(
|
||||
uuid,
|
||||
|
@ -174,17 +184,16 @@ class OrderMode:
|
|||
pointing=pointing,
|
||||
color=line.color
|
||||
)
|
||||
else:
|
||||
log.warn("No line for order {uuid}!?")
|
||||
|
||||
async def on_exec(
|
||||
self,
|
||||
|
||||
uuid: str,
|
||||
msg: Dict[str, Any],
|
||||
) -> None:
|
||||
|
||||
# only once all fills have cleared and the execution
|
||||
# is complet do we remove our "order line"
|
||||
line = self.lines.remove_line(uuid=uuid)
|
||||
log.debug(f'deleting {line} with oid: {uuid}')
|
||||
) -> None:
|
||||
|
||||
# DESKTOP NOTIFICATIONS
|
||||
#
|
||||
|
@ -192,6 +201,7 @@ class OrderMode:
|
|||
# not sure if this will ever be a bottleneck,
|
||||
# we probably could do graphics stuff first tho?
|
||||
|
||||
# TODO: make this not trash.
|
||||
# XXX: linux only for now
|
||||
result = await trio.run_process(
|
||||
[
|
||||
|
@ -204,7 +214,11 @@ class OrderMode:
|
|||
)
|
||||
log.runtime(result)
|
||||
|
||||
def on_cancel(self, uuid: str) -> None:
|
||||
def on_cancel(
|
||||
self,
|
||||
uuid: str
|
||||
|
||||
) -> None:
|
||||
|
||||
msg = self.book._sent_orders.pop(uuid, None)
|
||||
|
||||
|
@ -212,10 +226,9 @@ class OrderMode:
|
|||
self.lines.remove_line(uuid=uuid)
|
||||
self.chart.linked.cursor.show_xhair()
|
||||
|
||||
pending = self._pending_submissions.pop(uuid, None)
|
||||
if pending:
|
||||
order_line, func = pending
|
||||
func()
|
||||
dialog = self.dialogs.pop(uuid, None)
|
||||
if dialog:
|
||||
dialog.last_status_close()
|
||||
else:
|
||||
log.warning(
|
||||
f'Received cancel for unsubmitted order {pformat(msg)}'
|
||||
|
@ -225,7 +238,7 @@ class OrderMode:
|
|||
self,
|
||||
size: Optional[float] = None,
|
||||
|
||||
) -> LevelLine:
|
||||
) -> OrderDialog:
|
||||
"""Send execution order to EMS return a level line to
|
||||
represent the order on a chart.
|
||||
|
||||
|
@ -234,7 +247,7 @@ class OrderMode:
|
|||
# to be displayed when above order ack arrives
|
||||
# (means the line graphic doesn't show on screen until the
|
||||
# order is live in the emsd).
|
||||
uid = str(uuid.uuid4())
|
||||
oid = str(uuid.uuid4())
|
||||
|
||||
size = size or self._size
|
||||
|
||||
|
@ -242,13 +255,49 @@ class OrderMode:
|
|||
chart = cursor.active_plot
|
||||
y = cursor._datum_xy[1]
|
||||
|
||||
symbol = self.chart._lc._symbol
|
||||
|
||||
symbol = self.chart.linked.symbol
|
||||
action = self._action
|
||||
|
||||
# TODO: update the line once an ack event comes back
|
||||
# from the EMS!
|
||||
|
||||
# TODO: place a grey line in "submission" mode
|
||||
# which will be updated to it's appropriate action
|
||||
# color once the submission ack arrives.
|
||||
|
||||
# make line graphic if order push was sucessful
|
||||
line = self.lines.create_order_line(
|
||||
oid,
|
||||
level=y,
|
||||
chart=chart,
|
||||
size=size,
|
||||
action=action,
|
||||
)
|
||||
|
||||
dialog = OrderDialog(
|
||||
uuid=oid,
|
||||
line=line,
|
||||
last_status_close=self.multistatus.open_status(
|
||||
f'submitting {self._exec_mode}-{action}',
|
||||
final_msg=f'submitted {self._exec_mode}-{action}',
|
||||
clear_on_next=True,
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: create a new ``OrderLine`` with this optional var defined
|
||||
line.dialog = dialog
|
||||
|
||||
# enter submission which will be popped once a response
|
||||
# from the EMS is received to move the order to a different# status
|
||||
self.dialogs[oid] = dialog
|
||||
|
||||
# hook up mouse drag handlers
|
||||
line._on_drag_start = self.order_line_modify_start
|
||||
line._on_drag_end = self.order_line_modify_complete
|
||||
|
||||
# send order cmd to ems
|
||||
self.book.send(
|
||||
uuid=uid,
|
||||
uuid=oid,
|
||||
symbol=symbol.key,
|
||||
brokers=symbol.brokers,
|
||||
price=y,
|
||||
|
@ -257,36 +306,7 @@ class OrderMode:
|
|||
exec_mode=self._exec_mode,
|
||||
)
|
||||
|
||||
# TODO: update the line once an ack event comes back
|
||||
# from the EMS!
|
||||
|
||||
# make line graphic if order push was
|
||||
# sucessful
|
||||
line = self.lines.create_order_line(
|
||||
uid,
|
||||
level=y,
|
||||
chart=chart,
|
||||
size=size,
|
||||
action=action,
|
||||
)
|
||||
line.oid = uid
|
||||
|
||||
# enter submission which will be popped once a response
|
||||
# from the EMS is received to move the order to a different# status
|
||||
self._pending_submissions[uid] = (
|
||||
line,
|
||||
self.status_bar.open_status(
|
||||
f'submitting {self._exec_mode}-{action}',
|
||||
final_msg=f'submitted {self._exec_mode}-{action}',
|
||||
clear_on_next=True,
|
||||
)
|
||||
)
|
||||
|
||||
# hook up mouse drag handlers
|
||||
line._on_drag_start = self.order_line_modify_start
|
||||
line._on_drag_end = self.order_line_modify_complete
|
||||
|
||||
return line
|
||||
return dialog
|
||||
|
||||
def cancel_orders_under_cursor(self) -> list[str]:
|
||||
return self.cancel_orders_from_lines(
|
||||
|
@ -309,7 +329,7 @@ class OrderMode:
|
|||
|
||||
ids: list = []
|
||||
if lines:
|
||||
key = self.status_bar.open_status(
|
||||
key = self.multistatus.open_status(
|
||||
f'cancelling {len(lines)} orders',
|
||||
final_msg=f'cancelled {len(lines)} orders',
|
||||
group_key=True
|
||||
|
@ -317,16 +337,16 @@ class OrderMode:
|
|||
|
||||
# cancel all active orders and triggers
|
||||
for line in lines:
|
||||
oid = getattr(line, 'oid', None)
|
||||
dialog = getattr(line, 'dialog', None)
|
||||
|
||||
if oid:
|
||||
self._pending_submissions[oid] = (
|
||||
line,
|
||||
self.status_bar.open_status(
|
||||
if dialog:
|
||||
oid = dialog.uuid
|
||||
|
||||
cancel_status_close = self.multistatus.open_status(
|
||||
f'cancelling order {oid[:6]}',
|
||||
group_key=key,
|
||||
),
|
||||
)
|
||||
dialog.last_status_close = cancel_status_close
|
||||
|
||||
ids.append(oid)
|
||||
self.book.cancel(uuid=oid)
|
||||
|
@ -338,40 +358,87 @@ class OrderMode:
|
|||
def order_line_modify_start(
|
||||
self,
|
||||
line: LevelLine,
|
||||
|
||||
) -> None:
|
||||
|
||||
print(f'Line modify: {line}')
|
||||
# cancel original order until new position is found
|
||||
|
||||
def order_line_modify_complete(
|
||||
self,
|
||||
line: LevelLine,
|
||||
) -> None:
|
||||
self.book.update(
|
||||
uuid=line.oid,
|
||||
|
||||
# TODO: should we round this to a nearest tick here?
|
||||
) -> None:
|
||||
|
||||
self.book.update(
|
||||
uuid=line.dialog.uuid,
|
||||
|
||||
# TODO: must adjust sizing
|
||||
# - should we round this to a nearest tick here and how?
|
||||
# - need to recompute the size from the pp allocator
|
||||
price=line.value(),
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_order_mode(
|
||||
async def run_order_mode(
|
||||
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
symbol: Symbol,
|
||||
chart: pg.PlotWidget,
|
||||
book: OrderBook,
|
||||
brokername: str,
|
||||
started: trio.Event,
|
||||
|
||||
) -> None:
|
||||
'''Activate chart-trader order mode loop:
|
||||
|
||||
- connect to emsd
|
||||
- load existing positions
|
||||
- begin EMS response handling loop which updates local
|
||||
state, mostly graphics / UI.
|
||||
|
||||
'''
|
||||
multistatus = chart.window().status_bar
|
||||
done = multistatus.open_status('starting order mode..')
|
||||
|
||||
book: OrderBook
|
||||
trades_stream: tractor.MsgStream
|
||||
positions: dict
|
||||
|
||||
# spawn EMS actor-service
|
||||
async with (
|
||||
|
||||
open_ems(brokername, symbol) as (
|
||||
book,
|
||||
trades_stream,
|
||||
positions
|
||||
),
|
||||
|
||||
):
|
||||
status_bar: MultiStatus = main_window().status_bar
|
||||
view = chart._vb
|
||||
view = chart.view
|
||||
lines = LineEditor(chart=chart)
|
||||
arrows = ArrowEditor(chart, {})
|
||||
|
||||
log.info("Opening order mode")
|
||||
|
||||
mode = OrderMode(chart, book, lines, arrows, status_bar)
|
||||
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
|
||||
|
||||
|
@ -381,48 +448,15 @@ async def open_order_mode(
|
|||
else: # to be safe
|
||||
mode._size = 1.0
|
||||
|
||||
try:
|
||||
yield mode
|
||||
|
||||
finally:
|
||||
# XXX special teardown handling like for ex.
|
||||
# - cancelling orders if needed?
|
||||
# - closing positions if desired?
|
||||
# - switching special condition orders to safer/more reliable variants
|
||||
log.info("Closing order mode")
|
||||
|
||||
|
||||
async def start_order_mode(
|
||||
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
symbol: Symbol,
|
||||
brokername: str,
|
||||
|
||||
started: trio.Event,
|
||||
|
||||
) -> None:
|
||||
'''Activate chart-trader order mode loop:
|
||||
- connect to emsd
|
||||
- load existing positions
|
||||
- begin order handling loop
|
||||
|
||||
'''
|
||||
done = chart.window().status_bar.open_status('starting order mode..')
|
||||
|
||||
# spawn EMS actor-service
|
||||
async with (
|
||||
open_ems(brokername, symbol) as (book, trades_stream, positions),
|
||||
open_order_mode(symbol, chart, book) as order_mode,
|
||||
|
||||
# # start async input handling for chart's view
|
||||
# # await godwidget._task_stack.enter_async_context(
|
||||
# chart._vb.open_async_input_handler(),
|
||||
):
|
||||
|
||||
# update any exising positions
|
||||
# update any exising position
|
||||
for sym, msg in positions.items():
|
||||
order_mode.on_position_update(msg)
|
||||
|
||||
our_sym = mode.chart.linked._symbol.key
|
||||
if sym.lower() in our_sym:
|
||||
pp.update(msg)
|
||||
|
||||
# TODO: this should go onto some sort of
|
||||
# data-view strimg thinger..right?
|
||||
def get_index(time: float):
|
||||
|
||||
# XXX: not sure why the time is so off here
|
||||
|
@ -440,7 +474,12 @@ async def start_order_mode(
|
|||
done()
|
||||
|
||||
# start async input handling for chart's view
|
||||
async with chart._vb.open_async_input_handler():
|
||||
async with (
|
||||
chart._vb.open_async_input_handler(),
|
||||
|
||||
# TODO: config form handler nursery
|
||||
|
||||
):
|
||||
|
||||
# signal to top level symbol loading task we're ready
|
||||
# to handle input since the ems connection is ready
|
||||
|
@ -458,12 +497,29 @@ async def start_order_mode(
|
|||
'position',
|
||||
):
|
||||
# show line label once order is live
|
||||
order_mode.on_position_update(msg)
|
||||
|
||||
sym = mode.chart.linked.symbol
|
||||
if msg['symbol'].lower() in sym.key:
|
||||
pp.update(msg)
|
||||
|
||||
# short circuit to next msg to avoid
|
||||
# uncessary msg content lookups
|
||||
continue
|
||||
|
||||
resp = msg['resp']
|
||||
oid = msg['oid']
|
||||
|
||||
dialog = mode.dialogs.get(oid)
|
||||
if dialog is None:
|
||||
log.warning(f'received msg for untracked dialog:\n{fmsg}')
|
||||
|
||||
# TODO: enable pure tracking / mirroring of dialogs
|
||||
# is desired.
|
||||
continue
|
||||
|
||||
# record message to dialog tracking
|
||||
dialog.msgs[oid] = msg
|
||||
|
||||
# response to 'action' request (buy/sell)
|
||||
if resp in (
|
||||
'dark_submitted',
|
||||
|
@ -471,7 +527,7 @@ async def start_order_mode(
|
|||
):
|
||||
|
||||
# show line label once order is live
|
||||
order_mode.on_submit(oid)
|
||||
mode.on_submit(oid)
|
||||
|
||||
# resp to 'cancel' request or error condition
|
||||
# for action request
|
||||
|
@ -481,7 +537,7 @@ async def start_order_mode(
|
|||
'dark_cancelled'
|
||||
):
|
||||
# delete level line from view
|
||||
order_mode.on_cancel(oid)
|
||||
mode.on_cancel(oid)
|
||||
|
||||
elif resp in (
|
||||
'dark_triggered'
|
||||
|
@ -493,18 +549,23 @@ async def start_order_mode(
|
|||
):
|
||||
# should only be one "fill" for an alert
|
||||
# add a triangle and remove the level line
|
||||
order_mode.on_fill(
|
||||
mode.on_fill(
|
||||
oid,
|
||||
price=msg['trigger_price'],
|
||||
arrow_index=get_index(time.time())
|
||||
arrow_index=get_index(time.time()),
|
||||
)
|
||||
await order_mode.on_exec(oid, msg)
|
||||
mode.lines.remove_line(uuid=oid)
|
||||
await mode.on_exec(oid, msg)
|
||||
|
||||
# response to completed 'action' request for buy/sell
|
||||
elif resp in (
|
||||
'broker_executed',
|
||||
):
|
||||
await order_mode.on_exec(oid, msg)
|
||||
# right now this is just triggering a system alert
|
||||
await mode.on_exec(oid, msg)
|
||||
|
||||
if msg['brokerd_msg']['remaining'] == 0:
|
||||
mode.lines.remove_line(uuid=oid)
|
||||
|
||||
# each clearing tick is responded individually
|
||||
elif resp in ('broker_filled',):
|
||||
|
@ -518,7 +579,7 @@ async def start_order_mode(
|
|||
details = msg['brokerd_msg']
|
||||
|
||||
# TODO: some kinda progress system
|
||||
order_mode.on_fill(
|
||||
mode.on_fill(
|
||||
oid,
|
||||
price=details['price'],
|
||||
pointing='up' if action == 'buy' else 'down',
|
||||
|
@ -526,3 +587,5 @@ async def start_order_mode(
|
|||
# TODO: put the actual exchange timestamp
|
||||
arrow_index=get_index(details['broker_time']),
|
||||
)
|
||||
|
||||
pp.info.fills.append(msg)
|
||||
|
|
Loading…
Reference in New Issue