【背景】之前 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] 日志
358 lines
13 KiB
Python
358 lines
13 KiB
Python
#!/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()
|