"""个股日报生成器 v2.0。 根据 watchlist.yaml 配置,为每只关注股票生成日报: - AI 要点分析(DeepSeek 生成) - 公告 / 调研 / 互动问答(cninfo v2.0 CninfoItem 数据) - 相关新闻(Qdrant 语义检索) - HTML 报告 + 自动上传 数据来源: - cninfo 数据: data/raw/cninfo/{YYYYMMDD}/*.json (CninfoItem v2.0 格式) - 新闻: Qdrant 向量检索 """ from __future__ import annotations import json import os as _os import re import subprocess import time as _time from datetime import date, datetime, timedelta from pathlib import Path from typing import Any from loguru import logger UPLOAD_HOST = "simon@doorcome.cn" UPLOAD_BASE = "/var/www/html/echart/research" # --------------------------------------------------------------------------- # # 配置 # --------------------------------------------------------------------------- # def _load_source_names() -> dict[str, str]: import yaml try: with open("configs/sources.yaml", encoding="utf-8") as f: data = yaml.safe_load(f) names = {s["id"]: s["name"] for s in (data.get("sources") or []) if s.get("id")} except Exception: names = {} names["cninfo"] = "巨潮资讯网" return names _SOURCE_NAMES = _load_source_names() def _load_watchlist() -> list[dict]: import yaml try: with open("configs/watchlist.yaml", encoding="utf-8") as f: data = yaml.safe_load(f) or {} return list(data.get("watchlist") or []) except Exception: return [] def _report_days() -> int: """从 .env 读取报告天数,默认 15。""" return int(_os.environ.get("STOCK_REPORT_DAYS", "15")) # --------------------------------------------------------------------------- # # cninfo 数据读取 (v2.0 CninfoItem 格式) # --------------------------------------------------------------------------- # def _read_cninfo_items( code: str, days_back: int | None = None, item_type: str | None = None, ) -> list[dict]: """从 data/raw/cninfo/ 中读取指定股票的 CninfoItem JSON 数据。 Args: code: 6 位股票代码 days_back: 向前追溯天数,为 None 则使用 STOCK_REPORT_DAYS item_type: 过滤类型 None=全部, announcement/research/irm """ if days_back is None: days_back = _report_days() since = date.today() - timedelta(days=days_back) since_str = since.strftime("%Y-%m-%d") raw_root = Path("data/raw/cninfo") if not raw_root.is_dir(): logger.warning("cninfo raw 目录不存在: {}", raw_root) return [] items: list[dict] = [] for day_dir in sorted(raw_root.glob("*"), reverse=True): # 解析目录日期(抓取日期),用于早期跳出循环 try: day_str = day_dir.name if len(day_str) != 8: continue # 目录日期仅用于性能优化:如果目录日期太旧(>days_back*2),跳过 day_date = date(int(day_str[:4]), int(day_str[4:6]), int(day_str[6:])) if day_date < since - timedelta(days=days_back): continue except ValueError: continue # 从此日期的 index.jsonl 读取 idx_path = day_dir / "index.jsonl" if not idx_path.is_file(): # 直接读取 JSON 文件 for jf in sorted(day_dir.glob("*.json"), reverse=True): item = _try_load_cninfo_item(jf, code, item_type) if item: pt = (item.get("publish_time") or "").strip() if pt and pt >= since_str: items.append(item) elif not pt: # publish_time 为空(如 irm),仍纳入但标记 items.append(item) else: with idx_path.open("r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: rec = json.loads(line) except json.JSONDecodeError: continue # 按股票和类型过滤 if rec.get("stock_code") != code: continue if item_type and rec.get("item_type") != item_type: continue pt = (rec.get("publish_time") or "").strip() # 按 publish_time 过滤(而非抓取日期) if pt and pt < since_str: continue items.append({ "title": (rec.get("title") or "").strip(), "url": (rec.get("url") or "").strip(), "source": "巨潮资讯网", "score": 1.0, "publish_time": pt, "item_type": (rec.get("item_type") or "").strip(), "event": rec.get("extra", {}), }) # 限制同一天/同一类型最多取 50 条 if len(items) >= 50: break return items def _try_load_cninfo_item(json_path: Path, code: str, item_type: str | None) -> dict | None: """从单个 CninfoItem JSON 文件加载(无 index.jsonl 时的回退)。""" try: data = json.loads(json_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return None if data.get("stock_code") != code: return None if item_type and data.get("item_type") != item_type: return None return { "title": (data.get("title") or "").strip(), "url": (data.get("url") or "").strip(), "source": "巨潮资讯网", "score": 1.0, "publish_time": (data.get("publish_time") or "").strip(), "item_type": (data.get("item_type") or "").strip(), "event": data.get("extra", {}), } # --------------------------------------------------------------------------- # # Qdrant 新闻搜索 # --------------------------------------------------------------------------- # def _search_news_from_qdrant( emb: Any, store: Any, query_text: str, stock_codes: list[str], top_k: int = 30, days_back: int | None = None, company_name: str = "", ) -> list[dict]: """多策略搜索 Qdrant: 股票代码 → 公司名 → 语义。""" from vectorstore import SearchFilter if days_back is None: days_back = _report_days() vec = emb.embed_one(query_text) since = (date.today() - timedelta(days=days_back)).strftime("%Y-%m-%d") # 补全后缀 codes_with_suffix = [] for c in stock_codes: codes_with_suffix.extend([f"{c}.SZ", f"{c}.SH", f"{c}.BJ", c]) hits: list = [] hits = store.query(query_vector=vec, top_k=top_k, filter=SearchFilter(stock_codes=codes_with_suffix, publish_date_from=since)) if not hits and company_name: hits = store.query(query_vector=vec, top_k=top_k, filter=SearchFilter(company_names=[company_name], publish_date_from=since)) if not hits: hits = store.query(query_vector=vec, top_k=top_k, filter=SearchFilter(publish_date_from=since)) return [ { "title": h.title, "url": h.url, "source": _SOURCE_NAMES.get(h.source_id, h.source_id), "score": round(h.score, 4), "publish_time": h.publish_time.isoformat() if h.publish_time else None, "event": h.event or {}, } for h in hits if _SOURCE_NAMES.get(h.source_id, h.source_id) != "巨潮资讯网" ] # --------------------------------------------------------------------------- # # LLM AI 要点分析 # --------------------------------------------------------------------------- # def _generate_ai_summary(company_name: str, announcements: list[dict], news: list[dict], research: list[dict], irm: list[dict]) -> str: """LLM 生成个股要点分析。""" from llm.client import load_llm_config, make_sync_client lines = [] if announcements: lines.append(f"## 公告 ({len(announcements)} 条)") for a in announcements[:10]: lines.append(f"- {a['title']} ({a.get('publish_time', '')})") if research: lines.append(f"## 调研 ({len(research)} 条)") for r in research[:5]: lines.append(f"- {r['title']} ({r.get('publish_time', '')})") if news: lines.append(f"## 新闻 ({len(news)} 条)") for n in news[:10]: ev = n.get("event", {}) summary = ev.get("summary", "") lines.append( f"- [{n['source']}] {n['title']}" + (f"。{summary}" if summary else "") ) if irm: lines.append(f"## 互动问答 ({len(irm)} 条)") for q in irm[:5]: lines.append(f"- {q['title']}") if not lines: return "暂无足够数据生成 AI 摘要" prompt = f"""以下是 {company_name} 近期的公告、调研、新闻和互动问答: {chr(10).join(lines)[:3500]} 请输出 5-8 条要点分析,每条以 "- " 开头: 1. 最重要的公告或事件是什么?影响如何? 2. 近期有哪些值得关注的动态? 3. 市场情绪倾向(利好/利空)? 4. 后续需要关注什么? 直接输出要点列表:""" try: config = load_llm_config() client = make_sync_client(config) resp = client.chat.completions.create( model=config.model, messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=500, ) return (resp.choices[0].message.content or "").strip() except Exception as e: logger.warning("个股 AI 摘要失败: {}", e) return "AI 摘要暂不可用" # --------------------------------------------------------------------------- # # HTML 渲染 # --------------------------------------------------------------------------- # def _clean_markdown(text: str) -> str: """LLM 输出的简单 Markdown 转 HTML 片段。""" text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) text = re.sub(r"\*(.+?)\*", r"\1", text) text = re.sub(r"`(.+?)`", r"\1", text) return text _HTML_TEMPLATE = """ {company_name}({stock_code}) 个股日报 — {report_date}

