diff --git a/dividend-investing/SKILL.md b/dividend-investing/SKILL.md index 1814f84..5cc319f 100644 --- a/dividend-investing/SKILL.md +++ b/dividend-investing/SKILL.md @@ -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) diff --git a/dividend-investing/scripts/dividend_alert.py b/dividend-investing/scripts/dividend_alert.py new file mode 100644 index 0000000..acb5c93 --- /dev/null +++ b/dividend-investing/scripts/dividend_alert.py @@ -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() diff --git a/dividend-investing/scripts/dividend_alert_cn_hk.sh b/dividend-investing/scripts/dividend_alert_cn_hk.sh new file mode 100755 index 0000000..e0f6fe2 --- /dev/null +++ b/dividend-investing/scripts/dividend_alert_cn_hk.sh @@ -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\]' diff --git a/dividend-investing/scripts/dividend_alert_us.sh b/dividend-investing/scripts/dividend_alert_us.sh new file mode 100755 index 0000000..7a12131 --- /dev/null +++ b/dividend-investing/scripts/dividend_alert_us.sh @@ -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\]'