很多人盯盘时都有一种错觉:只要我盯得够勤,就能跟上大佬的调仓节奏。但真实情况是,基金季报、美股 13F、港股权益变动表这些公开数据,披露时间不固定,格式五花八门,人工去刷网页不仅效率低,还容易漏掉关键变化。真正值得关注的问题不是“盯不盯得住”,而是“为什么不让程序替你盯,让 AI 替你读”。
这篇文章要写的就是一个非常务实的 AI 工程实践:用一套 Python 定时任务体系,把“公开披露的持仓数据抓取、清洗、入库、AI 分析、生成简报”这条链路串起来,实现 7x24 小时自动监控。它不是什么黑科技,也没有不可复制的独家能力,全部由公开数据、开源工具链和常见的大模型 API 组成。读完这篇文章,你能得到一套可以直接改造成自己项目的完整骨架,以及在实际开发这类 AI Agent 时最容易踩的坑。
先给出核心判断:这类项目的价值不在“爬虫代码写得有多花哨”,而在于“数据链路是否稳、AI 分析是否可控、任务失败是否能自愈”。AI 在这里不是主角,定时调度、数据容错、结果校验才是真正决定项目能不能长期跑下去的关键。
1. 这篇文章真正要解决的问题
如果你平时关注机构持仓、基金经理调仓或者行业大佬的公开投资变动,大概率遇到过下面这些情况:
- 数据源分散。A 股基金季报在官网披露,美股 13F 在 SEC 网站公开,港股权益变动又在联交所披露易系统里。每个网站的数据格式都不一样,有 PDF、有 HTML、有 JSON 接口。
- 披露时间不固定。有的数据是下午更新,有的是晚上更新,还有的节假日前后突然发布。人工盯着刷,不仅累,而且很容易错过。
- 传统爬虫只是“拿到数据”,并没有解决“读懂数据”的问题。一份十几页的季报,人工看完要半小时,而真正需要关注的往往只是前十大重仓股变化、增持减持比例、新进标的这几项。
- 很多人一上来就想着用大模型直接读 PDF,却忽略了 PDF 解析的准确率问题。OCR 错一个字,AI 分析的结论就可能是错的。
这篇文章要解决的就是这一整条链路。它会讲清楚如何设计一个稳定、可扩展、带失败重试和结果验证的 AI 监控系统,而不是只给一段“抓个网页丢给 ChatGPT”的玩具代码。
文章适合四类读者:
- 想用 AI 自动化处理公开数据的开发者。
- 对基金持仓、机构调仓数据感兴趣,但不想整天刷网页的投资者。
- 想给自己的爬虫项目接入大模型分析能力的后端工程师。
- 需要设计定时任务、数据管道和 Agent 调度的 Python 开发者。
读完这篇文章,你可以搭建一个最小可用的系统,它自动完成“抓取-解析-入库-分析-报告”的完整循环。运行之后,你只需要每天打开一封自动生成的 Markdown 简报,就能了解监控对象的最新变化。
2. 项目整体架构与核心概念
2.1 系统架构总览
这个项目整体上分为四层:
| 层级 | 职责 | 核心技术 |
|---|---|---|
| 数据采集层 | 定时抓取公开数据源,解析半结构化内容 | requests、BeautifulSoup、PDF 解析库 |
| 数据存储层 | 保存原始数据和分析结果,支持增量更新和去重 | SQLite、JSON |
| AI 分析层 | 读取结构化持仓数据,调用大模型生成摘要、对比和风险提示 | OpenAI 兼容 API、提示词模板 |
| 调度与通知层 | 编排任务执行顺序,处理失败重试,输出 Markdown 报告 | APScheduler、日志模块 |
这个分层设计和普通爬虫项目的关键区别在于:数据采集层和应用逻辑层分离。采集层只负责拿到数据并做基础清洗,AI 分析层不关心数据来自哪个网站,只看数据库里的结构化结果。这样做的好处非常明显,数据源一旦变更,只需要修改采集层,AI 分析逻辑完全不用动。
2.2 核心概念解释
在进入实操之前,有必要先把几个容易混淆的概念讲清楚。
AI Agent 在本文中的含义。这里说的 Agent 不是一个能自主思考的通用人工智能,而是一个“有明确任务边界”的自动化流程:定时触发、获取数据、调用大模型、产出结构化结果。它的核心价值在于把多步骤任务编排起来,减少人工介入,而不是让模型自由发挥。
定时任务与事件驱动。公募基金季报和美股 13F 这类数据没有固定的推送通道,只能用“轮询”方式周期性地去检查数据源是否更新。定时任务就是解决“什么时候去检查”的问题。工程上常用的方案有两类:一类是进程内调度器(如 APScheduler),另一类是系统级定时器(如 Linux 的 crontab)。前者适合跑在常驻进程中,后者适合跑在容器或云服务器上。本文项目里用的 APScheduler,理由是它支持任务持久化和失败重试,对中小项目更友好。
结构化数据与半结构化数据。季报 PDF、HTML 表格这类内容属于半结构化数据,需要先解析成结构化数据(如 JSON 或数据库表),大模型才能稳定地进行分析。很多 AI 项目失败在第一步,就是因为直接让模型读原始 PDF,模型输出时好时坏,无法用于自动流程。
Token 消耗与成本控制。每次调用大模型都意味着 Token 消耗。如果每天全量分析几十份持仓文件,成本会快速上升。实际项目中更推荐“先用规则筛选,再让 AI 分析增量变化”的方式,只把真正有变化的数据发送给模型。
3. 环境准备与前置条件
本文所有示例代码在 Python 3.10 环境下开发调试,其他版本请以实际运行环境为准。
3.1 安装 Python 依赖
建议先创建一个独立的虚拟环境,避免污染系统级 Python:
mkdir holding-monitor && cd holding-monitor python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate然后安装项目依赖:
pip install requests beautifulsoup4 apscheduler openai各依赖库的作用如下:
| 依赖库 | 用途 |
|---|---|
| requests | 发送 HTTP 请求,抓取公开数据源 |
| beautifulsoup4 | 解析 HTML 表格内容 |
| apscheduler | 管理定时任务和调度 |
| openai | 调用大模型 API(兼容 OpenAI 协议的服务均可) |
如果数据源中包含 PDF 文件,还需要额外安装 PDF 解析库。常见选择是pdfplumber,它对文本型 PDF 的效果比较好;如果遇到扫描件,则需要配合 OCR 工具,但这部分工程复杂度会明显提升,建议初期先跳过扫描件数据源。
3.2 配置大模型 API
本项目的大模型 API 采用 OpenAI 兼容协议,因此无论你使用的是哪种服务商,只要它提供兼容接口,都可以接入。具体 endpoint、模型名称和密钥以你实际开通的服务为准,本文不绑定特定厂商。
建议通过环境变量管理敏感配置,而不是把密钥硬编码在代码里:
export LLM_API_KEY="your-api-key" export LLM_BASE_URL="https://api.example.com/v1" export LLM_MODEL="your-model-name"在 Windows PowerShell 中,使用$env:LLM_API_KEY="your-api-key"设置环境变量。
3.3 数据库初始化
本项目使用 SQLite 作为存储层,它不需要额外安装服务,单文件即可运行,非常适合中小型 Agent 项目:
# 文件路径:db.py import sqlite3 from pathlib import Path DB_PATH = Path("data/holdings.db") def get_connection(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def init_db(): conn = get_connection() conn.execute(""" CREATE TABLE IF NOT EXISTS raw_holdings ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, report_date TEXT NOT NULL, stock_code TEXT NOT NULL, stock_name TEXT, holding_value REAL, holding_ratio REAL, change_ratio REAL, raw_json TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(source, report_date, stock_code) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS analysis_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, report_date TEXT NOT NULL, summary TEXT, risk_tips TEXT, action_items TEXT, raw_json TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(source, report_date) ) """) conn.commit() conn.close() if __name__ == "__main__": init_db() print("数据库初始化完成")建表时用了UNIQUE约束,这是去重逻辑的核心。无论数据源被重复抓取多少次,相同(source, report_date, stock_code)的记录只会保留一条。这个设计可以避免重复数据把存储撑爆,也能防止 AI 分析阶段拿到重复内容。
4. 数据采集层设计:用公开数据源喂饱 AI
4.1 数据源选择原则
“查个底朝天”并不等于“什么数据都抓”。实际项目中,数据源的筛选优先级应该这样排:
- 官方公开披露优先。基金的季报、半年报、年报,美股的 13F 文件,港股的权益披露,这些数据具有法定约束力,可信度最高。
- 有稳定 URL 规则的优先。如果数据源每天都更换网页结构,运维成本会非常高。
- 能拿到结构化文本的优先。PDF 能解析出文本的,优先于需要 OCR 的扫描件。
- 数据源要有明确的披露时点。比如基金季报要求在季度结束后 15 个工作日内披露,13F 要求在季度结束后 45 天内披露。理解这些规则,才能合理设置定时任务的检查频率。
这里必须强调:本文只讨论通过公开合法渠道获取已披露的持仓信息,不涉及任何非公开数据、内幕信息或绕过访问限制的行为。在工程实践中,抓取公开数据前应先查看目标网站的 robots 协议和服务条款,并注意控制请求频率,不要对目标服务器造成压力。
4.2 抓取器的标准接口
为了让多数据源可以灵活接入,每个抓取器都实现同一个接口:
# 文件路径:fetchers/base.py from abc import ABC, abstractmethod from typing import List, Dict class BaseFetcher(ABC): """抓取器基类,所有数据源抓取器都需要实现统一接口""" source_name = "base" @abstractmethod def fetch(self, target_date: str) -> List[Dict]: """ 抓取指定日期的持仓数据。 Args: target_date: 报告日期,格式 YYYY-MM-DD Returns: 结构化的持仓数据列表,每个元素包含: stock_code, stock_name, holding_value, holding_ratio, change_ratio 等字段 """ pass def normalize(self, raw_item: Dict) -> Dict: """ 将原始数据规范化为统一格式。 这里主要负责补全默认值、统一字段名。 """ normalized = { "source": self.source_name, "stock_code": raw_item.get("stock_code", ""), "stock_name": raw_item.get("stock_name", ""), "holding_value": raw_item.get("holding_value", 0.0), "holding_ratio": raw_item.get("holding_ratio", 0.0), "change_ratio": raw_item.get("change_ratio", 0.0), "report_date": raw_item.get("report_date", ""), } return normalized统一接口的好处很多。新增数据源时,只需要写一个继承BaseFetcher的新类,数据库层和 AI 分析层完全不用改。
4.3 一个真实的抓取流程示例
下面以抓取一个公开 HTML 表格数据源为例,演示采集层的核心代码。实际应用时,你需要把DATA_URL替换为真实的数据源地址,并调整选择器来匹配目标网页结构:
# 文件路径:fetchers/html_fetcher.py import requests from bs4 import BeautifulSoup from typing import List, Dict from .base import BaseFetcher class HtmlTableFetcher(BaseFetcher): """通用 HTML 表格抓取器,适用于结构简单的数据源""" source_name = "html_table" def __init__(self, url: str, headers: Dict[str, str] = None): self.url = url self.headers = headers or { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) holding-monitor/1.0" } def fetch(self, target_date: str) -> List[Dict]: try: resp = requests.get(self.url, headers=self.headers, timeout=20) resp.raise_for_status() except requests.RequestException as e: print(f"[{self.source_name}] 请求失败: {e}") return [] soup = BeautifulSoup(resp.text, "html.parser") rows = soup.select("table tbody tr") results = [] for row in rows: cells = row.find_all("td") if len(cells) < 4: continue item = { "stock_code": cells[0].get_text(strip=True), "stock_name": cells[1].get_text(strip=True), "holding_value": float(cells[2].get_text(strip=True).replace(",", "") or 0), "holding_ratio": float(cells[3].get_text(strip=True).replace("%", "") or 0), "report_date": target_date, } results.append(self.normalize(item)) print(f"[{self.source_name}] 抓取到 {len(results)} 条记录") return results这段代码做了四件关键的事:
- 设置超时时间,避免某个数据源卡住整个任务。
- 用 CSS 选择器定位表格行,减少正则解析的脆弱性。
- 对金额、百分比做基础清洗,统一数据格式。
- 返回空列表而不是抛出异常,让上层调度逻辑能继续运行。
真正的工程环境里,数据源的返回格式千差万别。有的网页需要模拟翻页,有的是异步接口返回 JSON,还有些数据源会把数据打包在 PDF 里。但无论哪种情况,BaseFetcher接口保持不变,上层调用方不需要关心具体的解析逻辑。
5. 定时任务与 Agent 调度
5.1 为什么需要调度框架
很多初学者的第一个想法是用while True + time.sleep()来做定时任务。但实际项目里,这种方式存在三个明显问题:
- 任务崩溃后无法重启,进程退出了整个监控就停了。
- 没有持久化记录,无法知道某个任务上次执行是否成功。
- 没有任务依赖关系,采集完成和分析开始之间缺少顺序控制。
引入 APScheduler 之后,调度逻辑和业务逻辑分离,代码的健壮性会有质的提升。APScheduler 的BackgroundScheduler可以在 Python 进程内运行,也可以和 Flask/FastAPI 集成,部署起来非常灵活。
5.2 调度任务代码实现
下面是项目的核心调度入口:
# 文件路径:scheduler.py import json import time from datetime import datetime, timedelta from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from db import get_connection, init_db from fetchers.html_fetcher import HtmlTableFetcher from analyzer.llm_analyzer import analyze_holdings def load_fetchers(): """ 初始化所有抓取器。 实际使用时,把 URL 配置到环境变量或配置文件中。 """ return [ HtmlTableFetcher( url="https://example.com/api/public-holdings", headers={"User-Agent": "Mozilla/5.0 holding-monitor/1.0"} ) ] def collect_task(source: str, target_date: str): """采集任务:抓取数据并写入数据库""" print(f"[collect] 开始抓取 {source} 的报告,日期: {target_date}") fetchers = load_fetchers() fetcher = next(f for f in fetchers if f.source_name == source) records = fetcher.fetch(target_date) if not records: print(f"[collect] 未获取到数据,可能是数据源尚未更新") return conn = get_connection() inserted = 0 for item in records: try: conn.execute(""" INSERT OR IGNORE INTO raw_holdings (source, report_date, stock_code, stock_name, holding_value, holding_ratio, change_ratio, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( item["source"], item["report_date"], item["stock_code"], item["stock_name"], item["holding_value"], item["holding_ratio"], item["change_ratio"], json.dumps(item, ensure_ascii=False) )) inserted += 1 except Exception as e: print(f"[collect] 写入失败: {e}") conn.commit() conn.close() print(f"[collect] 写入完成,新增 {inserted} 条记录") def analyze_task(source: str, target_date: str): """分析任务:读取数据库中的最新数据,调用 AI 生成简报""" print(f"[analyze] 开始分析 {source} 的数据,日期: {target_date}") conn = get_connection() rows = conn.execute(""" SELECT * FROM raw_holdings WHERE source = ? AND report_date = ? ORDER BY holding_ratio DESC """, (source, target_date)).fetchall() conn.close() if not rows: print("[analyze] 没有可分析的数据,任务结束") return data_list = [dict(row) for row in rows] report = analyze_holdings(source, target_date, data_list) conn = get_connection() conn.execute(""" INSERT OR REPLACE INTO analysis_reports (source, report_date, summary, risk_tips, action_items, raw_json) VALUES (?, ?, ?, ?, ?, ?) """, ( source, target_date, report.get("summary", ""), report.get("risk_tips", ""), report.get("action_items", ""), json.dumps(report, ensure_ascii=False) )) conn.commit() conn.close() print(f"[analyze] 分析报告已生成并写入数据库") def job_collect_and_analyze(): """组合任务:先采集再分析""" target_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") collect_task("html_table", target_date) analyze_task("html_table", target_date) def start_scheduler(): init_db() scheduler = BackgroundScheduler(timezone="Asia/Shanghai") # 工作日早上 9 点执行一次完整任务 scheduler.add_job( job_collect_and_analyze, trigger=CronTrigger(day_of_week="mon-fri", hour=9, minute=0), id="daily_holdings_check", name="每日持仓数据采集与分析", replace_existing=True, misfire_grace_time=3600 ) # 盘中每隔两小时抓一次,应对数据源临时更新 scheduler.add_job( collect_task, trigger=IntervalTrigger(hours=2), args=["html_table", (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")], id="intraday_collect", name="盘中增量数据检查", replace_existing=True, misfire_grace_time=1800 ) scheduler.start() print("调度器已启动,按 Ctrl+C 退出") try: while True: time.sleep(60) except (KeyboardInterrupt, SystemExit): print("调度器已停止") if __name__ == "__main__": start_scheduler()这个调度器有两个值得关注的细节。
第一,misfire_grace_time参数的设置。它表示任务错过预定执行时间后,在多少秒内仍然允许补跑。比如服务器在凌晨 3 点因维护停机,原定 9 点执行的任务在 9:30 才被唤醒,只要misfire_grace_time大于 1800 秒,任务依然会执行。这个参数对可靠性要求高的监控项目非常重要。
第二,采集和分析拆成两个独立任务。即使采集阶段数据源没有更新,分析任务也可以安全跳过;如果分析阶段大模型 API 暂时不可用,下次周期还能继续分析新采集的数据,不会造成一条数据永远无人分析的情况。
5.3 任务执行时间策略
监控账目和数据抓取的时间设置,取决于数据源的披露规律:
- 数据源通常在工作日更新,周末大概率没有新数据,所以主任务放在工作日早上执行。
- 部分数据源可能在盘中临时更新,因此增加一个低频的盘中检查任务作为补充。
- 抓取频率要根据数据源实际更新频率来定。数据源一天只更新一次,半小时抓一次也是浪费资源。
- 执行时间尽量避开数据源自身的高峰期,减少给对方服务器造成压力的同时,也能降低被限流的概率。
6. AI 分析层:让大模型成为你的解读助手
6.1 结构化提示词模板设计
AI 分析是整个链路中最能体现工程经验的部分。很多人直接往大模型里丢一段数据就问“有什么发现”,结果输出质量完全不可控。正确做法是设计严格的提示词模板,明确输出格式,让模型的回答能被程序自动解析。
# 文件路径:analyzer/prompts.py SUMMARY_PROMPT_TEMPLATE = """ 你是一名专业的研究分析师。请根据以下持仓变化数据,写一份简明分析简报。 数据来源: {source} 报告日期: {report_date} 持仓数据如下: {holdings_data} 要求: 1. 总结本期持仓的总体变化情况,点出增减仓最明显的个股; 2. 对比持仓结构,指出哪些行业或板块受到关注; 3. 用质疑的眼光审查数据,保留 3 条潜在风险提示; 4. 如果发现有新进入前十大持仓的个股,单独列为行动项; 5. 所有分析必须基于给定数据,不要推测数据之外的交易逻辑。 输出格式要求,必须是 JSON 对象,包含三个字段: {{ "summary": "总体变化的概括,不超过 200 字", "risk_tips": "风险提示,最多 3 条,用列表表示", "action_items": "值得关注的行动项,最多 3 条,用列表表示" }} """这里的关键是“必须基于给定数据”。大模型的通病是容易发散,给定数据之外的内容它也能编出一套逻辑。加上这句约束之后,模型的输出会明显更贴近数据本身,减少幻觉。
6.2 调用大模型的执行器
# 文件路径:analyzer/llm_analyzer.py import json import os from openai import OpenAI from .prompts import SUMMARY_PROMPT_TEMPLATE client = OpenAI( api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL") ) def analyze_holdings(source: str, report_date: str, data_list: list) -> dict: """ 调用大模型分析持仓数据,返回结构化结果。 Args: source: 数据来源标识 report_date: 报告日期 data_list: 持仓数据列表,每个元素为 dict Returns: 包含 summary/risk_tips/action_items 的 dict """ if not data_list: return { "summary": "无数据", "risk_tips": [], "action_items": [] } data_text = json.dumps(data_list, ensure_ascii=False, indent=2) prompt = SUMMARY_PROMPT_TEMPLATE.format( source=source, report_date=report_date, holdings_data=data_text ) try: resp = client.chat.completions.create( model=os.getenv("LLM_MODEL"), messages=[ {"role": "system", "content": "你是一个严谨的量化研究助手,只输出符合格式要求的 JSON。"}, {"role": "user", "content": prompt} ], temperature=0.3, response_format={"type": "json_object"}, timeout=60 ) content = resp.choices[0].message.content result = json.loads(content) # 字段存在性校验 required_fields = {"summary", "risk_tips", "action_items"} missing_fields = required_fields - set(result.keys()) if missing_fields: raise ValueError(f"大模型输出缺少字段: {missing_fields}") # 类型校验 if not isinstance(result["risk_tips"], list): result["risk_tips"] = [str(result["risk_tips"])] if not isinstance(result["action_items"], list): result["action_items"] = [str(result["action_items"])] return result except json.JSONDecodeError as e: print(f"[llm] JSON 解析失败: {e}") return _fallback_result("AI 分析结果解析失败,请稍后重试") except Exception as e: print(f"[llm] 调用失败: {e}") return _fallback_result(f"AI 分析暂时不可用: {str(e)}") def _fallback_result(message: str) -> dict: """ 失败时的兜底结果,保证上层流程可以继续。 """ return { "summary": message, "risk_tips": [], "action_items": [] }这个执行器有几点工程上的考虑:
- 设置了
temperature=0.3。温度越低,输出越确定,适合需要精确解析的任务。 - 使用
response_format={"type": "json_object"}强制模型输出 JSON,降低解析失败概率。如果你的模型服务商不支持该参数,需要去掉这一行,改用在提示词里强调 JSON 格式。 - 解析后做字段校验和类型校验。大模型偶尔会漏字段或把列表写成字符串,代码里显式修正这些情况,避免下游报告生成时崩溃。
- 所有异常都有兜底结果,不会因为一次 API 调用失败就中断整个任务链。
6.3 Token 成本控制的实践
如果你每天监控多个数据源,每次都把所有持仓明细发给大模型,Token 消耗很快会涨起来。实践中,可以按以下策略降低成本:
第一,只在数据发生变化时才调用 AI 分析。数据库里有UNIQUE约束做去重,可以用对比查询找出持仓比例变化超过阈值的个股,只发送这些增量数据给模型。
第二,截断长文本。如果某只股票持仓明细特别长,先做聚合统计,只发送核心字段。
第三,定期检查 token 消耗。把每次分析的输入 token 数和输出 token 数记录到日志中,形成成本基线。
7. 完整运行与效果验证
7.1 跑通最小链路
先把数据库初始化,然后手动执行一次采集和分析任务,验证全链路是否通畅:
python db.py python -c " from scheduler import job_collect_and_analyze job_collect_and_analyze() "如果链路正常,你会看到类似下面的日志输出:
[collect] 开始抓取 html_table 的报告,日期: 2025-06-10 [collect] 抓取到 12 条记录 [collect] 写入完成,新增 12 条记录 [analyze] 开始分析 html_table 的数据,日期: 2025-06-10 [analyze] 分析报告已生成并写入数据库7.2 验证数据库中的数据
sqlite3 data/holdings.db "SELECT source, report_date, stock_name, holding_ratio FROM raw_holdings ORDER BY holding_ratio DESC LIMIT 5;"预期输出是最近一次抓取并清洗后的持仓明细。如果查询结果为空,先检查数据源 URL 是否可访问。
7.3 生成 Markdown 简报
为了让结果更容易阅读,可以把数据库中的分析报告导出为 Markdown 文件,便于收藏和分享:
# 文件路径:export_report.py import sqlite3 from pathlib import Path def export_markdown(source: str, report_date: str): conn = sqlite3.connect("data/holdings.db") conn.row_factory = sqlite3.Row row = conn.execute(""" SELECT * FROM analysis_reports WHERE source = ? AND report_date = ? ORDER BY id DESC LIMIT 1 """, (source, report_date)).fetchone() conn.close() if not row: print("没有找到可导出的分析报告") return holdings = [] conn = sqlite3.connect("data/holdings.db") conn.row_factory = sqlite3.Row rows = conn.execute(""" SELECT * FROM raw_holdings WHERE source = ? AND report_date = ? ORDER BY holding_ratio DESC """, (source, report_date)).fetchall() conn.close() lines = [ f"# {source} 持仓监控报告", f"报告日期:{report_date}", "", "## 本期要点", row["summary"], "", "## 风险提示", ] for tip in eval(row["risk_tips"]) if row["risk_tips"] else []: lines.append(f"- {tip}") lines.append("") lines.append("## 持仓明细") lines.append("| 股票代码 | 股票名称 | 持仓市值 | 持仓比例 |") lines.append("| --- | --- | --- | --- |") for item in rows: lines.append( f"| {item['stock_code']} | {item['stock_name']} | " f"{item['holding_value']:.2f} | {item['holding_ratio']:.2f}% |" ) report_dir = Path("reports") report_dir.mkdir(exist_ok=True) output_path = report_dir / f"{source}_{report_date}.md" output_path.write_text("\n".join(lines), encoding="utf-8") print(f"报告已导出: {output_path}") if __name__ == "__main__": export_markdown("html_table", "2025-06-10")python export_report.py打开生成的reports/html_table_2025-06-10.md文件,就能看到一份结构清晰的中文分析简报。这一步将数据库里的结构化结果变成了人可以直接阅读的内容,也是整个 Agent 闭环的最后一环。
7.4 判断系统是否成功的标准
一个监控系统是否成功,不能只看“能不能跑起来”,更要看以下指标:
- 数据抓取成功率连续 7 天保持在 95% 以上。
- 数据入库后重复记录占比低于 1%。
- AI 分析结果中 JSON 解析失败率低于 5%。
- 任务失败后能自动恢复,不需要人工干预。
如果这些指标都达标,说明系统已经具备在真实环境中持续运行的基础。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 数据抓取始终为空 | 数据源 URL 失效或网页结构变化 | 用 curl 或浏览器开发者工具检查返回内容 | 更新 URL,调整 CSS 选择器,或改用 JSON 接口 |
| SQLite 写入报 UNIQUE 约束错误 | 重复插入相同记录 | 查看完整错误日志 | 确认INSERT OR IGNORE是否被误写成INSERT |
| 大模型 API 返回超时 | 网络不稳定或模型服务负载高 | 查看 API 调用日志和响应时间 | 增加超时时间,设置指数退避重试,或使用备用模型 |
| JSON 输出解析失败 | 模型返回了额外文本或格式错误 | 打印原始返回内容 | 启用response_format,或把提示词里“只输出 JSON”加粗强调 |
| 调度任务偶尔不执行 | 服务器休眠或错过执行窗口 | 检查调度日志和misfire_grace_time设置 | 增大宽限时间,或改用系统 crontab 保证执行 |
| 导出的 Markdown 中文乱码 | 文件写入编码问题 | 检查文件编码 | 统一使用encoding="utf-8"写入 |
| 数据量增长过快 | 数据源抓取频率过高 | 统计每日新增记录数 | 降低任务频率,增加去重逻辑 |
排查所有问题时,第一原则是看日志。项目代码中已经通过print在每个关键节点打了日志,生产环境建议统一改为logging模块,输出到文件并定期轮转,这样才能在问题发生时快速定位。
9. 最佳实践与工程建议
9.1 数据合规与访问策略
在公开数据采集场景中,合规是底线。部署这类系统时,有几个原则需要遵守:
- 只访问官方发布的公开信息,不通过任何绕过访问限制的手段获取数据。
- 控制请求频率。如果数据源没有提供官方 API,建议请求间隔至少在几秒以上,单次任务不要并发拉取过多页面。
- 保留数据来源标识。数据库中保留
source字段,就是为了后续追溯数据来源和校验准确性。 - 对数据源服务商保持尊重。如果对方明确在 robots 协议或服务条款中禁止批量抓取,应停止该数据源的采集并寻找替代方案。
9.2 架构设计的可扩展性
这个项目的分层架构天然支持扩展。如果后续要接入新的数据源,只需要三步:
- 新建一个继承
BaseFetcher的类,实现fetch方法。 - 在
load_fetchers函数中注册新抓取器。 - 在调度器中新增一个任务,或者把新数据源加入现有组合任务。
AI 分析模块也可以按数据源类型定制不同提示词模板。比如基金的季报分析侧重重仓股变化,而 13F 分析侧重机构整体配置方向。每个模板独立维护,互不影响。
9.3 生产环境部署建议
项目跑通之后,如果想让它在服务器上长期稳定运行,有些细节值得提前考虑:
- 不要把
data/holdings.db放在临时目录或/tmp,建议放在固定数据盘并定期备份。 - 使用
systemd或容器编排工具守护 Python 进程,让它在崩溃后能自动重启。 - 敏感配置(API 密钥、数据库路径)统一从环境变量或配置中心读取,而不是写在代码里。
- 增加心跳检测。调度器每次执行任务后,向监控系统发送一条心跳消息,连续多次心跳缺失时触发告警。
- 日志分级。开发环境可以全量打印,生产环境只保留 WARNING 以上日志,避免磁盘被日志占满。
9.4 从监控到决策的边界
这套系统能帮你自动化地“看到”持仓变化,但“看到”不等于“理解”,“理解”也不等于“应该操作”。AI 分析生成的 summary 和风险提示只是辅助信息,不能直接作为投资决策依据。系统的价值在于节省人工盯盘和整理数据的时间,而决策判断仍然需要你结合宏观环境、个股基本面、估值水平等信息综合做出。
10. 总结与后续学习方向
从“人工刷网页盯持仓”到“AI 定时生成简报”,这段改造的核心不是引入了一个多聪明的模型,而是把确定性的工程部分做得足够扎实。数据抓取有重试、入库有去重、调度有宽限、AI 输出有校验和兜底,每一步都在降低系统在无人值守时出错的概率。
如果继续深入这个方向,还有几个可以钻研的课题:利用向量数据库存储历史持仓报告并进行长期趋势检索;把 PDF 季报的解析精度从“能读出文本”提升到“准确还原表格结构”;为不同的数据源开发独立的调度策略和告警规则。任何一块做到极致,都能让这个监控系统的可靠性上一个台阶。
对大多数开发者来说,建议先把这个最小骨架跑通,然后把一个真实数据源接入,跑上一周,观察日志和数据质量,再逐步扩展。技术从来不是越复杂越好,而是越稳定越好。