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
34import numpy as np
45import numpy .typing as npt
6+ from abc import ABC , abstractmethod
57from dataclasses import dataclass
68from 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
810from 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
1220ArrayFloat : 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).
1837CURRENCY_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
0 commit comments