Skip to content

Commit f2a2feb

Browse files
committed
demo_implot_stock: Pyodide-friendly data source + playground wiring (hidden)
Half II of the revamp (Half I shipped in 9c7fe50). Lets the stock viewer run in the Pyodide playground by serving prebaked OHLCV snapshots from the same Cloudflare Pages site. - DataSource ABC with two impls: YFinanceSource (sync, desktop) and WebSource (async via pyodide.http.pyfetch, fetches /stock_data/<slug>.json). - Platform-select at import time (`try: import pyodide`). - yfinance is lazy-imported inside YFinanceSource so the file imports clean in Pyodide. - Pyodide-only UX: ticker combo trimmed to the 9-ticker whitelist (AAPL/MSFT/GOOGL/NVDA + MC.PA/OR.PA/AIR.PA/DSY.PA + ASML.AS), period combo trimmed to 6mo/1y/2y/3y, manual ticker entry hidden, default ticker MC.PA. - Playground wiring: symlink demo_implot_stock.py into playground/examples/ and add an entry in examples.json. - ci_scripts/cf_fetch_stock_data.py refreshes the snapshots into docs/clone_website_resources/imgui-bundle.pages.dev/stock_data/ (separate repo; commit + push there by hand). Wrapped by the private `just _cf_fetch_stock_data` recipe.
1 parent bdabe2e commit f2a2feb

5 files changed

Lines changed: 343 additions & 45 deletions

File tree

bindings/imgui_bundle/demos_python/demos_implot/demo_implot_stock.py

Lines changed: 203 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
1-
# pip install yfinance
2-
import yfinance as yf # type: ignore
1+
# pip install yfinance (desktop only; in the Pyodide playground the WebSource is used)
2+
import asyncio
3+
import json
34
import numpy as np
45
import numpy.typing as npt
6+
from abc import ABC, abstractmethod
57
from dataclasses import dataclass
68
from imgui_bundle import implot, ImVec4, ImVec2, imgui, imgui_ctx, IM_COL32, immapp
7-
from typing import Optional, TypeAlias
9+
from typing import Any, Optional, TypeAlias
810
from functools import cached_property
911

12+
try:
13+
import pyodide # type: ignore[import-not-found] # noqa: F401
14+
IN_PYODIDE = True
15+
except ImportError:
16+
IN_PYODIDE = False
17+
1018

1119
# ArrayFloat: 1D array of float64
1220
ArrayFloat: TypeAlias = npt.NDArray[np.float64] # shape (N,)
1321

14-
TICKER_IDS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "META", "NFLX", "NVDA", "AMD", "INTC"]
15-
PERIOD_RANGES = ["1mo", "3mo", "6mo", "12mo", "24mo", "60mo"]
22+
if IN_PYODIDE:
23+
# Pyodide: matches the JSON whitelist baked into the website-resources repo.
24+
TICKER_IDS = ["AAPL", "MSFT", "GOOGL", "NVDA",
25+
"MC.PA", "OR.PA", "AIR.PA", "DSY.PA",
26+
"ASML.AS"]
27+
PERIOD_RANGES = ["6mo", "1y", "2y", "3y"]
28+
DEFAULT_PERIOD = "2y"
29+
DEFAULT_TICKER = "MC.PA"
30+
else:
31+
TICKER_IDS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "META", "NFLX", "NVDA", "AMD", "INTC"]
32+
PERIOD_RANGES = ["1mo", "3mo", "6mo", "12mo", "24mo", "60mo"]
33+
DEFAULT_PERIOD = "24mo"
34+
DEFAULT_TICKER = "GOOGL"
1635

