commit
91c005b3c1
|
@ -20,10 +20,17 @@ Handy financial calculations.
|
||||||
import math
|
import math
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
|
from bidict import bidict
|
||||||
|
|
||||||
|
|
||||||
|
_mag2suffix = bidict({3: 'k', 6: 'M', 9: 'B'})
|
||||||
|
|
||||||
|
|
||||||
def humanize(
|
def humanize(
|
||||||
|
|
||||||
number: float,
|
number: float,
|
||||||
digits: int = 1
|
digits: int = 1
|
||||||
|
|
||||||
) -> str:
|
) -> str:
|
||||||
'''Convert large numbers to something with at most ``digits`` and
|
'''Convert large numbers to something with at most ``digits`` and
|
||||||
a letter suffix (eg. k: thousand, M: million, B: billion).
|
a letter suffix (eg. k: thousand, M: million, B: billion).
|
||||||
|
@ -36,19 +43,38 @@ def humanize(
|
||||||
if not number or number <= 0:
|
if not number or number <= 0:
|
||||||
return round(number, ndigits=digits)
|
return round(number, ndigits=digits)
|
||||||
|
|
||||||
mag2suffix = {3: 'k', 6: 'M', 9: 'B'}
|
|
||||||
mag = math.floor(math.log(number, 10))
|
mag = math.floor(math.log(number, 10))
|
||||||
if mag < 3:
|
if mag < 3:
|
||||||
return round(number, ndigits=digits)
|
return round(number, ndigits=digits)
|
||||||
|
|
||||||
maxmag = max(itertools.takewhile(lambda key: mag >= key, mag2suffix))
|
maxmag = max(itertools.takewhile(lambda key: mag >= key, _mag2suffix))
|
||||||
|
|
||||||
return "{value}{suffix}".format(
|
return "{value}{suffix}".format(
|
||||||
value=round(number/10**maxmag, ndigits=digits),
|
value=round(number/10**maxmag, ndigits=digits),
|
||||||
suffix=mag2suffix[maxmag],
|
suffix=_mag2suffix[maxmag],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def puterize(
|
||||||
|
|
||||||
|
text: str,
|
||||||
|
digits: int = 1,
|
||||||
|
|
||||||
|
) -> float:
|
||||||
|
'''Inverse of ``humanize()`` above.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
suffix = str(text)[-1]
|
||||||
|
mult = _mag2suffix.inverse[suffix]
|
||||||
|
value = text.rstrip(suffix)
|
||||||
|
return round(float(value) * 10**mult, ndigits=digits)
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
# no matching suffix try just the value
|
||||||
|
return float(text)
|
||||||
|
|
||||||
|
|
||||||
def pnl(
|
def pnl(
|
||||||
|
|
||||||
init: float,
|
init: float,
|
||||||
|
|
|
@ -373,7 +373,9 @@ async def open_brokerd_trades_dialogue(
|
||||||
broker = feed.mod.name
|
broker = feed.mod.name
|
||||||
|
|
||||||
# TODO: make a `tractor` bug/test for this!
|
# TODO: make a `tractor` bug/test for this!
|
||||||
# portal = feed._brokerd_portal
|
# if only i could member what the problem was..
|
||||||
|
# probably some GC of the portal thing?
|
||||||
|
# portal = feed.portal
|
||||||
|
|
||||||
# XXX: we must have our own portal + channel otherwise
|
# XXX: we must have our own portal + channel otherwise
|
||||||
# when the data feed closes it may result in a half-closed
|
# when the data feed closes it may result in a half-closed
|
||||||
|
|
|
@ -393,18 +393,23 @@ class Feed:
|
||||||
shm: ShmArray
|
shm: ShmArray
|
||||||
mod: ModuleType
|
mod: ModuleType
|
||||||
first_quotes: dict # symbol names to first quote dicts
|
first_quotes: dict # symbol names to first quote dicts
|
||||||
stream: trio.abc.ReceiveChannel[dict[str, Any]]
|
|
||||||
|
|
||||||
_brokerd_portal: tractor._portal.Portal
|
_portal: tractor.Portal
|
||||||
|
|
||||||
|
stream: trio.abc.ReceiveChannel[dict[str, Any]]
|
||||||
|
throttle_rate: Optional[int] = None
|
||||||
|
|
||||||
_trade_stream: Optional[AsyncIterator[dict[str, Any]]] = None
|
_trade_stream: Optional[AsyncIterator[dict[str, Any]]] = None
|
||||||
_max_sample_rate: int = 0
|
_max_sample_rate: int = 0
|
||||||
|
|
||||||
search: Callable[..., Awaitable] = None
|
|
||||||
|
|
||||||
# cache of symbol info messages received as first message when
|
# cache of symbol info messages received as first message when
|
||||||
# a stream startsc.
|
# a stream startsc.
|
||||||
symbols: dict[str, Symbol] = field(default_factory=dict)
|
symbols: dict[str, Symbol] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def portal(self) -> tractor.Portal:
|
||||||
|
return self._portal
|
||||||
|
|
||||||
async def receive(self) -> dict:
|
async def receive(self) -> dict:
|
||||||
return await self.stream.receive()
|
return await self.stream.receive()
|
||||||
|
|
||||||
|
@ -418,7 +423,7 @@ class Feed:
|
||||||
delay_s = delay_s or self._max_sample_rate
|
delay_s = delay_s or self._max_sample_rate
|
||||||
|
|
||||||
async with open_sample_step_stream(
|
async with open_sample_step_stream(
|
||||||
self._brokerd_portal,
|
self.portal,
|
||||||
delay_s,
|
delay_s,
|
||||||
) as istream:
|
) as istream:
|
||||||
yield istream
|
yield istream
|
||||||
|
@ -526,7 +531,8 @@ async def open_feed(
|
||||||
mod=mod,
|
mod=mod,
|
||||||
first_quotes=first_quotes,
|
first_quotes=first_quotes,
|
||||||
stream=stream,
|
stream=stream,
|
||||||
_brokerd_portal=portal,
|
_portal=portal,
|
||||||
|
throttle_rate=tick_throttle,
|
||||||
)
|
)
|
||||||
ohlc_sample_rates = []
|
ohlc_sample_rates = []
|
||||||
|
|
||||||
|
|
|
@ -18,14 +18,14 @@
|
||||||
Financial signal processing for the peeps.
|
Financial signal processing for the peeps.
|
||||||
"""
|
"""
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import AsyncIterator, Callable, Tuple
|
from typing import AsyncIterator, Callable, Tuple, Optional
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
import tractor
|
import tractor
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger, get_console_log
|
||||||
from .. import data
|
from .. import data
|
||||||
from ._momo import _rsi, _wma
|
from ._momo import _rsi, _wma
|
||||||
from ._volume import _tina_vwap
|
from ._volume import _tina_vwap
|
||||||
|
@ -134,7 +134,7 @@ async def fsp_compute(
|
||||||
# check for data length mis-allignment and fill missing values
|
# check for data length mis-allignment and fill missing values
|
||||||
diff = len(src.array) - len(history)
|
diff = len(src.array) - len(history)
|
||||||
if diff >= 0:
|
if diff >= 0:
|
||||||
print(f"WTF DIFF SIGNAL to HISTORY {diff}")
|
log.warning(f"WTF DIFF SIGNAL to HISTORY {diff}")
|
||||||
for _ in range(diff):
|
for _ in range(diff):
|
||||||
dst.push(history[:1])
|
dst.push(history[:1])
|
||||||
|
|
||||||
|
@ -149,6 +149,12 @@ async def fsp_compute(
|
||||||
|
|
||||||
# rt stream
|
# rt stream
|
||||||
async for processed in out_stream:
|
async for processed in out_stream:
|
||||||
|
|
||||||
|
# period = time.time() - last
|
||||||
|
# hz = 1/period if period else float('nan')
|
||||||
|
# if hz > 60:
|
||||||
|
# log.info(f'FSP quote too fast: {hz}')
|
||||||
|
|
||||||
log.debug(f"{fsp_func_name}: {processed}")
|
log.debug(f"{fsp_func_name}: {processed}")
|
||||||
index = src.index
|
index = src.index
|
||||||
dst.array[-1][fsp_func_name] = processed
|
dst.array[-1][fsp_func_name] = processed
|
||||||
|
@ -165,12 +171,16 @@ async def cascade(
|
||||||
dst_shm_token: Tuple[str, np.dtype],
|
dst_shm_token: Tuple[str, np.dtype],
|
||||||
symbol: str,
|
symbol: str,
|
||||||
fsp_func_name: str,
|
fsp_func_name: str,
|
||||||
|
loglevel: Optional[str] = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Chain streaming signal processors and deliver output to
|
'''Chain streaming signal processors and deliver output to
|
||||||
destination mem buf.
|
destination mem buf.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
|
if loglevel:
|
||||||
|
get_console_log(loglevel)
|
||||||
|
|
||||||
src = attach_shm_array(token=src_shm_token)
|
src = attach_shm_array(token=src_shm_token)
|
||||||
dst = attach_shm_array(readonly=False, token=dst_shm_token)
|
dst = attach_shm_array(readonly=False, token=dst_shm_token)
|
||||||
|
|
||||||
|
@ -180,6 +190,10 @@ async def cascade(
|
||||||
async with data.feed.maybe_open_feed(
|
async with data.feed.maybe_open_feed(
|
||||||
brokername,
|
brokername,
|
||||||
[symbol],
|
[symbol],
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
# tick_throttle=60,
|
||||||
|
|
||||||
) as (feed, stream):
|
) as (feed, stream):
|
||||||
|
|
||||||
assert src.token == feed.shm.token
|
assert src.token == feed.shm.token
|
||||||
|
|
|
@ -276,6 +276,8 @@ class ChartnPane(QFrame):
|
||||||
hbox.setContentsMargins(0, 0, 0, 0)
|
hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
hbox.setSpacing(3)
|
hbox.setSpacing(3)
|
||||||
|
|
||||||
|
# self.setMaximumWidth()
|
||||||
|
|
||||||
|
|
||||||
class LinkedSplits(QWidget):
|
class LinkedSplits(QWidget):
|
||||||
'''
|
'''
|
||||||
|
@ -339,7 +341,8 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
def set_split_sizes(
|
def set_split_sizes(
|
||||||
self,
|
self,
|
||||||
prop: float = 0.375 # proportion allocated to consumer subcharts
|
# prop: float = 0.375, # proportion allocated to consumer subcharts
|
||||||
|
prop: float = 5/8,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''Set the proportion of space allocated for linked subcharts.
|
'''Set the proportion of space allocated for linked subcharts.
|
||||||
|
@ -450,7 +453,6 @@ class LinkedSplits(QWidget):
|
||||||
self.xaxis = xaxis
|
self.xaxis = xaxis
|
||||||
|
|
||||||
qframe = ChartnPane(sidepane=sidepane, parent=self.splitter)
|
qframe = ChartnPane(sidepane=sidepane, parent=self.splitter)
|
||||||
|
|
||||||
cpw = ChartPlotWidget(
|
cpw = ChartPlotWidget(
|
||||||
|
|
||||||
# this name will be used to register the primary
|
# this name will be used to register the primary
|
||||||
|
@ -522,10 +524,10 @@ class LinkedSplits(QWidget):
|
||||||
# track by name
|
# track by name
|
||||||
self.subplots[name] = cpw
|
self.subplots[name] = cpw
|
||||||
|
|
||||||
if sidepane:
|
# if sidepane:
|
||||||
# TODO: use a "panes" collection to manage this?
|
# # TODO: use a "panes" collection to manage this?
|
||||||
sidepane.setMinimumWidth(self.chart.sidepane.width())
|
# qframe.setMaximumWidth(self.chart.sidepane.width())
|
||||||
sidepane.setMaximumWidth(self.chart.sidepane.width())
|
# qframe.setMinimumWidth(self.chart.sidepane.width())
|
||||||
|
|
||||||
self.splitter.addWidget(qframe)
|
self.splitter.addWidget(qframe)
|
||||||
|
|
||||||
|
@ -537,6 +539,16 @@ class LinkedSplits(QWidget):
|
||||||
|
|
||||||
return cpw
|
return cpw
|
||||||
|
|
||||||
|
def resize_sidepanes(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
'''Size all sidepanes based on the OHLC "main" plot.
|
||||||
|
|
||||||
|
'''
|
||||||
|
for name, cpw in self.subplots.items():
|
||||||
|
cpw.sidepane.setMinimumWidth(self.chart.sidepane.width())
|
||||||
|
cpw.sidepane.setMaximumWidth(self.chart.sidepane.width())
|
||||||
|
|
||||||
|
|
||||||
class ChartPlotWidget(pg.PlotWidget):
|
class ChartPlotWidget(pg.PlotWidget):
|
||||||
'''
|
'''
|
||||||
|
@ -681,9 +693,9 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
"""Return a range tuple for the bars present in view.
|
"""Return a range tuple for the bars present in view.
|
||||||
"""
|
"""
|
||||||
l, r = self.view_range()
|
l, r = self.view_range()
|
||||||
a = self._arrays['ohlc']
|
array = self._arrays['ohlc']
|
||||||
lbar = max(l, a[0]['index'])
|
lbar = max(l, array[0]['index'])
|
||||||
rbar = min(r, a[-1]['index'])
|
rbar = min(r, array[-1]['index'])
|
||||||
return l, lbar, rbar, r
|
return l, lbar, rbar, r
|
||||||
|
|
||||||
def default_view(
|
def default_view(
|
||||||
|
@ -991,22 +1003,19 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
a = self._arrays['ohlc']
|
a = self._arrays['ohlc']
|
||||||
ifirst = a[0]['index']
|
ifirst = a[0]['index']
|
||||||
bars = a[lbar - ifirst:rbar - ifirst + 1]
|
bars = a[lbar - ifirst:rbar - ifirst + 1]
|
||||||
|
|
||||||
if not len(bars):
|
if not len(bars):
|
||||||
# likely no data loaded yet or extreme scrolling?
|
# likely no data loaded yet or extreme scrolling?
|
||||||
log.error(f"WTF bars_range = {lbar}:{rbar}")
|
log.error(f"WTF bars_range = {lbar}:{rbar}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# TODO: should probably just have some kinda attr mark
|
if self.data_key != self.linked.symbol.key:
|
||||||
# that determines this behavior based on array type
|
bars = a[self.data_key]
|
||||||
try:
|
ylow = np.nanmin(bars)
|
||||||
|
yhigh = np.nanmax((bars))
|
||||||
|
else:
|
||||||
|
# just the std ohlc bars
|
||||||
ylow = np.nanmin(bars['low'])
|
ylow = np.nanmin(bars['low'])
|
||||||
yhigh = np.nanmax(bars['high'])
|
yhigh = np.nanmax(bars['high'])
|
||||||
except (IndexError, ValueError):
|
|
||||||
# likely non-ohlc array?
|
|
||||||
bars = bars[self.name]
|
|
||||||
ylow = np.nanmin(bars)
|
|
||||||
yhigh = np.nanmax(bars)
|
|
||||||
|
|
||||||
if set_range:
|
if set_range:
|
||||||
# view margins: stay within a % of the "true range"
|
# view margins: stay within a % of the "true range"
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
Real-time display tasks for charting / graphics.
|
Real-time display tasks for charting / graphics.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
@ -264,7 +265,7 @@ async def chart_from_quotes(
|
||||||
last_mx, last_mn = mx, mn
|
last_mx, last_mn = mx, mn
|
||||||
|
|
||||||
|
|
||||||
async def spawn_fsps(
|
async def fan_out_spawn_fsp_daemons(
|
||||||
|
|
||||||
linkedsplits: LinkedSplits,
|
linkedsplits: LinkedSplits,
|
||||||
fsps: dict[str, str],
|
fsps: dict[str, str],
|
||||||
|
@ -275,109 +276,93 @@ async def spawn_fsps(
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start financial signal processing in subactor.
|
'''Create financial signal processing sub-actors (under flat tree)
|
||||||
|
for each entry in config and attach to local graphics update tasks.
|
||||||
|
|
||||||
Pass target entrypoint and historical data.
|
Pass target entrypoint and historical data.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
|
|
||||||
linkedsplits.focus()
|
linkedsplits.focus()
|
||||||
|
|
||||||
uid = tractor.current_actor().uid
|
uid = tractor.current_actor().uid
|
||||||
|
|
||||||
# spawns sub-processes which execute cpu bound FSP code
|
# spawns sub-processes which execute cpu bound FSP code
|
||||||
async with tractor.open_nursery(loglevel=loglevel) as n:
|
async with (
|
||||||
|
tractor.open_nursery() as n,
|
||||||
|
trio.open_nursery() as ln,
|
||||||
|
):
|
||||||
|
|
||||||
# spawns local task that consume and chart data streams from
|
# Currently we spawn an actor per fsp chain but
|
||||||
# sub-procs
|
# likely we'll want to pool them eventually to
|
||||||
async with trio.open_nursery() as ln:
|
# scale horizonatlly once cores are used up.
|
||||||
|
for display_name, conf in fsps.items():
|
||||||
|
|
||||||
# Currently we spawn an actor per fsp chain but
|
fsp_func_name = conf['fsp_func_name']
|
||||||
# likely we'll want to pool them eventually to
|
|
||||||
# scale horizonatlly once cores are used up.
|
|
||||||
for display_name, conf in fsps.items():
|
|
||||||
|
|
||||||
fsp_func_name = conf['fsp_func_name']
|
# TODO: load function here and introspect
|
||||||
|
# return stream type(s)
|
||||||
|
|
||||||
# TODO: load function here and introspect
|
# TODO: should `index` be a required internal field?
|
||||||
# return stream type(s)
|
fsp_dtype = np.dtype([('index', int), (fsp_func_name, float)])
|
||||||
|
|
||||||
# TODO: should `index` be a required internal field?
|
key = f'{sym}.fsp.{display_name}.{".".join(uid)}'
|
||||||
fsp_dtype = np.dtype([('index', int), (fsp_func_name, float)])
|
|
||||||
|
|
||||||
key = f'{sym}.fsp.{display_name}.{".".join(uid)}'
|
# this is all sync currently
|
||||||
|
shm, opened = maybe_open_shm_array(
|
||||||
|
key,
|
||||||
|
# TODO: create entry for each time frame
|
||||||
|
dtype=fsp_dtype,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
# this is all sync currently
|
# XXX: fsp may have been opened by a duplicate chart.
|
||||||
shm, opened = maybe_open_shm_array(
|
# Error for now until we figure out how to wrap fsps as
|
||||||
key,
|
# "feeds". assert opened, f"A chart for {key} likely
|
||||||
# TODO: create entry for each time frame
|
# already exists?"
|
||||||
dtype=fsp_dtype,
|
|
||||||
readonly=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# XXX: fsp may have been opened by a duplicate chart.
|
conf['shm'] = shm
|
||||||
# Error for now until we figure out how to wrap fsps as
|
|
||||||
# "feeds". assert opened, f"A chart for {key} likely
|
|
||||||
# already exists?"
|
|
||||||
|
|
||||||
conf['shm'] = shm
|
portal = await n.start_actor(
|
||||||
|
enable_modules=['piker.fsp'],
|
||||||
|
name='fsp.' + display_name,
|
||||||
|
)
|
||||||
|
|
||||||
portal = await n.start_actor(
|
# init async
|
||||||
enable_modules=['piker.fsp'],
|
ln.start_soon(
|
||||||
name='fsp.' + display_name,
|
run_fsp,
|
||||||
)
|
portal,
|
||||||
|
linkedsplits,
|
||||||
|
brokermod,
|
||||||
|
sym,
|
||||||
|
src_shm,
|
||||||
|
fsp_func_name,
|
||||||
|
display_name,
|
||||||
|
conf,
|
||||||
|
group_status_key,
|
||||||
|
loglevel,
|
||||||
|
)
|
||||||
|
|
||||||
# init async
|
# blocks here until all fsp actors complete
|
||||||
ln.start_soon(
|
|
||||||
run_fsp,
|
|
||||||
portal,
|
|
||||||
linkedsplits,
|
|
||||||
brokermod,
|
|
||||||
sym,
|
|
||||||
src_shm,
|
|
||||||
fsp_func_name,
|
|
||||||
display_name,
|
|
||||||
conf,
|
|
||||||
group_status_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
# blocks here until all fsp actors complete
|
|
||||||
|
|
||||||
|
|
||||||
async def run_fsp(
|
class FspConfig(BaseModel):
|
||||||
|
class Config:
|
||||||
|
validate_assignment = True
|
||||||
|
|
||||||
portal: tractor._portal.Portal,
|
name: str
|
||||||
linkedsplits: LinkedSplits,
|
period: int
|
||||||
brokermod: ModuleType,
|
|
||||||
sym: str,
|
|
||||||
src_shm: ShmArray,
|
@asynccontextmanager
|
||||||
fsp_func_name: str,
|
async def open_sidepane(
|
||||||
|
|
||||||
|
linked: LinkedSplits,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
conf: dict[str, Any],
|
|
||||||
group_status_key: str,
|
|
||||||
|
|
||||||
) -> None:
|
) -> FspConfig:
|
||||||
"""FSP stream chart update loop.
|
|
||||||
|
|
||||||
This is called once for each entry in the fsp
|
|
||||||
config map.
|
|
||||||
"""
|
|
||||||
done = linkedsplits.window().status_bar.open_status(
|
|
||||||
f'loading fsp, {display_name}..',
|
|
||||||
group_key=group_status_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
# make sidepane config widget
|
|
||||||
class FspConfig(BaseModel):
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
validate_assignment = True
|
|
||||||
|
|
||||||
name: str
|
|
||||||
period: int
|
|
||||||
|
|
||||||
sidepane: FieldsForm = mk_form(
|
sidepane: FieldsForm = mk_form(
|
||||||
parent=linkedsplits.godwidget,
|
parent=linked.godwidget,
|
||||||
fields_schema={
|
fields_schema={
|
||||||
'name': {
|
'name': {
|
||||||
'label': '**fsp**:',
|
'label': '**fsp**:',
|
||||||
|
@ -386,6 +371,8 @@ async def run_fsp(
|
||||||
f'{display_name}'
|
f'{display_name}'
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# TODO: generate this from input map
|
||||||
'period': {
|
'period': {
|
||||||
'label': '**period**:',
|
'label': '**period**:',
|
||||||
'type': 'edit',
|
'type': 'edit',
|
||||||
|
@ -403,10 +390,46 @@ async def run_fsp(
|
||||||
print(f'{key}: {value}')
|
print(f'{key}: {value}')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
async with (
|
||||||
|
open_form_input_handling(
|
||||||
|
sidepane,
|
||||||
|
focus_next=linked.godwidget,
|
||||||
|
on_value_change=settings_change,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
yield sidepane
|
||||||
|
|
||||||
|
|
||||||
|
async def run_fsp(
|
||||||
|
|
||||||
|
portal: tractor._portal.Portal,
|
||||||
|
linkedsplits: LinkedSplits,
|
||||||
|
brokermod: ModuleType,
|
||||||
|
sym: str,
|
||||||
|
src_shm: ShmArray,
|
||||||
|
fsp_func_name: str,
|
||||||
|
display_name: str,
|
||||||
|
conf: dict[str, Any],
|
||||||
|
group_status_key: str,
|
||||||
|
loglevel: str,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''FSP stream chart update loop.
|
||||||
|
|
||||||
|
This is called once for each entry in the fsp
|
||||||
|
config map.
|
||||||
|
|
||||||
|
'''
|
||||||
|
done = linkedsplits.window().status_bar.open_status(
|
||||||
|
f'loading fsp, {display_name}..',
|
||||||
|
group_key=group_status_key,
|
||||||
|
)
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
portal.open_stream_from(
|
portal.open_stream_from(
|
||||||
|
|
||||||
# subactor entrypoint
|
# chaining entrypoint
|
||||||
fsp.cascade,
|
fsp.cascade,
|
||||||
|
|
||||||
# name as title of sub-chart
|
# name as title of sub-chart
|
||||||
|
@ -415,15 +438,14 @@ async def run_fsp(
|
||||||
dst_shm_token=conf['shm'].token,
|
dst_shm_token=conf['shm'].token,
|
||||||
symbol=sym,
|
symbol=sym,
|
||||||
fsp_func_name=fsp_func_name,
|
fsp_func_name=fsp_func_name,
|
||||||
|
loglevel=loglevel,
|
||||||
|
|
||||||
) as stream,
|
) as stream,
|
||||||
|
|
||||||
# TODO:
|
open_sidepane(
|
||||||
open_form_input_handling(
|
linkedsplits,
|
||||||
sidepane,
|
display_name,
|
||||||
focus_next=linkedsplits.godwidget,
|
) as sidepane,
|
||||||
on_value_change=settings_change,
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
|
|
||||||
# receive last index for processed historical
|
# receive last index for processed historical
|
||||||
|
@ -472,7 +494,7 @@ async def run_fsp(
|
||||||
# read from last calculated value
|
# read from last calculated value
|
||||||
array = shm.array
|
array = shm.array
|
||||||
|
|
||||||
# XXX: fsp func names are unique meaning we don't have
|
# XXX: fsp func names must be unique meaning we don't have
|
||||||
# duplicates of the underlying data even if multiple
|
# duplicates of the underlying data even if multiple
|
||||||
# sub-charts reference it under different 'named charts'.
|
# sub-charts reference it under different 'named charts'.
|
||||||
value = array[fsp_func_name][-1]
|
value = array[fsp_func_name][-1]
|
||||||
|
@ -489,6 +511,8 @@ async def run_fsp(
|
||||||
array_key=fsp_func_name
|
array_key=fsp_func_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
chart.linked.resize_sidepanes()
|
||||||
|
|
||||||
# TODO: figure out if we can roll our own `FillToThreshold` to
|
# TODO: figure out if we can roll our own `FillToThreshold` to
|
||||||
# get brush filled polygons for OS/OB conditions.
|
# get brush filled polygons for OS/OB conditions.
|
||||||
# ``pg.FillBetweenItems`` seems to be one technique using
|
# ``pg.FillBetweenItems`` seems to be one technique using
|
||||||
|
@ -622,6 +646,73 @@ async def check_for_new_bars(feed, ohlcv, linkedsplits):
|
||||||
price_chart.increment_view()
|
price_chart.increment_view()
|
||||||
|
|
||||||
|
|
||||||
|
def has_vlm(ohlcv: ShmArray) -> bool:
|
||||||
|
# make sure that the instrument supports volume history
|
||||||
|
# (sometimes this is not the case for some commodities and
|
||||||
|
# derivatives)
|
||||||
|
volm = ohlcv.array['volume']
|
||||||
|
return not bool(np.all(np.isin(volm, -1)) or np.all(np.isnan(volm)))
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def maybe_open_vlm_display(
|
||||||
|
|
||||||
|
linked: LinkedSplits,
|
||||||
|
ohlcv: ShmArray,
|
||||||
|
|
||||||
|
) -> ChartPlotWidget:
|
||||||
|
|
||||||
|
# make sure that the instrument supports volume history
|
||||||
|
# (sometimes this is not the case for some commodities and
|
||||||
|
# derivatives)
|
||||||
|
# volm = ohlcv.array['volume']
|
||||||
|
# if (
|
||||||
|
# np.all(np.isin(volm, -1)) or
|
||||||
|
# np.all(np.isnan(volm))
|
||||||
|
# ):
|
||||||
|
if not has_vlm(ohlcv):
|
||||||
|
log.warning(f"{linked.symbol.key} does not seem to have volume info")
|
||||||
|
else:
|
||||||
|
async with open_sidepane(linked, 'volume') as sidepane:
|
||||||
|
# built-in $vlm
|
||||||
|
shm = ohlcv
|
||||||
|
chart = linked.add_plot(
|
||||||
|
name='vlm',
|
||||||
|
array=shm.array,
|
||||||
|
|
||||||
|
array_key='volume',
|
||||||
|
sidepane=sidepane,
|
||||||
|
|
||||||
|
# curve by default
|
||||||
|
ohlc=False,
|
||||||
|
|
||||||
|
# vertical bars
|
||||||
|
# stepMode=True,
|
||||||
|
# static_yrange=(0, 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
# XXX: ONLY for sub-chart fsps, overlays have their
|
||||||
|
# data looked up from the chart's internal array set.
|
||||||
|
# TODO: we must get a data view api going STAT!!
|
||||||
|
chart._shm = shm
|
||||||
|
|
||||||
|
# should **not** be the same sub-chart widget
|
||||||
|
assert chart.name != linked.chart.name
|
||||||
|
|
||||||
|
# sticky only on sub-charts atm
|
||||||
|
last_val_sticky = chart._ysticks[chart.name]
|
||||||
|
|
||||||
|
# read from last calculated value
|
||||||
|
value = shm.array['volume'][-1]
|
||||||
|
|
||||||
|
last_val_sticky.update_from_data(-1, value)
|
||||||
|
|
||||||
|
# size view to data once at outset
|
||||||
|
chart._set_yrange()
|
||||||
|
|
||||||
|
yield chart
|
||||||
|
|
||||||
|
|
||||||
async def display_symbol_data(
|
async def display_symbol_data(
|
||||||
|
|
||||||
godwidget: GodWidget,
|
godwidget: GodWidget,
|
||||||
|
@ -686,6 +777,7 @@ async def display_symbol_data(
|
||||||
# add as next-to-y-axis singleton pane
|
# add as next-to-y-axis singleton pane
|
||||||
godwidget.pp_pane = pp_pane
|
godwidget.pp_pane = pp_pane
|
||||||
|
|
||||||
|
# create main OHLC chart
|
||||||
chart = linkedsplits.plot_ohlc_main(
|
chart = linkedsplits.plot_ohlc_main(
|
||||||
symbol,
|
symbol,
|
||||||
bars,
|
bars,
|
||||||
|
@ -722,7 +814,7 @@ async def display_symbol_data(
|
||||||
'static_yrange': (0, 100),
|
'static_yrange': (0, 100),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# test for duplicate fsps on same chart
|
# # test for duplicate fsps on same chart
|
||||||
# 'rsi2': {
|
# 'rsi2': {
|
||||||
# 'fsp_func_name': 'rsi',
|
# 'fsp_func_name': 'rsi',
|
||||||
# 'period': 14,
|
# 'period': 14,
|
||||||
|
@ -733,18 +825,8 @@ async def display_symbol_data(
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# make sure that the instrument supports volume history
|
if has_vlm(ohlcv):
|
||||||
# (sometimes this is not the case for some commodities and
|
# add VWAP to fsp config for downstream loading
|
||||||
# derivatives)
|
|
||||||
volm = ohlcv.array['volume']
|
|
||||||
if (
|
|
||||||
np.all(np.isin(volm, -1)) or
|
|
||||||
np.all(np.isnan(volm))
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
f"{sym} does not seem to have volume info,"
|
|
||||||
" dropping volume signals")
|
|
||||||
else:
|
|
||||||
fsp_conf.update({
|
fsp_conf.update({
|
||||||
'vwap': {
|
'vwap': {
|
||||||
'fsp_func_name': 'vwap',
|
'fsp_func_name': 'vwap',
|
||||||
|
@ -756,11 +838,10 @@ async def display_symbol_data(
|
||||||
async with (
|
async with (
|
||||||
|
|
||||||
trio.open_nursery() as ln,
|
trio.open_nursery() as ln,
|
||||||
|
|
||||||
):
|
):
|
||||||
# load initial fsp chain (otherwise known as "indicators")
|
# load initial fsp chain (otherwise known as "indicators")
|
||||||
ln.start_soon(
|
ln.start_soon(
|
||||||
spawn_fsps,
|
fan_out_spawn_fsp_daemons,
|
||||||
linkedsplits,
|
linkedsplits,
|
||||||
fsp_conf,
|
fsp_conf,
|
||||||
sym,
|
sym,
|
||||||
|
@ -787,6 +868,7 @@ async def display_symbol_data(
|
||||||
)
|
)
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
|
maybe_open_vlm_display(linkedsplits, ohlcv),
|
||||||
|
|
||||||
open_order_mode(
|
open_order_mode(
|
||||||
feed,
|
feed,
|
||||||
|
|
|
@ -21,7 +21,6 @@ Text entry "forms" widgets (mostly for configuration and UI user input).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from textwrap import dedent
|
|
||||||
from typing import (
|
from typing import (
|
||||||
Optional, Any, Callable, Awaitable
|
Optional, Any, Callable, Awaitable
|
||||||
)
|
)
|
||||||
|
@ -320,14 +319,14 @@ class FieldsForm(QWidget):
|
||||||
self.vbox = QVBoxLayout(self)
|
self.vbox = QVBoxLayout(self)
|
||||||
# self.vbox.setAlignment(Qt.AlignVCenter)
|
# self.vbox.setAlignment(Qt.AlignVCenter)
|
||||||
self.vbox.setAlignment(Qt.AlignBottom)
|
self.vbox.setAlignment(Qt.AlignBottom)
|
||||||
self.vbox.setContentsMargins(0, 4, 3, 6)
|
self.vbox.setContentsMargins(3, 6, 3, 6)
|
||||||
self.vbox.setSpacing(0)
|
self.vbox.setSpacing(0)
|
||||||
|
|
||||||
# split layout for the (<label>: |<widget>|) parameters entry
|
# split layout for the (<label>: |<widget>|) parameters entry
|
||||||
self.form = QFormLayout()
|
self.form = QFormLayout()
|
||||||
self.form.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
self.form.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||||
self.form.setContentsMargins(0, 0, 0, 0)
|
self.form.setContentsMargins(0, 0, 3, 0)
|
||||||
self.form.setSpacing(3)
|
self.form.setSpacing(0)
|
||||||
self.form.setHorizontalSpacing(0)
|
self.form.setHorizontalSpacing(0)
|
||||||
|
|
||||||
self.vbox.addLayout(self.form, stretch=1/3)
|
self.vbox.addLayout(self.form, stretch=1/3)
|
||||||
|
@ -645,9 +644,7 @@ def mk_fill_status_bar(
|
||||||
# PnL on lhs
|
# PnL on lhs
|
||||||
bar_labels_lhs = QVBoxLayout()
|
bar_labels_lhs = QVBoxLayout()
|
||||||
left_label = form.add_field_label(
|
left_label = form.add_field_label(
|
||||||
dedent("""
|
'{pnl:>+.2%} pnl',
|
||||||
{pnl:>+.2%} pnl
|
|
||||||
"""),
|
|
||||||
font_size=bar_label_font_size,
|
font_size=bar_label_font_size,
|
||||||
font_color='gunmetal',
|
font_color='gunmetal',
|
||||||
)
|
)
|
||||||
|
@ -674,18 +671,13 @@ def mk_fill_status_bar(
|
||||||
# https://docs.python.org/3/library/string.html#grammar-token-precision
|
# https://docs.python.org/3/library/string.html#grammar-token-precision
|
||||||
|
|
||||||
top_label = form.add_field_label(
|
top_label = form.add_field_label(
|
||||||
# {limit:.1f} limit
|
'{limit}',
|
||||||
dedent("""
|
|
||||||
{limit}
|
|
||||||
"""),
|
|
||||||
font_size=bar_label_font_size,
|
font_size=bar_label_font_size,
|
||||||
font_color='gunmetal',
|
font_color='gunmetal',
|
||||||
)
|
)
|
||||||
|
|
||||||
bottom_label = form.add_field_label(
|
bottom_label = form.add_field_label(
|
||||||
dedent("""
|
'x: {step_size}',
|
||||||
x: {step_size}\n
|
|
||||||
"""),
|
|
||||||
font_size=bar_label_font_size,
|
font_size=bar_label_font_size,
|
||||||
font_color='gunmetal',
|
font_color='gunmetal',
|
||||||
)
|
)
|
||||||
|
@ -788,29 +780,10 @@ def mk_order_pane_layout(
|
||||||
# add pp fill bar + spacing
|
# add pp fill bar + spacing
|
||||||
vbox.addLayout(hbox, stretch=1/3)
|
vbox.addLayout(hbox, stretch=1/3)
|
||||||
|
|
||||||
# TODO: status labels for brokerd real-time info
|
|
||||||
# feed_label = form.add_field_label(
|
|
||||||
# dedent("""
|
|
||||||
# brokerd.ib\n
|
|
||||||
# |_@{host}:{port}\n
|
|
||||||
# |_consumers: {cons}\n
|
|
||||||
# |_streams: {streams}\n
|
|
||||||
# |_shms: {streams}\n
|
|
||||||
# """),
|
|
||||||
# font_size=_font_small.px_size,
|
|
||||||
# )
|
|
||||||
|
|
||||||
# # add feed info label
|
|
||||||
# vbox.addWidget(
|
|
||||||
# feed_label,
|
|
||||||
# alignment=Qt.AlignBottom,
|
|
||||||
# # stretch=1/3,
|
|
||||||
# )
|
|
||||||
|
|
||||||
# TODO: handle resize events and appropriately scale this
|
# TODO: handle resize events and appropriately scale this
|
||||||
# to the sidepane height?
|
# to the sidepane height?
|
||||||
# https://doc.qt.io/qt-5/layout.html#adding-widgets-to-a-layout
|
# https://doc.qt.io/qt-5/layout.html#adding-widgets-to-a-layout
|
||||||
vbox.setSpacing(_font.px_size * 1.375)
|
# vbox.setSpacing(_font.px_size * 1.375)
|
||||||
|
|
||||||
form.show()
|
form.show()
|
||||||
return form
|
return form
|
||||||
|
|
|
@ -23,7 +23,7 @@ from typing import Callable, Optional, Any
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PyQt5 import QtGui, QtWidgets
|
from PyQt5 import QtGui, QtWidgets
|
||||||
from PyQt5.QtWidgets import QLabel
|
from PyQt5.QtWidgets import QLabel, QSizePolicy
|
||||||
from PyQt5.QtCore import QPointF, QRectF, Qt
|
from PyQt5.QtCore import QPointF, QRectF, Qt
|
||||||
|
|
||||||
from ._style import (
|
from ._style import (
|
||||||
|
@ -269,8 +269,11 @@ class FormatLabel(QLabel):
|
||||||
self.setTextFormat(Qt.MarkdownText) # markdown
|
self.setTextFormat(Qt.MarkdownText) # markdown
|
||||||
self.setMargin(0)
|
self.setMargin(0)
|
||||||
|
|
||||||
self.setAlignment(
|
self.setSizePolicy(
|
||||||
Qt.AlignVCenter
|
QSizePolicy.Expanding,
|
||||||
|
QSizePolicy.Expanding,
|
||||||
|
)
|
||||||
|
self.setAlignment(Qt.AlignVCenter
|
||||||
| Qt.AlignLeft
|
| Qt.AlignLeft
|
||||||
)
|
)
|
||||||
self.setText(self.fmt_str)
|
self.setText(self.fmt_str)
|
||||||
|
|
|
@ -36,7 +36,7 @@ from ._anchors import (
|
||||||
pp_tight_and_right, # wanna keep it straight in the long run
|
pp_tight_and_right, # wanna keep it straight in the long run
|
||||||
gpath_pin,
|
gpath_pin,
|
||||||
)
|
)
|
||||||
from ..calc import humanize, pnl
|
from ..calc import humanize, pnl, puterize
|
||||||
from ..clearing._allocate import Allocator, Position
|
from ..clearing._allocate import Allocator, Position
|
||||||
from ..data._normalize import iterticks
|
from ..data._normalize import iterticks
|
||||||
from ..data.feed import Feed
|
from ..data.feed import Feed
|
||||||
|
@ -208,26 +208,31 @@ class SettingsPane:
|
||||||
size_unit = alloc.size_unit
|
size_unit = alloc.size_unit
|
||||||
|
|
||||||
# WRITE any settings to current pp's allocator
|
# WRITE any settings to current pp's allocator
|
||||||
if key == 'limit':
|
try:
|
||||||
if size_unit == 'currency':
|
value = puterize(value)
|
||||||
alloc.currency_limit = float(value)
|
if key == 'limit':
|
||||||
|
if size_unit == 'currency':
|
||||||
|
alloc.currency_limit = value
|
||||||
|
else:
|
||||||
|
alloc.units_limit = value
|
||||||
|
|
||||||
|
elif key == 'slots':
|
||||||
|
alloc.slots = int(value)
|
||||||
|
|
||||||
|
elif key == 'size_unit':
|
||||||
|
# TODO: if there's a limit size unit change re-compute
|
||||||
|
# the current settings in the new units
|
||||||
|
alloc.size_unit = value
|
||||||
|
|
||||||
else:
|
else:
|
||||||
alloc.units_limit = float(value)
|
raise ValueError(f'Unknown setting {key}')
|
||||||
|
|
||||||
elif key == 'slots':
|
log.info(f'settings change: {key}: {value}')
|
||||||
alloc.slots = int(value)
|
|
||||||
|
|
||||||
elif key == 'size_unit':
|
except ValueError:
|
||||||
# TODO: if there's a limit size unit change re-compute
|
log.error(f'Invalid value for `{key}`: {value}')
|
||||||
# the current settings in the new units
|
|
||||||
alloc.size_unit = value
|
|
||||||
|
|
||||||
elif key != 'account':
|
|
||||||
raise ValueError(f'Unknown setting {key}')
|
|
||||||
|
|
||||||
# READ out settings and update UI
|
# READ out settings and update UI
|
||||||
log.info(f'settings change: {key}: {value}')
|
|
||||||
|
|
||||||
suffix = {'currency': ' $', 'units': ' u'}[size_unit]
|
suffix = {'currency': ' $', 'units': ' u'}[size_unit]
|
||||||
limit = alloc.limit()
|
limit = alloc.limit()
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,7 @@ from ._position import (
|
||||||
PositionTracker,
|
PositionTracker,
|
||||||
SettingsPane,
|
SettingsPane,
|
||||||
)
|
)
|
||||||
|
from ._label import FormatLabel
|
||||||
from ._window import MultiStatus
|
from ._window import MultiStatus
|
||||||
from ..clearing._messages import Order
|
from ..clearing._messages import Order
|
||||||
from ._forms import open_form_input_handling
|
from ._forms import open_form_input_handling
|
||||||
|
@ -623,6 +624,59 @@ async def open_order_mode(
|
||||||
|
|
||||||
# setup order mode sidepane widgets
|
# setup order mode sidepane widgets
|
||||||
form = chart.sidepane
|
form = chart.sidepane
|
||||||
|
vbox = form.vbox
|
||||||
|
|
||||||
|
from textwrap import dedent
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
from ._style import _font, _font_small
|
||||||
|
from ..calc import humanize
|
||||||
|
|
||||||
|
feed_label = FormatLabel(
|
||||||
|
fmt_str=dedent("""
|
||||||
|
actor: **{actor_name}**\n
|
||||||
|
|_ @**{host}:{port}**\n
|
||||||
|
|_ throttle_hz: **{throttle_rate}**\n
|
||||||
|
|_ streams: **{symbols}**\n
|
||||||
|
|_ shm: **{shm}**\n
|
||||||
|
"""),
|
||||||
|
font=_font.font,
|
||||||
|
font_size=_font_small.px_size,
|
||||||
|
font_color='default_lightest',
|
||||||
|
)
|
||||||
|
|
||||||
|
form.feed_label = feed_label
|
||||||
|
|
||||||
|
# add feed info label to top
|
||||||
|
vbox.insertWidget(
|
||||||
|
0,
|
||||||
|
feed_label,
|
||||||
|
alignment=Qt.AlignBottom,
|
||||||
|
)
|
||||||
|
# vbox.setAlignment(feed_label, Qt.AlignBottom)
|
||||||
|
# vbox.setAlignment(Qt.AlignBottom)
|
||||||
|
blank_h = chart.height() - (
|
||||||
|
form.height() +
|
||||||
|
form.fill_bar.height()
|
||||||
|
# feed_label.height()
|
||||||
|
)
|
||||||
|
vbox.setSpacing((1 + 5/8)*_font.px_size)
|
||||||
|
|
||||||
|
# fill in brokerd feed info
|
||||||
|
host, port = feed.portal.channel.raddr
|
||||||
|
if host == '127.0.0.1':
|
||||||
|
host = 'localhost'
|
||||||
|
mpshm = feed.shm._shm
|
||||||
|
shmstr = f'{humanize(mpshm.size)}'
|
||||||
|
form.feed_label.format(
|
||||||
|
actor_name=feed.portal.channel.uid[0],
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
symbols=len(feed.symbols),
|
||||||
|
shm=shmstr,
|
||||||
|
throttle_rate=feed.throttle_rate,
|
||||||
|
)
|
||||||
|
|
||||||
order_pane = SettingsPane(
|
order_pane = SettingsPane(
|
||||||
form=form,
|
form=form,
|
||||||
|
|
Loading…
Reference in New Issue