第 7 章 · 01 yahooapi 与限流容错 本节摘要:本节精读 (约 260 行)——AutoHedge 股票端的数据来源。它用 库封装 Yahoo Finance,提供四个对外函数: (报价)、 (历史 K 线)、 (基本面+财报)、 (一锅端)。本节的重点不是「能拿到数据」,而是拿不到时怎么优雅降级:Yahoo 对免费请求频次很严,动不动返回 429(Too Many Requests),yfinance 此时抛的不是普通异常而是 (因为返回的是 HTML 错误页)。本项目专门捕获这一族异常,先拉不容易被限的 history(走 chart API),再拉容易被限的 info(走 quoteSummary),失败就返回部分数据并塞一个 字段,而不是整体崩溃。
本节摘要:本节精读
autohedge/tools/yahoo_api.py(约 260 行)——AutoHedge 股票端的数据来源。它用yfinance库封装 Yahoo Finance,提供四个对外函数:get_stock_quote(报价)、get_historical_prices(历史 K 线)、get_quote_summary(基本面+财报)、get_all_stock_data(一锅端)。本节的重点不是「能拿到数据」,而是拿不到时怎么优雅降级:Yahoo 对免费请求频次很严,动不动返回 429(Too Many Requests),yfinance 此时抛的不是普通异常而是JSONDecodeError(因为返回的是 HTML 错误页)。本项目专门捕获这一族异常,先拉不容易被限的 history(走 chart API),再拉容易被限的 info(走 quoteSummary),失败就返回部分数据并塞一个_warning字段,而不是整体崩溃。这是限流容错的典范写法。
内容来源:原项目源码
autohedge/tools/yahoo_api.py,逐行精读并套用体系化模板。
⚠️ 现实澄清:yfinance 是「非官方」抓 Yahoo 数据的库,Yahoo 随时可能改接口或加限流,数据可靠性不如付费 API。本项目用它做教学足够,但生产环境应换稳定数据源(如 polygon.io 真实订阅)。
阅读完本节,你应当能够:
yahoo_api.py 的核心函数(quote/history/summary/all)。JSONDecodeError,以及本项目怎么捕获它。_warning 字段)与整体抛错的取舍。_df_to_json_serializable 如何把 pandas DataFrame 转成 JSON 字符串供 LLM 读。yahoo_api.py 对外暴露四个函数,逐层加码:
| 函数 | 拉什么 | 走哪个 API | 被限流概率 |
|---|---|---|---|
get_stock_quote |
报价(price/volume/day range) | history(chart)+ info(quoteSummary) | 中 |
get_historical_prices |
历史 OHLCV | history(chart) | 低 |
get_quote_summary |
基本面+财报 | info + financials | 高 |
get_all_stock_data |
以上全要 | 全部 | 最高 |
统一的返回约定:返回 JSON 字符串(不是 dict),失败时返回 "{}" 或 "[]",绝不抛异常给上层。这和上一章 exa_search 的约定一致——工具返回字符串供 LLM 读,失败也返回安全的兜底字符串。
理解本文件的核心,是先搞清「Yahoo 限流在代码里长什么样」。文件开头(第 16-25 行)定义了限流异常清单:
# Errors from yfinance when Yahoo returns 429 or non-JSON (rate limit / block) _RATE_LIMIT_EXCEPTIONS: tuple = (json.JSONDecodeError,) try: import requests _RATE_LIMIT_EXCEPTIONS = ( *_RATE_LIMIT_EXCEPTIONS, requests.HTTPError, ) except ImportError: pass
逐行解读:
json.JSONDecodeError 算作限流异常。注释写得很明白——Yahoo 被限流时返回的不是 JSON,而是 HTML 错误页(如 429 Too Many Requests),yfinance 尝试 json.loads() 一个 HTML 字符串,就会抛 JSONDecodeError。requests,把 requests.HTTPError 也加进清单(429 的标准 HTTP 表现)。用 try/except ImportError 包住,是为了在没装 requests 的环境也能跑——这是「软依赖」处理。💡 核心心法:限流检测的关键不是「抓 429 这个数字」,而是「响应不是合法 JSON」。因为 yfinance 内部多套了一层,真正冒到调用方的往往是
JSONDecodeError而非原始的 429。抓异常类型要顺着实际的「症状」来,而不是文档里的「病因」。
基于上面的异常清单,文件定义了两个「安全拉取器」,限流时返回空值而非炸掉。
_safe_info(第 44-65 行)——拉 ticker.info:
def _safe_info(ticker: yf.Ticker) -> tuple[dict[str, Any], Optional[str]]: try: info = ticker.info return (info or {}), None except _RATE_LIMIT_EXCEPTIONS as e: logger.warning("Yahoo rate limit or invalid response (info): {}", e) return ( {}, "Rate limited or invalid response from Yahoo (429).", ) except Exception as e: logger.debug("get info failed: {}", e) return {}, str(e)
要点:
(info_dict, error_message_or_None)——成功时错误为 None,失败时 info 为空字典、错误为说明文字。_RATE_LIMIT_EXCEPTIONS,返回带友好提示的错误信息。debug(因为 info 拉失败太常见,不值得刷 warning 日志)。_safe_financials(第 68-96 行)——拉一系列财报属性:
def _safe_financials(ticker: yf.Ticker) -> dict[str, Any]: out: dict[str, Any] = {} attrs = [ ("balance_sheet", "balance_sheet"), ("quarterly_balance_sheet", "quarterly_balance_sheet"), ("income_stmt", "income_stmt"), ("quarterly_income_stmt", "quarterly_income_stmt"), ("cashflow", "cashflow"), ("quarterly_cashflow", "quarterly_cashflow"), ("recommendations", "recommendations"), ("calendar", "calendar"), ] for name, attr in attrs: try: val = getattr(ticker, attr, None) if val is not None: if hasattr(val, "to_json"): out[name] = _df_to_json_serializable(val) else: out[name] = val except _RATE_LIMIT_EXCEPTIONS: continue # ← 限流就跳过这一项,继续拉下一个 except Exception: continue return out
精妙之处在 continue:8 个财报属性逐个拉,哪个限流了就跳过,剩下的继续拉。最终返回的 dict 里只有「成功拉到」的那些键。这是「部分数据返回」的精髓——能拿多少拿多少,不因一项失败放弃全部。
💡 设计对比:朴素写法是「一个 try 包住全部,失败就整体返回空」。本文件改成「逐项 try、逐项跳过」,在限流场景下能多救回一大半数据。代价是代码变长、循环里有 8 个 try,可读性略降——但换来健壮性,值得。
yfinance 返回的 history/financials 是 pandas DataFrame,LLM 读不了 DataFrame,必须转成 JSON。_df_to_json_serializable(第 28-41 行):
def _df_to_json_serializable(df: Any) -> Any: if df is None or (hasattr(df, "empty") and df.empty): return None try: import pandas as pd if isinstance(df, pd.DataFrame): return json.loads( df.to_json(orient="split", date_format="iso") ) return df except Exception: return None
逐行:
"{}",让上层判断 None 就不往输出里塞这个键。to_json(orient="split"):pandas 的 split 格式把 DataFrame 序列化成 {"columns": [...], "index": [...], "data": [[...]]}——结构清晰、冗余少。date_format="iso":日期转 ISO 字符串(如 2024-01-15T00:00:00.000Z),避免序列化成时间戳数字 LLM 读不懂。json.loads 再转回 dict:为什么先 to_json 成字符串再 loads 回来?因为最终要 json.dumps 整个输出,提前转成原生 dict 能让外层 default=str 兜底处理残余的奇怪类型。import pandas 放在函数内,没装 pandas 也能 import 本模块(只是调用时返回 None)。看第一个对外函数(第 99-125 行):
def get_stock_quote(ticker: str) -> str: if not ticker or not ticker.strip(): logger.warning("get_stock_quote: ticker is empty") return "{}" symbol = ticker.strip().upper() try: t = yf.Ticker(symbol) hist = t.history(period="5d", interval="1d") # ← 先拉 history(chart API) info, info_err = _safe_info(t) # ← 再拉 info(易被限) out: dict[str, Any] = {"symbol": symbol, "info": info} if info_err: out["_warning"] = info_err # ← 把限流提示塞进输出 if hist is not None and not hist.empty: out["history"] = _df_to_json_serializable(hist) return json.dumps(out, default=str) except Exception as e: logger.error("get_stock_quote failed: {}\n{}", e, traceback.format_exc()) return "{}"
关键顺序:先 history 后 info。为什么?
history 走 Yahoo 的 chart API(query1.finance.yahoo.com/v8/finance/chart),这个端点限流较松。info 走 quoteSummary API(query2.finance.yahoo.com/v10/finance/quoteSummary),这个端点限流严,是 429 的重灾区。所以先抢不容易被限的 history,即使 info 被限,也至少拿到了最近 5 天的 K 线。_warning 字段把限流事实告诉下游(LLM 看到就知道「info 是空的因为被限流了」,而不是误以为这只股票没数据)。
💡 部分数据返回的核心:
out这个 dict 是「能塞什么塞什么」,最后json.dumps整体序列化。哪怕 info 空、history 有,也返回一个半成品,而不是抛错。LLM 拿到半成品还能勉强分析;拿到异常就只能停。
第 128-166 行,只拉 history,所以最不容易被限:
def get_historical_prices( ticker: str, interval: str = "1d", # 默认日 K range_str: str = "1mo", # 默认近 1 个月 ) -> str: ... t = yf.Ticker(symbol) hist = t.history(period=range_str, interval=interval) out: dict[str, Any] = {"symbol": symbol} out["history"] = _df_to_json_serializable(hist) return json.dumps(out, default=str)
参数支持 yfinance 的全部 interval(1m/2m/5m/15m/30m/1h/1d/1wk/1mo)和 range(1d/5d/1mo/3mo/6mo/1y/2y/5y/10y/ytd/max)。因为只动 chart API,基本不会触发 429——这是「我只要 K 线」时最稳的选择。
第 169-197 行,要 info + 全部财报,最容易撞限流:
def get_quote_summary(ticker: str, modules=None) -> str: ... t = yf.Ticker(symbol) info, info_err = _safe_info(t) # info 单独拉 out: dict[str, Any] = {"symbol": symbol, "info": info} if info_err: out["_warning"] = info_err financials = _safe_financials(t) # 8 项财报逐个拉 out.update(financials) return json.dumps(out, default=str)
注意 modules 参数被忽略(注释明说)——签名留着是为了兼容旧的「按模块名拉」用法,实际实现是全拉。这种「保留参数但不生效」的写法在生产代码里是妥协,教学上要警惕。
第 200-255 行,把前三者全要一遍。重点逻辑:
t = yf.Ticker(symbol) # Fetch history first (chart API is less rate-limited than quoteSummary) hist = t.history(period=history_range, interval="1d") # ← 还是先 history history_serialized = _df_to_json_serializable(hist) info, info_err = _safe_info(t) # ← 再 info ... quote_data: dict[str, Any] = {"symbol": symbol, "info": info} if info_err: quote_data["_warning"] = info_err if include_history and history_serialized: quote_data["history"] = history_serialized ... out: dict[str, Any] = { "symbol": symbol, "quote": quote_data, "quote_summary": summary_data, } if include_history: out["history"] = history_serialized return json.dumps(out, default=str)
观察:history 被序列化一次,但分别塞进 quote_data["history"] 和顶层 out["history"]——有点冗余(同一份数据存两份),但好处是无论下游取 out["quote"] 还是 out["history"] 都能拿到 K 线。注释再次强调「history first, chart API less rate-limited」——这是贯穿全文件的设计原则。
把全文件的容错策略归纳成一张表:
| 场景 | 朴素做法 | 本文件做法 |
|---|---|---|
| Yahoo 返回 429 | 整个函数抛异常 | 抓 JSONDecodeError/HTTPError,返回空 dict + _warning |
| info 拉失败 | 返回错误 | _safe_info 返回 ({}, "Rate limited...") |
| 某项财报拉失败 | 整个 summary 失败 | _safe_financials 跳过这一项,继续拉 |
| ticker 为空 | yfinance 抛怪异错 | 提前 return "{}" 并 warning |
| 整个函数异常 | 抛给上层 | 最外层 try/except 兜底,return "{}" |
⚠️ 现实澄清:这套容错写得相当专业,但它解决的只是「Yahoo 免费数据源的脆弱性」——本质问题是用了不稳定的非官方数据源。生产环境不会和 Yahoo 429 斗智斗勇,而是直接订阅付费、有 SLA 的数据 API。本文件的教学价值在容错模式,不在数据源选择。
"{}",绝不抛异常。JSONDecodeError;本文件把它和 requests.HTTPError 列入 _RATE_LIMIT_EXCEPTIONS。_safe_info 返回二元组 (dict, error_msg);_safe_financials 逐项 try、限流就 continue,能拿多少拿多少。_warning 字段告知下游被限流。_df_to_json_serializable 用 orient="split" + ISO 日期,空表返回 None。下一节,我们看 AutoHedge 怎么拉 Solana 链上代币的价格与搜索——
jupiter_price.py和jupiter_search.py,这两个工具走 Jupiter 的官方 API,比 Yahoo 稳得多。