Compare commits
12 Commits
fbb0fc6517
...
f417e8c170
| Author | SHA1 | Date |
|---|---|---|
|
|
f417e8c170 | |
|
|
f40ada7a86 | |
|
|
ed9c211b96 | |
|
|
f1b27e9696 | |
|
|
959d04024b | |
|
|
6f8a361e80 | |
|
|
2d678e1582 | |
|
|
48493e50b0 | |
|
|
f73b981173 | |
|
|
d5edd3484f | |
|
|
bac8317a4a | |
|
|
eb78437994 |
|
|
@ -275,9 +275,15 @@ async def open_history_client(
|
||||||
f'{times}'
|
f'{times}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# XXX, debug any case where the latest 1m bar we get is
|
||||||
|
# already another "sample's-step-old"..
|
||||||
if end_dt is None:
|
if end_dt is None:
|
||||||
inow: int = round(time.time())
|
inow: int = round(time.time())
|
||||||
if (inow - times[-1]) > 60:
|
if (
|
||||||
|
_time_step := (inow - times[-1])
|
||||||
|
>
|
||||||
|
timeframe * 2
|
||||||
|
):
|
||||||
await tractor.pause()
|
await tractor.pause()
|
||||||
|
|
||||||
start_dt = from_timestamp(times[0])
|
start_dt = from_timestamp(times[0])
|
||||||
|
|
|
||||||
|
|
@ -1115,6 +1115,7 @@ async def stream_quotes(
|
||||||
|
|
||||||
con: Contract = details.contract
|
con: Contract = details.contract
|
||||||
first_ticker: Ticker|None = None
|
first_ticker: Ticker|None = None
|
||||||
|
first_quote: dict[str, Any] = {}
|
||||||
|
|
||||||
timeout: float = 1.6
|
timeout: float = 1.6
|
||||||
with trio.move_on_after(timeout) as quote_cs:
|
with trio.move_on_after(timeout) as quote_cs:
|
||||||
|
|
@ -1167,15 +1168,14 @@ async def stream_quotes(
|
||||||
first_quote,
|
first_quote,
|
||||||
))
|
))
|
||||||
|
|
||||||
# it's not really live but this will unblock
|
|
||||||
# the brokerd feed task to tell the ui to update?
|
|
||||||
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
|
# XXX obvi given the venue is closed, we never expect feed
|
||||||
# to come up; a taskc should be the only way to
|
# to come up; a taskc should be the only way to
|
||||||
# terminate this task.
|
# terminate this task.
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
#
|
||||||
|
# ^^XXX^^TODO! INSTEAD impl a `trio.sleep()` for the
|
||||||
|
# duration until the venue opens!!
|
||||||
|
|
||||||
# ?TODO, we could instead spawn a task that waits on a feed
|
# ?TODO, we could 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
|
||||||
|
|
@ -1199,6 +1199,9 @@ async def stream_quotes(
|
||||||
'Rxed init quote:\n'
|
'Rxed init quote:\n'
|
||||||
f'{pformat(first_quote)}'
|
f'{pformat(first_quote)}'
|
||||||
)
|
)
|
||||||
|
# signal `.data.feed` layer that mkt quotes are LIVE
|
||||||
|
feed_is_live.set()
|
||||||
|
|
||||||
cs: trio.CancelScope|None = None
|
cs: trio.CancelScope|None = None
|
||||||
startup: bool = True
|
startup: bool = True
|
||||||
iter_quotes: trio.abc.Channel
|
iter_quotes: trio.abc.Channel
|
||||||
|
|
@ -1252,7 +1255,6 @@ async def stream_quotes(
|
||||||
# tick.
|
# tick.
|
||||||
ticker = await iter_quotes.receive()
|
ticker = await iter_quotes.receive()
|
||||||
quote = normalize(ticker)
|
quote = normalize(ticker)
|
||||||
feed_is_live.set()
|
|
||||||
fqme: str = quote['fqme']
|
fqme: str = quote['fqme']
|
||||||
await send_chan.send({fqme: quote})
|
await send_chan.send({fqme: quote})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,20 +80,20 @@ class Sampler:
|
||||||
This non-instantiated type is meant to be a singleton within
|
This non-instantiated type is meant to be a singleton within
|
||||||
a `samplerd` actor-service spawned once by the user wishing to
|
a `samplerd` actor-service spawned once by the user wishing to
|
||||||
time-step-sample (real-time) quote feeds, see
|
time-step-sample (real-time) quote feeds, see
|
||||||
``.service.maybe_open_samplerd()`` and the below
|
`.service.maybe_open_samplerd()` and the below
|
||||||
``register_with_sampler()``.
|
`register_with_sampler()`.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
service_nursery: None | trio.Nursery = None
|
service_nursery: None|trio.Nursery = None
|
||||||
|
|
||||||
# TODO: we could stick these in a composed type to avoid
|
# TODO: we could stick these in a composed type to avoid angering
|
||||||
# angering the "i hate module scoped variables crowd" (yawn).
|
# the "i hate module scoped variables crowd" (yawn).
|
||||||
ohlcv_shms: dict[float, list[ShmArray]] = {}
|
ohlcv_shms: dict[float, list[ShmArray]] = {}
|
||||||
|
|
||||||
# holds one-task-per-sample-period tasks which are spawned as-needed by
|
# holds one-task-per-sample-period tasks which are spawned as-needed by
|
||||||
# data feed requests with a given detected time step usually from
|
# data feed requests with a given detected time step usually from
|
||||||
# history loading.
|
# history loading.
|
||||||
incr_task_cs: trio.CancelScope | None = None
|
incr_task_cs: trio.CancelScope|None = None
|
||||||
|
|
||||||
bcast_errors: tuple[Exception] = (
|
bcast_errors: tuple[Exception] = (
|
||||||
trio.BrokenResourceError,
|
trio.BrokenResourceError,
|
||||||
|
|
@ -248,8 +248,8 @@ class Sampler:
|
||||||
async def broadcast(
|
async def broadcast(
|
||||||
self,
|
self,
|
||||||
period_s: float,
|
period_s: float,
|
||||||
time_stamp: float | None = None,
|
time_stamp: float|None = None,
|
||||||
info: dict | None = None,
|
info: dict|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -313,7 +313,7 @@ class Sampler:
|
||||||
@classmethod
|
@classmethod
|
||||||
async def broadcast_all(
|
async def broadcast_all(
|
||||||
self,
|
self,
|
||||||
info: dict | None = None,
|
info: dict|None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# NOTE: take a copy of subs since removals can happen
|
# NOTE: take a copy of subs since removals can happen
|
||||||
|
|
@ -330,12 +330,12 @@ class Sampler:
|
||||||
async def register_with_sampler(
|
async def register_with_sampler(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
period_s: float,
|
period_s: float,
|
||||||
shms_by_period: dict[float, dict] | None = None,
|
shms_by_period: dict[float, dict]|None = None,
|
||||||
|
|
||||||
open_index_stream: bool = True, # open a 2way stream for sample step msgs?
|
open_index_stream: bool = True, # open a 2way stream for sample step msgs?
|
||||||
sub_for_broadcasts: bool = True, # sampler side to send step updates?
|
sub_for_broadcasts: bool = True, # sampler side to send step updates?
|
||||||
|
|
||||||
) -> None:
|
) -> set[int]:
|
||||||
|
|
||||||
get_console_log(tractor.current_actor().loglevel)
|
get_console_log(tractor.current_actor().loglevel)
|
||||||
incr_was_started: bool = False
|
incr_was_started: bool = False
|
||||||
|
|
@ -362,7 +362,12 @@ async def register_with_sampler(
|
||||||
|
|
||||||
# insert the base 1s period (for OHLC style sampling) into
|
# insert the base 1s period (for OHLC style sampling) into
|
||||||
# the increment buffer set to update and shift every second.
|
# the increment buffer set to update and shift every second.
|
||||||
if shms_by_period is not None:
|
if (
|
||||||
|
shms_by_period is not None
|
||||||
|
# and
|
||||||
|
# feed_is_live.is_set()
|
||||||
|
# ^TODO? pass it in instead?
|
||||||
|
):
|
||||||
from ._sharedmem import (
|
from ._sharedmem import (
|
||||||
attach_shm_array,
|
attach_shm_array,
|
||||||
_Token,
|
_Token,
|
||||||
|
|
@ -376,12 +381,17 @@ async def register_with_sampler(
|
||||||
readonly=False,
|
readonly=False,
|
||||||
)
|
)
|
||||||
shms_by_period[period] = shm
|
shms_by_period[period] = shm
|
||||||
Sampler.ohlcv_shms.setdefault(period, []).append(shm)
|
Sampler.ohlcv_shms.setdefault(
|
||||||
|
period,
|
||||||
|
[],
|
||||||
|
).append(shm)
|
||||||
|
|
||||||
assert Sampler.ohlcv_shms
|
assert Sampler.ohlcv_shms
|
||||||
|
|
||||||
# unblock caller
|
# unblock caller
|
||||||
await ctx.started(set(Sampler.ohlcv_shms.keys()))
|
await ctx.started(
|
||||||
|
set(Sampler.ohlcv_shms.keys())
|
||||||
|
)
|
||||||
|
|
||||||
if open_index_stream:
|
if open_index_stream:
|
||||||
try:
|
try:
|
||||||
|
|
@ -427,7 +437,7 @@ async def register_with_sampler(
|
||||||
|
|
||||||
async def spawn_samplerd(
|
async def spawn_samplerd(
|
||||||
|
|
||||||
loglevel: str | None = None,
|
loglevel: str|None = None,
|
||||||
**extra_tractor_kwargs
|
**extra_tractor_kwargs
|
||||||
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
@ -473,7 +483,7 @@ async def spawn_samplerd(
|
||||||
@acm
|
@acm
|
||||||
async def maybe_open_samplerd(
|
async def maybe_open_samplerd(
|
||||||
|
|
||||||
loglevel: str | None = None,
|
loglevel: str|None = None,
|
||||||
**pikerd_kwargs,
|
**pikerd_kwargs,
|
||||||
|
|
||||||
) -> tractor.Portal: # noqa
|
) -> tractor.Portal: # noqa
|
||||||
|
|
@ -498,11 +508,11 @@ async def maybe_open_samplerd(
|
||||||
@acm
|
@acm
|
||||||
async def open_sample_stream(
|
async def open_sample_stream(
|
||||||
period_s: float,
|
period_s: float,
|
||||||
shms_by_period: dict[float, dict] | None = None,
|
shms_by_period: dict[float, dict]|None = None,
|
||||||
open_index_stream: bool = True,
|
open_index_stream: bool = True,
|
||||||
sub_for_broadcasts: bool = True,
|
sub_for_broadcasts: bool = True,
|
||||||
|
|
||||||
cache_key: str | None = None,
|
cache_key: str|None = None,
|
||||||
allow_new_sampler: bool = True,
|
allow_new_sampler: bool = True,
|
||||||
|
|
||||||
ensure_is_active: bool = False,
|
ensure_is_active: bool = False,
|
||||||
|
|
@ -533,6 +543,8 @@ async def open_sample_stream(
|
||||||
# yield bistream
|
# yield bistream
|
||||||
# else:
|
# else:
|
||||||
|
|
||||||
|
ctx: tractor.Context
|
||||||
|
shm_periods: set[int] # in `int`-seconds
|
||||||
async with (
|
async with (
|
||||||
# XXX: this should be singleton on a host,
|
# XXX: this should be singleton on a host,
|
||||||
# a lone broker-daemon per provider should be
|
# a lone broker-daemon per provider should be
|
||||||
|
|
@ -547,10 +559,10 @@ async def open_sample_stream(
|
||||||
'open_index_stream': open_index_stream,
|
'open_index_stream': open_index_stream,
|
||||||
'sub_for_broadcasts': sub_for_broadcasts,
|
'sub_for_broadcasts': sub_for_broadcasts,
|
||||||
},
|
},
|
||||||
) as (ctx, first)
|
) as (ctx, shm_periods)
|
||||||
):
|
):
|
||||||
if ensure_is_active:
|
if ensure_is_active:
|
||||||
assert len(first) > 1
|
assert len(shm_periods) > 1
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
ctx.open_stream(
|
ctx.open_stream(
|
||||||
|
|
|
||||||
|
|
@ -447,7 +447,13 @@ def ldshm(
|
||||||
)
|
)
|
||||||
# last chance manual overwrites in REPL
|
# last chance manual overwrites in REPL
|
||||||
# await tractor.pause()
|
# await tractor.pause()
|
||||||
assert aids
|
if not aids:
|
||||||
|
log.warning(
|
||||||
|
f'No gaps were found !?\n'
|
||||||
|
f'fqme: {fqme!r}\n'
|
||||||
|
f'timeframe: {period_s!r}\n'
|
||||||
|
f"WELL THAT'S GOOD NOOZ!\n"
|
||||||
|
)
|
||||||
tf2aids[period_s] = aids
|
tf2aids[period_s] = aids
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from pendulum import (
|
||||||
Duration,
|
Duration,
|
||||||
duration as mk_duration,
|
duration as mk_duration,
|
||||||
from_timestamp,
|
from_timestamp,
|
||||||
|
timezone,
|
||||||
)
|
)
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
@ -57,9 +58,7 @@ from piker.brokers import NoData
|
||||||
from piker.accounting import (
|
from piker.accounting import (
|
||||||
MktPair,
|
MktPair,
|
||||||
)
|
)
|
||||||
from piker.data._util import (
|
from piker.log import get_logger
|
||||||
log,
|
|
||||||
)
|
|
||||||
from ..data._sharedmem import (
|
from ..data._sharedmem import (
|
||||||
maybe_open_shm_array,
|
maybe_open_shm_array,
|
||||||
ShmArray,
|
ShmArray,
|
||||||
|
|
@ -97,6 +96,9 @@ if TYPE_CHECKING:
|
||||||
# from .feed import _FeedsBus
|
# from .feed import _FeedsBus
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger()
|
||||||
|
|
||||||
|
|
||||||
# `ShmArray` buffer sizing configuration:
|
# `ShmArray` buffer sizing configuration:
|
||||||
_mins_in_day = int(60 * 24)
|
_mins_in_day = int(60 * 24)
|
||||||
# how much is probably dependent on lifestyle
|
# how much is probably dependent on lifestyle
|
||||||
|
|
@ -247,6 +249,11 @@ async def maybe_fill_null_segments(
|
||||||
from_timestamp(array['time'][0])
|
from_timestamp(array['time'][0])
|
||||||
) < backfill_until_dt
|
) < backfill_until_dt
|
||||||
):
|
):
|
||||||
|
log.error(
|
||||||
|
f'Invalid frame_start !?\n'
|
||||||
|
f'frame_start_dt: {frame_start_dt!r}\n'
|
||||||
|
f'backfill_until_dt: {backfill_until_dt!r}\n'
|
||||||
|
)
|
||||||
await tractor.pause()
|
await tractor.pause()
|
||||||
|
|
||||||
# XXX TODO: pretty sure if i plot tsla, btcusdt.binance
|
# XXX TODO: pretty sure if i plot tsla, btcusdt.binance
|
||||||
|
|
@ -396,7 +403,9 @@ async def start_backfill(
|
||||||
|
|
||||||
# based on the sample step size, maybe load a certain amount history
|
# based on the sample step size, maybe load a certain amount history
|
||||||
update_start_on_prepend: bool = False
|
update_start_on_prepend: bool = False
|
||||||
if backfill_until_dt is None:
|
if (
|
||||||
|
_until_was_none := (backfill_until_dt is None)
|
||||||
|
):
|
||||||
|
|
||||||
# TODO: per-provider default history-durations?
|
# TODO: per-provider default history-durations?
|
||||||
# -[ ] inside the `open_history_client()` config allow
|
# -[ ] inside the `open_history_client()` config allow
|
||||||
|
|
@ -430,6 +439,8 @@ async def start_backfill(
|
||||||
last_start_dt: datetime = backfill_from_dt
|
last_start_dt: datetime = backfill_from_dt
|
||||||
next_prepend_index: int = backfill_from_shm_index
|
next_prepend_index: int = backfill_from_shm_index
|
||||||
|
|
||||||
|
est = timezone('EST')
|
||||||
|
|
||||||
while last_start_dt > backfill_until_dt:
|
while last_start_dt > backfill_until_dt:
|
||||||
log.info(
|
log.info(
|
||||||
f'Requesting {timeframe}s frame:\n'
|
f'Requesting {timeframe}s frame:\n'
|
||||||
|
|
@ -443,9 +454,10 @@ async def start_backfill(
|
||||||
next_end_dt,
|
next_end_dt,
|
||||||
) = await get_hist(
|
) = await get_hist(
|
||||||
timeframe,
|
timeframe,
|
||||||
end_dt=last_start_dt,
|
end_dt=(end_dt_param := last_start_dt),
|
||||||
)
|
)
|
||||||
except NoData as _daterr:
|
except NoData as nodata:
|
||||||
|
_nodata = nodata
|
||||||
orig_last_start_dt: datetime = last_start_dt
|
orig_last_start_dt: datetime = last_start_dt
|
||||||
gap_report: str = (
|
gap_report: str = (
|
||||||
f'EMPTY FRAME for `end_dt: {last_start_dt}`?\n'
|
f'EMPTY FRAME for `end_dt: {last_start_dt}`?\n'
|
||||||
|
|
@ -513,8 +525,32 @@ async def start_backfill(
|
||||||
==
|
==
|
||||||
next_start_dt.timestamp()
|
next_start_dt.timestamp()
|
||||||
)
|
)
|
||||||
|
assert (
|
||||||
|
(last_time := time[-1])
|
||||||
|
==
|
||||||
|
next_end_dt.timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
assert time[-1] == next_end_dt.timestamp()
|
frame_last_dt = from_timestamp(last_time)
|
||||||
|
if (
|
||||||
|
frame_last_dt.add(seconds=timeframe)
|
||||||
|
<
|
||||||
|
end_dt_param
|
||||||
|
):
|
||||||
|
est_frame_last_dt = est.convert(frame_last_dt)
|
||||||
|
est_end_dt_param = est.convert(end_dt_param)
|
||||||
|
log.warning(
|
||||||
|
f'Provider frame ending BEFORE requested end_dt={end_dt_param} ??\n'
|
||||||
|
f'frame_last_dt (EST): {est_frame_last_dt!r}\n'
|
||||||
|
f'end_dt_param (EST): {est_end_dt_param!r}\n'
|
||||||
|
f'\n'
|
||||||
|
f'Likely contains,\n'
|
||||||
|
f'- a venue closure.\n'
|
||||||
|
f'- (maybe?) missing data ?\n'
|
||||||
|
)
|
||||||
|
# ?TODO, check against venue closure hours
|
||||||
|
# if/when provided by backend?
|
||||||
|
await tractor.pause()
|
||||||
|
|
||||||
expected_dur: Interval = (
|
expected_dur: Interval = (
|
||||||
last_start_dt.subtract(
|
last_start_dt.subtract(
|
||||||
|
|
@ -576,10 +612,11 @@ async def start_backfill(
|
||||||
'0 BARS TO PUSH after diff!?\n'
|
'0 BARS TO PUSH after diff!?\n'
|
||||||
f'{next_start_dt} -> {last_start_dt}'
|
f'{next_start_dt} -> {last_start_dt}'
|
||||||
)
|
)
|
||||||
|
await tractor.pause()
|
||||||
|
|
||||||
# Check if we're about to exceed buffer capacity BEFORE
|
# Check if we're about to exceed buffer capacity BEFORE
|
||||||
# attempting the push
|
# attempting the push
|
||||||
if next_prepend_index - ln < 0:
|
if (next_prepend_index - ln) < 0:
|
||||||
log.warning(
|
log.warning(
|
||||||
f'Backfill would exceed buffer capacity!\n'
|
f'Backfill would exceed buffer capacity!\n'
|
||||||
f'next_prepend_index: {next_prepend_index}\n'
|
f'next_prepend_index: {next_prepend_index}\n'
|
||||||
|
|
@ -650,7 +687,7 @@ async def start_backfill(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
# can't push the entire frame? so
|
# XXX, can't push the entire frame? so
|
||||||
# push only the amount that can fit..
|
# push only the amount that can fit..
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -710,8 +747,8 @@ async def start_backfill(
|
||||||
) = dedupe(df)
|
) = dedupe(df)
|
||||||
if diff:
|
if diff:
|
||||||
log.warning(
|
log.warning(
|
||||||
f'Found {diff} duplicates in tsdb, '
|
f'Found {diff!r} duplicates in tsdb! '
|
||||||
f'overwriting with deduped data\n'
|
f'=> Overwriting with `deduped` data !! <=\n'
|
||||||
)
|
)
|
||||||
await storage.write_ohlcv(
|
await storage.write_ohlcv(
|
||||||
col_sym_key,
|
col_sym_key,
|
||||||
|
|
@ -1284,6 +1321,7 @@ async def manage_history(
|
||||||
some_data_ready: trio.Event,
|
some_data_ready: trio.Event,
|
||||||
feed_is_live: trio.Event,
|
feed_is_live: trio.Event,
|
||||||
timeframe: float = 60, # in seconds
|
timeframe: float = 60, # in seconds
|
||||||
|
wait_for_live_timeout: float = 0.5,
|
||||||
|
|
||||||
task_status: TaskStatus[
|
task_status: TaskStatus[
|
||||||
tuple[ShmArray, ShmArray]
|
tuple[ShmArray, ShmArray]
|
||||||
|
|
@ -1432,12 +1470,26 @@ async def manage_history(
|
||||||
1: rt_shm,
|
1: rt_shm,
|
||||||
60: hist_shm,
|
60: hist_shm,
|
||||||
}
|
}
|
||||||
async with open_sample_stream(
|
|
||||||
period_s=1.,
|
shms_by_period: dict|None = None
|
||||||
shms_by_period={
|
with trio.move_on_after(wait_for_live_timeout) as cs:
|
||||||
|
await feed_is_live.wait()
|
||||||
|
|
||||||
|
if cs.cancelled_caught:
|
||||||
|
log.warning(
|
||||||
|
f'No live feed within {wait_for_live_timeout!r}s\n'
|
||||||
|
f'fqme: {mkt.fqme!r}\n'
|
||||||
|
f'NOT activating shm-buffer-sampler!!\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
if feed_is_live.is_set():
|
||||||
|
shms_by_period: dict[int, dict] = {
|
||||||
1.: rt_shm.token,
|
1.: rt_shm.token,
|
||||||
60.: hist_shm.token,
|
60.: hist_shm.token,
|
||||||
},
|
}
|
||||||
|
async with open_sample_stream(
|
||||||
|
period_s=1.,
|
||||||
|
shms_by_period=shms_by_period,
|
||||||
|
|
||||||
# NOTE: we want to only open a stream for doing
|
# NOTE: we want to only open a stream for doing
|
||||||
# broadcasts on backfill operations, not receive the
|
# broadcasts on backfill operations, not receive the
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,15 @@ import trio
|
||||||
from piker.ui.qt import (
|
from piker.ui.qt import (
|
||||||
QEvent,
|
QEvent,
|
||||||
)
|
)
|
||||||
from ..service import maybe_spawn_brokerd
|
from . import _chart
|
||||||
from . import _event
|
from . import _event
|
||||||
from ._exec import run_qtractor
|
|
||||||
from ..data.feed import install_brokerd_search
|
|
||||||
from ..data._symcache import open_symcache
|
|
||||||
from ..accounting import unpack_fqme
|
|
||||||
from . import _search
|
from . import _search
|
||||||
from ._chart import GodWidget
|
from ..accounting import unpack_fqme
|
||||||
|
from ..data._symcache import open_symcache
|
||||||
|
from ..data.feed import install_brokerd_search
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
|
from ..service import maybe_spawn_brokerd
|
||||||
|
from ._exec import run_qtractor
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -73,8 +73,8 @@ async def load_provider_search(
|
||||||
|
|
||||||
async def _async_main(
|
async def _async_main(
|
||||||
|
|
||||||
# implicit required argument provided by ``qtractor_run()``
|
# implicit required argument provided by `qtractor_run()`
|
||||||
main_widget: GodWidget,
|
main_widget: _chart.GodWidget,
|
||||||
|
|
||||||
syms: list[str],
|
syms: list[str],
|
||||||
brokers: dict[str, ModuleType],
|
brokers: dict[str, ModuleType],
|
||||||
|
|
@ -87,6 +87,9 @@ async def _async_main(
|
||||||
Provision the "main" widget with initial symbol data and root nursery.
|
Provision the "main" widget with initial symbol data and root nursery.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
# set as singleton
|
||||||
|
_chart._godw = main_widget
|
||||||
|
|
||||||
from . import _display
|
from . import _display
|
||||||
from ._pg_overrides import _do_overrides
|
from ._pg_overrides import _do_overrides
|
||||||
_do_overrides()
|
_do_overrides()
|
||||||
|
|
@ -201,6 +204,6 @@ def _main(
|
||||||
brokermods,
|
brokermods,
|
||||||
piker_loglevel,
|
piker_loglevel,
|
||||||
),
|
),
|
||||||
main_widget_type=GodWidget,
|
main_widget_type=_chart.GodWidget,
|
||||||
tractor_kwargs=tractor_kwargs,
|
tractor_kwargs=tractor_kwargs,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,25 @@ if TYPE_CHECKING:
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_godw: GodWidget|None = None
|
||||||
|
|
||||||
|
def get_godw() -> GodWidget:
|
||||||
|
'''
|
||||||
|
Get the top level "god widget", the root/central-most Qt
|
||||||
|
widget-object set as `QMainWindow.setCentralWidget(_godw)`.
|
||||||
|
|
||||||
|
See `piker.ui._exec` for the runtime init details and all the
|
||||||
|
machinery for running `trio` on the Qt event loop in guest mode.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if _godw is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
'No god-widget initialized ??\n'
|
||||||
|
'Have you called `run_qtractor()` yet?\n'
|
||||||
|
)
|
||||||
|
return _godw
|
||||||
|
|
||||||
|
|
||||||
class GodWidget(QWidget):
|
class GodWidget(QWidget):
|
||||||
'''
|
'''
|
||||||
"Our lord and savior, the holy child of window-shua, there is no
|
"Our lord and savior, the holy child of window-shua, there is no
|
||||||
|
|
@ -104,7 +123,7 @@ class GodWidget(QWidget):
|
||||||
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
self.search: SearchWidget | None = None
|
self.search: SearchWidget|None = None
|
||||||
|
|
||||||
self.hbox = QHBoxLayout(self)
|
self.hbox = QHBoxLayout(self)
|
||||||
self.hbox.setContentsMargins(0, 0, 0, 0)
|
self.hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
@ -123,9 +142,9 @@ class GodWidget(QWidget):
|
||||||
tuple[LinkedSplits, LinkedSplits],
|
tuple[LinkedSplits, LinkedSplits],
|
||||||
] = {}
|
] = {}
|
||||||
|
|
||||||
self.hist_linked: LinkedSplits | None = None
|
self.hist_linked: LinkedSplits|None = None
|
||||||
self.rt_linked: LinkedSplits | None = None
|
self.rt_linked: LinkedSplits|None = None
|
||||||
self._active_cursor: Cursor | None = None
|
self._active_cursor: Cursor|None = None
|
||||||
|
|
||||||
# assigned in the startup func `_async_main()`
|
# assigned in the startup func `_async_main()`
|
||||||
self._root_n: trio.Nursery = None
|
self._root_n: trio.Nursery = None
|
||||||
|
|
@ -369,9 +388,9 @@ class ChartnPane(QFrame):
|
||||||
https://doc.qt.io/qt-5/qwidget.html#composite-widgets
|
https://doc.qt.io/qt-5/qwidget.html#composite-widgets
|
||||||
|
|
||||||
'''
|
'''
|
||||||
sidepane: FieldsForm | SearchWidget
|
sidepane: FieldsForm|SearchWidget
|
||||||
hbox: QHBoxLayout
|
hbox: QHBoxLayout
|
||||||
chart: ChartPlotWidget | None = None
|
chart: ChartPlotWidget|None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -387,13 +406,13 @@ class ChartnPane(QFrame):
|
||||||
self.chart = None
|
self.chart = None
|
||||||
|
|
||||||
hbox = self.hbox = QHBoxLayout(self)
|
hbox = self.hbox = QHBoxLayout(self)
|
||||||
hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
hbox.setAlignment(Qt.AlignTop|Qt.AlignLeft)
|
||||||
hbox.setContentsMargins(0, 0, 0, 0)
|
hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
hbox.setSpacing(3)
|
hbox.setSpacing(3)
|
||||||
|
|
||||||
def set_sidepane(
|
def set_sidepane(
|
||||||
self,
|
self,
|
||||||
sidepane: FieldsForm | SearchWidget,
|
sidepane: FieldsForm|SearchWidget,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# add sidepane **after** chart; place it on axis side
|
# add sidepane **after** chart; place it on axis side
|
||||||
|
|
@ -404,7 +423,7 @@ class ChartnPane(QFrame):
|
||||||
self._sidepane = sidepane
|
self._sidepane = sidepane
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sidepane(self) -> FieldsForm | SearchWidget:
|
def sidepane(self) -> FieldsForm|SearchWidget:
|
||||||
return self._sidepane
|
return self._sidepane
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -450,7 +469,7 @@ class LinkedSplits(QWidget):
|
||||||
# chart-local graphics state that can be passed to
|
# chart-local graphics state that can be passed to
|
||||||
# a ``graphic_update_cycle()`` call by any task wishing to
|
# a ``graphic_update_cycle()`` call by any task wishing to
|
||||||
# update the UI for a given "chart instance".
|
# update the UI for a given "chart instance".
|
||||||
self.display_state: DisplayState | None = None
|
self.display_state: DisplayState|None = None
|
||||||
|
|
||||||
self._mkt: MktPair = None
|
self._mkt: MktPair = None
|
||||||
|
|
||||||
|
|
@ -486,7 +505,7 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
def set_split_sizes(
|
def set_split_sizes(
|
||||||
self,
|
self,
|
||||||
prop: float | None = None,
|
prop: float|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -567,8 +586,8 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
# style?
|
# style?
|
||||||
self.chart.setFrameStyle(
|
self.chart.setFrameStyle(
|
||||||
QFrame.Shape.StyledPanel |
|
QFrame.Shape.StyledPanel
|
||||||
QFrame.Shadow.Plain
|
|QFrame.Shadow.Plain
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.chart
|
return self.chart
|
||||||
|
|
@ -580,11 +599,11 @@ class LinkedSplits(QWidget):
|
||||||
shm: ShmArray,
|
shm: ShmArray,
|
||||||
flume: Flume,
|
flume: Flume,
|
||||||
|
|
||||||
array_key: str | None = None,
|
array_key: str|None = None,
|
||||||
style: str = 'line',
|
style: str = 'line',
|
||||||
_is_main: bool = False,
|
_is_main: bool = False,
|
||||||
|
|
||||||
sidepane: QWidget | None = None,
|
sidepane: QWidget|None = None,
|
||||||
draw_kwargs: dict = {},
|
draw_kwargs: dict = {},
|
||||||
|
|
||||||
**cpw_kwargs,
|
**cpw_kwargs,
|
||||||
|
|
@ -687,7 +706,7 @@ class LinkedSplits(QWidget):
|
||||||
cpw.plotItem.vb.linked = self
|
cpw.plotItem.vb.linked = self
|
||||||
cpw.setFrameStyle(
|
cpw.setFrameStyle(
|
||||||
QFrame.Shape.StyledPanel
|
QFrame.Shape.StyledPanel
|
||||||
# | QFrame.Shadow.Plain
|
# |QFrame.Shadow.Plain
|
||||||
)
|
)
|
||||||
|
|
||||||
# don't show the little "autoscale" A label.
|
# don't show the little "autoscale" A label.
|
||||||
|
|
@ -800,7 +819,7 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
def resize_sidepanes(
|
def resize_sidepanes(
|
||||||
self,
|
self,
|
||||||
from_linked: LinkedSplits | None = None,
|
from_linked: LinkedSplits|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -874,7 +893,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# TODO: load from config
|
# TODO: load from config
|
||||||
use_open_gl: bool = False,
|
use_open_gl: bool = False,
|
||||||
|
|
||||||
static_yrange: tuple[float, float] | None = None,
|
static_yrange: tuple[float, float]|None = None,
|
||||||
|
|
||||||
parent=None,
|
parent=None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
@ -889,7 +908,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
# NOTE: must be set bfore calling ``.mk_vb()``
|
# NOTE: must be set bfore calling ``.mk_vb()``
|
||||||
self.linked = linkedsplits
|
self.linked = linkedsplits
|
||||||
self.sidepane: FieldsForm | None = None
|
self.sidepane: FieldsForm|None = None
|
||||||
|
|
||||||
# source of our custom interactions
|
# source of our custom interactions
|
||||||
self.cv = self.mk_vb(name)
|
self.cv = self.mk_vb(name)
|
||||||
|
|
@ -923,7 +942,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self.useOpenGL(use_open_gl)
|
self.useOpenGL(use_open_gl)
|
||||||
self.name = name
|
self.name = name
|
||||||
self.data_key = data_key or name
|
self.data_key = data_key or name
|
||||||
self.qframe: ChartnPane | None = None
|
self.qframe: ChartnPane|None = None
|
||||||
|
|
||||||
# scene-local placeholder for book graphics
|
# scene-local placeholder for book graphics
|
||||||
# sizing to avoid overlap with data contents
|
# sizing to avoid overlap with data contents
|
||||||
|
|
@ -934,7 +953,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# registry of overlay curve names
|
# registry of overlay curve names
|
||||||
self._vizs: dict[str, Viz] = {}
|
self._vizs: dict[str, Viz] = {}
|
||||||
|
|
||||||
self.feed: Feed | None = None
|
self.feed: Feed|None = None
|
||||||
|
|
||||||
self._labels = {} # registry of underlying graphics
|
self._labels = {} # registry of underlying graphics
|
||||||
self._ysticks = {} # registry of underlying graphics
|
self._ysticks = {} # registry of underlying graphics
|
||||||
|
|
@ -1027,7 +1046,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
def increment_view(
|
def increment_view(
|
||||||
self,
|
self,
|
||||||
datums: int = 1,
|
datums: int = 1,
|
||||||
vb: ChartView | None = None,
|
vb: ChartView|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -1058,8 +1077,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
def overlay_plotitem(
|
def overlay_plotitem(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
index: int | None = None,
|
index: int|None = None,
|
||||||
axis_title: str | None = None,
|
axis_title: str|None = None,
|
||||||
axis_side: str = 'right',
|
axis_side: str = 'right',
|
||||||
axis_kwargs: dict = {},
|
axis_kwargs: dict = {},
|
||||||
|
|
||||||
|
|
@ -1147,14 +1166,14 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
shm: ShmArray,
|
shm: ShmArray,
|
||||||
flume: Flume,
|
flume: Flume,
|
||||||
|
|
||||||
array_key: str | None = None,
|
array_key: str|None = None,
|
||||||
overlay: bool = False,
|
overlay: bool = False,
|
||||||
color: str | None = None,
|
color: str|None = None,
|
||||||
add_label: bool = True,
|
add_label: bool = True,
|
||||||
pi: pg.PlotItem | None = None,
|
pi: pg.PlotItem|None = None,
|
||||||
step_mode: bool = False,
|
step_mode: bool = False,
|
||||||
is_ohlc: bool = False,
|
is_ohlc: bool = False,
|
||||||
add_sticky: None | str = 'right',
|
add_sticky: None|str = 'right',
|
||||||
|
|
||||||
**graphics_kwargs,
|
**graphics_kwargs,
|
||||||
|
|
||||||
|
|
@ -1252,7 +1271,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# use the tick size precision for display
|
# use the tick size precision for display
|
||||||
name = name or pi.name
|
name = name or pi.name
|
||||||
mkt: MktPair = self.linked.mkt
|
mkt: MktPair = self.linked.mkt
|
||||||
digits: int | None = None
|
digits: int|None = None
|
||||||
if name in mkt.fqme:
|
if name in mkt.fqme:
|
||||||
digits = mkt.price_tick_digits
|
digits = mkt.price_tick_digits
|
||||||
|
|
||||||
|
|
@ -1286,7 +1305,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
shm: ShmArray,
|
shm: ShmArray,
|
||||||
flume: Flume,
|
flume: Flume,
|
||||||
|
|
||||||
array_key: str | None = None,
|
array_key: str|None = None,
|
||||||
**draw_curve_kwargs,
|
**draw_curve_kwargs,
|
||||||
|
|
||||||
) -> Viz:
|
) -> Viz:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import pyqtgraph as pg
|
||||||
|
|
||||||
from piker.ui.qt import (
|
from piker.ui.qt import (
|
||||||
QtWidgets,
|
QtWidgets,
|
||||||
QGraphicsItem,
|
|
||||||
Qt,
|
Qt,
|
||||||
QLineF,
|
QLineF,
|
||||||
QRectF,
|
QRectF,
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,10 @@ class ArrowEditor(Struct):
|
||||||
f'{arrow!r}\n'
|
f'{arrow!r}\n'
|
||||||
)
|
)
|
||||||
for linked in self.godw.iter_linked():
|
for linked in self.godw.iter_linked():
|
||||||
linked.chart.plotItem.removeItem(arrow)
|
if not (chart := linked.chart):
|
||||||
|
continue
|
||||||
|
|
||||||
|
chart.plotItem.removeItem(arrow)
|
||||||
try:
|
try:
|
||||||
arrows.remove(arrow)
|
arrows.remove(arrow)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,10 @@ def run_qtractor(
|
||||||
window_type: QMainWindow = None,
|
window_type: QMainWindow = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
'''
|
||||||
|
Run the Qt event loop and embed `trio` via guest mode on it.
|
||||||
|
|
||||||
|
'''
|
||||||
# avoids annoying message when entering debugger from qt loop
|
# avoids annoying message when entering debugger from qt loop
|
||||||
pyqtRemoveInputHook()
|
pyqtRemoveInputHook()
|
||||||
|
|
||||||
|
|
@ -170,7 +174,7 @@ def run_qtractor(
|
||||||
# hook into app focus change events
|
# hook into app focus change events
|
||||||
app.focusChanged.connect(window.on_focus_change)
|
app.focusChanged.connect(window.on_focus_change)
|
||||||
|
|
||||||
instance = main_widget_type()
|
instance: GodWidget = main_widget_type()
|
||||||
instance.window = window
|
instance.window = window
|
||||||
|
|
||||||
# override tractor's defaults
|
# override tractor's defaults
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ log = get_logger(__name__)
|
||||||
def update_fsp_chart(
|
def update_fsp_chart(
|
||||||
viz,
|
viz,
|
||||||
graphics_name: str,
|
graphics_name: str,
|
||||||
array_key: str | None,
|
array_key: str|None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -87,7 +87,11 @@ def update_fsp_chart(
|
||||||
|
|
||||||
# guard against unreadable case
|
# guard against unreadable case
|
||||||
if not last_row:
|
if not last_row:
|
||||||
log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}')
|
log.warning(
|
||||||
|
f'Read-race on shm array,\n'
|
||||||
|
f'graphics_name: {graphics_name!r}\n'
|
||||||
|
f'shm.token: {shm.token}\n'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# update graphics
|
# update graphics
|
||||||
|
|
@ -203,7 +207,6 @@ async def open_fsp_actor_cluster(
|
||||||
|
|
||||||
|
|
||||||
async def run_fsp_ui(
|
async def run_fsp_ui(
|
||||||
|
|
||||||
linkedsplits: LinkedSplits,
|
linkedsplits: LinkedSplits,
|
||||||
flume: Flume,
|
flume: Flume,
|
||||||
started: trio.Event,
|
started: trio.Event,
|
||||||
|
|
@ -471,7 +474,7 @@ class FspAdmin:
|
||||||
target: Fsp,
|
target: Fsp,
|
||||||
conf: dict[str, dict[str, Any]],
|
conf: dict[str, dict[str, Any]],
|
||||||
|
|
||||||
worker_name: str | None = None,
|
worker_name: str|None = None,
|
||||||
loglevel: str = 'info',
|
loglevel: str = 'info',
|
||||||
|
|
||||||
) -> (Flume, trio.Event):
|
) -> (Flume, trio.Event):
|
||||||
|
|
@ -623,8 +626,10 @@ async def open_fsp_admin(
|
||||||
event.set()
|
event.set()
|
||||||
|
|
||||||
|
|
||||||
|
# TODO, passing in `pikerd` related settings here!
|
||||||
|
# [ ] read in the `tractor` setting for `enable_transports: list`
|
||||||
|
# from the root `conf.toml`!
|
||||||
async def open_vlm_displays(
|
async def open_vlm_displays(
|
||||||
|
|
||||||
linked: LinkedSplits,
|
linked: LinkedSplits,
|
||||||
flume: Flume,
|
flume: Flume,
|
||||||
dvlm: bool = True,
|
dvlm: bool = True,
|
||||||
|
|
@ -634,12 +639,12 @@ async def open_vlm_displays(
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
Volume subchart displays.
|
Vlm (volume) subchart displays.
|
||||||
|
|
||||||
Since "volume" is often included directly alongside OHLCV price
|
Since "volume" is often included directly alongside OHLCV price
|
||||||
data, we don't really need a separate FSP-actor + shm array for it
|
data, we don't really need a separate FSP-actor + shm array for
|
||||||
since it's likely already directly adjacent to OHLC samples from the
|
it since it's likely already directly adjacent to OHLC samples
|
||||||
data provider.
|
from the data provider.
|
||||||
|
|
||||||
Further only if volume data is detected (it sometimes isn't provided
|
Further only if volume data is detected (it sometimes isn't provided
|
||||||
eg. forex, certain commodities markets) will volume dependent FSPs
|
eg. forex, certain commodities markets) will volume dependent FSPs
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@ class MultiStatus:
|
||||||
|
|
||||||
self,
|
self,
|
||||||
msg: str,
|
msg: str,
|
||||||
final_msg: str | None = None,
|
final_msg: str|None = None,
|
||||||
clear_on_next: bool = False,
|
clear_on_next: bool = False,
|
||||||
group_key: Union[bool, str] | None = False,
|
group_key: Union[bool, str]|None = False,
|
||||||
|
|
||||||
) -> Union[Callable[..., None], str]:
|
) -> Union[Callable[..., None], str]:
|
||||||
'''
|
'''
|
||||||
|
|
@ -175,11 +175,11 @@ class MainWindow(QMainWindow):
|
||||||
self.setWindowTitle(self.title)
|
self.setWindowTitle(self.title)
|
||||||
|
|
||||||
# set by runtime after `trio` is engaged.
|
# set by runtime after `trio` is engaged.
|
||||||
self.godwidget: GodWidget | None = None
|
self.godwidget: GodWidget|None = None
|
||||||
|
|
||||||
self._status_bar: QStatusBar = None
|
self._status_bar: QStatusBar = None
|
||||||
self._status_label: QLabel = None
|
self._status_label: QLabel = None
|
||||||
self._size: tuple[int, int] | None = None
|
self._size: tuple[int, int]|None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode_label(self) -> QLabel:
|
def mode_label(self) -> QLabel:
|
||||||
|
|
@ -202,7 +202,7 @@ class MainWindow(QMainWindow):
|
||||||
label.setMargin(2)
|
label.setMargin(2)
|
||||||
label.setAlignment(
|
label.setAlignment(
|
||||||
QtCore.Qt.AlignVCenter
|
QtCore.Qt.AlignVCenter
|
||||||
| QtCore.Qt.AlignRight
|
|QtCore.Qt.AlignRight
|
||||||
)
|
)
|
||||||
self.statusBar().addPermanentWidget(label)
|
self.statusBar().addPermanentWidget(label)
|
||||||
label.show()
|
label.show()
|
||||||
|
|
@ -288,7 +288,7 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def configure_to_desktop(
|
def configure_to_desktop(
|
||||||
self,
|
self,
|
||||||
size: tuple[int, int] | None = None,
|
size: tuple[int, int]|None = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue