Add comprehensive morning report skill with collectors for calendar, email, tasks, infrastructure status, news, stocks, and weather. Add stock lookup skill for quote queries.
176 lines
5.6 KiB
Python
Executable File
176 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Fetch stock quotes from Yahoo Finance API."""
|
|
|
|
import argparse
|
|
import json
|
|
import urllib.request
|
|
from datetime import datetime
|
|
|
|
|
|
def fetch_chart(symbol: str, range_: str = "1d", interval: str = "1d") -> dict:
|
|
"""Fetch chart data for a symbol."""
|
|
url = f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol.upper()}?interval={interval}&range={range_}"
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
return json.load(resp)
|
|
except urllib.error.HTTPError as e:
|
|
return {"error": f"HTTP {e.code}: Symbol '{symbol}' not found"}
|
|
except urllib.error.URLError as e:
|
|
return {"error": f"Network error: {e.reason}"}
|
|
|
|
|
|
def get_quote(symbol: str) -> dict:
|
|
"""Fetch quote data for a symbol."""
|
|
data = fetch_chart(symbol)
|
|
if "error" in data:
|
|
return data
|
|
|
|
try:
|
|
result = data["chart"]["result"][0]
|
|
meta = result["meta"]
|
|
return {
|
|
"symbol": meta["symbol"],
|
|
"name": meta.get("shortName", meta.get("longName", "N/A")),
|
|
"price": meta["regularMarketPrice"],
|
|
"previous_close": meta.get("chartPreviousClose", meta.get("previousClose")),
|
|
"currency": meta.get("currency", "USD"),
|
|
"exchange": meta.get("exchangeName", "N/A"),
|
|
"market_state": meta.get("marketState", "N/A"),
|
|
}
|
|
except (KeyError, IndexError, TypeError) as e:
|
|
return {"error": f"Parse error: {e}"}
|
|
|
|
|
|
def get_trend(symbol: str, range_: str = "3mo") -> dict:
|
|
"""Fetch trend data for a symbol over a time range."""
|
|
data = fetch_chart(symbol, range_=range_, interval="1d")
|
|
if "error" in data:
|
|
return data
|
|
|
|
try:
|
|
result = data["chart"]["result"][0]
|
|
meta = result["meta"]
|
|
timestamps = result["timestamp"]
|
|
closes = result["indicators"]["quote"][0]["close"]
|
|
|
|
# Filter valid data points
|
|
valid_data = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
|
|
if not valid_data:
|
|
return {"error": "No price data available"}
|
|
|
|
first_ts, first_price = valid_data[0]
|
|
last_ts, last_price = valid_data[-1]
|
|
prices_only = [c for _, c in valid_data]
|
|
|
|
high = max(prices_only)
|
|
low = min(prices_only)
|
|
change = last_price - first_price
|
|
pct_change = (change / first_price) * 100
|
|
|
|
return {
|
|
"symbol": meta["symbol"],
|
|
"name": meta.get("shortName", meta.get("longName", "N/A")),
|
|
"range": range_,
|
|
"start_date": datetime.fromtimestamp(first_ts).strftime("%b %d"),
|
|
"end_date": datetime.fromtimestamp(last_ts).strftime("%b %d"),
|
|
"start_price": first_price,
|
|
"end_price": last_price,
|
|
"change": change,
|
|
"pct_change": pct_change,
|
|
"high": high,
|
|
"low": low,
|
|
"prices": prices_only,
|
|
}
|
|
except (KeyError, IndexError, TypeError) as e:
|
|
return {"error": f"Parse error: {e}"}
|
|
|
|
|
|
def format_quote(q: dict) -> str:
|
|
"""Format quote for display."""
|
|
if "error" in q:
|
|
return f"Error: {q['error']}"
|
|
|
|
price = q["price"]
|
|
prev = q.get("previous_close")
|
|
|
|
if prev:
|
|
change = price - prev
|
|
pct = (change / prev) * 100
|
|
direction = "+" if change >= 0 else ""
|
|
change_str = f" ({direction}{change:.2f}, {direction}{pct:.2f}%)"
|
|
else:
|
|
change_str = ""
|
|
|
|
market = f" [{q['market_state']}]" if q.get("market_state") else ""
|
|
|
|
return f"{q['symbol']} ({q['name']}): ${price:.2f}{change_str}{market}"
|
|
|
|
|
|
def format_trend(t: dict) -> str:
|
|
"""Format trend data for display."""
|
|
if "error" in t:
|
|
return f"Error: {t['error']}"
|
|
|
|
direction = "+" if t["change"] >= 0 else ""
|
|
|
|
# Build sparkline
|
|
prices = t["prices"]
|
|
weekly = prices[::5] + [prices[-1]] # Sample every 5 trading days
|
|
min_p, max_p = min(weekly), max(weekly)
|
|
range_p = max_p - min_p if max_p > min_p else 1
|
|
bars = "▁▂▃▄▅▆▇█"
|
|
sparkline = ""
|
|
for p in weekly:
|
|
idx = int((p - min_p) / range_p * 7.99)
|
|
idx = min(7, max(0, idx))
|
|
sparkline += bars[idx]
|
|
|
|
return f"""{t['symbol']} ({t['name']}) - {t['range']} Trend
|
|
{'=' * 42}
|
|
Start ({t['start_date']}): ${t['start_price']:.2f}
|
|
Now ({t['end_date']}): ${t['end_price']:.2f}
|
|
Change: {direction}{t['change']:.2f} ({direction}{t['pct_change']:.1f}%)
|
|
High: ${t['high']:.2f}
|
|
Low: ${t['low']:.2f}
|
|
|
|
${min_p:.0f} {sparkline} ${max_p:.0f}"""
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Fetch stock quotes")
|
|
parser.add_argument("symbols", nargs="+", help="Stock symbol(s) to look up")
|
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
parser.add_argument(
|
|
"--trend",
|
|
nargs="?",
|
|
const="3mo",
|
|
metavar="RANGE",
|
|
help="Show trend (default: 3mo). Ranges: 1mo, 3mo, 6mo, 1y, 2y, 5y, ytd, max",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.trend:
|
|
results = [get_trend(s, args.trend) for s in args.symbols]
|
|
formatter = format_trend
|
|
else:
|
|
results = [get_quote(s) for s in args.symbols]
|
|
formatter = format_quote
|
|
|
|
if args.json:
|
|
# Remove prices array from JSON output (too verbose)
|
|
for r in results:
|
|
if "prices" in r:
|
|
del r["prices"]
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
for i, r in enumerate(results):
|
|
if i > 0:
|
|
print()
|
|
print(formatter(r))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|