From 99397a12e3b70c86982865710f944c380dbed0ae Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 2 Sep 2026 20:54:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(ccpm):=20=E4=B8=AD=E9=87=91=E6=89=80?= =?UTF-8?q?=E6=88=90=E4=BA=A4=E6=8C=81=E4=BB=93=E6=8E=92=E5=90=8D=E9=87=87?= =?UTF-8?q?=E9=9B=86=E2=80=94=E2=80=94CLI/API/WebUI=20=E4=B8=89=E7=AB=AF?= =?UTF-8?q?=20+=20=E6=96=B0=E6=89=8B=E7=A7=91=E6=99=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 easy_tdx.ccpm 模块(独立数据源,标准库 urllib 零依赖): 官网 /sj/ccpm/{YYYYMM}/{DD}/{品种}.xml,交易日约 16:15 发布, 单文件含全部合约 × 三类排名(datatypeid 0=成交量/1=持买单/2=持卖单)× 前 20 名会员 - 协议要点:?id= 为 0~99 随机防缓存参数可省略;非交易日 302→error_404, 禁用 urllib 自动重定向并翻译为 CcpmNoDataError(区别于网络错误 CcpmError); 仅 http 可用;历史可回溯至 2012 年 - 8 品种:IF/IH/IC/IM 股指 + TS/TF/T/TL 国债;latest_rank() 自动回溯最近交易日; 按日不可变 → ~/.easy_tdx/cache/ccpm/ 落盘缓存,历史二次查询零网络 - CLI:easy-tdx ccpm IF [--date] [--table] [--refresh] [--no-cache],all=全品种 - API:GET /ccpm/products(品种科普元数据)+ GET /ccpm/rank?product&date (404=非交易日/未发布,refresh 强制重抓) - WebUI「期货持仓排名」页(行情组):品种下拉+日期+自动回溯开关+一键采集, 合约页签自动标注主力,前 20 合计多/空/净持仓概览,三组排名并排表格 - 三段新手科普:「这是什么数据」「品种一览」「多单空单加减仓怎么看」 (强调排名看不出套保还是投机,空单多 ≠ 看空市场) - 测试 20 例(mock HTTP 零网络):解析/缓存/302 语义/回溯/路由/CLI --- .gitignore | 3 + CHANGELOG.md | 15 + README.md | 18 + src/easy_tdx/ccpm/__init__.py | 37 ++ src/easy_tdx/ccpm/client.py | 314 +++++++++++++++ src/easy_tdx/ccpm/models.py | 130 ++++++ src/easy_tdx/cli/__init__.py | 2 + src/easy_tdx/cli/cmd_ccpm.py | 98 +++++ src/easy_tdx/web/app.py | 3 + src/easy_tdx/web/routers/ccpm.py | 88 +++++ tests/unit/test_ccpm.py | 392 ++++++++++++++++++ web-ui/src/App.vue | 1 + web-ui/src/api.ts | 19 + web-ui/src/router.ts | 3 + web-ui/src/types.ts | 43 ++ web-ui/src/views/CcpmView.vue | 654 +++++++++++++++++++++++++++++++ 16 files changed, 1820 insertions(+) create mode 100644 src/easy_tdx/ccpm/__init__.py create mode 100644 src/easy_tdx/ccpm/client.py create mode 100644 src/easy_tdx/ccpm/models.py create mode 100644 src/easy_tdx/cli/cmd_ccpm.py create mode 100644 src/easy_tdx/web/routers/ccpm.py create mode 100644 tests/unit/test_ccpm.py create mode 100644 web-ui/src/views/CcpmView.vue diff --git a/.gitignore b/.gitignore index 04f0f0c..773d913 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ audit-report-*.html web-ui/e2e/.results/ web-ui/test-results/ web-ui/playwright-report/ + +# ZCode agent 会话目录(本地,不入库) +.zcode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 68055e7..f20c167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.29.1] — 2026-09-02 + +**中金所成交持仓排名采集(ccpm,独立数据源)**——散户能免费看到的**最接近"主力动向"的公开数据**:每个交易日收盘后约 16:15,中金所官网公布各期货品种「成交量 / 持买单量(多单)/ 持卖单量(空单)」各前 20 名期货公司会员排名。新增 `easy_tdx.ccpm` 模块并三端接入(CLI / Web API / WebUI),零第三方依赖(标准库 urllib)。 + +### 新增 + +- **核心模块 `easy_tdx.ccpm`**——抓取官网 `/sj/ccpm/{YYYYMM}/{DD}/{品种}.xml`(单文件含该品种全部合约 × 三类排名 × 各前 20 名会员)。协议逆向要点:官网 JS 的 `?id=` 仅为 0~99 随机防缓存参数可省略;非交易日返回 302→error_404,禁用 urllib 自动重定向并把 302/404 识别为「无数据」(`CcpmNoDataError`,区别于网络错误 `CcpmError`);仅 http 可用(https 握手失败);历史可回溯至 2012 年。`CcpmClient.get_rank()` 指定日期抓取、`latest_rank()` 自动回溯最近交易日(缺省最多回溯 15 天,覆盖春节长假);每个交易日数据发布后不可变 → 按日落盘缓存 `~/.easy_tdx/cache/ccpm/{YYYYMMDD}/{品种}.json`(随 `EASY_TDX_CONFIG_DIR`),历史二次查询零网络,`refresh=True` 强制重抓。 +- **品种覆盖 8 个**:IF 沪深300 / IH 上证50 / IC 中证500 / IM 中证1000 股指期货 + TS/TF/T/TL 2/5/10/30 年期国债期货;品种元数据(标的 / 合约规模 / 一句话科普)集中在 `ccpm/models.py`,三端共用同一份文案。 +- **CLI `easy-tdx ccpm`**——`easy-tdx ccpm IF [--date YYYY-MM-DD] [--table] [--refresh] [--no-cache]`,品种参数支持 `all` 一次抓全部 8 个品种(实测 460 行);`--table` 自动切换中文表头(JSON/CSV 保持英文机器友好列名)。 +- **Web API**——`GET /api/v1/ccpm/products`(品种科普元数据)+ `GET /api/v1/ccpm/rank?product=IF&date=2026-09-02`(`date` 缺省自动回溯;404=该日期非交易日或数据未发布,文案说明 16:15 发布时间;`refresh` 参数强制重抓)。 +- **WebUI「期货持仓排名」页**(行情组导航)——品种下拉(带中文名)+ 日期选择器 + 「自动取最近交易日」回溯开关 + 一键采集按钮;合约页签自动标注**主力**(=当日合计成交量最大的合约);前 20 名合计概览 chips(多单/空单/净持仓·多−空/当日成交,红涨绿跌);三组排名并排表格(与官网 CSV 同构),底部合计行;手动选非交易日给友好错误并可一键切回自动回溯。 +- **三段新手科普折叠帮助**(面向小白用户):①「这是什么数据」——"(代客)"=期货公司经纪客户合计而非自营、只统计前 20 名(约占全市场六到八成)、「增减」=加仓/减仓语义;②「品种一览」——IF/IH/IC/IM 各跟踪哪个指数、国债期货=利率期货(价格与市场利率反向,期限越长越敏感);③「多单、空单、加减仓怎么看」——多单=看涨或锁成本、空单=看跌**或**套保对冲,重点强调**排名表看不出套保还是投机,空单多 ≠ 看空市场**(股指期货空单大头常是机构套保盘),净持仓只是情绪参考,期货是零和合约全市场多空永远相等;另附页面级风险提示(期货带杠杆,亏损可超本金)。 +- **CLI/Web 测试 20 例**(`tests/unit/test_ccpm.py`,mock HTTP 零网络):XML 长表→宽表对齐、缺单元格容错、302→无数据翻译、按日缓存命中/强制刷新、latest_rank 回溯与耗尽、品种元数据完整性、路由 200/404/422、CliRunner 三例。 + ## [1.29.0] — 2026-09-02 **借鉴社区 Fork([swimmingaaron/easy_tdx](https://github.com/swimmingaaron/easy_tdx))的六项实用特性**——该 Fork 自 v1.20.12 分叉后独立演化出一批好想法,本轮逐项甄别后移植其精华(剥离其单文件前端/平行后端层/硬编码个人路径等不可维护部分):ZIG 策略、交易时段感知刷新、120 分钟 K 线、逐 bar 衍生字段、159 只核心龙头池、多 Provider LLM 直连。 @@ -17,6 +31,7 @@ - **AI 解读历史 + 龙头池页面(Web UI 导航新增「AI 解读历史」「龙头池」)**——每次成功的「直接解读」自动归档到 `~/.easy_tdx/llm_history.db`(SQLite,`llm_history_store`):提问 Prompt、解读正文、模型/耗时与当时的策略上下文(策略/参数/标的/周期/日期区间)。历史页按时间倒序展开查看,每条带「→ 去回测(带参数)」一键跳回回测页复现场景(复用寻优页的 query 预填链路)、查看提问 Prompt、删除/清空;API 为 `GET/DELETE /llm/history`。「龙头池」页展示 159 只核心龙头(搜索过滤 + 点击进个股详情,即 `universe=core` 同一名单)。另为前端路由表加兜底重定向:未注册路径(如把 API 路径当页面访问)回看板而非渲染空白。 - **多 Provider LLM 直连 + WebUI「AI 设置」页**——新增 `easy_tdx.ai` 模块与 `/llm/*` 路由。Provider 预设 9 家:DeepSeek / 通义千问 / 智谱 GLM(bigmodel.cn)/ Kimi / MiniMax / OpenAI / Claude(Anthropic 原生协议)/ Ollama(本地免 Key)/ 自定义(任意 OpenAI 兼容网关),base_url 与模型均可覆盖。配置落盘 `~/.easy_tdx/llm.json`(随 `EASY_TDX_CONFIG_DIR`),WebUI 表单与手工编辑同一份文件、双向兼容;字段级优先级 = 文件 > 环境变量(`LLM_PROVIDER`/`LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL`)> 预设默认。API:GET/PUT `/llm/config`(key 脱敏回显,回传脱敏串不覆盖真 key)、POST `/llm/test`(连通性+延迟)、POST `/llm/chat`。回测页「🤖 AI 解读」在模型已配置时新增「✨ 直接解读」——把组装好的报告 Prompt 提交为**后台任务**(接入与回测同一套 `task_runner`:4 线程池 + SQLite 持久化),前端短轮询 `GET /llm/chat/tasks/{task_id}` 取结果(`POST /llm/chat/async`,202),长耗时模型调用不占 HTTP 连接、断线重连后仍可查询,按钮实时显示已耗时;配置不完整在提交期即报 400,网络/鉴权/超时错误体现在任务态 `error`(读超时文案给出「调大超时」动作,默认超时 180s 可调至 600s)。未配置模型时保持导出 Prompt 手动路径。**思考型模型空白正文防御**(实测:GLM-5.x 的 `reasoning_content` 思考链计入 max_tokens,4000 预算被整份报告的思考耗尽后 `content` 为空白——truthy 但渲染为空,状态条报成功而正文空白):解析层对空白正文显式拦截——有思考链时报「调大 Max Tokens」的可操作错误(含当前值与 finish_reason),无思考链按格式错误上报,绝不返回空串;max_tokens 默认 4000→16000(上限即目标,按实际生成计费),前端再拦一道纯空白。零第三方依赖(标准库 urllib + `asyncio.to_thread`)。 + ### 测试 - 新增 6 个单测文件共 51 例:`test_mytt_zig.py`(ZIG 边界/单调/V 型/锯齿/阈值双写法)、`test_zig_strategy.py`(注册/参数校验/引擎成交/独立文件加载/预设网格)、`test_realtime_session.py`(窗口边界/午休/周末/session_info)、`test_bars_min120_derived.py`(重采样聚合/裁剪/缺列、衍生字段/兜底)、`test_screen_universe_core.py`(名单 159 只唯一性/已知龙头/core 过滤准确性)、`test_ai_llm.py`(配置文件↔环境变量优先级/脱敏/双协议请求组装/HTTP 错误包装,HTTP 层 monkeypatch 零真实网络)。 diff --git a/README.md b/README.md index 7193534..1bfdebe 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普 **v1.29 同场加映**(借鉴社区 Fork [swimmingaaron/easy_tdx](https://github.com/swimmingaaron/easy_tdx) 的好想法,逐项甄别后移植):**ZIG 右侧突破回补策略**(MyTT 新增之字转向指标,波谷建仓带硬止损 + 见顶记前高 + 右侧突破回补,对冲未来函数的前视偏差);**交易时段感知刷新**(看板轮询休市自动暂停、三态状态栏、手动刷新不受限,不再半夜空转刷数据);**120 分钟 K 线**(MAC 原生两级降级获取)与**逐 bar 涨跌幅/振幅字段**(`pre_close`/`change_pct`/`amplitude_pct` 直接返回);**159 只核心龙头池**(东财全行业龙头名单 → `universe=core` 一键只扫龙头,配「龙头池」页面;名单仅为扫描范围,不构成任何推荐);全站**风险提示与免责声明**常驻。WebUI 工程加固:未知 API 路径返回 JSON 404 而非 HTML、入口页 `no-store` 防缓存旧版——更新即所见。 +**期货持仓雷达:中金所成交持仓排名(v1.29.1 新增)**——每个交易日收盘后约 16:15,中金所官网公布各期货品种「成交量 / 持买单量(多单)/ 持卖单量(空单)」各前 20 名期货公司会员的排名,这是散户能免费看到的**最接近"主力动向"的公开数据**。本工具把它变成一键采集:CLI 一行命令(`easy-tdx ccpm IF --table`)、Web API(`GET /api/v1/ccpm/rank`)、WebUI「期货持仓排名」页(品种下拉 + 日期选择器 + 一键采集,合约页签自动标注主力合约,前 20 名合计多单/空单/净持仓概览,三组排名并排表格)。覆盖 IF 沪深300 / IH 上证50 / IC 中证500 / IM 中证1000 四大股指期货与 TS/TF/T/TL 2/5/10/30 年期国债期货;非交易日与未发布自动识别并可回溯最近交易日;每个交易日数据发布后不可变,按日落盘缓存(`~/.easy_tdx/cache/ccpm/`),历史二次查询零网络。页面附三段新手科普:「(代客)」是什么意思、多单/空单/加减仓怎么看、以及最重要的一条——**排名表看不出是套保还是投机,空单多 ≠ 看空市场**(股指期货空单大头常是机构套保盘)。 + 行情终端 Web UI(v1.23):市场看板 / 自选行情 / 个股与板块详情 **回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。v1.28.1 起新手与 AI 辅助两连击:三个报告框内置**名词解释折叠帮助**(33 个词条讲清每项指标是什么、怎么算、怎么看,默认收起点击展开,重点粗体、阈值橙色、细节细体);回测报告一键导出 **AI 解读 Prompt**——把配置 + 25 项指标 + WF 逐窗 + 一条龙评估 + 评级 + 成交摘要组装成结构化提示词,复制发给 ChatGPT / Claude / DeepSeek / 豆包,即可获得「老手朋友」口吻的通俗解读、改进建议与 **0-10 信心分**(附「该不该执行」行动刻度)。 @@ -176,6 +178,21 @@ easy-tdx announcement 601088 --count 5 --download 5 --download-dir ./pdfs > 独立数据源(巨潮资讯网),无需连接 TDX 行情服务器即可使用。 > 返回的 ``url`` 含 4 参数可直接打开,``pdf_url`` 为 PDF 直链。 +### 中金所成交持仓排名(v1.29.1) + +```bash +easy-tdx ccpm IF --table # 最近有数据的交易日(缺省自动回溯) +easy-tdx ccpm IF --date 2026-09-02 # 指定交易日 +easy-tdx ccpm all --date 2026-08-28 --table # 全部 8 个品种一次抓取 +easy-tdx ccpm TL --refresh # 忽略本地缓存,强制重新抓取 +``` + +> 独立数据源(中金所官网),无需连接 TDX 行情服务器。品种:IF 沪深300 / IH 上证50 / IC 中证500 / IM 中证1000 股指期货,TS/TF/T/TL 为 2/5/10/30 年期国债期货。 +> 每个交易日收盘后约 16:15 发布,含该品种**全部合约 × 三类排名(成交量 / 持买单量·多单 / 持卖单量·空单)× 各前 20 名会员**; +> 数据发布后不可变,按日缓存到 `~/.easy_tdx/cache/ccpm/`,历史二次查询零网络。 +> JSON 输出为英文列名(vol/long_pos/short_pos 等,机器友好),`--table` 自动切换中文表头。 +> WebUI 对应「期货持仓排名」页(含新手科普),API 为 `GET /api/v1/ccpm/rank`。 + ### 技术指标 ```bash @@ -518,6 +535,7 @@ Web UI 包含两大模块: - **行情终端(v1.23 新增)**——侧边栏专业终端布局: - **市场看板**:五大指数实时行情(SSE 推送)、全市场涨跌统计(涨/跌/平/涨停/跌停 + 堆叠条)、行业/概念板块热度榜、涨幅榜/跌幅榜、两市异动雷达(加速拉升/封涨停板/大单托盘等),点击个股打开五档盘口 + 分时/日K 对话框; - **自选行情**:输入 6 位代码一键加自选(SQLite 持久化),全表实时刷新(SSE),行内迷你分时图,点击行看个股详情; + - **期货持仓排名**(v1.29.1):中金所每日成交/持仓前 20 名会员一键采集(品种下拉 + 日期选择 + 自动回溯最近交易日开关),合约页签自动标注主力,前 20 名合计多单/空单/净持仓概览,三组排名并排表格;附「品种一览」「多单空单加减仓怎么看」新手科普(重点:排名看不出套保还是投机,空单多 ≠ 看空市场); - **实时推送架构**:后端单条轮询循环 fan-out 到所有 SSE 连接(交易时段 ~8s 一拍,盘外降频 60s,无人订阅自动休眠),前端指数退避重连。 - **回测工作台(v1.17 起)**——浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现,全程零代码。 diff --git a/src/easy_tdx/ccpm/__init__.py b/src/easy_tdx/ccpm/__init__.py new file mode 100644 index 0000000..b478b39 --- /dev/null +++ b/src/easy_tdx/ccpm/__init__.py @@ -0,0 +1,37 @@ +"""中金所成交持仓排名(ccpm)——官网每日成交/持仓前 20 名会员数据。 + +独立于 TDX 协议的 HTTP 数据源(中金所官网),无需连接行情服务器。 + +用法:: + + from easy_tdx.ccpm import CcpmClient + + client = CcpmClient() + df = client.get_rank("IF", "2026-09-02") # 指定交易日 + df = client.latest_rank("IF") # 自动回溯最近有数据的交易日 +""" + +from .client import WIDE_COLUMNS, CcpmClient, normalize_date, parse_xml +from .models import ( + PRODUCT_CODES, + PRODUCTS, + CcpmError, + CcpmNoDataError, + ProductMeta, + list_products, + normalize_product, +) + +__all__ = [ + "CcpmClient", + "CcpmError", + "CcpmNoDataError", + "PRODUCTS", + "PRODUCT_CODES", + "ProductMeta", + "WIDE_COLUMNS", + "list_products", + "normalize_date", + "normalize_product", + "parse_xml", +] diff --git a/src/easy_tdx/ccpm/client.py b/src/easy_tdx/ccpm/client.py new file mode 100644 index 0000000..4f41f8b --- /dev/null +++ b/src/easy_tdx/ccpm/client.py @@ -0,0 +1,314 @@ +"""中金所成交持仓排名(ccpm)客户端。 + +数据源:中国金融期货交易所官网「成交持仓排名」页 +http://www.cffex.com.cn/cn/ccpm.html + +实际数据文件(每个交易日收盘后约 16:10 北京时间批量生成):: + + http://www.cffex.com.cn/sj/ccpm/{YYYYMM}/{DD}/{品种}.xml + +协议要点(2026-09 实测): + +- 官网 JS 在 URL 上拼的 ``?id=<随机数>`` 仅为防浏览器缓存参数,无语义,可省略。 +- XML(UTF-8)包含该品种当日**所有合约** × 三类排名 × 各前 20 名会员: + ``datatypeid`` 0=成交量 / 1=持买单量(多单)/ 2=持卖单量(空单)。 +- 非交易日或未发布时官网返回 302 → ``error_404.html``:HTTP 客户端若 + 自动跟随重定向会拿到 200 的错误页,必须禁用重定向并把 302/404 识别为 + 「无数据」(:class:`CcpmNoDataError`)。 +- 仅支持 http(https 证书握手失败),无鉴权/无频控。 +- 每个交易日的数据发布后不可变 → 按日落盘缓存 + ``~/.easy_tdx/cache/ccpm/{YYYYMMDD}/{品种}.json``(随 + ``EASY_TDX_CONFIG_DIR``),历史日期二次查询零网络请求。 +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any +from urllib import request as urlrequest +from urllib.error import HTTPError +from xml.etree import ElementTree as ET +from zoneinfo import ZoneInfo + +import pandas as pd + +from .models import CcpmError, CcpmNoDataError, normalize_product + +logger = logging.getLogger(__name__) + +_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) +_BASE_URL = "http://www.cffex.com.cn/sj/ccpm/{yyyymm}/{dd}/{product}.xml" +_SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") + +#: datatypeid → 宽表列前缀(0=成交量 / 1=持买单量 / 2=持卖单量,官网 ccpm.js 语义) +_TYPE_KEYS = {0: "vol", 1: "long", 2: "short"} + +#: 宽表列(合约 × 排名 对齐三类排名,与官网 CSV 同构) +WIDE_COLUMNS = [ + "trading_day", + "product", + "instrument", + "rank", + "vol_member", + "vol", + "vol_chg", + "long_member", + "long_pos", + "long_chg", + "short_member", + "short_pos", + "short_chg", +] + + +class _NoRedirectHandler(urlrequest.HTTPRedirectHandler): + """禁止跟随重定向:非交易日的 302 → error_404.html 不能被当成数据页。""" + + def redirect_request( + self, + req: urlrequest.Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> urlrequest.Request | None: + return None + + +_OPENER = urlrequest.build_opener(_NoRedirectHandler) + + +def _fetch_xml(url: str, timeout: float) -> str: + """GET 原始 XML 文本(stdlib urllib,monkeypatch 点)。 + + 302/404 → :class:`CcpmNoDataError`(非交易日/未发布);其他 HTTP 错误 → + :class:`CcpmError`。 + """ + req = urlrequest.Request(url, headers={"User-Agent": _UA}) + try: + with _OPENER.open(req, timeout=timeout) as resp: + return str(resp.read(), encoding="utf-8") + except HTTPError as e: + if e.code in (301, 302, 303, 307, 308, 404): + raise CcpmNoDataError(f"该日期非交易日或数据尚未发布: {url}") from e + raise CcpmError(f"中金所返回 HTTP {e.code}: {url}") from e + + +def _today_shanghai() -> date: + return datetime.now(_SHANGHAI_TZ).date() + + +def normalize_date(value: str | date | datetime | None) -> date: + """日期归一化:接受 ``date``/``datetime``/``YYYY-MM-DD``/``YYYYMMDD``。""" + if value is None: + return _today_shanghai() + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + s = str(value).strip().replace("-", "").replace("/", "") + if not re.fullmatch(r"\d{8}", s): + raise ValueError(f"日期格式应为 YYYY-MM-DD 或 YYYYMMDD: {value}") + return datetime.strptime(s, "%Y%m%d").date() + + +def _to_int(v: str | None) -> int | None: + if v is None: + return None + v = v.strip() + if not v: + return None + try: + return int(v) + except ValueError: + return None + + +def parse_xml(text: str) -> list[dict[str, Any]]: + """解析 positionRank XML → 宽表行列表(合约 × 排名 对齐三类排名)。 + + XML 长表结构(每条记录一个 ```` 节点):: + + + IF2609 20260902 + 1 1 + 国泰君安(代客) 22066 + -789 0001 IF + ... + + 某合约某类型缺某排名时对应单元格置 None(不丢行)。 + """ + try: + root = ET.fromstring(text) + except ET.ParseError as e: + raise CcpmError(f"XML 解析失败(可能返回了错误页): {e}") from e + + def _g(node: Any, tag: str) -> str: + el = node.find(tag) + return (el.text or "").strip() if el is not None else "" + + cells: dict[tuple[str, int, int], dict[str, Any]] = {} + meta: dict[str, dict[str, str]] = {} + for node in root.findall("data"): + instrument = _g(node, "instrumentid") + dtype = _to_int(_g(node, "datatypeid")) + rank = _to_int(_g(node, "rank")) + if not instrument or dtype not in _TYPE_KEYS or rank is None: + continue + cells[(instrument, dtype, rank)] = { + "member": _g(node, "shortname"), + "value": _to_int(_g(node, "volume")), + "chg": _to_int(_g(node, "varvolume")), + } + meta[instrument] = { + "trading_day": _g(node, "tradingday"), + "product": _g(node, "productid"), + } + + rows: list[dict[str, Any]] = [] + ranks = sorted({r for (_, _, r) in cells}) + for instrument in sorted(meta): + for rk in ranks: + row: dict[str, Any] = { + "trading_day": meta[instrument]["trading_day"], + "product": meta[instrument]["product"], + "instrument": instrument, + "rank": rk, + } + for dtype, key in _TYPE_KEYS.items(): + cell = cells.get((instrument, dtype, rk)) + suffix = "vol" if key == "vol" else ("long_pos" if key == "long" else "short_pos") + row[f"{key}_member"] = cell["member"] if cell else None + row[suffix] = cell["value"] if cell else None + row[f"{key}_chg"] = cell["chg"] if cell else None + rows.append(row) + return rows + + +# --------------------------------------------------------------------------- +# 按日文件缓存(交易日数据发布后不可变,永不过期) +# --------------------------------------------------------------------------- + + +def _cache_dir() -> Path: + base = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx"))) + return base / "cache" / "ccpm" + + +def _cache_path(d: date, product: str) -> Path: + return _cache_dir() / d.strftime("%Y%m%d") / f"{product}.json" + + +def _load_cache(d: date, product: str) -> list[dict[str, Any]] | None: + path = _cache_path(d, product) + if not path.exists(): + return None + try: + raw = json.loads(path.read_text("utf-8")) + rows = raw.get("rows") + return rows if isinstance(rows, list) and rows else None + except Exception: # noqa: BLE001 — 损坏缓存当未命中,走网络 + return None + + +def _save_cache(d: date, product: str, rows: list[dict[str, Any]]) -> None: + try: + path = _cache_path(d, product) + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "trading_day": d.strftime("%Y%m%d"), + "product": product, + "fetched_at": datetime.now(_SHANGHAI_TZ).isoformat(), + "count": len(rows), + "rows": rows, + } + path.write_text(json.dumps(payload, ensure_ascii=False), "utf-8") + except Exception as e: # noqa: BLE001 — 缓存写失败不影响主流程 + logger.warning("ccpm 缓存写入失败: %s", e) + + +class CcpmClient: + """中金所成交持仓排名客户端(无状态 HTTP + 按日文件缓存)。 + + 用法:: + + from easy_tdx.ccpm import CcpmClient + + client = CcpmClient() + df = client.get_rank("IF", "2026-09-02") # 指定交易日(宽表 DataFrame) + df = client.latest_rank("IF") # 自动回溯最近有数据的交易日 + """ + + def __init__(self, *, timeout: float = 15.0, use_cache: bool = True) -> None: + self.timeout = timeout + self.use_cache = use_cache + + def get_rank( + self, + product: str, + trade_date: str | date | datetime | None = None, + *, + refresh: bool = False, + ) -> pd.DataFrame: + """获取某品种某交易日的成交持仓排名(前 20 名会员 × 全部合约)。 + + Args: + product: 品种代码(IF/IH/IC/IM/TS/TF/T/TL,大小写宽容)。 + trade_date: 交易日(``YYYY-MM-DD``/``YYYYMMDD``/``date``), + 缺省为今天(上海时区)。 + refresh: 忽略本地缓存强制重新抓取。 + + Returns: + 宽表 ``DataFrame``,列见 :data:`WIDE_COLUMNS`;每行 = 某合约某排名, + 三类排名(成交量 / 持买单量 / 持卖单量)并排对齐,与官网 CSV 同构。 + + Raises: + CcpmNoDataError: 该日期非交易日或数据尚未发布(约 16:15 后生成)。 + ValueError: 品种/日期格式非法。 + """ + meta = normalize_product(product) + d = normalize_date(trade_date) + + rows = _load_cache(d, meta.code) if self.use_cache and not refresh else None + if rows is None: + url = _BASE_URL.format( + yyyymm=d.strftime("%Y%m"), dd=d.strftime("%d"), product=meta.code + ) + try: + rows = parse_xml(_fetch_xml(url, self.timeout)) + except (CcpmError, CcpmNoDataError): + raise + except Exception as e: # noqa: BLE001 — HTTP/网络统一转领域异常 + raise CcpmError(f"中金所 ccpm 抓取失败: {e}") from e + if self.use_cache and rows: + _save_cache(d, meta.code, rows) + return pd.DataFrame(rows, columns=WIDE_COLUMNS) + + def latest_rank( + self, product: str, *, max_back: int = 15, refresh: bool = False + ) -> pd.DataFrame: + """自动回溯到最近一个有数据的交易日(从今天起最多往回找 ``max_back`` 天)。 + + 节假日/周末/当日未发布(约 16:15 前)会自然回退到上一交易日, + 春节等长假(≤8 个自然日)也在默认回溯范围内。 + """ + d = _today_shanghai() + last_err: Exception | None = None + for _ in range(max_back + 1): + try: + return self.get_rank(product, d, refresh=refresh) + except CcpmNoDataError as e: + last_err = e + d = d - timedelta(days=1) + raise CcpmError( + f"最近 {max_back + 1} 个自然日内未找到 {str(product).upper()} 的成交持仓数据" + ) from last_err diff --git a/src/easy_tdx/ccpm/models.py b/src/easy_tdx/ccpm/models.py new file mode 100644 index 0000000..5542ff1 --- /dev/null +++ b/src/easy_tdx/ccpm/models.py @@ -0,0 +1,130 @@ +"""中金所成交持仓排名(ccpm)领域模型与品种元数据。 + +品种科普信息(给 WebUI「小白」用户)集中在 :data:`PRODUCTS`, +CLI / API / WebUI 三端共用同一份文案,避免多处维护漂移。 +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + +from ..exceptions import TdxError + + +class CcpmError(TdxError): + """ccpm 数据抓取/解析失败(网络错误、官网协议变更等)。""" + + +class CcpmNoDataError(CcpmError): + """指定日期非交易日或数据尚未发布(官网返回 302 → error_404 页)。""" + + +@dataclass(frozen=True) +class ProductMeta: + """期货品种元数据(纯静态科普信息,不含行情)。""" + + code: str # 品种代码,如 IF + name: str # 全称,如 沪深300股指期货 + category: str # 分类:股指期货 / 国债期货 + underlying: str # 标的说明(跟踪哪个指数 / 名义国债条款) + underlying_code: str # 对应指数代码(国债期货为空串) + unit: str # 合约规模说明(乘数 / 面值) + intro: str # 一句话定位科普(这个品种代表市场的哪一块) + + def to_dict(self) -> dict[str, str]: + return asdict(self) + + +PRODUCTS: dict[str, ProductMeta] = { + "IF": ProductMeta( + code="IF", + name="沪深300股指期货", + category="股指期货", + underlying=( + "沪深300指数(000300)——沪深两市规模最大、流动性最好的 300 只股票,约覆盖 A 股六成市值" + ), + underlying_code="000300", + unit="合约乘数 300 元/点(指数 4000 点时 1 手 ≈ 120 万元)", + intro="代表「A 股大盘整体」的股指期货,是最主流的机构套保与多空博弈工具。", + ), + "IH": ProductMeta( + code="IH", + name="上证50股指期货", + category="股指期货", + underlying="上证50指数(000016)——沪市规模最大的 50 只超级蓝筹(银行、保险、白酒为主)", + underlying_code="000016", + unit="合约乘数 300 元/点(指数 3000 点时 1 手 ≈ 90 万元)", + intro="代表「超大盘权重股」,与 IH 空单常被用来观察机构对蓝筹/50ETF 的套保力度。", + ), + "IC": ProductMeta( + code="IC", + name="中证500股指期货", + category="股指期货", + underlying="中证500指数(000905)——剔除沪深300成分股后市值居前的 500 只中盘股", + underlying_code="000905", + unit="合约乘数 200 元/点(指数 6000 点时 1 手 ≈ 120 万元)", + intro="代表「中盘股」,中性策略(多头持票 + 空头 IC)最常用的对冲合约。", + ), + "IM": ProductMeta( + code="IM", + name="中证1000股指期货", + category="股指期货", + underlying="中证1000指数(000852)——剔除沪深300与中证500后市值居前的 1000 只小盘股", + underlying_code="000852", + unit="合约乘数 200 元/点(指数 6000 点时 1 手 ≈ 120 万元)", + intro="代表「小盘股」,小市值风格博弈与量化对冲的主战场。", + ), + "TS": ProductMeta( + code="TS", + name="2年期国债期货", + category="国债期货", + underlying="面值 200 万元、票面利率 3% 的名义中短期国债(利率期货)", + underlying_code="", + unit="1 手面值 200 万元,按百元净价报价", + intro="跟踪短端利率预期:价格涨 ≈ 市场预期利率下行,价格跌 ≈ 预期利率上行。", + ), + "TF": ProductMeta( + code="TF", + name="5年期国债期货", + category="国债期货", + underlying="面值 100 万元、票面利率 3% 的名义中期国债(利率期货)", + underlying_code="", + unit="1 手面值 100 万元,按百元净价报价", + intro="中期利率预期工具,债券机构常用的久期管理手段。", + ), + "T": ProductMeta( + code="T", + name="10年期国债期货", + category="国债期货", + underlying="面值 100 万元、票面利率 3% 的名义长期国债(利率期货)", + underlying_code="", + unit="1 手面值 100 万元,按百元净价报价", + intro="长端利率的「风向标」,成交持仓在国债期货里最活跃。", + ), + "TL": ProductMeta( + code="TL", + name="30年期国债期货", + category="国债期货", + underlying="面值 100 万元、票面利率 3% 的名义超长期国债(利率期货)", + underlying_code="", + unit="1 手面值 100 万元,按百元净价报价", + intro="久期最长、对利率最敏感,近年机构「资产荒」下的热门品种。", + ), +} + +#: 品种展示顺序(股指在前、国债在后) +PRODUCT_CODES: list[str] = list(PRODUCTS) + + +def normalize_product(product: str) -> ProductMeta: + """品种代码归一化(大小写/空格宽容),未知品种抛 ``ValueError``。""" + code = str(product).strip().upper() + meta = PRODUCTS.get(code) + if meta is None: + raise ValueError(f"未知品种 {product!r},支持: {', '.join(PRODUCT_CODES)}") + return meta + + +def list_products() -> list[dict[str, str]]: + """全部品种元数据(供 CLI / API / WebUI 展示)。""" + return [m.to_dict() for m in PRODUCTS.values()] diff --git a/src/easy_tdx/cli/__init__.py b/src/easy_tdx/cli/__init__.py index 7d16ffb..ccb7206 100644 --- a/src/easy_tdx/cli/__init__.py +++ b/src/easy_tdx/cli/__init__.py @@ -18,6 +18,7 @@ from .cmd_board import ( board_summary, ) from .cmd_capital import capital_flow +from .cmd_ccpm import ccpm from .cmd_chanlun import chanlun from .cmd_company import company_info, company_info_content, finance_info from .cmd_ex import ex @@ -77,6 +78,7 @@ cli.add_command(board_change_ranking) cli.add_command(board_summary) cli.add_command(belong_board) cli.add_command(capital_flow) +cli.add_command(ccpm) cli.add_command(unusual) cli.add_command(market_stat) cli.add_command(server_info) diff --git a/src/easy_tdx/cli/cmd_ccpm.py b/src/easy_tdx/cli/cmd_ccpm.py new file mode 100644 index 0000000..1f3e392 --- /dev/null +++ b/src/easy_tdx/cli/cmd_ccpm.py @@ -0,0 +1,98 @@ +"""中金所成交持仓排名命令(独立数据源,无需 TDX 服务器)。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + import pandas as pd + +#: 表格模式下的中文列名(JSON/CSV 保持英文机器友好列名) +_COLUMN_LABELS = { + "trading_day": "交易日", + "product": "品种", + "instrument": "合约", + "rank": "排名", + "vol_member": "成交量·会员", + "vol": "成交量(手)", + "vol_chg": "增减", + "long_member": "持买单·会员", + "long_pos": "持买单量(手)", + "long_chg": "增减2", + "short_member": "持卖单·会员", + "short_pos": "持卖单量(手)", + "short_chg": "增减3", +} + + +@click.command("ccpm") +@click.argument("product", default="IF") +@click.option( + "--date", + "trade_date", + default=None, + help="交易日 YYYY-MM-DD(缺省自动回溯到最近有数据的交易日)", +) +@click.option("--table", "use_table", is_flag=True, help="表格输出") +@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") +@click.option("--refresh", is_flag=True, help="忽略本地缓存,强制重新抓取") +@click.option("--no-cache", is_flag=True, help="本次不读也不写本地缓存") +def ccpm( + product: str, + trade_date: str | None, + use_table: bool, + output_fmt: str, + refresh: bool, + no_cache: bool, +) -> None: + """获取中金所成交持仓排名(官网每日收盘后约 16:15 发布,前 20 名会员)。 + + \b + 品种代码: + IF 沪深300 IH 上证50 IC 中证500 IM 中证1000 + TS 2年国债 TF 5年国债 T 10年国债 TL 30年国债 + all = 一次抓取全部 8 个品种 + + \b + 示例: + + easy-tdx ccpm IF --table + + easy-tdx ccpm IF --date 2026-09-02 + + easy-tdx ccpm all --date 2026-08-28 --table + """ + from ..ccpm import PRODUCT_CODES, CcpmClient, CcpmError + from .output import print_error, print_output + + products = PRODUCT_CODES if product.strip().lower() == "all" else [product.strip().upper()] + client = CcpmClient(use_cache=not no_cache) + frames = [] + try: + for p in products: + if trade_date: + frames.append(client.get_rank(p, trade_date, refresh=refresh)) + else: + frames.append(client.latest_rank(p, refresh=refresh)) + except (CcpmError, ValueError) as e: + print_error(str(e)) + raise SystemExit(1) from e + + import pandas as pd + + df = pd.concat(frames, ignore_index=True) if len(frames) > 1 else frames[0] + + fmt = "table" if use_table else output_fmt + if fmt == "table": + click.echo(_render_table(df)) + else: + print_output(df, fmt) + + +def _render_table(df: pd.DataFrame) -> str: + """中文表头 + 不截断会员名的表格渲染。""" + from .output import _render_table_full + + return _render_table_full(df.rename(columns=_COLUMN_LABELS)) diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index 472e694..fef387e 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -288,6 +288,7 @@ def _create_app( from easy_tdx.web.routers.bars import router as bars_router from easy_tdx.web.routers.block import router as block_router from easy_tdx.web.routers.board_mac import router as board_mac_router + from easy_tdx.web.routers.ccpm import router as ccpm_router from easy_tdx.web.routers.chanlun import router as chanlun_router from easy_tdx.web.routers.ex_market import router as ex_market_router from easy_tdx.web.routers.finance import router as finance_router @@ -323,6 +324,8 @@ def _create_app( app.include_router(announcement_router, prefix="/api/v1") # 新浪财报三表路由(独立数据源) app.include_router(sina_router, prefix="/api/v1") + # 中金所成交持仓排名路由(独立数据源,官网 ccpm 每日前 20 名会员) + app.include_router(ccpm_router, prefix="/api/v1") # 回测路由(纯计算,不依赖行情连接 lifespan) app.include_router(backtest_router, prefix="/api/v1") # 策略库路由(SQLite 持久化,纯数据 CRUD) diff --git a/src/easy_tdx/web/routers/ccpm.py b/src/easy_tdx/web/routers/ccpm.py new file mode 100644 index 0000000..5ff79e8 --- /dev/null +++ b/src/easy_tdx/web/routers/ccpm.py @@ -0,0 +1,88 @@ +"""中金所成交持仓排名路由(独立数据源,不依赖 TDX 服务器)。""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +router = APIRouter(tags=["ccpm"]) + +_PRODUCT_PATTERN = r"^(IF|IH|IC|IM|TS|TF|T|TL)$" +_DATE_PATTERN = r"^\d{4}-\d{2}-\d{2}$" + + +class CcpmProductsResponse(BaseModel): + """品种列表 + 科普元数据(静态)。""" + + products: list[dict[str, str]] + count: int + + +class CcpmRankResponse(BaseModel): + """成交持仓排名(宽表:合约 × 排名 对齐三类排名)。""" + + trading_day: str # 实际交易日 YYYYMMDD(自动回溯时可能 ≠ 请求日期) + product: str + product_name: str + data: list[dict[str, Any]] + count: int + + +@router.get("/ccpm/products", response_model=CcpmProductsResponse) +async def ccpm_products() -> CcpmProductsResponse: + """全部可采集品种与科普元数据(品种代码 / 标的 / 合约规模 / 一句话介绍)。""" + from easy_tdx.ccpm import list_products + + products = list_products() + return CcpmProductsResponse(products=products, count=len(products)) + + +@router.get("/ccpm/rank", response_model=CcpmRankResponse) +async def ccpm_rank( + product: str = Query("IF", pattern=_PRODUCT_PATTERN, description="品种代码"), + date: str | None = Query( + None, + pattern=_DATE_PATTERN, + description="交易日 YYYY-MM-DD;缺省自动回溯最近有数据的交易日", + ), + refresh: bool = Query(False, description="忽略本地缓存强制重新抓取"), +) -> CcpmRankResponse: + """获取某品种某交易日的成交持仓排名(官网每日收盘后约 16:15 发布)。 + + 每行 = 某合约某排名,成交量 / 持买单量(多单)/ 持卖单量(空单)三类 + 前 20 名会员并排对齐;包含该品种当日全部挂牌合约。 + """ + from easy_tdx.ccpm import CcpmClient, CcpmError, CcpmNoDataError, normalize_product + + client = CcpmClient() + + def _fetch() -> CcpmRankResponse: + try: + df = ( + client.get_rank(product, date, refresh=refresh) + if date + else client.latest_rank(product, refresh=refresh) + ) + except CcpmNoDataError as e: + raise HTTPException( + status_code=404, + detail=f"{product} 在 {date} 无数据:该日期非交易日或数据尚未发布" + "(每个交易日收盘后约 16:15 生成)", + ) from e + except CcpmError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + meta = normalize_product(product) + records = df.to_dict(orient="records") + trading_day = str(records[0]["trading_day"]) if records else (date or "").replace("-", "") + return CcpmRankResponse( + trading_day=trading_day, + product=meta.code, + product_name=meta.name, + data=records, + count=len(records), + ) + + return await asyncio.to_thread(_fetch) diff --git a/tests/unit/test_ccpm.py b/tests/unit/test_ccpm.py new file mode 100644 index 0000000..0293e3d --- /dev/null +++ b/tests/unit/test_ccpm.py @@ -0,0 +1,392 @@ +"""中金所成交持仓排名(ccpm)离线测试 —— mock HTTP,零网络依赖。 + +覆盖:XML 解析(长表→宽表对齐)、按日缓存读写、非交易日 302 语义、 +日期/品种归一化、latest_rank 自动回溯、品种元数据完整性、 +Web 路由(/ccpm/products、/ccpm/rank)与 CLI 命令。 +""" + +from __future__ import annotations + +import json +from datetime import date + +import pytest + +# --------------------------------------------------------------------------- +# 测试夹具:最小 positionRank XML(1 个合约 × 3 类排名 × 前 2 名) +# --------------------------------------------------------------------------- + +_SAMPLE_XML = """ + + + IF260920260902 + 01 + 甲期货(代客)10010 + 0001IF + + + IF260920260902 + 02 + 乙期货(代客)80-5 + 0002IF + + + IF260920260902 + 11 + 丙期货(代客)220-7 + 0003IF + + + IF260920260902 + 12 + 甲期货(代客)1800 + 0001IF + + + IF260920260902 + 21 + 乙期货(代客)1503 + 0002IF + + + IF260920260902 + 22 + 丙期货(代客)90-2 + 0003IF + + +""" + + +@pytest.fixture() +def isolated_config(tmp_path, monkeypatch): + """缓存目录隔离到 tmp_path(EASY_TDX_CONFIG_DIR 约定)。""" + monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path)) + return tmp_path + + +def _mock_fetch(monkeypatch, sample: str = _SAMPLE_XML): + """把 _fetch_xml 替换为返回固定 XML,并记录调用 URL。""" + from easy_tdx.ccpm import client as ccpm_client + + calls: list[str] = [] + + def fake(url: str, timeout: float) -> str: + calls.append(url) + return sample + + monkeypatch.setattr(ccpm_client, "_fetch_xml", fake) + return calls + + +# --------------------------------------------------------------------------- +# 领域异常与导出 +# --------------------------------------------------------------------------- + + +def test_error_hierarchy() -> None: + """CcpmError/CcpmNoDataError 必须继承 TdxError(全局 except 覆盖)。""" + from easy_tdx.ccpm import CcpmError, CcpmNoDataError + from easy_tdx.exceptions import TdxError + + assert issubclass(CcpmError, TdxError) + assert issubclass(CcpmNoDataError, CcpmError) + + +def test_public_exports() -> None: + from easy_tdx import ccpm + + for name in ("CcpmClient", "CcpmError", "CcpmNoDataError", "PRODUCTS", "list_products"): + assert hasattr(ccpm, name) + + +# --------------------------------------------------------------------------- +# 品种元数据 +# --------------------------------------------------------------------------- + + +def test_products_meta_complete() -> None: + """8 个品种:股指 4 + 国债 4,字段非空,股指带指数代码。""" + from easy_tdx.ccpm import PRODUCTS, list_products + + assert list(PRODUCTS) == ["IF", "IH", "IC", "IM", "TS", "TF", "T", "TL"] + metas = list_products() + assert len(metas) == 8 + for m in metas: + for field in ("code", "name", "category", "underlying", "unit", "intro"): + assert m[field], f"{m['code']} 缺少 {field}" + assert sum(1 for m in metas if m["category"] == "股指期货") == 4 + assert sum(1 for m in metas if m["category"] == "国债期货") == 4 + # 股指期货必须给出对应指数代码,国债期货为空串 + assert PRODUCTS["IF"].underlying_code == "000300" + assert PRODUCTS["TL"].underlying_code == "" + + +def test_normalize_product_case_insensitive() -> None: + from easy_tdx.ccpm import normalize_product + + assert normalize_product("if").code == "IF" + assert normalize_product(" Tl ").code == "TL" + with pytest.raises(ValueError, match="未知品种"): + normalize_product("XX") + + +# --------------------------------------------------------------------------- +# XML 解析:长表 → 宽表对齐 +# --------------------------------------------------------------------------- + + +def test_parse_xml_wide_alignment() -> None: + from easy_tdx.ccpm import WIDE_COLUMNS, parse_xml + + rows = parse_xml(_SAMPLE_XML) + assert len(rows) == 2 # 1 合约 × rank 1..2 + assert set(rows[0]) == set(WIDE_COLUMNS) + r1 = rows[0] + assert r1["instrument"] == "IF2609" + assert r1["trading_day"] == "20260902" + assert r1["product"] == "IF" + assert r1["rank"] == 1 + # 三类排名各自独立取自对应 datatypeid + assert r1["vol_member"] == "甲期货(代客)" and r1["vol"] == 100 and r1["vol_chg"] == 10 + assert r1["long_member"] == "丙期货(代客)" and r1["long_pos"] == 220 and r1["long_chg"] == -7 + assert r1["short_member"] == "乙期货(代客)" and r1["short_pos"] == 150 and r1["short_chg"] == 3 + r2 = rows[1] + assert r2["rank"] == 2 and r2["long_chg"] == 0 and r2["short_chg"] == -2 + + +def test_parse_xml_missing_cell_fills_none() -> None: + """某类型缺某排名时对应单元格为 None,不丢行、不错位。""" + from easy_tdx.ccpm import parse_xml + + # 只有 datatypeid=0 的 rank1,其余类型缺失 + partial = _SAMPLE_XML.replace( + "1", "9" + ).replace("2", "9") + rows = parse_xml(partial) + assert rows + assert rows[0]["vol"] == 100 + assert rows[0]["long_member"] is None and rows[0]["long_pos"] is None + assert rows[0]["short_member"] is None and rows[0]["short_pos"] is None + + +def test_parse_xml_error_page_raises() -> None: + from easy_tdx.ccpm import CcpmError, parse_xml + + with pytest.raises(CcpmError, match="XML 解析失败"): + parse_xml("404 page,非 XML 内容") + + +# --------------------------------------------------------------------------- +# 日期归一化 +# --------------------------------------------------------------------------- + + +def test_normalize_date_formats() -> None: + from easy_tdx.ccpm import normalize_date + + assert normalize_date("2026-09-02") == date(2026, 9, 2) + assert normalize_date("20260902") == date(2026, 9, 2) + assert normalize_date(date(2026, 9, 2)) == date(2026, 9, 2) + with pytest.raises(ValueError, match="日期格式"): + normalize_date("2026/9/2") + + +# --------------------------------------------------------------------------- +# CcpmClient:抓取 + 按日缓存 + 无数据语义 +# --------------------------------------------------------------------------- + + +def test_get_rank_fetch_and_columns(isolated_config, monkeypatch) -> None: + from easy_tdx.ccpm import CcpmClient + + calls = _mock_fetch(monkeypatch) + df = CcpmClient().get_rank("IF", "2026-09-02") + assert len(df) == 2 + assert df["instrument"].tolist() == ["IF2609", "IF2609"] + assert df["vol"].tolist() == [100, 80] + # URL 按官网协议拼装:月份/日零填充,无 ?id= 缓存戳 + assert calls == ["http://www.cffex.com.cn/sj/ccpm/202609/02/IF.xml"] + + +def test_cache_hit_skips_network(isolated_config, monkeypatch) -> None: + """第二次查询同 (日期, 品种) 应命中文件缓存,零网络。""" + from easy_tdx.ccpm import CcpmClient + from easy_tdx.ccpm import client as ccpm_client + + calls = _mock_fetch(monkeypatch) + c = CcpmClient() + c.get_rank("IF", "2026-09-02") + assert len(calls) == 1 + + # 网络层改为必炸:仍能取到数据 = 走了缓存 + monkeypatch.setattr( + ccpm_client, + "_fetch_xml", + lambda url, timeout: (_ for _ in ()).throw(AssertionError("不应联网")), + ) + df2 = c.get_rank("IF", "2026-09-02") + assert len(df2) == 2 + + # refresh=True 强制重新联网 + calls2 = _mock_fetch(monkeypatch) + c.get_rank("IF", "2026-09-02", refresh=True) + assert len(calls2) == 1 + + +def test_no_data_error_from_redirect(isolated_config, monkeypatch) -> None: + """官网非交易日 302 → error_404:走真实 _fetch_xml 的 302 翻译逻辑。""" + from urllib.error import HTTPError + + from easy_tdx.ccpm import CcpmClient, CcpmNoDataError + from easy_tdx.ccpm import client as ccpm_client + + def fake_open(req, timeout=None): # noqa: ANN001, ANN202 + raise HTTPError(req.full_url, 302, "Found", None, None) # type: ignore[arg-type] + + monkeypatch.setattr(ccpm_client._OPENER, "open", fake_open) + with pytest.raises(CcpmNoDataError, match="非交易日"): + CcpmClient().get_rank("IF", "2026-08-29") + + +def test_latest_rank_walks_back(isolated_config, monkeypatch) -> None: + """自动回溯:今天无数据 → 往前一天命中。""" + from easy_tdx.ccpm import CcpmClient, CcpmNoDataError + from easy_tdx.ccpm import client as ccpm_client + from easy_tdx.ccpm.client import _today_shanghai + + today = _today_shanghai() + calls: list[str] = [] + + def fake(url: str, timeout: float) -> str: + calls.append(url) + # 今天的 URL 抛无数据,昨天返回样例 + dd = f"{today.day:02d}" + if f"/{dd}/IF.xml" in url: + raise CcpmNoDataError("非交易日") + return _SAMPLE_XML.replace("20260902", today.strftime("%Y%m%d")).replace( + "IF2609", "IF" + today.strftime("%y%m") + ) + + monkeypatch.setattr(ccpm_client, "_fetch_xml", fake) + df = CcpmClient().latest_rank("IF") + assert len(df) == 2 + assert len(calls) == 2 # 今天一次 + 回退一天一次 + + +def test_latest_rank_exhausted(isolated_config, monkeypatch) -> None: + from easy_tdx.ccpm import CcpmClient, CcpmError, CcpmNoDataError + from easy_tdx.ccpm import client as ccpm_client + + def always_no(url: str, timeout: float) -> str: + raise CcpmNoDataError("非交易日") + + monkeypatch.setattr(ccpm_client, "_fetch_xml", always_no) + with pytest.raises(CcpmError, match="未找到"): + CcpmClient().latest_rank("IF", max_back=2) + + +# --------------------------------------------------------------------------- +# Web 路由 +# --------------------------------------------------------------------------- + + +def _make_app(): + fastapi = pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + from easy_tdx.web.routers.ccpm import router + + app = fastapi.FastAPI() + app.include_router(router, prefix="/api/v1") + return TestClient(app) + + +def test_route_products() -> None: + tc = _make_app() + r = tc.get("/api/v1/ccpm/products") + assert r.status_code == 200 + body = r.json() + assert body["count"] == 8 + codes = [p["code"] for p in body["products"]] + assert codes[0] == "IF" and "TL" in codes + + +def test_route_rank_ok(isolated_config, monkeypatch) -> None: + _mock_fetch(monkeypatch) + tc = _make_app() + r = tc.get("/api/v1/ccpm/rank", params={"product": "IF", "date": "2026-09-02"}) + assert r.status_code == 200 + body = r.json() + assert body["product"] == "IF" and body["product_name"] == "沪深300股指期货" + assert body["trading_day"] == "20260902" + assert body["count"] == 2 and body["data"][0]["instrument"] == "IF2609" + + +def test_route_rank_no_data_404(isolated_config, monkeypatch) -> None: + from easy_tdx.ccpm import CcpmNoDataError + from easy_tdx.ccpm import client as ccpm_client + + monkeypatch.setattr( + ccpm_client, + "_fetch_xml", + lambda url, timeout: (_ for _ in ()).throw(CcpmNoDataError("非交易日")), + ) + tc = _make_app() + r = tc.get("/api/v1/ccpm/rank", params={"product": "IF", "date": "2026-08-29"}) + assert r.status_code == 404 + assert "非交易日" in r.json()["detail"] + + +def test_route_rank_invalid_product_422() -> None: + tc = _make_app() + r = tc.get("/api/v1/ccpm/rank", params={"product": "XX"}) + assert r.status_code == 422 # pattern 校验 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def test_cli_ccpm_json(isolated_config, monkeypatch) -> None: + pytest.importorskip("click") + from click.testing import CliRunner + + from easy_tdx.cli.cmd_ccpm import ccpm as ccpm_cmd + + _mock_fetch(monkeypatch) + result = CliRunner().invoke(ccpm_cmd, ["IF", "--date", "2026-09-02"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.output) # print_output 的 JSON = records 数组 + assert len(payload) == 2 + assert payload[0]["instrument"] == "IF2609" + + +def test_cli_ccpm_table(isolated_config, monkeypatch) -> None: + pytest.importorskip("click") + from click.testing import CliRunner + + from easy_tdx.cli.cmd_ccpm import ccpm as ccpm_cmd + + _mock_fetch(monkeypatch) + result = CliRunner().invoke(ccpm_cmd, ["IF", "--date", "2026-09-02", "--table"]) + assert result.exit_code == 0, result.output + assert "合约" in result.output and "持买单·会员" in result.output + + +def test_cli_ccpm_no_data_exit_1(isolated_config, monkeypatch) -> None: + pytest.importorskip("click") + from click.testing import CliRunner + + from easy_tdx.ccpm import CcpmNoDataError + from easy_tdx.ccpm import client as ccpm_client + from easy_tdx.cli.cmd_ccpm import ccpm as ccpm_cmd + + monkeypatch.setattr( + ccpm_client, + "_fetch_xml", + lambda url, timeout: (_ for _ in ()).throw(CcpmNoDataError("非交易日")), + ) + result = CliRunner().invoke(ccpm_cmd, ["IF", "--date", "2026-08-29"]) + assert result.exit_code == 1 diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue index da7da26..bf1baef 100644 --- a/web-ui/src/App.vue +++ b/web-ui/src/App.vue @@ -28,6 +28,7 @@ const sseLabel: Record = { 市场看板 自选行情 龙头池 + 期货持仓排名 单标的回测 组合回测 diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index 8d4ee4d..5fa8bd0 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -9,6 +9,8 @@ import type { BoardRow, Category, CoreLeaderRow, + CcpmProductsResponse, + CcpmRankResponse, DataFrameResponse, LlmChatResponse, LlmChatContext, @@ -746,3 +748,20 @@ export async function fetchCoreLeaders(): Promise { const body = (await resp.json()) as DataFrameResponse return body.data as unknown as CoreLeaderRow[] } + +/** 中金所成交持仓排名:品种列表(含科普元数据)。 */ +export async function fetchCcpmProducts(): Promise { + const resp = await fetch(`${BASE}/ccpm/products`) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as CcpmProductsResponse +} + +/** 中金所成交持仓排名:按品种 + 交易日抓取(date 缺省自动回溯最近有数据的交易日)。 */ +export async function fetchCcpmRank(product: string, date?: string): Promise { + const params = new URLSearchParams() + params.set('product', product) + if (date) params.set('date', date) + const resp = await fetch(`${BASE}/ccpm/rank?${params.toString()}`) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as CcpmRankResponse +} diff --git a/web-ui/src/router.ts b/web-ui/src/router.ts index c3167ff..2f2ad61 100644 --- a/web-ui/src/router.ts +++ b/web-ui/src/router.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import BacktestView from './views/BacktestView.vue' +import CcpmView from './views/CcpmView.vue' import CompareView from './views/CompareView.vue' import CoreLeadersView from './views/CoreLeadersView.vue' import DashboardView from './views/DashboardView.vue' @@ -32,6 +33,8 @@ const routes = [ { path: '/ai-history', name: 'ai-history', component: LlmHistoryView }, // 核心龙头池(universe=core 的 159 只名单) { path: '/leaders', name: 'leaders', component: CoreLeadersView }, + // 中金所成交持仓排名(独立数据源,每日收盘后发布) + { path: '/ccpm', name: 'ccpm', component: CcpmView }, // 兜底:未注册路径(如把 API 路径当页面访问)回看板,不再渲染空白 { path: '/:pathMatch(.*)*', redirect: '/' }, ] diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index 89919c4..d78efed 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -762,3 +762,46 @@ export interface CoreLeaderRow { name: string market: string } + +// ── 中金所成交持仓排名(GET /api/v1/ccpm/*) ───────────────────────────────── + +/** 品种元数据(含给新手的科普文案)。 */ +export interface CcpmProductMeta { + code: string + name: string + category: string + underlying: string + underlying_code: string + unit: string + intro: string +} + +export interface CcpmProductsResponse { + products: CcpmProductMeta[] + count: number +} + +/** 排名行(宽表:合约 × 排名 对齐三类排名;单位均为「手」)。 */ +export interface CcpmRankRow { + trading_day: string + product: string + instrument: string + rank: number + vol_member: string | null + vol: number | null + vol_chg: number | null + long_member: string | null + long_pos: number | null + long_chg: number | null + short_member: string | null + short_pos: number | null + short_chg: number | null +} + +export interface CcpmRankResponse { + trading_day: string + product: string + product_name: string + data: CcpmRankRow[] + count: number +} diff --git a/web-ui/src/views/CcpmView.vue b/web-ui/src/views/CcpmView.vue new file mode 100644 index 0000000..816b2e3 --- /dev/null +++ b/web-ui/src/views/CcpmView.vue @@ -0,0 +1,654 @@ + + + + +