Files
Hermes-Skills/dividend-investing/references/akshare-dividend-history-and-push-enrichment.md
T

212 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AKShare 股息历史 API + Push 字段扩展模式 (2026-07-29 实战)
## 1. AKShare 两套历史股息 API
A 股 和 港股 用**不同的接口**(2026-07-29 验证可用):
| 市场 | API | 输入 | 列 | 来源 |
|------|------|------|------|------|
| **A 股** (6 位) | `ak.stock_history_dividend_detail(code, indicator='分红')` | 6 位代码 | 公告日期/送股/转增/派息/进度/除权除息日/股权登记日/红股上市日 | 新浪财经 |
| **港股** (5 位) | `ak.stock_hk_dividend_payout_em(code)` | 5 位代码 | 最新公告日期/财政年度/分红方案/分配类型/除净日/截至过户日/发放日 | 东方财富港股 |
**注意**: 美股 Nasdaq API (`api.nasdaq.com/api/calendar/dividends?date=YYYY-MM-DD`) **只返回当天** — 没有历史。
要美股历史需 `stock_us_dividend_em` (akshare) 或 yfinance。
### 1.1 美股"连续(估年)"近似列 (2026-07-30 实战)
**问题**: 美股没有可靠的**历史派息年数 API**(Nasdaq 当天, yfinance 国内 VPS 不稳定)。
用户 cron `366934c1474c` 美股推送缺"派息稳定"列。
**近似方案**: `连续(估年) = 年化 / 每股派` —— 当年总派息 ÷ 每股单次派息 ≈ 派息频率(可能 4=季度派, 12=月派, 24=双周派)。
**坑**: 这是**派息频率**, **不是**真实连续年数!
- STRC 月派 ×12 = 24 (24 表示一年 24 次派息, 不是 24 年连续)
- 季度派票显示 4.0 (合理)
- 半年派票显示 2.0 (合理)
**实现** (`dividend_alert.py` fmt 函数):
```python
cont_years = ann / d if d > 0 else 0 # ann=年化派息总额, d=每股派
if cont_years >= 10:
cont_str = f'🔥{cont_years:.0f}' # 可能假阳性(月派)
elif cont_years >= 5:
cont_str = f'⚡{cont_years:.0f}'
elif cont_years >= 1:
cont_str = f'{cont_years:.1f}'
else:
cont_str = '-'
```
**正确做法**: 推送时**显式标注估算** + 提示月派票会偏高:
```
⚠️ 连续(估年) = 年化 / 每股派 (月派票会偏高, 仅作参考; 真连续年数需 yfinance API 不稳定)
```
**未来**: 真要做精确,装 yfinance + `yf.Ticker(code).dividends` 拉历史, 算真实连续年数 (2026-07-30 国内 VPS 装 yfinance 失败, 暂用近似)。
### 1.1 helper 实现
```python
def get_dividend_history(code):
"""返回: (annual_count_avg, continuous_years, is_recent)
code: 6 位 A 股 OR 5 位 港股
"""
is_hk = code.isdigit() and len(code) == 5
is_a = code.isdigit() and len(code) == 6
try:
if is_hk:
df = ak.stock_hk_dividend_payout_em(code)
elif is_a:
df = ak.stock_history_dividend_detail(code)
else:
return (0, 0, False)
# 过滤 + 提取年份 (用除权日 / 除净日)
done = df.dropna(subset=['除权除息日' if is_a else '除净日']).copy()
if is_a:
done = done[(done['进度'] == '实施') & (done['送股'] == 0) & (done['转增'] == 0)]
if done.empty:
return (0, 0, False)
date_col = '除权除息日' if is_a else '除净日'
done['year'] = done[date_col].astype(str).str[:4].astype(int)
# 平均年度派息次数 (近 3 年)
last_3y = done[done['year'] >= 2024]
annual_count = round(last_3y.groupby('year').size().mean(), 1) if not last_3y.empty else 0
# 连续派息年数
years_with_div = sorted(set(done['year'].tolist()))
continuous = 0
for y in range(2025, 2010, -1):
if y in years_with_div:
continuous += 1
else:
break
return (annual_count, continuous, not done[done['year'] == 2026].empty)
except Exception:
return (0, 0, False)
```
**性能**: 5 票 ~1.2s, 12 票 ~3s (cron 推送时间内可接受)。
## 2. Push 字段扩展: 次/年 + 连续(年) + vs MA50
dividend_alert.py 在 2026-07-29 加了 3 列,显著提升推送价值。
### 2.1 最终表结构 (8 列)
| 票 | 名称 | 每10股派 | 现价 | 股息率 | 次/年 | 连续(年) | vs MA50 |
**字段说明**:
- **次/年**: 近 3 年平均派息次数。1.0 = 年度派, 1.5 = 半年+年度, 1.7 = 多季度(高息稳定)
- **连续(年)**: 派息连续年数。🔥10+ = 10 年以上稳定, ⚡5-9 = 5-9 年
- **vs MA50**: 当前价对 50 日均线的偏离 (用长桥 K 线算)
### 2.2 emoji 等级系统
| 状态 | 阈值 | emoji |
|------|------|------|
| 连续派息 ≥10 年 | 10+ | 🔥 |
| 连续派息 5-9 年 | 5-9 | ⚡ |
| 离 MA50 > +5% | 显著高估 | 🟢(但下行风险) |
| 离 MA50 0 ~ +5% | 略高 | 🟡 |
| 离 MA50 -10 ~ 0 | 略低 | 🔴(买入机会) |
| 离 MA50 < -10% | 大幅低于 | ⛔(危险/可能趋势反转) |
⚠️ 注意: 🟢/🔴 在此场景**语义反转** — 通常用 🟢 表示"好",但 vs MA50 偏离=大代表下行风险高。需在推送头部加注释说明。
## 3. ⚠️ 必看 Bug: `ma_off` double *100
**陷阱**: 在 `cn_dividend_buy_timing.py` 中我犯了这个错:
```python
# 错: 显示时再 *100
'ma_off': (current/ma50-1)*100, # 存储为 0.05 (= 5%)
# ...
L.append(f"| 离均线 | {'+' if r['ma_off']>=0 else ''}{ r['ma_off']*100:.1f}% |")
# 输出: +982.7% (应该是 +9.8%)
```
**正解**: 要么存 fraction (0.05), 要么存 percent (5.0), **但只能 *100 一次**。
```python
# ✅ 选 1: 存 percent, 显示直接用
'ma_off_pct': (current/ma50-1)*100,
# 显示: f"{'+' if r['ma_off_pct']>=0 else ''}{r['ma_off_pct']:.1f}%"
# ✅ 选 2: 存 fraction, 显示时 *100
'ma_off': (current/ma50-1), # 0.05
# 显示: f"{r['ma_off']*100:+.1f}%" # 显式 + 避免符号混乱
```
## 4. 数据累积 + 循环外拼装表 模式
**为什么**: 在循环内 `lines.append(f"| ...")` 难维护,容易出现 **状态混乱 bug**(像上面 ma_off)。
**模式**:
```python
# 1. 累积 (在循环内)
row_data = []
for c in candidates[:8]:
feats = compute(c)
row_data.append({
'code': c['code'],
'name': c['name'][:6],
'price': c['price'],
'ma_off_pct': (c['price']/feats['ma50']-1)*100,
# ... 一行所有字段
})
# 2. 输出 (在循环外)
if row_data:
cols = [r['code'] for r in row_data]
n = len(cols)
hdr = "| 项目 | " + " | ".join(cols) + " |"
sep = "|:---|" + "|".join([":---"] * n) + "|"
L.append(hdr)
L.append(sep)
L.append("| 现价 | " + " | ".join([f"{r['price']:.2f}" for r in row_data]) + " |")
L.append("| 离均线 | " + " | ".join([f"{r['ma_off_pct']:+.1f}%" for r in row_data]) + " |")
# ...
```
**好处**:
- 字段名在 dict 里出现一次
- 显示公式跟存储值**显式对应**(可单元测试)
- 多张表 (基本面/点位/性价比) 都是同一模式
- bug 容易被肉眼发现
## 5. 名称列单独一行 (横向表 4-8 列最佳实践)
**问题**: 横向表列是票代码 (601288),**但 QQ 用户滑动时想看到名称**(农业银行)。
**方案**: 名称作为单独行,放在代码行之上:
```python
L.append(name_hdr) # | 名称 | 农业银行 | 工商银行 | ...
L.append(name_sep)
L.append(hdr) # | 项目 | 601288 | 601398 | ...
L.append(sep)
L.append("| 现价 | ...")
```
**效果**:
```
| 名称 | 农业银行 | 工商银行 | 中国神华 | ...
|:---|:---|:---|:---|:---|
| 项目 | 601288 | 601398 | 601088 | ...
|:---|:---|:---|:---|:---|
| 现价 | 7.05 | 8.14 | 45.62 | ...
```
QQ mobile 用户**滑动时同时看到名称和代码**,**不需要查文档**。
## 6. 不要做
- ❌ 把每行字段全写成一行 (无换行, QQ 滚得累)
- ❌ 列里塞 `f"{x:.6f}"` 这种 6 位小数
- ❌ 列名用全角字符 (渲染乱)
- ❌ 一次性输出 20+ 列 (挤, 滑动难受)
- ❌ 混用 emoji 前缀 (⭐600809 和 600809 ⭐ 风格不一致)
- ❌ 在表格里用 `|` 字符 (转义难, 破表)
- ❌ `head -c 1800` 字节截断 UTF-8 文本 (Chinese 字符 3 字节,可能切坏)
- ❌ **每个数值单元都 *100 两次**(ma_off 教训)