#!/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()