commit 9028ca5ff9eaf4adf9e23611c99ece5220633df5 Author: zlei9 Date: Sun Mar 29 08:22:53 2026 +0800 Initial commit with translated description diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..5336098 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,82 @@ +--- +name: finance +description: "追踪股票、ETF、指数、加密货币(如可用)和外汇货币对,具备缓存和提供商回退功能。" +metadata: {"clawdbot":{"config":{"requiredEnv":["TWELVEDATA_API_KEY","ALPHAVANTAGE_API_KEY"],"stateDirs":[".cache/finance"],"example":"# Optional (only if you add a paid provider later)\n# export TWELVEDATA_API_KEY=\"...\"\n# export ALPHAVANTAGE_API_KEY=\"...\"\n"}}} +--- + +# Market Tracker Skill + +This skill helps you fetch **latest quotes** and **historical series** for: +- Stocks / ETFs / Indices (e.g., AAPL, MSFT, ^GSPC, VOO) +- FX pairs (e.g., USD/ZAR, EURUSD, GBP-JPY) +- Crypto tickers supported by the chosen provider (best-effort) + +It is optimized for: +- fast “what’s the price now?” queries +- lightweight tracking with a local watchlist +- caching to avoid rate-limits + +## When to use +Use this skill when the user asks: +- “What’s the latest price of ___?” +- “Track ___ and ___ and show me daily changes.” +- “Give me a 30-day series for ___.” +- “Convert USD to ZAR (or track USD/ZAR).” +- “Maintain a watchlist and summarize performance.” + +## Provider strategy (important) +- **Stocks/ETFs/indices** default: Yahoo Finance via `yfinance` (no key, broad coverage), but it is unofficial and can rate-limit. +- **FX** default: ExchangeRate-API Open Access endpoint (no key, daily update). +- If the user needs high-frequency or many symbols, recommend adding a paid provider later. + +See `providers.md` for details and symbol formats. + +--- + +# Quick start (how you run it) +These scripts are intended to be run from a terminal. The agent should: +1) ensure dependencies installed +2) run the scripts +3) summarize results cleanly + +Install: +- `python -m venv .venv && source .venv/bin/activate` (or Windows equivalent) +- `pip install -r requirements.txt` + +## Commands + +### 1) Latest quote (stock/ETF/index) +Examples: +- `python scripts/market_quote.py AAPL` +- `python scripts/market_quote.py ^GSPC` +- `python scripts/market_quote.py VOO` + +### 2) Latest FX rate +Examples: +- `python scripts/market_quote.py USD/ZAR` +- `python scripts/market_quote.py EURUSD` +- `python scripts/market_quote.py GBP-JPY` + +### 3) Historical series (CSV to stdout) +Examples: +- `python scripts/market_series.py AAPL --days 30` +- `python scripts/market_series.py USD/ZAR --days 30` + +### 4) Watchlist summary (local file) +- Add tickers: `python scripts/market_watchlist.py add AAPL MSFT USD/ZAR` +- Remove: `python scripts/market_watchlist.py remove MSFT` +- Show summary: `python scripts/market_watchlist.py summary` + +--- + +# Output expectations (what you should return to the user) +- For quotes: price, change %, timestamp/source, and any caveats (like “FX updates daily”). +- For series: confirm date range, number of points, and show a small preview (first/last few rows). +- If rate-limited: explain what happened and retry with backoff OR advise to reduce frequency. + +--- + +# Safety / correctness +- Never claim “real-time” unless the provider is truly real-time. FX open access updates daily. +- Always cache responses and throttle repeated calls. +- If Yahoo blocks requests, propose a paid provider or increase cache TTL. diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..7ca5b78 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn78eqgx8n6vbvb1eqtce4bmx980axpn", + "slug": "finance", + "version": "1.1.2", + "publishedAt": 1769983031560 +} \ No newline at end of file diff --git a/providers.md b/providers.md new file mode 100644 index 0000000..0d9dea4 --- /dev/null +++ b/providers.md @@ -0,0 +1,39 @@ +# Providers & symbol formats + +## Stocks / ETFs / Indices (default: Yahoo via yfinance) +Pros: +- Very broad symbol coverage, no API key. +Cons: +- Unofficial access patterns can be rate-limited or break. + +Common examples: +- AAPL, MSFT, TSLA +- Indices: ^GSPC (S&P 500), ^DJI, ^IXIC +- ETFs: VOO, SPY, QQQ + +## FX (default: ExchangeRate-API Open Access) +Endpoint: https://open.er-api.com/v6/latest/ +- No API key +- Updates once per day +- Rate-limited +- Attribution required by their terms + +Symbol formats accepted by this skill: +- USD/ZAR +- USDZAR +- GBP-JPY +- EURUSD + +We normalize to BASE/QUOTE and fetch BASE->all then pick QUOTE. + +## Why not exchangerate.host by default? +It now requires an API key for most useful endpoints and has limited free quotas, so it’s not a great no-key default. + +## Paid providers (optional future upgrade) +If you need many symbols or frequent polling: +- Twelve Data +- Alpha Vantage +- Polygon +- Finnhub + +This skill includes environment variable placeholders, but does not implement these providers yet. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e49b8e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +yfinance>=0.2.40 +pandas>=2.0.0 +requests>=2.31.0 diff --git a/scripts/market_quote.py b/scripts/market_quote.py new file mode 100644 index 0000000..c5d7653 --- /dev/null +++ b/scripts/market_quote.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +market_quote.py + +Fetch the latest quote for: +- Stocks/ETFs/indices (via yfinance) +- FX pairs (via ExchangeRate-API open access) + +Usage: + python scripts/market_quote.py AAPL + python scripts/market_quote.py ^GSPC + python scripts/market_quote.py USD/ZAR +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple + +import requests + +CACHE_DIR = os.path.join(".cache", "market-tracker") +os.makedirs(CACHE_DIR, exist_ok=True) + +# Conservative defaults to reduce rate-limit pain. +DEFAULT_TTL_SECONDS_STOCKS = 60 +DEFAULT_TTL_SECONDS_FX = 12 * 60 * 60 # open FX endpoint updates daily + + +@dataclass +class Quote: + symbol: str + kind: str # "stock" | "fx" + price: float + currency: Optional[str] + asof_unix: int + source: str + extra: Dict[str, Any] + + +def _cache_path(key: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", key) + return os.path.join(CACHE_DIR, f"{safe}.json") + + +def _cache_get(key: str, ttl: int) -> Optional[Dict[str, Any]]: + path = _cache_path(key) + if not os.path.exists(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + payload = json.load(f) + if int(time.time()) - int(payload.get("_cached_at", 0)) <= ttl: + return payload + except Exception: + return None + return None + + +def _cache_set(key: str, payload: Dict[str, Any]) -> None: + payload["_cached_at"] = int(time.time()) + with open(_cache_path(key), "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + +def _parse_fx_pair(s: str) -> Optional[Tuple[str, str]]: + s = s.strip().upper() + s = s.replace(" ", "") + s = s.replace("-", "/") + if "/" in s: + parts = s.split("/") + if len(parts) == 2 and len(parts[0]) == 3 and len(parts[1]) == 3: + return parts[0], parts[1] + return None + # e.g. EURUSD + if len(s) == 6 and s.isalpha(): + return s[:3], s[3:] + return None + + +def _fetch_fx(base: str, quote: str) -> Quote: + cache_key = f"fx_{base}_{quote}" + cached = _cache_get(cache_key, DEFAULT_TTL_SECONDS_FX) + if cached: + return Quote(**cached) + + url = f"https://open.er-api.com/v6/latest/{base}" + r = requests.get(url, timeout=20) + r.raise_for_status() + data = r.json() + + if data.get("result") != "success": + raise RuntimeError(f"FX provider error: {data}") + + rates = data.get("rates") or {} + if quote not in rates: + raise RuntimeError(f"FX pair not supported by provider: {base}/{quote}") + + q = Quote( + symbol=f"{base}/{quote}", + kind="fx", + price=float(rates[quote]), + currency=quote, + asof_unix=int(data.get("time_last_update_unix") or time.time()), + source="ExchangeRate-API Open Access (open.er-api.com)", + extra={ + "base": base, + "quote": quote, + "time_last_update_utc": data.get("time_last_update_utc"), + "time_next_update_utc": data.get("time_next_update_utc"), + "attribution_required": True, + "provider": data.get("provider"), + "documentation": data.get("documentation"), + }, + ) + + _cache_set(cache_key, q.__dict__) + return q + + +def _fetch_stock(symbol: str) -> Quote: + cache_key = f"stk_{symbol}" + cached = _cache_get(cache_key, DEFAULT_TTL_SECONDS_STOCKS) + if cached: + return Quote(**cached) + + # Import inside to keep startup light if user only does FX. + import yfinance as yf + + t = yf.Ticker(symbol) + # Fast path: try fast_info first (often less fragile than scraping-heavy calls) + price = None + currency = None + extra: Dict[str, Any] = {} + + try: + fi = getattr(t, "fast_info", None) + if fi: + price = fi.get("lastPrice") or fi.get("last_price") + currency = fi.get("currency") + extra["exchange"] = fi.get("exchange") + extra["timezone"] = fi.get("timezone") + except Exception: + pass + + # Fallback: use 1d history + if price is None: + try: + hist = t.history(period="1d", interval="1m") + if hist is not None and len(hist) > 0: + price = float(hist["Close"].iloc[-1]) + except Exception as e: + raise RuntimeError(f"Yahoo/yfinance fetch failed for {symbol}: {e}") + + if price is None: + raise RuntimeError(f"Could not resolve price for symbol: {symbol}") + + # Try to enrich + try: + info = t.get_info() # can be heavier / more likely to rate-limit + if isinstance(info, dict): + currency = currency or info.get("currency") + extra["shortName"] = info.get("shortName") or info.get("longName") + extra["marketState"] = info.get("marketState") + except Exception: + # Non-fatal: still return price + pass + + q = Quote( + symbol=symbol, + kind="stock", + price=float(price), + currency=currency, + asof_unix=int(time.time()), + source="Yahoo Finance via yfinance (unofficial; best-effort)", + extra=extra, + ) + + _cache_set(cache_key, q.__dict__) + return q + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("symbol", help="Ticker (AAPL, ^GSPC) or FX pair (USD/ZAR, EURUSD)") + args = ap.parse_args() + + s = args.symbol.strip() + + fx = _parse_fx_pair(s) + if fx: + base, quote = fx + q = _fetch_fx(base, quote) + else: + q = _fetch_stock(s) + + # Print as JSON so the calling agent can format nicely. + print(json.dumps(q.__dict__, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/market_series.py b/scripts/market_series.py new file mode 100644 index 0000000..ed2d17e --- /dev/null +++ b/scripts/market_series.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +market_series.py + +Fetch historical series and print CSV to stdout. + +Stocks/ETFs/indices: yfinance history (daily) +FX: derived from ExchangeRate-API open endpoint (daily snapshots not provided here), + so we return a simple "latest only" line unless you add a historical FX provider. + +Usage: + python scripts/market_series.py AAPL --days 30 + python scripts/market_series.py ^GSPC --days 120 + python scripts/market_series.py USD/ZAR --days 30 +""" + +from __future__ import annotations + +import argparse +import sys +import re +from datetime import datetime, timedelta +from typing import Optional, Tuple + +import pandas as pd + + +def _parse_fx_pair(s: str) -> Optional[Tuple[str, str]]: + s = s.strip().upper() + s = s.replace(" ", "") + s = s.replace("-", "/") + if "/" in s: + parts = s.split("/") + if len(parts) == 2 and len(parts[0]) == 3 and len(parts[1]) == 3: + return parts[0], parts[1] + return None + if len(s) == 6 and s.isalpha(): + return s[:3], s[3:] + return None + + +def _stock_series(symbol: str, days: int) -> pd.DataFrame: + import yfinance as yf + + end = datetime.utcnow() + start = end - timedelta(days=days) + + df = yf.download(symbol, start=start.date().isoformat(), end=end.date().isoformat(), progress=False) + if df is None or len(df) == 0: + raise RuntimeError(f"No data returned for symbol: {symbol}") + + df = df.reset_index() + # Normalize column names (handle MultiIndex tuples from yfinance) + cols = {} + for c in df.columns: + if isinstance(c, tuple): + # Flatten tuple to string (e.g., ('Close', 'AAPL') -> 'close') + name = "_".join(str(x) for x in c if x).lower().replace(" ", "_") + else: + name = str(c).lower().replace(" ", "_") + cols[c] = name + df = df.rename(columns=cols) + return df + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("symbol", help="Ticker or FX pair") + ap.add_argument("--days", type=int, default=30) + args = ap.parse_args() + + fx = _parse_fx_pair(args.symbol) + if fx: + # Open access endpoint does not provide true historical in this mode. + # We intentionally fail loudly so the agent explains the limitation. + raise SystemExit( + "FX historical series is not supported with the no-key open endpoint. " + "Use latest quotes, or add a historical FX provider (paid or other source)." + ) + + df = _stock_series(args.symbol, args.days) + df.to_csv(sys.stdout, index=False) + + +if __name__ == "__main__": + main() diff --git a/scripts/market_watchlist.py b/scripts/market_watchlist.py new file mode 100644 index 0000000..cff0f56 --- /dev/null +++ b/scripts/market_watchlist.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +market_watchlist.py + +Maintain a local watchlist and summarize current quotes. + +Usage: + python scripts/market_watchlist.py add AAPL MSFT USD/ZAR + python scripts/market_watchlist.py remove MSFT + python scripts/market_watchlist.py list + python scripts/market_watchlist.py summary +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import List + +WATCHLIST_PATH = os.path.join(".cache", "market-tracker", "watchlist.json") +os.makedirs(os.path.dirname(WATCHLIST_PATH), exist_ok=True) + + +def load_watchlist() -> List[str]: + if not os.path.exists(WATCHLIST_PATH): + return [] + with open(WATCHLIST_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + items = data.get("items") or [] + # de-dupe, preserve order + seen = set() + out = [] + for x in items: + x = str(x).strip() + if x and x not in seen: + out.append(x) + seen.add(x) + return out + + +def save_watchlist(items: List[str]) -> None: + with open(WATCHLIST_PATH, "w", encoding="utf-8") as f: + json.dump({"items": items}, f, ensure_ascii=False, indent=2) + + +def cmd_add(symbols: List[str]) -> None: + items = load_watchlist() + for s in symbols: + s = s.strip() + if s and s not in items: + items.append(s) + save_watchlist(items) + print(json.dumps({"ok": True, "items": items}, indent=2)) + + +def cmd_remove(symbols: List[str]) -> None: + items = load_watchlist() + rm = set(s.strip() for s in symbols if s.strip()) + items = [x for x in items if x not in rm] + save_watchlist(items) + print(json.dumps({"ok": True, "items": items}, indent=2)) + + +def cmd_list() -> None: + print(json.dumps({"items": load_watchlist()}, indent=2)) + + +def cmd_summary() -> None: + items = load_watchlist() + if not items: + print(json.dumps({"items": [], "summary": []}, indent=2)) + return + + results = [] + for sym in items: + # Call market_quote.py to keep logic centralized + p = subprocess.run( + [sys.executable, os.path.join(os.path.dirname(__file__), "market_quote.py"), sym], + capture_output=True, + text=True, + ) + if p.returncode != 0: + results.append({"symbol": sym, "error": p.stderr.strip() or p.stdout.strip()}) + continue + try: + results.append(json.loads(p.stdout)) + except Exception: + results.append({"symbol": sym, "error": "Invalid JSON from market_quote.py", "raw": p.stdout[:500]}) + + print(json.dumps({"items": items, "summary": results}, ensure_ascii=False, indent=2)) + + +def main() -> None: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + + sp_add = sub.add_parser("add") + sp_add.add_argument("symbols", nargs="+") + + sp_rm = sub.add_parser("remove") + sp_rm.add_argument("symbols", nargs="+") + + sub.add_parser("list") + sub.add_parser("summary") + + args = ap.parse_args() + + if args.cmd == "add": + cmd_add(args.symbols) + elif args.cmd == "remove": + cmd_remove(args.symbols) + elif args.cmd == "list": + cmd_list() + elif args.cmd == "summary": + cmd_summary() + else: + raise SystemExit("Unknown command") + + +if __name__ == "__main__": + main()