Compare commits

..

2 Commits

Author SHA1 Message Date
Tyler Goodlet 82c2256271 Add missing f-str prefix to log line 2026-01-02 16:55:15 -05:00
Tyler Goodlet a743fa28b5 Teensie `piker.data` styling tweaks
- use more compact optional value style with `|`-union
- fix `.flows` typing-only import since we need `MktPair` to be
  immediately defined for use on a `msgspec.Struct` field.
- more "tree-like" warning msg in `.validate()` reporting.
2026-01-02 16:55:15 -05:00
24 changed files with 424 additions and 1004 deletions

View File

@ -1,9 +1,7 @@
[network] [network]
pikerd = [ tsdb.backend = 'marketstore'
'/ipv4/127.0.0.1/tcp/6116', # std localhost daemon-actor tree tsdb.host = 'localhost'
# '/uds/6116', # TODO std uds socket file tsdb.grpc_port = 5995
]
[ui] [ui]
# set custom font + size which will scale entire UI # set custom font + size which will scale entire UI

View File

@ -1,71 +1,30 @@
running ``ib`` gateway in ``docker`` running ``ib`` gateway in ``docker``
------------------------------------ ------------------------------------
We have a config based on a well maintained community We have a config based on the (now defunct)
image from `@gnzsnz`: image from "waytrade":
https://github.com/gnzsnz/ib-gateway-docker https://github.com/waytrade/ib-gateway-docker
To startup this image with our custom settings
To startup this image simply run the command:: simply run the command::
docker compose up docker compose up
(For further usage^ see the official `docker-compose`_ docs) And you should have the following socket-available services:
- ``x11vnc1@127.0.0.1:3003``
And you should have the following socket-available services by
default:
- ``x11vnc1 @ 127.0.0.1:5900``
- ``ib-gw@127.0.0.1:4002`` - ``ib-gw@127.0.0.1:4002``
You can now attach to the container via a VNC client with password-auth; You can attach to the container via a VNC client
here is an example using ``vncclient`` on ``linux``:: without password auth.
vncviewer localhost:5900 SECURITY STUFF!?!?!
-------------------
Though "``ib``" claims they host filter connections outside
now enter the pw you set via an (see second code blob) `.env file`_ localhost (aka ``127.0.0.1``) it's probably better if you filter
or pw-file according to the `credentials section`_. the socket at the OS level using a stateless firewall rule::
If you want to change away from their default config see the example
`docker-compose.yml`-config issue and config-section of the readme,
- https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#configuration
- https://github.com/gnzsnz/ib-gateway-docker/discussions/103
.. _.env file: https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#how-to-use-it
.. _docker-compose: https://docs.docker.com/compose/
.. _credentials section: https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#credentials
IF you also want to run ``TWS``
-------------------------------
You can also run it containerized,
https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#using-tws
SECURITY stuff (advanced, only if you're paranoid)
--------------------------------------------------
First and foremost if doing a "distributed" container setup where you
run the ``ib-gw`` docker container and your connecting API client
(likely ``ib_async`` from python) on **different hosts** be sure to
read the `security considerations`_ section!
And for a further (somewhat paranoid) perspective from
a long-time-ago serious devops eng..
Though "``ib``" claims they filter remote host connections outside
``localhost`` (aka ``127.0.0.1`` on ipv4) it's prolly justified if
you'd like to filter the socket at the *OS level* using a stateless
firewall rule::
ip rule add not unicast iif lo to 0.0.0.0/0 dport 4002 ip rule add not unicast iif lo to 0.0.0.0/0 dport 4002
We will soon have this baked into our own custom image but for
We will soon have this either baked into our own custom derivative now you'll have to do it urself dawgy.
image (or patched into the current upstream one after further testin)
but for now you'll have to do it urself, diggity dawg.
.. _security considerations: https://github.com/gnzsnz/ib-gateway-docker?tab=readme-ov-file#security-considerations

View File

@ -98,14 +98,13 @@ async def open_cached_client(
If one has not been setup do it and cache it. If one has not been setup do it and cache it.
''' '''
brokermod: ModuleType = get_brokermod(brokername) brokermod = get_brokermod(brokername)
# TODO: make abstract or `typing.Protocol`
# client: Client
async with maybe_open_context( async with maybe_open_context(
acm_func=brokermod.get_client, acm_func=brokermod.get_client,
kwargs=kwargs, kwargs=kwargs,
) as (cache_hit, client): ) as (cache_hit, client):
if cache_hit: if cache_hit:
log.runtime(f'Reusing existing {client}') log.runtime(f'Reusing existing {client}')

View File

@ -374,14 +374,9 @@ class Client:
pair: Pair = pair_type(**item) pair: Pair = pair_type(**item)
except Exception as e: except Exception as e:
e.add_note( e.add_note(
f'\n' "\nDon't panic, prolly stupid binance changed their symbology schema again..\n"
f'New or removed field we need to codify!\n' 'Check out their API docs here:\n\n'
f'pair-type: {pair_type!r}\n' 'https://binance-docs.github.io/apidocs/spot/en/#exchange-information'
f'\n'
f"Don't panic, prolly stupid binance changed their symbology schema again..\n"
f'Check out their API docs here:\n'
f'\n'
f'https://binance-docs.github.io/apidocs/spot/en/#exchange-information\n'
) )
raise raise
pair_table[pair.symbol.upper()] = pair pair_table[pair.symbol.upper()] = pair

View File

@ -94,15 +94,13 @@ class L1(Struct):
# validation type # validation type
# https://developers.binance.com/docs/derivatives/usds-margined-futures/websocket-market-streams/Aggregate-Trade-Streams#response-example
class AggTrade(Struct, frozen=True): class AggTrade(Struct, frozen=True):
e: str # Event type e: str # Event type
E: int # Event time E: int # Event time
s: str # Symbol s: str # Symbol
a: int # Aggregate trade ID a: int # Aggregate trade ID
p: float # Price p: float # Price
q: float # Quantity with all the market trades q: float # Quantity
nq: float # Normal quantity without the trades involving RPI orders
f: int # First trade ID f: int # First trade ID
l: int # noqa Last trade ID l: int # noqa Last trade ID
T: int # Trade time T: int # Trade time

View File

@ -97,16 +97,6 @@ class Pair(Struct, frozen=True, kw_only=True):
baseAsset: str baseAsset: str
baseAssetPrecision: int baseAssetPrecision: int
permissionSets: list[list[str]]
# https://developers.binance.com/docs/binance-spot-api-docs#2025-08-26
# will become non-optional 2025-08-28?
# https://developers.binance.com/docs/binance-spot-api-docs#future-changes
pegInstructionsAllowed: bool = False
# https://developers.binance.com/docs/binance-spot-api-docs#2025-12-02
opoAllowed: bool = False
filters: dict[ filters: dict[
str, str,
str | int | float, str | int | float,
@ -152,11 +142,7 @@ class SpotPair(Pair, frozen=True):
defaultSelfTradePreventionMode: str defaultSelfTradePreventionMode: str
allowedSelfTradePreventionModes: list[str] allowedSelfTradePreventionModes: list[str]
permissions: list[str] permissions: list[str]
permissionSets: list[list[str]]
# can the paint botz creat liq gaps even easier on this asset?
# Bp
# https://developers.binance.com/docs/binance-spot-api-docs/faqs/order_amend_keep_priority
amendAllowed: bool
# NOTE: see `.data._symcache.SymbologyCache.load()` for why # NOTE: see `.data._symcache.SymbologyCache.load()` for why
ns_path: str = 'piker.brokers.binance:SpotPair' ns_path: str = 'piker.brokers.binance:SpotPair'
@ -223,10 +209,7 @@ class FutesPair(Pair):
assert pair == self.pair # sanity assert pair == self.pair # sanity
return f'{expiry}' return f'{expiry}'
case ( case 'PERPETUAL':
'PERPETUAL'
| 'TRADIFI_PERPETUAL'
):
return 'PERP' return 'PERP'
case '': case '':
@ -255,10 +238,7 @@ class FutesPair(Pair):
margin: str = self.marginAsset margin: str = self.marginAsset
match ctype: match ctype:
case ( case 'PERPETUAL':
'PERPETUAL'
| 'TRADIFI_PERPETUAL'
):
return f'{margin}M' return f'{margin}M'
case ( case (

View File

@ -471,15 +471,11 @@ def search(
''' '''
# global opts # global opts
brokermods: list[ModuleType] = list(config['brokermods'].values()) brokermods = list(config['brokermods'].values())
# TODO: this is coming from the `search --pdb` NOT from
# the `piker --pdb` XD ..
# -[ ] pull from the parent click ctx's values..dumdum
# assert pdb
# define tractor entrypoint # define tractor entrypoint
async def main(func): async def main(func):
async with maybe_open_pikerd( async with maybe_open_pikerd(
loglevel=config['loglevel'], loglevel=config['loglevel'],
debug_mode=pdb, debug_mode=pdb,

View File

@ -22,9 +22,7 @@ routines should be primitive data types where possible.
""" """
import inspect import inspect
from types import ModuleType from types import ModuleType
from typing import ( from typing import List, Dict, Any, Optional
Any,
)
import trio import trio
@ -36,10 +34,8 @@ from ..accounting import MktPair
async def api(brokername: str, methname: str, **kwargs) -> dict: async def api(brokername: str, methname: str, **kwargs) -> dict:
''' """Make (proxy through) a broker API call by name and return its result.
Make (proxy through) a broker API call by name and return its result. """
'''
brokermod = get_brokermod(brokername) brokermod = get_brokermod(brokername)
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
meth = getattr(client, methname, None) meth = getattr(client, methname, None)
@ -66,14 +62,10 @@ async def api(brokername: str, methname: str, **kwargs) -> dict:
async def stocks_quote( async def stocks_quote(
brokermod: ModuleType, brokermod: ModuleType,
tickers: list[str] tickers: List[str]
) -> Dict[str, Dict[str, Any]]:
) -> dict[str, dict[str, Any]]: """Return quotes dict for ``tickers``.
''' """
Return a `dict` of snapshot quotes for the provided input
`tickers`: a `list` of fqmes.
'''
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
return await client.quote(tickers) return await client.quote(tickers)
@ -82,15 +74,13 @@ async def stocks_quote(
async def option_chain( async def option_chain(
brokermod: ModuleType, brokermod: ModuleType,
symbol: str, symbol: str,
date: str|None = None, date: Optional[str] = None,
) -> dict[str, dict[str, dict[str, Any]]]: ) -> Dict[str, Dict[str, Dict[str, Any]]]:
''' """Return option chain for ``symbol`` for ``date``.
Return option chain for ``symbol`` for ``date``.
By default all expiries are returned. If ``date`` is provided By default all expiries are returned. If ``date`` is provided
then contract quotes for that single expiry are returned. then contract quotes for that single expiry are returned.
"""
'''
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
if date: if date:
id = int((await client.tickers2ids([symbol]))[symbol]) id = int((await client.tickers2ids([symbol]))[symbol])
@ -108,7 +98,7 @@ async def option_chain(
# async def contracts( # async def contracts(
# brokermod: ModuleType, # brokermod: ModuleType,
# symbol: str, # symbol: str,
# ) -> dict[str, dict[str, dict[str, Any]]]: # ) -> Dict[str, Dict[str, Dict[str, Any]]]:
# """Return option contracts (all expiries) for ``symbol``. # """Return option contracts (all expiries) for ``symbol``.
# """ # """
# async with brokermod.get_client() as client: # async with brokermod.get_client() as client:
@ -120,24 +110,15 @@ async def bars(
brokermod: ModuleType, brokermod: ModuleType,
symbol: str, symbol: str,
**kwargs, **kwargs,
) -> dict[str, dict[str, dict[str, Any]]]: ) -> Dict[str, Dict[str, Dict[str, Any]]]:
''' """Return option contracts (all expiries) for ``symbol``.
Return option contracts (all expiries) for ``symbol``. """
'''
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
return await client.bars(symbol, **kwargs) return await client.bars(symbol, **kwargs)
async def search_w_brokerd( async def search_w_brokerd(name: str, pattern: str) -> dict:
name: str,
pattern: str,
) -> dict:
# TODO: WHY NOT WORK!?!
# when we `step` through the next block?
# import tractor
# await tractor.pause()
async with open_cached_client(name) as client: async with open_cached_client(name) as client:
# TODO: support multiple asset type concurrent searches. # TODO: support multiple asset type concurrent searches.
@ -149,12 +130,12 @@ async def symbol_search(
pattern: str, pattern: str,
**kwargs, **kwargs,
) -> dict[str, dict[str, dict[str, Any]]]: ) -> Dict[str, Dict[str, Dict[str, Any]]]:
''' '''
Return symbol info from broker. Return symbol info from broker.
''' '''
results: list[str] = [] results = []
async def search_backend( async def search_backend(
brokermod: ModuleType brokermod: ModuleType
@ -162,13 +143,6 @@ async def symbol_search(
brokername: str = mod.name brokername: str = mod.name
# TODO: figure this the FUCK OUT
# -> ok so obvi in the root actor any async task that's
# spawned outside the main tractor-root-actor task needs to
# call this..
# await tractor.devx._debug.maybe_init_greenback()
# tractor.pause_from_sync()
async with maybe_spawn_brokerd( async with maybe_spawn_brokerd(
mod.name, mod.name,
infect_asyncio=getattr( infect_asyncio=getattr(
@ -188,6 +162,7 @@ async def symbol_search(
)) ))
async with trio.open_nursery() as n: async with trio.open_nursery() as n:
for mod in brokermods: for mod in brokermods:
n.start_soon(search_backend, mod.name) n.start_soon(search_backend, mod.name)
@ -197,13 +172,11 @@ async def symbol_search(
async def mkt_info( async def mkt_info(
brokermod: ModuleType, brokermod: ModuleType,
fqme: str, fqme: str,
**kwargs, **kwargs,
) -> MktPair: ) -> MktPair:
''' '''
Return the `piker.accounting.MktPair` info struct from a given Return MktPair info from broker including src and dst assets.
backend broker tradable src/dst asset pair.
''' '''
async with open_cached_client(brokermod.name) as client: async with open_cached_client(brokermod.name) as client:

View File

@ -20,11 +20,6 @@ runnable script-programs.
''' '''
from __future__ import annotations from __future__ import annotations
from datetime import ( # noqa
datetime,
date,
tzinfo as TzInfo,
)
from functools import partial from functools import partial
from typing import ( from typing import (
Literal, Literal,
@ -38,7 +33,7 @@ from piker.brokers._util import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from .api import Client from .api import Client
import i3ipc from ib_insync import IB
log = get_logger('piker.brokers.ib') log = get_logger('piker.brokers.ib')
@ -53,39 +48,8 @@ _reset_tech: Literal[
] = 'vnc' ] = 'vnc'
no_setup_msg:str = (
'No data reset hack test setup for {vnc_sockaddr}!\n'
'See config setup tips @\n'
'https://github.com/pikers/piker/tree/master/piker/brokers/ib'
)
def try_xdo_manual(
client: Client,
):
'''
Do the "manual" `xdo`-based screen switch + click
combo since apparently the `asyncvnc` client ain't workin..
Note this is only meant as a backup method for Xorg users,
ideally you can use a real vnc client and the `vnc_click_hack()`
impl!
'''
global _reset_tech
try:
i3ipc_xdotool_manual_click_hack()
_reset_tech = 'i3ipc_xdotool'
return True
except OSError:
vnc_sockaddr: str = client.conf.vnc_addrs
log.exception(
no_setup_msg.format(vnc_sockaddr=vnc_sockaddr)
)
return False
async def data_reset_hack( async def data_reset_hack(
# vnc_host: str,
client: Client, client: Client,
reset_type: Literal['data', 'connection'], reset_type: Literal['data', 'connection'],
@ -117,60 +81,65 @@ async def data_reset_hack(
that need to be wrangle. that need to be wrangle.
''' '''
ib_client: IB = client.ib
# look up any user defined vnc socket address mapped from # look up any user defined vnc socket address mapped from
# a particular API socket port. # a particular API socket port.
vnc_addrs: tuple[str]|None = client.conf.get('vnc_addrs') api_port: str = str(ib_client.client.port)
if not vnc_addrs: vnc_host: str
vnc_port: int
vnc_sockaddr: tuple[str] | None = client.conf.get('vnc_addrs')
no_setup_msg:str = (
f'No data reset hack test setup for {vnc_sockaddr}!\n'
'See config setup tips @\n'
'https://github.com/pikers/piker/tree/master/piker/brokers/ib'
)
if not vnc_sockaddr:
log.warning( log.warning(
no_setup_msg.format(vnc_sockaddr=client.conf) no_setup_msg
+ +
'REQUIRES A `vnc_addrs: array` ENTRY' 'REQUIRES A `vnc_addrs: array` ENTRY'
) )
vnc_host, vnc_port = vnc_sockaddr.get(
api_port,
('localhost', 3003)
)
global _reset_tech global _reset_tech
match _reset_tech: match _reset_tech:
case 'vnc': case 'vnc':
try: try:
await tractor.to_asyncio.run_task( await tractor.to_asyncio.run_task(
partial( partial(
vnc_click_hack, vnc_click_hack,
client=client, host=vnc_host,
port=vnc_port,
) )
) )
except ( except OSError:
OSError, # no VNC server avail.. if vnc_host != 'localhost':
PermissionError, # asyncvnc pw fail.. log.warning(no_setup_msg)
): return False
try: try:
import i3ipc # noqa (since a deps dynamic check) import i3ipc # noqa (since a deps dynamic check)
except ModuleNotFoundError: except ModuleNotFoundError:
log.warning( log.warning(no_setup_msg)
no_setup_msg.format(vnc_sockaddr=client.conf)
)
return False return False
# XXX, Xorg only workaround.. try:
# TODO? remove now that we have `pyvnc`? i3ipc_xdotool_manual_click_hack()
# if vnc_host not in { _reset_tech = 'i3ipc_xdotool'
# 'localhost', return True
# '127.0.0.1', except OSError:
# }: log.exception(no_setup_msg)
# focussed, matches = i3ipc_fin_wins_titled() return False
# if not matches:
# log.warning(
# no_setup_msg.format(vnc_sockaddr=vnc_sockaddr)
# )
# return False
# else:
# try_xdo_manual(vnc_sockaddr)
# localhost but no vnc-client or it borked..
else:
try_xdo_manual(client)
case 'i3ipc_xdotool': case 'i3ipc_xdotool':
try_xdo_manual(client) i3ipc_xdotool_manual_click_hack()
# i3ipc_xdotool_manual_click_hack()
case _ as tech: case _ as tech:
raise RuntimeError(f'{tech} is not supported for reset tech!?') raise RuntimeError(f'{tech} is not supported for reset tech!?')
@ -180,66 +149,21 @@ async def data_reset_hack(
async def vnc_click_hack( async def vnc_click_hack(
client: Client, host: str,
reset_type: str = 'data', port: int,
pw: str|None = None, reset_type: str = 'data'
) -> None: ) -> None:
''' '''
Reset the data or network connection for the VNC attached Reset the data or network connection for the VNC attached
ib-gateway using a (magic) keybinding combo. ib gateway using magic combos.
A vnc-server password can be set either by an input `pw` param or
set in the client's config with the latter loaded from the user's
`brokers.toml` in a vnc-addrs-port-mapping section,
.. code:: toml
[ib.vnc_addrs]
4002 = {host = 'localhost', port = 5900, pw = 'doggy'}
''' '''
api_port: str = str(client.ib.client.port)
conf: dict = client.conf
vnc_addrs: dict[int, tuple] = conf.get('vnc_addrs')
if not vnc_addrs:
return None
addr_entry: dict|tuple = vnc_addrs.get(
api_port,
('localhost', 5900) # a typical default
)
if pw is None:
match addr_entry:
case (
host,
port,
):
pass
case {
'host': host,
'port': port,
'pw': pw
}:
pass
case _:
raise ValueError(
f'Invalid `ib.vnc_addrs` entry ?\n'
f'{addr_entry!r}\n'
)
try: try:
from pyvnc import ( import asyncvnc
AsyncVNCClient,
VNCConfig,
Point,
MOUSE_BUTTON_LEFT,
)
except ModuleNotFoundError: except ModuleNotFoundError:
log.warning( log.warning(
"In order to leverage `piker`'s built-in data reset hacks, install " "In order to leverage `piker`'s built-in data reset hacks, install "
"the `pyvnc` project: https://github.com/regulad/pyvnc.git" "the `asyncvnc` project: https://github.com/barneygale/asyncvnc"
) )
return return
@ -250,79 +174,24 @@ async def vnc_click_hack(
'connection': 'r' 'connection': 'r'
}[reset_type] }[reset_type]
with tractor.devx.open_crash_handler(): async with asyncvnc.connect(
client = await AsyncVNCClient.connect( host,
VNCConfig(
host=host,
port=port, port=port,
password=pw,
) # TODO: doesn't work see:
) # https://github.com/barneygale/asyncvnc/issues/7
async with client: # password='ibcansmbz',
) as client:
# move to middle of screen # move to middle of screen
# 640x1800 # 640x1800
await client.move( client.mouse.move(
Point( x=500,
500, y=500,
500,
) )
) client.mouse.click()
# ensure the ib-gw window is active client.keyboard.press('Ctrl', 'Alt', key) # keys are stacked
await client.click(MOUSE_BUTTON_LEFT)
# send the hotkeys combo B)
await client.press('Ctrl', 'Alt', key) # keys are stacked
def i3ipc_fin_wins_titled(
titles: list[str] = [
'Interactive Brokers', # tws running in i3
'IB Gateway', # gw running in i3
# 'IB', # gw running in i3 (newer version?)
# !TODO, remote vnc instance
# -[ ] something in title (or other Con-props) that indicates
# this is explicitly for ibrk sw?
# |_[ ] !can use modden spawn eventually!
'TigerVNC',
# 'vncviewer', # the terminal..
],
) -> tuple[
i3ipc.Con, # orig focussed win
list[tuple[str, i3ipc.Con]], # matching wins by title
]:
'''
Attempt to find a local-DE window titled with an entry in
`titles`.
If found deliver the current focussed window and all matching
`i3ipc.Con`s in a list.
'''
import i3ipc
ipc = i3ipc.Connection()
# TODO: might be worth offering some kinda api for grabbing
# the window id from the pid?
# https://stackoverflow.com/a/2250879
tree = ipc.get_tree()
focussed: i3ipc.Con = tree.find_focused()
matches: list[i3ipc.Con] = []
for name in titles:
results = tree.find_titled(name)
print(f'results for {name}: {results}')
if results:
con = results[0]
matches.append((
name,
con,
))
return (
focussed,
matches,
)
def i3ipc_xdotool_manual_click_hack() -> None: def i3ipc_xdotool_manual_click_hack() -> None:
@ -330,10 +199,29 @@ def i3ipc_xdotool_manual_click_hack() -> None:
Do the data reset hack but expecting a local X-window using `xdotool`. Do the data reset hack but expecting a local X-window using `xdotool`.
''' '''
focussed, matches = i3ipc_fin_wins_titled() import i3ipc
orig_win_id = focussed.window i3 = i3ipc.Connection()
# TODO: might be worth offering some kinda api for grabbing
# the window id from the pid?
# https://stackoverflow.com/a/2250879
t = i3.get_tree()
orig_win_id = t.find_focused().window
# for tws
win_names: list[str] = [
'Interactive Brokers', # tws running in i3
'IB Gateway', # gw running in i3
# 'IB', # gw running in i3 (newer version?)
]
try: try:
for name, con in matches: for name in win_names:
results = t.find_titled(name)
print(f'results for {name}: {results}')
if results:
con = results[0]
print(f'Resetting data feed for {name}') print(f'Resetting data feed for {name}')
win_id = str(con.window) win_id = str(con.window)
w, h = con.rect.width, con.rect.height w, h = con.rect.width, con.rect.height
@ -379,99 +267,3 @@ def i3ipc_xdotool_manual_click_hack() -> None:
]) ])
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
log.exception('xdotool timed out?') log.exception('xdotool timed out?')
def is_current_time_in_range(
start_dt: datetime,
end_dt: datetime,
) -> bool:
'''
Check if current time is within the datetime range.
Use any/the-same timezone as provided by `start_dt.tzinfo` value
in the range.
'''
now: datetime = datetime.now(start_dt.tzinfo)
return start_dt <= now <= end_dt
# TODO, put this into `._util` and call it from here!
#
# NOTE, this was generated by @guille from a gpt5 prompt
# and was originally thot to be needed before learning about
# `ib_insync.contract.ContractDetails._parseSessions()` and
# it's downstream meths..
#
# This is still likely useful to keep for now to parse the
# `.tradingHours: str` value manually if we ever decide
# to move off `ib_async` and implement our own `trio`/`anyio`
# based version Bp
#
# >attempt to parse the retarted ib "time stampy thing" they
# >do for "venue hours" with this.. written by
# >gpt5-"thinking",
#
def parse_trading_hours(
spec: str,
tz: TzInfo|None = None
) -> dict[
date,
tuple[datetime, datetime]
]|None:
'''
Parse venue hours like:
'YYYYMMDD:HHMM-YYYYMMDD:HHMM;YYYYMMDD:CLOSED;...'
Returns `dict[date] = (open_dt, close_dt)` or `None` if
closed.
'''
if (
not isinstance(spec, str)
or
not spec
):
raise ValueError('spec must be a non-empty string')
out: dict[
date,
tuple[datetime, datetime]
]|None = {}
for part in (p.strip() for p in spec.split(';') if p.strip()):
if part.endswith(':CLOSED'):
day_s, _ = part.split(':', 1)
d = datetime.strptime(day_s, '%Y%m%d').date()
out[d] = None
continue
try:
start_s, end_s = part.split('-', 1)
start_dt = datetime.strptime(start_s, '%Y%m%d:%H%M')
end_dt = datetime.strptime(end_s, '%Y%m%d:%H%M')
except ValueError as exc:
raise ValueError(f'invalid segment: {part}') from exc
if tz is not None:
start_dt = start_dt.replace(tzinfo=tz)
end_dt = end_dt.replace(tzinfo=tz)
out[start_dt.date()] = (start_dt, end_dt)
return out
# ORIG desired usage,
#
# TODO, for non-drunk tomorrow,
# - call above fn and check that `output[today] is not None`
# trading_hrs: dict = parse_trading_hours(
# details.tradingHours
# )
# liq_hrs: dict = parse_trading_hours(
# details.liquidHours
# )

View File

@ -943,7 +943,6 @@ class Client:
) )
if tkr: if tkr:
break break
except TimeoutError as err: except TimeoutError as err:
timeouterr = err timeouterr = err
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
@ -952,9 +951,7 @@ class Client:
else: else:
if not warnset: if not warnset:
log.warning( log.warning(
f'Quote req timed out..\n' f'Quote req timed out..maybe venue is closed?\n'
f'Maybe the venue is closed?\n'
f'\n'
f'{asdict(contract)}' f'{asdict(contract)}'
) )
warnset = True warnset = True
@ -966,11 +963,9 @@ class Client:
) )
break break
else: else:
if ( if timeouterr and raise_on_timeout:
timeouterr import pdbp
and pdbp.set_trace()
raise_on_timeout
):
raise timeouterr raise timeouterr
if not warnset: if not warnset:
@ -1367,7 +1362,9 @@ async def load_aio_clients(
async def load_clients_for_trio( async def load_clients_for_trio(
chan: tractor.to_asyncio.LinkedTaskChannel, from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
) -> None: ) -> None:
''' '''
Pure async mngr proxy to ``load_aio_clients()``. Pure async mngr proxy to ``load_aio_clients()``.
@ -1380,7 +1377,8 @@ async def load_clients_for_trio(
disconnect_on_exit=False, disconnect_on_exit=False,
) as accts2clients: ) as accts2clients:
chan.started_nowait(accts2clients) to_trio.send_nowait(accts2clients)
# TODO: maybe a sync event to wait on instead? # TODO: maybe a sync event to wait on instead?
await asyncio.sleep(float('inf')) await asyncio.sleep(float('inf'))
@ -1524,22 +1522,23 @@ class MethodProxy:
async def open_aio_client_method_relay( async def open_aio_client_method_relay(
chan: tractor.to_asyncio.LinkedTaskChannel, from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
client: Client, client: Client,
event_consumers: dict[str, trio.Event], event_consumers: dict[str, trio.Event],
) -> None: ) -> None:
# sync with `open_client_proxy()` caller # sync with `open_client_proxy()` caller
chan.started_nowait(client) to_trio.send_nowait(client)
# TODO: separate channel for error handling? # TODO: separate channel for error handling?
client.inline_errors(chan) client.inline_errors(to_trio)
# relay all method requests to ``asyncio``-side client and deliver # relay all method requests to ``asyncio``-side client and deliver
# back results # back results
while not chan._to_trio._closed: # <- TODO, better check like `._web_bs`? while not to_trio._closed:
msg: tuple[str, dict]|dict|None = await chan.get() msg: tuple[str, dict] | dict | None = await from_trio.get()
match msg: match msg:
case None: # termination sentinel case None: # termination sentinel
log.info('asyncio `Client` method-proxy SHUTDOWN!') log.info('asyncio `Client` method-proxy SHUTDOWN!')
@ -1552,7 +1551,7 @@ async def open_aio_client_method_relay(
try: try:
resp = await meth(**kwargs) resp = await meth(**kwargs)
# echo the msg back # echo the msg back
chan.send_nowait({'result': resp}) to_trio.send_nowait({'result': resp})
except ( except (
RequestError, RequestError,
@ -1560,10 +1559,10 @@ async def open_aio_client_method_relay(
# TODO: relay all errors to trio? # TODO: relay all errors to trio?
# BaseException, # BaseException,
) as err: ) as err:
chan.send_nowait({'exception': err}) to_trio.send_nowait({'exception': err})
case {'error': content}: case {'error': content}:
chan.send_nowait({'exception': content}) to_trio.send_nowait({'exception': content})
case _: case _:
raise ValueError(f'Unhandled msg {msg}') raise ValueError(f'Unhandled msg {msg}')

View File

@ -116,11 +116,7 @@ def pack_position(
symbol=fqme, symbol=fqme,
currency=con.currency, currency=con.currency,
size=float(pos.position), size=float(pos.position),
avg_price=( avg_price=float(pos.avgCost) / float(con.multiplier or 1.0),
float(pos.avgCost)
/
float(con.multiplier or 1.0)
),
), ),
) )
@ -361,10 +357,6 @@ async def update_and_audit_pos_msg(
size=ibpos.position, size=ibpos.position,
avg_price=pikerpos.ppu, avg_price=pikerpos.ppu,
# XXX ensures matching even if multiple venue-names
# in `.bs_fqme`, likely from txn records..
bs_mktid=mkt.bs_mktid,
) )
ibfmtmsg: str = pformat(ibpos._asdict()) ibfmtmsg: str = pformat(ibpos._asdict())
@ -433,8 +425,7 @@ async def aggr_open_orders(
) -> None: ) -> None:
''' '''
Collect all open orders from client and fill in `order_msgs: Collect all open orders from client and fill in `order_msgs: list`.
list`.
''' '''
trades: list[Trade] = client.ib.openTrades() trades: list[Trade] = client.ib.openTrades()
@ -555,10 +546,7 @@ async def open_trade_dialog(
), ),
# TODO: do this as part of `open_account()`!? # TODO: do this as part of `open_account()`!?
open_symcache( open_symcache('ib', only_from_memcache=True) as symcache,
'ib',
only_from_memcache=True,
) as symcache,
): ):
# Open a trade ledgers stack for appending trade records over # Open a trade ledgers stack for appending trade records over
# multiple accounts. # multiple accounts.
@ -566,10 +554,8 @@ async def open_trade_dialog(
ledgers: dict[str, TransactionLedger] = {} ledgers: dict[str, TransactionLedger] = {}
tables: dict[str, Account] = {} tables: dict[str, Account] = {}
order_msgs: list[Status] = [] order_msgs: list[Status] = []
conf: dict = get_config() conf = get_config()
accounts_def_inv: bidict[str, str] = bidict( accounts_def_inv: bidict[str, str] = bidict(conf['accounts']).inverse
conf['accounts']
).inverse
with ( with (
ExitStack() as lstack, ExitStack() as lstack,
@ -719,11 +705,7 @@ async def open_trade_dialog(
# client-account and build out position msgs to deliver to # client-account and build out position msgs to deliver to
# EMS. # EMS.
for acctid, acnt in tables.items(): for acctid, acnt in tables.items():
active_pps: dict[str, Position] active_pps, closed_pps = acnt.dump_active()
(
active_pps,
closed_pps,
) = acnt.dump_active()
for pps in [active_pps, closed_pps]: for pps in [active_pps, closed_pps]:
piker_pps: list[Position] = list(pps.values()) piker_pps: list[Position] = list(pps.values())
@ -739,7 +721,6 @@ async def open_trade_dialog(
) )
if ibpos: if ibpos:
bs_mktid: str = str(ibpos.contract.conId) bs_mktid: str = str(ibpos.contract.conId)
msg = await update_and_audit_pos_msg( msg = await update_and_audit_pos_msg(
acctid, acctid,
pikerpos, pikerpos,
@ -1260,47 +1241,32 @@ async def deliver_trade_events(
# never relay errors for non-broker related issues # never relay errors for non-broker related issues
# https://interactivebrokers.github.io/tws-api/message_codes.html # https://interactivebrokers.github.io/tws-api/message_codes.html
code: int = err['error_code'] code: int = err['error_code']
reason: str = err['reason'] if code in {
reqid: str = str(err['reqid']) 200, # uhh
# "Warning:" msg codes,
# https://interactivebrokers.github.io/tws-api/message_codes.html#warning_codes
# - 2109: 'Outside Regular Trading Hours'
if 'Warning:' in reason:
log.warning(
f'Order-API-warning: {code!r}\n'
f'reqid: {reqid!r}\n'
f'\n'
f'{pformat(err)}\n'
# ^TODO? should we just print the `reason`
# not the full `err`-dict?
)
continue
# XXX known special (ignore) cases
elif code in {
200, # uhh.. ni idea
# hist pacing / connectivity # hist pacing / connectivity
162, 162,
165, 165,
# WARNING codes:
# https://interactivebrokers.github.io/tws-api/message_codes.html#warning_codes
# Attribute 'Outside Regular Trading Hours' is
# " 'ignored based on the order type and
# destination. PlaceOrder is now ' 'being
# processed.',
2109,
# XXX: lol this isn't even documented.. # XXX: lol this isn't even documented..
# 'No market data during competing live session' # 'No market data during competing live session'
1669, 1669,
}: }:
log.error(
f'Order-API-error which is non-cancel-causing ?!\n'
f'\n'
f'{pformat(err)}\n'
)
continue continue
reqid: str = str(err['reqid'])
reason: str = err['reason']
if err['reqid'] == -1: if err['reqid'] == -1:
log.error( log.error(f'TWS external order error:\n{pformat(err)}')
f'TWS external order error ??\n'
f'{pformat(err)}\n'
)
flow: dict = dict( flow: dict = dict(
flows.get(reqid) flows.get(reqid)

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # piker: trading gear for hackers
# Copyright (C) 2018-forever Tyler Goodlet (in stewardship for pikers) # 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
@ -13,12 +13,10 @@
# 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/>.
"""
Data feed endpoints pre-wrapped and ready for use with ``tractor``/``trio``.
''' """
Data feed endpoints pre-wrapped and ready for use with `tractor`/`trio`
via "infected-asyncio-mode".
'''
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from contextlib import ( from contextlib import (
@ -28,6 +26,7 @@ from dataclasses import asdict
from datetime import datetime from datetime import datetime
from functools import partial from functools import partial
from pprint import pformat from pprint import pformat
from math import isnan
import time import time
from typing import ( from typing import (
Any, Any,
@ -41,6 +40,7 @@ import numpy as np
from pendulum import ( from pendulum import (
now, now,
from_timestamp, from_timestamp,
# DateTime,
Duration, Duration,
duration as mk_duration, duration as mk_duration,
) )
@ -69,10 +69,7 @@ from .api import (
Contract, Contract,
RequestError, RequestError,
) )
from ._util import ( from ._util import data_reset_hack
data_reset_hack,
is_current_time_in_range,
)
from .symbols import get_mkt_info from .symbols import get_mkt_info
if TYPE_CHECKING: if TYPE_CHECKING:
@ -187,8 +184,7 @@ async def open_history_client(
if ( if (
start_dt start_dt
and and start_dt.timestamp() == 0
start_dt.timestamp() == 0
): ):
await tractor.pause() await tractor.pause()
@ -207,16 +203,14 @@ async def open_history_client(
): ):
count += 1 count += 1
mean += latency / count mean += latency / count
log.debug( print(
f'HISTORY FRAME QUERY LATENCY: {latency}\n' f'HISTORY FRAME QUERY LATENCY: {latency}\n'
f'mean: {mean}' f'mean: {mean}'
) )
# could be trying to retreive bars over weekend # could be trying to retreive bars over weekend
if out is None: if out is None:
log.error( log.error(f"Can't grab bars starting at {end_dt}!?!?")
f"No bars starting at {end_dt!r} !?!?"
)
if ( if (
end_dt end_dt
and head_dt and head_dt
@ -291,9 +285,8 @@ _pacing: str = (
async def wait_on_data_reset( async def wait_on_data_reset(
proxy: MethodProxy, proxy: MethodProxy,
reset_type: str = 'data', reset_type: str = 'data',
timeout: float = 16, timeout: float = 16, # float('inf'),
task_status: TaskStatus[ task_status: TaskStatus[
tuple[ tuple[
@ -302,47 +295,29 @@ async def wait_on_data_reset(
] ]
] = trio.TASK_STATUS_IGNORED, ] = trio.TASK_STATUS_IGNORED,
) -> bool: ) -> bool:
'''
Wait on a (global-ish) "data-farm" event to be emitted
by the IB api server.
Allows syncing to reconnect event-messages emitted on the API # TODO: we might have to put a task lock around this
console, such as: # method..
hist_ev = proxy.status_event(
- 'HMDS data farm connection is OK:ushmds'
- 'Market data farm is connecting:usfuture'
- 'Market data farm connection is OK:usfuture'
Deliver a `(cs, done: Event)` pair to the caller to support it
waiting or cancelling the associated "data-reset-request";
normally a manual data-reset-req is expected to be the cause and
thus trigger such events (such as our click-hack-magic from
`.ib._util`).
'''
# ?TODO, do we need a task-lock around this method?
#
# register for an API "status event" wrapped for `trio`-sync.
hist_ev: trio.Event = proxy.status_event(
'HMDS data farm connection is OK:ushmds' 'HMDS data farm connection is OK:ushmds'
) )
#
# ^TODO: other event-messages we might want to support waiting-for # TODO: other event messages we might want to try and
# but i wasn't able to get reliable.. # wait for but i wasn't able to get any of this
# # reliable..
# reconnect_start = proxy.status_event( # reconnect_start = proxy.status_event(
# 'Market data farm is connecting:usfuture' # 'Market data farm is connecting:usfuture'
# ) # )
# live_ev = proxy.status_event( # live_ev = proxy.status_event(
# 'Market data farm connection is OK:usfuture' # 'Market data farm connection is OK:usfuture'
# ) # )
# try to wait on the reset event(s) to arrive, a timeout # try to wait on the reset event(s) to arrive, a timeout
# will trigger a retry up to 6 times (for now). # will trigger a retry up to 6 times (for now).
client: Client = proxy._aio_ns client: Client = proxy._aio_ns
done = trio.Event() done = trio.Event()
with trio.move_on_after(timeout) as cs: with trio.move_on_after(timeout) as cs:
task_status.started((cs, done)) task_status.started((cs, done))
log.warning( log.warning(
@ -421,9 +396,8 @@ async def get_bars(
bool, # timed out hint bool, # timed out hint
]: ]:
''' '''
Request-n-retrieve historical data frames from a `trio.Task` Retrieve historical data from a ``trio``-side task using
using a `MethoProxy` to query the `asyncio`-side's a ``MethoProxy``.
`.ib.api.Client` methods.
''' '''
global _data_resetter_task, _failed_resets global _data_resetter_task, _failed_resets
@ -613,7 +587,7 @@ async def get_bars(
data_cs.cancel() data_cs.cancel()
# spawn new data reset task # spawn new data reset task
data_cs, reset_done = await tn.start( data_cs, reset_done = await nurse.start(
partial( partial(
wait_on_data_reset, wait_on_data_reset,
proxy, proxy,
@ -633,14 +607,11 @@ async def get_bars(
# such that simultaneous symbol queries don't try data resettingn # such that simultaneous symbol queries don't try data resettingn
# too fast.. # too fast..
unset_resetter: bool = False unset_resetter: bool = False
async with ( async with trio.open_nursery() as nurse:
tractor.trionics.collapse_eg(),
trio.open_nursery() as tn
):
# start history request that we allow # start history request that we allow
# to run indefinitely until a result is acquired # to run indefinitely until a result is acquired
tn.start_soon(query) nurse.start_soon(query)
# start history reset loop which waits up to the timeout # start history reset loop which waits up to the timeout
# for a result before triggering a data feed reset. # for a result before triggering a data feed reset.
@ -660,7 +631,7 @@ async def get_bars(
unset_resetter: bool = True unset_resetter: bool = True
# spawn new data reset task # spawn new data reset task
data_cs, reset_done = await tn.start( data_cs, reset_done = await nurse.start(
partial( partial(
wait_on_data_reset, wait_on_data_reset,
proxy, proxy,
@ -682,12 +653,14 @@ async def get_bars(
) )
# per-actor cache of inter-eventloop-chans
_quote_streams: dict[str, trio.abc.ReceiveStream] = {} _quote_streams: dict[str, trio.abc.ReceiveStream] = {}
async def _setup_quote_stream( async def _setup_quote_stream(
chan: tractor.to_asyncio.LinkedTaskChannel,
from_trio: asyncio.Queue,
to_trio: trio.abc.SendChannel,
symbol: str, symbol: str,
opts: tuple[int] = ( opts: tuple[int] = (
'375', # RT trade volume (excludes utrades) '375', # RT trade volume (excludes utrades)
@ -705,13 +678,10 @@ async def _setup_quote_stream(
) -> trio.abc.ReceiveChannel: ) -> trio.abc.ReceiveChannel:
''' '''
Stream L1 quotes via the `Ticker.updateEvent.connect(push)` Stream a ticker using the std L1 api.
callback API by registering a `push` callback which simply
`chan.send_nowait()`s quote msgs back to the calling
parent-`trio.Task`-side.
NOTE, that this task-fn is run on the `asyncio.Task`-side ONLY This task is ``asyncio``-side and must be called from
and is thus run via `tractor.to_asyncio.open_channel_from()`. ``tractor.to_asyncio.open_channel_from()``.
''' '''
global _quote_streams global _quote_streams
@ -719,79 +689,37 @@ async def _setup_quote_stream(
async with load_aio_clients( async with load_aio_clients(
disconnect_on_exit=False, disconnect_on_exit=False,
) as accts2clients: ) as accts2clients:
# XXX since this is an `asyncio.Task`, we must use
# tractor.pause_from_sync()
caccount_name, client = get_preferred_data_client(accts2clients) caccount_name, client = get_preferred_data_client(accts2clients)
contract = ( contract = contract or (await client.find_contract(symbol))
contract to_trio.send_nowait(contract) # cuz why not
or ticker: Ticker = client.ib.reqMktData(contract, ','.join(opts))
(await client.find_contract(symbol))
)
chan.started_nowait(contract) # cuz why not
ticker: Ticker = client.ib.reqMktData(
contract,
','.join(opts),
)
maybe_exc: BaseException|None = None
handler_tries: int = 0
aio_task: asyncio.Task = asyncio.current_task()
# ?TODO? this API is batch-wise and quite slow-af but, # NOTE: it's batch-wise and slow af but I guess could
# - seems to be 5s updates? # be good for backchecking? Seems to be every 5s maybe?
# - maybe we could use it for backchecking?
#
# ticker: Ticker = client.ib.reqTickByTickData( # ticker: Ticker = client.ib.reqTickByTickData(
# contract, 'Last', # contract, 'Last',
# ) # )
# define a very naive queue-pushing callback that relays # # define a simple queue push routine that streams quote packets
# quote-packets directly the calling (parent) `trio.Task`. # # to trio over the ``to_trio`` memory channel.
# Ensure on teardown we cancel the feed via their cancel API. # to_trio, from_aio = trio.open_memory_channel(2**8) # type: ignore
#
def teardown(): def teardown():
'''
Disconnect our `push`-er callback and cancel the data-feed
for `contract`.
'''
nonlocal maybe_exc
ticker.updateEvent.disconnect(push) ticker.updateEvent.disconnect(push)
report: str = f'Disconnected mkt-data for {symbol!r} due to ' log.error(f"Disconnected stream for `{symbol}`")
if maybe_exc is not None:
report += (
'error,\n'
f'{maybe_exc!r}\n'
)
log.error(report)
else:
report += (
'cancellation.\n'
)
log.cancel(report)
client.ib.cancelMktData(contract) client.ib.cancelMktData(contract)
# decouple broadcast mem chan # decouple broadcast mem chan
_quote_streams.pop(symbol, None) _quote_streams.pop(symbol, None)
def push( def push(t: Ticker) -> None:
t: Ticker, """
tries_before_raise: int = 6, Push quotes to trio task.
) -> None:
'''
Push quotes verbatim to parent-side `trio.Task`.
''' """
nonlocal maybe_exc, handler_tries # log.debug(t)
# log.debug(f'new IB quote: {t}\n')
try: try:
chan.send_nowait(t) to_trio.send_nowait(t)
# XXX TODO XXX replicate in `tractor` tests
# as per `CancelledError`-handler notes below!
# assert 0
except ( except (
trio.BrokenResourceError, trio.BrokenResourceError,
@ -806,107 +734,35 @@ async def _setup_quote_stream(
# resulting in tracebacks spammed to console.. # resulting in tracebacks spammed to console..
# Manually do the dereg ourselves. # Manually do the dereg ourselves.
teardown() teardown()
# for slow debugging purposes to avoid clobbering prompt
# with log msgs
except trio.WouldBlock: except trio.WouldBlock:
log.exception( # log.warning(
f'Asyncio->Trio `chan.send_nowait()` blocked !?\n' # f'channel is blocking symbol feed for {symbol}?'
f'\n' # f'\n{to_trio.statistics}'
f'{chan._to_trio.statistics()}\n' # )
) pass
# ?TODO, handle re-connection attempts?
except BaseException as _berr:
berr = _berr
if handler_tries >= tries_before_raise:
# breakpoint()
maybe_exc = _berr
# task.set_exception(berr)
aio_task.cancel(msg=berr.args)
raise berr
else:
handler_tries += 1
log.exception(
f'Failed to push ticker quote !?\n'
f'handler_tries={handler_tries!r}\n'
f'ticker: {t!r}\n'
f'\n'
f'{chan._to_trio.statistics()}\n'
f'\n'
f'CAUSE: {berr}\n'
)
# except trio.WouldBlock:
# # for slow debugging purposes to avoid clobbering prompt
# # with log msgs
# pass
ticker.updateEvent.connect(push) ticker.updateEvent.connect(push)
try: try:
await asyncio.sleep(float('inf')) await asyncio.sleep(float('inf'))
# XXX, for debug.. TODO? can we rm again?
#
# tractor.pause_from_sync()
# while True:
# await asyncio.sleep(1.6)
# if ticker.ticks:
# log.debug(
# f'ticker.ticks = \n'
# f'{ticker.ticks}\n'
# )
# else:
# log.warning(
# 'UHH no ticker.ticks ??'
# )
# XXX TODO XXX !?!?
# apparently **without this handler** and the subsequent
# re-raising of `maybe_exc from _taskc` cancelling the
# `aio_task` from the `push()`-callback will cause a very
# strange chain of exc raising that breaks alll sorts of
# downstream callers, tasks and remote-actor tasks!?
#
# -[ ] we need some lowlevel reproducting tests to replicate
# those worst-case scenarios in `tractor` core!!
# -[ ] likely we should factor-out the `tractor.to_asyncio`
# attempts at workarounds in `.translate_aio_errors()`
# for failed `asyncio.Task.set_exception()` to either
# call `aio_task.cancel()` and/or
# `aio_task._fut_waiter.set_exception()` to a re-useable
# toolset in something like a `.to_asyncio._utils`??
#
except asyncio.CancelledError as _taskc:
if maybe_exc is not None:
raise maybe_exc from _taskc
raise _taskc
except BaseException as _berr:
# stash any crash cause for reporting in `teardown()`
maybe_exc = _berr
raise _berr
finally: finally:
# always disconnect our `push()` and cancel the
# ib-"mkt-data-feed".
teardown() teardown()
# return from_aio
@acm @acm
async def open_aio_quote_stream( async def open_aio_quote_stream(
symbol: str, symbol: str,
contract: Contract | None = None, contract: Contract | None = None,
) -> ( ) -> trio.abc.ReceiveStream:
trio.abc.Channel| # iface
tractor.to_asyncio.LinkedTaskChannel # actually
):
'''
Open a real-time `Ticker` quote stream from an `asyncio.Task`
spawned via `tractor.to_asyncio.open_channel_from()`, deliver the
inter-event-loop channel to the `trio.Task` caller and cache it
globally for re-use.
'''
from tractor.trionics import broadcast_receiver from tractor.trionics import broadcast_receiver
global _quote_streams global _quote_streams
@ -922,7 +778,6 @@ async def open_aio_quote_stream(
yield from_aio yield from_aio
return return
from_aio: tractor.to_asyncio.LinkedTaskChannel
async with tractor.to_asyncio.open_channel_from( async with tractor.to_asyncio.open_channel_from(
_setup_quote_stream, _setup_quote_stream,
symbol=symbol, symbol=symbol,
@ -932,10 +787,6 @@ async def open_aio_quote_stream(
assert contract assert contract
# TODO? de-reg on teardown of last consumer task?
# -> why aren't we using `.trionics.maybe_open_context()`
# here again?? (we are in `open_client_proxies()` tho?)
#
# cache feed for later consumers # cache feed for later consumers
_quote_streams[symbol] = from_aio _quote_streams[symbol] = from_aio
@ -950,12 +801,7 @@ def normalize(
calc_price: bool = False calc_price: bool = False
) -> dict: ) -> dict:
'''
Translate `ib_async`'s `Ticker.ticks` values to a `piker`
normalized `dict` form for transmit to downstream `.data` layer
consumers.
'''
# check for special contract types # check for special contract types
con = ticker.contract con = ticker.contract
fqme, calc_price = con2fqme(con) fqme, calc_price = con2fqme(con)
@ -974,7 +820,7 @@ def normalize(
tbt = ticker.tickByTicks tbt = ticker.tickByTicks
if tbt: if tbt:
log.info(f'tickbyticks:\n {ticker.tickByTicks}') print(f'tickbyticks:\n {ticker.tickByTicks}')
ticker.ticks = new_ticks ticker.ticks = new_ticks
@ -1010,39 +856,27 @@ def normalize(
return data return data
# ?TODO? feels like this task-fn could be factored to reduce some
# indentation levels?
# -[ ] the reconnect while loop on ib-gw "data farm connection.."s
# -[ ] everything embedded under the `async with aclosing(stream):`
# as the "meat" of the quote delivery once the connection is
# stable.
#
async def stream_quotes( async def stream_quotes(
send_chan: trio.abc.SendChannel, send_chan: trio.abc.SendChannel,
symbols: list[str], symbols: list[str],
feed_is_live: trio.Event, feed_is_live: trio.Event,
loglevel: str = None,
# TODO? we need to hook into the `ib_async` logger like
# we can with i3ipc from modden!
# loglevel: str|None = None,
# startup sync # startup sync
task_status: TaskStatus[tuple[dict, dict]] = trio.TASK_STATUS_IGNORED, task_status: TaskStatus[tuple[dict, dict]] = trio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
''' '''
Stream `symbols[0]` quotes back via `send_chan`. Stream symbol quotes.
The `feed_is_live: Event` is set to signal the caller that it can This is a ``trio`` callable routine meant to be invoked
begin processing msgs from the mem-chan. once the brokerd is up.
''' '''
# TODO: support multiple subscriptions # TODO: support multiple subscriptions
sym: str = symbols[0] sym = symbols[0]
log.info( log.info(f'request for real-time quotes: {sym}')
f'request for real-time quotes\n'
f'sym: {sym!r}\n'
)
init_msgs: list[FeedInit] = [] init_msgs: list[FeedInit] = []
@ -1051,30 +885,21 @@ async def stream_quotes(
details: ibis.ContractDetails details: ibis.ContractDetails
async with ( async with (
open_data_client() as proxy, open_data_client() as proxy,
# trio.open_nursery() as tn,
): ):
mkt, details = await get_mkt_info( mkt, details = await get_mkt_info(
sym, sym,
proxy=proxy, # passed to avoid implicit client load proxy=proxy, # passed to avoid implicit client load
) )
# is venue active rn?
venue_is_open: bool = any(
is_current_time_in_range(
start_dt=sesh.start,
end_dt=sesh.end,
)
for sesh in details.tradingSessions()
)
init_msg = FeedInit(mkt_info=mkt) init_msg = FeedInit(mkt_info=mkt)
# NOTE, tell sampler (via config) to skip vlm summing for dst
# assets which provide no vlm data..
if mkt.dst.atype in { if mkt.dst.atype in {
'fiat', 'fiat',
'index', 'index',
'commodity', 'commodity',
}: }:
# tell sampler config that it shouldn't do vlm summing.
init_msg.shm_write_opts['sum_tick_vlm'] = False init_msg.shm_write_opts['sum_tick_vlm'] = False
init_msg.shm_write_opts['has_vlm'] = False init_msg.shm_write_opts['has_vlm'] = False
@ -1082,21 +907,12 @@ async def stream_quotes(
con: Contract = details.contract con: Contract = details.contract
first_ticker: Ticker | None = None first_ticker: Ticker | None = None
with trio.move_on_after(1):
timeout: float = 1.6
with trio.move_on_after(timeout) as quote_cs:
first_ticker: Ticker = await proxy.get_quote( first_ticker: Ticker = await proxy.get_quote(
contract=con, contract=con,
raise_on_timeout=False, raise_on_timeout=False,
) )
# XXX should never happen with this ep right?
# but if so then, more then likely mkt is closed?
if quote_cs.cancelled_caught:
log.warning(
f'First quote req timed out after {timeout!r}s'
)
if first_ticker: if first_ticker:
first_quote: dict = normalize(first_ticker) first_quote: dict = normalize(first_ticker)
@ -1108,27 +924,28 @@ async def stream_quotes(
f'{pformat(first_quote)}\n' f'{pformat(first_quote)}\n'
) )
# XXX NOTE: whenever we're "outside regular trading hours" # NOTE: it might be outside regular trading hours for
# (only relevant for assets coming from the "legacy markets" # assets with "standard venue operating hours" so we
# space) so we basically (from an API/runtime-operational # only "pretend the feed is live" when the dst asset
# perspective) "pretend the feed is live" even if it's # type is NOT within the NON-NORMAL-venue set: aka not
# actually closed. # commodities, forex or crypto currencies which CAN
# # always return a NaN on a snap quote request during
# IOW, we signal to the effective caller (task) that the live # normal venue hours. In the case of a closed venue
# feed is "already up" but really we're just indicating that # (equitiies, futes, bonds etc.) we at least try to
# the OHLCV history can start being loaded immediately by the # grab the OHLC history.
# `piker.data`/`.tsp` layers. if (
# first_ticker
# XXX, deats: the "pretend we're live" is just done by and
# a `feed_is_live.set()` even though nothing is actually live isnan(first_ticker.last)
# Bp # SO, if the last quote price value is NaN we ONLY
if not venue_is_open: # "pretend to do" `feed_is_live.set()` if it's a known
log.warning( # dst asset venue with a lot of closed operating hours.
f'Venue is closed, unable to establish real-time feed.\n' and mkt.dst.atype not in {
f'mkt: {mkt!r}\n' 'commodity',
f'\n' 'fiat',
f'first_ticker: {first_ticker}\n' 'crypto',
) }
):
task_status.started(( task_status.started((
init_msgs, init_msgs,
first_quote, first_quote,
@ -1139,12 +956,10 @@ async def stream_quotes(
feed_is_live.set() feed_is_live.set()
# block and let data history backfill code run. # block and let data history backfill code run.
# XXX obvi given the venue is closed, we never expect feed
# to come up; a taskc should be the only way to
# terminate this task.
await trio.sleep_forever() await trio.sleep_forever()
return # we never expect feed to come up?
# ?TODO, we could instead spawn a task that waits on a feed # TODO: we should instead spawn a task that waits on a feed
# to start and let it wait indefinitely..instead of this # to start and let it wait indefinitely..instead of this
# hard coded stuff. # hard coded stuff.
# async def wait_for_first_quote(): # async def wait_for_first_quote():
@ -1168,25 +983,21 @@ async def stream_quotes(
) )
cs: trio.CancelScope | None = None cs: trio.CancelScope | None = None
startup: bool = True startup: bool = True
iter_quotes: trio.abc.Channel
while ( while (
startup startup
or or cs.cancel_called
cs.cancel_called
): ):
with trio.CancelScope() as cs: with trio.CancelScope() as cs:
async with ( async with (
tractor.trionics.collapse_eg(), trio.open_nursery() as nurse,
trio.open_nursery() as tn,
open_aio_quote_stream( open_aio_quote_stream(
symbol=sym, symbol=sym,
contract=con, contract=con,
) as iter_quotes, ) as stream,
): ):
# ?TODO? can we rm this - particularly for `ib_async`?
# ugh, clear ticks since we've consumed them # ugh, clear ticks since we've consumed them
# (ahem, ib_insync is stateful trash) # (ahem, ib_insync is stateful trash)
# first_ticker.ticks = [] first_ticker.ticks = []
# only on first entry at feed boot up # only on first entry at feed boot up
if startup: if startup:
@ -1200,8 +1011,8 @@ async def stream_quotes(
# data feed event. # data feed event.
async def reset_on_feed(): async def reset_on_feed():
# ??TODO? this seems to be surpressed from the # TODO: this seems to be surpressed from the
# traceback in `tractor`? # traceback in ``tractor``?
# assert 0 # assert 0
rt_ev = proxy.status_event( rt_ev = proxy.status_event(
@ -1210,9 +1021,9 @@ async def stream_quotes(
await rt_ev.wait() await rt_ev.wait()
cs.cancel() # cancel called should now be set cs.cancel() # cancel called should now be set
tn.start_soon(reset_on_feed) nurse.start_soon(reset_on_feed)
async with aclosing(iter_quotes): async with aclosing(stream):
# if syminfo.get('no_vlm', False): # if syminfo.get('no_vlm', False):
if not init_msg.shm_write_opts['has_vlm']: if not init_msg.shm_write_opts['has_vlm']:
@ -1227,27 +1038,25 @@ async def stream_quotes(
# wait for real volume on feed (trading might be # wait for real volume on feed (trading might be
# closed) # closed)
while True: while True:
ticker = await iter_quotes.receive() ticker = await stream.receive()
# for a real volume contract we rait for # for a real volume contract we rait for
# the first "real" trade to take place # the first "real" trade to take place
if ( if (
# not calc_price # not calc_price
# and not ticker.rtTime # and not ticker.rtTime
False not ticker.rtTime
# not ticker.rtTime
): ):
# spin consuming tickers until we # spin consuming tickers until we
# get a real market datum # get a real market datum
log.debug(f"New unsent ticker: {ticker}") log.debug(f"New unsent ticker: {ticker}")
continue continue
else: else:
log.debug("Received first volume tick") log.debug("Received first volume tick")
# ugh, clear ticks since we've # ugh, clear ticks since we've
# consumed them (ahem, ib_insync is # consumed them (ahem, ib_insync is
# truly stateful trash) # truly stateful trash)
# ticker.ticks = [] ticker.ticks = []
# XXX: this works because we don't use # XXX: this works because we don't use
# ``aclosing()`` above? # ``aclosing()`` above?
@ -1257,24 +1066,15 @@ async def stream_quotes(
log.debug(f"First ticker received {quote}") log.debug(f"First ticker received {quote}")
# tell data-layer spawner-caller that live # tell data-layer spawner-caller that live
# quotes are now active desptie not having # quotes are now streaming.
# necessarily received a first vlm/clearing
# tick.
ticker = await iter_quotes.receive()
feed_is_live.set() feed_is_live.set()
fqme: str = quote['fqme']
await send_chan.send({fqme: quote})
# last = time.time() # last = time.time()
async for ticker in iter_quotes: async for ticker in stream:
quote = normalize(ticker) quote = normalize(ticker)
fqme: str = quote['fqme'] fqme = quote['fqme']
log.debug(
f'Sending quote\n'
f'{quote}'
)
await send_chan.send({fqme: quote}) await send_chan.send({fqme: quote})
# ugh, clear ticks since we've consumed them # ugh, clear ticks since we've consumed them
# ticker.ticks = [] ticker.ticks = []
# last = time.time() # last = time.time()

View File

@ -34,7 +34,6 @@ import urllib.parse
import hashlib import hashlib
import hmac import hmac
import base64 import base64
import tractor
import trio import trio
from piker import config from piker import config
@ -373,7 +372,8 @@ class Client:
# 1658347714, 'status': 'Success'}]} # 1658347714, 'status': 'Success'}]}
if xfers: if xfers:
await tractor.pause() import tractor
await tractor.pp()
trans: dict[str, Transaction] = {} trans: dict[str, Transaction] = {}
for entry in xfers: for entry in xfers:
@ -501,7 +501,6 @@ class Client:
for xkey, data in resp['result'].items(): for xkey, data in resp['result'].items():
# NOTE: always cache in pairs tables for faster lookup # NOTE: always cache in pairs tables for faster lookup
with tractor.devx.maybe_open_crash_handler(): # as bxerr:
pair = Pair(xname=xkey, **data) pair = Pair(xname=xkey, **data)
# register the above `Pair` structs for all # register the above `Pair` structs for all

View File

@ -175,8 +175,9 @@ async def handle_order_requests(
case { case {
'account': 'kraken.spot' as account, 'account': 'kraken.spot' as account,
'action': 'buy'|'sell', 'action': action,
}: } if action in {'buy', 'sell'}:
# validate # validate
order = BrokerdOrder(**msg) order = BrokerdOrder(**msg)
@ -261,12 +262,6 @@ async def handle_order_requests(
} | extra } | extra
log.info(f'Submitting WS order request:\n{pformat(req)}') log.info(f'Submitting WS order request:\n{pformat(req)}')
# NOTE HOWTO, debug order requests
#
# if 'XRP' in pair:
# await tractor.pause()
await ws.send_msg(req) await ws.send_msg(req)
# placehold for sanity checking in relay loop # placehold for sanity checking in relay loop
@ -549,7 +544,7 @@ async def open_trade_dialog(
# to be reloaded. # to be reloaded.
balances: dict[str, float] = await client.get_balances() balances: dict[str, float] = await client.get_balances()
await verify_balances( verify_balances(
acnt, acnt,
src_fiat, src_fiat,
balances, balances,
@ -1090,8 +1085,6 @@ async def handle_order_updates(
f'Failed to {action} order {reqid}:\n' f'Failed to {action} order {reqid}:\n'
f'{errmsg}' f'{errmsg}'
) )
# if tractor._state.debug_mode():
# await tractor.pause()
symbol: str = 'N/A' symbol: str = 'N/A'
if chain := apiflows.get(reqid): if chain := apiflows.get(reqid):

View File

@ -21,6 +21,7 @@ Symbology defs and search.
from decimal import Decimal from decimal import Decimal
import tractor import tractor
from rapidfuzz import process as fuzzy
from piker._cacheables import ( from piker._cacheables import (
async_lifo_cache, async_lifo_cache,
@ -40,13 +41,8 @@ from piker.accounting._mktinfo import (
) )
# https://www.kraken.com/features/api#get-tradable-pairs
class Pair(Struct): class Pair(Struct):
'''
A tradable asset pair as schema-defined by,
https://docs.kraken.com/api/docs/rest-api/get-tradable-asset-pairs
'''
xname: str # idiotic bs_mktid equiv i guess? xname: str # idiotic bs_mktid equiv i guess?
altname: str # alternate pair name altname: str # alternate pair name
wsname: str # WebSocket pair name (if available) wsname: str # WebSocket pair name (if available)
@ -57,6 +53,7 @@ class Pair(Struct):
lot: str # volume lot size lot: str # volume lot size
cost_decimals: int cost_decimals: int
costmin: float
pair_decimals: int # scaling decimal places for pair pair_decimals: int # scaling decimal places for pair
lot_decimals: int # scaling decimal places for volume lot_decimals: int # scaling decimal places for volume
@ -82,7 +79,6 @@ class Pair(Struct):
tick_size: float # min price step size tick_size: float # min price step size
status: str status: str
costmin: str|None = None # XXX, only some mktpairs?
short_position_limit: float = 0 short_position_limit: float = 0
long_position_limit: float = float('inf') long_position_limit: float = float('inf')

View File

@ -37,12 +37,6 @@ import tractor
from async_generator import asynccontextmanager from async_generator import asynccontextmanager
import numpy as np import numpy as np
import wrapt import wrapt
# TODO, port to `httpx`/`trio-websocket` whenver i get back to
# writing a proper ws-api streamer for this backend (since the data
# feeds are free now) as per GH feat-req:
# https://github.com/pikers/piker/issues/509
#
import asks import asks
from ..calc import humanize, percent_change from ..calc import humanize, percent_change

View File

@ -189,9 +189,28 @@ def pikerd(
# AsyncExitStack() as stack, # AsyncExitStack() as stack,
): ):
assert service_mngr # TODO: spawn all other sub-actor daemons according to
# ?TODO? spawn all other sub-actor daemons according to
# multiaddress endpoint spec defined by user config # multiaddress endpoint spec defined by user config
assert service_mngr
# if tsdb:
# dname, conf = await stack.enter_async_context(
# service.marketstore.start_ahab_daemon(
# service_mngr,
# loglevel=loglevel,
# )
# )
# log.info(f'TSDB `{dname}` up with conf:\n{conf}')
# if es:
# dname, conf = await stack.enter_async_context(
# service.elastic.start_ahab_daemon(
# service_mngr,
# loglevel=loglevel,
# )
# )
# log.info(f'DB `{dname}` up with conf:\n{conf}')
await trio.sleep_forever() await trio.sleep_forever()
trio.run(main) trio.run(main)

View File

@ -41,13 +41,10 @@ from .log import get_logger
log = get_logger('broker-config') log = get_logger('broker-config')
# XXX NOTE: taken from `click` # XXX NOTE: taken from ``click`` since apparently they have some
# |_https://github.com/pallets/click/blob/main/src/click/utils.py#L449 # super weirdness with sigint and sudo..no clue
# # we're probably going to slowly just modify it to our own version over
# (since apparently they have some super weirdness with SIGINT and # time..
# sudo.. no clue we're probably going to slowly just modify it to our
# own version over time..)
#
def get_app_dir( def get_app_dir(
app_name: str, app_name: str,
roaming: bool = True, roaming: bool = True,
@ -264,7 +261,7 @@ def load(
MutableMapping, MutableMapping,
] = tomllib.loads, ] = tomllib.loads,
touch_if_dne: bool = True, touch_if_dne: bool = False,
**tomlkws, **tomlkws,
@ -273,7 +270,7 @@ def load(
Load config file by name. Load config file by name.
If desired config is not in the top level piker-user config path then If desired config is not in the top level piker-user config path then
pass the `path: Path` explicitly. pass the ``path: Path`` explicitly.
''' '''
# create the $HOME/.config/piker dir if dne # create the $HOME/.config/piker dir if dne
@ -288,8 +285,7 @@ def load(
if ( if (
not path.is_file() not path.is_file()
and and touch_if_dne
touch_if_dne
): ):
# only do a template if no path provided, # only do a template if no path provided,
# just touch an empty file with same name. # just touch an empty file with same name.

View File

@ -91,18 +91,6 @@ class SymbologyCache(Struct):
# provided by the backend pkg. # provided by the backend pkg.
mktmaps: dict[str, MktPair] = field(default_factory=dict) mktmaps: dict[str, MktPair] = field(default_factory=dict)
def pformat(self) -> str:
return (
f'<{type(self).__name__}(\n'
f' .mod: {self.mod!r}\n'
f' .assets: {len(self.assets)!r}\n'
f' .pairs: {len(self.pairs)!r}\n'
f' .mktmaps: {len(self.mktmaps)!r}\n'
f')>'
)
__repr__ = pformat
def write_config(self) -> None: def write_config(self) -> None:
# put the backend's pair-struct type ref at the top # put the backend's pair-struct type ref at the top

View File

@ -352,9 +352,7 @@ async def allocate_persistent_feed(
# yield back control to starting nursery once we receive either # yield back control to starting nursery once we receive either
# some history or a real-time quote. # some history or a real-time quote.
log.info( log.info(f'loading OHLCV history: {fqme}')
f'loading OHLCV history: {fqme!r}\n'
)
await some_data_ready.wait() await some_data_ready.wait()
flume = Flume( flume = Flume(

View File

@ -269,8 +269,6 @@ def hcolor(name: str) -> str:
# default ohlc-bars/curve gray # default ohlc-bars/curve gray
'bracket': '#666666', # like the logo 'bracket': '#666666', # like the logo
'pikers': '#616161', # a trader shade of..
'beast': '#161616', # in the dark alone.
# bluish # bluish
'charcoal': '#36454F', 'charcoal': '#36454F',

View File

@ -1,22 +1,4 @@
# 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/>.
'''
A per-display, DPI (scaling) info dumper.
Resource list for mucking with DPIs on multiple screens: Resource list for mucking with DPIs on multiple screens:
- https://stackoverflow.com/questions/42141354/convert-pixel-size-to-point-size-for-fonts-on-multiple-platforms - https://stackoverflow.com/questions/42141354/convert-pixel-size-to-point-size-for-fonts-on-multiple-platforms
@ -30,86 +12,89 @@ Resource list for mucking with DPIs on multiple screens:
- https://stackoverflow.com/questions/16561879/what-is-the-difference-between-logicaldpix-and-physicaldpix-in-qt - https://stackoverflow.com/questions/16561879/what-is-the-difference-between-logicaldpix-and-physicaldpix-in-qt
- https://doc.qt.io/qt-5/qguiapplication.html#screenAt - https://doc.qt.io/qt-5/qguiapplication.html#screenAt
''' """
from pyqtgraph import QtGui from pyqtgraph import QtGui
from PyQt6 import ( from PyQt5.QtCore import (
QtCore, Qt, QCoreApplication
QtWidgets,
)
from PyQt6.QtCore import (
Qt,
QCoreApplication,
QSize,
QRect,
) )
# Proper high DPI scaling is available in Qt >= 5.6.0. This attibute # Proper high DPI scaling is available in Qt >= 5.6.0. This attibute
# must be set before creating the application # must be set before creating the application
if hasattr(Qt, 'AA_EnableHighDpiScaling'): if hasattr(Qt, 'AA_EnableHighDpiScaling'):
QCoreApplication.setAttribute( QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
Qt.AA_EnableHighDpiScaling,
True,
)
if hasattr(Qt, 'AA_UseHighDpiPixmaps'): if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
QCoreApplication.setAttribute( QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
Qt.AA_UseHighDpiPixmaps,
True,
)
app = QtWidgets.QApplication([])
window = QtWidgets.QMainWindow() app = QtGui.QApplication([])
main_widget = QtWidgets.QWidget() window = QtGui.QMainWindow()
main_widget = QtGui.QWidget()
window.setCentralWidget(main_widget) window.setCentralWidget(main_widget)
window.show() window.show()
pxr: float = main_widget.devicePixelRatioF() pxr = main_widget.devicePixelRatioF()
# explicitly get main widget and primary displays # screen_num = app.desktop().screenNumber()
current_screen: QtGui.QScreen = app.screenAt( # screen = app.screens()[screen_num]
main_widget.geometry().center()
)
primary_screen: QtGui.QScreen = app.primaryScreen()
screen: QtGui.QScreen screen = app.screenAt(main_widget.geometry().center())
for screen in app.screens():
name: str = screen.name() name = screen.name()
model: str = screen.model().rstrip() size = screen.size()
size: QSize = screen.size() geo = screen.availableGeometry()
geo: QRect = screen.availableGeometry() phydpi = screen.physicalDotsPerInch()
phydpi: float = screen.physicalDotsPerInch() logdpi = screen.logicalDotsPerInch()
logdpi: float = screen.logicalDotsPerInch()
is_primary: bool = screen is primary_screen
is_current: bool = screen is current_screen
print( print(
f'------ screen name: {name} ------\n' # f'screen number: {screen_num}\n',
f'|_primary: {is_primary}\n' f'screen name: {name}\n'
f' _current: {is_current}\n' f'screen size: {size}\n'
f' _model: {model}\n' f'screen geometry: {geo}\n\n'
f' _screen size: {size}\n' f'devicePixelRationF(): {pxr}\n'
f' _screen geometry: {geo}\n' f'physical dpi: {phydpi}\n'
f' _devicePixelRationF(): {pxr}\n' f'logical dpi: {logdpi}\n'
f' _physical dpi: {phydpi}\n'
f' _logical dpi: {logdpi}\n'
) )
# app-wide font info print('-'*50)
screen = app.primaryScreen()
name = screen.name()
size = screen.size()
geo = screen.availableGeometry()
phydpi = screen.physicalDotsPerInch()
logdpi = screen.logicalDotsPerInch()
print(
# f'screen number: {screen_num}\n',
f'screen name: {name}\n'
f'screen size: {size}\n'
f'screen geometry: {geo}\n\n'
f'devicePixelRationF(): {pxr}\n'
f'physical dpi: {phydpi}\n'
f'logical dpi: {logdpi}\n'
)
# app-wide font
font = QtGui.QFont("Hack") font = QtGui.QFont("Hack")
# use pixel size to be cross-resolution compatible? # use pixel size to be cross-resolution compatible?
font.setPixelSize(6) font.setPixelSize(6)
fm = QtGui.QFontMetrics(font)
fontdpi: float = fm.fontDpi()
font_h: int = fm.height()
string: str = '10000' fm = QtGui.QFontMetrics(font)
str_br: QtCore.QRect = fm.boundingRect(string) fontdpi = fm.fontDpi()
str_w: int = str_br.width() font_h = fm.height()
string = '10000'
str_br = fm.boundingRect(string)
str_w = str_br.width()
print( print(
f'------ global font settings ------\n' # f'screen number: {screen_num}\n',
f'font dpi: {fontdpi}\n' f'font dpi: {fontdpi}\n'
f'font height: {font_h}\n' f'font height: {font_h}\n'
f'string bounding rect: {str_br}\n' f'string bounding rect: {str_br}\n'

1
tags
View File

@ -1 +0,0 @@
TAG_feed_status_update ./piker/data/feed.py /TAG_feed_status_update/