Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ __pycache__
!secret.example.txt
*.json*
*.jpg
.*
/downloaded_video.mp4.in
/.cache/
/.conda/
/Miniforge3-Linux-x86_64.sh
/tmp/ticker_history*/
8 changes: 6 additions & 2 deletions handlers/gpt_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
142 changes: 127 additions & 15 deletions handlers/ticker_handler.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
93 changes: 93 additions & 0 deletions scripts/export_ticker_history.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 26 additions & 0 deletions tests/test_gpt_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading
Loading