fix(data): parse yfinance dividends from the single-column DataFrame yfinance 1.2.x returns (#1867)

yfinance 1.2.x returns Ticker.dividends as a single-column DataFrame instead of a
Series. YfinanceFundamentalAdapter iterated it with `.items()`, which for a DataFrame
yields (column_name, Series) — so `_safe_float(Series)` returned None, every dividend
event was dropped, and the TTM cash/count silently fell back to
`info.trailingAnnualDividendRate` (e.g. 24.0 with "0 次") instead of the true TTM sum.
Coerce to a Series before iterating.

Affects every yfinance-backed market (US/HK/JP/KR/TW); surfaced on a live TW report
(2330.TW showed 24.0 / 0 payouts vs the real ~22 across 4). + a regression test that
feeds a single-column DataFrame and asserts the events + TTM sum are parsed.

Co-authored-by: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
Wenyu Chiou
2026-07-02 18:52:08 +08:00
committed by GitHub
parent 48b9e18a5f
commit 0f57e4e2e5
3 changed files with 33 additions and 0 deletions

View File

@@ -274,6 +274,12 @@ class YfinanceFundamentalAdapter:
result["errors"].append(f"dividends:{type(exc).__name__}")
div_series = None
if div_series is not None and not div_series.empty:
# yfinance 1.2.x returns Ticker.dividends as a single-column DataFrame, not a
# Series; coerce so `.items()` yields (timestamp, value) rather than
# (column_name, Series). Otherwise every event is dropped (`_safe_float(Series)`
# -> None) and TTM silently falls back to the annual-rate estimate.
if hasattr(div_series, "columns"):
div_series = div_series.iloc[:, 0]
try:
# Index is timezone-aware (ex-dividend date)
cutoff = pd.Timestamp.now(tz=div_series.index.tz) - pd.Timedelta(days=365)

View File

@@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] #1743 Phase 4 修正 `opencode_cli` 静态指令,避免全局 JSON-only 约束影响 `generate_text()` 与大盘复盘自由文本输出。
- [文档] #1743 Phase 4 同步本地 CLI backend 隐私/部署边界local CLI 不是离线模型Docker/CI/远端需自行安装登录DSA 不读取 Claude/OpenCode credential 文件。
- [新功能] 台股报告接入三大法人tw 个股分析报告的 institution 区块改为展示 TWSE T86 / TPEx 三大法人原始买卖超净额(外资/投信/自营/合计,单位:股tw-only、严格 additiveA股/港股/美股/日韩股 offshore 流程字节不变、fail-open取不到数据维持 not_supported绝不中断分析不接 Web、不派生 capital_flow_signal、不改评分权重或 schema。
- [修复] yfinance 分红解析yfinance 1.2.x 将 `Ticker.dividends` 返回为单列 DataFrame而非 Series适配器按 Series 迭代 `.items()` 导致每笔分红被丢弃、TTM 每股分红与分红次数静默退回 `trailingAnnualDividendRate` 年率估算(如 24.0 / 分红次数 0而非真实 TTM 合计);现在迭代前强制转为 Series。影响所有 yfinance 后缀市场(美股/港股/日韩股/台股)。
- [改进] 台股报告完整消费三大法人tw 个股报告的 `institution` 区块现会在报告中渲染三大法人净买卖超表格,并注入 LLM 分析 prompt 作为台股筹码过滤器(此前仅接入数据层,报告与 prompt 均未消费,导致报告出现「筹码结构:数据缺失」);同时三大法人整市场抓取改用剩余 stage 预算而非较小的 per-symbol fetch 超时,避免单股/首档分析因冷抓取(~4-5s超时而降级为 not_supported。tw-only、严格 additive、fail-open。
- [修复] 台股财务金额币别标示TWD 金额此前落入默认「元」(在 A 股语境易误读为人民币)`_CURRENCY_SUFFIX` 补入 TWD→「新台币」营业收入/归母净利润/经营现金流/每股现金分红均正确标注新台币。
- [改进] 台股三大法人 fetcher 韧性加固:(1) 接入熔断器(复用 `realtime_types.CircuitBreaker`,按市场 twse/tpex 分流,连续失败 3 次→冷却 ~5min→半开探测TWSE/TPEx 端点异常时快速跳过网络往返并 fail-open避免端点故障时每档个股都付 timeout+throttle(2) TPEx OpenAPI 仅服务最新交易日,调用方传入与服务日期不符的明确日期时改为 fail-open返回无数据避免静默返回错日资料。

View File

@@ -136,6 +136,32 @@ class TestYfinanceFundamentalAdapter(unittest.TestCase):
],
)
def test_dividends_parsed_from_single_column_dataframe(self) -> None:
# yfinance 1.2.x returns Ticker.dividends as a single-column DataFrame, not a
# Series. Without coercion, `.items()` yields (column_name, Series), every event
# is dropped, and TTM silently falls back to the annual-rate estimate — the real
# bug seen on live US/HK/JP/KR/TW reports (24.0 / "0 次" instead of the true sum).
idx = pd.DatetimeIndex(
["2025-08-11", "2025-11-10", "2026-02-09", "2026-05-11"],
tz="America/New_York",
)
dividends_df = pd.DataFrame({"Dividends": [0.26, 0.26, 0.26, 0.27]}, index=idx)
info = {
"currency": "USD",
"financialCurrency": "USD",
"currentPrice": 210,
"trailingAnnualDividendRate": 99.0, # a WRONG fallback we must NOT fall back to
}
ticker = _build_mock_ticker(info, dividends=dividends_df)
with patch("yfinance.Ticker", return_value=ticker):
bundle = YfinanceFundamentalAdapter().get_fundamental_bundle("AAPL")
div = bundle["earnings"]["dividend"]
self.assertEqual(div["ttm_event_count"], 4) # was 0 before the fix
self.assertEqual(len(div["events"]), 4)
# summed TTM (0.26*3 + 0.27 = 1.05), NOT the trailingAnnualDividendRate 99.0 fallback
self.assertAlmostEqual(div["ttm_cash_dividend_per_share"], 1.05, places=2)
def test_falls_back_to_info_when_statements_only_have_4_quarters(self) -> None:
"""yfinance default is 4 quarters → statement-derived YoY refuses to use QoQ.