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:
@@ -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
@@ -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
@@ -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\]'
|
||||
Reference in New Issue
Block a user