diff --git a/.gitignore b/.gitignore index d520113..2371bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,9 @@ __pycache__ !secret.example.txt *.json* *.jpg +.* /downloaded_video.mp4.in /.cache/ /.conda/ /Miniforge3-Linux-x86_64.sh +/tmp/ticker_history*/ diff --git a/handlers/gpt_handler.py b/handlers/gpt_handler.py index 0b7c3c1..d9a85f3 100644 --- a/handlers/gpt_handler.py +++ b/handlers/gpt_handler.py @@ -451,7 +451,7 @@ def is_image_model(model_name: str) -> bool: def submit_gpt_image_gen(user_input, session_key=None, model=DEFAULT_IMAGE_MODEL): if session_key: - return [] + return {"message": "Image generation does not use conversation history.", "attachments": []} try: response = client.images.generate( @@ -484,7 +484,11 @@ def submit_gpt_image_gen(user_input, session_key=None, model=DEFAULT_IMAGE_MODEL if revised_prompt is None and isinstance(image_data, dict): revised_prompt = image_data.get("revised_prompt") - return {"message": revised_prompt, "attachments": [image_b64] if image_b64 else []} + message = revised_prompt or "Generated image attached." + if not image_b64: + message = f"{message} No image data was returned by the image API." + + return {"message": message, "attachments": [image_b64] if image_b64 else []} import requests diff --git a/handlers/ticker_handler.py b/handlers/ticker_handler.py index de1396b..0b1313d 100644 --- a/handlers/ticker_handler.py +++ b/handlers/ticker_handler.py @@ -1,19 +1,24 @@ from datetime import datetime, timedelta, timezone -from handlers.base_handler import BaseHandler -from utils.misc_utils import * -import yfinance as yf import re + import matplotlib.pyplot as plt +import yfinance as yf + +from handlers.base_handler import BaseHandler +from utils.misc_utils import * + DEFAULT_DURATION = "1y" INTRADAY_THRESHOLD_DAYS = 5 +RECENT_INTRADAY_PERIOD = "5d" +RECENT_INTRADAY_INTERVAL = "1h" class TickerHandler(BaseHandler): def can_handle(self) -> bool: self.tickers = extract_ticker_symbols(self.input_str) - return (len(self.tickers) > 0) + return len(self.tickers) > 0 def process_message(self, msg, attachments): if self.tickers: @@ -108,7 +113,7 @@ def get_history_options(duration, now=None): """Build yfinance history options for arbitrary durations.""" now = now or datetime.now(timezone.utc) delta = duration_to_timedelta(duration) - options = {"start": now - delta, "end": now} + options = {"start": now - delta, "end": now, "auto_adjust": False} if delta <= timedelta(days=INTRADAY_THRESHOLD_DAYS): options["interval"] = "1h" return options @@ -118,6 +123,95 @@ def format_price(value): return f"${value:.2f}" +def clean_price_history(hist): + """ + Return rows with usable close prices for plotting. + + Some quote providers return placeholder rows with Close == 0 or stale + zero-volume prices that are not safe to plot as real prices. + """ + if hist.empty or "Close" not in hist: + return hist + + hist = hist[hist["Close"].notna() & (hist["Close"] > 0)].copy() + if "Volume" in hist and (hist["Volume"] > 0).any(): + hist = hist[hist["Volume"] > 0].copy() + + return hist + + +def clean_history_for_plot(hist): + return clean_price_history(hist) + + +def has_placeholder_price_rows(hist): + if hist.empty: + return False + has_zero_or_missing_close = "Close" in hist and (hist["Close"].isna() | (hist["Close"] <= 0)).any() + has_mixed_volume = "Volume" in hist and (hist["Volume"] <= 0).any() and (hist["Volume"] > 0).any() + return bool(has_zero_or_missing_close or has_mixed_volume) + + +def should_use_recent_intraday_fallback(raw_hist, clean_hist, history_options): + if history_options.get("interval"): + return False + return len(clean_hist) < 3 or has_placeholder_price_rows(raw_hist) + + +def keep_latest_session(hist): + if hist.empty: + return hist + + latest_session = hist.index[-1].date() + return hist[[index_value.date() == latest_session for index_value in hist.index]].copy() + + +def fetch_price_history(stock, history_options): + raw_hist = stock.history(**history_options) + clean_hist = clean_price_history(raw_hist) + if should_use_recent_intraday_fallback(raw_hist, clean_hist, history_options): + intraday_hist = stock.history( + period=RECENT_INTRADAY_PERIOD, + interval=RECENT_INTRADAY_INTERVAL, + auto_adjust=False, + ) + intraday_clean = clean_price_history(intraday_hist) + if len(intraday_clean) >= len(clean_hist): + return intraday_clean + return clean_hist + + +def fetch_recent_intraday_history(stock): + hist = stock.history( + period=RECENT_INTRADAY_PERIOD, + interval=RECENT_INTRADAY_INTERVAL, + auto_adjust=False, + ) + return clean_price_history(hist) + + +def is_intraday_history(hist): + if hist.empty: + return False + return any(index_value.time() != datetime.min.time() for index_value in hist.index) + + +def history_window(histories): + nonempty_histories = [hist for hist in histories if not hist.empty] + if not nonempty_histories: + return None + return ( + min(hist.index[0] for hist in nonempty_histories), + max(hist.index[-1] for hist in nonempty_histories), + ) + + +def trim_history_to_window(hist, start, end): + if hist.empty: + return hist + return hist[(hist.index >= start) & (hist.index <= end)].copy() + + def plot_stock_data_base64(ticker_symbols): """ Plot historical prices for a list of ticker symbols as percentage changes. @@ -128,30 +222,48 @@ def plot_stock_data_base64(ticker_symbols): """ plt.figure(figsize=(10, 6)) - tickers_to_plot = list(ticker_symbols) + requested_tickers = list(ticker_symbols) longest_duration = max( - (duration for _, duration in tickers_to_plot), + (duration for _, duration in requested_tickers), key=lambda duration: duration_to_timedelta(duration), ) - if not any(ticker_symbol.lower() == "spy" for ticker_symbol, _ in tickers_to_plot): - tickers_to_plot.insert(0, ("SPY", longest_duration)) - + include_spy_benchmark = not any(ticker_symbol.lower() == "spy" for ticker_symbol, _ in requested_tickers) history_options = get_history_options(longest_duration) - plotted_any_series = False + histories_to_plot = [] - for ticker_symbol, _ in tickers_to_plot: + for ticker_symbol, _ in requested_tickers: try: stock = yf.Ticker(ticker_symbol) - hist = stock.history(**history_options) + hist = fetch_price_history(stock, history_options) + histories_to_plot.append((ticker_symbol, hist)) + except Exception as e: + print(f"An error occurred while fetching data for {ticker_symbol}: {e}") + + if include_spy_benchmark: + try: + stock = yf.Ticker("SPY") + if any(is_intraday_history(hist) for _, hist in histories_to_plot): + spy_hist = fetch_recent_intraday_history(stock) + window = history_window([hist for _, hist in histories_to_plot]) + if window: + spy_hist = trim_history_to_window(spy_hist, *window) + else: + spy_hist = fetch_price_history(stock, history_options) + histories_to_plot.insert(0, ("SPY", spy_hist)) + except Exception as e: + print(f"An error occurred while fetching data for SPY: {e}") + + for ticker_symbol, hist in histories_to_plot: + try: if hist.empty: - print(f"No historical data found for {ticker_symbol}") + print(f"No usable historical close prices found for {ticker_symbol}") continue hist["Normalized"] = (hist["Close"] / hist["Close"].iloc[0]) * 100 start_price = format_price(hist["Close"].iloc[0]) end_price = format_price(hist["Close"].iloc[-1]) - label = f"{ticker_symbol.upper()} ({start_price} → {end_price})" + label = f"{ticker_symbol.upper()} ({start_price} -> {end_price})" plt.plot(hist.index, hist["Normalized"], label=label) plotted_any_series = True except Exception as e: diff --git a/run.py b/run.py index b5079f7..b8510c8 100644 --- a/run.py +++ b/run.py @@ -282,8 +282,8 @@ async def handle(self, c: Context): returnMsg = "" try: retdict = handler.process_message(msg, b64_attachments) - returnMsg = retdict["message"] - returnAttachments = retdict["attachments"] + returnMsg = retdict.get("message") or "" + returnAttachments = retdict.get("attachments") or [] print(f"retmessage {returnMsg}") print(f"attachment len {len(returnAttachments)}") except Exception as e: diff --git a/scripts/export_ticker_history.py b/scripts/export_ticker_history.py new file mode 100644 index 0000000..21909ff --- /dev/null +++ b/scripts/export_ticker_history.py @@ -0,0 +1,93 @@ +import argparse +from datetime import datetime, timezone +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import yfinance as yf + +from handlers.ticker_handler import ( + RECENT_INTRADAY_INTERVAL, + RECENT_INTRADAY_PERIOD, + clean_price_history, + fetch_price_history, + get_history_options, + keep_latest_session, +) + + +def export_frame(frame, path): + path.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv(path, index_label="DateTime") + + +def export_ticker(symbol, duration, output_dir): + stock = yf.Ticker(symbol) + history_options = get_history_options(duration) + + raw_daily = stock.history(**history_options) + clean_daily = clean_price_history(raw_daily) + raw_intraday = stock.history( + period=RECENT_INTRADAY_PERIOD, + interval=RECENT_INTRADAY_INTERVAL, + auto_adjust=False, + ) + clean_intraday = clean_price_history(raw_intraday) + latest_intraday = keep_latest_session(clean_intraday) + selected = fetch_price_history(stock, history_options) + + ticker_dir = output_dir / symbol.upper() + frames = { + "raw_daily": raw_daily, + "clean_daily": clean_daily, + "raw_intraday": raw_intraday, + "clean_intraday": clean_intraday, + "latest_intraday": latest_intraday, + "selected_for_plot": selected, + } + for name, frame in frames.items(): + export_frame(frame, ticker_dir / f"{name}.csv") + + summary_path = ticker_dir / "summary.txt" + with summary_path.open("w", encoding="utf-8") as summary: + summary.write(f"symbol={symbol.upper()}\n") + summary.write(f"duration={duration}\n") + summary.write(f"history_options={history_options}\n") + for name, frame in frames.items(): + summary.write(f"\n{name}\n") + summary.write(f"rows={len(frame)}\n") + if frame.empty: + continue + summary.write(f"first_index={frame.index[0]}\n") + summary.write(f"last_index={frame.index[-1]}\n") + columns = [column for column in ("Close", "Volume") if column in frame.columns] + if columns: + summary.write(frame[columns].tail(10).to_string()) + summary.write("\n") + + +def main(): + parser = argparse.ArgumentParser(description="Export yfinance ticker history diagnostics.") + parser.add_argument("symbols", nargs="+", help="Ticker symbols to export") + parser.add_argument("--duration", default="1y", help="Duration to inspect, e.g. 10d, 1mo, 1y") + parser.add_argument("--output-dir", default=None, help="Directory for exported CSV files") + args = parser.parse_args() + + if args.output_dir: + output_dir = Path(args.output_dir) + else: + stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + output_dir = REPO_ROOT / "tmp" / f"ticker_history_{stamp}" + + for symbol in args.symbols: + export_ticker(symbol, args.duration, output_dir) + + print(output_dir) + + +if __name__ == "__main__": + main() diff --git a/tests/test_gpt_handler.py b/tests/test_gpt_handler.py index b2f6497..63724ca 100644 --- a/tests/test_gpt_handler.py +++ b/tests/test_gpt_handler.py @@ -76,6 +76,32 @@ def test_image_alias_resolves_to_default_image_model(self): self.assertEqual(response, {"message": "ok", "attachments": []}) submit_mock.assert_called_once_with("draw a tiny robot", None, DEFAULT_IMAGE_MODEL) + def test_image_generation_returns_fallback_message_without_revised_prompt(self): + class ImageData: + b64_json = "abc123" + revised_prompt = None + + class Response: + data = [ImageData()] + + handler = GptHandler("#gpt.image draw a tiny robot") + self.assertTrue(handler.can_handle()) + + with patch("handlers.gpt_handler.client.images.generate", return_value=Response()): + response = handler.process_message("#gpt.image draw a tiny robot", None) + + self.assertEqual(response, {"message": "Generated image attached.", "attachments": ["abc123"]}) + + def test_image_generation_session_key_returns_dict(self): + from handlers.gpt_handler import submit_gpt_image_gen + + response = submit_gpt_image_gen("draw a tiny robot", session_key="chat") + + self.assertEqual( + response, + {"message": "Image generation does not use conversation history.", "attachments": []}, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ticker_handler.py b/tests/test_ticker_handler.py index 7af5726..925c5ee 100644 --- a/tests/test_ticker_handler.py +++ b/tests/test_ticker_handler.py @@ -1,70 +1,300 @@ from datetime import datetime, timedelta, timezone +import unittest from unittest.mock import MagicMock, patch import pandas as pd from handlers.ticker_handler import ( DEFAULT_DURATION, + clean_history_for_plot, + clean_price_history, duration_to_timedelta, extract_ticker_symbols, + fetch_price_history, get_history_options, + history_window, + is_intraday_history, + keep_latest_session, plot_stock_data_base64, + trim_history_to_window, ) -def test_extract_ticker_symbols_supports_arbitrary_duration_units(): - assert extract_ticker_symbols("compare $amd.10d $msft.6w $goog.18mo $spy") == [ - ("amd", "10d"), - ("msft", "6w"), - ("goog", "18mo"), - ("spy", DEFAULT_DURATION), - ] - - -def test_duration_to_timedelta_supports_days_weeks_months_and_years(): - assert duration_to_timedelta("10d") == timedelta(days=10) - assert duration_to_timedelta("6w") == timedelta(weeks=6) - assert duration_to_timedelta("18mo") == timedelta(days=540) - assert duration_to_timedelta("2y") == timedelta(days=730) - - -def test_get_history_options_uses_requested_range_and_hourly_intraday(): - now = datetime(2026, 6, 15, tzinfo=timezone.utc) - - hourly_options = get_history_options("4d", now=now) - assert hourly_options == { - "start": now - timedelta(days=4), - "end": now, - "interval": "1h", - } - - daily_options = get_history_options("10d", now=now) - assert daily_options == { - "start": now - timedelta(days=10), - "end": now, - } - - -@patch("handlers.ticker_handler.file_to_base64", return_value="encoded-plot") -@patch("handlers.ticker_handler.plt") -@patch("handlers.ticker_handler.yf.Ticker") -def test_plot_uses_longest_requested_range_and_prices_in_legend(mock_ticker, mock_plt, mock_file_to_base64): - hist = pd.DataFrame( - {"Close": [10.0, 12.0]}, - index=pd.date_range("2026-06-01", periods=2, tz="UTC"), - ) - ticker_instance = MagicMock() - ticker_instance.history.return_value = hist - mock_ticker.return_value = ticker_instance - - result = plot_stock_data_base64([("AMD", "10d")]) - - assert result == "encoded-plot" - history_kwargs = ticker_instance.history.call_args.kwargs - assert set(history_kwargs) == {"start", "end"} - actual_range = history_kwargs["end"] - history_kwargs["start"] - assert timedelta(days=9, hours=23, minutes=59) < actual_range < timedelta(days=10, minutes=1) - assert mock_ticker.call_count == 2 - assert mock_plt.text.call_count == 0 - labels = [call.kwargs["label"] for call in mock_plt.plot.call_args_list] - assert labels == ["SPY ($10.00 → $12.00)", "AMD ($10.00 → $12.00)"] +class TickerHandlerTest(unittest.TestCase): + def test_extract_ticker_symbols_supports_arbitrary_duration_units(self): + self.assertEqual( + extract_ticker_symbols("compare $amd.10d $msft.6w $goog.18mo $spy"), + [ + ("amd", "10d"), + ("msft", "6w"), + ("goog", "18mo"), + ("spy", DEFAULT_DURATION), + ], + ) + + def test_duration_to_timedelta_supports_days_weeks_months_and_years(self): + self.assertEqual(duration_to_timedelta("10d"), timedelta(days=10)) + self.assertEqual(duration_to_timedelta("6w"), timedelta(weeks=6)) + self.assertEqual(duration_to_timedelta("18mo"), timedelta(days=540)) + self.assertEqual(duration_to_timedelta("2y"), timedelta(days=730)) + + def test_get_history_options_uses_requested_range_and_hourly_intraday(self): + now = datetime(2026, 6, 15, tzinfo=timezone.utc) + + hourly_options = get_history_options("4d", now=now) + self.assertEqual( + hourly_options, + { + "start": now - timedelta(days=4), + "end": now, + "auto_adjust": False, + "interval": "1h", + }, + ) + + daily_options = get_history_options("10d", now=now) + self.assertEqual( + daily_options, + { + "start": now - timedelta(days=10), + "end": now, + "auto_adjust": False, + }, + ) + + def test_clean_price_history_drops_trailing_zero_close(self): + hist = pd.DataFrame( + {"Close": [10.0, 11.0, 0.0]}, + index=pd.to_datetime(["2026-06-12", "2026-06-15", "2026-06-16"]), + ) + + cleaned = clean_price_history(hist) + + self.assertEqual(cleaned["Close"].tolist(), [10.0, 11.0]) + self.assertEqual(cleaned.index[-1], pd.Timestamp("2026-06-15")) + + def test_clean_price_history_drops_missing_close(self): + hist = pd.DataFrame( + {"Close": [10.0, None, 12.0]}, + index=pd.to_datetime(["2026-06-12", "2026-06-15", "2026-06-16"]), + ) + + cleaned = clean_price_history(hist) + + self.assertEqual(cleaned["Close"].tolist(), [10.0, 12.0]) + + def test_clean_price_history_drops_zero_volume_placeholders(self): + hist = pd.DataFrame( + {"Close": [20320.33, 21425.08, 13.69], "Volume": [0, 0, 9986045]}, + index=pd.to_datetime(["2026-06-12", "2026-06-15", "2026-06-16"]), + ) + + cleaned = clean_price_history(hist) + + self.assertEqual(cleaned["Close"].tolist(), [13.69]) + self.assertEqual(cleaned["Volume"].tolist(), [9986045]) + + def test_clean_history_for_plot_aliases_clean_price_history(self): + hist = pd.DataFrame({"Close": [10.0, 0.0]}) + + self.assertEqual(clean_history_for_plot(hist)["Close"].tolist(), [10.0]) + + @patch("handlers.ticker_handler.file_to_base64", return_value="encoded-plot") + @patch("handlers.ticker_handler.plt") + @patch("handlers.ticker_handler.yf.Ticker") + def test_plot_uses_longest_requested_range_and_prices_in_legend( + self, + mock_ticker, + mock_plt, + mock_file_to_base64, + ): + hist = pd.DataFrame( + {"Close": [10.0, 11.0, 12.0]}, + index=pd.date_range("2026-06-01", periods=3, tz="UTC"), + ) + ticker_instance = MagicMock() + ticker_instance.history.return_value = hist + mock_ticker.return_value = ticker_instance + + result = plot_stock_data_base64([("AMD", "10d")]) + + self.assertEqual(result, "encoded-plot") + history_kwargs = ticker_instance.history.call_args.kwargs + self.assertEqual(set(history_kwargs), {"start", "end", "auto_adjust"}) + self.assertIs(history_kwargs["auto_adjust"], False) + actual_range = history_kwargs["end"] - history_kwargs["start"] + self.assertGreater(actual_range, timedelta(days=9, hours=23, minutes=59)) + self.assertLess(actual_range, timedelta(days=10, minutes=1)) + self.assertEqual(mock_ticker.call_count, 2) + self.assertEqual(mock_plt.text.call_count, 0) + labels = [call.kwargs["label"] for call in mock_plt.plot.call_args_list] + self.assertEqual(labels, ["SPY ($10.00 -> $12.00)", "AMD ($10.00 -> $12.00)"]) + + def test_fetch_price_history_uses_intraday_fallback_for_broken_daily_data(self): + daily_hist = pd.DataFrame( + {"Close": [20320.33, 21425.08, 13.69], "Volume": [0, 0, 9986045]}, + index=pd.date_range("2026-06-12", periods=3, tz="UTC"), + ) + intraday_hist = pd.DataFrame( + {"Close": [21.94, 15.29, 12.58, 13.74], "Volume": [86602, 304779, 4454388, 849740]}, + index=pd.to_datetime( + [ + "2026-06-15 10:30", + "2026-06-15 15:30", + "2026-06-16 09:30", + "2026-06-16 15:30", + ], + utc=True, + ), + ) + stock = MagicMock() + stock.history.side_effect = [daily_hist, intraday_hist] + + cleaned = fetch_price_history(stock, {"period": "1y", "auto_adjust": False}) + + self.assertEqual(cleaned["Close"].tolist(), [21.94, 15.29, 12.58, 13.74]) + self.assertEqual(stock.history.call_count, 2) + self.assertEqual( + stock.history.call_args.kwargs, + {"period": "5d", "interval": "1h", "auto_adjust": False}, + ) + + def test_keep_latest_session_removes_older_intraday_bars(self): + hist = pd.DataFrame( + {"Close": [21.94, 15.29, 12.58, 13.74]}, + index=pd.to_datetime( + [ + "2026-06-15 10:30", + "2026-06-15 15:30", + "2026-06-16 09:30", + "2026-06-16 15:30", + ], + utc=True, + ), + ) + + latest = keep_latest_session(hist) + + self.assertEqual(latest["Close"].tolist(), [12.58, 13.74]) + + def test_history_window_uses_nonempty_series_bounds(self): + first = pd.DataFrame( + {"Close": [10.0, 11.0]}, + index=pd.to_datetime(["2026-06-15 10:30", "2026-06-16 15:30"], utc=True), + ) + second = pd.DataFrame( + {"Close": [12.0, 13.0]}, + index=pd.to_datetime(["2026-06-15 11:30", "2026-06-16 14:30"], utc=True), + ) + + self.assertEqual( + history_window([first, second]), + (first.index[0], first.index[-1]), + ) + + def test_trim_history_to_window_clips_benchmark_to_requested_tickers(self): + benchmark = pd.DataFrame( + {"Close": [99.0, 100.0, 101.0, 102.0]}, + index=pd.to_datetime( + [ + "2026-06-13 10:30", + "2026-06-15 10:30", + "2026-06-16 15:30", + "2026-06-17 10:30", + ], + utc=True, + ), + ) + + trimmed = trim_history_to_window( + benchmark, + pd.Timestamp("2026-06-15 10:30", tz="UTC"), + pd.Timestamp("2026-06-16 15:30", tz="UTC"), + ) + + self.assertEqual(trimmed["Close"].tolist(), [100.0, 101.0]) + + def test_is_intraday_history_detects_timed_bars(self): + daily = pd.DataFrame({"Close": [10.0]}, index=pd.to_datetime(["2026-06-16"], utc=True)) + intraday = pd.DataFrame( + {"Close": [10.0]}, + index=pd.to_datetime(["2026-06-16 10:30"], utc=True), + ) + + self.assertFalse(is_intraday_history(daily)) + self.assertTrue(is_intraday_history(intraday)) + + @patch("handlers.ticker_handler.file_to_base64", return_value="encoded-plot") + @patch("handlers.ticker_handler.plt") + @patch("handlers.ticker_handler.yf.Ticker") + def test_plot_ignores_zero_close_rows_when_labeling_and_normalizing( + self, + mock_ticker, + mock_plt, + mock_file_to_base64, + ): + hist = pd.DataFrame( + {"Close": [10.0, 11.0, 0.0]}, + index=pd.date_range("2026-06-01", periods=3, tz="UTC"), + ) + ticker_instance = MagicMock() + ticker_instance.history.return_value = hist + mock_ticker.return_value = ticker_instance + + result = plot_stock_data_base64([("SPCM", "10d")]) + + self.assertEqual(result, "encoded-plot") + labels = [call.kwargs["label"] for call in mock_plt.plot.call_args_list] + self.assertEqual(labels, ["SPY ($10.00 -> $11.00)", "SPCM ($10.00 -> $11.00)"]) + plotted_values = [call.args[1].tolist() for call in mock_plt.plot.call_args_list] + for values in plotted_values: + self.assertEqual(len(values), 2) + self.assertAlmostEqual(values[0], 100.0) + self.assertAlmostEqual(values[1], 110.0) + + @patch("handlers.ticker_handler.file_to_base64", return_value="encoded-plot") + @patch("handlers.ticker_handler.plt") + @patch("handlers.ticker_handler.yf.Ticker") + def test_plot_clips_spy_to_intraday_fallback_window( + self, + mock_ticker, + mock_plt, + mock_file_to_base64, + ): + spcm_daily = pd.DataFrame( + {"Close": [34.79, 38.26], "Volume": [1064400, 2795430]}, + index=pd.to_datetime(["2026-06-15", "2026-06-16"], utc=True), + ) + spcm_intraday = pd.DataFrame( + {"Close": [28.96, 38.13], "Volume": [55940, 178516]}, + index=pd.to_datetime(["2026-06-15 10:30", "2026-06-16 15:30"], utc=True), + ) + spy_intraday = pd.DataFrame( + {"Close": [599.0, 600.0, 601.0, 602.0], "Volume": [1, 1, 1, 1]}, + index=pd.to_datetime( + [ + "2026-06-13 10:30", + "2026-06-15 10:30", + "2026-06-16 15:30", + "2026-06-17 10:30", + ], + utc=True, + ), + ) + + spcm = MagicMock() + spcm.history.side_effect = [spcm_daily, spcm_intraday] + spy = MagicMock() + spy.history.return_value = spy_intraday + mock_ticker.side_effect = [spcm, spy] + + result = plot_stock_data_base64([("SPCM", "1y")]) + + self.assertEqual(result, "encoded-plot") + plotted_indexes = [call.args[0].tolist() for call in mock_plt.plot.call_args_list] + self.assertEqual(plotted_indexes[0], spy_intraday.index[1:3].tolist()) + self.assertEqual(plotted_indexes[1], spcm_intraday.index.tolist()) + + +if __name__ == "__main__": + unittest.main()