1. 项目概述与核心价值做量化策略、自动化交易或者仅仅是写个定时脚本去抓取股票数据最基础也最容易被忽视的一个问题就是今天是不是交易日你肯定不想在周六周日或者法定节假日让脚本傻乎乎地去请求一个没有数据的接口或者触发无意义的告警。这个需求听起来简单但自己从头实现一个精准的交易日判断逻辑却是个不小的坑。法定节假日每年都变调休补班让人头疼单纯用datetime的weekday()判断周末是远远不够的。所以一个可靠、权威的交易日历数据源至关重要。国内A股市场上海证券交易所和深圳证券交易所发布的官方交易日历就是最权威的答案。这个项目的核心思路就是通过Python爬虫自动从深交所官网抓取最新的交易日历数据并封装成一个方便调用的函数或工具用于判断任意日期是否为A股交易日。这不仅仅是“爬虫练习”而是一个具有强工程实用性的数据基础设施组件。对于金融数据分析、自动化运维、策略回测等场景它是确保逻辑正确性的第一道关卡。2. 整体方案设计与技术选型要实现这个目标我们需要拆解成几个核心步骤数据源定位、网页爬取、数据解析、数据存储与查询。每个环节都有多种技术选择这里我基于稳定性、可维护性和开发效率给出我的方案。2.1 数据源分析与定位首先得找到可靠的数据源。深圳证券交易所官网www.szse.cn在“市场数据” - “交易数据” - “交易日历”栏目下会发布年度交易日历。通常是一个静态HTML页面里面以表格形式列出了全年的交易日和非交易日节假日。我们的目标就是解析这个页面。注意不同网站的反爬策略不同。深交所官网对简单爬虫相对友好但我们仍需遵守robots.txt规则并采用礼貌的爬取策略如添加请求头、设置访问间隔避免对服务器造成压力。2.2 技术栈选型与理由网络请求库requests理由简单、易用、社区成熟。对于静态HTML页面的抓取requests足矣。相比urllib它的API更加人性化。虽然aiohttp适用于异步高并发但本项目是低频、定时如每日一次抓取同步请求更简单直接。HTML解析库BeautifulSoup4(bs4)理由深交所的交易日历页面结构通常比较规整表格数据用BeautifulSoup配合lxml解析引擎可以非常高效地提取。lxml的解析速度比内置的html.parser快很多。虽然pyquery语法类似jQueryparsel功能强大但BeautifulSoup的入门门槛最低文档丰富适合绝大多数静态页面解析场景。数据存储本地JSON文件 内存缓存理由交易日历数据量很小一年就365条记录更新频率低每年更新一次节假日安排发布时再更新。使用关系型数据库如SQLite或pickle序列化都显得“杀鸡用牛刀”。JSON文件是人类可读的便于直接查看和调试也易于被其他语言读取。配合一个内存中的字典或集合做缓存查询速度可以做到O(1)。调度与更新schedule库或操作系统定时任务crontab/Task Scheduler理由我们需要定期例如每年年底检查并抓取下一年度的交易日历。在Python脚本内部可以使用schedule库实现简单的定时循环。但在生产环境更推荐使用操作系统的原生定时任务Linux的cron或Windows的任务计划程序来调用脚本这样更稳定与脚本进程解耦。最终技术栈Python 3.8requestsbeautifulsoup4lxml。这是经过实践检验的、最稳健的组合。3. 核心实现步骤拆解与编码接下来我们一步步实现整个流程。我会先给出关键代码片段然后解释其背后的逻辑和注意事项。3.1 环境准备与依赖安装首先确保你的Python环境已经就绪。使用虚拟环境是一个好习惯。# 创建并激活虚拟环境可选但推荐 python -m venv venv # Windows: venv\Scripts\activate # Linux/Mac: source venv/bin/activate # 安装核心依赖 pip install requests beautifulsoup4 lxmllxml是一个解析XML/HTML的C语言库BeautifulSoup可以利用它加速解析。如果安装lxml失败可以先尝试安装系统级的编译工具如Windows下的Build Tools或Linux下的libxml2/libxslt开发包或者暂时使用Python内置的html.parser速度稍慢。3.2 网页抓取与请求头设置我们需要模拟浏览器访问这是绕过基础反爬的关键一步。import requests from bs4 import BeautifulSoup import json from datetime import datetime, date import time from typing import Set, Optional class TradingDayFetcher: def __init__(self): self.session requests.Session() # 设置一个合理的User-Agent这是最基本的“礼貌” self.headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 } self.session.headers.update(self.headers) # 数据缓存 self.trading_days_cache: Set[str] set() self.calendar_year: Optional[int] None def fetch_calendar_page(self, year: int) - Optional[str]: 抓取指定年份的深交所交易日历页面HTML。 深交所日历页面URL模式可能变化需要根据实际情况调整。 示例URL: http://www.szse.cn/disclosure/calendar/tradeCalendar/index.html?year2024 # 注意实际URL需要你打开浏览器查看网络请求来确定。 # 这里是一个示例可能已过期。 url fhttp://www.szse.cn/disclosure/calendar/tradeCalendar/index.html?year{year} try: # 添加延迟避免请求过快 time.sleep(1) resp self.session.get(url, timeout10) resp.raise_for_status() # 如果状态码不是200抛出HTTPError异常 # 检查编码中文网站常用gbk或utf-8 resp.encoding resp.apparent_encoding or utf-8 return resp.text except requests.exceptions.RequestException as e: print(f抓取{year}年日历页面失败: {e}) return None实操心得User-Agent一定要设置成常见的浏览器标识。requests.Session()可以复用TCP连接并在会话内保持cookies和headers效率稍高。time.sleep(1)是简单的礼貌延迟对于深交所这类网站1秒间隔通常足够安全。务必添加超时(timeout)参数防止网络问题导致脚本长期挂起。3.3 HTML解析与数据提取这是最核心的一步需要仔细分析目标网页的HTML结构。你需要用浏览器的“开发者工具”F12查看日历表格的DOM结构。假设日历数据在一个table idtradeCalendarTable的tbody中每一行(tr)代表一个日期某个td里的文本标记了是否为交易日。def parse_calendar_html(self, html: str, year: int) - Set[str]: 解析HTML提取交易日日期集合。 日期格式统一为YYYY-MM-DD便于比较和存储。 trading_days set() if not html: return trading_days soup BeautifulSoup(html, lxml) # 这里的选择器需要根据实际网页结构调整 # 你需要用开发者工具仔细查看表格结构 calendar_table soup.find(table, idtradeCalendarTable) if not calendar_table: # 如果id不对尝试其他定位方式比如class或特定的表格结构 print(f未在页面中找到交易日历表格请检查网页结构或选择器。) return trading_days tbody calendar_table.find(tbody) if not tbody: tbody calendar_table # 有些表格可能没有明确的tbody for row in tbody.find_all(tr): cols row.find_all(td) if len(cols) 3: # 假设前几列是日期、星期、是否交易日 continue # 假设第一列是日期文本格式可能是2024-01-02或1月2日 date_str_raw cols[0].get_text(stripTrue) # 假设第三列是状态文本为“休市”或“交易” status cols[2].get_text(stripTrue) # 将原始日期字符串转换为标准格式 standard_date_str self._parse_date_str(date_str_raw, year) if not standard_date_str: continue # 根据状态判断 if 交易 in status: # 包含“交易”字样即视为交易日 trading_days.add(standard_date_str) # 否则为非交易日休市不加入集合 print(f解析完成共找到{len(trading_days)}个交易日。) return trading_days staticmethod def _parse_date_str(raw_str: str, base_year: int) - Optional[str]: 清洗和标准化日期字符串。 这是一个复杂且易出错的部分因为网站格式可能多变。 # 示例1: 2024-01-02 # 示例2: 1月2日 # 示例3: 01/02 raw_str raw_str.strip() if not raw_str: return None # 尝试多种格式解析 date_formats [%Y-%m-%d, %m月%d日, %m/%d, %Y/%m/%d] for fmt in date_formats: try: # 对于不包含年份的格式使用传入的base_year if %Y not in fmt: parsed_date datetime.strptime(raw_str, fmt).date().replace(yearbase_year) else: parsed_date datetime.strptime(raw_str, fmt).date() return parsed_date.isoformat() # 返回YYYY-MM-DD except ValueError: continue print(f无法解析日期字符串: {raw_str}) return None避坑指南网页解析是爬虫最脆弱的部分。网站前端改版你的选择器就可能失效。因此选择器要健壮优先使用id其次是用class组合。避免使用依赖于具体位置如tr:nth-child(5)的选择器。做好异常处理find方法找不到会返回None后续调用.find_all就会报错。务必先判断。日期解析是重灾区网站显示的日期格式可能不统一。上面的_parse_date_str方法只是一个示例你必须根据目标网站的实际显示格式来调整date_formats列表。最稳妥的办法是先将抓取到的原始日期字符串打印出来观察规律。3.4 数据持久化与缓存加载解析出的数据需要保存到本地并加载到内存中供快速查询。def save_to_json(self, trading_days: Set[str], year: int, filename: str None): 将交易日集合保存为JSON文件。 if filename is None: filename fszse_trading_days_{year}.json data { year: year, update_time: datetime.now().isoformat(), trading_days: sorted(list(trading_days)) # 排序后存储便于阅读 } with open(filename, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) print(f交易日历已保存至 {filename}) def load_from_json(self, filename: str) - bool: 从JSON文件加载交易日历到内存缓存。 try: with open(filename, r, encodingutf-8) as f: data json.load(f) self.trading_days_cache set(data[trading_days]) self.calendar_year data[year] print(f已从 {filename} 加载 {self.calendar_year} 年交易日历共 {len(self.trading_days_cache)} 天。) return True except FileNotFoundError: print(f文件 {filename} 不存在请先抓取数据。) return False except (json.JSONDecodeError, KeyError) as e: print(f读取或解析JSON文件 {filename} 失败: {e}) return False使用JSON存储的好处是透明。你可以随时打开文件检查数据是否正确。update_time字段记录了数据抓取时间有助于判断数据的新鲜度。3.5 封装查询函数最后我们将上述功能封装成一个简洁易用的函数。def is_trading_day(self, target_date: date None) - bool: 判断给定日期是否为交易日。 如果未提供日期则默认判断今天。 if target_date is None: target_date date.today() # 检查缓存是否为空或年份不匹配 if not self.trading_days_cache or self.calendar_year ! target_date.year: # 尝试加载对应年份的数据文件 filename fszse_trading_days_{target_date.year}.json if not self.load_from_json(filename): # 如果文件不存在则尝试实时抓取生产环境慎用建议预抓取 print(f本地无{target_date.year}年数据尝试抓取...) html self.fetch_calendar_page(target_date.year) if html: days self.parse_calendar_html(html, target_date.year) self.trading_days_cache days self.calendar_year target_date.year self.save_to_json(days, target_date.year, filename) else: # 抓取失败退回简单的周末判断不准确仅作fallback print(警告抓取失败退回周末判断逻辑。) return target_date.weekday() 5 # 周一0, 周日6 # 标准查询 date_str target_date.isoformat() return date_str in self.trading_days_cache # 提供一个全局的单例或工具函数方便使用 _fetcher TradingDayFetcher() def is_trading_day(target_date: date None) - bool: 对外提供的便捷函数 return _fetcher.is_trading_day(target_date)现在在你的其他脚本里只需要这样调用from datetime import date from trading_calendar import is_trading_day if is_trading_day(): print(今天是交易日可以执行数据抓取任务。) else: print(今天是非交易日任务跳过。) # 也可以判断任意日期 some_day date(2024, 10, 1) # 国庆节 print(f2024-10-01是交易日吗 {is_trading_day(some_day)})4. 工程化进阶与生产环境考量上面的代码已经可以工作但对于一个生产环境可用的工具还需要考虑更多。4.1 数据更新策略交易日历不是一成不变的。每年年底会发布下一年度的安排年中也可能因特殊情况调整。我们的程序需要有更新机制。定时触发更新在主程序入口或一个独立的更新脚本中使用schedule库或操作系统cron job在每年12月1日或更早等日历发布后自动运行一次抓取任务更新下一年的JSON文件。按需懒加载与检查就像上面is_trading_day函数做的如果查询的年份数据不存在就尝试抓取。但在生产环境更推荐“预抓取”模式避免在关键查询路径上进行网络I/O。版本管理与回退保存历史年份的JSON文件。如果最新抓取的数据明显异常例如交易日数量远少于往年可以发出警报并自动使用上一年的数据作为fallback当然需要人工介入检查。4.2 错误处理与日志记录爬虫天生脆弱必须要有完善的错误处理和日志。import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(name)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(trading_calendar.log), logging.StreamHandler() ]) logger logging.getLogger(__name__) # 在代码中替换print为logger logger.info(f开始抓取{year}年交易日历...) logger.error(f抓取页面失败状态码: {resp.status_code}, exc_infoTrue)将print语句改为使用logging模块可以方便地控制日志级别并输出到文件便于后续排查问题。4.3 性能优化缓存与数据结构查询函数会被频繁调用必须高效。内存缓存我们已经用set在内存中缓存了数据查询时间复杂度是O(1)非常快。文件缓存JSON文件是持久化存储避免每次启动都重新爬取。多年度数据加载如果你的策略需要回测多年数据可以在初始化时一次性加载未来几年如当前年及前后各一年的日历到内存中用一个字典管理{2023: set(...), 2024: set(...), ...}。4.4 备选数据源与降级方案不能把鸡蛋放在一个篮子里。深交所网站万一临时不可用或改版我们需要备选方案。其他官方源上海证券交易所www.sse.com.cn也会发布交易日历解析逻辑类似。可以写一个适配器优先尝试深交所失败则尝试上交所。第三方数据API一些免费的金融数据API如akshare库中的接口也提供交易日历。可以将这些作为fallback源。但需注意第三方API的稳定性和调用限制。最终降级当所有网络数据源都失效时可以降级到基于周末和固定节假日的本地计算。虽然不准确无法处理调休但比完全无法判断要好。可以维护一个内置的常见固定节假日列表如春节、国庆等的大致日期范围作为最后保障。5. 常见问题与排查技巧实录在实际开发和运行中你几乎一定会遇到下面这些问题。5.1 爬虫抓取失败403/404/空数据问题返回403禁止访问或404找不到页面或HTML能抓到但解析不出数据。排查检查URL用浏览器手动访问你代码里的URL看是否能正常打开日历页面。网站路径可能已经变更。检查请求头特别是User-Agent。有些网站会检查Referer你可能需要加上Referer: http://www.szse.cn/。检查页面结构网站可能改版了。用浏览器的开发者工具重新检查日历表格的id、class或结构。你的BeautifulSoup选择器可能需要更新。查看响应内容将resp.text的前几千字符打印出来看看是否包含了预期的日历数据还是说返回了一个错误页面如“请启用JavaScript”。如果网站是动态加载Ajax的requests抓取静态HTML就无效了需要分析其背后的API接口。5.2 日期解析错误问题_parse_date_str函数无法解析某些日期字符串导致交易日缺失。排查打印原始数据在解析循环中将date_str_raw和status打印出来确认你抓取到的原始文本是什么样子。可能包含不可见的空格、换行符或特殊字符如\u3000全角空格。加强清洗在解析前使用raw_str.replace(\n, ).replace(\t, ).replace(\u3000, )等进行更彻底的清洗。扩展格式列表根据打印出的原始格式在date_formats列表中添加新的格式尝试。5.3 数据不准确漏掉交易日或包含非交易日问题判断结果与实际情况不符。排查人工核对随机挑几个已知的交易日如最近的周一和非交易日如最近的周六用你的函数判断并与实际情况对比。检查状态判断逻辑网站表格中“状态”一列的文本可能不是简单的“交易”和“休市”。可能是“开市”、“休市节假日”、“休市周末”等。你需要调整if 交易 in status:这一行条件可能需要更精确的匹配或者使用正则表达式。检查数据完整性将self.trading_days_cache排序后打印出来看看总数是否合理A股一年交易日大约240-250天。数量偏差过大肯定是解析逻辑有问题。5.4 网络不稳定或超时问题在服务器或云函数中运行时偶尔因网络问题抓取失败。解决增加重试机制使用tenacity库或自己写一个带指数退避的重试循环。from tenacity import retry, stop_after_attempt, wait_exponential retry(stopstop_after_attempt(3), waitwait_exponential(multiplier1, min2, max10)) def fetch_calendar_page_retry(self, year: int): # ... 原有的抓取逻辑 resp self.session.get(url, timeout15) # 适当增加超时时间 # ...设置更长的超时将timeout参数从10秒增加到15或30秒。使用更稳定的网络如果是在云服务器上确保网络出口IP没有被目标网站屏蔽。我个人在维护这类工具时会将其封装成一个独立的Python包通过pip安装。包内包含核心的抓取和判断逻辑以及一个命令行工具方便手动更新数据。同时会设置一个每日运行的轻量级检查任务它不抓取数据只是用本地数据判断今天是否为交易日并记录日志。一旦发现本地没有未来一段时间如下一周的数据就会触发告警提醒管理员手动或自动更新日历。这种“数据驱动监控告警”的模式能让这个小工具在后台稳定运行数年默默支撑着更上层的交易和数据应用。