#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 排雷警示系统 v2.0 - 主力数据源: akshare 巨潮封装 (keyword 搜索) - 辅助验证源: 巨潮 fulltextSearch API (交叉验证) - 本地 CSV 增量存储 + 新增变化高亮 + 实时简称更新 """ import os import re import time import requests import akshare as ak import pandas as pd from datetime import datetime, timedelta from tabulate import tabulate # ===================== 配置 ===================== DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") LOCAL_CSV = os.path.join(DATA_DIR, "排雷_公告记录.csv") SEARCH_DAYS = 365 # 搜索关键词: (关键词, 风险分组) KEYWORDS = [ ("立案告知书", "立案"), ("立案调查", "立案"), ("结案告知书", "结案"), ("行政处罚事先告知书", "处罚"), ("行政处罚决定书", "处罚"), ("终止上市风险", "退市"), ("退市风险警示", "退市"), ("重大违法强制退市", "退市"), ("终止上市事先告知书", "退市"), ("终止上市相关事项监管工作函", "退市"), ("申请撤销退市风险警示", "摘帽"), ("撤销退市风险警示", "摘帽"), ] # 高危退市关键词 (用于分类) HIGH_RISK_KW = {"终止上市事先告知书", "终止上市相关事项监管工作函", "重大违法强制退市"} COLS = ["股票代码", "股票名称", "关键词", "分组", "公告标题", "公告日期", "数据源", "首次发现"] # ===================== 数据获取: akshare 巨潮封装 ===================== def fetch_akshare(keyword, group, sdate, edate): """主力数据源""" try: df = ak.stock_zh_a_disclosure_report_cninfo( symbol="", market="沪深京", keyword=keyword, start_date=sdate, end_date=edate ) if df.empty: return pd.DataFrame(columns=COLS) out = pd.DataFrame({ "股票代码": df.iloc[:, 0].astype(str).str.zfill(6), "股票名称": df.iloc[:, 1].astype(str), "公告标题": df.iloc[:, 2].apply(lambda x: re.sub(r'<[^>]+>', '', str(x))), "公告日期": pd.to_datetime(df.iloc[:, 3]).dt.strftime("%Y-%m-%d"), }) out["关键词"], out["分组"], out["数据源"], out["首次发现"] = keyword, group, "akshare", "" return out[~out["股票代码"].str.startswith(("1", "5"))].reset_index(drop=True) except Exception as e: err_str = str(e) # akshare 在无结果时可能会抛出列索引错误或 JSON 解析错误,这属于正常情况,直接静默 if "None of [Index" not in err_str and "Expecting value:" not in err_str: print(f" ? akshare异常: {err_str}") return pd.DataFrame(columns=COLS) # ===================== 数据获取: 巨潮原始 API ===================== class CninfoRawAPI: """辅助交叉验证数据源""" def __init__(self): self.s = requests.Session() self.s.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'application/json', 'Referer': 'https://www.cninfo.com.cn/', }) self.url = "https://www.cninfo.com.cn/new/fulltextSearch/full" def search(self, keyword, group, sdate_dash, edate_dash): rows, page = [], 1 while True: params = { "searchkey": keyword, "sdate": sdate_dash, "edate": edate_dash, "isfulltext": "false", "sortName": "pubdate", "sortType": "desc", "pageNum": page, "pageSize": 500, } try: data = self.s.get(self.url, params=params, timeout=15).json() except Exception: break anns = data.get("announcements") or [] if not anns: break for a in anns: code = a.get("secCode", "") if code.startswith(("1", "5")): continue rows.append({ "股票代码": code.zfill(6), "股票名称": a.get("secName", ""), "公告标题": re.sub(r'<[^>]+>', '', a.get("announcementTitle", "")), "公告日期": datetime.fromtimestamp(a["announcementTime"] / 1000).strftime("%Y-%m-%d"), "关键词": keyword, "分组": group, "数据源": "cninfo_raw", "首次发现": "", }) if len(anns) < 500: break page += 1 return pd.DataFrame(rows, columns=COLS) if rows else pd.DataFrame(columns=COLS) # ===================== 最新股票简称 ===================== def _fetch_names_tencent(codes): """腾讯行情 API 批量拉取最新简称 (含 ST 标记), 每批 100 只""" name_map = {} batch_size = 100 for i in range(0, len(codes), batch_size): batch = codes[i:i + batch_size] # 转换为腾讯格式: 6开头→sh, 其他→sz symbols = [] for c in batch: prefix = "sh" if c.startswith("6") else "sz" symbols.append(f"{prefix}{c}") try: url = f"https://qt.gtimg.cn/q={','.join(symbols)}" resp = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"}) for line in resp.text.strip().split(";"): line = line.strip() if not line or "~" not in line: continue parts = line.split("~") if len(parts) > 3: code = parts[2].strip() name = parts[1].strip() if code and name: name_map[code] = name except Exception: continue time.sleep(0.15) return name_map def get_latest_names(extra_codes=None): """ 全市场最新简称映射 {代码: 简称} 策略: 腾讯API(稳, 从本地存量+本轮数据取代码列表) → 空 extra_codes: 本轮新拉取的代码集合, 用于首次运行时本地存量为空的场景 """ print("[腾讯API]", end=" ") # 从本地存量 + 本轮数据获取已知代码列表 all_codes = set() local = load_local() if not local.empty: all_codes = set(local["股票代码"].str.zfill(6)) if extra_codes: all_codes |= extra_codes if not all_codes: print("? 无存量代码, 跳过简称更新") return {} result = _fetch_names_tencent(sorted(all_codes)) if result: return result print("? 获取最新简称失败, 使用数据内自带名称") return {} # ===================== 本地数据管理 ===================== def load_local(): if os.path.exists(LOCAL_CSV): df = pd.read_csv(LOCAL_CSV, dtype={"股票代码": str}) df["股票代码"] = df["股票代码"].str.zfill(6) # 兼容旧版缺少的列 for c in COLS: if c not in df.columns: df[c] = "" return df return pd.DataFrame(columns=COLS) def save_local(df): os.makedirs(DATA_DIR, exist_ok=True) df[COLS].to_csv(LOCAL_CSV, index=False, encoding="utf-8-sig") # ===================== 唯一键 ===================== def make_key(df): """生成 (代码, 关键词, 日期) 元组集合""" if df.empty: return set() return set(zip(df["股票代码"], df["关键词"], df["公告日期"])) # ===================== 分类逻辑 ===================== def classify_stocks(df): """ 按股票聚合,判断每只股票所属风险分类。 返回 {分类名: DataFrame} 的有序字典。 """ if df.empty: return {} # 每只股票在每个分组的最新公告日期 pivot = df.groupby(["股票代码", "分组"])["公告日期"].max().unstack(fill_value="") # 股票名称取最新 names = df.sort_values("公告日期").drop_duplicates("股票代码", keep="last").set_index("股票代码")["股票名称"] pivot["股票名称"] = pivot.index.map(names) # 高危退市代码集合 hr_codes = set(df[df["关键词"].isin(HIGH_RISK_KW)]["股票代码"].unique()) def col(name): return pivot[name] if name in pivot.columns else pd.Series("", index=pivot.index) has_li = col("立案") != "" has_jie = col("结案") != "" has_cf = col("处罚") != "" has_ts = col("退市") != "" has_zm = col("摘帽") != "" is_hr = pivot.index.isin(hr_codes) cats = [ ("?? 高危退市区(终止上市告知书/监管函/重大违法)", is_hr), ("?? 已收到行政处罚", has_cf & ~is_hr), ("?? 立案调查中(未结案、未处罚)", has_li & ~has_jie & ~has_cf & ~is_hr), ("?? 已结案(未处罚)", has_li & has_jie & ~has_cf & ~is_hr), ("?? 退市风险提示(无立案、无处罚、无告知书)", has_ts & ~has_li & ~has_cf & ~is_hr), ("?? 摘帽/撤销风险警示", has_zm & ~has_li & ~has_cf & ~has_ts & ~is_hr), ] classified = pd.Series(False, index=pivot.index) result = {} for title, mask in cats: subset = pivot[mask].copy() if subset.empty: continue classified |= mask result[title] = subset # 兜底 leftover = pivot[~classified] if not leftover.empty: result["? 其他(未分类)"] = leftover return result # ===================== 展示 ===================== def show_db_additions(df_all, new_keys): """展示本次被数据库新收录的公告 (无论日期远近,头条展示)""" if not new_keys: print("\n" + "★" * 100) print(" ?? 数据库新增收录: 0 条 (? 本次扫描无任何新增入库公告)") print("★" * 100) return # 筛选新增记录 df_all["_key"] = list(zip(df_all["股票代码"], df_all["关键词"], df_all["公告日期"])) df_new = df_all[df_all["_key"].isin(new_keys)].copy() df_all.drop(columns=["_key"], inplace=True) df_new.drop(columns=["_key"], inplace=True) df_new = df_new.sort_values("公告日期", ascending=False) # 过滤 ST 股票 is_st = df_new["股票名称"].str.contains("ST", case=False, na=False) st_count = is_st.sum() df_display = df_new[~is_st] n_stocks = df_display["股票代码"].nunique() print("\n" + "★" * 100) print(f" ?? 数据库新增收录: 共 {len(df_display)} 条公告, 涉及 {n_stocks} 只正常经营股票 (已隐藏 {st_count} 条ST新增)") print("★" * 100) if not df_display.empty: show_cols = ["股票代码", "股票名称", "分组", "关键词", "公告日期", "公告标题"] print() print(tabulate( df_display[show_cols].head(200), headers="keys", tablefmt="grid", showindex=False, maxcolwidths=[8, 12, 6, 20, 12, 55] )) def show_recent_5_days(df_all): """展示实际公告日期在5天内的所有公告""" cutoff_date = (datetime.now() - timedelta(days=5)).strftime("%Y-%m-%d") is_recent = df_all["公告日期"] >= cutoff_date df_recent = df_all[is_recent].copy() if df_recent.empty: print("\n" + "=" * 100) print(" ? 近 5 日内无任何排雷公告") print("=" * 100) return df_recent = df_recent.sort_values("公告日期", ascending=False) # 过滤 ST 股票 is_st = df_recent["股票名称"].str.contains("ST", case=False, na=False) st_count = is_st.sum() df_display = df_recent[~is_st] n_stocks = df_display["股票代码"].nunique() print("\n" + "=" * 100) print(f" ?? 近 5 日内公告: 共 {len(df_display)} 条, 涉及 {n_stocks} 只正常经营股票 (已隐藏 {st_count} 条ST记录)") print("=" * 100) if not df_display.empty: show_cols = ["股票代码", "股票名称", "分组", "关键词", "公告日期", "公告标题"] print() print(tabulate( df_display[show_cols].head(200), headers="keys", tablefmt="grid", showindex=False, maxcolwidths=[8, 12, 6, 20, 12, 55] )) def show_categories(cat_dict, new_codes): """分类展示全量存量""" group_cols = ["立案", "结案", "处罚", "退市", "摘帽"] total_st_hidden = 0 for title, pivot in cat_dict.items(): # 只保留有数据的分组列 valid_cols = [c for c in group_cols if c in pivot.columns and (pivot[c] != "").any()] display = pivot[["股票名称"] + valid_cols].copy() # 过滤 ST 股票 is_st = display["股票名称"].str.contains("ST", case=False, na=False) st_count = is_st.sum() total_st_hidden += st_count display = display[~is_st] display.insert(0, "代码", display.index) display["新增"] = display["代码"].apply(lambda c: "??" if c in new_codes else "") # 按最新日期排序 if valid_cols and not display.empty: display["_sort"] = display[valid_cols].apply( lambda row: max((v for v in row if v), default=""), axis=1 ) display = display.sort_values("_sort", ascending=False).drop(columns=["_sort"]) if not display.empty: print(f"\n{'=' * 100}") print(f" {title} (展示 {len(display)} 只正常经营股票)") print(f"{'=' * 100}") print(tabulate(display, headers="keys", tablefmt="grid", showindex=False)) return total_st_hidden # ===================== 主流程 ===================== def main(): now = datetime.now() now_str = now.strftime("%Y-%m-%d %H:%M") end_date = now.strftime("%Y-%m-%d") start_date = (now - timedelta(days=SEARCH_DAYS)).strftime("%Y-%m-%d") sd_compact = start_date.replace("-", "") ed_compact = end_date.replace("-", "") print("=" * 100) print(f" 排雷警示系统 v2.0 | {end_date} | 扫描: {start_date} ~ {end_date}") print("=" * 100) # ---- 1. 加载本地存量 ---- df_local = load_local() local_keys = make_key(df_local) n_local = df_local["股票代码"].nunique() if not df_local.empty else 0 print(f"\n?? 本地存量: {len(df_local)} 条记录, {n_local} 只股票") # ---- 2. 确定拉取范围 (增量) ---- if not df_local.empty: latest = df_local["公告日期"].max() inc_start = (pd.to_datetime(latest) - timedelta(days=7)).strftime("%Y-%m-%d") inc_sd_compact = inc_start.replace("-", "") print(f"?? 增量拉取: {inc_start} ~ {end_date} (存量最新: {latest})") else: inc_start = start_date inc_sd_compact = sd_compact print(f"?? 全量拉取: {start_date} ~ {end_date}") # ---- 3. 逐关键词拉取 ---- cninfo = CninfoRawAPI() all_fresh = [] for kw, group in KEYWORDS: print(f" ?? [{group}] {kw} ... ", end="", flush=True) # 主力: akshare df_ak = fetch_akshare(kw, group, inc_sd_compact, ed_compact) time.sleep(0.4) # 辅助: cninfo raw df_raw = cninfo.search(kw, group, inc_start, end_date) time.sleep(0.3) # 合并去重 df_merged = pd.concat([df_ak, df_raw], ignore_index=True) df_merged = df_merged.drop_duplicates(subset=["股票代码", "关键词", "公告日期"], keep="first") # 交叉验证统计 ak_codes = set(df_ak["股票代码"]) if not df_ak.empty else set() raw_codes = set(df_raw["股票代码"]) if not df_raw.empty else set() xv = "" only_ak = ak_codes - raw_codes only_raw = raw_codes - ak_codes if only_ak: xv += f" ak独有:{len(only_ak)}" if only_raw: xv += f" raw独有:{len(only_raw)}" print(f"ak:{len(ak_codes):3d} raw:{len(raw_codes):3d} 合并:{len(df_merged):3d}{xv}") all_fresh.append(df_merged) df_fresh = pd.concat(all_fresh, ignore_index=True) if all_fresh else pd.DataFrame(columns=COLS) if df_fresh.empty: print("\n?? 未获取到任何数据") return # ---- 4. 首次全量补回: 如果本地无数据且增量 = 全量,则跳过重复拉取 ---- # 如果本地有数据,需要把旧的非增量范围保留 if not df_local.empty: old_outside = df_local[df_local["公告日期"] < inc_start] df_combined = pd.concat([old_outside, df_fresh], ignore_index=True) df_combined = df_combined.drop_duplicates(subset=["股票代码", "关键词", "公告日期"], keep="last") else: df_combined = df_fresh.copy() # ---- 5. 标记新增 ---- fresh_keys = make_key(df_combined) new_keys = fresh_keys - local_keys # 首次发现时间: 新记录标当前时间,旧记录保留原时间 if not df_local.empty: old_discover = df_local.set_index( df_local["股票代码"] + "|" + df_local["关键词"] + "|" + df_local["公告日期"] )["首次发现"].to_dict() df_combined["_uid"] = df_combined["股票代码"] + "|" + df_combined["关键词"] + "|" + df_combined["公告日期"] df_combined["首次发现"] = df_combined["_uid"].map(old_discover).fillna(now_str) df_combined.drop(columns=["_uid"], inplace=True) else: df_combined["首次发现"] = now_str # ---- 6. 更新最新股票简称 ---- print(f"\n?? 拉取最新股票简称 ... ", end="", flush=True) current_codes = set(df_combined["股票代码"].str.zfill(6)) name_map = get_latest_names(extra_codes=current_codes) if name_map: df_combined["股票名称"] = df_combined["股票代码"].map(name_map).fillna(df_combined["股票名称"]) print(f"已映射 {len(name_map)} 只") else: print("跳过") # ---- 7. 保存 ---- save_local(df_combined) n_total = df_combined["股票代码"].nunique() print(f"?? 已保存: {len(df_combined)} 条记录, {n_total} 只股票\n") # ---- 8. 展示 ---- new_codes = {code for code, _, _ in new_keys} # 8a. 数据库新增公告 (头条单独展示) show_db_additions(df_combined, new_keys) # 8b. 实际5日内公告展示 show_recent_5_days(df_combined) # 8c. 全量分类展示 (用完整历史范围,确保全貌) # 如果增量拉取,需要加载完整本地数据做分类 df_display = load_local() # 重新读取已保存的完整数据 cat_dict = classify_stocks(df_display) total_st_hidden = show_categories(cat_dict, new_codes) # ---- 9. 交叉验证摘要 ---- print(f"\n{'=' * 100}") print(f" ?? 汇总: {n_total} 只股票 | {len(df_combined)} 条记录 | ?? 新增 {len(new_keys)} 条") print(f" ??? 过滤: 依照量化轮动策略需求,已从展示中隐藏了 {total_st_hidden} 只 ST / *ST 股票") print(f"{'=' * 100}") # ---- 10. 保存两个明细区域到文件 (供 cron 推送微信) ---- try: def _md_table(df, cols): """生成简洁的 Markdown 风格表格""" if df.empty: return "" lines = [] lines.append("| " + " | ".join(cols) + " |") lines.append("| " + " | ".join(["---"] * len(cols)) + " |") for _, row in df[cols].head(200).iterrows(): vals = [str(v) if v else "" for v in row] lines.append("| " + " | ".join(vals) + " |") return "\n".join(lines) CRON_REPORT = os.path.join(DATA_DIR, "排雷_微信推送.txt") with open(CRON_REPORT, "w", encoding="utf-8") as f: f.write(f"??? 排雷日报 | {now_str}\n\n") # 区域1: ?? 数据库新增收录 if not new_keys: f.write("?? 数据库新增收录: 0 条 (? 本次扫描无任何新增入库公告)\n") else: df_all_copy = df_combined.copy() df_all_copy["_key"] = list(zip(df_all_copy["股票代码"], df_all_copy["关键词"], df_all_copy["公告日期"])) df_new = df_all_copy[df_all_copy["_key"].isin(new_keys)].copy() df_new.drop(columns=["_key"], inplace=True) df_new = df_new.sort_values("公告日期", ascending=False) is_st = df_new["股票名称"].str.contains("ST", case=False, na=False) st_count = is_st.sum() df_new = df_new[~is_st] n_stocks = df_new["股票代码"].nunique() f.write(f"?? 数据库新增收录: 共 {len(df_new)} 条公告, 涉及 {n_stocks} 只正常经营股票 (已隐藏 {st_count} 条ST新增)\n") if not df_new.empty: f.write("\n") f.write(_md_table(df_new, ["股票代码", "股票名称", "分组", "关键词", "公告日期"])) f.write("\n") f.write("\n") # 区域2: ?? 近 5 日内公告 cutoff_date = (now - timedelta(days=5)).strftime("%Y-%m-%d") df_recent = df_combined[df_combined["公告日期"] >= cutoff_date].copy() if df_recent.empty: f.write("?? 近 5 日内公告: 无\n") else: df_recent = df_recent.sort_values("公告日期", ascending=False) is_st = df_recent["股票名称"].str.contains("ST", case=False, na=False) st_count = is_st.sum() df_recent = df_recent[~is_st] n_stocks = df_recent["股票代码"].nunique() f.write(f"?? 近 5 日内公告: 共 {len(df_recent)} 条, 涉及 {n_stocks} 只正常经营股票 (已隐藏 {st_count} 条ST记录)\n") if not df_recent.empty: f.write("\n") f.write(_md_table(df_recent, ["股票代码", "股票名称", "分组", "关键词", "公告日期"])) f.write("\n") # 汇总 f.write(f"\n?? 汇总: {n_total} 只股票 | {len(df_combined)} 条记录 | ?? 新增 {len(new_keys)} 条\n") print(f"\n?? 微信推送报告已保存: {CRON_REPORT}") except Exception as e: print(f"\n?? 保存微信推送报告失败: {e}") if __name__ == "__main__": import sys CRON_MODE = "--cron" in sys.argv main()