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