piker/examples/trend_graphics.py

236 lines
7.3 KiB
Python

# piker: trading gear for hackers
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers)
# SPDX-License-Identifier: AGPL-3.0-or-later
# 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/>.
'''
Render an explicitly synthetic multiple-timeframe trend PNG.
Run with ``--output /tmp/piker-trend-graphics.png``. This uses the
actual shared tracker and snapshot graphics adapter. It launches
no visible GUI, opens no broker or Jev connection, reads no API key,
and isolates Qt/Piker configuration in a disposable directory.
'''
import argparse
import json
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
def render(
output: Path,
*,
periods: tuple[float, ...],
order: int,
samples: int,
) -> dict[str, object]:
'''
Replay synthetic closes through one actual tracker and renderer.
The final OHLC row remains mutable and is never submitted as a
completed observation. Each timeframe retains real aggregation
and TC state, while all plotted marker timestamps identify their
final contributing base bar. Shading is deterministic TC output,
not fabricated model inference.
'''
import numpy as np
import pyqtgraph as pg
from piker.fsp._regime import MultiTimeframeTrend, RegimeSnapshot
from piker.ui._trend_mtf import (
SnapshotTrendOverlay,
_SnapshotLayer,
)
from piker.ui.qt import QApplication
app: QApplication = QApplication(['piker-trend-graphics'])
app.setQuitOnLastWindowClosed(False)
steps: np.ndarray = np.arange(samples)
prices: np.ndarray = (
100 + .009 * steps
+ 1.5 * np.sin(steps * .06)
+ .38 * np.sin(steps * .43)
)
rows: np.ndarray = np.empty(samples, dtype=[
('index', 'i8'),
('time', 'f8'),
('close', 'f8'),
('low', 'f8'),
('high', 'f8'),
])
rows['index'] = 1000 + steps
rows['time'] = 1_700_000_100 + steps
rows['close'] = prices
rows['low'] = prices - .12
rows['high'] = prices + .12
shm: SimpleNamespace = SimpleNamespace(
array=rows,
_token='synthetic-demo-ohlcv',
)
widget: pg.PlotWidget = pg.PlotWidget()
widget.resize(1400, 760)
widget.setBackground('#101923')
widget.setTitle(
'SYNTHETIC: Trend Calculus across actual timeframes'
'<br><span style="font-size:11pt;color:#aab7c5">'
'Larger triangles represent slower sampling periods; '
'shading begins at confirmed regime detection.'
'</span>',
color='#dce5ed',
size='16pt',
)
widget.plotItem.titleLabel.setFixedHeight(60)
widget.showGrid(x=True, y=True, alpha=.13)
widget.setLabel('left', 'Synthetic price')
widget.setLabel('bottom', 'Absolute base-bar index (1 second)')
widget.plot(
rows['index'],
prices,
pen=pg.mkPen('#dce5ed', width=1.5),
)
left: int = max(0, samples - 400)
widget.setXRange(1000 + left, 1000 + samples, padding=0)
widget.setYRange(
float(prices[left:].min()) - .6,
float(prices[left:].max()) + .6,
padding=0,
)
viz: SimpleNamespace = SimpleNamespace(
plot=widget.plotItem,
shm=shm,
index_field='index',
)
tracker: MultiTimeframeTrend = MultiTimeframeTrend(
'synthetic.demo',
periods=periods,
windows=(2, 2, 2, 2),
history_limit=256,
)
adapter: SnapshotTrendOverlay = SnapshotTrendOverlay(
viz,
shm,
fqme='synthetic.demo',
base_period=1,
periods=periods,
order=order,
)
try:
row: np.void
snapshot: RegimeSnapshot
for row in rows[:-1]:
snapshot = tracker.push(
float(row['time']),
float(row['close']),
index=int(row['index']),
)
adapter.submit(snapshot)
legend: pg.LegendItem = widget.addLegend(offset=(12, 12))
period: float
layer: _SnapshotLayer
for period, layer in adapter.layers.items():
legend.addItem(
pg.ScatterPlotItem(
symbol='t1',
size=layer.markers.opts['size'],
brush='#46c7a4',
pen=pg.mkPen(None),
),
f'{period:g}s / order {order}',
)
widget.show()
app.processEvents()
output.parent.mkdir(parents=True, exist_ok=True)
if not widget.grab().save(str(output)):
raise RuntimeError('Qt could not save the requested PNG')
return {
'mode': 'synthetic_native_graphics_no_model_calls',
'output': str(output),
'base_period': 1,
'periods': periods,
'order': order,
'finalized_bars': samples - 1,
'last_event_end': snapshot.as_of,
'layers': {
period: {
'markers': len(layer.points),
'rectangles': len(layer.rectangles.intervals),
}
for period, layer in adapter.layers.items()
},
}
finally:
adapter.remove()
widget.close()
widget.deleteLater()
app.processEvents()
def main() -> None:
'''
Require an output path and isolate offscreen configuration.
'''
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description=__doc__,
)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument(
'--periods', nargs='+', type=float, default=[1, 5, 15],
)
parser.add_argument(
'--order', type=int, choices=range(1, 5), default=1,
)
parser.add_argument('--samples', type=int, default=600)
args: argparse.Namespace = parser.parse_args()
if not 120 <= args.samples <= 10_000:
parser.error('--samples must be between 120 and 10000')
prior: dict[str, str|None] = {
'QT_QPA_PLATFORM': os.environ.get('QT_QPA_PLATFORM'),
'XDG_CONFIG_HOME': os.environ.get('XDG_CONFIG_HOME'),
}
try:
path: str
with TemporaryDirectory(prefix='piker-trend-') as path:
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
os.environ['XDG_CONFIG_HOME'] = path
result: dict[str, object] = render(
args.output.resolve(),
periods=tuple(args.periods),
order=args.order,
samples=args.samples,
)
print(json.dumps(result, indent=2))
finally:
key: str
value: str|None
for key, value in prior.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
if __name__ == '__main__':
main()