1736
# Currency display: (tooltip prefix, ImPlot axis-format string).
1837
CURRENCY_FORMATS: dict[str, tuple[str, str]] = {
@@ -217,14 +236,145 @@ def rsi_14(self) -> ArrayFloat:
217236
return rsi
218237

219238

220-
class StockViewer:
239+
class DataSource(ABC):
240+
"""Abstracts where stock OHLCV data comes from.
241+
242+
`request` kicks off a fetch (sync on desktop, async on Pyodide).
243+
Each frame the viewer calls `take_result` to pick up a finished fetch.
244+
`is_loading` tells the GUI to show a "Loading…" state instead of the chart.
245+
"""
246+
247+
@abstractmethod
248+
def request(self, ticker: str, period: str) -> None: ...
249+
250+
@abstractmethod
251+
def is_loading(self) -> bool: ...
252+
253+
@abstractmethod
254+
def take_result(self) -> tuple[Optional["StockData"], Optional[str]]:
255+
"""Return (data, error_msg). At most one is non-None. (None, None) means nothing new."""
256+
...
257+
258+
259+
class YFinanceSource(DataSource):
260+
"""Synchronous data source backed by the `yfinance` package (desktop)."""
261+
221262
def __init__(self):
222-
self.ticker_input = "GOOGL"
263+
self._pending: tuple[Optional["StockData"], Optional[str]] = (None, None)
264+
265+
def request(self, ticker: str, period: str) -> None:
266+
import yfinance as yf # type: ignore[import-untyped] # lazy: not available in Pyodide
267+
try:
268+
df = yf.download(ticker, period=period, interval="1d")
269+
df = df.dropna()
270+
timestamps = df.index.map(lambda ts: ts.timestamp()).to_numpy(np.float64)
271+
data = StockData(
272+
timestamps,
273+
df["Open"].to_numpy().flatten(),
274+
df["Close"].to_numpy().flatten(),
275+
df["Low"].to_numpy().flatten(),
276+
df["High"].to_numpy().flatten(),
277+
df["Volume"].to_numpy().astype(np.float64).flatten(),
278+
)
279+
self._pending = (data, None)
280+
except Exception as e:
281+
self._pending = (None, str(e))
282+
283+
def is_loading(self) -> bool:
284+
return False
285+
286+
def take_result(self) -> tuple[Optional["StockData"], Optional[str]]:
287+
result = self._pending
288+
self._pending = (None, None)
289+
return result
290+
291+
292+
_PERIOD_DAYS: dict[str, int] = {
293+
"1mo": 21, "3mo": 63, "6mo": 126,
294+
"12mo": 252, "1y": 252,
295+
"24mo": 504, "2y": 504,
296+
"3y": 756, "60mo": 1260,
297+
}
298+
299+
300+
class WebSource(DataSource):
301+
"""Async data source for the Pyodide playground.
302+
303+
Fetches `/stock_data/<slug>.json` (served by the same Pages site as the
304+
playground) using `pyodide.http.pyfetch`. Each `request()` cancels any
305+
in-flight task and starts a new one. `take_result()` polls the task's
306+
state without blocking the GUI.
307+
"""
308+
309+
BASE_URL = "/stock_data"
310+
311+
def __init__(self):
312+
self._task: Optional[asyncio.Task[None]] = None
313+
self._pending: tuple[Optional["StockData"], Optional[str]] = (None, None)
314+
315+
def request(self, ticker: str, period: str) -> None:
316+
if self._task is not None and not self._task.done():
317+
self._task.cancel()
318+
self._pending = (None, None)
319+
self._task = asyncio.create_task(self._fetch_async(ticker, period))
320+
321+
def is_loading(self) -> bool:
322+
return self._task is not None and not self._task.done()
323+
324+
def take_result(self) -> tuple[Optional["StockData"], Optional[str]]:
325+
if self._task is None or not self._task.done():
326+
return (None, None)
327+
result = self._pending
328+
self._pending = (None, None)
329+
self._task = None
330+
return result
331+
332+
async def _fetch_async(self, ticker: str, period: str) -> None:
333+
from pyodide.http import pyfetch # type: ignore[import-not-found]
334+
slug = ticker.replace(".", "_")
335+
url = f"{self.BASE_URL}/{slug}.json"
336+
try:
337+
resp = await pyfetch(url)
338+
if resp.status != 200:
339+
self._pending = (None, f"HTTP {resp.status} for {url}")
340+
return
341+
text = await resp.string()
342+
doc = json.loads(text)
343+
self._pending = (self._parse(doc, period), None)
344+
except asyncio.CancelledError:
345+
raise
346+
except Exception as e:
347+
self._pending = (None, str(e))
348+
349+
@staticmethod
350+
def _parse(doc: dict[str, Any], period: str) -> "StockData":
351+
ts = np.asarray(doc["ts"], dtype=np.float64)
352+
opens = np.asarray(doc["open"], dtype=np.float64)
353+
highs = np.asarray(doc["high"], dtype=np.float64)
354+
lows = np.asarray(doc["low"], dtype=np.float64)
355+
closes = np.asarray(doc["close"], dtype=np.float64)
356+
volumes = np.asarray(doc["volume"], dtype=np.float64)
357+
days = _PERIOD_DAYS.get(period)
358+
if days is not None and ts.size > days:
359+
ts, opens, highs = ts[-days:], opens[-days:], highs[-days:]
360+
lows, closes, volumes = lows[-days:], closes[-days:], volumes[-days:]
361+
return StockData(ts, opens, closes, lows, highs, volumes,
362+
currency=doc.get("currency", "USD"))
363+
364+
365+
def _make_default_data_source() -> DataSource:
366+
return WebSource() if IN_PYODIDE else YFinanceSource()
367+
368+
369+
class StockViewer:
370+
def __init__(self, data_source: Optional[DataSource] = None):
371+
self.data_source: DataSource = data_source or _make_default_data_source()
372+
self.ticker_input = DEFAULT_TICKER
223373
self.stock_data: Optional[StockData] = None
224-
self.fetch_error = None
374+
self.fetch_error: Optional[str] = None
225375
self.loaded_ticker = ""
226376
self.needs_refresh_x_extent = True
227-
self.period = "24mo" # default
377+
self.period = DEFAULT_PERIOD
228378
# Drag-rect range selector x-bounds (unix-seconds). y is anchored to axis limits each frame.
229379
self.range_x1 = 0.0
230380
self.range_x2 = 0.0
@@ -235,47 +385,51 @@ def __init__(self):
235385
self.fetch_data()
236386

237387
def fetch_data(self):
238-
try:
239-
df = yf.download(self.ticker_input, period=self.period, interval="1d")
240-
df = df.dropna()
241-
timestamps = df.index.map(lambda ts: ts.timestamp()).to_numpy(np.float64)
242-
self.stock_data = StockData(
243-
timestamps,
244-
df["Open"].to_numpy().flatten(),
245-
df["Close"].to_numpy().flatten(),
246-
df["Low"].to_numpy().flatten(),
247-
df["High"].to_numpy().flatten(),
248-
df["Volume"].to_numpy().astype(np.float64).flatten(),
249-
)
250-
self.loaded_ticker = self.ticker_input
251-
self.fetch_error = None
252-
self.needs_refresh_x_extent = True
253-
# Reset the drag-rect x-range to the last ~90 days of the new series.
254-
sd = self.stock_data
255-
ts = sd.timestamps
256-
i0 = max(0, len(ts) - 90)
257-
self.range_x1 = float(ts[i0])
258-
self.range_x2 = float(ts[-1])
259-
# New data: start fully visible, playback paused.
260-
self.visible_count_f = float(len(ts))
261-
self.playback_active = False
262-
# Cache full-data axis bounds — used to pin axes during partial reveal.
263-
bb_lo = np.nanmin(sd.bollinger_lower) if np.isfinite(sd.bollinger_lower).any() else sd.lows.min()
264-
bb_hi = np.nanmax(sd.bollinger_upper) if np.isfinite(sd.bollinger_upper).any() else sd.highs.max()
265-
self._bounds_x = (float(ts[0]), float(ts[-1]))
266-
self._bounds_price = (float(min(sd.lows.min(), bb_lo)), float(max(sd.highs.max(), bb_hi)))
267-
self._bounds_volume = (0.0, float(max(sd.volumes.max(), sd.volume_ema_50.max())))
268-
self._bounds_drawdown = (float(sd.drawdown_pct.min()), 0.0)
269-
except Exception as e:
270-
self.fetch_error = str(e)
388+
"""Kick off a fetch. For sync sources the result is ready immediately;
389+
for async sources it lands on a later `_poll_data_source` call."""
390+
self.data_source.request(self.ticker_input, self.period)
391+
self._poll_data_source()
392+
393+
def _poll_data_source(self):
394+
"""Pick up a result from the data source if one is ready."""
395+
data, err = self.data_source.take_result()
396+
if data is None and err is None:
397+
return
398+
if err is not None:
399+
self.fetch_error = err
271400
self.stock_data = None
401+
return
402+
assert data is not None # narrows for type-checkers (both early returns above handle None)
403+
self.stock_data = data
404+
self.loaded_ticker = self.ticker_input
405+
self.fetch_error = None
406+
self.needs_refresh_x_extent = True
407+
# Reset the drag-rect x-range to the last ~90 days of the new series.
408+
sd = data
409+
ts = sd.timestamps
410+
i0 = max(0, len(ts) - 90)
411+
self.range_x1 = float(ts[i0])
412+
self.range_x2 = float(ts[-1])
413+
# New data: start fully visible, playback paused.
414+
self.visible_count_f = float(len(ts))
415+
self.playback_active = False
416+
# Cache full-data axis bounds — used to pin axes during partial reveal.
417+
bb_lo = np.nanmin(sd.bollinger_lower) if np.isfinite(sd.bollinger_lower).any() else sd.lows.min()
418+
bb_hi = np.nanmax(sd.bollinger_upper) if np.isfinite(sd.bollinger_upper).any() else sd.highs.max()
419+
self._bounds_x = (float(ts[0]), float(ts[-1]))
420+
self._bounds_price = (float(min(sd.lows.min(), bb_lo)), float(max(sd.highs.max(), bb_hi)))
421+
self._bounds_volume = (0.0, float(max(sd.volumes.max(), sd.volume_ema_50.max())))
422+
self._bounds_drawdown = (float(sd.drawdown_pct.min()), 0.0)
272423

273424
def _gui_fetch(self):
274425
imgui.set_next_item_width(immapp.em_size(10))
275426
if imgui.begin_combo("Ticker", self.ticker_input):
276-
changed, self.ticker_input = imgui.input_text("Manual entry", self.ticker_input, 16)
427+
if not IN_PYODIDE:
428+
# Desktop: free-form ticker entry (any symbol yfinance accepts).
429+
# Pyodide: locked to the prebaked whitelist.
430+
_, self.ticker_input = imgui.input_text("Manual entry", self.ticker_input, 16)
277431
for stock_id in TICKER_IDS:
278-
changed, selected = imgui.selectable(stock_id, self.ticker_input == stock_id)
432+
_, selected = imgui.selectable(stock_id, self.ticker_input == stock_id)
279433
if selected:
280434
self.ticker_input = stock_id
281435
imgui.end_combo()
@@ -370,13 +524,17 @@ def _gui_stats_panel(self):
370524
imgui.separator()
371525

372526
def gui(self):
527+
self._poll_data_source()
373528
self._gui_fetch()
374529
self._advance_playback()
375530

531+
if self.data_source.is_loading():
532+
imgui.text_colored(ImVec4(0.7, 0.7, 0.7, 1.0), f"Loading {self.ticker_input}…")
533+
376534
if self.fetch_error:
377535
imgui.text_colored(ImVec4(1.0, 0.4, 0.4, 1.0), f"Error: {self.fetch_error}")
378536

379-
if self.stock_data:
537+
if self.stock_data and not self.data_source.is_loading():
380538
self._gui_playback()
381539
self._gui_stats_panel()
382540

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../demos_implot/demo_implot_stock.py

bindings/imgui_bundle/demos_python/playground/examples/examples.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"label": "ImPlot: Full Demo",
2121
"filename": "implot_demo.py"
2222
},
23+
{
24+
"label": "ImPlot: Stock Viewer",
25+
"filename": "demo_implot_stock.py",
26+
"hidden": true
27+
},
2328
{
2429
"label": "ImPlot3D: Full Demo",
2530
"filename": "implot3d_demo.py",

0 commit comments

Comments
 (0)