{company_name} ({stock_code}) 个股日报

报告期间: {date_from} ~ {date_to} · 生成于 {generated_at}

一、AI 要点分析

{ai_summary_html}

二、公司公告 ({ann_count} 条)

近 {report_days} 日公告,来源 巨潮资讯网

{ann_table}

三、调研活动 ({research_count} 条)

近 {report_days} 日投资者关系活动,来源 巨潮资讯网

{research_table}

四、相关新闻 ({news_count} 条)

近 {report_days} 日财经新闻

{news_table}

五、互动问答 ({irm_count} 条)

近 {report_days} 日互动易平台问答

{irm_table}
""" def _render_table(items: list[dict], max_rows: int = 10) -> str: if not items: return "

暂无数据

" rows = [] for i, item in enumerate(items[:max_rows], 1): ev = item.get("event", {}) sentiment = ev.get("sentiment", "") badge = {"positive": "badge-pos", "negative": "badge-neg"}.get(sentiment, "badge-neu") icon = {"positive": "🟢", "negative": "🔴", "neutral": "⚪"}.get(sentiment, "") short_title = item["title"][:60] if len(item["title"]) > 60: short_title += "..." date_str = (item.get("publish_time") or "")[:10] url = item.get("url", "") title_cell = ( f'{short_title}' if url else short_title ) rows.append( f'{i}' f'{icon}' f'{title_cell}' f'{item["source"]}' f'{date_str}' ) return ( f"" f"{''.join(rows)}
#标题来源日期
" ) # --------------------------------------------------------------------------- # # 单股报告生成 # --------------------------------------------------------------------------- # def _generate_stock_report_with_backend(stock: dict, store: Any, emb: Any, *, upload: bool = True) -> Path | None: """为单个股票生成日报(使用共享 Qdrant backend)。""" code = stock["code"] name = stock["name"] days = _report_days() logger.info("生成个股报告: {} ({}) 近{}日", code, name, days) # ---- 从 cninfo v2.0 数据读取 ---- ann_items = _read_cninfo_items(code, days_back=days, item_type="announcement") research_items = _read_cninfo_items(code, days_back=days, item_type="research") irm_items = _read_cninfo_items(code, days_back=days, item_type="irm") # ---- 新闻: Qdrant 语义检索 ---- news_items = _search_news_from_qdrant( emb, store, f"{name} {code}", stock_codes=[code], top_k=30, days_back=days, company_name=name, ) # ---- AI 摘要 ---- ai = _generate_ai_summary(name, ann_items, news_items, research_items, irm_items) ai = _clean_markdown(ai) ai_html = ( "" if ai else "

AI 摘要暂不可用

" ) # ---- 渲染 HTML ---- today = date.today() start_date = today - timedelta(days=days) html = _HTML_TEMPLATE.format( company_name=name, stock_code=code, report_date=today.strftime("%Y-%m-%d"), date_from=start_date.strftime("%Y-%m-%d"), date_to=today.strftime("%Y-%m-%d"), generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), report_days=days, ai_summary_html=ai_html, ann_count=len(ann_items), ann_table=_render_table(ann_items), research_count=len(research_items), research_table=_render_table(research_items, max_rows=10), news_count=len(news_items), news_table=_render_table(news_items), irm_count=len(irm_items), irm_table=_render_table(irm_items, max_rows=10), ) # ---- 保存 ---- out_dir = Path("data/reports/stocks") out_dir.mkdir(parents=True, exist_ok=True) fname = f"{code}_{name}_个股日报_{today.strftime('%Y%m%d')}.html" html_path = out_dir / fname html_path.write_text(html, encoding="utf-8") logger.info("个股报告已保存: {} ({} KB)", html_path, len(html) // 1024) # ---- 上传 ---- if upload: _upload_stock_report(html_path, today.strftime("%Y%m%d")) return html_path # --------------------------------------------------------------------------- # # 上传 # --------------------------------------------------------------------------- # def _upload_stock_report(html_path: Path, day_str: str) -> bool: """上传个股报告到 Web 服务器。""" remote_dir = f"{UPLOAD_BASE}/{day_str}/" try: r1 = subprocess.run( ["ssh", UPLOAD_HOST, f"mkdir -p {remote_dir}"], timeout=15, capture_output=True, text=True, ) r2 = subprocess.run( ["scp", str(html_path), f"{UPLOAD_HOST}:{remote_dir}{html_path.name}"], timeout=30, capture_output=True, text=True, ) return r1.returncode == 0 and r2.returncode == 0 except Exception: return False # --------------------------------------------------------------------------- # # 批量生成主入口 # --------------------------------------------------------------------------- # def generate_all_stock_reports(upload: bool = True) -> int: """为关注列表中所有股票生成个股日报。返回生成的报告数。""" watchlist = _load_watchlist() if not watchlist: logger.warning("关注列表为空,跳过个股报告") return 0 # 共享 backend(Qdrant + Embedder, 加锁重试) from dotenv import load_dotenv load_dotenv() from embedding import make_sync_provider from vectorstore import VectorStore, make_qdrant_client emb = make_sync_provider() for retry in range(5): try: client = make_qdrant_client() store = VectorStore(client) break except RuntimeError: if retry < 4: logger.warning("Qdrant 被占用,{} 秒后重试...", (retry + 1) * 2) _time.sleep((retry + 1) * 2) else: raise count = 0 for stock in watchlist: code = stock.get("code", "") name = stock.get("name", "") try: path = _generate_stock_report_with_backend(stock, store, emb, upload=upload) if path: count += 1 except Exception as e: logger.error("个股报告生成失败 {} {}: {}", code, name, e) store.close() emb.close() logger.info("个股报告完成: {}/{} 家", count, len(watchlist)) return count