feat: dividend-investing 迁移脚本到 skill 仓库

【背景】之前 dividend_alert.py 和 shell wrapper 在 ~/.hermes/scripts/ (本地)
不在 skill 仓库, SKILL.md 用 external: 引用. 修改不版本化.

【迁移】
- scripts/dividend_alert.py (13333 bytes)
- scripts/dividend_alert_cn_hk.sh (proxychains4 + 过滤日志)
- scripts/dividend_alert_us.sh
- 删 ~/.hermes/scripts/* 三份旧副本
- cron jobs.json script 路径更新到 skill 仓库绝对路径
  - 789a7710b1cf (A股+港股) → .../dividend-investing/scripts/dividend_alert_cn_hk.sh
  - 366934c1474c (美股) → .../dividend-investing/scripts/dividend_alert_us.sh

【SKILL.md 更新】
- scripts 段从 'external' 改为正常引用
- 路径从绝对 ~/.hermes/scripts 改为相对 scripts/

【测试】cn_hk 脚本从新位置跑, 输出正确 (A股: 6 港股: 2), 无 [proxychains] 日志
This commit is contained in:
2026-07-24 11:17:52 +08:00
parent 8b70ce3048
commit cb752eeb88
4 changed files with 383 additions and 3 deletions
+2 -3
View File
@@ -10,14 +10,13 @@ metadata:
tags: [trading, dividends, stocks, a-shares, hk-stocks, us-stocks, cron, dividend-stability]
related_skills: [tonghuashun, longbridge-python-sdk, stock-analysis]
scripts:
- scripts/dividend_alert.py: "python3 ~/.hermes/scripts/dividend_alert.py — daily cron job; runs via cronjob no_agent=true (script output delivered verbatim)"
- scripts/stability_scorer.py: "score_dividend_stability(symbol, market) → dict (years/CAGR/volatility → 0-100). Used by dividend_alert to label each A-share with stability stars."
- "scripts/dividend_alert.py: 'Daily dividend scan. Cron jobs (id `789a7710b1cf` A股+港股, `366934c1474c` 美股) call `scripts/dividend_alert_cn_hk.sh` and `scripts/dividend_alert_us.sh` (proxychains4 + 过滤 [proxychains] 日志). Contains score_dividend_stability() with _stability_cache per symbol per run. Edit here in skill repo to update.'"
references:
- dividend-yield-rate-sort: "分红扫描按股息率% 倒序(用户偏好 2026-07-13)"
- fill-gap-timing: "填权时间线数据 + 抓取分析"
- dividend-yield-arbitrage: "股息率 vs 融资成本套息"
- cron-schedule-and-push-timing: "cron schedule / Beijing-time push timing (why 11:00 BJT)"
- dividend-stability-score: "5 维评分 (派息年数/CAGR/波动/最近/连续) 综合稳定性 0-100 + 5 星等级"
- dividend-stability-score: "5 维评分 (派息年数/CAGR/波动/最近/连续) 综合稳定性 0-100 + 5 星等级; 数据源 akshare stock_history_dividend_detail(indicator='分红'); 缓存避免重复查询; 2026-07-22 新增"
requires:
- python3 + akshare (pip install akshare)
- python3 + requests (stdlib)
@@ -0,0 +1,357 @@
#!/usr/bin/env python3
"""
股息登记日前一天提醒 — 纯 LongPort 版
用 LongPort 同时查价格和行情,美股走 Nasdaq API。
"""
import os, sys, json, re, time
from datetime import datetime, timedelta
import requests
import pandas as pd
# 关掉长桥走 SOCKS 代理 (历史 bug)
for k in ['http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY']:
os.environ.pop(k, None)
import requests
BJ_TZ = timedelta(hours=8)
HEADERS = {'User-Agent':'Mozilla/5.0'}
def bj_now():
return datetime.utcnow() + BJ_TZ
def next_trading_day(d):
while d.weekday() >= 5:
d += timedelta(days=1)
return d
# ─── LongPort 初始化 ───
# 2026-07-21 改用 longport_http module 替代 SDK (WSS 不稳定)
# batch_quote 直接调 longport_http.get_quotes, 不再依赖 SDK
_lp = True # 占位兼容旧代码
def get_lp():
"""始终返回 True (longport_http module 替代 SDK)"""
return True
# ─── A股 + 港股: AKShare 百度除权数据 ───
def fetch_ah(target_date):
import akshare as ak
ds = target_date.strftime('%Y%m%d')
time.sleep(0.5)
try:
df = ak.news_trade_notify_dividend_baidu(date=ds)
res = []
for _, row in df.iterrows():
code = str(row['股票代码']).strip()
dr = str(row.get('分红','') or '').strip()
xch = str(row.get('交易所','') or '').strip()
name = str(row.get('股票简称','') or '').strip()
val = 0.0
if dr:
dr = dr.replace(',','')
nums = re.findall(r'[\d.]+', dr)
if nums: val = float(nums[0])
if val <= 0: continue
mkt = 'A' if xch != 'HK' else 'HK'
if xch == 'BJ': mkt = 'BJ'
res.append({'code':code,'name':name,'market':mkt,'div':val})
return res
except Exception as e:
return [{'error':str(e)}]
# ─── 美股除权: Nasdaq API ───
def fetch_us(target_date):
ds = target_date.strftime('%Y-%m-%d')
try:
r = requests.get(f'https://api.nasdaq.com/api/calendar/dividends?date={ds}',
headers=HEADERS, timeout=15)
rows = r.json().get('data',{}).get('calendar',{}).get('rows',[])
res = []
for row in rows:
sym = row.get('symbol','').strip()
rate = float(row.get('dividend_Rate',0) or 0)
ann = float(row.get('indicated_Annual_Dividend',0) or 0)
rec = row.get('record_Date', row.get('dividend_Ex_Date','')).strip()
if rate <= 0: continue
res.append({'code':sym,'market':'US','div':rate,'ann':ann,'rec':rec})
return res
except Exception as e:
print(f"[WARN] fetch_us failed: {e}")
return []
# ─── 价格查询(全走 LongPort) ───
def map_a(code):
"""603733 → 603733.SH"""
n = int(code) if code.isdigit() else 0
if 500000 <= n <= 689999: return f"{code}.SH"
if 0 <= n <= 399999: return f"{code}.SZ"
return f"{code}.BJ"
def map_hk(code):
"""01088 → 01088.HK"""
try: return f"{int(code):05d}.HK"
except: return f"{code}.HK"
def batch_quote(symbols):
"""Batch quote via longport_http (HTTP, 替代 longport SDK WSS)"""
if not symbols:
return {}
try:
# 2026-07-21 改用 longport_http module (避免 WSS 不稳定)
import sys
sys.path.insert(0, '/home/openclaw/.hermes/scripts')
from longport_http import get_quotes
quotes = get_quotes(symbols)
# 返回 {symbol: price} 格式 (兼容旧代码)
return {sym: q['price'] for sym, q in quotes.items()}
except Exception:
return {}
# ─── 分红稳定性评分 ───
_stability_cache = {} # symbol -> dict (避免重复 akshare 调用)
def score_dividend_stability(symbol: str, market: str) -> dict:
"""
评分: 派息年数 + 派息增长 + 波动率 → 0-100 分
返回: {"score": 75, "level": 4, "years": 10, "cagr": 5.2, "volatility": 0.3, "label": "基本稳定"}
"""
cache_key = f"{market}:{symbol}"
if cache_key in _stability_cache:
return _stability_cache[cache_key]
try:
if market != "CN":
return None # 暂时只支持 A 股 (akshare)
import akshare as ak
# 拿历史派息
if market == "CN":
code = symbol.replace(".SH", "").replace(".SZ", "").replace(".BJ", "")
df = ak.stock_history_dividend_detail(symbol=code, indicator="分红")
if df is None or len(df) < 3:
_stability_cache[cache_key] = None
return None
# 只取"实施"过(排除预案/未实施)
df = df[df["进度"] == "实施"].copy()
df["派息"] = pd.to_numeric(df["派息"], errors="coerce")
df = df.dropna(subset=["派息"])
df = df[df["派息"] > 0] # 排除 0 派息
if len(df) < 3:
return None
# 排序(新→旧)
df["年份"] = pd.to_datetime(df["公告日期"]).dt.year
df = df.sort_values("年份", ascending=False).reset_index(drop=True)
years_count = df["年份"].nunique()
latest_div = df["派息"].iloc[0]
oldest_div = df["派息"].iloc[-1]
# 1. 派息年数 (30分)
years_score = min(30, years_count * 2) # 15 年封顶 30 分
# 2. 派息 CAGR (20分) - 复合年增长
if years_count >= 2 and oldest_div > 0:
cagr = (latest_div / oldest_div) ** (1 / (years_count - 1)) - 1
if cagr >= 0.10:
cagr_score = 20
elif cagr >= 0.05:
cagr_score = 15
elif cagr >= 0.02:
cagr_score = 10
elif cagr >= 0:
cagr_score = 5
else:
cagr_score = 0
else:
cagr = 0
cagr_score = 0
# 3. 波动率 (25分) - 派息变异系数 (std/mean)
if len(df) >= 3:
mean_div = df["派息"].mean()
std_div = df["派息"].std()
cv = std_div / mean_div if mean_div > 0 else 1
if cv <= 0.2:
vol_score = 25
elif cv <= 0.4:
vol_score = 20
elif cv <= 0.6:
vol_score = 15
elif cv <= 0.8:
vol_score = 10
else:
vol_score = 5
else:
cv = 1
vol_score = 5
# 4. 最近派息正向 (15分) - 最新 ≥ 上一次
if len(df) >= 2 and df["派息"].iloc[0] >= df["派息"].iloc[1]:
recent_score = 15
else:
recent_score = 5
# 5. 连续性 (10分) - 最近 3 年都派
if years_count >= 3 and len(df["年份"].head(3).unique()) >= 3:
consecutive_score = 10
else:
consecutive_score = 0
total = years_score + cagr_score + vol_score + recent_score + consecutive_score
# 等级
if total >= 80:
level, label = 5, "长期稳定"
elif total >= 60:
level, label = 4, "基本稳定"
elif total >= 40:
level, label = 3, "不稳定"
elif total >= 20:
level, label = 2, "风险大"
else:
level, label = 1, "不推荐"
return {
"score": total,
"level": level,
"label": label,
"years": years_count,
"cagr": cagr * 100,
"cv": cv,
"latest_div": latest_div,
}
_stability_cache[cache_key] = {
"score": total,
"level": level,
"label": label,
"years": years_count,
"cagr": cagr * 100,
"cv": cv,
"latest_div": latest_div,
}
return _stability_cache[cache_key]
except Exception as e:
print(f" [WARN] score_dividend_stability {symbol} failed: {e}", file=sys.stderr)
_stability_cache[cache_key] = None
return None
# ─── 格式化 ───
def fmt(a_h, hk_h, us_h, a_p, hk_p, us_p):
now = bj_now()
tomorrow = next_trading_day(now + timedelta(days=1))
wd = ['周一','周二','周三','周四','周五','周六','周日'][tomorrow.weekday()]
L = ['', '📢 明日除权·红利提醒', ''*24,
f'📅 今日 {now.strftime("%Y-%m-%d")} 推送',
f'⏰ 明天 {tomorrow.strftime("%Y-%m-%d")} ({wd}) 除权除息',
'💡 明天是登记日,今天买入仍享分红', '']
has = False
if a_h:
has = True; L += [''*24, '🇨🇳 A股 明日除权 TOP', '']
for r in a_h[:12]:
d,c,n = r['div'],r['code'],r['name']
star = '' if d>=10 else '💎' if d>=5 else ''
p = a_p.get(f"{c}.SH") or a_p.get(f"{c}.SZ") or a_p.get(f"{c}.BJ")
y = f' | 股息率 {d/10/p*100:.2f}%' if p and p>0 else ''
# 2026-07-22 加分红稳定性评分
stab = score_dividend_stability(c, "CN")
stab_tag = f' [{stab["level"]}{stab["label"]} {stab["years"]}年CAGR{stab["cagr"]:+.0f}%]' if stab else ''
L += [f' {star}{c} {n}{stab_tag}', f' 💰每10股派{d:.2f}元 | 📊{p if p else "N/A"}{y}', '']
if hk_h:
has = True; L += [''*24, '🇭🇰 港股 明日除权 TOP', '']
for r in hk_h[:8]:
d,c,n = r['div'],r['code'],r['name']
lp = f"{int(c):05d}.HK"
p = hk_p.get(lp)
y = f' | 股息率 {d/10/p*100:.2f}%' if p and p>0 else ''
L += [f' {c} {n}', f' 💰每10股派{d:.2f}港元 | 📊HKD {p if p else "N/A"}{y}', '']
if us_h:
has = True; L += [''*24, '🇺🇸 美股 明日除权 TOP', '']
for r in us_h[:8]:
d,c = r['div'],r['code']
ann = r.get('ann',0)
rec = r.get('rec','')
p = us_p.get(f"{c}.US")
y = f' | 股息率 {ann/p*100:.2f}%' if p and p>0 and ann>0 else ''
L += [f' {c}', f' 💰${d:.2f}/股 | 年化${ann:.2f} | {ann/d:.0f}次/年']
if p: L += [f' 📊${p:.2f}{y}']
L += [f' 📅登记日{rec}', '']
if not has: L += ['✅ 明天没有高息股票除权,休息一天~', '']
L += [''*24, '📌 操作提示',
'• 今天买入 → 明天登记 → 拿分红',
'• A股持股>1年免税,<1月20%',
''*24, '🤖 Hermes 每日红利雷达']
return '\n'.join(L)
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--market', default='all',
choices=['all', 'cn_hk', 'us'],
help='all = A+HK+US, cn_hk = A股+港股, us = 美股')
args = parser.parse_args()
now = bj_now()
tomorrow = next_trading_day(now + timedelta(days=1))
print(f'[Div] {now.strftime("%Y-%m-%d")} - ex-div {tomorrow.strftime("%Y-%m-%d")} market={args.market}')
# 1. A+H除权数据
a_r, hk_r = [], []
if args.market in ('all', 'cn_hk'):
ah = fetch_ah(tomorrow)
a_r = [r for r in ah if r.get('market') in ('A','BJ')]
hk_r = [r for r in ah if r.get('market')=='HK']
print(f' A股: {len(a_r)} 港股: {len(hk_r)}')
# 2. 美股除权
us_r = []
if args.market in ('all', 'us'):
us_r = fetch_us(tomorrow)
print(f' 美股: {len(us_r)}')
# 3. 临时按 div 排序 (占位), 后面 batch_quote 拿到价后会重新按股息率排序
a_h = sorted(a_r, key=lambda x: -x['div'])
hk_h = sorted(hk_r, key=lambda x: -x['div'])
us_h = sorted(us_r, key=lambda x: -x['div'])
# 4. 批量查价
a_syms = [map_a(r['code']) for r in a_h[:15]] if a_h else []
hk_syms = [map_hk(r['code']) for r in hk_h[:10]] if hk_h else []
us_syms = [f"{r['code']}.US" for r in us_h[:10]] if us_h else []
all_syms = a_syms + hk_syms + us_syms
if not get_lp():
print("[WARN] LongPort not available")
p_all = batch_quote(all_syms) if get_lp() else {}
# Split prices by market
a_p = {k:v for k,v in p_all.items() if k.endswith(('.SH','.SZ','.BJ'))}
hk_p = {k:v for k,v in p_all.items() if k.endswith('.HK')}
us_p = {k:v for k,v in p_all.items() if k.endswith('.US')}
# 4.5. 重排序 - 按股息率% 倒序 (派息 / 当前价 × 100), 越高越排前
def _yr_a(r):
price = (a_p.get(r['code'] + '.SH') or a_p.get(r['code'] + '.SZ') or a_p.get(r['code'] + '.BJ') or 0)
if price <= 0: return 0
return (r.get('div', 0) / 10) / price * 100
def _yr_hk(r):
price = (hk_p.get(r['code'] + '.HK') or 0)
if price <= 0: return 0
return (r.get('div', 0) / 10) / price * 100
def _yr_us(r):
price = (us_p.get(r['code'] + '.US') or 0)
if price <= 0: return 0
# fetch_us 字段名是 'ann' (不是 'ann_div'), 单次派息是 'div'
ann = r.get('ann', 0) or r.get('div', 0)
return ann / price * 100
if a_p: a_h = sorted(a_h, key=lambda x: -_yr_a(x))
if hk_p: hk_h = sorted(hk_h, key=lambda x: -_yr_hk(x))
if us_p: us_h = sorted(us_h, key=lambda x: -_yr_us(x))
# 5. 输出
print('\n' + fmt(a_h, hk_h, us_h, a_p, hk_p, us_p))
if __name__ == '__main__':
main()
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# 股息扫描 - A股 + 港股 (北京时间 11:00 跑)
# 用于 cron '股息登记日前一天提醒 - A股港股' (11:00 北京时间)
export LONGBRIDGE_HTTP_URL=https://openapi.longbridge.com
export LONGBRIDGE_REGION=ap
export LONGBRIDGE_TRADE_ENABLED=true
export PROXYCHAINS_CONF=/home/openclaw/.proxychains/proxychains.conf
# 跑 + 过滤 proxychains 调试日志 (保留正常输出)
proxychains4 -f ~/.proxychains/proxychains.conf \
python3 $(dirname $0)/dividend_alert.py --market cn_hk 2>&1 \
| grep -v '^\[proxychains\]'
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# 股息扫描 - 美股 (北京时间 21:00 跑, 美股开盘前 30 min)
# 用于 cron '股息登记日前一天提醒 - 美股' (21:00 北京时间)
export LONGBRIDGE_HTTP_URL=https://openapi.longbridge.com
export LONGBRIDGE_REGION=ap
export LONGBRIDGE_TRADE_ENABLED=true
export PROXYCHAINS_CONF=/home/openclaw/.proxychains/proxychains.conf
# 跑 + 过滤 proxychains 调试日志
proxychains4 -f ~/.proxychains/proxychains.conf \
python3 $(dirname $0)/dividend_alert.py --market us 2>&1 \
| grep -v '^\[proxychains\]'