Compare commits
71 Commits
310_plus
...
windows_te
Author | SHA1 | Date |
---|---|---|
wattygetlood | 98df996209 | |
wattygetlood | 614089ae84 | |
wattygetlood | 3c9c772177 | |
wattygetlood | a5d38df689 | |
wattygetlood | 02e9240986 | |
wattygetlood | ad623119fa | |
wattygetlood | 48ad97de2c | |
wattygetlood | 25eb6f0087 | |
Tyler Goodlet | 6c86db8f2c | |
Tyler Goodlet | 704cc80708 | |
Tyler Goodlet | 90f3db31bb | |
Tyler Goodlet | 8f0662f3aa | |
Tyler Goodlet | 43c59a2149 | |
Tyler Goodlet | baf3fcb6fd | |
Tyler Goodlet | be21df33c5 | |
Tyler Goodlet | 456cdc1820 | |
Tyler Goodlet | d2d1f3223b | |
Tyler Goodlet | 0735d924a6 | |
Tyler Goodlet | e978ae3151 | |
Tyler Goodlet | 284e9397f7 | |
Tyler Goodlet | 905dc5a456 | |
Tyler Goodlet | ef6eb3808e | |
Tyler Goodlet | 3a3be89bd2 | |
Tyler Goodlet | 4812cce8cf | |
wattygetlood | 5bc2609a24 | |
Tyler Goodlet | baaf8d4a9d | |
Tyler Goodlet | 8b7e7fd110 | |
Tyler Goodlet | ade0993d61 | |
Tyler Goodlet | 1ad7e9bae2 | |
Tyler Goodlet | ceb1152f2c | |
Tyler Goodlet | 7d8202a63c | |
Tyler Goodlet | 4783dd7efa | |
Tyler Goodlet | 4057209900 | |
Tyler Goodlet | dde8697f71 | |
Tyler Goodlet | 926ee78216 | |
Tyler Goodlet | 49227b6e57 | |
Tyler Goodlet | 3f39c2bdfa | |
Tyler Goodlet | 6897393715 | |
Tyler Goodlet | 549ff4ef11 | |
Tyler Goodlet | 6751840568 | |
Tyler Goodlet | d0bad2e98e | |
Tyler Goodlet | f269fe9732 | |
Tyler Goodlet | c37e6e2440 | |
Tyler Goodlet | 95baca219d | |
Tyler Goodlet | 6a82b1802b | |
Tyler Goodlet | d751707e06 | |
Tyler Goodlet | 063b860cd4 | |
Tyler Goodlet | 94793c276f | |
Tyler Goodlet | baaf6abec6 | |
Tyler Goodlet | a1b455349f | |
Tyler Goodlet | 940fc5d2f0 | |
Tyler Goodlet | 6dd42bcfb3 | |
Tyler Goodlet | 3670184c18 | |
Tyler Goodlet | d6d284fec5 | |
Tyler Goodlet | c82046d59b | |
Tyler Goodlet | 230fb00777 | |
Tyler Goodlet | 168c1eb2a9 | |
Tyler Goodlet | 699e670363 | |
Tyler Goodlet | 1e20b1d4dd | |
Tyler Goodlet | df14ff052a | |
Tyler Goodlet | 8d34a2f13a | |
Tyler Goodlet | 8d44706588 | |
Tyler Goodlet | cdf9f740f7 | |
Tyler Goodlet | 96c8908e9c | |
Tyler Goodlet | 3efe3b38b8 | |
Tyler Goodlet | a6629520b1 | |
Tyler Goodlet | febb9310e8 | |
Tyler Goodlet | 8cfd046284 | |
Tyler Goodlet | 05a00c9dcc | |
Tyler Goodlet | 12858e9113 | |
Tyler Goodlet | edcbc88a42 |
28
README.rst
28
README.rst
|
@ -72,6 +72,34 @@ for a development install::
|
|||
pip install -r requirements.txt -e .
|
||||
|
||||
|
||||
install for tinas
|
||||
*****************
|
||||
for windows peeps you can start by getting `conda installed`_
|
||||
and the `C++ build toolz`_ on your system.
|
||||
|
||||
then, `crack a conda shell`_ and run the following commands::
|
||||
|
||||
conda create piker --python=3.9
|
||||
conda activate piker
|
||||
conda install pip
|
||||
pip install --upgrade setuptools
|
||||
cd dIreCToRieZ\oF\cODez\piker\
|
||||
pip install -r requirements -e .
|
||||
|
||||
|
||||
in order to look coolio in front of all ur tina friends (and maybe
|
||||
want to help us with testin, hackzing or configgin), install
|
||||
`vscode`_ and `setup a coolio tiled wm console`_ so you can start
|
||||
living the life of the tech literate..
|
||||
|
||||
.. _conda installed: https://
|
||||
.. _C++ build toolz: https://
|
||||
.. _crack a conda shell: https://
|
||||
.. _vscode: https://
|
||||
|
||||
.. link to the tina guide
|
||||
.. _setup a coolio tiled wm console: https://
|
||||
|
||||
provider support
|
||||
****************
|
||||
for live data feeds the in-progress set of supported brokers is:
|
||||
|
|
|
@ -18,30 +18,18 @@
|
|||
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 (
|
||||
Any,
|
||||
Hashable,
|
||||
Optional,
|
||||
TypeVar,
|
||||
AsyncContextManager,
|
||||
)
|
||||
from contextlib import (
|
||||
asynccontextmanager,
|
||||
)
|
||||
|
||||
import trio
|
||||
from trio_typing import TaskStatus
|
||||
import tractor
|
||||
from tractor.trionics import maybe_open_context
|
||||
|
||||
from .brokers import get_brokermod
|
||||
from .log import get_logger
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
|
@ -74,112 +62,6 @@ def async_lifo_cache(maxsize=128):
|
|||
return decorator
|
||||
|
||||
|
||||
_cache: dict[str, 'Client'] = {} # noqa
|
||||
|
||||
|
||||
class cache:
|
||||
'''Globally (processs wide) cached, task access to a
|
||||
kept-alive-while-in-use async resource.
|
||||
|
||||
'''
|
||||
lock = trio.Lock()
|
||||
users: int = 0
|
||||
values: dict[Any, Any] = {}
|
||||
resources: dict[
|
||||
int,
|
||||
Optional[tuple[trio.Nursery, trio.Event]]
|
||||
] = {}
|
||||
no_more_users: Optional[trio.Event] = None
|
||||
|
||||
@classmethod
|
||||
async def run_ctx(
|
||||
cls,
|
||||
mng,
|
||||
key,
|
||||
task_status: TaskStatus[T] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
) -> None:
|
||||
async with mng as value:
|
||||
|
||||
_, no_more_users = cls.resources[id(mng)]
|
||||
cls.values[key] = value
|
||||
task_status.started(value)
|
||||
try:
|
||||
await no_more_users.wait()
|
||||
finally:
|
||||
value = cls.values.pop(key)
|
||||
# discard nursery ref so it won't be re-used (an error)
|
||||
cls.resources.pop(id(mng))
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
'''
|
||||
|
||||
await cache.lock.acquire()
|
||||
|
||||
ctx_key = id(mngr)
|
||||
|
||||
value = None
|
||||
try:
|
||||
# lock feed acquisition around task racing / ``trio``'s
|
||||
# scheduler protocol
|
||||
value = cache.values[key]
|
||||
log.info(f'Reusing cached resource for {key}')
|
||||
cache.users += 1
|
||||
cache.lock.release()
|
||||
yield True, value
|
||||
|
||||
except KeyError:
|
||||
log.info(f'Allocating new resource for {key}')
|
||||
|
||||
# **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.
|
||||
|
||||
# TODO: avoid pulling from ``tractor`` internals and
|
||||
# instead offer a "root nursery" in piker actors?
|
||||
service_n = tractor.current_actor()._service_n
|
||||
|
||||
# TODO: does this need to be a tractor "root nursery"?
|
||||
ln = cache.resources.get(ctx_key)
|
||||
assert not ln
|
||||
|
||||
ln, _ = cache.resources[ctx_key] = (service_n, trio.Event())
|
||||
|
||||
value = await ln.start(cache.run_ctx, mngr, key)
|
||||
cache.users += 1
|
||||
cache.lock.release()
|
||||
|
||||
yield False, value
|
||||
|
||||
finally:
|
||||
cache.users -= 1
|
||||
|
||||
if cache.lock.locked():
|
||||
cache.lock.release()
|
||||
|
||||
if value is not None:
|
||||
# if no more consumers, teardown the client
|
||||
if cache.users <= 0:
|
||||
log.warning(f'De-allocating resource for {key}')
|
||||
|
||||
# terminate mngr nursery
|
||||
entry = cache.resources.get(ctx_key)
|
||||
if entry:
|
||||
_, no_more_users = entry
|
||||
no_more_users.set()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def open_cached_client(
|
||||
brokername: str,
|
||||
|
@ -190,7 +72,7 @@ async def open_cached_client(
|
|||
|
||||
'''
|
||||
brokermod = get_brokermod(brokername)
|
||||
async with maybe_open_ctx(
|
||||
async with maybe_open_context(
|
||||
key=brokername,
|
||||
mngr=brokermod.get_client(),
|
||||
) as (cache_hit, client):
|
||||
|
|
|
@ -21,7 +21,7 @@ Profiling wrappers for internal libs.
|
|||
import time
|
||||
from functools import wraps
|
||||
|
||||
_pg_profile: bool = False
|
||||
_pg_profile: bool = True
|
||||
|
||||
|
||||
def pg_profile_enabled() -> bool:
|
||||
|
|
|
@ -19,7 +19,7 @@ Binance backend
|
|||
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import List, Dict, Any, Tuple, Union, Optional
|
||||
from typing import List, Dict, Any, Tuple, Union, Optional, AsyncGenerator
|
||||
import time
|
||||
|
||||
import trio
|
||||
|
@ -37,7 +37,7 @@ from .._cacheables import open_cached_client
|
|||
from ._util import resproc, SymbolNotFound
|
||||
from ..log import get_logger, get_console_log
|
||||
from ..data import ShmArray
|
||||
from ..data._web_bs import open_autorecon_ws
|
||||
from ..data._web_bs import open_autorecon_ws, NoBsWs
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
@ -213,7 +213,7 @@ class Client:
|
|||
)
|
||||
# repack in dict form
|
||||
return {item[0]['symbol']: item[0]
|
||||
for item in matches}
|
||||
for item in matches}
|
||||
|
||||
async def bars(
|
||||
self,
|
||||
|
@ -295,7 +295,7 @@ class AggTrade(BaseModel):
|
|||
M: bool # Ignore
|
||||
|
||||
|
||||
async def stream_messages(ws):
|
||||
async def stream_messages(ws: NoBsWs) -> AsyncGenerator[NoBsWs, dict]:
|
||||
|
||||
timeouts = 0
|
||||
while True:
|
||||
|
@ -487,11 +487,20 @@ async def stream_quotes(
|
|||
# signal to caller feed is ready for consumption
|
||||
feed_is_live.set()
|
||||
|
||||
# import time
|
||||
# last = time.time()
|
||||
|
||||
# start streaming
|
||||
async for typ, msg in msg_gen:
|
||||
|
||||
# period = time.time() - last
|
||||
# hz = 1/period if period else float('inf')
|
||||
# if hz > 60:
|
||||
# log.info(f'Binance quotez : {hz}')
|
||||
|
||||
topic = msg['symbol'].lower()
|
||||
await send_chan.send({topic: msg})
|
||||
# last = time.time()
|
||||
|
||||
|
||||
@tractor.context
|
||||
|
|
|
@ -1157,6 +1157,11 @@ async def backfill_bars(
|
|||
https://github.com/pikers/piker/issues/128
|
||||
|
||||
"""
|
||||
if platform.system() == 'Windows':
|
||||
log.warning(
|
||||
'Decreasing history query count to 4 since, windows...')
|
||||
count = 4
|
||||
|
||||
out, fails = await get_bars(sym)
|
||||
if out is None:
|
||||
raise RuntimeError("Could not pull currrent history?!")
|
||||
|
|
|
@ -87,13 +87,21 @@ class Allocator(BaseModel):
|
|||
|
||||
symbol: Symbol
|
||||
account: Optional[str] = 'paper'
|
||||
size_unit: SizeUnit = 'currency'
|
||||
# TODO: for enums this clearly doesn't fucking work, you can't set
|
||||
# a default at startup by passing in a `dict` but yet you can set
|
||||
# that value through assignment..for wtv cucked reason.. honestly, pure
|
||||
# unintuitive garbage.
|
||||
size_unit: str = 'currency'
|
||||
_size_units: dict[str, Optional[str]] = _size_units
|
||||
|
||||
@validator('size_unit')
|
||||
def lookup_key(cls, v):
|
||||
@validator('size_unit', pre=True)
|
||||
def maybe_lookup_key(cls, v):
|
||||
# apply the corresponding enum key for the text "description" value
|
||||
return v.name
|
||||
if v not in _size_units:
|
||||
return _size_units.inverse[v]
|
||||
|
||||
assert v in _size_units
|
||||
return v
|
||||
|
||||
# TODO: if we ever want ot support non-uniform entry-slot-proportion
|
||||
# "sizes"
|
||||
|
@ -157,6 +165,9 @@ class Allocator(BaseModel):
|
|||
slot_size = currency_per_slot / price
|
||||
l_sub_pp = (self.currency_limit - live_cost_basis) / price
|
||||
|
||||
else:
|
||||
raise ValueError(f"Not valid size unit '{size}'")
|
||||
|
||||
# an entry (adding-to or starting a pp)
|
||||
if (
|
||||
action == 'buy' and live_size > 0 or
|
||||
|
@ -204,7 +215,14 @@ class Allocator(BaseModel):
|
|||
# **without** going past a net-zero pp. if the pp is
|
||||
# > 1.5x a slot size, then front load: exit a slot's and
|
||||
# expect net-zero to be acquired on the final exit.
|
||||
slot_size < pp_size < round((1.5*slot_size), ndigits=ld)
|
||||
slot_size < pp_size < round((1.5*slot_size), ndigits=ld) or
|
||||
|
||||
# underlying requires discrete (int) units (eg. stocks)
|
||||
# and thus our slot size (based on our limit) would
|
||||
# exit a fractional unit's worth so, presuming we aren't
|
||||
# supporting a fractional-units-style broker, we need
|
||||
# exit the final unit.
|
||||
ld == 0 and abs_live_size == 1
|
||||
):
|
||||
order_size = abs_live_size
|
||||
|
||||
|
@ -259,7 +277,7 @@ def mk_allocator(
|
|||
# default allocation settings
|
||||
defaults: dict[str, float] = {
|
||||
'account': None, # select paper by default
|
||||
'size_unit': _size_units['currency'],
|
||||
'size_unit': 'currency', #_size_units['currency'],
|
||||
'units_limit': 400,
|
||||
'currency_limit': 5e3,
|
||||
'slots': 4,
|
||||
|
@ -274,8 +292,8 @@ def mk_allocator(
|
|||
# load and retreive user settings for default allocations
|
||||
# ``config.toml``
|
||||
user_def = {
|
||||
'currency_limit': 5e3,
|
||||
'slots': 4,
|
||||
'currency_limit': 6e3,
|
||||
'slots': 6,
|
||||
}
|
||||
|
||||
defaults.update(user_def)
|
||||
|
@ -287,6 +305,7 @@ def mk_allocator(
|
|||
|
||||
asset_type = symbol.type_key
|
||||
|
||||
|
||||
# specific configs by asset class / type
|
||||
|
||||
if asset_type in ('future', 'option', 'futures_option'):
|
||||
|
@ -308,9 +327,12 @@ def mk_allocator(
|
|||
alloc.currency_limit = round(startup_size, ndigits=2)
|
||||
|
||||
else:
|
||||
startup_size = startup_pp.size
|
||||
startup_size = abs(startup_pp.size)
|
||||
|
||||
if startup_size > alloc.units_limit:
|
||||
alloc.units_limit = startup_size
|
||||
|
||||
if asset_type in ('future', 'option', 'futures_option'):
|
||||
alloc.slots = alloc.units_limit
|
||||
|
||||
return alloc
|
||||
|
|
|
@ -20,6 +20,7 @@ In da suit parlances: "Execution management systems"
|
|||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from math import isnan
|
||||
from pprint import pformat
|
||||
import time
|
||||
from typing import AsyncIterator, Callable
|
||||
|
@ -47,9 +48,11 @@ log = get_logger(__name__)
|
|||
|
||||
# TODO: numba all of this
|
||||
def mk_check(
|
||||
|
||||
trigger_price: float,
|
||||
known_last: float,
|
||||
action: str,
|
||||
|
||||
) -> Callable[[float, float], bool]:
|
||||
"""Create a predicate for given ``exec_price`` based on last known
|
||||
price, ``known_last``.
|
||||
|
@ -77,8 +80,7 @@ def mk_check(
|
|||
|
||||
return check_lt
|
||||
|
||||
else:
|
||||
return None
|
||||
raise ValueError('trigger: {trigger_price}, last: {known_last}')
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -177,7 +179,15 @@ async def clear_dark_triggers(
|
|||
tuple(execs.items())
|
||||
):
|
||||
|
||||
if not pred or (ttype not in tf) or (not pred(price)):
|
||||
if (
|
||||
not pred or
|
||||
ttype not in tf or
|
||||
not pred(price)
|
||||
):
|
||||
log.debug(
|
||||
f'skipping quote for {sym} '
|
||||
f'{pred}, {ttype} not in {tf}?, {pred(price)}'
|
||||
)
|
||||
# majority of iterations will be non-matches
|
||||
continue
|
||||
|
||||
|
@ -269,7 +279,7 @@ class TradesRelay:
|
|||
positions: dict[str, dict[str, BrokerdPosition]]
|
||||
|
||||
# allowed account names
|
||||
accounts: set[str]
|
||||
accounts: tuple[str]
|
||||
|
||||
# count of connected ems clients for this ``brokerd``
|
||||
consumers: int = 0
|
||||
|
@ -414,6 +424,9 @@ async def open_brokerd_trades_dialogue(
|
|||
)
|
||||
|
||||
try:
|
||||
positions: list[BrokerdPosition]
|
||||
accounts: tuple[str]
|
||||
|
||||
async with (
|
||||
open_trades_endpoint as (brokerd_ctx, (positions, accounts,)),
|
||||
brokerd_ctx.open_stream() as brokerd_trades_stream,
|
||||
|
@ -449,7 +462,7 @@ async def open_brokerd_trades_dialogue(
|
|||
relay = TradesRelay(
|
||||
brokerd_dialogue=brokerd_trades_stream,
|
||||
positions=pps,
|
||||
accounts=set(accounts),
|
||||
accounts=accounts,
|
||||
consumers=1,
|
||||
)
|
||||
|
||||
|
@ -1002,7 +1015,8 @@ async def _emsd_main(
|
|||
first_quote = feed.first_quotes[symbol]
|
||||
|
||||
book = _router.get_dark_book(broker)
|
||||
book.lasts[(broker, symbol)] = first_quote['last']
|
||||
last = book.lasts[(broker, symbol)] = first_quote['last']
|
||||
assert not isnan(last) # ib is a cucker but we've fixed it in the backend
|
||||
|
||||
# open a stream with the brokerd backend for order
|
||||
# flow dialogue
|
||||
|
@ -1032,7 +1046,7 @@ async def _emsd_main(
|
|||
|
||||
# signal to client that we're started and deliver
|
||||
# all known pps and accounts for this ``brokerd``.
|
||||
await ems_ctx.started((pp_msgs, relay.accounts))
|
||||
await ems_ctx.started((pp_msgs, list(relay.accounts)))
|
||||
|
||||
# establish 2-way stream with requesting order-client and
|
||||
# begin handling inbound order requests and updates
|
||||
|
|
|
@ -60,7 +60,7 @@ def repodir():
|
|||
"""
|
||||
dirpath = os.path.abspath(
|
||||
# we're 3 levels down in **this** module file
|
||||
dirname(dirname(dirname(os.path.realpath(__file__))))
|
||||
dirname(dirname(os.path.realpath(__file__)))
|
||||
)
|
||||
return dirpath
|
||||
|
||||
|
@ -73,7 +73,7 @@ def load(
|
|||
path = path or get_broker_conf_path()
|
||||
if not os.path.isfile(path):
|
||||
shutil.copyfile(
|
||||
os.path.join(repodir(), 'data/brokers.toml'),
|
||||
os.path.join(repodir(), 'config', 'brokers.toml'),
|
||||
path,
|
||||
)
|
||||
|
||||
|
|
|
@ -313,7 +313,8 @@ async def uniform_rate_send(
|
|||
|
||||
except trio.WouldBlock:
|
||||
now = time.time()
|
||||
rate = 1 / (now - last_send)
|
||||
diff = now - last_send
|
||||
rate = 1 / diff if diff else float('inf')
|
||||
last_send = now
|
||||
|
||||
# log.info(f'{rate} Hz sending quotes') # \n{first_quote}')
|
||||
|
|
|
@ -272,9 +272,8 @@ class ShmArray:
|
|||
return end
|
||||
|
||||
except ValueError as err:
|
||||
# shoudl raise if diff detected
|
||||
# should raise if diff detected
|
||||
self.diff_err_fields(data)
|
||||
|
||||
raise err
|
||||
|
||||
def diff_err_fields(
|
||||
|
@ -395,6 +394,7 @@ def open_shm_array(
|
|||
|
||||
# "unlink" created shm on process teardown by
|
||||
# pushing teardown calls onto actor context stack
|
||||
|
||||
tractor._actor._lifetime_stack.callback(shmarr.close)
|
||||
tractor._actor._lifetime_stack.callback(shmarr.destroy)
|
||||
|
||||
|
|
|
@ -133,9 +133,11 @@ def mk_symbol(
|
|||
|
||||
|
||||
def from_df(
|
||||
|
||||
df: pd.DataFrame,
|
||||
source=None,
|
||||
default_tf=None
|
||||
|
||||
) -> np.recarray:
|
||||
"""Convert OHLC formatted ``pandas.DataFrame`` to ``numpy.recarray``.
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ ToOlS fOr CoPInG wITh "tHE wEB" protocols.
|
|||
"""
|
||||
from contextlib import asynccontextmanager, AsyncExitStack
|
||||
from types import ModuleType
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, AsyncGenerator
|
||||
import json
|
||||
|
||||
import trio
|
||||
|
@ -127,7 +127,7 @@ async def open_autorecon_ws(
|
|||
|
||||
# TODO: proper type annot smh
|
||||
fixture: Callable,
|
||||
):
|
||||
) -> AsyncGenerator[tuple[...], NoBsWs]:
|
||||
"""Apparently we can QoS for all sorts of reasons..so catch em.
|
||||
|
||||
"""
|
||||
|
|
|
@ -34,11 +34,10 @@ import trio
|
|||
from trio.abc import ReceiveChannel
|
||||
from trio_typing import TaskStatus
|
||||
import tractor
|
||||
# from tractor import _broadcast
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..brokers import get_brokermod
|
||||
from .._cacheables import maybe_open_ctx
|
||||
from .._cacheables import maybe_open_context
|
||||
from ..log import get_logger, get_console_log
|
||||
from .._daemon import (
|
||||
maybe_spawn_brokerd,
|
||||
|
@ -247,7 +246,7 @@ async def allocate_persistent_feed(
|
|||
|
||||
|
||||
@tractor.context
|
||||
async def attach_feed_bus(
|
||||
async def open_feed_bus(
|
||||
|
||||
ctx: tractor.Context,
|
||||
brokername: str,
|
||||
|
@ -364,7 +363,7 @@ async def open_sample_step_stream(
|
|||
# XXX: this should be singleton on a host,
|
||||
# a lone broker-daemon per provider should be
|
||||
# created for all practical purposes
|
||||
async with maybe_open_ctx(
|
||||
async with maybe_open_context(
|
||||
key=delay_s,
|
||||
mngr=portal.open_stream_from(
|
||||
iter_ohlc_periods,
|
||||
|
@ -507,7 +506,7 @@ async def open_feed(
|
|||
|
||||
portal.open_context(
|
||||
|
||||
attach_feed_bus,
|
||||
open_feed_bus,
|
||||
brokername=brokername,
|
||||
symbol=sym,
|
||||
loglevel=loglevel,
|
||||
|
@ -586,7 +585,7 @@ async def maybe_open_feed(
|
|||
'''
|
||||
sym = symbols[0].lower()
|
||||
|
||||
async with maybe_open_ctx(
|
||||
async with maybe_open_context(
|
||||
key=(brokername, sym),
|
||||
mngr=open_feed(
|
||||
brokername,
|
||||
|
|
|
@ -34,7 +34,7 @@ from ..data import attach_shm_array
|
|||
from ..data.feed import Feed
|
||||
from ..data._sharedmem import ShmArray
|
||||
from ._momo import _rsi, _wma
|
||||
from ._volume import _tina_vwap
|
||||
from ._volume import _tina_vwap, dolla_vlm
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
@ -42,6 +42,7 @@ _fsp_builtins = {
|
|||
'rsi': _rsi,
|
||||
'wma': _wma,
|
||||
'vwap': _tina_vwap,
|
||||
'dolla_vlm': dolla_vlm,
|
||||
}
|
||||
|
||||
# TODO: things to figure the heck out:
|
||||
|
|
|
@ -14,16 +14,20 @@
|
|||
# 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/>.
|
||||
|
||||
from typing import AsyncIterator, Optional
|
||||
from typing import AsyncIterator, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
from tractor.trionics._broadcast import AsyncReceiver
|
||||
|
||||
from ..data._normalize import iterticks
|
||||
from ..data._sharedmem import ShmArray
|
||||
|
||||
|
||||
def wap(
|
||||
|
||||
signal: np.ndarray,
|
||||
weights: np.ndarray,
|
||||
|
||||
) -> np.ndarray:
|
||||
"""Weighted average price from signal and weights.
|
||||
|
||||
|
@ -47,15 +51,22 @@ def wap(
|
|||
|
||||
|
||||
async def _tina_vwap(
|
||||
source, #: AsyncStream[np.ndarray],
|
||||
ohlcv: np.ndarray, # price time-frame "aware"
|
||||
|
||||
source: AsyncReceiver[dict],
|
||||
ohlcv: ShmArray, # OHLC sampled history
|
||||
|
||||
# TODO: anchor logic (eg. to session start)
|
||||
anchors: Optional[np.ndarray] = None,
|
||||
) -> AsyncIterator[np.ndarray]: # maybe something like like FspStream?
|
||||
"""Streaming volume weighted moving average.
|
||||
|
||||
) -> Union[
|
||||
AsyncIterator[np.ndarray],
|
||||
float
|
||||
]:
|
||||
'''Streaming volume weighted moving average.
|
||||
|
||||
Calling this "tina" for now since we're using HLC3 instead of tick.
|
||||
|
||||
"""
|
||||
'''
|
||||
if anchors is None:
|
||||
# TODO:
|
||||
# anchor to session start of data if possible
|
||||
|
@ -75,7 +86,6 @@ async def _tina_vwap(
|
|||
# vwap_tot = h_vwap[-1]
|
||||
|
||||
async for quote in source:
|
||||
|
||||
for tick in iterticks(quote, types=['trade']):
|
||||
|
||||
# c, h, l, v = ohlcv.array[-1][
|
||||
|
@ -91,3 +101,44 @@ async def _tina_vwap(
|
|||
|
||||
# yield ((((o + h + l) / 3) * v) weights_tot) / v_tot
|
||||
yield w_tot / v_tot
|
||||
|
||||
|
||||
async def dolla_vlm(
|
||||
source: AsyncReceiver[dict],
|
||||
ohlcv: ShmArray, # OHLC sampled history
|
||||
|
||||
) -> Union[
|
||||
AsyncIterator[np.ndarray],
|
||||
float
|
||||
]:
|
||||
a = ohlcv.array
|
||||
chl3 = (a['close'] + a['high'] + a['low']) / 3
|
||||
v = a['volume']
|
||||
|
||||
# history
|
||||
yield chl3 * v
|
||||
|
||||
i = ohlcv.index
|
||||
lvlm = 0
|
||||
|
||||
async for quote in source:
|
||||
for tick in iterticks(quote):
|
||||
|
||||
# this computes tick-by-tick weightings from here forward
|
||||
size = tick['size']
|
||||
price = tick['price']
|
||||
|
||||
li = ohlcv.index
|
||||
if li > i:
|
||||
i = li
|
||||
lvlm = 0
|
||||
|
||||
c, h, l, v = ohlcv.last()[
|
||||
['close', 'high', 'low', 'volume']
|
||||
][0]
|
||||
|
||||
lvlm += price * size
|
||||
tina_lvlm = c+h+l/3 * v
|
||||
# print(f' tinal vlm: {tina_lvlm}')
|
||||
|
||||
yield lvlm
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
# piker: trading gear for hackers
|
||||
# Copyright (C) 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/>.
|
||||
|
||||
'''
|
||||
sugarz for trio/tractor conc peeps.
|
||||
|
||||
'''
|
||||
from typing import AsyncContextManager
|
||||
from typing import TypeVar
|
||||
from contextlib import asynccontextmanager as acm
|
||||
|
||||
import trio
|
||||
|
||||
|
||||
# A regular invariant generic type
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
async def _enter_and_sleep(
|
||||
|
||||
mngr: AsyncContextManager[T],
|
||||
to_yield: dict[int, T],
|
||||
all_entered: trio.Event,
|
||||
# task_status: TaskStatus[T] = trio.TASK_STATUS_IGNORED,
|
||||
|
||||
) -> T:
|
||||
'''Open the async context manager deliver it's value
|
||||
to this task's spawner and sleep until cancelled.
|
||||
|
||||
'''
|
||||
async with mngr as value:
|
||||
to_yield[id(mngr)] = value
|
||||
|
||||
if all(to_yield.values()):
|
||||
all_entered.set()
|
||||
|
||||
# sleep until cancelled
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
||||
@acm
|
||||
async def async_enter_all(
|
||||
|
||||
*mngrs: list[AsyncContextManager[T]],
|
||||
|
||||
) -> tuple[T]:
|
||||
|
||||
to_yield = {}.fromkeys(id(mngr) for mngr in mngrs)
|
||||
|
||||
all_entered = trio.Event()
|
||||
|
||||
async with trio.open_nursery() as n:
|
||||
for mngr in mngrs:
|
||||
n.start_soon(
|
||||
_enter_and_sleep,
|
||||
mngr,
|
||||
to_yield,
|
||||
all_entered,
|
||||
)
|
||||
|
||||
# deliver control once all managers have started up
|
||||
await all_entered.wait()
|
||||
yield tuple(to_yield.values())
|
||||
|
||||
# tear down all sleeper tasks thus triggering individual
|
||||
# mngr ``__aexit__()``s.
|
||||
n.cancel_scope.cancel()
|
|
@ -85,11 +85,11 @@ async def _async_main(
|
|||
screen = godwidget.window.current_screen()
|
||||
|
||||
# configure graphics update throttling based on display refresh rate
|
||||
_display._clear_throttle_rate = min(
|
||||
_display._quote_throttle_rate = min(
|
||||
round(screen.refreshRate()),
|
||||
_display._clear_throttle_rate,
|
||||
_display._quote_throttle_rate,
|
||||
)
|
||||
log.info(f'Set graphics update rate to {_display._clear_throttle_rate} Hz')
|
||||
log.info(f'Set graphics update rate to {_display._quote_throttle_rate} Hz')
|
||||
|
||||
# TODO: do styling / themeing setup
|
||||
# _style.style_ze_sheets(godwidget)
|
||||
|
|
|
@ -25,6 +25,9 @@ from PyQt5.QtCore import Qt
|
|||
from PyQt5.QtWidgets import (
|
||||
QFrame,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QSplitter,
|
||||
# QSizePolicy,
|
||||
)
|
||||
import numpy as np
|
||||
|
@ -53,6 +56,7 @@ from ._style import (
|
|||
)
|
||||
from ..data.feed import Feed
|
||||
from ..data._source import Symbol
|
||||
from ..data._sharedmem import ShmArray
|
||||
from ..log import get_logger
|
||||
from ._interaction import ChartView
|
||||
from ._forms import FieldsForm
|
||||
|
@ -64,11 +68,11 @@ log = get_logger(__name__)
|
|||
class GodWidget(QWidget):
|
||||
'''
|
||||
"Our lord and savior, the holy child of window-shua, there is no
|
||||
widget above thee." - 6|6
|
||||
widget above thee." - 6||6
|
||||
|
||||
The highest level composed widget which contains layouts for
|
||||
organizing lower level charts as well as other widgets used to
|
||||
control or modify them.
|
||||
organizing charts as well as other sub-widgets used to control or
|
||||
modify them.
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
|
@ -80,19 +84,19 @@ class GodWidget(QWidget):
|
|||
|
||||
super().__init__(parent)
|
||||
|
||||
self.hbox = QtWidgets.QHBoxLayout(self)
|
||||
self.hbox = QHBoxLayout(self)
|
||||
self.hbox.setContentsMargins(0, 0, 0, 0)
|
||||
self.hbox.setSpacing(6)
|
||||
self.hbox.setAlignment(Qt.AlignTop)
|
||||
|
||||
self.vbox = QtWidgets.QVBoxLayout()
|
||||
self.vbox = QVBoxLayout()
|
||||
self.vbox.setContentsMargins(0, 0, 0, 0)
|
||||
self.vbox.setSpacing(2)
|
||||
self.vbox.setAlignment(Qt.AlignTop)
|
||||
|
||||
self.hbox.addLayout(self.vbox)
|
||||
|
||||
# self.toolbar_layout = QtWidgets.QHBoxLayout()
|
||||
# self.toolbar_layout = QHBoxLayout()
|
||||
# self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# self.vbox.addLayout(self.toolbar_layout)
|
||||
|
||||
|
@ -106,25 +110,8 @@ class GodWidget(QWidget):
|
|||
# assigned in the startup func `_async_main()`
|
||||
self._root_n: trio.Nursery = None
|
||||
|
||||
def set_chart_symbol(
|
||||
self,
|
||||
symbol_key: str, # of form <fqsn>.<providername>
|
||||
linkedsplits: 'LinkedSplits', # type: ignore
|
||||
|
||||
) -> None:
|
||||
# re-sort org cache symbol list in LIFO order
|
||||
cache = self._chart_cache
|
||||
cache.pop(symbol_key, None)
|
||||
cache[symbol_key] = linkedsplits
|
||||
|
||||
def get_chart_symbol(
|
||||
self,
|
||||
symbol_key: str,
|
||||
) -> 'LinkedSplits': # type: ignore
|
||||
return self._chart_cache.get(symbol_key)
|
||||
|
||||
# def init_timeframes_ui(self):
|
||||
# self.tf_layout = QtWidgets.QHBoxLayout()
|
||||
# self.tf_layout = QHBoxLayout()
|
||||
# self.tf_layout.setSpacing(0)
|
||||
# self.tf_layout.setContentsMargins(0, 12, 0, 0)
|
||||
# time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN')
|
||||
|
@ -145,6 +132,23 @@ class GodWidget(QWidget):
|
|||
# self.strategy_box = StrategyBoxWidget(self)
|
||||
# self.toolbar_layout.addWidget(self.strategy_box)
|
||||
|
||||
def set_chart_symbol(
|
||||
self,
|
||||
symbol_key: str, # of form <fqsn>.<providername>
|
||||
linkedsplits: 'LinkedSplits', # type: ignore
|
||||
|
||||
) -> None:
|
||||
# re-sort org cache symbol list in LIFO order
|
||||
cache = self._chart_cache
|
||||
cache.pop(symbol_key, None)
|
||||
cache[symbol_key] = linkedsplits
|
||||
|
||||
def get_chart_symbol(
|
||||
self,
|
||||
symbol_key: str,
|
||||
) -> 'LinkedSplits': # type: ignore
|
||||
return self._chart_cache.get(symbol_key)
|
||||
|
||||
async def load_symbol(
|
||||
self,
|
||||
|
||||
|
@ -255,7 +259,7 @@ class ChartnPane(QFrame):
|
|||
|
||||
'''
|
||||
sidepane: FieldsForm
|
||||
hbox: QtWidgets.QHBoxLayout
|
||||
hbox: QHBoxLayout
|
||||
chart: Optional['ChartPlotWidget'] = None
|
||||
|
||||
def __init__(
|
||||
|
@ -271,7 +275,7 @@ class ChartnPane(QFrame):
|
|||
self.sidepane = sidepane
|
||||
self.chart = None
|
||||
|
||||
hbox = self.hbox = QtWidgets.QHBoxLayout(self)
|
||||
hbox = self.hbox = QHBoxLayout(self)
|
||||
hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
hbox.setContentsMargins(0, 0, 0, 0)
|
||||
hbox.setSpacing(3)
|
||||
|
@ -281,21 +285,14 @@ class ChartnPane(QFrame):
|
|||
|
||||
class LinkedSplits(QWidget):
|
||||
'''
|
||||
Widget that holds a central chart plus derived
|
||||
subcharts computed from the original data set apart
|
||||
by splitters for resizing.
|
||||
Composite that holds a central chart plus a set of (derived)
|
||||
subcharts (usually computed from the original data) arranged in
|
||||
a splitter for resizing.
|
||||
|
||||
A single internal references to the data is maintained
|
||||
for each chart and can be updated externally.
|
||||
|
||||
'''
|
||||
long_pen = pg.mkPen('#006000')
|
||||
long_brush = pg.mkBrush('#00ff00')
|
||||
short_pen = pg.mkPen('#600000')
|
||||
short_brush = pg.mkBrush('#ff0000')
|
||||
|
||||
zoomIsDisabled = QtCore.pyqtSignal(bool)
|
||||
|
||||
def __init__(
|
||||
|
||||
self,
|
||||
|
@ -325,11 +322,11 @@ class LinkedSplits(QWidget):
|
|||
# self.xaxis_ind.setStyle(showValues=False)
|
||||
# self.xaxis.hide()
|
||||
|
||||
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical)
|
||||
self.splitter.setMidLineWidth(1)
|
||||
self.splitter.setHandleWidth(0)
|
||||
self.splitter = QSplitter(QtCore.Qt.Vertical)
|
||||
self.splitter.setMidLineWidth(0)
|
||||
self.splitter.setHandleWidth(2)
|
||||
|
||||
self.layout = QtWidgets.QVBoxLayout(self)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.addWidget(self.splitter)
|
||||
|
||||
|
@ -341,20 +338,28 @@ class LinkedSplits(QWidget):
|
|||
|
||||
def set_split_sizes(
|
||||
self,
|
||||
# prop: float = 0.375, # proportion allocated to consumer subcharts
|
||||
prop: float = 5/8,
|
||||
prop: Optional[float] = None,
|
||||
|
||||
) -> None:
|
||||
'''Set the proportion of space allocated for linked subcharts.
|
||||
|
||||
'''
|
||||
ln = len(self.subplots)
|
||||
|
||||
if not prop:
|
||||
# proportion allocated to consumer subcharts
|
||||
if ln < 2:
|
||||
prop = 1/(.666 * 6)
|
||||
elif ln >= 2:
|
||||
prop = 3/8
|
||||
|
||||
major = 1 - prop
|
||||
min_h_ind = int((self.height() * prop) / len(self.subplots))
|
||||
min_h_ind = int((self.height() * prop) / ln)
|
||||
|
||||
sizes = [int(self.height() * major)]
|
||||
sizes.extend([min_h_ind] * len(self.subplots))
|
||||
sizes.extend([min_h_ind] * ln)
|
||||
|
||||
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
|
||||
self.splitter.setSizes(sizes)
|
||||
|
||||
def focus(self) -> None:
|
||||
if self.chart is not None:
|
||||
|
@ -374,16 +379,21 @@ class LinkedSplits(QWidget):
|
|||
style: str = 'bar',
|
||||
|
||||
) -> 'ChartPlotWidget':
|
||||
"""Start up and show main (price) chart and all linked subcharts.
|
||||
'''Start up and show main (price) chart and all linked subcharts.
|
||||
|
||||
The data input struct array must include OHLC fields.
|
||||
"""
|
||||
|
||||
'''
|
||||
# add crosshairs
|
||||
self.cursor = Cursor(
|
||||
linkedsplits=self,
|
||||
digits=symbol.tick_size_digits,
|
||||
)
|
||||
|
||||
# NOTE: atm the first (and only) OHLC price chart for the symbol
|
||||
# is given a special reference but in the future there shouldn't
|
||||
# be no distinction since we will have multiple symbols per
|
||||
# view as part of "aggregate feeds".
|
||||
self.chart = self.add_plot(
|
||||
|
||||
name=symbol.key,
|
||||
|
@ -425,9 +435,7 @@ class LinkedSplits(QWidget):
|
|||
**cpw_kwargs,
|
||||
|
||||
) -> 'ChartPlotWidget':
|
||||
'''Add (sub)plots to chart widget by name.
|
||||
|
||||
If ``name`` == ``"main"`` the chart will be the the primary view.
|
||||
'''Add (sub)plots to chart widget by key.
|
||||
|
||||
'''
|
||||
if self.chart is None and not _is_main:
|
||||
|
@ -495,8 +503,9 @@ class LinkedSplits(QWidget):
|
|||
cpw.plotItem.vb.linkedsplits = self
|
||||
cpw.setFrameStyle(
|
||||
QtWidgets.QFrame.StyledPanel
|
||||
# | QtWidgets.QFrame.Plain)
|
||||
# | QtWidgets.QFrame.Plain
|
||||
)
|
||||
|
||||
cpw.hideButtons()
|
||||
|
||||
# XXX: gives us outline on backside of y-axis
|
||||
|
@ -515,7 +524,22 @@ class LinkedSplits(QWidget):
|
|||
cpw.draw_ohlc(name, array, array_key=array_key)
|
||||
|
||||
elif style == 'line':
|
||||
cpw.draw_curve(name, array, array_key=array_key)
|
||||
cpw.draw_curve(
|
||||
name,
|
||||
array,
|
||||
array_key=array_key,
|
||||
color='default_light',
|
||||
)
|
||||
|
||||
elif style == 'step':
|
||||
cpw.draw_curve(
|
||||
name,
|
||||
array,
|
||||
array_key=array_key,
|
||||
step_mode=True,
|
||||
color='davies',
|
||||
fill_color='davies',
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Chart style {style} is currently unsupported")
|
||||
|
@ -523,14 +547,7 @@ class LinkedSplits(QWidget):
|
|||
if not _is_main:
|
||||
# track by name
|
||||
self.subplots[name] = cpw
|
||||
|
||||
# if sidepane:
|
||||
# # TODO: use a "panes" collection to manage this?
|
||||
# qframe.setMaximumWidth(self.chart.sidepane.width())
|
||||
# qframe.setMinimumWidth(self.chart.sidepane.width())
|
||||
|
||||
self.splitter.addWidget(qframe)
|
||||
|
||||
# scale split regions
|
||||
self.set_split_sizes()
|
||||
|
||||
|
@ -586,6 +603,9 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
view_color: str = 'papas_special',
|
||||
pen_color: str = 'bracket',
|
||||
|
||||
# TODO: load from config
|
||||
use_open_gl: bool = False,
|
||||
|
||||
static_yrange: Optional[tuple[float, float]] = None,
|
||||
|
||||
**kwargs,
|
||||
|
@ -600,9 +620,9 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# parent=None,
|
||||
# plotItem=None,
|
||||
# antialias=True,
|
||||
useOpenGL=True,
|
||||
**kwargs
|
||||
)
|
||||
self.useOpenGL(use_open_gl)
|
||||
self.name = name
|
||||
self.data_key = data_key
|
||||
self.linked = linkedsplits
|
||||
|
@ -619,7 +639,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
'ohlc': array,
|
||||
}
|
||||
self._graphics = {} # registry of underlying graphics
|
||||
self._overlays = set() # registry of overlay curve names
|
||||
# registry of overlay curve names
|
||||
self._overlays: dict[str, ShmArray] = {}
|
||||
|
||||
self._feeds: dict[Symbol, Feed] = {}
|
||||
|
||||
|
@ -732,6 +753,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
self._vb.setXRange(
|
||||
min=l + 1,
|
||||
max=r + 1,
|
||||
|
||||
# TODO: holy shit, wtf dude... why tf would this not be 0 by
|
||||
# default... speechless.
|
||||
padding=0,
|
||||
|
@ -772,7 +794,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
update_func=ContentsLabel.update_from_ohlc,
|
||||
)
|
||||
|
||||
self._add_sticky(name)
|
||||
self._add_sticky(name, bg_color='davies')
|
||||
|
||||
return graphics
|
||||
|
||||
|
@ -784,7 +806,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
array_key: Optional[str] = None,
|
||||
overlay: bool = False,
|
||||
color: str = 'default_light',
|
||||
color: Optional[str] = None,
|
||||
add_label: bool = True,
|
||||
|
||||
**pdi_kwargs,
|
||||
|
@ -794,15 +816,18 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
the input array ``data``.
|
||||
|
||||
"""
|
||||
_pdi_defaults = {
|
||||
'pen': pg.mkPen(hcolor(color)),
|
||||
}
|
||||
pdi_kwargs.update(_pdi_defaults)
|
||||
color = color or self.pen_color or 'default_light'
|
||||
pdi_kwargs.update({
|
||||
'color': color
|
||||
})
|
||||
|
||||
data_key = array_key or name
|
||||
|
||||
# pg internals for reference.
|
||||
# curve = pg.PlotDataItem(
|
||||
# curve = pg.PlotCurveItem(
|
||||
|
||||
# yah, we wrote our own B)
|
||||
curve = FastAppendCurve(
|
||||
y=data[data_key],
|
||||
x=data['index'],
|
||||
|
@ -840,14 +865,14 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
if overlay:
|
||||
anchor_at = ('bottom', 'left')
|
||||
self._overlays.add(name)
|
||||
self._overlays[name] = None
|
||||
|
||||
else:
|
||||
anchor_at = ('top', 'left')
|
||||
|
||||
# TODO: something instead of stickies for overlays
|
||||
# (we need something that avoids clutter on x-axis).
|
||||
self._add_sticky(name, bg_color='default_light')
|
||||
self._add_sticky(name, bg_color=color)
|
||||
|
||||
if self.linked.cursor:
|
||||
self.linked.cursor.add_curve_cursor(self, curve)
|
||||
|
@ -861,6 +886,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
return curve
|
||||
|
||||
# TODO: make this a ctx mngr
|
||||
def _add_sticky(
|
||||
self,
|
||||
|
||||
|
@ -890,67 +916,78 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
def update_ohlc_from_array(
|
||||
self,
|
||||
name: str,
|
||||
|
||||
graphics_name: str,
|
||||
array: np.ndarray,
|
||||
**kwargs,
|
||||
) -> pg.GraphicsObject:
|
||||
"""Update the named internal graphics from ``array``.
|
||||
|
||||
"""
|
||||
) -> pg.GraphicsObject:
|
||||
'''Update the named internal graphics from ``array``.
|
||||
|
||||
'''
|
||||
self._arrays['ohlc'] = array
|
||||
graphics = self._graphics[name]
|
||||
graphics = self._graphics[graphics_name]
|
||||
graphics.update_from_array(array, **kwargs)
|
||||
return graphics
|
||||
|
||||
def update_curve_from_array(
|
||||
self,
|
||||
|
||||
name: str,
|
||||
graphics_name: str,
|
||||
array: np.ndarray,
|
||||
array_key: Optional[str] = None,
|
||||
|
||||
**kwargs,
|
||||
|
||||
) -> pg.GraphicsObject:
|
||||
"""Update the named internal graphics from ``array``.
|
||||
'''Update the named internal graphics from ``array``.
|
||||
|
||||
"""
|
||||
'''
|
||||
assert len(array)
|
||||
data_key = array_key or graphics_name
|
||||
|
||||
data_key = array_key or name
|
||||
if name not in self._overlays:
|
||||
if graphics_name not in self._overlays:
|
||||
self._arrays['ohlc'] = array
|
||||
else:
|
||||
self._arrays[data_key] = array
|
||||
|
||||
curve = self._graphics[name]
|
||||
curve = self._graphics[graphics_name]
|
||||
|
||||
if len(array):
|
||||
# TODO: we should instead implement a diff based
|
||||
# "only update with new items" on the pg.PlotCurveItem
|
||||
# one place to dig around this might be the `QBackingStore`
|
||||
# https://doc.qt.io/qt-5/qbackingstore.html
|
||||
# curve.setData(y=array[name], x=array['index'], **kwargs)
|
||||
curve.update_from_array(
|
||||
x=array['index'],
|
||||
y=array[data_key],
|
||||
**kwargs
|
||||
)
|
||||
# NOTE: back when we weren't implementing the curve graphics
|
||||
# ourselves you'd have updates using this method:
|
||||
# curve.setData(y=array[graphics_name], x=array['index'], **kwargs)
|
||||
|
||||
# NOTE: graphics **must** implement a diff based update
|
||||
# operation where an internal ``FastUpdateCurve._xrange`` is
|
||||
# used to determine if the underlying path needs to be
|
||||
# pre/ap-pended.
|
||||
curve.update_from_array(
|
||||
x=array['index'],
|
||||
y=array[data_key],
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return curve
|
||||
|
||||
def _set_yrange(
|
||||
self,
|
||||
*,
|
||||
|
||||
yrange: Optional[tuple[float, float]] = None,
|
||||
range_margin: float = 0.06,
|
||||
bars_range: Optional[tuple[int, int, int, int]] = None,
|
||||
|
||||
# flag to prevent triggering sibling charts from the same linked
|
||||
# set from recursion errors.
|
||||
autoscale_linked_plots: bool = True,
|
||||
|
||||
) -> None:
|
||||
"""Set the viewable y-range based on embedded data.
|
||||
'''Set the viewable y-range based on embedded data.
|
||||
|
||||
This adds auto-scaling like zoom on the scroll wheel such
|
||||
that data always fits nicely inside the current view of the
|
||||
data set.
|
||||
|
||||
"""
|
||||
'''
|
||||
set_range = True
|
||||
|
||||
if self._static_yrange == 'axis':
|
||||
|
@ -966,52 +1003,50 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# Determine max, min y values in viewable x-range from data.
|
||||
# Make sure min bars/datums on screen is adhered.
|
||||
|
||||
l, lbar, rbar, r = self.bars_range()
|
||||
l, lbar, rbar, r = bars_range or self.bars_range()
|
||||
|
||||
# figure out x-range in view such that user can scroll "off"
|
||||
# the data set up to the point where ``_min_points_to_show``
|
||||
# are left.
|
||||
# view_len = r - l
|
||||
if autoscale_linked_plots:
|
||||
# avoid recursion by sibling plots
|
||||
linked = self.linked
|
||||
plots = list(linked.subplots.copy().values())
|
||||
main = linked.chart
|
||||
if main:
|
||||
plots.append(main)
|
||||
|
||||
for chart in plots:
|
||||
if chart and not chart._static_yrange:
|
||||
chart._set_yrange(
|
||||
bars_range=(l, lbar, rbar, r),
|
||||
autoscale_linked_plots=False,
|
||||
)
|
||||
|
||||
# TODO: logic to check if end of bars in view
|
||||
# extra = view_len - _min_points_to_show
|
||||
|
||||
# begin = self._arrays['ohlc'][0]['index'] - extra
|
||||
|
||||
# # end = len(self._arrays['ohlc']) - 1 + extra
|
||||
# end = self._arrays['ohlc'][-1]['index'] - 1 + extra
|
||||
|
||||
# XXX: test code for only rendering lines for the bars in view.
|
||||
# This turns out to be very very poor perf when scaling out to
|
||||
# many bars (think > 1k) on screen.
|
||||
# name = self.name
|
||||
# bars = self._graphics[self.name]
|
||||
# bars.draw_lines(
|
||||
# istart=max(lbar, l), iend=min(rbar, r), just_history=True)
|
||||
|
||||
# bars_len = rbar - lbar
|
||||
# log.debug(
|
||||
# f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n"
|
||||
# f"view_len: {view_len}, bars_len: {bars_len}\n"
|
||||
# f"begin: {begin}, end: {end}, extra: {extra}"
|
||||
# )
|
||||
# self._set_xlimits(begin, end)
|
||||
|
||||
# TODO: this should be some kind of numpy view api
|
||||
# bars = self._arrays['ohlc'][lbar:rbar]
|
||||
|
||||
a = self._arrays['ohlc']
|
||||
ifirst = a[0]['index']
|
||||
bars = a[lbar - ifirst:rbar - ifirst + 1]
|
||||
|
||||
if not len(bars):
|
||||
# likely no data loaded yet or extreme scrolling?
|
||||
log.error(f"WTF bars_range = {lbar}:{rbar}")
|
||||
return
|
||||
|
||||
if self.data_key != self.linked.symbol.key:
|
||||
bars = a[self.data_key]
|
||||
bars = bars[self.data_key]
|
||||
ylow = np.nanmin(bars)
|
||||
yhigh = np.nanmax((bars))
|
||||
yhigh = np.nanmax(bars)
|
||||
# print(f'{(ylow, yhigh)}')
|
||||
else:
|
||||
# just the std ohlc bars
|
||||
ylow = np.nanmin(bars['low'])
|
||||
|
@ -1072,7 +1107,6 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# TODO: this should go onto some sort of
|
||||
# data-view strimg thinger..right?
|
||||
ohlc = self._shm.array
|
||||
# ohlc = chart._shm.array
|
||||
|
||||
# XXX: not sure why the time is so off here
|
||||
# looks like we're gonna have to do some fixing..
|
||||
|
|
|
@ -18,25 +18,105 @@
|
|||
Fast, smooth, sexy curves.
|
||||
|
||||
"""
|
||||
from typing import Tuple
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5 import QtGui, QtWidgets
|
||||
from PyQt5.QtCore import (
|
||||
QLineF,
|
||||
QSizeF,
|
||||
QRectF,
|
||||
QPointF,
|
||||
)
|
||||
|
||||
from .._profile import pg_profile_enabled
|
||||
from ._style import hcolor
|
||||
|
||||
|
||||
def step_path_arrays_from_1d(
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
include_endpoints: bool = False,
|
||||
|
||||
) -> (np.ndarray, np.ndarray):
|
||||
'''Generate a "step mode" curve aligned with OHLC style bars
|
||||
such that each segment spans each bar (aka "centered" style).
|
||||
|
||||
'''
|
||||
y_out = y.copy()
|
||||
x_out = x.copy()
|
||||
x2 = np.empty(
|
||||
# the data + 2 endpoints on either end for
|
||||
# "termination of the path".
|
||||
(len(x) + 1, 2),
|
||||
# we want to align with OHLC or other sampling style
|
||||
# bars likely so we need fractinal values
|
||||
dtype=float,
|
||||
)
|
||||
x2[0] = x[0] - 0.5
|
||||
x2[1] = x[0] + 0.5
|
||||
x2[1:] = x[:, np.newaxis] + 0.5
|
||||
|
||||
# flatten to 1-d
|
||||
x_out = x2.reshape(x2.size)
|
||||
|
||||
# we create a 1d with 2 extra indexes to
|
||||
# hold the start and (current) end value for the steps
|
||||
# on either end
|
||||
y2 = np.empty((len(y), 2), dtype=y.dtype)
|
||||
y2[:] = y[:, np.newaxis]
|
||||
|
||||
y_out = np.empty(
|
||||
2*len(y) + 2,
|
||||
dtype=y.dtype
|
||||
)
|
||||
|
||||
# flatten and set 0 endpoints
|
||||
y_out[1:-1] = y2.reshape(y2.size)
|
||||
y_out[0] = 0
|
||||
y_out[-1] = 0
|
||||
|
||||
if not include_endpoints:
|
||||
return x_out[:-1], y_out[:-1]
|
||||
|
||||
else:
|
||||
return x_out, y_out
|
||||
|
||||
|
||||
# TODO: got a feeling that dropping this inheritance gets us even more speedups
|
||||
class FastAppendCurve(pg.PlotCurveItem):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
step_mode: bool = False,
|
||||
color: str = 'default_lightest',
|
||||
fill_color: Optional[str] = None,
|
||||
|
||||
**kwargs
|
||||
|
||||
) -> None:
|
||||
|
||||
# TODO: we can probably just dispense with the parent since
|
||||
# we're basically only using the pen setting now...
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._last_line: QtCore.QLineF = None
|
||||
self._xrange: Tuple[int, int] = self.dataBounds(ax=0)
|
||||
self._xrange: tuple[int, int] = self.dataBounds(ax=0)
|
||||
|
||||
# all history of curve is drawn in single px thickness
|
||||
self.setPen(hcolor(color))
|
||||
|
||||
# last segment is drawn in 2px thickness for emphasis
|
||||
self.last_step_pen = pg.mkPen(hcolor(color), width=2)
|
||||
self._last_line: QLineF = None
|
||||
self._last_step_rect: QRectF = None
|
||||
|
||||
# flat-top style histogram-like discrete curve
|
||||
self._step_mode: bool = step_mode
|
||||
|
||||
self._fill = False
|
||||
self.setBrush(hcolor(fill_color or color))
|
||||
|
||||
# TODO: one question still remaining is if this makes trasform
|
||||
# interactions slower (such as zooming) and if so maybe if/when
|
||||
|
@ -46,8 +126,9 @@ class FastAppendCurve(pg.PlotCurveItem):
|
|||
|
||||
def update_from_array(
|
||||
self,
|
||||
x,
|
||||
y,
|
||||
x: np.ndarray,
|
||||
y: np.ndarray,
|
||||
|
||||
) -> QtGui.QPainterPath:
|
||||
|
||||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
|
@ -56,17 +137,33 @@ class FastAppendCurve(pg.PlotCurveItem):
|
|||
# print(f"xrange: {self._xrange}")
|
||||
istart, istop = self._xrange
|
||||
|
||||
# compute the length diffs between the first/last index entry in
|
||||
# the input data and the last indexes we have on record from the
|
||||
# last time we updated the curve index.
|
||||
prepend_length = istart - x[0]
|
||||
append_length = x[-1] - istop
|
||||
|
||||
if self.path is None or prepend_length:
|
||||
# step mode: draw flat top discrete "step"
|
||||
# over the index space for each datum.
|
||||
if self._step_mode:
|
||||
x_out, y_out = step_path_arrays_from_1d(x[:-1], y[:-1])
|
||||
|
||||
else:
|
||||
# by default we only pull data up to the last (current) index
|
||||
x_out, y_out = x[:-1], y[:-1]
|
||||
|
||||
if self.path is None or prepend_length > 0:
|
||||
self.path = pg.functions.arrayToQPath(
|
||||
x[:-1],
|
||||
y[:-1],
|
||||
connect='all'
|
||||
x_out,
|
||||
y_out,
|
||||
connect='all',
|
||||
finiteCheck=False,
|
||||
)
|
||||
profiler('generate fresh path')
|
||||
|
||||
# if self._step_mode:
|
||||
# self.path.closeSubpath()
|
||||
|
||||
# TODO: get this working - right now it's giving heck on vwap...
|
||||
# if prepend_length:
|
||||
# breakpoint()
|
||||
|
@ -83,21 +180,47 @@ class FastAppendCurve(pg.PlotCurveItem):
|
|||
# # self.path.moveTo(new_x[0], new_y[0])
|
||||
# self.path.connectPath(old_path)
|
||||
|
||||
if append_length:
|
||||
# print(f"append_length: {append_length}")
|
||||
new_x = x[-append_length - 2:-1]
|
||||
new_y = y[-append_length - 2:-1]
|
||||
# print((new_x, new_y))
|
||||
elif append_length > 0:
|
||||
if self._step_mode:
|
||||
new_x, new_y = step_path_arrays_from_1d(
|
||||
x[-append_length - 2:-1],
|
||||
y[-append_length - 2:-1],
|
||||
)
|
||||
new_x = new_x[1:]
|
||||
new_y = new_y[1:]
|
||||
|
||||
else:
|
||||
# print(f"append_length: {append_length}")
|
||||
new_x = x[-append_length - 2:-1]
|
||||
new_y = y[-append_length - 2:-1]
|
||||
# print((new_x, new_y))
|
||||
|
||||
append_path = pg.functions.arrayToQPath(
|
||||
new_x,
|
||||
new_y,
|
||||
connect='all'
|
||||
connect='all',
|
||||
# finiteCheck=False,
|
||||
)
|
||||
# print(f"append_path br: {append_path.boundingRect()}")
|
||||
# self.path.moveTo(new_x[0], new_y[0])
|
||||
# self.path.connectPath(append_path)
|
||||
self.path.connectPath(append_path)
|
||||
|
||||
path = self.path
|
||||
|
||||
# other merging ideas:
|
||||
# https://stackoverflow.com/questions/8936225/how-to-merge-qpainterpaths
|
||||
if self._step_mode:
|
||||
if self._fill:
|
||||
# XXX: super slow set "union" op
|
||||
self.path = self.path.united(append_path).simplified()
|
||||
|
||||
# path.addPath(append_path)
|
||||
# path.closeSubpath()
|
||||
else:
|
||||
# path.addPath(append_path)
|
||||
self.path.connectPath(append_path)
|
||||
else:
|
||||
# print(f"append_path br: {append_path.boundingRect()}")
|
||||
# self.path.moveTo(new_x[0], new_y[0])
|
||||
# self.path.connectPath(append_path)
|
||||
path.connectPath(append_path)
|
||||
|
||||
# XXX: pretty annoying but, without this there's little
|
||||
# artefacts on the append updates to the curve...
|
||||
|
@ -112,8 +235,23 @@ class FastAppendCurve(pg.PlotCurveItem):
|
|||
self.xData = x
|
||||
self.yData = y
|
||||
|
||||
self._xrange = x[0], x[-1]
|
||||
self._last_line = QtCore.QLineF(x[-2], y[-2], x[-1], y[-1])
|
||||
x0, x_last = self._xrange = x[0], x[-1]
|
||||
y_last = y[-1]
|
||||
|
||||
if self._step_mode:
|
||||
self._last_line = QLineF(
|
||||
x_last - 0.5, 0,
|
||||
x_last + 0.5, 0,
|
||||
)
|
||||
self._last_step_rect = QRectF(
|
||||
x_last - 0.5, 0,
|
||||
x_last + 0.5, y_last
|
||||
)
|
||||
else:
|
||||
self._last_line = QLineF(
|
||||
x[-2], y[-2],
|
||||
x[-1], y_last
|
||||
)
|
||||
|
||||
# trigger redraw of path
|
||||
# do update before reverting to cache mode
|
||||
|
@ -143,13 +281,13 @@ class FastAppendCurve(pg.PlotCurveItem):
|
|||
w = hb_size.width() + 1
|
||||
h = hb_size.height() + 1
|
||||
|
||||
br = QtCore.QRectF(
|
||||
br = QRectF(
|
||||
|
||||
# top left
|
||||
QtCore.QPointF(hb.topLeft()),
|
||||
QPointF(hb.topLeft()),
|
||||
|
||||
# total size
|
||||
QtCore.QSizeF(w, h)
|
||||
QSizeF(w, h)
|
||||
)
|
||||
# print(f'bounding rect: {br}')
|
||||
return br
|
||||
|
@ -164,9 +302,26 @@ class FastAppendCurve(pg.PlotCurveItem):
|
|||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
# p.setRenderHint(p.Antialiasing, True)
|
||||
|
||||
p.setPen(self.opts['pen'])
|
||||
if self._step_mode:
|
||||
|
||||
brush = self.opts['brush']
|
||||
# p.drawLines(*tuple(filter(bool, self._last_step_lines)))
|
||||
# p.drawRect(self._last_step_rect)
|
||||
p.fillRect(self._last_step_rect, brush)
|
||||
|
||||
# p.drawPath(self.path)
|
||||
|
||||
# profiler('.drawPath()')
|
||||
|
||||
# else:
|
||||
p.setPen(self.last_step_pen)
|
||||
p.drawLine(self._last_line)
|
||||
profiler('.drawLine()')
|
||||
|
||||
p.setPen(self.opts['pen'])
|
||||
p.drawPath(self.path)
|
||||
profiler('.drawPath()')
|
||||
|
||||
if self._fill:
|
||||
print('FILLED')
|
||||
p.fillPath(self.path, brush)
|
||||
|
|
1001
piker/ui/_display.py
1001
piker/ui/_display.py
File diff suppressed because it is too large
Load Diff
|
@ -61,7 +61,9 @@ _do_overrides()
|
|||
# XXX: pretty sure none of this shit works on linux as per:
|
||||
# https://bugreports.qt.io/browse/QTBUG-53022
|
||||
# it seems to work on windows.. no idea wtf is up.
|
||||
is_windows = False
|
||||
if platform.system() == "Windows":
|
||||
is_windows = True
|
||||
|
||||
# Proper high DPI scaling is available in Qt >= 5.6.0. This attibute
|
||||
# must be set before creating the application
|
||||
|
@ -182,6 +184,8 @@ def run_qtractor(
|
|||
|
||||
window.main_widget = main_widget
|
||||
window.setCentralWidget(instance)
|
||||
if is_windows:
|
||||
window.configure_to_desktop()
|
||||
|
||||
# actually render to screen
|
||||
window.show()
|
||||
|
|
|
@ -48,7 +48,7 @@ from ._style import hcolor, _font, _font_small, DpiAwareFont
|
|||
from ._label import FormatLabel
|
||||
|
||||
|
||||
class FontAndChartAwareLineEdit(QLineEdit):
|
||||
class Edit(QLineEdit):
|
||||
|
||||
def __init__(
|
||||
|
||||
|
@ -369,13 +369,14 @@ class FieldsForm(QWidget):
|
|||
key: str,
|
||||
label_name: str,
|
||||
value: str,
|
||||
readonly: bool = False,
|
||||
|
||||
) -> FontAndChartAwareLineEdit:
|
||||
) -> Edit:
|
||||
|
||||
# TODO: maybe a distint layout per "field" item?
|
||||
label = self.add_field_label(label_name)
|
||||
|
||||
edit = FontAndChartAwareLineEdit(
|
||||
edit = Edit(
|
||||
parent=self,
|
||||
# width_in_chars=6,
|
||||
)
|
||||
|
@ -386,6 +387,7 @@ class FieldsForm(QWidget):
|
|||
}}
|
||||
"""
|
||||
)
|
||||
edit.setReadOnly(readonly)
|
||||
edit.setText(str(value))
|
||||
self.form.addRow(label, edit)
|
||||
|
||||
|
@ -478,13 +480,15 @@ def mk_form(
|
|||
for key, conf in fields_schema.items():
|
||||
wtype = conf['type']
|
||||
label = str(conf.get('label', key))
|
||||
kwargs = conf.get('kwargs', {})
|
||||
|
||||
# plain (line) edit field
|
||||
if wtype == 'edit':
|
||||
w = form.add_edit_field(
|
||||
key,
|
||||
label,
|
||||
conf['default_value']
|
||||
conf['default_value'],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# drop-down selection
|
||||
|
@ -493,7 +497,8 @@ def mk_form(
|
|||
w = form.add_select_field(
|
||||
key,
|
||||
label,
|
||||
values
|
||||
values,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
w._key = key
|
||||
|
@ -648,11 +653,21 @@ def mk_fill_status_bar(
|
|||
font_size=bar_label_font_size,
|
||||
font_color='gunmetal',
|
||||
)
|
||||
# size according to dpi scaled fonted contents to avoid
|
||||
# resizes on magnitude changes (eg. 9 -> 10 %)
|
||||
min_w = _font.boundingRect('1000.0M% pnl').width()
|
||||
left_label.setMinimumWidth(min_w)
|
||||
left_label.resize(
|
||||
min_w,
|
||||
left_label.size().height(),
|
||||
)
|
||||
|
||||
bar_labels_lhs.addSpacing(5/8 * bar_h)
|
||||
bar_labels_lhs.addWidget(
|
||||
left_label,
|
||||
alignment=Qt.AlignLeft | Qt.AlignTop,
|
||||
# XXX: doesn't seem to actually push up against
|
||||
# the status bar?
|
||||
alignment=Qt.AlignRight | Qt.AlignTop,
|
||||
)
|
||||
|
||||
# this hbox is added as a layout by the paner maker/caller
|
||||
|
@ -717,7 +732,7 @@ def mk_order_pane_layout(
|
|||
|
||||
) -> FieldsForm:
|
||||
|
||||
font_size: int = _font.px_size - 1
|
||||
font_size: int = _font.px_size - 2
|
||||
|
||||
# TODO: maybe just allocate the whole fields form here
|
||||
# and expect an async ctx entry?
|
||||
|
|
|
@ -341,7 +341,14 @@ class ChartView(ViewBox):
|
|||
**kwargs,
|
||||
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
# TODO: look into the default view padding
|
||||
# support that might replace somem of our
|
||||
# ``ChartPlotWidget._set_yrange()`
|
||||
# defaultPadding=0.,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# disable vertical scrolling
|
||||
self.setMouseEnabled(x=True, y=False)
|
||||
|
@ -533,7 +540,6 @@ class ChartView(ViewBox):
|
|||
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
||||
else:
|
||||
# default bevavior: click to pan view
|
||||
|
||||
tr = self.childGroup.transform()
|
||||
tr = fn.invertQTransform(tr)
|
||||
tr = tr.map(dif*mask) - tr.map(Point(0, 0))
|
||||
|
|
|
@ -146,7 +146,7 @@ def path_arrays_from_ohlc(
|
|||
# specifies that the first edge is never connected to the
|
||||
# prior bars last edge thus providing a small "gap"/"space"
|
||||
# between bars determined by ``bar_gap``.
|
||||
c[istart:istop] = (0, 1, 1, 1, 1, 1)
|
||||
c[istart:istop] = (1, 1, 1, 1, 1, 0)
|
||||
|
||||
return x, y, c
|
||||
|
||||
|
@ -182,12 +182,14 @@ class BarItems(pg.GraphicsObject):
|
|||
# scene: 'QGraphicsScene', # noqa
|
||||
plotitem: 'pg.PlotItem', # noqa
|
||||
pen_color: str = 'bracket',
|
||||
last_bar_color: str = 'bracket',
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
# XXX: for the mega-lulz increasing width here increases draw latency...
|
||||
# so probably don't do it until we figure that out.
|
||||
# XXX: for the mega-lulz increasing width here increases draw
|
||||
# latency... so probably don't do it until we figure that out.
|
||||
self.bars_pen = pg.mkPen(hcolor(pen_color), width=1)
|
||||
self.last_bar_pen = pg.mkPen(hcolor(last_bar_color), width=2)
|
||||
|
||||
# NOTE: this prevents redraws on mouse interaction which is
|
||||
# a huge boon for avg interaction latency.
|
||||
|
@ -354,30 +356,6 @@ class BarItems(pg.GraphicsObject):
|
|||
if flip_cache:
|
||||
self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
) -> None:
|
||||
|
||||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
|
||||
# p.setCompositionMode(0)
|
||||
p.setPen(self.bars_pen)
|
||||
|
||||
# TODO: one thing we could try here is pictures being drawn of
|
||||
# a fixed count of bars such that based on the viewbox indices we
|
||||
# only draw the "rounded up" number of "pictures worth" of bars
|
||||
# as is necesarry for what's in "view". Not sure if this will
|
||||
# lead to any perf gains other then when zoomed in to less bars
|
||||
# in view.
|
||||
p.drawLines(*tuple(filter(bool, self._last_bar_lines)))
|
||||
profiler('draw last bar')
|
||||
|
||||
p.drawPath(self.path)
|
||||
profiler('draw history path')
|
||||
|
||||
def boundingRect(self):
|
||||
# Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
|
||||
|
||||
|
@ -421,3 +399,28 @@ class BarItems(pg.GraphicsObject):
|
|||
)
|
||||
|
||||
)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
) -> None:
|
||||
|
||||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
|
||||
# p.setCompositionMode(0)
|
||||
|
||||
# TODO: one thing we could try here is pictures being drawn of
|
||||
# a fixed count of bars such that based on the viewbox indices we
|
||||
# only draw the "rounded up" number of "pictures worth" of bars
|
||||
# as is necesarry for what's in "view". Not sure if this will
|
||||
# lead to any perf gains other then when zoomed in to less bars
|
||||
# in view.
|
||||
p.setPen(self.last_bar_pen)
|
||||
p.drawLines(*tuple(filter(bool, self._last_bar_lines)))
|
||||
profiler('draw last bar')
|
||||
|
||||
p.setPen(self.bars_pen)
|
||||
p.drawPath(self.path)
|
||||
profiler('draw history path')
|
||||
|
|
|
@ -36,7 +36,7 @@ from PyQt5.QtWidgets import (
|
|||
|
||||
from ._forms import (
|
||||
# FontScaledDelegate,
|
||||
FontAndChartAwareLineEdit,
|
||||
Edit,
|
||||
)
|
||||
|
||||
|
||||
|
@ -97,7 +97,7 @@ class Selection(Field[DataType], Generic[DataType]):
|
|||
class Edit(Field[DataType], Generic[DataType]):
|
||||
'''An edit field which takes a number.
|
||||
'''
|
||||
widget_factory = FontAndChartAwareLineEdit
|
||||
widget_factory = Edit
|
||||
|
||||
|
||||
class AllocatorPane(BaseModel):
|
||||
|
|
|
@ -54,6 +54,7 @@ async def update_pnl_from_feed(
|
|||
|
||||
feed: Feed,
|
||||
order_mode: OrderMode, # noqa
|
||||
tracker: PositionTracker,
|
||||
|
||||
) -> None:
|
||||
'''Real-time display the current pp's PnL in the appropriate label.
|
||||
|
@ -76,7 +77,8 @@ async def update_pnl_from_feed(
|
|||
types = ('bid', 'last', 'last', 'utrade')
|
||||
|
||||
else:
|
||||
raise RuntimeError('No pp?!?!')
|
||||
log.info(f'No position (yet) for {tracker.alloc.account}@{key}')
|
||||
return
|
||||
|
||||
# real-time update pnl on the status pane
|
||||
try:
|
||||
|
@ -152,7 +154,7 @@ class SettingsPane:
|
|||
'''Called on any order pane drop down selection change.
|
||||
|
||||
'''
|
||||
log.info(f'selection input: {text}')
|
||||
log.info(f'selection input {key}:{text}')
|
||||
self.on_ui_settings_change(key, text)
|
||||
|
||||
def on_ui_settings_change(
|
||||
|
@ -209,30 +211,31 @@ class SettingsPane:
|
|||
|
||||
# WRITE any settings to current pp's allocator
|
||||
try:
|
||||
value = puterize(value)
|
||||
if key == 'limit':
|
||||
if size_unit == 'currency':
|
||||
alloc.currency_limit = value
|
||||
else:
|
||||
alloc.units_limit = value
|
||||
|
||||
elif key == 'slots':
|
||||
alloc.slots = int(value)
|
||||
|
||||
elif key == 'size_unit':
|
||||
# TODO: if there's a limit size unit change re-compute
|
||||
# the current settings in the new units
|
||||
if key == 'size_unit':
|
||||
# implicit re-write of value if input
|
||||
# is the "text name" of the units.
|
||||
# yah yah, i know this is badd..
|
||||
alloc.size_unit = value
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown setting {key}')
|
||||
value = puterize(value)
|
||||
if key == 'limit':
|
||||
if size_unit == 'currency':
|
||||
alloc.currency_limit = value
|
||||
else:
|
||||
alloc.units_limit = value
|
||||
|
||||
elif key == 'slots':
|
||||
alloc.slots = int(value)
|
||||
|
||||
else:
|
||||
raise ValueError(f'Unknown setting {key}')
|
||||
|
||||
log.info(f'settings change: {key}: {value}')
|
||||
|
||||
except ValueError:
|
||||
log.error(f'Invalid value for `{key}`: {value}')
|
||||
|
||||
# READ out settings and update UI
|
||||
# READ out settings and update the status UI / settings widgets
|
||||
suffix = {'currency': ' $', 'units': ' u'}[size_unit]
|
||||
limit = alloc.limit()
|
||||
|
||||
|
@ -259,6 +262,9 @@ class SettingsPane:
|
|||
self.form.fields['slots'].setText(str(alloc.slots))
|
||||
self.form.fields['limit'].setText(str(limit))
|
||||
|
||||
# update of level marker size label based on any new settings
|
||||
tracker.update_from_pp()
|
||||
|
||||
# TODO: maybe return a diff of settings so if we can an error we
|
||||
# can have general input handling code to report it through the
|
||||
# UI in some way?
|
||||
|
@ -339,6 +345,7 @@ class SettingsPane:
|
|||
update_pnl_from_feed,
|
||||
feed,
|
||||
mode,
|
||||
tracker,
|
||||
)
|
||||
|
||||
# immediately display in status label
|
||||
|
|
|
@ -49,7 +49,7 @@ from PyQt5 import QtCore
|
|||
from PyQt5 import QtWidgets
|
||||
from PyQt5.QtCore import (
|
||||
Qt,
|
||||
# QSize,
|
||||
QSize,
|
||||
QModelIndex,
|
||||
QItemSelectionModel,
|
||||
)
|
||||
|
@ -72,7 +72,7 @@ from ._style import (
|
|||
_font,
|
||||
hcolor,
|
||||
)
|
||||
from ._forms import FontAndChartAwareLineEdit, FontScaledDelegate
|
||||
from ._forms import Edit, FontScaledDelegate
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
@ -112,6 +112,7 @@ class CompleterView(QTreeView):
|
|||
|
||||
model = QStandardItemModel(self)
|
||||
self.labels = labels
|
||||
self._last_window_h: Optional[int] = None
|
||||
|
||||
# a std "tabular" config
|
||||
self.setItemDelegate(FontScaledDelegate(self))
|
||||
|
@ -126,6 +127,10 @@ class CompleterView(QTreeView):
|
|||
# self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
|
||||
|
||||
# ux settings
|
||||
self.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
)
|
||||
self.setItemsExpandable(True)
|
||||
self.setExpandsOnDoubleClick(False)
|
||||
self.setAnimated(False)
|
||||
|
@ -152,24 +157,41 @@ class CompleterView(QTreeView):
|
|||
self._font_size = size
|
||||
|
||||
self.setStyleSheet(f"font: {size}px")
|
||||
|
||||
#def resizeEvent(self, event: 'QEvent') -> None:
|
||||
# self.resize_to_results()
|
||||
# super().resizeEvent(event)
|
||||
|
||||
def resize(self):
|
||||
def resize_to_results(self):
|
||||
model = self.model()
|
||||
cols = model.columnCount()
|
||||
|
||||
for i in range(cols):
|
||||
self.resizeColumnToContents(i)
|
||||
|
||||
# inclusive of search bar and header "rows" in pixel terms
|
||||
rows = 100
|
||||
# max_rows = 8 # 6 + search and headers
|
||||
row_px = self.rowHeight(self.currentIndex())
|
||||
# print(f'font_h: {font_h}\n px_height: {px_height}')
|
||||
|
||||
# TODO: probably make this more general / less hacky
|
||||
self.setMinimumSize(self.width(), rows * row_px)
|
||||
self.setMaximumSize(self.width() + 10, rows * row_px)
|
||||
# we should figure out the exact number of rows to allow
|
||||
# inclusive of search bar and header "rows", in pixel terms.
|
||||
window_h = self.window().height()
|
||||
rows = round(window_h * 0.5 / row_px) - 4
|
||||
|
||||
# TODO: the problem here is that this view widget is **not** resizing/scaling
|
||||
# when the parent layout is adjusted, not sure what exactly is up...
|
||||
# only "scale up" the results view when the window size has increased/
|
||||
if not self._last_window_h or self._last_window_h < window_h:
|
||||
self.setMaximumSize(self.width(), rows * row_px)
|
||||
self.setMinimumSize(self.width(), rows * row_px)
|
||||
|
||||
#elif not self._last_window_h or self._last_window_h > window_h:
|
||||
# self.setMinimumSize(self.width(), rows * row_px)
|
||||
# self.setMaximumSize(self.width(), rows * row_px)
|
||||
|
||||
self.resize(self.width(), rows * row_px)
|
||||
self._last_window_h = window_h
|
||||
self.setFixedWidth(333)
|
||||
self.update()
|
||||
|
||||
def is_selecting_d1(self) -> bool:
|
||||
cidx = self.selectionModel().currentIndex()
|
||||
|
@ -334,7 +356,7 @@ class CompleterView(QTreeView):
|
|||
else:
|
||||
model.setItem(idx.row(), 1, QStandardItem())
|
||||
|
||||
self.resize()
|
||||
self.resize_to_results()
|
||||
|
||||
return idx
|
||||
else:
|
||||
|
@ -404,10 +426,10 @@ class CompleterView(QTreeView):
|
|||
|
||||
def show_matches(self) -> None:
|
||||
self.show()
|
||||
self.resize()
|
||||
self.resize_to_results()
|
||||
|
||||
|
||||
class SearchBar(FontAndChartAwareLineEdit):
|
||||
class SearchBar(Edit):
|
||||
|
||||
mode_name: str = 'search'
|
||||
|
||||
|
@ -457,7 +479,7 @@ class SearchWidget(QtWidgets.QWidget):
|
|||
# size it as we specify
|
||||
self.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Fixed,
|
||||
QtWidgets.QSizePolicy.Fixed,
|
||||
QtWidgets.QSizePolicy.Expanding,
|
||||
)
|
||||
|
||||
self.godwidget = godwidget
|
||||
|
|
|
@ -110,7 +110,7 @@ class DpiAwareFont:
|
|||
|
||||
mx_dpi = max(pdpi, ldpi)
|
||||
mn_dpi = min(pdpi, ldpi)
|
||||
scale = round(ldpi/pdpi)
|
||||
scale = round(ldpi/pdpi, ndigits=2)
|
||||
|
||||
if mx_dpi <= 97: # for low dpi use larger font sizes
|
||||
inches = _font_sizes['lo'][self._font_size]
|
||||
|
@ -121,17 +121,29 @@ class DpiAwareFont:
|
|||
dpi = mn_dpi
|
||||
|
||||
# dpi is likely somewhat scaled down so use slightly larger font size
|
||||
if scale > 1 and self._font_size:
|
||||
# TODO: this denominator should probably be determined from
|
||||
# relative aspect ratios or something?
|
||||
inches = inches * (1 / scale) * (1 + 6/16)
|
||||
if scale >= 1.1 and self._font_size:
|
||||
|
||||
if 1.2 <= scale:
|
||||
inches *= (1 / scale) * 1.0616
|
||||
|
||||
if scale < 1.4 or scale >= 1.5:
|
||||
# TODO: this denominator should probably be determined from
|
||||
# relative aspect ratios or something?
|
||||
inches = inches * (1 + 6/16)
|
||||
|
||||
dpi = mx_dpi
|
||||
log.info(f'USING MAX DPI {dpi}')
|
||||
|
||||
# TODO: we might want to fiddle with incrementing font size by
|
||||
# +1 for the edge cases above. it seems doing it via scaling is
|
||||
# always going to hit that error in range mapping from inches:
|
||||
# float to px size: int.
|
||||
self._font_inches = inches
|
||||
|
||||
font_size = math.floor(inches * dpi)
|
||||
log.debug(
|
||||
f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}"
|
||||
|
||||
log.info(
|
||||
f"screen:{screen.name()}]\n"
|
||||
f"pDPI: {pdpi}, lDPI: {ldpi}, scale: {scale}\n"
|
||||
f"\nOur best guess font size is {font_size}\n"
|
||||
)
|
||||
# apply the size
|
||||
|
@ -205,19 +217,26 @@ def hcolor(name: str) -> str:
|
|||
'svags': '#0a0e14',
|
||||
|
||||
# fifty shades
|
||||
'original': '#a9a9a9',
|
||||
'gray': '#808080', # like the kick
|
||||
'grayer': '#4c4c4c',
|
||||
'grayest': '#3f3f3f',
|
||||
'i3': '#494D4F',
|
||||
'jet': '#343434',
|
||||
'cadet': '#91A3B0',
|
||||
'marengo': '#91A3B0',
|
||||
'charcoal': '#36454F',
|
||||
'gunmetal': '#91A3B0',
|
||||
'battleship': '#848482',
|
||||
'davies': '#555555',
|
||||
|
||||
# bluish
|
||||
'charcoal': '#36454F',
|
||||
|
||||
# default bars
|
||||
'bracket': '#666666', # like the logo
|
||||
'original': '#a9a9a9',
|
||||
|
||||
# work well for filled polygons which want a 'bracket' feel
|
||||
# going light to dark
|
||||
'davies': '#555555',
|
||||
'i3': '#494D4F',
|
||||
'jet': '#343434',
|
||||
|
||||
# from ``qdarkstyle`` palette
|
||||
'default_darkest': DarkPalette.COLOR_BACKGROUND_1,
|
||||
|
|
|
@ -151,8 +151,8 @@ class MainWindow(QtGui.QMainWindow):
|
|||
# XXX: for tiling wms this should scale
|
||||
# with the alloted window size.
|
||||
# TODO: detect for tiling and if untrue set some size?
|
||||
# size = (300, 500)
|
||||
size = (0, 0)
|
||||
size = (300, 500)
|
||||
#size = (0, 0)
|
||||
|
||||
title = 'piker chart (ur symbol is loading bby)'
|
||||
|
||||
|
@ -163,7 +163,8 @@ class MainWindow(QtGui.QMainWindow):
|
|||
|
||||
self._status_bar: QStatusBar = None
|
||||
self._status_label: QLabel = None
|
||||
|
||||
self._size: Optional[tuple[int, int]] = None
|
||||
|
||||
@property
|
||||
def mode_label(self) -> QtGui.QLabel:
|
||||
|
||||
|
@ -267,6 +268,22 @@ class MainWindow(QtGui.QMainWindow):
|
|||
assert screen, "Wow Qt is dumb as shit and has no screen..."
|
||||
return screen
|
||||
|
||||
def configure_to_desktop(
|
||||
self,
|
||||
size: Optional[tuple[int, int]] = None,
|
||||
) -> None:
|
||||
# https://stackoverflow.com/a/18975846
|
||||
if not size and not self._size:
|
||||
app = QtGui.QApplication.instance()
|
||||
geo = self.current_screen().geometry()
|
||||
h, w = geo.height(), geo.width()
|
||||
self.setMaximumSize(w, h)
|
||||
# use approx 1/3 of the area of the screen by default
|
||||
self._size = round(w * .666), round(h * .666)
|
||||
|
||||
self.resize(*size or self._size)
|
||||
|
||||
|
||||
|
||||
# singleton app per actor
|
||||
_qt_win: QtGui.QMainWindow = None
|
||||
|
|
|
@ -22,6 +22,7 @@ from contextlib import asynccontextmanager
|
|||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from pprint import pformat
|
||||
import platform
|
||||
import time
|
||||
from typing import Optional, Dict, Callable, Any
|
||||
import uuid
|
||||
|
@ -47,7 +48,7 @@ from ._position import (
|
|||
)
|
||||
from ._label import FormatLabel
|
||||
from ._window import MultiStatus
|
||||
from ..clearing._messages import Order
|
||||
from ..clearing._messages import Order, BrokerdPosition
|
||||
from ._forms import open_form_input_handling
|
||||
|
||||
|
||||
|
@ -429,16 +430,17 @@ class OrderMode:
|
|||
|
||||
# TODO: make this not trash.
|
||||
# XXX: linux only for now
|
||||
result = await trio.run_process(
|
||||
[
|
||||
'notify-send',
|
||||
'-u', 'normal',
|
||||
'-t', '10000',
|
||||
'piker',
|
||||
f'alert: {msg}',
|
||||
],
|
||||
)
|
||||
log.runtime(result)
|
||||
if platform.system() != "Windows":
|
||||
result = await trio.run_process(
|
||||
[
|
||||
'notify-send',
|
||||
'-u', 'normal',
|
||||
'-t', '10000',
|
||||
'piker',
|
||||
f'alert: {msg}',
|
||||
],
|
||||
)
|
||||
log.runtime(result)
|
||||
|
||||
def on_cancel(
|
||||
self,
|
||||
|
@ -529,7 +531,12 @@ async def open_order_mode(
|
|||
|
||||
book: OrderBook
|
||||
trades_stream: tractor.MsgStream
|
||||
position_msgs: dict
|
||||
|
||||
# The keys in this dict **must** be in set our set of "normalized"
|
||||
# symbol names (i.e. the same names you'd get back in search
|
||||
# results) in order for position msgs to correctly trigger the
|
||||
# display of a position indicator on screen.
|
||||
position_msgs: dict[str, list[BrokerdPosition]]
|
||||
|
||||
# spawn EMS actor-service
|
||||
async with (
|
||||
|
@ -563,7 +570,9 @@ async def open_order_mode(
|
|||
providers=symbol.brokers
|
||||
)
|
||||
|
||||
# use only loaded accounts according to brokerd
|
||||
# XXX: ``brokerd`` delivers a set of account names that it allows
|
||||
# use of but the user also can define the accounts they'd like
|
||||
# to use, in order, in their `brokers.toml` file.
|
||||
accounts = {}
|
||||
for name in brokerd_accounts:
|
||||
# ensure name is in ``brokers.toml``
|
||||
|
@ -571,7 +580,10 @@ async def open_order_mode(
|
|||
|
||||
# first account listed is the one we select at startup
|
||||
# (aka order based selection).
|
||||
pp_account = next(iter(accounts.keys())) if accounts else 'paper'
|
||||
pp_account = next(
|
||||
# choose first account based on line order from `brokers.toml`.
|
||||
iter(accounts.keys())
|
||||
) if accounts else 'paper'
|
||||
|
||||
# NOTE: requires the backend exactly specifies
|
||||
# the expected symbol key in its positions msg.
|
||||
|
@ -617,8 +629,8 @@ async def open_order_mode(
|
|||
# alloc?
|
||||
pp_tracker.update_from_pp()
|
||||
|
||||
# on existing position, show pp tracking graphics
|
||||
if pp_tracker.startup_pp.size != 0:
|
||||
# if no position, don't show pp tracking graphics
|
||||
pp_tracker.show()
|
||||
pp_tracker.hide_info()
|
||||
|
||||
|
@ -802,12 +814,13 @@ async def process_trades_and_update_ui(
|
|||
|
||||
tracker = mode.trackers[msg['account']]
|
||||
tracker.live_pp.update_from_msg(msg)
|
||||
tracker.update_from_pp()
|
||||
|
||||
# update order pane widgets
|
||||
tracker.update_from_pp()
|
||||
mode.pane.update_status_ui(tracker)
|
||||
# display pnl
|
||||
mode.pane.display_pnl(tracker)
|
||||
|
||||
if tracker.live_pp.size:
|
||||
# display pnl
|
||||
mode.pane.display_pnl(tracker)
|
||||
|
||||
# short circuit to next msg to avoid
|
||||
# unnecessary msg content lookups
|
||||
|
|
|
@ -25,6 +25,8 @@ import i3ipc
|
|||
i3 = i3ipc.Connection()
|
||||
t = i3.get_tree()
|
||||
|
||||
orig_win_id = t.find_focused().window
|
||||
|
||||
# for tws
|
||||
win_names: list[str] = [
|
||||
'Interactive Brokers', # tws running in i3
|
||||
|
@ -51,11 +53,20 @@ for name in win_names:
|
|||
|
||||
# move mouse to bottom left of window (where there should
|
||||
# be nothing to click).
|
||||
'mousemove_relative', '--sync', str(w-3), str(h-3),
|
||||
'mousemove_relative', '--sync', str(w-4), str(h-4),
|
||||
|
||||
# NOTE: we may need to stick a `--retry 3` in here..
|
||||
'click', '--window', win_id, '1',
|
||||
'click', '--window', win_id, '--repeat', '3', '1',
|
||||
|
||||
# hackzorzes
|
||||
'key', 'ctrl+alt+f',
|
||||
])
|
||||
],
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
# re-activate and focus original window
|
||||
subprocess.call([
|
||||
'xdotool',
|
||||
'windowactivate', '--sync', str(orig_win_id),
|
||||
'click', '--window', str(orig_win_id), '1',
|
||||
])
|
||||
|
|
Loading…
Reference in New Issue